해당 게시글은 mostly-adequate-guide 의 시리즈들을 번역했습니다.
해당 게시글의 저작권은 mostly-adequate-guide 의 저작권인 Attribution-ShareAlike 4.0 International 을 준수합니다.
🤖 AI가 요약한 글이에요!
이 장에서는 자연 변환(Natural Transformation) 개념과 실제 코드에서의 활용법을 알아봅니다.
자연 변환은 한 펑터 타입에서 다른 펑터 타입으로 값을 건드리지 않고 구조적으로 변환하는 함수(f a -> g a
)입니다.
Task(Maybe(Either(...)))
와 같이 중첩된 타입을 다루기 어려울 때, 자연 변환을 사용해 타입을 통일(Task
)하고join
하여 코드를 단순화할 수 있습니다.
eitherToTask
,maybeToTask
등의 변환 예시와 함께, 변환 후에도map
연산이 일관되게 동작함을 보장하는 법칙(nt . map(f) == map(f) . nt
)을 설명합니다.
이를 통해 타입 변환을 원칙적으로 수행하고 코드의 가독성을 높이는 방법을 배웁니다.
Chapter 11: 다시 한번, 자연스럽게 변환하기 (Transform Again, Naturally)
우리는 일상 코드에서의 실용적인 유용성이라는 맥락에서 자연 변환(natural transformations) 에 대해 논의하려고 합니다. 마침 이것들은 범주론의 기둥이며 수학을 적용하여 코드를 추론하고 리팩토링할 때 절대적으로 필수적 입니다. 따라서, 저의 제한된 범위 때문에 여러분이 목격하게 될 유감스러운 부당함에 대해 알려드리는 것이 저의 의무라고 생각합니다. 시작해 봅시다.
이 중첩을 저주하라 (Curse This Nest)
중첩(nesting) 문제에 대해 이야기하고 싶습니다. 곧 부모가 될 사람들이 강박적으로 정리하고 재배열하는 본능적인 충동이 아니라... 음, 사실 생각해보면, 앞으로 나올 장들에서 보게 될 것처럼 그것과 그리 멀지 않습니다... 어쨌든, 제가 중첩 이라고 의미하는 것은 두 개 이상의 다른 타입들이 마치 갓난아기를 안고 있는 것처럼 값 주위에 함께 모여 있는 것을 말합니다.
Right(Maybe("b"));
IO(Task(IO(1000)));
[Identity("bee thousand")];
지금까지 우리는 신중하게 만들어진 예제들로 이 흔한 시나리오를 피해왔지만, 실제로는 코딩을 하다 보면 타입들이 마치 엑소시즘 중인 이어폰처럼 얽히는 경향이 있습니다. 진행하면서 꼼꼼하게 타입을 정리하지 않으면, 우리 코드는 고양이 카페의 비트닉보다 더 복잡하게 읽힐 것입니다.
상황 코미디 (A Situational Comedy)
// getValue :: Selector -> Task Error (Maybe String)
// postComment :: String -> Task Error Comment
// validate :: String -> Either ValidationError String
// saveComment :: () -> Task Error (Maybe (Either ValidationError (Task Error Comment)))
const saveComment = compose(
map(map(map(postComment))),
map(map(validate)),
getValue("#comment")
);
우리 타입 시그니처를 실망스럽게도, 모든 친구들이 여기 모였습니다. 코드를 간략하게 설명하겠습니다. 우리는 getValue('#comment')
로 사용자 입력을 얻는 것으로 시작합니다. 이것은 요소의 텍스트를 검색하는 액션입니다. 이제, 요소를 찾는 데 오류가 발생하거나 값 문자열이 존재하지 않을 수 있으므로 Task Error (Maybe String)
을 반환합니다. 그 후, Task
와 Maybe
둘 다에 대해 map
을 사용하여 텍스트를 validate
에 전달해야 합니다. 이는 차례로 ValidationError
또는 우리의 String
중 하나인 Either
를 반환합니다. 그런 다음 현재 Task Error (Maybe (Either ValidationError String))
안의 String
을 postComment
로 보내기 위해 여러 번 매핑해야 하며, 이는 결과 Task
를 반환합니다.
정말 끔찍한 혼란 입니다. 추상 타입의 콜라주, 아마추어 타입 표현주의, 다형성 폴록, 단일체 몬드리안. 이 흔한 문제에 대한 많은 해결책이 있습니다. 타입들을 하나의 거대한 컨테이너로 합성하거나, 몇 개를 정렬하고 join
하거나, 동질화하거나, 해체하는 등등. 이 장에서는 자연 변환 을 통해 동질화하는 데 초점을 맞출 것입니다.
모두 자연스럽게 (All Natural)
자연 변환(Natural Transformation) 은 "펑터 간의 사상(morphism between functors)"입니다. 즉, 컨테이너 자체에 작용하는 함수입니다. 타입 측면에서는 (Functor f, Functor g) => f a -> g a
형태의 함수입니다. 이것을 특별하게 만드는 것은 어떤 이유로든 펑터의 내용을 들여다볼 수 없다는 것입니다. 고도로 분류된 정보를 교환하는 것으로 생각해보세요 - 두 당사자는 "일급 비밀" 도장이 찍힌 봉인된 마닐라 봉투 안에 무엇이 들어 있는지 모릅니다. 이것은 구조적인 작업 입니다. 펑터적인 의상 변경입니다. 공식적으로, 자연 변환 은 다음이 성립하는 모든 함수입니다:
자연변환
nt . fmap f = fmap f . nt
또는 코드로 표현하면:
// nt :: (Functor f, Functor g) => f a -> g a
compose(map(f), nt) === compose(nt, map(f));
원칙적인 타입 변환 (Principled Type Conversions)
프로그래머로서 우리는 타입 변환에 익숙합니다. String
을 Boolean
으로, Integer
를 Float
으로 변환합니다 (비록 자바스크립트에는 Number
만 있지만). 여기서 차이점은 단지 우리가 대수적 컨테이너를 다루고 있으며 약간의 이론을 사용할 수 있다는 것입니다.
이러한 예시들을 살펴봅시다:
// idToMaybe :: Identity a -> Maybe a
const idToMaybe = (x) => Maybe.of(x.$value);
// idToIO :: Identity a -> IO a
const idToIO = (x) => IO.of(x.$value);
// eitherToTask :: Either a b -> Task a b
const eitherToTask = either(Task.rejected, Task.of);
// ioToTask :: IO a -> Task () a
const ioToTask = (x) =>
new Task((reject, resolve) => resolve(x.unsafePerform()));
// maybeToTask :: Maybe a -> Task () a
const maybeToTask = (x) => (x.isNothing ? Task.rejected() : Task.of(x.$value));
// arrayToMaybe :: [a] -> Maybe a
const arrayToMaybe = (x) => Maybe.of(x[0]);
아이디어를 아시겠나요? 우리는 단지 하나의 펑터를 다른 펑터로 바꾸고 있을 뿐입니다. 형태 변환 과정에서 정보가 손실될 수 있지만, map
할 값이 그 과정에서 길을 잃지 않는 한 괜찮습니다. 이것이 바로 핵심 입니다: 우리의 정의에 따르면, map
은 변환 후에도 계속되어야 합니다.
다른 관점에서 보면, 우리는 효과(effects)를 변환하고 있는 것입니다. 그런 관점에서 ioToTask
는 동기적인 것을 비동기적인 것으로 변환하거나, arrayToMaybe
는 비결정성(nondeterminism)을 가능한 실패로 변환하는 것으로 볼 수 있습니다. 주의할 점은 자바스크립트에서는 비동기적인 것을 동기적인 것으로 변환할 수 없으므로 taskToIO
를 작성할 수 없다는 것입니다 - 그것은 초자연적인 변환(supernatural transformation)일 것입니다.
기능 탐욕 (Feature Envy)
List
의 sortBy
와 같은 다른 타입의 기능을 사용하고 싶다고 가정해 봅시다. 자연 변환 은 우리의 map
이 유효하다는 것을 알면서 대상 타입으로 변환하는 좋은 방법을 제공합니다.
// arrayToList :: [a] -> List a
const arrayToList = List.of;
const doListyThings = compose(sortBy(h), filter(g), arrayToList, map(f));
const doListyThings_ = compose(sortBy(h), filter(g), map(f), arrayToList); // 법칙 적용됨
코를 살짝 찡긋하고, 마법봉을 세 번 두드리고, arrayToList
를 넣으면, 짜잔! 우리의 [a]
는 List a
가 되고 원한다면 sortBy
를 할 수 있습니다.
또한, doListyThings_
에서 보듯이 map(f)
를 자연 변환 의 왼쪽으로 이동하여 연산을 최적화하거나 융합(fuse)하는 것이 더 쉬워집니다.
동형 자바스크립트 (Isomorphic JavaScript)
정보 손실 없이 완전히 앞뒤로 변환할 수 있을 때, 그것은 동형(isomorphism) 으로 간주됩니다. 이것은 "동일한 데이터를 보유한다"는 것을 의미하는 멋진 단어일 뿐입니다. 두 타입이 동형 이라고 말하는 것은 증거로 "to"와 "from" 자연 변환 을 제공할 수 있을 때입니다:
// promiseToTask :: Promise a b -> Task a b
const promiseToTask = (x) =>
new Task((reject, resolve) => x.then(resolve).catch(reject));
// taskToPromise :: Task a b -> Promise a b
const taskToPromise = (x) =>
new Promise((resolve, reject) => x.fork(reject, resolve));
const x = Promise.resolve("ring");
taskToPromise(promiseToTask(x)) === x; // true
const y = Task.of("rabbit");
promiseToTask(taskToPromise(y)) === y; // true
Q.E.D. Promise
와 Task
는 동형 입니다. 우리는 또한 arrayToList
를 보완하는 listToArray
를 작성하여 그것들도 동형임을 보일 수 있습니다. 반례로, arrayToMaybe
는 정보를 잃기 때문에 동형 이 아닙니다:
// maybeToArray :: Maybe a -> [a]
const maybeToArray = (x) => (x.isNothing ? [] : [x.$value]);
// arrayToMaybe :: [a] -> Maybe a
const arrayToMaybe = (x) => Maybe.of(x[0]);
const x = ["elvis costello", "the attractions"];
// 동형이 아님
maybeToArray(arrayToMaybe(x)); // ['elvis costello']
// 하지만 자연 변환임
compose(arrayToMaybe, map(replace("elvis", "lou")))(x); // Just('lou costello')
// ==
compose(map(replace("elvis", "lou")), arrayToMaybe)(x); // Just('lou costello')
그러나 양쪽의 map
이 동일한 결과를 산출하기 때문에 이것들은 실제로 자연 변환 입니다. 주제에 대해 이야기하는 동안 장 중간에 동형 을 언급하지만, 속지 마세요, 그것들은 엄청나게 강력하고 널리 퍼져있는 개념입니다. 어쨌든, 계속 진행합시다.
더 넓은 정의 (A Broader Definition)
이러한 구조적 함수는 결코 타입 변환에만 국한되지 않습니다.
여기 몇 가지 다른 예시들이 있습니다:
reverse :: [a] -> [a]
join :: (Monad m) => m (m a) -> m a
head :: [a] -> a
of :: a -> f a
자연 변환 법칙은 이러한 함수들에도 적용됩니다. 여러분을 혼란스럽게 할 수 있는 한 가지는 head :: [a] -> a
가 head :: [a] -> Identity a
로 볼 수 있다는 것입니다. 우리는 법칙을 증명하는 동안 Identity
를 원하는 곳에 자유롭게 삽입할 수 있습니다. 왜냐하면 결국 a
가 Identity a
와 동형임을 증명할 수 있기 때문입니다 (보세요, 제가 동형이 널리 퍼져 있다고 말했죠).
하나의 중첩 해결책 (One Nesting Solution)
우리의 코미디 같은 타입 시그니처로 돌아가 봅시다. 호출 코드 전체에 약간의 자연 변환 을 뿌려서 각기 다른 타입들을 균일하게 만들고, 따라서 join
가능하게 만들 수 있습니다.
// getValue :: Selector -> Task Error (Maybe String)
// postComment :: String -> Task Error Comment
// validate :: String -> Either ValidationError String
// saveComment :: () -> Task Error Comment
const saveComment = compose(
chain(postComment),
chain(eitherToTask), // Either를 Task로 변환 후 join
map(validate),
chain(maybeToTask), // Maybe를 Task로 변환 후 join
getValue("#comment")
);
자, 여기서 무엇을 했나요? 우리는 단순히 chain(maybeToTask)
와 chain(eitherToTask)
를 추가했습니다. 둘 다 동일한 효과를 가집니다; Task
가 가지고 있는 펑터를 자연스럽게 다른 Task
로 변환한 다음 두 Task
를 join
합니다. 마치 창턱의 비둘기 방지 스파이크처럼, 우리는 근원에서부터 중첩을 피합니다. 빛의 도시(파리)에서 말하듯이, "Mieux vaut prévenir que guérir" - 예방의 1온스는 치료의 1파운드 가치가 있습니다.
요약 (In Summary)
자연 변환 은 펑터 자체에 대한 함수입니다. 이것들은 범주론에서 매우 중요한 개념이며 더 많은 추상화가 채택되면 어디에나 나타나기 시작할 것이지만, 지금은 몇 가지 구체적인 응용 프로그램으로 범위를 좁혔습니다. 보았듯이, 우리의 합성이 유지될 것이라는 보장과 함께 타입을 변환하여 다른 효과를 얻을 수 있습니다. 또한 중첩된 타입에도 도움이 될 수 있지만, 실제로는 가장 불안정한 효과를 가진 펑터(대부분의 경우 Task
)인 최소 공통 분모로 펑터를 동질화하는 일반적인 효과가 있습니다.
이러한 지속적이고 지루한 타입 정렬은 우리가 그것들을 구체화하고 - 에테르에서 소환한 - 대가입니다. 물론, 암묵적인 효과는 훨씬 더 교활하므로 우리는 여기서 선한 싸움을 하고 있습니다. 더 큰 타입 합병을 처리하기 전에 도구 상자에 몇 가지 도구가 더 필요할 것입니다. 다음으로, Traversable 을 사용하여 타입의 순서를 변경하는 방법을 살펴보겠습니다.
연습 문제 (Exercises)
// eitherToTask :: Either a b -> Task a b
const eitherToTask = either(Task.rejected, Task.of);
참고로, 다음 함수들은 연습 문제 컨텍스트에서 사용할 수 있습니다:
split :: String -> String -> [String]
intercalate :: String -> [String] -> String