널 가능성

맥모닝·2023년 11월 29일
0

Kotlin

목록 보기
8/10

널 가능성

컴파일 시점에 NullPointerException 오류를 방지하기 위해 도움을 주는 코틀린 타입 시스템의 특성

널이 될 수 있는 타입

코틀린 타입 시스템은 타입 이름 뒤에 물음표(?)를 명시하여 널이 될 수 있는 타입을 정의할 수 있도록 지원한다.

에러가 발생하는 경우

모든 타입은 기본적으로 널이 될 수 없는 타입이다.

1. 널이 될 수 있는 타입에 대해 직접적으로 메서드나 프로퍼티에 접근하려고 할 때

fun strLenSafe(s: String?) = s.length()
// 결과: Error: only safe(?.) or non-null asserted (!!.) calls are allowed on ..

2. 널이 될 수 있는 값을 널이 될 수 없는 타입의 변수에 대입할 때

val x: String? = null
var y: String = x
// 결과: Error: Type mismatch: inferred type is String? but String was expected 

3. 널이 될 수 없는 타입의 파라미터를 받는 함수에 널이 될 수 있는 타입의 값을 전달할 때

strLen(x)
// 결과: Error: Type mismatch: inferred type is String? but String was expected

타입

사전적 정의 : 어떤 부류의 형식이나 형태

소프트웨어 관점 : 여러 종류의 데이터를 식별하는 분류로, 어떤 값이 가능한지와 그 타입에 대해 수행할 수 있는 연산의 종류를 결정한다.

널이 될 수 있는 타입의 역할 : 널이 될 수 있는 타입과 널이 될 수 없는 타입을 명확히 구분하면 각 타입의 값에 대해 가능한 연산을 명확하게 이해할 수 있으며, 실행 시점에 예외를 발생시킬 수 있는 연산을 사전에 판단하여 이를 금지시킬 수 있다.

널 가능성 체크

1. 널 타입(?) 및 널 아님 단언(!!) 연산자

널 타입 : 타입 뒤에 물음표(?)를 붙여서 널 참조 허용을 표시한다.

널 아님 단언 연산자(!!) : 널이 될 수 있는 타입을 널이 될 수 없는 타입으로 바꿀 수 있다.

fun ignoreNulls(s: String?) {
    // 예외는 null 값을 사용하는 코드가 아닌 단언문이 위치한 곳을 가리킨다.
    val sNotNull: String = s!!
    println(sNotNull.length)
}

ignoreNulls(null)
// Expceion in thread "main" kotlin.KotlinNullPointerException ...

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

널이 될 수 있는 객체에 안전하게 접근할 수 있다. 객체가 널이 아니면 해당 객체의 멤버에 접근하고, 객체가 널이면 널을 반환한다.

// 객체 그래프에서 널이 될 수 있는 중간 객체가 여럿 있는 경우
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: String? = this.company?.address?.country
    return country
}

val person = Person("Dmitry", null)
println(person.countryName()) // null

3. 엘비스 연산자(?:)

왼쪽 식이 널이 아니면 해당 값을 반환하고, 널이면 오른쪽 식의 값(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 printShippingLabel(person: Person) {
    // 엘비스 연산자를 활용해 널 값 다루기 - 주소가 없으면 예외를 발생시킨다.
    val address = person.company?.address
        ?: throw IllegalArgumentException("No address")

    // 첫 번째 인자로 받는 객체를 사용하며, 람다의 마지막 표현식을 결과로 반환
    with(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)
// Elsestr. 47
// 80687 Munich, Germany

printShippingLabel(Person("Alexey", null))
// java.lang.IllegalArgumentException: No address

4. 안전한 캐스트(as?)

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

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

val p1 = Person("Dmitry", "Jemerov")
val p2 = Person("Dmitry", "Jemerov")
println(p1 == p2) // true
println(p1.equals(42)) // false

5. 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) } // 아무 일도 일어나지 않는다.

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

  • 코틀린은 일반적으로 생성자에서 모든 프로퍼티를 초기화해야 한다.
// 클래스 안의 널이 될 수 없는 프로퍼티를 생성자에서 초기화하지 않은 경우
class MyClass(val nonNullableProperty: String) {
    // 메소드 안에서 초기화할 수 없음
    fun initializeProperty(value: String) {
        // 컴파일 오류: Val cannot be reassigned
        nonNullableProperty = value
    }
}

val instance = MyClass("initialValue")
instance.initializeProperty("newValue")
  • 초기화 값을 제공할 수 없다면 널이 될 수 있는 타입을 사용할 밖에 없다.
class MyService {
    fun performAction(): String = "foo"
}

class MyTest {
    // null로 초기화하기 위해 널이 될 수 있는 타입인 프로퍼티를 선언
    private var myService: MyService? = null
    
    @Before fun setUp() {
        myService = myService()
    }

    @Test fun testAction() {
        Assert.assertEquals("foo", myService!!.performAction())
    }
}
  • 추천하지 않는 코드 : 모든 프로퍼티 접근에 널 가능성을 신경 써야 한다.
  • HOW) 해당 프로퍼티를 나중에 초기화(late-initialized)한다.
class myService {
    fun performAction(): String = "foo"
}

class MyTest {
    private lateinit var myService: MyService

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

    // 널 검사를 수행하지 않고 프로퍼티를 사용한다.
    @Test fun testAction() {
        Assert.assertEquals("foo", myService.performAction())
    }
} 
  • 나중에 초기화하는 프로퍼티는 항상 var여야 한다.
  • WHY) val 프로퍼티는 final 필드로 컴파일되며, 생성자 안에서 반드시 초기화해야 한다. 따라서 생성자 밖에서 초기화해야 하는 나중에 초기화하는 프로퍼티는 항상 var여야 한다.
class Example {
    // 필드 선언시 초기값 설정 
    val name: String = "DefaultName"
}

// val 프로퍼티를 선언과 동시에 초기화하면 해당 초기화 구문이 생성자의 일부로 취급되어, 
// 컴파일러는 이를 내부적으로 생성된 생성자 코드로 변환
class Example {
    val name: String

    constructor() {
        this.name = "DefaultName"
    }
}

7. 널이 될 수 있는 타입 확장

널이 될 수 있는 타입에 대한 확장 함수와 스마트 캐스트를 사용하면, 해당 타입의 널 처리 로직을 추상화할 수 있고, 안전하게 타입 변환을 수행하여 코드를 간결하게 만들 수 있다.

WHY) 일반 멤버 호출은 객체 인스턴스를 통해 동적 디스패치되므로 해당 인스턴스가 널인지 여부를 검사하지 않는다.

fun Int?.safeSquare(): Int {
    return this?.let { it * it } ?: 0
}

fun String?.isNullOrBlank(): Boolean =
    // 두 번째 "this"에는 스마트 캐스트가 적용된다.
    this == null || this.isBlank()

fun main() {
    val nullableNumber: Int? = null
    val result = nullableNumber.safeSquare()
    println(result)  // 0
}

동적 디스패치 : 객체지향 언어에서 객체의 동적 타입에 따라 적절한 메소드를 호출해주는 방식

정적 디스패치 : 반대로 컴파일러가 컴파일 시점에 어떤 메소드가 호출될지 결정해서 코드를 생성하는 방식

타입 파라미터의 널 가능성

타입 파라미터는 기본적으로 널이 될 수 있는 타입이기 때문에 널이 아님을 확실히 하려면 널이 될 수 없는 타입 상한(upper bound)를 지정해야 한다.

fun <T> printHashCode(t: T) {
    // "t"가 null이 될 수 있으므로 안전한 호출을 써야만 한다.
    println(t?.hashCode())
}

printHashCode(null) // null
  • 타입 파라미터 T에 대해 추론한 타입은 널이 될 수 있는 Any? 타입이다.
fun <T: Any> printHashCode(t: T) {
    println(t.hashCode())
}

printHashCode(null)
// Error: Type parameter bound for `T` is not satisfied
profile
필요한 내용을 공부하고 저장합니다.

0개의 댓글