함수형 프로그래밍 - go, pipe, curry 함수

함수형 프로그래밍은 코드를 값으로 다루어 표현력을 높이는 방법을 배울 수 있습니다. 

  • `go` 함수: 인자에 들어있는 함수들을 차례대로 실행하여 하나의 값으로 만들어나가는 함수
  • `pipe` 함수: 여러 함수들을 합성하여 하나의 함수로 리턴해주는 함수
  • `curry` 함수: 값으로 다루면서 받아둔 함수를 원하는 시점에 평가시키는 함수

차례대로 공부해봅시다. 앞으로의 모든 코드들에는 `reduce` 함수가 있으니 `reduce` 함수에 대해 이해가 안되신 경우에는 제 이전 글을 보시면 감사하겠습니다.

 

1. go 함수

코드를 값으로 다룰 수 있기 때문에, 함수가 다른 함수를 인자로 받아 평가하는 시점을 원하는 대로 다룰 수 있습니다. 이를 통해 코드의 표현력을 높일 수 있습니다.

 

`go` 함수는 인자들을 특정 함수로 축약해서 하나의 값으로 만들어나갑니다.

const add = (a: number, b: number) => a + b;

const reduce = (fn: Function, acc: any, iter?: any) => {
  if (!iter) {
    iter = acc[Symbol.iterator]();
    acc = iter.next().value;
  }
  for (const item of iter) {
    acc = fn(acc, item);
  }
  return acc;
};

function go(...args: unknown[]) {
  return reduce((a: number, f: Function) => f(a), args);
}

go(
  0,
  (a: number) => a + 1,
  (a: number) => a + 10,
  (a: number) => a + 100,
  console.log
);

go(
  add(0, 1),
  (a: number) => a + 1,
  (a: number) => a + 10,
  (a: number) => a + 100,
  console.log
);

 

`go` 함수를 봅시다.

`go` 함수는 매개변수로 특정한 값과 함수들을 받습니다. `go`함수를 사용한다면, 매개변수로 중첩된 함수들을 순서대로 나열하여 가독성을 높일 수 있게 됩니다.

첫 번째 매개변수는 특정한 값이어야 합니다. 왜냐하면 `acc`로 사용해야하기 때문이죠.

이후, 뒤에 들어오는 매개변수들을 순서대로 실행하는데, `reduce`의 첫 번째 인자로 함수를 실행하는 함수를 넘겨주었기 때문입니다.

매개변수로 받은`args`는 `reduce` 함수 내에서 `acc`에 0, `iter`에 나머지 함수들의 목록을 넘겨주게 됩니다.

그리고 for ... of 문에서 `iter`에 들어있는 함수들을 순차적으로 실행해 주게 되는 것이죠. (`fn`이 `reduce`의 첫번째 인자이므로)

 

`go`함수를 사용하면 코드를 좀 더 가독성있게 표현할 수 있게 됩니다. 아래의 예시를 보시면 체감이 되실 겁니다!

type Product = {
  name: string;
  price: number;
};

const products: Product[] = [
  { name: "반팔티", price: 15000 },
  { name: "긴팔티", price: 20000 },
  { name: "핸드폰케이스", price: 15000 },
  { name: "후드티", price: 30000 },
  { name: "바지", price: 25000 },
];

log(
  reduce(
    add,
    map(p => p.price,
      filter(p => p.price < 20000, products))));
            

go(
  products,
  products => filter(p => p.price < 20000, products),
  products => map(p => p.price, products),
  price => reduce(add, prices),
  console.log
)

2.  pipe 함수

`pipe` 함수는 하나의 합성 함수로 만들어 줍니다.

먼저 코드를 보시죠.

const reduce = (fn: Function, acc: any, iter?: any) => {
  if (!iter) {
    iter = acc[Symbol.iterator]();
    acc = iter.next().value;
  }
  for (const item of iter) {
    acc = fn(acc, item);
  }
  return acc;
};

function go(...args: unknown[]) {
  return reduce((a: number, f: Function) => f(a), args);
}

// 함수를 리턴하는 함수여야 함 
function pipe(...fns: Function[]) {
  return <T>(a: T) => go(a, ...fns);
}

// 3개의 함수를 연속적으로 실행하면서 축약하는 함수를 만듦
const fn = pipe(
  (a: number) => a + 1,
  (a: number) => a + 10,
  (a: number) => a + 100
);

console.log(fn(0)); // 111

 

`pipe` 함수는 `go` 함수를 이용하여 만드는데, 차이점은 초기값을 나중에 넣을 수 있도록 해준다는 점입니다.

`go`함수는 인자로 `초깃값, 실행함수1, 실행함수2, ...` 이렇게 넘겨서 함수 실행 즉시 값을 반환하지만 `pipe` 함수는 인자로 `실행함수1, 실행함수2, ...` 를 넘겨 하나의 합성함수를 반환한 후에 실행하고 싶은 곳에서 초깃값을 인자로 넘겨서 사용합니다.

 

코드가 조금 더 깔끔해진 것처럼 보입니다. 하지만 지금의 `pipe` 함수는 `fn(0, 1)`처럼 2개의 인자를 넣어도 제대로 작동되지 않습니다.

그렇다면 `pipe` 함수를 조금 변형을 해보도록 합시다.

const add = (a: number, b: number) => a + b;
const pipe = (f: Function, ...fns: Function[]) => {
  return <T>(...a: T[]) => go(f(...a), ...fns);
};
const fn = pipe(
  add,
  (a: number) => a + 1,
  (a: number) => a + 10,
  (a: number) => a + 100
);
console.log(fn(0, 1));

`pipe`의 매개변수를 분리함으로써 앞에서 이야기했던 문제는 해결할 수 있었습니다.

3. curry 함수

`curry` 함수는 함수를 값으로 다루면서 받아둔 함수를 원하는 시점에 평가시키는 함수입니다.

더 자세히 말하자면 함수를 받아서 함수를 리턴하는데, 인자를 받아서 원하는 개수의 인자가 들어온 경우, 실행시키는 함수입니다.

이 때, 인자는 계속 내부적으로 저장해둡니다.

 

먼저 `curry` 함수를 봅시다.

const curry =
  (fn: Function) =>
  (a: any, ..._: any[]) => {
    return _.length ? fn(a, ..._) : (..._: any[]) => fn(a, ..._);
  };

함수를 매개변수로 받아서 특정한 함수로 리턴해줍니다.

반환된 함수에 넣는 인자에 따라 반환된 값이 다릅니다.

  • 인자를 하나만 넣은 경우는 인자를 첫번째 인자에 넣어둔 함수를 다시 반환 (클로저)
  • 2개 이상을 넣은 경우는 함수를 실행해줍니다.
  const mult = curry((a: number, b: number, c: number) => a * b);

  console.log(mult(3)(2)); // 6
  console.log(mult(3, 2)); // 6

 

go, currry를 같이 사용하여 코드를 좀 더 깔끔하게 작성할 수 있습니다.

먼저 `filter`, `map`, `reduce` 함수를 `curry` 함수로 감쌉시다.

const curry =
    (fn: Function) =>
    (a: any, ..._: any[]) => {
      return _.length ? fn(a, ..._) : (..._: any[]) => fn(a, ..._);
    };

  const reduce = curry((fn: Function, acc: any, iter?: any) => {
    if (!iter) {
      iter = acc[Symbol.iterator]();
      acc = iter.next().value;
    }
    for (const item of iter) {
      acc = fn(acc, item);
    }
    return acc;
  });

  const filter = curry((f: Function, iter: Iterable<any>) => {
    const res: Product[] = [];
    for (const a of iter) {
      if (f(a)) res.push(a);
    }
    return res;
  });

  const map = curry((f: Function, iter: Iterable<any>) => {
    const res: any[] = [];
    for (const item of iter) {
      res.push(f(item));
    }
    return res;
  });

 

`curry` 함수를 감싸게 되면 코드를 다음과 같이 실행할 수 있게 됩니다.

// curry 함수 적용 이전
go(
  products,
  (products: Product[]) => filter((p: Product) => p.price < 20000, products)
  (products: Product[]) => map((p: Product) => p.price, products)
  (price: number)  => reduce(add, prices),
  console.log
)

// curry 함수 적용 이후
go(
  products,
  (products: Product[]) => filter((p: Product) => p.price < 20000)(products),
  (products: Product[]) => map((p: Product) => p.price)(products),
  (price: number) => reduce(add)(price),
  console.log
);

 

그냥 함수 호출을 2번으로 분리해준 것 이외에 차이점이 없어보일 수 있습니다. 하지만 다음과 같이 단축 호출을 하게 된다면 가독성과 표현력 면에서 많은 차이를 낼 수 있습니다.

go(
  products,
  filter((p: Product) => p.price < 20000),
  map((p: Product) => p.price),
  reduce(add),
  console.log
);