!!(널 아님 단언)
널 아님 단언은 코틀린에서 널이 될 수 있는 타입의 값을 다룰 때 사용할 수 있는 도구 중 가장 단순하면서 무딘 도구다
!! 연산자를 사용하면 어떤 값이든 널이 될 수 없는 타입으로 바꿀 수 있다.
fun main(args: Array<String>) {
ignoreNulls(null)
}
fun ignoreNulls(s: String?) {
val sNotNull: String = s!! //예외는 이 지점을 가리킨다
println(sNotNull.length)
}
- s 가 널이면 코틀린이 택할 수 있는 대안은 예외를 던지는 일 외에는 없다.
- 예외가 발생한 곳은 null값을 사용하는 코드가 아니라 단언문이 위치한 곳이다(!!)
- 근본적으로 !!는 컴파일러에게 이 값이 null이 아님을 알고 있음을 선언하고, 예외가 발생해도 감수하겠다는 의미이다.
- !! 는 컴파일러에게 소리를 지르는 느낌이 드는데 이는 이를 의도하고 작성한 것이다( 컴파일러가 검증할 수 없는 단언을 사용하기보다는 더 나은 방법을 찾아보라는 의도를 넌지시 표현하기위해 !! 기호를 택했다)
- 어떤 함수가 값이 널 인지 검사한 다음에 다른 함수를 호출한다고 해도 컴파일러는 호출된 함수 안에서 안전하게 그 값을 사용할 수 있음을 인식할 수 없다.
- 단, 이럴 경우에도 호출된 함수가 언제나 다른 함수에서 널이 아닌 값을 전달받는 게 명확하다면 굳이 널 검사를 다시 수행하고 싶지 않을 것인데 이럴 때 널 아님 단언문을 사용할 수 있다.
class CopyRowAction(val list:JList<String>): AbstractAction() {
override fun isEnabled(): Boolean = list.selectedValue != null
override fun actionPerformed(e: ActionEvent?) {
val value = list.selectedValue!!
//value selected 복사
}
}
- !! 연산자를 사용하지 않으려면 val value = list.selectedValue?: return과 같이 널이 될 수 없는 타입 값을 얻어야 한다.
- 이럴 경우 널 아님 단언문을 선언하게 되면 null일 경우 함수가 조기 종료되므로 함수의 나머지 본문에서는 value가 항상 널이 아니게 된다.
- !! 를 널에 대해 사용해서 발생하는 예외의 스택 트레이스에는 어떤 파일의 몇 번째 줄인 지에 대한 정보는 있지만, 어떤 식에서 예외가 발생했는지에 대한 정보는 없기 때문에 여러 !! 단언문을 한 줄에 함께 쓰는 일을 피하자
let 함수
let 함수를 사용하면 널이 될 수 있는 식을 더 쉽게 다룰 수 있다.
let 함수를 안전한 호출 연산자와 함께 사용하면 원하는 식을 평가해서 결과가 널 인지 검사한 다음에 그 결과를 변수에 넣는 작업을 간단한 식을 사용해 한꺼번에 처리할 수 있다.
let을 사용하는 가장 흔한 경우는 널이 될 수 있는 값을 널이 아닌 값만 인자로 받는 함수에 넘기는 경우이다.
fun main(args: Array<String>) {
val email: String? = null
//sendEmailTo(email) //불가능하다. 널이 될 수 있는 타입의 값을 넘길수 없다
if(email != null)
sendEmailTo(email)//null 인지 검사후 전달해야한다
}
fun sendEmailTo(email: String){
//logic
}
- 해당 함수에 널이 될 수 있는 값을 넘길 수 없다.
- 인자를 넘기기전에 null인지 검사해야 한다
- 하지만 let 함수를 통해 인자를 전달할 수 도 있다.
- let함수는 자신의 수신 객체를 인자로 전달받은 람다에게 넘긴다.
- 널이 될 수 있는 값에 대해 안전한 호출 구문을 사용해 let을 호출하되 널이 될 수 없는 타입의 값으로 바꿔서 람다에 전달하게 된다.
- let 함수는 이메일 주소 값이 널이 아닌 경우에만 호출된다.
fun main(args: Array<String>) {
var email: String? = "yole@example.com"
email?.let { sendEmailTo(it) }
email = null
email?.let { sendEmailTo(it) }
}
fun sendEmailTo(email: String){
//logic
println("sending email to $email")
}
- 아주 긴 식이 있고 그 값이 널이 아닐 때 수행해야 하는 로직이 있을 때 let을 쓰면 편하다
- let을 쓰면 긴 식의 결과를 저장하는 변수를 따로 만들 필요가 없다
- 여러 값이 널 인지 검사해야 한다면 let 호출을 중첩시켜서 처리할 수 있지만 코드가 복잡해져서 알아보기 힘들다
- 일반적인 if문을 사용해 모든 값을 한꺼번에 검사하는 편이 낫다
나중에 초기화할 프로퍼티
객체 인스턴스를 일단 생성한 뒤, 나중에 초기화하는 프레임워크들이 많이 있다. JUnit 같은 경우 @Before로 애노테이션 된 메서드 안에서 초기화 로직을 수행해야 한다.
하지만, 코틀린에서 클래스 안의 널이 될 수 없는 프로퍼티를 생성자 안에서 초기화하지 않고 특별한 메서드 안에서 초기화할 수 없다. 코틀린에서는 일발적으로 생성자에서 모든 프로퍼티를 초기화해야 한다. 게다가 프로퍼티 타입이 널이 될 수 없는 타입이라면 반드시 널이 아닌 값으로 프로퍼티를 초기화해야 한다. 그런 초기화 값을 제공할 수 없다면 널이 될 수 있는 타입을 사용할 수밖에 없는데 널이 될 수 있는 타입을 사용하면 모든 프로퍼티 접근에 널 검사를 넣거나!! 연산자를 써야 한다
class MyService {
fun performAction(): String = "foo"
}
class MyTest {
private var myService:MyService? = null //null 로 초기화 하기 위해 널이 될 수 있는 타입인 프로퍼티를 선언
@Before
fun setUp(){
myService = MyService() //setUp 메서드 안에서 진짜 초깃값을 지정
}
}
- 코드 가독성이 좋지 않다. 프로퍼티를 여러 번 사용해야 하면 코드가 더 못생겨진다.
- myService 프로퍼티를 나중에 초기화할 수 있는데 lateinit 변경자를 붙이면 된다.
class MyService {
fun performAction(): String = "foo"
}
class MyTest {
private lateinit var myService:MyService // 초기화하지 않고 널이 될 수 없는 프로퍼티를 선언
@Before
fun setUp(){
myService = MyService() //setUp 메서드 안에서 진짜 초깃값을 지정
}
@Test
fun testAction(){
assertEquals("foo", myService.performAction()) //널 검사를 수행하지 않고 프로퍼티를 사용한다
}
}
- 나중에 초기화하는 프로퍼티는 항상 var여야 한다.
- val 프로퍼티는 final 필드로 컴파일되며 생성자 안에서 반드시 초기화해야 한다.
- 따라서 생성자 밖에서 초기화해야 하는 프로퍼티는 반드시 var 여야 한다.
- 나중에 초기화하는 프로퍼티는 널이 될 수 없는 타입이라고 해도 더 이상 생성자 안에서 초기화할 필요가 없다.
- 프로퍼티를 초기화하기 전에 프로퍼티에 접근하면 lateinit property myService has not been initialized 예외가 발생한다.
- 예외가 어디서 잘못됐는지 알 수 있으므로 단순 NullPointException 보다 좋다.
널이 될 수 있는 타입 확장
널이 될 수 있는 타입에 대한 확장 함수를 정의하면 null 값을 다루는 강력한 도구로 활용할 수 있다. 어떤 메서드를 호출하기 전에 수신 객체 역할을 하는 변수가 널이 될 수 없다고 보장하는 대신, 직접 변수에 대해 메서드를 호출해도 확장 함수인 메서드가 알아서 널을 처리 해준다.
이런 처리는 확장 함수에서만 가능하며, 일반 멤버 호출은 객체 인스턴스를 통해 디스패치 되므로 그 인스턴스가 널 인지 여부를 검사하지 않는다
fun main(args: Array<String>) {
verifyUserInput(" ")
verifyUserInput(null)
}
fun verifyUserInput(input: String?){
if(input.isNullOrBlank()) //안전한 호출을 하지 않아도 된다
println("Please fill in the required fields")
}
- 안전한 호출 없이도 널이 될 수 있는 수신 객체 타입에 대해 선언된 확장 함수를 호출 가능하다.
- input.isNullorBlank()
- 함수는 null 값이 들어오는 경우 이를 적절하게 처리한다.
- isNullOrBlank는 널을 명시적으로 검사해서 널인 경우 true를 반환하고, 널이 아닌 경우 isBlank를 호출한다.
- isBlank는 널이 아닌 문자열 타입의 값에 대해서만 호출할 수 있다.
@kotlin.internal.InlineOnly
public inline fun CharSequence?.isNullOrBlank(): Boolean { //널이 될 수 있는 String 확장
contract {
returns(false) implies (this@isNullOrBlank != null)
}
return this == null || this.isBlank() // 두번째 this는 스마트 캐스트 적용
}
- 널이 될 수 있는 타입에 대한 확장을 정의하면 널이 될 수 있는 값에 대해 그 확장 함수를 호출할 수 있다.
- 그 함수의 내부에서 this는 널이 될 수 있으므로 명시적으로 널 여부를 검사해야 한다.
- 자바에서는 메서드 안 this는 호출된 수신 객체를 가리켜 null이 아니지만, 코틀린에서는 null이 될 수 있는 타입의 확장 함수 안에서는 this도 널이 될 수 있다.
val person:Person? = Person("test", null)
person.let { sendEmailTo(it.name) //it는 널이 될수 있는 타입으로 취급 }
- let 함수 같은 경우도 널이 될수 있는 타입의 값에 대해 호출할 수 있지만 this가 널 인지 검사하지 않는다
- 널이 될 수 있는 타입의 값에 대해 안전한 호출을 사용하지 않고 let을 호출하면 람다의 인자는 널이 될 수 있는 타입으로 추론된다.
- 따라서 let을 사용할 때 수신 객체가 널이 아닌지 검사하고 싶다면 예전에 살펴본 person?. let {sendEmail(it)}처럼 반드시 안전한 호출 연산인?. 을 사용해야 한다
타입 파라미터의 널 가능성
코틀린에서 함수나 클래스의 모든 타입 파라미터는 기본적으로 널이 될 수 있다. 널이 될 수 있는 타입을 포함하는 어떤 타입이라도 타입 파라미터를 대신할 수 있다.
따라서 타입 파라미터 T를 클래스나 함수 안에서 타입이름으로 사용하면 이름 끝에 물음표가 없더라도 T가 널이 될 수 있는 타입이다
fun main(args: Array<String>) {
printHashCode(null) //T타입은 Any?로 추론된다
}
fun <T> printHashCode(t: T){
println(t?.hashCode()) //t가 null이 될 수 있으므로 안전한 호출을 써야하만 한다
}
- printHashCode 호출에서 타입 파라미터 T에 대해 추론한 타입은 널이 될 수 있는 Any? 타입이다.
- t 파라미터의 타입 이름 T에는 물음표가 붙어 있지 않지만 t는 null을 받을 수 있다.
- 타입 파라미터가 널이 아님을 확실히 하려면 널이 될 수 없는 타입 상한를 지정해야한다.(지정시 널이 될 수 있는 값 거부)
fun main(args: Array<String>) {
//printHashCode(null) //해당 코드는 더이상 컴파일 되지 않고 널이 될 수 있는 타입의 파라미터에 넘길수 없다
println(42)
}
fun <T:Any> printHashCode(t: T){ //T는 널이 될 수 없는 타입이다
println(t.hashCode())
}
널 가능성과 자바
자바의 타입 시스템은 널 가능성을 지원하지 않는다. 그렇다면 자바와 코틀린을 조합하면 어떤 일이 생길까??
첫째로 자바 코드에도 애노테이션으로 표시된 널 가능성 정보가 있으므로 이런 정보가 코드에 있다면 코틀린은 그 정보를 활용한다.
예를 들어 @Nullable String = String? , @NotNull String = String이다
만약 널 가능성 애노테이션이 없다면 자바의 타입은 코틀린의 플랫폼 타입이 된다
플랫폼 타입
코틀린이 널 관련 정보를 알 수 없는 타입을 플랫폼 타입이라 한다. 그 타입을 널이 될 수 있는 타입으로 처리해도 되고, 널이 될 수 없는 타입으로 처리해도 된다. 플렛폼 타입에 대해 수행하는 모든 연산에 대한 책임은 온전히 사용자에게 있다는 의미로 컴파일러는 모든 연산을 허용한다.
코틀린은 플랫폼타입의 값에 대해 널 안전성을 검사를 중복 수행해도 아무 경고도 표시하지 않는다. 어떤 플렛폼 타입의 값이 널이 될 수도 있음을 알고 있다면 그 값을 사용하기 전에 널인지 검사할 수 있다. 어떤 플랫폼 타입의 값이 널이 아님을 알고 있다면 아무 널 검사 없이 그값을 직접 사용해도 된다. 만약 사용자가 틀렸다면 NullPointerException이 발생한다.
JAVA
public class Person {
private final String name;
public Person(String name) {
this.name = name;
}
public String getName(){
return name;
}
}
- getName은 null을 리턴할지 말지, 코틀린 컴파일러는 널 가능성에 대해 전혀 알지 못한다.
- 널 가능성을 개발자가 직접 처리해야만 한다.
- 이 변수가 널이 아님을 확신할 수 있다면, 자바와 마찬가지로 추가 검사 없이 이를 참조 할 수 있지만, 추가검사를 하지 않으면 예외가 발생할 수 있음을 염두해야 한다
fun main(args: Array<String>) {
yellAt(Person(null))
}
fun yellAt(person: Person){
println(person.name.toUpperCase() + "!!!") //toUpperCase의 수신 객체 person.name이 널이여서 예외 발생
}
- 코틀린 컴파일러는 공개 가시성인 코틀린 함수의 널이 아닌 타입인 파라미터와 수신 객체에 대한 널 검사를 추가해준다.
- 공개 가시성 함수에 널 값을 사용하면 즉시 예외가 발생한다.
- 이런 파라미터 값 검사는 함수 내부에서 파라미터를 사용하는 시점이 아닌 함수 호출 시점에 이뤄진다
- 잘못된 인자로 함수를 호출해도 그 인자가 여러 함수에 전달돼 전혀 엉뚱한 위치에서 예외가 발생하는 것이 아닌 가능한 한 빨리 예외가 발생하기 때문에 원인 파악을 더 빨리 할 수 있다.
fun main(args: Array<String>) {
yellAt(Person(null))
}
fun yellAt(person: Person){
println((person.name ?: "AnyOne").toUpperCase() + "!!!")
}
- 대부분을 자바 API 라이브러리는 널 관련 애노테이션을 쓰지 않는다.
- 따라서 모든 타입을 널이 아닌 것처럼 다루기 쉽지만 오류가 발생할 수 있으므로 조심해서 다뤄야 한다
- 오류를 피하기 위해서는 자바 메서드 문서를 자세히 읽어보고 널 검사 로직을 추가할지 말지 생각하자
TIP : 코틀린이 왜 플랫폼 타입을 도입했나
모든 자바 타입을 널이 될 수 있는 타입으로 다루면 더 안전하다고 생각할 수도 있지만 결코 널이 될 수 없는 값에 대해서도 불필요한 널 검사가 들어간다. 특히 제네릭 같은 경우 ArrayList<String> 을 ArrayList<String?> 처럼 다루게 된다면 원소에 접근할 때마다 널 검사를 수행하거나 안전한 캐스트가 필요하므로 널 안정성으로 얻는 비용보다 검사에 드는 비용이 더 크다. 그래서 코틀린 설계자는 자바의 타입을 가져온 경우 프로그래머에게 그 타입을 제대로 처리할 책임을 부여하는 방식을 선택했다
fun main(args: Array<String>) {
val person = Person("2")
val i:Int = person.name
}

- 코틀린에서는 플랫폼 타입을 선언할 수 없고, 자바 코드에서 가져온 타입만 플랫폼 타입이 된다.
- String!라는 타입은 자바 코드에서 온 타입으로,! 표기는 String! 타입의 널 가능성에 대해 아무 정보도 없다는 뜻이다.
상속
코틀린에서 자바 메서드를 오버라이드 할 때 그 메서드의 파라미터와 반환 타입을 널이 될 수 있는 타입으로 선언할지 널이 될 수 없는 타입으로 선언할 지 결정해야 한다.
JAVA Interface
public interface StringProcessor {
void process(String value);
}
Kotlin
class StringPrinter: StringProcessor {
override fun process(value: String) {
println(value)
}
}
class NullableStringPrinter: StringProcessor{
override fun process(value: String?) {
if(value != null){
println(value)
}
}
}
- 코틀린 컴파일러는 두 구현 모두 받아들인다
- 구현 메서드를 다른 코틀린 코드가 호출할 수 있으므로 코틀린 컴파일러는 널이 될 수 없는 타입으로 선언한 모든 파라미터에 대해 널이 아님을 검사하는 단언문을 만들어 준다.
- 자바 코드가 그 메서드에게 널 값을 넘기면 이 단언문이 발동돼 예외가 발생
- 파라미터를 메서드 안에서 사용하지 않더라도 예외를 피할 수 없다
'KotlinInAction > 코틀린 타입 시스템' 카테고리의 다른 글
| 컬렉션과 배열 (0) | 2022.08.07 |
|---|---|
| 코틀린의 원시 타입 (0) | 2022.08.07 |
| 널 가능성(1) (0) | 2022.08.06 |