LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

コード品質向上のテクニック:第31回 同じ釜のプロパティ

こんにちは。コミュニケーションアプリ「LINE」のモバイルクライアントを開発している石川です。

この記事は、毎週木曜の定期連載 “Weekly Report” 共有の第 31 回です。Weekly Report については、第 1 回の記事を参照してください。

同じ釜のプロパティ

「緯度/経度」や「場所の ID」で位置情報を登録するサービスを実装しているとしましょう。この位置情報のデータモデルは GeoLocationPinModel というクラスで表現するとします。このとき、直和型を使うことで「位置情報は必ず緯度/経度か場所 ID のどちらか一方で示される」ことを型安全に保証できます。Kotlin や Java では、直和型は sealed classsealed interface で実現できます。(sealed の子クラスのプロパティを取得するためには、ダウンキャストが必要になりますが、このダウンキャストは問題ないことが多いです。一方で sealed 以外に対するダウンキャストは、できる限り避けたほうがよいです。)

以下の GeoLocationPinModelsealed 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
}

このデータモデルでは、LatLonPlace で共通するプロパティ (例: 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
}

しかし、この実装にはまだ改善の余地があります。それは何でしょうか?

共通のプロパティを抽出する

改善後のコードでは、共通のプロパティを追加・変更・削除しようとした場合に、GeoLocationPinModelLatLonPlace のすべてのクラスを更新しなけければなりません。また、LatLonPlace の定義が煩雑で、「何のプロパティを持つか」が一目ではわかりにくい点も問題です。

このようなときは、 共通のプロパティを直和型の外に抽出する ことで、より分かりやすい構造になる可能性があります。以下の実装では、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

コード品質向上のテクニックの他の記事を読む

コード品質向上のテクニックの記事一覧