Files
Computer-Fundamentals/go/02_core_language_fundamentals.md
tarun-elango be31df2d44 more text
2026-04-26 14:09:04 -04:00

18 KiB

Go: Core Language Fundamentals

Learning Objectives

  • Understand Go's type system and value model.
  • Use variables, constants, control flow, and core data structures idiomatically.
  • Understand how slices, maps, strings, and pointers actually behave.
  • Write functions, methods, structs, and interfaces with practical clarity.
  • Handle errors the Go way instead of forcing exception-style thinking onto the language.
  • Build intuition for memory, receivers, and abstraction choices in real backend code.

Start with the Right Mental Model

Go is a statically typed language centered on values. That sounds simple, but it shapes almost everything.

In practice, most Go code is about:

  • creating values
  • transforming values
  • passing values between functions
  • attaching behavior to named types
  • returning explicit errors when something goes wrong

Go is not a class hierarchy language. It is not an exception-driven language. It is not a macro-heavy metaprogramming language. It is a language that tries to keep data flow and control flow visible.

That visibility is why production Go code can be easy to reason about even when the system itself is large.

Variables, Constants, and Zero Values

What They Are

Variables store values whose contents can change. Constants are fixed compile-time values.

package main

import "fmt"

func main() {
    var retries int = 3
    timeoutSeconds := 10
    const serviceName = "billing-api"

    fmt.Println(retries, timeoutSeconds, serviceName)
}

Why Go Has Multiple Declaration Styles

Go gives you a few common forms:

  • var name Type when the type matters or you want the zero value
  • var name Type = value when you want explicitness
  • name := value inside functions when the type is obvious
  • const for fixed values known at compile time

Idiomatic Go uses short declaration heavily inside functions because it keeps code readable without losing type safety.

Zero Values Matter More Than They Seem

Every variable has a useful default value.

  • numbers become 0
  • booleans become false
  • strings become ""
  • pointers, slices, maps, interfaces, channels, and functions become nil

This matters because many Go types are designed so the zero value is already valid.

Examples:

  • a bytes.Buffer can be used immediately
  • a sync.Mutex can be locked immediately
  • a time.Time has a well-defined zero state

That design lowers initialization friction and reduces a whole class of bugs.

When to Use const

Use constants for values that are conceptually fixed:

  • protocol names
  • status labels
  • numeric tuning knobs that should not change at runtime

Do not use const just because a variable happens not to change in one function. Prefer clarity over ceremony.

iota and Enumerated Constants

Go does not have enums in the Java sense, but it uses typed constants effectively.

type JobState int

const (
    JobPending JobState = iota
    JobRunning
    JobDone
    JobFailed
)

Why this exists:

  • it gives you readable symbolic values
  • it keeps the type distinct from unrelated integers
  • it works well with switches, logging, and serialization helpers

Control Flow: Small Set, High Clarity

Go intentionally keeps control flow simple.

if

if err != nil {
    return err
}

This pattern appears constantly in Go because errors are explicit return values.

You will also see scoped initialization inside if:

if user, err := repo.Find(ctx, id); err != nil {
    return err
} else {
    fmt.Println(user.Name)
}

Use this sparingly. It is useful when the scope should remain local, but overused it can make code denser than necessary.

for

Go has one looping keyword: for.

for i := 0; i < 3; i++ {
    fmt.Println(i)
}

for condition {
    fmt.Println("acts like while")
    break
}

for {
    break
}

Why this is useful:

  • fewer looping forms to remember
  • easier syntax surface for reading code
  • the language avoids duplicated constructs with minor differences

range

range iterates over slices, arrays, strings, maps, and channels.

nums := []int{10, 20, 30}
for index, value := range nums {
    fmt.Println(index, value)
}

Important practical details:

  • ranging over a map does not guarantee order
  • ranging over a string gives runes, not raw bytes
  • ranging over a channel continues until the channel is closed

switch

Go's switch is more flexible than many beginners expect.

switch state {
case JobPending:
    fmt.Println("queued")
case JobRunning:
    fmt.Println("in progress")
default:
    fmt.Println("terminal state")
}

There is also expression-less switch, which is a readable alternative to long if ladders.

Core Data Structures

Arrays

Arrays have fixed length and are value types.

var ports [3]int
ports[0] = 8080

Arrays exist, but in day-to-day Go you use slices much more often.

Why arrays still matter:

  • they are the underlying foundation for slices
  • fixed-size data can be useful for performance-sensitive code
  • array values emphasize that size is part of the type

Slices

A slice is a small descriptor pointing at an underlying array.

ids := []int{101, 102, 103}
ids = append(ids, 104)

Internally, a slice conceptually contains:

  • a pointer to backing storage
  • a length
  • a capacity
flowchart LR
    A[Slice Header] --> B[Pointer]
    A --> C[Length]
    A --> D[Capacity]
    B --> E[Backing Array]

Why Slices Exist

They give you dynamic-seeming sequences without hiding memory behavior completely. They are a practical middle ground:

  • easier to use than raw arrays
  • cheaper than many boxed collection abstractions
  • explicit enough that performance behavior is still understandable

How append Works Internally

If capacity is available, append writes into the existing backing array. If capacity is exhausted, Go allocates a new array, copies the old contents, and returns a new slice header.

That means two things:

  • appending may reallocate
  • slices sharing backing storage can affect each other unexpectedly

Example:

base := []int{1, 2, 3, 4}
a := base[:2]
b := base[:3]

a = append(a, 99)
fmt.Println(base, a, b)

This surprises many beginners because a may overwrite data visible through b if both still share the same backing array.

Maps

Maps are Go's hash table type.

counts := map[string]int{
    "ok":    12,
    "error": 3,
}

counts["retry"]++

Why maps exist:

  • fast key lookup
  • natural representation for counters, indexes, sets, and lookup tables

Important details:

  • reading a missing key returns the value type's zero value
  • use the two-result form when absence matters
  • map iteration order is deliberately not stable
value, ok := counts["missing"]
fmt.Println(value, ok)

Maps are reference-like structures managed by the runtime. A nil map can be read from, but writing to it panics.

Strings, Bytes, and Runes

Go strings are immutable sequences of bytes, usually holding UTF-8 encoded text.

This distinction matters:

  • byte is an alias for uint8
  • rune is an alias for int32 and represents a Unicode code point
message := "Go cafe"
fmt.Println(len(message))

for _, r := range message {
    fmt.Printf("%c\n", r)
}

Why this matters in real systems:

  • HTTP payloads and file formats are byte-oriented
  • user-visible text is Unicode-oriented
  • confusing the two leads to subtle bugs in validation, truncation, and indexing

If you need mutable byte data, use []byte, not string.

Functions: Multiple Return Values and Defer

Functions as the Unit of Composition

Go prefers straightforward function composition over deep inheritance trees.

func parsePort(value string) (int, error) {
    port, err := strconv.Atoi(value)
    if err != nil {
        return 0, fmt.Errorf("parse port %q: %w", value, err)
    }

    return port, nil
}

Why Multiple Return Values Exist

This is one of the most important Go design choices. Instead of exceptions for normal failure, functions can return both a result and an error.

Benefits:

  • the failure path is visible in the function signature
  • callers must consciously handle errors
  • control flow stays explicit

In backend systems, this reduces the hidden control-flow jumps that exception-heavy code can create.

defer

defer schedules a function call to run when the surrounding function returns.

func readConfig(path string) error {
    file, err := os.Open(path)
    if err != nil {
        return err
    }
    defer file.Close()

    // read file
    return nil
}

Why it exists:

  • resource cleanup should be hard to forget
  • cleanup code often belongs near acquisition code

Internally, deferred calls are recorded and executed in last-in, first-out order when the function exits.

Use defer for correctness first. In very hot paths, you may care about its cost, but in most business logic the clarity win is worth it.

Pointers and Memory Intuition

What a Pointer Is

A pointer stores the address of another value.

func increment(value *int) {
    *value++
}

Go pointers exist so you can:

  • mutate shared state intentionally
  • avoid copying very large values when appropriate
  • define methods that update a receiver

Why Go Pointers Feel Safer Than C Pointers

Go allows pointers, but it removes several sharp edges:

  • no pointer arithmetic
  • garbage collection manages object lifetime
  • type safety is preserved

This is a very deliberate design choice. Go wants the practical utility of pointers without turning everyday backend code into manual memory management.

Stack, Heap, and Escape Analysis

Do not think of Go as "everything is on the heap." The compiler decides where values live.

  • if a value can stay local safely, it may live on the stack
  • if it must outlive the local frame or be referenced elsewhere, it may escape to the heap

This compiler decision process is called escape analysis.

Why it matters:

  • heap allocation can increase GC pressure
  • unnecessary pointer-heavy designs can make code slower and harder to reason about

You usually do not hand-place objects yourself. Instead, you write clear code and learn enough about allocation behavior to avoid obviously wasteful patterns.

Structs and Methods

Structs: Go's Primary Data Modeling Tool

Structs group related fields.

type User struct {
    ID    int64
    Name  string
    Email string
}

Why structs matter:

  • they model domain entities cleanly
  • they work naturally with JSON, databases, and configuration
  • they keep data layout explicit

Methods

Methods are functions attached to a type.

type Counter struct {
    value int
}

func (c *Counter) Inc() {
    c.value++
}

func (c Counter) Value() int {
    return c.value
}

Value Receiver or Pointer Receiver

Use a pointer receiver when:

  • the method mutates the receiver
  • the struct is large enough that copying is undesirable
  • consistency across methods is clearer with pointers

Use a value receiver when:

  • the value is small and conceptually immutable
  • copying is cheap and expected

In real projects, consistency matters. If a type usually uses pointer receivers, use them across the method set unless there is a strong reason not to.

Composition Over Inheritance

Go does not have class inheritance. Instead, it leans on composition and embedding.

type Logger struct{}

func (Logger) Info(msg string) {
    fmt.Println("INFO:", msg)
}

type Server struct {
    Logger
    addr string
}

Embedding can promote fields and methods, but use it to express a real structural relationship, not to imitate inheritance mechanically.

Interfaces: Small, Behavioral, and Implicit

What an Interface Is

An interface describes behavior, not concrete data layout.

type Store interface {
    Save(ctx context.Context, user User) error
}

Any type with a matching method set satisfies the interface automatically.

Why Implicit Satisfaction Exists

Go avoids the ceremony of explicit implements declarations. The benefit is that interfaces are lightweight and decoupled from concrete types.

That means you can define interfaces where they are needed, usually at the consumer side.

Example:

type UserService struct {
    store Store
}

The service depends on behavior, not a particular database implementation.

How Interfaces Work Internally

Conceptually, an interface value holds:

  • the dynamic concrete type
  • the concrete value or pointer value

This is why the "nil interface" pitfall exists. An interface can contain a typed nil pointer and still be non-nil as an interface value.

That pitfall shows up in logging, error returns, and optional dependency wiring.

Keep Interfaces Small

Good Go interfaces are often tiny.

type Clock interface {
    Now() time.Time
}

Why small interfaces are better:

  • easier to implement
  • easier to test
  • less coupling
  • behavior stays focused

Large interfaces usually signal that an abstraction was designed too early or at the wrong level.

graph TD
    A[HTTP Handler] --> B[UserService interface]
    B --> C[PostgresUserService]
    B --> D[InMemoryUserService]

Generics: Useful, But Not the Center of Go

Modern Go supports type parameters.

func MapSlice[T any, U any](items []T, fn func(T) U) []U {
    result := make([]U, 0, len(items))
    for _, item := range items {
        result = append(result, fn(item))
    }
    return result
}

Why generics exist:

  • avoid repetitive boilerplate for reusable containers and algorithms
  • preserve static type safety
  • reduce reliance on interface{} or reflection for generic utilities

When to use them:

  • collections
  • reusable helpers where type-specific duplication is obvious
  • infrastructure libraries

When not to use them:

  • when a plain interface or a concrete type is simpler
  • when the abstraction is more confusing than the duplication it removes

Go generics are powerful, but idiomatic Go still prefers the simplest tool that expresses the problem clearly.

Error Handling Idioms

Errors Are Values

This is a core Go idea. Failure is usually represented as an error return value.

func loadUser(ctx context.Context, repo Store, user User) error {
    if err := repo.Save(ctx, user); err != nil {
        return fmt.Errorf("save user %d: %w", user.ID, err)
    }

    return nil
}

Why Go Chooses Explicit Errors

In systems code, unexpected control flow is expensive to reason about. Explicit errors keep the failure path visible.

This helps with:

  • tracing the source of failures
  • adding context at each layer
  • making retry and fallback decisions deliberately

Wrapping Errors

Use %w with fmt.Errorf when you want to preserve the underlying cause.

return fmt.Errorf("read config: %w", err)

Then callers can inspect it with errors.Is or errors.As.

Sentinel Errors and Typed Errors

Sentinel errors are package-level variables for meaningful known cases.

var ErrNotFound = errors.New("not found")

Typed errors are useful when the caller needs structured details.

Use them carefully. Too many special-case errors can make APIs harder to use.

When to Panic

panic is for truly unrecoverable programmer or runtime states, not normal business failures.

Good uses are rare:

  • impossible invariants that indicate a bug
  • startup-time failures in very small programs where continuing makes no sense

Bad uses are common:

  • validation failure from user input
  • database connection hiccups during a request
  • file-not-found in normal control flow

In production services, panicking on ordinary errors creates instability and poor operability.

Real-World Usage Patterns

Request and Response Structs

Structs commonly model HTTP payloads, database rows, queue messages, and config.

type CreateOrderRequest struct {
    CustomerID string `json:"customer_id"`
    AmountCents int64 `json:"amount_cents"`
}

Interfaces at Boundaries

A service often depends on an interface for storage or external calls, while the concrete implementation stays in another package.

Explicit Error Returns for Every I/O Layer

Anything involving the network, disk, serialization, or databases should return precise errors with context. This is normal, not noisy. It is how production Go code stays debuggable.

Common Mistakes and Misconceptions

Mistake: Treating Slices Like Independent Dynamic Arrays

Slices can share backing storage. Appends and sub-slices can interact in surprising ways if you ignore capacity and aliasing.

Mistake: Using Pointers Everywhere

Beginners sometimes assume pointer-heavy code is more advanced. Often it is just harder to read and increases allocation pressure. Use pointers for a reason, not by default.

Mistake: Designing Huge Interfaces Up Front

Go interfaces work best when they are small and shaped by actual use. Large "service interfaces" often become rigid and awkward.

Mistake: Ignoring Unicode Details

Indexing a string operates on bytes, not necessarily user-visible characters. This matters for APIs, validation, and text handling.

Mistake: Using panic for Routine Error Handling

That is usually a sign you are importing habits from another language rather than using Go idioms.

Mistake: Confusing Nil Values

Nil slices, nil maps, nil pointers, and nil interfaces do not all behave the same. Learn their semantics explicitly.

Summary

Go's core language is intentionally compact, but it is not shallow. The important ideas are practical:

  • values and types are central
  • slices, maps, and strings have concrete runtime behavior worth understanding
  • functions return errors explicitly
  • structs and methods model data and behavior
  • interfaces describe behavior and stay best when small
  • pointers and memory behavior matter, but Go shields you from manual memory management

If you can read and write these fundamentals fluently, you are ready for the most distinctive part of Go: concurrency with goroutines, channels, synchronization primitives, and request-scoped cancellation.