Lesson 07 / 12
Structs
The same Point value is built three separate ways — full syntax, update syntax, associated function — and all three produce a value as an expression; the struct declaration itself produces no value, it only introduces a name the following lines can use.
Published Updated
In this lesson
- Shows that all three syntaxes for constructing a struct value are expressions
- Explains that a struct declaration is an item and produces no value on its own
- Distinguishes a method from an associated function by the presence of a receiver
- Compares the identity of field order in a named struct versus a tuple struct
Contents
The Control Flow lesson measured, one by one, whether each of five control-flow forms
produces a value: if and match were expressions, loop carried a value only through
break, and while and for never gave one. This lesson carries the same question to
different material. There are no longer control-flow forms but data types, and the
question does not change: is constructing a value from a type an expression, or is writing
the type itself to the source an expression?
In Rust, the way to gather several fields under one type is a struct. This lesson measures the difference between a struct’s declaration and the construction of a value from that struct using three separate syntaxes — full syntax, update syntax, and an associated function — and shows that all three fall into the same class. The declaration itself never falls into that class. The second half of the lesson returns to what the declaration contains, namely fields: the same set of fields behaves quite differently across two forms of struct — the named and the unnamed — and that difference is measured separately.
A Declaration Is an Item, Constructing a Value Is an Expression
The line struct Point { x: i32, y: i32 } produces nothing when it runs. This line is not
a computation but a declaration that introduces a name to the compiler: it is the
precondition for later lines being able to use the name Point, but it is not itself a
value. In the previous lesson’s term, this is an item — it does not “run” at any point
in the source, it merely sits there and adds a type the compiler will know about.
By contrast, the syntax Point { x: 3, y: 4 } produces a value when it runs: it takes two
integers and packs them into a single value of type Point. This is an expression —
its result can be bound to a variable, passed as an argument to a function, appended to a
list. The difference here is exactly the distinction the previous lesson set up: one sits
there without being evaluated, the other is evaluated and gives back a value.
DM1. — The claim that a struct declaration by itself produces no output is not
verified here by a separate run but by the structure of the program below: the line
struct Point { ... } sits before main, not inside it, and no print call triggers that
line. The only output the program shows when it runs comes from the lines where the name
Point is used.
The reason behind this distinction is that the two kinds of lines are processed by the
compiler at different times. A declaration is read at compile time: the compiler records
the name Point, its fields, and their types into a table, but this recording leaves no
trace at the program’s runtime. A value-construction syntax, however, acts at runtime:
when the line Point { x: 3, y: 4 } executes, two integers are actually assembled in
memory. This is the same order as a binding built with let (the subject of the Variables
and Immutability lesson) taking place at runtime — the difference is that what sits to the
left of a binding must be an expression, not an item.
This is one of the places where the three classes separate most clearly. A statement
is still evaluated — only its result falls to () or is discarded — but an item is
never evaluated at all; there is nothing that “runs” where it sits in the source. A
struct declaration falls into this third category: it is not a step passed over while the
program runs, but a record the compiler processes once while reading the source.
The run below places three different ways of constructing a value from the same Point
type side by side.
// struct_construction.rs — a struct declaration is an item, constructing a struct value is an expression
struct Point {
x: i32,
y: i32,
}
impl Point {
fn origin() -> Point {
Point { x: 0, y: 0 }
}
fn magnitude(&self) -> i32 {
self.x.abs() + self.y.abs()
}
}
fn main() {
let a = Point { x: 3, y: 4 };
println!("{:<21}: x={} y={}", "full syntax", a.x, a.y);
let b = Point { y: 9, ..a };
println!("{:<21}: x={} y={}", "update syntax", b.x, b.y);
let c = Point::origin();
println!("{:<21}: x={} y={}", "associated function", c.x, c.y);
println!("{:<21}: {}", "method result", b.magnitude());
}
full syntax : x=3 y=4 update syntax : x=3 y=9 associated function : x=0 y=0 method result : 12
Full syntax gives every field one by one: x and y are written explicitly, and the
result is a value with x=3 y=4. Update syntax (..a) is a shorthand for that: only
the y field is given again, and the remaining fields are copied from a. The result is
still a single Point value produced in one call — the syntax differs, the class does not.
The associated function (Point::origin()) reaches the same class by an entirely
different route: without ever writing x and y, the function call itself produces a
Point. What the three syntaxes share is that all three can be bound to a name with
let — something an expression can do by definition, and something meaningless for an
item.
Method vs. Associated Function: the Receiver’s Presence
The calls Point::origin() and b.magnitude() look similar on the surface: both are
defined inside an impl Point block, and both return a value. The difference between them
shows up in the call syntax and comes down to a single point: the presence of a
receiver.
The origin function’s signature has no self; it is therefore called through the type
name (Point::origin()) and requires no instance. In Rust’s own terms this is an
associated function — it is associated with the Point type, but not with any
particular Point value. The magnitude function’s signature, on the other hand, has
&self as its first parameter; this parameter states which value the call is being made
on, and it does not appear at the call site — the b in b.magnitude() takes its place.
This is what Rust calls a method.
The distinction is measured not by what the work does but by what the signature requires:
with a self parameter, the call goes through a value (method); without one, through the
type name (associated function). In the run above, origin() produced a value from
nothing, needing no existing Point; magnitude() could only be called through the
already-existing value b. Both are expressions — both return a value — and this adds no
new branch to the expression/statement/item axis: every Rust function call that does not
return () is already an expression, whether method or associated function. The
distinction lies outside that axis, in the call syntax, in whether a receiver is present.
The magnitude function computes the point’s distance from the origin as the sum of two
absolute values: self.x.abs() + self.y.abs(). In the run, b carried the value x=3 y=9, so the magnitude() call gave 3 + 9 = 12. origin, as its name suggests, plays the
role of a constructor: it is not the only one of the three ways to produce a Point value,
but a common naming habit — presenting a type’s most common starting value through an
associated function like Type::origin(), Type::empty(), or Type::default(). There is
no limit at the declaration stage on how many such functions there can be; the impl Point
block gathers all of them in the same place.
Named-struct syntax asks for a field name only when constructing a value; a field name
never appears at a method call. In the syntax b.magnitude(), b states which value the
call is made on, but inside the magnitude function, the fields self.x and self.y are
reached by name. Even though the receiver appears reduced to a single dot (.) in the call
syntax, inside the function body it is the full Point value whose fields are reached by
name.
The self parameter carries a single & sign in front of it. The exact meaning of this
sign, and why the receiver can be used without being copied, is the subject of the
Ownership and the Memory Model course; what is measured here is only how the receiver’s
presence determines the call syntax, not how the receiver is passed.
There is no upper bound on how many associated functions or methods a type’s impl block
can hold. Even a small type like Point can have several constructor associated
functions — alongside origin(), names such as unit() -> Point or pair(x: i32, y: i32) -> Point could offer other default values. Which names get used is a naming habit, not a
language rule; the compiler treats all of them as the same class, the associated function.
Field Order Carries No Identity in a Named Struct
When constructing a struct value, the order in which the fields are written in the source
does not have to match the order in the declaration. Point { x: 3, y: 4 } and Point { y: 4, x: 3 } produce the same value; the compiler matches each field by its name, not its
position.
// field_order.rs — field order carries no identity in a named struct
struct Point {
x: i32,
y: i32,
}
fn main() {
let d = Point { x: 3, y: 4 };
let e = Point { y: 4, x: 3 };
println!("{:<18}: x={} y={}", "d (x first)", d.x, d.y);
println!("{:<18}: x={} y={}", "e (y first)", e.x, e.y);
println!("{:<18}: {}", "d and e equal", d.x == e.x && d.y == e.y);
}
d (x first) : x=3 y=4 e (y first) : x=3 y=4 d and e equal : true
d and e were written in different order in the source, but both carry the same x and
y values. A field name here is not a position marker but a matching key: the writing
order is a matter of readability preference, it does not change the meaning.
This is an exception that falls outside Rust’s general call convention. In a function call,
arguments are matched by position — in the syntax double(3), which parameter the 3
goes to is decided by its position in the parameter list, not by a name. Struct-construction
syntax is the one exception to this: each field: value pair inside { } finds its match
in the struct declaration by name. This privilege belongs only to construction
syntax; when accessing a struct value’s field (as in a.x), it is already accessed by
name, and no “order” question ever arises there.
DM2. — The claim that “order does not change meaning” is measured here not by a
separate field-comparison method but directly by the line d.x == e.x && d.y == e.y; this
line reads that both values’ fields are equal to each other one by one, it does not compare
d and e themselves.
Position Is the Only Identity in a Tuple Struct
The exact opposite of the named struct is the tuple struct: its fields have no name, only a
position and a type. The declaration Color(u8, u8, u8) builds a type that carries three
bytes, but it is not name but position that decides which of these three bytes is “red”
and which is “blue.”
// tuple_identity.rs — position is the only identity in a tuple struct
struct Color(u8, u8, u8);
fn main() {
let red = Color(220, 20, 20);
let Color(r1, g1, b1) = red;
println!("{:<18}: r={} g={} b={}", "Color(220,20,20)", r1, g1, b1);
let blue = Color(20, 20, 220);
let Color(r2, g2, b2) = blue;
println!("{:<18}: r={} g={} b={}", "Color(20,20,220)", r2, g2, b2);
}
Color(220,20,20) : r=220 g=20 b=20 Color(20,20,220) : r=20 g=20 b=220
This is the limiting measurement: while field order was free in a named struct (the
previous run showed this with d and e), position is the sole identity in a tuple
struct. Color(220, 20, 20) and Color(20, 20, 220) do not carry the same three numbers —
in the first, the first position is large; in the second, the third position is — and the
result is two different color values. In a named struct, a name answers the question “which
field”; in a tuple struct, only position answers that same question. In the writing
Color(20, 20, 220), which field the first two 20s belong to is resolved by looking at
where that value sits.
DM3. — The claim “position is the only identity” in a tuple struct is measured here by
placing the same three numbers into different positions, that is by producing the pair
Color(220, 20, 20) and Color(20, 20, 220): the two calls carry the same three values (a
permutation of 20, 20, 220), but because their positions differ, so do the results.
Destructuring also comes into play here: the line let Color(r1, g1, b1) = red; binds the
three fields of the value red in order to the names r1, g1, b1. This writing is in
fact a pattern — it matches the shape of the value on the right against the names on the
left — and the full scope of taking values apart with patterns is the subject of the next
lesson. Here it is only visible that accessing a tuple struct’s fields goes through this
route: where a.x can be written for a named struct, a tuple struct’s field is reached
either by position (color.0, color.1, color.2) or by destructuring, not by name.
A single-field tuple struct is also used to keep two quantities of the same underlying type
distinct from each other. struct Pixel(i32); and struct Millimeter(i32); both carry an
i32 inside, but for the compiler they are two separate types; a Pixel value does not
stand in for a Millimeter value, even though its field looks the same. This is the tuple
struct’s most common use: not giving an existing type a new name, but a new identity. A
named struct reaches the same goal with a field name (Location { pixel: i32 }); a tuple
struct does it with a single wrapper field, no field name ever written.
DM4. — The contrast between the named struct and the tuple struct is measured by
placing two separate runs side by side: field_order.rs changes the order and shows the
result stays the same, tuple_identity.rs changes the position and shows the result is
different. Both runs use the same three inputs (three numbers); what changes is only
whether the type is named or unnamed.
A struct is Rust’s most direct way of holding data together: a fixed number of fields, each
tied to a single type, all present at once. The three syntaxes this lesson measured — full
syntax, update syntax, associated function — all arrive at the same result because all of
them fill the same fixed set of fields. But not every struct value is bound to a fixed set
of fields; the data a program has to model often takes more than one possible shape in an
“either this or that” way. A struct alone cannot meet this — a Point always carries both
x and y, there is no third “carries nothing” state. The next lesson builds the type
that fills exactly that gap.
Summary
- A
structdeclaration is an item: it produces no value when it runs, it only introduces a name. - All three syntaxes for constructing a struct value — full syntax, update syntax, associated function — are expressions and produce a value.
- The difference between a method and an associated function is the presence of a receiver
(
self): a method is called through a value, an associated function through a type name. - Field order carries no meaning in a named struct; fields are matched by name.
- A tuple struct’s fields have no name, only a position; position is the sole identity and the result changes when it is changed.
Next Step
This lesson measured how many fields a struct value carries and how those fields are constructed — but a struct value always carries the same set of fields. The next lesson carries the same question to a different shape: can a type have more than one form, where which fields it carries changes depending on state? An enum sets up a value being able to carry one of several separate states under the same type; the next question is how these states are read from the source.
To keep your progress and take notes, Log in
My notes
Log in to take notes.