첫 번째 글에서는 제네레이터와 체이닝을 중심으로 함수형 프로그래밍의 기본 흐름을 정리했다. 이번에는 every, some, find, take 같은 함수를 직접 구현하면서 단축 평가와 지연 평가가 실제로 어떻게 동작하는지 살펴봤다.

배열 메서드로 사용할 때는 everysome이 너무 당연하게 느껴진다. 하지만 직접 구현해보면 이 함수들의 핵심은 “필요 이상으로 계산하지 않는 것”에 있다는 점이 잘 보인다.

every와 some

every는 모든 값이 조건을 만족해야 true를 반환한다. 하나라도 조건을 만족하지 않으면 더 볼 필요가 없다.

function every<A>(f: (a: A) => boolean, iterable: Iterable<A>): boolean {
  for (const a of iterable) {
    if (!f(a)) return false
  }
  return true
}

반대로 some은 하나라도 조건을 만족하면 바로 true를 반환한다.

function some<A>(f: (a: A) => boolean, iterable: Iterable<A>): boolean {
  for (const a of iterable) {
    if (f(a)) return true
  }
  return false
}

둘 다 구현 자체는 단순하지만, 중요한 건 반복을 끝내는 시점이다. 함수형으로 표현할 때도 이 성질을 잃지 않아야 한다. 예를 들어 some을 체이닝으로 구현한다면 take(1)처럼 필요한 값 하나만 가져오는 연산이 있어야 단축 평가의 장점을 살릴 수 있다.

function someWithChain<A>(f: (a: A) => boolean, iterable: Iterable<A>): boolean {
  return fx(iterable)
    .map(f)
    .filter((a) => a)
    .take(1)
    .reduce((acc, curr) => acc || curr, false)
}

여기서 take(1)이 빠지면 표현은 함수형처럼 보여도 실제로는 필요 없는 값까지 계속 계산할 수 있다. 이 지점이 직접 구현해보면서 가장 먼저 체감된 부분이었다.

find를 여러 방식으로 구현해보기

find도 같은 관점에서 볼 수 있다. 조건을 만족하는 첫 번째 값을 찾으면 바로 끝나야 한다. 가장 직접적인 구현은 이터레이터를 직접 조작하는 방식이다.

function find<A>(f: (a: A) => boolean, iterable: Iterable<A>): A | undefined {
  const iterator = iterable[Symbol.iterator]()
 
  while (true) {
    const { done, value } = iterator.next()
    if (done) break
    if (f(value)) return value
  }
 
  return undefined
}

이미 만들어둔 filterhead를 조합해도 같은 일을 표현할 수 있다.

const head = <A>(iterable: Iterable<A>): A | undefined => {
  return iterable[Symbol.iterator]().next().value
}
 
const findWithFilter = <A>(f: (a: A) => boolean, iterable: Iterable<A>): A | undefined => {
  return head(filter(f, iterable))
}

체이닝을 사용하면 더 읽기 쉬운 형태가 된다.

const findWithChain = <A>(f: (a: A) => boolean, iterable: Iterable<A>): A | undefined => {
  return fx(iterable).filter(f).to(head)
}

같은 기능이라도 직접 이터레이터를 다루는 방식, 작은 함수를 조합하는 방식, 체이닝으로 표현하는 방식이 각각 다른 장단점을 가진다. 학습할 때는 여러 방식으로 구현해보는 것이 꽤 도움이 됐다. 함수 하나의 동작보다 그 함수를 둘러싼 추상화 수준이 더 잘 보이기 때문이다.

take와 지연 평가

take는 간단해 보이지만, 지연 평가를 다룰 때 핵심이 되는 함수다. 이 함수가 있으면 무한 시퀀스나 큰 데이터에서도 필요한 만큼만 값을 소비할 수 있다.

export function* take<A>(limit: number, iterable: Iterable<A>): IterableIterator<A> {
  const iterator = iterable[Symbol.iterator]()
 
  while (true) {
    const { value, done } = iterator.next()
    if (done) break
 
    yield value
    if (--limit === 0) break
  }
}

각 함수에 console.log를 넣어 실행 순서를 보면 지연 평가의 동작이 더 분명해진다.

function* filter<A>(f: (a: A) => boolean, iterable: Iterable<A>): IterableIterator<A> {
  const iterator = iterable[Symbol.iterator]()
 
  while (true) {
    console.log("filter")
    const { done, value } = iterator.next()
    if (done) break
    if (f(value)) yield value
  }
}
 
function* map<A, B>(f: (a: A) => B, iterable: Iterable<A>): IterableIterator<B> {
  const iterator = iterable[Symbol.iterator]()
 
  while (true) {
    console.log("map")
    const { done, value } = iterator.next()
    if (done) break
    yield f(value)
  }
}

값 하나가 필요할 때마다 체인 전체가 조금씩 움직인다. filter가 값을 걸러내면 다음 값을 찾기 위해 다시 실행되고, take가 필요한 개수만큼 값을 받으면 전체 흐름이 멈춘다. “필요할 때 계산한다”는 말이 추상적인 설명이 아니라 실제 실행 순서라는 걸 확인할 수 있었다.

명령형 코드와 함수형 코드

홀수의 제곱합을 구하는 예제를 보면 차이가 더 잘 보인다. 명령형 방식은 반복문과 조건문으로 흐름을 직접 제어한다.

function sumOfSquaresOfOddNumbers(limit: number, list: number[]): number {
  let acc = 0
 
  for (const num of list) {
    if (num % 2 === 1) {
      const squared = num * num
      acc += squared
      if (--limit === 0) break
    }
  }
 
  return acc
}

함수형 방식은 같은 과정을 변환의 흐름으로 표현한다.

const sumOfSquaresOfOddNumbers = (limit: number, list: number[]): number =>
  fx(list)
    .filter((a) => a % 2 === 1)
    .map((a) => a * a)
    .take(limit)
    .reduce((acc, a) => acc + a, 0)

명령형 코드는 어떻게 반복하고 언제 멈출지를 코드 안에 직접 드러낸다. 함수형 코드는 홀수만 남기고, 제곱으로 바꾸고, 필요한 개수만 가져와 합친다는 의도를 앞에 세운다. 어느 쪽이 항상 더 낫다기보다, 문제를 어떤 수준에서 읽고 싶은지에 따라 선택이 달라진다.

concat과 이터러블 조합

두 이터러블을 합치는 concat도 제네레이터로 표현하면 간단하다.

export function* concat<A>(iterable1: Iterable<A>, iterable2: Iterable<A>): IterableIterator<A> {
  for (const iterable of [iterable1, iterable2]) yield* iterable
}

배열의 concat은 결과 배열을 즉시 만든다. 반면 제네레이터 기반 concat은 값을 실제로 소비할 때까지 계산을 미룬다. 데이터가 작을 때는 차이가 잘 보이지 않지만, 큰 데이터나 무한 시퀀스에서는 이 차이가 중요해진다.

이번 구현을 통해 정리된 생각은 단순하다. 함수형 프로그래밍의 장점은 문법이 멋있어서가 아니라, 계산을 작은 단위로 나누고 필요한 순간까지 미룰 수 있다는 데 있다. every, some, find, take처럼 익숙한 함수들도 직접 만들어보면 그 안에 숨어 있는 실행 전략이 보인다.