The math module
sysl.math — the Float trait over both widths, Signed and Bits over the open integer family, the constants, min/max/clamp over anything ordered, the float comparisons, and the integer arithmetic above the operators.
sysl.math is four files and three traits, and the interesting thing about it is that the three
traits are written three different ways — because the types they cover are three different shapes.
Above them sit the free functions, which are not members of anything and say in a bound what they
need. It requires no capability at all: every name here is reachable under no alloc and on a target
with no operating system.
@no_alloc
@no_os
import sysl.math.{Float, Bits, min, pi}
var two = 2.0
var u = 0b1011u8
print(two.sqrt(), pi, min(3, 7))
print(u.count_ones(), u.rotate_left(1u32))
1.41421 3.14159 3
3 22
The constants
import sysl.math.{pi, tau, e, sqrt2, ln2, ln10}
print(pi, tau, e)
print(sqrt2, ln2, ln10)
3.14159 6.28319 2.71828
1.41421 0.693147 2.30259
Those are the full-precision values printed by %g‘s six significant digits, which is what
print does with a float. The constants themselves carry every digit a real
holds.
All six are real, which is the width they are correct to and the width arithmetic reaches for
unless a program says otherwise. An f32 program writes f32(pi): the conversion is a constant the
compiler folds, so it costs nothing at run time, and one declaration per constant is better than two
that could drift apart.
They are digits rather than expressions. tau is written out rather than as 2.0 * pi, because a
constant is a value and not a computation — and the last bit of a doubled binary64 is not always the
last bit of the correctly rounded product.
tau earns its place beside pi because it is the one that appears in the arguments to sin and
cos: a whole turn is tau, a quarter turn is tau / 4.0, and no factor of two has to be carried
around to remember it.
Float
trait Float: Eq + Ord + Neg + Add + Sub + Mul + Div
// The type's own values, asked without a receiver.
zero() -> Self
one() -> Self
max_value() -> Self
epsilon() -> Self
infinity() -> Self
nan() -> Self
pi() -> Self
// Required — each width binds these to its own libm entry point.
sqrt(self) -> Self
cbrt(self) -> Self
exp(self) -> Self
exp2(self) -> Self
ln(self) -> Self
log2(self) -> Self
log10(self) -> Self
pow(self, exponent: Self) -> Self
hypot(self, other: Self) -> Self
sin(self) -> Self
cos(self) -> Self
tan(self) -> Self
asin(self) -> Self
acos(self) -> Self
atan(self) -> Self
atan2(self, x: Self) -> Self
sinh(self) -> Self
cosh(self) -> Self
tanh(self) -> Self
asinh(self) -> Self
acosh(self) -> Self
atanh(self) -> Self
floor(self) -> Self
ceil(self) -> Self
round(self) -> Self
trunc(self) -> Self
fmod(self, divisor: Self) -> Self
abs(self) -> Self
copysign(self, sign: Self) -> Self
to_radians(self) -> Self
to_degrees(self) -> Self
// Answered by the trait, once, for both widths.
signum(self) -> Self
recip(self) -> Self
square(self) -> Self
log(self, base: Self) -> Self
lerp(self, to: Self, t: Self) -> Self
is_nan(self) -> bool
is_infinite(self) -> bool
is_finite(self) -> bool
A trait rather than two sets of functions. The shape was forced by a language decision that has
since been reversed, and is kept because it is the better one anyway. sysl had no
overloading when this was written, so free functions could not call the
square root of a real and the square root of an f32 by one name — it would have needed sqrt
and sqrtf the way C does. Two free sqrts would resolve correctly today.
What the trait still buys is the half overloading does not: a member that is arithmetic over the
others — the logarithm in an arbitrary base, the hypotenuse — is written once as a default and
inherited by both widths, where two free functions would need it twice and could disagree. Dispatch
on the receiver is worth having for its own sake too: x.sqrt() is the same three words whichever
width x is, and changing a declaration from f32 to real sends nobody editing call sites.
The split between what is required and what is answered is where the mathematics is. A method
whose result C computes — a range-reduced sine, a correctly rounded root — is required, and each width
binds it to its own libm entry point. A method that is arithmetic over the others is a default,
written once and inherited by both. So log in an arbitrary base exists in exactly one place, and
adding a third floating-point width would be 38 bindings and no new mathematics.
import sysl.math.{Float, tau, e}
var two = 2.0
var three = 3.0
var eight = 8.0
var hundred = 100.0
var eightyone = 81.0
var quarter = tau / 4.0
print(two.sqrt(), eight.cbrt())
print(e.ln(), two.exp2())
print(hundred.log10(), eight.log2(), eightyone.log(three))
print(two.pow(10.0), three.hypot(4.0))
print(quarter.sin(), quarter.cos())
1.41421 2
1 4
2 3 4
1024 5
1 6.12323e-17
Four of those lines are decisions rather than arithmetic.
ln is spelled for what it is, rather than as C’s bare log — which reads as though it were the
general one and is the single most common way to get a base wrong. log(base) is the general one, and
it is the default written over ln.
log2 and log10 are required separately rather than left to that default, because reading them
back through a ratio loses digits that libm keeps.
hypot is not (x*x + y*y).sqrt(). The squares of operands near the top of the range overflow to
infinity when the answer itself is perfectly representable; libm’s does the scaling that avoids it.
cos(tau/4) is 6.12e-17 and not zero, which is not a bug in anything — a quarter turn is not
exactly representable in binary, so the argument handed to cos is not exactly π/2. This is the
ordinary floating-point fact, and the page shows it rather than choosing an example that hides it.
Rounding, sign, and the rest
import sysl.math.Float
var half = 2.5
var neg = -2.5
var three = 3.0
var seven = 7.5
var four = 4.0
var zero = 0.0
var ten = 10.0
var one = 1.0
print(half.floor(), half.ceil(), half.round(), neg.trunc())
print(neg.abs(), three.copysign(-1.0))
print(seven.fmod(2.0))
print(four.recip(), three.square(), zero.lerp(ten, 0.25))
print(one.atan2(one).to_degrees())
print(neg.signum(), zero.signum())
2 3 3 -2
2.5 -3
1.5
0.25 9 2.5
45
-1 0
All four rounding functions answer in the float’s own type. A floor that returned an integer
would have no answer for the operands that do not fit one — the caller who wants an integer is the
caller who knows the range, and casts.
round goes away from zero at a half, which is C’s rule and not the banker’s rounding a printed
value gets. trunc goes towards zero, which is what a cast already does.
fmod is not %. The integer types have Rem and the floats do not, because a float remainder is
a library operation rather than an instruction. It keeps the sign of the receiver.
atan2 takes the two coordinates rather than their ratio, which is what lets it tell the four
quadrants apart, and the receiver is the vertical coordinate — matching the argument order the name
has had since Fortran.
lerp is written a + (b - a) * t rather than a * (1 - t) + b * t. The second form is exact at
t = 1 and this one is exact at t = 0, and starting where you said you would start is what a caller
notices.
signum answers a zero with that zero rather than with a one it cannot justify: it is a pair of
comparisons, so it does not see a negative zero, and a NaN satisfies neither comparison and leaves by
the same arm holding itself. abs and copysign are the other two readings of a sign, and those
do see a negative zero, because they work on the bit.
Hyperbolics, and where they have no answer
import sysl.math.Float
var one = 1.0
var two = 2.0
var half = 0.5
var below = 0.5
var outside = 2.0
var neg = -3.0
print(one.sinh(), one.cosh(), one.tanh())
print(one.sinh().asinh(), two.cosh().acosh(), half.tanh().atanh())
print(neg.asinh(), below.acosh().is_nan(), outside.atanh().is_nan())
1.1752 1.54308 0.761594
1 2 0.5
-1.81845 true true
The three inverses are the ones with domains. acosh wants an argument of at least one, atanh
one strictly between −1 and 1, and asinh is defined everywhere — which is why the third line asks
two of them for an answer they do not have.
Outside a domain the answer is a NaN, not a trap. That is the same thing asin and acos do
outside theirs: a float has a value meaning no answer, and the library hands it back rather than
stopping the program. It is quiet, so a program that can reach outside a domain should say what it
does about it — is_nan is the test, and a NaN that flows on will keep failing every comparison it
meets.
The type’s own values
import sysl.math.Float
var f: f32 = 2.0f32
print(real.epsilon(), real.max_value())
print(f.sqrt(), f32.pi(), f32.epsilon())
2.22045e-16 1.79769e+308
1.41421 3.14159 1.19209e-07
These are members with no receiver, reached through the type, and they are what makes the defaults
possible at all: a signum needs a one to answer with and a recip needs a one to divide, and
neither can be written in a body shared by two widths unless there is a way to ask a type for its own
one. Self.one() is that way, so a routine bounded by [T: Float] can build a value of a width it
has never met.
epsilon is what a convergence test should be written against — a loop that stops when two iterations
agree to within a few epsilons stops at the right point at both widths, where a literal tolerance
does not.
The two that no literal spells get bodies that say what they are: infinity() is 1.0 / 0.0 and
nan() is 0.0 / 0.0. Dividing a float by zero is not the error dividing an integer by zero is — IEEE
754 says what the answer is, and this is where the library says it too.
The only thing that stays per width beyond the libm bindings is the pair of angle conversions, whose factor is π over 180 — and 180 is not something a zero and a one can be built up into.
NaN, and what compares to it
import sysl.math.{Float, infinity, nan, min}
var one = 1.0
print(nan().is_nan(), infinity().is_infinite(), one.is_finite())
print(min(nan(), one).is_nan(), min(one, nan()).is_nan())
true true true
true false
is_nan is self != self — the only value not equal to itself, which is both the definition and
the test, and the reason an equality check cannot be used to look for one. is_infinite asks whether
the magnitude exceeds the largest finite value, a condition only the two infinities meet and which a
NaN fails the way it fails every comparison.
That second line is the one to read carefully. min(nan(), 1.0) is a NaN and min(1.0, nan()) is
1.0, and neither is a bug. A NaN is less than nothing and greater than nothing, so the single
comparison each of these makes is false whichever way round the operands go, and both fall through to
the arm holding the first argument.
That is said here rather than worked around. Propagating a NaN from one argument position while
dropping it from the other is what C’s fmin was criticised for — and the alternative is a comparison
per argument on every call, to spare a case a caller can see coming. A program that must reject a NaN
tests for one.
min, max and clamp are not Float‘s
import sysl.math.{min, max, clamp}
var half = 2.5
print(min(3, 7), max(3, 7), clamp(12, 0, 10))
print(min("b", "a"), max(half, 1.5))
print(clamp(-5, 0, 10), clamp(5, 0, 10))
print(min((1, 2), (1, 3)))
3 7 10
a 2.5
0 5
(1, 2)
They are generic over Ord, and that is why they are in a file of their own. Nothing about
picking the smaller of two things is arithmetic: min over the integers is the same three words as
min over the floats, over a string, over a tuple, and over any type a program has written an
lt for. A version living on Float would have been the narrowest useful one and would have left
every other type asking why.
A tie answers with the first argument. min is written if b < a then b else a rather than the
other way round, and for types whose equality does not mean identity — a record ordered on one field,
a pair ordered on its first — which of two indistinguishable values comes back is something a caller
can observe. Taking the first is what makes a fold over a sequence stable.
clamp tests the low end first, so an inverted range answers low. There is no check that the two
bounds are the right way round: a bound is nearly always a constant or a length at the call site, and
a contract is the tool for saying so where it is not.
Comparing floats that were computed
Binary floating point does not hold 0.1 + 0.2 == 0.3, so a program that checks a computation
against a written-down number needs a tolerance rather than an equality:
import sysl.math.{approx_eq, approx_eq_rel, nan, infinity, Float}
print(approx_eq(0.1 + 0.2, 0.3, 1e-12), approx_eq(1.0, 1.5, 0.1))
print(approx_eq(infinity(), infinity(), 0.0), approx_eq(nan(), nan(), 1.0))
print(approx_eq_rel(1e18, 1e18 + 1000.0, 1e-6), approx_eq_rel(1.0, 1.5, 1e-6))
true false
true false
true false
approx_eq takes an absolute tolerance, which is what a delta comparison is. approx_eq_rel
scales it to the larger of the two operands, so the tolerance reads as a fraction — 0.001 means
“within a tenth of a percent” whatever the magnitude — and is the one to reach for when the values
could be any size. Near zero the relative form becomes strict, since the scale goes to zero with the
operands; that is the case the absolute form is for.
Two behaviours are worth knowing rather than discovering. Identical infinities are close, because
both functions test equality before subtracting — inf - inf is a NaN, and the subtraction alone
would call a value unequal to itself. And a NaN is close to nothing, including another NaN,
whatever the tolerance, which falls out of the comparisons and agrees with ==.
Each has an assertion beside it, which stops the program and names both values and the tolerance
rather than answering bool:
import sysl.math.assert_approx_eq
assert_approx_eq(0.1 + 0.2, 0.3, 1e-12)
assert_approx_eq(0.1 + 0.2, 0.3, 1e-12, "the sum")
print("both held")
both held
assert_approx_eq_rel is the same against the relative test. They live here rather than beside
assert_eq in the core, and that is forced rather than chosen: Float is
declared in this module and reaches Eq, Ord and the arithmetic traits in the core, so sysl.math
depends on sysl — and a float assertion written in the core would point an edge back the other way,
which the module graph refuses. Anyone writing float assertions imports this
module already, since it is where the float functions are.
Mixing types is refused, as everywhere else in the language:
import sysl.math.min
print(min(1, 2.0))
'b' of 'sysl.math.min' is int, but real was given
Signed and Bits — a different mechanism
trait Signed
abs(self) -> Self
signum(self) -> Self
trait Bits
count_ones(self) -> u32
count_zeros(self) -> u32
leading_zeros(self) -> u32
trailing_zeros(self) -> u32
leading_ones(self) -> u32
trailing_ones(self) -> u32
reverse_bits(self) -> Self
rotate_left(self, n: u32) -> Self
rotate_right(self, n: u32) -> Self
Neither of these has an impl block anywhere, and neither could. Float is a trait with an impl
per width because there are exactly two widths. The integers are an open family:
i5 and u12 are types a program may name, so there is no finite list of scalars to write an impl
for, and five blocks covering i8 through isize would leave i128 and every narrow width without
one — a worse surface than none at all.
So membership is the compiler’s, by the same rule that makes an int an Add without anything
having written impl Add for int. What is in the source file is the part a declaration can say: the
names, the signatures, and what each one means.
The trait still has to be in scope to be reached. That is what a compiler-provided membership does not change — it settles which types have the member, not which files may name it:
import sysl.math.pi
var x = 2.0
print(pi, x.sqrt())
real has 'sqrt' from sysl.math.Float, and that trait is not in scope here — import it to reach the member
Importing the module is not the same as importing the trait: pi is in scope on that line and
sqrt is not.
Signed covers the signed widths only; Bits covers both signednesses, because a bit pattern is a
bit pattern:
import sysl.math.Signed
var u = 5u8
print(u.abs())
type 'byte' has no method 'abs'
Signed
import sysl.math.Signed
var n = -42
var z = 0
var m: i32 = -2147483647 - 1
var big: i128 = -170141183460469231731687303715884105727
print(n.abs(), n.signum(), z.signum())
print(m.abs())
print(big.abs(), big.signum())
42 -1 0
-2147483648
170141183460469231731687303715884105727 -1
At the most negative value, abs answers that value again. The magnitude is one larger than the
width can hold, and plain integer arithmetic in sysl wraps — so this is what the
two’s-complement negation beside it already does, and the alternative would be a member that traps
where the - next to it does not.
signum answers in Self rather than a fixed width, so it can be multiplied back into a value of the
same type — which is what a signum is usually for.
Bits
import sysl.math.Bits
var u = 0b1011u8
var zero8 = 0u8
var all8 = 255u8
var wide: u32 = 1u32
print(u.count_ones(), u.count_zeros())
print(u.leading_zeros(), u.trailing_zeros())
print(u.leading_ones(), u.trailing_ones())
print(u.reverse_bits(), u.rotate_left(1u32), u.rotate_right(1u32))
print(zero8.leading_zeros(), zero8.trailing_zeros())
print(all8.leading_ones(), all8.count_zeros())
print(wide.leading_zeros(), wide.rotate_right(1u32))
3 5
4 0
0 2
208 22 133
8 8
8 0
31 2147483648
Every one of these is a shift-and-mask loop a program would otherwise write, and every one is a
single instruction on the machines sysl targets — count_ones is popcnt, leading_zeros is
lzcnt or clz, the rotations are rol and ror. That is the case for a member rather than a
comment recommending a loop.
Zero answers the width, at both ends, rather than being undefined the way the bare machine
instruction is on some targets — 0u8.leading_zeros() is 8 and so is its trailing_zeros. That is
what makes leading_zeros usable as “how far left is the top bit” with no special case in front of
it, and what makes the same program print the same number on every machine.
count_zeros is worth having rather than left to a subtraction, because the width is the fact the
caller would otherwise have to know and this is the member that already knows it. leading_ones and
trailing_ones are the same pair counted over set bits, so -1 answers the width and 0 answers
nothing.
The rotation amount is taken modulo the width, so every amount is meaningful and none of it is
undefined — which is the whole reason to call this rather than write (x << n) | (x >> (w - n)), an
expression that shifts by the width when n is zero and is undefined when it does. The amount is a
u32 rather than Self, because how far to rotate is a count of bit positions and not a value of the
type being rotated: a narrow receiver would otherwise be unable to state an amount its own width
cannot hold.
reverse_bits is not a byte order. The width is the receiver’s, so it is a different function at
every type.
There is deliberately no swap_bytes
import sysl.math.Bits
var w: u32 = 7u32
print(w.swap_bytes())
type 'uint' has no method 'swap_bytes'
Reversing the byte order needs a whole number of bytes and at least two, so a u24 has no answer to
it and a u4 has none either. Every member of Bits is total over every integer type, because a
[T: Bits] body is written once and instantiated later — a member that worked at u32 and not at
u24 would turn a bound that was supposed to have proven an operation into a failure at somebody
else’s instantiation.
A program that means to reorder bytes has the shifts, and knows its own width while writing them.
The arithmetic above the operators
Six free functions sit above Signed and Bits, and they are what the operators do not give you:
pow(base, exponent) gcd(a, b) lcm(a, b)
divmod(a, b) -> T, T is_power_of_two(x) next_power_of_two(x) -> Option[T]
@no_alloc
@no_os
import sysl.math.{pow, gcd, lcm, is_power_of_two, next_power_of_two}
print(pow(2, 10), pow(3, 0u32))
print(gcd(12, 18), lcm(4, 6), lcm(21, 6))
print(is_power_of_two(8), is_power_of_two(0))
print(next_power_of_two(17).unwrap_or(-1))
1024 1
6 12 42
true false
32
Free functions, and the bound is the specification
None of these is a trait member, and the reason is the one this page already gave for min. A
member has to belong to a trait, and the trait is what decides which types have it — but nothing here
is a question about a bit pattern, which is what Bits collects, nor about having a sign, which is
what Signed collects.
What each one actually needs is written in its own bound instead:
| function | bound |
|---|---|
pow | Mul + Ord |
gcd | Rem + Eq + Ord + Sub |
lcm | Rem + Eq + Ord + Sub + Div + Mul |
divmod | Div + Rem |
is_power_of_two | Bits + Ord |
next_power_of_two | Bits + Ord + Shl + Sub |
Read gcd‘s: Rem because Euclid’s algorithm is a remainder loop, Sub and Ord because it has to
answer a magnitude — and deliberately not Signed, which would have shut the unsigned widths out
of a function that serves them perfectly well.
That is also the thing a trait member could not have offered. The memberships of Bits and Signed
are the compiler’s, and no program can join them — so a member would have been closed to a
program’s own numeric type forever, where a bound is satisfied by whoever satisfies it.
When one is not, the bound is what says so, by name:
import sysl.math.is_power_of_two
print(is_power_of_two(8.0))
'sysl.math.is_power_of_two' requires its type parameter 'T' to implement 'sysl.math.Bits', but real does not
And these are ordinary names in the module, so they are reached the way pi is — by importing them,
not by importing the module:
import sysl.math.pi
print(gcd(12, 18), pi)
undefined function 'gcd'
pow
@no_alloc
@no_os
import sysl.math.pow
print(pow(2, 10), pow(-3, 3u32))
print(pow(2, 64u32), pow(2u8, 9u32))
1024 -27
0 0
The exponent is a u32 and not a T, because how many times to multiply is a count rather
than a value of the type being multiplied — the same reason rotate_left takes one. A negative
exponent has no answer among the integers, and saying so in the type is better than a trap:
import sysl.math.pow
print(pow(2, -1))
the literal -1 does not fit uint
An overflowing power wraps, as every other integer operation in the language does. pow(2, 64)
at int is 0 and pow(2u8, 9) is 0, both arrived at honestly — the doubling that overflows is a
* like any other. A program that needs to know writes the check it needs.
The implementation is repeated squaring, so the exponent costs a logarithmic number of multiplies rather than a linear one.
This one is the integers’. Its bound admits a real, and the body does not:
import sysl.math.pow
print(pow(2.0, 10u32))
cannot initialize 'acc': declared real but the value is int
A float raises through Float‘s own member — 2.0.pow(10.0) — which takes its exponent as
a Self rather than a count, because a float exponent is a meaningful thing to have and an integer
one is not the same operation.
gcd and lcm
@no_alloc
@no_os
import sysl.math.{gcd, lcm}
print(gcd(12, 18), gcd(-12, 18), gcd(12, -18), gcd(-12, -18))
print(gcd(7, 0), gcd(0, 7), gcd(12u8, 18u8))
print(lcm(4, 6), lcm(0, 5), lcm(21, 6))
6 6 6 6
7 7 6
12 0 42
Neither ever answers a negative, and how gcd gets there is worth reading. The magnitude is
taken at the end rather than at the start: % truncates, so Euclid’s loop over negative operands
arrives at the right divisor already, carrying the wrong sign, and one comparison at the end fixes
it. Doing it at the start would have meant negating both operands first — two operations instead of
one, and a signed-only one at that.
Because the negation is written zero - x rather than -x. Unary minus requires Neg, which the
language gives to the signed integers alone, while Sub is every integer’s — so that one
spelling is what keeps gcd(12u8, 18u8) on the third value of the second line.
gcd(x, 0) is x, which is both the identity the loop already produces and the answer number theory
gives: everything divides zero, so the largest divisor the pair has in common is x‘s own.
lcm divides before multiplying — a / gcd(a, b) * b and not a * b / gcd(a, b) — because the
product of the operands overflows at half the width where the answer itself would fit, and the
quotient is exact by construction, gcd being a divisor of a. That is the same reason hypot is
not sqrt(x*x + y*y). A zero operand answers zero rather than dividing by one, since gcd(0, 0) is
zero and reaching the division would be a division by it.
divmod
@no_alloc
@no_os
import sysl.math.divmod
show()
val q, r = divmod(17, 5)
val nq, nr = divmod(-17, 5)
print(q, r)
print(nq, nr)
show()
3 2
-3 -2
Both are the operators’ own, so both truncate toward zero and the remainder takes the sign of the dividend. The reason to call this rather than write the two operators is that it says once what a reader would otherwise have to check twice: that the same two operands feed both.
It answers a result list and not a tuple, because the pair travels from callee to caller and nothing afterwards needs to hold the two together. A caller that does need to hold them writes the tuple itself.
A binding naming several things is a local form — its parts have nowhere to write a type — so the top of a program, which is a body, takes one exactly as a function does:
import sysl.math.divmod
val q, r = divmod(17, 5)
print(q, r)
3 2
Asking for the module’s storage instead is what has nowhere to put the types, and is refused:
import sysl.math.divmod
static val q, r = divmod(17, 5)
print(q, r)
a module-level 'val' states its type, and a binding that names several things has nowhere to write one — declare 'q' and 'r' separately
is_power_of_two and next_power_of_two
@no_alloc
@no_os
import sysl.math.{is_power_of_two, next_power_of_two}
print(is_power_of_two(8), is_power_of_two(0), is_power_of_two(-8), is_power_of_two(1))
print(is_power_of_two(-128i8), is_power_of_two(128u8))
print(next_power_of_two(17).unwrap_or(-1), next_power_of_two(16).unwrap_or(-1))
print(next_power_of_two(0).unwrap_or(-1), next_power_of_two(-5).unwrap_or(-1))
print(next_power_of_two(200u8).is_some(), next_power_of_two(128u8).unwrap_or(0u8))
print(next_power_of_two(100i8).is_some(), next_power_of_two(60i8).unwrap_or(0i8))
true false false true
false true
32 16
1 1
false 128
false 64
is_power_of_two is one set bit and a comparison against zero, and the second line is why the
comparison is there. -128i8 has exactly one bit set — count_ones answers 1 — and it is
emphatically not a power of two. A comparison costs nothing and is the whole of the difference.
next_power_of_two answers an Option, and that is what totality costs here. 200u8‘s next
power is 256, which no u8 holds. Wrapping to zero would be silently wrong, and a trap would make
a library function rule that a caller’s arithmetic is a bug — so the absence goes in the type,
where a caller has to look at it. Values at or below one answer 1, negatives included: one is the
smallest power of two and every negative is below it.
The last two lines are the same width answering differently, and they are the reason the
implementation works at all. 128 fits a u8 and does not fit an i8, because the top bit of a
signed byte is the sign — so next_power_of_two(100i8) is none while next_power_of_two(128u8) is
128. The body does not ask which T it has, which is a question a generic body has no way to put:
it performs the shift and compares the result against zero, since a signed shift that has reached
the sign bit comes back negative and an unsigned one never does.
Next: sysl.time — instants, durations, and the calendar between them.