Chapter 3

Basic Types and Control Flow

Basic Types and Control Flow

A programming language's type system reflects its worldview. C's attitude toward types is "trust the programmer" — allowing almost any implicit conversion. Python's attitude is "don't let types get in the way" — checking types only at runtime. Haskell's attitude is "types are proofs" — using the type system to express program correctness constraints.

Go's attitude is: Types are communication tools. Types are primarily not for the compiler, but for the humans reading the code. This is why Go requires all type conversions to be explicit, why Go has zero-value design, and why Go has so many integer types — these designs let you understand the precise semantics of data when reading code, without running the program.

Level 1: What You Need to Know

Integer Types

Go provides two categories of integer types: fixed-size and platform-dependent.

Fixed-size integer types:

Type Size Range Usage
int8 1 byte -128 to 127 Rarely used directly
int16 2 bytes -32768 to 32767 Rarely used directly
int32 4 bytes -2^31 to 2^31-1 ~±2.14 billion
int64 8 bytes -2^63 to 2^63-1 ~±9.2 × 10^18
uint8 1 byte 0 to 255 Same as byte
uint16 2 bytes 0 to 65535
uint32 4 bytes 0 to 2^32-1 ~4.29 billion
uint64 8 bytes 0 to 2^64-1 ~1.8 × 10^19

Platform-dependent integer types:

Type 32-bit system 64-bit system Usage
int 4 bytes 8 bytes Most commonly used integer type
uint 4 bytes 8 bytes Unsigned counting
uintptr 4 bytes 8 bytes Storing pointer values (with unsafe package)

Which integer type to choose?

// Default choice: use int
count := 42        // Type-inferred as int
for i := 0; i < n; i++ { }  // Loop variable is int

// When exact size is needed (e.g., protocol parsing, file formats)
type PacketHeader struct {
    Version  uint8
    Type     uint8
    Length   uint16
    Sequence uint32
}

// Working with timestamps
timestamp := time.Now().Unix()  // Returns int64

// Slice length and indexing
length := len(slice)  // Returns int

Rule of thumb: Unless you have a specific reason to choose a particular size, use int. Only use fixed-size types when interfacing with external systems (network protocols, file formats, database fields).

Floating-Point Types

Go has two floating-point types, both following the IEEE 754 standard:

Type Size Precision Range
float32 4 bytes ~7 significant digits ±3.4 × 10^38
float64 8 bytes ~15 significant digits ±1.8 × 10^308
// Default to float64
price := 19.99         // float64
pi := 3.141592653589   // float64

// Use float32 only in memory-sensitive scenarios (e.g., many 3D coordinates)
type Vector3 struct {
    X, Y, Z float32
}

// Scientific notation
avogadro := 6.022e23     // 6.022 × 10^23
planck := 6.626e-34      // 6.626 × 10^-34

// Special values
inf := math.Inf(1)       // +∞
negInf := math.Inf(-1)   // -∞
nan := math.NaN()        // Not a Number

// NaN's peculiarity: NaN != NaN
fmt.Println(nan == nan)  // false!
fmt.Println(math.IsNaN(nan)) // true

Important: Never use floating-point for monetary amounts! Floating-point cannot precisely represent decimal fractions.

// Wrong approach
price := 0.1 + 0.2
fmt.Println(price == 0.3)  // false! price is actually 0.30000000000000004

// Correct approach: use integers representing smallest unit (cents)
priceCents := 199  // $1.99 = 199 cents

Strings

Go strings have three core properties:

  1. Immutable — once created, their content cannot be modified
  2. UTF-8 encoded — UTF-8 by default, no additional encoding conversion needed
  3. Essentially a read-only byte slicestring's underlying representation is []byte, but modification is not allowed
// String literals
s1 := "Hello, 世界"    // Double quotes, supports escape characters
s2 := `Hello, 世界`    // Backticks, raw string (no escaping)

// String length
fmt.Println(len(s1))              // 13 bytes ("Hello, " 7 bytes + "世界" 6 bytes)
fmt.Println(utf8.RuneCountInString(s1)) // 9 characters

// String concatenation
greeting := "Hello" + ", " + "World"  // Simple concatenation

// Multi-line strings
sql := `
    SELECT id, name, email
    FROM users
    WHERE status = 'active'
    ORDER BY created_at DESC
    LIMIT 100
`

// String iteration
for i, ch := range "Hello, 世界" {
    fmt.Printf("index=%d char=%c unicode=%U\n", i, ch, ch)
}
// index=0 char=H unicode=U+0048
// index=1 char=e unicode=U+0065
// ...
// index=7 char=世 unicode=U+4E16  (note: index jumps!)
// index=10 char=界 unicode=U+754C

Boolean Type

var b bool          // Zero value is false
isReady := true
isEmpty := len(s) == 0

// Go has no implicit truthy/falsy values
// if 1 { }          // Compilation error! Can't use integer as condition
// if "" { }         // Compilation error! Can't use string as condition
if n != 0 { }       // Must compare explicitly
if s != "" { }      // Must compare explicitly

The rune Type

rune is an alias for int32, used to represent a Unicode code point.

// rune literals use single quotes
var ch rune = '中'     // Unicode code point U+4E2D, value 20013
var a rune = 'A'      // Unicode code point U+0041, value 65

// rune is just int32
fmt.Printf("%T\n", ch)  // int32
fmt.Printf("%d\n", ch)  // 20013
fmt.Printf("%c\n", ch)  // 中
fmt.Printf("%U\n", ch)  // U+4E2D

// byte vs rune
var b byte = 'A'  // byte is alias for uint8, can only represent ASCII
var r rune = '中' // rune is alias for int32, can represent all Unicode

Why is rune needed? Because Go strings are UTF-8 encoded byte sequences. A Chinese character occupies 3 bytes; indexing by byte directly gives wrong results:

s := "Go语言"
fmt.Println(s[0])    // 71 ('G' ASCII)
fmt.Println(s[2])    // 232 (not '语'! It's the first byte of '语''s UTF-8 encoding)

// Correct approach: convert to rune slice
runes := []rune(s)
fmt.Println(string(runes[2]))  // "语"

Control Flow

Go's control flow statements are very minimal — only if, for, switch, select (plus goto, break, continue).

if statement:

// Basic if
if x > 0 {
    fmt.Println("positive")
} else if x == 0 {
    fmt.Println("zero")
} else {
    fmt.Println("negative")
}

// if with init statement (Go-specific! Very commonly used)
if err := doSomething(); err != nil {
    return err
}
// err's scope is limited to the if block

// This pattern is extremely common in Go
if n, err := fmt.Scanf("%d", &x); err != nil {
    log.Fatal(err)
} else if n != 1 {
    log.Fatal("expected 1 value")
}

for loop (Go's only loop statement):

// Traditional for
for i := 0; i < 10; i++ {
    fmt.Println(i)
}

// while equivalent (Go has no while keyword)
for condition {
    // ...
}

// Infinite loop
for {
    // break to exit
}

// range iteration
for index, value := range slice { }
for key, value := range myMap { }
for index, char := range "string" { }  // char is rune type
for line := range channel { }           // Go 1.22+: range over func

// Index only
for i := range slice { }

// Value only (ignore index)
for _, v := range slice { }

switch statement:

// Basic switch (no break needed! Auto-breaks)
switch day {
case "Monday":
    fmt.Println("Monday")
case "Tuesday", "Wednesday":  // Multi-value match
    fmt.Println("Tuesday or Wednesday")
default:
    fmt.Println("other")
}

// Tagless switch (replaces long if-else chains)
switch {
case score >= 90:
    grade = "A"
case score >= 80:
    grade = "B"
case score >= 70:
    grade = "C"
default:
    grade = "F"
}

// Type switch (for interface type assertion)
switch v := i.(type) {
case int:
    fmt.Println("int:", v)
case string:
    fmt.Println("string:", v)
case nil:
    fmt.Println("nil")
default:
    fmt.Printf("unexpected type %T\n", v)
}

// fallthrough (explicitly continue to next case)
switch n {
case 1:
    fmt.Println("one")
    fallthrough
case 2:
    fmt.Println("at most two")
}

defer statement:

// defer executes before function returns, LIFO order
func processFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close()  // Guarantees file closure regardless of errors

    // Process file...
    return nil
}

// defer execution order
func example() {
    defer fmt.Println("first")   // Executes last
    defer fmt.Println("second")  // Second to last
    defer fmt.Println("third")   // Executes first
    // Output: third, second, first
}

// Common defer uses
mutex.Lock()
defer mutex.Unlock()

resp, err := http.Get(url)
if err != nil { return err }
defer resp.Body.Close()

Level 2: How It Works Under the Hood

Zero Value Design Philosophy

Every type in Go has a zero value — if a variable is declared without explicit initialization, it automatically has its zero value.

Type Zero Value
Numeric types (int, float, etc.) 0
bool false
string "" (empty string)
pointer nil
slice nil (but len=0, cap=0)
map nil
channel nil
interface nil
struct All fields recursively zero-valued
array All elements recursively zero-valued
function nil

The philosophical significance of zero values: Declaration means ready to use.

In C, uninitialized local variables contain garbage values (undefined behavior), which is a breeding ground for bugs. In Java, using an uninitialized local variable causes a compilation error (though instance fields have default values). Go's zero value design unifies all scenarios — every variable has a determinate value from the moment of birth.

This design allows many types to be used "out of the box" without constructors:

// sync.Mutex's zero value is an unlocked mutex — immediately usable
var mu sync.Mutex
mu.Lock()
// ...
mu.Unlock()

// bytes.Buffer's zero value is an empty buffer — immediately usable
var buf bytes.Buffer
buf.WriteString("hello")
fmt.Println(buf.String()) // "hello"

// sync.WaitGroup's zero value has count 0 — immediately usable
var wg sync.WaitGroup
wg.Add(1)
go func() {
    defer wg.Done()
    // ...
}()
wg.Wait()

Go's standard library extensively leverages zero-value usability. One of Rob Pike's Go proverbs is:

"Make the zero value useful."

When designing your own types, follow this principle — make the zero value a meaningful default state whenever possible.

Type Conversion Rules

Go has no implicit type conversions. All type conversions must be explicit.

var i int = 42
var f float64 = float64(i)  // int → float64, must be explicit
var u uint = uint(f)        // float64 → uint, must be explicit

// All of the following are compilation errors:
// var f float64 = i       // Error: cannot implicitly convert
// var x int = 3.14        // Error: float literal cannot assign to int
// var b byte = 256        // Error: overflow

// Different-sized integers also need conversion
var i32 int32 = 100
var i64 int64 = int64(i32)  // Must be explicit
var i16 int16 = int16(i32)  // Must be explicit (may lose data!)

// int and int64 are different types! (even if same size on 64-bit systems)
var a int = 42
var b int64 = int64(a)      // Must be explicit

Why is Go so "strict"?

  1. Readability. When you see float64(x), you immediately know a type conversion is happening that might have precision loss. Implicit conversions hide this information.

  2. Safety. Many C security vulnerabilities come from implicit type conversions — implicit conversions between signed and unsigned integers can lead to integer overflow exploits.

  3. Clarity. int and int64 are semantically different — int means "I don't care about the exact size," while int64 means "I need exactly 8 bytes." Even if they're the same size on 64-bit systems, their semantics differ.

Constants and iota

Go's constant system has a unique feature — untyped constants.

const pi = 3.14159265358979323846  // Untyped float constant
const e = 2.71828182845904523536   // Untyped float constant
const maxInt = 9223372036854775807 // Untyped integer constant

// Untyped constants have far higher precision than any float type
// Go spec requires at least 256 bits of precision
const (
    huge = 1 << 100           // Can't fit in any integer variable, but legal as constant
    half = huge / 2           // Still legal constant arithmetic
)

// Untyped constants can be used with any compatible type
var f32 float32 = pi  // OK, compiler truncates precision at assignment
var f64 float64 = pi  // OK, higher precision

iota — Enum Generator:

iota is Go's enumeration tool. It starts at 0 within each const block and increments by 1 for each line:

type Weekday int

const (
    Sunday    Weekday = iota  // 0
    Monday                     // 1
    Tuesday                    // 2
    Wednesday                  // 3
    Thursday                   // 4
    Friday                     // 5
    Saturday                   // 6
)

// Skipping values
type Permission uint8

const (
    Read    Permission = 1 << iota  // 1 (001)
    Write                           // 2 (010)
    Execute                         // 4 (100)
)

// Can be combined
readWrite := Read | Write  // 3 (011)

// Using iota to define byte units
const (
    _  = iota              // Skip 0
    KB = 1 << (10 * iota)  // 1 << 10 = 1024
    MB                     // 1 << 20 = 1048576
    GB                     // 1 << 30
    TB                     // 1 << 40
    PB                     // 1 << 50
)

// iota's scope is the const block
const (
    a = iota  // 0
    b         // 1
)
const (
    c = iota  // 0 (new const block, restarts from 0)
    d         // 1
)

Strings and []byte/[]rune Relationship

The key to understanding Go strings is understanding three layers of abstraction:

Layer 1: string    → Immutable UTF-8 byte sequence
Layer 2: []byte    → Mutable byte slice
Layer 3: []rune    → Mutable Unicode code point slice
s := "Hello, 世界"

// string → []byte (copies! O(n))
bytes := []byte(s)
// [72 101 108 108 111 44 32 228 184 150 231 149 140]
// H   e   l   l   o   ,  SP |----世----|  |----界----|

// string → []rune (copies! O(n))
runes := []rune(s)
// [72 101 108 108 111 44 32 19990 30028]
// H   e   l   l   o   ,  SP  世    界

// []byte → string (copies! O(n))
s2 := string(bytes)

// []rune → string (copies! O(n))
s3 := string(runes)

Why does string ↔ []byte conversion require copying?

Because string is immutable and []byte is mutable. Without copying, modifying the []byte would violate string immutability.

But the Go compiler optimizes away this copy in certain cases:

// These scenarios don't actually copy
for _, b := range []byte(s) { }  // Optimized: iterates string bytes directly
if string(byteSlice) == "hello" { }  // Optimized: compares directly, no allocation
m[string(byteSlice)]  // Optimized: no allocation during map lookup

String Concatenation Methods

// Method 1: + operator (allocates new memory each time)
s := "Hello" + " " + "World"
// Suitable for: few fixed string concatenations

// Method 2: fmt.Sprintf (reflection overhead, slower)
s := fmt.Sprintf("Name: %s, Age: %d", name, age)
// Suitable for: formatting scenarios

// Method 3: strings.Builder (recommended for loop concatenation!)
var builder strings.Builder
for i := 0; i < 1000; i++ {
    builder.WriteString("hello")
}
result := builder.String()
// Suitable for: large-scale concatenation in loops

// Method 4: strings.Join
parts := []string{"Hello", "World", "Go"}
s := strings.Join(parts, ", ")
// Suitable for: joining an existing string slice

// Method 5: []byte manual management
buf := make([]byte, 0, 1024)
buf = append(buf, "hello"...)
buf = append(buf, ' ')
buf = append(buf, "world"...)
result := string(buf)
// Suitable for: extreme performance requirements

Performance comparison (1000 concatenations of "hello"):

BenchmarkPlusOperator-8     1000  15420 ns/op  530000 B/op  999 allocs/op
BenchmarkSprintf-8          1000  52300 ns/op  531000 B/op  2001 allocs/op
BenchmarkBuilder-8          1000   4120 ns/op    5376 B/op    1 allocs/op
BenchmarkBytesAppend-8      1000   3890 ns/op    5376 B/op    1 allocs/op

strings.Builder is fast because it internally maintains a dynamically-growing []byte and only converts to a string when String() is called — and since Builder guarantees it won't modify the underlying bytes again, the String() method uses unsafe.String to avoid the final copy.

Level 3: What the Specification Says

IEEE 754 Floating-Point Precision Issues

Go's float32 and float64 strictly follow the IEEE 754-2008 standard. Understanding how floating-point works is critical for avoiding precision traps.

IEEE 754 double precision (float64) internal structure:

|1 bit|   11 bits   |              52 bits              |
|sign | exponent    |           mantissa/fraction       |

A float64's value equals: (-1)^sign × 2^(exponent-1023) × 1.mantissa

This means the largest consecutive integer that float64 can precisely represent is 2^53 = 9007199254740992. Beyond this value, the gap between adjacent representable floats is greater than 1:

// float64's integer precision boundary
a := float64(9007199254740992)  // 2^53, precisely representable
b := float64(9007199254740993)  // 2^53 + 1, NOT precisely representable!
fmt.Println(a == b)             // true! Two different numbers represented as same value

// This is a real problem when handling database IDs
// Large integers in JSON may lose precision
type Response struct {
    ID int64 `json:"id,string"` // Use string tag to prevent JS precision loss
}

The problem of decimals that can't be precisely represented:

IEEE 754 uses binary fractions. Many decimal fractions cannot be represented exactly with finite binary digits:

// 0.1 in binary is an infinitely repeating fraction
// 0.1₁₀ = 0.0001100110011001100...₂ (repeating)
fmt.Printf("%.20f\n", 0.1)  // 0.10000000000000000555
fmt.Printf("%.20f\n", 0.2)  // 0.20000000000000001110
fmt.Printf("%.20f\n", 0.3)  // 0.29999999999999998890

// So 0.1 + 0.2 ≠ 0.3
fmt.Println(0.1 + 0.2 == 0.3)  // false

// Correct floating-point comparison
func almostEqual(a, b, epsilon float64) bool {
    return math.Abs(a-b) < epsilon
}
fmt.Println(almostEqual(0.1+0.2, 0.3, 1e-10))  // true

Special value behavior (Go Language Specification section on Floating-point operators):

// Positive infinity / Negative infinity
fmt.Println(1.0 / 0.0)    // +Inf (not a panic!)
fmt.Println(-1.0 / 0.0)   // -Inf
fmt.Println(math.IsInf(1.0/0.0, 1))  // true

// NaN (Not a Number)
fmt.Println(0.0 / 0.0)    // NaN
fmt.Println(math.Log(-1)) // NaN

// NaN's bizarre properties
nan := math.NaN()
fmt.Println(nan == nan)    // false (the only value not equal to itself!)
fmt.Println(nan < 0)       // false
fmt.Println(nan > 0)       // false
fmt.Println(nan != nan)    // true

// NaN "poisons" all operations
fmt.Println(nan + 1)       // NaN
fmt.Println(nan * 0)       // NaN
fmt.Println(math.Max(nan, 100)) // NaN

String Internals: StringHeader

Go string's runtime representation is exposed in the reflect package as StringHeader:

// reflect.StringHeader (deprecated, but concept unchanged)
type StringHeader struct {
    Data uintptr  // Pointer to underlying byte array
    Len  int      // String's byte length
}

In the actual Go runtime (runtime/string.go), strings are defined as:

// Runtime internal representation
type stringStruct struct {
    str unsafe.Pointer
    len int
}

This design leads to several important implications:

  1. String assignment is O(1) — only copies pointer and length, not underlying data
s1 := "a very long string..."
s2 := s1  // O(1), s1 and s2 share the same underlying memory
  1. Substring operations are also O(1) — new string points to some offset in original string's memory
s := "Hello, World"
sub := s[7:]  // O(1), sub's Data points to s's Data+7
// But sub prevents s's underlying array from being GC'd!
  1. String comparison is O(n) — requires byte-by-byte comparison
// First compare lengths (O(1)), different → return false immediately
// Then compare pointers (O(1)), same → return true (same memory)
// Finally compare byte-by-byte (O(n))
s1 == s2
  1. Substrings of long strings prevent GC
// A common memory leak pattern
var cache string

func processLargeData(data string) {
    // data might be a several-MB string
    cache = data[:10]  // cache only needs 10 bytes
    // But since cache's underlying pointer points to data's memory
    // the entire several-MB data cannot be GC'd!

    // Correct: copy the needed portion
    cache = strings.Clone(data[:10])  // Go 1.20+
    // Or
    cache = string([]byte(data[:10]))  // Force copy
}

Type Alias vs Type Definition

Go has two ways to create new type names, with completely different semantics:

// Type Definition — creates a brand new type
type UserID int64
type Celsius float64
type Handler func(http.ResponseWriter, *http.Request)

// UserID and int64 are different types, can't directly assign
var id UserID = 42
var n int64 = id   // Compilation error! Need int64(id)

// Can define methods for the new type
func (c Celsius) ToFahrenheit() float64 {
    return float64(c)*9/5 + 32
}

// ============================================

// Type Alias — just another name, completely identical
type byte = uint8    // Go built-in alias
type rune = int32    // Go built-in alias
type any = interface{} // Go 1.18 introduced

// Custom alias
type MyInt = int
var a MyInt = 42
var b int = a  // OK! MyInt and int are the same type

// Cannot define methods for an alias (because it's not a new type)
// func (m MyInt) String() string { }  // Compilation error

When to use type definition vs alias?

Compile-Time Constant Evaluation

Go constants are evaluated at compile time and don't occupy runtime memory:

const (
    maxSize = 1024 * 1024 * 100  // Computed to 104857600 at compile time
    message = "hello" + " " + "world"  // Concatenated at compile time
    isDebug = false
)

// Compiler optimizes away unreachable code
if isDebug {
    // If isDebug is const false, this entire block is removed by compiler
    log.Println("debug info")
}

// Constant expression overflow detected at compile time
// const tooBig = 1 << 1000  // Would be compilation error if assigned to variable
// But as intermediate values in constant arithmetic, it's allowed
const (
    big = 1 << 100
    small = big >> 99  // = 2, can be assigned to int variable
)

Level 4: Edge Cases and Pitfalls

Pitfall 1: Integer Overflow

Go's integer overflow is silent — it doesn't panic, it wraps around:

var u uint8 = 255
u++  // u becomes 0, not 256! No warning whatsoever!

var i int8 = 127
i++  // i becomes -128!

// This has caused serious bugs in real systems
// Examples: counter overflow, timestamp calculation errors, buffer size miscalculation

// Defensive programming
func safeAdd(a, b int64) (int64, error) {
    if (b > 0 && a > math.MaxInt64-b) || (b < 0 && a < math.MinInt64-b) {
        return 0, errors.New("integer overflow")
    }
    return a + b, nil
}

// Or use math/bits package (Go 1.12+)
hi, lo := bits.Add64(a, b, 0)
if hi != 0 {
    // Overflow occurred
}

Integer division pitfall:

// Integer division truncates toward zero (not floor division!)
fmt.Println(7 / 2)     // 3 (positive)
fmt.Println(-7 / 2)    // -3 (not -4! Truncates toward zero)

// Remainder sign follows the dividend
fmt.Println(7 % 2)     // 1
fmt.Println(-7 % 2)    // -1 (not 1!)
fmt.Println(7 % -2)    // 1

// If you need mathematical modulo (always non-negative)
func mod(a, b int) int {
    return ((a % b) + b) % b
}

Pitfall 2: Floating-Point Comparison

// Never compare floats directly
if 0.1+0.2 == 0.3 {  // This condition is ALWAYS false!
    fmt.Println("equal")
}

// Correct: use epsilon comparison
const epsilon = 1e-9

func floatEqual(a, b float64) bool {
    if a == b {
        return true  // Handles ±Inf and exactly identical values
    }
    diff := math.Abs(a - b)
    if a == 0 || b == 0 || diff < math.SmallestNonzeroFloat64 {
        return diff < epsilon*math.SmallestNonzeroFloat64
    }
    return diff/(math.Abs(a)+math.Abs(b)) < epsilon
}

// Or a simpler but sufficient version
func approxEqual(a, b float64) bool {
    return math.Abs(a-b) < 1e-9
}

// float64 → int truncation
fmt.Println(int(2.9))   // 2 (truncates toward zero, not rounding!)
fmt.Println(int(-2.9))  // -2
fmt.Println(int(math.Round(2.5)))  // 2 (Go's Round uses banker's rounding!)
fmt.Println(int(math.Round(3.5)))  // 4

// For traditional rounding
func roundTraditional(x float64) int {
    return int(math.Floor(x + 0.5))
}

Pitfall 3: String Concatenation Performance

// Anti-pattern: using + in loops
func badConcat(n int) string {
    s := ""
    for i := 0; i < n; i++ {
        s += "hello"  // Allocates new string every time! O(n²) time complexity!
    }
    return s
}
// When n=10000, this function allocates ~250MB of temporary memory

// Correct: use strings.Builder
func goodConcat(n int) string {
    var builder strings.Builder
    builder.Grow(n * 5)  // Pre-allocate capacity (optional but recommended)
    for i := 0; i < n; i++ {
        builder.WriteString("hello")
    }
    return builder.String()
}
// When n=10000, allocates only ~50KB once

Why is + concatenation so slow in loops?

Because strings are immutable. Each s += "hello" actually:

  1. Allocates new memory (size = len(s) + 5)
  2. Copies old s's content to new memory
  3. Copies "hello" to end of new memory
  4. Old s's memory waits for GC

As s grows longer, each copy grows larger. Total copy volume for 1000 concatenations: 5+10+15+...+5000 = O(n²).

Pitfall 4: nil slice vs empty slice

// nil slice
var s1 []int          // s1 == nil, len=0, cap=0
// empty slice
s2 := []int{}         // s2 != nil, len=0, cap=0
s3 := make([]int, 0)  // s3 != nil, len=0, cap=0

// In most operations they're equivalent
fmt.Println(len(s1), len(s2))  // 0 0
s1 = append(s1, 1)  // OK
s2 = append(s2, 1)  // OK
for range s1 { }     // OK (no panic)

// But they differ in serialization!
data1, _ := json.Marshal(s1)  // null
data2, _ := json.Marshal(s2)  // []

// This distinction matters in API design
type Response struct {
    Items []Item `json:"items"`
}
// If Items is nil → {"items": null}
// If Items is []Item{} → {"items": []}
// Frontend typically expects the latter, so initialize empty slice:
resp := Response{Items: []Item{}}

Pitfall 5: Map Iteration Order Is Random

// Map iteration order is random!
m := map[string]int{"a": 1, "b": 2, "c": 3}
for k, v := range m {
    fmt.Println(k, v)
}
// Results may differ on each run! Go deliberately randomizes iteration order
// to prevent programmers from depending on a specific order

// If you need ordered iteration, sort keys manually
keys := make([]string, 0, len(m))
for k := range m {
    keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
    fmt.Println(k, m[k])
}

Pitfall 6: For-Range Variable Capture

// Classic pitfall in Go 1.21 and earlier
values := []int{1, 2, 3, 4, 5}
var funcs []func()
for _, v := range values {
    funcs = append(funcs, func() {
        fmt.Println(v)  // Captures the ADDRESS of variable v, not the value!
    })
}
for _, f := range funcs {
    f()  // Prints 5 5 5 5 5 (all the last value!)
}

// Fix 1: Create a local variable
for _, v := range values {
    v := v  // Create new variable
    funcs = append(funcs, func() {
        fmt.Println(v)  // Captures new variable
    })
}

// Fix 2: Pass as parameter
for _, v := range values {
    funcs = append(funcs, func(val int) func() {
        return func() { fmt.Println(val) }
    }(v))
}

// Go 1.22+ fixes this!
// Starting from Go 1.22, each loop iteration creates a new loop variable
// The above code correctly prints 1 2 3 4 5 in Go 1.22+

Pitfall 7: Precision Loss in Numeric Conversion

// int64 → float64 precision loss
n := int64(9007199254740993)  // 2^53 + 1
f := float64(n)
fmt.Println(int64(f))  // 9007199254740992 (lost 1!)

// float64 → int truncation
fmt.Println(int(1.9))   // 1 (not 2!)
fmt.Println(int(-1.9))  // -1 (not -2!)

// uint → int overflow
var u uint64 = math.MaxUint64  // 18446744073709551615
var i int64 = int64(u)
fmt.Println(i)  // -1 (same bit pattern, different interpretation)

// Large int to small int truncation
var big int64 = 256
var small int8 = int8(big)
fmt.Println(small)  // 0 (256's low 8 bits are all zeros)

var big2 int64 = 300
var small2 int8 = int8(big2)
fmt.Println(small2)  // 44 (300 = 256 + 44, only low 8 bits kept)

Pitfall 8: String Indexing Returns Bytes, Not Characters

s := "café"
fmt.Println(len(s))     // 5 (not 4! é occupies 2 bytes)
fmt.Println(s[3])       // 169 (first byte of é's value, not the whole character)
fmt.Println(string(s[3]))  // Garbled (incomplete UTF-8 sequence)

// Correctly getting the nth character
runes := []rune(s)
fmt.Println(string(runes[3]))  // é

// Or use the utf8 package
r, size := utf8.DecodeRuneInString(s[3:])
fmt.Printf("%c (%d bytes)\n", r, size)  // é (2 bytes)

// String slicing is also byte-based
fmt.Println(s[:3])   // "caf" (first 3 bytes)
fmt.Println(s[:4])   // "caf\xc3" (incomplete UTF-8!)
fmt.Println(s[:5])   // "café" (complete)

Practical Recommendations Summary

Scenario Recommended Approach
General integers int
Exact size (protocols/file formats) int32/int64/uint16 etc.
Money Integer (cents as unit) or shopspring/decimal library
Float comparison Epsilon comparison, not ==
Loop string concatenation strings.Builder
Iterating string characters for _, r := range s (r is rune)
Getting nth character of string []rune(s)[n]
Getting character count of string utf8.RuneCountInString(s)
Large integer IDs in JSON Use json:"...,string" tag
Checking integer overflow math/bits package or manual check

Key takeaways from this chapter:

  1. Go has rich numeric types, but int and float64 suffice for daily use
  2. Zero value design makes variables usable upon declaration — "Make the zero value useful"
  3. All type conversions must be explicit, no implicit conversions
  4. Strings are immutable UTF-8 byte sequences; len() returns byte count, not character count
  5. iota is a compile-time enum generator supporting bit operations and complex expressions
  6. Floats follow IEEE 754 and cannot precisely represent decimal fractions — don't compare with ==
  7. Strings have a {pointer, length} underlying structure; assignment is O(1), substrings may cause memory leaks
  8. Integer overflow is silent — must defend manually
  9. Use strings.Builder for loop string concatenation, not +
  10. Go 1.22 fixes the classic for-range loop variable capture pitfall
Rate this chapter
4.8  / 5  (102 ratings)

💬 Comments