Kotlinの委譲プロパティ: lazyとobservable
1. 概要
Kotlinはbyキーワードを使ってプロパティの読み書きを別のオブジェクトに委譲できます。このメカニズムは委譲プロパティ(delegated properties)と呼ばれます。標準ライブラリにはよく使われるいくつかのdelegateが用意されています。最も一般的なのは、遅延初期化用のlazy、変更を監視するobservable、そして無効な変更をブロックするvetoableです。
この記事を読み終わったら、各delegateの使い方とlazyの正しいスレッドセーフティモードの選び方がわかるでしょう。また、テストの書き方と避けるべきシーンについても学べます。
2. セットアップ
この記事で扱うdelegateはkotlin-stdlibに含まれているため、追加のライブラリは不要です。テストを書くには、JUnit 5とAssertJを追加してください:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
Spring Boot 3.xを使っている場合、spring-boot-starter-testには両方のライブラリが既に含まれています。
3. 委譲プロパティの仕組み
val x: T by someDelegateと書くと、コンパイラはdelegateを保持するための非表示フィールドを生成します。xのgetterはsomeDelegate.getValue(...)を呼び出します。xがvarの場合、setterはsetValue(...)を呼び出します。
メカニズムをはっきり理解するために、シンプルなdelegateを自分で書いてみることができます:
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
class TrimmedString(private var value: String = "") : ReadWriteProperty<Any?, String> {
override fun getValue(thisRef: Any?, property: KProperty<*>): String = value
override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
this.value = value.trim()
}
}
class Customer {
var name: String by TrimmedString()
}
fun main() {
val customer = Customer()
customer.name = " Nguyễn Văn An "
println("[${customer.name}]")
}
各部分の説明:
TrimmedStringはReadWritePropertyを実装し、getValueとsetValueの2つのメソッドを持ちます。customer.nameを割り当てるたびに、KotlinはsetValueを呼び出し、文字列は保存される前にtrim()されます。propertyパラメータは、アクセスされているプロパティの名前を教えてくれます。observableはこのパラメータを使って、どのプロパティが変更されたかをあなたに通知します。
[Nguyễn Văn An]
標準ライブラリにはよく使われるものが揃っているため、自分でdelegateを書く必要があることはめったにありません。
4. by lazyによる遅延初期化
4.1. 基本的な使い方
lazyはラムダを受け取ります。ラムダはプロパティを初めて読むときだけ実行されます。その結果はキャッシュされ、以降の読み取りで再利用されます。
class ReportService {
val exchangeRates: Map<String, Double> by lazy {
println("Đang tải tỷ giá...")
mapOf("USD" to 25_400.0, "EUR" to 27_300.0)
}
}
fun main() {
val service = ReportService()
println("Service đã tạo")
println(service.exchangeRates["USD"])
println(service.exchangeRates["EUR"])
}
結果:
Service đã tạo
Đang tải tỷ giá...
25400.0
27300.0
3つの重要なポイントがあります:
ReportServiceを作成しても、為替レートの読み込みは開始されません。- “Đang tải tỷ giá…“という行は、プロパティが2回読み込まれても1回だけ出力されます。
lazyはvalでのみ使用できます。var x by lazy { ... }と書くとコンパイルエラーになります。なぜならLazyにはsetValueがないからです。
lazyは、作成に時間がかかるが常に必要とは限らない値に適しています。例えば、複雑な正規表現、ファイルから読み込まれた設定、または外部サービスを呼び出すクライアントなどです。
4.2. スレッドセーフティモード
デフォルトでは、lazyはLazyThreadSafetyMode.SYNCHRONIZEDを使用します。このモードは、複数のスレッドが同時にアクセスしても、ラムダが正確に1回だけ実行されることを保証します。必要に応じてモードを変更できます:
class PriceCalculator {
// 1つのスレッドだけが初期化処理を実行でき、他のスレッドは待機します
val taxTable: Map<String, Double> by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
mapOf("VN" to 0.1, "SG" to 0.09)
}
// 複数のスレッドが初期化処理を実行できますが、最初の値が保持されます
val discountRules: List<String> by lazy(LazyThreadSafetyMode.PUBLICATION) {
listOf("BLACK_FRIDAY", "TET_SALE")
}
// 同期なし。プロパティが確実に1つのスレッドからのみアクセスされる場合にのみ使用してください
val formatter: java.text.DecimalFormat by lazy(LazyThreadSafetyMode.NONE) {
java.text.DecimalFormat("#,###")
}
}
fun main() {
val calculator = PriceCalculator()
println(calculator.taxTable["VN"])
println(calculator.discountRules.first())
println(calculator.formatter.format(1_250_000))
}
0.1
BLACK_FRIDAY
1,250,000
以下の表に基づいて、モードを選択できます:
| モード | Initializerの実行 | 使用時機 |
|---|---|---|
SYNCHRONIZED |
正確に1回 | デフォルト、ほとんどの場合に安全です |
PUBLICATION |
複数回の可能性あり、最初の結果を保持 | Initializerが軽く、副作用がなく、ロックを避けたい場合 |
NONE |
保証なし | コードが1つのスレッド(UIスレッド、内部ループ)からのみ実行される場合 |
注意:
NONEはロックがないため若干高速です。複数のリクエストが並行してアクセスするSpring beanシングルトンで使用すると、initializerが複数回実行される可能性があります。その場合、スレッドは異なるインスタンスを見る可能性があります。測定なしにパフォーマンスのために単にモードを変更しないでください。
4.3. Initializerが例外をスローする場合
ラムダが例外をスロー場合、値はキャッシュされません。次の読み取りはラムダを再度実行します:
class ConfigLoader {
var attempts = 0
val apiKey: String by lazy {
attempts++
if (attempts < 2) error("Chưa đọc được file cấu hình")
"secret-key-123"
}
}
fun main() {
val loader = ConfigLoader()
runCatching { loader.apiKey }.onFailure { println("Lỗi: ${it.message}") }
println(loader.apiKey)
println("Số lần thử: ${loader.attempts}")
}
Lỗi: Chưa đọc được file cấu hình
secret-key-123
Số lần thử: 2
この動作は、エラーが一時的な場合に有益です。ただし、initializerが非常にコストが高く、エラーが繰り返される場合、アクセスするたびにそのコストが再度発生します。
4.4. 初期化済みかどうかを確認する
値が初期化されているかどうかを知る必要がある場合は、Lazyオブジェクトを別の変数に保持し、そこから委譲してください:
class CacheHolder {
private val productCacheDelegate = lazy { mutableMapOf<String, Int>() }
val productCache: MutableMap<String, Int> by productCacheDelegate
fun isCacheReady(): Boolean = productCacheDelegate.isInitialized()
}
fun main() {
val holder = CacheHolder()
println(holder.isCacheReady())
holder.productCache["SKU-1"] = 10
println(holder.isCacheReady())
}
false
true
この方法は、シャットダウン時にリソースを解放したい場合に有用ですが、そのリソースが実際に作成されている場合だけです。
4.5. lazyまたはlateinit?
この2つはしばしば混同されます:
lazy:valで使用します。値はプロパティ自体によって計算され、最初の読み取りで実行されます。lateinit:varと非nullな型で使用します。Intなどのプリミティブには適用されません。値は外部から後で割り当てられます。例えば、依存性注入またはテストの@BeforeEachメソッドを通じて。
クラス内で値を計算する方法がわかっている場合は、lazyを使用してください。値が外部から“導入”される場合は、lateinitを使用してください。
5. Delegates.observableで変更を追跡する
Delegates.observableは初期値とコールバックを受け取ります。コールバックは新しい値が割り当てられた後に実行されます。
import kotlin.properties.Delegates
enum class OrderStatus { NEW, PAID, SHIPPED, DELIVERED }
class Order(val id: String) {
val history = mutableListOf<String>()
var status: OrderStatus by Delegates.observable(OrderStatus.NEW) { property, old, new ->
history += "${property.name}: $old -> $new"
}
}
fun main() {
val order = Order("ORD-001")
order.status = OrderStatus.PAID
order.status = OrderStatus.SHIPPED
order.status = OrderStatus.SHIPPED
order.history.forEach(::println)
}
status: NEW -> PAID
status: PAID -> SHIPPED
status: SHIPPED -> SHIPPED
説明:
OrderStatus.NEWは初期値です。この値を割り当てることはコールバックをトリガーしません。- コールバックは3つのパラメータを受け取ります:
property(メタデータ)、old(古い値)、new(新しい値)。 - 3番目の行は、同じ値を再度割り当てる場合でもコールバックが実行されることを示しています。
値が実際に変更されたときだけ反応したい場合は、コールバック内で自分でフィルタリングしてください:
var status: OrderStatus by Delegates.observable(OrderStatus.NEW) { _, old, new ->
if (old != new) history += "$old -> $new"
}
observableは、通常、監査ログの記録、オブジェクトを“dirty”としてマーク(再保存が必要)、または派生値の更新に使用されます。
6. Delegates.vetoableで無効な値をブロックする
vetoableはobservableに似ていますが、コールバックは割り当て前に実行され、Booleanを返します。コールバックがfalseを返す場合、古い値が保持されます。
import kotlin.properties.Delegates
class CartItem(val sku: String) {
var quantity: Int by Delegates.vetoable(1) { _, _, new ->
new in 1..99
}
}
fun main() {
val item = CartItem("SKU-42")
item.quantity = 5
println(item.quantity)
item.quantity = 0
println(item.quantity)
item.quantity = 150
println(item.quantity)
}
5
5
5
0と150への割り当ては静かに拒否され、quantityは5のままです。
注意:
vetoableは例外をスローしないため、呼び出し側は割り当てが失敗したかどうかわかりません。支払い額などの重要なビジネスデータの場合、エラーを明確に公開するために、require(...)を使ったカスタムsetterを使用してください。vetoableは、UIのスライダーから値を制限するなど、無効な値をスキップすることが望ましい動作である場合に適しています。
7. Delegateのテストを書く
以下のテストセクションは、3つの主な動作をテストします:lazyは1回だけ初期化され、observableは履歴を記録し、vetoableは無効な値を拒否します。
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
class DelegatedPropertiesTest {
class ExpensiveHolder(private val counter: AtomicInteger) {
val value: String by lazy {
counter.incrementAndGet()
Thread.sleep(50)
"ready"
}
}
@Test
fun `lazy chỉ chạy initializer một lần dù nhiều thread cùng truy cập`() {
val counter = AtomicInteger()
val holder = ExpensiveHolder(counter)
val executor = Executors.newFixedThreadPool(8)
repeat(8) { executor.submit { holder.value } }
executor.shutdown()
executor.awaitTermination(5, TimeUnit.SECONDS)
assertThat(holder.value).isEqualTo("ready")
assertThat(counter.get()).isEqualTo(1)
}
@Test
fun `observable ghi lại mọi lần đổi trạng thái`() {
val order = Order("ORD-002")
order.status = OrderStatus.PAID
order.status = OrderStatus.DELIVERED
assertThat(order.history).containsExactly(
"status: NEW -> PAID",
"status: PAID -> DELIVERED"
)
}
@Test
fun `vetoable giữ nguyên giá trị cũ khi bị từ chối`() {
val item = CartItem("SKU-7")
item.quantity = 10
item.quantity = -3
assertThat(item.quantity).isEqualTo(10)
}
}
最初のテストは、8つのスレッドが同時にvalueを読み込みます。デフォルトモードのSYNCHRONIZEDでは、counterは常に1です。LazyThreadSafetyMode.NONEに変更して数回実行すると、テストが失敗することがあります。これは、各モード間の違いを自分で検証するための迅速な方法です。
8. よくあるエラーと使用すべきでない場面
JPAエンティティでdelegateを使用する。 コンパイラが生成する実フィールドはstatus$delegateで、型はObservablePropertyであり、OrderStatusではありません。Hibernateはこのような型のフィールドを自動的にマップできません。エンティティでは、通常のフィールドにカスタムsetterまたはエンティティリスナーを使用してください。
lazyのラムダが参照を保持する。 ラムダは実行完了まで保持されます。大きなオブジェクトをキャプチャしていて、プロパティが読み込まれない場合、そのオブジェクトはメモリに保持されたままです。
initブロック内でlazyを早すぎるタイミングで読む。 ラムダが下で宣言されたプロパティを使用し、init内でlazyプロパティを読む場合、nullまたは0などの初期化されていない値を受け取る可能性があります。
フレームワークのメカニズムを置き換える。 Springでは、by lazyを使ってbeanを自分で作成したり、コネクションプールのライフサイクルを自分で管理しないでください。@LazyまたはObjectProviderを使用して、コンテナがリソースのクローズを管理できるようにしてください。
observable内の重い副作用。 コールバックはsetter内で同期的に実行されます。コールバック内でHTTPを呼び出したりデータベースに書き込んだりすると、単純な割り当てが遅くなりエラーが発生しやすくなります。その場で重い処理を行う代わりに、イベントを発行してください。
observableがスレッドセーフだと誤解する。 observableとvetoableには同期メカニズムがありません。複数のスレッドが同時に割り当てる場合、コールバックは不正確なoldを受け取る可能性があります。
9. まとめ
委譲プロパティにより、プロパティアクセスロジックをクラスから分離できるため、コードがより簡潔で読みやすくなります。作成にコストがかかる値にはby lazyを使用し、明確な理由がない限りデフォルトのSYNCHRONIZEDモードを保ちます。変更のたびに反応する必要がある場合はDelegates.observableを使用し、値が変わらない場合でもコールバックが実行されることを忘れずに。無効な値をスキップしたい場合はDelegates.vetoableを使用し、エラーを明確に報告する必要がある場合はsetterのrequireに切り替えてください。
getValueとsetValueを理解したら、文字列の正規化、Mapからの値の読み込み、またはアクセスログなど、独自のニーズに合わせてdelegateを作成できます。ただし、JPAエンティティなど、フレームワークが実フィールドを見る必要がある場所では使用しないようにしてください。