슬라이스의 실체
Go의 런타임 코드를 보면, Go의 슬라이스는 다음과 같이 정의되어 있습니다.
type slice struct {
array unsafe.Pointer
len int
cap int
}
reflect 패키지의 SliceHeader
를 봐도 다음과 같이 정의되어 있습니다.
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
즉, Go의 슬라이스는 다음 그림처럼 배열에 대한 포인터와 길이, 그리고 용량을 가진 값으로 표현됩니다.
런타임과 reflect 패키지에서 포인터를 unsafe.Pointer
와 uintptr
로 표현하는 방법은 다르지만, 둘 다 포인터를 나타내는 값입니다.
unsafe.Pointer
는 임의의 타입 포인터와 상호 변환 가능한 타입입니다.
반면, uintptr
는 정수형으로 unsafe.Pointer
로 변환이 가능한 타입입니다.
uintptr
는 정수형의 하나이므로, int
형 등과 같이 사칙연산이 가능합니다.
다음과 같이, []int
타입을 reflect.SliceHeader
타입으로 해석해 보겠습니다.
먼저, []int
타입의 변수인 ns
의 포인터를 가져와 unsafe.Pointer
타입으로 변환합니다.
변환된 값은 ptr
이라는 변수에 저장합니다.
그 다음, ptr
을 *reflect.SliceHeader
로 캐스트하여, 그 포인터가 가리키는 값을 reflect.SliceHeader
타입의 변수 s
에 대입합니다.
package main
import (
"fmt"
"reflect"
"unsafe"
)
func main() {
ns := []int{10, 20, 30}
// ns를 unsafe.Pointer로 변환
ptr := unsafe.Pointer(&ns)
// ptr을 *reflect.SliceHeader로 캐스트하고, 그 값을 s에 대입
s := *(*reflect.SliceHeader)(ptr)
fmt.Printf("%#v\n", s)
}
요소에 접근하기
reflect.SliceHeader
를 사용하여 i번째 슬라이스 요소에 접근하는 방법을 생각해 보겠습니다.
reflect.SliceHeader
는 슬라이스가 참조하는 배열에 대한 포인터를 가지고 있습니다.
그 포인터는 슬라이스의 0번째 요소를 가리키는 포인터입니다.
따라서, 다음의 at
함수처럼, 포인터의 시작 위치에서 요소 i개의 포인터를 진행한 위치가 i번째 요소의 포인터가 됩니다.
요소의 타입에 따라 1요소당 얼마나 포인터를 진행해야 하는지는 다르므로, 다음 예제에서는 int
타입의 경우로 한정합니다.
임의의 타입 크기를 얻으려면 unsafe.Sizeof
를 사용합니다.
package main
import (
"fmt"
"reflect"
"unsafe"
)
func at(s reflect.SliceHeader, i int) unsafe.Pointer {
// 시작 포인터 + 인덱스 * int 타입의 크기
return unsafe.Pointer(s.Data + uintptr(i)*unsafe.Sizeof(int(0)))
}
func main() {
a := [...]int{10, 20, 30}
s := reflect.SliceHeader{Data: uintptr(unsafe.Pointer(&a[0])), Len: 2, Cap: len(a)}
*(*int)(at(s, 0)) = 100 // unsafe.Pointer를 *int로 변환하여 대입
fmt.Println(a)
}
요소 추가하기
여기서 append
함수와 같은 기능을 구현해보겠습니다.
append
는 추가할 때, 슬라이스가 참조하는 배열의 용량이 충분한 경우와 그렇지 않은 경우의 동작이 다릅니다.
용량이 충분한 경우
용량이 충분한 경우에는 다음의 절차로 요소를 추가합니다.
- 새로운 요소를 복사합니다.
- 길이를 업데이트합니다.
package main
import (
"fmt"
"reflect"
"unsafe"
)
func at(s reflect.SliceHeader, i int) unsafe.Pointer {
// 시작 포인터 + 인덱스 * int 타입의 크기
return unsafe.Pointer(s.Data + uintptr(i)*unsafe.Sizeof(int(0)))
}
func myAppend(s reflect.SliceHeader, vs ...int) reflect.SliceHeader {
// 새로운 요소 추가
for i := 0; i < len(vs); i++ {
*((*int)(at(s, s.Len+i))) = vs[i]
}
return reflect.SliceHeader{Data: s.Data, Len: s.Len + len(vs), Cap: s.Cap}
}
func main() {
a := [...]int{10, 20, 30}
// s := a[0:2] -> [10 20]
s := reflect.SliceHeader{Data: uintptr(unsafe.Pointer(&a[0])), Len: 2, Cap: len(a)}
s = myAppend(s, 400)
var ns []int
*(*reflect.SliceHeader)(unsafe.Pointer(&ns)) = s
fmt.Println(ns)
}
용량이 부족한 경우
용량이 부족한 경우에는 다음과 같이 배열을 재할당해야 합니다.
- 원래 용량의 약 2배를 확보합니다.
- 배열에 대한 포인터를 다시 설정합니다.
- 원래 배열에서 요소를 복사합니다.
- 새로운 요소를 복사합니다.
- 길이와 용량을 업데이트합니다.
이 절차로 슬라이스의 크기를 확장하는 함수 growslice
를 구현하면 다음과 같습니다.
그리고 myAppend
내에서 용량이 부족한 경우 growslice
를 호출하여 슬라이스를 확장할 수 있습니다.
package main
import (
"fmt"
"reflect"
"unsafe"
)
func at(s reflect.SliceHeader, i int) unsafe.Pointer {
// 시작 포인터 + 인덱스 * int 타입의 크기
return unsafe.Pointer(s.Data + uintptr(i)*unsafe.Sizeof(int(0)))
}
func myAppend(s reflect.SliceHeader, vs ...int) reflect.SliceHeader {
// 용량이 부족한 경우
if s.Len+len(vs) > s.Cap {
s = growslice(s, s.Len+len(vs))
}
// 새로운 요소 추가
for i := 0; i < len(vs); i++ {
*((*int)(at(s, s.Len+i))) = vs[i]
}
return reflect.SliceHeader{Data: s.Data, Len: s.Len + len(vs), Cap: s.Cap}
}
func growslice(old reflect.SliceHeader, cap int) reflect.SliceHeader {
newcap := cap
doublecap := old.Cap + old.Cap
if cap < doublecap {
newcap = doublecap
}
s := make([]int, old.Len, newcap)
newslice := *(*reflect.SliceHeader)(unsafe.Pointer(&s))
// 기존 슬라이스에서 요소 복사
for i := 0; i < old.Len; i++ {
*((*int)(at(newslice, i))) = *((*int)(at(old, i)))
}
return newslice
}
func main() {
a := [...]int{10, 20, 30}
// s := a[0:2] -> [10 20]
s := reflect.SliceHeader{Data: uintptr(unsafe.Pointer(&a[0])), Len: 2, Cap: len(a)}
s = myAppend(s, 400, 500) // 용량 초과
var ns []int
*(*reflect.SliceHeader)(unsafe.Pointer(&ns)) = s
fmt.Println(ns)
}
여기서 주의할 점은 myAppend
의 반환값이 reflect.SliceHeader
라는 점입니다.
요소가 추가되면 길이가 업데이트되므로 반환값으로 돌려줘야 합니다.
이는 reflect.SliceHeader
가 구조체이기 때문에, 인수로 받은 값의 필드를 아무리 변경해도 호출한 쪽에 영향을 주지 않기 때문입니다.
예를 들어, 다음 예제에서 s2.Len
을 변경해도 s1.Len
에는 영향을 미치지 않습니다.
func main() {
a := [...]int{10, 20, 30}
s1 := reflect.SliceHeader{Data: uintptr(unsafe.Pointer(&a[0])), Len: 2, Cap: len(a)}
s2 := s1
s2.Len = 3 // s1에는 영향 없음
fmt.Println(s1) // {4310908 2 3}
fmt.Println(s2) // {4310908 3 3}
}
마무리
이 글에서는 슬라이스가 어떻게 표현되는지 조사하고, 슬라이스에 관련된 처리를 실제로 구현해보면서 슬라이스를 이해해보았습니다.
여기서 다룬 unsafe.Pointer
는 잘못 다루면 매우 위험할 수 있으니 주의하시기 바랍니다.
예를 들어, 다음과 같이 잘못 사용하면 문제가 발생했습니다.
func main() {
v := struct {
a [5]int
b [2]int
}{
a: [...]int{10, 20, 30, 40, 50},
b: [...]int{100, 200},
}
// cap이 len(a)보다 큽니다
s := reflect.SliceHeader{Data: uintptr(unsafe.Pointer(&v.a[1])), Len: 2, Cap: 6}
var ns []int
*(*reflect.SliceHeader)(unsafe.Pointer(&ns)) = s
// a를 넘어 append
ns = append(ns, 60, 70, 80)
fmt.Println(ns, v.a, v.b)
}
이 코드는 ns
슬라이스가 v.a
배열의 경계를 넘어 v.b
배열의 값을 덮어쓰는 문제를 일으킬 수 있습니다.
unsafe
패키지를 사용할 때는 이러한 점을 주의해야 합니다.
마무리
이 글에서는 Go 언어의 슬라이스가 어떻게 내부적으로 구현되는지 살펴보았습니다.
슬라이스는 배열을 기반으로 하며, 포인터, 길이, 용량을 통해 관리됩니다.
또한, reflect
패키지와 unsafe
패키지를 사용하여 슬라이스의 내부 구조를 직접 확인하고, 슬라이스의 요소를 추가하는 과정을 구현해보았습니다.
슬라이스는 매우 강력한 기능을 제공하지만, unsafe
패키지를 사용할 때는 항상 신중해야 합니다.
잘못된 포인터 연산이나 배열 경계를 넘는 접근은 예기치 않은 버그를 일으킬 수 있습니다.
따라서, unsafe
패키지는 필요한 경우에만 사용하고, 사용할 때는 충분한 주의를 기울여야 합니다.
위의 내용을 잘 이해하고 나면, Go 언어의 슬라이스를 더 효과적으로 사용할 수 있을 것입니다.
슬라이스를 잘 활용하면 더 간결하고 효율적인 코드를 작성할 수 있습니다.
이번 기회에 슬라이스의 내부 구조와 동작 원리를 깊이 있게 이해하고, 이를 바탕으로 Go 언어를 더 잘 활용해 보세요.
'Go' 카테고리의 다른 글
Go의 문자열 결합 성능 비교 (0) | 2024.05.19 |
---|---|
Go 언어로 문자열 결합 최적화하기: strings.Builder 완벽 가이드 및 벤치마크 (0) | 2024.05.19 |
동적인 요소를 가진 JSON을 깔끔하게 Unmarshal하기 (0) | 2024.05.17 |
Go에서의 소수점 연산과 오차 처리 (0) | 2024.05.17 |
json.Unmarshal 사용시 타임(time) 형식을 유연하게 변경하는 방법 (0) | 2024.05.04 |