배열과 함수의 응용

2017년 03월 22일


ap

ap은 인자로 받은 값의 배열에 여러개의 함수(함수의 배열)를 적용합니다. map이 할 수 있는 모든 동작을 포함해서, 좀 더 많은 일을 할 수 있습니다.

예시

다음과 같이 하나의 원소만을 갖는 배열에 대해서는 map과 동작이 같지만, 인자가 늘어나면 동작이 달라집니다.

여러개의 함수가 전달 되면 앞에 있는 함수를 먼저 적용합니다.

import * as R from 'ramda';

const xs = R.range(0, 3);

R.ap([R.inc], xs); // [1, 2, 3]
R.map(R.inc,  xs); // [1, 2, 3]

R.ap([R.inc, R.dec], xs); // [1, 2, 3, -1, 0, 1]
R.ap([R.dec, R.inc], xs); // [-1, 0, 1, 1, 2, 3]

배열에 대한 ap의 성질

ap도 map과 마찬가지로 사이드 이펙트가 없고 종료된다는 보장만 있을때 드러나는 유용한 성질이 있습니다.

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

map은 단순히 배열의 길이를 보존했지만, ap은 길이를 변경합니다. 하지만 이러한 변경에도 일정한 규칙이 있습니다.

import * as R from 'ramda';

const xs = R.range(0, 3);
const ys = R.range(0, 2);

R.ap([R.inc], xs); // [1, 2, 3]
R.ap([R.inc], ys); // [1, 2]

R.ap([R.inc, R.dec], xs); // [1, 2, 3, -1, 0, 1]
R.ap([R.inc, R.dec], ys); // [1, 2, -1, 0]

ap은 함수의 배열과 원소의 배열을 조합하는 동작이기 때문에, 결과값의 길이는 항상 이 두 배열 길이의 곱이 됩니다.

위 경우 첫 번째와 두 번째는 함수 배열 길이가 1이기 때문에 값 배열의 길이가 보존되지만

세 번째와 네 번째는 함수 배열의 길이가 2이기 때문에 결과값의 길이는 값 배열 길이의 두 배입니다.

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

연속적으로 호출하는 경우 계산 순서를 바꿀 수 있습니다.

ap을 연속으로 호출하는 것과, 연속으로 호출할 때 사용하는 함수의 배열들을 하나의 함수의 배열로 조합한 뒤 ap을 호출하는 것과 결과가 같습니다.

코드로 작성하면 다음과 같습니다.

import * as R from 'ramda';

const xs = R.range(1, 4);
const fs = [R.inc, R.dec];
const gs = [R.multiply(10), R.multiply(30)];
const compose2 = (f) => (g) => (x) => f(g(x));

const f = R.compose(R.ap(fs), R.ap(gs));
const g = R.ap(R.ap(R.map(compose2, fs), gs));

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

우선 ap을 두 번 호출하는 f의 동작을 살펴보겠습니다.

ap의 호출 결과로의 결과로 임시배열이 생성되고, 이 배열을 다음 ap의 인자로 사용합니다.

[R.multiply(10), R.multiply(30)]
 R.multiply(10)  R.multiply(30)
1 -> 10          1 -> 30
2 -> 20          2 -> 60
3 -> 30          3 -> 90

[R.inc, R.dec]
 R.inc      R.dec
 10 -> 11   10 ->  9
 20 -> 21   20 -> 19
 30 -> 31   30 -> 29
 30 -> 31   30 -> 29
 60 -> 61   60 -> 59
 90 -> 91   90 -> 89

반면에 g는 각각의 ap 호출에 사용하는 함수를 미리 조합해 새로운 함수의 배열을 만들고, 이 함수의 배열을 이용해 ap을 호출합니다.

이 방식은 한 번의 ap만으로 결과값을 내기 때문에 임시배열을 생성하지 않습니다.

a: R.pipe(R.multiply(10), R.inc)
b: R.pipe(R.multiply(30), R.inc)
c: R.pipe(R.multiply(10), R.dec)
d: R.pipe(R.multiply(30), R.dec)

[a, b, c, d]
 a         b         c         d
 1 -> 11   1 -> 31   1 ->  9   1 -> 29
 2 -> 21   2 -> 61   2 -> 19   2 -> 59
 3 -> 31   3 -> 91   3 -> 29   3 -> 89

함수 유도하기

함수형 프로그래밍에서는 함수들을 조합해서 새로운 함수를 파생시키는 일이 많습니다.

ap도 마찬가지입니다. 몇 가지 함수들과 조합하면 새로운 함수를 유도할 수 있습니다.

map 유도하기

map은 ap의 특별한 경우로, 인자로 받은 함수의 배열이 하나의 함수만을 갖는 경우로 생각할 수 있습니다.

때문에 ap을 이용하면 자연스럽게 map을 만들 수 있습니다.

import * as R from 'ramda';

const map = (f, xs) => R.ap([f], xs);

위 코드만 보면 ap만 있으면 map을 유도할 수 있을 것 같지만 그렇지 않습니다.

다시 한 번 살펴봅시다.

import * as R from 'ramda';

const map = (f, xs) => R.ap(Array.of(f), xs);

자바스크립트에 익숙한 나머지 배열 리터럴을 써버렸습니다.

만약 Array.of나 배열 리터럴과 같이 값으로 부터 배열을 생성할 수단이 없었다면 ap만으로는 map을 만들지 못했을 것입니다.

이제는 확실히 말할 수 있습니다. ap과 of만 있으면 map을 파생시킬 수 있습니다.

lift 유도하기

lift는 ap과 여러가지 함수를 조합해 유도할 수 있습니다.

여기서는 ap, map, curry, reduce 4개의 함수를 조합해 lift를 유도했습니다.

코드 길이만 보면 한 줄로 쉬워보이지만 과정은 만만치 않습니다.

import * as R from 'ramda';

const lift = (fn) => (xs, ...xss) => R.reduce(R.ap, R.map(R.curry(fn), xs), xss);
const f = (x, y, z) => x + y + z;
const g = lift(f);

const as = [200, 500]
const bs = [10, 80];
const cs = [3, 7];

g(as, bs, cs); // [213, 217, 283, 287, 513, 517, 583, 587]

함수가 실행되는 과정을 단계별로 살펴보겠습니다.

import * as R from 'ramda';

const lift = (fn) => (xs, ...xss) => R.reduce(R.ap, R.map(R.curry(fn), xs), xss);
const f = (x, y, z) => x + y + z;
const g = lift(f);

const as = [200, 500]
const bs = [10, 80];
const cs = [3, 7];

g(ab, bs, cs); 
// f 커링, xs: [200, 500], xss: [[10, 80], [3, 7]]
// reduce 호출
R.reduce(
  R.ap, 
  R.map((x) => (y) => (z) => x + y + z, [200, 500]), 
  [[10, 80], [3, 7]]
);

// R.map 평가
// 커링된 함수를 map 했기 때문에 결과 값이 다시 함수
R.reduce(
  R.ap,
  [(y) => (z) => 200 + y + z, (y) => (z) => 500 + y + z],
  [[10, 80], [3, 7]]
);

// reduce 실행
// R.ap 적용
// 이전 map의 결과 값(초기 값)과 [10, 80]을 ap의 인자로 호출
R.reduce(
  R.ap, 
  [(z) => 200 + 10 + z, (z) => 200 + 80 + z, 
   (z) => 500 + 10 + z, (z) => 500 + 80 + z], 
  [[3, 7]]
);

// reduce 실행
// R.ap 적용
// ap의 결과 값과 [3, 7]을 ap의 인자로 사용
R.reduce(
  R.ap, 
  [200 + 10 + 3, 200 + 10 + 7,
   200 + 80 + 3, 200 + 80 + 7,
   500 + 10 + 3, 500 + 10 + 7,
   500 + 80 + 3, 500 + 80 + 7], 
  []
);

// 모든 인자 소모, reduce 종료
[213, 217, 283, 287, 513, 517, 583, 587];

map을 통해 초기값(커링된 함수의 배열)을 구하고, reduce를 통해 인자의 배열로 호출을 반복합니다.

reduce의 과정마다 커링이 조금씩 진행되고, 최종적으로는 커링이 끝나 값으로 평가됩니다.