배열과 함수의 응용

2017년 03월 22일


chain

chain, flatMap, >>=등 다양한 이름으로 불리는 이 함수는 악명높은 모나드에 직접 관련된 함수입니다.

다행히 동작은 ap보다 간단합니다.

unnest와 map의 조합

chain을 이해하는 방법은 여러가지가 있지만 가장 간단한 방법은 map과 unnest의 조합으로 이해하는 것입니다.

unnest는 다음과 같이 중첩된 배열(고차원 배열)을 한 단계 펼쳐주는(차원을 한 단계 낮추는) 함수입니다.

import * as R from 'ramda';

const foo = [[0, 1], [2, 3], [4], [5, 6, 7]]
R.unnest(foo); // [0, 1, 2, 3, 4, 5, 6, 7]

const bar = [[[0, 1], [2, 3]], [[4]], [[5, 6, 7]]]
R.unnest(bar); // [[0, 1], [2, 3], [4], [5, 6, 7]]

const myUnnest = (xs) => [].concat(...xs);
myUnnest(foo); // [0, 1, 2, 3, 4, 5, 6, 7]
myUnnest(bar); // [[0, 1], [2, 3], [4], [5, 6, 7]]

chain은 값을 받아 배열을 반환하는(차원을 높혀주는) 함수를 인자로 받아 map을 적용한 뒤 결과값에 unnest를 적용한 것과 같습니다.

말은 복잡하지만 chain은 특별한 함수를 인자로 map을 적용한 뒤, 결과를 한 번 unnest해주는 것입니다.

다음과 같이 동작합니다.

import * as R from 'ramda';

const toN = R.range(0);
const xs = toN(5); // [0, 1, 2, 3, 4]

const a = R.map(toN, xs);   // [[], [0], [0, 1], [0, 1, 2], [0, 1, 2, 3]]
const b = R.unnest(a);      // [0, 0, 1, 0, 1, 2, 0, 1, 2, 3]
const c = R.chain(toN, xs); // [0, 0, 1, 0, 1, 2, 0, 1, 2, 3]

chain의 첫 번째 인자로는 항상 값을 받아 배열을 반환하는 함수를 사용해야 합니다.

다르게 말하자면, 배열에 대해 한 차원 높여주는 함수를 인자로 받습니다.

import * as R from 'ramda';

const xs = R.range(1, 5);
R.chain((n) => [-n, n], xs); // [-1, 1, -2, 2, -3, 3, -4, 4]

다음은 잘못된 chain의 예시입니다. R.identity가 배열을 받아서 배열을 반환하지만, 배열의 차원(중첩된 정도)이 높아지지 않았습니다.

배열을 값으로 다룰때 값에 대한 배열을 반환한다는 것은 배열에 대한 배열을 반환해야 한다는 것을 의미합니다. 여기서는 배열을 받아 단순히 그대로 반환하기 때문에 조건에 어긋납니다.

재밌게도 이러한 호출은 잘못된 것이지만, R.unnest와 R.chain(R.identity)의 동작이 같습니다.

import * as R from 'ramda';

const xs = [[0, 1], [2, 3], [4], [5, 6, 7]];
R.chain(R.identity, xs); // [0, 1, 2, 3, 4, 5, 6, 7]

다음은 올바른 예시입니다. 인자로 배열(값)을 받고 배열(값)에 대한 배열을 반환하는 함수를 인자로 사용했습니다.

반환값은 map 함수와 비슷하지만, 중첩이 한 단계 풀어진 것을 확인할 수 있습니다.

import * as R from 'ramda';

const xs = [[0, 1], [2, 3]];
const foo = (xs) => [xs, xs];
R.chain(foo, xs); // [[0, 1], [0, 1], [2, 3], [2, 3]]
R.map(foo, xs);   // [[[0, 1], [0, 1]], [[2, 3], [2, 3]]]

배열에 대한 chain의 성질

map, ap과 마찬가지로 chain도 사이드 이펙트가 없을때 유용하게 사용할 수 있는 성질이 있습니다.

인자로 결과값의 길이를 예측할 수 없다

chain은 ap과는 다르게 결과값의 길이를 예측할 수 없습니다.

이는 성질이 줄어든 것이지만, 길이에 대한 제약이 사라졌기 때문에 chain은 ap이 할 수 없는 작업을 할 수 있습니다.

import * as R from 'ramda';

const xs = R.range(0, 5);
R.map(R.inc, xs); // [1, 2, 3, 4, 5]
// xs의 원소 하나가 1개의 결과값에 대응

const fs = [R.negate, R.identity];
R.ap(fs, xs); // [0, -0, 1, -1, 2, -2, 3, -3, 4, -4]
// xs의 원소 하나가 2개(fs의 길이)의 결과값에 대응

const toN = R.range(0);
const c = R.chain(toN, xs); // [0, 0, 1, 0, 1, 2, 0, 1, 2, 3]
// xs의 원소 다양한 갯수의 결과값에 대응

이어서 호출하는 경우 계산 순서를 바꿀 수 있다

마찬가지로 연속으로 호출하는 경우 계산 순서를 바꿀 수 있습니다. ap과 비슷하게 이어서 호출할 함수를 하나의 형태로 줄인 뒤 적용하는 방식입니다.

구현은 ap보다 간단합니다. 다음 예시는 편의를 위해 고정된 길이의 배열을 반환하는 함수 a와 b를 사용했지만, 어떤 길이의 배열을 반환하더라도 상관없습니다.

import * as R from 'ramda';

const xs = R.range(1, 4);

const a = (x) => [10 * x, 30 * x];
const b = (x) => [x + 1, x - 1];

const f = R.pipe(R.chain(a), R.chain(b));
const g = R.chain((x) => R.chain(b, a(x)));

R.equals(f(xs), g(xs)); // true

우선 f의 경우를 살펴보겠습니다. 이 방식은 배열에 너비 우선으로 2개의 함수를 적용하는 것으로 생각할 수 있습니다.

연속으로 chain을 호출하는 과정에 임시배열이 생성되는 특징이 있습니다.

a: (x) => [10 * x, 30 * x]
b: (x) => [x + 1, x - 1]

a
1 -> 10
  -> 30
2 -> 20
  -> 60
3 -> 30
  -> 90

b
10 -> 11
   ->  9
30 -> 31
   -> 29
20 -> 21
   -> 19
60 -> 61
   -> 59
30 -> 31
   -> 29
90 -> 91
   -> 89

반면에 g는 두 개의 함수를 하나의 함수로 축약한 뒤 chain을 적용합니다.

이 방식은 깊이 우선 방식으로 함수를 적용합니다. 덕분에 임시배열을 생성하지 않습니다.

a: (x) => [10 * x, 30 * x]
b: (x) => [x + 1, x - 1]
c: R.chain((x) => R.chain(b, a(x)))

c: (x) => [x * 10 + 1, x * 10 - 1, x * 30 + 1, x * 30 - 1]

c
1 -> 11
  ->  9
  -> 31
  -> 29
2 -> 21
  -> 19
  -> 61
  -> 59
3 -> 31
  -> 29
  -> 91
  -> 89

계산 순서를 바꾸는 것은 기본적으로 임시배열을 없애는 최적화에 사용할 수 있지만 훨씬 다양하게 응용할 수 있습니다.

예를들어 여러 갈래로 흩어지는 비동기 동작을 제어하거나, 타이머와 마우스 클릭 이벤트에 대한 스트림을 조합할때 이 성질을 사용할 수 있습니다.

그 밖에도 Comprehension 문법을 만들거나, Promise 없이 async 구문을 흉내내는 등 다양한 응용이 가능합니다.