시작하며

Go 언어를 공부하면서 가장 먼저 마주치는 자료구조 중 하나가 슬라이스(Slice)다. 배열과 비슷해 보이지만 동작 방식이 전혀 다르다. 슬라이스 내부에 어떤 구조가 숨어 있는지, 그리고 실전에서 어떻게 활용하는지를 정리한다.

Slice 기본

Go에서 제공하는 동적 배열

var slice1 = []int{1,2,3} // {} 를 통한 초기화
var slice2 = make([]int, 3) // make() 를 통한 초기화
  // make(배열을 가르키는 포인터, 요소 개수, 실제 배열의 길이)
var slice2 = make([]int, 3, 5) // make() 를 통한 초기화2

Slice 순회

// range 순회
for i, v := range slice1 {
    slice1[i] = v*2
    fmt.Println(slice1[i])
}

Slice 추가

// append로 추가
// append()함수가 호출되면 슬라이스에 값을 추가할 수 있는 빈 공간이 있는지 확인한다. (cap - len)
// (중요!) 빈공간이 없다면 새로운 큰 배열(일반적으로 2배 크기)을 마련한다. -> 이후 기존 배열 요소를 새로운 배열에 복사한다. -> 새로운 배열 맨 뒤에 새 값을 추가한다.
// 예를 들어, slice1 과 slice2 가 같은 배열주소를 가르키고 있는 상황에서, slice1,2의 len,cap 상황에 따라 append를 적용했을 떄 다른 나머지 slice의 결과에도 영향이 갈 수 있다.
// (ex Slice1은 빈공간이 없지만 Slice2는 빈공간이 있는데, Slice2에 append했더니 빈공간이 없는 Slice1의 값도 바뀜)
slice3 := append(slice2, 4)
slice3 := append(slice2, 3,4,5,6,7)
slice_origin = append(slice_origin, 14, 13, 14)

Slice 동작 원리

reflect package > SliceHeader

  // SliceHeader의 모습
  type SliceHeader struct {
    Data uintptr // 배열 포인터
    Len  int // 요소 개수
    Cap  int // 배열 길이
  }
  // 슬라이스 변수 대입 시 배열에 비해 사용되는 메모리나 속도에 이점이 있다.

Slice 와 Array 동작 차이

SliceArray 는 동작 방식에 차이가 있다. Go 언어에서 모든 값에 대한 대입은 복사로 이루어진다. Slice 는 포인터 필드를 비롯한 SliceHeader가 복사되고 Array 는 값의 복사가 이루어진다. 따라서 아래와 같이 array [1, 2, 3, 4, 5] / slice [1, 2, 200, 4, 5] 처럼 값이 다르게 나타나게 된다.

  // Array <-> Slice 차이
  func changeArray(array [5]int) {
    array[2] = 200
  }
 
  func changeSlice(slice []int){
    slice[2] = 200
  }
 
  func main(){
    array1 := [5]int{1,2,3,4,5}
    slice1 := []int{1,2,3,4,5}
 
    changeArray(array1)
    changeSlice(slice1)
    fmt.Println("array: ", array1) 
    fmt.Println("slice: ", slice1) 
 
    // array:  [1 2 3 4 5]
    // slice:  [1 2 200 4 5]
  }

Slicing - 배열의 일부를 집어내는 기능

  // Slice 기능을 사용하면 배열은 그대로 len 5로 존재하고, SliceHeader의 값만 바뀐다.
  array := [5]int{1, 2, 3, 4, 5}
  slice := array[1:2]
 
  // 예를 들면 SliceHeader -> uintptr: array주소, Len: 1, Cap: 4 이다.
  arrayToSlice := array[:] // 전체 슬라이스. 배열 전체를 슬라이스로 만들고 싶을 때 주로 사용한다.
 
  // cap 사이즈까지 바꾸고 싶은 경우에는 인덱스 3개로 Slicing한다.
  slice1 := []int{1, 2, 3, 4, 5}
  slice2 := slice1[1:3:4]
 
  fmt.Println(slice2)      // [2 3]
  fmt.Println(len(slice2)) // 2
  fmt.Println(cap(slice2)) // 3

Slice 기능 활용

슬라이스 복제

두 Slice가 같은 배열을 가리키면 여러 가지 문제가 발생한다. 가장 좋은 방법은 Slice를 복제하는 것이다.

  // copy sol 1.
  for i, v := range slice1 {
    slice2[i] = v
  }
 
  // copy sol 2.
  slice2 = append([]int{}, slice1...)
 
  // copy sol 3.
  result := copy(slice2, slice1)

슬라이스 요소 삭제

중간 요소를 삭제하는 경우, 중간 요소를 삭제하고 이후의 값을 앞당긴 다음 마지막 값을 지워준다.

  // sol 1.
  for i := idx+1; i<len(slice); i++ {
    slice[i-1] = slice[i]
  }
  slice = slice[:len(slice)-1]
 
  // sol 2.
  slice = append(slice[:idx], slice[idx+1:]...) // 각 요소들 append

슬라이스 요소 추가

중간 요소를 추가하는 경우, 슬라이스 맨 뒤에 요소를 추가한다 맨 뒷값부터 삽입하려는 위치까지 한 칸씩 뒤로 밀어준다 중간 요소부분과 값을 바꿔준다.

  // sol 1.
  slice = append(slice, 0)
  for i := len(slice)-2; i >= idx; i-- {
    slice[i+1] = slice[i]
  }
  slice[idx] = 100
 
  // sol 2.
  slice = append(slice[:idx], append([]int{100}, slice[idx:]...)...)
 
  // sol 2* 불필요한 메모리 개선하기
  copy(slice[idx+1:], slice[idx:])
  slice[idx] = 100

슬라이스 정렬

Go 기본 패키지 sort 를 사용한다.

  // sort 1.
  slice := []int{5, 2, 6, 3, 1, 4}
  sort.Ints(slice)
 
  // sort 2.
  // sort.Sort() -> Len, Less, Swap 함수에 대한 구현이 필요
  type Student struct {
    Name string
    Age int
  }
 
  type Students []Student
 
  func (s Students) Len() int { return len(s) }
  func (s Students) Less(i, j int) bool { return s[i].Age < s[j].Age }
  func (s Students) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 
 
  func main() {
    s := []Student{ {"화랑", 31}, {"백두산", 52}, {"류", 42}, {"켄", 38}, {"송하나", 23} }
    sort.Sort(Students(s))
    fmt.Println(s)
  }

정리하며

슬라이스는 내부적으로 포인터, 길이(len), 용량(cap) 세 가지 필드로 구성된 SliceHeader를 사용한다. 배열과 달리 대입 시 헤더만 복사되기 때문에 메모리 효율이 높지만, 같은 배열을 공유하는 슬라이스가 여러 개 생길 수 있다는 점에 주의해야 한다. append 시 빈 공간(cap - len)이 부족하면 2배 크기의 새 배열을 할당한다는 동작 원리를 이해하면 예측하기 어려운 버그를 사전에 방지할 수 있다.

연습 문제

Q1. 동적배열을 초기화하는 방법 2가지

  // 1
  slice := make([]int, x)
  // 2
  slice := []int{a,c,v,...}

Q2. slice에서 append() 사용 시 빈 공간 계산 방식, 빈 공간이 없을 때 동작

  1) cap - len > 값의 개수 로 비교
  2) 기존 배열의 cap * 2로 cap을 늘인 새로운 배열을 지정하고 값을 추가

Q3. Slice 내부 헤더 3요소

  ptr, len, cap

Q4. slice1 := []int{1, 2, 3, 4, 5} 에서 [1, 2] 를 추출, cap은 3

  slice2 := slice1[0:2:3]

Q5. slice1의 인덱스 0과 1 사이에 200 삽입 (idx = 1)

  slice1 := []int{1, 2, 3, 4, 5}
  slice1 = append(slice1, 0)
  idx := 1
  for i := len(slice1) - 2; i >= idx; i-- {
    slice1[i+1] = slice1[i]
  }
  slice1[idx] = 200
  fmt.Println(slice1)