Lesson 04 / 12
Primitive Types
Integer, floating-point, boolean, character, and tuple — five primitive types that produce five separate values when run as expressions; but there is a sixth type too — (), the type with a single value — and that what a statement produces is not nothingness but exactly this type's one value is shown by printing the two values side by side in the same run.
Published Updated
In this lesson
- Shows by running it the value each of the five primitive types (integer, floating-point, boolean, character, tuple) produces as an expression
- Measures integer overflow's defined behavior with the wrapping method, and that a character is not a byte with a length comparison
- Proves that the () type has a single value and that what a statement produces is this value
Contents
The previous lesson took up the binding itself but did not enter the variety of bound values —
only integers were seen. This lesson fills that gap: what Rust’s five primitive types
(integer, floating-point, boolean, character, tuple) produce when run as expressions, and why
the sixth type standing beside these five — () — is not “valueless,” but, on the contrary, a
type in its own right.
Each of the five primitive types carries a fixed boundary built on top of the previous lesson’s immutability discussion. An integer has a specific bit width, and that width determines the largest and smallest values it can hold; a floating-point number carries an approximate precision; a boolean can hold only one of two states; a character represents a single Unicode code point; a tuple consists of a fixed number of components, and that number is known at compile time. Each of these boundaries is measured separately in this lesson.
Five Types, Five Values
The source below binds one value from each of the five primitive types and prints each one; it then separates the tuple’s components into separate names through destructuring.
// primitive_types.rs — the value each primitive type produces as an expression
fn main() {
let integer: i32 = -7;
let floating: f64 = 2.5;
let boolean: bool = floating > 2.0;
let letter: char = 'r';
let pair: (i32, bool) = (integer, boolean);
println!("integer: {}", integer);
println!("floating-point: {}", floating);
println!("boolean: {}", boolean);
println!("character: {}", letter);
println!("tuple: ({}, {})", pair.0, pair.1);
let (first, second) = pair;
println!("destructured first: {}", first);
println!("destructured second: {}", second);
let unit: () = ();
let mut counter = 0;
let statement_result = { counter += 1; };
println!("explicitly typed unit: {:?}", unit);
println!("what a statement produces: {:?}", statement_result);
println!("are the two the same value: {}", unit == statement_result);
println!("counter now: {}", counter);
}
integer: -7 floating-point: 2.5 boolean: true character: r tuple: (-7, true) destructured first: -7 destructured second: true explicitly typed unit: () what a statement produces: () are the two the same value: true counter now: 1
The first five print lines repeat the previous lesson’s definition that “an expression
produces a value” across five separate types: -7 is an integer value, 2.5 is a
floating-point value, floating > 2.0 produces a boolean value, 'r' is a single character,
(integer, boolean) is a tuple holding two values together. boolean itself is worth
noticing too: what produces it is not a constant but a comparison expression — in Rust,
comparison operators are themselves expressions too, and return a value of type bool.
The type annotations for these five values (i32, f64, bool, char, (i32, bool)) are
deliberately written out explicitly here; Rust can infer most of them from the value on the
right, but the type itself always exists even if it is not written in the source — every
binding has a single fixed type, and that type is determined at compile time. The integer
variable carries the value -7 and its type is i32; this is Rust’s default integer type,
thirty-two bits wide, and can carry both negative and positive values. The floating
variable, in turn, is of type f64, a sixty-four-bit-wide floating-point number, and is
Rust’s default floating-point type. A tuple’s type is written in the form (i32, bool) — in
parentheses, comma-separated, the components’ own types in order. A tuple’s type is determined
not only by its component types but also by its component count: a two-element tuple and a
three-element tuple are different types even if their components are otherwise the same.
LB11. The line let (first, second) = pair; uses an access path different from the
pair.0 / pair.1 form seen in the previous section: instead of reading the tuple’s
components one by one, it repeats the tuple’s shape in a pattern and binds two new names in a
single line. This works by evaluating the pair expression on the right and matching it
against the pattern on the left; if the left side is in the shape of a tuple, the right side
must also be a tuple with the same number of components. Pattern matching’s more general form
will be taken up separately later; here only this single form specific to tuples was used.
A tuple’s fixed component count gives a different guarantee than a growable structure like
Vec from the previous lesson: from the moment pair is set up with two components, no third
component can be added and no first component can be removed anywhere in the source. This
fixedness is known at compile time and requires the pattern on the left of the destructuring
line to match the tuple on the right in exact component count — a writing like
let (first, second, third) = pair; does not compile, because pair carries only two
components. This is another view of a tuple’s type being defined not only by its components’
types but also by their count.
Limiting Measurement: () Is Not Valueless, It Is Single-Valued
The output’s last four lines carry this lesson’s main claim. The binding named unit was
explicitly defined as type () and bound with the value () — this shows that () is a real
type, carrying a type name just like i32 or bool. statement_result, on the other hand,
arrived by a completely different path: as the result of a statement that increments counter
by one inside a block. The two values were produced by different paths, but the equality test
gave true — because the () type has exactly one value, and both bindings hold that one
value.
This shows where the sentence “a statement produces no value” can be misunderstood. Saying a
statement produces no value would be misleading; the correct statement is that a statement
produces ()’s single value. Nothingness and “the value of a single-valued type” are not the
same thing: nothingness means not even having a type, whereas () is a real type, its size is
defined, and it can be written explicitly as a return type in function signatures — indeed,
while a function like double in the previous measurement core writes -> i32, a function
whose body produces only a side effect writes no return type at all, because the default
return type is already ().
f64 carries a boundary of its own among these six types: although the number of values it
can hold is finite, because the real numbers are infinite, not every decimal value finds an
exact counterpart — while some values like 2.5 are represented exactly, some simple
fractions are represented by an approximate value. This lesson does not measure this
approximation separately; here only what value floating itself produces as an expression was
shown, the boundaries of precision are a separate subject on their own.
Each of the other five types can hold more than one value — an i32 can be one of billions of
different values, a bool at least one of two. () departs from this pattern: the number of
values it can hold is exactly one. This does not make () “less” than the other five types,
only different — if the amount of information a type carries is measured by how many separate
values that type can hold, () carries zero information, and that is exactly what a statement
wants to convey: the information that “this line ran,” itself independent of what value was
produced.
If we order the six types by the number of values they hold, () sits at one end (one value),
bool right next to it (two values), then char (roughly a million Unicode code points’
worth), then i32 and f64 (billions of values); a tuple’s own value count is the product of
its components’ value counts. This ordering is not arbitrary: the amount of information a type
can carry is directly related to this number, and () sitting at the far end shows why a
statement is defined as “a result that carries no information.”
This reading also explains why () differs from bool. Because bool carries two values, it
can encode a “yes/no” piece of information; because () carries a single value, it can encode
no distinction at all — knowing it was produced is not the same as knowing which option
occurred, because there is only one option. If a method’s return type is (), you know that
method’s call succeeded (otherwise the program would have crashed or the compiler would have
rejected it), but you cannot know what state the call produced — this is the same point as the
Vec::push example from the previous lesson: push guarantees an element was added, but gives
no further information about “how” the addition went.
Integer Overflow: A Defined Behavior
Integer types have a fixed bit width, and a sum that exceeds this width overflows. Rust
does not leave this case undefined; it requires you to explicitly choose what overflow does.
The wrapping (wrapping_add) method is one of these choices: it computes the result by
taking the value one past the type’s largest value back to its smallest value.
// overflow_and_char.rs — the defined behavior of overflow and that a character is not a byte
fn main() {
let largest: u8 = u8::MAX;
let overflowed = largest.wrapping_add(1);
println!("largest u8: {}", largest);
println!("after overflow: {}", overflowed);
let ascii_word = "resume";
let accented_word = "résumé";
println!("resume bytes: {} characters: {}", ascii_word.len(), ascii_word.chars().count());
println!("résumé bytes: {} characters: {}", accented_word.len(), accented_word.chars().count());
}
largest u8: 255 after overflow: 0 resume bytes: 6 characters: 6 résumé bytes: 8 characters: 6
u8 is an eight-bit integer type, and the largest value it can hold is 255. Adding 1 to
255 exceeds the type’s boundary in ordinary addition; wrapping_add ties this excess to a
defined rule — it “wraps” the result into the type’s range and returns to 0. This shows that
overflow does not lead to random behavior: wrapping_add gives the same answer on every run,
on every machine, because the rule comes from the bit width itself, not from the runtime’s
state at that moment.
This behavior is not a randomly chosen example, it is the counterpart of a real need: a value
expected to count cyclically and return to the start at a certain boundary — a day’s position
within a week, a position in a circular buffer — needs exactly wrapping behavior.
wrapping_add meets this need with a single call, and the result makes visible, right in the
source, which rule handles the overflow. The standard library offers other methods in the same
family — ones that report overflow as an error, and ones that stop overflow at the type’s
boundary; each gives a different defined answer to the same problem, but none of them leaves
it undefined.
LB12. An integer type’s bit width (eight bits for u8, thirty-two bits for i32) is fixed
in the language itself and does not vary by machine — this differs from types like usize,
which depend on pointer width. This is exactly why wrapping_add gives the same result on
every run: what is measured is a boundary that belongs to the type’s own definition, not to
the hardware.
Character Is Not a Byte
The second half of the same source measures the length of two words in two different ways.
len() gives the string’s length in bytes, while chars().count() counts how many
characters the string contains. In the string "resume" the two come out equal — six
bytes, six characters — but in the string "résumé" they diverge: eight bytes, six characters.
This is because Rust encodes strings with UTF-8 — letters in the basic English alphabet are
represented by a single byte, while letters like é require two bytes. The char type itself
represents a single Unicode code point, not a byte in the string; so a char value is always
one character, but a byte is not always one character. The six-letter word "résumé" taking
up eight bytes is the concrete consequence of this distinction: code that tries to read the
word byte by byte would split the two accented letters from the wrong place; chars()
prevents this splitting and gives one complete code point each time. Placing the two words side
by side also shows that the difference only shows up in letters carrying diacritics — in a
string made up of plain ASCII characters, the byte count and the character count already
coincide, because every ASCII character is represented by a single byte.
The counterpart of this distinction in the source is this: when reading a string’s length, attention must be paid to which question is being asked. The answer to “how many bytes does it hold” matters for storage and transfer over a network; the answer to “how many characters does it contain” matters for the length to be shown to a user. Since the two can give different numbers for the same string, a codebase not knowing which question it is asking can turn into an unmeasurable class of bug.
The six types measured throughout this lesson (the five primitive types and ()) were all met
with the same two questions: what is the set of values this type produces, and where does that
set’s boundary come from. For integer and character, the boundary came from bit width; for
tuple, from component count; for (), from the type’s definition — a single value. These
questions follow the same method as the previous two lessons’ limiting measurements: drawing a
guarantee’s scope by looking at the definition of the structure that gives the guarantee. In
the next lesson, these six types will be used as ready material, and the focus will shift to
how this material is gathered inside a body and turned into a single return value.
Summary
- Each of the five primitive types (integer, floating-point, boolean, character, tuple)
produces a value of its own type when run as an expression; a tuple’s components can be read
both by index (
.0) and by destructuring (let (a, b) = ...). ()is a real type and has a single value; what a statement produces is not nothingness, but exactly this type’s one value.- Integer overflow is not undefined in Rust; methods like
wrapping_addexplicitly determine how overflow is handled, and the result is the same on every run. - Fixed-bit-width integer types (such as
u8,i32) do not vary by machine; this is a separate class from types that depend on pointer width. - A
charis a Unicode code point, not a byte; a string’s byte length and character count can be different numbers. - A tuple’s type is determined by both its components’ types and their count; the count is fixed at compile time and requires the destructuring pattern to match the tuple on the right exactly.
Next Step
The types of values are now defined, but most of the writings producing these values have so
far been single-line expressions. The next lesson asks how a whole body produces a value —
it shows by running it how whether a function’s last line ends with a semicolon determines the
return value, and when that line requires writing return. () will appear there too: why
the return type of a function whose body produces only a side effect is valid even when never
written will be seen as a direct consequence of the definition this lesson established.
To keep your progress and take notes, Log in
My notes
Log in to take notes.