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:
- Immutable โ once created, their content cannot be modified
- UTF-8 encoded โ UTF-8 by default, no additional encoding conversion needed
- Essentially a read-only byte slice โ
string'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"?
-
Readability. When you see
float64(x), you immediately know a type conversion is happening that might have precision loss. Implicit conversions hide this information. -
Safety. Many C security vulnerabilities come from implicit type conversions โ implicit conversions between signed and unsigned integers can lead to integer overflow exploits.
-
Clarity.
intandint64are semantically different โintmeans "I don't care about the exact size," whileint64means "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:
- 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
- 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!
- 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
- 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?
- Type definition: When you want to create a type with different semantics (e.g., UserID vs OrderID are both int64 but semantically different)
- Type alias: Mainly for gradual migration during large refactoring (using the same type name across two packages)
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:
- Allocates new memory (size = len(s) + 5)
- Copies old s's content to new memory
- Copies "hello" to end of new memory
- 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:
- Go has rich numeric types, but
intandfloat64suffice for daily use - Zero value design makes variables usable upon declaration โ "Make the zero value useful"
- All type conversions must be explicit, no implicit conversions
- Strings are immutable UTF-8 byte sequences;
len()returns byte count, not character count iotais a compile-time enum generator supporting bit operations and complex expressions- Floats follow IEEE 754 and cannot precisely represent decimal fractions โ don't compare with
== - Strings have a
{pointer, length}underlying structure; assignment is O(1), substrings may cause memory leaks - Integer overflow is silent โ must defend manually
- Use
strings.Builderfor loop string concatenation, not+ - Go 1.22 fixes the classic for-range loop variable capture pitfall