[go] Slice는 어떻게 동작하는가?

Posted by Start Bootstrap on September 28, 2019

go, golang, slice,array


1. 왜.

  • 작업하다 slice를 명확하게 이해하지 못하고 business logic을 잘 못짠 경험이 있다. slice를 잘 이해해보자.



2. 그래서 무엇인가.

GODOC

“Arrays have their place, but they’re a bit inflexible, so you don’t see them too often in Go code. Slices, though, are everywhere. They build on arrays to provide great power and convenience.”

  • 일반적으로 역동적인 array구조를 만들기 위해서 go에서 기본적으로 제공해주는 reference type의 data structure이다.
  • 자바의 arraylist와 같이 자유롭게 데이터를 추가하고 빼고 하는 등의 기능이 가능하다. reference type logic을 짤때 slice의 상태를 염두하고 짜야한다.
  • slice를 짤때 이해해야될 몇 가지에대해 알아보자


3. Slice make

  • slice는 make라는 constructure 역할을하는 init함수로 그 사이즈가 결정된다.

  • make는 다양한 collection에서 쓰이는데 slice에서 len, capacity 로 초기화된다.

    • len : 초기값을 설정해줄 index (len의 사이즈 만큼 zero value로 채워준다.)
    • capacity : 최초 확장가능한 max 값 (capa를 적어주지 않으면 len과 같은 사이즈로 잡힌다)
//lendl 1이이고 cap이 5인 슬라이스를 만든다. 

data := make([]string,1,5) 

fmt.Printf("data len:%v cap:%v %v\n", len(data), cap(data),data)
  • 분명 자유롭게 추가 수정이 가능하다고 이야기했지만, 내가 해당 배열의 capacity를 알경우는 미리 capacity를 설정해주면 보다 효율적으로 memory를 쓸 수 있겠다.


3.1 Nil Slice

  • 일반적으로 var clice []int 와 같이 사용되는 slice를 nil slice라고 말한다.

  • 이유는 아래처럼 nil인 상태기때문이다

  • nil slice는 len:0 capa:0 인상태이고, 주소값이 0x0으로 되어있다.

  • append할경우 init이 되며 메모리에 실제 값이 할당된다.

  • 실제 특정 structrue에 append작업이 들어가지 않은 nil slice를 넣게되면 nil값으로 그 값이 할당된다.

  • 그러나 make([]string,0)로 초기화게되면 [] 비배열이 할당된다.

var s []int
if  s== nil    //=> 이값은 true이다.


3.2 Slice append

  • append는 slice에 값을 추가한다.

    data := make([]string,1,3) data = append(data,”test1”)

  • append가 확장 될때는 capa안에서는 같은 주소값으로 지속적으로 추가된다.

  • capa를 넘어가면 어떻게될까

data := make([]string,0,3)
fmt.Printf("data addr:%p len:%v cap:%v %v\n", data, len(data), cap(data),data)
data = append(data,"test1")
data = append(data,"test1")
data = append(data,"test1")
fmt.Printf("data addr:%p len:%v cap:%v %v\n", data, len(data), cap(data),data)
data = append(data,"test1")
fmt.Printf("data addr:%p len:%v cap:%v %v\n", data, len(data), cap(data),data)
////////
//output
data addr:0x446280 len:0 cap:3 []
data addr:0x446280 len:3 cap:3 [test1 test1 test1]
data addr:0x43c0c0 len:4 cap:6 [test1 test1 test1 test1]
  • 위의 output처럼 두 가지 변화가 생긴다.
    1. capa가 2배로로 커지고
    2. addr가 바뀌었다. 즉 새로운 메모리영역에 할당받았다
  • 위와 같이 동작하기 위해 실제 내부에서는 capa 2배짜리 slice 를 만들고 기존에 있던 정보를 복사해주는 작업을 하게 된다.


3.3 Slice Detail

  • Slice는 실제 어떻게 생겼을까
  • Slice는 단순 껍데기에 불과하고 내부적으로 숨겨진 underlying array에 정보가 저장된다.
  • Slice structure는 ptr, len, capa로 이루어져있다.
  • 실제 첫번째 ptr가 underlhying array에 주소값을 가지고있다. 그래서 slice의 주소와 slice배열의 첫번째값의 주소를 보면 그 결과가 같음을 확인 할 수 있다.
data := make([]string,0,3)
fmt.Printf("data addr:%p len:%v cap:%v %v\n", data, len(data), cap(data),data)
data = append(data,"test1")
fmt.Printf("data[0] addr:%p \n",&data[0])



4. 내가 겪은 문제를 이해할 수있는 example로 나타내보자..

  • slice가 reference type인것을 제대로 이해하지못하면 아래와 같은 경우 헷갈릴 수 있다.(나처럼..)
  • 예제가 복잡하니 볼사람만 보자..
type Timi struct {
ti []string
}
func main() {
var a []string
var timi []Timi
a = a[:0]
fmt.Printf("a adrr:%p \n", a)
fmt.Printf("a len:%v cap:%v %v\n", len(a), cap(a),a)
a = append(a, "hi")
fmt.Printf("a adrr:%p len:%v cap:%v %v\n", a, len(a), cap(a),a)
timi = append(timi, Timi{ti: a})
fmt.Printf("timi.ti:%v addr:%p \n", timi,timi)
a = a[:0]
// !@ 참고: 놀랍게도 a를 0인 배열로 초기화 시킨것 같지만 사실 아래를 보면 실제 값이 남아있다.
// 즉 a[:0]은 실제 내부 배열을 초기화한것이 아니라 그냥 length를 0으로 바꾼것일 뿐 나머지 값들은 존재하게 되는것이다. 
// 하여 [:1]을 이용해 앞에서부터 1번째까지 범위를 잡고 찍게되면 결과가 출력된다.
// 그래서 여기에는 안나오지만 a=a[:1]등을 실행하게되면 다시 값들이 보이기시작한다. 
fmt.Println(a[:1]) // [hi]
fmt.Printf("a adrr:%p len:%v cap:%v %v\n", a, len(a), cap(a),a)
a = append(a, "nic1")
fmt.Printf("a adrr:%p len:%v cap:%v %v\n", a, len(a), cap(a),a)
fmt.Printf("timi.ti adrr:%p %v \n", timi,timi)
a = append(a, "nice2")
fmt.Printf("a adrr:%p len:%v cap:%v  %v\n", a, len(a), cap(a),a)
timi = append(timi, Timi{ti: a})
fmt.Printf("timi.ti adrr:%p %v \n", timi,timi)
}
//////////////
// ouput
a adrr:0x0
a len:0 cap:0 []
a adrr:0x40c140 len:1 cap:1 [hi]
timi.ti:[{[hi]}] addr:0x40a100 
[hi]
a adrr:0x40c140 len:0 cap:1 []
a adrr:0x40c140 len:1 cap:1 [nic1]
timi.ti adrr:0x40a100 [{[nic1]}] 
a adrr:0x40a1c0 len:2 cap:2  [nic1 nice2]
timi.ti adrr:0x43e280 [{[nic1]} {[nic1 nice2]}]
  • 지금 보면 마지막 결과가 당연하지만..
  • 실제 로직을 짤때 timi.ti의 첫번째 값이 {[ hi ]}가 들어가야한다고 생각했고 그렇게 작동하기를 원했다.
  • 그 이유는 reference type임을 명확히 인지하지 못하고 Timi{ti: a} 식으로 추가되면 해당 a의 값이 structure를 만드니까! value값으로 박제(?), 사진(?) 찍듯이 들어갈것처럼 느껴졌기 때문이다.(문제는 이 느낌이다.. 그냥 될거같아.. 아무 이유없이..)
  • 원하던 대로 {[hi]}가 들어가도록 하려면 현재 값을 초기화하는 것 처럼 보이는 [:0]의 코드를 make로 바꾸어 새로이 할당하는 방식으로 작동하게 하여야한다.
  • 그렇게 하면 매번 새로운 slice에 값이 들어갈 것임으로 후에 append되는 것에 영향을 받지 않게된다.
// 화살표 오른쪽과 같이 수정되어야한다.

a = a[:0] => a = make(0,0) 



4.마치며

  • 최초 이 글을 쓰게된 에러를 발생한 경우가 실은 이와 관련없는 다른 질문을 직속 선배에게 물어보다가.. 우연찮게 얻어걸렸는데 위와 같이 레퍼런스를 이용한 코드였다. 잘못된 데이터가 지속적으로 들어갈 치명적인 결함이 될 수 있었다.

  • 원인에는 전형적인 필자의 문제가 있는데.. 코드의 동작 결과를 눈여겨 확인하지 않는데 그 이유가 있다.. 즉 너무나도 잘 동작할 것 같아 대략적으로 결과를 스쳐지나가듯 본것이다.

  • 동작하는 최소한의 bestcase만 눈여겨 확인했어도 위와 같은 오작동 코드는 나오지 않았으리라..

  • 두 가지 Action item이 있어야 겠는데

    1. 코드 짤때 마음 한켠에.. 안일한 마음이 있는데.. production 레벨의 코드를 짠다는 방어적(?) 마음 가짐을 가지자.
    2. 동작 확인 가능한 testcase의 최소한의 필수 시나리오를 항시 돌려보아야 겠다.
  • 누가 그랬다.

    잘 짠 코드는 프로그램의 high performance를 보장하는 것처럼 보이지만, 실제로는 기대하는 input에 기대하는 output이 나오는 코드라는 것.


Ref