컴포지션과 파이프라인

함수형 컴포지션(functional composition) 이란 간단히 함수형 프로그래밍에서 컴포지션이라 일컫는다.
컴포지션 아이디어의 이론과 간단한 예제를 살펴본 후 compose 함수를 작성해본다.

함수형 컴포지션

map, filter 다시 살펴보기

1
2
3
4
5
6
let addressBooks = [
{id: 111, title: "C# 6.0", author: "ANDREW TROELSEN", rating: [4.7], reviews: [{good: 4, excellent: 12}]},
{id: 222, title: "Efficient Learning Machines", author: "Rahul Khanna", rating: [4.5], reviews: []},
{id: 333, title: "Pro Angular JS", author: "Adam Freeman", rating: [4.0], reviews: []},
{id: 444, title: "Pro ASP.NET", author: "Adam Freeman", rating: [4.2], reviews: [{good: 14, excellent: 12}]},
]

위와 같은 구조의 배열 객체가 있을때 review 값이 4.5 이상인 addressBooks 객체에서 title 과 author 를 뽑아내려고 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const map = (array, fn) => {
let results = [];
for (const value of array) {
results.push(fn(value));
}
return results;
}

const filter = (array, fn) => {
let results = [];
for (const value of array) {
if (fn(value)) results.push(value);
}
return results;
}

map(filter(addressBooks, book => book.rating[0] > 4.5), book => {
return {title: book.title, author: book.author}
})

/**
[
{
title: "C# 6.0",
author: "ANDREW TROELSEN"
}
]
*/

filter 함수에서 온 데이터는 map 함수에 입력 인자로 전달된다.
compose 함수를 사용하여 함수의 출력을 다른 함수의 입력으로 전달해 두 함수를 연결하는 함수를 만들 수 있다.

compose 함수

compose 함수의 생성은 쉽고 직관적이다. compose 함수는 함수의 출력을 받아 다른 함수에 입력한다.

간단한 compopse 함수 정의
1
const compose = (a, b) => c => a(b(c))

compose 함수는 간단하고 필요한 처리를 한다. 두 함수 a와 b를 받아 하나의 인자 c를 갖는 함수를 반환한다.
c에 값을 넣어 compose 함수를 호출하면 c의 입력을 갖는 함수 b를 호출하고, 함수 b의 출력은 함수 a의 입력으로 간다.
이것이 바로 compose 함수의 정의다.

compose 함수 다루기

주어진 수를 반올림한다고 해보자. 이 수는 실수이므로, 수를 실수형으로 변환한 후 Math.rorund 를 호출한다.
compose 없이 다음과 같이 할 수 있다.

1
2
let data = parseFloat("3.56");
let number = Math.round(data);

위에서 보듯이 data 는 Math.round 에 입력으로 전달된다. 이를 compose 함수를 사용하면 다음과 같다.

1
2
let number = compose(Math.round, parseFloat);
// number = c => Math.round(parseFloat(c))

이것이 바로 함수형 컴포지션이다. 두 함수를 구성해 새로운 함수를 구현했다.
여기서 중요한 것은 Math.round 와 parseFloat 함수는 number 함수가 호출되기 전까지 실행되지 않는다.

구원자: curry와 partial

함수가 하나의 인자를 입력으로 취할 때 두 함수를 합성할 수 있다. 하지만 여러 인자를 갖는 함수가 있으므로 항상 그렇지는 않다.
이러한 함수는 curry와 partial 함수 중 하나를 사용해 합성할 수 있다.

위에서 addressBooks 의 평점 코드를 다시 확인해보자.

1
2
3
map(filter(addressBooks, book => book.rating[0] > 4.5), book => {
return {title: book.title, author: book.author}
})

map 과 filter 함수 모두 두 인자를 취하는 함수다. 첫 번째 인자는 배열이고 두 번째 인자는 이 배열을 연산하는 함수다.
따라서 이 두 함수를 직접 합성할 수는 없다.

하지만 partial 함수에서 도움을 받을 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let addressBooks = [
{id: 111, title: "C# 6.0", author: "ANDREW TROELSEN", rating: [4.7], reviews: [{good: 4, excellent: 12}]},
{id: 222, title: "Efficient Learning Machines", author: "Rahul Khanna", rating: [4.5], reviews: []},
{id: 333, title: "Pro Angular JS", author: "Adam Freeman", rating: [4.0], reviews: []},
{id: 444, title: "Pro ASP.NET", author: "Adam Freeman", rating: [4.2], reviews: [{good: 14, excellent: 12}]},
]

let filterOutStandingBooks = book => book.rating[0] === 5;
let filterGoodBooks = book => book.rating[0] > 4.5;
let filterBadBooks = book => book.rating[0] < 3.5;

let projectTitleAndAuthor = book => ({title: book.title, author: book.author});
let projectAuthor = book => ({author: book.author});
let projectTitle = book => ({title: book.title});

위 코드와 같이 rating 을 기반으로 도서를 필터링하는 여러 작은 함수와 결과를 도출해내는 함수를 만들었다.
이제 문제를 해결하는 데 다음과 같이 compose 와 partial 을 사용할 수 있다.

1
2
3
let queryGoodBooks = partial(filter, undefined, filterGoodBooks);
let mapTitleAndAuthor = partial(map, undefined, projectTitleAndAuthor);
let titleAndAuthorForGoodBooks = compose(mapTitleAndAuthor, queryGoodBooks);

compose 함수는 하나의 인자를 갖는 함수를 합성한다. 하지만 filter 와 map 둘 다 인자 두 개를 취하고, 직접적으로 이를 합성할 수 없다.

여기서 보듯이 이것이 바로 map 과 filter 의 두 번째 인자에 부분적으로 적용하고자 partial 함수를 사용한 이유다.
이제 compose 함수를 사용하여 반환된 함수를 실행하여 결과를 확인한다.

1
2
3
4
5
6
7
8
9
titleAndAuthorForGoodBooks(addressBooks);
/**
[
{
title: "C# 6.0",
author: "ANDREW TROELSEN"
}
]
*/

compose 함수 없이도 원하는 것을 정확히 얻을 수 있었다. 하지만 최종 합성된 버전인 titleAndAuthorForGoodBooks 는 훨씬 가독성이 높고 간결하다.
필요에 따라 compose 를 사용해 재구성할 수 있는 작은 함수 단위를 생성하는 것이 중요하다.

여러 함수 합성

현재 compose 함수 형태는 주어진 두 함수만 합성한다. compose 를 다시 작성하여 함수가 세 개, 네 개 이상 합성해보려 한다.

각 함수의 출력을 다른 함수의 입력으로 전달해야 한다고 해보자. n개의 함수 호출을 한 번으로 축소하는 reduce 함수를 사용해 compose 함수를 작성해본다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const reduce = (array, fn, initialValue) => {
let accumlator;

if (initialValue !== undefined) accumlator = initialValue;
else accumlator = array[0];

if (initialValue === undefined) {
for (let i = 1; i < array.length; i++) {
accumlator = fn(accumlator, array[i]);
}
} else {
for (const value of array) {
accumlator = fn(accumlator, value);
}
}

return accumlator;
};

const _compose = (...fns) => value => reduce(fns.reverse(), (acc, fn) => fn(acc), value);

fns.reverse() 를 통해 함수 배열을 뒤집고, acc 값을 인자로 전달하면 각 함수에서 다른 함수로 호출하는
(acc, fn) => fn(acc) 로 함수를 전달한다.

1
2
3
4
5
6
7
8
9
let splitIntoSpaces = str => str.split(" ");
let count = array => array.length;
const countWords = _compose(count, splitIntoSpaces);

console.log(countWords("hello your reading about composition")) // 5

let oddOrEven = ip => ip % 2 === 0 ? "even" : "odd";
const oddOrEvenWords = _compose(oddOrEven, count, splitIntoSpaces);
console.log(oddOrEvenWords("hello your reading about composition")); // "odd"

파이프라인과 시퀀스

함수가 오른쪽부터 먼저 실행돼 가장 왼쪽의 함수가 마지막으로 실행될 때까지 다음 함수에 데이터를 전달하므로,
compose 의 데이터 플로우가 오른쪽에서 왼쪽임을 살펴봤다.

일부 사람은 가장 왼쪽의 함수가 먼저 실행되고 오른쪽 함수가 마지막에 실행되는 방법을 선호한다.
compose 함수와 동일한 동작을 하지만 데이터 플로우가 반대인 pipe 라는 새로운 함수를 구현할 것이다.

데이터를 왼쪽에서 오른쪽으로 이동하는 과정을 파이프라인 또는 짝수 시퀀스(even sequences) 라 부른다.
파이프 라인이나 시퀀스라고 편하게 부를 수도 있다.

파이프 구현

pipe 함수는 compose 함수의 복제 형태다. 변경된 부분은 데이터 플로우 뿐이다.

1
const pipe = (...fns) => value => reduce(fns, (acc,fn) => fn(acc), value);

compose 와의 차이는 reverse 함수를 쓰지 않는다는 것이다.

1
2
3
const pipe = (...fns) => value => reduce(fns, (acc,fn) => fn(acc), value);
const oddOrEvenWords = pipe(splitIntoSpaces, count, oddOrEven);
console.log(oddOrEvenWords("hello your reading about composition")); // "odd"

컴포지션은 결합 법칙이 성립한다.

함수형 컴포짓션은 항상 결합 법칙을 따른다. 예를 들어 일반적으로 결합 법칙은 표현의 결과를
괄호 순서에 상관없이 동일하게 한다.

1
2
x * (y * z) = (x * y) * z = xyz
compose(f, compose(g, h)) == compose(compose(f, g), h)

함수형 컴포지션은 결합이 가능하다. 따라서 compose 를 결합해 함수를 그룹화할 수 있다.

1
2
3
4
5
let countWords = compose(count, splitIntoSpaces);
let oddOrEvenWords = compose(oddOrEven, countWords);

let countOddOrEven = compose(oddOrEven, count);
let oddOrEvenWords = compose(countOddOrEven, splitIntoSpaces);

작은 함수를 생성하는 것은 컴포지션의 핵심이다. compose 는 결합할 수 있으므로 동일한 결과로 걱정 없이
컴포지션을 통해 작은 함수를 생성할 수 있다.

파이프라인 연산자

기본 함수를 합성하고 연결하는 또 다른 방법은 파이프라인 연산자를 사용하는 것이다.
새로운 파이프라인 연산자는 자바스크립트 함수 코드의 가독성과 확장성을 높인다.

단일 문자열 인자를 연산하는 다음 수학 함수를 살펴보자.

1
2
3
const double = n => n * 2;
const increment = n => n + 1;
const ntimes = n => n * n;

모든 숫자로 이 함수를 호출하려면 일반적으로 다음 문장을 작성해야 한다.

1
ntimes(double(increment(double(double(5)))));

위의 결과 값은 1764 이다. 이 문장의 문제는 가독성인데, 연산자의 시퀀스와 연산자의 개수는 가독성을 떨어뜨린다.
코드 가독성을 높이고자 유사한 연산자가 추가됐다. 이 연산자의 이름은 파이프라인(또는 이항 연산자) 이며 |> 이다.

1
5 |> double |> double |> increment |> double |> ntimes

파이프라인 연산자가 단항 함수에서만 동작하지만, 다항 인자 함수에서도 사용할 수 있는 방법이 있다.

1
2
3
4
5
6
7
8
let add = (x,y) => x + y;
let double = x => x + x;

// 파이프 연산자 없이
add(10, double(7));

// 파이프 연산자 사용
7 |> double |> (_ => add(10, _));

compose 디버깅

identity 라는 간단한 함수를 만들어 인자를 받고 동일한 인자를 반환한다.

1
2
3
4
const identity = it => {
console.log(it);
return it;
}

간단하게 console.log을 추가해 값을 출력하고 이를 다시 반환한다.

1
compose(oddOrEven, count, splitIntoSpaces)("Test string");

위와 같은 compose 함수가 있다고 했을때 identity 를 플로우 내에 추가하여 디버깅할 수 있다.

1
compose(oddOrEven, count, identity, splitIntoSpaces)("Test string");

참조: 함수형 자바스크립트 입문 2/e

댓글

You need to set client_id and slot_id to show this AD unit. Please set it in _config.yml.