Go 언어 - 슬라이스

검프·2021년 3월 20일
1

Go 언어 학습

목록 보기
5/9
post-thumbnail

The Ultimate Go Study Guide의 내용을 참고하여 작성했습니다.

슬라이스

슬라이스 선언과 초기화

슬라이스Slice^{Slice}var 변수명 []타입의 형태로 선언하며, make(타입, 길이, 용량)라는 내장 함수를 이용하여 생성합니다. make 함수는 슬라이스, 맵Map^{Map}, 채널Channel^{Channel} 객체를 생성할때 사용합니다. 아래 예제는 5개의 요소를 갖는 슬라이스를 생성합니다. 예제 처럼 용량을 생략할 경우 용량이 길이와 같은 값을 갖습니다.

fruits := make([]string, 5)
fruits[0] = "Apple"
fruits[1] = "Orange"
fruits[2] = "Banana"
fruits[3] = "Grape"
fruits[4] = "Plum"

슬라이스의 첫 번째 워드는 5개의 문자열 배열을 갖는 배열을 가리키고, 두 번째 워드는 길이, 세 번째 워드는 크기를 나타냅니다. 이처럼 슬라이스는 배열을 감싸고 있습니다.

슬라이스의 길이와 용량

길이Length^{Length}는 포인터 위치에서 접근해서 사용할 수 있는 배열 요소의 수를 의미하고, 용량Capacity^{Capacity}는 포인터 위치에서 추가할 수 있는 배열 요소의 최대수를 의미합니다. 슬라이스의 사용 방법은 배열과 비슷하며, 요소 할당 비용도 배열과 동일합니다. 한 가지 다른 점은 []string처럼 대괄호 안에 값이 없다는 것입니다. 실제로 크기가 고정되어 있는 경우에만 배열을 사용하고 대부분의 경우에는 슬라이스를 사용하게됩니다.

5개의 요소를 갖고 용량이 8개인 슬라이스를 만든 후 슬라이스의 길이, 크기, 각 요소의 주소와 값을 출력하는 예제입니다. len 함수는 슬라이스의 길이를, cap 함수는 슬라이스의 크기를 알려줍니다.

fruits := make([]string, 5, 8)
fruits[0] = "Apple"
fruits[1] = "Orange"
fruits[2] = "Banana"
fruits[3] = "Grape"
fruits[4] = "Plum"

fmt.Printf("Length=%d Capacity=%d\n", len(slice), cap(slice))

<출력>
Length=5 Capacity=8


참조 타입

슬라이스는 함수에 전달할 때 참조값Reference^{Reference}을 전달한다는 설명을 찾을 수 있습니다. 좀 더 정확한 설명이 필요합니다. Go언어에서는 함수를 호출할 때 매개변수 값을 복사해서 함수 내부에 전달합니다. 그래서 함수에서 전달된 매개변수의 원본 값을 변경할 수 없습니다. 함수에서 원본 값을 변경하려면 포인터를 사용해야합니다.

그럼 왜 슬라이스는 참조를 전달한다고 설명하는 걸까요? 이유는 슬라이스의 값은 3워드로 구성된 배열의 참조, 길이, 크기 값을 의미하기 때문입니다. 데이터에 해당하는 배열의 메모리 주소도 그대로 복사되므로 데이터에 대한 참조를 복사하게됩니다. 아래 예제를 살펴보면 함수 안/밖에서 출력한 슬라이스의 메모리 주소가 서로 다르지만, 배열의 0번째 요소의 메모리 주소는 같은 것을 확인할 수 있습니다. 이를 통해 슬라이스의 값은 복사되어 전달되지만, 배열의 메모리 주소도 복사되므로 배열의 각 요소에 대한 참조가 전달되는 것과 같은 결과를 얻게된다는 것을 알 수 있습니다.

func main() {
	fruits := [3]{}
	fruits[0] = "Apple"
	fruits[1] = "Orange"
	fruits[2] = "Banana"

	fmt.Printf("1: %p\t%p\n", &fruits, &fruits[0])

	printAddress(fruits)
}

var printAddress = func(slice []string) {
	fmt.Printf("2: %p\t%p\n", &slice, &slice[0])
}

<출력>
1: 0xc000198000 0xc00019a000
2: 0xc000198018 0xc00019a000

아래 예제는 위 예제에서 슬라이스를 배열로 대체하여 출력한 결과입니다. 배열은 함수에 매개변수로 전달할때 배열의 포인터가 가르키는 값을 복사하여 전달하는데요, 결과를 확인해보면 배열의 메모리 주소 뿐만 아니가 배열 요소의 메모리 주소 까지도 다르게 출력되는 것을 확인할 수 있습니다. 배열은 배열의 데이터까지도 복사하여 전달하므로 너무 큰 배열을 함수에 전달할 경우 주의를 할 필요가 있습니다.

func main() {
	fruits := [3]string{}
	fruits[0] = "Apple"
	fruits[1] = "Orange"
	fruits[2] = "Banana"

	fmt.Printf("1: %p\t%p\n", &fruits, &fruits[0])

	printAddress(fruits)
}

var printAddress = func(slice [3]string) {
	fmt.Printf("2: %p\t%p\n", &slice, &slice[0])
}

<출력>
1: 0xc00007c180 0xc00007c180
2: 0xc00007c1b0 0xc00007c1b0

슬라이스는 내부적으로 배열을 사용하기 때문에 각 요소의 주소 값들이 순차적으로 증가하는 것을 확인할 수 있습니다.

fruits := make([]string, 5, 8)
fruits[0] = "Apple"
fruits[1] = "Orange"
fruits[2] = "Banana"
fruits[3] = "Grape"
fruits[4] = "Plum"

printAddress(fruits)

var printAddress = func(slice []string) {
    for i := range slice {
        fmt.Printf("[%d] %p %s\n", i, &slice[i], slice[i])
    }
}

<출력>
[0] 0xc0000be000 Apple
[1] 0xc0000be010 Orange
[2] 0xc0000be020 Banana
[3] 0xc0000be030 Grape
[4] 0xc0000be040 Plum

빈 슬라이스

문자열로 nil 슬라이스를 선언하면 슬라이스의 값은 제로값으로 설정됩니다. 첫번째 워드는 nil, 두 번째와 세 번째 워드는 0으로 표현되는 워드 자료구조입니다.

var data []string  // <1>

data := []string{}   // <2>

<1>, <2> 두 가지 표현은 같은 표현일까요? 정답은 다릅니다. <2>와 같이 빈 리터럴 표현식을 사용할 경우 데이터는 제로값으로 설정되지 않고, 빈 배열에 대한 포인터를 갖습니다. <1>, <2>marshal 함수에 넘기면 전혀 다른 결과를 얻게됩니다.

var data []string

fmt.Printf("data is nil? %t\ncapacity is %d\n", data == nil, cap(data))

b, _ := json.Marshal(data)
fmt.Printf("json representation is %s\n", string(b))

<출력>
data is nil? true
capacity is 0
json representation is null

---

data := []string{}

fmt.Printf("data is nil? %t\ncapacity is %d\n", data == nil, cap(data))

b, _ := json.Marshal(data)
fmt.Printf("json representation is %s\n", string(b))

<출력>
data is nil? false
capacity is 0
json representation is []

리슬라이스

리슬라이스Re-slice는 원본 슬라이스에 대한 뷰View를 생성합니다. 원본 슬라이스에 대한 복사본을 만드는 것이 아닌 부분에 대한 참조만을 갖습니다. 리슬라이스는 [시작 인덱스:(시작 인덱스 + 길이)]의 형태로 생성합니다.

아래 예제를 보면 fruits를 리슬라이싱하여 1~3번 인덱스를 참조하는 punnet 슬라이스를 생성합니다. punnet 슬라이스의 요소를 변경했을때 fruits 슬라이스의 값도 함께 변경되는 것을 통해서 같은 배열을 참조하고 있음을 알 수 있습니다.

func main() {
	fruits := make([]string, 5)
	fruits[0] = "Apple"
	fruits[1] = "Orange"
	fruits[2] = "Banana"
	fruits[3] = "Grape"
	fruits[4] = "Plum"

	punnet := fruits[1:4]
	punnet[2] = "Strawberry"

	fmt.Println(fruits)
	fmt.Println("---")
	fmt.Println(punnet)
	fmt.Println("---")
	inspectSlice(fruits)
	fmt.Println("---")
	inspectSlice(punnet)
}

func inspectSlice(slice []string) {
	fmt.Printf("Length[%d] Capacity[%d]\n", len(slice), cap(slice))
	for i := range slice {
		fmt.Printf("[%d] %p %s\n", i, &slice[i], slice[i])
	}
}

<출력>
[Apple Orange Banana Strawberry Plum]
---
[Orange Banana Strawberry]
---
Length[5] Capacity[5]
[0] 0xc000118000 Apple
[1] 0xc000118010 Orange
[2] 0xc000118020 Banana
[3] 0xc000118030 Strawberry
[4] 0xc000118040 Plum
---
Length[3] Capacity[4]
[0] 0xc000118010 Orange
[1] 0xc000118020 Banana
[2] 0xc000118030 Strawberry

리슬라이스하여 사용할 경우 주의해야 할 점은 원본 슬라이스의 배열을 참조하고 있기 때문에 원본 슬라이스가 더 이상 사용하지 않게 돼도 배열이 메모리에 남게 된다는 점입니다.


슬라이스 복사

내장 함수인 copy 함수를 이용하여 슬라이스를 복사할 수 있습니다. 슬라이스를 복사할 때눈 원본의 요소들을 담을 수 있는 새 슬라이스를 만들고 copy함수를 이용하여 값을 복사합니다.

아래 예제에서 fruits를 리슬라이스하여 punnet에 복사합니다. 이후 punnet에 특정 요소를 다른 값으로 변경하여도 fruits에는 영향이 없는 것을 확인할 수 있습니다.

fruits := make([]string, 5)
fruits[0] = "Apple"
fruits[1] = "Orange"
fruits[2] = "Banana"
fruits[3] = "Grape"
fruits[4] = "Plum"

punnet := make([]string, 3)
copy(punnet, fruits[1:4])
punnet[2] = "Strawberry"

fmt.Println(fruits)
fmt.Println(punnet)

<출력>
[Apple Orange Banana Grape Plum]
[Orange Banana Strawberry]

슬라이스의 크기 증가

슬라이스는 append 함수로 요소를 추가할 경우 크기가 부족하면 동적으로 슬라이스의 크기를 증가 시킵니다. 이를 슬라이스의 크기 증가Slice capacity growth라고 이야기 합니다.

자세히 이야기 해보면 내장 함수 append는 슬라이스가 참조하는 배열의 끝에 하나 이상의 요소를 추가합니다. 슬라이스의 크기가 충분한 경우에는 새 요소를 위해서 리슬라이스됩니다. 반대로 크기가 충분하지 않은 경우 더 큰 배열을 생성하여 값을 복사한 후 새 배열을 참조한 후 슬라이스를 반환합니다.

슬라이스의 이런 특성 때문에 특정 요소를 변수로 참조할 경우 주의가 필요합니다. 아래 예제에서 numSlice는 크기가 증가하면서 내부적으로 참조하는 배열이 변경됩니다. 즉, willBeOutdate 변수는 이전 배열의 요소를 참조하고 있어서 numSlice의 요소 값을 변경해도 willBeOutdate의 값은 변경되지 않습니다. 이는 프로그래머가 의도한 결과가 아닐 것이므로 주의해야 합니다.

numSlice := make([]int, 7)
for i := 0; i < 7; i++ {
	numSlice[i] = i * 100
}

// numSlice의 1번째 요소를 참조
willBeOutdate := &numSlice[1]

fmt.Printf("Before append:\t%p\t%p\n", &numSlice, &numSlice[0])

// append 함수 호출 시 슬라이스의 값이 복사되어 전달되므로 반환값을 사용해야 함
numSlice = append(numSlice, 800)
numSlice[1]++

fmt.Printf("After append:\t%p\t%p\n", &numSlice, &numSlice[0])
fmt.Println("willBeOutdate:", *willBeOutdate, "numSlice[1]:", numSlice[1])

<출력>
Before append:  0xc000198000    0xc00018c040
After append:   0xc000198000    0xc0001a0000
elementAtOne:    100 numSlice[1]: 101

추가로 src/runtime/slice.go에 슬라이스의 크기 증가 시 증가되는 크기에 대한 알고리즘이 구현되어 있습니다.

  1. 이전 슬라이스 크기의 두배를 새 슬라이스의 용량과 비교하여 새 슬라이스의 크기가 클 경우 새 슬라이스의 크기로 사용합니다. <1>
  2. 이전 슬라이스 크기의 두배를 새 슬라이스의 용량과 비교하여 새 슬라이스의 크기가 작고 이전 슬라이스의 길이가 1024보다 작은 경우 이전 슬라이스 크기의 두배를 새 슬라이스의 크리로 사용합니다. <2>
  3. 이전 슬라이스의 길이가 1024보다 크면 새 슬라이스의 용량으로 이전 슬라이스 용량의 25%를 증가시켜서 사용합니다. <3>
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
	newcap = cap // <1>
} else {
	if old.len < 1024 {
		newcap = doublecap // <2>
	} else {
		// Check 0 < newcap to detect overflow
		// and prevent an infinite loop.
		for 0 < newcap && newcap < cap {
			newcap += newcap / 4 // <3>
		}
		// Set newcap to the requested cap when
		// the newcap calculation overflowed.
		if newcap <= 0 {
			newcap = cap
		}
	}
}

길이와 크키가 같은 슬라이스에 요소를 추가하여 크기가 어떻게 증가하는지 확인하는 예제입니다.

func appendAndPrintCapacity(slice []int) {
	newSlice := append(slice, 1)
	fmt.Println(cap(newSlice))
}

appendAndPrintCapacity(make([]int, 128))	// 128 + 128
appendAndPrintCapacity(make([]int, 256))	// 256 + 256
appendAndPrintCapacity(make([]int, 512))	// 1024 + 1024
appendAndPrintCapacity(make([]int, 1024))	// 1024 + 1024 / 4
appendAndPrintCapacity(make([]int, 2048))	// 2048 + 2048 / 4

<출력>
256
512
1024
1280
2560

슬라이스의 요소 삭제

Go언어에서는 슬라이스의 요소를 삭제하는 문법을 별도로 제공하지 않습니다. 아래와 같이 append 함수와 리슬라이스를 이용하여 슬라이스의 요소를 삭제할 수 있습니다.

fruits := make([]string, 5)
fruits[0] = "Apple"
fruits[1] = "Orange"
fruits[2] = "Banana"
fruits[3] = "Grape"
fruits[4] = "Plum"

fmt.Printf("Length[%d] Capacity[%d] %v\n", len(fruits), cap(fruits), fruits)

// Banana를 삭제
fruits = append(fruits[:2], fruits[3:]...)

fmt.Printf("Length[%d] Capacity[%d] %v\n", len(fruits), cap(fruits), fruits)

<출력>
Length[5] Capacity[5] [Apple Orange Banana Grape Plum]
Length[4] Capacity[5] [Apple Orange Grape Plum]

만약 크기도 줄이고 싶다면 아래와 같이 합니다. 배열 복사가 이루어지므로 잘 고민해보고 사용해야합니다.

func main() {
	fruits := make([]string, 5)
	fruits[0] = "Apple"
	fruits[1] = "Orange"
	fruits[2] = "Banana"
	fruits[3] = "Grape"
	fruits[4] = "Plum"

	fmt.Printf("Length[%d] Capacity[%d] %v\n", len(fruits), cap(fruits), fruits)

	// Banana를 삭제
	fruits = deleteAt(2, fruits)

	fmt.Printf("Length[%d] Capacity[%d] %v\n", len(fruits), cap(fruits), fruits)
}

func deleteAt(index int, slice []string) []string {
	slice = append(slice[:index], slice[index+1:]...)
	newSlice := make([]string, len(slice))
	copy(newSlice, slice)
	return newSlice
}

<출력>
Length[5] Capacity[5] [Apple Orange Banana Grape Plum]
Length[4] Capacity[4] [Apple Orange Grape Plum]
profile
권구혁

0개의 댓글