abonglog logoabonglog

Chapter 11: Transform Again, Naturally [번역] 의 썸네일

Chapter 11: Transform Again, Naturally [번역]

mostly-adequate-guide
프로필 이미지
yonghyeun

해당 게시글은 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)을 반환합니다. 그 후, TaskMaybe 둘 다에 대해 map을 사용하여 텍스트를 validate에 전달해야 합니다. 이는 차례로 ValidationError 또는 우리의 String 중 하나인 Either를 반환합니다. 그런 다음 현재 Task Error (Maybe (Either ValidationError String)) 안의 StringpostComment로 보내기 위해 여러 번 매핑해야 하며, 이는 결과 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)

프로그래머로서 우리는 타입 변환에 익숙합니다. StringBoolean으로, IntegerFloat으로 변환합니다 (비록 자바스크립트에는 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)

ListsortBy와 같은 다른 타입의 기능을 사용하고 싶다고 가정해 봅시다. 자연 변환 은 우리의 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" 자연 변환 을 제공할 수 있을 때입니다:

Task와 Promise의 동형 관계
// 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. PromiseTask동형 입니다. 우리는 또한 arrayToList를 보완하는 listToArray를 작성하여 그것들도 동형임을 보일 수 있습니다. 반례로, arrayToMaybe는 정보를 잃기 때문에 동형 이 아닙니다:

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] -> ahead :: [a] -> Identity a로 볼 수 있다는 것입니다. 우리는 법칙을 증명하는 동안 Identity를 원하는 곳에 자유롭게 삽입할 수 있습니다. 왜냐하면 결국 aIdentity 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로 변환한 다음 두 Taskjoin합니다. 마치 창턱의 비둘기 방지 스파이크처럼, 우리는 근원에서부터 중첩을 피합니다. 빛의 도시(파리)에서 말하듯이, "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