dev./golang

Ch16) Slice

minikuma 2024. 12. 31. 10:40

16.1 슬라이스


슬라이스를 선언하고 요소에 접근, 순회, 추가하는 방법을 알아보자.

 

16.1.1 슬라이스 선언


var array [10]int

위 코드는 고정길이 10을 가지고 있는 배열을 나타낸 코드이다. 선언된 길이보다 더 많은 값을 저장하기 위해서는 어떻게 해야 할까? 별도 저장공간을 할당한 뒤 기존의 값을 새로운 공간에 복사하는 방식을 사용해야 한다. 이러한 배열의 불편함을 해소해 줄 수 있는 게 슬라이스이다. 슬라이스는 동적 배열을 다루는 데 다양한 방법을 제공해 주고 있다.

 

var slice []int

위와 같이 슬라이스를 만들자. 슬라이스를 초기화 하지 않으면 길이가 0인 슬라이스가 만들어 진다. 크기가 0인 슬라이스에 값을 추가하게 되면 Panic 에러가 발생하고 프로그램은 종료된다. Panic에 대해서는 "Tucker의 Go 언어 프로그래밍" 에러 관련 내용을 참고하면 된다. 결국 슬라이스를 만들 때는 초기화가 필요하다.

 

1) {} 를 사용하여 초기화 하기

 

배열과 유사하게 중괄호({})를 사용하여 초기화 하는 방법이다.

var slice1 = []int{1, 2, 3}
var slice2 = []int{1, 5:2, 10:3} // 5의 인덱스 값: 2, 10의 인덱스 값: 3
slice1:  [1 2 3]
slice2:  [1 0 0 0 0 2 0 0 0 0 3]

 

2) make 를 사용하여 초기화 하기

 

make() 내장 함수를 이용하는 방법이다.

 

내장 함수

func make(t Type, size ...IntegerType) Type

 

사용법

var slice = make([]int, 3)
slice:  [0 0 0]   // 타입의 기본값 = 0

 

16.1.2 슬라이스 요소 접근


슬라이스에 접근하는 방법은 배열과 유사하다.

var slice = make([]int, 3)
slice[1] = 5 // 슬라이스 1번째 인덱스 값을 5로 변경

 

16.1.3 슬라이스 요소 순회


슬라이스 값 또한 배열과 유사하게 순회할 수 있다. 동적으로 배열의 크기가 늘어난다라는 점을 제외하고는 나머지는 배열과 같다.

var slice = []int{1, 2, 3}

for i := 0; i < len(slice); i++ {
    slice[i] += 10
}

fmt.Println("(1) slice : ", slice)

for i, v := range slice {   // 첫번 째 i는 인덱스, 두번 째 v는 값
    slice[i] = v * 2
}

fmt.Println("(2) slice : ", slice)
(1) slice :  [11 12 13]
(2) slice :  [22 24 26]

 

16.1.4 슬라이스 요소 추가 (append)


슬라이스의 고유의 기능이다. 슬라이스는 배열과 달리 동적으로 크기를 늘릴 수 있다. append()를 사용하면 처음 선언된 크기보다 더 큰 요소를 추가할 수 있다. 여기서 중요한 점은 새로운 슬라이스와 배열을 만든다는 점이다.
var slice1 = []int{1, 2, 3}
fmt.Println("slice 1의 주소값, 값:", &slice[0], slice)

slice2 := append(slice, 4) // 기존 크기를 늘리고 값을 추가, 새로운 슬라이스를 반환
fmt.Println("slice 2의 주소값, 값:", &slice2[0], slice2)
slice 1의 주소값: 0x1400001a108 [1 2 3]     
slice 2의 주소값: 0x14000014180 [1 2 3 4] // 새로운 슬라이스, 새로운 배열 생성

 

16.1.5 여러 값 추가하기


append()를 사용하면 여러 개의 값도 추가 가능하다.

var slice = []int{1, 2, 3}  // 3의 크기와 길이를 가지고 있고 1, 2, 3 의 값을 가진 슬라이스
fmt.Println("slice 의 주소값, 값:", &slice[0], slice)

slice = append(slice, 4, 5, 6, 7, 8) 
fmt.Println("slice(값 추가) 주소값, 값: ", &slice[0], slice)
slice 의 주소값, 값: 0x14000118018 [1 2 3]
slice(값 추가) 주소값, 값:  0x140001160c0 [1 2 3 4 5 6 7 8]

 

16.2 슬라이스 동작 원리


이제부터는 슬라이스의 내부 구조를 살펴보자. 내부 동작을 이해하기 위해서는 내부 구현에서 사용하고 있는 SliceHeader 구조체를 알아야  한다.

type SliceHeader struct {
    Data uintptr   // 실제 배열을 가르키는 포인터
    Len  int       // 요소 개수
    Cap  int       // 실제 배열의 크기
}

 

16.2.1 make() 함수를 이용한 선언


var slice = make([]int, 3)

위 코드를 통해 만들어진 슬라이스는 Length(길이)가 3이고, Capacity(용량)은 3으로 만들어 진다. 지금은 길이와 용량이 같은 경우지만 만약 길이보다 용량이 더 큰 경우라면 남은 용량은 나중에 추가될 공간으로 확보된다.

 

16.2.2 슬라이스와 배열의 동작 차이


package main

import "fmt"

// 배열 변경 함수
func changeArray(array2 [5]int) {
    array2[2] = 200
}

// 슬라이스 변경 함수
func changeSlice(slice2 [] int) {
    slice2[2] = 200
}

func main() {
    array := [5]int{1, 2, 3, 4, 5}
    slice := []int{1, 2, 3, 4, 5}

    changeArray(array)
    changeSlice(slice)

    fmt.Println("array:", array)
    fmt.Println("slice:", slice)
}
array: [1 2 3 4 5] 
slice: [1 2 200 4 5]

배열은 새로운 공간을 만들어서 복사하는 방식이라 기존 배열의 값에 영향을 주지 않는다. 반면 슬라이스는 새로운 슬라이스에 Pointer, Len, Cap이 복사된다. 여기서 중요한 거는 실제 배열을 가르키고 있는 포인터값이 복사된다는 점이다. (아래 그림 참조)

Tucker Go 언어 프로그래밍 그림 발췌

 

결국 슬라이스의 값은 변경되었지만, 배열의 값은 변경되지 않았다.

 

16.2.3 동작 차이의 원인


배열, 슬라이스 둘다 복사가 일어났지만 어떤 값을 복사했으냐에 따라 다른 결과를 나타낼 수 있다.

복사는 새로운 공간에 값을 복사하는 거다. 슬라이스에서 복사되는 값은 포인터, 길이, 용량의 값이 복사된다.

 

배열은 새로운 공간에 값이 복사되는 구조라 배열의 값을 변경해도 이전의 배열의 값에 영향을 주지 않는다. 새로운 배열에 값이 변경된다. 하지만 슬라이스는 배열의 값이 아닌 배열을 가르키고 있는 포인터, 길이, 용량의 값을 복사하는 구조이기 때문에  기존 배열의 값에 영향을 줄 수 있다.

 

16.2.4 append()를 사용할 때 발생하는 예기치 못한 문제 1


append()를 사용할 때, 어떤 문제를 만날 수 있을지 살펴보자. append()의 동작은 아래와 같다.

  • 슬라이스에 값을 추가할 수 있는 빈 공간이 있는 지 확인 (남은 공간 = cap - len)
  • 빈 공간이 추가할 공간보다 크거나 같은 경우: 배열의 뒷 부분에 값을 추가하고 len 값을 증가

먼저 만들어진 슬라이스 기준으로 다른 슬라이스를 만들게 되면, 슬라이스에 값을 변경할 때, 예기치 못한 상황을 만나게 될 수 있다. 추가할 값이 빈 공간을 넘지 않는 경우이다. 아래 코드를 보자.

package main

import "fmt"

func main() {
    slice1 := make([]int, 3, 5)
    // 요소 추가: 4, 5
    slice2 := append(slice1, 4, 5) // len:3, cap:5 슬라이스 만들어짐

    fmt.Println("slice1:", slice1, len(slice1), cap(slice1))
    fmt.Println("slice2:", slice2, len(slice2), cap(slice2))
    // 요소 변경: 1번째 100으로 변경
    slice2[1] = 100 // slice2도 변경

    fmt.Println("After change second element")
    fmt.Println("slice1:", slice1, len(slice1), cap(slice1))
    fmt.Println("slice2:", slice2, len(slice2), cap(slice2))

    // 요소 추가: 500
    slice1 = append(slice1, 500) // slice2도 변경

    fmt.Println("After append 500")
    fmt.Println("slice1:", slice1, len(slice1), cap(slice1))
    fmt.Println("slice2:", slice2, len(slice2), cap(slice2))
}
slice1: [0 0 0] 3 5
slice2: [0 0 0 4 5] 5 5

After change second element
slice1: [0 100 0] 3 5
slice2: [0 100 0 4 5] 5 5

After append 500
slice1: [0 100 0 500] 4 5
slice2: [0 100 0 500 5] 5 5

slice1에 값을 변경했는 데, slice2에도 값이 변경됨을 알 수 있다. 왜냐하면 slice1과 slice2는 모두 같은 배열을 가르키고 있기 때문이다.

 

16.2.5 append()를 사용할 때 발생하는 예기치 못한 문제 2


두 번째 경우는 빈 공간이 없는 경우를 살펴보자. append() 함수의 동작은 위에서 살펴본 대로 동작한다. 다른 점은 공간이 충분하지 않은 경우 기존 배열의 2배 크기를 가지는 배열을 새로 만든 뒤 기존 값을 복사한다는 점이다. 예제를 통해 살펴보자.

package main

import "fmt"

func main() {
    slice1 := []int{1, 2, 3}       // len:3, cap:3 슬라이스 생성
    slice2 := append(slice1, 4, 5) // 4, 5 요소를 추가 (실제 배열도 2배 크기로 신규 생성)

    fmt.Println("slice1:", slice1, len(slice1), cap(slice1))
    fmt.Println("slice2:", slice2, len(slice2), cap(slice2))

    slice1[1] = 100 // slice1의 요소 값 변경

    fmt.Println("After change second element")
    fmt.Println("slice1:", slice1, len(slice1), cap(slice1))
    fmt.Println("slice2:", slice2, len(slice2), cap(slice2))

    slice1 = append(slice1, 500) // slice1의 값 추가

    fmt.Println("After append 500")
    fmt.Println("slice1:", slice1, len(slice1), cap(slice1))
    fmt.Println("slice2:", slice2, len(slice2), cap(slice2))
}
slice1: [1 2 3] 3 3
slice2: [1 2 3 4 5] 5 6

After change second element
slice1: [1 100 3] 3 3
slice2: [1 2 3 4 5] 5 6   // 실제 배열도 신규로 만들어져 slice1 변경이 slice2에 영향을 주지 않음

After append 500
slice1: [1 100 3 500] 4 6
slice2: [1 2 3 4 5] 5 6   // 실제 배열도 신규로 만들어져 slice1 변경이 slice2에 영향을 주지 않음

이 경우에는 실제 배열도 신규로 만들어지기 때문에 slice1의 변경이 slice2에 영향이 주지 않는다. 빈 공간이 충분한 경우, 그렇지 않는 경우에 따라 예상하지 못한 동작을 할 수 있으니 슬라이스를 사용할 때 주의가 필요하다.

  • 빈 공간이 충분한 경우: 실제 배열 유지
  • 빈 공간이 충분하지 않는 경우: 실제 배열 변경

 

16.3 슬라이싱


슬라이싱은 배열의 일부분을 가져올 수 있는 기능이다.

array(startIdx:endIndex) // 시작 인덱스:끝 인덱스
슬라이싱을 하면 그 결과로 배열 일부를 가르키는 슬라이스를 반환한다. 실제 배열을 새롭게 만들지는 않는다.
package main

import "fmt"

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

    slice := array[1:2] // 슬라이싱

    fmt.Println("array:", array)
    fmt.Println("slice:", slice, len(slice), cap(slice))

    array[1] = 100 // 배열 1번 인덱스 값 변경

    fmt.Println("After change second element")
    fmt.Println("array:", array)
    fmt.Println("slice:", slice, len(slice), cap(slice))

    slice = append(slice, 500)

    fmt.Println("After append 500")
    fmt.Println("array:", array)
    fmt.Println("slice:", slice, len(slice), cap(slice))
}
array: [1 2 3 4 5]
slice: [2] 1 4

After change second element
array: [1 100 3 4 5]
slice: [100] 1 4

After append 500
array: [1 100 500 4 5]
slice: [100 500] 2 4
  1. slice[1:2]로 array 배열 일부를 슬라이싱한다.
  2. array의 두 번째 값을 변경한다. slice, array값이 모두 변경된다.
  3. slice는 Len:1, Cap:4를 갖는다.

3번 같은 경우는 어떻게 보면 헷갈릴 수 있는 부분이라 알아두면 좋다.

 

16.3.1 슬라이싱으로 배열 일부를 가르키는 슬라이스 만들기


슬라이스는 배열의 일부를 가르키는 타입이다. 슬라이스는 배열을 가르키는 포인터와 길이(Len), 용량(Cap)으로 구성되어 있다. 포인터는 주소값으로 배열의 어느 위치를 가르킬 수 있고, Len은 현재 포인터가 가르키고 있는 값의 개수를 나타낸다. 마지막으로 Cap은 포인터가 가르키는 위치부터 사용이 가능한 전체 공간이라 보면 된다. 이러한 특성이 실제 슬라이싱을 할 때 어떤 영향을 미치는 지 알아보자.

  1. 변경하려고 하는 값이 슬라이싱된 배열 범위에 있지 않은 경우: 배열만 변경
  2. 변경하려고 하는 값이  전체 할당 공간을 벗어나지 않는 경우: 슬라이스, 배열 모두 변경
  3. 변경하려고 하는 값이 전체 할당 공간을 벗어나는 경우: 슬라이스만 변경
package main

import "fmt"

func main() {
    array := [5]int{1, 2, 3, 4, 5}
    slice := array[1:2]

    // 변경하는 배열의 인덱스가 슬라이싱 배열에 포함되이 않은 경우 값 변경 없음 
    array[0] = 11
    fmt.Println("1) 슬라이스가 배열 인덱스 범위에 있지 않은 경우")
    fmt.Println("array => ", array)
    fmt.Println("slice [1:2] => ", slice)

    slice = append(slice, 500)
    slice = append(slice, 600)
    slice = append(slice, 700)
    slice = append(slice, 800)

    // 슬라이스에 추가되는 값이 배열의 크기를 벗어나는 경우 배열의 값은 변경되지 않음
    fmt.Println("2) 추가된 슬라이스 요소가 배열의 범위를 벗어나는 경우")
    fmt.Println("array => ", array)
    fmt.Println("slice [1:2] => ", slice)
}
1) 슬라이스가 배열 인덱스 범위에 있지 않은 경우
array =>  [11 2 3 4 5]
slice [1:2] =>  [2]
2) 추가된 슬라이스 요소가 배열의 범위를 벗어나느 경우
array =>  [11 2 500 600 700]
slice [1:2] =>  [2 500 600 700 800]

 

16.3.2 슬라이스를 슬라이싱하기


슬라이싱 기능은 배열뿐 아니라 슬라이스의 일부 요소를 가져올 때도 사용할 수 있다.

slice1 := []int{1, 2, 3, 4, 5}
slice2 := slice1[1:2] // slice1[2] 인덱스

Tucker Go 언어 프로그래밍 그림 발췌

슬라이스를 슬라이싱하는 방법은 아래와 같이 몇 가지 방법이 있다. (Tucker Go 언어 프로그래밍 2판 참고)

  1. 처음부터 슬라이싱
  2. 끝까지 슬라이싱
  3. 전체 슬라이싱
  4. Cap(용량)으로 조절하는 슬라이싱

 

16.4 유용한 슬라이싱 기능 활용


슬라이싱과 append() 기능을 활용해 슬라이스 복제, 요소 추가, 요소 삭제하는 방법을 알아보자.

 

16.4.1 슬라이스 복제


앞에서 두개의 슬라이스가 같은 배열을 가르켜서 발생하는 문제를 살펴보았다. 이러한 문제가 발생되지 않도록 항상 다른 배열을 가르키도록 만들수 있는 방법이 있는데, 바로 슬라이스 복제를 사용하는 것이다.

package main

import "fmt"

func main() {
    slice1 := []int{1, 2, 3, 4, 5}
    slice2 := make([]int, len(slice1)) // slice1과 같은 길이의 슬라이스 생성

    for i, v := range slice1 {
        slice2[i] = v // 모든 요솟값 복사
    }

    slice1[1] = 100

    fmt.Println("slice1:", slice1)
    fmt.Println("slice2:", slice2)
}
slice1: [1 100 3 4 5]
slice2: [1 2 3 4 5]

루프로 모든 요소 값을 복사하는 구조라 책에서는 append(), copy()를 사용하는 방법도 소개하고 있으니, 한번 보면 좋을 거 같다.

 

16.4.2 요소 삭제


슬라이스 중간의 값을 삭제하는 경우라면 중간 값을 삭제한 뒤 중간 이후 값을 앞으로 당겨서 삭제된 요소를 채워야 한다. 마지막으로 마지막 값을 지워준다.

package main

import "fmt"

func main() {
    slice := []int{1, 2, 3, 4, 5}
    idx := 2

    for i := idx + 1; i < len(slice); i++ {
        slice[i - 1] = slice[i] // 요소 앞당기기
    }

    slice = slice[: len(slice) - 1] // 맨 마지막 요소 삭제

    fmt.Println(slice)
}
[1 2 4 5 6]

해당 코드도 append()를 사용하면 조금 더 간단하게 변경 가능하다.

 

16.4.3 요소 추가


슬라이스 중간에 요소를 추가하면 슬라이스 맨 뒤에 요소를 추가한 뒤 추가하려는 위치를 비워두기 위해 한칸씩 뒤로 이동시킨다. 마지막으로 추가하려는 위치에 요소를 추가한다.

package main

import "fmt"

func main() {
    slice := []int{1, 2, 3, 4, 5, 6}
    
    // 맨 뒤에 요소 추가
    slice = append(slice, 0)
    
    idx := 2 // 추가하려는 위치
	
    // 맨 뒤부터 추가하려는 위치까지 이동
    for i := len(slice) - 2; i >= idx; i-- {
        slice[i + 1] = slice[i]
    }

    slice[idx] = 100 // 값 변경

    fmt.Println(slice)
}
[1 2 100 3 4 5 6]

 

References


  • Tucker의 Go 언어 프로그래맹 2판, 공봉식 저