아마 JavaScript를 쓰면서 Array
의 map
메소드를 사용해보지 않은 분은 거의 없을 겁니다. 그런데 혹시, 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 mapR<T, U>(xs: T[], f: (value: T) => U): U[] {
const result = [];
for(const i of xs) {
result.unshift(f(i)); // 거꾸로 넣습니다
}
return result; // `map`을 한후 뒤집은 결과와 같습니다
}
이런 트롤링 구현도 있습니다. 이 외에도 수많은 그저 타입만 만족하는 잘못된 map
구현이 존재합니다. map
의 타입은 그 구현을 상당히 좁게 제약시키지만, 유일하게 제약시키진 못합니다.
그런데 잠깐, 여기서 잘못됐다는게 뭘 의미하는 거지요? 정확히 어느 부분이 문제라는 건가요?
우리는 직감적으로 기존의 map
구현은 자연스러운데 반해, 아래 두 예시는 뭔가 부자연스럽다고 느낍니다. 하지만 자연스럽다와 같은 모호한 단어로 특정 구현을 옹호하는건 썩 만족스럽지 못합니다. 좀더 나은 설명을 찾아봅시다.
먼저, mapN
은 전혀 유용하지 않습니다. 저 함수를 현업에서 사용할일이 있을까요? 사실상 아무 기능도 없지 않습니까.
mapR
은 뭐, 가끔 저런 동작이 필요할 때도 있겠지요. 하지만 기존의 map
과 reverse
를 사용해 같은 기능을 쉽게 구현할 수 있습니다. 만약에 mapR
이 map
대신에 Array
의 기본 메소드였다고 상상해보세요. 거의 매번, 결과를 다시 뒤집어 원래대로 되돌리며 도대체 이런 수고를 왜 해야되냐고 불평했을 것입니다.
사실, 여기까지만 해도 우리 개발자들을 설득하기엔 충분하다고 생각합니다. 하지만 수학자들은 더 정교한 설명을 진작에 준비해놓았습니다. 바로 functor를 통해 말입니다. functor의 정의는 우리가 막연히 자연스럽다라고 했던 것의 진짜 의미를 끄집어내줍니다.
functor의 map
은 다음의 조건들을 만족해야합니다.
x.map(id) = x
x.map(f).map(g) = x.map(compose(f, g))
기존의 map
과 mapN
, mapR
이 조건들을 만족하는지 살펴봅시다.
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
물론입니다. 지극히 당연하다고 느껴지지 않나요? 그야 이미 알고 있었으니까요.
mapN
과 mapR
는 이 조건을 만족하지 않습니다. 전자는 빈 배열을 만들고, 후자는 순서가 뒤집힌 배열을 주지요.
compose(f, g)
는(i) => f(g(i))
와 같습니다. 가장 기본적인 함수 합성이에요.
혹시 평소에 개발하다가 좌변과 같은 코드가 나왔을때, 왠지 더 예뻐보인다거나, 왠지 map
을 두번 쓰기 싫다거나 해서 우변의 형태로 변경해본적이 있지 않나요? 꼭 compose
를 쓰진 않았더라도 비슷한 방법으로 말입니다. 그랬다면 당신은 직감적으로 이 등식이 성립한다는걸 알고 있었던 겁니다!
실제로, 대부분의 경우 우변의 형태가 의미를 더 잘 나타냅니다. 좌변이 된장 따로, 고추장 따로 넣는거라면, 우변은 한번에 쌈장을 넣는 것이지요. 레시피에 어떻게 써있는게 낫나요?
mapN
은 의외로 이 조건을 만족합니다. mapR
은 그렇지 않습니다. 이유는 찬찬히 생각해보세요.
자, 지금가지 설명한 두조건을 모두 만족하면서 상기한 타입을 가진 map
은 정말로 유일합니다. 그리고 그게 바로 우리가 쓰는 map
이에요.
이 조건들과 자연스러움이 어떻게 연관이 되는 걸까요?
우리가 기존 map
의 동작이 자연스럽다고 느끼는 점은, 바로 배열의 형태를 변경시키지 않는 것입니다. 빈 배열에 map
을 적용하면 f
가 뭐가 됐건 빈 배열이 나옵니다. 일반적으로 말해서, map
은 언제나 원래 배열과 크기가 같은 배열을 돌려줍니다. 또한, f
를 원래 순서에 그대로 적용해서 돌려줍니다. 그게 아니라면, x.map(id)
를 했을때 종종 순서가 바뀐 배열을 얻었겠지요. 그런데 여태 그런적이 있었나요?
map
의 인자와 반환 타입을 보고, 우리는 이 함수가 배열의 형태가 아닌, 배열의 각 요소의 값을 다루는 함수라고 기대합니다.
f
를 각 요소에 적용해야 한다는 점이, map
의 타입을 통해 제약됩니다. 그 결과로 우리는 유일하게 자연스러운 map
을 얻게 되는 거지요!👏👏👏
지금까지 functor는 제쳐두고 map
에 대한 이야기만 했지요. 그래서 functor가 뭔데?라는 질문이 나올만한 상황입니다. 일단 Array
는 functor입니다. 왜냐면 map
이 있으니까요. 물론 이름이 map
인 메소드가 하나 있기만 하면 다 functor인 것은 아닙니다. Array
는 map
이 상기한 타입과 두 조건을 만족하기에 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>;
개발자들은 일반화를 좋아합니다. 제네릭은 그 뜻부터가 일반화지요. 우리는 제네릭을 써서 특정한 타입에 제약되지 않은 여러 타입에 걸쳐 유효한 함수를 정의합니다. 위에서 number
나 string
대신에 T
나 U
를 쓰는것 같이 말이지요. 위의 타입 표현은 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
, Map
등을 그렇게 부르지요. 만약 본인이 쓰는 프로그래밍 언어의 Map
에 map
이 구현되어 있지 않다? 그러면 바로 functor로 만들어줍시다. 앞으로 짜게될 코드가 단순명쾌해집니다.
마지막으로, 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
였지요. increase
는 mapIncrease
와 비교해서 더 범용적인 함수입니다. 하지만 number[]
에 적용할 수는 없습니다. 이때 map
이 increase
를 손쉽게 업그레이드시켜 number[]
에 적용할수 있게 만들어주었습니다.
map
은 타입이 (x: T) => U
인 함수를 받아서 타입이 (xs: Functor<T>) => Functor<U>
인 함수로 만들어줍니다. 양변에 Functor
가 추가됐지요. 이걸 lifting이라고 부르는데요. 함수를 들어올린다고 하면 느낌이 올까요?
더 이상은 비밀로 하기 힘드네요. 사실 함수도 functor였답니다. 쉽게 생각하기 위해 우선 인자로 number
를 받는 함수만 생각해보세요.
type NumberFunction<T> = (x: number) => T;
NumberFunction
을 functor로 만들어보세요. 알다시피 해야할 일은 단 하나, map
을 조건에 맞게 구현하는 것입니다. 그럼 화이팅!🤘
쉽게 설명해주셔서 쓱쓱 잘 읽혔습니다. :)