타입을 검사하고 나면 그 블록 안에서 컴파일러가 캐스팅을 대신 해 준다.
fun describe(x: Any): String {
if (x is String) return x.length.toString() // x 를 String 으로 쓴다 — 캐스팅 없이
if (x != null) { /* 여기서 x 는 non-null */ }
return "?"
}
자바라면 if (x instanceof String) { ((String) x).length(); } 처럼 방금 확인한 것을 다시 적어야 한다. 검사와 캐스팅이 따로 놀면 둘이 어긋날 여지가 생기는데, 스마트 캐스트는 그 여지를 없앤다.
널 검사도 같은 원리다. if (x != null) 이후로 x 는 non-null 타입으로 취급되어 ?. 없이 쓸 수 있다.
바뀔 수 있는 값은 안 해 준다
여기가 실제로 걸리는 지점이다.
class Foo { var name: String? = null }
fun f(foo: Foo) {
if (foo.name != null) {
println(foo.name.length) // 컴파일 에러
}
}
var 프로퍼티는 검사한 다음 줄에서 다른 스레드가 바꿀 수 있다. 검사 시점의 결과를 믿을 근거가 없으므로 컴파일러가 거부한다. 안전을 위한 거절이지 문법의 결함이 아니다.
같은 이유로 다음도 안 된다.
- 커스텀 게터가 있는 프로퍼티 — 부를 때마다 다른 값을 줄 수 있다
- 다른 모듈의
open프로퍼티 — 하위 클래스가 게터를 재정의했을 수 있다
지역 변수로 받으면 된다
val n = foo.name // 값을 붙잡는다
if (n != null) println(n.length) // val 이라 안 바뀐다 → 스마트 캐스트 성립
foo.name?.let { println(it.length) } // 관용적인 방법
val 지역 변수는 한 번 정해지면 바뀌지 않으므로 컴파일러가 안심하고 기억한다. ?.let 은 같은 일을 한 줄로 하는 방식이다 — 값이 있을 때만 블록이 돌고, 그 안에서 it 은 non-null 이다.
조건 안에서도 이어진다
if (x is String && x.length > 3) { ... } // && 오른쪽에서 이미 String
if (x !is String || x.length > 3) { ... } // || 오른쪽에서도 성립
&& 는 왼쪽이 참일 때만 오른쪽을 평가하므로, 오른쪽에서는 검사가 이미 통과한 상태다. 컴파일러가 그 흐름을 따라간다. || 는 왼쪽이 거짓일 때만 오른쪽으로 가므로 !is 의 부정, 즉 "String 이다" 가 성립한다.
이 흐름 추적을 라이브러리 함수까지 넓히는 장치가 계약(contract) 이다. isNullOrEmpty() 를 통과한 뒤 non-null 로 취급되는 것이 그 덕분이다 — 함수가 "false 를 반환했다면 인자는 null 이 아니다" 를 컴파일러에게 알려 준다.
when 과 함께 쓸 때가 진가다
sealed interface Result
data class Ok(val value: String) : Result
data class Err(val code: Int) : Result
fun handle(r: Result) = when (r) {
is Ok -> r.value // Ok 로 스마트 캐스트
is Err -> "E${r.code}" // Err 로 스마트 캐스트
}
sealed 로 하위 타입이 고정되어 있으면 컴파일러가 가짓수를 다 다뤘는지까지 검사한다. 새 하위 타입을 추가했을 때 처리를 빠뜨린 when 이 컴파일 시점에 드러난다.
면접 함정
- ❌ "스마트 캐스트가 안 되는 건 코틀린의 한계" →
var는 검사 후 바뀔 수 있으므로 거부가 맞다. - ❌ "
!!로 넘기면 된다" → 검사를 했는데!!를 쓴다는 것은 컴파일러의 경고를 무시하는 것이다. 지역val이나?.let으로 푼다. - ❌ "as 캐스팅과 같다" →
as는 실패하면 예외다. 스마트 캐스트는 검사가 이미 끝난 뒤라 실패할 수 없다.