more text

This commit is contained in:
tarun-elango
2026-04-26 14:09:04 -04:00
parent 26810e43d0
commit be31df2d44
22 changed files with 10664 additions and 0 deletions
+717
View File
@@ -0,0 +1,717 @@
# 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.
```go
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.
```go
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`
```go
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`:
```go
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`.
```go
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.
```go
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.
```go
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.
```go
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.
```go
ids := []int{101, 102, 103}
ids = append(ids, 104)
```
Internally, a slice conceptually contains:
- a pointer to backing storage
- a length
- a capacity
```mermaid
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:
```go
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.
```go
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
```go
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
```go
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.
```go
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.
```go
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.
```go
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.
```go
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.
```go
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.
```go
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.
```go
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:
```go
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.
```go
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.
```mermaid
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.
```go
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.
```go
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.
```go
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.
```go
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.
```go
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.