Maybe 타입 알아보기

2017년 03월 31일


Maybe 연쇄

Maybe가 값이 있거나 없는 객체를 표현한다면, Maybe에 대한 함수는 값이 있고 없고에 관계 없이 Maybe를 다룰 수 있게 해줍니다.

이 중 특히 유용한 것이 R.chain을 이용한 Maybe의 연쇄입니다. 값을 받아 Maybe를 반환하는 함수를 Nothing 걱정없이 이어줍니다.

Maybe에 chain 적용하기

Maybe에 대한 chain은 배열과 마찬가지로 map과 unnest의 조합으로 이해할 수 있습니다.

Maybe의 unnest는 간단합니다. Just가 인자로 오면 값을 꺼내고, Nothing이 인자로 오면 Nothing을 반환합니다.

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

const x = S.Just(S.Just(42));
const y = S.Just(S.Nothing);
const z = S.Nothing;

R.unnest(x); // Just(42)
R.unnest(y); // Nothing
R.unnest(z); // Nothing

unnest를 이용해서 chain의 동작을 살펴보면 다음과 같습니다.

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

const f = (x) => x > 40 ? S.Just(x) : S.Nothing;
const x = S.Just(42);
const y = S.Just(30);
const z = S.Nothing;

const a = R.map(f, x); // Just(Just(42))
R.unnest(a);           // Just(42)
R.chain(f, x)          // Just(42)

const b = R.map(f, y); // Just(Nothing)
R.unnest(b);           // Nothing
R.chain(f, y)          // Nothing

const c = R.map(f, z); // Nothing
R.unnest(c);           // Nothing
R.chain(f, z)          // Nothing

chain은 결과값이 없을 수 있는 함수들을 순서대로 실행해야 하는 상황에 유용하게 사용할 수 있습니다.

다음은 comment 데이터에서 user를 알아내 user의 profile에 접근하는 코드입니다. comment를 작성한 user가 탈퇴한 경우와, 모종의 이유로 user에 대한 profile이 없는 경우가 있다 가정하고 이에 대한 적절한 처리를 추가했습니다.

import * as R from 'ramda';

const comments = [
  { id: 12, payload: 'merong', userId: 87 },
  { id: 13, payload: 'oyster', userId: 22 },
  { id: 14, payload: 'bikini', userId: 51 },
];

const users = [
  { id: 87, plan: 'pro', profileId: 15 },
  { id: 22, plan: 'free', profileId: 81 }
];

const profiles = [
  { id: 15, nickname: 'Jelly Fish' }
];

const findComment = (id) => R.find(R.propEq('id', id), comments);
const getUser = (comment) => R.find(R.propEq('id', comment.userId), users);
const getProfile = (user) => R.find(R.propEq('id', user.profileId), profiles);

function findProfile(commentId) {
  const comment = findComment(commentId);
  if (!comment)
    return undefined;
  const user = getUser(comment);
  if (!user)
    return undefined;
  return getProfile(user);
}

findProfile(12); // { id: 15, nickname: 'Jelly Fish' }
findProfile(13); // undefined
findProfile(14); // undefined

위의 방식은 findProfile이 올바르게 실행되는 것을 보장하기 위해 실행 사이사이에 undefined를 검사합니다. 이를 Maybe로 엮으면 다음과 같습니다.

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

const comments = [
  { id: 12, payload: 'merong', userId: 87 },
  { id: 13, payload: 'oyster', userId: 22 },
  { id: 14, payload: 'bikini', userId: 51 },
];

const users = [
  { id: 87, plan: 'pro', profileId: 15 },
  { id: 22, plan: 'free', profileId: 81 }
];

const profiles = [
  { id: 15, nickname: 'Jelly Fish' }
];

const findComment = (id) => S.toMaybe(R.find(R.propEq('id', id), comments));
const getUser = (comment) => S.toMaybe(R.find(R.propEq('id', comment.userId), users));
const getProfile = (user) => S.toMaybe(R.find(R.propEq('id', user.profileId), profiles));

/*
 * @param  commentId: number
 * @return profile:   Maybe<Profile>
 */
const findProfile = R.pipeK(findComment, getUser, getProfile);
//    findProfile = R.pipe(findComment, R.chain(getUser), R.chain(getProfile))

findProfile(12); // Just({ id: 15, nickname: 'Jelly Fish' })
findProfile(13); // Nothing
findProfile(14); // Nothing

pipeK는 pipe와 비슷합니다. 첫 번째 인자로 받은 함수는 그대로 실행하고, 그 이후 함수는 R.chain으로 한 번 커링한 뒤 실행합니다.

Maybe와 pipeK를 이용한 방식은 중간에 직접 undefined를 검사하는 과정이 없습니다. 덕분에 findProfile를 함수 합성으로 표현할 수 있고, 좀 더 실수할 여지가 적은 코드를 만들 수 있습니다.

Maybe에 대한 chain의 성질

map, ap과 마찬가지로 Maybe에 대한 chain은 배열과 비슷한 몇 가지 특징이 있습니다.

인자가 Just인 경우 결과값을 예측할 수 없다

chain은 map한 뒤 unnest를 하는 함수입니다. 때문에 인자가 Nothing인 경우에는 항상 Nothing을 반환하는 것을 예측할 수 있습니다.

하지만 Just인 경우에는 얘기가 다릅니다. chain에 첫 번째 인자로 전달되는 함수는 값을 받아 Maybe값을 반환하기 때문에 다시 Just가 될 수도, Nothing이 될 수도 있습니다.

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

Maybe에 chain을 적용한 뒤, 이 결과값에 chain을 적용하는 동작은 적용할 chain들을 하나의 함수로 엮은 뒤 Maybe에 한 번만 적용하는 것과 결과가 같습니다.

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

const mx = S.Just(5);
const my = S.Just(12);
const mz = S.Just(-7);

const rectifyL = (x) => 0 < x ? S.Just(x) : S.Nothing;
const rectifyR = (x) => x < 9 ? S.Just(x) : S.Nothing;

const f = R.pipe(R.chain(rectifyL), R.chain(rectifyR));
const g = R.chain((x) => R.chain(rectifyR, rectifyL(x)));

R.equals(f(mx), g(mx)); // true -> Just(5)
R.equals(f(my), g(my)); // true -> Nothing
R.equals(f(mz), g(mz)); // true -> Nothing

코드를 보기만 해서는 어떤 차이가 있는지 알기 어렵습니다.

이 두 방식의 차이는 chain을 map과 unnest로 나눠서 볼 때, 두 함수가 호출되는 순서에 있습니다.

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

const mx = S.Just(5);
const my = S.Just(12);
const mz = S.Just(-7);

const rectifyL = (x) => 0 < x ? S.Just(x) : S.Nothing;
const rectifyR = (x) => x < 9 ? S.Just(x) : S.Nothing;

const f = R.pipe(R.map(rectifyL), R.unnest, R.map(rectifyR), R.unnest);
const g = R.pipe(R.map(rectifyL), R.map(R.map(rectifyR)), R.unnest, R.unnest);

// call    -> map(rectifyL) -> unnest  -> map(rectifyR) -> unnest
// Just(5) -> Just(Just(5)) -> Just(5) -> Just(Just(5)) -> Just(5)
f(mx);

// call    -> map(rectifyL) -> map(map(rectifyR))  -> unnest x2
// Just(5) -> Just(Just(5)) -> Just(Just(Just(5))) -> Just(5)
g(mx);

// call     -> map(rectifyL)  -> unnest   -> map(rectifyR) -> unnest
// Just(12) -> Just(Just(12)) -> Just(12) -> Just(Nothing) -> Nothing
f(my);

// call     -> map(rectifyL)  -> map(map(rectifyR))  -> unnest x2
// Just(12) -> Just(Just(12)) -> Just(Just(Nothing)) -> Nothing
g(my);

// call     -> map(rectifyL) -> unnest  -> map(rectifyR) -> unnest
// Just(-5) -> Just(Nothing) -> Nothing -> Nothing       -> Nothing
f(mz);

// call     -> map(rectifyL) -> map(map(rectifyR))  -> unnest x2
// Just(-5) -> Just(Nothing) -> Just(Nothing)       -> Nothing
g(mz);

f는 너비를 우선으로 동작하고 매번 unnest를 호출하기 때문에 Maybe가 깊어지지 않지만

g는 깊이를 우선으로 동작하고 마지막에 몰아서 unnest를 호출하기 때문에 계산 과정에서 Maybe가 여러 단계로 중첩되는 특징이 있습니다.

사이드 이펙트가 없다면 둘의 결과는 항상 같기 때문에 상호교환이 가능합니다.