Lesson 12 / 18
Structs
The zero value of unwritten fields, the fragility carried by fields being writable positionally, and embedding carrying unwritten names to the outer type are measured: an embedded type's methods can be called without ever being written on the outer type. The limiting measure shows that embedding is not inheritance: when an embedded type's method is called, the receiver is the embedded value itself, not the outer struct.
Published
In this lesson
- Reads unwritten fields' zero value and compares positional notation's fragility with keyed notation
- Measures that a struct's comparability depends on its fields' comparability
- Shows that embedding carries unwritten names to the outer type and that the receiver is the embedded value
Contents
A map’s key was an indivisible value: a single string or integer, carrying a single piece of information. A struct is a composite type that holds several fields together, with each field accessed by its name. It shares one question with a map — what happens if fields are not given a value — but a struct has two more questions of its own: does the fields’ order carry meaning, and what happens when a struct is embedded inside another struct?
These two questions sit on an axis separate from the “copy or sharing” question established in the array and slice lessons. The Arrays and Slices lesson measured how much of a value gets copied; a struct is also a value and follows the same rule. But a struct’s own question is not about copying, it is about the naming of fields: a field’s name, its position, and its source — whether it was written — carry three separate pieces of information, independent of each other.
The Zero Value of Unwritten Fields
When a struct variable is declared without an initial value, each of its fields takes its own
type’s zero value — a string field gets an empty string, an integer field gets zero, a boolean
field gets false. This is one of the cases falling into the defined class of the Zero
Values lesson’s binding 8/4/3 distribution; it is mentioned here in a single sentence, the
whole distribution is not rebuilt.
// zero.go — the zero value of unwritten struct fields
package main
import "fmt"
type Record struct {
Name string
Number int
Active bool
}
func main() {
var empty Record
fmt.Printf("empty record: %q %d %t\n", empty.Name, empty.Number, empty.Active)
}
empty record: "" 0 false
The three fields carry the zero value of three separate types, and all three come from a
single line, the notation var empty Record. No field was given a value explicitly; because
the struct itself knows which field is which type, it fills in each one’s zero value itself.
This means a struct variable does not need a separate constructor call to be built —
var empty Record on its own produces a usable value, leaving no field in a “not yet built”
state.
This filling-in also works for embedded types: if a struct embeds another struct, the embedded field itself also takes its own zero value.
// embedded-zero.go — an embedded type's zero value is also filled in automatically
package main
import "fmt"
type Base struct {
Name string
}
type Extended struct {
Base
Password string
}
func main() {
var g Extended
fmt.Printf("empty extended: Name=%q Password=%q\n", g.Name, g.Password)
}
empty extended: Name="" Password=""
g.Name here is Base’s field, g.Password is Extended‘s own field; both came from a
single line, var g Extended. Zero-value filling holds for nested structs too, because the
rule is the same at every level: every field takes its own type’s zero value, even when the
field itself is another struct.
A struct is also a value, just like an array, and is copied entirely when assigned — every
struct example seen throughout this lesson stands under this rule. After copy := source is
written, a change made to copy does not affect source at all, because the two variables
now carry two independent copies. This behavior will gain its real importance in the Pointers
lesson: a function that wants to change a struct cannot change anything at the caller when it
is given a copy.
The Fragility of Fields Being Writable Positionally
A struct literal can be written in two forms: without writing field names, purely by order (positional), or by matching each value to its own field name (keyed). Positional notation is short, but it rests on an assumption — the assumption that the field arrangement at the time it was written will stay the same as the struct’s current definition. When this assumption breaks, the compiler gives no warning at all, as long as the two fields are the same type.
// ordering.go — positional notation depends on field order, keyed notation does not
package main
import "fmt"
type AddressV1 struct {
City string
Country string
}
type AddressV2 struct { // field order changed: Country moved to the front
Country string
City string
}
func main() {
v1 := AddressV1{"Ankara", "Turkey"}
v2 := AddressV2{"Ankara", "Turkey"} // code was not updated, values written for V1
fmt.Println("V1 positional:", "City=", v1.City, " Country=", v1.Country)
fmt.Println("V2 positional:", "Country=", v2.Country, " City=", v2.City)
v2Correct := AddressV2{City: "Ankara", Country: "Turkey"}
fmt.Println("V2 keyed :", "Country=", v2Correct.Country, " City=", v2Correct.City)
}
V1 positional: City= Ankara Country= Turkey V2 positional: Country= Ankara City= Turkey V2 keyed : Country= Turkey City= Ankara
AddressV2 carries the same two fields as AddressV1, only its order has changed: Country
has been moved to the front. The line AddressV2{"Ankara", "Turkey"} was left completely
unupdated — it still assumes the old order (city first, then country). The compiler rejects
nothing here, because both fields are of type string; positional notation only looks at
position, it never asks for the name. The result: Ankara is now written into the Country
field, Turkey into the City field — the values have swapped places. Keyed notation puts
the same two values into the correct field no matter what order they are written in;
v2Correct is entirely independent of field order.
This fragility becomes visible only when field order changes, and the compiler never catches it as long as the two fields are the same type — none of the four rejected cases (unused variable, unused import, an unwritten return, implicit type conversion) covers this situation. Had the fields’ types been different, positional notation would not compile and the error would appear at compile time; the type staying the same postpones the error to run time, or even to never being noticed at all.
CT11. — AddressV1 and AddressV2 are deliberately built here with two fields, both of
the same type; the goal is to show the narrowest case the compiler cannot catch. Had a third
field of a different type been added, positional notation would not compile on that field, and
the fragility would not be visible.
This situation arises in a real codebase when a struct’s field order is changed later: the struct’s definition is updated, but every line that builds it with positional notation is not updated automatically. Because the compiler gives no warning, this becomes a defect class that is hard to notice without running the code and checking the result by eye — keyed notation removes this class entirely, because which value goes into which field stands in the notation itself, entirely independent of field order.
Comparability Is Inherited From the Fields
The Arrays and Slices lesson measured that an array can be compared with == and a slice
cannot, and left it in a single sentence that this would carry over to structs too. Whether a
struct is comparable is not a rule of its own, it is the sum of its fields’ comparability.
CT12. — This measurement is run here with a compiler call, and only the class “compiled” or “did not compile” is printed; both type definitions carry a single field that looks the same, and only that field’s type determines the difference.
// comparison.go — a struct's comparability is inherited from its fields
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
)
func compiles(source string) bool {
dir, err := os.MkdirTemp("", "trial")
if err != nil {
return false
}
defer os.RemoveAll(dir)
os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module trial\n\ngo 1.24\n"), 0o644)
os.WriteFile(filepath.Join(dir, "code.go"), []byte(source), 0o644)
cmd := exec.Command("go", "build", "./...")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
return cmd.Run() == nil
}
func main() {
arrayField := `package trial
type K struct { Labels [2]string }
func F() bool {
a := K{[2]string{"x", "y"}}
b := K{[2]string{"x", "y"}}
return a == b
}`
sliceField := `package trial
type K struct { Labels []string }
func F() bool {
a := K{[]string{"x", "y"}}
b := K{[]string{"x", "y"}}
return a == b
}`
fmt.Println("array-field-only struct:", compiles(arrayField))
fmt.Println("slice-field struct :", compiles(sliceField))
}
array-field-only struct: true slice-field struct : false
Both structs carry a single field; both appear to hold the same information (two labels). The
difference is in the field’s type: one is an array, the other a slice. The array-field struct
can be compared with == because the array itself is comparable; the slice-field struct
cannot be compared because a slice is not comparable, and this property passes to the struct
exactly as is. A struct does not define a comparison rule of its own — it inherits its
fields’ rule.
Embedding: Unwritten Names Carried to the Outer Type
A struct can contain, instead of a named field, a field that carries only another type’s
name; this is called embedding. The embedded type’s fields and methods become directly
callable through the outer type, without ever being written on the outer type. This lets
several behaviors be carried to the outer type at once with a single field; in the measurement
below, Base’s two methods appear on the Extended side without ever being written there.
// embedding.go — an embedded type's names are carried to the outer type without being written
package main
import "fmt"
type Base struct {
Name string
}
func (t Base) Greet() string {
return "Hello, " + t.Name
}
func (t Base) WhoIsSpeaking() string {
return fmt.Sprintf("%T says: %s", t, t.Name)
}
type Extended struct {
Base
Password string
}
func (g Extended) Verify(s string) bool {
return g.Password == s
}
func main() {
g := Extended{Base: Base{Name: "Alice"}, Password: "secret"}
fmt.Println("embedded field direct :", g.Name)
fmt.Println("embedded method direct :", g.Greet())
fmt.Println("own method :", g.Verify("secret"))
}
embedded field direct : Alice embedded method direct : Hello, Alice own method : true
In the Extended struct there is no field written as Name, no method written as Greet
either — both belong to Base. But g.Name and g.Greet() work, as if Extended had
defined both itself. Base‘s two methods (Greet, WhoIsSpeaking) were carried to Extended
without being written; the one method Extended writes for itself is Verify. An
embedded type’s names appear as though they were the embedding type’s own names — unlike a
key in a map, access here happens not by name, but directly by field selection.
CT13. — The number of methods carried over is two here (Greet, WhoIsSpeaking); the
number depends on how many methods were written on Base, and is independent of how many
methods the embedding type writes itself. Even if Extended wrote zero methods, both would
still be visible.
Embedding also works with a struct embedding more than one type, and the carried names stack up; if two embedded types have a field or method with the same name, this lesson does not test that collision, but the compiler rejects direct access from the outer type in such a collision — which embedded type is meant has to be written explicitly. This lesson measures only a single level of embedding; one embedded type whose name and methods do not collide.
Limiting Measure: Embedding Is Not Inheritance
Embedding resembles inheritance at the level of call notation, but it is not the same thing. The difference lies in who the receiver is inside the called method.
// receiver.go — when an embedded method is called, the receiver is the embedded value itself
package main
import "fmt"
type Base struct {
Name string
}
func (t Base) WhoIsSpeaking() string {
return fmt.Sprintf("%T says: %s", t, t.Name)
}
type Extended struct {
Base
Password string
}
func main() {
g := Extended{Base: Base{Name: "Alice"}, Password: "secret"}
fmt.Println("method naming the receiver:", g.WhoIsSpeaking())
}
method naming the receiver: main.Base says: Alice
The call g.WhoIsSpeaking() was made through Extended, but the type printed inside the
method with %T is main.Base — not main.Extended. This proves that the receiver is not
g itself, it is the embedded Base value inside g. In languages with inheritance, a
superclass method called through a subclass can see the full identity of the object
calling it; in Go, an embedded method can never step outside its own type — none of Base’s
methods know that Extended exists, because at the moment Base was defined, a type called
Extended did not exist yet. Embedding is the compiler automatically expanding the
notation g.WhoIsSpeaking() into g.Base.WhoIsSpeaking(); it is not a new call form, it is a
shortened notation for an existing call.
This becomes even more evident when the outer type defines a field with the same name as the embedded type.
// shadow.go — the outer type's same-named field shadows the embedded one, but the method is unaffected
package main
import "fmt"
type Base struct {
Name string
}
func (t Base) Greet() string {
return "Base says: Hello " + t.Name
}
type Extended struct {
Base
Name string // same-named field on the outer type, shadows the embedded one
}
func main() {
g := Extended{Base: Base{Name: "EmbeddedName"}, Name: "OuterName"}
fmt.Println("g.Name (direct) :", g.Name)
fmt.Println("g.Base.Name (explicit):", g.Base.Name)
fmt.Println("g.Greet() (embedded) :", g.Greet())
}
g.Name (direct) : OuterName g.Base.Name (explicit): EmbeddedName g.Greet() (embedded) : Base says: Hello EmbeddedName
When Extended defines its own Name field, that field shadows the embedded Base.Name:
g.Name now goes to the outer type’s own field ("OuterName"); reaching the embedded field
requires writing g.Base.Name explicitly. But the call g.Greet() still uses
"EmbeddedName" — because Greet belongs to Base, and Base’s own Name was never
shadowed at all, only its visibility from the outer type changed. This is further proof
that the receiver is the embedded value itself: the outer type reusing the same name changes
nothing inside the embedded type itself.
Summary
- An unwritten struct field takes its own type’s zero value; this is an example that falls into the defined class of the Zero Values lesson’s binding distribution.
- Positional struct literals depend on field order; if two fields are the same type, the compiler catches nothing when the order changes, and values silently swap places. Keyed notation does not carry this fragility.
- A struct’s comparability with
==is not a rule of its own; it is the sum of every field’s comparability — a single slice field makes the struct non-comparable. - Embedding carries the embedded type’s fields and methods to the outer type without being written; access happens directly by field selection.
- When an embedded method is called, the receiver is the embedded value itself, not the outer struct; embedding is not inheritance, it is a shorthand the compiler expands automatically.
Next Step
In this lesson, the notation Base{Name: "Alice"} was a value, and access to g.Name
happened directly through that value. But a method that wants to change a struct, if it
operates on its own copy, never affects the caller — just like an array being copied when
passed to a function. The next lesson looks at this problem: why is it not possible to change
a struct without taking a variable’s address, and once the address is taken, which calls
affect the caller?
To keep your progress and take notes, Log in
My notes
Log in to take notes.