Skip to content

RFC: Replace numeric subtyping with explicit numeric modes #191

Description

@timfennis

Status

Proposed.

Summary

Replace the current numeric subtype hierarchy with three sibling numeric types:

  • Int: a primitive signed 64-bit integer
  • Float: a primitive IEEE 754 64-bit floating-point number
  • Number: an opt-in advanced numeric type

Number will remain an enum internally and may contain a BigInt, Rational, Float, or Complex value. These internal flavours will no longer be exposed as separate types in the language.

Initially, all three numeric types will remain subtypes of Any. This RFC does not attempt to remove all subtyping from the language. It is intended as the first step in reducing the amount of work currently performed by subtype relationships.

Motivation

The existing numeric hierarchy is:

Any
└── Number
    ├── Int
    ├── Float
    ├── Rational
    └── Complex

This makes Number serve several unrelated purposes:

  • a common supertype for overload resolution;
  • a fallback type when numeric inference loses precision;
  • the internal implementation of numeric arithmetic;
  • automatic promotion from small integers to BigInt;
  • automatic construction of rational and complex results.

The result is convenient, but it also makes ordinary arithmetic relatively magical. The programmer cannot choose between cheap machine arithmetic and the advanced numeric tower, and the type checker relies on Number to paper over otherwise unrelated numeric types.

The proposed model makes the trade-off explicit. Ordinary literals use predictable machine types. Advanced arithmetic remains available by opting into Number.

Goals

  • Make Int and Float simple, predictable primitive types.
  • Make arbitrary-precision, rational, and complex arithmetic opt-in.
  • Remove the numeric subtype relationship between Int, Float, and Number.
  • Reduce the amount of magic in arithmetic semantics.
  • Allow statically resolved primitive arithmetic to be lowered to specialised bytecode later.
  • Hide the internal representation of advanced numbers from the public type system.

Non-goals

  • Removing the relationship between numeric types and Any in this change.
  • Removing all other forms of subtyping.
  • Introducing union types.
  • Supporting heterogeneous numeric collections as a first-class use case.
  • Implementing every possible bytecode optimisation as part of the initial semantic change.

Proposed type model

The new public relationship is:

Any
├── Int
├── Float
└── Number

Int, Float, and Number are siblings. An Int is not a Number, and a function accepting Number does not automatically accept an Int or Float.

For example:

fn f(x: Number) {
    // ...
}

f(5);         // type error
f(Number(5)); // accepted

Numeric promotion is expressed through explicit overloads. It is not a general assignment or function-call conversion rule.

Runtime representation

Primitive values remain directly represented:

Value::Int(i64)
Value::Float(f64)

Advanced values must remain wrapped as Number values regardless of their current internal flavour. A possible representation is:

enum Value {
    Int(i64),
    Float(f64),
    Number(Rc<AdvancedNumber>),
    // ...
}

enum AdvancedNumber {
    BigInt(BigInt),
    Rational(BigRational),
    Float(f64),
    Complex(Complex64),
}

A Number must not silently demote to Int or Float when its internal value happens to fit those representations.

For example, both values below remain statically and dynamically typed as Number:

Number(4) / Number(2)
Number(5) / Number(2)

The first may internally contain an integer-like result and the second a rational result, but that distinction is not visible to the type system.

Numeric promotion

For ordinary arithmetic, the result type is determined by this table:

Left / right Int Float Number
Int Int Float Number
Float Float Float Number
Number Number Number Number

This is implemented using overloads rather than subtyping:

fn +(Int, Int) -> Int
fn +(Int, Float) -> Float
fn +(Float, Int) -> Float
fn +(Float, Float) -> Float
fn +(Int, Number) -> Number
fn +(Number, Int) -> Number
fn +(Float, Number) -> Number
fn +(Number, Float) -> Number
fn +(Number, Number) -> Number

The overload matrix should be generated declaratively rather than maintained as repeated handwritten registrations.

Not every operation must support the complete matrix. Bitwise operations and shifts, for example, should remain integer-only. Comparisons always return Bool. Each operator family should define its supported combinations explicitly.

Arithmetic semantics

Integer arithmetic

Int operations use checked i64 arithmetic.

i64::MAX + 1 // runtime error

Overflow must not silently promote to BigInt and must not depend on debug or release compiler settings.

Explicit wrapping, saturating, or checked operations may be added separately if they become useful.

Division

Division follows the public operand types:

5 / 3                 // Int: integer division
5.0 / 3               // Float
Number(5) / 3         // Number containing a rational value
Number(5) / Number(3) // Number containing a rational value

The exact rounding rule for negative integer division remains an open question.

Exponentiation

The result must obey the same opt-in principle. In particular, an operation involving only Int values must not silently produce a rational or BigInt.

The behaviour of negative integer exponents and overflowing integer powers remains an open question, but both should produce an error unless the operation includes a Number operand.

Advanced arithmetic

Operations involving Number may change internal flavour as needed:

  • exact integer results may use BigInt;
  • division of exact values may produce Rational;
  • approximate operations may produce an internal Float;
  • operations requiring complex values may produce Complex.

The public result type remains Number in all cases.

Constructors and literals

Advanced arithmetic is initially available through an explicit constructor:

Number(5)
Number(3.0)
Number(5) / Number(3)

A numeric literal shorthand should be added later. The exact syntax is not decided by this RFC. Possible forms include:

5 / 3n
5 / 3r

If the suffix marks one operand as Number, the normal overload rules are sufficient to make the entire expression produce a Number. The lexer does not need to treat the complete fraction as one literal.

An integer literal that does not fit in i64 should be rejected with a diagnostic suggesting the advanced-number syntax.

Standard library

Numeric standard-library functions must use explicit overloads.

For example:

fn sqrt(Int) -> Float
fn sqrt(Float) -> Float
fn sqrt(Number) -> Number

This makes the distinction between ordinary approximate arithmetic and advanced arithmetic visible in the signature.

Sequence operations should also preserve their concrete numeric type:

fn sum(Sequence<Int>) -> Int
fn sum(Sequence<Float>) -> Float
fn sum(Sequence<Number>) -> Number

fn product(Sequence<Int>) -> Int
fn product(Sequence<Float>) -> Float
fn product(Sequence<Number>) -> Number

There will be no sum(Sequence<Any>) fallback. A mixed collection is not considered sufficiently important to justify dynamic numeric dispatch throughout the standard library.

Mixed collections and common types

During the transitional phase:

[1, 2.0] // List<Any>

Since Int and Float are siblings, Number is no longer a valid least upper bound for them. The runtime values are not converted merely to produce a more convenient static type.

The long-term goal is to reject heterogeneous list literals and require an explicit common representation:

[Number(1), Number(2.0)] // List<Number>

This RFC does not propose union types as an alternative.

The same rule applies to branches and reassignment. Where incompatible concrete types meet and no explicit conversion is present, the transitional common type is Any.

Overload resolution

Static calls should normally resolve to an exact overload because the numeric types are siblings.

Calls involving an Any operand may still require runtime dispatch. With several numeric overloads, their inferred result may also remain Any until better inference is available.

The implementation must not treat the promotion table as a new subtype relation. Promotion exists because a matching overload explicitly implements the mixed-type operation.

Equality, ordering, and hashing

Cross-type numeric equality needs an explicit decision:

1 == 1.0
1 == Number(1)

If these comparisons remain true, equal numeric values must continue to produce identical hashes so that maps and sets remain valid.

This behaviour must be specified and tested before the migration is complete.

Bytecode optimisation

The new model makes primitive arithmetic suitable for specialised bytecode:

AddInt
SubInt
MulInt
DivInt
AddFloat
SubFloat
MulFloat
DivFloat

This is related to #110.

The compiler should lower a resolved built-in overload through explicit intrinsic metadata. It should not recognise an intrinsic only from the function name because functions and operators can be overloaded or shadowed.

For example, a statically known:

x += 1

could initially compile to:

GetLocal x
Constant 1
AddInt
SetLocal x

Further fused opcodes should only be introduced after benchmarking.

The semantic redesign does not depend on the bytecode optimisation and should be implemented and tested first.

Compatibility impact

This is an intentionally breaking change.

Notable changes include:

  • Int / Int no longer creates a rational value.
  • integer overflow no longer promotes to BigInt;
  • large integer literals require an explicit Number form;
  • Rational and Complex disappear as public static types;
  • functions accepting Number no longer automatically accept Int and Float;
  • mixed numeric collections initially infer Any and will eventually be rejected;
  • negative integer exponents can no longer silently produce rational results.

Diagnostics should suggest an explicit Number conversion where it is a likely migration.

Implementation plan

  1. Define and test the result-type matrix for every numeric operator.
  2. Introduce a permanently wrapped runtime representation for Number.
  3. Remove Rational and Complex from StaticType.
  4. Remove the numeric subtype relationship from StaticType::is_subtype.
  5. Add the explicit Number constructor.
  6. Register the primitive and mixed-type overloads.
  7. Change integer division, overflow, and exponentiation semantics.
  8. Update standard-library numeric functions and sequence operations.
  9. Update common-type inference for lists, branches, and reassignment.
  10. Specify and test cross-type equality, ordering, and hashing.
  11. Add literal shorthand.
  12. Add intrinsic bytecode lowering and benchmark it separately.

Open questions

  • What suffix should construct a Number literal?
  • Should negative Int / Int division truncate toward zero or round down?
  • What exact errors should integer exponentiation produce for negative or overflowing results?
  • Should 1, 1.0, and Number(1) compare as equal?
  • How should sqrt(-1) behave for Int and Float compared with Number(-1)?
  • Should the public type retain the name Number, even though it is no longer the common supertype of all numeric values?

Acceptance criteria

  • Int, Float, and Number are sibling types.
  • Rational and Complex are no longer exposed as public static types.
  • Primitive integer arithmetic cannot silently allocate or promote to BigInt.
  • Number values never demote to primitive values based on internal flavour.
  • The overload matrix determines mixed numeric arithmetic.
  • Mixed numeric lists infer List<Any> during the transition.
  • Homogeneous numeric sequence functions preserve their concrete numeric type.
  • Overflow produces a deterministic runtime error.
  • Existing numeric behaviour changes are covered by migration tests and documentation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestexperimentExperimental feature or research

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions