2.1. Numeric types
The rok::core::num module defines every numeric type of rok. rok keeps numbers from two
worlds side by side, and the user is free to choose either.
int / float (auto-promote, arbitrary precision, opt-in)
fixed primitive (i8..i64, u8..u64, isize, usize, f32, f64; hardware-friendly, default)
The two worlds coexist. An unsuffixed literal follows the expected type; if there is no expected type it is i32 / f64 (refs-lang 1.6.3). int / float are used only where the type is written out.
2.1.1. Two worlds of numbers - why they are separate
int / float - the auto-promote world
This world aims at arbitrary precision. Overflow never happens; computation ends with OOM
only when the memory limit is reached. Small values go through the small-int optimization
and are stored in hardware fixed-width form; as a value grows it is automatically promoted
to a heap BigInt. int / float values are immutable, and a heap BigInt is never modified in place, only shared (which is why they are Copy). The user is thereby guaranteed arithmetic correctness: I32_MAX + 1 never
silently wraps into an absurd result.
Fixed primitives - the hardware-friendly world
Fixed primitives, by contrast, are the world close to the hardware. They comprise 15 types
such as i32 / f64; their syntax is defined by refs-lang 2.1 Type expressions.
They suit situations that need precise control of memory and performance (FFI interfaces, SIMD,
large arrays, hot loops), and their overflow policy is explicit (see the fixed-width arithmetic section of 2.3 ops
arithmetic).
Which one is the default
An unsuffixed literal (42, 3.14) is i32 / f64 when there is no expected type. The auto-promote world is used only where int / float is written out.
let total: int = 0; // int (auto-promote) - explicit
for x in xs { total = total + x; }
// total is auto-promoted even past the i32 range - no silent overflow
let n = 0; // i32 (no expected type)
let perf_critical: i64 = 0; // i64 - follows the expected type
2.1.2. int - arbitrary-precision integer
#[keyword("int")]
#[lang("int_type")]
struct Int { ... } // internal representation: small-int or BigInt
impl Int {
fn new() -> int; // initialized to 0
fn from_i64(n: i64) -> int;
fn from_u64(n: u64) -> int;
fn parse_radix(s: str, radix: u32) -> int | none; // radix parsing (base 10 is FromStr)
fn to_i32(self) -> i32 | none; // the value if in range, none otherwise
fn to_i64(self) -> i64 | none;
fn to_f64(self) -> f64; // always succeeds (may lose precision)
fn pow(self, exp: u32) -> int; // exp: u32 as for fixed-width integers
fn isqrt(self) -> int; // floor(sqrt(self)); panics if self < 0
fn checked_isqrt(self) -> int | none; // none if self < 0
fn div_euclid(self, rhs: int) -> int; // the quotient for which rem_euclid >= 0; panics if rhs == 0
fn rem_euclid(self, rhs: int) -> int; // in 0..rhs.abs(); panics if rhs == 0
fn modpow(self, exp: int, m: int) -> int; // self^exp %% m; panics if m == 0 or exp < 0
fn gcd(self, other: int) -> int; // never negative; gcd(0, 0) == 0
fn lcm(self, other: int) -> int; // never negative; 0 if either operand is 0
...
}
These follow the fixed-width integers' names and panics. pow takes a u32 exponent: a larger one gives at least 2^32 bits for any base but 0 and ±1. modpow takes an int exponent, since its result stays below |m|; the result has the sign of m, like %%. A negative exponent is an error (no modular inverse).
Int is defined in rok::core::num::bigint::Int (Float in
rok::core::num::bigfloat::Float). #[keyword("int")] registers the identifier int as a type keyword,
and this registration applies to every package. User code therefore writes the type as lowercase
int, not PascalCase Int
(refs-lang 6.6 is
authoritative). The compiler finds the int type through #[lang("int_type")].
The key properties of int are as follows. In memory, small
values go through the small-int optimization and are stored directly in a 64-bit word, while large values are
automatically promoted to a heap BigInt representation; all of this is
transparent to the user. The representation is always canonical: a value within the i64 range is in small form, otherwise it is a BigInt, and when an arithmetic result returns to the i64 range it returns to small form. Equal values have equal representations. The lang slot used is int_type (0.4 lang slot
table). An unsuffixed integer literal is int only when the expected type is int.
Arithmetic
Arithmetic between ints never wraps or saturates.
let big: int = 1;
big = big * 1000;
big = big * 1000;
big = big * 1000;
big = big * 1000;
big = big * 1000;
big = big * 1000;
// big = 10^18 - near the end of the i64 range
big = big * 1000;
// big = 10^21 - outside the i64 range, promoted to BigInt
big = big * big;
// big = 10^42 - a larger BigInt, exact
All of the arithmetic traits (2.3 ops) Add, Sub, Mul, Div, Rem are
implemented. Div is integer division (truncating), so
7 / 2 = 3 and -7 / 2 = -3. The comparison traits (2.5
cmp) Eq, Ord, Hash are all implemented as well.
Main uses
int serves in many situations. As the arithmetic default in a REPL or Jupyter,
it guarantees correctness without typing annotations every time; accumulated sums or
products in numeric simulations carry no overflow concern. Cryptographic operations and
large-integer processing work with the standard library alone, without depending on a separate BigInt library.
Limitations
int has limitations. In memory, BigInt promotion requires heap
allocation, so fixed primitives are recommended in hot loops and large arrays.
In performance, it is close to fixed primitives within the small-int range, but necessarily slower after BigInt
promotion. For FFI, extern "C"
functions accept only fixed primitives, so an int must be explicitly converted to i64 or similar before
being passed.
2.1.3. float - floating-point with unbounded exponent
#[keyword("float")]
#[lang("float_type")]
struct Float { ... } // internal: f64 or BigFloat (when the exponent is outside f64)
impl Float {
fn from_f64(x: f64) -> float;
fn try_parse(s: str) -> float | none; // FromStr is implemented too
fn to_f32(self) -> f32 | none; // none only when a finite value is outside the f32 range
fn to_f64(self) -> f64;
...
}
#[keyword("float")] registers the float keyword and routes it to struct Float.
User code therefore sees only lowercase float.
A float value has a 53-bit significand (the same precision as f64) and an unbounded (i64) exponent.
It normally uses the f64 representation; when a finite result exceeds the f64 range (overflow) or becomes
too small toward 0 (underflow), it is promoted to a BigFloat representation instead of becoming inf / 0.
inf/NaNarise only from division by zero or frominf/NaNoperands.%is an exact fmod.Displayhas the same shape as f64's%g; a promoted value is written with an exponent, as in1.5e+400.
Stated limitations
The limitations of float must be stated clearly. Its precision is 53 bits, not unbounded,
so it has the same round-off as IEEE 754 f64. For example, 0.1 + 0.2 not being
0.3 happens in rok as well. Eq is also not
implemented: float does not implement the Eq marker trait and implements only PartialEq, to handle NaN.
See 2.5 cmp for the full hierarchy.
lang slot
The compiler finds the float type through float_type (0.4 lang slot table). An unsuffixed floating-point literal is float only when the expected type is float.
2.1.4. Fixed-width primitives
refs-lang 2.1 Type expressions defines the names of the 15 primitives. This section defines the standard library methods and behavior on top of that.
Integers (10 types)
| Type | Bit width | Range |
|---|---|---|
i8 | 8 | -128 ~ 127 |
i16 | 16 | -32768 ~ 32767 |
i32 | 32 | -2³¹ ~ 2³¹-1 |
i64 | 64 | -2⁶³ ~ 2⁶³-1 |
isize | target pointer width (32 or 64) | target-dep |
u8 | 8 | 0 ~ 255 |
u16 | 16 | 0 ~ 65535 |
u32 | 32 | 0 ~ 2³²-1 |
u64 | 64 | 0 ~ 2⁶⁴-1 |
usize | target pointer width | target-dep |
Floating-point (2 types)
| Type | Bit width | Standard |
|---|---|---|
f32 | 32 | IEEE 754 binary32 (single precision) |
f64 | 64 | IEEE 754 binary64 (double precision) |
Boolean / character (3 types)
| Type | Meaning |
|---|---|
bool | true / false |
char | Unicode scalar value (4 bytes), compiler primitive. Methods in 2.15 |
c_char | C ABI compatible char (1-byte signed); FFI only |
Two groups - language built-ins vs standard library additions
The numeric / boolean / character types of this chapter fall into two groups.
| Group | Members | Registration |
|---|---|---|
| 16 language built-ins | i8/i16/i32/i64, u8/u16/u32/u64, isize/usize, f32/f64, bool, char, c_char, str | Recognized by the language - no #[keyword] needed |
| 2 standard library additions | int, float | Registered with #[keyword(...)] |
Language built-ins are tokens the compiler (lexer/parser) recognizes directly, so the standard library does not register them as keywords. This automatically guarantees that users cannot redefine these names.
The standard library additions (int / float), in contrast, are registered with #[keyword(...)].
The #[keyword] attribute is attached to the Int / Float definitions in this module (refs-lang
6.6 is authoritative).
Standard library form of the built-in primitives
struct i32 { ... } // used directly by users (the lowercase identifier is the type)
struct f64 { ... }
struct bool { ... }
struct c_char { ... }
struct str { ... }
// ... the remaining 11 have the same shape
The standard library only attaches impl blocks to implement traits (Add, Display, Clone, ...).
Recognizing the type itself is the compiler's responsibility.
Arithmetic / comparison trait impls
Each primitive implements the traits of 2.3 ops, 2.4 ops-assign and
2.5 cmp. Every impl carries
#[intrinsic("rok.OP.TYPE")], which binds it directly to a compiler backend PrimOp.
Methods (per primitive)
Each integer / floating-point type exposes the following methods (the example is i32).
impl i32 {
const MIN: i32 = -2147483648;
const MAX: i32 = 2147483647;
fn abs(self) -> i32;
fn pow(self, exp: u32) -> i32;
fn count_ones(self) -> u32;
fn leading_zeros(self) -> u32;
fn trailing_zeros(self) -> u32;
fn rotate_left(self, n: u32) -> i32;
fn rotate_right(self, n: u32) -> i32;
fn swap_bytes(self) -> i32;
fn to_le_bytes(self) -> [u8; 4];
fn to_be_bytes(self) -> [u8; 4];
fn from_le_bytes(bytes: [u8; 4]) -> i32;
fn from_be_bytes(bytes: [u8; 4]) -> i32;
// explicit-overflow variants
fn checked_add(self, rhs: i32) -> i32 | none;
fn checked_sub(self, rhs: i32) -> i32 | none;
fn checked_mul(self, rhs: i32) -> i32 | none;
fn checked_div(self, rhs: i32) -> i32 | none;
fn checked_rem(self, rhs: i32) -> i32 | none;
fn checked_neg(self) -> i32 | none;
fn checked_shl(self, n: u32) -> i32 | none;
fn checked_shr(self, n: u32) -> i32 | none;
fn saturating_add(self, rhs: i32) -> i32; // clamp to i32::MAX or MIN
fn saturating_sub(self, rhs: i32) -> i32;
fn saturating_mul(self, rhs: i32) -> i32;
fn wrapping_add(self, rhs: i32) -> i32; // modular arithmetic
fn wrapping_sub(self, rhs: i32) -> i32;
fn wrapping_mul(self, rhs: i32) -> i32;
fn wrapping_neg(self) -> i32;
fn wrapping_shl(self, n: u32) -> i32;
fn wrapping_shr(self, n: u32) -> i32;
}
Unsigned, floating-point and isize types follow a similar pattern, each exposing only the methods appropriate to it.
The minimum / maximum of an integer type are the associated constants i32::MIN / i32::MAX, not functions. An argument-less inherent method min() / max() would shadow Ord::min / Ord::max (a.min(b)) of the same name.
Floating-point types (f32, f64) provide these additional methods.
impl f64 {
fn nan() -> f64; // f64::NAN
fn infinity() -> f64; // f64::INFINITY
fn neg_infinity() -> f64;
fn epsilon() -> f64; // 2^-52
fn is_nan(self) -> bool;
fn is_infinite(self) -> bool;
fn is_finite(self) -> bool;
fn is_sign_positive(self) -> bool;
fn is_sign_negative(self) -> bool;
fn floor(self) -> f64;
fn ceil(self) -> f64;
fn round(self) -> f64;
fn trunc(self) -> f64;
fn fract(self) -> f64; // self - self.trunc()
fn abs(self) -> f64;
fn signum(self) -> f64; // 1.0 if positive, -1.0 if negative, that zero if ±0.0, NaN if NaN
fn sqrt(self) -> f64;
fn cbrt(self) -> f64;
fn pow(self, exp: f64) -> f64;
fn exp(self) -> f64;
fn ln(self) -> f64;
fn log(self, base: f64) -> f64;
fn log2(self) -> f64;
fn log10(self) -> f64;
fn sin(self) -> f64;
fn cos(self) -> f64;
fn tan(self) -> f64;
fn asin(self) -> f64;
fn acos(self) -> f64;
fn atan(self) -> f64;
fn atan2(self, other: f64) -> f64;
fn sinh(self) -> f64;
fn cosh(self) -> f64;
fn tanh(self) -> f64;
fn to_bits(self) -> u64; // bit pattern
fn from_bits(bits: u64) -> f64;
}
2.1.5. Literal → which type?
The rules of refs-lang 1.6 Numeric literals are:
| Form | Resulting type |
|---|---|
42 | Expected type (integer / floating-point type), else i32 |
3.14 | Expected type (floating-point type), else f64 |
42i32 | i32 |
42u64 | u64 |
3.14f32 | f32 |
3.14f64 | f64 |
'A' | char |
c'A' | c_char (primitive) |
0xFF | Same as decimal - expected type, else i32 |
0xFFi32 | i32 |
Expected type and range checking
Ranges are checked at compile time.
let x: i32 = 42; // i32
let y: i32 = 9999999999; // compile error (outside the i32 range)
let z: f32 = 3.14; // f32
let f: f64 = 1; // f64 - an integer literal fits a floating-point slot
let w: u8 = 256; // compile error (256 ∉ u8 range 0..=255)
let i: i32 = 1.5; // compile error - a floating-point literal cannot fill an integer slot
let big: int = 9999999999; // int
The full conversion matrix is in 2.11 Conversion.
Zero / One
trait Zero { fn zero() -> Self; } // additive identity
trait One { fn one() -> Self; } // multiplicative identity
Every numeric primitive and int / float implement both traits. Iterator::sum / product use them (2.7.1).
2.1.6. Arithmetic result types
int + int = int
Because auto-promote applies, the result is always int itself. No fixed-width inference
takes place.
let a: int = 1000;
let b: int = 2000;
let c = a + b; // int (3000)
i32 + i32 = i32
Fixed width is preserved. A user who wants a wider type must use an explicit conversion.
let a: i32 = 5;
let b: i32 = 3;
let c: i32 = a + b; // i32
let wider: i64 = (a as i64) + (b as i64); // i64
int + i32 (mixed - rejected)
let a: int = 5; // int
let b: i32 = 3; // i32
let c = a + b; // compile error - type mismatch
An explicit conversion is required here.
let c1 = a + int::from_i32(b); // int (a + int(3))
let c2 = (a.to_i32()? + b); // i32 (OK if a is within the i32 range)
This policy blocks silent type mixing. Unlike Julia, rok does not auto-promote; the user states which world to use.
2.1.7. Fixed primitive ↔ int conversion
Conversion between fixed primitives and int is explicit only.
| Conversion | Method | On failure |
|---|---|---|
i32 → int | .into::<int>() or int::from_i32 | Always succeeds (lossless) |
int → i32 | .try_into::<i32>() | Throws OverflowError |
int → i32 (saturate) | .saturating_into::<i32>() | i32::MIN or MAX |
int → i32 (wrap) | .wrapping_into::<i32>() | Modular arithmetic |
int → f64 | .into::<f64>() or int::to_f64 | Rounds to the nearest value (a documented exception to From, 2.11.8). float → f64 is the same exception |
2.1.8. Niche optimization (brief)
refs-lang 2.7 niche defines the mechanism. The primitives that get an automatic niche are:
bool | none is 1 byte (niche value 2), and Ptr<T> | none / ConstPtr<T> | none
are pointer-sized (null is none) (refs-lang 2.7.2). i8..i64,
u8..u64, f32 and f64 themselves get no automatic niche, because every bit
pattern is a valid value.
When an opt-in niche is needed, use the
NonZero<T> / NonNan<F> / Finite<F> / Positive<T> wrappers of 2.2 non-zero.
2.1.9. lang slot mapping
The 16 language built-ins use lowercase struct names. The compiler recognizes them
directly, so no #[keyword] is needed.
| primitive | lang slot | struct name | Registration |
|---|---|---|---|
i8 | i8_type | i8 | Language built-in |
i16 | i16_type | i16 | Language built-in |
i32 | i32_type | i32 | Language built-in |
i64 | i64_type | i64 | Language built-in |
u8 | u8_type | u8 | Language built-in |
u16 | u16_type | u16 | Language built-in |
u32 | u32_type | u32 | Language built-in |
u64 | u64_type | u64 | Language built-in |
isize | isize_type | isize | Language built-in |
usize | usize_type | usize | Language built-in |
f32 | f32_type | f32 | Language built-in |
f64 | f64_type | f64 | Language built-in |
bool | bool_type | bool | Language built-in |
c_char | cchar_type | c_char | Language built-in |
char | char_type | char | Language built-in |
str | str_type | str | Language built-in |
The 3 types added by the standard library are registered as PascalCase structs with #[keyword(...)].
| primitive | lang slot | struct name | Registration |
|---|---|---|---|
int | int_type | Int | #[keyword("int")] |
float | float_type | Float | #[keyword("float")] |
Range<T> (a..b) is defined by 2.7 iter.
2.1.10. Comparison with other languages - design rationale
rok's two-world model (auto-promote int and fixed primitives) draws on several
languages. Smalltalk's SmallInteger ↔ LargeInteger is the prototype of auto-promote,
and the numeric tower of Lisp and Scheme (fixnum / bignum /
rational / real) is another important reference. Python int makes arbitrary precision
the default but has no fixed primitives, while Julia makes fixed
primitives the default. In rok too, literals default to fixed (i32 / f64), and int is opt-in.
The differences from each language: Python offers only int; lacking
fixed width, it depends on NumPy for FFI and performance-critical
work. Julia, like rok, defaults to fixed and requires an explicit BigInt when arbitrary precision is
needed. Rust is fixed only, so the user must always
write the type. rok lets the two worlds coexist:
literals follow the expected type (i32 / f64 if none), and int / float are written where arbitrary precision is needed.