Go for Ruby Developers

Key Concepts

The Go compiler's SSA backend converts code into Static Single Assignment form for optimization, distinct from source-level variables.

From classes to structs

You write Ruby. You define a class, add methods, and instantiate objects. Everything is an object. You inherit from ActiveRecord::Base or Object, and the magic happens. Now you open a Go file. You see struct instead of class. You see func floating outside of anything. You try to add a method to a type and the syntax looks like a function with a receiver. The magic is gone. That is the point. Go trades implicit behavior for explicit structure.

In Ruby, a class bundles data and behavior together. In Go, data and behavior are separate. A struct is just data. It is a collection of fields. Methods are functions that take a receiver. You attach a method to a type by declaring the receiver in the function signature. This separation makes composition easier than inheritance. You build complex types by embedding smaller structs, not by climbing a hierarchy of classes.

// User holds profile data.
type User struct {
    Name string
    Age  int
}

// Greet prints a message. The receiver (u User) binds this function to the User type.
func (u User) Greet() string {
    return "Hello, " + u.Name
}

Ruby developers often reach for inheritance to share code. Go developers reach for composition. If you need behavior from another type, embed it. The embedded type's methods become available on the outer struct. You get the behavior without the rigid parent-child relationship.

Structs hold data. Methods hold behavior. Keep them separate.

Zero values replace nil

In Ruby, a variable is nil until you assign it. You call a method on nil and you get a NoMethodError unless you guard against it. Go takes a different approach. Every type has a zero value. When you declare a variable, the compiler initializes it to that zero value automatically.

A string becomes an empty string "". An integer becomes 0. A boolean becomes false. A slice becomes nil. A map becomes nil. This design reduces crashes. You don't need to check if a variable exists before using it in many cases. The zero value is often a sensible default.

func main() {
    // count is initialized to 0 automatically. No nil check needed.
    var count int
    fmt.Println(count) // prints 0

    // name is initialized to "".
    var name string
    fmt.Println(name) // prints empty string
}

This shift changes how you write defensive code. In Ruby, you might write user.name || "Anonymous". In Go, you just use user.Name. If the struct is uninitialized, the field is already empty. You only need to worry about nil when dealing with pointers, interfaces, maps, slices, and channels. Even then, the zero value of a pointer is nil, which is explicit.

Zero values make the common case simple. You write less boilerplate to handle uninitialized state.

Interfaces without implements

Ruby uses duck typing. If it walks like a duck and quacks like a duck, it is a duck. Go is statically typed, but it achieves duck typing through interfaces. An interface defines a set of methods. Any type that implements those methods satisfies the interface. You do not declare that a type implements an interface. The compiler checks it for you.

This is a powerful pattern. You define what you need, not what you have. You write functions that accept an interface. Any type that matches the interface can be passed in. You can swap implementations without changing the function signature.

// Logger defines behavior for logging.
type Logger interface {
    Log(msg string)
}

// ConsoleLogger implements Logger implicitly.
type ConsoleLogger struct{}

func (c ConsoleLogger) Log(msg string) {
    fmt.Println(msg)
}

// Process accepts any type that satisfies Logger.
func Process(l Logger) {
    l.Log("processing")
}

In Ruby, you might pass any object and hope it responds to log. In Go, the compiler guarantees the object has the method. If you pass a type that does not match, the compiler rejects the code with cannot use ConsoleLogger (variable of struct type ConsoleLogger) as Logger value in argument: ConsoleLogger does not implement Logger (wrong type for method Log). This error tells you exactly what is missing.

Interfaces allow you to write flexible code without sacrificing type safety. You can mock dependencies for testing by creating a simple struct that implements the interface. You don't need a mocking library. You just write a struct with the right methods.

Accept interfaces, return structs. This mantra guides Go design. Functions accept interfaces to remain flexible. Functions return structs to provide concrete data.

Errors are values, not exceptions

Ruby raises exceptions. Control flow jumps to a rescue block. Exceptions can propagate up the stack silently. Go treats errors as values. Functions return an error as the last return value. You check the error immediately. If it is not nil, you handle it.

This approach makes failure paths visible. You cannot ignore an error without explicitly discarding it. The compiler forces you to acknowledge the return values. If you assign an error to a variable and do not use it, the compiler complains with err declared and not used. You must handle the error or use the blank identifier _ to discard it intentionally.

// ReadFile returns data and an error.
func ReadFile(path string) ([]byte, error) {
    // Simulate failure.
    return nil, fmt.Errorf("file not found")
}

func main() {
    data, err := ReadFile("config.txt")
    if err != nil {
        // Handle the error immediately.
        fmt.Println("failed to read:", err)
        return
    }
    fmt.Println(data)
}

The if err != nil pattern looks verbose compared to begin/rescue. The community accepts the boilerplate because it makes the unhappy path explicit. You see exactly where errors can occur. You decide how to handle them. You can wrap errors with context using fmt.Errorf("wrapped: %w", err). The %w verb wraps the error so callers can unwrap it later.

Errors are values. Handle them where they happen. Do not let them escape silently.

Pointers and the cost of copying

In Ruby, everything is a reference. When you assign a = b, you copy the reference. Both variables point to the same object. Mutating a affects b. Go passes values by value. When you assign a = b, you copy the entire value. Mutating a does not affect b.

This distinction matters for performance and mutation. If you have a large struct, copying it can be expensive. You use a pointer to share the data. A pointer holds the memory address of a value. You pass the pointer to share the underlying data without copying.

type User struct {
    Name string
}

func main() {
    u := User{Name: "Alice"}
    
    // passByValue receives a copy. Changes do not affect u.
    passByValue(u)
    fmt.Println(u.Name) // prints Alice

    // passByPointer receives the address. Changes affect u.
    passByPointer(&u)
    fmt.Println(u.Name) // prints Bob
}

func passByValue(u User) {
    u.Name = "Bob"
}

func passByPointer(u *User) {
    u.Name = "Bob"
}

Ruby developers often overuse pointers in Go. You do not need a pointer for everything. Small structs are cheap to copy. Use a pointer when you need to mutate the value or when the struct is large. The convention is to pass pointers for types that are modified or are large. Strings are cheap to pass by value. Do not pass a *string.

The receiver name follows a convention. Use one or two letters matching the type. (u *User), not (this *User) or (self *User). This keeps method signatures clean.

Pointers share data. Values isolate data. Choose based on mutation and size.

The compiler and SSA

Ruby runs on a virtual machine. Code is interpreted or JIT compiled. Go compiles to machine code. The compiler generates a binary that runs directly on the CPU. This gives Go predictable performance and fast startup times. You do not need a runtime environment installed on the target machine.

The Go compiler uses an internal representation called SSA, which stands for Static Single Assignment. In SSA form, every variable is assigned exactly once. The compiler transforms your Go code into SSA to analyze data flow and perform optimizations. It tracks how values move through the program. It eliminates dead code. It inlines functions. It generates efficient machine code.

You do not write SSA. The compiler handles it. You can inspect the assembly output using compiler flags. Running go build -gcflags=-S main.go prints the assembly code generated by the compiler. This is useful for debugging performance issues. The SSA backend is internal. You interact with it through the compiler flags and the resulting binary.

The SSA optimization allows Go to be fast without a JIT compiler. The compiler does the heavy lifting ahead of time. You get consistent performance across different machines. The binary is portable. You compile once and run anywhere.

The compiler optimizes aggressively. Write idiomatic Go and trust the toolchain.

Concurrency with goroutines

Ruby has threads, but the Global VM Lock limits parallelism. Only one thread runs Ruby code at a time. Go has goroutines. Goroutines are lightweight threads managed by the Go runtime. You can spawn thousands of goroutines. They run concurrently and share memory safely through channels.

A goroutine starts with the go keyword. It runs the function in the background. The main function continues executing. You use channels to communicate between goroutines. Channels are typed conduits. You send values on a channel and receive them on the other end. This prevents race conditions. You share memory by communicating, not by communicating by sharing memory.

func worker(id int, jobs <-chan int, results chan<- int) {
    for j := range jobs {
        // Process job.
        results <- j * 2
    }
}

func main() {
    jobs := make(chan int, 100)
    results := make(chan int, 100)

    // Start three worker goroutines.
    for w := 1; w <= 3; w++ {
        go worker(w, jobs, results)
    }

    // Send jobs.
    for j := 1; j <= 5; j++ {
        jobs <- j
    }
    close(jobs)

    // Collect results.
    for a := 1; a <= 5; a++ {
        <-results
    }
}

Goroutine leaks happen when a goroutine waits on a channel that never gets closed. Always ensure channels are closed when no more values will be sent. Use context.Context to cancel long-running goroutines. Context is plumbing. Run it through every long-lived call site.

Goroutines are cheap. Channels are the glue. Design for concurrency from the start.

When to use what

Use a struct when you need to group related data into a single unit. Use an interface when you want to define behavior without tying it to a specific type. Use a goroutine when you have independent work that can run concurrently. Use a channel when you need to pass data between goroutines safely. Use error returns when a function can fail and the caller needs to decide how to handle it. Use a pointer when you need to mutate a value or when the type is large. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.

Where to go next