こんにちは。コミュニケーションアプリ「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
を取得するにはダウンキャストが必要になります。そして、そのダウンキャストを行うには、「FooProvider
は FooDataModel
を返却する」という制約を知っておく必要があります。しかし、その制約は型安全には実現されていないため、コード変更時のミスの原因になります。
更に言うと「1 つのプロバイダが 1 つのデータモデルに対応している」ことも、暗黙の制約に過ぎず、保証された動作ではありません。もし、FooDataModel
と BarDataModel
の両方を返しうるプロバイダが作成された場合、それが安全に取り扱えるかを確認することは容易ではないでしょう。
より一般的に言うと、 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