Go in 2 Weeks #02: Pointers Without Arithmetic, Slices vs std::vector, and Maps
Key takeaways
Go pointers, slices, and maps for C++ developers: safe *T, len/cap/append, map lookup with ok, and how slices differ from std::vector. Part of the 2-week Go series.
Series overview
📚 Go in 2 Weeks #02 | Full series index
This post covers Days 3–4 of the two-week Go curriculum for C++ developers.
Previous: #01 Philosophy & syntax ← | → Next: #03 OOP & composition
Introduction: the world of safe pointers
In C++, pointer arithmetic (p++, p + offset) lets you walk memory freely—but segmentation faults come with the territory. Go has pointers but no pointer arithmetic. Safety was chosen on purpose. This article compares Go pointers and core data structures—slices and maps—to C++.
You will learn:
- Go pointer restrictions and safety
- Call by value vs pointers
- Slice length and capacity
- Map usage and pitfalls
What changes when you come from C++
The syntax in this post is simple; the adjustment is in what you stop worrying about and what you have to start watching instead. You no longer decide where an object lives: the compiler’s escape analysis puts a value on the heap when it outlives its function, so returning &local is safe in Go, where the same code in C++ returns a dangling pointer. There is no delete, no RAII destructor, and no pointer arithmetic to get wrong. In exchange, the traps move to places C++ developers do not expect: slices that silently share memory, append that sometimes copies and sometimes does not, and maps that panic when written from two goroutines at once. Most of this post is about those.
Pointers: no arithmetic, but dereference works
C++ vs Go: pointer basics
// C++: pointer arithmetic allowed
int x = 10;
int* p = &x;
*p = 20; // dereference
p++; // pointer arithmetic (next int)
*(p + 5) = 30; // offset access
int arr[10];
int* ptr = arr;
ptr[5] = 100; // array indexing = pointer arithmetic
// Go: no pointer arithmetic
x := 10
p := &x
*p = 20 // — dereference OK
// p++ // — compile error: no pointer arithmetic
// *(p + 5) // — compile error
// Use indexing for arrays
arr := [10]int{}
arr[5] = 100 // — index access
Key differences:
- Go does not allow pointer arithmetic (safety)
- Only
*(dereference) and&(address-of) - Array access uses index syntax only
C++ vs Go: function arguments
// C++: value, pointer, reference
void byValue(int x) {
x = 100; // original unchanged
}
void byPointer(int* p) {
*p = 100; // original changed
}
void byReference(int& r) {
r = 100; // original changed
}
int main() {
int x = 10;
byValue(x); // x still 10
byPointer(&x); // x becomes 100
byReference(x); // x becomes 100
}
// Go: value or pointer (no references)
func byValue(x int) {
x = 100 // original unchanged
}
func byPointer(p *int) {
*p = 100 // original changed
}
func main() {
x := 10
byValue(x) // x still 10
byPointer(&x) // x becomes 100
}
Pointer usage:
- Small types (int, bool): pass by value
- Large structs (~64+ bytes): pass pointer
- Mutation needed: pass pointer
- Read-only: value or pointer (stay consistent)
Everything in Go is passed by value, including slices, maps and strings; the difference is what the value contains. A slice value is a small header with a pointer inside, so a function that receives a slice can modify its elements, but cannot change the caller’s length. A map value is a pointer to the runtime’s hash table, so writes are always visible to the caller. That is why the rule above talks about structs: for them, “by value” really does copy every field.
The “large structs” threshold is a rule of thumb, not a law. Passing a pointer can make things slower: if the pointer makes the struct escape to the heap, you pay for an allocation and garbage collection instead of a cheap stack copy. go build -gcflags=-m prints the compiler’s escape decisions (moved to heap: x), which is the way to check instead of guessing.
func newCounter() *int {
n := 0
return &n // fine in Go: n escapes to the heap; in C++ this would dangle
}
Nil pointers
// C++: nullptr
int* p = nullptr;
if (p == nullptr) {
std::cout << "null pointer\n";
}
// Dereference — segfault
// *p = 10; // crash!
// Go: nil
var p *int
if p == nil {
fmt.Println("nil pointer")
}
// Dereference — panic
// *p = 10 // panic: runtime error: invalid memory address or nil pointer dereference
The difference from C++ is that a nil dereference in Go is always a well-defined, recoverable panic with a stack trace pointing at the line, never silent memory corruption. It is still a crash in production, and the most common source is a struct field of pointer type that nobody initialized, or an error that was ignored (v, _ := f()) so the returned pointer is nil.
Arrays: fixed-size value types
C++ vs Go: array declarations
// C++: array
int arr[5] = {1, 2, 3, 4, 5};
int size = sizeof(arr) / sizeof(arr[0]); // 5
// Decays to pointer when passed to functions
void process(int* arr, int size) {
// ...
}
// Go: array (size is part of the type)
var arr [5]int = [5]int{1, 2, 3, 4, 5}
// or
arr := [5]int{1, 2, 3, 4, 5}
// or (size inferred)
arr := [...]int{1, 2, 3, 4, 5}
length := len(arr) // 5
// Passed by value—full copy (no decay to pointer)
func process(arr [5]int) {
// entire array copied
}
Key differences:
- In Go, array size is part of the type:
[5]intand[10]intdiffer - Passing an array copies the whole array (no C-style decay)
- In practice, slices are used far more often than arrays
Slices: Go’s dynamic arrays
Slices are the most common container in Go. They resemble std::vector but with different ergonomics.
C++ vs Go: dynamic arrays
// C++: std::vector
#include <vector>
#include <iostream>
int main() {
std::vector<int> vec;
vec.push_back(1);
vec.push_back(2);
vec.push_back(3);
std::cout << "Size: " << vec.size() << "\n";
std::cout << "Capacity: " << vec.capacity() << "\n";
vec.reserve(100);
for (const auto& v : vec) {
std::cout << v << " ";
}
}
// Go: slice
package main
import "fmt"
func main() {
var slice []int // nil slice
slice = append(slice, 1)
slice = append(slice, 2)
slice = append(slice, 3)
fmt.Println("Length:", len(slice))
fmt.Println("Capacity:", cap(slice))
slice2 := make([]int, 0, 100) // len=0, cap=100
fmt.Println(len(slice2), cap(slice2)) // Go rejects unused variables
for i, v := range slice {
fmt.Println(i, v)
}
}
Note that range yields a copy of each element in v. Assigning v = 0 inside the loop does nothing to the slice, and for a slice of large structs every iteration copies one; use the index (slice[i].Field = x) when you need to modify elements.
Slice internals
// Conceptually, a slice is a small struct of three fields
// (the runtime's real definition uses unsafe.Pointer for the array)
type slice struct {
ptr *T // pointer to the first element in the backing array
len int // length
cap int // capacity
}
graph LR
A[Slice] --> B["ptr: array pointer"]
A --> C["len: length"]
A --> D["cap: capacity"]
B --> E[Backing array memory]
Creating slices
package main
import "fmt"
func main() {
// 1. nil slice
var s1 []int
fmt.Println(s1 == nil) // true
// 2. literal
s2 := []int{1, 2, 3}
// 3. make (length and capacity)
s3 := make([]int, 5) // len=5, cap=5, zero-filled
s4 := make([]int, 5, 10) // len=5, cap=10
// 4. slicing
arr := [5]int{1, 2, 3, 4, 5}
s5 := arr[1:4] // [2, 3, 4], shares arr
fmt.Println(s1, s2, s3, s4, s5)
}
A nil slice and an empty slice ([]int{}) behave identically for len, append and range, so most code should not care which it has. The difference shows up at boundaries: encoding/json marshals a nil slice as null and an empty slice as [], which breaks API clients that expect an array. Initialize with []T{} or make([]T, 0) for values that are serialized.
Slice expressions
// C++: sub-range (copy)
std::vector<int> vec = {1, 2, 3, 4, 5};
std::vector<int> sub(vec.begin() + 1, vec.begin() + 4); // [2, 3, 4]
// Go: slicing (shares backing array)
slice := []int{1, 2, 3, 4, 5}
sub := slice[1:4] // [2, 3, 4], indices 1..3
sub[0] = 100 // slice[1] becomes 100 too!
// slice[low:high] // low through high-1
// slice[low:] // low to end
// slice[:high] // start through high-1
// slice[:] // whole slice (still a view, not a deep copy)
copied := make([]int, len(sub))
copy(copied, sub)
Caveat: slicing shares the backing array; writes through one slice affect the other.
The version of this that bites in real code involves append, not assignment. sub := slice[1:3] has length 2 but capacity 4, because its capacity extends to the end of the backing array. append(sub, 99) therefore does not allocate: it writes 99 into slice[3], silently overwriting an element the original owner still uses. The bug appears or disappears depending on capacity, which makes it hard to reproduce. When you hand out a sub-slice that the receiver might append to, cap it with a full slice expression, slice[1:3:3] (low:high:max), so the next append is forced to allocate a new array. The same aliasing is why keeping a tiny sub-slice of a huge buffer keeps the whole buffer alive for the garbage collector; copy the part you need instead.
append and reallocation
package main
import "fmt"
func main() {
slice := make([]int, 0, 3) // len=0, cap=3
fmt.Printf("len=%d cap=%d\n", len(slice), cap(slice))
slice = append(slice, 1)
slice = append(slice, 2)
slice = append(slice, 3)
fmt.Printf("len=%d cap=%d\n", len(slice), cap(slice))
slice = append(slice, 4) // len=4, cap=6 (realloc!)
fmt.Printf("len=%d cap=%d\n", len(slice), cap(slice))
}
This is also why append returns a value you must assign back. Within capacity, it writes in place and returns a header with a larger length; beyond capacity, it allocates a new, larger array (roughly doubling for small slices, growing more slowly for large ones), copies the elements, and returns a header pointing at the new array. Writing append(slice, 4) without slice = is a compile error (append(slice, 4) (value of type []int) is not used), but passing a slice to a function that appends and not returning it compiles fine and loses the new elements: the caller’s header still has the old length.
Performance tip: when you know final size, use make([]T, 0, capacity).
// — inefficient: many reallocations
slice := []int{}
for i := 0; i < 10000; i++ {
slice = append(slice, i)
}
// — preallocate capacity
slice := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
slice = append(slice, i)
}
Maps: hash tables
C++ vs Go: maps
#include <unordered_map>
#include <iostream>
int main() {
std::unordered_map<std::string, int> m;
m["apple"] = 100;
m["banana"] = 200;
m.insert({"cherry", 300});
if (m.find("apple") != m.end()) {
std::cout << "Found: " << m["apple"] << "\n";
}
int x = m["nonexistent"]; // inserts "nonexistent": 0
m.erase("apple");
for (const auto& [key, value] : m) {
std::cout << key << ": " << value << "\n";
}
}
package main
import "fmt"
func main() {
m := make(map[string]int)
m["apple"] = 100
m["banana"] = 200
m["cherry"] = 300
if v, ok := m["apple"]; ok {
fmt.Println("Found:", v)
}
x := m["nonexistent"] // 0, map unchanged
delete(m, "apple")
for key, value := range m { // order not defined
fmt.Println(key, ":", value)
}
}
Key differences:
- Go maps do not guarantee iteration order
- Missing keys yield the zero value without inserting (unlike
operator[]onunordered_mapin the “read missing key” case—Go does not insert) - Use the comma-ok form (
v, ok := m[k]) to detect presence
The random iteration order is deliberate: the runtime starts each range over a map at a random position, so code that accidentally depends on insertion order fails in tests right away rather than after a Go upgrade. When output must be stable (logs, tests, JSON), collect the keys, sort them with slices.Sort, and iterate over the sorted keys.
Map creation
package main
import "fmt"
func main() {
m1 := make(map[string]int)
m2 := map[string]int{
"apple": 100,
"banana": 200,
}
var m3 map[string]int
// m3["apple"] = 1 // — panic: assignment to entry in nil map
v := m3["apple"] // — 0, read OK
m4 := make(map[string]int, 100) // hint ~100 entries
fmt.Println(m1, m2, v, len(m4))
}
The nil-map panic is the classic mistake with structs: a map field declared in a struct is nil until someone calls make, so the first s.cache[k] = v panics even though reads worked fine. Initialize map fields in a constructor function (NewCache). The other runtime failure is concurrency: Go maps are not safe for concurrent writes, and the runtime detects it and kills the whole program with fatal error: concurrent map writes, which, unlike a panic, cannot be recovered. Guard shared maps with a sync.Mutex/sync.RWMutex, or use sync.Map for the specific case of keys written once and read many times.
Map patterns
package main
import "fmt"
func wordCount(words []string) map[string]int {
counts := make(map[string]int)
for _, word := range words {
counts[word]++
}
return counts
}
func uniqueElements(numbers []int) []int {
seen := make(map[int]bool)
result := []int{}
for _, num := range numbers {
if !seen[num] {
seen[num] = true
result = append(result, num)
}
}
return result
}
func main() {
words := []string{"go", "is", "go", "is", "simple"}
fmt.Println(wordCount(words))
nums := []int{1, 2, 2, 3, 3, 3, 4}
fmt.Println(uniqueElements(nums))
}
Exercises
Exercise 1: reverse a slice in place
package main
import "fmt"
func reverse(slice []int) {
for i, j := 0, len(slice)-1; i < j; i, j = i+1, j-1 {
slice[i], slice[j] = slice[j], slice[i]
}
}
func main() {
nums := []int{1, 2, 3, 4, 5}
reverse(nums)
fmt.Println(nums) // [5 4 3 2 1]
}
C++ comparison: std::reverse(vec.begin(), vec.end()).
Exercise 2: deduplicate
package main
import "fmt"
func removeDuplicates(slice []int) []int {
seen := make(map[int]bool)
result := []int{}
for _, v := range slice {
if !seen[v] {
seen[v] = true
result = append(result, v)
}
}
return result
}
func main() {
nums := []int{1, 2, 2, 3, 3, 3, 4, 5, 5}
fmt.Println(removeDuplicates(nums))
}
Exercise 3: merge two slices
package main
import "fmt"
func merge(s1, s2 []int) []int {
result := make([]int, 0, len(s1)+len(s2))
result = append(result, s1...)
result = append(result, s2...)
return result
}
func main() {
a := []int{1, 2, 3}
b := []int{4, 5, 6}
fmt.Println(merge(a, b))
}
Exercise 4: group with a map
package main
import "fmt"
type Student struct {
Name string
Score int
}
func groupByScore(students []Student) map[int][]string {
groups := make(map[int][]string)
for _, s := range students {
groups[s.Score] = append(groups[s.Score], s.Name)
}
return groups
}
func main() {
students := []Student{
{"Alice", 90},
{"Bob", 85},
{"Charlie", 90},
{"David", 85},
}
fmt.Println(groupByScore(students))
}
Wrap-up: Days 3–4 checklist
Goals
- Go pointers: no arithmetic; dereference only
- Arguments: by value vs by pointer
- Arrays are fixed-size; slices are the default tool
- Understand
len,cap, andappendgrowth - Slicing shares storage—watch aliasing
- Maps: construction, lookup, comma-ok
- Complete the four exercises
C++ — Go cheat sheet
| C++ | Go | Notes |
|---|---|---|
int* p; p++ | p := &x (no ++) | Safety first |
std::vector<T> | []T | Simpler surface syntax |
vec.size() | len(slice) | builtin |
vec.capacity() | cap(slice) | builtin |
std::unordered_map | map[K]V | builtin type |
m.find(k) != m.end() | v, ok := m[k] | idiomatic |
Next
Next: OOP without classes—composition over inheritance, methods and receivers.
Series navigation
| Previous | Index | Next |
|---|---|---|
| #01 Syntax | Index | #03 OOP |
Go in 2 weeks: Curriculum · #01 · #02 · #03 · #04 · #05 · #06 · #07 · #08 · #09
TL;DR: Go pointers are safe, slices are powerful, maps are concise—you can do the same jobs as in C++ with less machinery.
FAQ
Q. What is the difference between a slice and an array?
A. Arrays have a fixed size that is part of the type ([5]int) and are copied by value. Slices ([]int) are variable-length views backed by an underlying array, so copying a slice shares the same elements.
Q. Why does slice capacity matter?
A. When append grows past capacity, a new array is allocated and the elements are copied, and any other slice still pointing at the old array stops seeing the new writes. Preallocating with make([]T, 0, n) avoids repeated reallocation when you know the size roughly in advance.