This is part 3. Part 2 read a program whose variables each had one type and stayed there. Real programs move values between types constantly, and that is where languages quietly differ: what happens to a number that will not fit where it is being put, and whether you were told.
Mica’s answer is one rule with two halves. A value moving to a wider type is
promoted on its own, because nothing can be lost. Every conversion that can
lose something is written down as an explicit as cast. Nothing narrows
silently, and a narrowing that loses bits has a defined answer, which is the
part worth understanding properly.
The example is examples/Cast.
Keep it open.
make -C mica-container/examples/Cast runCast — promotion and explicit casts
Widening happens on its own
constant into int64: 42
int32 value into int64: 1000
float32 value into float64: 2.500000
Narrowing is written down
127 as int8: 127
-42 as int16: -42
3.14159265359 as float32: 3.1415927410
... widened back to float64: 3.1415927410
2^53 + 1 as float64: 9007199254740992
What a narrowing cast costs
3000 as int8: -72
300 as uint8: 44The example, walked
Widening needs nothing
i64v := 42;
source := 1000;
i64v := source;The first line puts a constant into an int64. A constant has no width of
its own: the use that receives it gives it a type, and the value arrives
exactly as written. The second puts a 32-bit value into a 64-bit variable, and
says nothing about it either. Every int32 value is an int64 value, so
there is nothing to decide and nothing to lose. Writing a cast here would add a
word that says only what the two types already say.
Note the difference between the two. For a value in a variable, the types decide whether a conversion is safe. For a constant, the value decides, because the compiler knows it exactly.
Floating point works the same way:
f32v := 2.5;
f64v := f32v;The second line is a plain promotion. The first is a constant again: a real
constant stays exact until its use and is rounded once where it is used, here
to the nearest float32. 2.5 is exact in binary, so the rounding changes
nothing.
Narrowing is written down
source := 127;
i8v := source as int8;
source := -42;
i16v := source as int16;as is the explicit cast, and it is required whenever a value’s type is wider
than its destination. Both values above fit comfortably, so nothing is lost —
but the cast is still there, and that is deliberate. A reader scanning this
file never has to work out where a value quietly changed width; the places
where it could have are the places where as appears.
A constant needs no cast to get there: i8v := 127 compiles, because the
compiler knows the value and the value fits. One that does not fit is refused
before the program exists, naming the domain it missed:
analyzer error 5233: constant value 300 is outside the domain -128..127 of 'int8'What float32 does to π
f64v := 3.14159265359;
f32v := f64v as float32;
f64v := f32v; 3.14159265359 as float32: 3.1415927410
... widened back to float64: 3.1415927410A float32 holds roughly seven decimal digits. π arrives with far more, and
the cast keeps what fits: 3.1415927. The second line is the one to look at.
Widening back to float64 does not restore the lost digits — it only gives
the value room to be printed. A cast changes a value’s type; it cannot recover
its history.
What float64 does to a large integer
i64v := 9007199254740993;
f64v := i64v as float64; 2^53 + 1 as float64: 9007199254740992An int64 and a float64 are both eight bytes, and still this conversion is
written. A float64 carries fifty-three bits of precision, so it holds every
integer exactly only up to 2^53, and an int64 reaches past that: 2^53 + 1 is
the first integer it cannot hold, and the cast rounds it to the nearest one it
can. Leave the cast out and the compiler refuses the assignment, and says what
to write:
analyzer error 5599: assignment to f64v: int64 to float64 is written, never
implicit, because float64 holds every integer exactly only up to 2^53 and int64
reaches past it — convert the int64 value with 'as float64', which rounds once
where the program asks for itWhat a narrowing cast costs
i64v := 3000;
i8v := i64v as int8; 3000 as int8: -723000 does not fit in an int8. Mica does not refuse this, and at the debug
and release tiers it does not trap: the cast keeps the low eight bits of
the two’s-complement representation. 3000 is 0x0BB8, the surviving byte is
0xB8, and read as a signed int8 that is −72.
This is the design worth being explicit about. Mica does not stop you from narrowing a value that does not fit — it stops you from doing it by accident. The information loss is real, it is defined rather than undefined, and it is visible in the source at the exact point it happens.
The checked tier goes one step further and range-checks every narrowing cast,
because a checked build is the one run to find where values stop fitting.
Run this example at that tier, make -C mica-container/examples/Cast run MICA_OPTS=checked,
and it stops on this line:
Mica runtime failure: reason=cast_out_of_range (4), token_stream_index=203
Mica runtime context: file=Cast.mica, line=99, column=17
Mica runtime source: i8v := i64v as int8;A truncation meant on every tier says so by masking first: with i32v
holding 300, (i32v and 255) as uint8 is 44 at every tier, because the value
the cast receives already fits.
One place the rule is stricter: typed constants. A constant declared with a
type holds its value to that type, so after const Ceiling : int32 = 2147483647; the sum Ceiling + 1 computes in int32. Both operands are known
at compile time, so the exact sum is too, and an operation that leaves the type
it computes in is refused outright, on every tier, naming the exact value and a
way to give it room (5421). A constant declared with no type has no type to
leave: 2147483647 + 1 is exactly 2147483648. A cast of a typed constant is
held the same way: Ceiling as int16 is refused when compiling, on every
tier (5233), while 300 as int8, a number with no type, is 44 on every tier.
The unsigned case truncates identically and reads the surviving bits as
unsigned: 300 is 0x012C, the byte that survives is 0x2C, and that is 44.
Try it
1. Delete a cast. Remove as int8 from the 3000 line, so it reads
i8v := i64v;, and rebuild:
analyzer error 5143: incompatible data types in assignment to i8v: int64 to int8The compiler will not narrow on your behalf. A conversion that can lose information exists in this program only where you can read it, and this message is that rule holding its ground. Put the cast back.
2. Predict a truncation, then check it. Change 3000 to 200 and work out
the answer before you build: 200 is above int8’s maximum of 127, so it wraps
to 200 − 256. Rebuild:
3000 as int8: -56−56, as predicted. (The label still says 3000 — it is a fixed string in the
WriteLn beside the calculation. Change it too, and notice that nothing in the
language kept those two in step for you.)
3. Find where float32 gives up. Change the printed precision on the π line
from %.10f to %.20f and rebuild:
3.14159265359 as float32: 3.14159274101257324219Line those digits up against π itself — 3.14159265358979… — and they agree to
3.1415927 and then part company. What follows is not π and not noise: it is
exactly what the stored 32-bit value means, printed in full. Nothing invented
those digits and nothing rounded them away; they were simply never π’s.
What the compiler proved
Every conversion in this program that could lose information is visible in the source, because the compiler refuses to perform one you did not write. Every conversion that cannot lose information happened without ceremony. And the narrowing that does lose information has a defined result rather than an undefined one: the same −72 on x86-64 and ARM64 at the debug and release tiers, and a stop on its own line at the checked tier.
What Mica takes from you here is the ability to be surprised. What it does not take is the ability to truncate deliberately, which systems code needs.
Next
Choices and repetition — part 4: the two ways a program decides and the three ways it repeats, each loop stating what it knows before it runs. Every example behind the series lives in the tutorial repository, in learning order, each with a header that says what it teaches.