반응형
Recent Posts
Recent Comments
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Archives
Today
Total
관리 메뉴

Bbaktaeho

[Go/range] range 제대로 이해하기 본문

프로그래밍 (Programming)/고랭 (Golang)

[Go/range] range 제대로 이해하기

Bbaktaeho 2024. 2. 11. 22:16
반응형

들어가며

현재 Go 언어에서 range에 관련해서 새로운 변화가 생기고 있습니다. 새로운 Go 버전에서 변화된 range를 받아들이기 전에 기존 range에 대해서 자세히 파악할 필요가 있어 이번 기회에 정리하려고 합니다.

이 글은 Go 100가지 실수 패턴과 솔루션 도서를 참고해서 작성되었으며 자세한 내용은 도서를 확인해 주세요.

 

range

Go 언어에서 반복하는 로직을 작성하기 위해서 for 키워드만 존재합니다.

보통은 range 키워드와 같이 사용하게 되는데 range가 값을 대입하는 과정을 모르고 사용하다가 실수가 발생하곤 합니다. 어떤 실수들이 존재하는지 예시를 통해 확인해보겠습니다.

 

개념

range는 index나 종료 조건을 다룰 필요가 없어서 반복하는 로직을 작성하는데 편리합니다. 또한 range의 특징은 루프에서 인수 평가를 단 한 번만 평가됩니다.

range를 통해 반복 가능한 타입은 아래와 같습니다:

  • string
  • array, *array
  • slice
  • map
  • chan

여기서 chan 타입만 사용 방법이 다른데 range를 통해서 index 및 key를 사용하지 못하고 값 하나만 생성합니다.

// string, array, slice, map
for i, v := range data { ... }

// chan
for v := range channel { ... }

 

일반 for 루프와 range 루프 비교

비교해 볼 로직은 루프 대상이 되는 slice에 원소를 하나씩 추가하는 로직입니다.

data := []int{1, 2, 3}
for i := 0; i < len(data); i++ {
	data = append(data, 5)
}

일반적인 for 루프로 작성된 코드의 결과를 보자면

i = 0 일 때
i = 1 일 때
i = 2 일 때
i = 3 일 때
i = 4 일 때

len(data)가 매번 반복마다 평가되어 i 가 증가하는 모습을 볼 수 있습니다. 즉, 무한 반복이 된다는 것을 확인할 수 있습니다.
그러나 range 루프는 이와 다르게 동작합니다. range 루프는 단 한 번만 평가됩니다.

data := []int{1, 2, 3}
for i := range data {
	_ = i
	data = append(data, 5)
}

디버깅에서 반복 횟수를 확인하기 위해 i 변수를 초기화했습니다.

코드 결과를 확인하면

i = 0 일 때
i = 1 일 때
i = 2 일 때
종료

i 가 2 까지 증가하고 for 루프를 종료하는 것을 확인할 수 있습니다.

그 이유는 range 루프를 사용할 때 루프 시작 전 단 한 번 평가가 되어 임시 변수에 복제된 뒤 그 임시 변수를 바라보며 루프가 실행되고 있다는 것입니다.

range를 통해 새로운 복제본 생성

따라서 data에 append를 수행해도 range로 인해 새로 생긴 복제본은 변경되지 않으며 단 한 번 평가되므로 매번 반복마다 복제본을 만들지 않습니다.

 

값 복제에 대해 이해하기

앞서 range는 단 한 번 평가되며 값을 복제해 두고 그 복제된 값을 바라보며 반복한다고 했습니다.

이러한 내용을 정확하게 이해하고 있다면 아래와 같은 실수는 없을 것입니다.

type Account struct {
	address string
	balance uint64
}

accounts := []Account{
	{address: "0xa", balance: 1},
	{address: "0xb", balance: 2},
	{address: "0xc", balance: 3},
}

for _, account := range accounts {
	account.balance += 10
}

위 코드가 실행되면 모든 account의 balance는 변경되지 않을 것입니다.

for range 루프에서 account 변수는 값 복사가 된 임시 변수일뿐입니다.

디버깅을 통해 accounts의 원소들을 봐도, 현재 마지막 원소까지 반복하고 있을 때 0xa, 0xb의 balance는 변경되지 않았습니다.

 

본래 accounts를 변경하려면 index로 접근하는 방법이 있습니다.

for i := range accounts {
	accounts[i].balance += 10
}

 

또한 슬라이스 포인터로 초기화한다면 인덱스 접근 없이 가능합니다.

accounts := []*Account{
	{address: "0xa", balance: 1},
	{address: "0xb", balance: 2},
	{address: "0xc", balance: 3},
}

for _, account := range accounts {
	account.balance += 10
}

하지만 슬라이스 포인터에 대해 반복하면 CPU 연산 효율이 떨어집니다. 이유는 다음 항목을 미리 예측할 수 없기 때문이라고 합니다.

 

문자열에서 range

문자열에 대한 range 루프는 rune의 시작 인덱스와 룬 자체에 대한 변수 두개를 티런합니다.

여기서 rune은 Go 언어의 타입 중 하나로 int32의 alias 타입입니다. 쉽게 말해서 UTF-8(1~4byte)을 표현하는 문자 타입으로 보시면 됩니다.

아래 예제를 통해서 알아보겠습니다.

s := "bbaktaeho"
for i, c := range s {
	fmt.Printf("i:%d, c:%d, %c\n", i, c, c)
}
i:0, c:98,  b
i:1, c:98,  b
i:2, c:97,  a
i:3, c:107, k
i:4, c:116, t
i:5, c:97,  a
i:6, c:101, e
i:7, c:104, h
i:8, c:111, o

마치 문자의 배열처럼 인덱스와 배열에 해당하는 문자가 출력되는 것으로 이해할 수 있는데 이는 잘못 이해한 것입니다.

여기서 인덱스를 의미하는 i는 rune의 시작 인덱스라고 한 내용을 정확히 이해해야 합니다.

한글 예제로 다시 살펴보겠습니다.

s := "빡태호"

 변수 s를 한글 문자열로 변경 후 실행하면

i:0, c:48737, 빡
i:3, c:53468, 태
i:6, c:54840, 호

i의 값이 3씩 증가되어 출력된 모습을 볼 수 있습니다.

좀 더 코드를 수정해보겠습니다.

s := "빡태호"
for i, c := range s {
	fmt.Printf("i:%d, s[i]:%c, c:%d, %c\n", i, s[i], c, c)
}
i:0, s[i]:ë, c:48737, 빡
i:3, s[i]:í, c:53468, 태
i:6, s[i]:í, c:54840, 호

s를 마치 배열로 인식해서 index로 확인해보면 예상한 값을 얻을 수가 없을 것입니다.

이러한 이유는 문자열이 바이트로 구성된 슬라이스이기 때문입니다.

따라서 s[i]는 단일 바이트 값이 출력될 것이며 그 바이트를 문자로 표현한 것으로 이해하면 됩니다.

다시 위 코드를 봤을 때 문자열의 range로 얻을 수 있는 인덱스는 rune의 시작 인덱스임을 이해하실 수 있을 겁니다.

자세한 내용은 rune을 찾아보세요!

 

배열과 포인터 배열에서 range

배열도 마찬가지로 값 복사가 이뤄지면 또 다른 메모리 공간에 새로운 배열이 초기화됩니다.

이번에도 복사된 배열이 맞는지 코드를 통해 확인해보겠습니다.

data := [3]int{1, 2, 3}
for i, v := range data {
	if len(data) != i {
		data[i+1] = 5
	}
	_ = v
}

i 가 0 일 때 data[1] 값을 5 로 변경시켰습니다.

data 배열이 복제되어 있기 때문에 v도 기존 data가 복제된 원소를 가리키고 있어서 5를 가리키지 않습니다.

 

하지만 배열을 포인터 배열로 range 루프를 동작시킨다면 본래의 배열을 가리키기 때문에 v 역시 달라질 것입니다.

data := [3]int{1, 2, 3}
for i, v := range &data {
	if len(data) != i {
		data[i+1] = 5
	}
	_ = v
}

역시 i가 0 일 때 data[1] 값을 5 로 변경시켰습니다.

v 값이 변화된 data[1] 에 해당하는 값과 동일하다는 것을 볼 수 있었습니다.

 

맵에서 range

맵을 range로 반복할 때는 꼭 알아야 할 특성이 있습니다.

  • 데이터가 키에 대해 정렬되지 않음
  • 추가한 순서가 유지되지 않음
  • 반복 순서가 일정하지 않음
  • 반복 중 추가된 원소는 추가될 수도 있고 건너뛸 수도 있음

예시를 통해 확인해보겠습니다.

data := map[string]int{
	"a": 1,
	"b": 2,
	"c": 3,
	"d": 4,
	"e": 5,
}

var i int
for k, v := range data {
	_ = i
	_, _ = k, v
	i++
}

반복 순서를 확인하기 위해 i 변수를 임의로 생성했습니다.

첫 순서(i=0) 부터 map의 c 가 먼저 평가되었습니다.

다시 실행해서 첫 순서로 어떤 key, value가 평가되는지 확인해보겠습니다.

이번에는 첫 순서로 a 가 먼저 평가되었습니다.

몇 번이고 디버깅해 봐도 순서는 보장되지 않는 것을 확인해 볼 수 있었습니다.

 

반복하는 과정에서 map에 업데이트하는 과정을 추가했을 때도 몇 가지 주의할 점이 있습니다.

func forloop() {
	data := map[int]bool{
		0: true,
		1: false,
		2: true,
	}

	for k, v := range data {
		if v {
			data[10+k] = true
		}
	}

	fmt.Println(data)
}

func Test_forloop(t *testing.T) {
	for i := 0; i < 10; i++ {
		forloop()
	}
}

위 코드는 map을 반복 중에 업데이트하는 로직을 10번 반복하는 코드입니다.

실행 결과는 다음과 같습니다

map[0:true 1:false 2:true 10:true 12:true 20:true 22:true 30:true 32:true 40:true]
map[0:true 1:false 2:true 10:true 12:true 20:true 22:true 30:true 32:true 40:true]
map[0:true 1:false 2:true 10:true 12:true 20:true 22:true]
map[0:true 1:false 2:true 10:true 12:true 22:true 32:true 42:true 52:true 62:true]
map[0:true 1:false 2:true 10:true 12:true 20:true 22:true 30:true]
map[0:true 1:false 2:true 10:true 12:true 20:true 22:true 30:true 32:true]
map[0:true 1:false 2:true 10:true 12:true 20:true 22:true]
map[0:true 1:false 2:true 10:true 12:true 20:true 22:true 30:true 32:true 40:true]
map[0:true 1:false 2:true 10:true 12:true 20:true 22:true 30:true 32:true]
map[0:true 1:false 2:true 10:true 12:true 20:true]

결과를 보면 전혀 예측할 수 없을 것입니다.

이러한 결과를 보이는 이유는 루프를 도는 동안 맵 항목이 생성되었다면 추가될 수도 있고 건너뛸 수도 있는 규정 때문입니다.

올바른 실행 결과로 나타내려면 복제된 맵을 사용 해야 합니다.

 

range 에서 포인터 원소 사용

필자도 초기 Go 언어 사용할 때 자주 실수한 케이스입니다.

새로운 버전에서는 해당 케이스가 사라진다고 하니 다행이지만, 버전에 따라 다르게 동작하게 된다는 것이므로 주의가 필요한 부분입니다. 따라서 정확히 이해하고 넘어가야 합니다.

예제를 통해 확인해보겠습니다.

data := []int{1, 2, 3}
newData := [3]*int{}
for i, v := range data {
	newData[i] = &v
}

for _, v := range newData {
	fmt.Println(*v)
}
3
3
3

위 코드를 실행했을 때 출력 결과를 보니 data의 마지막 원소인 3 으로만 초기화되어 있습니다.

반복할 때마다 포인터 주소를 찍어보면 원인을 알 수 있습니다.

data := []int{1, 2, 3}
newData := [3]*int{}
for i, v := range data {
	fmt.Printf("%p\\n", &v)
	newData[i] = &v
}

for _, v := range newData {
	fmt.Println(*v)
}
0x1400000e2d8
0x1400000e2d8
0x1400000e2d8
3
3
3

동일한 포인터를 바라보고 있다는 것을 확인할 수 있습니다.

이러한 이유는 v 변수가 고정된 주소를 담고 있기 때문에 포인터가 항상 일정하기 때문입니다.

v는 고정된 변수

위와 같은 문제를 해결하려면 포인터 원소로 된 슬라이스를 range 반복하거나, 반복문 안에 임의의 변수를 초기화시켜서 포인터를 쓸 수 있게 해야 하는 번거로움이 있습니다.

data := []int{1, 2, 3}
newData := [3]*int{}
for i, v := range data {
	newV := v
	fmt.Printf("%p\\n", &newV)
	newData[i] = &newV
}

for _, v := range newData {
	fmt.Println(*v)
}
0x1400009c018
0x1400009c030
0x1400009c038
1
2
3

원하는 결과로 출력되는 모습을 볼 수 있습니다.

하지만 이러한 문제점은 알고 있어도 간간히 실수하기 마련입니다.

Go 언어의 v1.22 버전에서 이와 같은 케이스를 해결할 것으로 보고 있습니다.

 

GOEXPERIMENT 환경 변수를 통해 v1.21 버전에서도 미리 확인해 볼 수 있습니다.

// main.go
func main() {
	data := []int{1, 2, 3}
	newData := [3]*int{}
	for i, v := range data {
		fmt.Printf("%p\\n", &v)
		newData[i] = &v
	}

	for _, v := range newData {
		fmt.Println(*v)
	}
}
GOEXPERIMENT=loopvar go run main.go

Go 언어를 실행할 때 GOEXPERIMENT 환경 변수가 loopvar로 초기화되어 있다면 위와 같은 문제가 발생하지 않습니다.

0x1400000e0f8
0x1400000e110
0x1400000e118
1
2
3

https://github.com/golang/go/blob/4a7f3ac8eb4381ea62caa1741eeeec28363245b4/src/internal/goexperiment/flags.go#L57 코드를 확인하면 다양한 flag를 확인해볼 수 있습니다.

 

결론

  • range 루프에서 값 원소는 복제본임
  • range 는 단 한 번만 평가됨
  • range 루프에서 map 사용 시 순서 보장하지 않음
  • range 루프에서 동일한 map 수정 과정은 피하자
  • range를 통해 복제한 변수를 포인터로 다루지 않도록 주의
  • Go 버전에 따라 추가 및 달라지는 range를 확인하자

 

References

  • Go 100가지 실수 패턴과 솔루션
반응형