배열(array)
과 슬라이스(slice)
Go언어에 배열과 슬라이스에 대해 알아보겠습니다.
Go언어는 많은 객체 지향 언어에서 기본으로 지원하는 list타입이 없고, 배열과 슬라이스가 존재합니다.
배열을 선언하는 법은 먼저 배열의 길이를 선언하고, 타입(type) 뒤에 초기화 할 값을 넣어줍니다.
배열 선언
array := [5]int{1,2,3,4,5}
array := […]int{1,2,3}
위의 형태로 사용합니다.
Go언어는 일반적으로 알고 있는 (C, Java와 같은) 언어들과 type declaration Syntex
가 반대입니다.
golang
의 syntex에 대한 설명은 아래 링크를 참고하시면 됩니다.
golang syntex에 대해
위의 배열 선언 코드를 보면 익숙하지 않는 연산자가 나옵니다.
:-
위의 연산자는 변수의 선언과 할당을 동시에 할때 사용하는 연산자로 Go언어에서 자주 쓰입니다.
앞으로 Go언어에 대한 코드를 볼때 자주 접하게 될겁니다.
슬라이스 선언
slice := []int
slice
는 동적 배열의 개념으로 만들어진 것으로 빠르고 효과적으로 배열의 크기를 늘리거나 줄일 수 있습니다.
append
라는 내장 함수를 이용해서 데이터를 추가 할 수 있으며, 아래 보이는 형태를 이용해서 slice
를 쉽게 잘라낼 수도 있습니다.
slice := []int{1,2,3,4}
newSlice := append(slice, 5) // newSlice는 [1,2,3,4,5]
newSlice2 := slice[1:2] // newSlice2는 [2,3]
위의 두 가지를 보면 배열과 슬라이스의 차이를 명확하게 알수 있습니다.
배열은 사이즈를 정확히 지정해야하고, 슬라이스는 사이즈를 지정할 필요가 없습니다.
즉, 사이즈를 지정하면 배열로 선언이 되고, 사이즈를 지정하지 않는다면 슬라이스로 선언이 되는 것입니다.
슬라이스(slice)의 특징
슬라이스의 특징을 알아보기 위해 슬라이스의 주소값을 출력하는 샘플코드 입니다.
package main
import (
"fmt"
)
func testArray(array [5]int) {
fmt.Printf("in testArray() func %p\n", &array)
}
func testSlice(slice []int) []int {
fmt.Printf("in testSlice() func %p\n", slice)
return append(slice, 6)
}
func main() {
array := [5]int{1, 2, 3, 4, 5}
fmt.Printf("origin ptr: %p\n", &array)
testArray(array)
// 배열을 슬라이스로 변환, 메모리 주소는 현재까지 동일함
slice := array[:]
fmt.Printf("%v, %p\n", slice, slice)
slice2 := testSlice(slice)
// 메모리가 변함. 새로 할당 한 듯
fmt.Printf("%v, %p\n", slice2, slice2)
// 이후부터는 같음
slice2 = append(slice2, 7)
fmt.Printf("%v, %p\n", slice2, slice2)
}
처음 배열을 선언하고 메모리 주소를 확인하고, 이후 슬라이스로 변환하고, 슬라이스의 데이터를 변환하면서 주소를 확인해 가는 코드입니다.
output
origin ptr: 0xc420012180
in testArray() func 0xc4200121b0
[1 2 3 4 5], 0xc420012180
in testSlice() func 0xc420012180
[1 2 3 4 5 6], 0xc420016140
[1 2 3 4 5 6 7], 0xc420016140
실행결과는 위와 같습니다.
간단히 해석하자면,
origin ptr: 0xc420012180
in testArray() func 0xc4200121b0
함수 실행시 넘긴 배열인자의 주소를 확인한 결과 주소가 변했습니다.
이 경우는 callbyvalue
로 함수를 호출하게 되어 배열를 deep copy하므로 주소가 변한 것입니다.
배열과 슬라이스의 callbyvalue
, callbyreference
에 대한 내용은 아래에서 다른 예제코드로 보도록 하겠습니다.
[1 2 3 4 5], 0xc420012180
in testSlice() func 0xc420012180
슬라이스로 변환한 결과 배열과 주소는 동일합니다.
이 경우는 결국 타입만 변경된 것입니다.
실제 주소는 같습니다. 결국 내부 데이터를 array
에서 바꾼다고 하면, slice
의 값도 변할 것입니다.
[1 2 3 4 5 6], 0xc420016140
[1 2 3 4 5 6 7], 0xc420016140
여기서 부터 재미있습니다.
testSlice(...)
함수를 호출했을 때 내부에서 append
라는 내장 함수를 호출합니다.
append
는 slice에 값을 추가할 때 사용하는 내장함수로 len
, cap
등과 함께 자주 접하게 될 것입니다.
append
함수를 호출하고 return
을 하게 되면 새로운 slice
가 반환하게 됩니다.
[1 2 3 4 5 6], 0xc420016140
보면 주소가 변경 되었다는 것을 알수 있습니다.
그런데!!, 두번째 append
호출했을 때는 주소가 그대로 인 것을 알 수 있습니다.
[1 2 3 4 5 6 7], 0xc420016140
slice
는 기본적으로 length
와 capacity
를 가지고 있는데, 이것에 대한 상세한 설명은 두개의 링크로 대신합니다.
Go Slices: usage and internals
Arrays, slices (and strings): The mechanics of ‘append’
배열(array)과 슬라이스(slice)의 차이점
- Call by value
- Call by reference
둘의 가장 큰 차이점입니다.
배열은 인자(argument)로 받을 경우 callbyvalue
로,
슬라이스는 callbyreference
로 받게 됩니다.
이 것을 눈으로 확인해보기 위해 간단한 샘플코드를 만들었습니다.
func testArray(array [1e7]int)
* 천만개의 배열을 복사
func testSlice(slice []int)
* 포인터만 복사
callbyvalue
와 callbyreferrence
를 단순비교하기 위해서는
위의 샘플코드처럼 배열과 슬라이스의 주소를 확인하기면 하면 되지만,
배열과 슬라이스를 함수에서 사용할 때 둘 사이의 차이를 쉽게 느껴보고자 아래 코드를 만들었습니다.
package main
import (
"fmt"
"time"
)
func callByValue(array [1e7]int) int64 {
return time.Now().UnixNano() / int64(time.Millisecond)
}
func callByReference(slice []int) int64 {
return time.Now().UnixNano() / int64(time.Millisecond)
}
func main() {
array := [1e7]int{}
t := time.Now().UnixNano() / int64(time.Millisecond)
t2 := callByValue(array)
fmt.Printf("call by value elapsed time : %f\n", float32(t2-t)/1000)
t = time.Now().UnixNano() / int64(time.Millisecond)
slice := array[:]
t3 := callByReference(slice)
fmt.Printf("call by reference elapsed time : %f\n", float32(t3-t)/1000)
}
output
call by value elapsed time : 0.043000
call by reference elapsed time : 0.000000
큰 배열을 인자로 넘겨야하는 경우, 배열은 전체복사가 된다는 점을 알고 있어야 합니다.
여기까지가 배열과 슬라이스에 대한 간단한 정리였습니다.