DEV Community

Cover image for Structs and Methods in Go: A Beginner-Friendly Guide
DOREEN ATIENO
DOREEN ATIENO

Posted on

Structs and Methods in Go: A Beginner-Friendly Guide

Go (or Golang) is known for its simplicity and efficiency. Structs and methods are foundational concepts in Go that help you organize and manage data effectively. This guide will take you through the basics of structs, methods, and how to use them systematically. Ensure you have go installed in your machine, use the right package and correct imports for you to test the functions.

Understanding Structs in Go

What is a Struct?
I understand struct in Go as a composite data type that groups together variables (fields) under a single name. It is particularly useful for representing more complex data structures. While it serves a role similar to classes in object-oriented programming languages, it does so in a lightweight, straightforward manner without methods like inheritance.

Defining Struct
A struct is defined using the type keyword followed by the struct name(User) and the fields enclosed in curly braces {}.
Each field has a name and a type. For example:

type User struct {
    ID       int
    Name     string
    Email    string
    IsActive bool
}
Enter fullscreen mode Exit fullscreen mode

ID is of type int.
Name and Email are of type string.
IsActive is of type bool.
Fields like ID, Name, Email, and IsActive are capitalized to make them exportable. This means they can be accessed from outside the package.
If a field starts with a lowercase letter, it is unexported and cannot be accessed outside the package where it is defined.

Struct Initialization in Go
Structs in Go can be initialized using named fields or unnamed fields. Let’s explore the differences, advantages, and potential pitfalls of both approaches.

1. Named Fields
When initializing a struct using named fields, you explicitly specify the name of each field along with its value.
Example:

user := User{ 
ID: 1, 
Name: "Doreen", 
Email: "doreen@example.com", 
IsActive: true, 
}
Enter fullscreen mode Exit fullscreen mode

Advantages of Named Fields:
1. Readability:
Each field is clearly labeled with its name, making the code easier to read and understand.
Example: ID: 1 directly shows that the field ID is being assigned the value 1.

2. Order Independence:
The fields can be listed in any order, regardless of how they are defined in the struct.
Example:

user := User{ 
Name: "Doreen", 
IsActive: true, 
Email: "doreen@example.com", 
ID: 1, 
}
Enter fullscreen mode Exit fullscreen mode

3. Safety:
Reduces the chance of assigning values to the wrong fields.
Example:
If you switch the order of ID and Name, the program will still work as intended because the fields are explicitly named.

4. Best Use Cases:
When a struct has many fields, especially of the same type (e.g., multiple strings or integers).
When you want to make your code self-documenting.

2. Unnamed Fields
When initializing a struct using unnamed fields, you omit the field names and provide values in the exact order the fields are defined in the struct.
Example:

user := User{1, "Doreen", "doreen@example.com", true}
Enter fullscreen mode Exit fullscreen mode

Advantages of Unnamed Fields:
Conciseness:
It requires less typing and results in shorter code.

Disadvantages of Unnamed Fields:
1. Order Dependency:
The values must appear in the exact order in which the fields are defined in the struct. If the order changes, it can lead to subtle bugs or incorrect data assignment.
Example:

user := User{"Doreen", 1, "doreen@example.com", true} // This will cause a type mismatch error.
Enter fullscreen mode Exit fullscreen mode

2. Reduced Readability:
Without field names, it’s harder to immediately understand what each value represents, especially if the struct has multiple fields of the same type.
Example:

user := User{1, "Doreen", "doreen@example.com", true}
Enter fullscreen mode Exit fullscreen mode

At a glance, it might not be clear which value corresponds to ID, Name, Email, or IsActive.

Which One Should You Use?
Whenever I decide to use a struct to tackle a problem, I lean toward using named fields for clarity and maintainability. This is especially important in production code or collaborative projects, where readability and reducing errors are key.

I might consider using unnamed fields only if the struct is very small, the order of fields is obvious, and the context is extremely clear. Otherwise, named fields provide a safer and more reliable approach.

Default Values in Go Structs
In Go, when you create a struct instance without specifying values for all the fields, the default values (or "zero values") of the respective types are automatically assigned to the omitted fields. This behavior ensures that structs are always initialized, even if you don’t explicitly set all the fields.

What Are Default (Zero) Values?
Zero values represent the "default state" of each data type in Go. I have several examples that will help you understand zero values.

Example 1: Omitted Fields in Struct Initialization
Consider the following struct:

type User struct { 
ID int 
Name string 
Email string 
IsActive bool 
}
Enter fullscreen mode Exit fullscreen mode

If you initialize an instance and omit some fields:

user := User{
    ID: 10,     // ID is initialized
    Name:  "Doreen",  // Name is initialized
    // Email and IsActive are omitted
}
Enter fullscreen mode Exit fullscreen mode

The omitted fields (Email and IsActive) take their zero values:
Email (string): "" (empty string)
IsActive (bool): false

fmt.Println(user) // {10 Doreen  false}
Enter fullscreen mode Exit fullscreen mode

Example 2: Empty Struct Initialization
If you don’t initialize any fields at all:

user := User{}
fmt.Println(user)
Enter fullscreen mode Exit fullscreen mode

All fields will have their zero values:
ID → 0 (default for int)
Name → "" (default for string)
Email → "" (default for string)
IsActive → false (default for bool)

Output:

{0   false}
Enter fullscreen mode Exit fullscreen mode

Example 3: Partial Initialization with Positional Values
If you use unnamed fields but don’t provide values for all fields, the omitted fields will still be set to their zero values:

user := User{1, "Johns"}
fmt.Println(user)
Enter fullscreen mode Exit fullscreen mode

Output:

{1 Johns  false}
Enter fullscreen mode Exit fullscreen mode

Here:
Email (string) defaults to "".
IsActive (bool) defaults to false.

Why Are Zero Values Useful?
1. Safety:
Go ensures that all fields are initialized, so there are no uninitialized fields causing undefined behavior.

2. Simplicity:
You don’t have to explicitly initialize every field if you’re okay with some fields having default values.

3. Convenience in Prototyping:
When quickly testing or prototyping, you can focus only on the fields you care about.

4. Avoid Reliance on Default Values for Logic
While Go's zero values (default values) are convenient, relying on them for important application logic can lead to subtle bugs and unintended behavior. This happens because the zero value for a type might not adequately represent your intended state or meaning.

Why is it that, Relying on Default Values Can Be Problematic?
1. Lack of Clarity:
If a field is left uninitialized and automatically takes its zero value, it can be unclear whether the zero value was explicitly set by the programmer or simply defaulted. This ambiguity can make debugging difficult.

2. Unintended State:
If your code assumes a field is initialized with a meaningful value and processes the zero value instead, it could cause incorrect logic or results.

3. Logic Errors:
Using default values might pass unnoticed in validation checks or business logic. For example, if 0 is a valid input for an int field, you won’t know if it was explicitly set or is the default.

Example of the Problem
Imagine a struct for a user profile:

type User struct {
    ID      int
    Name    string
    Email   string
    IsActive bool
}
Enter fullscreen mode Exit fullscreen mode

Take it this way,suppose you write a logic that relies on IsActive to determine if a user is active:

func CheckActive(user User) {
    if user.IsActive {
        fmt.Println("User is active!")
    } else {
        fmt.Println("User is inactive.")
    }
}

Enter fullscreen mode Exit fullscreen mode

And you accidentally leave IsActive uninitialized when creating a user:

newUser := User{
    ID: 1,
    Name:  "Doreen",
    Email: "doreen@example.com",
    // IsActive is omitted
}
CheckActive(newUser)
Enter fullscreen mode Exit fullscreen mode

Output:

User is inactive.
Enter fullscreen mode Exit fullscreen mode

The logic assumes that IsActive = false meaning the user is inactive, but the field wasn’t explicitly initialized—it defaulted to false. This could lead to misclassification of users.

Best Practices to Avoid Issues
1. Explicit Initialization:
Always initialize fields explicitly if their value is meaningful to your logic.

newUser := User{
    ID:     1,
    Name:   "Doreen",
    Email:  "doreen@example.com",
    IsActive: true, // Explicitly set
}
Enter fullscreen mode Exit fullscreen mode

2. Use Pointers for Optional Fields:
If a field is optional or its absence is meaningful, use a pointer (*type) instead of relying on its zero value. This makes it clear whether a value was intentionally set.

type User struct {
    ID      int
    Name    string
    Email   string
    IsActive *bool
}

isActive := true
newUser := User{
    ID:     1,
    Name:   "Doreen",
    Email:  "doreen@example.com",
    IsActive: &isActive, // Explicitly set
}
Enter fullscreen mode Exit fullscreen mode

In this case, if IsActive is nil, you know it was never set since we are taking its dereferenced value.

3. Custom Default Values:
Define custom "default" values during struct initialization by using constructor functions.

func NewUser(id int, name, email string) User {
    return User{
        ID:     id,
        Name:   name,
        Email:  email,
        IsActive: false, // Explicit default value
    }
}

user := NewUser(1, "Johns", "johns@example.com")
fmt.Println(user.IsActive) // Outputs: false

Enter fullscreen mode Exit fullscreen mode

In this case IsActive is false because it is omitted so it automatically takes the default value which is false.

4. Validation Logic:
Include validation checks to ensure fields are initialized with appropriate values before use.

func ValidateUser(user User) error {
    if user.Name == "" {
        return fmt.Errorf("Name cannot be empty")
    }
    if user.Email == "" {
        return fmt.Errorf("Email cannot be empty")
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

In this case, empty Name and Email is not accepted.

Why Use Structs in Go?
There are a number of features and advantages that structs come with that has, is and will always make me choose structs. Here's why structs are so valuable:

1. Organizing Related Data
Structs group related fields (variables) into a single logical unit. This makes your code more readable and manageable.
Instead of managing multiple variables, you can handle them as a single Struct (User).
Instead of initializing all these variables,

var ID int
var Name string
var Email string
var IsActive bool

Enter fullscreen mode Exit fullscreen mode

you can always group them in one struct.

type User struct {
    ID      int
    Name    string
    Email   string
    IsActive bool
}

Enter fullscreen mode Exit fullscreen mode

2. Reusability
Structs enable you to define reusable types that can be used across different parts of your program.
Example:

type Product struct {
    ID      int
    Name    string
    Price   float64
}
Enter fullscreen mode Exit fullscreen mode

With the above struct you can create multiple product instances without redefining fields each time:

product1 := Product{1, "Laptop", 1200.50}
product2 := Product{2, "Smartphone", 800.00}
Enter fullscreen mode Exit fullscreen mode

3. Customization with Methods
Structs can have methods attached to them, allowing you to define custom behaviors for struct instances.
Take a chill pill we are about to explore on methods.
Here is an example to make you eager.
Example:

type User struct {
    Name     string
    IsActive bool
}

func (u User) Greet() string {
    return "Hello, " + u.Name
}

func main() {
    user := User{Name: "Doreen", IsActive: true}
    fmt.Println(user.Greet()) // Output: Hello, Doreen
}
Enter fullscreen mode Exit fullscreen mode

This enhances the struct’s functionality and encapsulates related behaviors.

4. Encapsulation
Structs allow you to control access to their fields using export rules:
Exported Fields: Start with an uppercase letter and are accessible outside their package.
Unexported Fields: Start with a lowercase letter and are private to the package.

Example:

type User struct {
    ID      int
    name    string // private field
    Email   string // public field
}
Enter fullscreen mode Exit fullscreen mode

5. Flexible and Extensible
Structs can be extended with composition, a common pattern in Go where you embed one struct inside another.

Example:

type Address struct {
    City  string
    State string
}

type User struct {
    Name    string
    Address // Embedded struct
}

func main() {
    user := User{
        Name: "Doreen",
        Address: Address{
            City:  "Nairobi",
            State: "Kenya",
        },
    }
    fmt.Println(user.City) // Output: Nairobi
}
Enter fullscreen mode Exit fullscreen mode

6. Efficient Memory Representation
Structs provide a compact and efficient way to group fields in memory. They use less overhead compared to alternatives like maps or slices for organizing data.

7. Foundation for Object-Oriented Design
Although Go is not an object-oriented language, structs form the backbone of its type system. You can:
Simulate classes by attaching methods to structs.
Use interfaces to define shared behaviors between structs.

8. Essential for JSON, XML, and Other Formats
Structs are often used to work with data serialization formats like JSON, XML, or databases. Go’s encoding packages (like encoding/json) can easily marshal and unmarshal structs.
Example: JSON Serialization

import "encoding/json"

type User struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

func main() {
    user := User{Name: "Doreen", Email: "doreen@example.com"}
    jsonData, _ := json.Marshal(user)
    fmt.Println(string(jsonData)) // Output: {"name":"Doreen","email":"doreen@example.com"}
}
Enter fullscreen mode Exit fullscreen mode

9. Better Type Safety
Structs provide a more type-safe way to handle related data compared to alternatives like maps, where keys and values can have any type. With structs, field types are fixed, and the compiler can catch errors.
Example:

With a map:

data := map[string]interface{}{"ID": 1, "Name": "Doreen"}
Enter fullscreen mode Exit fullscreen mode

With struct

type User struct {
    ID   int
    Name string
}
Enter fullscreen mode Exit fullscreen mode

The compiler ensures that ID is always an int and Name is always a string.

Customization with Methods in Go Structs

In Go, you can attach methods to structs to define behaviors or actions related to the struct. This makes your code more organized, readable, and modular. It allows structs to encapsulate both data (fields) and related functionality (methods), mimicking the behavior of classes in object-oriented programming.

What is a Method?
A method in Go is simply a function that has a receiver. The receiver specifies the struct (or other type) the method is associated with. The method can then operate on the struct's fields.
Syntax of a Method

func (receiver ReceiverType) MethodName(parameters) returnType {
    // Method body
}
Enter fullscreen mode Exit fullscreen mode

Receiver: The variable name and type (e.g., (u User)) that the method is attached to.
MethodName: The name of the method.
Parameters: The inputs to the method (optional).
ReturnType: The type of value the method returns (optional).

Example: Methods on a Struct

type User struct {
    Name    string
    IsActive bool
}

// Method attached to User struct
func (u User) Greet() string {
    return "Hello, " + u.Name
}
Enter fullscreen mode Exit fullscreen mode

Receiver: (u User) associates the method Greet with the User struct.
Purpose: The method generates a greeting message using the Name field of the struct.
Using the Method

func main() {
    user := User{Name: "Doreen", IsActive: true}
    fmt.Println(user.Greet()) // Output: Hello, Doreen
}

Enter fullscreen mode Exit fullscreen mode

Receiver Types: Value vs. Pointer
Methods can have either a value receiver or a pointer receiver, depending on how you want the method to interact with the struct's data.
1. Value Receiver
A copy of the struct is passed to the method.
Changes made to the struct within the method do not affect the original instance.
You can use this when the method does not modify the struct or when the struct is small.

func (u User) DisplayStatus() {
    u.IsActive = false // This modifies a copy
    fmt.Println("Inside method:", u.IsActive) // false
}

func main() {
    user := User{Name: "Doreen", IsActive: true}
    user.DisplayStatus()
    fmt.Println("Outside method:", user.IsActive) // true
}
Enter fullscreen mode Exit fullscreen mode

2. Pointer Receiver
Here a pointer to the struct is passed to the method.
Changes made within the method affect the original struct.
You can use this when the method modifies the struct or the struct is large.

func (u *User) Deactivate() {
    u.IsActive = false // Modifies the original instance
}

func main() {
    user := User{Name: "Doreen", IsActive: true}
    user.Deactivate()
    fmt.Println("User status:", user.IsActive) // false
}

Enter fullscreen mode Exit fullscreen mode

Methods with Parameters and Return Values
Methods can take additional parameters and return values like regular functions.
Example: Calculating Discount

type Product struct {
    Name  string
    Price float64
}

// Method to calculate discounted price
func (p Product) Discount(rate float64) float64 {
    return p.Price * (1 - rate)
}

func main() {
    product := Product{Name: "Laptop", Price: 1000}
    fmt.Println("Discounted Price:", product.Discount(0.1)) // 900
}

Enter fullscreen mode Exit fullscreen mode

Encapsulation and Abstraction
Methods allow you to encapsulate complex logic inside structs, exposing only necessary details to the user.
Example: Encapsulating Validation Logic

type User struct {
    Name  string
    Email string
}

func (u User) IsValidEmail() bool {
    return strings.Contains(u.Email, "@")
}

func main() {
    user := User{Name: "Johns", Email: "johns@example.com"}
    fmt.Println("Valid email:", user.IsValidEmail()) // true
}
Enter fullscreen mode Exit fullscreen mode

The logic for validating the email is encapsulated in the IsValidEmail method, keeping it separate from other parts of the code.

Chaining Methods
Since methods can return the struct itself (or a pointer to it), you can chain multiple methods together for more concise and readable code.
Example: Chaining User Updates

type User struct {
    Name  string
    Email string
}

func (u *User) SetName(name string) *User {
    u.Name = name
    return u
}

func (u *User) SetEmail(email string) *User {
    u.Email = email
    return u
}

func main() {
    user := &User{}
    user.SetName("Doreen").SetEmail("doreen@example.com")
    fmt.Println(user) // &{Doreen doreen@example.com}
}
Enter fullscreen mode Exit fullscreen mode

Benefits of Methods in Structs
Code Organization: Groups related data (fields) and functionality (methods) together.
Readability: Methods clearly express the actions or behaviors of a struct.
Encapsulation: Keeps implementation details hidden and exposes only necessary functionality.
Reusability: Methods can operate on struct instances in various parts of the program.
Extensibility: Easily add new behaviors without altering external code.
By attaching methods to structs, you can make your Go programs more modular, maintainable, and expressive!

Conclusion

Structs in Go are powerful, flexible, and integral to writing clean, efficient, and organized code. They are the go-to choice when you need to represent real-world entities, manage data, or implement object-oriented principles. By mastering structs, you can create scalable and maintainable Go applications.
By attaching methods to structs, you can make your Go programs more modular, maintainable, and expressive!

Top comments (0)