Hello, I'm Munetoshi Ishikawa, a mobile client developer for the LINE messaging app.
This article is the latest installment of our weekly series "Improving code quality". For more information about the Weekly Report, please see the first article.
Clone family
Assume there are two data models, FooDataModel
and BarDataModel
, and their corresponding data providers, FooModelProvider
and BarModelProvider
. Each data model is created from a common object called OriginalData
.
In the following implementation, ParentProvider
is defined to standardize the logic for obtaining OriginalData
. To minimize changes in behavior through inheritance, createModel
is defined as a non-overridable method, and the method convert
that converts OriginalData
to each data model is defined as an abstract method.
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 = ...
}
Are there any issues with this structure?
Which family?
The code to obtain an instance of FooDataModel
is as follows:
val fooProvider = FooProvider(...)
...
val fooDataModel = fooProvider.createModel() as FooDataModel
This code contains a type safety issue.
Since the return type of createModel
is CommonDataModel
, downcasting is necessary to obtain FooDataModel
. To perform this downcast, you need to know the constraint that "FooProvider returns FooDataModel". However, this constraint is not type-safe, which can lead to errors when changing the code.
Furthermore, the fact that "one provider corresponds to one data model" is only an implicit constraint and not a guaranteed behavior. If a provider that can return both FooDataModel
and BarDataModel
is created, it would not be easy to handle it safely.
More generally, the problem is that there are two inheritance trees, and although individual classes correspond across the trees, this correspondence is implicit. In this case, the following solutions can be considered:
- Replace inheritance with aggregation or composition
- Use parametric polymorphism
Solution 1: Replace with aggregation or composition
In ParentProvider
, inheritance is used only to standardize the code. However, if the sole purpose is to standardize the code, aggregation or composition is often more appropriate than inheritance. In the following code, the logic for obtaining OriginalData
is extracted as OriginalDataProvider
, and each provider holds an instance of OriginalDataProvider
as a property.
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 { ... }
}
By doing this, you can obtain each data model without downcasting.
val fooProvider = FooProvider(...)
...
val fooDataModel: FooDataModel = fooProvider.createModel()
Solution 2: Use parametric polymorphism
There are situations where polymorphism is necessary, such as when you need a parent class to manage the lifecycle of multiple providers in a single collection. In such cases, it is natural to have the logic for obtaining OriginalData
in the parent class.
However, even if a parent-child relationship is necessary for providers, it is not necessary to extend this relationship to data models. By making each provider responsible for determining the return type, you can eliminate the need for CommonDataModel
. One way to achieve this is by using generics, or "parametric polymorphism". In the following code, the return type of createModel
is defined using the type parameter T
, allowing each provider to specify its own data model.
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 = ...
}
By doing this, you can define the parent provider ParentProvider
while allowing each provider to specify its own data model.
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)
If you need to define a parent class for data models, CommonDataModel
, you can define the upper bound of the type parameter. For example, you can define it as open class ParentProvider<T : CommonDataModel>
.
Summary
To avoid implicit correspondence between two inheritance trees, replace inheritance with composition or aggregation, or use parametric polymorphism.
Keywords: type safety
, inheritance tree
, implicit correspondence