Structs as data

Station 2.7 14 min Interchange: arrays as DTOs & readonly classes → struct

The fourth job PHP's array does is carrying records around: ['dest' => 'Epping', 'platform' => 3]. Modern PHP often upgrades that to a readonly class with promoted constructor properties. Go's version is the struct, which you met briefly in 1.4. This lesson finishes the job.

A fixed, checked shape

type Departure struct {
	Dest     string
	Platform int
}

A struct type lists its fields and their types, and that list is final. Read or write a field that isn't there and it's a compile error, not a quiet new key:

PHP$dep['platfrom'] = 4;
PHP, quietlya brand-new key called "platfrom"
Godep.Platfrom undefined
Typos are compile errors now.

(The capital letters are 1.11's visibility rule. Dest and Platform can be used from other packages, and lower-case fields couldn't.)

Literals

dep := Departure{Dest: "Epping", Platform: 3}
later := Departure{Dest: "Ealing"} // Platform is 0
var blank Departure               // every field at zero

A keyed literal names each field, a lot like PHP's named arguments. Any field you leave out gets its zero value (1.4), and that's the only kind of "default" a struct has. There's no constructor here: Go code that needs validation or real defaults writes a NewDeparture function, and Unit 3 covers that.

You can also leave the names out, Departure{"Epping", 3}, as long as you give every field in order. Prefer keyed literals: an unkeyed one breaks, or worse, silently swaps two fields, as soon as someone adds or reorders a field. go vet complains about unkeyed literals of structs from other packages for exactly that reason.

Structs can hold other structs, slices and maps, and slices and maps can hold structs. A []Departure is a departure board, and a map[string]Departure looks one up by destination.

Structs are values

Assigning a struct copies every field, just like an array in 2.1:

a := Departure{Dest: "Epping", Platform: 3}
b := a
b.Platform = 4 // a.Platform is still 3

That's the opposite of a PHP object, where $b = $a gives you the same object. Passing a struct to a function copies it too. (When you do want to share one, that's a pointer, lesson 3.3.) Two structs can be compared with == when all their fields can, and such a struct can even be a map key.

One catch: copying a struct copies its fields, and a slice field is only a header (2.3). Copy a struct with a Stops []string field and both copies share the same stops.

Structs inside slices and maps

Two rules follow from "structs are values", and both trip people up.

range gives you a copy of each struct. You saw this in 1.9 with strings, and it bites harder with structs, because changing a field looks so reasonable:

for _, t := range board {
	t.Delay += 5 // changes the copy, then throws it away
}
for i := range board {
	board[i].Delay += 5 // changes the board
}

You can't change a field of a struct inside a map.

Mind the gap

byDest["Epping"].Platform = 4 doesn't compile. You get cannot assign to struct field byDest["Epping"].Platform in map. A map lookup hands you a copy, so changing one field of it would be lost, and Go refuses rather than let you think it worked. Copy the struct out, change it, and put it back: t := byDest["Epping"], then t.Platform = 4, then byDest["Epping"] = t.

Anonymous structs

Sometimes a shape is only needed in one place. An anonymous struct defines the type right where it's used:

row := struct {
	Station string
	Taps    int
}{"Bank", 950}

You've been reading these all course. Every table-driven test since 0.6 starts with tests := []struct{ … }{ … }: a slice of an anonymous struct, one per test case. They're also handy for one-off JSON shapes (7.3).

Laravel corner

An Eloquent model's attributes go through magic __get, so $departure->platfrom is just null, with no warning. Packages like spatie/laravel-data exist to give Laravel the typed, checked DTOs that a Go struct gives you by default.

Recap

  • A struct is a fixed set of typed fields. Unknown fields are compile errors.
  • Use keyed literals. Missing fields get zero values, and there are no other defaults.
  • Structs are values: assigning or passing copies them, unlike PHP objects. Slice fields inside still share.
  • range gives copies, so change structs in a slice with xs[i].Field.
  • You can't assign to a field of a struct in a map: copy it out, change it, put it back.
  • Anonymous structs, struct{ … }{ … }, are for one-off shapes like test tables.

1Arrays as DTOs

PHPdto.php
<?php
$dep = ['dest' => 'Epping', 'platform' => 3];

$dep['platfrom'] = 4; // typo: a brand-new key
echo $dep['platform']; // still 3
GOdto.go
dep := Departure{Dest: "Epping", Platform: 3}

dep.Platfrom = 4 // compile error:
// dep.Platfrom undefined

Mind the gap

A struct's fields are fixed and checked by the compiler, so a typo is a build error rather than a quiet new key.

2Defining one

PHPdefine.php
<?php
final readonly class Departure
{
    public function __construct(
        public string $dest,
        public int $platform = 1,
    ) {}
}

$dep = new Departure(dest: 'Epping', platform: 3);
GOdefine.go
type Departure struct {
	Dest     string
	Platform int
}

dep := Departure{Dest: "Epping", Platform: 3}
later := Departure{Dest: "Ealing"} // Platform: 0

Mind the gap

A keyed struct literal reads like PHP's named arguments. Fields you leave out get their zero value, and that's the only kind of default Go has.

3Copying

PHPcopy.php
<?php
$a = new Train(dest: 'Epping', platform: 3);
$b = $a;          // the same object
$b->platform = 4;

echo $a->platform; // 4
GOcopy.go
a := Train{Dest: "Epping", Platform: 3}
b := a // a copy of every field
b.Platform = 4

fmt.Println(a.Platform) // still 3

Mind the gap

PHP objects are shared on assignment. Go structs are values, copied like arrays. To share one, you'd use a pointer (lesson 3.3).

4One-off shapes

PHPanon.php
<?php
$row = ['station' => 'Bank', 'taps' => 950];
GOanon.go
row := struct {
	Station string
	Taps    int
}{"Bank", 950}

Mind the gap

An anonymous struct is a struct type defined right where you use it. You've been reading them in every table-driven test since 0.6.