Go generic을 이용해 함수형 이터레이터를 구현해 보자

김성현·2022년 3월 22일
0
post-thumbnail

혹시 Node.js, Python 3, Rust 같은 언어를 써 본적이 있는가?

이 언어들에는 map, reduce, filter 로 대표되는 iterator 관련 API가 존재한다.

이터레이터는 살짝 과장 섞으면 현대적인 프로그래밍 언어 자격증이라고 불러도 될 정도로 대다수의 언어에서 지원하는 기능이다.

이터레이터를 그러면 어째서 다들 도입하는 걸까?

한번 아래 문장으로 코드를 짠다고 생각해 보자

당신은 이름과, C 언어 시험점수, JAVA 언어 시험점수를 가지는 학생 배열을 가지고 있다.

여기서 학생 배열을 받아 학생들의 점수 평균을 측정한다

이후 평균이 90점 이상인 학생들만 가지고 정렬을 해야 한다.

만약 go 에서 이를 작성하면 다음과 같을 것이다.

package main

import (
	"fmt"
	"sort"
)

type Student struct {
	name         string
	cLanguage    float32
	javaLanguage float32
}
type StudentMean struct {
	name string
	mean float32
}
type ArrayStudentMean []StudentMean

func (a ArrayStudentMean) Len() int           { return len(a) }
func (a ArrayStudentMean) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }
func (a ArrayStudentMean) Less(i, j int) bool { return a[i].mean < a[j].mean }

func main() {
	var students []Student = []Student{
		{name: "나", cLanguage: 100, javaLanguage: 100},
		{name: "철수", cLanguage: 90, javaLanguage: 80},
		{name: "영희", cLanguage: 90, javaLanguage: 95},
	}
	//
	var above90 = ArrayStudentMean(make([]StudentMean, 0, len(students)))
	for _, student := range students {
		var studentMean = StudentMean{
			name: student.name,
			mean: (student.cLanguage + student.javaLanguage) / 2,
		}
		if studentMean.mean >= 90 {
			above90 = append(above90, studentMean)
		}
	}
	sort.Sort(above90)
	//
	for _, data := range above90 {
		fmt.Printf("%s : %f\n", data.name, data.mean)
	}
}

일부로 주석을 안달았는데 한번 위의 설명과 코드를 보고 솔직하게 생각해 보자.

위 코드만 보고 이 코드가 어떤 알고리즘을 구현한건지 직관적으로 알 수 있는가?

이러한 코드는 자주 쓰기에 익숙해서 빠르게 분석이 가능할 뿐, 코드만 보고 for문이 어떤 역활을 하는지 등을 한번에 알아내는 것은 불가능하다.

거기다 위 코드는 Go는 태생적인 단점인 sort.Interface 관련한 문제가 있다.

Go는 제네릭을 지원하지 않기에 사용자 구조체를 정렬하려면 위 코드에서 ArrayStudentMean같은 배열 타입을 만들어 Len, Swap, Less 메서드를 직접 구현해 줘야 한다.

보다시피 해당 코드는 별것도 없지만 만약 사용자가 이걸 직접 구현하려면 상당히 귀찮다.

특히 가장 문제라고 생각하는 부분은 for문의 의미가 불명확하다는 것이다.

우리는 이미 for문을 많이 봐 왔고 위 코드의 for문은 사실 매우 간단하기에 별로 어렵지 않게 해석 가능하지만, 만약 이보다 더 긴 조건이 온다면 이 for문이 어떤 목적으로 사용된 건지 모호할 것이다.

그러면 위의 코드와 아래 rust 코드와 한번 비교를 해 보자.

struct Student {
    name: String,
    cLanguage: f32,
    javaLanguage: f32,
}

fn main() {
    let students = vec![
        Student {
            name: "나".to_string(),
            cLanguage: 100.,
            javaLanguage: 100.,
        },
        Student {
            name: "철수".to_string(),
            cLanguage: 90.,
            javaLanguage: 80.,
        },
        Student {
            name: "영희".to_string(),
            cLanguage: 90.,
            javaLanguage: 95.,
        },
    ];
    let mut data = students
        .into_iter()
        .map(|student| {
            (
                student.name,
                (student.cLanguage + student.javaLanguage) / 2.,
            )
        })
        .filter(|(_, mean)| *mean >= 90.)
        .collect::<Vec<_>>();
    data.sort_by(|(_, a_mean), (_, b_mean)| a_mean.partial_cmp(b_mean).unwrap());
    data.iter().for_each(|(name, mean)| {
        println!("{} : {}", name, mean);
    });
}

아마 대부분 rust언어를 모를 테지만 위 소스코드를 이해하는데는 아무런 문제가 없을 것이다.

  • student배열을 map을 통해 평균을 계산시키고
  • filter를 통해 90점 이상만을 선택하며
  • collect를 통해 평균 90점 이상만을 모아서
  • sort_by를 통해 정렬하며
  • for_each를 통해 한개씩 출력한다.

이터레이터를 쓰면 코드 내용을 읽기 전까지는 그 역활에 대해 이해를 할 수 없는 for문에 비해 코드만으로 의미가 더 명확해진다고 생각된다.

따라서 나는 이터레이터 관련 API를 매우 선호하고 가능하다면 언제나 사용하려 한다.

그래서 실제 구현해본 이터레이터의 사용은 아래와 같다.

type Student struct {
	name         string
	cLanguage    float32
	javaLanguage float32
}
type StudentMean struct {
	name string
	mean float32
}

func main() {
	var students []Student = []Student{
		{name: "나", cLanguage: 100, javaLanguage: 100},
		{name: "철수", cLanguage: 90, javaLanguage: 80},
		{name: "영희", cLanguage: 90, javaLanguage: 95},
	}

	ForEach(func(t StudentMean) { fmt.Printf("%s : %f\n", t.name, t.mean) })(
		Sorted(func(a, b StudentMean) int {
			if a.mean == b.mean {
				return 0
			} else if a.mean < b.mean {
				return -1
			} else {
				return 1
			}
		})(
			Filter(func(t StudentMean) bool { return t.mean >= 90 })(
				Map(func(t Student) StudentMean {
					return StudentMean{name: t.name, mean: (t.cLanguage + t.javaLanguage) / 2}
				})(
					FromArray(students),
				),
			),
		),
	)
}

안타깝게도, Go는 메서드 제네릭을 허용치 않아, 위처럼 불편한 이터레이터를 사용할 수밖에 없다.

하지만 차후에, 좀 더 진보된 제네릭 지원, 메서드 제네릭을 지원한다면 아래처럼 코드를 짤 수 있을 것이다.


func main() {
	var students []Student = []Student{
		{name: "나", cLanguage: 100, javaLanguage: 100},
		{name: "철수", cLanguage: 90, javaLanguage: 80},
		{name: "영희", cLanguage: 90, javaLanguage: 95},
	}
	// 메서드 체이닝 방식의 이터레이터
	FromArray(students)
		.Map(func(t Student) StudentMean {
			return StudentMean{name: t.name, mean: (t.cLanguage + t.javaLanguage) / 2}
		})
		.Filter(func(t StudentMean) bool { return t.mean >= 90 })
		.Sorted(func(a, b StudentMean) int {
			if a.mean == b.mean {
				return 0
			} else if a.mean < b.mean {
				return -1
			} else {
				return 1
			}
		}),
		.ForEach(func(t StudentMean) { fmt.Printf("%s : %f\n", t.name, t.mean) })
}

만약 차후에 좀 더 제네릭 지원이 활발해져 위처럼 코드를 짤 수 있다면 과연 어떨까?

개인적으로는 매우 기대된다.

현재는 제네릭의 한계로 인해 위의 코드와 같은 구현은 불가능하다.
정확히는 .Map 메서드 하나만 구현 불가능하다. (나머지는 구현 가능)
이에 대해서 자세히 알고 싶다면 go proposal : type-parameters #No-parameterized-methods 를 읽어보면 된다.


안타깝게도 이번 제네릭은 내 기대를 100% 충족시켜주진 못했다.

하지만 차후로도 제네릭 발전의 가능성을 나는 매우 희망차게 바라보고 있다.

특히 제네릭을 언어 차원에서 지원하기 시작하고, 이건 희망사항일 뿐이지만... 이터레이터 API에 goroutine을 통합시킨다면???

이터레이터 API는 대체로 순서 의존성이 없어서 goroutine을 통한 병렬처리 증가의 덕을 크게 볼 수 있기에 기대가 매우 크다.

큰 발을 내딛은 Go 언어에 치얼스!

마지막으로 내가 구현한 Iterator API 코드를 마지막으로 포스트를 마친다.

// Lazy evaluation 기반
type Iter[T any] func() (T, bool)

// 배열을 Iterator 로 변환
func FromArray[T any](array []T) Iter[T] {
	var index = -1
	var end T
	return func() (T, bool) {
		if index+1 < len(array) {
			index += 1
			return array[index], true
		}
		return end, false
	}
}

// fn함수에 따라 매개변수로 주어지는 Iterator를 필터링
func Filter[T any](fn func(T) bool) func(Iter[T]) Iter[T] {
	return func(origin Iter[T]) Iter[T] {
		var end T
		return func() (T, bool) {
			for data, ok := origin(); ok; data, ok = origin() {
				if fn(data) {
					return data, true
				}
			}
			return end, false
		}
	}
}

// fn함수에 따라 매개변수로 주어지는 Iterator를 변환
func Map[T any, E any](fn func(T) E) func(Iter[T]) Iter[E] {
	return func(origin Iter[T]) Iter[E] {
		var end E
		return func() (E, bool) {
			for data, ok := origin(); ok; data, ok = origin() {
				return fn(data), true
			}
			return end, false
		}
	}
}

// fn함수에 따라 매개변수로 주어지는 Iterator를 정렬
func Sorted[T any](fn func(a, b T) int) func(Iter[T]) Iter[T] {
	return func(origin Iter[T]) Iter[T] {
		var collect = Collect(origin)
		var n = len(collect)
		for i := 1; i < n; i++ {
			j := i
			for j > 0 {
				if fn(collect[j-1], collect[j]) > 0 {
					collect[j-1], collect[j] = collect[j], collect[j-1]
				}
				j = j - 1
			}
		}
		return FromArray(collect)
	}
}

// 매개변수로 주어지는 Iterator를 배열로 묶음
func Collect[T any](origin Iter[T]) []T {
	var collect = make([]T, 0, 10)
	for data, ok := origin(); ok; data, ok = origin() {
		collect = append(collect, data)
	}
	return collect
}

// Iterator각 요소를 fn을 적용시켜 실행
func ForEach[T any](fn func(T)) func(Iter[T]) {
	return func(origin Iter[T]) {
		for data, ok := origin(); ok; data, ok = origin() {
			fn(data)
		}
	}
}
profile
수준 높은 기술 포스트를 위해서 노력중...

0개의 댓글