Skip to content

이터레이터

Go 에서 특정 데이터 구조를 반복하는 데 사용되는 키워드는 for range 입니다. 이전 장에서 이미 몇 가지 사용법을 소개했는데, 이는 내장된 몇 가지 데이터 구조에만 적용할 수 있습니다.

  • 배열
  • 슬라이스
  • 문자열
  • map
  • chan
  • 정수 값

이렇게 사용하면 매우 유연하지 않고 확장성이 없으며 사용자 정의 타입에는 거의 지원되지 않습니다. 하지만 Go1.23 버전 업데이트 이후 for range 키워드가 range over func 스타일을 지원하게 되어 사용자 정의 이터레이터가 가능해졌습니다.

이해

아래 예제를 통해 이터레이터를初步적으로 이해해 보겠습니다. 함수 섹션에서 소개한 클로저로 피보나치 수열을 구하는 예제를 기억하시나요? 구현 코드는 다음과 같습니다.

go
func Fibonacci(n int) func() (int, bool) {
  a, b, c := 1, 1, 2
  i := 0
  return func() (int, bool) {
    if i >= n {
      return 0, false
    } else if i < 2 {
      f := i
      i++
      return f, true
    }

    a, b = b, c
    c = a + b
    i++

    return a, true
  }
}

이를 이터레이터로 개선할 수 있습니다. 아래 코드에서 코드량이 줄어든 것을 볼 수 있습니다.

go
func Fibonacci(n int) func(yield func(int) bool) {
  a, b, c := 0, 1, 1
  return func(yield func(int) bool) {
    for range n {
      if !yield(a) {
        return
      }
      a, b = b, c
      c = a + b
    }
  }
}

Go 의 이터레이터는 range over func 스타일로, for range 키워드를 직접 사용하여 더 편리하게 사용할 수 있습니다.

go
func main() {
    n := 8
  for f := range Fibonacci(n) {
    fmt.Println(f)
  }
}

출력

0
1
1
2
3
5
8
13

위에서 보듯 이터레이터는 클로저 함수이며, 콜백 함수를 매개변수로 받습니다. 심지어 yield 와 같은 키워드도 볼 수 있는데, Python 을写过인 분들은 익숙할 것입니다. 이는 Python 의 제너레이터와 유사합니다. Go 의 이터레이터는 새로운 키워드나 문법 기능을 추가하지 않았습니다. 위 예제에서 yield 는 단지 콜백 함수일 뿐 키워드가 아닙니다. 공식에서 이 이름을 붙인 것은 이해를 돕기 위함입니다.

푸시형 이터레이터

이터레이터의 정의는 iter 패키지에서 다음과 같이 찾을 수 있습니다.

An iterator is a function that passes successive elements of a sequence to a callback function, conventionally named yield.

이터레이터는 시퀀스의 연속된 요소를 콜백 함수 (일반적으로 yield 라고 함) 에 전달하는 함수입니다.

여기서 알 수 있는 점은 이터레이터는 콜백 함수를 매개변수로 받는 함수이며, 반복 과정에서 시퀀스의 요소를 하나씩 yield 콜백 함수에 전달한다는 것입니다. 이전 예제에서는 다음과 같이 이터레이터를 사용했습니다.

go
for f := range Fibonacci(n) {
    fmt.Println(f)
}

공식 정의에 따르면 위 이터레이터 Backward 의 예제는 아래 코드와 동일합니다.

go
Fibonacci(n)(func(f int) bool {
    fmt.Println(f)
    return true
})

루프 본문은 이터레이터의 콜백 함수 yield 입니다. 함수가 true 를 반환하면 이터레이터는 계속 반복하고, 그렇지 않으면 중지됩니다.

또한 iter 표준 라이브러리에는 이터레이터 타입 iter.Seq 가 정의되어 있으며, 이 타입은 함수입니다.

go
type Seq[V any] func(yield func(V) bool)

type Seq2[K, V any] func(yield func(K, V) bool)

iter.Seq 의 콜백 함수는 하나의 매개변수만 받으므로 반복 시 for range 는 하나의 반환 값만 가집니다.

go
for v := range iter {
  // body
}

iter.Seq2 의 콜백 함수는 두 개의 매개변수를 받으므로 반복 시 for range 는 두 개의 반환 값을 가집니다.

go
for k, v := range iter {
  // body
}

표준 라이브러리에는 0 개의 매개변수를 가진 Seq 가 정의되어 있지 않지만 이는 완전히 허용됩니다. 이는 다음과 같습니다.

go
func(yield func() bool)

사용 방법은 다음과 같습니다.

go
for range iter {
  // body
}

콜백 함수의 매개변수 수는 0 에서 2 개까지 가능하며, 그 이상이면 컴파일을 통과할 수 없습니다.

간단히 말해, for range 의 루프 본문은 이터레이터의 yield 콜백 함수이며, for range 가 반환하는 값의 수만큼 yield 함수의 매개변수가 있습니다. 각 반복마다 이터레이터는 yield 함수를 호출하여 루프 본문의 코드를 실행하고 시퀀스의 요소를 yield 함수에 전달합니다. 이처럼 요소를 능동적으로 전달하는 이터레이터를 일반적으로 푸시형 이터레이터 (pushing iterator) 라고 합니다. 대표적인 예로 다른 언어의 foreach 가 있습니다. 예를 들어 JavaScript:

javascript
let arr = [1, 2, 3, 4, 5];
arr
  .filter((e) => e % 2 === 0)
  .forEach((e) => {
    console.log(e);
  });

Go 에서는 range 가 반복되는 요소를 반환하는 형태로 표현됩니다.

go
for index, value := range iterator() {
  fmt.Println(index, value)
}

일부 언어 (예: Java) 에서는 이를 데이터 스트림 처리라고도 합니다.

루프 본문의 코드가 콜백 함수로 이터레이터에 전달되며, 이는 클로저 함수일 수 있으므로 Go 는 클로저 함수가 defer, return, break, goto 등의 키워드를 사용할 때 일반 루프 본문 코드 세그먼트와 동일하게 동작하도록 해야 합니다. 다음 몇 가지 상황을 생각해 보십시오.

예를 들어 이터레이터 루프에서 반환할 때 yield 콜백 함수에서 이 return 을 어떻게 처리해야 할까요?

go
for index, value := range iterator() {
    if value > 10 {
        return
  }
  fmt.Println(index, value)
}

콜백 함수에서 직접 return 할 수는 없습니다. 이렇게 하면 이터레이터만 중지될 뿐 return 효과를 달성할 수 없습니다.

go
iterator()(func(index int, value int) bool {
  if value > 10 {
    return false
  }
  fmt.Println(index, value)
})

또한 이터레이터 루프에서 defer 를 사용하는 경우도 있습니다.

go
for index, value := range iterator() {
    defer fmt.Println(index, value)
}

콜백 함수에서 직접 defer 를 사용할 수도 없습니다. 이렇게 하면 콜백 함수가 끝날 때 바로 지연 호출되기 때문입니다.

go
iterator()(func(index int, value int) bool {
  defer fmt.Println(index, value)
})

다른 키워드 break, continue, goto 도 마찬가지입니다. 다행히 Go 가 이를 처리해 주므로 우리는 사용하기만 하면 됩니다. 관심이 있다면 rangefunc/rewrite.go 의 소스 코드를 직접 확인해 보십시오.

풀형 이터레이터

푸시형 이터레이터 (pushing iterator) 는 이터레이터가 반복 로직을 제어하고 사용자가 수동으로 요소를获取하는 반면, 풀형 이터레이터 (pulling iterator) 는 사용자가 반복 로직을 제어하고 능동적으로 시퀀스 요소를 가져옵니다. 일반적으로 풀형 이터레이터에는 next(), stop() 과 같은 특정 함수가 있어 반복의 시작 또는 끝을 제어하며, 이는 클로저 또는 구조체일 수 있습니다.

go
scanner := bufio.NewScanner(file)
for scanner.Scan() {
    line, err := scanner.Text(), scanner.Err()
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(line)
}

위에서 Scanner 는 Text() 메서드를 통해 파일의 다음 줄 텍스트를 가져오고, Scan() 메서드를 통해 반복이 끝났는지 나타냅니다. 이 또한 풀형 이터레이터의 한 형태입니다. Scanner 는 구조체를 사용하여 상태를 기록하는 반면, iter 패키지에 정의된 풀형 이터레이터는 클로저를 사용하여 상태를 기록합니다. iter.Pull 또는 iter.Pull2 함수를 통해 표준 푸시형 이터레이터를 풀형 이터레이터로 변환할 수 있습니다. iter.Pulliter.Pull2 의 차이는 후자의 반환 값이 두 개라는 점이며, 시그니처는 다음과 같습니다.

go
func Pull[V any](seq Seq[V]) (next func() (V, bool), stop func())

func Pull2[K, V any](seq Seq2[K, V]) (next func() (K, V, bool), stop func())

이들은 모두 이터레이터를 매개변수로 받아 next()stop() 두 함수를 반환하며, 이는 반복의 계속과 중지를 제어하는 데 사용됩니다.

go
func next() (V, bool)

func stop()

next 는 반복되는 요소와 현재 값의 유효성을 나타내는 불리언 값을 반환합니다. 반복이 끝나면 next 함수는 요소의 제로 값과 false 를 반환합니다. stop 함수는 반복 프로세스를 종료하며, 호출자가 더 이상 이터레이터를 사용하지 않을 때는 반드시 stop 함수를 사용하여 반복을 종료해야 합니다. 참고로 여러 고루틴에서 동일한 이터레이터의 next 함수를 호출하는 것은 잘못된 방법입니다. 이는 동시성 안전하지 않기 때문입니다.

다음 예제를 통해 살펴보면, 이전의 피보나치 이터레이터를 풀형 이터레이터로 개선한 것입니다.

go
func main() {
  n := 10
  next, stop := iter.Pull(Fibonacci(n))
  defer stop()
  for {
    fibn, ok := next()
    if !ok {
      break
    }
    fmt.Println(fibn)
  }
}

출력

0
1
1
2
3
5
8
13
21
34

이제 nextstop 함수를 통해 반복 로직을 수동으로 제어할 수 있습니다. 아마도 이것이 불필요하다고 생각할 수 있습니다. 이렇게 하려면 왜 처음의 클로저 버전을 그대로 사용하지 않을까요? 클로저를 사용하면 스스로 반복을 제어할 수 있습니다. 클로저 사용 방법은 다음과 같습니다.

go
func main() {
  fib := Fibonacci(10)
    for {
        n, ok := fib()
        if !ok {
            break
        }
        fmt.Println(n)
    }
}

변환 과정: 클로저 → 이터레이터 → 풀형 이터레이터, 클로저와 풀형 이터레이터의 사용법은 거의 동일하며 사상도 같습니다. 후자는 다양한 처리로 인해 성능 저하가 발생할 수 있습니다. 솔직히 말하면 이는 불필요한 일이며 사용 사례도 많지 않습니다. 하지만 iter.Pulliter.Seq 를 위해 존재하며, 즉 푸시형 이터레이터를 풀형 이터레이터로 변환하기 위해 존재합니다. 단순히 풀형 이터레이터가 필요하다면 직접 구현하는 복잡도와 성능을 고려해 보십시오. 이 피보나치 수열 예제처럼 한 바퀴 돌아서 제자리로 돌아오는 셈입니다. 유일한 장점은 공식 이터레이터 규격을 준수한다는 점일 수 있습니다.

에러 처리

반복 중 에러가 발생하면 어떻게 해야 할까요? 이를 yield 함수에 전달하여 for range 가 반환하도록 하고 호출자가 처리하도록 할 수 있습니다. 아래 행 이터레이터 예제와 같습니다.

go
func ScanLines(reader io.Reader) iter.Seq2[string, error] {
  scanner := bufio.NewScanner(reader)
  return func(yield func(string, error) bool) {
    for scanner.Scan() {
      if !yield(scanner.Text(), scanner.Err()) {
        return
      }
    }
  }
}

TIP

值得注意的是,ScanLines 이터레이터는 일회용이며, 파일이 닫힌 후에는 다시 사용할 수 없습니다.

두 번째 반환 값이 error 타입인 것을 볼 수 있으며, 사용 방법은 다음과 같습니다.

go
for line, err := range ScanLines(file) {
    if err != nil {
        fmt.Println(err)
        break
    }
    fmt.Println(line)
}

이렇게 처리하면 일반 에러 처리와 차이가 없으며, 풀형 이터레이터도 마찬가지입니다.

go
next, stop := iter.Pull2(ScanLines(file))
defer stop()
for {
    line, err, ok := next()
    if err != nil {
        fmt.Println(err)
        break
    } else if !ok {
        break
    }
    fmt.Println(line)
}

panic 이 발생하면 평소와 같이 recovery 를 사용하면 됩니다.

go
defer func() {
    if err := recover(); err != nil {
        fmt.Println("panic:", err)
        os.Exit(1)
    }
}()

for line, err := range ScanLines(file) {
    if err != nil {
        fmt.Println(err)
        break
    }
    fmt.Println(line)
}

풀형 이터레이터도 마찬가지이므로 여기서는 생략합니다.

표준 라이브러리

많은 표준 라이브러리도 이터레이터를 지원합니다. 가장 많이 사용되는 것은 slicesmaps 표준 라이브러리입니다. 아래에서 몇 가지 실용적인 기능을 소개합니다.

slices.All

go
func All[Slice ~[]E, E any](s Slice) iter.Seq2[int, E]

slices.All 은 슬라이스를 슬라이스 이터레이터로 변환합니다.

go
func main() {
  s := []int{1, 2, 3, 4, 5}
  for i, n := range slices.All(s) {
    fmt.Println(i, n)
  }
}

출력

0 1
1 2
2 3
3 4
4 5

slices.Values

go
func Values[Slice ~[]E, E any](s Slice) iter.Seq[E]

slices.Values 는 슬라이스를 슬라이스 이터레이터로 변환하지만 인덱스는 포함하지 않습니다.

go
func main() {
  s := []int{1, 2, 3, 4, 5}
  for n := range slices.Values(s) {
    fmt.Println(n)
  }
}

출력

1
2
3
4
5

slices.Chunk

go
func Chunk[Slice ~[]E, E any](s Slice, n int) iter.Seq[Slice]

slices.Chunk 함수는 n 개 요소 단위로 슬라이스를 호출자에게 전달하는 이터레이터를 반환합니다.

go
func main() {
  s := []int{1, 2, 3, 4, 5}
  for chunk := range slices.Chunk(s, 2) {
    fmt.Println(chunk)
  }
}

출력

[1 2]
[3 4]
[5]

slices.Collect

go
func Collect[E any](seq iter.Seq[E]) []E

slices.Collect 함수는 슬라이스 이터레이터를 슬라이스로 수집합니다.

go
func main() {
  s := []int{1, 2, 3, 4, 5}
  s2 := slices.Collect(slices.Values(s))
  fmt.Println(s2)
}

출력

[1 2 3 4 5]

maps.Keys

go
func Keys[Map ~map[K]V, K comparable, V any](m Map) iter.Seq[K]

maps.Keys 는 map 의 모든 키를 반복하는 이터레이터를 반환하며, slices.Collect 와 함께 사용하면 슬라이스로 직접 수집할 수 있습니다.

go
func main() {
  m := map[string]int{"one": 1, "two": 2, "three": 3}
  keys := slices.Collect(maps.Keys(m))
  fmt.Println(keys)
}

출력

[three one two]

map 은 순서가 없으므로 출력도 고정되지 않습니다.

maps.Values

go
func Values[Map ~map[K]V, K comparable, V any](m Map) iter.Seq[V]

maps.Values 는 map 의 모든 값을 반복하는 이터레이터를 반환하며, slices.Collect 와 함께 사용하면 슬라이스로 직접 수집할 수 있습니다.

go
func main() {
  m := map[string]int{"one": 1, "two": 2, "three": 3}
  keys := slices.Collect(maps.Values(m))
  fmt.Println(keys)
}

출력

[3 1 2]

map 은 순서가 없으므로 출력도 고정되지 않습니다.

maps.All

go
func All[Map ~map[K]V, K comparable, V any](m Map) iter.Seq2[K, V]

maps.All 는 map 을 map 이터레이터로 변환합니다.

go
func main() {
  m := map[string]int{"one": 1, "two": 2, "three": 3}
  for k, v := range maps.All(m) {
    fmt.Println(k, v)
  }
}

보통 이렇게 직접 사용하지 않고 다른 데이터 스트림 처리 함수와 함께 사용합니다.

maps.Collect

go
func Collect[K comparable, V any](seq iter.Seq2[K, V]) map[K]V

maps.Collect 는 map 이터레이터를 map 으로 수집합니다.

go
func main() {
  m := map[string]int{"one": 1, "two": 2, "three": 3}
  m2 := maps.Collect(maps.All(m))
  fmt.Println(m2)
}

collect 함수는 일반적으로 데이터 스트림 처리의 종료 함수로 사용됩니다.

체인 호출

위에서 표준 라이브러리가 제공하는 함수를 조합하여 데이터 스트림을 처리할 수 있습니다. 예를 들어 데이터 스트림을 정렬하는 경우:

go
sortedSlices := slices.Sorted(slices.Values(s))

Go 의 이터레이터는 클로저를 사용하므로 이렇게 중첩 함수 호출만 가능하며, 자체적으로 체인 호출은 할 수 없습니다. 호출 체인이 길어지면 가독성이 떨어지지만, 구조체를 사용하여 이터레이터를 기록하면 체인 호출을 구현할 수 있습니다.

데모

간단한 체인 호출 데모는 다음과 같습니다. Filter, Map, Find, Some 등의 일반적인 기능을 포함합니다.

go
package iterx

import (
  "iter"
  "slices"
)

type SliceSeq[E any] struct {
  seq iter.Seq2[int, E]
}

func (s SliceSeq[E]) All() iter.Seq2[int, E] {
  return s.seq
}

func (s SliceSeq[E]) Filter(filter func(int, E) bool) SliceSeq[E] {
  return SliceSeq[E]{
    seq: func(yield func(int, E) bool) {
      // 인덱스 재구성
      i := 0
      for k, v := range s.seq {
        if filter(k, v) {
          if !yield(i, v) {
            return
          }
          i++
        }
      }
    },
  }
}

func (s SliceSeq[E]) Map(mapFn func(E) E) SliceSeq[E] {
  return SliceSeq[E]{
    seq: func(yield func(int, E) bool) {
      for k, v := range s.seq {
        if !yield(k, mapFn(v)) {
          return
        }
      }
    },
  }
}

func (s SliceSeq[E]) Fill(fill E) SliceSeq[E] {
  return SliceSeq[E]{
    seq: func(yield func(int, E) bool) {
      for i, _ := range s.seq {
        if !yield(i, fill) {
          return
        }
      }
    },
  }
}

func (s SliceSeq[E]) Find(equal func(int, E) bool) (_ E) {
  for i, v := range s.seq {
    if equal(i, v) {
      return v
    }
  }
  return
}

func (s SliceSeq[E]) Some(match func(int, E) bool) bool {
  for i, v := range s.seq {
    if match(i, v) {
      return true
    }
  }
  return false
}

func (s SliceSeq[E]) Every(match func(int, E) bool) bool {
  for i, v := range s.seq {
    if !match(i, v) {
      return false
    }
  }
  return true
}

func (s SliceSeq[E]) Collect() []E {
  var res []E
  for _, v := range s.seq {
    res = append(res, v)
  }
  return res
}

func (s SliceSeq[E]) Sort(cmp func(x, y E) int) []E {
  collect := s.Collect()
  slices.SortFunc(collect, cmp)
  return collect
}

func (s SliceSeq[E]) SortStable(cmp func(x, y E) int) []E {
  collect := s.Collect()
  slices.SortStableFunc(collect, cmp)
  return collect
}

func Slice[S ~[]E, E any](s S) SliceSeq[E] {
  return SliceSeq[E]{seq: slices.All(s)}
}

이제 체인 호출을 통해 처리할 수 있습니다. 몇 가지 사용 사례를 보겠습니다.

요소 값 처리

go
func main() {
  s := []string{"apple", "banana", "cherry"}
  all := iterx.Slice(s).Map(strings.ToUpper).All()
  for i, v := range all {
    fmt.Printf("index: %d, value: %s\n", i, v)
  }
}

출력

index: 0, value: APPLE
index: 1, value: BANANA
index: 2, value: CHERRY

지정된 값 찾기

go
func main() {
  s := []int{1, 2, 3, 4, 5}
  result := iterx.Slice(s).Find(func(i int, e int) bool {
    return e == 3
  })
  fmt.Println(result)
}

출력

3

슬라이스 채우기

go
func main() {
  s := []int{1, 2, 3, 4, 5}
  result := iterx.Slice(s).Fill(6).Collect()
  fmt.Println(result)
}

출력

[6 6 6 6 6]

요소 필터링

go
func main() {
  s := []int{1, 2, 3, 4, 5}
  filter := iterx.Slice(s).Filter(func(i int, e int) bool {
    return e%2 == 0
  }).All()
  for i, v := range filter {
    fmt.Printf("Index: %d, Value: %d\n", i, v)
  }
}

출력

Index: 0, Value: 2
Index: 1, Value: 4

아쉽게도 Go 는 아직 간소화된 익명 함수를 지원하지 않습니다. JavaScript, Rust, Java 의 화살표 함수와 같다면 체인 호출을 더 간결하고 우아하게 만들 수 있을 것입니다.

성능

Go 가 이터레이터를 위해 많은 처리를 했기 때문에 성능은 네이티브 for range 루프보다 떨어집니다. 가장 간단한 슬라이스 순회를 예로 들어 성능 차이를 테스트해 보겠습니다. 다음 세 가지로 구분됩니다.

  • 네이티브 for 루프
  • 푸시형 이터레이터
  • 풀형 이터레이터

테스트 코드는 다음과 같으며, 테스트 슬라이스 길이는 10000 입니다.

go
package main

import (
  "iter"
  "slices"
  "testing"
)

var s []int

const n = 10000

func init() {
  for i := range n {
    s = append(s, i)
  }
}

func testNaiveFor(s []int) {
  for i, n := range s {
    _ = i
    _ = n
  }
}

func testPushing(s []int) {
  for i, n := range slices.All(s) {
    _ = i
    _ = n
  }
}

func testPulling(s []int) {
  next, stop := iter.Pull2(slices.All(s))
  for {
    i, n, ok := next()
    if !ok {
      stop()
      return
    }
    _ = i
    _ = n
  }
}

func BenchmarkNaive_10000(b *testing.B) {
  for range b.N {
    testNaiveFor(s)
  }
}

func BenchmarkPushing_10000(b *testing.B) {
  for range b.N {
    testPushing(s)
  }
}

func BenchmarkPulling_10000(b *testing.B) {
  for range b.N {
    testPulling(s)
  }
}

테스트 결과는 다음과 같습니다.

goos: windows
goarch: amd64
pkg: golearn
cpu: 11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz
BenchmarkNaive_10000
BenchmarkNaive_10000-16           492658              2398 ns/op               0 B/op          0 allocs/op
BenchmarkPushing_10000
BenchmarkPushing_10000-16         315889              3707 ns/op               0 B/op          0 allocs/op
BenchmarkPulling_10000
BenchmarkPulling_10000-16           2016            574509 ns/op             440 B/op         14 allocs/op
PASS
ok      golearn 4.029s

결과를 통해 푸시형 이터레이터는 네이티브 for range 루프와 큰 차이가 없지만, 풀형 이터레이터는 앞의 두 가지보다 거의 두 자릿수 정도 느린 것을 볼 수 있습니다. 사용할 때는 실제 상황에 따라 고려하시기 바랍니다.

##小结

제네릭과 마찬가지로 Go 의 이터레이터도 많은 논쟁을 불러일으켰습니다. 일부 사람들은 이터레이터가 너무 많은 복잡도를 도입하여 Go 의 간결함 철학에 위배된다고 주장합니다. 이러한 이터레이터 클로저 코드가 많아지면 디버깅이 어려워지고 읽기도 더 짜증납니다.

이터레이터에 대한激烈的인 논의를 여러 곳에서 볼 수 있습니다.

Go 이터레이터를 이성적으로 바라보면 슬라이스 타입을 처리할 때 코드를 더 편리하게 작성할 수 있게 해주는 것은 사실이지만, 동시에 일부 복잡도를 도입하여 이터레이터 부분의 코드 가독성을 낮춥니다. 하지만 전반적으로 볼 때 이는 실용적인 기능이라고 생각합니다.

Golang by www.golangdev.cn edit