사람들이 'Go가 쉽다'고 자주 이야기해서 난 진짜 Go가 쉽고 간단한 언어인 줄 알았는데 그것은 모두 사실이 아니었다.
'이렇게까지 생각하면서 코딩해야 된다고???' 싶은 개념들이 슬라이스 파트부터 나오기 시작했다.
슬라이스(Slice)는 배열과 유사한 자료 구조다.
배열(Array)이 고정된 길이를 가지는 것에 비해 가변 길이를 가지며 동적으로 크기가 조정될 수 있다.
슬라이스는 기본적으로 배열의 참조를 나타낸다.
그래서 배열의 길이를 조정할 땐 복사가 발생하지만 슬라이스의 길이를 조정할 땐 복사가 발생하지 않는다.
이는 슬라이스가 내부적으로 포인터, 길이, 용량을 가지고 있기 때문이다.
var array [10]int <- int값 10개를 저장하는 배열
var slice []int <- 슬라이스 선언 시에는 [] 안에 배열의 개수를 적지 않는다.
그런데 이렇게 선언하면 초기화가 안돼서 슬라이스 길이가 0이 되고
슬라이스 길이를 초과해서 접근하면 런타임 에러가 발생한다.
따라서
var slice1 = []int{1,2,3}
var slice2 = []int{1, 5:2, 10:3}
이런 식으로 초기화를 해 줘야 한다.
var slice = make([]int, 3) <- 요소 3개, 기본값은 0.
고로 [0 0 0] 이렇게 됨.
var slice = make([]int, 3)
slice[1] = 5
이렇게 하면 된다.
그러면 [0 0 0]이 [0 1 0] 이렇게 된다.
var slice = []int{1,2,3}
for i := 0; i < len(slice)l i++ {
slice[i] += 10
} <- c스타일 for문을 쓰거나
for i, v := range slice {
slice[i] = v * 2
} <- for range문을 쓴다.
var slice = []int{1,2,3}
slice2 := append(slice, 4)
slice는 [1 2 3]
slice2는 [1 2 3 4]
지금 이 경우에는 slice의 용량이 3이다.
그래서 slice2를 만들기 위해 새로운 배열을 만들고 둘은 서로 다른 배열을 가리킨다.
그래서 한쪽에서 값이 바뀌어도 상대편에 영향이 가지 않는다.
그런데 만약 slice의 용량이 4다.
그래서 slice2를 만들 때 새로운 배열을 만들 필요가 없게 된다.
그러면 slice와 slice2가 같은 배열을 가리키게 된다.
그러면 한 쪽의 값이 바뀔 때 다른 쪽에도 영향이 간다!
(이 부분부터 어려워지기 시작했다...)
slice = append(slice, 3, 4, 5, 6, 7)
위의 slice가 [1 2 3]이라면
지금 슬라이스는 [1 2 3 3 4 5 6 7] 이렇게 될 것이다.
핵심은
포인터에 있다.
고랭에서 모든 값의 대입은 복사로 일어난다.
배열에 새로운 값을 더하면 새 배열이 만들어지고 새 값이 더해진다.
그래서 처음 배열과 뒤의 배열은 서로 다른 배열이다.
그런데 슬라이스는 내부적으로 포인터를 사용한다.
그래서 (슬라이스의 길이에 따라 다르지만) 슬라이스에 새로운 값을 더할 때 처음 슬라이스와 뒤의 슬라이스는 같은 배열을 가리키는 경우가 있다.
요소가 3개, 용량은 5인 slice1이 있다고 가정하자.
[1 2 3] <- 잉여 용량은 보이지 않는다. 0으로 초기화되지 않는다. 요소랑 용량은 다르다.
slice2 := append(slice1, 4, 5)
를 하면 어떻게 될까.
slice1의 용량이 3이면 용량이 5짜리 새로운 슬라이스가 만들어질 것이다.
그런데 지금 여유 공간이 있다.
그래서 slice1와 slice2는 같은 배열을 가리키게 된다.
그래서 slice[1] = 100
이라고 했다 치면
slice1와 slice2의 index1값이 모두 100으로 변한다.
만약 여기서 slice1 = append(slice1, 500)을 하면 어떻게 될까?
slice1는 현재 [1 100 3]이다.
그래서 공간이 2개 남는다.
따라서 [1 100 3 500]이 된다.
slice2는 [1 100 3 500 5]다.
정신 없지 않은가?
빈 공간이 없을 때 값을 추가하면 어떻게 되는가?
위에 언급한 바 있는데 좀 더 자세히 얘기하자면
slice1 := []int{1,2,3}
slice2 := append(slice1, 4,5}
이때 slice1의 용량은 3이다.
그래서 슬라이스를 복사해야 한다.
이때 복사되는 용량은 기존 슬라이스의 용량이다.
그래서 총 용량 6의 슬라이스가 만들어지고 새 값이 들어온다.
그러면 slice2는 길이 5, 용량6의 슬라이스가 된다.
[1 2 3 4 5]인데 용량은 6.
슬라이스의 용량은 일반적으로 현재 용량의 2배로 증가하지만, 이는 구현에 따라 달라질 수 있다고 한다.
추가하는 요소의 수에 따라 더 큰 용량의 증가가 발생할 수도 있다고 한다.
따라서, append 연산 후 슬라이스의 용량은 예측하기 어려울 수 있으며, 새 요소를 추가한 후 실제로 얼마나 증가했는지 확인하려면 cap() 함수를 사용해야 한다고 한다.
나머지는 다른 언어에서도 많이 쓰이는 일반적인 슬라이싱에 대한 내용이다.
array := [5]int{1,2,3,4,5}
slice := array[1:2]
slice1 := []int{1,2,3,4,5}
slice2 := slice1[1:2]
처음부터 슬라이싱하면
slice2 := slice1[0:3]
slice2 := slice1[:3]
끝까지 슬라이싱하면
slice1 := []int{1,2,3,4,5}
slice2 := slice1[2:len(slice1)]
전체 슬라이싱은
array := [5]int{1,2,3,4,5}
slice := array[:]
인덱스 3개로 cap(용량) 크기 조절하려면
slice1 := []int{1,2,3,4,5}
slice2 := slice1[1:3:4]
여기서 맨 뒤의 용량 조절 부분이 좀 이해하기 까다롭지만
slice1[1:3]은 slice1의 인덱스 1과 2에 해당하는 요소를 포함하므로, slice2의 값은 [2, 3]이 된다.
slice1[1:3:4]에서 추가된 :4는 slice2의 용량을 인덱스 4까지로 제한한다. 이는 slice1의 시작 인덱스 1부터 용량 상한 인덱스 4까지를 의미하므로, slice2의 용량은 3이 된다(slice1의 인덱스 1, 2, 3을 포함할 수 있는 공간).
결론적으로, slice2의 값은 [2, 3]이 되며, 길이는 2, 용량은 3이 된다.
정도로 이해하면 될 것 같다.
직관적으로 이해하기 어렵게 되어 있는 것 같다...
슬라이스 복제시
append() 함수로 코드 개선하기,
copy() 함수로 코드 개선하기,
요소 삭제 시
append() 함수로 코드 개선하기
요소 추가 시
append() 함수로 코드 개선하기
불필요한 메모리 사용이 없도록 코드 개선하기
이 부분은 책을 통해서 꼼꼼하게 확인하는 것을 권장한다.
여기에서 '글'로 간단하게 설명할 수 없다.
마지막 슬라이스 정렬 부분도
여기에서 하나 하나 설명하는 것보다 내가 궁금했던 내용을 적는 것으로 마무리하겠다.
이 부분도 제대로 설명하자면 글 하나를 통째로 할애해야 한다.
고랭의 sort 패키지에 정의된 Interface 타입을 구현하기 위해서다!
이 인터페이스는 정렬 알고리즘이 슬라이스의 길이를 얻고, 요소 간에 순서를 비교하며, 요소의 위치를 바꾸는데 사용된다.
sort.Interface는 다음과 같다.
type Interface interface {
Len() int
Less(i, j int) bool
Swap(i, j int)
}
이대로는 쓸 수가 없다.
내가 내용을 구현해야 한다!
Len 메서드는 슬라이스의 길이를 반환한다. 정렬 알고리즘은 전체 슬라이스의 범위를 알아야 하므로 이 메서드가 필요하다.
Less 메서드는 두 요소의 인덱스 i와 j를 받아서, i번째 요소가 j번째 요소보다 "작은지" 여부를 판단한다. 이 메서드는 정렬의 기준이 되며, 슬라이스를 특정 순서대로 정렬하는 데 사용된다.
Swap 메서드는 두 요소의 인덱스 i와 j를 받아서, 해당 요소들의 위치를 슬라이스 내에서 바꾼다. 정렬 과정에서 요소의 순서를 변경할 필요가 있을 때 이 메서드를 사용한다.
내부적으로 사용된다!!!
실제로 sort.Sort 함수를 호출할 때 이 세 메서드를 직접 호출하는 것처럼 보이지 않을 수 있다. 이는 sort.Sort 함수가 sort.Interface를 구현한 타입의 인스턴스를 매개변수로 받고, 이 인터페이스의 메서드를 내부적으로 호출하기 때문이다. 사용자는 이 인터페이스를 만족시키는 타입을 정의하고, 해당 타입의 인스턴스를 sort.Sort에 전달하기만 하면 된다. sort 패키지는 제공된 인스턴스의 Len, Less, Swap 메서드를 호출하여 정렬을 수행한다.
이외에도 sort 패키지는 기본 자료형을 위한 여러 편의 함수를 제공한다. 예를 들어, sort.Ints, sort.Float64s, sort.Strings 등은 각각 정수, 실수, 문자열 슬라이스를 정렬하기 위한 함수다. 이러한 함수들을 사용할 때는 Len, Less, Swap 메서드를 직접 구현할 필요가 없으며, 함수 호출만으로 슬라이스를 쉽게 정렬할 수 있다.
YES!
고랭의 sort.Interface를 사용할 때, Len(), Less(i, j int) bool, Swap(i, j int) 이 세 메서드를 구현함으로써 슬라이스의 정렬 방식을 사용자 정의할 수 있다. 이를 통해 다양한 기준에 따른 정렬 로직을 구현할 수 있다.
예를 들어, 내가 사용자 정의 구조체 슬라이스를 가지고 있고, 이를 특정 필드의 값에 따라 정렬하고 싶다면, 해당 구조체에 대한 Len, Less, Swap 메서드를 구현하여 정렬 기준을 정의할 수 있다.
type CustomType struct {
// 사용자 정의 필드
}
type ByCustomField []CustomType
func (a ByCustomField) Len() int { return len(a) }
func (a ByCustomField) Less(i, j int) bool { return a[i].SomeField < a[j].SomeField } // SomeField에 따라 정렬
func (a ByCustomField) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
이렇게 sort.Interface를 구현한 후에는 sort.Sort() 함수를 사용하여 해당 슬라이스를 정렬한다.
var mySlice ByCustomField = // 슬라이스 초기화
sort.Sort(mySlice)
이 방식을 통해, 슬라이스 내 요소의 특정 필드, 내림차순 정렬, 복합 조건 정렬 등 다양한 사용자 정의 정렬 로직을 구현할 수 있다.
묘공단 - Tucker의 Go 언어 프로그래밍 - 슬라이스편은 여기서 끝!