Search code examples
kotlinclassconstructorinitialization

Initializing properties in the primary constructor of a Kotlin data class


I'm working on a Kotlin project that needs to work with 'rational' numbers. To do that I have a class that stores a rational number as two integers (numerator and denominator) and performs some arithmetic using those values. The problem I am running into is that one of the requirements is that the values be normalized. An incoming value of 2/4 should be stored as 1/2, 6/8 should be 1/4, and so on. I also want to make this a 'data class' so I can take advantage of the built-in equals and hashcode functions.

My class looks like this:

data class Rational(val numerator: BigInteger, val denominator: BigInteger): Comparable<Rational>
{
    init {
        if(denominator.equals(0))
        {
            throw IllegalArgumentException("0 is not a valid denominator")
        }

        //normalize the value by reducing to the least common denominator
        val gcd = numerator.gcd(denominator)
        this.numerator = numerator.div(gcd)
        this.denominator = denominator.div(gcd)

    }
}

infix fun BigInteger.divBy(d: BigInteger): Rational
{
    return Rational(this, d)
}

fun main() {

    val half = 2 divBy 4
    println(half) // expected: Rational(numerator=1, denominator=2)
}

which doesn't compile since the parameters are 'val'. I don't want to make the properties mutable, but I'm not sure how else to do the calculation before setting the value. I can't remove the modifiers because they are required for a data class.

What is the procedure for initializing properties that need to be processed before the values are set? The only answer I have been able to find so far is this: How to store temporary variable while initializing a Kotlin object? which appears to be for an old (pre 1.0) version of kotlin.


Solution

  • I believe this is not directly possible. By design, a data class is a simple data holder. If we pass a value to it, we expect it to hold exactly this value. Transforming the value on-the-fly may be considered an unexpected behavior. Maybe it would be better to provide a normalized() member function or a factory function?

    Having said that, we can cheat it a little by hiding the primary constructor and providing invoke() operator for the companion object:

    fun main() {
        println(Rational(4.toBigInteger(), 8.toBigInteger())) // Rational(numerator=1, denominator=2)
    }
    
    data class Rational private constructor(val numerator: BigInteger, val denominator: BigInteger) {
        companion object {
            operator fun invoke(numerator: BigInteger, denominator: BigInteger): Rational {
                if(denominator.equals(0))
                {
                    throw IllegalArgumentException("0 is not a valid denominator")
                }
    
                //normalize the value by reducing to the least common denominator
                val gcd = numerator.gcd(denominator)
                return Rational(numerator.div(gcd), denominator.div(gcd))
            }
        }
    }
    

    Of course, this is not entirely what you asked for. We don't use the constructor here, but a function which looks like a constructor. Also, we need to remember about the copy() function which doesn't do normalization and I believe we can't overwrite it.