본문 바로가기

[Go] 슬라이스 본문

Dev

[Go] 슬라이스

겨울바람_ 2024. 11. 7. 22:09

Slice란?

슬라이스는 Go 언어에서 제공하는 자료구조 중 하나로, 동적으로 크기를 증가시키는 동적 배열이다. 또한, 슬라이싱 기능을 이용해 배열의 일부를 나타내는 슬라이스를 만들 수 있다.

Slice 선언

🚀{}를 이용한 초기화

var slice []int

일반적인 배열과 달리 [] 내부에 배열의 개수를 적지 않고 선언한다. 별도로 슬라이스의 크기를 지정해주지 않으면, 길이가 0인 슬라이스가 생성된다.

var slice []int

if len(slice) == 0 {
    fmt.Println("slice is Empty")
}
// slice is Empty

슬라이스 초기화 시점에 슬라이스 내부에 포함되는 값들을 미리 설정하기 위해서는 배열처럼 {}를 이용하여 요소값을 지정해야 한다.

var slice1 = []int {1, 2, 3} // [1, 2, 3]
var slice2 = []int {1, 5:2, 10:3} // [1, 0, 0, 0, 0, 2, 0, 0, 0, 0, 3]

slice1은 1, 2, 3을 값으로 갖는 슬라이스가 되며, slice2는 인덱스 1은 1, 인덱스 5는 2, 인덱스 10은 3을 각각 값으로 갖고 나머지는 0인 슬라이스로 초기화 된다.

🚀make()를 이용한 초기화

내장 함수 make()를 사용하는 방법도 있다.

함수의 첫 번째 인자로 생성하고자 하는 슬라이스의 타입을 입력하고, 두 번째 인자로 해당 슬라이스의 길이를 작성한다.

var slice = make([]int, 3) // [0, 0, 0]

슬라이스에 포함된 값은 int 타입의 기본 값인 0으로 초기화된다.

Slice 요소 추가

➕ append

슬라이스의 요소를 조회하거나 순회하는 방식은 일반 배열과 동일하지만, 값을 추가하는 방식에서 일반적인 배열과 차이점이 존재한다.

 

일반적인 배열은 초기화 시점에서 길이가 정해지면 길이를 늘릴 수 없지만, 슬라이스는 요소를 추가하는 것으로 길이를 늘릴 수 있다.

 

요소를 추가하기 위해서는 내장 함수인 append()를 사용하면 된다. 다른 언어에서 사용되는 것과 동일하게 슬라이스의 맨 뒤에 요소를 추가한다.

 

이때 기존의 슬라이스 값을 변경하는 것이 아닌 인수를 추가한 슬라이스를 반환하기 때문에 append()의 결과를 대입할 값이 필요하다.

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

sliceForAppend := append(slice, 4)

fmt.Println(slice) // output > 1, 2, 3
fmt.Println(sliceForAppend) // output > 1, 2, 3, 4

append()를 사용해 slice에 4를 추가해 반환한 값을 sliceForAppend에 대입한다.

append()를 사용하면 여러 개의 값을 한 번에 추가할 수도 있다.

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

sliceForAppend := append(slice, 4, 5, 6)

fmt.Println(slice) // output > 1, 2, 3
fmt.Println(sliceForAppend) // output > 1, 2, 3, 4, 5, 6

Slice 동작 원리

슬라이스는 내장 타입으로 내부 구현이 감춰져 있지만, SliceHeader 구조체를 사용해 내부 구현을 살펴볼 수 있다. 슬라이스 내부 정의는 다음과 같다.

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

슬라이스 구조체는 배열을 가리키는 포인터와 요소 개수를 나타내는 len, 전체 배열 길이를 나타내는 cap 필드로 구성되어 있다.

 

슬라이스가 실제 배열을 가리키는 포인터를 가지고 있기 때문에 쉽게 크기가 다른 배열을 가리키도록 변경할 수 있고, 슬라이스 변수 대입 시 배열에 비해서 사용되는 메모리나 속도에 이점이 있다.

var slice := make([]int, 3)
var slice2 := make([]int, 3, 5)

slicelen이 3이고 cap 또한 3이다. slice2len이 3이고 cap은 5다. 그림을 통해 살펴보면 다음과 같다.

🚀Slice와 배열의 동작 차이

슬라이스의 내부 구현이 배열과 다르기 때문에 동작 또한 배열과 다르다. 예제를 통해 차이를 살펴보자.

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) // array: [1, 2, 3, 4, 5]
    fmt.Println("slice:", slice) // slice: [1, 2, 200, 4, 5]
}

배열의 값은 변경되지 않는 반면, 슬라이스는 값이 변경되는 것을 확인할 수 있다.

 

Go언어에서 모든 값의 대입은 복사로 일어난다. 함수에 인수로 전달될 때나 다른 변수에 대입할 때나 값의 이동은 복사로 일어난다.

 

복사는 타입의 값이 복사되는데, 포인터의 경우 포인터의 값인 메모리 주소가 복사되고 구조체가 복사될 때는 구조체의 모든 필드가 복사된다.

 

changeArray() 함수에 인자로 들어간 arrayint 타입의 요소 다섯 개를 가지고 있다. 즉, array의 크기는 40byte인 것을 알 수 있다. (8 x 5 = 40) changeArray() 함수가 동작했을 때 array의 값이 array2로 복사된다.

 

arrayarray2는 별도의 메모리 공간을 가지는 아예 별개의 배열인 것이다. 그렇기 때문에, array2의 요소값을 변경해도 array의 요소값에는 아무런 영향이 없는 것이다.

🚀append()를 사용했을 때 발생할 수 있는 문제 (1)

append() 함수가 호출되면 먼저 슬라이스에 값을 추가할 수 있는 빈 공간이 있는지 확인한다. 남은 빈 공간은 cap에서 len을 뺀 값이다.

 

남은 빈 공간의 개수가 추가하는 값의 개수보다 크거나 같은 경우 배열 뒷부분에 값을 추가한 뒤 len 값을 증가시킨다.

다음 코드를 수행했을 때 slice1slice2에서 발생하는 문제점과 원인에 대해서 알아보자.

package main

import "fmt"

func main() {
    slice1 := make(int[], 3, 5)

    slice2 := append(slice1, 4, 5)
    fmt.Println("slice1:", slice1, len(slice1), cap(slice1)) 
    // slice1: [0, 0, 0], 3, 5 
    fmt.Println("slice2:", slice2, len(slice2), cap(slice2))
    // slice2: [0, 0, 0, 4, 5], 5, 5

    slice1[1] = 100

    fmt.Println("slice1:", slice1, len(slice1), cap(slice1)) 
    // slice1: [0, 100, 0], 3, 5 
    fmt.Println("slice2:", slice2, len(slice2), cap(slice2))
    // slice2: [0, 100, 0, 4, 5], 5, 5

    slice1 := append(slice1, 500)

    fmt.Println("slice1:", slice1, len(slice1), cap(slice1)) 
    // slice1: [0, 100, 0, 500], 4, 5 
    fmt.Println("slice2:", slice2, len(slice2), cap(slice2))
    // slice2: [0, 100, 0, 500, 5], 5, 5
}

출력된 결과를 보면 알 수 있다시피 slice1에 값을 변경, 추가했을 때 slice2에도 해당 변화가 영향을 미치는 것을 확인할 수 있다.

 

이는 두 슬라이스 모두 동일한 배열의 주소를 포인터로 가지고 있기 때문이다. 즉, 다음의 그림과 같은 상황이 발생한다.

 

슬라이스가 배열의 주소를 포인터로 가지고 있고, append() 사용 시 해당 주소값을 가지고 있는 포인터가 복사된다는 것을 알고있지 못한다면 이렇듯 예기치 못한 문제에 마주칠 수 있다.

🚀append()를 사용했을 때 발생할 수 있는 문제 (2)

append() 함수가 호출되면 빈 공간이 있는지 확인한 후 빈 공간이 충분하지 않다면 통상적으로 기존 배열의 2배 크기의 새로운 배열을 마련한다. 그리고 새로운 배열에 기존 배열의 모든 요소를 복사한 뒤, 맨 뒤에 새 값을 추가한다.

 

이 과정에서 cap은 새로운 배열의 길이가 되고, len은 기존 길이에 추가한 개수만큼 더한 값이 되며, 포인터는 새로운 배열을 가리키는 슬라이스 구조체를 반환한다.

package main

import "fmt"

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

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

    slice[1] = 100

    fmt.Println("slice1:", slice1, len(slice1), cap(slice1)) 
    // slice1: [1, 100, 3], 3, 3 
    fmt.Println("slice2:", slice2, len(slice2), cap(slice2))
    // slice2: [1, 2, 3, 4, 5], 5, 6

    slice1 := append(slice1, 500)

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

append()함수로 인해 slice1slice2가 서로 다른 배열의 주소를 포인터로 가지게 됐기 때문에 slice1의 변화에도 slice2는 영향을 받지 않는다.

Slicing

슬라이싱은 배열의 일부를 집어내는 기능을 의미한다. 슬라이싱을 사용하면 그 결과로 슬라이스를 반환한다.

array[startIndex:endIndex] // 시작 인덱스 ~ 끝 인덱스 - 1

슬라이싱을 통해 반환된 슬라이스는 포인터 값으로 메모리 주소를 갖기 때문에 배열의 중간을 가리킬 수도 있다.

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

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

slice := append(slice, 500)

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

위의 코드를 그림으로 표현하면 아래의 그림과 같다. 배열을 슬라이싱을 통해 슬라이싱을 통해 가져오면 cap은 배열의 총 길이에서 시작 인덱스를 뺀 4로, len은 슬라이스에 포함된 요소의 수만큼 각각 할당된다.

시작 인덱스와 끝 인덱스 2개만 사용할 때 cap은 배열의 전체 길이에서 시작 인덱스를 뺀 값이 된다. 인덱스를 3개 사용하여 cap까지 조절이 가능하다.

array[startIndex:endIndex:maxIndex]

시작 인덱스부터 끝 인덱스 하나 전까지 추출하고 최대 인덱스까지만 배열을 사용한다는 의미다. 즉, 슬라이스의 cap 값은 최대 인덱스 - 시작인덱스가 된다. 다음 예시를 살펴보자.

slice1 := []int {1, 2, 3, 4, 5}
slice2 := slice1[1:3:4]

인덱스 1부터 2까지 값을 추출한 slice2[2, 3]이 되고, cap은 최대 인덱스가 4이므로 1이 된다.

 

이 경우 slice1[1:3:4]의 의미는 slice1이 가리키는 전체 배열 길이를 전부 사용하는 것이 아니라 인덱스 4까지만 배열을 사용하겠다는 의미가 된다.

 

즉, 배열의 두 번째부터 네 번째까지만 배열을 사용하게 된다.

Slice 복제

슬라이스가 서로 동일한 배열을 가르키기 때문에 발생하는 문제를 해결하기 위해, 두 슬라이스가 서로 다른 배열을 가르키게 만드는 방법 또한 존재한다.

 

바로 슬라이스를 복제하는 것이다. slice1이 가리키는 배열과 똑같은 배열을 복제한 뒤 slice2가 가리키게 한다면 서로 다른 배열을 가리키기 때문에 slice1의 변화에도 slice2는 영향을 받지 않을 것이다. 반대 또한 마찬가지다.

 

반복문을 사용하는 방법과 append() 함수를 사용하는 방법 또한 존재하지만, 내장 함수인 copy()를 활용하는 방법을 알아보자.

func copy(dst, src []Type) int

copy() 함수의 첫 번째 인수로 복사한 결과를 저장하는 슬라이스 변수를 넣고, 두 번째 인수로 복사 대상이 되는 슬라이스 변수를 넣는다. 반환값은 실제로 복사된 요소의 개수다.

Slice 요소 삭제

슬라이스의 중간에 위치한 요소를 삭제하는 방법에 대해서도 알아보자. 아래의 그림처럼 중간의 요소를 삭제하고 다른 요소의 위치를 옮겨야 한다고 가정해보자.

append() 함수를 사용해 위의 그림을 구현해보자.

slice = append(slice[:idx], slice[idx+1:])

굉장히 간단하다. 한 번 각 인덱스가 어떤 역할을 하는지 분석해보자.

 

slice[:idx]는 슬라이스의 처음부터 끝 요소의 바로 앞에 위치한 요소까지 집어낸 슬라이스다. 지우고자 하는 인덱스의 요소는 포함되지 않는다. 위의 그림에서 지워야 하는 값은 3이기 때문에 slice[:idx[1, 2]가 된다.

 

slice[idx+1:]idx의 하나 뒤의 값부터 끝까지 집어낸 슬라이스다. slice[idx+1:][4, 5, 6]이 될 것이다.

마지막으로 append()를 통해 [1, 2][4, 5, 6]을 이어주면 [1, 2, 4, 5, 6]이 된다.

Slice 요소 추가

슬라이스 중간에 요소를 추가하는 방법에 대해서 알아보자. 전개되는 상황은 아래의 그림과 동일하다.

append() 함수를 사용하여 위의 그림을 구현하는 코드는 아래와 같다.

slice = append(slice, 0)
copy(slice[idx+1:], slice[idx:])
slice[idx] = 100

append(slice, 0)으로 슬라이스 맨 뒤에 요소를 추가한다. 내장 함수 copy()를 사용해 슬라이스 값을 복사한다. 이때, 첫 번째 인수는 복사하는 위치, 두 번째 인수는 복사하려는 대상이다.

 

slice[idx + 1:] 즉 삽입하려는 위치 하나 다음부터 끝까지는 slice[idx:] 위치부터 복사한다. 그렇게되면 한 칸씩 밀려서 복사가 이루어지게 된다. 이후 idx의 위치에 100을 삽입한다.

Slice 정렬

int 타입의 슬라이스를 정렬하는 방법은 매우 간단하다.

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

그렇다면 구조체 타입의 슬라이스를 정렬하려면 어떻게 해야 할까? 구조체 슬라이스를 정렬하기 위해서는 Sort() 함수 사용에 필요한 Len(), Less(), Swap() 세 메소드를 구현해야 한다.

 

Golang에서의 메소드 구현 방식에 대한 자세한 설명은 다음 포스팅에서 다뤄보기로 하고 우선 코드를 살펴보자.

package main

import (
    "fmt"
    "sort"
)

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},
        {"켄", 37}, {"송하나", 17}
    }

    sort.Sort(Students(s))
    fmt.Println(s)
}

// [{"송하나", 17}, {"화랑", 31}, {"켄", 37}, {"류", 42}, {"백두산", 52}]

[]Student의 별칭 타입인 Students를 만들고 Len(), Less(), Swap() 메소드를 구현하는 것으로 sort.Interface를 사용할 수 있게 되었다.

 

Less() 메소드를 통해 Student 구조체의 Age 필드를 통해 값을 비교하도록 선언했다.

'Dev' 카테고리의 다른 글

[Go] 인터페이스  (3) 2024.11.10
[Go] 메소드  (2) 2024.11.09
그림으로 이해하는 객체 지향 설계 5원칙 [SOLID]  (2) 2024.07.20
CPU-Scheduling  (2) 2024.04.07
RabbitMQ  (0) 2024.03.31
Comments