Mastering Go Systems Programming
Type Systems and Structs
Building with Structs
In many programming languages, you'd group related data using a class. Go takes a simpler approach with structs. A struct is a collection of fields that lets you create custom data types. Think of it as a blueprint for grouping different pieces of information into a single, logical unit. It's Go's primary tool for building complex data structures, moving away from the inheritance hierarchies common in object-oriented programming.
// A User struct to hold user information.
// Note the capitalized fields: this makes them 'exported',
// meaning they are visible to other packages.
type User struct {
ID int
FirstName string
LastName string
IsActive bool
}
Once a struct is defined, you can create instances of it. There are a few ways to initialize a struct. You can rely on the order of the fields, or, more safely, you can specify the field names. Using field names is generally recommended because it makes your code more readable and less likely to break if you add a new field later.
// Initialize using a struct literal
u1 := User{
ID: 1,
FirstName: "Jane",
LastName: "Doe",
IsActive: true,
}
// You can omit fields, and they will be set to their zero value.
// For strings, the zero value is ""; for numbers, it's 0; for booleans, it's false.
u2 := User{
ID: 2,
FirstName: "John",
}
// Get a pointer to a new instance
u3 := &User{ID: 3, FirstName: "Admin"}
Composition Over Inheritance
Go doesn't have classes or inheritance. Instead, it favors a powerful concept called composition, achieved through struct embedding. Instead of saying "a Manager is a type of Employee," Go encourages you to think "a Manager has the properties of an Employee."
Embedding a struct is like adding all of its fields to the parent struct. The fields of the embedded struct are promoted to the top level, so you can access them directly, as if they were defined on the outer struct itself.
// A base struct with contact information
type ContactInfo struct {
Email string
Phone string
}
// The Employee struct embeds ContactInfo.
// This is done by specifying the type without a field name.
type Employee struct {
ContactInfo // Embedded struct
Name string
Position string
}
// Let's create an employee
func main() {
employee := Employee{
Name: "Alice",
Position: "Engineer",
ContactInfo: ContactInfo{
Email: "alice@example.com",
Phone: "555-1234",
},
}
// Access embedded fields directly
fmt.Println(employee.Email) // Prints "alice@example.com"
fmt.Println(employee.ContactInfo.Phone) // This also works
}
This approach keeps types smaller and more focused, letting you build complex structures by combining simple ones. It's like building with LEGOs instead of carving from a single block of wood.
Talking to the Outside World
When you're building APIs or working with databases, you often need to convert your Go structs to formats like JSON or map them to database columns. Go's structs have a built-in mechanism for this called struct tags. A tag is a string of metadata attached to a struct field, enclosed in backticks ``.
import "encoding/json"
type Product struct {
// Use `json:"..."` to control how the field is encoded/decoded.
ID int `json:"id"`
Name string `json:"name"`
// The `omitempty` option hides the field if its value is zero.
Stock int `json:"stock,omitempty"`
// A hyphen `-"` tells the json package to ignore this field.
internalNotes string `json:"-"`
}
These tags don't affect your Go code directly, but they're readable by other packages using reflection. The encoding/json package uses them to map struct fields to JSON keys. Database drivers and ORMs use them to map fields to table columns. This keeps the serialization logic right next to the data definition, making your code clean and easy to understand.
Methods: Pointers vs. Values
You can attach functions, called methods, to any type you define. When defining a method, you specify a receiver, which is the instance of the type the method operates on. The receiver can be either a value or a pointer.
- A value receiver operates on a copy of the original value. Any modifications made inside the method are lost when it returns.
- A pointer receiver operates on a pointer to the original value. This allows the method to modify the data in place.
Choosing between them is important. If the method needs to change the struct's state, you must use a pointer receiver. If not, a value receiver is often fine, but pointer receivers can be more efficient for large structs because they avoid copying the entire data structure every time the method is called.
// A simple Rectangle struct
type Rectangle struct {
Width, Height float64
}
// area() has a value receiver (r Rectangle).
// It doesn't need to modify the rectangle, just read its values.
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
// Scale() has a pointer receiver (*r Rectangle).
// It modifies the rectangle's fields.
func (r *Rectangle) Scale(factor float64) {
r.Width *= factor
r.Height *= factor
}
A common rule of thumb: if any method on a type needs a pointer receiver, make all methods on that type have pointer receivers for consistency.
Time to see what you've learned about structs and methods.
What is the primary purpose of a struct in Go?
When defining a method on a struct, what is the key difference between a value receiver and a pointer receiver?
Structs form the backbone of data modeling in Go. By understanding how to define, compose, and attach behavior to them, you can build clean, efficient, and maintainable systems.