05 — Strings and Maps — Immutable Text and Keyed Lookup
Strings and maps looked familiar from other languages, and then both surprised me. The model that made both click: a string is an immutable sequence of bytes that you walk by rune when you care about characters, and a map is a reference-typed hash table whose lookups return two values on purpose, so you never confuse "missing key" with "zero value." [1][2] Once I internalized the byte-vs-rune split for strings and the comma-ok pattern for maps, the two most common sources of subtle bugs — broken Unicode handling and mistaking 0 for "absent" — stopped showing up.
Strings are immutable byte sequences
A Go string is a read-only sequence of bytes — typically UTF-8 encoded text, but the type itself makes no encoding guarantee [1]. Three properties shape everything:
- Immutable. You cannot change a string in place. Every "modification" (concatenation, replacement) builds a new string.
- Bytes, not characters. Indexing s[i] returns a byte, the i-th byte — not the i-th human character. Operations create new strings rather than mutating.
- UTF-8 by convention. String literals are UTF-8, and most Go code assumes it, but the length of a string is its byte count, not its character count.
That last point is where naive code breaks. A string holding "中" has len(s) == 3 (three UTF-8 bytes), not 1. To count characters or index by character, you must decode runes.
Walking a string: bytes vs runes
Go gives you two ways to iterate a string, and the choice determines what you see [1]:
s := "Go中"
// Byte-by-byte (indexing)
for i := 0; i < len(s); i++ {
fmt.Printf("%d: %x\n", i, s[i]) // 0: 47, 1: 6f, 2: e4, 3: b8, 4: ad
}
// Rune-by-rune (for range)
for i, r := range s {
fmt.Printf("%d: %c\n", i, r) // 0: G, 1: o, 2: 中
}The for range form automatically decodes UTF-8 as it walks, yielding each rune and the _byte offset_ where it begins (note i jumps from 1 to 2, skipping to where the multi-byte character started). For random access by character index, convert once: runes := []rune(s), then index runes[2]. The rule I keep is: byte work uses []byte(s) and indexing; character work uses for range or []rune [4]. Mixing them up is the classic source of "my string handling works in English and breaks on emoji."
Maps — reference-typed hash tables
A map is Go's associative array — keys to values, backed by a hash table [2]:
ages := make(map[string]int)
ages["alice"] = 30
ages["bob"] = 25
// or with a literal
ages := map[string]int{"alice": 30, "bob": 25}Keys must be a comparable type (anything that supports == — strings, numbers, bools, pointers, interfaces, structs, arrays of those). Values can be any type, including other maps or slices. Maps are reference types: passing a map to a function does not copy the data, and modifying it inside the function affects the caller's map [2].
Two practical gotchas. A nil map reads fine — a missing key returns the zero value — but writing to a nil map panics, so you must make it first. And map iteration order is deliberately randomized by the runtime, so you never rely on it; if you need ordered output, extract and sort the keys [5].
The comma-ok idiom
The single most important map pattern is the comma-ok lookup, which disambiguates "the key is absent" from "the key exists and its value is the zero value" [3]:
ages := map[string]int{"alice": 30}
v := ages["charlie"] // v == 0 — but is charlie absent, or present with age 0?
v, ok := ages["charlie"] // v == 0, ok == false — definitively absent
v, ok = ages["alice"] // v == 30, ok == trueWithout the second return value, there is no way to tell ages["charlie"] (missing → 0) from a hypothetical ages["charlie"] = 0 (present with 0). The ok boolean resolves it. The same value, ok := shape recurs throughout Go — type assertions and channel receives use the identical idiom — so it is worth recognizing it as a language-wide convention rather than a one-off [3].
Deleting a key is delete(m, key), which is a no-op if the key is absent. There is no "clear a map" built-in; you either iterate and delete, or reassign the variable to a fresh make.
How I use this
Two habits crystallized from these notes. For any string handling that crosses outside ASCII — filenames, user input, display text — I default to for range or []rune and treat raw byte indexing as a smell. For map lookups, I make the comma-ok form the _default_ rather than the exception: a bare v := m[k] is something I write only when I genuinely do not care whether the key was present, because every "I assumed the zero value meant absent" bug traces back to skipping ok. The immutable-byte-slice picture for strings and the always-two-value probe for maps are the two ways of thinking that stopped the leaks.
References
[1] The Go Authors, "Strings, bytes, runes and characters in Go," The Go Blog, 2013. [Online]. Available: https://go.dev/blog/strings
[2] The Go Authors, "Go Maps in Action," The Go Blog, 2013. [Online]. Available: https://go.dev/blog/maps
[3] freeCodeCamp, "How the Comma Ok Idiom and Package System Work in Go," 2024. [Online]. Available: https://www.freecodecamp.org/news/how-the-comma-ok-idiom-and-package-system-work-in-go/
[4] Medium / @golangda, "Golang Quick Reference: Strings — Introduction," 2024. [Online]. Available: https://medium.com/@golangda/golang-quick-reference-strings-0d68bb036c29
[5] Leapcell, "Iterating Over Maps in Go: Methods, Order, and Best Practices," 2024. [Online]. Available: https://leapcell.io/blog/iterating-over-maps-in-go-methods-order-and-best-practices
Knowledge check · Question 1 of 5
len("中") in Go returns 3, not 1, because…
Comments
Leave a Comment
You must be signed in to comment
0 Comments
No comments yet. Be the first to comment!