오늘도 더 나은 코드를 작성하였습니까?

코틀린 타입 시스템 ( Null 가능성) 본문

Kotlin in Action/코틀린 기초

코틀린 타입 시스템 ( Null 가능성)

hik14 2020. 8. 19. 19:45

코틀린을 비롯한 최신 언어에서는 null에 대한 접근방법은 가능한 이문제를 runtTime 시점이 아닌 compile 시점으로 옮기는 것이다.  null 이 될 수 있는지 여부를 타입 시스템에 추가함으로써 컴파일러가 여러 가지 오류를 컴파일 시 미리 감지해서 실행 시점에 발생하는 예외를 줄이는 것이다.

 

Null 이 될 수 있는 타입

- 코틀린은 null 이 될 수 있는 타입을 명시적 지원한다

- TYPE?  = TYPE    or   null

- null이 될 가능성이 있는 타입의 변수는 메서드의 직접 호출을 할 수 없다.

- null이 될 가능성이 있는 타입의 변수를 null 이 될 수 없는 타입의 변수에 대입할 수 없다.

- null이 될 가능성이 있는 타입을 null 될 수 없는 타입을 파라미터로 받는 함수에 전달할 수 없다.

 

타입의 의미

 

- 타입은 어떤 값들이 가능한지와 그타 입에 대해 수행할 수 있는 연산의 종류를 결정한다. 

- String name = null;  에서 String과 null은 완전히 다른 종류이다. 

- null이 될 수 있는 타입은  타입은 있자만 null인지 확인을 추가로 검사하지 않는 이상 연산을 수행할 수 있을지 알 수 없다.

 

코틀린은 놀이 가능한 타입과 널리 불가능한 타입을 명시하고 각 타입에 대해 어떤 연산이 수행될지 명확히 이해할 수 있으며 실행 시점에 예외가 발생하는 연산 판단 자체를 피할 수 있다. 

 

안전한 호출 연산자 (?.)

 

?. 은 null 검사와 메서드 호출을 한 번의 연산으로 수행한다.

즉 널이면 널을 대입하고 널이 아니면 호출한다.

 

s?. toUpperCase()  ===> if (s!= null ) s.toUpperCase() else null 

 

안전한 호출의 결과로 반환되는 타입도 널이 될 수 있는 타입이라는 사실이 중요하다.

s?. toUpperCase()의 결과 타입은 String?이다.

 

예시

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("hik", null)
    println(person.countryName())
}

 

엘비스 연산자 (?:)

코틀린은 null을 대신 사용할 디폴트 값을 지정할 때 편리하게 사용할 수 있는 연산자를 제공한다.

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

fun main() {
    
    val str :String?  = null
    println(foo(str))
}

?: 은 이항 연산자이다 좌측 피연산자를 벌인 지 검사 후 널이 아니면 좌측을 선택하고 널이면 우측 피연산자를 선택한다.

 

엘비스 연산자를 객체가 널인 경우 널을 반환하는 안전 호출 연산자와 함께 사용해서 객체가 널인 경우에 대비한 값을 지정한다.

fun strLenSafe(s: String?): Int = s?.length ?: 0

fun main() {

    val str1 = "abc"
    val str2 = null 
    
    println(strLenSafe(str1))
    println(strLenSafe(str2))
}

코틀린에서는 return이나 throw 연산도 식이다. 따라서 엘비스 연산자의 우항에 return 또는 throw 등의 연산을 넣을 수 있고 엘비스 연산자를 이용하여 더욱 편하게 사용할 수 있다. 

 

fun printShippingLabel(person: Person){

    val address = person.company?.address
            ?: throw IllegalArgumentException("No Address")

    with(address){
        println(streetAddress)
        println("$zipCode $city $country")
    }
}


fun main() {

    val address = Address("Elsestr 47",80687,"Munich", "Germany")
    val jetbrains = Company("JetBrains", address)
    val person = Person("hik", jetbrains)


    val person2 = Person("hoho", null)

    printShippingLabel(person)
    printShippingLabel(person2)
}

 

안전한 캐스트 ( as?)

 

코틀린은 타입 캐스트 연산을 할 때 대상 값을 as로 지정한 타입으로 변환이 될 수 없으면 ClassCastException이 발생한다. 물론 as를 사용하기 전에 Is (스마트 캐스트)를 통해 미리 as로 변환 가능한지 검사를 해볼 수 있지만, 

안전하면서 간결한 해법을 코틀린은 제공해준다. 

 

as? 는 어떤 값을 지정한 타입으로 캐스트 한다. 변환이 가능하면 진행하고 변환이 되지 않으면 null을 반환한다.

 

안전한 캐스트를 사용할 때는 일반적 패턴은 엘비스 연산자를 사용한다.

 

class Person(val firstName: String, val lastName: String){

    override fun equals(other: Any?): Boolean {
        val otherPerson = other as? Person ?: return false

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

fun main() {

    val p1 = Person("Dmitry", "Jemerov")
    val p2 = Person("Dmitry", "Jemerov")

    println(p1 == p2)

    println(p1.equals(100))

널 아님 단언!!

- 가장 단순하면서 무딘 도구다

- 어떤 값을 가진 타입이든 날이 될 수 없는 타입으로 강제 변환한다. (널값에 대해!! 적용 시 연산을 하면 NPE 발생한다. )

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

fun main() {
    ignoreNulls(null)
}

위 코드를 실행 시면 NPE가 발생한다. 발생한 지점이 sNotNull.length가 아닌  val sNotNull = s!!이다.

 

근본적으로!! 는 컴파일러에게 나는 이 값이 null 이 아님을 잘 알고 있다. 만약 null 이 된다면 예외가 발생해도 감수하겠다는 표현이다.

 

코틀린 설계자들은 컴파일러가 검증할 수 없는 단언 !! 을 사용하기보단 더 나은 방법을 찾아보라는 표현으로 !! 기호를 택했다. 

 

또한 !! 한 줄에 연속해서 사용하지 말라. 

 

그렇다면 어떤 경우에!! 를 사용할까?

 

어떤 함수가 값이 널 인지 이미 확인한 후 다른 함수를 호출하려고 할 때도 코틀린 컴파일러는 호출된 함수 안에서 그 값을 안전하게 사용할 수 있음을 인식할 수 없다. 

 

다른 함수에서 이미 널값이 아닌 값을 전달받는 게 확실하다면 다시 널 검사를 할 필요가 없다. 

 

class CopyList(val list: List<String>?){

    fun isEnabled(): Boolean =
            list != null

    fun executeCopy(): List<String>{
        val newList = arrayListOf<String>()
           if (isEnabled())
               newList.addAll( list!! )
        return newList
    }
}

 

let 함수

let 함수를 안전한 호출 연산자와 함께 사용하면 원하는 식을 평가해서 결과가 널 인지 검사한 다음에 그 결과를 변수에 넣는 작업을 간단한 식을 사용해 한번에 처리할 수 있다.

 

널이 될 수 있는 값을 널이 아닌 값만 인자로 받는 함수에 넘기는 경우다.

 

A? 를 A 타입의 인자로 넘기고 싶을때!

 

let은 자신을 수신 객체를 인자로 전달 받은 람다에게 넘긴다.

널이 될 수 있는 값에 대해 안전한 호출 구문을 사용해 let을 호출하되

널이 될 수 없는 타입을 인자로 받는 람다를 let에 전달한다.

 

foo?. let { it ->

  // it은 날이 될 수 없는 타입이다.

}

 

 

foo가 널이면 아무 일도 일어나지 않는다.

 

fun sendEmailTo(email: String){
    println("Sending email to $email")
}

fun main() {

    var email: String? = "hahah@gmail.com"
    var email2 = null

    email?.let { sendEmailTo(it) }
    email2?.let { sendEmailTo(it) }
}

 

어떤 식이 있고 그 값이 널이 아닐 때 수행해야 되는 로직이 있을 때 let을 쓰면 훨씬 편하다.

fun sendEmailTo(email: String){
    println("Sending email to $email")
}

class Person(val name: String, val email:String)

fun getTheBestPersonInTheWorld(): Person? = null

fun main() {
    
    val person: Person? = getTheBestPersonInTheWorld()
    if (person != null) sendEmailTo(person.email)

    getTheBestPersonInTheWorld()?.let { sendEmailTo(it.email) }
}

 

여러 값에 대한 null 검사를 let을 호출을 중첩시켜 처리하기보단 if로 하는 것이 낫다.

나중에 초기화할 프로퍼티 ( lateinit )

코틀린의 클래스 안에 널이 될 수 없는 프로퍼티를 생성자 안에서 초기화하지 않고 따로 특별한 메서드에서 초기화할 수 없다.

 

코틀린에서는 기본적으로 생성자에서 모든 프로퍼티를 초기화해야 된다.

 

프로퍼티 타입이 널이 될 수 없는 타입이라면 반드시 널이 아닌 값으로 초기화를 해야 된다.

 

lateinit은 var 프로퍼티에만 사용할 수 있다.

val 은 final필드로서 반드시 생성자 안에서 초기화해야 된다. 

 

생성자 밖에서 초기화해야 되는 경우 프로퍼티는 var이어야 한다. 

지연 초기화하는 프로퍼티는 널이 될 수 없는 타입이라 해도 더 이상 생성자 안에서 초기화할 필요가 없다.

 

널이 될 수 있는 타입의 확장

널이 될 수 있는 타입에 대한 확장 함수를 정의하면 null값을 다루는 강력한 도구로 활용할 수 있다.

 

특정 메서드를 호출하기 전에 수신 객체가 널이 될 수 없다고 보장해주는 것 대신 

직접 메서드를 호출해도 확장 함수인 메서드가 알아서 널 처리를 하게 한다.

 

fun verifyUserInput(input: String?){
    if(input.isNullOrBlank()){ // 널이 될수있는 타입에 안전호출 하지않음
        println("please fill in the required fields") 
    }
}

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

 

input(널이 될 수 있음). isNullOrBlank()  (널이 될 수 있는 타입의 확장 함수) 

 

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

    return this == null || this.isBlank()
}

CharSequence?  널이 될 수 있는 타입에 대한 확장 함수를 정의하면 안전 호출 없이 호출 가능하다.

 

 return this == null || this.isBlank()   2번째 this 스마트 캐스트가 적용된다.

 

 . isNullOrBlank() 을 호출했다고 해서 널이 될 수 없는 타입으로 변하진 않는다.

 

* let은 널이 될 수 있는 값에 안전 호출을 사용하지 않고 호출 시 실제 수신 객체가 널인경우 에러가 발생한다.

let은 반드시 안전 호출과 같이 사용하는 것이 좋다.

 

확장함수를 작성할 때 처음에는 널이 될 수 없는 타입에 대해서 정의를 하라 그러고 나서 나중에 널이 될 수 있는 타입에 대해서 그 함수를 호출할 상황이 생긴다면 확장 함수 안에서 널을 처리하게 되면 안전하게 그 확장 함수를 널이 될 수 있는 타입에 대한 확장 함수로 바꿀 수 있다

 

< T > 타입 파라 피터의 널 가능성

코틀린에서 함수나 클래스의 모든 타입 파라미터는 기본적으로 널이 될 수 있다.

즉, 타입 파라미터 T를 클래스나 함수 안에서 타입 이름으로 사용하면 이름끝에 ?가 없더라도 널이 될 수 있는 타입이다.

 

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

fun main() {
    printHashCode(null) // T의 타입은 Any? 로 추론된다
}

 T? 아닐지라도 t는 널값을 받을 수 있다.

 

타입 파라미터가 널이 아님을 확신한다면 널이 될 수 없는 타입 상한을 지정해야 한다.

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

 

타입 파라미터는 널이 될 수 있는 타입을 표시하려면 반드시 물음표를 타입 이름에  뒤에 붙여하는 규칙의 유일한 예외다.

널 가능성과 자바 

코틀린은 자바와 상호 운용성을 강조하는 언어이다. 그렇다면 자바와 코틀린을 조합하면 어떤 일이 생길까?

 

1. 자바 코드에도 애노테이션으로 표시된 널 가능성 정보가 있다. 이런 정보가 존재하면 코틀린의 컴파일러도 그 정보를 활용한다.

 

@Nullable String  ---> String?

@NotNull String  ---> String 

 

코틀린이 이해하는 널 가능성 애노테이션들은  JSR-305 표준,  젯브레인스 도구가 지원하는  애노테이션,  안드로이드 스튜디오 등이다.

 

2 플랫폼 타입.

 

String! 은 자바에서 온 플랫폼 타입이란 것이다.type뒤에! 은 널 가능성에 대한 어떤 정보도 없다는 뜻이다.

public class Person {

    private final String name;

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

    public String getName(){
        return name;
    }
}
fun main() {
    
    val person = Person("kotlin")
    
    val name1: String = person.name
    val name2: String? = person.name
}

 

플랫폼 타입은 코틀린에게 있어 널 관련 정보를 알 수 없는 타입이다.자바와 마찬가지로 플랫폼 타입에 있어 모든 연산의 책임은 개발자에게 있다. 컴파일러는 모든 연산을 허용한다.하지만 널인 값에 대한 연산 수행 시 NPE를 발생한다.

fun yellAt(person: Person){
    println(person.name.toUpperCase()+"!!!")
}

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

코틀린 컴파일러는 공개 가시성 코틀린 함수의 널이 아닌 타입인 파라미터와 수신 객체에 대한 널 검사를 추가해준다.

즉, toUpperCase 호출 시점에  person.name의 널 검사를 해서 위와 같은 에러가 발생하는 것이다.

 

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

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

자바 API 문서를 잘 읽고  그 메서드가 널을 반환 할지 알아내고 널을 반환 한다면 추가적인 검사를 해야 된다.

 

3. 상속 및 구현

 

코틀린에서 자바 메서드를 오버라이드 할 경우 파라미터와 반환 타입을 널이 될수있는 타입인지 널이 될 수 없는 타입인지 선언할 것인지 결정해야 된다.

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)
    }
}

아래와 같은 2가지 타입에 대한 구현 전부를 받아드릴 수 있다. 

 

널이 될수없는 타입으로 선언한 모든 파라미터에 대해 널이 아님을 검사하는 단언 문들 코틀린 컴파일러가 생성하여

다른 코틀린 코드가 이 함수를 사용할 때 만약 자바 코드가 null을 넘기면 단언 문이 발동해 예외가 발생한다.