Skip to Content
Course content

104: Implementing Data Structures Idiomatically in Go

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Should I just export my internal map or slice, or wrap it in a struct?

I see this a lot when people coming from Python or JavaScript start with Go. They'll just use a map[string]bool to represent a Set and pass it around. While that works for a tiny script, it's a nightmare for a real project. Why? Because you have zero control over how that map is manipulated. Any part of your code can accidentally clear it, put in a nil value, or—worst of all—cause a panic by writing to it from multiple goroutines.

The idiomatic way is to wrap your data in a struct and keep the internal storage private (lowercase). This lets you define a strict API for how the data is accessed. Here is how I usually set up a simple Set:

type Set struct {
    elements map[string]struct{}
}

func NewSet() *Set {
    return &Set{
        elements: make(map[string]struct{}),
    }
}

func (s *Set) Add(item string) {
    s.elements[item] = struct{}{}
}

func (s *Set) Contains(item string) bool {
    _, exists := s.elements[item]
    return exists
}

Notice I used struct{} as the map value. It's a Go trick: an empty struct consumes zero bytes of memory, making it the most efficient way to implement a set. If you used bool, you'd be wasting a byte for every single entry.

Do I always need pointer receivers for my data structure methods?

In short: yes, almost always. If your method needs to modify the internal state of the structure—like adding an element to our Set—you must use a pointer receiver (*Set). If you use a value receiver, Go creates a copy of the entire struct every time you call the method, and you'll be modifying a copy that disappears the moment the function returns. It's a classic "why isn't my data updating?" bug.

But there's a second reason to use pointers: consistency. I've seen developers use value receivers for "read-only" methods (like Contains) and pointer receivers for "write" methods (like Add). Don't do that. It's confusing. Pick one and stick to it for the entire type. Since your data structures will almost certainly have at least one method that modifies state, make them all pointer receivers.

How do I make my data structure flexible without using classes or inheritance?

Since Go doesn't have inheritance, you'll be tempted to try and "mimic" it. Resist that urge. Instead, focus on interfaces. If you're building a data structure, don't force the rest of your app to depend on the concrete Set struct. Instead, define an interface that describes what the structure does.

For example, if you have a system that needs to track unique visitors, don't pass around a *Set. Pass around a UniqueTracker interface:

type UniqueTracker interface {
    Add(id string)
    Contains(id string) bool
}

// Now this function doesn't care if you're using a Map-based Set, 
// a Redis-backed Set, or a Bloom Filter.
func ProcessVisitors(tracker UniqueTracker, ids []string) {
    for _, id := range ids {
        if !tracker.Contains(id) {
            tracker.Add(id)
            fmt.Println("New visitor discovered:", id)
        }
    }
}

This approach decouples your storage implementation from your business logic. I've spent hours refactoring old Go code where the developer tied everything to a specific struct, only to realize later they needed to switch to a more performant data structure. Using interfaces saves you from that pain.




📋 Practical Task

Exercise: Building a Thread-Safe Unique User Registry

In a real production environment, your data structures are often accessed by multiple goroutines simultaneously. A standard map in Go is not thread-safe and will crash your program with a concurrent map write panic if you're not careful.

Your Task: Modify the Set implementation from the lesson to create a UserRegistry. Your implementation must meet the following requirements:

  • Use a sync.RWMutex to ensure that multiple goroutines can read from the registry, but only one can write to it at a time.
  • Implement a method Add(userId string) that safely adds a user.
  • Implement a method HasUser(userId string) bool that safely checks for a user's existence using a read lock (RLock) for better performance.
  • Implement a method Remove(userId string) that safely deletes a user from the registry.
  • Ensure the internal map is private and cannot be accessed from outside the package.

Test your implementation by launching 100 goroutines that concurrently add and remove users from the same registry instance to ensure no panics occur.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.