中級: プロパティ
初心者向けツアーでは、プロパティがクラスインスタンスの特性を宣言し、それにアクセスする方法としてどのように使用されるかを学びました。この章では、Kotlinでのプロパティの動作を深く掘り下げ、コード内でプロパティを使用する他の方法を探ります。
バッキングフィールド
Kotlinでは、プロパティはデフォルトのget()関数とset()関数(プロパティアクセサーとして知られている)を持ち、それらの値の取得と変更を処理します。これらのデフォルト関数はコード上では明示的に見えませんが、コンパイラは舞台裏でプロパティアクセスを管理するためにそれらを自動的に生成します。これらのアクセサーは、実際のプロパティ値を格納するためにバッキングフィールドを使用します。
バッキングフィールドは、以下のいずれかの条件が真の場合に存在します。
- プロパティのデフォルトの
get()関数またはset()関数を使用する場合。 fieldキーワードを使用してコード内でプロパティ値にアクセスしようとする場合。
get()関数とset()関数は、ゲッターとセッターとも呼ばれます。
例えば、このコードにはカスタムのget()関数やset()関数を持たないcategoryプロパティがあり、そのためデフォルトの実装を使用しています。
class Contact(val id: Int, var email: String) {
var category: String = ""
}内部的には、これは以下の擬似コードと同等です。
class Contact(val id: Int, var email: String) {
var 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)
// Extension property to get the full name
val Person.fullName: String
get() = "$firstName $lastName"
fun main() {
val person = Person(firstName = "John", lastName = "Doe")
// Use the extension property
println(person.fullName)
// John Doe
}拡張プロパティは、クラスの既存のプロパティをオーバーライドすることはできません。
拡張関数と同様に、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パラメータは、値にアクセスまたは変更されるプロパティを参照します。このパラメータを使用して、プロパティの名前や型などの情報にアクセスできます。デフォルトでは型はKProperty<*>に設定されていますが、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ではありません。この場合、ロギングのために別の文字列が出力されます。最後に、関数はElvis演算子を使用して、キャッシュされた値、または値が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")
// First access computes and caches the value
println(user.displayName)
// Computed and cached: John Doe
// John Doe
// Subsequent accesses retrieve the value from cache
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() {
// First time accessing databaseConnection
fetchData()
// Connecting to the database...
// Data: [Data1, Data2, Data3]
// Subsequent access uses the existing connection
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()関数に提供されるラムダ式は次のとおりです:_:プロパティ自体を参照します。old:プロパティの古い値です。new:プロパティの新しい値です。newパラメータが25より大きいかどうかをチェックし、結果に応じて文字列をコンソールに出力します。
main()関数は次のとおりです:thermostatというThermostatクラスのインスタンスを作成します。- インスタンスの
temperatureプロパティの値を22.5に更新します。これにより、温度更新を伴うprint文がトリガーされます。 - インスタンスの
temperatureプロパティの値を27.0に更新します。これにより、警告を伴うprint文がトリガーされます。
可観測プロパティは、ロギングやデバッグ目的だけでなく、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> {
// Write your code here
}
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]
}解答例2
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という拡張プロパティを作成してください。
キロメートルをマイルに変換する式は、
miles = kilometers * 0.621371です。
ヒント
get()関数が必要であることを覚えておいてください。 val // Write your code here
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() {
// Write your code here
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
あなたはシンプルな予算追跡アプリを構築しています。このアプリは、ユーザーの残りの予算の変更を監視し、特定のしきい値を下回るたびにユーザーに通知する必要があります。あなたは、初期予算額を含むtotalBudgetプロパティで初期化されるBudgetクラスを持っています。クラス内に、remainingBudgetという可観測プロパティを作成し、以下を出力するようにしてください:
- 値が初期予算の20%未満の場合に警告。
- 予算が前の値から増加した場合に励ましのメッセージ。
import kotlin.properties.Delegates.observable
class Budget(val totalBudget: Int) {
var remainingBudget: Int // Write your code here
}
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.
}