Everything you need to know about rubber-duck debugging for Go
As a Go developer, have you ever found yourself stuck on a bug for hours, only to have that “aha” moment when explaining the problem to someone else? This problem isn’t new, and it’s at the heart of what we call Rubber Duck Debugging.
What is Rubber Duck Debugging?
Rubber Duck Debugging is a simple technique where developers explain their code, line by line, to an inanimate object — traditionally, a rubber duck. To explain a problem, you need to understand it, and often whilst trying to find the words to describe the issue you can discover the flaws in your logic. In a world of AI, it’s easy to forget about simple techniques, but this is still a really useful one!
How Does It Work?
The process is straightforward:
-
Grab Your Rubber Duck – If you don’t have a rubber duck, any object will do. The more ridiculous the better.
-
Explain Your Code – Go line by line through your Go code, explaining what each piece is supposed to do and also what it actually does.
-
Analyze the Problem – As you explain the logic, you’ll likely spot differences between what it’s meant to do and what it’s actually doing.
Why Rubber Duck Debugging is Perfect for Go Developers
Go is known for its simplicity, but that doesn’t mean debugging is always easy. Many Go developers find themselves dealing with concurrency issues, memory leaks, or unexpected behavior in their programs. Here’s why rubber duck debugging is particularly useful:
-
Concurrency Challenges: Go routines and channels can be tricky to get right. Verbally explaining how your program handles concurrency can uncover subtle bugs. Have a go at explaining this code and see if you can spot the issue:
package main import ( "fmt" "time" ) func worker(id int, jobs <-chan int, results chan<- int) { for job := range jobs { fmt.Printf("Worker %d started job %dn", id, job) time.Sleep(time.Second) // Simulate work fmt.Printf("Worker %d finished job %dn", id, job) results <- job * 2 // Simulate a result } } func main() { jobs := make(chan int, 5) results := make(chan int, 5) for w := 1; w <= 3; w++ { go worker(w, jobs, results) } for j := 1; j <= 5; j++ { jobs <- j } close(jobs) for r := 1; r <= 5; r++ { fmt.Printf("Result: %dn", <-results) } }
-
Go’s Error-Handling Patterns: Go’s explicit error handling is both a strength and a challenge. Explaining how errors propagate through your program can highlight overlooked mistakes. Here’s some code to try and talk through with your new rubber duck friend. Where might it be not working as expected?
package main import ( "errors" "fmt" "os" ) func readFile(filename string) (string, error) { file, err := os.Open(filename) if err != nil { return "", fmt.Errorf("error opening file %s: %w", filename, err) } defer file.Close() // Simulate reading the file content := "file content" if content == "" { return "", errors.New("file is empty") } return content, nil } func processFile(filename string) error { content, _ := readFile(filename) // Simulate processing the content if len(content) < 10 { return errors.New("file content is too short") } fmt.Println("File processed successfully") return nil } func main() { err := processFile("example.txt") if err != nil { fmt.Printf("An error occurred: %vn", err) } else { fmt.Println("Program executed successfully") } }
-
Memory Management: Debugging memory usage in Go, especially with pointers, can be confusing. By explaining the flow of data, rubber duck debugging helps you trace potential issues more clearly. Here’s some code to debug. This one is tough (tip: think about things being nil).
package main import ( "fmt" ) type Node struct { Value int Next *Node } func createNode(value int) *Node { node := Node{Value: value} return &node } func addNode(head *Node, value int) { current := head for current.Next != nil { current = current.Next } newNode := createNode(value) current.Next = newNode } func printList(head *Node) { current := head for current != nil { fmt.Printf("Node value: %dn", current.Value) current = current.Next } } func main() { head := createNode(1) addNode(head, 2) addNode(head, 3) addNode(head, 4) printList(head) }
How to Integrate Rubber Duck Debugging with Your Debugging Process
Rubber duck debugging should be used alongside other debugging techniques:
-
Use Go’s Built-in Tools: Tools like
go test
,go vet
, anddelve
are great for Go debugging. Rubber duck debugging works best when there is a fundamental flaw in your thinking - these other tools will likely not help you discover that. -
Pair Programming with a Duck: If pair programming isn’t an option, rubber duck debugging can replicate the experience, giving you an opportunity to articulate your thought process without needing a second person.
-
Combine with Logging: Debugging Go often involves logging key variables or states. Walk through your logs and explain them to your duck. If you want to learn more about logging for Go, here’s everything you need to know.
Enhance Your Debugging Skills with Our Go Debugging Courses
Rubber duck debugging is a fantastic method, but it’s only one tool in your arsenal. If you’re looking to master debugging Go, our Go Debugging Courses can help you tackle even the toughest bugs.
Ready to dive deeper? Check out our Go Debugging Courses and start squashing those bugs today! If you’re not sure if it’s right for you, I gave a speed run of the contents as a talk at Gophercon UK that you can watch for free here.