中級: プロパティ
初級ツアーでは、プロパティがクラスインスタンスの特性を宣言するためにどのように使用され、それらにアクセスする方法について学びました。この章では、Kotlinでのプロパティの仕組みをさらに深く掘り下げ、コードでそれらを使用する他の方法を探ります。
バッキングフィールド
Kotlinでは、プロパティにはデフォルトのget()
関数とset()
関数があり、これらはプロパティアクセサーとして知られ、値の取得と変更を扱います。これらのデフォルト関数はコード内で明示的に表示されませんが、コンパイラはプロパティへのアクセスをバックグラウンドで管理するために自動的にそれらを生成します。これらのアクセサーは、実際のプロパティ値を格納するためにバッキングフィールドを使用します。
バッキングフィールドは、以下のいずれかの条件が真の場合に存在します。
- そのプロパティにデフォルトの
get()
またはset()
関数を使用する場合。 - コード内で
field
キーワードを使用してプロパティ値にアクセスしようとする場合。
TIP
get()
関数とset()
関数は、ゲッターとセッターとも呼ばれます。
例えば、このコードにはカスタムのget()
関数やset()
関数を持たず、したがってデフォルトの実装を使用するcategory
プロパティがあります。
class Contact(val id: Int, var email: String) {
val category: String = ""
}
内部的には、これはこの擬似コードに相当します。
class Contact(val id: Int, var email: String) {
val category: String = ""
get() = field
set(value) {
field = value
}
}
この例では:
get()
関数はフィールドからプロパティ値""
を取得します。set()
関数はvalue
をパラメータとして受け取り、それをフィールドに割り当てます。ここでvalue
は""
です。
バッキングフィールドへのアクセスは、無限ループを引き起こすことなく、get()
関数またはset()
関数に追加のロジックを追加したい場合に役立ちます。例えば、name
プロパティを持つPerson
クラスがあるとします。
class Person {
var name: String = ""
}
name
プロパティの最初の文字が大文字であることを保証したいとします。そこで、.replaceFirstChar()
と.uppercase()
拡張関数を使用するカスタムset()
関数を作成します。しかし、set()
関数内でプロパティを直接参照すると、無限ループが発生し、実行時にStackOverflowError
が発生します。
class Person {
var name: String = ""
set(value) {
// This causes a runtime error
name = value.replaceFirstChar { firstChar -> firstChar.uppercase() }
}
}
fun main() {
val person = Person()
person.name = "kodee"
println(person.name)
// Exception in thread "main" java.lang.StackOverflowError
}
これを修正するには、field
キーワードで参照することにより、代わりにset()
関数でバッキングフィールドを使用できます。
class Person {
var name: String = ""
set(value) {
field = value.replaceFirstChar { firstChar -> firstChar.uppercase() }
}
}
fun main() {
val person = Person()
person.name = "kodee"
println(person.name)
// Kodee
}
バッキングフィールドは、ロギングを追加したり、プロパティ値が変更されたときに通知を送信したり、古いプロパティ値と新しいプロパティ値を比較する追加のロジックを使用したりする場合にも役立ちます。
詳細については、「バッキングフィールド」を参照してください。
拡張プロパティ
拡張関数と同様に、拡張プロパティもあります。拡張プロパティを使用すると、既存のクラスのソースコードを変更することなく、新しいプロパティを追加できます。しかし、Kotlinの拡張プロパティはバッキングフィールドを持ちません。これは、get()
関数とset()
関数を自分で記述する必要があることを意味します。さらに、バッキングフィールドがないということは、状態を保持できないことを意味します。
拡張プロパティを宣言するには、拡張したいクラス名に続けて.
とプロパティ名を記述します。通常のクラスプロパティと同様に、プロパティのレシーバー型を宣言する必要があります。例えば:
val String.lastChar: Char
拡張プロパティは、継承を使用せずに、プロパティに計算された値を含ませたい場合に最も役立ちます。拡張プロパティは、レシーバーオブジェクトという1つのパラメータのみを持つ関数のように機能すると考えることができます。
例えば、firstName
とlastName
という2つのプロパティを持つPerson
というデータクラスがあるとします。
data class Person(val firstName: String, val lastName: String)
Person
データクラスを変更したり、そこから継承したりすることなく、人のフルネームにアクセスできるようにしたいとします。これを行うには、カスタムget()
関数を持つ拡張プロパティを作成します。
data class Person(val firstName: String, val lastName: String)
// フルネームを取得するための拡張プロパティ
val Person.fullName: String
get() = "$firstName $lastName"
fun main() {
val person = Person(firstName = "John", lastName = "Doe")
// 拡張プロパティを使用する
println(person.fullName)
// John Doe
}
NOTE
拡張プロパティは、クラスの既存のプロパティをオーバーライドできません。
拡張関数と同様に、Kotlin標準ライブラリは拡張プロパティを幅広く使用しています。例えば、CharSequence
のlastIndex
プロパティを参照してください。
委譲プロパティ
クラスとインターフェースの章で、委譲についてすでに学びました。プロパティでも委譲を使用でき、そのプロパティアクセサーを別のオブジェクトに委譲できます。これは、データベーステーブル、ブラウザセッション、マップなどに値を格納するなど、単純なバッキングフィールドでは処理できない複雑なプロパティの格納要件がある場合に役立ちます。委譲プロパティを使用すると、プロパティの取得と設定のロジックが委譲先のオブジェクトにのみ含まれるため、ボイラープレートコードも削減されます。
構文はクラスでの委譲の使用に似ていますが、異なるレベルで動作します。プロパティを宣言し、その後にby
キーワードと委譲したいオブジェクトを続けます。例えば:
val displayName: String by Delegate
ここで、委譲プロパティdisplayName
は、そのプロパティアクセサーのためにDelegate
オブジェクトを参照します。
委譲するすべてのオブジェクトは、Kotlinが委譲プロパティの値を取得するために使用するgetValue()
演算子関数を持たなければなりません。プロパティが可変の場合、Kotlinがその値を設定するためにsetValue()
演算子関数も持たなければなりません。
デフォルトでは、getValue()
関数とsetValue()
関数は以下の構造を持っています。
operator fun getValue(thisRef: Any?, property: KProperty<*>): String {}
operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {}
これらの関数では:
operator
キーワードは、これらの関数を演算子関数としてマークし、get()
関数とset()
関数をオーバーロードできるようにします。thisRef
パラメータは、委譲プロパティを含むオブジェクトを参照します。デフォルトでは型はAny?
に設定されていますが、より具体的な型を宣言する必要がある場合があります。property
パラメータは、値がアクセスまたは変更されるプロパティを参照します。このパラメータを使用して、プロパティの名前や型などの情報にアクセスできます。デフォルトでは型はAny?
に設定されています。コードでこれを変更することを心配する必要はありません。
getValue()
関数の戻り値の型はデフォルトでString
ですが、必要に応じてこれを調整できます。
setValue()
関数には追加のパラメータvalue
があり、これはプロパティに割り当てられる新しい値を保持するために使用されます。
では、これは実際にどのように見えるでしょうか?ユーザーの表示名のような計算されたプロパティを持ちたいとします。この操作はコストが高く、アプリケーションはパフォーマンスに敏感であるため、一度だけ計算されるようにします。委譲プロパティを使用して表示名をキャッシュすることで、一度だけ計算され、パフォーマンスに影響を与えることなくいつでもアクセスできるようにすることができます。
まず、委譲先のオブジェクトを作成する必要があります。この場合、オブジェクトはCachedStringDelegate
クラスのインスタンスになります。
class CachedStringDelegate {
var cachedValue: String? = null
}
cachedValue
プロパティにはキャッシュされた値が含まれます。CachedStringDelegate
クラス内で、委譲プロパティのget()
関数から望む動作をgetValue()
演算子関数の本体に追加します。
class CachedStringDelegate {
var cachedValue: String? = null
operator fun getValue(thisRef: Any?, property: Any?): String {
if (cachedValue == null) {
cachedValue = "Default Value"
println("Computed and cached: $cachedValue")
} else {
println("Accessed from cache: $cachedValue")
}
return cachedValue ?: "Unknown"
}
}
getValue()
関数は、cachedValue
プロパティがnull
であるかどうかをチェックします。null
の場合、関数は"Default value"
を割り当て、ロギングのために文字列を出力します。cachedValue
プロパティがすでに計算されている場合、そのプロパティはnull
ではありません。この場合、ロギングのために別の文字列が出力されます。最後に、関数はエルビス演算子を使用して、キャッシュされた値を返すか、値がnull
の場合は"Unknown"
を返します。
これで、キャッシュしたいプロパティ(val displayName
)をCachedStringDelegate
クラスのインスタンスに委譲できます。
class CachedStringDelegate {
var cachedValue: String? = null
operator fun getValue(thisRef: User, property: Any?): String {
if (cachedValue == null) {
cachedValue = "${thisRef.firstName} ${thisRef.lastName}"
println("Computed and cached: $cachedValue")
} else {
println("Accessed from cache: $cachedValue")
}
return cachedValue ?: "Unknown"
}
}
class User(val firstName: String, val lastName: String) {
val displayName: String by CachedStringDelegate()
}
fun main() {
val user = User("John", "Doe")
// 最初のアクセスで値を計算し、キャッシュする
println(user.displayName)
// Computed and cached: John Doe
// John Doe
// 以降のアクセスではキャッシュから値を取得する
println(user.displayName)
// Accessed from cache: John Doe
// John Doe
}
この例では:
- ヘッダーに
firstName
とlastName
の2つのプロパティを、クラス本体にdisplayName
という1つのプロパティを持つUser
クラスを作成します。 displayName
プロパティをCachedStringDelegate
クラスのインスタンスに委譲します。user
というUser
クラスのインスタンスを作成します。user
インスタンスのdisplayName
プロパティにアクセスした結果を出力します。
なお、getValue()
関数では、thisRef
パラメータの型がAny?
型からオブジェクト型であるUser
に絞られています。これは、コンパイラがUser
クラスのfirstName
およびlastName
プロパティにアクセスできるようにするためです。
標準の委譲
Kotlin標準ライブラリは、便利なデリゲートをいくつか提供しているため、常にゼロから作成する必要はありません。これらのデリゲートのいずれかを使用する場合、標準ライブラリが自動的にgetValue()
関数とsetValue()
関数を提供するため、それらを定義する必要はありません。
遅延プロパティ
プロパティが最初にアクセスされたときにのみ初期化するには、遅延プロパティを使用します。標準ライブラリは委譲のためにLazy
インターフェースを提供しています。
Lazy
インターフェースのインスタンスを作成するには、lazy()
関数を使用し、get()
関数が初めて呼び出されたときに実行するラムダ式を渡します。get()
関数のそれ以降の呼び出しは、最初の呼び出しで提供されたものと同じ結果を返します。遅延プロパティは、ラムダ式を渡すために末尾ラムダ構文を使用します。
例えば:
class Database {
fun connect() {
println("Connecting to the database...")
}
fun query(sql: String): List<String> {
return listOf("Data1", "Data2", "Data3")
}
}
val databaseConnection: Database by lazy {
val db = Database()
db.connect()
db
}
fun fetchData() {
val data = databaseConnection.query("SELECT * FROM data")
println("Data: $data")
}
fun main() {
// databaseConnection に初めてアクセスする
fetchData()
// Connecting to the database...
// Data: [Data1, Data2, Data3]
// 以降のアクセスでは既存の接続を使用する
fetchData()
// Data: [Data1, Data2, Data3]
}
この例では:
connect()
関数とquery()
メンバー関数を持つDatabase
クラスがあります。connect()
関数はコンソールに文字列を出力し、query()
関数はSQLクエリを受け取ってリストを返します。databaseConnection
プロパティは遅延プロパティです。lazy()
関数に提供されるラムダ式は:Database
クラスのインスタンスを作成します。- このインスタンス(
db
)上でconnect()
メンバー関数を呼び出します。 - インスタンスを返します。
fetchData()
関数は:databaseConnection
プロパティ上でquery()
関数を呼び出すことにより、SQLクエリを作成します。- SQLクエリを
data
変数に割り当てます。 data
変数をコンソールに出力します。
main()
関数はfetchData()
関数を呼び出します。最初に呼び出されると、遅延プロパティが初期化されます。2回目には、最初の呼び出しと同じ結果が返されます。
遅延プロパティは、初期化がリソースを大量に消費する場合だけでなく、プロパティがコード内で使用されない可能性がある場合にも役立ちます。さらに、遅延プロパティはデフォルトでスレッドセーフであり、これは並行環境で作業している場合に特に有利です。
詳細については、「遅延プロパティ」を参照してください。
監視可能プロパティ
プロパティの値が変更されたかどうかを監視するには、監視可能プロパティを使用します。監視可能プロパティは、プロパティ値の変更を検出し、この知識を使用して反応をトリガーしたい場合に役立ちます。標準ライブラリは委譲のためにDelegates
オブジェクトを提供しています。
監視可能プロパティを作成するには、まずkotlin.properties.Delegates.observable
をインポートする必要があります。次に、observable()
関数を使用し、プロパティが変更されるたびに実行するラムダ式を渡します。遅延プロパティと同様に、監視可能プロパティもラムダ式を渡すために末尾ラムダ構文を使用します。
例えば:
import kotlin.properties.Delegates.observable
class Thermostat {
var temperature: Double by observable(20.0) { _, old, new ->
if (new > 25) {
println("Warning: Temperature is too high! ($old°C -> $new°C)")
} else {
println("Temperature updated: $old°C -> $new°C")
}
}
}
fun main() {
val thermostat = Thermostat()
thermostat.temperature = 22.5
// Temperature updated: 20.0°C -> 22.5°C
thermostat.temperature = 27.0
// Warning: Temperature is too high! (22.5°C -> 27.0°C)
}
この例では:
- 監視可能プロパティ
temperature
を含むThermostat
クラスがあります。 observable()
関数は20.0
をパラメータとして受け取り、それを使用してプロパティを初期化します。observable()
関数に提供されるラムダ式は:- 3つのパラメータを持ちます:
_
はプロパティ自体を参照します。old
はプロパティの古い値です。new
はプロパティの新しい値です。
new
パラメータが25
より大きいかどうかをチェックし、結果に応じて文字列をコンソールに出力します。
- 3つのパラメータを持ちます:
main()
関数は:thermostat
というThermostat
クラスのインスタンスを作成します。- インスタンスの
temperature
プロパティの値を22.5
に更新します。これにより、温度更新のプリント文がトリガーされます。 - インスタンスの
temperature
プロパティの値を27.0
に更新します。これにより、警告のプリント文がトリガーされます。
監視可能プロパティは、ロギングやデバッグの目的だけでなく、UIの更新や、データの有効性の検証のような追加のチェックを実行するユースケースにも使用できます。
詳細については、「監視可能プロパティ」を参照してください。
練習問題
演習1
あなたは書店の在庫管理システムを管理しています。在庫はリストとして保存されており、各項目は特定の本の数量を表します。例えば、listOf(3, 0, 7, 12)
は、店に最初の本が3冊、2番目の本が0冊、3番目の本が7冊、4番目の本が12冊あることを意味します。
在庫切れの本すべてのインデックスのリストを返すfindOutOfStockBooks()
という関数を記述してください。
ヒント1
標準ライブラリのindices
拡張プロパティを使用してください。
ヒント2
手動で可変リストを作成して返す代わりに、buildList()
関数を使用してリストを作成および管理できます。buildList()
関数は、以前の章で学んだレシーバーを持つラムダを使用します。
|--|--|
fun findOutOfStockBooks(inventory: List<Int>): List<Int> {
// ここにコードを記述してください
}
fun main() {
val inventory = listOf(3, 0, 7, 0, 5)
println(findOutOfStockBooks(inventory))
// [1, 3]
}
|---|---|
fun findOutOfStockBooks(inventory: List<Int>): List<Int> {
val outOfStockIndices = mutableListOf<Int>()
for (index in inventory.indices) {
if (inventory[index] == 0) {
outOfStockIndices.add(index)
}
}
return outOfStockIndices
}
fun main() {
val inventory = listOf(3, 0, 7, 0, 5)
println(findOutOfStockBooks(inventory))
// [1, 3]
}
|---|---|
fun findOutOfStockBooks(inventory: List<Int>): List<Int> = buildList {
for (index in inventory.indices) {
if (inventory[index] == 0) {
add(index)
}
}
}
fun main() {
val inventory = listOf(3, 0, 7, 0, 5)
println(findOutOfStockBooks(inventory))
// [1, 3]
}
演習2
キロメートルとマイルの両方で距離を表示する必要がある旅行アプリがあります。キロメートル単位の距離をマイルに変換するための、Double
型のasMiles
という拡張プロパティを作成してください。
NOTE
キロメートルをマイルに変換する式は、miles = kilometers * 0.621371
です。
ヒント
拡張プロパティにはカスタムget()
関数が必要であることを忘れないでください。
|---|---|
val // ここにコードを記述してください
fun main() {
val distanceKm = 5.0
println("$distanceKm km is ${distanceKm.asMiles} miles")
// 5.0 km is 3.106855 miles
val marathonDistance = 42.195
println("$marathonDistance km is ${marathonDistance.asMiles} miles")
// 42.195 km is 26.218757 miles
}
|---|---|
val Double.asMiles: Double
get() = this * 0.621371
fun main() {
val distanceKm = 5.0
println("$distanceKm km is ${distanceKm.asMiles} miles")
// 5.0 km is 3.106855 miles
val marathonDistance = 42.195
println("$marathonDistance km is ${marathonDistance.asMiles} miles")
// 42.195 km is 26.218757 miles
}
演習3
あなたはクラウドシステムの健全性を判断できるシステムヘルスチェッカーを持っています。しかし、ヘルスチェックを実行できる2つの関数はパフォーマンスを大量に消費します。コストのかかる関数が必要なときにのみ実行されるように、遅延プロパティを使用してチェックを初期化してください。
|---|---|
fun checkAppServer(): Boolean {
println("Performing application server health check...")
return true
}
fun checkDatabase(): Boolean {
println("Performing database health check...")
return false
}
fun main() {
// ここにコードを記述してください
when {
isAppServerHealthy -> println("Application server is online and healthy")
isDatabaseHealthy -> println("Database is healthy")
else -> println("System is offline")
}
// Performing application server health check...
// Application server is online and healthy
}
|---|---|
fun checkAppServer(): Boolean {
println("Performing application server health check...")
return true
}
fun checkDatabase(): Boolean {
println("Performing database health check...")
return false
}
fun main() {
val isAppServerHealthy by lazy { checkAppServer() }
val isDatabaseHealthy by lazy { checkDatabase() }
when {
isAppServerHealthy -> println("Application server is online and healthy")
isDatabaseHealthy -> println("Database is healthy")
else -> println("System is offline")
}
// Performing application server health check...
// Application server is online and healthy
}
演習4
あなたはシンプルな家計管理アプリを構築しています。このアプリは、ユーザーの残り予算の変化を監視し、特定のしきい値を下回るたびに通知する必要があります。Budget
クラスがあり、初期予算額を含むtotalBudget
プロパティで初期化されます。クラス内に、remainingBudget
という監視可能プロパティを作成し、以下を出力するようにしてください。
- 値が初期予算の20%未満になったときに警告。
- 予算が以前の値から増加したときに励ましのメッセージ。
|---|---|
import kotlin.properties.Delegates.observable
class Budget(val totalBudget: Int) {
var remainingBudget: Int // ここにコードを記述してください
}
fun main() {
val myBudget = Budget(totalBudget = 1000)
myBudget.remainingBudget = 800
myBudget.remainingBudget = 150
// Warning: Your remaining budget (150) is below 20% of your total budget.
myBudget.remainingBudget = 50
// Warning: Your remaining budget (50) is below 20% of your total budget.
myBudget.remainingBudget = 300
// Good news: Your remaining budget increased to 300.
}
|---|---|
import kotlin.properties.Delegates.observable
class Budget(val totalBudget: Int) {
var remainingBudget: Int by observable(totalBudget) { _, oldValue, newValue ->
if (newValue < totalBudget * 0.2) {
println("Warning: Your remaining budget ($newValue) is below 20% of your total budget.")
} else if (newValue > oldValue) {
println("Good news: Your remaining budget increased to $newValue.")
}
}
}
fun main() {
val myBudget = Budget(totalBudget = 1000)
myBudget.remainingBudget = 800
myBudget.remainingBudget = 150
// Warning: Your remaining budget (150) is below 20% of your total budget.
myBudget.remainingBudget = 50
// Warning: Your remaining budget (50) is below 20% of your total budget.
myBudget.remainingBudget = 300
// Good news: Your remaining budget increased to 300.
}