[함수형 프로그래밍] 함수 체인

Heechul Yoon·2022년 8월 15일
1

우리가 일반적으로 사용하는 list.map((a) ⇒ a + 1) 과 같은 것들은 모나드가 적용된 예이다. 여기서 list가 위에서 알아본 Monad 이다. Array 객체안에는 실제 heap 메모리의 시작주소(배열)를 가리키고 있는 포인터변수가 멤버변수로 있을 것이고, 그 동적할당된 배열을 조작하는 map같은 메서드가 존재한다.

이걸 통해서 javascript에서 forEach().map().reduce() 와 같은 iterable 한 객체를 체이닝해서 연산하는게 가능한것은 각각의 함수가 모나드를 리턴하기 때문이다

generator 함수

function* generatorFunc1(f, iter) { // [1]
  // do something goood
  yield val;
}

처음 호출될때 그냥 안에 있는 로직을 실행하지 않고 곧바로 generator 객체를 호출한다. 나중에 generator 객체에서 next() 연산이 이루어질 때가 되어서야 함수 안에있는 로직이 실행된다.

함수 로직을 실행하면서 처리할거 다 처리하고 값을 yield 해준다. iterable을 돌면서 한번의 스코프마다 하나의 값을 yield 해주고, 리턴된 generator 객체를 next() 할 때 마다 iterable에 있는 다음 값을 평가해서 로직을 돌리고 yield 해준다.

iterable에 더이상 값이 없을 때 generator는 종료된다.

함수형 프로그래밍에서의 함수들

*filter

function* filter(f, iter) { // [1]
  for (const e of iter) { // [1.1]
    if(f(e)) { // [1.2]
      yield e;
    }
  }
}

iterable한 객체를 매개변수로 받는다. 링크드리스트에서 노드 하나를 받는다고 보면된다. next에 Null이 들어가있으면 반복문을 멈춘다.

f로 넘어온 함수를 실행해서 나온 결과가 if조건에서 true로 나온다면 값을 generate 한다

사진과 같이 일단 처음에 호출될때는 generator 객체를 리턴하고 나중에 next()가 될 때 filter함수 안에 있는 로직이 돌아간다.

이 객체안에는 어떤값이 yield 되어서 담겨있을 수도 있고, yield를 실행못하고 반환되면 그냥 그대로 끝나는 함수다
yield가 되어야지 그 반환된 generator 값을 가지고 다음 연산을 할 수 있다.

*map

function* map(f, iter) { // [2]
  for (const e of iter) { // [2.1]
    yield f(e);
  }
}

iterator를 받아서 각각의 인자에 콜백함수를 적용해주는 함수다

take

function take(length, iter) {
  const l = []
  for (const e of iter) {
    l.push(e);
    if (l.length == length) {
      return l;
    }
  }
    
  return l;
}

iterator에서 yield된 값을 돌면서 배열에 추가해준다. 배열이 원하는 길이만큼 채워졌다면 배열을 리턴한다

함수들을 사용한 예제

const list = [1, 3, 5, 7, 9];
const gen = filter(a => a > 1, list); // [3]
const gen2 = map(a => a + 1, gen); // [4]
console.log(gen2.next()) // [5]{ value: 4, done: false }

함수의 호출스택을 따라가보자

  • 우선 [3]이 실행되면 [4]는 처음에 generator를 리턴할 뿐 안에 있는 함수 로직실행하지 않는다.
  • [5] 에서 next()를 호출 했을 때가 되어서야 *map 함수가 실행된다
  • *map함수 안으로 들어가서 [2.1]를 보니 매개변수로 넘겨받은 iterable이 [3] 에서 *filter에서 리턴된 generator 객체인걸 알게되고
  • *filter에서 리턴된 generator 객체 안에 딱히 yield된 값이 없어서 처리를 할수 없게되자 gen.next()를 내부적으로 호출한다
  • 이제 함수 호출스택은 다시 *filter 매서드로 돌아왔다
    • filter 함수에서는 이제서야 iterable(여기서 받은건 배열, generator 객체가 아님)을 [1.1]과 같이 순회하면서 yield할게 있나 확인한다
    • 마침 f의 기준에 맞아서 yield 할게 있다면 yield를 해서 [5]로 돌아간다
  • [5]에서는 값을 평가한다
  • [4]에서 generate 된 값을 하나하나 평가해서 사용해보고 싶다면 반복문을 돌면서 yield된 값을 하나하나 처리할 수 있다
  • 각각의 순수함수들을 모나드에 속하는 함수가 아닌 일반함수로 구현해보자

reduce

    
    function reduce(f, acc, iter) {
      for(const a of iter) {
        acc = f(acc, a)
      }
    
      return acc;
    }

iterable로 받은걸 순회하면서 현재 값에 매개변수로 받은 초기값을 f로 적용해준다. f의 반환타입에 따라 달라진다. 타입스크립트에서 쓰려면 f의 반환타입을 따로 받아서 reduce 함수의 반환타입으로 정해줘야할듯 하다.

pipe

이제 저런 순수함수들을 pipelining 해서 구현해보자 최종 결과값을 도출하는 함수를 만들어보자

function pipe(value, ...funcs) {
  let acc = value;
  for (const f of funcs) {
    acc = f(acc);
  }
  return acc;
}
    
const l = [1, 2, 3, 4, 5] // 초기 값
    
const r = pipe(
  l,
  l => map(a => a * a, l),
  l => filter(a => a > 3, l),
  l => take(3, l)
)
console.log(r) // [ 4, 9, 16 ]

초기값으로 주어진 배열을 처음에 넣어주고 각각 함수의 리턴값을 다음 함수의 인자로 넣어주는 파이프라이닝을 실행하는 함수이다.
굳이 시작값이 배열이 아니라도 리턴값과 인자값의 타입만 맞으면 연산이 되어서 최종 결과가 나온다. pipe를 사용하는 다른 예를 더 보자

function do1(list) {
  let c = 0;
  for (const a of list) {
    c += a;
  }
    
  return c;
}
    
function do2(a) {
  return a * a;
}
    
const l = [1, 2, 3, 4, 5]
    
const r1 = pipe(
  l,
  do1,
  do2
)
    
console.log(r1) // 225

이렇게 같은 값이라도 다른 함수를 넣으니 결과가 완전히 달라졌다. 타입스크립트에서 쓰기에는 좀 더 까다로울 수 있겠지만 데이터를 가공할때는 비지니스로직이 직관적으로 보이기 때문에 더 좋을 수 있을 것 같다. 단 데이터 가공의 로직이 충분히 복잡해야 쓰는 보람이 있을 것 같다.

커링

커리함수는 함수를 받고 콜백함수를 리턴한다

function curry(f) {
  return (a, ...bs) => {
    return bs.length ? f(a, ...bs) : (...bs) => f(a, ...bs)
  }
}

커리함수를 사용하기 위해서는 원하는 함수를 커리함수로 감싸줘야 한다

const add = curry((a, b) => a + b);
const add5 = add(5, 10);
console.log(add5)

내가 사용하고싶은 함수는 a 랑 b를 더하는 함수인데 그걸 커리함수로 감싸줬다. 커리함수로 감싸주면 원하는 인자를 시간차로 전달할 수 있다.

function curry(f) {
  return (a, ...bs) => {
    return bs.length ? f(a, ...bs) : (...bs) => f(a, ...bs)
  }
}

리턴되는 함수를 보면 …bs의 길이가 있으면 함수를 실행해버린다. 길이가 있다는 말은 값이 하나라도 들어왔다는 소리다. 즉, “변수 b가 매개변수로 전달이 되었다면”으로 보면된다.

…bs 로 배열로 처리한 이유는 함수를 범용적으로 적용하기 위해서다. f로 넘어오는 함수의 매개변수가 몇개일지 모르기 때문이다.

우선 위에서 커리함수로 감싸줘서 add함수에 인자를 하나만 넘긴다면 어떻게 될까?

const add = curry((a, b) => a + b);
const add5 = add(5); // [Function (anonymous)]
/* 다른 무언가 중요한걸 함.... */
add5(10) // 15

함수를 리턴한다.
“인자가 덜 넘어왔으니깐 계산 못해줘. 다음에 인자 하나 더 넘기면 계산해줄게” 이런뜻이다.

리턴된 함수에 인자를 다시 넘겨주면 계산을 해준다. 5를 인자로 넘겨진 후에 다른 어떤일을 하고 나중에 10을 더해줘도 된다. 즉, 연산시점을 개발자가 정할 수 있다.

원하는만큼 인자가 다 넘어오면 함수를 리턴하는게 아니라 계산해서 값을 리턴한다

const add = curry((a, b) => a + b);
add(5, 10); // 15

이제 위에서 봤던 일반 함수를 curry로 감싸주자.

function curry(f) {
  return (a, ...bs) => {
    return bs.length ? f(a, ...bs) : (...bs) => f(a, ...bs)
  }
}
    
const filter = curry(function *(f, iter) {
  for (const e of iter) {
    if(f(e)) {
      // console.log(e)
      yield e;
    }
  }
})
    
const map = curry(function *(f, iter) {
  for (const e of iter) {
    yield f(e);
  }
})
    
const take = curry(function take(length, iter) {
  const l = []
  for (const e of iter) {
    l.push(e);
    if (l.length == length) {
      return l;
    }
  }

  return l;
})

커리로 감쌌다는건 첫번째 인자로 함수를 넘기고, 두번째 인자를 아무것도 안넘겼으니, 커리함수는 다음에 인자를 다시 받아서 함수를 실행할 수 있도록 함수 자체를 리턴한다.

이제 커리함수를 통해서 함수의 호출부를 더 간결하게 바꿀 수 있다.

const l = [1, 2, 3, 4, 5] // 초기 값

/* 변경 전 */
const r = pipe(
  l,
  l => map(a => a * a, l),
  l => filter(a => a > 3, l),
  l => take(3, l)
)

/* 중간 과정 */
const r = pipe(
  l,
  l => map(a => a * a)(l),
  l => filter(a => a > 3)(l),
  l => take(3)(l)
)

/* 변경 후 */
const r = pipe(
  l,
  map(a => a * a), // [1]
  filter(a => a > 3), // [2]
  take(3) // [3]
)

중간 과정을 보면 l을 매개변수로 받아서 맵을 실행하고 넘겨받은 함수에 다시 l을 넘겨준다.

pipe 함수는 이전함수의 리턴값현재함수의 인자가 맞다면 실행시켜주기 때문에 그냥 map함수를 실행한 리턴만 pipe 함수의 인자로 넘겨줘도 실행이 된다

generator 함수를 활용한 시간복잡도 최적화

다시한번 위의 과정을 살펴보면 [1]과 [2]에서 두 함수는 generator 함수이다. 그 말은 함수가 호출되었을 당시에는 generator만 리턴을 할 뿐 함수 안에 있는 비지니스 로직을 실행하지 않는다.

두 함수 모두 함수 안에 있는 비지니스 로직은 take 함수 안에서 generator 객체에 대한 반복문이 실행되었을 때 이루어진다.

즉, map에서 generating된 하나의 value에 대해서 콜백함수 (a*a) 가 실행이 되고나서 “실행된 값 하나가 포함된 generator”가 filter함수의 평가 대상으로 넘어간다.

filter 함수에서는 해당 인자가 a > 3 의 기준 넘어가는지만 판별해서 take 함수에 generator 객체를 다시 생성해서 값을 전달한다

take 함수에서는 filter 함수로 부터 넘겨받은 generator 객체안에 있는 value를 확인해서 값이 있으면 리턴하고자 하는 배열에 담는다

take함수에서 모든 조건(배열의 길이가 3)이 충족되어서 함수가 리턴되면 더이상 iterator 를 돌 필요가 없이 값을 리턴해버린다

즉, generator 객체를 활용해서 값을 실제로 사용하는 take함수에서 필요한 만큼만 반복문을 돌기 때문에 시간복잡도가 줄어든다

Future Monad

자바스크립트에서는 Promise가 future monad에 해당한. Promise는 어떤걸 비동기로 실행하고, 실행된 값을 .then으로 뽑아쓰기 위해서 존재한다고 보는 것 보다는, 미래에 실행될 어떤 기능을 일급객체로 관리할 수 있도록 하는것에 가장 큰 의미가 있다

예를들어

const printDelayed = (a, t) => new Promise((resolve) => {
  setTimeout(() => resolve(a + 50), t)
})

미래에 실행될 setTimeout 이라는 기능은 Promise 라는 future monad에 감싸져서 resolved, rejected, pending 이라는 상태를 가지고 있게 된다.

만약 그냥 Promise 라는 future monad로 감싸주는 것 없이 setTimeout(() => resolve(a + 50), t) 이걸 실행한다면 중간에 오류가 나면 그 다음 동작을 실행할 수 없다.

Promise 라는 모나드가 이게 오류가 나도 시스템 오류로 던져버리지 않고 모나드 안에 “rejected”라는 상태로 들고 있기 때문에 이런 연산의 흐름이 가능해지는 것이다.

그러면 printDelayed(1, 100).then(e ⇒ e + 1).then(e ⇒ e + 2); 이런 .then 체이닝을 하게 되어도 실행 도중 에러가 나도 시스템 오류로 번지지 않는다.

그렇다면 이제 위에서 순수함수 pipelining을 했던 것 처럼 비동기 함수의 파이프 라이닝을 만들어보자.

function pipeAsync(value, ...funcs) {
  let acc = value;
  for (const f of funcs) {
    acc = acc.then(e => f(e));
  }
  return acc;
}

const printDelayed = (a, t) => new Promise((resolve) => {
  setTimeout(() => resolve(a + 50), t)
})

pipeAsync(
  Promise.resolve(1),
  a => a + 1,
  a => printDelayed(a, 1000)
).then(e => console.log(e)) // 52

이 함수는 첫번째로 전달되는 value가 Promise 객체라는 것을 전제로 한다. 그래서 Promise 객체에서 .then을 꺼내서 거기에 다시 promise를 리턴하는 함수를 적용해준다

profile
Quit talking, Begin doing

0개의 댓글