Day 11: Exploring Structs - Building Custom Data Types in Go

Venkat Annangi
Venkat Annangi
22/10/2024 17:17 5 min read 38 views
#golang #108 days of golang

Day 11: Exploring Structs - Building Custom Data Types in Go

Introduction

Structs are a core concept in Go, providing a mechanism to group fields and create complex types. They are central to Go's type system and are used extensively in all kinds of Go applications, including web frameworks, microservices, and system-level programs. Mastering structs is key to becoming proficient in Go, as they enable you to model real-world entities effectively.

This article delves deeper into defining and using structs, struct methods, nesting structs, and the implications of passing structs by value or reference. We will also explore how structs compare to classes in other languages and how to leverage them to build robust and maintainable Go applications.

What Are Structs in Go?

A struct is a composite data type in Go that allows you to group variables of different types into a single entity. Unlike primitive types such as int and string, structs provide a way to create more complex data types, which are critical for modeling real-world entities.

Structs in Go are somewhat analogous to classes in object-oriented programming languages, but Go does not support traditional inheritance. Instead, Go focuses on composition, using structs to build modular, reusable code.

Let’s start by understanding the syntax for declaring structs:


type Employee struct {
    Name     string
    Position string
    Salary   float64
    Active   bool
}
    

Defining and Initializing Structs

Structs can be initialized in multiple ways in Go, offering flexibility based on the use case:

  • Positional Initialization: Fields are initialized based on the order in which they are declared.
john := Employee{"John Doe", "Developer", 80000, true}
  • Named Field Initialization: Preferred for readability, especially in larger structs. The fields are explicitly named.

jane := Employee{
    Name:     "Jane Smith",
    Position: "Manager",
    Salary:   95000,
    Active:   true,
}
    

This method improves readability and avoids issues that may arise from incorrectly ordering values.

  • Zero Initialization: You can initialize an empty struct where each field takes the zero value for its type (0 for int, false for bool, "" for string).
var intern Employee // All fields are zero-initialized

Struct Tags and JSON Serialization

In real-world applications, structs are often used to represent data models, especially when working with APIs. Struct tags are used to specify how Go fields should be encoded and decoded when working with formats like JSON, XML, etc.


type Employee struct {
    Name     string  `json:"name"`
    Position string  `json:"position"`
    Salary   float64 `json:"salary"`
    Active   bool    `json:"active"`
}
    

The tags inside backticks (`) define how the fields will be represented when serialized to JSON. Here’s how you can use Go’s encoding/json package to serialize and deserialize JSON data:


import (
    "encoding/json"
    "fmt"
)

func main() {
    emp := Employee{Name: "Alice", Position: "CTO", Salary: 120000, Active: true}

    // Serialize to JSON
    jsonData, _ := json.Marshal(emp)
    fmt.Println(string(jsonData)) // Output: {"name":"Alice","position":"CTO","salary":120000,"active":true}

    // Deserialize from JSON
    var emp2 Employee
    json.Unmarshal(jsonData, &emp2)
    fmt.Println(emp2.Name) // Output: Alice
}
    

Working with Struct Fields

Struct fields can be accessed and modified using the dot notation. Go's type system ensures that you cannot accidentally assign values of incorrect types to struct fields.


func main() {
    emp := Employee{Name: "Bob", Position: "Engineer", Salary: 75000, Active: true}

    // Accessing fields
    fmt.Println(emp.Name)  // Output: Bob
    fmt.Println(emp.Salary) // Output: 75000

    // Modifying fields
    emp.Salary += 5000
    fmt.Println(emp.Salary) // Output: 80000
}
    

Struct Methods and Receivers

In Go, you can associate methods with a struct by defining receiver functions. A receiver is like the this keyword in object-oriented languages, allowing you to call methods on struct instances.

Let’s add a method to the Employee struct to check if an employee is active:


func (e Employee) IsActive() bool {
    return e.Active
}

func main() {
    emp := Employee{Name: "Charlie", Position: "CEO", Active: true}

    if emp.IsActive() {
        fmt.Println("Employee is active")
    }
}
    

In the example, IsActive is a method that operates on the Employee type. Receivers can be value receivers (as above) or pointer receivers.

Pointer Receivers

If you need to modify the struct’s fields, you must use a pointer receiver to avoid working on a copy:


func (e *Employee) Promote(newPosition string, raise float64) {
    e.Position = newPosition
    e.Salary += raise
}

func main() {
    emp := Employee{Name: "Diana", Position: "Junior Dev", Salary: 60000}
    emp.Promote("Senior Dev", 10000)
    fmt.Println(emp.Position) // Output: Senior Dev
    fmt.Println(emp.Salary)   // Output: 70000
}
    

Nested Structs and Composition

Go uses composition rather than inheritance to allow complex types to be built from simpler ones. You can embed one struct inside another to achieve this:


type Department struct {
    Name    string
    Manager Employee
}

func main() {
    dept := Department{
        Name:    "Engineering",
        Manager: Employee{Name: "Erika", Position: "Lead", Salary: 100000},
    }

    fmt.Println(dept.Manager.Name) // Output: Erika
}
    

Passing Structs: Value vs. Reference

When you pass structs to functions, Go makes a copy of the struct by default (pass by value). If you want to modify the original struct, pass a pointer instead.

Passing by Value

This creates a copy, leaving the original struct unmodified:


func changePosition(emp Employee) {
    emp.Position = "Manager"
}

func main() {
    emp := Employee{Name: "Greg", Position: "Developer"}
    changePosition(emp)
    fmt.Println(emp.Position) // Output: Developer (unchanged)
}
    

Passing by Reference

Passes a pointer, allowing modifications to the original struct:


func changePosition(emp *Employee) {
    emp.Position = "Manager"
}

func main() {
    emp := Employee{Name: "Greg", Position: "Developer"}
    changePosition(&emp)
    fmt.Println(emp.Position) // Output: Manager
}
    

Best Practices with Structs

  • Use Named Field Initialization: This enhances readability, especially with structs containing many fields.
  • Leverage Composition: Instead of trying to use inheritance (which Go lacks), build complex types by embedding structs.
  • Pass Large Structs by Pointer: To avoid expensive copies, pass structs by pointer, especially when they are large or when you need to modify the original.
  • Be Cautious with Mutability: Use value receivers when you don’t need to modify a struct and pointer receivers when you do.
  • Use Struct Tags: Make your structs compatible with external systems by defining custom JSON/XML tags.

Conclusion

Structs are an essential part of Go’s type system. By mastering them, you’ll be able to design clean, maintainable, and scalable applications. Whether you're building a web framework, an API, or a system utility, structs enable you to represent data in a clear and organized way. Struct methods, pointer receivers, and nested structs offer powerful features to handle more complex scenarios effectively.

Comments