Kotlin の val は「再代入できない変数」、var は「再代入できる変数」です。 まず val で書き、値を入れ直す必要が出たときだけ var にする、という順番で考えると迷いません。さらに、コンパイル時に決まる定数には const val、あとから値を入れたいときは lateinit var、最初に使う瞬間まで計算を遅らせたいときは by lazy と、目的に合わせた書き方が用意されています。

この記事では、val / var / const val / lateinit var / by lazy の5つを「再代入・初期化の時点・使える型・使える場所・スレッド安全」で1枚の表に並べ、それぞれを動くコードと実際の出力で確かめます。コンパイルエラーの文面、例外のメッセージ、型推論の結果もすべて手元で出したものを載せています。

確認環境:Ubuntu 24.04 / Kotlin 2.4.20(kotlinc-jvm)/ OpenJDK 21.0.10。掲載したコードはすべてこの環境でコンパイル・実行し、出力をそのまま貼っています。

val・var・const val・lateinit・by lazy の早見表

書き方 再代入 初期化の時点 使える型 使える場所 スレッド安全
val x = 値 できない 宣言したとき(または1回だけの代入、クラスなら init まで) すべての型 ローカル・クラス・トップレベル 参照は変わらない。中身が変更できるオブジェクトなら別途配慮する
var x = 値 できる 宣言したとき すべての型 ローカル・クラス・トップレベル 複数スレッドから書くなら同期を自分で用意する
const val X = 値 できない コンパイル時(使う側に値が埋め込まれる) プリミティブ型と String のみ トップレベル・object・companion object 値が固定なのでどこから読んでも同じ
lateinit var x: 型 できる あとで代入したとき null を許容しない、プリミティブ以外の型 トップレベル・ローカル・クラス本体(主コンストラクタは不可) 代入の同期は自分で用意する
val x by lazy { } できない 最初に読んだとき(1回だけ) すべての型(null 許容型も可) ローカル・クラス・トップレベル 既定で同期あり(SYNCHRONIZED)。モードを選べる

迷ったときの選び方は次のとおりです。

やりたいこと 選ぶ書き方
値を入れたら変えない val
カウンターや合計のように値を入れ直す var
名前つきの固定値(上限数、キー名など) const val
生成のあと、別のメソッドで値を入れる lateinit var
作るのに時間がかかる値を、使うときまで作らない val ... by lazy
「まだ無い」状態も正しい値として扱いたい var x: 型? = null

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Kotlin val・varの違い|const・lateinit・lazy」

この記事のコードの動かし方

コードはすべて単独のファイルで動きます。ファイルに保存し、次の2行でコンパイルと実行ができます。

kotlinc sample.kt -include-runtime -d sample.jar
java -jar sample.jar

日本語の出力が ? になるときは、java -Dstdout.encoding=UTF-8 -jar sample.jar と指定すると表示されます(この環境で確認しました)。

val と var の違いは「再代入できるかどうか」

val は value(値)、var は variable(変数)の略と覚えると区別しやすくなります。違いは再代入できるかどうかの1点です。

fun main() {
    val language = "Kotlin"   // 再代入しない
    var count = 0             // 再代入する
    count = count + 1
    count += 10
    println("$language $count")

    // val は「あとで1回だけ代入」もできる
    val label: String
    if (count > 5) {
        label = "多い"
    } else {
        label = "少ない"
    }
    println(label)

    // for の変数は毎回あたらしい val
    for (i in 1..3) {
        val doubled = i * 2
        print("$doubled ")
    }
    println()
}

実行結果

Kotlin 11
多い
2 4 6 

ポイントは3つです。

  • val は宣言と代入を分けられます。 型だけ先に書き、if の各分岐で1回ずつ代入する形はコンパイルが通ります。
  • ループの中の val は、周回ごとに新しく作られます。 「val なのに毎回ちがう値が入る」ように見えますが、再代入ではなく毎回の新規宣言です。
  • var でも型は変わりません。 最初に Int が入った var に、あとから文字列は入れられません(後述)。

val に再代入したときのエラー

fun main() {
    val name = "Kotlin"
    name = "Java"
    println(name)
}

コンパイル結果

e1.kt:3:5: error: 'val' cannot be reassigned.
    name = "Java"
    ^^^^

「Val cannot be reassigned」として知られるエラーで、Kotlin 2.4.20 では 'val' cannot be reassigned. と表示されます。直し方は2通りです。

状況 直し方
本当に値を入れ直したい val を var に変える
入れ直す必要はなく、別の値を持ちたいだけ 新しい val を別名で宣言する

同じエラーは次の場面でも出ます。どれも「val として扱われているもの」への代入です。

class Order(val id: Int) {
    val code: String            // 宣言だけして init で入れる
    var status = "NEW"
    init {
        code = "ORD-" + id.toString().padStart(4, '0')
    }
}
class Broken {
    val code: String
}
fun main() {
    val order = Order(7)
    println(order.code)
    order.status = "DONE"
    order.id = 8
    fun update(n: Int) { n = n + 1 }
}

コンパイル結果

v1.kt:9:5: error: property must be initialized or be abstract.
    val code: String
    ^^^^^^^^^^^^^^^^
v1.kt:15:11: error: 'val' cannot be reassigned.
    order.id = 8
          ^^
v1.kt:16:26: error: 'val' cannot be reassigned.
    fun update(n: Int) { n = n + 1 }
                         ^
行 何が起きたか
9行目 クラスの val は、宣言時か init ブロックで値を入れる必要がある。Order は init で入れているので通り、Broken は入れていないのでエラー
15行目 コンストラクタで val id と宣言したプロパティは外からも再代入できない
16行目 関数の引数は val と同じ扱い。書き換えたいときは関数の中で var m = n のように別の変数を作る

val でも中身は変えられる:MutableList の例

val が止めるのは「変数に別のものを入れ直すこと」だけです。val に入っているオブジェクトそのものが変更できる作りなら、中身は変えられます。

data class User(val name: String, var age: Int)

fun main() {
    val tags = mutableListOf("a", "b")
    tags.add("c")            // 中身の変更はできる
    tags[0] = "z"
    println(tags)
    // tags = mutableListOf("x")   // これはコンパイルエラー('val' cannot be reassigned.)

    val readOnly: List<String> = tags
    // readOnly.add("d")           // List には add がない
    tags.add("d")
    println(readOnly)        // 同じリストを見ているので増えている

    val copied = tags.toList()
    tags.add("e")
    println(copied)          // コピーは変わらない

    val user = User("sato", 20)
    user.age = 21            // var のプロパティは変えられる
    println(user)
}

実行結果

[z, b, c]
[z, b, c, d]
[z, b, c, d]
User(name=sato, age=21)

「変えられない」には2つの段階があると整理できます。

段階 何を止めるか 書き方
変数の再代入を止める 別のオブジェクトを入れ直すこと val
中身の変更を止める 要素の追加・削除・書き換え List(読み取り専用の型)や、プロパティをすべて val にしたクラス

読み取り専用の List に add を呼んだり、Pair の中身を書き換えたりすると、次のようにコンパイルの時点で分かります。

fun main() {
    val readOnly: List<String> = listOf("a", "b")
    readOnly.add("c")
    val user = Pair("sato", 20)
    user.first = "suzuki"
}
r2b.kt:3:14: error: unresolved reference 'add' on receiver of type 'List<String>'.
    readOnly.add("c")
             ^^^
r2b.kt:5:10: error: 'val' cannot be reassigned.
    user.first = "suzuki"
         ^^^^^

先ほどの実行結果の2行目のとおり、List 型の変数で受けても、元の MutableList に追加すれば見える内容は増えます。内容を固定して渡したいときは toList() でコピーを作ると、3行目のように元の変更の影響を受けません。

型の書き方と型推論

変数の型は val 名前: 型 = 値 と書きます。右辺から型が決まるときは型を省略でき、これを型推論と呼びます。

val count: Int = 10      // 型を書く
val total = 10           // 型推論で Int になる

基本型の一覧

次の表は、各型のビット数と範囲を実際に出力したものです。

型 ビット数 範囲・最大値(実行結果) リテラルの例
Byte 8 -128 .. 127 val b: Byte = 1
Short 16 -32768 .. 32767 val s: Short = 1
Int 32 -2147483648 .. 2147483647 1、0xFF、0b1010
Long 64 -9223372036854775808 .. 9223372036854775807 1L、3_000_000_000
Float 32 最大 3.4028235E38 1.5f
Double 64 最大 1.7976931348623157E308 1.5
UInt 32 0 .. 4294967295 10u
ULong 64 0 .. 18446744073709551615 10uL
Char 16 1文字 'A'
Boolean ― true / false true
String ― 文字列 "A"

この表は次のコードの出力から作りました。

fun main() {
    println("Byte   ${Byte.SIZE_BITS}bit ${Byte.MIN_VALUE} .. ${Byte.MAX_VALUE}")
    println("Short  ${Short.SIZE_BITS}bit ${Short.MIN_VALUE} .. ${Short.MAX_VALUE}")
    println("Int    ${Int.SIZE_BITS}bit ${Int.MIN_VALUE} .. ${Int.MAX_VALUE}")
    println("Long   ${Long.SIZE_BITS}bit ${Long.MIN_VALUE} .. ${Long.MAX_VALUE}")
    println("Float  ${Float.SIZE_BITS}bit max=${Float.MAX_VALUE}")
    println("Double ${Double.SIZE_BITS}bit max=${Double.MAX_VALUE}")
    println("Char   ${Char.SIZE_BITS}bit")
    println("UInt   ${UInt.SIZE_BITS}bit ${UInt.MIN_VALUE} .. ${UInt.MAX_VALUE}")
    println("ULong  ${ULong.SIZE_BITS}bit ${ULong.MIN_VALUE} .. ${ULong.MAX_VALUE}")

    val n: Int = 100
    val big: Long = n.toLong()     // 自動では広がらないので toLong()
    val text: String = n.toString()
    val back: Int = "42".toInt()
    val safe: Int? = "abc".toIntOrNull()
    println("$big $text $back $safe")
    println(Int.MAX_VALUE + 1)     // あふれると折り返す
}

実行結果

Byte   8bit -128 .. 127
Short  16bit -32768 .. 32767
Int    32bit -2147483648 .. 2147483647
Long   64bit -9223372036854775808 .. 9223372036854775807
Float  32bit max=3.4028235E38
Double 64bit max=1.7976931348623157E308
Char   16bit
UInt   32bit 0 .. 4294967295
ULong  64bit 0 .. 18446744073709551615
100 100 42 null
-2147483648

最後の行のとおり、Int の最大値に 1 を足すと最小値に折り返します。21億を超える可能性がある数は、最初から Long で宣言しておくと安心です。

型推論の結果一覧

「この書き方だと何型になるのか」を、コンパイラが推論した型のまま出力しました。

右辺の書き方 推論される型
1 Int
3_000_000_000 Long(Int に収まらない整数は自動で Long)
1L Long
1.5 Double
1.5f Float
'A' Char
"A" String
true Boolean
0xFF Int
10u UInt
1 + 2L Long
1 / 2 Int(値は 0)
1 / 2.0 Double(値は 0.5)
listOf(1, 2) List<Int>
mutableListOf("a") MutableList<String>
listOf(1, 2.5) List<Any>
listOf(1, "a") List<Any>
mapOf("a" to 1) Map<String, Int>
arrayOf(1, 2) Array<Int>
intArrayOf(1, 2) IntArray
1..3 IntRange
null Nothing?
if (a > 0) "x" else null String?
"42".toIntOrNull() Int?
{ n: Int -> n * 2 } (Int) -> Int

確認に使ったコードです。typeOf に型引数を推論させることで、実行時のクラス名ではなくコンパイラが決めた型を表示しています(抜粋。表の全行を同じ形で出力しました)。

import kotlin.reflect.typeOf

inline fun <reified T> typeNameOf(@Suppress("UNUSED_PARAMETER") value: T): String =
    typeOf<T>().toString().replace("kotlin.collections.", "").replace("kotlin.ranges.", "").replace("kotlin.", "")

fun main() {
    val a = 1
    println("3_000_000_000          -> " + typeNameOf(3_000_000_000))
    println("1 / 2                  -> " + typeNameOf(1 / 2) + " (値は ${1 / 2})")
    println("listOf(1, 2.5)         -> " + typeNameOf(listOf(1, 2.5)))
    println("null                   -> " + typeNameOf(null))
    println("if (a > 0) \"x\" else null -> " + typeNameOf(if (a > 0) "x" else null))
    println("{ n: Int -> n * 2 }    -> " + typeNameOf({ n: Int -> n * 2 }))
}
3_000_000_000          -> Long
1 / 2                  -> Int (値は 0)
listOf(1, 2.5)         -> List<Any>
null                   -> Nothing?
if (a > 0) "x" else null -> String?
{ n: Int -> n * 2 }    -> (Int) -> Int

型推論でつまずきやすい点を3つ挙げます。

  • val x = null は Nothing? 型になります。 あとで文字列を入れたいなら var x: String? = null と型を書きます。
  • 整数どうしの割り算は整数になります。 小数がほしいときは片方を 2.0 にするか toDouble() を使います。
  • Int と Double を混ぜたリストは List<Any> になります。 数値として計算したいなら listOf(1.0, 2.5) のようにそろえます。

型が合わないときのエラー

Kotlin は数値の型を自動で広げません。Int を Long の変数に入れる、Double のリテラルを Float に入れる、といった書き方はコンパイルの時点で知らせてくれます。

fun main() {
    val count = 1
    val big: Long = count
    val f: Float = 1.5
    val c: Char = "A"
}
e5.kt:3:19: error: initializer type mismatch: expected 'Long', actual 'Int'.
    val big: Long = count
                  ^
e5.kt:4:18: error: initializer type mismatch: expected 'Float', actual 'Double'.
    val f: Float = 1.5
                 ^
e5.kt:5:17: error: initializer type mismatch: expected 'Char', actual 'String'.
    val c: Char = "A"
                ^
エラーの行 直し方
Long に Int count.toLong()
Float に Double 1.5f
Char に String 'A'(シングルクォート)

var に別の型を入れたとき、null を入れたとき、型も値も書かなかったときのエラーも並べておきます。

fun main() {
    var count = 1
    count = "two"
    val n: Int = null
    val x
}
e4.kt:3:11: error: assignment type mismatch: actual type is 'String', but 'Int' was expected.
    count = "two"
          ^
e4.kt:4:18: error: null cannot be a value of a non-null type 'Int'.
    val n: Int = null
                 ^^^^
e4.kt:5:9: error: this variable must either have an explicit type or be initialized.
    val x
        ^

var は「値を入れ直せる」だけで、「型を変えられる」わけではありません。 null を入れたいときは Int? のように型の後ろに ? を付けます。

const val:コンパイル時に決まる定数

const val は、コンパイルの時点で値が決まる定数です。公式ドキュメントでは、次の条件が示されています。

  • トップレベル、object 宣言、companion object のいずれかに置く
  • String かプリミティブ型の値で初期化する
  • 独自の getter を持たない
const val APP_NAME = "stock-tool"            // トップレベル
const val MAX_RETRY = 3
const val TIMEOUT_MS = MAX_RETRY * 1_000L    // 定数どうしの計算は書ける
const val GREETING = "Hello, $APP_NAME"      // 定数だけの文字列テンプレートも書ける

object Limits {
    const val PAGE_SIZE = 20                 // object の中
}

class Task {
    companion object {
        const val STATUS_OPEN = "OPEN"       // companion object の中
    }
}

val startedAt = System.currentTimeMillis()   // 実行時に決まる値は val

fun label(retry: Int): String = when (retry) {
    MAX_RETRY -> "上限"
    else -> "継続"
}

fun main() {
    println(APP_NAME)
    println(TIMEOUT_MS)
    println(GREETING)
    println(Limits.PAGE_SIZE)
    println(Task.STATUS_OPEN)
    println(label(3))
    println(startedAt > 0)
}

実行結果

stock-tool
3000
Hello, stock-tool
20
OPEN
上限
true

const val に関数呼び出しを書いたときのエラー

fun now(): Long = System.currentTimeMillis()

const val MAX_RETRY = 3
const val STARTED_AT = now()
const val TAGS = listOf("a", "b")

fun main() {
    const val LOCAL = 1
    println(MAX_RETRY)
}

コンパイル結果

e2.kt:4:24: error: const 'val' initializer must be a constant value.
const val STARTED_AT = now()
                       ^^^^^
e2.kt:5:1: error: const 'val' has type 'List<String>'. Only primitive types and 'String' are allowed.
const val TAGS = listOf("a", "b")
^^^^^
e2.kt:8:5: error: modifier 'const' is not applicable to 'local variable'.
    const val LOCAL = 1
    ^^^^^
エラー文 原因 直し方
const 'val' initializer must be a constant value. 右辺に関数呼び出しなど、実行しないと決まらない式がある const を外して val にする
const 'val' has type 'List<String>'. Only primitive types and 'String' are allowed. リストなど、プリミティブ型と String 以外を入れている val TAGS = listOf(...) にする
modifier 'const' is not applicable to 'local variable'. 関数の中で宣言している トップレベルか object / companion object に移す

val と const val は何がちがうのか

コンパイル後のクラスを javap -p -constants で見ると、違いがはっきりします。

public final class C1Kt {
  public static final java.lang.String APP_NAME = "stock-tool";
  public static final int MAX_RETRY = 3;
  public static final long TIMEOUT_MS = 3000l;
  public static final java.lang.String GREETING = "Hello, stock-tool";
  private static final long startedAt;
  public static final long getStartedAt();
  ...
}

さらに javap -c で main の中を見ると、println(APP_NAME) は ldc "stock-tool"、println(Limits.PAGE_SIZE) は bipush 20 になっていて、使う側に値そのものが埋め込まれていることが確認できます。一方 startedAt は getstatic でフィールドを読みに行っています。

比べる点 val(トップレベル) const val
値が決まる時点 実行時(そのファイルのクラスが読み込まれたとき) コンパイル時
入れられる値 何でも プリミティブ型と String
右辺に関数呼び出し 書ける 書けない
生成されるもの private なフィールドと getter public static final なフィールド
使う側のコード フィールドを読みに行く 値が直接埋め込まれる
アノテーションの引数 使えない(only 'const val' can be used in constant expressions. になる) 使える

判断の目安は「数値や文字列の固定値なら const val、それ以外は val」です。定数名は MAX_RETRY のように大文字とアンダースコアで書くのが一般的です。

lateinit var:あとから値を入れる

lateinit は「宣言の時点では値を入れず、あとで必ず入れる」と約束する書き方です。初期化メソッドで値を入れるクラスや、テストの準備処理で値を入れる変数など、コンストラクタの時点では値を用意できない場面で使います。null 許容型にしなくて済むので、使う側で ?. を書く必要がなくなります。

// l1.kt
class ReportService {
    lateinit var title: String

    fun setUp() {
        title = "月次レポート"
    }

    fun show() {
        if (this::title.isInitialized) {
            println("title = $title")
        } else {
            println("title はまだ入っていません")
        }
    }
}

fun main() {
    val service = ReportService()
    service.show()
    service.setUp()
    service.show()
    service.title = "週次レポート"   // var なので入れ直せる
    service.show()

    val another = ReportService()
    println(another.title)          // 入れる前に読む
}

実行結果

title はまだ入っていません
title = 月次レポート
title = 週次レポート
Exception in thread "main" kotlin.UninitializedPropertyAccessException: lateinit property title has not been initialized
    at ReportService.getTitle(l1.kt:2)
    at L1Kt.main(l1.kt:26)
    at L1Kt.main(l1.kt)

値を入れる前に読むと UninitializedPropertyAccessException で止まります。 メッセージに lateinit property title has not been initialized とプロパティ名が入るので、どの変数に入れ忘れたかがすぐ分かります。

入っているかどうかは this::title.isInitialized で調べられます。ただし、毎回この確認が必要になるなら、後述の null 許容型のほうが意図に合っています。

lateinit を付けられない場面のエラー

class Screen {
    lateinit var count: Int
    lateinit val title: String
    lateinit var memo: String?
    lateinit var label: String = "x"
}
fun main() {}

コンパイル結果

e3.kt:2:5: error: 'lateinit' modifier is not allowed on properties of primitive types.
    lateinit var count: Int
    ^^^^^^^^
e3.kt:3:5: error: 'lateinit' modifier is allowed only on mutable properties.
    lateinit val title: String
    ^^^^^^^^
e3.kt:4:5: error: 'lateinit' modifier is not allowed on properties of a type with nullable upper bound.
    lateinit var memo: String?
    ^^^^^^^^
e3.kt:5:5: error: 'lateinit' modifier is not allowed on properties with initializer.
    lateinit var label: String = "x"
    ^^^^^^^^
書いたもの エラーの要点 代わりの書き方
lateinit var count: Int プリミティブ型には付けられない var count = 0 または var count: Int? = null
lateinit val title: String var にだけ付けられる lateinit var、または val title by lazy { }
lateinit var memo: String? null 許容型には付けられない var memo: String? = null
lateinit var label: String = "x" 初期値とは併用できない var label = "x"

公式ドキュメントでは、このほかに「独自の getter / setter を持たないこと」「クラスのプロパティなら主コンストラクタではなく本体で宣言すること」が条件として挙げられています。

トップレベルやローカル変数でも使える

// l2.kt
lateinit var globalName: String        // トップレベル

fun main() {
    lateinit var local: String         // ローカル変数
    val print = { println(local) }
    local = "ready"
    print()

    println(::globalName.isInitialized)
    globalName = "main"
    println(::globalName.isInitialized)

    lateinit var later: String
    println(later)
}

実行結果

ready
false
true
Exception in thread "main" kotlin.UninitializedPropertyAccessException: lateinit property later has not been initialized
    at L2Kt.main(l2.kt:14)
    at L2Kt.main(l2.kt)

lateinit と null 許容型の使い分け

「まだ無い」こと自体に意味がある値は、null 許容型で表すほうが素直です。

class Profile {
    var nickname: String? = null       // null 許容型で「まだ無い」を表す
}

fun main() {
    val profile = Profile()
    println(profile.nickname?.length)          // 安全呼び出し
    println(profile.nickname ?: "未設定")       // 既定値
    profile.nickname = "taro"
    println(profile.nickname?.length)
}
null
未設定
4
比べる点 lateinit var x: String var x: String? = null
入れる前に読むと 例外で止まる null が返る
使う側の書き方 そのまま x.length x?.length や x ?: 既定値
Int などの数値型 使えない 使える
向いている場面 使う前に必ず入る流れが決まっている 無い状態が続くこともある

by lazy:最初に使うときに1回だけ計算する

by lazy は、val の初期化を「最初に読まれた瞬間」まで遅らせる書き方です。公式ドキュメントでは「最初の get() の呼び出しでラムダを実行して結果を覚え、以降は覚えた結果を返す」と説明されています。ログを出して確かめます。

class Catalog {
    val items: List<String> by lazy {
        println("  [lazy] 読み込みを実行")
        listOf("pen", "note", "bag")
    }
    val createdAt: Long = System.nanoTime().also { println("  [val] 生成時に実行") }
}

fun main() {
    println("1. インスタンスを作る")
    val catalog = Catalog()
    println("2. 1回目のアクセス")
    println("  " + catalog.items)
    println("3. 2回目のアクセス")
    println("  " + catalog.items)
    println("4. 3回目のアクセス")
    println("  " + catalog.items.size)
}

実行結果

1. インスタンスを作る
  [val] 生成時に実行
2. 1回目のアクセス
  [lazy] 読み込みを実行
  [pen, note, bag]
3. 2回目のアクセス
  [pen, note, bag]
4. 3回目のアクセス
  3

読み取れることは次の3点です。

  • ふつうの val はインスタンスを作った時点で計算される([val] 生成時に実行 が先に出る)。
  • by lazy のブロックは1回目のアクセスで初めて動く。
  • 2回目以降はブロックが動かず、覚えた値が返る([lazy] 読み込みを実行 は1回だけ)。

一度も読まれなければブロックは一度も動かないので、使うかどうか分からない重い準備を後回しにできます。

トップレベル、ローカル、null を返す場合

by lazy はクラスの外でも使えます。ブロックが null を返した場合も「計算済み」として覚えられ、2回目は動きません。

val topLevel: String by lazy { println("[lazy] top"); "top" }
class A { val memo: String? by lazy { println("[lazy] null"); null } }
fun main() {
    println(topLevel); println(topLevel)
    val a = A(); println(a.memo); println(a.memo)
}
[lazy] top
top
top
[lazy] null
null
null

初期化の途中で例外が出たとき

ブロックが例外を投げた場合、値は覚えられず、次のアクセスでもう一度ブロックが動きました。

fun main() {
    var attempts = 0
    val config: String by lazy {
        attempts++
        println("  [lazy] ${attempts}回目の初期化")
        if (attempts == 1) throw IllegalStateException("設定を読めませんでした")
        "config-v1"
    }
    try {
        println(config)
    } catch (e: IllegalStateException) {
        println("  例外: ${e.message}")
    }
    println(config)
    println(config)

    val holder = lazy { "created" }
    println(holder.isInitialized())
    println(holder.value)
    println(holder.isInitialized())
}
  [lazy] 1回目の初期化
  例外: 設定を読めませんでした
  [lazy] 2回目の初期化
config-v1
config-v1
false
created
true

後半のように by を使わず lazy { } をそのまま変数に入れると、isInitialized() で計算済みかどうかを調べられます。

スレッド安全:3つのモードを動かして比べる

lazy() には、複数スレッドから同時に読まれたときの動きを決めるモードがあります。8本のスレッドから同時に読み、ブロックが何回動いたかを数えました。

import java.util.concurrent.CountDownLatch
import java.util.concurrent.atomic.AtomicInteger
import kotlin.concurrent.thread

fun measure(mode: LazyThreadSafetyMode): String {
    val calls = AtomicInteger(0)
    val value: Int by lazy(mode) {
        calls.incrementAndGet()
        Thread.sleep(50)          // 初期化に時間がかかる想定
        42
    }
    val start = CountDownLatch(1)
    val results = java.util.Collections.synchronizedSet(mutableSetOf<Int>())
    val threads = (1..8).map {
        thread {
            start.await()
            results.add(value)
        }
    }
    start.countDown()
    threads.forEach { it.join() }
    return "$mode: 初期化ブロックの実行回数=${calls.get()} 受け取った値=$results"
}

fun main() {
    println(measure(LazyThreadSafetyMode.SYNCHRONIZED))
    println(measure(LazyThreadSafetyMode.PUBLICATION))
    println(measure(LazyThreadSafetyMode.NONE))
}

実行結果(2回実行して同じ結果でした)

SYNCHRONIZED: 初期化ブロックの実行回数=1 受け取った値=[42]
PUBLICATION: 初期化ブロックの実行回数=8 受け取った値=[42]
NONE: 初期化ブロックの実行回数=8 受け取った値=[42]
モード 今回の実行回数 公式ドキュメントの説明(要約) 向いている場面
SYNCHRONIZED(既定) 1回 値は1つのスレッドだけで計算され、すべてのスレッドが同じ値を見る 迷ったらこれ。by lazy { } と書くとこのモード
PUBLICATION 8回 初期化の同期が不要で、複数スレッドが同時に実行してよい場合に指定する ブロックが何度動いても差し支えない計算
NONE 8回 初期化が使用と同じスレッドで行われると確信できる場合に使う。スレッド安全の保証とそのための負荷がない 1本のスレッドからしか触らない値

何も指定しない by lazy は SYNCHRONIZED なので、複数スレッドから読んでもブロックは1回だけです。この例ではどのモードでも受け取った値は 42 でしたが、ブロックが毎回ちがうオブジェクトを作る処理の場合、NONE ではスレッド安全の保証がない点に気を付けます。

「読むたびに計算する val」との違い

val には、値を持たずに getter で毎回計算する書き方もあります。by lazy と混同しやすいので並べて確認します。

class Counter {
    var count = 0
    val stored = count * 2            // 生成時に1回だけ計算して保持
    val computed: Int                 // 読むたびに計算(値は持たない)
        get() = count * 2
}

fun main() {
    val counter = Counter()
    counter.count = 5
    println("stored=${counter.stored} computed=${counter.computed}")
    counter.count = 8
    println("stored=${counter.stored} computed=${counter.computed}")
}
stored=0 computed=10
stored=0 computed=16
書き方 計算される時点 計算の回数
val x = 式 インスタンスを作ったとき 1回
val x get() = 式 読むたび 毎回
val x by lazy { 式 } 最初に読んだとき 1回

val は「再代入できない」という意味であって、「読むたびに同じ値が返る」とは限りません。 getter つきの val は、もとになる var が変われば返す値も変わります。

どれを使うかの判断表

flowchart TD
    A[値を入れ直す必要がある] -->|いいえ| B[コンパイル時に決まる数値か文字列]
    A -->|はい| F[宣言の時点で値を用意できる]
    B -->|はい| C[const val]
    B -->|いいえ| D[作るのに時間がかかり 使わないこともある]
    D -->|はい| E[val by lazy]
    D -->|いいえ| V[val]
    F -->|はい| G[var]
    F -->|いいえ| H[使う前に必ず入る流れになっている]
    H -->|はい| I[lateinit var]
    H -->|いいえ| J[var と null 許容型]
場面 おすすめ 理由
関数の中の計算結果 val 入れ直さないと分かると読みやすい
ループで加算する合計 var 入れ直しが必要
上限数、キー名、URL のパス const val コンパイル時に決まり、アノテーションにも使える
現在時刻やリストなど実行時に決まる固定値 val const にはできない
準備メソッドで入れる依存オブジェクト lateinit var null 許容にせずに済む
設定の読み込み、重い計算結果 val by lazy 使うときまで動かず、1回で済む
未入力がありうる項目 var x: 型? = null 無い状態を型で表せる

エラー文の逆引き表

この記事で再現したメッセージ(Kotlin 2.4.20)をまとめます。

メッセージ 種類 対処
'val' cannot be reassigned. コンパイル var にする、または新しい val を作る
property must be initialized or be abstract. コンパイル 宣言時か init で値を入れる
this variable must either have an explicit type or be initialized. コンパイル 型を書くか値を入れる
initializer type mismatch: expected 'Long', actual 'Int'. コンパイル toLong() などで変換する
assignment type mismatch: actual type is 'String', but 'Int' was expected. コンパイル 同じ型の値を入れる
null cannot be a value of a non-null type 'Int'. コンパイル 型を Int? にする
const 'val' initializer must be a constant value. コンパイル const を外す
Only primitive types and 'String' are allowed. コンパイル const を外す
modifier 'const' is not applicable to 'local variable'. コンパイル トップレベルか object に移す
'lateinit' modifier is not allowed on properties of primitive types. コンパイル 初期値を入れるか null 許容型にする
'lateinit' modifier is allowed only on mutable properties. コンパイル var にするか by lazy を使う
lateinit property title has not been initialized 実行時の例外 読む前に代入する。必要なら isInitialized で確認

よくある質問

Q. val と var はどちらを基本にすればよいですか? A. val を基本にし、入れ直しが必要になったときだけ var にします。再代入されないと分かっている変数は、読む人が値の変化を追わずに済みます。

Q. val で宣言したリストに要素を追加できるのはなぜですか? A. val が止めるのは変数への再代入だけだからです。MutableList は中身を変更できる型なので、add や要素の書き換えができます。中身も変えたくないときは List 型で持ち、渡すときに toList() でコピーします。

Q. const val と val の使い分けは? A. 数値や文字列の固定値で、トップレベル・object・companion object に置けるなら const val です。関数の戻り値やリストなど、実行して初めて決まる値は val にします。

Q. lateinit と by lazy はどうちがいますか? A. lateinit は var で、値を外から代入します(入れ直しもできます)。by lazy は val で、値の作り方を宣言の場所に書き、最初に読まれたときに1回だけ計算します。「誰が値を用意するか」で選ぶと分かりやすく、外から渡されるなら lateinit、自分で作れるなら by lazy です。

まとめ

  • val は再代入できない、var は再代入できる。 まず val で書き、必要なときだけ var にする
  • val でも MutableList などの中身は変えられる。 中身も固定したいときは読み取り専用の型とコピーを使う
  • 型推論で型は省略できる。 3_000_000_000 は Long、1 / 2 は Int の 0、null だけだと Nothing? になる
  • const val はコンパイル時に決まるプリミティブ型と String 専用。 関数呼び出しを書くと const 'val' initializer must be a constant value. になる
  • lateinit var は入れる前に読むと UninitializedPropertyAccessException。 プリミティブ型・null 許容型・val には付けられない
  • by lazy は最初のアクセスで1回だけ計算し、既定でスレッド間の同期がある
  • 迷ったら早見表の「再代入・初期化の時点・使える型・使える場所」の4点で選ぶ

参考

  • Kotlin 公式ドキュメント Properties(const val と lateinit の条件): https://kotlinlang.org/docs/properties.html
  • Kotlin 公式ドキュメント Delegated properties(lazy とスレッド安全のモード): https://kotlinlang.org/docs/delegated-properties.html

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Kotlin val・varの違い|const・lateinit・lazy」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?