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 21: Knock on the constructor for good luck

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.

Knock on the constructor for good luck

The following FooVideoPlayer class is used to play videos. You can call FooVideoPlayer.play to play a video, but you need to call prepare first to set the preparedValue. If you call play without calling prepare, an exception will be thrown due to an error.

/**
 * A video player for a file specified by [videoUri]. 
 * 
 * Call [play] to play the video, but [prepare] must be called before calling `play`.
 * Otherwise `play` will throw [IllegalStateException].
 */
class FooVideoPlayer(
    private val videoUri: Uri,
    ... // other options
) {
    private var preparedValue: PreparedValue? = null

    fun prepare() {
        if (preparedValue != null) {
            error("Already prepared")
        }

        preparedValue = ... // execute `prepare` logic
    }

    fun play() {
        val currentValue = preparedValue
        if (currentValue == null) {
            error("Not prepared yet")
        }

        // ... play `videoUri`.
    }
}

Can this code be improved?

Fixing the broken constructor

The FooVideoPlayer class has an issue where it can't safely handle the "not prepared" state. When an instance of FooVideoPlayer is passed around, you don't know if prepare has been called, and calling the wrong function will throw an exception.

Classes or functions that require careful usage can cause bugs. While you can document the precautions, it's ideal to make it impossible to use them incorrectly. In this case, there are a few solutions:

  1. Execute prepare during initialization
  2. Execute prepare during the first play call
  3. Make it impossible to call play before prepare

Option 1: Execute prepare during initialization

The simplest solution is to execute the logic equivalent to prepare in the constructor or initializer.

class FooVideoPlayer(
    private val videoUri: Uri,
    ... // other options
) {
    private val preparedValue: PreparedValue

    init {
       preparedValue = ... // execute `prepare` logic
    }

    fun play() {
        // ... play `videoUri`.
    }
}

The advantage of this method is that many properties can be read-only. Even properties that are determined during prepare can be declared as val.

However, this approach can lead to unsafe code. If a function called within the initializer reads uninitialized properties, it can cause bugs. Some languages like Swift restrict calling properties or functions before initialization is complete, preventing such bugs. However, this might mean you can't write the logic equivalent to prepare in the initializer. There are various constraints on constructors. For example, in Kotlin, you can't make the constructor itself suspend.

Writing complex logic or logic with significant side effects in the constructor or initializer can cause problems. Therefore, consider making the constructor private and defining a separate factory function to write the logic equivalent to prepare. In the code below, a factory function createInstance is defined within the companion object. (Functions defined within a companion object are equivalent to static methods in Java.)

class FooVideoPlayer private constructor(
    private val videoUri: Uri,
    ..., // other options
    private val preparedValue: PreparedValue
) {
    fun play() {
        // ... play `videoUri`.
    }

    companion object {
        fun createInstance(videoUri: Uri, ...): FooVideoPlayer {
            val preparedValue = ... // execute `prepare` logic

            return FooVideoPlayer(
                videoUri,
                ...,
                preparedValue
            )
        }
    }
}

Option 2: Execute prepare during the first play call

Instead of executing the logic equivalent to prepare when the instance is created, you can execute it during the first play call.

class FooVideoPlayer(
    private val videoUri: Uri,
    ... // other options
) {
    private var preparedValue: PreparedValue? = null

    fun play() {
        val preparedValue = prepare()

        // ... play `videoUri`.
    }

    private fun prepare(): PreparedValue {
        val existingValue = preparedValue
        if (existingValue != null) {
            return existingValue
        }

        val newValue = ... // preparation logic
        preparedValue = newValue
        return newValue
    }
}

This method is effective when there's a high chance that play won't be called even if an instance is created, and the cost of prepare is high. However, it has the drawback of requiring properties determined during prepare to be mutable (var). This issue can be mitigated by using mechanisms like Kotlin's lazy, which executes logic on the first access.

class FooVideoPlayer(
    private val videoUri: Uri,
    ... // other options
) {
    private val preparedValue: PreparedValue by lazy {
        ... // preparation logic
    }

    fun play() {
        // ... play `videoUri` by using `preparedValue`
    }
}

Option 3: Make it impossible to call play before prepare

In statically typed languages, you can separate the types before and after prepare, defining play only after prepare. The idea is to make it impossible to compile if used incorrectly. This method is particularly effective when you want the caller to control the timing of prepare due to reasons like high execution cost. From another perspective, this can be seen as a class-based version of the factory function in Option 1. Unique advantages of this method include the ability to cache prepared instances of FooVideoPlayer and manage "incremental initialization states".

class FooVideoPlayer(
    private val videoUri: Uri,
    ... // other options
) {
    fun prepare(): PreparedFooVideoPlayer {
        val preparedValue = ... // execute `prepare` logic

        return PreparedFooVideoPlayer(
            videoUri,
            ...,
            preparedValue
        )
    }
}

class PreparedFooVideoPlayer(
    private val videoUri: Uri,
    ..., // other options
    private val preparedValue: PreparedValue
) {
    fun play() {
        // ... play `videoUri`.
    }
}

Summary

Make sure instances that aren't prepared can't be used.

Keywords: initialization, constructor, factory

List of articles on improving code quality