관리 메뉴

나만을 위한 블로그

[Kotlin in Action] 6장. 코틀린 타입 시스템 본문

책/Kotlin in Action

[Kotlin in Action] 6장. 코틀린 타입 시스템

참깨빵위에참깨빵 2023. 10. 6. 21:37
728x90
반응형
널 가능성

 

널 가능성(nullability)은 NPE 오류를 피할 수 있게 하기 위한 코틀린 타입 시스템의 특성이다. 코틀린을 비롯한 최신 언어에서 null에 대한 접근법은 가능한 이 문제를 실행 시점에서 컴파일 시점으로 옮기는 것이다. 널이 될 수 있는지 여부를 타입 시스템에 추가해서 컴파일러가 여러 오류를 컴파일 시에 미리 감지해서, 실행 시점에 발생할 수 있는 예외 가능성을 줄일 수 있다.

 

널이 될 수 있는 타입

 

코틀린, 자바의 가장 중요한 차이는 코틀린 타입 시스템이 널이 될 수 있는 타입을 명시적으로 지원한다는 것이다. 어떤 변수가 널이 될 수 있다면 그 변수에 대해 메서드 호출 시 NPE가 발생할 수 있어 안전하지 않다. 코틀린은 그런 메서드 호출을 금지해서 많은 오류를 방지한다.

널이 인자로 들어올 수 없다면 코틀린에선 함수를 아래처럼 정의할 수 있다.

 

fun strLen(s: String) = s.length

 

널이거나 널이 될 수 있는 인자를 넘기면 컴파일 에러가 발생한다. 이 함수가 널, 문자열을 인자로 받으려면 타입 뒤에 ?를 붙여야 한다.

 

fun strLen(s: String?) = s.length

 

어떤 타입이든 이름 뒤에 ?를 붙이면 그 타입의 변수, 프로퍼티에 널 참조를 저장할 수 있다는 뜻이다.

그러나 위처럼 수정하면 컴파일 에러가 발생한다. 널이 될 수 있는 타입인 변수에 대해 메서드를 직접 호출할 수 없기 때문이다.

 

안전한 호출 연산자 : ?.

 

"?."은 널 검사, 메서드 호출을 한 번의 연산으로 수행한다. s?.toUpperCase()는 "if (s != null) s.toUpperCase() else null"과 같다. 호출하려는 값이 널이 아니라면 ?.은 일반 메서드 호출처럼 작동하고, 널이면 이 호출은 무시되고 결과값이 널이 된다.

프로퍼티를 읽거나 쓸 때도 안전한 호출을 쓸 수 있다.

 

class Employee(val name: String, val manager: Employee?)

fun managerName(employee: Employee): String? = employee.manager?.name

fun main() {
    val ceo = Employee("김철수", null)
    val dev = Employee("김영희", ceo)
    println(managerName(ceo))
    println(managerName(dev))
}

// >> null
// >> 김철수

 

엘비스 연산자 : ?:

 

널 대신 사용할 디폴트 값을 지정할 때 사용할 수 있는 연산자가 엘비스 연산자다.

 

fun foo(s: String?) {
    val t: String = s ?: ""
}

 

이항 연산자로 좌항을 계산한 값이 널인지 검사한다. 널이 아니면 좌항 값을 결과로 넣고, 널이면 우항 값을 결과로 넣는다.

코틀린에선 return, throw 등의 연산도 식이라서 엘비스 연산자의 우항에 넣을 수 있다. 좌항이 널이면 함수가 어떤 값을 즉시 리턴하거나 예외를 던지게 되는 것이다. 이런 패턴은 함수의 전제 조건을 검사할 경우 유용하다.

 

안전한 캐스트 : as?

 

as? 연산자를 쓰면 어떤 값을 지정한 타입으로 캐스트한다. 값을 대상 타입으로 변환할 수 없다면 널을 리턴한다.

equals() 구현 시 캐스트를 수행한 뒤 엘비스 연산자를 쓰는 패턴이 유용하다.

 

class Person(val firstName: String, val lastName: String) {
    override fun equals(o: Any?): Boolean {
        val otherPerson = o as? Person ?: return false

        return otherPerson.firstName == firstName && otherPerson.lastName == lastName
    }

    override fun hashCode(): Int =
        firstName.hashCode() * 37 + lastName.hashCode()
}

fun main() {
    val p1 = Person("김", "철수")
    val p2 = Person("김", "철수")
    println(p1 == p2)
    println(p1.equals(42))
}

// >> true
// >> false

 

이 패턴을 쓰면 파라미터로 받은 값이 원하는 타입인지 검사하고, 타입이 맞지 않으면 false를 반환하는 모든 동작이 한 식으로 해결 가능하다.

 

널 아님 단언 : !!

 

!!을 쓰면 어떤 값이든 널이 될 수 없는 타입으로 강제로 바꿀 수 있다. 실제 널에 대해 !!를 쓰면 NPE가 발생한다. 아래는 널이 될 수 있는 인자를 널이 될 수 없는 타입으로 바꾸는 예시다.

 

fun ignoreNulls(s: String?) {
    val sNotNull: String = s!!
    println(sNotNull.length)
}

fun main() {
    ignoreNulls(null)
}

// >> Exception in thread "main" java.lang.NullPointerException

 

발생한 예외는 널값을 쓰는 코드가 아니라 단언문(!!)을 가리킨다. 근본적으로 !!는 컴파일러에게 "이게 널이 아닌 걸 알고 있고 잘못 생각한 거면 예외 발생해도 감수하겠다"고 말하는 것이다.

하지만 !!이 더 나은 해법일 수도 있다. 어떤 함수가 값이 널인지 검사한 다음 다른 함수를 호출해도, 컴파일러는 호출된 함수 안에서 안전하게 그 값을 쓸 수 있다는 걸 인식할 수 없다. 하지만 이 경우 호출된 함수가 항상 다른 함수에서 널이 아닌 값을 받는단 게 분명하다면 굳이 널 검사를 하고 싶지 않을 것이다. 이 때 !!을 쓸 수 있다.

!!을 널에 사용해서 예외가 발생했을 때 스택 트레이스엔 몇 번 줄에서 에러가 발생했다는 정보는 있지만 어떤 식에서 예외가 발생했는지에 대한 정보는 없다. 어떤 값이 널인지 확실히 하기 위해 여러 !!을 한 줄에 같이 쓰지 마라.

 

let 함수

 

let 함수를 쓰면 널이 될 수 있는 식을 더 쉽게 다룰 수 있다. "?."와 같이 쓰면 원하는 식을 평가해서 결과가 널인지 검사한 다음, 그 결과를 변수에 넣는 작업을 한 번에 처리할 수 있다.

가장 흔한 용례는 널이 될 수 있는 값을 널이 아닌 값만 인자로 받는 함수에 넘기는 것이다.

 

fun sendEmailTo(email: String) {
    // ...
}

 

이 함수에 널이 될 수 있는 타입의 값을 넘길 순 없다. 하지만 let을 통해 인자를 전달할 수도 있다.

 

fun sendEmailTo(email: String) {
    // ...
}

fun main() {
    val value: String? = null
    value?.let { email -> sendEmailTo(email) }
}

 

여러 값이 널인지 검사해야 한다면 let 호출을 중첩해서 처리할 수 있지만 이렇게 하면 코드가 복잡해져서 알아보기 어려워진다. 이 경우 if를 써서 모든 값을 한 번에 검사하는 게 낫다.

 

나중에 초기화할 프로퍼티

 

코틀린에서 클래스 안의 널이 될 수 있는 프로퍼티를 생성자에서 초기화하지 않고 특별한 메서드 안에서 초기화할 수는 없다. 코틀린에선 일반적으로 생성자에서 모든 프로퍼티를 초기화해야 한다. 널이 될 수 있는 타입을 쓰면 모든 프로퍼티 접근에 널 검사를 넣거나 !!을 써야 한다.

lateinit 키워드를 붙이면 프로퍼티를 나중에 초기화할 수 있다.

 

class MyService {
    fun performAction(): String = "foo"
}

class MyTest {
    private lateinit var myService: MyService

    private fun setUp() {
        myService = MyService()
    }

    fun testAction() {
        setUp()
        println("foo" == myService.performAction())
    }
}

fun main() {
    MyTest().testAction()
}

// >> true

 

나중에 초기화하는 프로퍼티는 항상 var여야 한다. val 프로퍼티는 final 필드로 컴파일되고 생성자 안에서 반드시 초기화돼야 한다. 따라서 생성자 밖에서 초기화되는 프로퍼티는 항상 var여야 한다.

lateinit 프로퍼티 초기화 전에 접근하면 "has not been initialized" 에러가 발생하고 어디가 잘못됐는지 확실하게 알 수 있다.

 

널이 될 수 있는 타입 확장

 

어떤 메서드를 호출하기 전에 수신 객체 역할을 하는 변수가 널이 될 수 없다고 보장하는 대신, 직접 변수에 대해 메서드를 호출해도 확장 함수인 메서드가 알아서 널을 처리해준다.

실제로 String? 타입의 수신 객체에 대해 호출 가능한 isNullOrEmpty, isNullOrBlank()가 있다.

 

fun verifyUserInput(input: String?) {
    if (input.isNullOrBlank()) {
        println("빈 곳이 있어요")
    }
}

fun main() {
    verifyUserInput("")
    verifyUserInput(null)
}

// >> 빈 곳이 있어요
// >> 빈 곳이 있어요

 

isNullOrBlank()는 명시적으로 널을 검사해서 널이면 true를 리턴하고 널이 아니면 false를 리턴한다. isBlank()는 널이 아닌 문자열 값에 대해서만 호출 가능하다.

널이 될 수 있는 타입에 대한 확장을 정의하면 널이 될 수 있는 값에 대해 그 확장 함수를 호출할 수 있다. 그 함수 내부에서 this는 널이 될 수 있어서 명시적으로 널 검사를 수행해야 한다. 코틀린에선 널이 될 수 있는 타입의 확장 함수 안에선 this가 널이 될 수 있다는 게 자바와 다르다.

 

코틀린의 원시 타입

 

원시 타입 : Int, Boolean 등

 

자바는 원시 타입, 참조 타입을 구분한다. 원시 타입 변수에는 그 값이 직접 들어가지만 참조 변수에는 메모리 상의 객체 위치가 들어간다. 또한 참조 타입이 필요하면 래퍼 타입으로 원시 타입 값을 감싸서 사용한다. 그래서 정수 컬렉션을 정의하려면 Collection<Integer>를 사용해야 한다.

코틀린은 원시 타입, 래퍼 타입을 구분하지 않아서 항상 같은 타입을 사용한다.

 

fun main() {
    val i: Int = 1
    val list: List<Int> = listOf(1, 2, 3)
}

 

래퍼 타입을 따로 구분하지 않으면 편하다. 더 나아가서 코틀린은 숫자 타입 등 원시 타입 값에 대해 메서드를 호출할 수 있다.

 

fun showProgress(progress: Int) {
    val percent = progress.coerceIn(0, 100)
    println("현재 ${percent}%")
}

fun main() {
    showProgress(1450)
}

// >> 현재 100%

 

원시 타입, 참조 타입이 같다면 코틀린이 이것들을 항상 객체로 표현하는가? 실제로 항상 객체로 표현한다면 비효율적이지만 코틀린은 그러지 않는다.

실행 시점에 숫자 타입은 가장 효율적인 방식으로 표현된다. 대부분 Int 타입은 자바 int 타입으로 컴파일된다. 이런 컴파일이 불가능하면 컬렉션 같은 제네릭 클래스를 쓰는 것 뿐이다. Int를 컬렉션의 타입 파라미터로 넘기면 그 컬렉션에는 Int의 래퍼 타입인 java.lang.Integer 객체가 들어간다.

 

널이 될 수 있는 원시 타입 : Int?, Boolean? 등

 

널 참조를 자바의 참조 타입 변수에만 대입할 수 있어서 널이 될 수 있는 코틀린 타입은 자바 원시 타입으로 표현할 수 없다. 따라서 코틀린에서 널이 될 수 있는 원시 타입을 사용하면 그 타입은 자바의 래퍼 타입으로 컴파일된다.

age, name 프로퍼티가 있는 Person 클래스가 있을 때, 두 프로퍼티 모두 널이 될 수 있고 어떤 사람이 다른 사람보다 나이가 많은지 검사하는 함수 예시를 본다.

 

data class Person(
    val name: String,
    val age: Int? = null
) {
    fun isOlderThan(other: Person): Boolean? {
        if (age == null || other.age == null) return null
        return age > other.age
    }
}

fun main() {
    println(Person("김철수", 33).isOlderThan(Person("이영희", 42)))
    println(Person("김철수", 33).isOlderThan(Person("이영희")))
}

// >> false
// >> null

 

숫자 변환

 

코틀린, 자바의 큰 차이 중 하나는 숫자 변환 방식이다. 코틀린은 한 타입의 숫자를 다른 타입의 숫자로 자동 변환하지 않는다.

 

fun main() {
    val i = 1
    val l: Long = i // Type mismatch
}

 

그래서 직접 변환하는 메서드(toLong())를 호출해야 한다. 코틀린은 Boolean을 제외한 모든 원시 타입에 대한 변환 함수를 제공한다.

어떤 타입을 더 표현 범위가 넓은 타입으로 바꿀 수도 있고 좁은 타입으로 바꿀 수도 있다.

코틀린은 개발자의 혼란을 피하기 위해 타입 변환을 명시하기로 결정했다. 박스 타입을 equals()로 비교하는 경우, 코틀린에선 타입을 명시적으로 변환해서 같은 타입의 값으로 만든 후 비교해야 한다.

 

Any, Any? : 최상위 타입

 

자바에서 Object가 최상위 타입이듯 코틀린에선 Any 타입이 모든 널이 될 수 없는 타입의 조상 타입이다. 하지만 자바에선 참조 타입만 Object를 최상위로 하는 타입 계층에 포함되고 원시 타입은 들어있지 않다. 이는 자바에서 Object 타입 객체가 필요하면 int 같은 원시 타입을 java.lang.Integer 같은 래퍼 타입으로 감싸야 한다는 뜻이다. 하지만 코틀린에선 Any가 Int 등 원시 타입을 포함한 모든 타입의 조상 타입이다.

자바처럼 코틀린에서도 원시 타입 값을 Any 타입 변수에 대입하면 자동으로 값을 객체로 감싼다. Any는 널이 될 수 없는 타입임에 주의하라.

 

Unit : 코틀린의 void

 

값을 리턴하지 않는 함수의 리턴타입으로 Unit을 쓸 수 있다. 이것은 리턴타입 선언 없이 정의한 블록이 본문인 함수와 같다.

Unit은 모든 기능을 갖는 일반적인 타입이고 void와 달리 타입 인자로 쓸 수 있다. Unit 타입에 속한 값은 하나뿐이며 그 이름도 Unit이다. 이 특성은 제네릭 파라미터를 리턴하는 함수를 재정의하면서 리턴타입으로 Unit을 쓸 때 유용하다.

 

interface Processor<T> {
    fun process(): T
}

class NoResultProcessor: Processor<Unit> {
    override fun process() {    // Unit을 리턴하지만 리턴타입을 명시할 필요 없음
        // 리턴타입을 명시할 필요 없음
    }
}

 

Nothing : 이 함수는 절대 정상적으로 끝나지 않는다

 

코틀린에는 리턴값이란 개념 자체가 무의미한 함수가 일부 존재한다. 테스트 라이브러리들은 fail이란 함수를 제공하는 경우가 많다. fail은 특별한 메시지가 들어있는 예외를 던져 현재 테스트를 실패시킨다. 무한 루프를 도는 함수도 정상적으로 끝나지 않는다. 그런 함수를 호출하는 코드를 분석할 경우 함수가 정상적으로 끝나지 않는다는 사실을 알면 유용하다. 이걸 표현하기 위해 Nothing이란 리턴타입이 있다.

 

fun fail(message: String): Nothing {
    throw IllegalArgumentException(message)
}

fun main() {
    fail("에러 발생")
}

// >> Exception in thread "main" java.lang.IllegalArgumentException: 에러 발생

 

Nothing은 아무 값도 포함하지 않는다. 따라서 Nothing은 함수의 리턴타입이나 리턴타입으로 쓰일 타입 파라미터로만 쓸 수 있다.

 

컬렉션, 배열

 

널 가능성과 컬렉션

 

컬렉션 안에 널값을 넣을 수 있는지 여부는 어떤 변수의 값이 널이 될 수 있는지 여부와 마찬가지로 중요하다. 변수 타입 뒤에 ?를 붙이면 그 변수에 널을 저장할 수 있다는 뜻인 것과 같이, 타입 인자로 쓰인 타입에도 같은 표시를 할 수 있다.

 

fun readNumbers(reader: BufferedReader): List<Int?> {
    val result = ArrayList<Int?>()
    for (line in reader.lineSequence()) {
        try {
            val number = line.toInt()
            result.add(number)
        } catch (e: NumberFormatException) {
            result.add(null)
        }
    }
    
    return result
}

 

List<Int?>에는 Int 또는 널값을 저장할 수 있다. 현재 줄을 파싱할 수 있다면 result에 정수를 넣고 그게 아니면 널을 넣는다.

어떤 경우 널이 될 수 있는 값으로 이뤄진 널이 될 수 있는 리스트를 정의해야 할 수도 있다. 코틀린에선 ?를 2개 써서 List<Int?>?로 이를 표현한다. 이런 리스트를 처리할 때는 변수에 대해 널 검사를 수행한 다음, 그 리스트에 속한 모든 원소에 대해 다시 널 검사를 수행해야 한다.

 

fun addValidNumbers(numbers: List<Int?>) {
    var sumOfValidNumbers = 0
    var invalidNumbers = 0
    for (number in numbers) {
        if (number != null) {
            sumOfValidNumbers += number
        } else {
            invalidNumbers++
        }
    }

    println("유효한 숫자의 합 : $sumOfValidNumbers")
    println("유효하지 않은 숫자의 개수 : $invalidNumbers")
}

 

특별한 코드는 아니다. 리스트의 원소에 접근하면 Int?을 얻고 그 값을 산술식에 사용하기 전에 널 체크를 해야 한다. 널이 될 수 있는 값으로 이뤄진 컬렉션으로 널값을 걸러내는 경우가 자주 있어서 코틀린 표준 라이브러리는 그런 일을 하는 filterNotNull()을 제공한다.

 

fun addValidNumbers(numbers: List<Int?>) {
    val validNumbers = numbers.filterNotNull()
    println("유효한 숫자의 합 : ${validNumbers.sum()}")
    println("유효하지 않은 숫자의 개수 : ${numbers.size - validNumbers.size}")
}

 

읽기 전용, 변경 가능한 컬렉션

 

자바 컬렉션, 코틀린 컬렉션을 나누는 중요한 특성 중 하나는 코틀린에선 컬렉션 안의 데이터에 접근하는 인터페이스와 컬렉션 안의 데이터를 변경하는 인터페이스를 분리했다는 것이다. 이 구분은 코틀린 컬렉션을 다룰 때 쓰는 가장 기초적인 인터페이스인 kotlin.collections.Collection부터 시작한다. 이 인터페이스를 쓰면 컬렉션 안의 원소에 대해 순회하고, 컬렉션의 크기를 얻거나 어떤 값이 컬렉션 안에 들어있는지 검사하는 등 연산을 수행할 수 있다. 하지만 원소 추가, 제거 메서드가 없다.

컬렉션의 데이터를 수정하려면 kotlin.collections.MutableCollection 인터페이스를 사용한다. 이것은 Collection 인터페이스를 확장하면서 원소 추가, 삭제, 컬렉션 내 원소 모두 삭제 등의 메서드를 제공한다.

아래 함수는 source 컬렉션은 변경하지 않지만 target 컬렉션을 변경할 거라는 사실은 알 수 있다.

 

fun <T> copyElements(source: Collection<T>, target: MutableCollection<T>) {
    for (item in source) {
        target.add(item)
    }
}

fun main() {
    val source: Collection<Int> = arrayListOf(3, 5, 7)
    val target: MutableCollection<Int> = arrayListOf(1)
    copyElements(source, target)
    println(target)
}

// >> [1, 3, 5, 7]

 

target의 인자로 읽기 전용 컬렉션을 넘길 수 없다. 실제로 그 값(컬렉션)이 변경 가능한 컬렉션인지 여부와 상관없이 선언된 타입이 읽기 전용이면 target에 넘길 경우 컴파일 에러가 발생한다.

컬렉션 인터페이스를 쓸 때 항상 기억할 핵심은 읽기 전용 컬렉션이라고 꼭 변경 불가능한 컬렉션일 필요는 없다는 것이다. 또 읽기 전용 컬렉션이 항상 thread safe하지 않다는 걸 명심해야 한다. 멀티 쓰레드 환경에서 데이터를 다루는 경우 그 데이터를 적절히 동기화하거나 동시 접근을 허용하는 데이터 구조를 활용해야 한다.

 

객체의 배열과 원시 타입의 배열

 

코틀린 배열은 타입 파라미터를 받는 클래스다. 배열의 원소 타입은 그 타입 파라미터에 의해 결정된다. 코틀린에서 배열을 만드는 법은 다양하다.

 

  • arrayOf()
  • arrayOfNulls()
  • Array 생성자

 

아래는 Array 생성자를 써서 26개의 알파벳 소문자에 해당하는 문자열이 원소인 배열을 만드는 예시다.

 

fun main() {
    val letters = Array<String>(26) { i -> ('a' + i).toString() }
    println(letters.joinToString(""))
}

// >> abcdefghijklmnopqrstuvwxyz

 

 

원시 타입의 배열을 만드는 법도 다양하다.

 

  • 각 배열 타입의 생성자
  • intArrayOf() 등 팩토리 함수
  • 크기, 람다를 인자로 받는 생성자 사용

 

반응형
Comments