[Kotlin In Action] 6. 코틀린 타입 시스템
#1. null 가능성 (nullability)
NPE를 피할 수 있게 돕기 위한 코틀린 타입 시스템의 특성
Nullable 타입
코틀린 타입 시스템이 null이 될 수 있는 타입을 명시적으로 지원한다.
타입 이름 뒤에 ?
를 붙이면 그 타입의 변수나 프로퍼티에 null 참조를 저장할 수 있다.
?
가 없는 타입은 그 변수가 null 참조를 저장할 수 없다.
- Nullable 타입인 변수에 대해
변수.메서드()
처럼 메서드를 직접 호출할 수는 없다. - Nullable 값을 NonNull 타입의 변수에 대입할 수 없다.
val x: String? = null
val y: String = x //=> Error: Type mismatch: inferred type is String? but String was expected
- Nullable 타입의 값을 NonNull 타입의 파라미터를 받는 함수에 전달할 수 없다.
null이 아님이 확실한 영역에서는 해당 값을 NonNull 타입의 값처럼 사용할 수 있다.
fun strLenSafe(s: String?): Int =
if (s != null) s.length else 0
>>> val x: String? = null
>>> println(strLenSafe(x))
0
>>> println(strLenSafe("abc"))
3
자바의 Optional과 차이점
Optional은 어떤 값이 정의되거나 정의되지 않을 수 있음을 표현하는 래퍼 타입이다. 코드가 지저분해지고, 래퍼가 추가되어 실행 시점 성능에 영향을 준다.
코틀린의 Nullable 타입과 NonNull 타입은 실행 시점에 같은 타입이다. 모든 검사는 컴파일 타임에 수행되어, 별도의 실행 시점 부가 비용이 들지 않는다.
안전한 호출 연산자 .?
null 검사와 메서드 호출을 한 번의 연산으로 수행한다.
호출하려는 값이 null이 아니라면 .?
은 일반 메서드 호출처럼 동작한다.
null이면 이 호출은 무시되고 null이 반환된다.
.?
호출의 결과 타입은 Nullable이다.
엘비스 연산자 :?
null 대신 사용할 디폴트 값을 지정할 때 사용한다.
fun foo(s: String?) {
val t: String = s ?: "" // s가 null이면 ""이 결과
}
엘비스 연산자의 우항에 return
, throw
등의 연산을 넣을 수 있다.
(코틀린에서는 return
, throw
등의 연산도 식이다.)
=> 이 경우 좌항이 null이면 즉시 어떤 값을 반환하거나 예외를 던진다.
안전한 캐스트 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
}
override fun hashCode(): Int =
firstName.hashCode() * 37 + lastName.hashCode()
}
null 아님 단언 !!
NonNull 타입으로 (강제로) 바꿀 수 있다. null인 값에 사용하면 NPE가 발생한다.
[중요]
!!
를 null에 사용하여 발생한 예외 스택 트레이스에는 어떤 식에서 예외가 발생했는지 담겨 있지 않다. 어떤 값이 null이었는지 확실히 하기 위해서는 !!
를 한 줄에 여러 개 쓰는 일은 피하는 것이 좋다.
컴파일러가 검증할 수 없는 단언을 사용하기 보다는 더 나은 방법을 찾아보라는 설계의도가 담겨있다.
let
함수
let
함수는 자신의 수신 객체를 인자로 전달받은 람다에 넘긴다.
let
을 사용하는 가장 흔한 예는 Nullable 값을 NonNull만 받는 함수에 파라미터로 넘기는 경우이다.
foo?.let { … }
과 같이 안전한 호출 구문을 사용하여 호출하면, NonNull 타입을 인자로 받는 람다를 let
에 전달한다.
여러 값이 null인지 검사해야 한다면, let
호출을 중첩시켜서 처리할 수도 있지만 중첩시키면 코드가 복잡해져서 알아보기 어려워진다. 이 경우에는 일반적인 if 문을 사용해 모든 값을 한 번에 검사하는 편이 낫다.
나중에 초기화할 프로퍼티
lateinit
변경자를 붙이면 프로퍼티를 나중에 초기화할 수 있다.
이 프로퍼티는 항상 var
이어야 한다.
(val
프로퍼티는 final 필드로 컴파일되며, 생성자 안에서 반드시 초기화해야 한다.)
class MyService {
fun performAction(): String = "foo"
}
class MyTest {
private lateinit var myService: MyService // 나중에 초기화할 NonNull 프로퍼티 선언
@Before fun setUp() {
myService = MyService()
}
@Test fun testAction() {
Assert.assertEquals("foo", myService.performAction())
}
}
Nullable 타입의 확장 함수
fun String?.isNullOrBlank(): Boolean =
this == null || this.isBlank()
Nullable 타입에 대한 확장 함수를 정의하면, Nullable 값에 대해 그 확장 함수를 호출할 수 있다. 해당 함수 내에서 this
는 null이 될 수 있다.
타입 파라미터의 null 가능성
타입 파라미터 T
를 클래스나 함수 안에서 타입 이름으로 사용하면 이름 끝에 ?
가 없더라도 T
는 null이 될 수 있는 타입이다.
fun <T> printHashCode(t: T) { // T의 타입은 Any?로 추론된다.
println(t?.hashCode())
}
null이 아님을 명시하려면 null이 될 수 없는 upper bound를 지정해야 한다.
fun <T: Any> printHashCode(t: T) { // T는 null이 될 수 없는 타입이다.
println(t.hashCode())
}
타입 파라미터는 nul이 될 수 있는 타입을 표시하려면 반드시 물음표를 타입 이름 뒤에 붙여야 한다.
null 가능성과 자바
자바의 @Nullable String
은 코틀린에서 String?
과 같고, @NotNull String
은 String
과 같다.
플랫폼 타입
: 자바의 타입 중 코틀린이 null 관련 정보를 알 수 없는 타입
코틀린에서 플랫폼 타입을 선언할 수는 없다. 자바 코드에서 가져온 타입만 플랫폼 타입이 된다.
!
표기는 String!
타입의 null 가능성에 대해 아무 정보도 없다는 뜻이다.
자바에서 가져온 null 값을 코틀린의 NonNull 변수에 대입하면 실행 시점에 예외가 발생한다.
#2. 코틀린의 원시 타입 (Primitive type)
- 자바는 원시 타입과 참조 타입을 구분한다.
- 원시 타입 변수에는 그 값이 직접 들어가지만, 참조 타입 변수에는 메모리상의 객체 위치가 저장된다.
- 코틀린은 원시 타입과 래퍼 타입을 구분하지 않는다.
- 항상 같은 타입을 사용하며, 원시 타입의 값에 대해 메서드를 호출할 수 있다.
원시 타입: Int, Boolean 등
실행 시점에 숫자 타입은 가장 효율적인 방식으로 표현된다.
대부분의 경우, 코틀린 Int
타입은 자바의 int
로 컴파일된다.
Int
와 같이 null이 될 수 없는 타입은 그에 상응하는 자바 원시 타입으로 컴파일할 수 있다.
반대로 자바 원시 타입은 null이 될 수 없으므로, 코틀린에서 사용할 때 null이 될 수 없는 타입으로 취급할 수 있다.
null이 될 수 있는 원시 타입: Int?, Boolean? 등
null이 될 수 있는 원시 타입은 자바 원시 타입으로 표현할 수 없다. Nullable 원시 타입은 자바의 래퍼 타입으로 컴파일된다.
제네릭 클래스의 경우에도 래퍼 타입을 사용한다. JVM은 타입 인자로 원시 타입을 허용하지 않기 때문에, 자바와 코틀린 모두 제네릭 클래스는 항상 박스 타입을 사용해야 한다.
숫자 변환
코틀린은 한 타입의 숫자를 다른 타입의 숫자로 자동 변환하지 않는다.
val i = 1
val l: Long = i.toLong() // 변환 메서드를 직접 호출해줘야 한다.
코틀린은 Boolean을 제외한 모든 원시 타입에 대한 변환 함수를 제공한다.
(toInt()
, toByte()
, …)
코드에서 동시에 여러 숫자 타입을 사용하려면 예상치 못한 동작을 피하기 위해 각 변수를 명시적으로 변환해야 한다.
Any, Any?: 최상위 타입
Any 타입이 코틀린의 모든 null이 될 수 없는 타입의 조상 타입이며, Int 등의 원시 타입도 모두 포함된다. null을 포함하는 모든 값을 대입할 변수를 선언하려면 Any?를 사용한다.
내부적으로 Any 타입은 자바의 Object에 대응한다. 하지만 toString
, equals
, hashcode
를 제외한 Object의 wait
, notify
등의 메서드는 Any에서 사용할 수 없다. 사용하려면 java.lang.Object로 캐스팅해야 한다.
Unit: 코틀린의 void
Unit 타입에 속한 값은 단 하나뿐이며, 그 이름도 Unit이다. Unit 타입의 함수는 묵시적으로 Unit 값을 반환한다.
void와 달리 Unit을 타입 인자로 쓸 수 있다.
interface Processor<T> {
fun process(): T
}
class NoResultProcessor : Processor<Unit> {
override fun process() {
// do stuff
}
}
Nothing 타입: 이 함수는 절대 정상적으로 끝나지 않는다.
Nothing은 함수의 반환 타입이나 반환 타입으로 쓰일 타입 파라미터에만 쓸 수 있다. 함수가 결코 정상 종료되지 않음(예외 발생, 테스트 실패 등)을 표현할 때 사용한다.
#3. 컬렉션과 배열
null 가능성과 컬렉션
List<Int?>
- 리스트 자체는 항상 null이 아니다.
- 리스트 안의 각 값이 null이 될 수 있다.
List<Int>?
- 전체 리스트가 null이 될 수 있다.
- 리스트 안에는 null이 들어갈 수 없다.
-> null이 될 수 있는 게 컬렉션의 원소인지 컬렉션 자체인지 주의해야 한다.
null이 될 수 있는 값을 갖는 컬렉션에서 null 값을 걸러내는 경우,
filterNotNull
이라는 함수를 이용하면 편리하다.
읽기 전용과 변경 가능한 컬렉션
Collection
: 원소를 추가하거나 제거하는 메서드가 없다.MutableCollection
: 원소 추가, 삭제 등 메서드를 제공한다.
Comments