LY Corporation Tech Blog

We are promoting the technology and development culture that supports the services of LY Corporation and LY Corporation Group (LINE Plus, LINE Taiwan and LINE Vietnam).

This post is also available in the following languages. Japanese

Improving code quality - Session 13: Clone family

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