Go 컬렉션 - Slice

1. 슬라이스(Slice)

Go 배열은 고정된 배열크기 안에 동일한 타입의 데이타를 연속적으로 저장하지만, 배열의 크기를 동적으로 증가시키거나 부분 배열을 발췌하는 등의 기능을 가지고 있지 않다. Go Slice는 내부적으로 배열에 기초하여 만들어 졌지만 배열의 이런 제약점들을 넘어 개발자에게 편리하고 유용한 기능들을 제공한다. 슬라이스는 배열과 달리 고정된 크기를 미리 지정하지 않을 수 있고, 차후 그 크기를 동적으로 변경할 수도 있고, 또한 부분 배열을 발췌할 수도 있다.

Go Slice 선언은 배열을 선언하듯이 "var v []T" 처럼 하는데 배열과 달리 크기는 지정하지 않는다. 예를 들어, 정수형 Slice 변수 a를 선언하기 위해서 "var a []int" 처럼 선언할 수 있다.

package main
import "fmt"

func main() {
	var a []int        //슬라이스 변수 선언
	a = []int{1, 2, 3} //슬라이스에 리터럴값 지정
	a[1] = 10
	fmt.Println(a)     // [1, 10, 3]출력
}

Go에서 Slice를 생성하는 또 다른 방법으로 Go의 내장함수 make() 함수를 이용할 수 있다. make() 함수로 슬라이스를 생성하면, 개발자가 슬라이스의 길이(Length)와 용량(Capacity)을 임의로 지정할 수 있는 장점이 있다. make() 함수의 첫번째 파라미터에 생성할 슬라이스 타입을 지정하고, 두번째는 Length (슬라이스의 길이), 그리고 세번째는 Capacity (내부 배열의 최대 길이)를 지정하면, 모든 요소가 Zero value인 슬라이스를 만들게 된다. 여기서 만약 세번째 Capacity 파라미터를 생략하면 Capacity는 Length와 같은 값을 갖는다. 그리고 슬라이스의 길이 및 용량은 내장함수 len(), cap()을 써서 확인할 수 있다.

func main() {
	s := make([]int, 5, 10)
	println(len(s), cap(s)) // len 5, cap 10
}

슬라이스에 별도의 길이와 용량을 지정하지 않으면, 기본적으로 길이와 용량이 0 인 슬라이스를 만드는데, 이를 Nil Slice 라 하고, nil 과 비교하면 참을 리턴한다.

func main() {
	var s []int

	if s == nil {
		println("Nil Slice")
	}
	println(len(s), cap(s)) // 모두 0
}

2. 부분 슬라이스(Sub-slice)

슬라이스에서 일부를 발췌하여 부분 슬라이스를 만들 수 있다. 부분 슬라이스는 "슬라이스[처음인덱스:마지막인덱스]" 형식으로 만드는데, 예를 들어 슬라이스 s 에 대해 인덱스 2부터 4까지 데이타를 갖는 부분 슬라이스를 만드려면, s[2:5]와 같이 표현한다. 여기서 [2:4]가 아니라 [2:5]으로 쓰는데, 마지막인덱스는 원하는 인덱스+1 을 사용한다. 즉, 처음인덱스는 Inclusive 이며, 마지막인덱스는 Exclusive이다 (주: Python과 동일).

package main
import "fmt"

func main() {
	s := []int{0, 1, 2, 3, 4, 5}
	s = s[2:5]	
	fmt.Println(s) //2,3,4 출력
}

슬라이스 인덱스는 처음/마지막 둘 중 하나 혹은 둘 다를 생략할 수도 있다. 처음 인덱스가 생략되면 0 이, 마지막 인덱스가 생략되면 그 슬라이스의 마지막 인덱스가 자동 대입된다. 즉, 처음 0 부터 인덱스 4까지를 포함하기 위해서는 [:5]를, 인덱스 2부터 마지막까지 포함하기 위해서는 [2:]와 같이 쓸 수 있다. 만약 [:]와 같이 모두 생략하면, 전체를 표현한다.

	s := []int{0, 1, 2, 3, 4, 5}
	s = s[2:5]	   // 2, 3, 4
	s = s[1:]      // 3, 4
	fmt.Println(s) // 3, 4 출력

3. 슬라이스 추가,병합(append)과 복사(copy)

배열은 고정된 크기로 그 크기 이상의 데이타를 임의로 추가할 수 없지만, 슬라이스는 자유롭게 새로운 요소를 추가할 수 있다. 슬라이스에 새로운 요소를 추가하기 위해서는 Go 내장함수인 append()를 사용한다. append()의 첫 파라미터는 슬라이스 객체이고, 두번째는 추가할 요소의 값이다. 또한 여러 개의 요소 값들을 한꺼번에 추가하기 위해서는 append() 두번째 파라미터 뒤에 계속하여 값을 추가할 수 있다.

func main() {
	s := []int{0, 1}

	// 하나 확장
	s = append(s, 2)       // 0, 1, 2
	// 복수 요소들 확장
	s = append(s, 3, 4, 5) // 0,1,2,3,4,5

	fmt.Println(s)
}

내장함수 append()가 슬라이스에 데이타를 추가할 때, 내부적으로 다음과 같은 일이 일어난다. 슬라이스 용량(capacity)이 아직 남아 있는 경우는 그 용량 내에서 슬라이스의 길이(length)를 변경하여 데이타를 추가하고, 용량(capacity)을 초과하는 경우 현재 용량의 2배에 해당하는 새로운 Underlying array (주: 아래 내부구조 참조) 을 생성하고 기존 배열 값들을 모두 새 배열에 복제한 후 다시 슬라이스를 할당한다. 아래 예제는 길이 0/용량 3의 슬라이스에 1부터 15까지의 숫자를 계속 추가하면서 슬라이스의 길이와 용량이 어떻게 변하는 지를 체크하는 코드이다. 이 코드를 실행하면 1~3까지는 기존의 용량 3을 사용하고, 4~6까지는 용량 6을, 7~12는 용량 12, 그리고 13~15는 용량 24의 슬라이스가 사용되고 있음을 알 수 있다.

package main

import "fmt"

func main() {
	// len=0, cap=3 인 슬라이스
	sliceA := make([]int, 0, 3)

	// 계속 한 요소씩 추가
	for i := 1; i <= 15; i++ {
		sliceA = append(sliceA, i)
		// 슬라이스 길이와 용량 확인
		fmt.Println(len(sliceA), cap(sliceA))
	}

	fmt.Println(sliceA) // 1 부터 15 까지 숫자 출력 
}

한 슬라이스를 다른 슬라이스 뒤에 병합하기 위해서는 아래 예제와 같이 append()를 사용한다. 이 append 함수에서는 2개의 슬라이스를 파라미터로 갖는데, 처음 슬라이스 뒤에 두번째 파라미터의 슬라이스를 추가하게 된다. 여기서 한가지 주의할 것은 두번째 슬라이스 뒤에 ... 을 붙인다는 것인데, 이 ellipsis(...)는 해당 슬라이스의 컬렉션을 표현하는 것으로 두번째 슬라이스의 모든 요소들의 집합을 나타낸다. 즉, 아래 예제에서 sliceB... 는 4, 5, 6 으로 치환된다고 볼 수 있다.

package main

import "fmt"

func main() {
	sliceA := []int{1, 2, 3}
	sliceB := []int{4, 5, 6}

	sliceA = append(sliceA, sliceB...)
	//sliceA = append(sliceA, 4, 5, 6)

	fmt.Println(sliceA) // [1 2 3 4 5 6] 출력
}

이러한 추가/확장 기능과 더불어, Go 슬라이스는 내장함수 copy()를 사용하여 한 슬라이스를 다른 슬라이스로 복사할 수도 있다. 아래 예제는 3개의 요소를 갖는 소스 슬라이스를 그 2배의 크기 즉 6개를 갖는 타겟슬라이스로 복사하는 예를 보여준다. (주: 아래에서 설명하듯이 슬라이스는 실제 배열을 가리키는 포인터 정보만을 가지므로, 복사를 좀 더 정확히 표현하면, 소스 슬라이스가 갖는 배열의 데이타를 타겟 슬라이스가 갖는 배열로 복제하는 것임)

func main() {
	source := []int{0, 1, 2}
	target := make([]int, len(source), cap(source)*2)
	copy(target, source)
	fmt.Println(target)  // [0 1 2 ] 출력
	println(len(target), cap(target)) // 3, 6 출력
}

5. 슬라이스의 내부구조

슬라이스는 내부적으로 사용하는 배열의 부분 영역인 세그먼트에 대한 메타 정보를 가지고 있다. 슬라이스는 크게 3개의 필드로 구성되어 있는데, 첫째 필드는 내부적으로 사용하는 배열에 대한 포인터 정보이고, 두번째는 세그먼트의 길이를, 그리고 마지막으로 세번째는 세그먼트의 최대 용량(Capacity)이다.

처음 슬라이스가 생성될 때, 만약 길이와 용량이 지정되었다면, 내부적으로 용량(Capacity)만큼의 배열을 생성하고, 슬라이스 첫번째 필드에 그 배열의 처음 메모리 위치를 지정한다. 그리고, 두번째 길이 필드는 지정된 (첫 배열요소로부터의) 길이를 갖게되고, 세번째 용량 필드는 전체 배열의 크기를 갖는다.

예를 들어, 아래 첫번 문장을 보면, 0부터 5까지 6개의 요소를 갖는 슬라이스를 생성하고 있음을 볼 수 있다. 이때, 슬라이스의 배열포인터는 내부 배열의 첫번째 요소인 0을 가리키고, 길이는 전체 6, 그리고 용량도 6으로 설정된다.

Go 슬라이스 내부

그런데, 만약 이 슬라이스 S 로부터 Sub-slice S[2:5]를 하게 되면 슬라이스의 내부데이타 어떻게 변경될까? S[2:5]는 인덱스2부터 인덱스4까지의 배열요소를 가리키므로, 슬라이스 S의 배열포인터는 세번째 배열요소인 2를 가리키고, 길이는 3을, 그리고 용량은 세번째 배열요소부터 배열 마지막까지 즉 4를 갖게된다.