배열과 슬라이스
이 챕터에서 다루는 것
Go에는 "리스트"가 두 개 있다. 배열(array) 은 길이가 타입에 박혀 있는 값이고, 슬라이스(slice) 는 그 배열의 일부를 가리키는 세 필드짜리 작은 구조체다. 이 구분을 정확히 잡아 두면 2-5에서 미뤄 둔 "슬라이스는 왜 공유되는가"가 한 번에 풀린다.
문제 — 왜 두 개인가
C의 배열은 길이 정보가 없어서 void f(int *a, int n)처럼 길이를 따로 들고 다녀야 한다.
Java의 배열은 길이는 알지만 크기를 바꿀 수 없어서 ArrayList가 따로 필요하다.
Python의 리스트는 편하지만 그 안에 뭐가 들었는지 런타임에만 안다.
Go는 이 셋을 두 타입으로 나눴다.
- 배열 — 고정 길이. 길이가 타입의 일부. 값 의미론(복사하면 통째로 복사).
- 슬라이스 — 가변 길이 뷰. 배열을 가리킨다. 헤더만 복사된다.
실전에서 쓰는 것은 거의 항상 슬라이스다. 그런데도 배열을 먼저 봐야 하는 이유는, 슬라이스가 배열 위에 세워져 있기 때문이다.
배열 — 길이가 타입의 일부
package main
import "fmt"
// sum은 길이 3짜리 배열만 받는다. 길이가 타입의 일부이기 때문이다.
func sum(a [3]int) int {
total := 0
for _, v := range a {
total += v
}
return total
}
func main() {
var zero [3]int
fmt.Println("제로값:", zero)
a := [3]int{1, 2, 3}
b := a // 통째로 복사된다
b[0] = 100
fmt.Println("a:", a, "b:", b)
fmt.Println("a == b:", a == b)
// [...]는 요소 개수를 컴파일러가 세게 한다.
c := [...]string{"go", "rust", "zig"}
fmt.Printf("%T len=%d\n", c, len(c))
// 인덱스를 지정한 리터럴. 나머지 자리는 제로값이다.
sparse := [5]int{0: 10, 4: 40}
fmt.Println("sparse:", sparse)
fmt.Println("sum:", sum(a))
// 배열의 배열도 값이다.
var grid [2][3]int
grid[1][2] = 7
fmt.Println("grid:", grid)
}
go run ./01-arrays
제로값: [0 0 0]
a: [1 2 3] b: [100 2 3]
a == b: false
[3]string len=3
sparse: [10 0 0 0 40]
sum: 6
grid: [[0 0 0] [0 0 7]]
여기서 가져갈 세 가지.
[3]int와[4]int는 서로 다른 타입이다.sum([4]int{...})는 컴파일 에러다. 이 경직성이 배열을 실무에서 거의 쓰지 않게 만든 이유다.b := a는 전체 복사다.b[0] = 100이a에 영향을 주지 않는다. 함수 인자로 넘길 때도 똑같이 복사된다 — 2-5의bumpArray가 아무 일도 못 했던 이유가 이것이다.- 배열은
==로 비교된다. 요소 타입이 비교 가능하면 배열도 비교 가능하고, 맵 키로도 쓸 수 있다. 슬라이스는 그렇지 않다.
:::note 배열을 실제로 쓰는 곳
[16]byte(해시 값), [4]byte(IPv4 주소), [N]T 룩업 테이블처럼 크기가 진짜로 고정된
것에 쓴다. 값이라서 복사 비용이 예측 가능하고, 맵 키가 되며, 힙 할당을 피하기 쉽다.
그 밖에는 슬라이스를 쓴다.
:::
슬라이스 헤더 — 세 개의 필드
슬라이스 값의 정체는 이 세 필드다.
| 필드 | 의미 |
|---|---|
| 포인터 | 백킹 배열(backing array)의 어디부터 보는지 |
길이 len | 지금 볼 수 있는 요소 수 |
용량 cap | 시작 지점부터 백킹 배열 끝까지의 요소 수 |
백킹 배열은 슬라이스가 실제로 요소를 담고 있는 배열이다. 슬라이스 자체는 데이터를 담지 않는다. 어디를 얼마나 볼지에 대한 정보만 담는다. 64비트 환경에서 슬라이스 값의 크기는 항상 24바이트다 — 요소가 몇 개든 상관없다.
package main
import "fmt"
func describe(label string, s []int) {
// 슬라이스에 %-24v를 쓰면 폭이 요소마다 적용된다. 먼저 문자열로 만든다.
fmt.Printf("%-12s %-26s len=%d cap=%d\n", label, fmt.Sprint(s), len(s), cap(s))
}
func main() {
var nilSlice []int
describe("nil", nilSlice)
fmt.Println("nilSlice == nil:", nilSlice == nil)
empty := []int{}
describe("[]int{}", empty)
fmt.Println("empty == nil: ", empty == nil)
describe("make(3, 8)", make([]int, 3, 8))
lit := []int{0, 1, 2, 3, 4, 5, 6, 7}
describe("lit", lit)
describe("lit[2:5]", lit[2:5])
describe("lit[:3]", lit[:3])
describe("lit[5:]", lit[5:])
describe("lit[:]", lit[:])
// cap은 자른 시작 지점부터 백킹 배열 끝까지다. 그래서 다시 늘릴 수 있다.
s := lit[2:5]
describe("s", s)
describe("s[:cap(s)]", s[:cap(s)])
// 배열에서 슬라이스를 뜨면 그 배열이 곧 백킹 배열이다.
arr := [4]string{"a", "b", "c", "d"}
view := arr[1:3]
view[0] = "B"
fmt.Println("arr:", arr, "view:", view)
}
go run ./01-slice-header
nil [] len=0 cap=0
nilSlice == nil: true
[]int{} [] len=0 cap=0
empty == nil: false
make(3, 8) [0 0 0] len=3 cap=8
lit [0 1 2 3 4 5 6 7] len=8 cap=8
lit[2:5] [2 3 4] len=3 cap=6
lit[:3] [0 1 2] len=3 cap=8
lit[5:] [5 6 7] len=3 cap=3
lit[:] [0 1 2 3 4 5 6 7] len=8 cap=8
s [2 3 4] len=3 cap=6
s[:cap(s)] [2 3 4 5 6 7] len=6 cap=6
arr: [a B c d] view: [B c]
읽어야 할 것
nil 슬라이스와 빈 슬라이스는 다르다. 둘 다 len이 0이고 range가 0번 돌지만,
nil == nil은 true, []int{} == nil은 false다. nil 슬라이스는 포인터가 없는
헤더이고, []int{}는 길이 0인 배열을 가리키는 헤더다.
실무에서는 var s []T로 시작하는 쪽을 기본으로 쓴다. append가 알아서 배열을
만들어 주고, 할당이 하나 줄고, JSON으로 나갈 때 null이 되긴 하지만 대부분 문제가
안 된다. 빈 슬라이스가 꼭 필요한 자리(JSON []가 필요할 때)에만 []T{}를 쓴다.
cap은 "자른 시작 지점부터 배열 끝까지" 다. lit[2:5]는 len=3인데 cap=6이다.
8칸짜리 배열에서 인덱스 2부터 보기 시작했으니 뒤로 6칸이 남았다는 뜻이다. 그래서
s[:cap(s)]로 잘라 낸 뒤쪽을 다시 되살릴 수 있다. 이것이 다음 챕터에서 다룰
백킹 배열 오염의 씨앗이다.
lit[:3]의 cap은 여전히 8이다. 앞을 자르면 cap이 줄지만 뒤를 자르면 안 줄어든다.
배열에서 뜬 슬라이스는 그 배열을 고친다. view[0] = "B"가 arr를 바꿨다.
make와 리터럴
make([]int, 3) // len=3, cap=3, 요소는 제로값
make([]int, 0, 8) // len=0, cap=8 — 비어 있지만 8개 들어갈 자리를 잡아 뒀다
[]int{1, 2, 3} // len=3, cap=3
[]int{5: 1} // len=6, cap=6 — 인덱스 지정
var s []int // nil, len=0, cap=0
make([]int, 0, n)과 make([]int, n)을 헷갈리면 조용히 틀린다. 후자는 이미 n개의
제로값이 들어 있는 상태다. 여기에 append하면 n개의 0 뒤에 붙는다.
s := make([]int, 5)
s = append(s, 1)
// s == [0 0 0 0 0 1], len=6
슬라이싱 표현식
s[low:high] // low 포함, high 미포함
s[low:] // high = len(s)
s[:high] // low = 0
s[:] // 전체
s[low:high:max] // full slice expression — cap도 지정한다 (3-2에서)
인덱스 조건은 0 <= low <= high <= cap(s)다. 눈여겨볼 것은 len이 아니라 cap이
상한이라는 점이다. len을 넘어선 자리를 다시 노출시킬 수 있다는 뜻이고, 방금 본
s[:cap(s)]가 정확히 그 동작이다. cap을 넘으면 런타임 패닉
slice bounds out of range가 난다.
문자열 슬라이싱도 같은 문법이지만 결과는 string이고, 바이트 단위다.
2-3에서 본 그대로다.
range가 주는 것은 복사본이다
2-4에서 예고한 내용을 여기서 정리한다.
package main
import "fmt"
type Item struct {
Name string
Count int
}
func main() {
items := []Item{
{Name: "사과", Count: 1},
{Name: "배", Count: 2},
}
// range가 주는 v는 복사본이다. 고쳐도 원본은 그대로다.
for _, v := range items {
v.Count += 10
}
fmt.Println("복사본 수정 후:", items)
// 인덱스로 접근해야 원본이 바뀐다.
for i := range items {
items[i].Count += 10
}
fmt.Println("인덱스 수정 후:", items)
// 배열에 대한 range는 배열의 복사본을 순회한다.
arr := [3]int{1, 2, 3}
for i, v := range arr {
arr[2] = 99
fmt.Println("배열 ", i, v)
}
fmt.Println("arr:", arr)
// 슬라이스는 백킹 배열을 공유하므로 도중의 수정이 보인다.
sl := []int{1, 2, 3}
for i, v := range sl {
sl[2] = 99
fmt.Println("슬라이스", i, v)
}
fmt.Println("sl:", sl)
}
go run ./01-range-copy
복사본 수정 후: [{사과 1} {배 2}]
인덱스 수정 후: [{사과 11} {배 12}]
배열 0 1
배열 1 2
배열 2 3
arr: [1 2 99]
슬라이스 0 1
슬라이스 1 2
슬라이스 2 99
sl: [1 2 99]
두 번째 블록이 재미있다. 배열에 대한 range는 순회를 시작하기 전에 배열을 한 번
복사한다. 그래서 루프 안에서 arr[2] = 99를 해도 v는 원래 값 3을 준다. 반면
슬라이스는 헤더만 복사되고 백킹 배열은 그대로라, 세 번째 반복에서 v가 99가 된다.
규칙은 두 줄로 요약된다.
- 요소를 읽기만 하면
for _, v := range s. - 요소를 고쳐야 하면
for i := range s를 쓰고s[i]로 접근한다.
요소가 큰 구조체라면 읽기만 할 때도 for i := range s가 나을 수 있다. 반복마다 구조체
전체가 복사되기 때문이다. 다만 이건 실측하고 나서 하는 최적화다.
함수에 넘길 때 정확히 무슨 일이 일어나는가
2-5의 05-pass-by-value 예제를 이제 제대로
읽을 수 있다. 그 프로그램의 결과는 이랬다.
구조체: {hits 0}
배열: [1 2 3]
슬라이스: [100 2 3]
append 후: [1 2 3]
bumpArray(a [3]int)— 배열 24바이트가 통째로 복사된다. 함수 안의a는 완전히 다른 배열이다.bumpSlice(s []int)— 헤더 24바이트만 복사된다. 복사본의 포인터도 원본과 같은 백킹 배열을 가리키므로s[0] = 100이 호출자에게 보인다.appendSlice(s []int)—append가 용량을 넘겨 새 백킹 배열을 잡았다. 그 순간 함수 안의s는 호출자와 다른 배열을 가리키게 되고, 이후 수정은 사라진다.
세 번째가 왜 그렇게 되는지 — append가 언제 새 배열을 잡는지 — 가 다음 챕터의 주제다.
또 하나, zeroFirst(data...)가 원본을 고쳤던 것도 같은 이야기다. 컴파일러는 data...
전개에서 새 슬라이스를 만들지 않고 헤더를 그대로 넘긴다.
흔히 하는 실수
1. append 결과를 버린다
append(s, 1) // 아무 일도 일어나지 않는다
s = append(s, 1)
append는 새 헤더를 반환한다. 반환값을 받지 않으면 길이가 늘지 않는다. 다행히
컴파일러가 append(s, 1) (value of type []int) is not used 에러를 낸다. 문제는
s2 = append(s, 1)처럼 다른 변수에 받았을 때다. 이건 에러가 안 나고, 3-2에서 볼
백킹 배열 공유 사고로 이어진다.
2. make([]T, n)과 make([]T, 0, n)을 헷갈린다
result := make([]string, len(input)) // 이미 빈 문자열 n개가 들어 있다
for _, s := range input {
result = append(result, strings.ToUpper(s)) // 뒤에 붙는다. 앞의 n개는 ""
}
make([]string, 0, len(input))이 맞다. 아니면 append 대신 result[i] = ...을 쓴다.
3. range의 복사본을 고친다
for _, item := range items {
item.Count++ // 사라진다
}
컴파일 에러가 안 나고 조용히 아무 일도 안 일어나서 발견이 늦다. items[i].Count++.
4. 길이가 다른 배열을 같은 함수에 넘기려 한다
func f(a [3]int)
f([4]int{1, 2, 3, 4}) // 컴파일 에러
배열을 인자로 받는 함수를 쓰고 있다면 십중팔구 슬라이스를 받아야 한다.
func f(a []int)로 바꾸면 어떤 길이든 받는다.
5. nil 슬라이스를 피하려고 []T{}를 남발한다
nil 슬라이스는 안전하다. len, cap, range, append 전부 정상 동작한다.
2-1의 "제로값이 정의돼 있다 ≠
제로값이 안전하다"에서 위험한 쪽은 맵이지 슬라이스가 아니다. 맵은 3-3에서 본다.
정리
- 배열은 값이고 길이가 타입의 일부다. 복사하면 통째로 복사되고
==로 비교된다. - 슬라이스 값은 (포인터, len, cap) 세 필드짜리 헤더다. 64비트에서 24바이트.
- 백킹 배열은 슬라이스가 가리키는 실제 배열이다. 헤더를 복사해도 배열은 공유된다.
cap은 자른 시작 지점부터 배열 끝까지.s[:cap(s)]로 잘라 낸 뒤쪽을 되살릴 수 있다.nil슬라이스와[]T{}는 다르지만 둘 다 안전하다. 기본은var s []T.range가 주는 값은 복사본이다. 고치려면s[i].- 배열에 대한
range는 배열의 복사본을 순회한다.
연습문제
-
func firstThree(s []int) [3]int를 쓰고, 길이가 3보다 짧은 입력에 어떻게 대응할지 정해 보자. 반환 타입을[]int로 바꾸면 무엇이 달라지는가? 힌트: 배열 반환은 복사, 슬라이스 반환은 공유다. -
lit := []int{0,1,2,3,4,5,6,7}에서lit[2:5]를 얻은 뒤, 원본을 건드리지 않고[2 3 4]만 따로 갖는 슬라이스를 만들어 보자. 그리고 원래 방식과 어떻게 다른지cap을 찍어 확인해 보자. -
요소가 필드 열 개짜리 구조체인 슬라이스를 만들고,
for _, v := range와for i := range로 각각 전체를 합산하는 코드를 써 보자. 어느 쪽이 무엇을 복사하는지 설명할 수 있으면 된다.