널 가능성(nullability)은 NullPointerException(NPE - 아래에서는 이제 NPE라고 적겠습니다) 오류를 피할 수 있게 돕기 위한 코틀린 타입 시스템의 특성입니다. 코틀린을 비롯한 최신 언어에서 null에 대한 접근 방법은 가능한 한 이 문제(NPE)를 실행 시점에서 컴파일 시점으로 옮기는 것입니다. 널이 될 수 있는지 여부를 타입 시스템에 추가함으로써 컴파일러가 여러 가지 오류를 컴파일 시 미리 감지해서 실행 시점에 발생할 수 있는 예외의 가능성을 줄일 수 있습니다.
코틀린과 자바의 첫 번째이자 가장 중요한 차이는 코틀린 타입 시스템이 널이 될 수 있는 타입을 명시적으로 지원한다는 점입니다. 이 말의 뜻은 널이 될 수 있는 타입은 프로그램 안의 프로퍼티나 변수에 null을 허용하게 만드는 방법입니다.
int strLen(String s) {
return s.length();
}
다음과 같은 자바 함수에 만약 null을 넘기면 NPE가 발생합니다. 이러한 코드를 코틀린으로 작성한다면 우선 "이 함수가 널을 인자로 받을 수 있는가?"에 답해야 합니다. 여기서 널을 인자로 받을 수 있다는 말은 strLen(null)처럼 null 리터럴을 사용하는 경우뿐 아니라 변수나 식의 값이 시행 시점에 null이 될 수 있는 경우를 모두 포함합니다.
// null이 인자로 들어올 수 없음
fun strLen(s: String): Int {
return s.length
}
// null이 인자로 들어올 수 있음
fun strLen(s: String?): Int? {
return s?.length
}
첫 번째 strLen에 null이거나 널이 될 수 있는 인자를 넘기는 것은 금지되며, 그런 값을 넘기면 컴파일 시 오류가 발생합니다. 여기서 파라미터 s의 타입은 String인데 이는 s가 항상 String의 인스턴스여야 한다는 뜻으로 컴파일러는 널이 될 수 있는 값을 strLen에게 인자로 넘기지 못하게 막습니다. 따라서 strLen 함수가 결코 실행 시점에 NPE을 발생시키지 않으리라 장담할 수 있습니다.
만약 함수가 널과 문자열을 인자로 받을 수 있게 하려면 두 번째 strLen처럼 타입 이름 뒤에 물음표(?)를 명시해야합니다. String?, Int?, MyCustomType? 등 어떤 타입이든 타입 이름 뒤에 물음표를 붙이면 그 타입의 변수나 프로퍼티에 null 참조를 저장할 수 있다는 뜻입니다.
다시 말하면 물음표가 없는 타입은 그 변수가 null 참조를 저장할 수 없다는 뜻입니다. 따라서 모든 타입은 기본적으로 널이 될 수 없는 타입입니다. 뒤에 ?가 붙어야 널이 될 수 있습니다.
널이 될 수 있는 타입의 변수가 있다면 그에 대해 수행할 수 있는 연산이 제한됩니다.
변수.메서드()
처럼 메서를 직접 호출할 수 없습니다.// null이 인자로 들어올 수 있음
fun strLen(s: String?): Int? {
// 컴파일 에러 발생
return s.length
}
val x: String? = null
// 컴파일 에러 발생
val y: String = x
이렇게 많은 제약 속에서 널이 될 수 있는 타입의 값으로 할 수 있는게 뭐가 있을까요? 가장 중요한 일은 null과 비교하는 것입니다. 일단 null과 비교하고 나면 컴파일러는 그 사실을 기억하고 null이 아님이 확실한 영역에서는 해당 값을 널이 될 수 없는 타입의 값처럼 사용할 수 있습니다.
fun strLenSafe(s: String?): Int =
// null과 비교
if(s != null)
//널이 될 수 없는 타입의 값처럼 사용
s.length else 0
위의 예제처럼 null을 다룰 수 있습니다. 다만 널 가능성을 다루기 위해 사용하는 도구가 if 뿐이라면 코드가 번잡해집니다. 이를 위해 코틀린은 여러 도구를 제공합니다. 이 도구들을 살펴보도록 하겠습니다.
?. 은 null 검사와 메소드 호출을 한 번의 연산으로 수행합니다. 예를 들어 s?.toUpperCase()는 훨씬 더 복잡한 if(s != null) s.toUpperCase() else null과 같습니다.
다시 말하면 호출하려는 값이 null이 아니라면 ?.은 일반 메서드 호출처럼 작동하고 호출하려는 값이 null이면 이 호출은 무시되고 null이 결과 값이 됩니다.
fun printAllCaps(s: String?) {
// s가 널이 될 수 있는 타입의 경유
// S?.upperCase() 식의 결과 타입은 String?
val allCaps: String? = s?.uppercase()
}
class Address(val streetAdress: 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"
}
이 위의 코드에서 맨 마지막에 country가 null인지 검사해서 정상적으로 얻은 country 값을 반환하거나 null인 경우에 대응하는 "Unknown"을 반환합니다. 이러한 코드는 아래에서 배울 엘비스 연산자를 사용하면 없앨 수 있습니다.
코틀린은 null 대신 사용할 디폴트 값을 지정할 때 편리하게 사용할 수 있는 연산자를 제공하는데 그 연산자가 엘비스 연산자(?:)입니다. 아래는 엘비스 연산자에 대한 기본적인 사용 방법입니다.
fun foo(s: String?) {
// s가 null이면 결과는 빈 문자열
val t: String = s ?: ""
}
이 연산자는 이항 연산자로 좌항을 계산한 값이 널인지 검사합니다. 좌항 값이 널이 아니면 좌항 값을 결과로 하고, 좌항 값이 널이면 우항 값을 결과로 합니다. 아래의 그림과 같은 동작을 하는 것입니다.
fun strLenSafe(s: String?): Int = s?.length ?: 0
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")
// address는 여기서 널이 아닙니다.
with(address) {
println(streetAddress)
println("$zipCode $city, $country")
}
}
위의 함수는 모든 정보가 제대로 있으면 주소를 출력하고, 주소가 없다면그냥 NullPointerException을 던지는 대신에 의미 있는 오류를 발생시킵니다.
코틀린 타입 캐스트 연산자는 as로 자바 타입 캐스트와 마찬가지로 대상 값을 as로 지정한 타입으로 바꿀 수 없다면 ClassCastException이 발생합니다. as를 사용할 때마다 is를 통해 변환 가능한 타입인지 검사할 수 있지만 코틀린에서는 더 좋은 해법을 제공합니다.
as? 연산자는 어떤 값을 지정한 타입으로 캐스트하고 값을 대상 타입으로 변환할 수 없으면 null을 반환합니다. 아래의 그림과 같이 동작합니다.
안전한 캐스트를 사용할 때 일반적인 패턴은 캐스트를 수행한 뒤에 엘비스 연산자(?:)를 사용하는 것입니다. 예를 들어 equals를 구현할 때 이런 패턴이 유용합니다.
class Person(val firstName: String, val lastName: String) {
override fun equals(other: Any?): Boolean {
// 타입이 서로 일치하지 않으면 false를 반환
val otherPerson = other as? Person ?: return false
// 안전한 캐스트를 하고 나면 otherPerson이 Person 타입으로 스마트 캐스팅
return otherPerson.firstName == firstName &&
otherPerson.lastName == lastName
}
}
이러한 패턴을 사용하면 파라미터로 받은 값이 원하는 타입인지 쉽게 검사하고 캐스트할 수 있고, 타입이 맞지 않으면 쉽게 false를 반환합니다.
널 아님 단언(not-null assertion)은 코틀린에서 널이 될 수 있는 타입의 값을 다룰 때 사용할 수 있는 도구 중에서 가장 단순하고 무딘 도구입니다. 느낌표를 이중(!!)으로 사용하면 어떤 값이든 널이 될 수 없는 타입으로 (강제로) 바꿀 수 있습니다. 이때, 실제 널에 대해 !!를 적용하면 NPE가 발생합니다. 아래의 그림과 같이 동작합니다.
fun ignoreNulls(s: String?) {
// String s가 null이 아님을 단언
val sNotNull = s!! // 만약 null을 넣으면 예외는 이 지점을 가리킵니다.
println(sNotNull.length)
}
널 아님 단언문은 자주 사용되지는 않지만 나은 해법인 경우가 있습니다. 어떤 함수가 값이 널인지 검사한 후 다른 함수를 호출한다고 해도 컴파일러는 호출된 함수 안에서 안전하게 그 값을 사용할 수 있음을 인식할 수 없습니다. 하지만 이런 경우 호출된 함수가 언제나 다른 함수에서 널이 아닌 값을 전달받는게 분명하다면 굳이 널 검사를 다시 수행하고 싶지 않을 것입니다. 이럴 때 널 아님 단언문을 사용할 수 있습니다.
실무에서 스윙과 같은 다양한 UI 프레임워크에 있는 액션 클래스에서 이런 일이 발생합니다. 액션 클래스 안에는 그 액션의 상태를 변경(활성화 또는 비활성화
)하는 메소드와 실제 액션을 실행하는 메소드가 있습니다. update 메소드 안에서 검사하는 조건을 만족하지 않는 경우 execute 메소드는 호출될 수 없습니다. 하지만 컴파일러는 그런 연관관계를 모릅니다.
class CopyRowAction(val list: JList<String>): AbstractAction() {
// update 메소드에 해당
override fun isEnabled(): Boolean =
// 리스트 아이템의 value가 null이 아닌지
list.selectedValue != null
// actionPerformed는 isEnabled가 "true"인 경우에만 호출
// execute 메소드에 해당
override fun actionPerformed(e: ActionEvent?) {
val value = list.selectedValue!!
// 나머지 코드...
}
}
위의 코드에서 !!를 사용하지 않으려면 val value = list.selectedValue ?: return 처럼 널이 될 수 없는 타입의 값을 얻어와야 합니다. 하지만 위와 같이 이러한 패턴을 사용하면 list.selectedValue가 null이면 함수가 조기 종료되므로 함수의 나머지 본문에서는 value가 항상 널이 아니게 됩니다.
!!를 널에 대해 사용해서 발생하는 예외의 스택 트레이스에는 어떤 파일의 몇 번째 줄인지에 대한 정보는 들어있지만 어떤 식에서 예외가 발생했는지에 대한 정보는 들어있지 않습니다. 따라서 어떤 값이 널이었는지 확실히 하기 위해 !! 단언문을 한 줄에 함께 쓰는 일을 피해야합니다.
// !! 단언문을 한줄에 작성 X
person.company!!.address!!.country
let 함수를 사용하면 널이 될 수 있는 식을 더 쉽게 다룰 수 있습니다. let 함수를 안전한 호출 연산자와 함께 사용하면 원하는 식을 평가해 결과가 널인지 검사한 후 그 결과를 변수에 넣는 작업을 간단한 식을 사용해 한꺼번에 처리할 수 있습니다.
let을 사용하는 가장 흔한 용례는 널이 될 수 있는 값을 널이 아닌 값만 인자로 받는 함수에 넘기는 경우입니다. let 함수는 자신의 수신 객체를 인자로 전달받은 람다에게 넘깁니다. 널이 될 수 있는 값에 대해 안전한 호출 구문을 사용해 let을 호출하되 널이 될 수 없는 타입을 인자로 받는 람다를 let에 전달합니다. 이렇게 하면 널이 될 수 있는 타입의 값을 널이 될 수 없는 타입의 값으로 바꿔서 람다에 전달하게 됩니다.
// 널이 아닌 값만 인자로 받는 메서드
fun sendEmailTo(email: String) {
println("Sending email to $email")
}
fun main() {
// 널이 될 수 있는 값
var email: String? = "test@gmail.com"
// email이 null이 아니기에 메서드가 호출
email?.let { sendEmailTo(it) }
email = null
// email이 null이기에 람다 식이 실행되지 않습니다.
email?.let { email -> sendEmailTo(email) }
}
위의 코드에서 sendEmailTo는 널이 될 수 없는 값을 인자로 받는데 let을 사용해 null이 될 수 있는 값인 email을 인자로 주었습니다. 만약 null이 아니라면 람다 식이 정상적으로 실행되고 null이라면 람다 식이 실행되지 않습니다.
여러 값이 널인지 검사해야 한다면 let 호출을 중첩시켜서 처리할 수 있는데 그렇게 let을 중첩시켜 처리하면 코드가 복잡해져서 알아보기 어려워집니다. 그런 경우 일반적인 if를 사용해 모든 값을 한꺼번에 검사하는 편이 낫습니다.
만약 let을 더 자세히 알아보고 싶으시면 수신 객체 지정 람다의 글을 참조하시면 됩니다.
널이 될 수 있는 타입에 확장 함수를 정의하면 null 값을 다루는 강력한 도구로 활용할 수 있습니다. 어떤 메소드를 호출하기 전에 수신 객체 역할을 하는 변수가 널이 될수 없다고 보장하는 대신, 직접 변수에 대해 메소드를 호출해도 확장 함수인 메소드가 알아서 널을 처리해줍니다. 이런 처리는 확장 함수에서만 가능합니다. 일반 멤버 호출은 객체 인스턴스를 통해 디스패치되므로 그 인스턴스가 널인지 여부를 검사하지 않습니다.
예를 들어 코틀린 라이브러리는 String을 확장해 정의된 isEmpty와 isBlank라는 함수가 있습니다. isEmpty는 빈 문자열("")인지 검사하고, isBlank는 문자열이 모두 공백문자로 이뤄졌는지 검사합니다. 여기에 널이 될 수 있는 문자열(String?) 타입의 수신 객체에 대해 호출할 수 있는 isNullOrEmpty이나 isNullorBlank 메소드가 있습니다.
fun verifyUserInput(input: String?) {
// 안전한 호출을 하지 않아도 됩니다.
if(input.isNullOrBlank()) {
println("Please fill in the required fields")
}
}
안전한 호출 없이도 널이 될 수 있는 수신 객체 타입에 대해 선언된 확장 함수를 호출할 수 있습니다. 이러한 확장 함수는 null 값이 들어오는 경우 이를 적절히 처리합니다.
isNullOrBlank()는 널을 명시적으로 검사해 널인 경우 true를 반환 하고, 널이 아닌 경우 isBlank를 호출합니다. isBlank는 널이 아닌 문자열 타입의 값에 대해서만 호출할 수 있습니다.
직접 널이 될 수 있는 타입에 대한 확장을 정의할 수 있는데, 정의하면 널이 될 수 있는 값에 대해 그 확장 함수를 호출할 수 있습니다. 그런데 그 함수의 내부에서는 this가 널이 될 수 있기에 명시적으로 널 여부를 검사해야합니다. 다만 처음에는 널이 될 수 없는 타입에 대한 확장 함수를 정의합니다. 나중에 대부분 널이 될 수 있는 타입에 대해 그 함수를 호출했다면 코드를 수정해 확장 함수 안에서 널을 제대로 처리하게 하면 안전하게 그 확장 함수를 널이 될 수 있는 타입에 대한 확장 함수로 바꿀 수 있습니다.
코틀린에서는 함수나 클래스의 모든 타입 파라미터는 기본적으로 널이 될 수 있습니다. 널이 될 수 있는 타입을 포함하는 어떤 타입이라도 타입 파라미터를 대신할 수 있습니다. 따라서 타입 파라미터 T를 클래스나 함수 안에서 타입 이름으로 사용하면 이름 끝에 물음표가 없더라도 T가 널이 될 수 있는 타입입니다.
fun <T> printHashCode(t: T) {
// "t"가 널이 될 수 있으므로 안전한 호출을 써야만 합니다.
println(t?.hashCode())
}
fun main() {
// "T"의 타입은 Any?로 추론됩니다.
printHashCode(null)
}
위의 코드에서 타입 파라미터 T에 대해 추론한 타입은 널이 될 수 있는 Any? 타입입니다. t 파라미터의 타입 이름 T에는 물음표가 붙어있지 않지만 t는 null을 받을 수 있습니다.
fun <T: Any> printHashCode(t: T) {
// 이제 "T"는 널이 될 수 없는 타입입니다.
println(t.hashCode())
}
fun main() {
// 컴파일 오류 발생
// 상한을 지정하여 널이 될 수 없는 타입에 해당
printHashCode(null)
}
타입 파라미터가 널이 아님을 확실히 하려면 위와 같이 널이 될 수 없는 타입 상한(upper bound)를 지정해야 합니다. 이렇게 널이 될 수 없는 타입 상한을 지정하면 널이 될 수 있는 값을 거부하게 됩니다.
타입 파라미터는 널이 될 수 있는 타입을 표시하려면 반드시 물음표를 타입 이름 뒤에 붙여야 한다는 규칙의 유일한 예외입니다.
참조
Kotlin In Action
틀린 부분을 댓글로 남겨주시면 수정하겠습니다..!!