こんにちは。コミュニケーションアプリ「LINE」のモバイルクライアントを開発している石川です。
この記事は、毎週木曜の定期連載 “Weekly Report” 共有の第 31 回です。Weekly Report については、第 1 回の記事を参照してください。
同じ釜のプロパティ
「緯度/経度」や「場所の ID」で位置情報を登録するサービスを実装しているとしましょう。この位置情報のデータモデルは GeoLocationPinModel
というクラスで表現するとします。このとき、直和型を使うことで「位置情報は必ず緯度/経度か場所 ID のどちらか一方で示される」ことを型安全に保証できます。Kotlin や Java では、直和型は sealed class
や sealed interface
で実現できます。(sealed
の子クラスのプロパティを取得するためには、ダウンキャストが必要になりますが、このダウンキャストは問題ないことが多いです。一方で sealed
以外に対するダウンキャストは、できる限り避けたほうがよいです。)
以下の GeoLocationPinModel
は sealed interface
として実装されていますが、プロパティの構造が適切であるとは言えません。
sealed interface GeoLocationPinModel {
data class LatLon(
val userId: ULong,
val timestampInMillis: Long,
val comment: String,
val latitudeE6: Long,
val longitudeE6: Long,
) : GeoLocationPinModel
data class Place(
val userId: ULong,
val timestampInMillis: Long,
val comment: String,
val placeId: ULong,
) : GeoLocationPinModel
}
このデータモデルでは、LatLon
と Place
で共通するプロパティ (例: userId
) を取得するためにもダウンキャストが必要です。そのため、共通のプロパティを取得するコードが煩雑になってしまいます。
fun caller(pinModel: GeoLocationPinModel) {
val userId: ULong
val creationTimestampInMillis: Long
when (pinModel) {
is GeoLocationPinModel.LatLon -> {
userId = pinModel.userId
creationTimestampInMillis = pinModel.timestampInMillis
}
is GeoLocationPinModel.Place -> {
userId = pinModel.userId
creationTimestampInMillis = pinModel.timestampInMillis
}
}
... // snip
}
そこで以下ように、共通のプロパティを親クラスに抽出することで、ダウンキ ャストなしで取得できるよう改善を試みました。
sealed class GeoLocationPinModel(
open val userId: ULong,
open val timestampInMillis: Long,
open val comment: String,
) {
data class LatLon(
override val userId: ULong,
override val timestampInMillis: Long,
override val comment: String,
val latitudeE6: Long,
val longitudeE6: Long,
) : GeoLocationPinModel(userId, timestampInMillis, comment)
data class Place(
override val userId: ULong,
override val timestampInMillis: Long,
override val comment: String,
val placeId: ULong,
) : GeoLocationPinModel(userId, timestampInMillis, comment)
}
fun caller(pinModel: GeoLocationPinModel) {
val userId = pinModel.userId
val creationTimestampInMillis = pinModel.timestampInMillis
... // snip
}
しかし、この実装にはまだ改善の余地があります。それは何でしょうか?
共通のプロパティを抽出する
改善後のコードでは、共通のプロパティを追加・変更・削除しようとした場合に、GeoLocationPinModel
、LatLon
、Place
のすべてのクラスを更新しなけければなりません。また、LatLon
や Place
の定義が煩雑で、「何のプロパティを持つか」が一目ではわかりにくい点も問題です。
このようなときは、 共通のプロパティを直和型の外に抽出する ことで、より分かりやすい構造になる可能性があります。以下の実装では、sealed interface
には共通のプロパティを持たせず、sealed
でないクラスに共通のプロパティを持たせています。userId
などの共通のプロパティを使いたいときでも、GeoLocationPinModel
の定義を見れば十分に理解できるようになります。
sealed interface GeoLocation {
data class LatLon(val latitudeE6: Long, val longitudeE6: Long) : GeoLocation
data class Place(val placeId: ULong) : GeoLocation
}
data class GeoLocationPinModel(
val userId: ULong,
val timestampInMillis: Long,
val comment: String,
val location : GeoLocation
)
直和型から共通のプロパティを分離することには、以下のような利点があります。
- どれが共通のプロパティで、どれが型固有のプロパティであるかが明確になる
- 共通のプロパティを追加・変更・削除する際のコード変更量が減る
- (
sealed interface
の場合) 共通のプロパティを親に持たせないことで、computed property でな いことを保証できる
もちろん、プロパティの抽出が不要なケースもあります。型固有のプロパティが重要で、共通のプロパティは付随的に過ぎない場合などが当てはまるでしょう。抽出する・しないコードを見比べ、どちらがより分かりやすく取り扱いやすいかを検討してください。
構造的部分型付けの場合
TypeScript のような構造的部分型付けの言語では、以下のように、共通のプロパティを直接取得することができます。そのため、共通のプロパティを抽出する利点は、比較的小さくなります。
type GeoLocationPinModel =
| {
readonly type: 'latlon';
readonly userId: number;
readonly latitude: number;
readonly longitude: number;
}
| {
readonly type: 'place_id';
readonly userId: number;
readonly placeId: number;
};
const caller = (pinModel: GeoLocationPinModel) => {
// `.userId` is accessible because it's a common property.
const userId = pinModel.userId;
if (pinModel.type === 'place_id') {
// `.placeId` is accessible by type-guard.
const placeId = pinModel.placeId;
}
};
しかし依然として、共通のプロパティを抽出することには、以下の 2 つの利点があります。
- どれが共通のプロパティで、どれが型固有のプロパティであるかが明確になる
- 共通のプロパティを追加・変更・削除する際のコード変更量が減る
構造的部分型付けの言語でも、共通のプロパティを抽出するか否かでどのようなメリット・デメリットがあるのか、コードを比較することが重要です。
一言まとめ
直和型に共通するプロパティを 抽出することで、より分かりやすい構造にできることがある。
キーワード: sum type
, sealed class
, common property