당신은 이미 펑터Functor를 알고 있다

yokitomi·2021년 6월 8일
18
post-thumbnail

map 다시 보기

아마 JavaScript를 쓰면서 Arraymap 메소드를 사용해보지 않은 분은 거의 없을 겁니다. 그런데 혹시, map의 TypeScript 타입을 지금 바로 써내릴 수 있나요? 미리 외워두고 있지 않았더라도, 제네릭에 익숙하고 map 의 동작을 이해하신다면 금방 정답을 찾아낼 것입니다.

function map<T, U>(xs: T[], f: (value: T) => U): U[];

이걸 참고해서 이제 map을 구현해볼까요?

일단 첫번째 인자로 받은 xs: T[]가 있습니다. for문으로 순회하면 T 타입의 값을 얻을 수 있습니다. 이게 우리가 지금 T타입의 값을 얻을 수 있는 유일한 방법입니다.

for(const i of xs) {
  // i: T
}

T 타입의 값을 확보하고 나니, 이걸 어디다 써야할지가 보입니다. 바로 두번째 인자 f입니다. i 값을 쓸 곳도 f 밖에 없고, f를 호출할 방법도 i를 인자로 사용하는 것 외에는 없지요.

for(const i of xs) {
  const x = f(i);
}

x의 타입이 바로 U입니다. 우리가 하려던게 뭐였지요? U[] 타입의 값을 구해야지요.

const result = [];
for(const i of xs) {
  result.push(f(i));
}
return result;

마치 이케아 가구를 조립하는 것 같습니다. 부품 하나하나 적재적소에 꽂아야하는 곳이 있고, 조립을 끝내고보니 남은 부품이 하나도 없습니다.

이쯤에서 이런 생각도 들지 않나요? 혹시 map 의 타입을 만족하는 구현이 딱 저것만 있는게 아닐까? 설계도가 이리도 칼같은데, 완성본이 여러개일 수가 있을까요? 정말 map의 구현은 저게 유일한 걸까요?

아쉽게도 아닙니다.

function mapN<T, U>(xs: T[], f: (value: T) => U): U[] {
  return [];
}

이런 넌센스 구현도 있고,

function mapT<T, U>(xs: T[], f: (value: T) => U): U[] {
  const result = [];
  for(const i of xs) {
    const x = f(i);
    result.push(x);
    result.push(x); // 두번 push합니다
  }
  return result;
}

이런 트롤링 구현도 있습니다. 이 외에도 수많은 그저 타입만 만족하는 잘못된 map 구현이 존재합니다. map의 타입은 그 구현을 상당히 좁게 제약시키지만, 유일하게 제약시키진 못합니다.

그런데 잠깐, 여기서 잘못됐다는게 뭘 의미하는 거지요? 정확히 어느 부분이 문제라는 건가요?

Functor의 조건

우리는 직감적으로 기존의 map 구현은 자연스러운데 반해, 아래 두 예시는 뭔가 부자연스럽다고 느낍니다. 하지만 자연스럽다와 같은 모호한 단어로 특정 구현을 옹호하는건 썩 만족스럽지 못합니다. 좀더 나은 설명을 찾아봅시다.

먼저, mapN은 전혀 유용하지 않습니다. 저 함수를 현업에서 사용할일이 있을까요? 사실상 아무 기능도 없지 않습니까.

mapT는 뭐, 가끔 저런 동작이 필요할 때도 있겠지요. 하지만 기존의 map을 이용해 같은 동작을 깔끔하게 구현할 수 있습니다. (x.map((i) => [f(i), f(i)]).flat() 정도면 되겠네요. 반면, 반대로 하는건 지저분합니다. 기존의 map이 더 원자적이기에 더 범용적입니다.

사실, 여기까지만 해도 우리 개발자들을 설득하기엔 충분하다고 생각합니다. 하지만 수학자들은 더 정교한 설명을 진작에 준비해놓았습니다. 바로 functor를 통해 말입니다. functor의 정의는 우리가 막연히 자연스럽다라고 했던 것의 진짜 의미를 끄집어내줍니다.

functor의 map은 다음의 조건들을 만족해야합니다.

  • x.map(id) = x
  • x.map(f).map(g) = x.map(compose(f, g))

기존의 mapmapN, mapT가 이 조건들을 만족하는지 안하는지 살펴봅시다.

x.map(id) = x

id는 다음과 같은 타입을 만족하는 함수입니다.

function id<T>(x: T): T;

구현은 자명합니다.

function id<T>(x: T): T {
  return x;
}

혹시 이것외에 다른 구현이 있을까요? map이 그랬던 것처럼?

이번에는 없습니다. 저게 유일한 id 구현입니다. 👏👏👏

이 경우엔 정말로 타입이 함수의 완전한 설계도라고 할수 있겠네요.

이외에도 구현이 딱 2개만 있거나, 아예 없거나, id가 아니면서 구현이 딱 하나만 있는 타입들이 있습니다. 심심하면 찾아보세요.

JavaScript의 map 은 위의 조건을 만족할까요?

isEqual([1, 2, 3].map((i) => i), [1, 2, 3]) === true

물론입니다. 지극히 당연하다고 느껴지지 않나요? 그야 이미 알고 있었으니까요.

mapNmapT는 이 조건을 만족하지 않습니다. 전자는 빈 배열을 만들고, 후자는 배열을 두 배로 불려버리지요.

x.map(f).map(g) = x.map(compose(f, g))

compose(f, g)(i) => f(g(i))와 같습니다. 가장 기본적인 함수 합성이에요.

혹시 평소에 개발하다가 좌변과 같은 코드가 나왔을때, 왠지 더 예뻐보인다거나, 왠지 map을 두번 쓰기 싫다거나 해서 우변의 형태로 변경해본적이 있지 않나요? 꼭 compose를 쓰진 않았더라도 비슷한 방법으로 말입니다. 그랬다면 당신은 직감적으로 이 등식이 성립한다는걸 알고 있었던 겁니다!

실제로, 대부분의 경우 우변의 형태가 의미를 더 잘 나타냅니다. 좌변이 된장 따로, 고추장 따로 넣는거라면, 우변은 한번에 쌈장을 넣는 것이지요. 레시피에 어떻게 써있는게 낫나요?

mapN은 의외로 이 조건을 만족합니다. mapT는 그렇지 않습니다. 이유는 찬찬히 생각해보세요.


자, 지금가지 설명한 두조건을 모두 만족하면서 상기한 타입을 가진 map은 정말로 유일합니다. 그리고 그게 바로 우리가 쓰는 map이에요.

이 조건들과 자연스러움이 어떻게 연관이 되는 걸까요?

우리가 기존 map의 동작이 자연스럽다고 느끼는 점은, 바로 배열의 형태를 변경시키지 않는 것입니다. 빈 배열에 map을 적용하면 f가 뭐가 됐건 빈 배열이 나옵니다. 일반적으로 말해서, map은 언제나 원래 배열과 크기가 같은 배열을 돌려줍니다. 또한, f를 원래 순서에 그대로 적용해서 돌려줍니다. 그게 아니라면, x.map(id)를 했을때 종종 순서가 바뀐 배열을 얻었겠지요. 그런데 여태 그런적이 있었나요?

map의 인자와 반환 타입을 보고, 우리는 이 함수가 배열의 형태가 아닌, 배열의 각 요소의 값을 다루는 함수라고 기대합니다.

  • 배열의 형태를 보존한다는 점이 functor의 두 조건을 통해 제약됩니다.
  • 배열의 요소의 값을 다룬다는 점이, 정확히 말해 f를 각 요소에 적용해야 한다는 점이, map 의 타입을 통해 제약됩니다.

그 결과로 우리는 유일하게 자연스러운 map을 얻게 되는 거지요!👏👏👏

알고보니 Functor였던 것들

지금까지 functor는 제쳐두고 map에 대한 이야기만 했지요. 그래서 functor가 뭔데?라는 질문이 나올만한 상황입니다. 일단 Array는 functor입니다. 왜냐면 map이 있으니까요. 물론 이름이 map인 메소드가 하나 있기만 하면 다 functor인 것은 아닙니다. Arraymap이 상기한 타입과 두 조건을 만족하기에 functor인 것입니다. map의 타입을 다시 한번 봅시다.

function map<T, U>(xs: T[], f: (value: T) => U): U[];

이걸 이렇게 쓸 수 있지요.

function map<T, U>(xs: Array<T>, f: (value: T) => U): Array<T>;

개발자들은 일반화를 좋아합니다. 제네릭은 그 뜻부터가 일반화지요. 우리는 제네릭을 써서 구체적인 타입으로 제약시키지 않고 여러 타입에 걸쳐 유효한 함수를 정의합니다. 위에서 numberstring 대신에 TU를 쓰는것 같이 말이지요. 위의 타입 표현은 map이 그 어떤 타입의 요소를 갖는 Array에도 사용될수 있음을 나타냅니다.

이것만으로도 충분히 유용하단걸 우린 잘 압니다. 하지만 여기서 멈추지 말고 더 나아가 봅시다. 무언가 일반화를 하려면 먼저 기존에 고정돼있던 요소를 바꾸어 보아야겠죠. 그런 다음에도 여전히 의미와 쓸모가 있다면, 그때 비로소 일반화할 가치가 있습니다.

여기서 그 고정된 요소란건 바로 Array입니다. Array의 자리에 다른게 들어갈 수 있을까요? 그 예시를 몇개 나열해보지요.

function map<T, U>(xs: Promise<T>, f: (value: T) => U): Promise<U>;

이 타입을 만족하면서 functor의 두가지 조건을 만족하는 구현을 여러분은 어렵지 않게 찾을 수 있을겁니다.

type Nullable<T> = T | null;
function map<T, U>(xs: Nullable<T>, f: (value: T) => U): Nullable<U>;

이건 어떤가요?

type Record<T> = { [key: string]: T };
function map<T, U>(xs: Record<T>, f: (value: T) => U): Record<U>;

map은 사실 이미 lodash에 있습니다. mapValues란 이름으로 말이지요. mapValues의 동작도, 그 이름만 봐도 빤히 짐작이 될 정도로 자연스럽고 당연합니다. 물론 functor의 두 조건도 만족합니다.

위의 map들의 구현을 모두 찾아내셨나요? 시간이 충분하다면 그렇게 하고나서 마저 읽는 것을 권합니다.


아시다시피 제네릭은 타입을 일반화합니다. 그렇다면 제네릭을 일반화하면 어떻게 될까요?

function map<Functor, T, U>(xs: Functor<T>, f: (value: T) => U): Functor<U>;

Array, Promise, Nullable 등의 제네릭들을 Functor라는 변수로 일반화한 형태입니다. 아쉽게도 이건 올바른 TypeScript 코드는 아닙니다. 그래도 그 의미는 이해할 수 있지요. 자, 이젠 얘기할 수 있겠네요. 저기서 Functor바로 그 functor입니다. 저 타입을 만족시키면서, functor의 두 조건을 만족시키는 map 함수를 구현할 수 있으면, 그게 functor입니다. 정의에서 볼수 있듯, Functor는 꼭 하나의 인자를 요구하는 제네릭이어야 합니다.

반대로 말하면, 뭔가를 functor로 만들고 싶으면 map을 조건에 맞게 구현해주면 됩니다. mapValues는 JavaScript의 표준함수가 아니지요. 필요에 따라 lodash에 추가된 함수입니다. mapValues를 import하는 순간 Record<T>는 functor가 되는 것입니다.

functor는 수학에서 나온 용어라 대부분의 사람들에게 낯설것입니다. 그런데 현업에 쓰이는 프로그래밍 언어에 비슷한 의미로 쓰이는 용어가 있습니다. 바로 container와 collection입니다. Array, List, HashMap 등을 그렇게 부르지요. 만약 본인이 쓰는 프로그래밍 언어의 HashMapmap이 구현되어 있지 않다? 그러면 바로 functor로 만들어줍시다. 앞으로 짜게될 코드가 단순명쾌해집니다.

Lifting

마지막으로, map을 다른 시각으로 보는 방법을 소개합니다. 지금까지의 설명을 듣고나면, map은 functor와 함수를 주면, functor에서 값을 꺼내서 함수를 적용하고 다시 넣는다고 이해할 수 있습니다. 물론 이것도 정확한 설명입니다.

그런데 Haskell과 같은 함수형 언어에서는 보통 map 인자의 순서가 바뀌어 있습니다. 함수를 먼저 받고 functor를 나중에 받는거지요. 함수형 lodash라 불리는 ramda에도 이런식으로 되어있습니다. 그리고 커링도 가능하도록 되어있지요.

function map<T, U>(f: (value: T) => T): (xs: T[]) => U[];

이 새로운 map은 이런 식으로 쓸 수 있습니다.

const increase = (x: number) => x + 1;
const mapIncrease = map(increase);
mapIncrease([1, 2, 3]);
map(increase)([1, 2, 3]);

여기서 mapIncrease의 타입이 무엇일까요? (xs: number[]) => number[]입니다. increase의 타입은 (x: number) => number였지요. increasemapIncrease와 비교해서 더 범용적인 함수입니다. 하지만 number[]에 적용할 수는 없습니다. 이때 mapincrease를 손쉽게 업그레이드시켜 number[]에 적용할수 있게 만들어주었습니다.

map은 타입이 (x: T) => U인 함수를 받아서 타입이 (xs: Functor<T>) => Functor<U>인 함수로 만들어줍니다. 양변에 Functor가 추가됐지요. 이걸 lifting이라고 부르는데요. 함수를 들어올린다고 하면 느낌이 올까요?

연습 문제

더 이상은 비밀로 하기 힘드네요. 사실 함수도 functor였답니다. 쉽게 생각하기 위해 우선 인자로 number를 받는 함수만 생각해보세요.

type NumberFunction<T> = (x: number) => T;

NumberFunction을 functor로 만들어보세요. 알다시피 해야할 일은 단 하나, map을 조건에 맞게 구현하는 것입니다. 그럼 화이팅!🤘

profile
어쩌다 쓴 글들이 아까운 사람

2개의 댓글

comment-user-thumbnail
2021년 6월 9일

쉽게 설명해주셔서 쓱쓱 잘 읽혔습니다. :)

답글 달기
comment-user-thumbnail
2021년 6월 18일

치킨 먹고싶습니다

답글 달기