KB 16 널 안전성 — String 과 String? 은 다른 타입이다
고친 사람 github-actions[bot]
0. 널을 넣으려는 순간 컴파일이 멈춘다
자바 개발자가 가장 자주 보는 예외를 하나 꼽으라면 NullPointerException(줄여서 NPE)일 겁니다. null 이 든 변수로 메서드를 부르면 나는 예외입니다. 무서운 건 이 예외가 실행해 봐야 보인다는 점입니다.
문자열 길이를 돌려주는 함수에 null 을 넘겨 봤습니다. 먼저 자바입니다.
public class First {
static int len(String s) {
return s.length();
}
public static void main(String[] args) {
System.out.println(len("java"));
System.out.println(len(null));
}
}
$ javac -d jout First.java
$ java -cp jout First
4
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "<parameter1>" is null
at First.len(First.java:3)
at First.main(First.java:8)
javac 는 아무 말 없이 컴파일했습니다. 실행하자 첫 줄은 4 를 찍고, 둘째 호출에서 NPE 로 멈췄습니다. null 을 넘긴 곳은 8번째 줄인데, 터진 곳은 3번째 줄 함수 안입니다.
같은 함수를 코틀린으로 옮겼습니다.
fun len(s: String): Int = s.length
fun main() {
println(len("kotlin"))
println(len(null)) // 컴파일 오류
}
fun len(s: String): Int— 함수 선언입니다. 코틀린은 이름 뒤에 콜론, 그 뒤에 타입을 씁니다. 괄호 뒤의: Int가 반환 타입입니다= s.length— 본문이 식 하나면 중괄호와return대신 등호 뒤에 적습니다.s.length는 자바의s.length()입니다fun main()— 프로그램 시작 함수입니다. 클래스 없이 파일 바로 아래에 둡니다
코틀린 컴파일러 kotlinc 에 넣었습니다. -d out 은 결과물을 out 폴더에 쓰라는 옵션입니다.
$ kotlinc First.kt -d out
First.kt:5:17: error: null cannot be a value of a non-null type 'String'.
println(len(null)) // 컴파일 오류
^^^^
실행까지 가지도 못했습니다. 에러는 null 이 널 불가(non-null) 타입 String 의 값이 될 수 없다는 뜻입니다. 그리고 에러가 가리키는 곳은 함수 안이 아니라 null 을 넘긴 호출부입니다.
자바에서 String 은 「문자열이거나 null」입니다. 코틀린에서 String 은 「문자열」뿐입니다. 그러면 null 이 필요할 때는 어떻게 쓸까요. 이 편은 그 답인 String? 에서 시작합니다.
1절이 String 과 String? 의 관계, 2절과 3절이 String? 을 다루는 연산자 셋, 4절이 null 검사 뒤에 컴파일러가 해 주는 일입니다. 5절에서 이 모든 것이 숫자 타입에 붙을 때의 비용을 짚습니다. 출력은 전부 Kotlin 2.4.10, JDK 21 에서 직접 돌린 결과입니다.
1. String 과 String? — 대입이 한 방향만 된다
타입 뒤에 ? 를 붙이면 null 도 담는 타입이 됩니다. 이런 타입을 널 허용 타입(nullable type)이라고 부릅니다. 이 절에서는 String 과 String? 변수를 서로 대입해 보고, String? 으로 메서드를 불러 봅니다.
fun main() {
val a: String = "kotlin"
val b: String? = a
val c: String? = null
val d: String = c // 컴파일 오류
println(c.length) // 컴파일 오류
println(b.length) // 컴파일 오류
}
val— 다시 대입할 수 없는 변수입니다. 자바의final지역 변수에 해당합니다String?—String값과null을 둘 다 담는 타입입니다
$ kotlinc Nullable.kt -d out
Nullable.kt:5:19: error: initializer type mismatch: expected 'String', actual 'String?'.
val d: String = c // 컴파일 오류
^
Nullable.kt:6:14: error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type 'String?'.
println(c.length) // 컴파일 오류
^
Nullable.kt:7:14: error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type 'String?'.
println(b.length) // 컴파일 오류
^
3번째 줄 val b: String? = a 는 통과했습니다. String 값은 String? 변수에 들어갑니다. 반대로 5번째 줄 String? 값을 String 변수에 넣는 쪽은 타입이 안 맞는다며 막혔습니다.
그림으로 그리면 이렇습니다.
flowchart TD
A["String 값 · 문자열뿐"] -->|"대입 된다"| B["String? 변수 · 문자열 또는 null"]
C["String? 값 · null 일 수도 있다"] -.->|"컴파일 오류"| D["String 변수 · null 을 못 담는다"]
String 에 든 값은 늘 String? 의 값이기도 하니 넣어도 됩니다. String? 값은 null 일 수도 있으니 String 에 넣으면 약속이 깨집니다. 넓은 쪽으로는 가고 좁은 쪽으로는 못 갑니다.
값이 들어 있어도 타입이 막는다
6·7번째 줄의 에러는 받는 쪽(receiver), 곧 점 앞의 값이 널 허용 타입이면 . 으로 부를 수 없다는 뜻입니다. 대신 쓸 수 있는 것으로 ?. 와 !!. 를 알려 줍니다. 둘은 2절과 3절에서 봅니다.
눈여겨볼 것은 7번째 줄입니다. b 에는 "kotlin" 이 들어 있습니다. 그래도 막혔습니다. 컴파일러는 값이 아니라 선언한 타입을 봅니다. String? 이라고 적은 이상, 거기서 메서드를 부르려면 null 일 때 어떻게 할지를 코드에 적어야 합니다.
에러 난 세 줄을 지우고 println(b) 와 println(c) 를 넣으면 kotlin 과 null 이 찍힙니다. String? 변수를 그냥 출력하는 것은 문제가 없습니다. 막히는 것은 null 일 때 할 일이 정해지지 않은 호출뿐입니다.
정리하면, 코틀린에서 null 은 타입에 적힌 것입니다. String 에는 못 넣고 String? 에만 넣습니다. 그리고 String? 으로 무엇을 하려면 null 인 경우를 코드로 가려야 합니다. 자바에서 머릿속이나 주석에 적던 「이건 null 일 수 있음」이 타입으로 올라온 것입니다.
2. ?. 과 ?: — 널 검사 몇 줄이 한 줄이 된다
null 인 경우를 가리는 가장 흔한 두 연산자를 봅니다. 사용자의 주소의 도시 이름을 꺼내되, 중간에 하나라도 없으면 "unknown" 을 돌려주는 함수를 두 언어로 썼습니다.
자바부터 봅니다. Safe 클래스 안의 선언만 옮겼습니다. record 는 필드와 읽기 메서드를 한 줄로 만드는 자바 16 의 문법입니다.
record Address(String city) {}
record User(Address address) {}
static String cityOf(User u) {
if (u != null) {
Address a = u.address();
if (a != null) {
String c = a.city();
if (c != null) {
return c;
}
}
}
return "unknown";
}
main 에서 주소가 있는 사용자와 없는 사용자를 넣어 돌리면 Seoul 과 unknown 을 찍습니다. null 일 수 있는 것이 셋이라 if 도 셋입니다. 같은 일을 코틀린으로 쓰면 함수 본문이 한 줄입니다.
class Address(val city: String?)
class User(val address: Address?)
fun cityOf(u: User?): String =
u?.address?.city ?: "unknown"
fun main() {
val full = User(Address("Seoul"))
val noCity = User(Address(null))
val noAddr = User(null)
println(cityOf(full)) // Seoul
println(cityOf(noCity)) // unknown
println(cityOf(noAddr)) // unknown
println(cityOf(null)) // unknown
}
class Address(val city: String?)— 괄호 안의val city가 필드와 게터를 함께 만듭니다. 읽을 때는a.city처럼 괄호 없이 씁니다User(...)— 생성자 호출입니다. 코틀린에는new가 없습니다?.— 안전 호출(safe call)입니다. 점 앞이null이면 뒤를 부르지 않고 식 전체가null이 됩니다?:— 엘비스 연산자(Elvis operator)입니다. 왼쪽이null이 아니면 왼쪽 값을,null이면 오른쪽 값을 씁니다
$ kotlinc Safe.kt -d out
$ kotlin -cp out SafeKt
kotlin 은 java 와 같은 일을 하되 코틀린 표준 라이브러리를 classpath 에 스스로 넣는 명령입니다. SafeKt 는 파일 바로 아래에 쓴 함수를 담으려고 컴파일러가 파일 이름 + Kt 로 지은 클래스입니다.
네 경우가 모두 기대대로 나왔습니다. 도시가 없든, 주소가 없든, 사용자가 없든 ?. 사슬이 그 지점에서 null 이 되고, 끝의 ?: 가 그것을 "unknown" 으로 바꿨습니다.
자바에서 if 를 안 쓰고 줄이려면 Optional 을 씁니다. 같은 Safe.java 에 Optional.ofNullable(u).map(User::address).map(Address::city).orElse("unknown") 을 담은 메서드를 더했더니, 같은 두 입력에 역시 Seoul 과 unknown 을 찍었습니다. 코틀린은 같은 일을 감싸는 객체 없이 연산자 둘로 합니다.
?. 뒤는 아예 계산하지 않는다
?. 의 결과가 무슨 타입인지, 그리고 null 일 때 뒤쪽 인자가 계산되는지를 봤습니다. arg() 는 불릴 때 한 줄을 찍는 함수입니다. 무엇이 어떤 순서로 찍힐까요.
fun arg(): Int {
println("arg 계산")
return 1
}
fun main() {
val s: String? = null
val len = s?.length
println(len)
println(s?.substring(arg()))
val t: String? = "kotlin"
println(t?.substring(arg()))
}
$ kotlinc Skip.kt -d out
$ kotlin -cp out SkipKt
null
null
arg 계산
otlin
s?.length 는 에러 없이 null 을 찍었습니다. length 는 Int 인데, ?. 을 거치면 null 이 될 수 있으니 len 의 타입은 Int? 입니다. ?. 을 한 번 쓰면 결과도 널 허용 타입이 되고, 그래서 대개 끝에 ?: 를 붙여 기본값을 줍니다.
둘째 null 앞에 arg 계산 이 없습니다. s 가 null 이라 substring 을 부르지 않았고, 인자 arg() 도 계산하지 않았습니다. t 쪽에서만 arg 계산 이 찍히고 substring(1) 의 결과 otlin 이 나왔습니다. 인자 식에 부수 효과(값을 돌려주는 것 말고 바깥에 남기는 일, 여기서는 출력)가 있으면 이 차이를 알고 써야 합니다.
조심할 점이 하나 더 있습니다. u?.address?.city ?: "unknown" 은 셋 중 어느 것이 null 이었는지 알려 주지 않습니다. 주소가 없는 것과 도시가 없는 것을 다르게 처리해야 한다면 한 줄로 합치지 말고 나눠 써야 합니다.
정리하면, ?. 는 「null 이면 여기서 멈추고 null」, ?: 는 「null 이면 이것」입니다. 둘을 이어 쓰면 자바의 중첩 if 가 한 줄이 됩니다.
3. !! — 널이 아니라고 못박는다
?. 가 「null 이면 넘어간다」라면 !! 는 반대로 「null 일 리 없다」고 못박는 연산자입니다. 이름은 널 아님 단언(not-null assertion)입니다. 이 절에서는 !! 가 틀렸을 때 무엇이 찍히는지를 자바 NPE 와 나란히 보고, 그 대신 쓸 수 있는 것을 봅니다.
System.getenv 는 환경 변수를 읽고, 없으면 null 을 돌려주는 자바 메서드입니다. 그래서 결과를 String? 으로 받았습니다. 자바에서 온 값을 코틀린이 어떤 타입으로 보는지는 뒤에 나올 편에서 다룹니다. 있는 변수 HOME 과 없는 변수 NO_SUCH_VAR 를 !! 로 읽었습니다.
fun main() {
val home: String? = System.getenv("HOME")
println(home!!.isNotEmpty())
val missing: String? = System.getenv("NO_SUCH_VAR")
println(missing!!.length)
}
home!!—home이null이 아니면String으로 쓰고,null이면 그 줄에서 예외를 던집니다isNotEmpty()— 빈 문자열이 아니면true를 돌려주는 코틀린 표준 라이브러리 함수입니다
$ kotlinc Bang.kt -d out
$ kotlin -cp out BangKt
true
Exception in thread "main" java.lang.NullPointerException
at BangKt.main(Bang.kt:5)
at BangKt.main(Bang.kt)
HOME 은 있어서 true 가 찍혔습니다. NO_SUCH_VAR 는 없어서 5번째 줄에서 NullPointerException 이 났습니다. !! 는 NPE 를 없애는 것이 아니라, 터질 곳을 그 줄로 당겨 오는 것입니다.
메시지를 보면 java.lang.NullPointerException 뒤에 아무것도 없습니다. 무엇이 null 이었는지 알려 주는 것은 줄 번호 Bang.kt:5 뿐입니다. 아래쪽 BangKt.main(Bang.kt) 는 인자 없는 main 을 JVM 이 찾는 모양으로 부르려고 컴파일러가 덧붙인 메서드입니다.
같은 일을 자바로 하면 메시지가 더 자세합니다.
public class Bang {
public static void main(String[] args) {
String missing = System.getenv("NO_SUCH_VAR");
System.out.println(missing.length());
}
}
$ javac -d jout Bang.java
$ java -cp jout Bang
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "<local1>" is null
at Bang.main(Bang.java:4)
JDK 14 에 들어와 15 부터 기본으로 켜진 도움말 NPE 메시지(helpful NullPointerException, JEP 358)입니다. 무엇을 부르다 무엇이 null 이라 터졌는지를 적습니다. <local1> 은 변수 이름 대신 들어간 번호인데, javac 가 기본 설정에서 지역 변수 이름을 클래스 파일에 남기지 않아서입니다.
!! 가 던진 NPE 에는 이 설명이 없습니다. 그래서 한 줄에 a!!.b!! 처럼 둘을 쓰면 어느 쪽이 null 이었는지 줄 번호로는 못 가립니다.
언제 쓰고, 무엇으로 바꾸나
!! 는 「여기서 null 이면 프로그램이 틀린 것이고, 거기서 멈추는 게 맞다」고 확신할 때 씁니다. 테스트 코드나, 앞에서 이미 검사했는데 컴파일러가 그걸 모르는 경우(4절)가 그렇습니다. 코틀린 쪽 값이 이미 널 불가 타입이면 쓸 일이 없고, 써도 kotlinc 가 warning: unnecessary non-null assertion (!!) on a non-null receiver of type 'String'. 으로 알려 줍니다.
멈추는 게 맞더라도 메시지는 남기는 편이 낫습니다. ?: 의 오른쪽에는 값뿐 아니라 return 과 예외도 올 수 있습니다.
fun port(raw: String?): Int {
val text = raw ?: return 8080
return text.toInt()
}
fun main() {
println(port(null)) // 8080
println(port("9090")) // 9090
val key: String? = System.getenv("NO_SUCH_VAR")
val v = key ?: error("NO_SUCH_VAR 가 없다")
println(v.length)
}
raw ?: return 8080—raw가null이면 함수를 바로 끝내고8080을 돌려줍니다. 아니면text에raw가 들어갑니다text.toInt()— 문자열을 정수로 바꿉니다.text의 타입은?없는String이라.으로 부를 수 있습니다error("...")— 주어진 메시지로IllegalStateException을 던지는 표준 라이브러리 함수입니다
$ kotlinc Alt.kt -d out
$ kotlin -cp out AltKt
8080
9090
Exception in thread "main" java.lang.IllegalStateException: NO_SUCH_VAR 가 없다
at AltKt.main(Alt.kt:10)
at AltKt.main(Alt.kt)
앞의 두 줄은 port 의 두 경우입니다. 셋째 줄부터가 !! 대신 쓴 ?: error(...) 가 멈춘 모습입니다. 멈추는 줄은 !! 와 같지만, 무엇이 없어서 멈췄는지가 메시지에 남습니다. ?: return 은 자바에서 if (raw == null) return 8080; 로 쓰던 이른 반환을 대입 한 줄에 합친 것입니다(?: 는 KB 08 문과 식 — if 와 try 가 값을 돌려준다 에서도 다뤘습니다).
정리하면, !! 는 null 이면 설명 없는 NPE 를 던집니다. 멈춰야 할 곳이면 ?: error("...") 로 이유를 남기고, 멈출 필요가 없으면 ?: 로 기본값이나 return 을 줍니다.
4. 스마트 캐스트 — 검사한 뒤에는 검사가 사라진다
?. 와 ?: 만으로 모든 것을 쓰지는 않습니다. 자바처럼 if (s != null) 로 먼저 검사하는 코드도 흔합니다. 이 절에서는 그 검사 뒤에 컴파일러가 무엇을 해 주는지, 그리고 그것이 안 되는 경우를 봅니다.
fun lengthOf(s: String?): Int {
if (s != null) {
return s.length
}
return -1
}
fun early(s: String?): Int {
if (s == null) return -1
return s.length
}
fun longer(s: String?): Boolean =
s != null && s.length > 3
fun main() {
println(lengthOf("kotlin")) // 6
println(lengthOf(null)) // -1
println(early("abc")) // 3
println(longer("kotlin")) // true
println(longer(null)) // false
}
$ kotlinc Smart.kt -d out
$ kotlin -cp out SmartKt
세 함수 모두 s.length 를 ?. 없이 불렀는데 컴파일됐습니다. 1절에서는 같은 String? 이 . 으로 부르자마자 막혔습니다.
차이는 앞의 검사입니다. if (s != null) 블록 안에서 컴파일러는 s 를 String 으로 다룹니다. 이렇게 검사를 통과한 변수를 컴파일러가 좁은 타입으로 바꿔 주는 것을 스마트 캐스트(smart cast)라고 부릅니다. is String 같은 타입 검사 뒤에도 같은 일이 일어납니다(KB 09 when — break 없는 switch 3절).
early 처럼 null 이면 먼저 return 하는 모양에서도, longer 처럼 && 오른쪽에서도 됩니다. 앞의 조건이 참이어야 뒤로 온다는 것을 컴파일러가 따라가기 때문입니다. 자바에서 하던 검사 모양을 그대로 쓰면서, 검사한 뒤에는 캐스트나 두 번째 검사를 적지 않아도 됩니다.
var 프로퍼티에서는 안 된다
같은 검사를 클래스의 var 프로퍼티에 걸어 봤습니다. 프로퍼티(property)는 필드와 게터·세터를 한 이름으로 묶은 코틀린 선언이고, var 는 다시 대입할 수 있다는 뜻입니다.
class Box(var text: String?)
fun lengthOf(b: Box): Int {
if (b.text != null) {
return b.text.length // 컴파일 오류
}
return -1
}
$ kotlinc SmartVar.kt -d out
SmartVar.kt:5:16: error: smart cast to 'String' is impossible, because 'text' is a mutable property that could be mutated concurrently.
return b.text.length // 컴파일 오류
^^^^^^
검사는 했는데 스마트 캐스트가 불가능하다(impossible)고 합니다. 까닭도 적혀 있습니다. text 는 바뀔 수 있는(mutable) 프로퍼티라, 동시에(concurrently) 누가 바꿀 수 있다는 것입니다.
장면으로 그리면 이렇습니다. 4번째 줄에서 b.text 를 읽어 null 이 아님을 확인합니다. 그 사이 다른 스레드가 b.text = null 을 합니다. 5번째 줄에서 b.text 를 다시 읽으면 null 입니다. 검사한 값과 쓰는 값이 다른 읽기라서, 컴파일러는 검사를 믿지 않습니다.
고치는 방법은 셋입니다. 셋 다 컴파일되고 돌아갑니다.
class Box(var text: String?)
class Fixed(val text: String?)
fun viaLocal(box: Box): Int {
val t = box.text
if (t != null) {
return t.length
}
return -1
}
fun viaSafe(box: Box): Int =
box.text?.length ?: -1
fun viaVal(f: Fixed): Int {
if (f.text != null) {
return f.text.length
}
return -1
}
fun main() {
println(viaLocal(Box("kotlin"))) // 6
println(viaSafe(Box(null))) // -1
println(viaVal(Fixed("abc"))) // 3
}
$ kotlinc SmartFix.kt -d out
$ kotlin -cp out SmartFixKt
viaLocal— 프로퍼티를 지역val에 한 번 받아 두고 그것을 검사합니다. 지역val은 누구도 바꿀 수 없으니 스마트 캐스트가 됩니다. 가장 흔히 쓰는 모양입니다viaSafe— 한 번만 읽는?.로 씁니다. 읽기가 하나라 검사와 사용이 어긋날 틈이 없습니다viaVal— 프로퍼티를val로 선언하면 같은 코드가 컴파일됩니다. 다시 대입할 수 없으니 두 번 읽어도 같은 값입니다
자바에서도 필드를 검사한 뒤 쓸 때 지역 변수에 받아 두는 것이 안전한 습관이었습니다. 자바는 그 습관을 사람에게 맡기고, 코틀린은 안 지키면 컴파일을 멈춥니다.
정리하면, 지역 변수와 매개변수는 null 검사 뒤에 스마트 캐스트가 됩니다. 검사와 사용 사이에 값이 바뀔 수 있는 var 프로퍼티는 안 되고, 지역 val 에 받아 두거나 ?. 로 한 번에 읽습니다.
5. 성능 — Int? 는 박싱된다
지금까지 ? 는 String 에만 붙였습니다. String 은 원래 객체라서 ? 를 붙여도 JVM 쪽에서 달라지는 것이 없습니다. 그런데 Int 같은 숫자 타입에 ? 를 붙이면 이야기가 달라집니다.
코틀린의 Int 는 평소 자바의 원시 타입 int 로 컴파일됩니다. 원시 타입은 객체가 아니라 값 그 자체라 null 을 못 담습니다. 그래서 Int? 는 객체인 java.lang.Integer 가 됩니다(KB 06 숫자 타입 — Int 하나 뒤의 int 와 Integer 1절). int 값을 Integer 객체로 감싸는 일을 박싱(boxing)이라고 부릅니다.
? 하나로 값이 객체가 됩니다. 정말 객체가 새로 생기는지, 같은 Int 값을 Int? 변수 둘에 넣고 같은 객체인지 봤습니다. === 는 같은 객체인지 보는 참조 비교로, 자바의 == 에 해당합니다. 코틀린의 == 는 내용 비교입니다.
fun main() {
val n = 1000
val a: Int? = n
val b: Int? = n
println(a == b) // true
println(a === b) // false
val small = 100
val c: Int? = small
val d: Int? = small
println(c === d) // true
}
$ kotlinc Boxing.kt -d out
Boxing.kt:6:13: warning: identity equality for arguments of types 'Int?' and 'Int?' is prohibited.
println(a === b) // false
^^^^^^^
Boxing.kt:10:13: warning: identity equality for arguments of types 'Int?' and 'Int?' is prohibited.
println(c === d) // true
^^^^^^^
$ kotlin -cp out BoxingKt
경고는 Int? 끼리의 참조 비교를 막는다는 뜻입니다. 박싱된 숫자를 === 로 견주는 것은 대개 실수라서 컴파일러가 알려 줍니다. 여기서는 객체가 생기는지를 보려고 일부러 썼습니다.
a 와 b 는 같은 n 에서 왔는데 === 가 false 입니다. Int 를 Int? 에 넣을 때마다 Integer 객체가 새로 생긴 것입니다. 내용은 같으니 == 는 true 입니다.
100 은 true 입니다. 박싱에 쓰이는 Integer.valueOf 가 -128 부터 127 까지는 미리 만들어 둔 객체를 돌려주기 때문입니다. 이 캐시가 === 결과를 가르는 함정은 KB 11 동등성 비교 — == 와 === 가 자바와 뒤바뀌었다 3절에서 자세히 봤습니다. 캐시 범위를 벗어난 값은 박싱할 때마다 새 객체입니다.
언제 신경 쓰나
객체가 하나 생기는 비용은 작습니다. 변수 몇 개, 요청마다 한 번 도는 코드라면 Int? 를 써도 차이를 느낄 일이 없습니다. null 이 뜻을 가지면(「아직 값이 없음」처럼) Int? 가 맞는 타입입니다.
신경 쓸 곳은 같은 코드가 아주 많이 도는 곳입니다.
| 모양 | 무엇이 생기나 | 바꿀 것 |
|---|---|---|
반복문 안에서 갱신하는 var total: Int? |
127 을 넘는 값을 넣을 때마다 새 Integer |
null 이 필요 없으면 Int |
큰 Array<Int?> |
원소마다 Integer 객체 |
null 을 안 쓰면 IntArray |
반복문 안에서 Int? 매개변수에 Int 를 넘김 |
부를 때마다 인자를 박싱 | null 을 안 받으면 매개변수를 Int 로 |
IntArray 는 자바의 int[] 가 되는, 원소를 감싸지 않는 배열 타입입니다.
반대로, List<Int> 는 ? 가 없어도 원래 Integer 를 담습니다(제네릭 타입 인자라서, KB 06 1절). 그러니 리스트 원소를 Int? 로 바꾼다고 비용이 더 늘지는 않습니다.
정리하면, Int? 는 ? 하나로 int 가 Integer 객체가 되는 타입입니다. 평소에는 뜻에 맞게 고르면 되고, 수백만 번 도는 반복문이나 큰 배열에서만 Int 나 IntArray 로 바꿀 까닭이 생깁니다.
6. 한 장 요약
코틀린에서
null은 타입에 적힌다.String은null을 못 담고String?만 담는다.String은String?에 들어가지만 반대는 컴파일 오류다.String?으로 무엇을 하려면?.(null이면 멈춤),?:(null이면 이것),!!(null이면 NPE) 가운데 하나로null인 경우를 코드에 적거나,if (x != null)로 검사해 스마트 캐스트를 받는다. 숫자 타입에?를 붙이면 박싱된다.
| 하는 일 | 자바 | 코틀린 | 확인한 방법 |
|---|---|---|---|
널 불가 인자에 null 넘기기 |
컴파일 통과, 실행 중 NPE | null cannot be a value of a non-null type |
javac·kotlinc · 실행 |
String? → String 대입 |
해당 없음 | initializer type mismatch |
kotlinc 에러 |
String? 로 메서드 호출 |
그냥 부르고 NPE 를 각오 | only safe (?.) or non-null asserted (!!.) calls are allowed |
kotlinc 에러 |
중첩된 null 검사 |
if 셋 또는 Optional |
u?.address?.city ?: "unknown" |
양쪽 실행 |
null 이면 뒤를 건너뜀 |
if 로 감쌈 |
?. 뒤의 호출과 인자 계산을 안 한다 |
실행 출력 순서 |
null 이면 끝내기 |
if (x == null) return ...; |
x ?: return ... · x ?: error("...") |
실행 |
null 이 아니라고 못박기 |
해당 없음 | !! — 메시지 없는 NPE, 줄 번호만 |
양쪽 실행 |
| 검사 뒤 사용 | 검사 후 그대로 씀 | 스마트 캐스트. var 프로퍼티는 smart cast ... is impossible |
실행 · kotlinc 에러 |
Int? |
Integer |
Integer 로 박싱. 127 을 넘으면 넣을 때마다 새 객체 |
=== 실행 |
관련 항목
널 안전성을 이루는 타입과 연산자
널 안전성 · 널 허용 타입 · 널 불가 타입 · 안전 호출 연산자 · 엘비스 연산자 · 널 아님 단언 연산자 · 스마트 캐스트
널 안전성이 막으려는 예외와 그 메시지
NullPointerException · Helpful NullPointerExceptions · IllegalStateException · IllegalArgumentException · 스택 트레이스
자바에서 null 을 다루던 방법
Optional · 널 검사 · 이른 반환 · 방어적 프로그래밍 · record
스마트 캐스트가 되고 안 되는 조건을 가르는 것
val · var · 프로퍼티 · 지역 변수 · is 연산자 · 경쟁 상태 · 단락 평가
null 을 담으려고 숫자 타입이 치르는 비용
박싱 · 언박싱 · Integer 캐시 · Integer.valueOf · 원시 타입 · 래퍼 클래스 · IntArray · 참조 비교
null 인 경우를 한 줄로 처리하는 표준 라이브러리 함수
error · isNotEmpty · System.getenv · requireNotNull