Maybe 타입 알아보기

2017년 03월 31일


함수 파생시키기

함수형 프로그래밍에선 여러 함수를 짜맞추어 새로운 함수를 만드는 일이 많습니다.

이러한 함수의 파생에 제네릭을 더하면 좀 더 함수를 유연하게 사용할 수 있습니다.

Maybe에 ap 적용하기

함수를 파생시키기에 앞서, 파생에 사용할 함수에 ap에 대해 우선 알아봅니다.

배열에 대한 ap은 함수의 배열과 값의 배열을 받아 동작했다면, Maybe에 대한 ap은 함수에 대한 Maybe와 값에 대한 Maybe로 동작합니다.

Maybe에 대한 ap은 두 인자가 모두 Just인 경우에만 적용되어 Just를 반환하고 Nothing이 섞여있는 경우는 Nothing을 반환합니다.

import * as R from 'ramda';
import * as S from 'sanctuary';

const mf = S.Just(R.inc);
const mg = S.Nothing;
const mx = S.Just(42);
const my = S.Nothing;

R.ap(mf, mx); // Just(43)
R.ap(mf, my); // Nothing
R.ap(mg, mx); // Nothing
R.ap(mg, my); // Nothing

Maybe에 대한 ap의 성질

Maybe에 대한 ap의 성질은 배열에 대한 ap의 성질과 비슷합니다.

인자로 결과값의 Just, Nothing을 예측할 수 있다

이는 배열과 비슷한 성질입니다. ap의 인자를 보고 결과값을 예측할 수 있습니다.

다음 표는 Maybe에 대한 ap의 결과값에 대한 경우의 수 테이블입니다. 가로 축은 첫 번째 인자(함수의 Maybe), 세로 축은 두 번째 인자(값의 Maybe)를 나타냅니다.

N J
N N N
J N J

Maybe를 배열의 특별한 경우로 나타낼 수 있다는 점을 착안해서 위의 표를 배열의 길이에 대한 테이블로 바꿀 수 있습니다.

0 1
0 0 0
1 0 1

이는 배열에 대한 ap의 결과값은 인자의 길이의 곱으로 예측할 수 있다는 성질과 같습니다.

이어서 호출하는 경우 계산을 하나로 합칠 수 있다

Maybe에 대한 ap도 계산 순서를 바꿀 수 있습니다.

배열과 마찬가지로 연속한 ap의 호출을 하나로 합쳐서 적용합니다.

import * as R from 'ramda';
import * as S from 'sanctuary';

const mx = S.Just(-41);
const mf = S.Just(R.inc);
const mg = S.Just(R.negate);

const compose2 = (f) => (g) => (x) => f(g(x));
const f = R.compose(R.ap(mf), R.ap(mg));
const g = R.ap(R.ap(R.map(compose2, mf), mg));

f(mx); // Jusr(42)
g(mx); // Jusr(42)

f는 함수에 대한 Maybe를 값에 대한 Maybe에 두 번 ap을 적용하지만, g는 함수에 대한 Maybe를 먼저 구한 뒤 값에 대한 Maybe에 한 번만 ap을 적용합니다.

다음은 이러한 성질을 들여다보기 위해 출력 사이드 이펙트를 섞은 코드입니다.

import * as R from 'ramda';
import * as S from 'sanctuary';

function spy(x) {
  console.log('sneaky sneaky');
  return x;
}
// const spy = R.tap(() => console.log('sneaky sneaky'));

const mx = S.Just(-41);
const mf = S.Nothing;
const mg = S.Just(spy);

const compose2 = (f) => (g) => (x) => f(g(x));
const f = R.compose(R.ap(mf), R.ap(mg));
const g = R.ap(R.ap(R.map(compose2, mf), mg));
//    g = R.ap(S.Nothing)

f(mx); // Nothing (sneaky sneaky)
g(mx); // Nothing

f는 순차적으로 ap을 적용하기 때문에 spy를 실행시키지만, g는 함수에 대한 Maybe가 Nothing이 되기 때문에 spy가 실행되지 않는 것을 확인할 수 있습니다.

함수 유도하기

이전 강좌에서는 기존의 함수들을 짜맞추어 배열에 적용할 수 있는 새로운 함수를 파생시켰습니다. 이번에는 Maybe, 더 나아가 제네릭한 타입을 받을 수 있도록 함수를 파생시켜봅니다.

map 유도하기

이전 강좌에서 배열에 대한 ap을 통해 map을 파생시킬때는 다음과 같았습니다.

import * as R from 'ramda';

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

같은 방식으로 Maybe에 대한 map을 파생시킬 수 있습니다. Maybe에 대한 of는 Just의 생성자에 해당하기 때문에 다음과 같이 표현할 수 있습니다.

import * as R from 'ramda';
import * as S from 'sanctuary';

const map = (f, mx) => R.ap(S.Just(f), mx);

Sanctuary의 of 함수를 이용하면 좀 더 제네릭한 map을 만들 수 있습니다. of 함수는 첫 번째 인자로 타입을, 두 번째 인자로 생성할 값을 받습니다

인자로 받은 값의 constructor로 타입을 알아내고, 이 타입에 대한 of를 함수에 적용시킨 뒤 ap을 호출합니다.

import * as R from 'ramda';
import * as S from 'sanctuary';

const foo = S.of(Array);
const bar = S.of(S.Maybe);

foo(42); // [42]
bar(42); // Just(42)

const map = (f, m) => R.ap(S.of(m.constructor, f), m);
map(R.inc, [1, 2, 3]);  // [2, 3, 4]
map(R.inc, S.Just(41)); // Just(42)

lift 유도하기

map과 ap이 다형성을 갖고있기 때문에 이에만 의존하는 lift는 이미 다형성을 갖고 있습니다. 즉, 배열뿐만 아니라 map과 ap이 호환되는 모든 타입은 lift도 사용할 수 있습니다.

Maybe는 map과 ap 모두 호환되는 타입이기 때문에 별 다른 처리 없이 lift된 함수의 인자로 사용할 수 있습니다.

다음의 lift의 구현은 이전 강좌 배열 제대로 다루기에서 사용한 구현과 완전히 동일합니다.

import * as R from 'ramda';
import * as S from 'sanctuary';

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

const a = S.Just(200);
const b = S.Just(10);
const c = S.Just(3);

g(a, b, c); // Just(213)

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

import * as R from 'ramda';
import * as S from 'sanctuary';

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

const a = S.Just(200);
const b = S.Just(10);
const c = S.Just(3);

g(a, b, c);
// f 커링, m: Just(200), ms: [Just(10), Just(3)]
// reduce 호출
R.reduce(
  R.ap, 
  R.map((x) => (y) => (z) => x + y + z, S.Just(200)), 
  [S.Just(10), S.Just(3)]
);

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

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

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

// 모든 인자 소모, reduce 종료
// Just(200 + 10 + 3)

위 과정은 인자에 Nothing이 없어서 끝까지 Just의 계산이 이뤄졌지만, Nothing이 있었다면 계산 도중 Nothing이 발생할 것이고 Maybe에 대한 ap의 동작에 의해 끝까지 Nothing이 유지됐을 것입니다.

import * as R from 'ramda';
import * as S from 'sanctuary';

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

const a = S.Just(200);
const b = S.Nothing;
const c = S.Just(3);

g(a, b, c); // Nothing