Add decimal module and operator interop registry
This commit is contained in:
1 parent
4269310beb
commit
580256d520
18 files changed
+1992
-102
No files matched your search
+250
@@ -0,0 +1,250 @@
|
||||
# Decimal (`lyng.decimal`)
|
||||
|
||||
`lyng.decimal` adds an arbitrary-precision decimal type to Lyng as a normal library module.
|
||||
|
||||
Import it when you need decimal arithmetic that should not inherit `Real`'s binary floating-point behavior:
|
||||
|
||||
```lyng
|
||||
import lyng.decimal
|
||||
```
|
||||
|
||||
## What `BigDecimal` Is For
|
||||
|
||||
Use `BigDecimal` when values are fundamentally decimal:
|
||||
|
||||
- money
|
||||
- human-entered quantities
|
||||
- exact decimal text
|
||||
- predictable decimal rounding
|
||||
- user-facing formatting and tests
|
||||
|
||||
Do not use it just because a number has a fractional part. `Real` is still the right type for ordinary double-precision numeric work.
|
||||
|
||||
## Creating Decimal Values
|
||||
|
||||
There are three supported conversions:
|
||||
|
||||
```lyng
|
||||
import lyng.decimal
|
||||
|
||||
val a = 1.d
|
||||
val b = 2.2.d
|
||||
val c = "2.2".d
|
||||
|
||||
assertEquals("1", a.toStringExpanded())
|
||||
assertEquals("2.2", b.toStringExpanded())
|
||||
assertEquals("2.2", c.toStringExpanded())
|
||||
```
|
||||
|
||||
The three forms mean different things:
|
||||
|
||||
- `1.d`: convert `Int -> BigDecimal`
|
||||
- `2.2.d`: convert `Real -> BigDecimal`
|
||||
- `"2.2".d`: parse exact decimal text
|
||||
|
||||
That distinction is intentional.
|
||||
|
||||
### `Real.d` vs `"..." .d`
|
||||
|
||||
`Real.d` preserves the current `Real` value. It does not pretend the source code was exact decimal text.
|
||||
|
||||
```lyng
|
||||
import lyng.decimal
|
||||
|
||||
assertEquals("0.30000000000000004", (0.1 + 0.2).d.toStringExpanded())
|
||||
assertEquals("0.3", "0.3".d.toStringExpanded())
|
||||
```
|
||||
|
||||
This follows the "minimal confusion" rule:
|
||||
|
||||
- if you start from a `Real`, you get a decimal representation of that `Real`
|
||||
- if you want exact decimal source text, use a `String`
|
||||
|
||||
## Factory Functions
|
||||
|
||||
The explicit factory methods are:
|
||||
|
||||
```lyng
|
||||
import lyng.decimal
|
||||
|
||||
BigDecimal.fromInt(10)
|
||||
BigDecimal.fromReal(2.5)
|
||||
BigDecimal.fromString("12.34")
|
||||
```
|
||||
|
||||
These are equivalent to the conversion-property forms, but sometimes clearer in APIs or generated code.
|
||||
|
||||
## Core Operations
|
||||
|
||||
`BigDecimal` supports:
|
||||
|
||||
- `+`
|
||||
- `-`
|
||||
- `*`
|
||||
- `/`
|
||||
- `%`
|
||||
- unary `-`
|
||||
- comparison operators
|
||||
- equality operators
|
||||
|
||||
Examples:
|
||||
|
||||
```lyng
|
||||
import lyng.decimal
|
||||
|
||||
assertEquals("3.75", ("1.5".d + "2.25".d).toStringExpanded())
|
||||
assertEquals("1.25", ("2.5".d - "1.25".d).toStringExpanded())
|
||||
assertEquals("3.0", ("1.5".d * 2.d).toStringExpanded())
|
||||
assertEquals("0.5", (1.d / 2.d).toStringExpanded())
|
||||
assert("2.0".d > "1.5".d)
|
||||
assert("2.0".d == 2.d)
|
||||
```
|
||||
|
||||
## Interoperability With `Int` and `Real`
|
||||
|
||||
The decimal module registers mixed-operand operator bridges so both sides read naturally:
|
||||
|
||||
```lyng
|
||||
import lyng.decimal
|
||||
|
||||
assertEquals(3.d, 1 + 2.d)
|
||||
assertEquals(3.d, 2.d + 1)
|
||||
assertEquals(1.5.d, 1.d + 0.5)
|
||||
assertEquals(1.5.d, 0.5 + 1.d)
|
||||
assert(2 == 2.d)
|
||||
assert(3 > 2.d)
|
||||
```
|
||||
|
||||
Without this registration mechanism, only the cases directly implemented on the left-hand class would work. The bridge fills the gap for expressions such as `Int + BigDecimal` and `Real + BigDecimal`.
|
||||
|
||||
See [OperatorInterop.md](OperatorInterop.md) for the generic mechanism behind that.
|
||||
|
||||
## String Representation
|
||||
|
||||
Use `toStringExpanded()` when you want plain decimal output without scientific notation:
|
||||
|
||||
```lyng
|
||||
import lyng.decimal
|
||||
|
||||
assertEquals("12.34", "12.34".d.toStringExpanded())
|
||||
```
|
||||
|
||||
This is the recommended representation for:
|
||||
|
||||
- tests
|
||||
- user-visible diagnostics
|
||||
- decimal formatting checks
|
||||
|
||||
## Conversions Back To Built-ins
|
||||
|
||||
```lyng
|
||||
import lyng.decimal
|
||||
|
||||
assertEquals(2, "2.9".d.toInt())
|
||||
assertEquals(2.9, "2.9".d.toReal())
|
||||
```
|
||||
|
||||
Use `toReal()` only when you are willing to return to binary floating-point semantics.
|
||||
|
||||
## Division Context
|
||||
|
||||
Division is the operation where precision and rounding matter most.
|
||||
|
||||
By default, decimal division uses:
|
||||
|
||||
- precision: `34` significant digits
|
||||
- rounding: `HalfEven`
|
||||
|
||||
Example:
|
||||
|
||||
```lyng
|
||||
import lyng.decimal
|
||||
|
||||
assertEquals("0.3333333333333333333333333333333333", (1.d / 3.d).toStringExpanded())
|
||||
assertEquals("0.6666666666666666666666666666666667", ("2".d / 3.d).toStringExpanded())
|
||||
```
|
||||
|
||||
## `withDecimalContext(...)`
|
||||
|
||||
Use `withDecimalContext(...)` to override decimal division rules inside a block:
|
||||
|
||||
```lyng
|
||||
import lyng.decimal
|
||||
|
||||
assertEquals(
|
||||
"0.3333333333",
|
||||
withDecimalContext(10) { (1.d / 3.d).toStringExpanded() }
|
||||
)
|
||||
```
|
||||
|
||||
You can also pass an explicit context object:
|
||||
|
||||
```lyng
|
||||
import lyng.decimal
|
||||
|
||||
val ctx = DecimalContext(6, DecimalRounding.HalfAwayFromZero)
|
||||
assertEquals("0.666667", withDecimalContext(ctx) { ("2".d / 3.d).toStringExpanded() })
|
||||
```
|
||||
|
||||
The context is dynamic and local to the block. After the block exits, the previous context is restored.
|
||||
|
||||
## Rounding Modes
|
||||
|
||||
Available rounding modes:
|
||||
|
||||
- `HalfEven`
|
||||
- `HalfAwayFromZero`
|
||||
- `HalfTowardsZero`
|
||||
- `Ceiling`
|
||||
- `Floor`
|
||||
- `AwayFromZero`
|
||||
- `TowardsZero`
|
||||
|
||||
Tie example at precision `2`:
|
||||
|
||||
```lyng
|
||||
import lyng.decimal
|
||||
|
||||
assertEquals("0.12", withDecimalContext(2, DecimalRounding.HalfEven) { (1.d / 8.d).toStringExpanded() })
|
||||
assertEquals("0.13", withDecimalContext(2, DecimalRounding.HalfAwayFromZero) { (1.d / 8.d).toStringExpanded() })
|
||||
assertEquals("0.12", withDecimalContext(2, DecimalRounding.HalfTowardsZero) { (1.d / 8.d).toStringExpanded() })
|
||||
assertEquals("0.13", withDecimalContext(2, DecimalRounding.Ceiling) { (1.d / 8.d).toStringExpanded() })
|
||||
assertEquals("0.12", withDecimalContext(2, DecimalRounding.Floor) { (1.d / 8.d).toStringExpanded() })
|
||||
```
|
||||
|
||||
Negative values follow the same named policy in the obvious direction:
|
||||
|
||||
```lyng
|
||||
import lyng.decimal
|
||||
|
||||
assertEquals("-0.13", withDecimalContext(2, DecimalRounding.HalfAwayFromZero) { (-1.d / 8.d).toStringExpanded() })
|
||||
assertEquals("-0.12", withDecimalContext(2, DecimalRounding.HalfTowardsZero) { (-1.d / 8.d).toStringExpanded() })
|
||||
```
|
||||
|
||||
## Recommended Usage Rules
|
||||
|
||||
If you care about exact decimal source text:
|
||||
|
||||
```lyng
|
||||
"12.34".d
|
||||
```
|
||||
|
||||
If you intentionally convert an existing binary floating-point value:
|
||||
|
||||
```lyng
|
||||
someReal.d
|
||||
```
|
||||
|
||||
If you want local control over division:
|
||||
|
||||
```lyng
|
||||
withDecimalContext(precision, rounding) { ... }
|
||||
```
|
||||
|
||||
If you want custom mixed operators for your own type, follow the same pattern as Decimal and use the operator registry:
|
||||
|
||||
- define the operators on your own class
|
||||
- choose a common class
|
||||
- register mixed operand bridges
|
||||
|
||||
See [OperatorInterop.md](OperatorInterop.md).
|
||||
@@ -0,0 +1,309 @@
|
||||
# Operator Interop Registry
|
||||
|
||||
`lyng.operators` provides a runtime registry for mixed-class binary operators.
|
||||
|
||||
Import it when you want expressions such as:
|
||||
|
||||
- `1 + MyType(...)`
|
||||
- `2 < MyType(...)`
|
||||
- `3 == MyType(...)`
|
||||
|
||||
to work without modifying the built-in `Int` or `Real` classes.
|
||||
|
||||
```lyng
|
||||
import lyng.operators
|
||||
```
|
||||
|
||||
## Why This Exists
|
||||
|
||||
If your class defines:
|
||||
|
||||
```lyng
|
||||
class Amount(val value: Int) {
|
||||
fun plus(other: Amount) = Amount(value + other.value)
|
||||
}
|
||||
```
|
||||
|
||||
then:
|
||||
|
||||
```lyng
|
||||
Amount(1) + Amount(2)
|
||||
```
|
||||
|
||||
works, because the left operand already knows how to add another `Amount`.
|
||||
|
||||
But:
|
||||
|
||||
```lyng
|
||||
1 + Amount(2)
|
||||
```
|
||||
|
||||
does not naturally work, because `Int` has not been rewritten to know about `Amount`.
|
||||
|
||||
The operator interop registry solves exactly that problem.
|
||||
|
||||
## Mental Model
|
||||
|
||||
Registration describes a mixed pair:
|
||||
|
||||
- left class `L`
|
||||
- right class `R`
|
||||
- common class `C`
|
||||
|
||||
When Lyng sees `L op R`, it:
|
||||
|
||||
1. converts `L -> C`
|
||||
2. converts `R -> C`
|
||||
3. evaluates the operator as `C op C`
|
||||
|
||||
So the registry is a bridge, not a separate arithmetic engine.
|
||||
|
||||
## API
|
||||
|
||||
```lyng
|
||||
OperatorInterop.register(
|
||||
leftClass,
|
||||
rightClass,
|
||||
commonClass,
|
||||
operators,
|
||||
leftToCommon,
|
||||
rightToCommon
|
||||
)
|
||||
```
|
||||
|
||||
Parameters:
|
||||
|
||||
- `leftClass`: original left operand class
|
||||
- `rightClass`: original right operand class
|
||||
- `commonClass`: class that will actually execute the operator methods
|
||||
- `operators`: list of operators enabled for this pair
|
||||
- `leftToCommon`: conversion from left operand to common class
|
||||
- `rightToCommon`: conversion from right operand to common class
|
||||
|
||||
## Supported Operators
|
||||
|
||||
`BinaryOperator` values:
|
||||
|
||||
- `Plus`
|
||||
- `Minus`
|
||||
- `Mul`
|
||||
- `Div`
|
||||
- `Mod`
|
||||
- `Compare`
|
||||
- `Equals`
|
||||
|
||||
Meaning:
|
||||
|
||||
- `Compare` enables `<`, `<=`, `>`, `>=`, and `<=>`
|
||||
- `Equals` enables `==` and `!=`
|
||||
|
||||
## Minimal Working Example
|
||||
|
||||
```lyng
|
||||
package test.decimalbox
|
||||
import lyng.operators
|
||||
|
||||
class DecimalBox(val value: Int) {
|
||||
fun plus(other: DecimalBox) = DecimalBox(value + other.value)
|
||||
fun minus(other: DecimalBox) = DecimalBox(value - other.value)
|
||||
fun mul(other: DecimalBox) = DecimalBox(value * other.value)
|
||||
fun div(other: DecimalBox) = DecimalBox(value / other.value)
|
||||
fun mod(other: DecimalBox) = DecimalBox(value % other.value)
|
||||
fun compareTo(other: DecimalBox) = value <=> other.value
|
||||
}
|
||||
|
||||
OperatorInterop.register(
|
||||
Int,
|
||||
DecimalBox,
|
||||
DecimalBox,
|
||||
[
|
||||
BinaryOperator.Plus,
|
||||
BinaryOperator.Minus,
|
||||
BinaryOperator.Mul,
|
||||
BinaryOperator.Div,
|
||||
BinaryOperator.Mod,
|
||||
BinaryOperator.Compare,
|
||||
BinaryOperator.Equals
|
||||
],
|
||||
{ x: Int -> DecimalBox(x) },
|
||||
{ x: DecimalBox -> x }
|
||||
)
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```lyng
|
||||
import test.decimalbox
|
||||
|
||||
assertEquals(DecimalBox(3), 1 + DecimalBox(2))
|
||||
assertEquals(DecimalBox(1), 3 - DecimalBox(2))
|
||||
assertEquals(DecimalBox(8), 4 * DecimalBox(2))
|
||||
assertEquals(DecimalBox(4), 8 / DecimalBox(2))
|
||||
assertEquals(DecimalBox(1), 7 % DecimalBox(2))
|
||||
assert(1 < DecimalBox(2))
|
||||
assert(2 <= DecimalBox(2))
|
||||
assert(3 > DecimalBox(2))
|
||||
assert(2 == DecimalBox(2))
|
||||
assert(2 != DecimalBox(3))
|
||||
```
|
||||
|
||||
## How Decimal Uses It
|
||||
|
||||
`lyng.decimal` uses this same mechanism so that:
|
||||
|
||||
```lyng
|
||||
import lyng.decimal
|
||||
|
||||
1 + 2.d
|
||||
0.5 + 1.d
|
||||
2 == 2.d
|
||||
3 > 2.d
|
||||
```
|
||||
|
||||
work naturally even though `Int` and `Real` themselves were not edited to know `BigDecimal`.
|
||||
|
||||
The shape is:
|
||||
|
||||
- `leftClass = Int` or `Real`
|
||||
- `rightClass = BigDecimal`
|
||||
- `commonClass = BigDecimal`
|
||||
- convert built-ins into `BigDecimal`
|
||||
- leave `BigDecimal` values unchanged
|
||||
|
||||
## Step-By-Step Pattern For Your Own Type
|
||||
|
||||
### 1. Pick the common class
|
||||
|
||||
Choose one class that will be the actual arithmetic domain.
|
||||
|
||||
For numeric-like types, that is usually your own class:
|
||||
|
||||
```lyng
|
||||
class Rational(...)
|
||||
```
|
||||
|
||||
### 2. Implement operators on that class
|
||||
|
||||
The common class should define the operations you plan to register.
|
||||
|
||||
Example:
|
||||
|
||||
```lyng
|
||||
class Rational(val num: Int, val den: Int) {
|
||||
fun plus(other: Rational) = Rational(num * other.den + other.num * den, den * other.den)
|
||||
fun minus(other: Rational) = Rational(num * other.den - other.num * den, den * other.den)
|
||||
fun mul(other: Rational) = Rational(num * other.num, den * other.den)
|
||||
fun div(other: Rational) = Rational(num * other.den, den * other.num)
|
||||
fun compareTo(other: Rational) = (num * other.den) <=> (other.num * den)
|
||||
|
||||
static fun fromInt(value: Int) = Rational(value, 1)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Register the mixed pair
|
||||
|
||||
```lyng
|
||||
import lyng.operators
|
||||
|
||||
OperatorInterop.register(
|
||||
Int,
|
||||
Rational,
|
||||
Rational,
|
||||
[
|
||||
BinaryOperator.Plus,
|
||||
BinaryOperator.Minus,
|
||||
BinaryOperator.Mul,
|
||||
BinaryOperator.Div,
|
||||
BinaryOperator.Compare,
|
||||
BinaryOperator.Equals
|
||||
],
|
||||
{ x: Int -> Rational.fromInt(x) },
|
||||
{ x: Rational -> x }
|
||||
)
|
||||
```
|
||||
|
||||
### 4. Use it
|
||||
|
||||
```lyng
|
||||
assertEquals(Rational(3, 2), 1 + Rational(1, 2))
|
||||
assert(Rational(3, 2) == Rational(3, 2))
|
||||
assert(2 > Rational(3, 2))
|
||||
```
|
||||
|
||||
## Registering More Than One Built-in Type
|
||||
|
||||
If you want both `Int + MyType` and `Real + MyType`, register both pairs explicitly:
|
||||
|
||||
```lyng
|
||||
OperatorInterop.register(
|
||||
Int,
|
||||
MyType,
|
||||
MyType,
|
||||
[BinaryOperator.Plus, BinaryOperator.Compare, BinaryOperator.Equals],
|
||||
{ x: Int -> MyType.fromInt(x) },
|
||||
{ x: MyType -> x }
|
||||
)
|
||||
|
||||
OperatorInterop.register(
|
||||
Real,
|
||||
MyType,
|
||||
MyType,
|
||||
[BinaryOperator.Plus, BinaryOperator.Compare, BinaryOperator.Equals],
|
||||
{ x: Real -> MyType.fromReal(x) },
|
||||
{ x: MyType -> x }
|
||||
)
|
||||
```
|
||||
|
||||
Each mixed pair is independent.
|
||||
|
||||
## Pure Lyng Registration
|
||||
|
||||
This mechanism is intentionally useful from pure Lyng code, not only from Kotlin-backed modules.
|
||||
|
||||
That means you can:
|
||||
|
||||
- declare a class in Lyng
|
||||
- define its operators in Lyng
|
||||
- register mixed operand bridges in Lyng
|
||||
|
||||
without touching compiler internals.
|
||||
|
||||
## Where To Register
|
||||
|
||||
Register once during module initialization.
|
||||
|
||||
Top-level module code is a good place:
|
||||
|
||||
```lyng
|
||||
package my.rational
|
||||
import lyng.operators
|
||||
|
||||
class Rational(...)
|
||||
|
||||
OperatorInterop.register(...)
|
||||
```
|
||||
|
||||
That keeps registration close to the type declaration and makes importing the module enough to activate the interop.
|
||||
|
||||
## What Registration Does Not Do
|
||||
|
||||
The registry does not:
|
||||
|
||||
- invent operators your common class does not implement
|
||||
- change the original `Int`, `Real`, or other built-ins
|
||||
- automatically cover every class pair
|
||||
- replace normal method overload resolution when the left-hand class already knows what to do
|
||||
|
||||
It only teaches Lyng how to bridge a specific mixed pair into a common class for the listed operators.
|
||||
|
||||
## Recommended Design Rules
|
||||
|
||||
If you want interop to feel natural:
|
||||
|
||||
- choose one obvious common class
|
||||
- make conversions explicit and unsurprising
|
||||
- implement `compareTo` if you want ordering operators
|
||||
- register `Equals` whenever mixed equality should work
|
||||
- keep the registered operator list minimal and accurate
|
||||
|
||||
For decimal-like semantics, also read [Decimal.md](Decimal.md).
|
||||
@@ -5,6 +5,73 @@ For a programmer-focused migration summary, see `docs/whats_new_1_5.md`.
|
||||
|
||||
## Language Features
|
||||
|
||||
### Decimal Arithmetic Module (`lyng.decimal`)
|
||||
Lyng now ships a first-class decimal module built as a regular extension library rather than a deep core special case.
|
||||
|
||||
It provides:
|
||||
|
||||
- `BigDecimal`
|
||||
- convenient `.d` conversions from `Int`, `Real`, and `String`
|
||||
- mixed arithmetic with `Int` and `Real`
|
||||
- local division precision and rounding control via `withDecimalContext(...)`
|
||||
|
||||
```lyng
|
||||
import lyng.decimal
|
||||
|
||||
assertEquals("3", (1 + 2.d).toStringExpanded())
|
||||
assertEquals("0.30000000000000004", (0.1 + 0.2).d.toStringExpanded())
|
||||
assertEquals("0.3", "0.3".d.toStringExpanded())
|
||||
|
||||
assertEquals(
|
||||
"0.3333333333",
|
||||
withDecimalContext(10) { (1.d / 3.d).toStringExpanded() }
|
||||
)
|
||||
```
|
||||
|
||||
The distinction between `Real -> Decimal` and exact decimal parsing is explicit by design:
|
||||
|
||||
- `2.2.d` converts the current `Real` value
|
||||
- `"2.2".d` parses exact decimal text
|
||||
|
||||
See [Decimal](Decimal.md).
|
||||
|
||||
### Binary Operator Interop Registry
|
||||
Lyng now provides a general mechanism for mixed binary operators through `lyng.operators`.
|
||||
|
||||
This solves cases like:
|
||||
|
||||
- `Int + MyType`
|
||||
- `Real < MyType`
|
||||
- `Int == MyType`
|
||||
|
||||
without requiring changes to built-in classes.
|
||||
|
||||
```lyng
|
||||
import lyng.operators
|
||||
|
||||
class DecimalBox(val value: Int) {
|
||||
fun plus(other: DecimalBox) = DecimalBox(value + other.value)
|
||||
fun compareTo(other: DecimalBox) = value <=> other.value
|
||||
}
|
||||
|
||||
OperatorInterop.register(
|
||||
Int,
|
||||
DecimalBox,
|
||||
DecimalBox,
|
||||
[BinaryOperator.Plus, BinaryOperator.Compare, BinaryOperator.Equals],
|
||||
{ x: Int -> DecimalBox(x) },
|
||||
{ x: DecimalBox -> x }
|
||||
)
|
||||
|
||||
assertEquals(DecimalBox(3), 1 + DecimalBox(2))
|
||||
assert(1 < DecimalBox(2))
|
||||
assert(2 == DecimalBox(2))
|
||||
```
|
||||
|
||||
`lyng.decimal` uses this same mechanism internally to interoperate with `Int` and `Real`.
|
||||
|
||||
See [Operator Interop Registry](OperatorInterop.md).
|
||||
|
||||
### Class Properties with Accessors
|
||||
Classes now support properties with custom `get()` and `set()` accessors. Properties in Lyng do **not** have automatic backing fields; they are pure accessors.
|
||||
|
||||
|
||||
Reference in new issue
Block a user