Roc ships with several numeric types, on top of which you can make new custom number types.
Number Literals
Roc number literals can consist of any combination of the following:
- Number Digits (0-9; zeros at the very beginning of the number never change the number)
- Letter Digits (a-f, optionally capitalized; these represent the digits 10 through 15 in hexadecimal number literals)
- Base Prefix (
0x in front of the number means the digits will be treated as hexadecimal, which is base-16 instead of base-10. The other options are 0o for octal, which is base-8, and 0b, for binary, which is base-2. If there's no prefix, the digits default to being interpreted as decimal, which is base-10. The letter must be lowercase.) - Scientific Notation Suffix (if the number is base-10 and ends in
e___, everything before the e will be multiplied by 10 to the power of the number in the ___. This suffix can't be used if any base prefix is specified.) - A decimal point (can optionally be combined with scientific notation, but cannot be used if a base prefix is specified because uppercase hexadecimal letters after the decimal point would be ambiguous with a type suffix such as
.F64.) - Underscores (the compiler skips over these; they're just for making long numbers easier to read. They can appear in between any digits, including letter digits, and digits after the decimal point, but each underscore must always have a digit on either side of it.)
- Minus sign in front (for negative numbers, not to be confused with the unary negate operator which is an operator that applies to expressions. For example,
-x applies the unary negate operator to x, but -1 is just an ordinary number literal and no negate operation will be executed. This distinction can matter for custom number types.)
Here are some examples of valid number literals:
1
-1
1.23
-123.456e789
0x1abcde42
-1_000_000.123_456_789
Type Suffixes
Roc's compiler will infer the type of your number literal based on how it's used. For example:
List.get(my_list, 3)
Here, the type of 3 will be U64 based on how it's used here, because List.get takes a List as its first argument and a U64 as its second argument.
If you want to specify an explicit type for the number (perhaps for documentation, or maybe because you want an error report if it gets used as any other type), you can add the type you want after a dot at the end. For example, here's how you would specify that the number -12.34 should be interpreted as a Dec:
-12.34.Dec
This not only works with builtin number types, but also with any custom number type you might make—the only requirement is that the type name must be in scope (which you can accomplish using import as long as it's accessible to your module).
Defaulting to Dec
In some situations, a number literal never gets inferred to a specific type. For example:
if 2 > 1 {
# ...
}
Here, 2 > 1 must be evaluated in order to tell whether the if should be taken, yet it's never used in a way that would associate it with any particular number type. In these cases, Roc will use the builtin Dec number type for the literal. So this code will do exactly the same thing as:
if 2.Dec > 1.Dec {
# ...
}
This almost never comes up in practice, unless you're playing around putting numbers into the REPL. In those situations, Dec can be a nice default because it both supports fractions and can give precise answers when doing quick calculations in the REPL.
Builtin Number Types
All of Roc's builtin number types have a fixed size (and that size never varies by what target you're building for), and they only ever perform heap allocations when converting to heap-allocated types like Str.
Integers
Here are Roc's builtin integer types, along with their ranges and sizes in memory:
| Range | Type | Size |
|---|
-128 | I8 | 1 Byte |
127 | | |
0 | U8 | 1 Byte |
255 | | |
-32_768 | I16 | 2 Bytes |
32_767 | | |
0 | U16 | 2 Bytes |
65_535 | | |
-2_147_483_648 | I32 | 4 Bytes |
2_147_483_647 | | |
0 | U32 | 4 Bytes |
(over 4 billion) 4_294_967_295 | | |
-9_223_372_036_854_775_808 | I64 | 8 Bytes |
9_223_372_036_854_775_807 | | |
0 | U64 | 8 Bytes |
(over 18 quintillion) 18_446_744_073_709_551_615 | | |
-170_141_183_460_469_231_731_687_303_715_884_105_728 | I128 | 16 Bytes |
170_141_183_460_469_231_731_687_303_715_884_105_727 | | |
(number below is over 340 undecillion) 0 | U128 | 16 Bytes |
340_282_366_920_938_463_463_374_607_431_768_211_455 | | |
Integers come in two flavors: signed and unsigned.
- Unsigned integers can never be negative. The lowest value they can hold is zero.
- Signed integers can be negative.
Integers also come in different sizes. Choosing a size depends on your performance
needs and the range of numbers you need to represent. At a high level, the
general trade-offs are:
- Larger integer sizes can represent a wider range of numbers. If you absolutely need to represent numbers in a certain range, make sure to pick an integer size that can hold them!
- Smaller integer sizes take up less memory. These savings rarely matter in variables and function arguments, but the sizes of integers that you use in data structures can add up. This can also affect whether those data structures fit in cache lines, which can be a performance bottleneck.
- Certain CPUs work faster on some numeric sizes than others. If the CPU is taking too long to run numeric calculations, you may find a performance improvement by experimenting with numeric sizes that are larger than otherwise necessary. However, in practice, doing this typically degrades overall performance, so be careful to measure properly!
Overflow and Division by Zero
If an integer operation's answer doesn't fit in the integer's type, that's called overflow. For
example, 250.U8 + 10 would be 260, which is bigger than the biggest U8 (255).
In Roc, integer overflow crashes the program, and so does dividing an integer by zero:
too_big = 250.U8 + 10 # Crashes with "Integer addition overflowed"
Some languages instead wrap around when integers overflow, so that 250.U8 + 10 would be 4.
Roc doesn't do this by default, because when it happens by accident, it can cause bugs that are
hard to track down; a program that keeps running with a wrong number can be worse than a program
that stops. (Remember that if the overflow happens during compile-time evaluation,
the crash will be reported as a compile-time error.)
When you want some other behavior, each integer type has methods for that:
| Method | 250.U8 and 10 | Behavior on overflow |
|---|
plus (what + calls) | crashes | Crash |
plus_try | Err(Overflow) | Return an Err instead of crashing |
plus_wrap | 4 | Wrap around |
plus_saturated | 255 | Stay at the highest (or lowest) possible value |
plus_overflows | True | Return whether it would overflow |
There are minus and times versions of each of these too. For division, div_try returns
Err(DivByZero) instead of crashing when dividing by zero.
Fractions
Roc has three builtin types for numbers that can have digits after the decimal point:
| Type | Size | Representation |
|---|
Dec | 16 Bytes | Fixed-point decimal with 18 decimal places |
F64 | 8 Bytes | 64-bit binary floating-point |
F32 | 4 Bytes | 32-bit binary floating-point |
Dec stores numbers in base-10, so it can represent decimal fractions like 0.1 exactly. That means
arithmetic on it gives the answers you'd get by doing the math by hand:
0.1.Dec + 0.2 == 0.3 # True
Dec has exactly 18 digits after the decimal point, and can represent numbers between
-170141183460469231731.687303715884105728 and 170141183460469231731.687303715884105727. Dividing
a Dec by zero crashes.
F32 and F64 are IEEE 754 floating-point numbers, which
store numbers in base-2. Many decimal fractions (including 0.1) can't be represented exactly in base-2,
so floating-point arithmetic often gives answers that are slightly off:
0.1.F64 + 0.2 == 0.3 # False, because the sum is 0.30000000000000004
In exchange, floating-point numbers can represent a much wider range of values (including extremely large and
extremely small numbers), and CPUs have dedicated hardware for floating-point arithmetic, which makes it
much faster than Dec arithmetic. Floats also have special values that Dec doesn't: dividing a float
by zero doesn't crash, but rather evaluates to infinity (or negative infinity), and some operations
evaluate to NaN.
As a rule of thumb, Dec is a good choice for things like money, where exact decimal answers matter,
and floats are a good choice for things like graphics and scientific simulations, where speed matters more
than exact decimal answers.
Ranges
The range operators build a reusable Range value describing
a span of numbers. start..<end excludes end, while start..=end includes
it:
var $sum = 0
for n in 0..<3 {
$sum = $sum + n # runs with n = 0, then 1, then 2
}
Both bounds must have the same type. A U8 range is a Range(U8), a Dec
range is a Range(Dec), and so on. When nothing pins the bounds' type, range
literals default the same way other number literals do.
A range starts with a step of 1. Calling range.step_by(new_step) replaces that
absolute step; calling it a second time replaces the first step rather than
multiplying them. range.size_hint() returns Known(count) when the exact
count fits in a U64, and Unknown otherwise.
Call range.iter() to obtain a forward iterator. Integer and Dec ranges also
support range.iter_rev(), which produces the same lower-anchored members in
reverse order. For example, (5.I64..=12).step_by(2).iter_rev() yields 11, 9,
7, and 5. F32 and F64 support forward ranges but deliberately do not
support iter_rev, because repeated floating-point addition is not exactly
reversible. Once adding the step can no longer produce a larger float, forward
iteration yields the current value once and then ends.
A range is empty when its lower bound is not below (..<) or at (..=) its
upper bound, or when its step is not positive. for loops call the range's
iter method automatically, as in the example above.
Like the other operators, ranges use static dispatch:
start..<end calls range_exclusive_to on the bounds' type, and
start..=end calls range_inclusive_to. The corresponding
range_exclusive_from and range_inclusive_from methods opt a type into exact
reverse iteration. Third-party numeric types can construct the stored value
with Range.custom and define range_iter using their own arithmetic—no
interaction with U64 is required for the stored step.
Custom Number Types
We already saw how you can use optional number type suffixes to specify the type of a number literal instead of letting it be inferred. For example:
-12.34.F64
F64 is a builtin type, but you can use your own custom number type in the same way. Let's say you made a custom number type called Ratio which stores both a numerator and denominator, so it can represent fractions like two-thirds which can't be precisely represented using either decimals or floating-point numbers. You could create a Ratio value like this:
-12.34.Ratio
Here's what will happen if you write this:
- Just based on the syntax here, at compile time, Roc will call
Ratio.from_numeral(...) - It will pass an argument to specify that this is a negative number with the digits
12 before the decimal point and 34 after it. Ratio.from_numeral will return a Try representing whether the specified digits are a valid Ratio. (Some custom number types may have limits on the size of the numbers they store, may or may not support negative numbers, may or may not support digits after the decimal point, etc.)- If
Ratio.from_numeral returned a Try.Ok tag, then that tag's payload will contain the actual number value that these digits resolved to. - If it returned an
Err, then (since this is all being evaluated at compile time), the compiler will report an error for this number literal before the program even runs.
from_numeral is one of Roc's
well-known static-dispatch methods.
Inferred Custom Number Types
Just like with builtin number types, you don't have to annotate your number literals to specify that they use your custom number type. Instead, you can let the compiler infer the type based on usage. For example, let's say you have a function named from_ratio which takes a Ratio. Then you could write:
from_ratio(12.57)
First, the compiler would determine that the argument to from_ratio is a Ratio, and therefore call Ratio.from_numeral specifying that the digits before the decimal point are 12 and the digits after the decimal point are 57. Assuming that returns Ok, the value inside that Ok would be what ended up getting passed to from_ratio.
From there, everything works the same way as in the previous example with the explicit .Ratio suffix. The only difference is that you didn't have to write the word Ratio because the compiler inferred that was the type, and called its from_numeral method.
Custom Number Types and Operators
Operators like + and / work on custom number types the same way they work on builtin ones: they
desugar to method calls. So if Ratio has a div_by method, then a / b
calls Ratio.div_by(a, b) when a is a Ratio.
Here's a Ratio type which supports number literals, +, and /:
Ratio := { numerator : I64, denominator : I64 }.{
from_numeral : Numeral -> Try(Ratio, [InvalidNumeral(Str)])
from_numeral = |numeral| {
if numeral.digits_after_pt_count() > 0 {
Err(InvalidNumeral("Ratio literals must be whole numbers, like 3 or 5"))
} else {
match I64.from_numeral(numeral) {
Ok(n) => Ok({ numerator: n, denominator: 1 })
Err(err) => Err(err)
}
}
}
plus : Ratio, Ratio -> Ratio
plus = |a, b| {
numerator: (a.numerator * b.denominator) + (b.numerator * a.denominator),
denominator: a.denominator * b.denominator,
}
div_by : Ratio, Ratio -> Ratio
div_by = |a, b| {
if b.numerator == 0 {
crash "Ratio division by zero"
} else {
{ numerator: a.numerator * b.denominator, denominator: a.denominator * b.numerator }
}
}
}
Now we can write two-thirds as a fraction:
two_thirds : Ratio
two_thirds = 2 / 3
Here, the literals 2 and 3 are converted to Ratio values using Ratio.from_numeral, and then /
calls Ratio.div_by on them. Since two_thirds is a top-level constant, all of this happens at
compile time, so the program just has the finished Ratio value embedded in it.
That also means problems get reported at compile time. There are two ways a custom number type can reject
something:
from_numeral can return an Err, which rejects the literal itself. Here, Ratio rejects literals with digits after the decimal point, so writing 2.5 / 3 would give a compile-time error with the message "Ratio literals must be whole numbers, like 3 or 5".- An operator's method can
crash, which rejects the operation. Here, 2 / 0 is made of two valid literals, but dividing by zero crashes in div_by. Since this happens during compile-time evaluation, it's reported as a compile-time error too.
Of course, if the same operation happens at runtime (for example, if the denominator came from user input),
then a crash in the operator's method would happen at runtime.
Creating a from_numeral Implementation
A from_numeral method has this type (where T is the custom number type):
from_numeral : Numeral -> Try(T, [InvalidNumeral(Str)])
The Numeral argument describes the literal's exact value, using these methods:
| Method | Returns |
|---|
numeral.is_negative() | True if the literal had a minus sign in front of it |
numeral.digits_before_pt() | A List(U8) of the digits before the decimal point, in base-256 |
numeral.digits_after_pt() | A List(U8) of the digits after the decimal point, in base-256 |
numeral.digits_after_pt_count() | How many base-10 digits the literal had after its decimal point |
The digits are given in base-256, with the most significant digit first, because that uses every bit of
each U8. For example, for the literal 356.5170:
digits_before_pt is [1, 100], because 356 = (1 × 256) + 100digits_after_pt is [20, 50], because 5170 = (20 × 256) + 50digits_after_pt_count is 4, because 5170 has four digits
The count is needed because the digits after the point are stored as a whole number, so the count
is what distinguishes .5170 from .517 or .005170. (For example, 1.0 has a digits_after_pt of [] and a
digits_after_pt_count of 1, whereas 1 has a digits_after_pt_count of 0, which is how you can tell
whether the literal had a decimal point.)
Zero digits are represented by an empty list, so the literal 0 has a digits_before_pt of [],
and 0.25 has a digits_before_pt of [] and a digits_after_pt of [25].
The compiler normalizes the literal before calling from_numeral, so these don't need to be handled separately:
- Underscores are removed, so
1_000 is the same as 1000. - Base prefixes are applied, so
0xff has a digits_before_pt of [255], just like 255 does. - Scientific notation is applied, so
1e3 is the same as 1000, and 1.5e-2 is the same as 0.015.
Since the digits can be arbitrarily long, a custom number type can support numbers of any size. For
example, an arbitrary-precision integer type could accept literals with hundreds of digits.
The simplest way to implement from_numeral is often to delegate to a builtin number type's
from_numeral, as in I64.from_numeral(numeral) in the Ratio example above,
and then convert the result.
If the literal isn't valid for the type, return Err(InvalidNumeral(message)). The compiler will
report the message as a compile-time error, pointing at the literal.
Memory
Numbers are always stored inline. They never involve a heap allocation or a
reference count, and each type's size is the same on every
target (see the tables in Builtin Number Types).
When you store lots of numbers, such as in a big list, the size of each one adds up. A
List(U8) with a million elements takes up about 1 megabyte, whereas a List(U64) with the same
number of elements takes up about 8. Using the smallest type that can hold the values you need
means more of them fit in the CPU's cache, which can
make a big difference when going through lots of them.
Overflow Checks
Since integer overflow crashes, integer +, -, and * each
check whether the result overflowed. Most CPUs can do this check very cheaply, with a single
instruction that's almost always correctly predicted not to jump anywhere. On top of that, when
the compiler can prove an operation can't overflow (for example, because an earlier comparison
already ruled it out), it leaves the check out entirely.
If you actually want wrapping behavior (in a hash function, for example), use the _wrap methods
like plus_wrap. Those never check for overflow, so they compile to the same instruction as
integer addition in C.
Dec vs. Floats
F32 and F64 arithmetic happens in the CPU's floating-point hardware, so it's very fast.
Dec arithmetic has to be done in software, using 128-bit integers. Adding and subtracting
Decs is still fast (about as fast as adding and subtracting I128s), but multiplying and
dividing them takes many more instructions than multiplying or dividing floats. So if your
program does a lot of multiplication or division on fractional numbers, and doesn't need exact
decimal answers, floats will be faster.
Literals
Number literals never cost anything at runtime beyond the number itself. Even for
custom number types, from_numeral gets called at compile time, so the
program contains the finished value.