KB 05 val 과 var — final 과 무엇이 같고 다른가
고친 사람 github-actions[bot]
0. val 로 잡은 리스트에 add 가 된다
코틀린을 처음 배우면 val 은 바뀌지 않는 값이라고 듣습니다. 그 말을 믿고 아래 네 줄을 봅니다.
fun main() {
val names = mutableListOf("kim")
names.add("lee")
println(names) // [kim, lee]
}
fun main()— 프로그램이 시작하는 함수입니다.fun은 함수를 선언하는 키워드입니다val names = ...— 변수names를 선언합니다. 타입은 적지 않았습니다. 컴파일러가 오른쪽 값에서 알아냅니다mutableListOf("kim")— 원소를 더하고 뺄 수 있는 리스트를 만드는 코틀린 표준 함수입니다. 앞에mutable이 없는listOf("kim")은 원소를 더하고 뺄 수 없습니다. 그 함수가 돌려주는 타입List에는add·remove같은 함수가 아예 없어서, 여기서listOf로 바꾸면 아래names.add("lee")줄이 컴파일 에러가 됩니다(6절에서 같은 에러를 직접 봅니다)println(names)— 한 줄 출력입니다. 앞에System.out.이 없고 줄 끝에 세미콜론도 없습니다
Names.kt 로 저장하고 코틀린 컴파일러 kotlinc 로 컴파일해 돌렸습니다.
$ kotlinc Names.kt -d out
$ kotlin -cp out NamesKt
-d out— 결과물을out폴더에 쓰라는 옵션입니다kotlin— 코틀린 설치에 딸린 실행 명령입니다.java와 같은 일을 하되 코틀린 표준 라이브러리 jar 를 classpath 에 스스로 넣습니다NamesKt— 컴파일러가Names.kt에서 만든 클래스 이름입니다. 파일 이름 뒤에Kt가 붙습니다
val 인데 원소가 둘로 늘었습니다. 에러도 경고도 없습니다. 자바 개발자에게는 낯익은 장면일 겁니다. final List<String> names 에 add 를 두 번 부르는 Names.java 도 똑같이 돕니다.
$ javac -d jout Names.java
$ java -cp jout Names
자바 쪽도 같은 [kim, lee] 를 찍었습니다.
final 이 막는 것은 변수에 다른 객체를 다시 대입하는 일입니다. 그 객체 안을 바꾸는 일은 막지 않습니다. val 도 같습니다.
그러면 val 은 자바의 final 과 완전히 같을까요. 대입 규칙은 같습니다. 그런데 클래스 안에 두면 final 에 없던 게터가 생깁니다. 그 게터 몸통을 직접 정의하면 val 인데 읽을 때마다 값이 달라지기도 합니다.
이 편은 그 같은 점과 다른 점을 자바 코드와 나란히 놓고 확인합니다. 출력은 전부 Kotlin 2.4.10, JDK 21 에서 직접 돌린 결과입니다.
1. val 과 var — 자바의 final 과 같은 대입 규칙
val 이 무엇을 막는지부터 봅니다. 재료는 변수에 다시 대입하는 한 줄입니다. 코틀린과 자바의 컴파일 에러를 맞댑니다.
두 키워드와 선언 모양
코틀린의 변수 선언은 val 이나 var 로 시작합니다. val 은 다시 대입할 수 없는 변수입니다. var 는 다시 대입할 수 있는 변수입니다.
타입을 적을 때는 이름 뒤에 콜론을 찍고 그 뒤에 씁니다. 자바와 순서가 반대입니다. 아래 표의 Int 는 자바의 int 에 대응하는 코틀린 타입입니다.
| 코틀린 | 같은 뜻의 자바 |
|---|---|
val a: Int = 1 |
final int a = 1; |
var b: Int = 1 |
int b = 1; |
val c = 1 |
final var c = 1; |
var d = 1 |
var d = 1; |
표의 마지막 두 줄은 타입을 생략한 모양입니다. 자바에도 타입을 생략하는 var 가 있습니다. JDK 10 에 들어온 문법으로, 초기값을 보고 지역 변수의 타입을 정합니다(OpenJDK 문서).
표의 final var c 와 var d 를 담은 FinalVar.java 는 경고 없이 컴파일돼 3 을 찍었습니다.
정리하면 코틀린의 val 은 자바의 final 변수에 대응합니다. var 는 final 이 없는 변수입니다.
다시 대입하면
var count 와 val names 에 한 번씩 다시 대입하는 Reassign.kt 입니다.
fun main() {
var count = 0
count = 1
val names = mutableListOf("kim")
names = mutableListOf("lee")
println(count)
}
컴파일하면 5번째 줄에서 멈춥니다.
$ kotlinc Reassign.kt -d out
Reassign.kt:5:5: error: 'val' cannot be reassigned.
names = mutableListOf("lee")
^^^^^
에러는 5번째 줄 names = ... 에서만 났습니다. count = 1 은 통과했습니다. 'val' cannot be reassigned, 곧 val 에는 다시 대입할 수 없다는 뜻입니다. 같은 모양의 Reassign.java 는 int count 와 final List<String> names 로 옮겼습니다.
$ javac -d jout Reassign.java
Reassign.java:9: error: cannot assign a value to final variable names
names = new ArrayList<>();
^
1 error
에러 문구와 줄번호는 다릅니다. 막힌 문장은 같습니다(names 에 다시 대입하는 문장). 둘 다 컴파일러가 대입하는 줄을 보고 막습니다. 0절의 add 는 대입이 아니라 메서드 호출이라 어느 쪽 컴파일러도 막지 않았습니다.
나중에 한 번 대입
값을 나중에 한 번만 주는 것도 두 언어가 같습니다. val grade: String 으로 값 없이 선언하고 if·else 두 갈래에서 한 번씩 대입한 Later.kt 는 컴파일돼 A 를 찍었습니다. 같은 함수를 final String grade; 로 옮긴 Later.java 도 같은 A 를 찍었습니다.
두 언어가 막는 것은 두 번째 대입입니다. 선언과 첫 대입이 떨어져 있는 것은 막지 않습니다.
2. 함수 안의 val 과 클래스 안의 val 은 무엇이 다른가
대입 규칙이 같다면 컴파일 결과도 같을까요. 이 절은 함수 안의 val 과 클래스 안의 val 을 각각 컴파일해 자바의 final 과 맞댑니다. 클래스 안에 쓴 val 은 코틀린에서 프로퍼티(property)라고 부릅니다. 필드와 그 필드를 읽고 쓰는 게터·세터를 한 이름으로 묶은 선언입니다. 클래스 파일에서 어떤 모양이 되는지는 이 절 뒤쪽에서 봅니다.
함수 안의 val 과 var
val 하나와 var 하나를 쓰는 함수입니다.
fun total(price: Int, count: Int): Int {
val sum = price * count
var result = sum
result = result + 100
return result
}
price: Int— 인자도 이름 뒤에 콜론과 타입을 씁니다. 괄호 뒤의: Int는 반환 타입입니다
Price.kt 를 컴파일하고, 나온 클래스 파일(class file)을 디컴파일러 CFR 0.152 로 되돌렸습니다. 디컴파일 결과는 원래 소스를 되살린 것이 아니라 클래스 파일에 적힌 내용을 자바 문법으로 다시 적은 것입니다.
$ kotlinc Price.kt -d out
$ java -jar cfr.jar out/PriceKt.class
...
public final class PriceKt {
public static final int total(int price, int count) {
int sum;
int result = sum = price * count;
return result += 100;
}
}
...— 결과 맨 위의 머리 주석,import, 한 줄이 아주 긴@Metadata(...)(코틀린 컴파일러가 남기는 부가 정보)를 줄였습니다. 이 편의 CFR 출력은 모두 이렇게 줄여 싣습니다PriceKt의static메서드 — 클래스 밖, 파일 바로 아래에 쓴 선언을 톱레벨(top-level) 선언이라고 합니다. 톱레벨 함수는파일이름Kt클래스의static메서드가 됩니다
val sum 과 var result 가 둘 다 그냥 int 입니다. 어느 쪽이 val 이었는지 알 길이 없습니다. 소스와 모양이 다른 것은 CFR 이 대입 두 줄을 한 줄로 합치고 + 100 을 += 로 다시 적었기 때문입니다. 앞에서 말한 「되살린 게 아니라 다시 적은 것」의 흔적입니다.
지역 변수의 val·var·final 은 컴파일러만 보고 버리는 표시입니다. 검사는 컴파일할 때 끝납니다. 클래스 파일이 지역 변수에 대해 적는 정보는 이름과 타입, 쓰이는 범위뿐이고 final 을 적는 칸이 없습니다(자바 가상 머신 명세 4.7.13).
적을 칸이 없는 것은 언어를 가리지 않습니다. 같은 함수를 자바로 옮기면서 sum 에 final 을 붙여도 그 표시는 클래스 파일에 남지 않습니다.
클래스 안의 val
클래스 본문에 쓴 val 은 지역 변수가 아닙니다. 코틀린은 이것을 프로퍼티(property)라고 부릅니다. 프로퍼티는 보통 필드와 그 필드를 읽는 게터로 컴파일되는 선언입니다. 필드 없이 게터만 남는 경우는 6절에서 봅니다.
class Account {
val id = 7
}
class Account { ... }— 클래스 선언입니다. 수식어가 없으면public으로 봅니다val id = 7— 클래스 본문에 쓴 프로퍼티입니다
클래스 파일의 모양은 JDK 에 딸린 javap 로 봅니다. javap -p 는 클래스 이름, 필드와 메서드 선언을 자바 선언문처럼 보여 줍니다. -p 는 private 멤버까지 보여 달라는 옵션입니다.
같은 모양을 자바로 쓰면 private final int id = 7; 필드 하나와 그 값을 돌려주는 public int getId() 하나가 됩니다. 코틀린의 한 줄이 정말 그 둘이 되는지 클래스 파일을 열어 확인합니다.
$ kotlinc Account.kt -d out
$ javap -p out/Account.class
Compiled from "Account.kt"
public final class Account {
private final int id;
public Account();
public final int getId();
}
val id = 7 한 줄이 private final int id 필드와 getId() 게터가 됐습니다. 앞에 적은 자바 선언과 멤버가 하나하나 맞습니다. 다른 것은 final 둘뿐으로, 코틀린 쪽은 클래스와 게터에 final 이 붙었습니다.
이 final 은 「다시 대입 못 한다」는 뜻이 아니라 상속과 재정의를 막는다는 뜻입니다. 코틀린은 수식어가 없으면 그렇게 봅니다(공식 문서). 상속은 뒤에 나올 편에서 다룹니다.
게터가 늘 붙는 것은 아닙니다. private 프로퍼티를 하나 가진 Purse 입니다. 3절에서 다시 쓸 var balance 와 메서드 bump() 도 함께 넣었습니다.
class Purse {
private val secret = 1
var balance = 0
fun bump() {
balance = balance + secret
}
}
javap -p 로 열면 private final int secret 필드만 있고 게터가 없습니다. 기본 게터를 쓰는 private 프로퍼티에는 컴파일러가 게터를 만들지 않습니다.
3. 클래스 안에 쓴 val 과 var 는 무엇이 갈리는가
val 프로퍼티가 필드와 게터라면 var 프로퍼티는 무엇이 될까요. val 과 var 를 하나씩 둔 클래스를 만들어 자바 쪽에서 불러 봅니다.
class Wallet {
val id = 7
var balance = 0
}
fun main() {
val w = Wallet()
w.balance = 100
println(w.balance)
}
Wallet()— 생성자 호출입니다. 코틀린에는new가 없습니다w.balance = 100— 프로퍼티에 대입하는 모양입니다. 필드에 직접 쓰는 것처럼 보입니다
자바에서 부르면
코틀린 프로퍼티는 자바 쪽에서 평범한 메서드로 보입니다. UseWallet.java 에서 w.setBalance(100); 과 함께, 없어야 할 w.setId(8); 도 불렀습니다. -cp out 은 코틀린이 만든 Wallet.class 를 찾을 수 있게 out 폴더를 classpath 에 넣는 옵션입니다.
$ javac -cp out -d jout UseWallet.java
UseWallet.java:5: error: cannot find symbol
w.setId(8);
^
symbol: method setId(int)
location: variable w of type Wallet
1 error
에러는 setId(8) 에서만 났습니다. setBalance(100) 줄은 통과했습니다. var balance 에는 세터 setBalance(int) 가 생기고, val id 에는 생기지 않습니다. 메서드 이름은 자바빈즈 이름 규칙 그대로입니다.
자바에서 보면 val 은 세터가 없는 프로퍼티입니다. var 프로퍼티는 2절에서 본 필드와 게터에 세터를 더합니다.
w.balance = 100 은 무엇을 부르나
대입처럼 생긴 이 줄은 필드에 쓰는 걸까요. 프로퍼티를 받치는 필드는 2절에서 본 대로 private 입니다. main 이 든 WalletKt 를 CFR 로 되돌리면 이 줄은 w.setBalance(100); 입니다. 값을 읽는 줄은 int n = w.getBalance(); 입니다. 클래스 밖의 코틀린 코드도 게터와 세터를 거쳐 필드에 닿습니다.
클래스 안에서는 다릅니다. 2절에 실은 Purse 는 메서드 bump() 안에서 balance = balance + secret 을 썼습니다. 그 클래스를 CFR 로 되돌리면 이 줄이 this.balance += this.secret; 입니다.
클래스 안에서는 게터·세터를 거치지 않고 필드를 바로 읽고 씁니다.
4. 타입 추론 — 언제 생략하고 언제 적나
지금까지 val id = 7 처럼 타입을 거의 안 적었습니다. 컴파일러가 값에서 타입을 알아내는 일을 타입 추론이라고 합니다. 이 절은 컴파일러가 고른 타입을 에러로 읽어 내고, 추론이 안 되는 두 경우를 자바의 var 와 맞댑니다.
컴파일러가 고른 타입
톱레벨에 val 다섯 개를 둔 Infer.kt 입니다. 2절의 total 처럼 파일 바로 아래에 쓴 선언이 톱레벨 선언입니다.
val count = 10
val big = 3_000_000_000
val rate = 0.5
val name = "kim"
val limit: Long = 10
3_000_000_000— 숫자 사이의 밑줄은 자릿수를 읽기 쉽게 끊는 표시입니다. 값에는 영향이 없습니다val limit: Long = 10— 이것만 타입을 적었습니다
컴파일러가 무슨 타입을 골랐는지는 일부러 틀린 타입을 적어 물어볼 수 있습니다. 앞의 네 선언에 String 이나 Int 를 엉뚱하게 적은 InferBad.kt 를 컴파일했습니다.
$ kotlinc InferBad.kt -d bad
InferBad.kt:1:19: error: initializer type mismatch: expected 'String', actual 'Int'.
val count: String = 10
^
InferBad.kt:2:17: error: initializer type mismatch: expected 'String', actual 'Long'.
val big: String = 3_000_000_000
^
InferBad.kt:3:18: error: initializer type mismatch: expected 'String', actual 'Double'.
val rate: String = 0.5
^
InferBad.kt:4:15: error: initializer type mismatch: expected 'Int', actual 'String'.
val name: Int = "kim"
^
에러 문구의 actual 쪽이 컴파일러가 고른 타입입니다. 10 은 Int, 0.5 는 Double, "kim" 은 String 이고, 3_000_000_000 은 Int 범위를 넘어 Long 이 됐습니다. 정수는 Int 부터 보고 넘치면 Long 으로 정하는 규칙입니다(공식 문서).
클래스 파일에서는 같은 선언이 조금 다르게 보입니다. 톱레벨 val 은 파일이름Kt 클래스의 static 필드와 게터가 되는데, javap -p 출력에서 필드 다섯 줄만 싣고 게터 줄은 ... 로 줄였습니다.
$ kotlinc Infer.kt -d out
$ javap -p out/InferKt.class
Compiled from "Infer.kt"
public final class InferKt {
private static final int count;
private static final long big;
private static final double rate;
private static final java.lang.String name;
private static final long limit;
...
Int 자리에 int 가, Double 자리에 double 이 앉아 있습니다. javap 가 보여 주는 것은 JVM 타입이라 코틀린이 고른 타입과 한 겹 다릅니다. 고른 타입 자체를 묻는 자리에서는 앞의 에러 쪽이 곧바로 답합니다.
그래도 타입을 적은 선언과 안 적은 선언이 갈리는 것은 여기서도 보입니다. 같은 10 이라도 : Long 을 적은 limit 는 long 이고 count 는 int 입니다. 적으면 적은 타입이 이깁니다.
추론이 안 되는 두 경우
값을 안 준 val 하나와, 원소 없이 만든 리스트 하나입니다.
fun main() {
val later
val empty = mutableListOf()
}
두 줄 다 에러입니다.
$ kotlinc NoInfer.kt -d out
NoInfer.kt:2:9: error: this variable must either have an explicit type or be initialized.
val later
^^^^^
NoInfer.kt:3:17: error: cannot infer type for type parameter 'T'. Specify it explicitly.
val empty = mutableListOf()
^^^^^^^^^^^^^
첫 에러는 타입을 적든지 값을 주든지 하라는 뜻입니다. 추론할 값이 없으니 당연합니다.
두 번째 에러는 조금 설명이 필요합니다. mutableListOf 는 타입 매개변수 T 로 원소 타입을 받는 함수입니다. 타입 매개변수는 자바의 <T> 와 같은 것으로, 쓸 때 정하라고 비워 둔 이름입니다.
T 에 실제로 채워 넣는 타입은 타입 인자라고 부릅니다. 원소가 하나도 없으니 타입 인자를 정할 단서가 없어 컴파일러가 멈췄습니다.
자바의 var 로 같은 두 줄을 옮긴 NoInfer.java 는 var later; 에서 cannot infer type for local variable later 에러를 냈습니다. 값 없는 변수는 자바도 거절합니다. 그 줄만 뺀 NoInfer2.java 의 main 입니다.
var empty = new ArrayList<>();
empty.add(1);
empty.add("two");
System.out.println(empty); // [1, two]
$ javac -d jout NoInfer2.java
$ java -cp jout NoInfer2
빈 ArrayList<> 는 자바가 통과시켰습니다. 원소 타입을 정할 단서가 없으면 자바는 Object 로 정하고 넘어갑니다. 그래서 1 과 "two" 가 한 리스트에 들어갔습니다.
코틀린은 같은 두 줄을 에러로 막습니다. 고치는 법은 타입을 적는 것입니다. 변수는 val later: String 으로 선언하고, 리스트는 타입 인자를 직접 적은 mutableListOf<String>() 로 만든 Fixed.kt 입니다.
fun main() {
val later: String
later = "set"
val empty = mutableListOf<String>()
empty.add(later)
println(empty) // [set]
}
두 선언 다 통과했습니다.
정리하면, 값이 타입을 알려 주면 생략합니다. 값이 없을 때, 빈 리스트처럼 값이 단서를 안 줄 때, 값과 다른 타입으로 잡고 싶을 때는 적습니다.
5. const val — val 과 무엇이 다른가
val 앞에 const 를 붙일 수 있는 경우가 있습니다. const val 은 컴파일할 때 값이 정해지는 상수입니다. 이 절은 val 과 const val 이 클래스 파일에서 어떻게 갈리는지 보고, const 를 붙일 수 있는 조건을 에러로 확인합니다.
object 안의 val 과 const val
재료는 object 입니다. object Config { ... } 는 클래스 선언과 그 클래스의 유일한 인스턴스를 한 번에 만드는 코틀린 문법입니다. 자바의 싱글턴 클래스를 한 번에 쓰는 셈이라, 인스턴스를 따로 만들지 않고 Config.MAX 처럼 object 이름으로 멤버에 닿습니다.
object Config {
const val MAX = 3
val name = "shop"
}
fun main() {
println(Config.MAX)
println(Config.name)
}
$ kotlinc Config.kt -d out
$ javap -p out/Config.class
Compiled from "Config.kt"
public final class Config {
public static final Config INSTANCE;
public static final int MAX;
private static final java.lang.String name;
private Config();
public final java.lang.String getName();
static {};
}
INSTANCE 필드와 private 생성자가 object 가 싱글턴이라는 흔적입니다. 두 프로퍼티는 이렇게 갈렸습니다. 마지막 줄은 main 이 든 ConfigKt 를 CFR 로 되돌려 본 결과입니다.
const val MAX = 3 |
val name = "shop" |
|
|---|---|---|
| 필드 | public static final int MAX |
private static final String name |
| 게터 | 없음 | getName() (Config.INSTANCE 를 거쳐 부른다) |
main 에서 읽으면 |
값이 복사돼 int n = 3; |
Config.INSTANCE.getName() 호출 |
const val 을 읽는 코드는 필드를 읽지 않습니다. 코틀린 컴파일러가 상수 값을 쓰는 쪽에 복사합니다. 자바에서 const val 을 읽어도 호출부에 값이 복사됩니다. 그 이야기는 KB 02 자바에서 부르고 실행하기 — 파일 이름, const, kotlin-stdlib 에서 다뤘습니다.
const 를 붙일 수 있는 조건
조건을 어긴 선언 셋을 한 파일에 모았습니다. 클래스 안의 const val, 리스트 타입의 const val, 함수 호출 결과를 담은 const val 입니다. listOf("a") 는 리스트를 만드는 코틀린 표준 함수입니다.
class Shop {
const val LIMIT = 5
}
const val TAGS = listOf("a")
const val START = System.currentTimeMillis()
세 선언이 모두 거절됩니다.
$ kotlinc BadConst.kt -d bad
BadConst.kt:2:5: error: const 'val' is only allowed on top level, in named objects, in companion objects or companion blocks.
const val LIMIT = 5
^^^^^
BadConst.kt:5:1: error: const 'val' has type 'List<String>'. Only primitive types and 'String' are allowed.
const val TAGS = listOf("a")
^^^^^
BadConst.kt:6:19: error: const 'val' initializer must be a constant value.
const val START = System.currentTimeMillis()
^^^^^^^^^^^^^^^^^^^^^^^^^^
에러 셋이 조건 셋입니다. 둘째 조건의 원시 타입은 Int·Long·Double 처럼 자바의 int·long·double 이 되는 타입입니다. 첫 에러 문구에 나오는 companion 으로 시작하는 두 문법은 이 편에서 다루지 않습니다.
자바의 const 는 예약만 되어 있고 쓰이지 않는 키워드입니다(자바 언어 명세 3.9). 상수인지는 컴파일러가 초기값을 보고 가립니다. 이렇게 상수로 인정받는 final 변수를 자바 언어 명세는 상수 변수(constant variable)라고 부릅니다(자바 언어 명세 4.12.4).
| 조건 | 코틀린에서 어기면 | 자바의 상수 변수 |
|---|---|---|
톱레벨이나 object 안에 둔다 |
클래스 안의 LIMIT 이 거절됐다 |
위치 제한 없음 |
원시 타입이나 String 이다 |
List<String> 타입의 TAGS 가 거절됐다 |
같음 |
| 값이 컴파일할 때 정해진다 | 함수 호출 결과인 START 가 거절됐다 |
같음 |
위치 조건은 코틀린에만 있습니다. 보통 클래스 안에 static final int LIMIT = 5; 를 둔 Shop.java 는 javac 를 에러 없이 통과했습니다.
const 가 꼭 필요한 곳
값이 같다면 val 과 const val 이 무슨 차이일까요. 애노테이션 인자처럼 컴파일할 때 값이 있어야 하는 곳에서 갈립니다. 애노테이션은 선언에 붙이는 표시고, 괄호 안에 적는 값이 그 인자입니다. 아래 @Deprecated 는 앞으로 없앨 선언에 붙이는 애노테이션입니다.
상수 하나와 상수 아닌 것 하나를 인자로 넣은 코틀린 쪽 재료입니다.
const val OLD = "use newName"
val OLD2 = "use newName"
@Deprecated(OLD)
fun oldName() {}
@Deprecated(OLD2)
fun oldName2() {}
같은 모양을 자바로도 옮겨 두 언어에서 컴파일했습니다. 결과는 이렇습니다.
| 통과한 인자 | 거절된 인자 | 거절 문구 | |
|---|---|---|---|
코틀린 @Deprecated |
const val OLD |
val OLD2 |
only 'const val' can be used in constant expressions. |
자바 @SuppressWarnings |
OLD = "unused" |
OLD2 = String.valueOf("unused") |
element value must be a constant expression |
자바 쪽 두 필드는 둘 다 static final String 입니다. 함수 호출이 든 초기값은 final 이어도 상수가 아닙니다.
정리하면, 자바는 초기값을 보고 상수인지 가리고, 코틀린은 const 를 붙인 것만 상수로 봅니다. 상수가 되는 조건 가운데 타입과 초기값 둘은 두 언어가 같고, 톱레벨이나 object 안이라는 위치 조건은 코틀린에만 있습니다.
6. val 인데 값이 바뀌는 경우
0절에서 val 리스트에 원소를 더했습니다. 이 절은 val 인데 읽을 때마다 다른 값이 나오는 두 경우를 봅니다. 하나는 게터 몸통을 직접 정의한 프로퍼티, 하나는 같은 리스트를 가리키는 두 변수입니다.
게터 몸통을 직접 정의한 프로퍼티
2절에서 val 프로퍼티는 필드와 게터가 됐습니다. 코틀린은 그 게터의 몸통을 직접 정의하게 해 줍니다.
class Box {
var count = 0
val doubled: Int
get() = count * 2
}
fun main() {
val b = Box()
println(b.doubled) // 0
b.count = 5
println(b.doubled) // 10
}
val doubled: Int— 값을 주지 않고 타입만 적은 프로퍼티입니다get() = count * 2— 이 프로퍼티의 게터 몸통입니다. 읽을 때마다count * 2를 계산해 돌려줍니다
$ kotlinc Box.kt -d out
$ kotlin -cp out BoxKt
val doubled 가 0 에서 10 으로 바뀌었습니다.
같은 것을 자바로 쓴다면 count 필드와 그 게터·세터에 int getDoubled() { return count * 2; } 를 더한 클래스입니다. doubled 를 담는 필드는 없습니다. 코틀린 쪽 클래스 파일도 같은 모양인지 열어 확인합니다.
$ javap -p out/Box.class
Compiled from "Box.kt"
public final class Box {
private int count;
public Box();
public final int getCount();
public final void setCount(int);
public final int getDoubled();
}
목록에 doubled 필드가 없고 getDoubled() 메서드만 있습니다. 코틀린의 val doubled 는 자바로 치면 필드 없이 계산하는 게터 하나입니다.
val id = 7 처럼 프로퍼티 값을 담아 두는 필드를 코틀린 문서는 backing field 라고 부릅니다(공식 문서). 게터가 그 필드를 안 쓰면 컴파일러는 필드를 만들지 않습니다.
자바에서 「다시 대입 못 한다」는 뜻의 final 은 변수와 필드에 붙습니다. doubled 에는 필드가 없으니 그 final 을 붙일 곳이 없습니다. getDoubled() 앞에 찍힌 final 은 재정의를 막는다는 다른 뜻입니다(2절).
프로퍼티의 val 이 약속하는 것은 「세터가 없다」입니다. 「값이 고정이다」가 아닙니다.
같은 리스트를 가리키는 두 변수
두 번째 경우는 0절의 연장입니다. 원소를 바꿀 수 있는 리스트를 만들고, 그 리스트를 List<Int> 타입 변수에 한 번 더 담습니다.
코틀린의 컬렉션은 타입이 둘로 나뉩니다. MutableList 는 add 같은 변경 메서드가 있는 리스트입니다. List 는 읽기 메서드만 있는 읽기 전용 타입입니다. mutableListOf 가 돌려주는 것이 MutableList 입니다.
fun main() {
val source = mutableListOf(1, 2)
val view: List<Int> = source
source.add(3)
println(view) // [1, 2, 3]
}
ViewAdd.kt 는 넷째 줄을 view.add(3) 으로 바꾸고 출력 줄을 뺀 파일입니다.
$ kotlinc View.kt -d out
$ kotlin -cp out ViewKt
$ kotlinc ViewAdd.kt -d out
ViewAdd.kt:4:10: error: unresolved reference 'add'.
view.add(3)
^^^
view 로는 add 를 부를 수조차 없습니다. 그런데 source 로 더한 3 이 view 에 보입니다. view 가 val 이고 읽기 전용 타입인데도 내용이 바뀌었습니다. 두 변수가 같은 리스트 객체를 가리키기 때문입니다. 읽기 전용은 코틀린 컴파일러가 타입으로 거는 제한이지 객체의 성질이 아닙니다.
자바의 final List<Integer> view = source; 도 같은 객체를 가리킵니다. 다른 점은 자바의 List 에 읽기 전용 타입이 따로 없다는 것입니다. view.add(3) 을 부른 ViewJ.java 는 그대로 컴파일돼 돌았습니다.
$ javac -d jout ViewJ.java
$ java -cp jout ViewJ
view 로 3 을 더한 자바 쪽도 [1, 2, 3] 을 찍었습니다. 자바는 view 로도 add 가 되고, 코틀린은 컴파일 에러입니다.
val 은 변수가 가리키는 대상을 못 바꾸게 할 뿐입니다. 게터가 계산하는 값이나 다른 변수가 바꾸는 객체의 내용까지 막지는 않습니다.
7. 한 장 요약
val은 자바final변수와 같은 대입 규칙이다. 지역val은 클래스 파일에 흔적이 없다. 클래스 안의val은 보통private final필드와 게터가 된다.private이면 게터가 없고, 게터 몸통을 정의하면 필드가 없다.var는 여기에 세터를 더한다.val이 막는 것은 다시 대입하는 일뿐이다. 게터가 계산하는 값과 가변 객체의 내용은 바뀔 수 있다. 컴파일할 때 값이 필요하면const val을 쓴다.
| 하고 싶은 일 | 자바 | 코틀린 | 확인한 방법 |
|---|---|---|---|
| 다시 대입 못 하는 지역 변수 | final int a = 1; |
val a = 1 |
대입 줄의 컴파일 에러 |
| 나중에 한 번 대입 | final String g; |
val g: String |
둘 다 A 출력 |
지역 변수의 final 흔적 |
안 남음 | 안 남음 | CFR · JVMS 4.7.13 |
| 읽기만 되는 필드 | private final 필드 + 게터 |
val 프로퍼티 |
javap -p |
| 읽고 쓰는 필드 | private 필드 + 게터 + 세터 |
var 프로퍼티 |
자바의 setId 에러 |
| 컴파일러가 고른 타입 확인 | — | 틀린 타입을 적어 에러로 읽는다 | actual 'Int' · actual 'Long' |
| 빈 리스트 추론 | ArrayList<Object> 로 통과 |
T 추론 실패 에러 |
[1, two] · 컴파일 에러 |
| 컴파일 시점 상수 | final + 상수 초기값 (위치 무관) |
const val (톱레벨·object) |
javap -p · CFR 의 3 · Shop.java 통과 |
| 애노테이션 인자 | 함수 호출 초기값이면 에러 | const val 만 허용 |
컴파일 에러 |
| 읽을 때 계산하는 값 | 필드 없는 게터 | 게터 몸통을 정의한 val |
0 → 10 · javap -p 에 필드 없음 |
| 읽기 전용으로 넘기기 | 타입으로는 못 막음 | List 타입 변수 |
코틀린 add 에러 · 자바 [1, 2, 3] |
관련 항목
val 과 var 가 대응하는 자바 선언
final · 지역 변수 · 필드 · 지역 변수 타입 추론 · 자바빈즈
val 과 var 를 이루는 코틀린 문법 요소
val · var · 프로퍼티 · backing field · 톱레벨 프로퍼티 · object 선언
프로퍼티가 번역되는 클래스 파일 멤버
게터 · 세터 · 정적 필드 · 정적 초기화 블록 · 싱글턴
const val 을 가르는 조건과 쓰임
const val · 컴파일 타임 상수 · 원시 타입 · 애노테이션 · companion object
변수 타입을 정하는 규칙
타입 추론 · 타입 매개변수 · 타입 인자 · 제네릭 · 다이아몬드 연산자
val 이 막지 않는 변경
가변 객체 · 불변 객체 · 읽기 전용 컬렉션 · MutableList · 컬렉션