ranged_int
Type safe ranged integer operations for Gleam.
Some features:
- Builtin types for the most used ranges
- Create custom types for your own ranges in a type safe way
- Big integer arithmetic to avoid loss of precision
- Opt-in support for overflow and underflow for all ranges (with both a minimum and maximum limit)
- Partially limited ranges (only a minimum or maximum limit)
- Generic ranged integers for cases where ranges are not known at compile time
Big integers
This library uses the bigi library for handling big
integers. The API operates on big integers and only a few builtin types have helpers for
working with regular Gleam Int
s. This is to ensure correctness on both targets by
avoiding the JavaScript
number type limitations.
When dealing with math operations, it is useful to check the bigi documentation, as the math operations merely delegate to the bigi math functions.
Overflow
Whenever a ranged integer is created from an unranged big integer, or a math operation is performed on a ranged integer, the result is either a new ranged integer, or overflow. In this context overflow includes both overflow and underflow, i.e. the ranged integer’s maximum or minimum limit being broken, respectively.
If wanted, the resulting utils.Overflow
type can then be passed on to the
interface.overflow
or interface.eject
functions to decide how to handle the
situation: the former will calculate a new value with the overflow algorithm, and the
latter will return an unranged big integer. An example with ranged_int/builtin/uint8
,
where these functions are wrapped:
let assert Ok(a) = uint8.from_int(120)
let b = bigi.from_int(160)
uint8.overflow(uint8.add(a, b)) // Uint8(24)
uint8.eject(uint8.add(a, b)) // BigInt(280)
Overflow algorithm
The overflow algorithm is based on how unsigned integer overflow is done in the C language. In C it is not defined for signed integers, but this library defines it for all ranged integers, using 2’s complement for the builtin signed integers. The formula for calculating the overflowed result is
(value - min) % amount_of_values + min
where min
is the minimum allowed value in the range, amount_of_values
is the total
amount of allowed values, and %
is the modulo (not remainder) operation.
In effect, for types that represent unsigned integers or 2’s complement signed integers, this is equivalent to truncating the value to the amount of bits that defines the range.
It’s left up to the user to decide when overflowing makes sense, and to call overflow
when appropriate.
The builtin types
In the ranged_int/builtin
namespace there are builtin types for the most popular
ranges: unsigned and signed integers from 8 to 128 bits, and an unsigned integer of
unlimited size. When implementing your own integer range, you may wish to consult the
sources of these implementations as a template.
Implementing your own type
If none of the builtin types match your use case, you can create your own type with
custom limits. Creating your own type allows for type safety, for example preventing
accidentally passing a regular Int
in place of your ranged integer, or mixing different
kinds of ranged integers in the same List
.
Creating your own type requires that the possible limits are known at compile time. If you don’t know the limits until runtime, you should take a look at the builtin generic ranged integer.
Minimum viable product
Let’s say you want an integer that tracks the years the band Katzenjammer was active. It’s understandable, those were some good years. Let’s make a minimal ranged integer that goes from 2007 to 2015:
import bigi.{type BigInt}
import ranged_int/interface.{type Interface, Interface}
const max_limit = 2015
const min_limit = 2007
pub opaque type Katzen {
Katzen(data: BigInt)
}
This is just a start, and you can’t do anything with this yet. But we define the limits and an opaque type to represent our data. The type is opaque to prevent anyone from outside the module touching it, because that would ruin our correctness guarantees.
Next, let’s define the minimal set of functions to operate with the type:
pub fn to_bigint(value: Katzen) {
value.data
}
pub fn limits() {
interface.overflowable_limits(
bigi.from_int(min_limit),
bigi.from_int(max_limit),
)
}
pub fn from_bigint_unsafe(value: BigInt) {
Katzen(data: value)
}
to_bigint
converts the ranged integer to an unlimited big integer.limits
sets the limits of the integer. There are helper functions ininterface
for constructing these limits.from_bigint_unsafe
constructs the value unsafely from an unlimited big integer. This function is for the library’s internal use and not for other use, as it breaks the guarantees of the library.
Finally, we need an interface structure that tells the library how to use your type:
pub const iface: Interface(Katzen, interface.Overflowable) = Interface(
from_bigint_unsafe: from_bigint_unsafe,
to_bigint: to_bigint,
limits: limits,
)
The interface contains references to the aforementioned functions, that the library
needs to operate on your integers. The second type parameter defines if your integer can
use the overflow operation: if interface.NonOverflowable
was passed, the
type system would prevent that. Note that overflowing is still opt-in, and must be
explicitly called.
Note that due to a Gleam bug, the
functions used in a public constant have to be public, even though limits
and
from_bigint_unsafe
would be better suited as private. If you define the constant as
private and define wrapper functions for all the interface
API functions (described
later), then these functions may also be private.
Now this module is ready to be used:
import gleam/order
import bigi
import gleeunit/should
import ranged_int/interface
import ranged_int/utils
import mvp
pub fn mvp_test() {
let assert Ok(a) = interface.from_bigint(bigi.from_int(2007), mvp.iface)
let assert Ok(b) = interface.from_bigint(bigi.from_int(2009), mvp.iface)
let c = interface.compare(a, b, mvp.iface)
should.equal(c, order.Lt)
}
We can also do math on the integers:
pub fn mvp_add_test() {
let assert Ok(a) = interface.from_bigint(bigi.from_int(2007), mvp.iface)
let b = bigi.from_int(9)
let c = interface.math_op(a, b, mvp.iface, bigi.add)
should.equal(c, Error(utils.WouldOverflow(bigi.from_int(1))))
}
Here we see that the addition failed because it would have overflowed the allowed range.
Prettifying the API
Now, interface.math_op(a, b, mvp.iface, bigi.add)
is really a mouthful. It calls the
interface
module’s generic math operation function, passing the operands, the ranged
integer’s interface, and the math operation itself (from bigi
). It works, but it’s not
nice to use, and is prone to mistakes (you could replace bigi.add
with bigi.subtract
and the type system wouldn’t be able to help you).
To make the type easier to use, we can wrap the functions of the interface
module:
pub fn from_bigint(value: BigInt) {
interface.from_bigint(value, iface)
}
pub fn add(a: Katzen, b: BigInt) {
interface.math_op(a, b, iface, bigi.add)
}
pub fn compare(a: Katzen, b: Katzen) {
interface.compare(a, b, iface)
}
This way, the user can call e.g. mvp.add(a, b)
directly, without having to mess with
the interface and the underlying math functions. The API is now nicer and safer to use!
Additionally, the interface constant and the limits
and from_bigint_unsafe
can be
made private, meaning the only way to use the integer is via the functions you choose to
implement.
We can now see that the integer’s usage is simpler:
import gleam/order
import bigi
import gleeunit/should
import ranged_int/utils
import readme/mvp2
pub fn mvp2_test() {
let assert Ok(a) = mvp2.from_bigint(bigi.from_int(2007))
let assert Ok(b) = mvp2.from_bigint(bigi.from_int(2009))
let c = mvp2.compare(a, b)
should.equal(c, order.Lt)
}
pub fn mvp2_add_test() {
let assert Ok(a) = mvp2.from_bigint(bigi.from_int(2007))
let b = bigi.from_int(9)
let c = mvp2.add(a, b)
should.equal(c, Error(utils.WouldOverflow(bigi.from_int(1))))
should.equal(
mvp2.overflow(c)
|> mvp2.to_bigint(),
bigi.from_int(2007),
)
}
You may wish to check out the sources of the builtin implementations for seeing how they can be done, and for easier copying of the boilerplate.