Values and Types
This chapter covers the building blocks: variables, the scalar types, and
the rules of arithmetic. Most of it will look familiar, but Goose is
stricter than C about integer widths and looser than Rust about literals,
and it has one rule about % that is different from all three of the
languages you know. Those are the parts to read carefully.
#Declaring variables3.1
Goose has three ways to declare a variable:
fn main() {
let flock_size = 24; // cannot be reassigned
var hatched: i32 = 0; // can be reassigned
const pens = [4, 8, 12]; // cannot be reassigned or written into
hatched += 3;
hatched++;
print(flock_size, " ", hatched, " ", pens);
}
24 4 [4, 8, 12]
The type comes after the name, separated by a colon, and it can almost
always be left out: an un-annotated declaration takes the type of its
initializer. hatched++ is a statement, not an expression, so x = y++ is
not something you can write. That removes a whole class of puzzles about
evaluation order.
The difference between let and const deserves a moment. let stops you
reassigning the name, but the contents of a let array or struct are as
writable as their type allows. const makes the contents read-only too:
fn main() {
let shelf = [1, 2, 3];
shelf[0] = 10; // fine: the elements of a let are writable
print(shelf);
}
[10, 2, 3]
fn main() {
const shelf = [1, 2, 3];
shelf[0] = 10;
}
error: cannot assign through this path (const, or a read-only instantiation, §9.5)
Reassigning a let is an error that names the rule it breaks:
fn main() {
let count = 3;
count = 4;
}
error: cannot assign to let count (§4.4)
The §4.4 refers to the section of the language specification that has the
rule. You will see these references in many error messages.
#Assigned exactly once3.1.1
A let can be declared without an initializer, as long as it is assigned
exactly once on every path before it is read. The compiler checks this:
fn main() {
let honks = 3;
let verdict: i64;
if honks > 2 { verdict = 1; } else { verdict = 0; }
print(verdict);
}
1
Reading a variable that might not have been assigned is an error, for let
and var alike. Goose never gives you an uninitialized value:
fn main() {
let x: i64;
print(x);
}
error: variable x may be used before it is assigned
#Integers3.2
The integer types are i8, i16, i32 and i64, and their unsigned
counterparts u8 to u64. There is no int, usize or size_t. By
convention i64 is the type for counts, sizes and indices, and it is the
type that .len returns and that an unconstrained integer literal becomes.
The narrower types are for data: fields and array elements that should be compact, and loops that need a particular width.
#Literals adapt3.2.1
An integer literal has no type of its own. It takes whatever integer type its context needs, provided its value fits:
fn main() {
let weight: u8 = 200; // 200 fits a u8
let total = 42; // nothing says otherwise: an i64
let sum = weight + 50; // 50 adapts to u8, so this is a u8 add
print(weight, " ", total, " ", sum);
}
200 42 250
A literal that does not fit is a compile error, not a silent truncation:
fn main() {
let x: u8 = 256;
}
error: constant 256 does not fit u8
Integer literals can be written in decimal or hexadecimal (0xFF). There
are no binary literals; use hexadecimal or a shift. A character literal such
as 'A' is just an integer, the value of that byte, so 'A' + 1 is 66.
#Arithmetic happens at the operands' width3.2.2
This is the rule that matters most: an operation is performed at the
width of its operands. Adding two u8 values is an 8-bit addition.
Multiplying two i32 values is a 32-bit multiplication. Nothing is
promoted to a wider type behind your back.
When the two operands have different types, Goose looks for one type that both can be converted to without losing information:
- If one operand is a literal, it adapts to the other operand's type.
- If one type widens into the other, the wider one is used:
u8 + i64is ani64addition. - An integer meeting a float becomes that float type.
- Anything else is an error.
These are the implicit widenings:
An unsigned type widens into a strictly wider signed type, because every
u8 value fits in an i16. But a u32 and an i32 have no common type
that holds every value of both without going to 64 bits, and Goose will not
silently pick one for you:
fn main() {
let eggs: u32 = 7;
let change: i32 = -3;
print(eggs + change);
}
error: operands of + have no common type: u32 and i32 (convert one with `as`)
The fix is to say what you mean, for example eggs as i64 + change, which
converts one operand to i64 and lets the other widen to meet it.
Because literals adapt to the other operand, you will occasionally be surprised in the other direction:
fn main() {
let small: u8 = 200;
let total = small + 1000;
}
error: constant 1000 does not fit u8
The literal tried to become a u8 to match small. Write
small as i64 + 1000, or store small in an i64 first.
#Conversions3.2.3
A conversion that might lose information is never implicit. Narrowing, changing signedness, and converting a float to an integer all need a cast:
fn main() {
let big: i64 = 1000;
let small: i32 = big;
}
error: expected a value of type i32, got i64 (narrowing and sign changes require an explicit `as`)
There are two cast operators:
x as Tconverts, and in a debug build checks that the value survives. If it does not, the program stops. In a release build it truncates, wraps or rounds toward zero without checking.x as! Tconverts without checking, in every build. Use it when losing bits is the point, such as taking the low byte of a hash.
fn main() {
let weight = 4.75;
let whole = weight as i64; // rounds toward zero: 4
let big = 300;
let low_byte = big as! u8; // 300 - 256 = 44, deliberately
let neg = -1;
print(whole, " ", low_byte, " ", neg as! u32, " ", -2.7 as i64);
}
4 44 4294967295 -2
The compiler also tells you when a cast is unnecessary, which happens more often than you would think once you are used to C:
fn main() {
let b: u8 = 7;
let w: i64 = b as i64;
print(w);
}
warning: redundant `as i64`: a u8 converts to i64 implicitly here
7
A cast that chooses something is not redundant and gets no warning. For
example, let id = 5 as u32; picks a type that nothing else gives, and
(a as i64) * b with i32 operands picks the width of the multiply.
#% is Euclidean3.2.4
Here is the rule that differs from C, Odin and Rust alike. In Goose, the
result of % is never negative. For any a and any non-zero b, a % b
is in the range 0 up to |b| - 1:
fn main() {
print(-7 % 3, " ", 7 % -3, " ", -7 / 3);
}
2 1 -2
In C, -7 % 3 is -1. The Goose rule is the one you actually want when
you use % to wrap an index, because x % n is then a valid index into an
array of length n for every x, negative or not. It also means the
compiler can drop the bounds check on arr[x % arr.len], as Chapter 23
shows.
Division still truncates toward zero, as in C, so / and % no longer
pair up for negative numbers. The std module provides the two missing
functions: rem(a, b) is the C-style remainder that pairs with /, and
div_euclid(a, b) is the quotient that pairs with %.
import std;
fn main() {
print(rem(-7, 3), " ", div_euclid(-7, 3));
// which 10-wide pen is position -13 in, and where in it?
let pos = -13;
print("pen ", div_euclid(pos, 10), ", offset ", pos % 10);
}
-1 -3 pen -2, offset 7
On floats, % is C's fmod, so -7.5 % 2.0 is -1.5. The Euclidean rule
is for integers only.
#Overflow3.2.5
What happens on overflow depends on signedness:
- Unsigned arithmetic wraps, in every build. This is the definition of unsigned arithmetic in Goose, not an accident of the hardware, so hash functions and random number generators can rely on it.
- Signed overflow is a bug. A debug build stops with a message, and a release build wraps.
Here is the 32-bit FNV-1a hash of a string, which depends on unsigned wrap-around:
fn main() {
var h: u32 = 2166136261;
for c in "goose" { h = (h ^ c) * 16777619; }
print(h);
}
3265791416
Division by zero aborts in every build.
#Floats3.3
f32 and f64 are IEEE floats. Arithmetic on f32 stays 32-bit. An f32
widens to f64 implicitly, never the reverse, and every integer type
converts to either float type implicitly.
A float literal adapts, like an integer literal, to the float type around
it. Where nothing gives it a type it is an f64. The same goes for an
expression made only of literals and integers, which is computed at
whatever width its destination needs:
fn main() {
let weight: f32 = 4.5;
let geese: i32 = 3;
let total = weight * geese; // the i32 converts: an f32 multiply
let third: f32 = 1.0 / 3.0; // computed in f32
let third64 = 1.0 / 3.0; // an f64
print(total, " ", third, " ", third64);
}
13.5 0.33333334 0.3333333333333333
Floats print in the shortest form that reads back as the same value of
their own type, which is why the f32 third has fewer digits. A whole
number prints with .0, so you can always tell a float from an integer in
output:
fn main() {
print(2.0, " ", 0.1 + 0.2, " ", 1e20, " ", 1e-7, " ", 1.0 / 0.0);
}
2.0 0.30000000000000004 1e+20 1e-07 inf
#Booleans3.4
bool is true or false, stored in one byte. Comparisons produce one,
&& and || short-circuit, and ! negates. if and while need a
bool, and there is no conversion from integers:
fn main() {
let eggs = 5;
if eggs { print("there are eggs"); }
}
error: condition must be bool or an optional, got i64
Write if eggs != 0. The "or an optional" in that message is a hint of
things to come: an optional can be tested directly, and Chapter 11 is
about that.
#Bitwise operators3.5
&, |, ^, ~, << and >> work as you expect, and keep the type of
their operand. >> fills with zeroes on unsigned types and copies the sign
bit on signed ones.
The precedence is Rust's, not C's, and there is one difference that will bite you if you are used to C: bitwise operators bind tighter than comparisons. So this does what it looks like it does:
fn main() {
let flags: u8 = 0x35;
let mask: u8 = 0x0F;
print(flags & mask != 0, " ", flags >> 4, " ", ~mask, " ", flags ^ 0xFF);
}
true 3 240 202
In C, flags & mask != 0 parses as flags & (mask != 0), which is a
classic bug. In Goose it is (flags & mask) != 0.
#Named constants and type aliases3.6
A let at the top level of a file, with no type written and a constant
initializer, is a named constant. A named constant behaves exactly like
the literal it stands for: it adapts to any type its value fits, and it can
be used as an array size.
let FLOCK_SIZE = 24;
let HALF = FLOCK_SIZE / 2;
fn main() {
let small: u8 = FLOCK_SIZE; // adapts, as 24 would
let pens: i32[HALF] = [0; HALF]; // an array of 12 zeroes
print(small, " ", pens.len);
}
24 12
Give the declaration a type, let FLOCK_SIZE: i64 = 24;, and it becomes an
ordinary i64 value that no longer adapts.
type Name = T; gives a type a second name. An alias is the same type, not
a new one, so values convert freely between the two names:
type Grams = u32;
fn main() {
let egg: Grams = 61;
let plain: u32 = egg;
print(egg + plain);
}
122
#Exercises3.7
- Without running it, work out what this prints. Then run it and check.
goose check
fn main() {
let a: u8 = 250;
let b: u8 = 10;
let c: i64 = 250;
print(a + b, " ", c + b, " ", -1 % 5, " ", -1 / 5);
}
-
Write a function-free program that converts a temperature of
-40degrees Celsius, stored in ani32, into Fahrenheit as anf32, usingf = c * 9 / 5 + 32. Make sure the division happens in floating point. How many casts do you need? -
A clock shows hours
0to23. Write the expression for the hour that iskhours before hourh, for anyk, and check it forh = 3,k = 50.