/kunits

Units of measurement in Kotlin

Primary LanguageKotlinThe UnlicenseUnlicense

Public Domain

KUnits

Units of measure in Kotlin

KUnits

build issues vulnerabilities license

This project covers historical, fantasy, or whimsical units: Metric is uninteresting except that being based on base 10, it is not representable by binary computers (the French revolutionaries overlooked that). USD is provided as a practical example.

The project is a demonstration of the power (and limits) of generics in Kotlin and in writing a clean DSL: see Main.kt. It is also fun.

Build

Try ./run for a demonstration.

The build is vanilla Maven, with Batect offered as a means to reproduce locally what CI does.

$ ./mvnw clean verify
$ ./run
# Or:
$ ./batect build
$ ./batect demo

Test coverage is 100% for lines, branches, and instructions.

Systems of units

Kotlin rational

This library depends on kotlin-rational for representing big rationals.

Presently there is no published dependency for kotlin-rational. To build KUnits, install locally from the kotlin-rational-2.2.0 tag.

Platform

This code targets JDK 17.

Design

DSL

Creating measures of units

Arithmetic

Converting measures into other units

Pretty printing

API

Included for Measure are the usual simple arithmetic operations.

The exemplar of quirkiness is traditional English units:

Unreal systems of units for testing:

Below is the source for the Martian system of units showing the minimal code needed for setting up a system of units:

// Define the Martian system of units, a singleton
object Martian : System<Martian>("Martian")

class Grok private constructor(value: FixedBigRational) :
// Grok is a measure of length in the Martian system
    Measure<Length, Martian, Groks, Grok>(Groks, value) {
    // Groks are units measured as multiples of one grok
    companion object Groks : Units<Length, Martian, Groks, Grok>(
        Length, Martian, "grok", ONE
    ) {
        override fun new(quantity: FixedBigRational) = Grok(quantity)
        override fun format(quantity: FixedBigRational) = "$quantity groks"
    }
}

// Factory extension properties for creating some quantity of groks
val FixedBigRational.groks get() = Groks.new(this)
val Long.groks get() = (this over 1).groks
val Int.groks get() = (this over 1).groks

For convenience, systems of units may provide conversions into other systems:

infix fun <
        V : Units<Length, Metasyntactic, V, N>,
        N : Measure<Length, Metasyntactic, V, N>,
        >
// Specialize converting Martian units of length to Metasyntactic ones
// Elsewhere, define the reflexive `intoMartian` to reverse the conversion
        Measure<Length, Martian, *, *>.intoMetasyntactic(other: V) =
    into(other) {
        it * (1 over 3)
    }

Typically, the base type for units of measure (MartialLengths, above) is sealed as there is a known, fixed number of units. However, OtherDnDDenominations is an example of extending a kind of units.

Also, see ShoeSizes for an example of creating new kinds of units.

Use of generics

Generic signatures pervade types and function signatures. The standard ordering is:

  • K "kind" — is this length, weight, etc.
  • S "system" ‐ is this English units, etc.
  • U "unit" ‐ what unit is this?
  • M "measure" ‐ how many units?

Considerations

Syntactic sugar

Syntactic sugar causes cancer of the semicolon.
— Alan J. Perlis

There are too many options for "nice" Kotlin syntactic sugar. The most "natural English" approach might be:

2.feet in Inches // *not* valid Kotlin

However, this is a compilation failure as the "in" needs to be "`in`" since in is a keyword in Kotlin.

Another might be:

2.feet to Inches

However, this overloads the universal to function for creating Pairs.

Or consider:

2.feet as Inches

Unfortunately, as is an existing keyword for type casting.

The chosen compromise is an infix into function, and a more general version for conversions into unit units of the same kind in another system.

2.feet into Inches

Though infix functions do not chain nicely:

2.feet into Inches shouldBe 24.inches // what you expect
2.feet shouldBe 24.inches into Feet // does not compile

More readable might be:

(2.feet into Inches) shouldBe 24.inches // parentheses for readability
2.feet shouldBe (24.inches into Feet) // parentheses needed to compile
2.feet / Inches shouldBe 24.inches // operator binds more tightly than infix
2.feet shouldBe 24.inches / Feet // correct, but harder to read

And parentheses are required for correct binding order in some cases:

24.inches shouldBe (2.feet into Inches)

One may skip syntactic sugar altogether:

Feet(2).into(Inches)

At the cost of losing some pleasantness of Kotlin.

Inline

The trivial extension properties for converting Int, Long, and FixedBigRational into units could be inline (as well as several others). However, JaCoCo's Kotlin inline functions are not marked as covered lowers test coverage, and Kover's Feature request: Equivalent Maven plugin does not support Maven.

Following The Rules, inline is removed for now, until JaCoCo resolves this issue.

Mixing compilation errors with runtime errors for the same problem

Incompatible unit conversions are inconsistent. The two cases are:

  1. Converting between units of different kinds (say, lengths and weights) in the same system of units
  2. Converting between units of the same kind (say, lengths) but in different systems of units

Behavior:

  • Operations between incompatible units do not compile. This is by design. For example, you cannot convert feet into pounds.
// Does not compile: feet and pounds are different kinds of units
1.feet into Pounds
// Does not compile: both are lengths, but of different systems:
1.smoots into Inches
// This would both compile and run successfully:
1.smoots intoEnglish Inches

Reading