[Chapter06] 널 가능성, 플랫폼 타입

Kotlin In Action을 공부하며 작성한 글입니다.
혼자 공부하고 정리한 내용이며, 틀린 부분은 지적해주시면 감사드리겠습니다 😀

널 가능성

널 가능성(nullability)은 NPE(Null Pointer Exception)을 피할 수 있게 돕기 위한 코틀린 타입 시스템의 특성이다. 널이 될 수 있는지 여부를 타입 시스템에 추가함으로써 컴파일러가 여러가지 오류를 컴파일 타임에 미리 감지해서 런 타임 시점에 발생할 수 있는 예외의 가능성을 줄여준다.

1. 널이 될 수 있는 타입

코틀린에서는 널이 될 수 있는 타입을 자바와 다르게 명시적으로 지원한다.

int strLen(String s) {
    return s.length()
}

위 코드에 null을 넘기면 길이를 가져오기 위해 s.을 하는 순간 NPE가 발생할 것이다. 이와 같이 자바에서는 함수의 매개변수가 null인지 검사할 필요가 있다. 그럼 코틀린 코드는 어떻게 다른지 확인해보자.

fun strLen(s: String) = s.length

위 코드는 strLen의 매개변수로 null을 넣을 수 없는 상태이다. 혹시라도 null을 넣는 시도를 한다면 다음과 같은 컴파일 에러를 마주치게 된다.

Null can not be a value of a non-null type String

이 이유는 코틀린에서 null을 받을 수 있는 타입이 따로 분류되기 때문이다. 만약 null을 허용하고 싶다면 strLen(s: String?)과 같이 물음표(?)를 붙여줘야 한다. 어떤 타입이든 타입 뒤에 물음표를 붙이면 그 타입의 변수나 프로퍼티에 null을 참조 저장할 수 있게 된다.

또한, null이 될 수 있는 값을 null이 될 수 없는 타입의 변수에 값을 대입할 경우 컴파일 에러가 발생한다.

var aInt: Int = 10  
var bInt: Int? = 20  

// Type mismatch: inferred type is Int? but Int was expected
aInt = bInt

이렇게 제약이 많은 null이 될 수 있는 타입의 값으로 할 수 있는 일은 바로 null과 비교해 null이 아님이 확실한 영역에서는 일반 타입처럼 사용할 수 있게 된다.

fun strLenSage(s: String?): Int = if(s != null) s.length else 0

위와 같이 if문을 통해서 null이 아닌 것을 확인했다면, 내부 영역에서는 일반 타입으로 스마트 캐스팅이 된다.

2. 타입의 의미

타입이란, 분류(classification)이며, 타입은 어떤 값들이 가능한지와 그 타입에 대해 수행할 수 잇는 연산의 종류를 결정한다.

double과 String 타입을 비교해보면, 자바의 String 타입 변수에는 문자열이나 null이라는 두 종류의 값이 들어갈 수 있다. 문자열과 null은 엄연히 서로 다른 타입이며, null instanceof String을 하면 false를 반환한다.

또한, null을 막기 위해 Optional을 사용하거나 어노테이션을 사용하더라도 결국엔 null 여부를 추가로 검사하기 전까지 그 변수에 대해 어떤 연산을 수행할 수 있을지 알 수 없다. 즉, 자바에서는 타입 시스템이 널을 제대로 다루지 못한다는 뜻이다.

코틀린의 nullable 타입은 이런 문제에 대해 종합적인 해법을 제공한다.

3. 안전한 호출 연산자 : ?.

?. 연산자는 null 검사와 메소드 호출을 한 번의 연산으로 수행한다. 호출하려는 값이 null일 경우 . 뒷 함수 호출이 무시되고, null이 아닐 경우 뒷 함수들이 호출된다.

var s: String? = "hellow, how are you, i'm under the water"
// 기존 방식
if(s != null) s.toUpperCase() else null
// ?.연산 방식
s?.uppercase()
fun printAllCaps(s: String?) {  
    val allCaps: String? = s?.uppercase()  
    println(allCaps)  
}  

fun main() {  
    printAllCaps("abc")  
    printAllCaps(null)  
}
class Employee(val name: String, val manager: Employee?)  

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

fun main() {  
    val ceo = Employee("Da Boss", null)  
    val developer = Employee("Bob Smith", ceo)  

    println(managerName(developer))  
    println(managerName(ceo))  
}

위와 같이 메소드 호출, 프로퍼티를 읽거나 쓸 때도 안전한 호출을 사용할 수 있다. 또한 안전한 호출을 연쇄해서도 사용이 가능하다.

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() {  
    val person = Person("Dmitry", null)
    // 출력 : Unknown
    println(person.countryName())  
}

4. 엘비스 연산자 : ?:

코틀린은 null 대신 사용할 디폴트 값을 지정할 때, if-else가 아닌 엘비스(elvis) 연산자를 사용한다.

엘비스 연산자를 시계 방향으로 90도 돌리면 엘비스 프레슬리의 헤어 스타일과 눈이 보이며, 다른 이름으로는 널 복합(null coalescing) 연산자라는 이름으로도 사용된다.

이 엘비스 연산자를 앞서 정의한 Person.countryName() 함수에 적용해보자.

fun Person.countryName(): String {  
    return company?.address?.country ?: "Unknown"
}

엘비스 연산자의 우항에는 return, throw 등의 연산을 넣을 수 있다. 이런 패턴은 함수의 전제 조건(precondition)을 검사하는 경우 특히 유용하다.

fun printShippingLabel(person: Person) {
    // 주소가 없으면 Exception을 던진다.
    val address = person.company?.address  
        ?: throw IllegalArgumentException("No address")
    // 주소가 있으면 아래 내용이 출력된다.
    with(address) {  
        println(streetAddress)  
        println("$zipCode $city, $country")  
    }  
}

5. 안전한 캐스트 : as?

as 연산자는 자바의 타입 캐스팅과 동일한 역할을 한다. 특정 타입으로 변환할 수 없으면 ClassCastException이 발생하기 때문에 is 연산자를 통해 미리 검사를 한다.

하지만 이런 방식보다는 as?를 사용해 어떤 값을 지정한 타입으로 캐스트할 수 있다. 만약 캐스트할 수 없을 경우 ClassCastException이 발생하는 것이 아닌 null을 반환한다. 이를 엘비스 연산자와 함께 사용할 경우 효과를 배로 만들 수 있다.

class Animal(val firstName: String, val lastName: String) {  
    override fun equals(o: Any?): Boolean {  
        val otherAnimal = o as? Animal ?: return false  
        return otherAnimal.firstName == firstName &&  
                otherAnimal.lastName == lastName  
    }
}

코드에 보여지는 것과 같이 otherAnimal을 구하는 과정에서 as?를 사용해 null이 나올 경우 false를 반환하도록 했다. 이후 타입 캐스팅에 성공할 경우에는 otherAnimal을 Animal 타입으로 스마트 캐스트해 사용할 수 있다.

6. 널 아님 단언 : !!

이중 느낌표(!!)를 사용하면 어떤 값이든 널이 될 수 없는 타입으로 강제 변환할 수 있다.

실제 null이 든 값에 사용할 경우 NullPointerException이 발생한다.

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

fun main() { 
    // 런타임 에러 발생 : Exception in thread "main" java.lang.NullPointerException
    ignoreNulls(null)  
}

근본적으로 !!를 사용하면 컴파일러에게 '이 값은 null이 아님을 잘 알고 있으니 문제가 생겨서 예외가 발생해도 감수하겠다.'라고 전달하는 것이다.

!! 기호는 무례하고, 못생겼다. 그 이유는 코틀린 설계자들은 컴파일러가 검증할 수 없는 단언을 사용하기 보다는 더 나은 방법을 찾아보라는 의도로 못생긴 기호를 택한 것이다.

크게 사용하지 않을 것 같은 이 단언문은 가끔 더 나은 해법으로 사용될 경우도 있다.

어떤 함수가 값이 널인지 검사한 다음에 다른 함수를 호출한다고 해도 컴파일러는 호출된 함수 안에서 안전하게 그 값을 사용할 수 있음을 인식할 수 없다. 하지만 이런 경우 호출된 함수가 언제나 다른 함수에서 널이 아닌 값을 전달받는다는 사실이 분명하다면 굳이 널 검사를 다시 수행하지 않기 위해 널 아님 단언문을 사용할 수 있다.

아래 코드는 리스트 컨트롤에서 선택된 줄을 클립보드에 복사하는 코드이다. 또한, isEnabled가 true일 경우에만 actionPerformed를 호출해준다고 가정하자.

class CopyRowAction(val list: JList<String>) : AbstractAction() {  
    override fun isEnabled(): Boolean = list.selectedValue != null  
    override fun actionPerformed(e: ActionEvent) {  
        val value = list.selectedValue!!  
        // doSomething()  
    }  
}

컴파일러가 봤을 때는, actionPerformed 함수 안에서 사용되는 selectedValue가 null일 수 있다고 판단한다. 하지만 actionPerformed가 호출되는 전제 조건은 isEnabled이기 때문에 우리는 selectedValue가 널이 아님을 알고 있다. 이런 상황에서 널 아님 단언을 사용하면 중복된 널 검사를 하지 않아도 되기 때문에 사용하기 편하다.

7. let 함수

let 함수는 원하는 식을 평가해서 결과가 널인지 검사하고, 그 결과를 변수에 넣는 작업을 간단한 식을 사용해 처리할 수 있다. 이를 사용하는 가장 흔한 용례는 널이 될 수 있는 값을 널이 아닌 값만 인자로 받는 함수에 넘기는 경우다.

fun sendEmailTo(email: String) { /*...*/ }  
fun main() {  
    val email: String? = "전청조"
    // 컴파일 에러 : Type mismatch: inferred type is String? but String was expected 
    sendEmailTo(email)  
}

함수의 파라미터가 널을 허용하지 않기 때문에 메인 함수의 email 변수가 인자로 들어갈 수 없다. 이를 해결하기 위해선 널 검사를 통해 널이 아닐 경우에만 함수를 호출하도록 하는 방법 뿐이다.

fun sendEmailTo(email: String) { /*...*/ }  
fun main() {  
    val email: String? = "전청조"  
    if(email != null) sendEmailTo(email)  
}

하지만 이 방법 외에도 let 함수를 통해 인자를 전달할 수 있다.

fun main() {  
    val email: String? = "전청조"
    email?.let { email -> sendEmailTo(email) }  
}

let 함수는 이메일 주소 값이 널이 아닌 경우에만 호출되기 때문에 람다 안에서는 널이 될 수 없는 타입으로 email을 사용할 수 있다.

8. 나중에 초기화할 프로퍼티

객체 인스턴스를 일단 생성한 다음에 나중에 초기화하는 프레임워크가 많다. 하지만 코틀린에서 클래스 안의 널이 될 수 없는 프로퍼티를 생성자 안에서 초기화하지 않고, 특별한 메소드 안에서 초기화할 수는 없다. JUnit의 예시를 통해 확인해보자.

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

class MyServiceTest {
    private var myService: MyService? = null

    @Before fun setUp() {
        myService = MyService()
    }

    @Test fun actionTest() {
        Assert.assertEquals("FOO", myService!!.performAction())
    }
}

myService를 처음에 nullable한 타입으로 선언했기 때문에 사용하는 모든 곳에서 널 아님 단언을 선언해줘야 한다. 이를 해결하기 위해 lateinit 변경자를 붙이면 프로퍼티를 나중에 초기화할 수 있다.

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

class MyServiceTest {
    private lateinit var myService: MyService

    @Before fun setUp() {
        myService = MyService()
    }

    @Test fun actionTest() {
        Assert.assertEquals("FOO", myService.performAction())
    }
}

나중에 초기화되는 프로퍼티는 항상 var로 선언되어야 한다. val을 사용하기 위해서는 생성자 안에서 반드시 초기화해야 한다.

9. 널이 될 수 없는 타입 확장

널인 상태의 변수를 참조해 메소드를 호출할 경우 NPE가 발생한다. 하지만 널이 될 수 없는 타입 확장을 사용하면, 그 인스턴스가 널인지 여부를 일일이 검사하지 않아도 된다.

fun verifyUserInput(input: String?) {
    if(input.isNullOrBlank()) println("Please fill in the required fields")
}

이러한 호출이 가능한 이유는 객체 인스턴스를 통해 디스패치(dispatch)되기 때문이다.

디스패치(dispatch)에는 두 가지 종류가 있다.

  1. 동적 디스패치 : 객체의 동적 타입에 따라 적절한 메소드를 호출해주는 방식
  2. 직접 디스패치 : 컴파일러가 컴파일 시점에 어떤 메소드가 호출될지 결정해 코드를 생성하는 방식

isNullOrBlank의 구현 코드를 보면 다음과 같다.

@kotlin.internal.InlineOnly  
public inline fun CharSequence?.isNullOrBlank(): Boolean {  
    contract {
        returns(false) implies (this@isNullOrBlank != null)  
    }
    return this == null || this.isBlank()  
}

contract를 통해 컴파일러에게 "수신 객체가 null이 아니면, false를 리턴한다."라는 사실을 알려주고, 수신 객체가 null이 아님을 이해시켜준다. 때문에 클라이언트 코드에서 수신 객체를 참조해도 컴파일러는 null이 아님을 알고 있기 때문에 NPE가 발생하지 않게 해준다.

10. 타입 파라미터의 널 가능성

제네릭을 사용한 타입 파라미터 T를 사용할 경우, 물음표가 없더라도 T가 널이 될 수 있는 타입으로 들어가진다.

fun <T> printHashCode(t: T) {
    println(t?.hashCode())
}

위처럼 t?를 사용할 경우 T의 타입은 Any?가 된다. T에 대한 타입 자체에 물음표가 붙어있지 않아도 t는 null을 받을 수 있는 것이다. 만약 타입 파라미터가 널이 아님을 확실히 하려면 널이 될 수 없는 타입 상한(upper bound)을 지정해야 한다.

fun <T: Any> printHashCode(t: T) {  
    println(t.hashCode())  
}

이렇게 T에 대한 타입을 Any로 확정해 null을 받을 수 없도록 만들면 된다.

11. 널 가능성과 자바

자바 타입 시스템은 널 가능성을 지원하지 않는다. 아래 코드를 확인해보자.

fun printString(s1: String?, s2: String) {  
    println(s1 ?: "null")  
    println(s2)  
}

위 코드를 자바 코드로 디컴파일 해보면 다음과 같은 결과가 나온다.

public static final void printString(@Nullable String s1, @NotNull String s2) {  
   Intrinsics.checkNotNullParameter(s2, "s2");  
   String var10000 = s1;  
   if (s1 == null) {  
      var10000 = "null";  
   }  

   String var2 = var10000;  
   System.out.println(var2);  
   System.out.println(s2);  
}

자바 코드로 디컴파일을 해보면, 널을 허용하는 파라미터 s1은 @Nullable 어노테이션이 붙고, 널을 허용하지 않는 s2는 @NotNull 어노테이션이 붙는다. 실제로 위 자바 코드를 그대로 만들어

널 가능성을 허용하던 코틀린 함수 파라미터의 s1은 자바로 디컴파일이 될 때, @Nullable 어노테이션이 붙는다.

public class NullableTest {  
    public static final void printString(@Nullable String s1, @NotNull String s2) {  
        Intrinsics.checkNotNullParameter(s2, "s2");  
        String v1 = s1;  
        if (s1 == null) {  
            v1 = "null";  
        }  
        String v2 = v1;  
        System.out.println(v2);  
        System.out.println(s2);  
    }  
    public static void main(String[] args) { 
        // 컴파일 경고 : Passing 'null' argument to parameter annotated as @NotNull
        printString(null, null);  
    }  
}

@NotNull 어노테이션을 사용한다고 하더라도, 인자로 명시적으로 null을 보내줄 경우 컴파일 타임에 에러를 잡아주진 않고, 단순 경고만 발생한다.

11-1. 플랫폼 타입

플랫폼 타입은 코틀린이 널 관련 정보를 알 수 없는 타입을 말한다. 코드로 보면 다음과 같다.

자바       코틀린
Type = Type? || Type

코틀린은 보통 널이 될 수 없는 값에 널 안전성 검사하는 연산을 수행하면 경고를 표시하지만, 플랫폼 타입의 값에 대해 널 안전성 검사를 중복 수행해도 아무 경고를 표시하지 않는다.

fun printString(s: String) {
    // 경고 : Elvis operator (?:) always returns the left operand of non-nullable type String
    println(s ?: "null")
}

자바 타입은 코틀린에서 플랫폼 타입(널이 될 수 있는 타입 || 널이 될 수 없는 타입)으로 모두 사용할 수 있다. 아래 코드를 통해 확인해보자.

public class JavaPerson {  
    private final String name;  

    public JavaPerson(String name) {  this.name = name;  }  

    public String getName() {  return name;  }  
}
fun yellAt(person: JavaPerson) {  
    // 런타임 에러 : Exception in thread "main" java.lang.NullPointerException: getName(...) must not be null
    println(person.name.uppercase() + "!!!")  
}  

fun main() {  
    yellAt(JavaPerson(null))  
}

이렇게 널 검사 없이 자바 클래스에 접근할 경우 런타임 에러를 내뿜게 된다. 때문에 다음과 같이 널 검사를 통해 자바 클래스에 접근해야 한다.

fun yellAt(person: JavaPerson) {
    println((person.name ?: "Anyone").uppercase() + "!!!")
}

코틀린이 플랫폼 타입을 도입한 이유는 단순하다. 모든 타입을 널이 될 수 있는 타입으로 다루면, 결코 널이 될 수 없는 값에 대해서도 불필요한 널 검사를 진행해야 하기 때문이다. 제네릭을 사용할 경우 ArrayList<String>?과 같이 배열의 원소에 접근할 때마다 널 검사를 해야한다. 널 안전성으로 얻는 이익보다 검사에 드는 비용이 더 커지기 때문에 실용적인 접근 방법을 택했다.

11-2. 상속

코틀린에서 자바 메소드를 오버라이드할 때, 그 메소드의 파라미터와 반환 타입을 널이 될 수 있는 타입으로 선언할지 널이 될 수 없는 타입으로 선언할지 결정해야 한다.

public interface StringProcessor {  
    void process(String value);  
}
class StringPrinter : StringProcessor {  
    override fun process(value: String) {  
        println(value)  
    }  
}  

class NullableStringPrinter : StringProcessor {  
    override fun process(value: String?) {  
        if (value != null) { println(value) }  
    }
}

코틀린 컴파일러는 String과 String? 두 선언을 모두 받아들인다. 만약 자바 코드에서 StringPrinter에 있는 process를 통해 null 인자로 보내줄 경우 예외가 발생하게 되고, 이런 예외는 피할 수 없다.

public class StringProcessorTest {  
    public static void main(String[] args) {  
        StringProcessor sp = new StringPrinter();  
        sp.process(null);  
    }  
}
Exception in thread "main" java.lang.NullPointerException: Parameter specified as non-null is null: method action.junyoung.chapter06.StringPrinter.process, parameter value