컴파일 시점에 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
사전적 정의 : 어떤 부류의 형식이나 형태
소프트웨어 관점 : 여러 종류의 데이터를 식별하는 분류로, 어떤 값이 가능한지와 그 타입에 대해 수행할 수 있는 연산의 종류를 결정한다.
널이 될 수 있는 타입의 역할 : 널이 될 수 있는 타입과 널이 될 수 없는 타입을 명확히 구분하면 각 타입의 값에 대해 가능한 연산을 명확하게 이해할 수 있으며, 실행 시점에 예외를 발생시킬 수 있는 연산을 사전에 판단하여 이를 금지시킬 수 있다.
널 타입 : 타입 뒤에 물음표(?)를 붙여서 널 참조 허용을 표시한다.
널 아님 단언 연산자(!!) : 널이 될 수 있는 타입을 널이 될 수 없는 타입으로 바꿀 수 있다.
fun ignoreNulls(s: String?) {
// 예외는 null 값을 사용하는 코드가 아닌 단언문이 위치한 곳을 가리킨다.
val sNotNull: String = s!!
println(sNotNull.length)
}
ignoreNulls(null)
// Expceion in thread "main" kotlin.KotlinNullPointerException ...
널이 될 수 있는 객체에 안전하게 접근할 수 있다. 객체가 널이 아니면 해당 객체의 멤버에 접근하고, 객체가 널이면 널을 반환한다.
// 객체 그래프에서 널이 될 수 있는 중간 객체가 여럿 있는 경우
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
왼쪽 식이 널이 아니면 해당 값을 반환하고, 널이면 오른쪽 식의 값(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
어떤 값을 지정한 타입으로 캐스트한다. 값을 대상 타입으로 변환할 수 없으면 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
자신의 수신 객체를 인자로 전달받은 람다에게 넘긴다. 안전한 호출 연산자(?.)와 함께 사용하면 널이 될 수 없는 타입을 인자로 받는 람다를 실행할 수 있다.
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) } // 아무 일도 일어나지 않는다.
// 클래스 안의 널이 될 수 없는 프로퍼티를 생성자에서 초기화하지 않은 경우
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())
}
}
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())
}
}
class Example {
// 필드 선언시 초기값 설정
val name: String = "DefaultName"
}
// val 프로퍼티를 선언과 동시에 초기화하면 해당 초기화 구문이 생성자의 일부로 취급되어,
// 컴파일러는 이를 내부적으로 생성된 생성자 코드로 변환
class Example {
val name: String
constructor() {
this.name = "DefaultName"
}
}
널이 될 수 있는 타입에 대한 확장 함수와 스마트 캐스트를 사용하면, 해당 타입의 널 처리 로직을 추상화할 수 있고, 안전하게 타입 변환을 수행하여 코드를 간결하게 만들 수 있다.
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
fun <T: Any> printHashCode(t: T) {
println(t.hashCode())
}
printHashCode(null)
// Error: Type parameter bound for `T` is not satisfied