Skip to content
academia.sh

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_add explicitly 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 char is 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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close