ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Go 1.23 이터레이터 완벽 정리
    Go 2024. 8. 20. 21:35

     

    안녕하세요?

     

    오늘은 Go 1.23 버전부터 새롭게 추가된 이터레이터에 대해 자세히 알아보려고 합니다.

     

    Go 1.23 이터레이터, 넌 누구니?

    2024년 8月 13일, 드디어 Go 1.23 버전이 세상에 공개되었습니다!

     

    이번 버전의 가장 큰 변화 중 하나는 바로 이터레이터의 등장인데요,

     

    오늘 포스팅에서는 Go의 이터레이터가 무엇인지, 어떻게 사용하는지, 그리고 여러분이 이터레이터에 대해 어디까지 알아야 하는지 자세히 알려드리겠습니다.

     

    자세히 알아보기

    1. 달라진 for 문 range 루프

    Go 1.22 버전까지는 for 문의 range 루프를 사용할 때 배열, 슬라이스, 문자열, 맵, 채널, 정수만 사용할 수 있었습니다.

     

    하지만 Go 1.23 버전부터는 특정 형식의 함수도 range 루프에서 사용할 수 있게 되었어요!

    2. 이터레이터 함수란?

    range 루프에서 사용 가능한 함수를 이터레이터 함수, 줄여서 이터레이터라고 부릅니다.

     

    이터레이터는 값을 반환하는 방식에 따라 3가지 종류로 나뉘는데요, 각각 어떤 형식인지 아래에서 자세히 살펴보겠습니다.

    3. 이터레이터 3총사

    이터레이터는 값을 반환하는 방식에 따라 다음과 같이 3가지 유형으로 나뉩니다.

    1. 값을 반환하지 않는 유형: func(func() bool)
    2. 이터레이션마다 값을 하나씩 반환하는 유형: func(func(V) bool) (채널, 정수에 대한 range 루프와 동일)
    3. 이터레이션마다 값을 두 개씩 반환하는 유형: func(func(K, V) bool) (슬라이스, 맵에 대한 range 루프와 동일)

    2번과 3번 유형에서 V와 K는 어떤 타입이든 될 수 있으며, range 루프에서는 해당 타입의 값을 각각 받게 됩니다.

    // 1. 값을 반환하지 않는 유형
    var f1 func(func() bool)
    for range f1 {} // 값을 반환하지 않으므로 x := range f1 형식으로 작성할 수 없습니다.
    
    // 2. 이터레이션마다 값을 하나씩 반환하는 유형
    var f2 func(func(int) bool)
    for x := range f2 {} // x는 int 타입입니다.
    
    // 3. 이터레이션마다 값을 두 개씩 반환하는 유형
    var f3 func(func(string, int) bool)
    for x, y := range f3 {} // x는 string 타입, y는 int 타입입니다.

     

    2번과 3번 유형의 함수 타입은 표준 라이브러리인 iter 패키지에 iter.Seqiter.Seq2로 정의되어 있습니다.

     

    따라서 1번 유형을 제외하고는 기본적으로 iter 패키지를 사용하게 됩니다.

    package iter
    
    // 2: func(func(V) bool)
    type Seq[V any] func(yield func(V) bool)
    // 3: func(func(K, V) bool)
    type Seq2[K, V any] func(yield func(K, V) bool)

     

    실제 range 루프 예시로, 문자열 슬라이스에서 iter.Seq[string]iter.Seq2[int, string]를 생성하는 slices.Valuesslices.All 함수 사용 예를 살펴보겠습니다.

    package main
    
    import (
            "fmt"
            "slices"
    )
    
    func main() {
            s := []string{"a", "b", "c"}
    
            seq := slices.Values(s) // iter.Seq[string] 타입
            for v := range seq {
                    fmt.Println(v) // a, b, c 순서대로 출력
            }
    
            seq2 := slices.All(s) // iter.Seq2[int, string] 타입
            for i, v := range seq2 {
                    fmt.Println(i, v) // 0 a, 1 b, 2 c 순서대로 출력
            }
    }

     

    4. Go 이터레이터, 너의 정체는?

     

    제 생각에 Go의 이터레이터는 "임의의 데이터 구조를 숨기고 데이터 열로 다루기 위한 일반적인 형식"입니다.

     

    좀 더 자세히 설명해 드릴게요.

     

    4.1 데이터 구조 숨기기

     

    Go의 이터레이터는 단순한 함수이기 때문에 어떤 데이터 구조든 숨길 수 있습니다.

     

    즉, 어떤 데이터 구조든 range 루프를 사용할 수 있도록 만들 수 있다는 뜻입니다.

     

    하지만 이터레이터는 인터페이스가 아니기 때문에, 임의의 데이터 구조를 그대로 사용할 수는 없습니다.

     

    range 루프에서 사용하려면 해당 데이터 구조를 숨긴 함수를 생성해야 합니다.

     

    값의 집합이나 값의 열을 나타내는 데이터 구조는 여러 가지가 있지만, Go 1.23 이후에는 이러한 데이터 구조를 이터레이터로 변환하는 함수나 메서드가 각 라이브러리에서 제공될 것으로 예상됩니다.

     

    예를 들어, Russ Cox가 만든 omap 패키지에서는 Ordered Map 데이터 구조에 All() iter.Seq2[K, V] 메서드가 구현되어 있어 쉽게 range 루프를 사용할 수 있습니다.

     

    4.2 슬라이스보다 더 일반적인 데이터 열 형식

     

    Go 1.22까지는 슬라이스가 데이터 열을 표현하는 가장 유연한 데이터 형식이었습니다.

     

    하지만 데이터 구조에 따라 슬라이스를 사용하는 것이 효율적이지 않거나, 애초에 표현할 수 없는 경우도 존재했습니다.

     

    슬라이스가 이터레이터와 다른 점은 데이터 열의 길이만큼 메모리가 필요하다는 점과 데이터 열에 끝이 있다는 점입니다.

     

    예를 들어, 순환 리스트에 대해 range 루프를 돌리고 싶을 때, 이 데이터 열에는 끝이 없기 때문에 슬라이스로 변환할 수 없습니다.

     

    또한, 모든 요소를 모아 슬라이스로 만들면 메모리를 많이 사용하게 되지만, 원래 데이터 구조를 그대로 사용하여 하나씩 요소를 가져오는 것이 더 효율적인 경우도 있습니다.

     

    위와 같이 이터레이터는 슬라이스보다 더 많은 경우에 대응할 수 있는, 더 일반적인 데이터 열 형식이라고 할 수 있습니다.

     

    그리고 Go 1.23 이후에는 데이터 열의 조작은 기본적으로 이터레이터를 대상으로 하는 형태가 될 것으로 예상됩니다.

     

    사용하는 라이브러리 등과의 관계에서, 일부 데이터 열을 슬라이스로 다룰 필요가 있는 조작도 있을 것입니다.

     

    이러한 경우에는 slices 패키지를 사용하여 슬라이스와 이터레이터를 상호 변환하여 사용하게 됩니다.

     

    마찬가지로 maps 패키지를 사용하여 맵과의 상호 변환도 가능합니다.

     

    한 가지 주의해야 할 점은 이터레이터에는 이터레이션마다 값을 반환하지 않는 형식이 있다는 것입니다.

     

    이러한 유형의 이터레이터를 데이터 열이라고 불러야 할지는 의문입니다. (더 적절한 표현이 있을 수도 있지만) Go의 이터레이터는 전체적으로 "데이터 열의 일반적인 형식"이라기보다는 "반복 처리의 지속과 종료"를 일반화한 것으로 파악하는 것이 좋습니다.

     

    다만, 기본적인 이해는 이터레이터의 대부분 유스케이스에 해당하는 "데이터 열의 일반적인 형식"으로 이해해도 무방하다고 생각합니다.

     

    5. 이터레이터 사용법

    이터레이터는 크게 두 가지 방법으로 사용할 수 있습니다.

    1. range 루프에서 사용
    2. 이터레이터를 받는 함수에 전달

    1번은 앞에서 설명한 대로 슬라이스나 맵의 range 루프와 사용법이 크게 다르지 않습니다.

     

    2번에 대해서는 구체적인 예를 들어 설명하겠습니다.

     

    5.1 데이터 열 변환

     

    이 경우 이터레이터를 받은 함수는 데이터 열에 포함된 각 요소를 다른 요소로 변환합니다.

     

    간단한 예로, string 타입의 데이터 열을 받아서 모두 대문자로 변환하여 반환하는 함수를 만들어 보겠습니다. (다른 언어의 map 연산을 떠올리시면 이해하기 쉽습니다.)

    package main
    
    import (
            "fmt"
            "slices"
            "strings"
    
            "golang.org/x/exp/constraints"
            "golang.org/x/exp/slices"
    )
    
    func ToUpper[T constraints.Ordered](seq slices.Seq[T]) slices.Seq[T] {
            return func(yield func(T) bool) {
                    for _, v := range seq {
                            if !yield(strings.ToUpper(v)) {
                                    return
                            }
                    }
            }
    }
    
    func main() {
            s := []string{"a", "b", "c"}
    
            for _, v := range s {
                    fmt.Println(v) // a, b, c
            }
    
            for v := range ToUpper(slices.Values(s)) {
                    fmt.Println(v) // A, B, C
            }
    }

     

    위 예제에서 ToUpper 함수는 iter.Seq[string]을 같은 iter.Seq[string]으로 변환하는 형태로 감싸고 있습니다.

     

    ToUpper 함수를 통해 얻어지는 이터레이터는 감싼 이터레이터의 요소를 한꺼번에 처리하는 것이 아니라, 하나씩 순차적으로 처리하기 때문에 효율적입니다.

     

    또한, string 타입의 값을 요소로 가지는 이터레이터로 표현되어 있다면 어떤 데이터 구조에도 적용할 수 있다는 장점이 있습니다.

     

    이 예제는 데이터 열을 효율적으로 다룰 수 있다는 점에서 io.Reader와 그 래퍼와 유사합니다.

    package main
    
    import (
            "io"
            "os"
            "strings"
    )
    
    type ToUpperReader struct {
            r io.Reader
    }
    
    func (r *ToUpperReader) Read(p []byte) (n int, err error) {
            n, err = r.r.Read(p)
            for i := 0; i < n; i++ {
                    if p[i] >= 'a' && p[i] <= 'z' {
                            p[i] = p[i] - 'a' + 'A'
                    }
            }
            return
    }
    
    func main() {
            s := "abc"
    
            sr := strings.NewReader(s)
            io.Copy(os.Stdout, sr) // abc
    
            ur := &ToUpperReader{r: strings.NewReader(s)}
            io.Copy(os.Stdout, ur) // ABC
    }

     

    하지만 이터레이터는 임의의 타입의 값을 데이터 열에 포함할 수 있는 반면, io.Reader는 바이트 열의 스트림일 뿐이라는 점에서 큰 차이가 있습니다.

     

    5.2 데이터 열 집계

     

    이 경우 이터레이터를 받은 함수는 열거된 값을 하나의 값으로 집계합니다.

    (다른 언어의 reducefold 연산과 비슷합니다.)

     

    예를 들어, slices.Collectiter.Seq[V] 이터레이터를 []V 슬라이스로 집계합니다.

    package main
    
    import (
            "fmt"
            "golang.org/x/exp/slices"
    )
    
    func main() {
            s1 := []string{"a", "b", "c"}
            seq := slices.Values(s1) // iter.Seq[string]
            s2 := slices.Collect(seq) // []string{"a", "b", "c"}
            fmt.Println(s2)
    }

     

    마찬가지로 maps.Collectiter.Seq2[K, V] 이터레이터를 map[K]V 맵으로 집계합니다.

    package main
    
    import (
            "fmt"
    
            "golang.org/x/exp/maps"
    )
    
    func main() {
            m1 := map[string]int{
                    "a": 1,
                    "b": 2,
                    "c": 3,
            }
            seq := maps.All(m1) // iter.Seq2[string, int]
            m2 := maps.Collect(seq) // map[string]int
            fmt.Println(m2)
    }

     

    아래 예제처럼 iter.Seq[int] 데이터 열의 값을 더하는 SumInt와 같은 함수도 작성할 수 있습니다.

    package main
    
    import (
            "fmt"
            "golang.org/x/exp/slices"
    )
    
    func SumInt(seq slices.Seq[int]) int {
            var result int
            for i := range seq {
                    result += i
            }
            return result
    }
    
    func main() {
            ints := []int{1, 2, 3}
            sum := SumInt(slices.Values(ints))
            fmt.Println(sum) // 6
    }

     

    5.3 기타 활용 예

    • 데이터 열의 요소를 조건식에 따라 추출하는 함수 (filter)
    • 데이터 열 연결

    등 다양한 활용 사례를 생각해 볼 수 있습니다.

     

    go-functional v2 betait 패키지에 Map, Fold, Filter, Chain 등의 구현이 있으니, 관심 있는 분들은 확인해 보세요.

     

    6. 슬라이스/맵과 이터레이터 상호 변환

    Go 1.23 릴리스와 함께 slices / maps 패키지에 이터레이터를 다루기 위한 함수가 추가되었습니다.

     

    추가된 함수 목록은 Go 1.23 릴리스 노트에 자세히 나와 있으니, 자세한 내용은 해당 노트를 참고하시기 바랍니다.

     

    추가된 함수 중 가장 많이 사용될 것으로 예상되는 것은 슬라이스/맵과 이터레이터를 상호 변환하는 함수입니다.

     

    6.1 슬라이스와 이터레이터 상호 변환

     

    슬라이스를 이터레이터로 변환하는 데 사용하는 함수는 다음과 같습니다.

    • slices.All(): []V 슬라이스를 iter.Seq2[int, V] 형식으로, 인덱스와 값의 쌍으로 이루어진 이터레이터로 변환합니다.
    • slices.Values(): []V 슬라이스를 iter.Seq[V] 형식으로, 값만으로 이루어진 이터레이터로 변환합니다.

    이터레이터를 슬라이스로 변환하는 데 사용하는 함수는 다음과 같습니다.

    • slices.Collect(): iter.Seq[V] 이터레이터를 []V 슬라이스로 변환합니다.
    • slices.Sorted(): iter.Seq[V constraints.Ordered] 이터레이터를 정렬된 []V 슬라이스로 변환합니다.
      • 정렬 방법을 지정할 수 있는 slices.SortedFunc() 함수도 추가되었습니다.

    6.2 맵과 이터레이터 상호 변환

     

    맵을 이터레이터로 변환하는 데 사용하는 함수는 다음과 같습니다.

    • maps.All(): map[K]V 맵을 iter.Seq2[K, V] 형식으로, 키와 값의 쌍으로 이루어진 이터레이터로 변환합니다.
    • maps.Keys(): map[K]V 맵을 iter.Seq[K] 형식으로, 키만으로 이루어진 이터레이터로 변환합니다.
    • maps.Values(): map[K]V 맵을 iter.Seq[V] 형식으로, 값만으로 이루어진 이터레이터로 변환합니다.

    이터레이터를 맵으로 변환하는 데 사용하는 함수는 다음과 같습니다.

    • maps.Collect(): iter.Seq2[K, V] 이터레이터를 map[K]V 맵으로 변환합니다.

    7. 이터레이터, 어디까지 알아야 할까요?

    기본적으로 이 글에서 소개한 내용, 즉

    • 이터레이터의 종류
    • 이터레이터의 사용 방법
    • 슬라이스/맵과 이터레이터의 상호 변환

    에 대해 알고 있으면 충분합니다.

     

    이터레이터의 구현 방법까지 자세히 아는 것도 물론 좋지만, 표준 라이브러리만으로도 이터레이터와 슬라이스/맵의 상호 변환이 가능하며, 서드파티 라이브러리의 경우 라이브러리 작성자가 이터레이터 변환 기능을 제공할 것으로 기대할 수 있습니다.

     

    이터레이터와 관련된 복잡한 작업을 수행해야 하는 경우에도 앞서 소개한 go-functional 등으로 충분히 해결할 수 있는 경우가 많습니다.

     

    아직 이터레이터는 등장한 지 얼마 되지 않았지만, Go 생태계가 이터레이터를 지원하는 방향으로 발전할 것으로 예상되므로, 라이브러리가 갖춰지는 것에 맞춰 천천히 적응해도 늦지 않습니다.

     

Designed by Tistory.