해당 게시글은 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; // trueQ.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