KotlinInAction/코틀린 타입 시스템

널 가능성(1)

webmaster 2022. 8. 6. 00:41
728x90

널 가능성은 NullPointException 오류를 피할 수 있게 돕기 위한 코틀린 타입 시스템의 특성이다.

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

널이 될 수 있는 타입

코틀린과 자바의 첫 번째 차이이자 가장 중요한 차이는 코틀린 타입 시스템이 널이 될 수 있는 타입을 명시적으로 지원한다는 점이다.

널이 될 수 있는 타입은 프로그램 안의 프로퍼티나 변수에 null을 허용하게 하는 것인데, 어떤 변수가 널이 될 수 있다면 그 변수에 대해 메서드를 호출하게 된다면 NullPointException이 발생할 수 있으므로 안전하지 않다. 코틀린은 그런 메서드 호출을 금지함으로써 많은 오류를 방지한다.

JAVA

int strLen(String s) {
	return s.length();
}
  • 이 함수는 안전할까? s가 null일 경우 NullPointException이 발생한다.
  • 그렇다면 이 함수는 s가 null인지 꼭 검사해야 할까? 검사 필요 여부는 함수의 의도에 따라 달라진다.
fun main(args: Array<String>) {
    strLen(null)
}

fun strLen(s: String) = s.length

Null일경우 오류가 난다.

  • 이 함수가 null을 인자로 받을 수 있는가에 대한 답을 알아야 해당 함수를 작성할 수 있다.
    • null을 인자로 받는다는 뜻은 strLen(null)처럼 직접 null 리터럴을 사용하는 경우뿐 아니라, 변수나 식의 값이 실행 시점에 null이 될 수 있는 경우를 모두 포함한다.
  • 현재 작성한 함수는 null이 들어올 수 없는 경우를 모두 포함한다.
  • null이거나 null이 될 수 있는 인자를 넘기는 것은 금지되며, 혹시 그런 값을 넘기면 컴파일 오류가 발생한다.
  • strLen 함수에서 파라미터 s의 타입은 String인데, s가 항상 String 인스턴스여야 한다는 의미이다.
    • 이때, 컴파일러는 널이 될 수 있는 값을 strLen에게 인자로 넘기지 못하게 막는다
    • 따라서 strLen함수가 결코 실행 시점에 NullPointException을 발생시키지 않으리라 장담할 수 있다.
  • null과 문자열을 인자로 받을 수 있게 하려면 타입 이름 뒤에 물음표를 명시해야 한다.
fun main(args: Array<String>) {
    println(strLenSafe(null))
}

fun strLenSafe(s: String?) = s?.length ?: 0
//fun strLenSafe(s: String?) = s.length //null이 될 수 있는 타입에 메서드 호출 제한

널이 될수 있는 타입인 변수에 대핸 변수.메서드() 처럼 메서드를 직접호출할 수 없다

  • String? Int? MyCustomType? 등 어떤 타입이든 타입 뒤에 물음표를 붙이면 그 타입의 변수나 프로퍼티에 null 참조를 저장할 수 있다.
    • Type? = Type or null
  • 물음표가 없는 타입은 그 변수가 null 참조를 저장할 수 없다는 뜻이다, 따라서 모든 타입은 기본적으로 널이 될 수 없는 타입이다.
  • 널이 될 수 있는 타입의 변수가 있다면 그에 대해 수행할 수 있는 연산이 제한된다.
    • 널이 될 수 있는 타입인 변수에 대한 변수. 메서드()처럼 메서드를 직접 호출할 수 없다
fun main(args: Array<String>) {
    val x : String? = null
    val y:String = x
    strLen(x)
}
fun strLen(s: String) = s.length

널이 될수 있는 값을 널이 될수 없는 타입의 변수에 대입할 수 없다
널이 될수 있는 타입의 값을 널이 될수 없는 타입의 파라미터를 받는 함수에 전달할 수 없다

  • 널이 될 수 있는 값을 널이 될 수 없는 타입의 변수에 대입할 수 없다
  • 널이 될 수 있는 타입의 값을 널이 될 수 없는 타입의 파라미터를 받는 함수에 전달할 수 없다
  • 대체 널이 될 수 있는 타입의 값으로 무엇을 할 수 있을까?
    • 가장 중요한 일은 null과 비교하는 것이다.
    • null과 비교하고 나면 컴파일러는 그 사실을 기억하고 null이 아님이 확실한 영역에서는 해당 값을 널이 될 수 없는 타입의 값처럼 사용할 수 있다.
fun main(args: Array<String>) {
    val x: String? = null
    println(strLenSafe(x))
    println(strLenSafe("abc"))
}

fun strLenSafe(s: String?): Int =
    if (s != null) s.length else 0 //null검사를 추가하면 코드가 컴파일된다
  • 널 가능성을 다루기 위해 사용할 수 있는 도구가 if 검사뿐이라면 코드가 번잡해질 것이다
  • 코틀린은 널이 될 수 있는 값을 다룰 때 도움이 되는 여러 도구를 제공한다

타입의 의미

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

이런 정의를 자바 타입에 적용해보자!!

먼저 double 타입을 살펴보면 64비트 부동소수점 수로, double 타입의 값에 대해 일반 수학 연산 함수를 적용할 수 있다. 따라서 double 타입의 변수가 있고, 그 변수에 대한 연산을 컴파일러가 통과시킨 경우 그 연산이 성공적으로 실행되리란 사실을 확인할 수 있다.

String 타입을 비교해보자!! 자바에서 String 타입의 변수에는 String, null 두 가지 종류의 값이 들어갈 수 있고, 두 종류의 값은 서로 완전히 다르다( instanceof 연산자도 null이 String이 아니라고 한다). 두 종류의 값에 대해 실행할 수 있는 연산도 완전히 달라, null이 들어있는 경우 사용할 수 있는 연산이 많지 않다.

이는 자바의 타입 시스템이 널을 제대로 다루지 못한다는 의미이다. 변수에 선언된 타입이 있지만 널 여부를 추가로 검사하기 전에는 그 변수에 대해 어떤 연산을 수행할 수 있을지 알 수 없다. 프로그램을 작성하면서 프로그램의 데이터 흐름 속에서 특정 위치에 특정 변수가 절대로 널 일수 없다는 사실을 확신하고 이런 검사를 생략하는 경우가 자주 있지만 그 생각이 틀리면 실행 시점에 프로그램이 NullPointerException예외를 발생시키며 오류로 중단된다.

코틀린의 널이 될 수 있는 타입은 이런 문제에 대한 종합적인 해법을 제공한다. 널이 될 수 있는 타입과 널이 될 수 없는 타입을 구분하면 각 타입의 값에 대해 어떤 연산이 가능할지 명확하게 이해할 수 있고, 실행 시점에 예외를 발생시킬 수 있는 연산을 판단할 수 있다.(연산을 아예 금지시킬 수 있다)

 

TIP : NullPointerException 오류를 다루는 다른 방법

자바에서도 NullPointerException을 다루는 도구가 여러 개 있는데 @Nullable, @NotNull 어노테이션을 통해 NullPointerException이 발생하는 위치를 찾아주는 도구가 있다. 하지만 이런 도구는 표준 자바 컴파일 절차의 일부가 아니기 때문에 일관성 있게 적용된다는 보장을 할 수 없으며, 오류가 발생할 위치를 정확하게 찾기 위해 라이브러리를 포함하는 모든 코드 베이스에 어노테이션을 추가하는 일도 쉽지가 않다.

그래서 이 방법을 해결하기 위해 nulll 값을 코드에서 절대 사용하지 않고 optional 타입 등의 null을 감싸는 특별한 래퍼 타입을 이용하는 것이다. 이런 해법은 몇 가지 단점이 있는데, 코드가 더 지저분해지고 래퍼가 추가됨에 따라 실행 시점에 성능이 저하되며, 전체 시스템에서 일관성 있게 사용하기 어렵다. 내가 작성한 코드에서는 Optional을 사용하지만 다른 JDK 라이브러리, 안드로이드 프레임워크, 서드파트 라이브러리에서 반환되는 값은 여전히 null처리를 해줘야 하는 단점이 있는 것이다.

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

코틀린이 제공하는 가장 유용한 도구 중 하나가 안전한 호출 연산자인 (?.)이다. (?.)은 null 검사와 메서드 호출을 한 번의 연산으로 수행한다. 호출하려는 값이 null이 아니라면 (?.)은 일반 메서드 호출처럼 작동한다. 호출하려는 값이 null이면 이 호출은 무시되고 null이 결괏값이 된다.

fun main(args: Array<String>) {
    printAllCaps("abc")
    printAllCaps(null)
}
fun printAllCaps(s: String?){
    val allCaps: String? = s?.toUpperCase() //allCaps는 널일수 있다
    println(allCaps)
}
  • 안전한 호출의 결과 타입도 널이 될 수 있는 타입이라는 것을 유의하자.
  • s?.toUpperCase() 식의 결과 타입은 String?이다.
class Employee(val name:String, val manager: Employee?)

fun managerName(employee: Employee):String? = employee.manager?.name
fun main(args: Array<String>) {
    val ceo = Employee("Da Boss", null)
    val developer = Employee("Bob Smith", ceo)
    println(managerName(ceo))
    println(managerName(developer))
}
  • 널이 될 수 있는 프로퍼티가 있는 간단한 코틀린 클래스로 프로퍼티 접근 시 안전한 호출을 사용하는 방법을 보여준다.
  • 객체 그래프에서 널이 될 수 있는 중간 객체가 여럿 있다면 한 식 안에서 안전한 호출을 연쇄해서 함께 사용한다면 편할 때가 있다
    • ?. 연산자를 사용하면 다른 추가 검사 없이 프로퍼티를 한 줄로 가지고 올 수 있다.
class Person(val name: String, val company:Company?) {

}

class Company(val name: String, val address: Address?) {
    
}

class Address(
    val streetAddress: String, val zipCode: Int,
    val city: String, val country: String) {

}

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())
}
  • 자바에서 널 검사가 들어간 호출이 연달아 들어가 있는 코드를 코틀린에서는 간결하게 널 검사를 할 수 있다.

엘비스 연산자(?:)

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

엘비스 연산자는 (?:) 처럼 생겼다

fun foo(s: String?){
    val t:String = s ?: ""//s 가 null 이면  결과는 "" 이다.  
}
  • 이항 연산자로 좌항을 계산한 값이 널인지 검사한다.
    • 좌항 값이 널이 아니면 좌항 값을 결과로 한다.
    • 좌항 값이 널이면 우항 값을 결과로 한다.
fun main(args: Array<String>) {
    println(strLenSafe("abc"))
    println(strLenSafe(null))
}
fun strLenSafe(s: String?): Int = s?.length ?: 0
  • 엘비스 연산자를 객체가 널인 경우 널을 반환하는 안전한 호출 연산자와 함께 사용해서 객체가 널인 경우에 대비한 값을 지정하는 경우도 많다.
class Person(val name: String, val company: Company?) {

}

class Company(val name: String, val address: Address?) {

}

class Address(
    val streetAddress: String, val zipCode: Int,
    val city: String, val country: String
) {

}

fun printShippingLabel(person: Person) {
    // 주소가 없으면 예외 발생
    val address = person.company?.address ?: throw IllegalArgumentException("No address")
    
    with(address){  //address 는 null이 아니다
        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))
}
  • 코틀린에서는 return이나 throw 등의 연산도 식으로 엘비스 연산자의 우항에 해당 연산을 넣을 수 있다.
    • 그런 경우 엘비스 연산 좌항이 널이면 함수가 즉시 어떤 값을 반환하거나 예외를 던진다
    • 이런 패턴은 함수의 전제 조건을 검사하는 경우 유용하다.
  • printShippingLabel 함수는 모든 정보가 제대로 있으면 주소를 출력하고 없으면 NullPointerException을 던지는 것이 아닌 의미 있는 예외를 던진다.

as?(안전한 캐스트)

자바 타입 캐스트와 마찬가지로 대상 값을 as로 지정한 타입으로 바꿀 수 없다면 ClassCastException이 발생한다.

as를 사용할 떄마다 is를 통해 미리 as로 변환 가능한 타입인지 검사해 볼 수도 있지만 코틀린은  더 좋은 해법을 제공한다.

as? 연산자는 어떤 값을 지정한 타입으로 캐스트 한다. 값을 대상 타입으로 변환할 수 없으면 null을 반환한다.

class Person(val firstName: String, val lastName: String) {
    override fun equals(o: Any?): Boolean {
        val otherPerson = o as? Person ?: return false //타입이 서로 일치하지 않으면 false 반환
        return otherPerson.firstName == firstName &&
                otherPerson.lastName == lastName //안전한 캐스트를 하고 나면 otherPerson이 Person 타입으로 스마트 캐스트 된다
    }

    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를 반환할 수 있다.
  • 스마트 캐스트 상황에서도 적용할 수 있는데, 일단 타입을 검사한 후 null 값을 거부하고 나면 컴파일러가 otherPerson변수의 값이 Person이라는 사실을 알고 적절하게 처리해 줄 수 있다.
728x90

'KotlinInAction > 코틀린 타입 시스템' 카테고리의 다른 글

컬렉션과 배열  (0) 2022.08.07
코틀린의 원시 타입  (0) 2022.08.07
널 가능성(2)  (0) 2022.08.06