02. 데이터 구조와 메모리
데이터를 담는 구조들이 내부에서 어떻게 동작하는지, 값을 넘길 때 무엇이 복사되는지 정리합니다.
배열과 슬라이스
배열은 길이가 타입의 일부입니다. [3]int와 [4]int는 다른 타입이라 서로 대입할 수 없습니다.
var fixed [3]int
fixed[0] = 1
len(fixed) // 3실제로는 길이가 유연한 슬라이스를 거의 항상 씁니다.
list := []int{1, 2, 3}
list = append(list, 4)
empty := make([]int, 0) // 길이 0
sized := make([]int, 3) // [0 0 0]슬라이스는 배열을 들여다보는 창입니다. 포인터와 길이, 용량 세 가지를 들고 있습니다.
origin := []int{1, 2, 3, 4, 5}
window := origin[1:3] // [2 3]
window[0] = 99
origin // [1 99 3 4 5] ← 같은 배열을 봅니다잘라 낸 슬라이스를 고치면 원본이 바뀝니다. 끊어 내려면 복사합니다.
copied := make([]int, len(window))
copy(copied, window)슬라이스의 길이와 용량
len은 지금 보이는 개수, cap은 바탕 배열에 들어갈 수 있는 개수입니다.
list := make([]int, 2, 5)
len(list) // 2
cap(list) // 5append는 용량이 남으면 그 자리에 쓰고, 모자라면 더 큰 배열을 새로 만들어 옮깁니다.
a := make([]int, 0, 2)
a = append(a, 1, 2)
b := append(a, 3) // 용량 초과 — 새 배열이 만들어집니다
a = append(a, 99) // a는 옛 배열을 씁니다
b[0] // b는 영향을 받지 않습니다용량이 남아 있을 때는 같은 배열을 공유해서 결과가 달라집니다.
a := make([]int, 2, 5) // [0 0]
b := append(a, 1) // 같은 배열에 씁니다
c := append(a, 2) // 같은 자리를 덮어씁니다
b[2] // 2 ← 1이 아닙니다그래서 append의 결과는 항상 같은 변수에 다시 받는 것이 관례입니다.
list = append(list, item)크기를 알고 있으면 미리 잡아 두는 편이 낫습니다. 재할당과 복사가 줄어듭니다.
result := make([]Todo, 0, len(todos))슬라이스의 기본값은 nil입니다. 길이가 0인 것과 거의 같게 동작하므로 그냥 써도 됩니다.
var list []int
len(list) // 0
list = append(list, 1) // 동작합니다
list == nil // 처음에는 true맵
키와 값을 짝지어 담습니다. 쓰기 전에 만들어야 합니다.
m := make(map[string]int)
m := map[string]int{"a": 1}
var nilMap map[string]int
nilMap["a"] = 1 // panic: assignment to entry in nil map없는 키를 읽으면 오류가 아니라 기본값이 나옵니다. 있는지 확인하려면 두 번째 반환값을 받습니다.
count := m["없는키"] // 0
value, ok := m["없는키"]
if !ok {
fmt.Println("없습니다")
}
delete(m, "a")
len(m)값이 0인 것과 키가 없는 것을 구분하려면 반드시 ok를 확인해야 합니다.
맵은 순서가 없습니다. 정렬된 순회가 필요하면 키를 모아 정렬합니다.
keys := make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
fmt.Println(key, m[key])
}집합이 필요하면 값이 빈 구조체인 맵을 씁니다. 메모리를 쓰지 않습니다.
seen := map[string]struct{}{}
seen["a"] = struct{}{}
if _, ok := seen["a"]; ok { }구조체
필드를 묶은 타입입니다. 클래스는 없습니다.
type Todo struct {
ID int64
Title string
Done bool
}
todo := Todo{ID: 1, Title: "공부"}
todo := Todo{1, "공부", false} // 순서대로 — 필드가 바뀌면 깨집니다
var empty Todo // 모든 필드가 기본값필드 이름을 적는 쪽이 안전합니다. 구조체에 필드가 추가돼도 그대로 컴파일됩니다.
JSON과 주고받을 때는 태그로 이름을 지정합니다.
type Todo struct {
ID int64 `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
Memo string `json:"memo,omitempty"`
}소문자로 시작하는 필드는 패키지 밖에서 보이지 않고, JSON으로도 나가지 않습니다.
구조체는 값입니다. 대입하면 전체가 복사됩니다.
a := Todo{Title: "공부"}
b := a
b.Title = "운동"
a.Title // "공부" — 그대로입니다비교도 됩니다. 모든 필드가 비교 가능한 타입이어야 합니다.
Todo{ID: 1} == Todo{ID: 1} // true슬라이스나 맵이 필드에 있으면 비교할 수 없습니다.
포인터
값이 있는 위치를 가리킵니다.
todo := Todo{Title: "공부"}
p := &todo // 주소를 얻습니다
p.Title // (*p).Title 을 줄여 쓴 것입니다
p.Title = "운동"
todo.Title // "운동" — 원본이 바뀌었습니다Go에는 포인터 연산이 없습니다. 주소를 더하거나 빼서 옮겨 다닐 수 없습니다.
nil 포인터를 따라가면 패닉이 납니다.
var p *Todo
p.Title // panic: nil pointer dereference
if p != nil {
p.Title
}new로 만들 수도 있지만, 보통은 구조체 리터럴의 주소를 씁니다.
p := new(Todo) // 기본값으로 채운 Todo의 포인터
p := &Todo{Title: "공부"} // 이쪽을 더 많이 씁니다값 복사와 공유
Go는 항상 값을 복사해서 전달합니다. 포인터를 넘기는 것도 "주소라는 값"을 복사하는 것입니다.
func rename(todo Todo) {
todo.Title = "운동" // 복사본을 고칩니다
}
func renamePtr(todo *Todo) {
todo.Title = "운동" // 원본을 고칩니다
}
todo := Todo{Title: "공부"}
rename(todo)
todo.Title // "공부"
renamePtr(&todo)
todo.Title // "운동"슬라이스와 맵은 내부에 포인터를 들고 있어서, 값으로 넘겨도 내용은 공유됩니다.
func fill(list []int) {
list[0] = 99 // 원본이 바뀝니다
}
func add(list []int) {
list = append(list, 1) // 재할당되면 원본과 끊어집니다
}append가 새 배열을 만들면 함수 안의 변수만 그것을 가리킵니다. 늘어난 결과가 필요하면 반환해서 다시 받아야 합니다.
func add(list []int) []int {
return append(list, 1)
}
list = add(list)포인터를 쓸지 값을 쓸지는 두 가지로 판단합니다.
- 원본을 고쳐야 하면 포인터
- 구조체가 커서 복사 비용이 크면 포인터
작고 바뀌지 않는 구조체는 값으로 넘기는 편이 단순하고 안전합니다.