-
6장. 코틀린 타입 시스템 - (1) 널 가능성Kotlin 2022. 3. 25. 12:01
널 가능성(nullability)
NullPointerException 오류를 피할 수 있게 돕기 위한 코틀린 타입 시스템의 특성이다.
null에 대한 접근 방법은 가능한 한 이 문제를 실행 시점에서 컴파일 시점으로 옮기는 것이다.
널이 될 수 있는지 여부를 타입 시스템에 추가함으로써 컴파일러가 여러 가지 오류를 컴파일 시
미리 감지해서 실행 시점에 발생할 수 있는 예외의 가능성을 줄일 수 있다.
널이 될 수 있는 타입
자바와 달리 코틀린 타입 시스템이 널이 될 수 있는 타입을 명시적으로 지원한다는 점이다.
널이 될 수 있는 타입은 프로그램 안의 프로퍼티나 변수에 null을 허용하게 만드는 방법이다.
어떤 변수가 널이 될 수 있다면 그 변수에 대해(그 변수를 수신 객체로) 메소드를 호출하면 NullPointerException이 발생할 수 있으므로 안전하지 않다. 코틀린은 그런 메소드 호출을 금지함으로써 많은 오류를 방지한다.
"이 함수가 널을 인자로 받을 수 있는가?"
직접 null 리터럴을 사용하는 경우뿐 아니라 변수나 식의 값이 실행 시점에 null이 될 수 있는 경우를 모두 포함한다.
널이 인자로 들어올 수 없는 경우
파라미터 s의 타입은 String인데 코틀린에서는 이는 s가 항상 String의 인스턴스여야 한다는 뜻이다.
컴파일러는 널이 될 수 있는 값을 strLen에게 인자로 넘기지 못하게 막는다.
strLen 함수가 결코 실행 시점에 NullPointerException을 발생시키지 않으리라 장담할 수 있다.
fun strLen(s:String) = s.length strLen(null) //Null can not be a value of a non-null type String
널이 인자로 들어올 수 있는 경우
널과 문자열을 인자로 받을 수 있게 하려면 타입 이름 뒤에 물음표(?)를 명시해야 한다. (ex. String?, Int?)
어떤 타입이든 타입 이름 뒤에 물음표를 붙이면 그 타입의 변수나 프로퍼티에 null 참조를 저장할 수 있다는 뜻이다.
Type? = Type 또는 null
널이 될 수 있는 타입의 변수에는 null 참조를 저장할 수 있다.
물음표가 없는 타입 - 그 변수가 null 참조를 저장할 수 없다는 뜻
모든 타입은 기본적으로 널이 될 수 없는 타입이다. 뒤에 ?가 붙어야 널이 될 수 있다.
널이 될 수 있는 타입의 변수는 수행할 수 있는 연산이 제한
1. 변수.메소드() 처럼 메소드를 직접 호출할 수 없다.
2. 널이 될 수 있는 값을 널이 될 수 없는 타입의 변수에 대입할 수 없다.
3. 널이 될 수 있는 값을 널이 될 수 없는 타입의 파라미터를 받는 함수에 전달할 수 없다.
//메소드 직접 호출 불가 - Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String? fun strLenSafe(s:String?) = s.length()
//널이 될 수 있는 값을 널이 될 수 없는 타입의 변수에 대입 불가 //val y : String = x//Type mismatch: inferred type is String? but String was expected val x : String? = null val y : String = x
//널이 될 수 있는 타입의 값을 널이 될 수 없는 타입의 파라미터를 받는 함수에 전달할 수 없다. //Type mismatch: inferred type is String? but String was expected strLen(x)
널이 될 수 있는 타입의 역할
가장 중요한 일은 null가 비교하는 것이다.
일단 null가 비교하고 나면 컴파일러는 그 사실을 기억하고 null이 아님이 확실한 영역에서는
해당 값을 널이 될 수 없는 타입의 값처럼 사용할 수 있다.
fun strLenSafe(s:String?) :Int = if(s != null) s.length else 0 //null 검사를 추가하면 코드가 컴파일된다. val y : String? = null println(strLenSafe(y)) // 0 println(strLenSafe("abc")) // 3
타입의 의미
"타입은 분류로. .타입은 어떤 값들이 가능한지와 그 타입에 대해 수행할 수 있는 연산의 종류를 결정한다."
자바의 String 타입의 변수
String이나 null이라는 두 가지 종류의 값이 들어갈 수 있다. 이 두 종류의 값은 서로 완전히 다르다.
변수에 선언된 타입이 있지만 널 여부를 추가로 검사하기 전에는 그 변수에 대해 어떤 연산을 수행할 수 있
을지 알 수 없다.
해결법 - 코틀린의 널이 될 수 있는 타입
널이 될 수 있는 타입과 널이 될 수 없는 타입을 구분하면 각 타입의 값에 대해 어떤 연산이 가능할지
명확하게 이해할 수 있고, 실행 시점에 예외를 발생시킬 수 있는 연산을 판단할 수 있다.
따라서 그런 연산을 아예 금지시킬 수 있다.
안전한 호출 연산자: ?.
?.은 null 검사와 메소드 호출을 한 번의 연산으로 수행한다.
호출하려는 값이 null이 아니라면 ?.은 일반 메소드 호출처럼 작동한다.
호출하려는 값이 null이라면 이 호출은 무시되고 null이 결과 값이 된다.
안전한 호출 연산자는 널이 아닌 값에 대해서만 메소드를 호출한다 안전할 호출의 결과 타입도 널이 될 수 있는 타입이라는 사실에 유의해야 한다.
String.toUpperCase는 String 타입의 값을 반환하지만 s가 널이 될 수 있는 타입인 경우
s?.toUpperCase() 식의 결과 타입은 String?이다.
fun printAllCaps(s: String?){ val allCaps : String? = s?.toUpperCase() //allCaps는 널일 수도 있다. println(allCaps) } printAllCaps("abc") // ABC printAllCaps(null) // null
널이 될 수 있는 프로퍼티를 다루기 위해 안전한 호출 사용하기
class Employee(val name: String, val manager:Employee?) fun managerName(employee: Employee) : String ?= employee.manager?.name val ceo = Employee("Da Boss" , null) val developer = Employee("Bob Smith", ceo) println(managerName(developer)) //Da Boss println(managerName(ceo)) //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" } val person = Person("Dmitry", null) println(person.countryName())//Unknown
엘비스 연산자: ?:
코틀린은 null 대신 사용할 디폴트 값을 지정할 때 편리하게 사용할 수 있는 엘비스 연산자를 제공한다.
fun foo(s:String?){ val t: String = s ?: "" //"s"가 null이면 결과는 빈 문자열("")이다 }
이 연산자는 이항 연산자로 좌항을 계산한 값이 널인지 검사한다.
좌항 값이 널이 아니면 좌항 값을 결과로 하고, 좌항 값이 널이면 우항 값을 결과로 한다.
엘비스 연산자는 널을 특정 값으로 바꿔준다. 엘비스 연산자를 활용해 널 값 다루기
엘비스 연산자를 객체가 널인 경우 널을 반환하는 안전한 호출 연산자와 함께 사용해서 객체가 널인 경우에 대비한 값을 지정하는 경우도 많다.
fun strLenSafeNull(s:String?) : Int = s?.length ?: 0 println(strLenSafeNull("abc"))//3 println(strLenSafeNull(null))//0
fun Person.countryNameNull() = company?.address?.country ?: "unKnown"
throw와 엘비스 연산자 함께 사용하기
코틀린에서는 return이나 throw 등의 연산도 식이다.
따라서 엘비스 연산자의 우항에 return, throw 등의 연산을 넣을 수 있고, 엘비스 연산자를 더욱 편하게 사용할 수 있다.
그런 경우 엘비스 연산자의 좌항이 널이면 함수가 즉시 어떤 값을 반환하거나 예외를 던진다.
printShippintLabel 함수는 모든 정보가 제대로 있으면 주소를 출력한다.
주소가 없다면 그냥 NullPointerException을 던지는 대신에 의미 있는 오류를 발생시킨다.
주소가 있다면 라벨은 거리 주소, 우편 번호, 도시, 나라 순으로 구성된다.
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) { //"address"는 널이 아니다. println(streetAddress) println("$zipCode $city $country") } } val address = Address("Elsestr. 47", 80687 ,"Munich", "Germany") val jetbrains = Company("JetBrains", address) val person_ = Person("Dmitry", jetbrains) printShippingLabel(person_) printShippingLabel(Person("Alexey",null))//java.lang.IllegalArgumentException: No address
안전한 캐스트 : as?
타입 캐스트 as는 대상 값을 as로 지정한 타입으로 바꿀 수 없으면 ClassCastException이 발생한다.
as 를 사용할 때마다 is를 통해 미리 as로 변환 가능한 타입인지 검사해볼 수 있다.
as? 연산자는 어떤 값을 지정한 타입으로 캐스트한다.
as?는 값을 대상 타입으로 변환할 수 없으면 null을 반환한다.
안전한 캐스트를 사용할 때 일반적인 패턴은 캐스트를 수행한 뒤에 엘비스 연산자를 사용하는 것이다.
타입 캐스트 연산자는 값을 주어진 타입으로 변환하려 시도하고 타입이 맞지 않으면 null을 반환한다. class People(val firstName:String, val lastName :String) { override fun equals(o: Any?): Boolean { val otherPerson = o as? People ?: return false // 타입이 일치 하지 않으면 false를 반환한다. //안전한 캐스트를 하고 나면 otherPerson이 Person 타입으로 스마트 캐스트 된다. return otherPerson.firstName == firstName && otherPerson.lastName == lastName } override fun hashCode(): Int = firstName.hashCode() * 37 + lastName.hashCode() } val p1 = People("Dmitry", "Jemerov") val p2 = People("Dmitry", "Jemerov") println(p1==p2) //true println(p1.equals(42)) //false
널 아님 단언 : !!
느낌표를 이중(!!)으로 사용하면 어떤 값이든 널이 될 수 없는 타입으로 (강제로) 바꿀 수 있다.
실제 널에 대해 !!를 적용하면 NPE가 발생한다.
널 아님 단언을 사용하면 값이 널일 때 NPE를 던질 수 있다. fun ignoreNulls(s:String?){ val sNotNull : String = s!! //예외는 이 지점을 가리킨다. println(sNotNull.length) } ignoreNulls(null) //java.lang.NullPointerException
발생한 예외는 null 값을 사용하는 코드(sNotNull.length가 있는 줄) 가 아니라 단언문이 위치한 곳을 가리킨다는 점을 유의하라.
!!는 컴파일러에게 "나는 이 값이 null이 아님을 잘 알고 있다. 내가 잘못 생각했다면 예외가 발생해도 감수하겠다."
class CopyRowAction(val list: JList<String>) : AbstractAction() { override fun isEnabled() : Boolean = list.selectedValue != null override fun actionPerformed(e: ActionEvent){//actionPerformed는 isEnabled가 "true"인 경우에만 호출된다. val value = list.selectedValue!! //value를 클립보드로 복사 } }
※ 주의 ※
!!를 널에 대해 사용해서 발생하는 예외의 스택 트레이스에는 어떤 파일의 몇 번째 줄인지에 대한 정보는 들어있지만 어떤 식에서 예외가 발생했는지에 대한 정보는 들어있지 않다. 어떤 값이 널이었는지 확실히 하기 위해 여러 !! 단언문을 한 줄에 함께 쓰는 일을 피하라.
person.company!!.address!!.country ← 이런 식으로 코드를 작성하지 말라.
let 함수
let 함수를 안전한 호출 연산자와 함께 사용하면 원하는 식을 평가해서 결과가 널인지 검사한 다음에 그 결과를 변수에 넣는 작업을 간단한 식을 사용해 한꺼번에 처리할 수 있다.
가장 흔한 용례는 널이 될 수 있는 값을 널이 아닌 값만 인자로 받는 함수에 넘기는 경우다.
let 함수는 자신의 수신 객체를 인자로 전달받은 람다에게 넘긴다. 널이 될 수 있는 값에 대해 안전한 호출 구문을 사용해 let을 호출하되 널이 될 수 없는 타입을 인자로 받는 람다를 let에 전달한다.
널이 될 수 있는 타입의 값을 널이 될 수 없는 타입의 값으로 바꿔서 람다에 전달하게 된다.
let을 안전하게 호출하면 수신 객체가 널이 아닌 경우 람다를 실행해준다. fun sendEmailTo(email:String){ println("Sending email to $email") } var email : String ? ="yole@example.com" email?.let{ sendEmailTo(it)} //Sending email to yole@example.com email = null email?.let{ sendEmailTo(it)} // 아무일도 일어나지 않는다.
let을 쓰면 긴 식의 결과를 저장하는 변수를 따로 만들 필요가 없다.
여러 값이 널인지 검사해야 한다면 let 호출을 중첩시켜서 처리할 수 있다. 그렇게 let을 중첩시켜 처리하면 코드가 복잡해져서 알아보기 어려워진다. 그런 경우 일반적인 if를 사용해 모든 값을 한꺼번에 검사하는 편이 낫다.
나중에 초기화할 프로퍼티
코틀린 안에서 클래스 안의 널이 될 수 없는 프로퍼티를 생성자 안에서 초기화하지 않고 특별한 메소드 안에서 초기화할 수는 없다. 코틀린에서는 일반적으로 생성자에서 모든 프로퍼티를 초기화해야 한다. 프로퍼티 타입이 널이 될 수 없는 타입이라면 반드시 널이 아닌 값으로 그 프로퍼티를 초기화해야 한다. 초기화 값을 제공할 수 없으면 널이 될 수 있는 타입을 사용할 수 밖에 없다. 널이 될 수 있는 타입을 사용하면 모든 프로퍼티 접근에 널 검사를 넣거나 !! 연산자를 써야 한다.
널 아님 단언을 사용해 널이 될 수 있는 프로퍼티 접근하기
class MyService { fun performAction(): String = "foo" } class MyTest { private var myService : MyService?= null //null로 초기화하기 위해 널이 될 수 있는 타입인 프로퍼티를 선언한다. @Before fun setUp(){ myService = MyService()// setUp 메소드 안에서 진짜 초기값을 지정한다. } @Test fun testAction(){ Assert.assertEquals("foo", myService!!.performAction()) // 반드시 널 가능성에 신경써야 한다. !!나 ?을 꼭 써야 한다. } }
프로퍼티를 여러 번 사용해서 코드가 못생겨진다. 이를 해결하기 위해 myService 프로퍼티를 나중에 초기화(late-initialized)할 수 있다. lateinit 변경자를 붙이면 프로퍼티를 나중에 초기화할 수 있다.
나중에 초기화하는 프로퍼티 사용하기
class MyService { fun performAction(): String = "foo" } class MyTest { private lateinit var myService : MyService // 초기화하지 않고 널이 될 수 없는 프로퍼티를 선언한다. @Before fun setUp(){ myService = MyService()// 예제 6.10과 마찬가지로 setUp 메소드에서 프로퍼티를 초기화한다. } @Test fun testAction(){ Assert.assertEquals("foo", myService.performAction()) // 널 검사를 수행하지 않고 프로퍼티를 사용한다. } }
나중에 초기화하는 프로퍼티는 항상 var이여야 한다. val 프로퍼티는 final 필드로 컴파일되며, 생성자 안에서 반드시 초기화해야 한다. 따라서 생성자 밖에서 초기화해야 하는 나중에 초기화하는 프로퍼티는 항상 var여야 한다. 그렇지만 나중에 초기화하는 프로퍼티는 널이 될 수 없는 타입이라 해도 더 이상 생성자 안에서 초기화할 필요가 없다.
프로퍼티를 초기화하기 전에 프로퍼티에 접근하면 "lateinit property myService has not been initalized"(myService라는 lateinit 프로퍼티를 아직 초기화하지 않았음)이라는 예외가 발생한다.
코틀린은 lateinit가 지정된 프로퍼티와 가시성이 똑같은 필드를 생성해준다. 어떤 프로퍼티가 public이라면 코틀린이 생성한 필드도 public이다.
널이 될 수 있는 타입 확장
널이 될 수 있는 타입에 대한 확장 함수를 정의하면 null 값을 다루는 강력한 도구로 활용할 수 있다. 어떤 메소드를 호출하기 전에 수신 객체 역할을 하는 변수가 널이 될 수 없다고 보장하는 대신, 직접 변수에 대해 메소드를 호출해도 확장 함수인 메소드가 알아서 널을 처리해준다.
String? 타입의 수신 객체에 대해 호출할 수 있는 isNullOrEmpty이나 isNullOrBlank 메소드가 있다.
null이 될 수 있는 수신 객체에 대해 확장 함수 호출하기
fun verifyUserInput(input:String?){ if(input.isNullOrBlank()) { // 안전한 호출을 하지 않아도 된다. println("Please fill in the required fields") } } verifyUserInput(" ")//Please fill in the required fields //isNullOrBlank에 "null"을 수신 객체로 전달해도 아무런 예외가 발생하지 않는다. verifyUserInput(null)//Please fill in the required fields
안전한 호출 없이도 널이 될 수 있는 수신 객체 타입에 대해 선언된 확장 함수를 호출 가능하다.
- isEmpty : 문자열이 빈 문자열("")인지 검사한다.
- isBlank : 문자열이 모두 공백문자로 이뤄졌는지 검사한다. 널이 아닌 문자열 타입의 값에 대해서만 호출할 수 있다.
- isNullOrBlank : 널을 명시적으로 검사해서 널인 경우 true를 반환하고, 널이 아닌 경우 isBlank를 호출한다.
자바에서는 메소드 안의 this는 그 메소드가 호출된 수신 객체를 가리키므로 항상 널이 아니다.
코틀린에서는 널이 될 수 있는 타입의 확장 함수 안에서는 this가 널이 될 수 있다는 점이 자바와 다르다.
let은 this가 널인지 검사하지 않는다. 널이 될 수 있는 타입의 값에 대해 안전한 호출을 사용하지 않고 let을 호출하면 람다의 인자는 널이 될 수 있는 타입으로 추론한다. let을 사용할 때 수신 객체가 널이 아닌지 검사하고 싶다면 반드시 안전한 호출 연산인 ?.을 사용해야 한다.
타입 파라미터의 널 가능성
코틀린에서는 함수나 클래스의 모든 타입 파라미터는 기본적으로 널이 될 수 있다. 널이 될 수 있는 타입을 포함하는 어떤 타입이라도 타입 파라미터를 대신할 수 있다. 따라서 타입 파라미터 T를 클래스나 함수 안에서 타입 이름으로 사용하면 이름 끝에 물음표가 없더라도 T가 널이 될 수 있는 타입이다.
fun <T> printHashCode(t:T){ println(t?.hashCode()) // "t"가 null이 될 수 있으므로 안전한 호출을 써야만 한다. } //"T"의 타입은 "Any?"로 추론된다. printHashCode(null)//null
타입 파라미터가 널이 아님을 확실히 하려면 널이 될 수 없는 타입 상한(upper bound)를 지정해야 한다. 이렇게 널이 될 수 없는 타입 상한을 지정하면 널이 될 수 있는 값을 거부하게 된다.
fun <T:Any> printHashCodeT(t:T){// 이제 "T"는 널이 될 수 없는 타입이다. println(t.hashCode()) } //printHashCodeT(null) //이 코드는 컴파일되지 않는다. 널이 될 수 없는 타입의 파라미터에 널을 넘길 수 없다. printHashCodeT(42)//42
타입 파라미터는 널이 될 수 있는 타입을 표시하려면 반드시 물음표를 타입 이름 뒤에 붙여야 한다는 규칙의 유일한 예외다.
널 가능성과 자바
자바 코틀린 @Nullable Type Type? @NotNull Type Type 코틀린은 애노테이션이 붙은 자바 타입을 애노테이션에 따라 널이 될 수 있는 타입이나 널이 될 수 없는 타입으로 취급한다.
널 가능성 애노테이션이 소스코드에 없는 경우 자바의 타입은 코틀린의 플랫폼 타입이 된다.
플랫폼 타입
플랫폼 타입은 코틀린이 널 관련 정보를 알 수 없는 타입을 말한다. 플랫폼 타입은 널이 될 수 있는 타입이나 널이 될 수 없는 타입 모두로 사용할 수 있다. 컴파일러는 모든 연산을 허용한다.
모든 연산의 책임은 온전히 개발자에게 있다. 어떤 플랫폼 타입의 값이 널이 될 수도 있음을 알고 있다면 그 값을 사용하기 전에 널인지 검사할 수 있다. 어떤 플랫폼 타입의 값이 널이 아님을 알고 있다면 아무 널 검사 없이 그 값을 직접 사용해도 된다. 자바와 마찬가지로 여러분이 틀렸다면 NullPointerException이 발생한다.
코틀린은 보통 널이 될 수 없는 타입의 값에 대해 널 안전성을 검사하는 연산을 수행하면 경고를 표시하지만 플랫폼 타입의 값에 대해 널 안정성 검사를 중복 수행해도 아무 경고도 표시하지 않는다.
public class PersonTest { private final String name; public PersonTest(String name) { this.name = name; } public String getName(){ return name; } }
fun yellAt(persontest: PersonTest){ println(persontest.name.toUpperCase()+ "!!!") //toUpperCase()의 수신 객체 person.name가 널이어서 예외가 발생한다. } yellAt(PersonTest(null))
코틀린 컴파일러는 공개 가시성인 코틀린 함수의 널이 아닌 타입인 파라미터와 수신 객체에 대한 널 검사를 추가해준다. 따라서 공개 가시성 함수에 널 값을 사용하면 즉시 예외가 발생한다. 이런 파라미터 값 검사는 함수 내부에서 파라미터를 사용하는 시점이 아니라 함수 호출 시점에 이뤄진다.
fun yellAtSafe(personTest: PersonTest){ println((personTest.name ?: "Anyone").toUpperCase() + "!!!") //toUpperCase()의 수신 객체 person.name가 널이어서 예외가 발생한다. } yellAtSafe(PersonTest(null)) // ANYONE!!!
널 값을 제대로 처리하므로 실행 시점에 예외가 발생하지 않는다.
코틀린에서는 플랫폼 타입을 선언할 수는 없다. 자바 코드에서 가져온 타입만 플랫폼 타입이 된다.
상속
코틀린에서 자바 메소드를 오버라이드할 때 그 메소드의 파라미터와 반환 타입을 널이 될 수 있는 타입으로 선언할지 널이 될 수 없는 타입으로 선언할지 결정해야 한다.
자바 클래스나 인터페이스를 코틀린에서 구현할 경우 널 가능성을 제대로 처리하는 일이 중요하다. 구현 메소드를 다른 코틀린 코드가 호추할 수 있으므로 코틀린 컴파일러는 널이 될 수 없는 타입으로 선언한 모든 파라미터에 대해 널이 아님을 검사하는 단언문을 만들어준다.
'Kotlin' 카테고리의 다른 글
6장. 코틀린 타입 시스템 - (3) 컬렉션과 배열 (0) 2022.04.04 6장. 코틀린 타입 시스템 - (2) 코틀린의 원시 타입 (0) 2022.03.25 5장. 람다로 프로그래밍 - (5) 수신 객체 지정 람다 : with와 apply (0) 2022.03.17 5장. 람다로 프로그래밍 - (4) 자바 함수형 인터페이스 활용 (0) 2022.03.17 5장. 람다로 프로그래밍 - (3) 지연 계산(lazy) 컬렉션 연산 (0) 2022.03.11