メインコンテンツまでスキップ

クラスとオブジェクト

この章では、Kotlin でクラスを定義し、データとそれを扱う処理をまとめる書き方を扱います。前半はコンストラクタとプロパティ、継承と interface です。後半では data class や sealed class のような用途ごとのクラスを見て、object 宣言と演算子のオーバーロードと委譲で締めくくります。読み終えると、注文明細や商品をクラスで表し、不正な値を外から入れさせないプロパティと、種類ごとの分岐を書き漏らさない型を自分で書けるようになります。

クラスとプロパティ​

コンストラクタとプロパティ​

クラスは class で宣言します。クラス名の後ろの括弧は プライマリコンストラクタ で、インスタンスを作るときに渡す引数を並べます。引数に val か var を付けると、その引数は同じ名前の プロパティ になります。val は読み取り専用で、var は書き換えられるプロパティです。

class Product(val name: String, val price: Int)

class OrderLine(val product: Product, val quantity: Int = 1) {
val amount: Int = product.price * quantity
init {
require(quantity > 0) { "数量は 1 以上: $quantity" }
}
constructor(name: String, price: Int, quantity: Int) : this(Product(name, price), quantity)
}

fun main() {
val pen = Product("ボールペン", 120)
println(OrderLine(pen, 3).amount)
println(OrderLine(pen).quantity)
println(OrderLine("ノート", 200, 2).amount)
try { OrderLine(pen, 0) } catch (e: IllegalArgumentException) { println(e.message) }
}
実行結果
360
1
400
数量は 1 以上: 0

インスタンスは、Product("ボールペン", 120) のようにクラス名を関数のように呼んで作ります。OrderLine の quantity にはデフォルト引数を付けているので、OrderLine(pen) のように省略できます。amount のようにクラスの本体で宣言したプロパティは、初期化子の式で値を決めます。

init ブロックは、インスタンスを作るたびに実行される処理です。プロパティの初期化子と init ブロックは、本体に書いた順に上から実行されます。この例の require は、条件が偽のときに例外を投げてインスタンスを作らせません (require は 例外と失敗の表し方 で扱います)。実行結果の最後の行が、そのときの例外のメッセージです。constructor で始まる宣言は セカンダリコンストラクタ です。プライマリコンストラクタがあるクラスでは、セカンダリコンストラクタは : this(...) でプライマリコンストラクタを必ず呼びます。別の引数の並びを受け付けたいときに使いますが、多くの場合はデフォルト引数で足ります。

カスタムアクセサと field​

プロパティを読むと ゲッター が、代入すると セッター が呼ばれます。何も書かなければ、値をそのまま返すゲッターと、値をそのまま保存するセッターをコンパイラが作ります。プロパティの宣言の下に get() や set(value) を書くと、この処理を自分で書いたものに置き換えられます。

class Inventory(val productName: String) {
var quantity: Int = 0
set(value) {
require(value >= 0) { "在庫数は 0 以上: $value" }
field = value
}
val isEmpty: Boolean
get() = quantity == 0
}

fun main() {
val inventory = Inventory("ボールペン")
println(inventory.isEmpty)
inventory.quantity = 5
println(inventory.isEmpty)
try { inventory.quantity = -1 } catch (e: IllegalArgumentException) { println(e.message) }
}
実行結果
true
false
在庫数は 0 以上: -1

quantity のセッターは、負の数を受け付けずに例外を投げます。セッターの中の field は、プロパティの値を実際に保存している場所 (バッキングフィールド) を指します。セッターの中で quantity = value と書くと同じセッターを呼び直し続けるので、field に代入します。宣言の = 0 はバッキングフィールドに直接入り、セッターを通りません。isEmpty はゲッターだけを持つプロパティで、読むたびに quantity から計算します。値を保存しないので、バッキングフィールドはありません。実行結果の 1 行目と 2 行目を比べると、quantity を変えたあとに読み直した isEmpty が変わっています。

lateinit と by lazy​

プロパティの値は、インスタンスを作る時点で決めるのが原則です。値をあとから決めたいときは、lateinit か by lazy を使います。lateinit は、初期値を書かずに var のプロパティを宣言する修飾子です。型を null 許容にしなくてよい代わりに、使う前に代入することを書き手が保証します。

class OrderMailer {
lateinit var sender: String

fun send(orderId: Long) {
val message = if (this::sender.isInitialized) "$sender から注文 $orderId を送ります" else "送信元が未設定です"
println(message)
}
}

fun main() {
val mailer = OrderMailer()
mailer.send(1L)
mailer.sender = "shop@example.com"
mailer.send(1L)
try { println(OrderMailer().sender) } catch (e: UninitializedPropertyAccessException) { println(e::class.simpleName) }
}
実行結果
送信元が未設定です
shop@example.com から注文 1 を送ります
UninitializedPropertyAccessException

this::sender.isInitialized で、代入が済んでいるかを確かめられます。代入する前に読むと例外が投げられ、実行結果の最後の行がその例外のクラス名です。lateinit を付けられるのは、null 非許容の型を持つ var だけです。Int や Boolean のような基本型には付けられず、カスタムアクセサも持てません。

一方の by lazy は、最初に読まれたときに一度だけ値を計算する val を作ります。

class OrderSummary(private val amounts: List<Int>) {
val total: Int by lazy {
println("合計を計算します")
amounts.sum()
}
}

fun main() {
val summary = OrderSummary(listOf(600, 80, 1200))
println("作成しました")
println(summary.total)
println(summary.total)
}
実行結果
作成しました
合計を計算します
1880
1880

実行結果の順番を見ると、ラムダの中の処理はインスタンスを作った時点では動かず、total を最初に読んだときに 1 回だけ動いています。2 回目に読むと、計算済みの値がそのまま返ります。計算に時間がかかる値や、使わないかもしれない値に向いた書き方です。by lazy は、この章の最後の プロパティの委譲 の使い方の 1 つです。

可視性​

クラスやメンバーをどこから使えるようにするかは、可視性修飾子 で決めます。何も付けなければ public で、どこからでも使えます。

修飾子クラスのメンバーに付けたときトップレベルの宣言に付けたとき
publicどこからでも使えますどこからでも使えます
privateそのクラスの中だけで使えますそのファイルの中だけで使えます
protectedそのクラスとサブクラスの中で使えます付けられません
internal同じモジュールの中で使えます同じモジュールの中で使えます

モジュール は、一緒にコンパイルされる Kotlin のファイルのまとまりです。Gradle では source set が 1 つのモジュールになります。src/main と src/test は別のモジュールですが、test からは main の internal の宣言を使えます。サブプロジェクトの外には見せず、中では自由に使いたい宣言に internal を付けます。

class Inventory(val productName: String, initial: Int) {
var quantity: Int = initial
private set
fun reserve(amount: Int): Boolean {
if (amount > quantity) return false
quantity -= amount
return true
}
}

internal fun describe(inventory: Inventory): String = "${inventory.productName}: 残り ${inventory.quantity}"

fun main() {
val inventory = Inventory("ノート", 10)
println(inventory.reserve(3))
println(inventory.reserve(20))
println(describe(inventory))
}
実行結果
true
false
ノート: 残り 7

private set は、セッターだけの可視性を下げる書き方です。quantity はクラスの外から読めますが、代入はクラスの中でしかできません。外から在庫数を変える手段は reserve だけになるので、在庫数より多い引き当てを reserve の中で断れます。

入れ子のクラスと inner​

クラスの中に宣言したクラスは 入れ子のクラス です。入れ子のクラスは外側のクラスのインスタンスと結び付かず、Order.Summary(...) のように外側のクラス名を付けて作ります。外側のインスタンスのプロパティを使いたいときは、inner を付けて 内部クラス にします。

class Order(val id: Long) {
class Summary(val lineCount: Int, val total: Int)

inner class Note(val text: String) {
fun render(): String = "注文 $id: $text"
}
}

fun main() {
println(Order.Summary(lineCount = 2, total = 680).total)
val note = Order(7L).Note("午前中に配達")
println(note.render())
}
実行結果
680
注文 7: 午前中に配達

Note は inner なので、Order(7L).Note(...) のように外側のインスタンスから作ります。render の中では、外側の id をそのまま参照できます。外側のインスタンスそのものは、this@Order のようにクラス名のラベルを付けた this で指せます。

継承と interface​

open と override​

Kotlin のクラスは、何も付けなければ継承できません。継承を許すクラスには open を付け、サブクラスで上書きを許すメンバーにも open を付けます。サブクラスの宣言では、: Product(name, price) のように継承するクラスとそのコンストラクタの呼び出しを書きます。

open class Product(val name: String, val price: Int) {
open fun shippingFee(): Int = 500
fun describe(): String = "$name ${price}円 送料 ${shippingFee()}円"
}

class DigitalProduct(name: String, price: Int) : Product(name, price) {
override fun shippingFee(): Int = 0
}

class HeavyProduct(name: String, price: Int) : Product(name, price) {
override fun shippingFee(): Int = super.shippingFee() + 300
}

fun main() {
for (product in listOf(Product("ノート", 200), DigitalProduct("電子書籍", 1500), HeavyProduct("米 10kg", 4000))) {
println(product.describe())
}
}
実行結果
ノート 200円 送料 500円
電子書籍 1500円 送料 0円
米 10kg 4000円 送料 800円

上書きするメンバーには override を必ず付けます。super.shippingFee() で、継承元の実装を呼べます。describe は open でないので上書きできませんが、中で呼ぶ shippingFee() には、インスタンスの実際のクラスのものが選ばれます。実行結果の 3 行は、同じ describe が商品の種類ごとに違う送料を出したものです。override を付けたメンバーはそれ自体も上書きできるので、下のサブクラスに上書きさせたくなければ final override と書きます。open を付けていないクラスを継承しようとすると、コンパイルエラーになります。

abstract class​

abstract を付けたクラスは 抽象クラス で、インスタンスを作れません。本体の無い abstract のメンバーを持てて、サブクラスはそれを必ず実装します。抽象クラスとその abstract のメンバーは、open を付けなくても継承と上書きができます。

abstract class Discount(val label: String) {
abstract fun discountedPrice(price: Int): Int
fun describe(price: Int): String = "$label: $price -> ${discountedPrice(price)}"
}

class PercentDiscount(private val percent: Int) : Discount("${percent}% 引き") {
override fun discountedPrice(price: Int): Int = price * (100 - percent) / 100
}

class FixedDiscount(private val amount: Int) : Discount("${amount} 円引き") {
override fun discountedPrice(price: Int): Int = maxOf(price - amount, 0)
}

fun main() {
for (discount in listOf(PercentDiscount(10), FixedDiscount(300))) {
println(discount.describe(1000))
}
}
実行結果
10% 引き: 1000 -> 900
300 円引き: 1000 -> 700

describe は抽象クラスの側に 1 度だけ書き、割引の計算だけをサブクラスに任せています。サブクラスは Discount("...") のように、抽象クラスのコンストラクタに値を渡せます。

interface​

interface は、クラスが持つべきメンバーを宣言する型です。抽象クラスとの違いは 2 つあります。interface は状態 (バッキングフィールド) を持てず、1 つのクラスが複数の interface を実装できます。メソッドには本体を書いておけて、実装するクラスはそれをそのまま使えます。

interface Priced {
val price: Int
fun priceWithTax(): Int = price * 110 / 100
fun label(): String = "価格あり"
}

interface Shippable {
val weightGrams: Int
fun label(): String = "配送あり"
}

class Product(val name: String, override val price: Int, override val weightGrams: Int) : Priced, Shippable {
override fun label(): String = "${super<Priced>.label()} ${super<Shippable>.label()}"
}

fun main() {
val product = Product("ノート", 200, 150)
println(product.priceWithTax())
println(product.label())
}
実行結果
220
価格あり 配送あり

price と weightGrams は、interface の側では値を持たないプロパティです。Product がコンストラクタの override val で実装しています。Priced と Shippable の両方が本体のある label を持つので、Product はどちらを使うかを決めるために label を上書きしなければなりません。super<Priced>.label() のように、型の名前で呼び分けます。

object 式​

ある interface を実装したインスタンスが 1 つだけ要るときは、クラスに名前を付けずに object 式 で作れます。

interface PriceRule { fun adjust(price: Int): Int }

fun main() {
var applied = 0
val halfPrice = object : PriceRule {
override fun adjust(price: Int): Int {
applied++
return price / 2
}
}
println(halfPrice.adjust(1000))
println(halfPrice.adjust(300))
println(applied)
}
実行結果
500
150
2

object : PriceRule { ... } が、PriceRule を実装した名前の無いクラスのインスタンスです。object 式の中からは周りのローカル変数を読み書きできるので、applied に呼ばれた回数が残ります。メソッドが 1 つだけの interface なら、関数とラムダ で扱った fun interface にして、ラムダで実装を渡す書き方もできます。

値を表すクラス​

data class と分解宣言​

値を運ぶためのクラスには data を付けます。data class では、コンパイラがプライマリコンストラクタのプロパティから次の関数を作ります。文字列への変換 (toString)、等価の判定 (equals と hashCode)、写しの作成 (copy)、分解宣言に使う componentN です。

data class OrderLine(val productName: String, val quantity: Int, val unitPrice: Int)

fun main() {
val line = OrderLine("ノート", 2, 200)
println(line)
println(line == OrderLine("ノート", 2, 200))
val more = line.copy(quantity = 5)
println(more)

val (name, quantity) = more
println("$name x $quantity")
for ((productName, count) in listOf(line, more)) {
println("$productName $count")
}
}
実行結果
OrderLine(productName=ノート, quantity=2, unitPrice=200)
true
OrderLine(productName=ノート, quantity=5, unitPrice=200)
ノート x 5
ノート 2
ノート 5

println で出る文字列には、プロパティの名前と値が並びます (実行結果の 1 行目)。== は equals を呼ぶので、プロパティの値がすべて等しい 2 つのインスタンスは等しいと判定されます。copy は、名前付き引数で指定したプロパティだけを変えた新しいインスタンスを返します。元の line は変わりません。

val (name, quantity) = more は 分解宣言 です。data class の component1() と component2() がプライマリコンストラクタのプロパティの順に呼ばれ、左から順に変数へ入ります。要らない位置は _ で飛ばします。for の変数にも分解宣言を書けます。Map の要素も同じ形で分解でき、コレクションとジェネリクス で使います。分解宣言は名前でなく位置で値を取り出します。data class のプロパティの順番を入れ替えると、分解している側は別のプロパティを受け取ります。型が同じなら、コンパイラは何も知らせません。プロパティの多い data class は、分解せずに名前で読むほうが安全です。

value class​

value class は、1 つの値を包んで別の型にするクラスです。商品 ID と数量はどちらも整数です。fun reserve(productId: Long, quantity: Long) のように同じ整数の型で受け取ると、引数の順番を取り違えてもコンパイルが通ります。value class にしておけば、型が違うのでコンパイラが止めます。

@JvmInline
value class ProductId(val value: Long) {
init { require(value > 0) { "商品 ID は正の数: $value" } }
}

@JvmInline
value class Quantity(val value: Int)

fun reserve(productId: ProductId, quantity: Quantity): String =
"商品 ${productId.value} を ${quantity.value} 個引き当てます"

fun main() {
println(reserve(ProductId(42), Quantity(3)))
println(ProductId(42) == ProductId(42))
try { ProductId(0) } catch (e: IllegalArgumentException) { println(e.message) }
}
実行結果
商品 42 を 3 個引き当てます
true
商品 ID は正の数: 0

value class は、プライマリコンストラクタにプロパティを 1 つだけ持ちます。JVM の上では @JvmInline を付けて宣言します。実行時には多くの場面で中の値そのものに置き換わるので、包むためのインスタンスを作らずに済みます。init ブロックとメソッドも持てるので、値の範囲の確認を型の中に置けます。== で比べることはできますが、同じインスタンスかを比べる === は使えません。前の例の reserve に、引数を逆の順番で渡すとコンパイルエラーになります。

reserve(Quantity(3), ProductId(42))
コンパイルエラー
Argument type mismatch: actual type is 'Quantity', but 'ProductId' was expected.

enum class​

決まった数の値だけを取る型は、enum class で表します。並べた名前の 1 つずつが、その型のインスタンスです。enum class もコンストラクタとプロパティとメソッドを持てます。

enum class OrderStatus(val label: String) {
PENDING("受付"), SHIPPED("発送済み"), CANCELLED("取消");

fun canCancel(): Boolean = this == PENDING
}

fun main() {
val status = OrderStatus.valueOf("SHIPPED")
println("${status.name} ${status.label} ${status.ordinal} ${status.canCancel()}")
println(OrderStatus.entries.joinToString { it.label })
println(when (status) {
OrderStatus.PENDING -> "まだ取り消せます"
OrderStatus.SHIPPED -> "発送済みです"
OrderStatus.CANCELLED -> "取り消し済みです"
})
}
実行結果
SHIPPED 発送済み 1 false
受付, 発送済み, 取消
発送済みです

valueOf は名前の文字列から値を返します。name は宣言した名前を、ordinal は宣言した順番を 0 から数えた値を返します。entries は、宣言した順にすべての値を並べたリストです (joinToString は、リストの要素を区切りの文字列でつないだ文字列を作る関数です)。メンバーを持つ enum class では、最後の値の後ろに ; を書いてからメンバーを宣言します。when の分岐にすべての値を並べると、else を書かなくても式として使えます。この性質は、次の sealed class でも同じです。

sealed class と sealed interface​

enum class の値は、どれも同じ形のデータを持ちます。値の種類ごとに持つデータの形を変えたいときは、sealed class か sealed interface を使います。

sealed class​

sealed class は、直接のサブクラスがコンパイルの時点ですべて決まるクラスです。直接のサブクラスは同じパッケージと同じモジュールの中にしか宣言できず、それ以外の場所からは継承できません。sealed class 自体は抽象クラスなので、インスタンスを作れません。

sealed class OrderState(val label: String) {
data object Pending : OrderState("受付")
data class Shipped(val trackingNumber: String) : OrderState("発送済み")
data class Cancelled(val reason: String) : OrderState("取消")
}

fun detail(state: OrderState): String = when (state) {
OrderState.Pending -> "発送を待っています"
is OrderState.Shipped -> "追跡番号 ${state.trackingNumber}"
is OrderState.Cancelled -> "理由: ${state.reason}"
}

fun main() {
for (state in listOf(OrderState.Pending, OrderState.Shipped("TN-001"), OrderState.Cancelled("在庫切れ"))) {
println("${state.label} ${detail(state)}")
}
println(OrderState.Pending)
}
実行結果
受付 発送を待っています
発送済み 追跡番号 TN-001
取消 理由: 在庫切れ
Pending

受付の状態は持つデータが無いので、data object で 1 つだけのインスタンスにしています。data object は、object と companion object で扱う object 宣言に data を付けたもので、println すると名前が出ます (実行結果の 4 行目)。発送済みと取消は、それぞれ追跡番号と理由を持つ data class です。共通の label は sealed class のコンストラクタで受け取っています。detail の when では、is でサブクラスを判定すると、その分岐の中で state がそのサブクラスとして扱われます (スマートキャスト)。data object の分岐は、is を付けずに値そのもので比べます。

sealed interface と when の網羅性​

sealed の型を when で分けるとき、コンパイラはすべてのサブクラスが分岐にあるかを確かめます。前の例の detail に else が無いのは、3 つのサブクラスをすべて分岐に書いているからです。分岐が足りないと、コンパイルが通りません。次の例は、支払い方法を sealed interface で表し、GiftCard を足したのに describe を直していない状態です。sealed interface は、実装するクラスがコンパイルの時点ですべて決まる interface です。共通のプロパティやコンストラクタが要らないときは、sealed class でなく sealed interface にします。

sealed interface Payment
data class CreditCard(val last4: String) : Payment
data class BankTransfer(val dueDays: Int) : Payment
data object CashOnDelivery : Payment
data class GiftCard(val code: String) : Payment

fun describe(payment: Payment): String = when (payment) {
is CreditCard -> "カード払い (下 4 桁 ${payment.last4})"
is BankTransfer -> "銀行振込 (期限 ${payment.dueDays} 日)"
CashOnDelivery -> "代金引換"
}
コンパイルエラー
'when' expression must be exhaustive. Add the 'is GiftCard' branch or an 'else' branch.

エラーには、足りない分岐の名前が出ます。else を書いた when では、GiftCard も else の分岐に入るので、コンパイラは何も知らせません。sealed の型や enum class を分ける when には、else を書かずにすべての分岐を並べることをすすめます。この性質を失敗の表し方に使う例は、例外と失敗の表し方 で扱います。

object と companion object​

インスタンスが 1 つあれば足りるものは、object で宣言します。object 宣言 は、クラスの宣言とそのただ 1 つのインスタンスを同時に作ります。インスタンスは、最初に使われたときに作られます。

object TaxRate {
const val PERCENT = 10
fun withTax(price: Int): Int = price * (100 + PERCENT) / 100
}

class Product private constructor(val name: String, val price: Int) {
companion object {
fun of(name: String, price: Int): Product {
require(price >= 0) { "価格は 0 以上: $price" }
return Product(name, price)
}
}
}

fun main() {
println(TaxRate.withTax(1000))
val product = Product.of("ノート", 200)
println("${product.name} ${product.price}")
}
実行結果
1100
ノート 200

TaxRate.withTax(1000) のように、object の名前を付けてメンバーを呼びます。クラスの中に companion object を宣言すると、そのメンバーをクラスの名前で呼べます。これを コンパニオンオブジェクト と呼び、インスタンスを作らずに使う関数や定数をクラスのそばに置くのに使います。Product のコンストラクタは private なので、外からは Product.of を通してしか作れません。コンパニオンオブジェクトはクラスの内側にあるので、private のコンストラクタを呼べます。この形にすると、値を確かめる関数を通らない Product は作れなくなります。

演算子のオーバーロードと委譲​

演算子のオーバーロード​

+ や [] のような演算子は、決まった名前の関数の呼び出しに置き換えられます。その名前の関数に operator を付けて定義すると、自分のクラスでも演算子を使えます。

式呼ばれる関数
a + ba.plus(b)
a * ba.times(b)
a > ba.compareTo(b) > 0
a[i]a.get(i)
a[i] = va.set(i, v)
x in aa.contains(x)
a += ba.plusAssign(b) (無ければ a = a.plus(b))
data class Money(val amount: Long) {
operator fun plus(other: Money): Money = Money(amount + other.amount)
operator fun times(count: Int): Money = Money(amount * count)
operator fun compareTo(other: Money): Int = amount.compareTo(other.amount)
}

class Cart {
private val quantities = mutableMapOf<String, Int>()
operator fun get(productName: String): Int = quantities[productName] ?: 0
operator fun contains(productName: String): Boolean = productName in quantities
operator fun plusAssign(productName: String) { quantities[productName] = this[productName] + 1 }
}

fun main() {
val total = Money(200) * 3 + Money(80)
println(total)
println(total > Money(600))
val cart = Cart()
cart += "ノート"
cart += "ノート"
println(cart["ノート"])
println("消しゴム" in cart)
}
実行結果
Money(amount=680)
true
2
false

Money(200) * 3 + Money(80) は、times と plus の呼び出しになります。演算子の優先順位は、関数を定義しても変わりません。compareTo を定義すると、> や <= で比べられます。cart += "ノート" は plusAssign の呼び出しです。cart は val ですが、plusAssign はインスタンスの中身を変えるだけで、cart への代入はしていません。演算子は、その記号から読み手が思い浮かべる意味と合うときだけ定義します。金額の + は合計と読めます。一方、注文に商品を足す + は、新しい注文を返すのか元の注文を変えるのかが記号から分かりません。

クラスの委譲​

interface の実装を、別のインスタンスに任せられます。: InventoryRepository by inner と書くと、InventoryRepository のメソッドのうち自分で上書きしなかったものを、inner の同じメソッドを呼ぶ形でコンパイラが作ります。

interface InventoryRepository {
fun quantityOf(productName: String): Int
fun save(productName: String, quantity: Int)
}

class InMemoryInventoryRepository : InventoryRepository {
private val quantities = mutableMapOf<String, Int>()
override fun quantityOf(productName: String): Int = quantities[productName] ?: 0
override fun save(productName: String, quantity: Int) { quantities[productName] = quantity }
}

class LoggingInventoryRepository(private val inner: InventoryRepository) : InventoryRepository by inner {
override fun save(productName: String, quantity: Int) {
println("保存: $productName = $quantity")
inner.save(productName, quantity)
}
}

fun main() {
val repository = LoggingInventoryRepository(InMemoryInventoryRepository())
repository.save("ノート", 30)
println(repository.quantityOf("ノート"))
}
実行結果
保存: ノート = 30
30

LoggingInventoryRepository は save だけを書き、保存の前に記録を出しています。quantityOf は書いていませんが、呼ぶと inner の quantityOf が動きます。interface のメソッドが多くても、変えたいメソッドだけを書けば済みます。ただし、inner の中から save を呼んだ場合は、上書きした save を通りません。委譲される側は、委譲する側のインスタンスを知らないためです。

プロパティの委譲​

by はプロパティにも使えます。var quantity: Int by NonNegative(0) と書くと、quantity を読んだときに NonNegative の getValue が、代入したときに setValue が呼ばれます。by lazy も、lazy が返す Lazy に値の取り出しを任せる、この仕組みの使い方の 1 つです。

import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty

class NonNegative(private var current: Int) : ReadWriteProperty<Any?, Int> {
override fun getValue(thisRef: Any?, property: KProperty<*>): Int = current
override fun setValue(thisRef: Any?, property: KProperty<*>, value: Int) {
require(value >= 0) { "${property.name} は 0 以上: $value" }
current = value
}
}

class Inventory {
var quantity: Int by NonNegative(0)
var reserved: Int by NonNegative(0)
}

fun main() {
val inventory = Inventory()
inventory.quantity = 10
try { inventory.reserved = -1 } catch (e: IllegalArgumentException) { println(e.message) }
println("${inventory.quantity} ${inventory.reserved}")
}
実行結果
reserved は 0 以上: -1
10 0

NonNegative は、0 以上かを確かめる処理を 1 か所にまとめたクラスです。quantity と reserved の 2 つのプロパティが、同じ確認を使っています。setValue が受け取る property から、委譲しているプロパティの名前を取り出せます。そのため、メッセージにどのプロパティかを入れられます (実行結果の 1 行目)。ReadWriteProperty は、getValue と setValue の形を決めた標準ライブラリの interface です。val のプロパティに使う委譲なら、getValue だけの ReadOnlyProperty を実装します。標準ライブラリの Delegates には、値が変わるたびにラムダを呼ぶ Delegates.observable などの委譲が用意されています。

まとめ​

  • プライマリコンストラクタの引数に val か var を付けると、プロパティになります。init ブロックとプロパティの初期化子は、書いた順に実行されます
  • カスタムアクセサの中では、値を保存する場所を field で指します
  • 値をあとから決めるプロパティは、var なら lateinit で、最初に読むときに計算する val なら by lazy で書きます
  • 何も付けない宣言は public です。internal の宣言は同じモジュールの中だけで使えます
  • クラスは何も付けなければ継承できません。継承させるクラスとメンバーに open を付けます
  • 値を運ぶクラスは data class にし、1 つの値に専用の型を付けるなら value class にします
  • 種類ごとにデータの形が違う値は sealed class か sealed interface で表します。when には else を書かずに、すべての分岐を並べます
  • object は 1 つだけのインスタンスを作り、companion object はクラスの名前で呼べるメンバーを作ります
  • by を使うと、interface の実装とプロパティの値の出し入れを、別のインスタンスに任せられます

次に読む​

前の章は 関数とラムダ です。次章 コレクションとジェネリクス では、この章で作った注文明細のようなクラスを List や Map に入れて、まとめて変換したり集計したりする書き方を扱います。型を引数に取るクラスと関数 (ジェネリクス) も、そこで扱います。

練習問題​

注文のクラス Order が明細のリストを書き換えられるリストのまま公開していると、外から明細を消したり、数量が負の明細を足したりできます。明細の追加だけを許し、数量が 0 以下の明細を入れられないように直してください

解答例

data class OrderLine(val productName: String, val quantity: Int, val unitPrice: Int)

class Order(val id: Long) {
private val _lines = mutableListOf<OrderLine>()
val lines: List<OrderLine>
get() = _lines
val total: Int
get() = _lines.sumOf { it.quantity * it.unitPrice }
fun addLine(line: OrderLine) {
require(line.quantity > 0) { "数量は 1 以上: ${line.quantity}" }
_lines.add(line)
}
}

fun main() {
val order = Order(1L)
order.addLine(OrderLine("ノート", 3, 200))
order.addLine(OrderLine("消しゴム", 2, 80))
try { order.addLine(OrderLine("ノート", -5, 200)) } catch (e: IllegalArgumentException) { println(e.message) }
println("${order.lines.size} ${order.total}")
}
実行結果
数量は 1 以上: -5
2 760

明細のリストを private val _lines に移し、外には読み取り専用の List<OrderLine> を返すプロパティ lines を用意します。List には add も clear も無いので、外からは明細を変えられません (読み取り専用のコレクションは コレクションとジェネリクス で扱います)。明細を足す手段は addLine だけになり、そこで require によって数量を確かめます。合計の total はゲッターで毎回計算するので、明細と合計が食い違うことはありません (sumOf は要素ごとの値を足し合わせる関数です)。lines が返すのは _lines と同じリストなので、受け取った側が型を MutableList に変換すれば書き換えられます。それも防ぎたいなら、ゲッターで _lines.toList() のように写しを返します。

金額を Long のまま扱うと、金額でない整数と足し間違えても気づけません。金額の型 Money を value class で作り、注文明細の金額と注文の合計を + と * で計算できるようにしてください

解答例

@JvmInline
value class Money(val amount: Long) {
operator fun plus(other: Money): Money = Money(amount + other.amount)
operator fun times(quantity: Int): Money = Money(amount * quantity)
}

data class OrderLine(val productName: String, val unitPrice: Money, val quantity: Int) {
val amount: Money
get() = unitPrice * quantity
}

fun main() {
val lines = listOf(OrderLine("ノート", Money(200), 3), OrderLine("消しゴム", Money(80), 2))
var total = Money(0)
for (line in lines) total += line.amount
println(total.amount)
}
実行結果
760

Money は Long を 1 つ包む value class です。plus と times に operator を付けたので、unitPrice * quantity と total += line.amount が書けます。Money には plusAssign が無く、total が var なので、total += line.amount は total = total + line.amount として扱われます。Money に Int を足す演算子は定義していないので、金額に数量を足すような式はコンパイルが通りません。