LINEヤフー Tech Blog

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

This post is also available in the following languages. English

コード品質向上のテクニック:第13回 クローン家族

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

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

クローン家族

2つのデータモデル FooDataModel/BarDataModel と、それに対応するデータプロバイダ FooModelProvider/BarModelProvider があることを想定します。各データモデルは、OriginalData と呼ばれる共通オブジェクトから作られます。

以下の実装では、OriginalData の取得ロジックを共通化するために、ParentProvider を定義しています。また、継承で変える動作を最小限にするために、createModel はオーバーライド不能なメソッドとして定義し、OriginalData から各データモデルに変換するメソッド convert を抽象メソッドとして定義しています。

interface CommonDataModel
class FooDataModel(...): CommonDataModel
class BarDataModel(...): CommonDataModel

open class ParentProvider(...) {
    fun createModel(...): CommonDataModel {
       val originalData = getOriginalData(...)
       return convert(originalData)
    }

    protected abstract fun convert(originalData: OriginalData): CommonDataModel

    private fun getOriginalData(): OriginalData { ... }
}

class FooProvider(...): ParentProvider(...) {
    override fun convert(originalData: OriginalData): FooDataModel = ...
}

class BarProvider(...): ParentProvider(...) {
    override fun convert(originalData: OriginalData): BarDataModel = ...
}

この構造でなにか問題点はありますか?

どちらの家族か

FooDataModel のインスタンスを取得するコードは、以下のようになります。

val fooProvider = FooProvider(...)
...
val fooDataModel = fooProvider.createModel() as FooDataModel

このコードは、型安全性に関する問題を含んでいます。

createModel の戻り値型は CommonDataModel であるため、FooDataModel を取得するにはダウンキャストが必要になります。そして、そのダウンキャストを行うには、「FooProviderFooDataModel を返却する」という制約を知っておく必要があります。しかし、その制約は型安全には実現されていないため、コード変更時のミスの原因になります。

更に言うと「1 つのプロバイダが 1 つのデータモデルに対応している」ことも、暗黙の制約に過ぎず、保証された動作ではありません。もし、FooDataModelBarDataModel の両方を返しうるプロバイダが作成された場合、それが安全に取り扱えるかを確認することは容易ではないでしょう。

より一般的に言うと、 2 つの継承ツリーがあり、個々のクラスはツリーをまたがって対応関係があるものの、その対応関係は暗黙的である ことが問題になっています。この場合は、以下のような解決案が考えられます。

  • 継承を集約やコンポジションで置き換える
  • パラメトリック多相を利用する

解決案 1: 集約やコンポジションで置き換える

ParentProvider では、継承をコードの共通化のためだけに使っています。しかし、コードの共通化だけが目的の場合、継承よりもコンポジションや集約のほうが適切なことが多いです。以下のコードでは、OriginalData の取得ロジックを OriginalDataProvider として抽出し、各プロバイダは OriginalDataProvider のインスタンスをプロパティとして保持しています。

class FooDataModel(...) // No parent type
class BarDataModel(...) // No parent type

class FooProvider(...) { // No parent type
    private val originalDataProvider: OriginalDataProvider = ...

    fun createModel(...): FooDataModel {
        val originalData = originalDataProvider.create(...)
        ...
    }
}

class BarProvider(...) { // No parent type
    private val originalDataProvider: OriginalDataProvider = ...

    fun createModel(...): BarDataModel {
        val originalData = originalDataProvider.create(...)
        ...
    }
}

class OriginalDataProvider(...) {
    fun create(...): OriginalData { ... }
}

このようにすることで、ダウンキャストなしに各データモデルを取得できます。

val fooProvider = FooProvider(...)
...
val fooDataModel: FooDataModel = fooProvider.createModel()

解決案 2: パラメトリック多相を使う

多相性が必要な状況などで、親クラスが必要になることもあります。例えば、複数のプロバイダのライフサイクルを管理するために、単一のコレクションにまとめる場合などが当てはまります。このようなときは、OriginalData の取得ロジックを親クラスに持たせるのは自然な実装といえます。

しかし、プロバイダに継承関係が必要だとしても、データモデルまでその関係を持ち込む必要はありません。 戻り値の型の決定の責任を、各プロバイダに行わせることで、CommonDataModel を作る必要はなくなります。これを実現する方法の 1 つとして、ジェネリクスなどの「パラメトリック多相」があります。以下のコードでは、createModel の戻り値をタイプパラメータ T を使って定義することで、各プロバイダが個別のデータモデルを指定できるようにしています。

class FooDataModel(...) // No parent type
class BarDataModel(...) // No parent type

open class ParentProvider<T>(...) {
    fun createModel(...): T {
       val originalData = getOriginalData(...)
       return convert(originalData)
    }

    protected abstract fun convert(originalData: OriginalData): T

    private fun getOriginalData(): OriginalData { ... }
}


class FooProvider(...): ParentProvider<FooDataModel>(...) {
    override fun convert(originalData: OriginalData): FooDataModel = ...
}

class BarProvider(...): ParentProvider<BarDataModel>(...) {
    override fun convert(originalData: OriginalData): BarDataModel = ...
}

このようにすることで、親のプロバイダ ParentProvider を定義しつつ、各プロバイダは個別のデータモデルを指定することができます。

val fooProvider = FooProvider(...)
...
val fooDataModel: FooDataModel = fooProvider.createModel()

// Also, up-casting is valid
val barProvider: ParentProvider<BarDataModel> = BarProvider(...)

// We may create a list of ParentProvider<*>.
val providers = listOf(fooProvider, barProvider)

もし、データモデルの親クラス CommonDataModel を定義する必要がある場合は、タイプパラメータの上界を定義するとよいでしょう。例えば、open class ParentProvider<T : CommonDataModel> のように定義できます。


一言まとめ

2つの継承ツリー間での暗黙の対応関係を避けるために、継承をコンポジションや集約に置き換えたり、パラメトリック多相を利用する。

キーワード: type safeness, inheritance tree, implicit correspondence

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

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