Kotlin in Action: 6장 코틀린 타입 시스템: 6.1 Nullable(널 가능성)
6. 코틀린의 타입 시스템(type system)
자바와 비교하면 코틀린의 타입 시스템은 코드의 가독성을 향상시키는 데 도움이 되는 몇가지 특성을 새로 제공한다. 그런 특성으로는 Nullable type과 읽기 전용 컬렉션이 있다. 또한 코틀린은 자바 타입 시스템에서 불필요하거나 문제가 되던 부분을 제거했다. 배열 지원이 그런 부분에 속한다.
6.1 널가능성(Nullable)
널 가능성은 NullPointerException 오류를 피할 수 있게 돕기 위한 코틀린 타입 시스템의 특성이다. 코틀린을 비롯한 최신 언어에서 null에 대한 접근 방법은 가능한 한 이 문제를 실행 시점에서 컴파일 시점으로 옮기는 것이다. 널이 될 수 있는지 여부를 타입 시스템에 추가함으로써 컴파일러가 여러 가지 오류를 컴파일 시 미리 감지해서 실행 시점에 발생할 수 있는 예외의 가능성을 줄일 수 있게 해준다.
1. 널이 될 수 있는 타입
그렇다면 과연 타입 시스템이 널이 될 수 있는 타입을 명시적으로 지원한다는 말이 무슨 말일까? 널이 될 수 있는 타입은 프로그램 안의 프로퍼티나 변수에 null을 허용하게 만드는 방법이다.
2. 타입의 의미
자바에서는 null이 들어있는 경우에는 사용할 수 이쓴 연산이 많지 않다. 자바의 타입 시스템이 널을 제대로 다루지 못한다는 뜻이다. 변수에 선언된 타입이 있지만 널 여부를 추가로 검사하기 전에는 그 변수에 대한 연산을 수행할 수 있을지 알 수 없다.
자바에도 NullPointerException 문제를 해결하는데 도움을 주는 도구가 있다. 예를 들어 애노테이션을 사용해 값이 널이 될 수 있는지 여부를 표시(@Nullable 이나 @Notnull) 하기도 한다. 이런 애노테이션을 활용해 NullPointerException이 발생할 수 있는 위치를 찾아주는 도구가 있다.
그런데 이런 과정에서 문제가 있기 때문에 코틀린의 Nullable 타입은 종합적인 해법을 제시한다.
실행 시점에 예외를 발생시킬 수 있는 연산을 판단할 수 있기 때문에 그런 연산을 아예 금지시킬 수 있다.
실행 시점에 널이 될 수 있는 타입이나 널이 될 수 없는 타입의 객체는 같다. 널이 될 수 없는 타입을 감산 래퍼 타입이 아니다. 모든 검사는 컴파일 시점에 수행된다.
3. 안전한 호출 연산자: ?.
?.은 null 검사와 메소드 호출을 한 번의 연산으로 수행한다. 예를 들어 s?.toUpperCase()는 훨씬 더 복잡한 if(s ≠ null) s.toUpperCase() else null과 같다.
호출하려는 값이 null이 아니라면 ?.은 일반 메소드 호출처럼 작동한다 호출하려는 값이 null이면 이 호출은 무시되고 null값이 결과 값이 된다.
메소드 호출뿐 아니라 프로퍼티를 읽거나 쓸 때도 안전한 호출을 사용할 수 있다.
class Address(val streetAddress: String, val zipCode: Int,
val city: String, val country: String)
class Company(val name: String, val address: Address?)
class Person(val name: String, val company: Company?)
fun Person.countryName(): String {
val country = this.company?.address?.country
return if (country != null) country else "Unknown"
}
fun main(args: Array<String>) {
val person = Person("Dmitry", null)
println(person.countryName())
}
객체 그래프에서 널이 될 수 있는 중간 객체가 여럿 있다면 한 식안에서 안전한 호출을 연쇄해서 함께 사용하면 편할 때가 자주 있다. 널 검사가 들어간 호출이 연달아 있는 경우를 자바 코드에서 자주 볼 수 있다. 하지만 코틀린에서는 훨씬 간결하게 널 검사를 할 수 있다.
?. 연산자를 사용하면 다른 추가 검사 없이 Person의 회사 주소에서 country 프로퍼티를 단 한 줄로 가져올 수 있다.
위의 코드에는 if 문을 통해 대응하는 값을 반환하는 코드가 존재한다. 여기서는 엘비스 연산자를 사용할 수 있다.
4. 엘비스 연산자: ?:
코틀린은 null 대신 사용할 디폴트 값을 지정할 때 편리하게 사용할 수 있는 연산자를 제공한다. 그 연산자는 엘비스(elvis) 연산자라고 한다.(널 복합 연산자라고도 부른다)
이 연산자는 이항 연산자로 좌항을 계산한 값이 널인지 검사한다. 좌항 값이 널이 아니라면 좌항 값을 결과로 하고 좌항 값이 널이면 우항 값을 결과로 한다.
코틀린에서는 return이나 throw 등의 연산도 식이다. 따라서 엘비스 연산자의 유항에 return, throw 등의 연산을 넣을 수 있고, 엘비스 연산자를 더욱 편하게 사용할 수 있다.
class Address(val streetAddress: String, val zipCode: Int,
val city: String, val country: String)
class Company(val name: String, val address: Address?)
class Person(val name: String, val company: Company?)
fun printShippingLabel(person: Person) {
val address = person.company?.address
?: throw IllegalArgumentException("No address")
with (address) {
println(streetAddress)
println("$zipCode $city, $country")
}
}
fun main(args: Array<String>) {
val address = Address("Elsestr. 47", 80687, "Munich", "Germany")
val jetbrains = Company("JetBrains", address)
val person = Person("Dmitry", jetbrains)
printShippingLabel(person)
printShippingLabel(Person("Alexey", null))
}
printShippingLabel 함수는 모든 정보가 제대로 있으면 주소를 출력한다. 주소가 없으면 그냥 NullPointerException을 던지는 대신에 의미 있는 오류를 발생시킨다.
지금까지는 만약 널이 아닐 경우 코틀린의 작동방식을 살펴봤다. 이제는 자바 instanceof 검사 대신 코틀린이 제공하는 더 안전한 타입 캐스트 연산자를 살펴보자.
여기서 instanceof를 잘 모르는 사람이 있을 수도 있어 자세히 예시와 함께 설명되어 있는 링크를 남긴다.
5. 안전한 캐스트: as?
자바 타입 캐스트와 마찬가지로 대상 값을 as로 지정한 타입으로 바꿀 수 없으면 ClassCastException이 발생한다. 이보다 코틀린은 더 좋은 해법을 제공한다.
as?는 값을 대상 타입으로 변환할 수 없으면 null을 반환한다.
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(args: Array<String>) {
val p1 = Person("Dmitry", "Jemerov")
val p2 = Person("Dmitry", "Jemerov")
println(p1 == p2)
println(p1.equals(42))
}
이 패턴을 사용하면 파라미터로 받은 값이 원하는 타입인지 쉽게 검사하고 캐스트할 수 있고, 타입이 맞지 않으면 쉽게 false를 반환할 수 있다.
코틀린의 널 처리 지원을 활용하는 대신 직접 컴파일러에게 어떤 값이 널이 아니라는 사실을 알려주고 싶은 경우가 있다. 이제 그런 정보를 컴파일러에게 어떻게 넘길 수 있는지 살펴보자.
6. 널 아님 단언: !!
느낌표를 이중(!!)으로 사용하면 어떤 값이든 널이 될 수 없는 타입으로 (강제로) 바꿀 수 있다. 실제 널에 대해 !!를 적용하면 NullPointerError가 발생한다.
fun ignoreNulls(s: String?) {
val sNotNull: String = s!!
println(sNotNull.length)
}
하지만 사용시 기억해야만 하는 함정이 있다. !!를 널에 대해 사용해서 발생하는 예외의 스택 트레이스(stack trace)에는 어떤 파일의 몇 번째 줄인지에 대한 정보는 들어있지만 어떤식에서 예외가 발생했는지에 대한 정보는 들어있지 않다. 어떤 값이 널이었는지 확실히 하기 위해 여러 !! 단언문을 한 줄에 함께 쓰는 일을 피하라.
7. let 함수
let 함수를 사용하면 널이 될 수 있는 식을 더 쉽게 다룰 수 있다. let 함수를 안전한 호출 연산자와 함께 사용하면 원하는 식을 평가해서 결과가 널인지 검사한 다음에 그 결과를 변수에 넣는 작업을 간단한 식을 사용해 한꺼번에 처리할 수 있다.
let 함수는 자신의 수신 객체를 인자로 전달받은 람다에게 넘긴다. 널이 될 수 있는 값에 대해 안전한 호출 구문을 사용해 let을 호출하되 널이 될 수 없는 타입을 인자로 받는 람다를 let에 전달한다.
fun sendEmailTo(email: String) {
println("Sending email to $email")
}
fun main(args: Array<String>) {
var email: String? = "yole@example.com"
email?.let { sendEmailTo(it) }
email = null
email?.let { sendEmailTo(it) }
}
여러 값이 널인지 검사해야 한다면 let 호출을 중첩시켜서 처리할 수 있다. 그렇게 let을 중첩시켜 처리하면 코드가 복잡해져서 알아보기 어려워진다. 그런 경우 일반적인 if를 사용해 모든 값을 한꺼번에 검사하는 편이 낫다.
8. 나중에 초기화할 프로퍼티
코틀린에서 클래스 안의 널이 될 수 없는 프로퍼티를 생성자 안에서 초기화할지 않고 특별한 메소드 안에서 초기화할 수는 없다. 코틀린에서는 일반적으로 생성자에서 모든 프로퍼티를 초기화해야 한다. 게다가 프로퍼티 타입이 널이 될 수 없는 타입이라면 반드시 널이 아닌 값으로 그 프로퍼티를 초기화해야 한다. 그런 초기화 값을 제공할 수 없으면 널이 될 수 있는 타입을 사용할 수밖에 없다. 하지만 널이 될 수 있는 타입을 사용하면 모든 프로퍼티 접근에 널 검사를 넣거나 !! 연산자를 써야 한다.
class MyService {
fun performAction(): String = "foo"
}
class MyTest {
private lateinit var myService: MyService // 초기화하지 않고 널이 될 수 없는 프로퍼티를 선언한다.
@Before fun setUp() {
myService = MyService()
}
@Test fun testAction() {
Assert.assertEquals("foo",
myService.performAction()) // 널 검사를 수행하지 않고 프로퍼티를 사용한다.
}
}
이 코드에서는 프로퍼티를 나중에 초기화할 수 있게 한다. 널이 될 수 없는 타입이라 해도 더 이상 생성자 안에서 초기화할 필요가 없다.