Chapter 08: 터퍼웨어 (Tupperware)
해당 게시글은 mostly-adequate-guide 의 시리즈들을 번역했습니다.
해당 게시글의 저작권은 mostly-adequate-guide 의 저작권인 Attribution-ShareAlike 4.0 International 을 준수합니다.
🤖 AI가 요약한 글이에요!
이 장에서는 펑터(Functor) 라는 개념을 소개하고, 데이터를 안전하게 감싸는 컨테이너 타입을 다룹니다.
Container
(Identity),Maybe
,Either
,IO
,Task
와 같은 다양한 펑터 구현체를 살펴봅니다.
Maybe
는null
또는undefined
값의 안전한 처리를 돕고,Either
는 오류 처리를 위한 명시적인 방법을 제공합니다.
IO
는 부수 효과(side effect)를 순수하게 관리하며,Task
는 비동기 작업을 다루는 데 사용됩니다.
펑터의map
함수를 통해 컨테이너 내부의 값에 함수를 적용하는 방법을 배우고, 펑터 법칙의 중요성을 이해합니다.
강력한 컨테이너 (The Mighty Container)
값을 담는 컨테이너
우리는 순수 함수들의 연속을 통해 데이터를 파이프라인하는 프로그램을 작성하는 방법을 보았습니다. 이것들은 행동의 선언적 명세입니다. 하지만 제어 흐름, 오류 처리, 비동기 액션, 상태, 그리고 감히 말하자면, 효과(effects)는 어떨까요?! 이 장에서는 이러한 모든 유용한 추상화가 구축되는 기반을 발견할 것입니다.
먼저 컨테이너를 만들 것입니다. 이 컨테이너는 어떤 타입의 값이든 담을 수 있어야 합니다; 타피오카 푸딩만 담는 지퍼락은 거의 쓸모가 없습니다. 이것은 객체가 될 것이지만, 객체 지향(OO)적인 의미의 속성과 메서드를 부여하지는 않을 것입니다. 아니요, 우리는 이것을 보물 상자처럼 다룰 것입니다 - 우리의 소중한 데이터를 품는 특별한 상자입니다.
class Container {
constructor(x) {
this.$value = x;
}
static of(x) {
return new Container(x);
}
}
여기 우리의 첫 번째 컨테이너가 있습니다. 우리는 사려 깊게 Container
라고 이름 지었습니다. 우리는 Container.of
를 생성자로 사용할 것이며, 이는 끔찍한 new
키워드를 쓰는 것을 막아줍니다. of
함수에는 눈에 보이는 것 이상의 것이 있지만, 지금은 값을 컨테이너에 넣는 적절한 방법이라고 생각합시다.
우리의 새로운 상자를 살펴봅시다...
Container.of(3);
// Container(3)
Container.of("hotdogs");
// Container("hotdogs")
Container.of(Container.of({ name: "yoda" }));
// Container(Container({ name: 'yoda' }))
Node를 사용하고 있다면, Container(x)
를 가지고 있음에도 불구하고 {$value: x}
를 보게 될 것입니다. Chrome은 타입을 제대로 출력하지만, 상관없습니다; Container
가 어떻게 생겼는지 이해하는 한 괜찮습니다. 원한다면 일부 환경에서 inspect
메서드를 덮어쓸 수 있지만, 우리는 그렇게 철저하지 않을 것입니다. 이 책에서는 교육적이고 미적인 이유로 {$value: x}
보다 훨씬 유익하기 때문에 inspect
를 덮어쓴 것처럼 개념적 출력을 작성할 것입니다.
넘어가기 전에 몇 가지를 명확히 합시다:
Container
는 하나의 속성을 가진 객체입니다. 많은 컨테이너는 단지 하나만 담지만, 하나로 제한되지는 않습니다. 우리는 임의로 그 속성을$value
라고 이름 지었습니다.$value
는 특정 타입일 수 없습니다. 그렇지 않으면 우리의Container
는 이름값을 하지 못할 것입니다.- 데이터가
Container
에 들어가면 거기에 머뭅니다..$value
를 사용하여 꺼낼 수도 있지만, 그것은 목적에 어긋납니다.
우리가 왜 이렇게 하는지에 대한 이유는 유리병처럼 명확해질 것이지만, 지금은 저를 믿어주세요.
나의 첫 번째 펑터 (My First Functor)
우리의 값이 무엇이든 간에 컨테이너 안에 들어가면, 그 값에 함수를 실행할 방법이 필요합니다.
// (a -> b) -> Container a -> Container b
Container.prototype.map = function (f) {
return Container.of(f(this.$value));
};
이런, 이것은 Array의 유명한 map
과 똑같습니다. 단지 [a]
대신 Container a
를 가질 뿐입니다. 그리고 본질적으로 같은 방식으로 작동합니다:
Container.of(2).map((two) => two + 2);
// Container(4)
Container.of("flamethrowers").map((s) => s.toUpperCase());
// Container('FLAMETHROWERS')
Container.of("bombs").map(append(" away")).map(prop("length"));
// Container(10)
우리는 Container
를 떠나지 않고도 우리의 값으로 작업할 수 있습니다. 이것은 놀라운 일입니다. Container
안의 우리의 값은 map
함수에 전달되어 우리가 그것을 만지작거릴 수 있게 하고, 그 후에는 안전한 보관을 위해 Container
로 반환됩니다. Container
를 절대 떠나지 않는 결과로, 우리는 원하는 대로 함수를 실행하며 계속해서 map
할 수 있습니다. 세 예제 중 마지막에서 보여주듯이, 진행하면서 타입을 변경할 수도 있습니다.
잠깐만요, 계속 map
을 호출하면, 이것은 일종의 합성처럼 보입니다! 어떤 수학적 마법이 작용하고 있는 걸까요? 글쎄요, 여러분, 우리는 방금 펑터(Functors) 를 발견했습니다.
펑터는
map
을 구현하고 몇 가지 법칙을 따르는 타입입니다.
네, 펑터는 단순히 계약이 있는 인터페이스입니다. 우리는 그것을 Mappable이라고 이름 지을 수도 있었지만, 그러면 재미가 어디 있겠습니까? 펑터는 카테고리 이론에서 왔으며, 이 장의 끝부분에서 수학을 자세히 살펴볼 것이지만, 지금은 이 기괴하게 이름 붙여진 인터페이스에 대한 직관과 실제 사용법에 대해 작업합시다.
값을 병에 담아 map
을 사용하여 접근하는 데 어떤 이유가 있을 수 있을까요? 답은 더 나은 질문을 선택하면 드러납니다: 컨테이너에게 함수를 적용하도록 요청함으로써 우리는 무엇을 얻을까요? 글쎄요, 함수 적용의 추상화입니다. 함수를 map
할 때, 우리는 컨테이너 타입에게 그것을 실행하도록 요청합니다. 이것은 참으로 매우 강력한 개념입니다.
슈뢰딩거의 Maybe (Schrödinger's Maybe)
슈뢰딩거의 메이비
Container
는 상당히 지루합니다. 사실, 이것은 보통 Identity
라고 불리며 우리의 id
함수와 거의 같은 영향을 미칩니다 (다시 말하지만, 때가 되면 살펴볼 수학적 연결이 있습니다). 그러나, 매핑하는 동안 유용한 행동을 제공할 수 있는 다른 펑터, 즉 적절한 map
함수를 가진 컨테이너와 유사한 타입들이 있습니다. 지금 하나 정의해 봅시다.
완전한 구현은 부록 B에 제공됩니다.
class Maybe {
static of(x) {
return new Maybe(x);
}
get isNothing() {
return this.$value === null || this.$value === undefined;
}
constructor(x) {
this.$value = x;
}
map(fn) {
return this.isNothing ? this : Maybe.of(fn(this.$value));
}
inspect() {
return this.isNothing ? "Nothing" : `Just(${inspect(this.$value)})`;
}
}
이제, Maybe
는 Container
와 매우 유사해 보이지만 한 가지 사소한 변경 사항이 있습니다: 제공된 함수를 호출하기 전에 먼저 값이 있는지 확인할 것입니다. 이것은 우리가 map
할 때 성가신 null들을 회피하는 효과가 있습니다 (이 구현은 교육을 위해 단순화되었습니다).
Maybe.of("Malkovich Malkovich").map(match(/a/gi));
// Just(true) // 원문은 True 이나, JS에서는 boolean true
Maybe.of(null).map(match(/a/gi));
// Nothing
Maybe.of({ name: "Boris" }).map(prop("age")).map(add(10));
// Nothing
Maybe.of({ name: "Dinah", age: 14 }).map(prop("age")).map(add(10));
// Just(24)
null 값에 함수를 매핑할 때 앱이 오류로 폭발하지 않는다는 점에 주목하세요. 이는 Maybe
가 함수를 적용할 때마다 값을 확인하는 데 주의를 기울이기 때문입니다.
이 점 표기법은 완벽하게 괜찮고 함수형이지만, 파트 1에서 언급한 이유로 우리는 포인트프리 스타일을 유지하고 싶습니다. 마침, map
은 어떤 펑터를 받든 위임할 준비가 되어 있습니다:
// map :: Functor f => (a -> b) -> f a -> f b
const map = curry((f, anyFunctor) => anyFunctor.map(f));
이것은 우리가 평소처럼 합성을 계속할 수 있고 map
이 예상대로 작동하기 때문에 즐겁습니다. Ramda의 map
도 마찬가지입니다. 설명이 필요할 때는 점 표기법을 사용하고 편리할 때는 포인트프리 버전을 사용할 것입니다. 눈치채셨나요? 저는 몰래 타입 시그니처에 추가 표기법을 도입했습니다. Functor f =>
는 f
가 펑터여야 함을 알려줍니다. 그렇게 어렵지는 않지만, 언급해야 한다고 생각했습니다.
한 번만 다시 짚어가봅시다.
Functor f =>
는f
가Functor
여야하는 제약 조건을 의미합니다.첫 번째 인수로 받는
f
는a -> b
로 사상시키는 함수입니다.따라서,
Functor f =>
는f
가(a -> b)
타입의 함수를 인자로 받아서, 펑터 내부의 a 타입 값들을 b 타입 값들로 사상시킬 수 있는 map 메소드를 제공해야 함을 의미합니다."
f a -> f b
는 인수로a
값을 가지고 있는 어떤Functor
를 받고b
값을 가지고 있는Functor
로 반환한단 것을 의미 합니다.
사용 사례 (Use Cases)
실제로는 결과를 반환하지 못할 수 있는 함수에서 Maybe
를 사용하는 것을 일반적으로 볼 수 있습니다.
// safeHead :: [a] -> Maybe(a)
const safeHead = (xs) => Maybe.of(xs[0]);
// streetName :: Object -> Maybe String
const streetName = compose(map(prop("street")), safeHead, prop("addresses"));
streetName({ addresses: [] });
// Nothing
streetName({ addresses: [{ street: "Shady Ln.", number: 4201 }] });
// Just('Shady Ln.')
safeHead
는 우리의 일반적인 head
와 같지만 타입 안전성이 추가되었습니다. Maybe
가 코드에 도입될 때 흥미로운 일이 발생합니다; 우리는 그 교활한 null
값들을 처리하도록 강요받습니다. safeHead
함수는 가능한 실패에 대해 정직하고 솔직하며 - 부끄러워할 것이 전혀 없습니다 - 그래서 이 문제에 대해 알려주기 위해 Maybe
를 반환합니다. 그러나 우리는 단순히 정보를 받는 것 이상입니다. 왜냐하면 우리가 원하는 값은 Maybe
객체 안에 숨겨져 있기 때문에 그것에 접근하기 위해 map
을 사용하도록 강요받기 때문입니다. 본질적으로, 이것은 safeHead
함수 자체에 의해 강제되는 null
검사입니다. 우리는 이제 가장 예상치 못한 순간에 null
값이 그 추악하고 목 잘린 머리를 들이밀지 않을 것이라는 것을 알고 밤에 더 잘 잘 수 있습니다. 이와 같은 API는 허술한 애플리케이션을 종이와 압정에서 나무와 못으로 업그레이드할 것입니다. 그것들은 더 안전한 소프트웨어를 보장할 것입니다.
때때로 함수는 실패를 알리기 위해 명시적으로 Nothing
을 반환할 수 있습니다. 예를 들어:
// withdraw :: Number -> Account -> Maybe(Account)
const withdraw = curry((amount, { balance }) =>
Maybe.of(balance >= amount ? { balance: balance - amount } : null)
);
// 이 함수는 가상이며, 여기서 구현되지 않았습니다... 다른 곳에서도 마찬가지입니다.
// updateLedger :: Account -> Account
const updateLedger = (account) => account;
// remainingBalance :: Account -> String
const remainingBalance = ({ balance }) => `Your balance is $${balance}`;
// finishTransaction :: Account -> String
const finishTransaction = compose(remainingBalance, updateLedger);
// getTwenty :: Account -> Maybe(String)
const getTwenty = compose(map(finishTransaction), withdraw(20));
getTwenty({ balance: 200.0 });
// Just('Your balance is $180')
getTwenty({ balance: 10.0 });
// Nothing
withdraw
는 현금이 부족하면 우리를 비웃으며 Nothing
을 반환할 것입니다. 이 함수는 또한 변덕스러움을 전달하며 우리에게 그 이후의 모든 것을 map
하도록 강요합니다. 차이점은 여기서 null
이 의도적이었다는 것입니다. Just('..')
대신 실패를 알리는 Nothing
을 받고 우리의 애플리케이션은 효과적으로 중단됩니다. 이것은 주목해야 할 중요한 점입니다: 만약 withdraw
가 실패하면, map
은 매핑된 함수, 즉 finishTransaction
을 절대 실행하지 않기 때문에 나머지 계산을 중단할 것입니다. 이것은 우리가 자금을 성공적으로 인출하지 못했다면 원장을 업데이트하거나 새 잔액을 표시하고 싶지 않기 때문에 정확히 의도된 행동입니다.
값 해제하기 (Releasing the Value)
사람들이 종종 놓치는 한 가지는 항상 끝이 있다는 것입니다; JSON을 보내거나, 화면에 인쇄하거나, 파일 시스템을 변경하거나, 무엇이든 하는 어떤 효과적인 함수가 있습니다. 우리는 return
으로 출력을 전달할 수 없으며, 그것을 세상에 내보내기 위해 어떤 함수든 실행해야 합니다. 우리는 그것을 선불교 공안처럼 표현할 수 있습니다: "만약 프로그램에 관찰 가능한 효과가 없다면, 그것은 실행되기라도 하는가?". 그것은 자신의 만족을 위해 올바르게 실행되는가? 나는 그것이 단지 약간의 사이클을 태우고 다시 잠드는 것이라고 의심합니다...
우리 애플리케이션의 임무는 데이터를 검색하고, 변환하고, 작별 인사를 할 시간이 될 때까지 그것을 운반하는 것이며, 그렇게 하는 함수는 매핑될 수 있으므로 값은 컨테이너의 따뜻한 자궁을 떠날 필요가 없습니다. 실제로, 흔한 오류는 가능한 값이 갑자기 구체화되고 모든 것이 용서될 것처럼 어떤 식으로든 Maybe
에서 값을 제거하려고 시도하는 것입니다. 우리는 그것이 우리의 값이 운명을 다할 수 없는 코드 분기일 수 있다는 것을 이해해야 합니다. 우리의 코드는 슈뢰딩거의 고양이처럼 동시에 두 상태에 있으며 최종 함수까지 그 사실을 유지해야 합니다. 이것은 논리적 분기에도 불구하고 코드에 선형적인 흐름을 제공합니다.
그러나 탈출구가 있습니다. 사용자 정의 값을 반환하고 계속 진행하고 싶다면, maybe
라는 작은 도우미를 사용할 수 있습니다.
// maybe :: b -> (a -> b) -> Maybe a -> b
const maybe = curry((v, f, m) => {
if (m.isNothing) {
return v;
}
return f(m.$value);
});
// getTwenty :: Account -> String
const getTwenty = compose(
maybe("You're broke!", finishTransaction),
withdraw(20)
);
getTwenty({ balance: 200.0 });
// 'Your balance is $180.00'
getTwenty({ balance: 10.0 });
// 'You're broke!'
이제 우리는 정적 값(finishTransaction
이 반환하는 것과 동일한 타입)을 반환하거나 Maybe
없이 즐겁게 거래를 마무리할 것입니다. maybe
를 사용하면 if/else
문과 동등한 것을 목격하고 있으며, 반면 map
을 사용하면 명령형 유사체는 다음과 같습니다: if (x !== null) { return f(x) }
.
Maybe
의 도입은 초기에 약간의 불편함을 유발할 수 있습니다. Swift와 Scala 사용자는 Option(al)
이라는 이름으로 핵심 라이브러리에 내장되어 있기 때문에 제가 무슨 말을 하는지 알 것입니다. 항상 null
검사를 처리하도록 강요받을 때 (그리고 값이 존재한다는 것을 절대적으로 확신하는 때가 있습니다), 대부분의 사람들은 그것이 약간 수고스럽다고 느끼지 않을 수 없습니다. 그러나 시간이 지남에 따라 그것은 제2의 천성이 될 것이고 당신은 아마도 안전성을 높이 평가할 것입니다. 결국, 대부분의 경우 그것은 지름길을 막고 우리의 목숨을 구할 것입니다.
안전하지 않은 소프트웨어를 작성하는 것은 각 달걀을 교통 체증 속으로 던지기 전에 파스텔로 조심스럽게 칠하는 것과 같습니다; 세 마리 아기 돼지가 경고한 재료로 양로원을 짓는 것과 같습니다. 함수에 약간의 안전성을 부여하는 것이 우리에게 좋을 것이며 Maybe
는 바로 그것을 돕습니다.
"실제" 구현은 Maybe
를 두 가지 타입으로 나눌 것이라는 점을 언급하지 않을 수 없습니다: 하나는 무언가를 위한 것이고 다른 하나는 아무것도 없는 것을 위한 것입니다. 이것은 우리가 map
에서 파라미터성을 준수할 수 있게 하여 null
및 undefined
와 같은 값도 매핑될 수 있고 펑터 내 값의 보편적 한정이 존중될 수 있도록 합니다. 값에 대한 null
검사를 수행하는 Maybe
대신 Some(x) / None
또는 Just(x) / Nothing
과 같은 타입을 종종 보게 될 것입니다.
순수한 오류 처리 (Pure Error Handling)
오류 뿌셔
충격적일 수 있지만, throw/catch
는 그다지 순수하지 않습니다. 오류가 발생하면 출력 값을 반환하는 대신 경보를 울립니다! 함수는 공격하여, 침입하는 입력에 대한 전기적 전투에서 방패와 창처럼 수천 개의 0과 1을 뿜어냅니다. 우리의 새로운 친구 Either
를 사용하면 입력에 전쟁을 선포하는 것보다 더 잘할 수 있습니다. 정중한 메시지로 응답할 수 있습니다. 살펴봅시다:
완전한 구현은 부록 B에 제공됩니다.
class Either {
static of(x) {
return new Right(x);
}
constructor(x) {
this.$value = x;
}
}
class Left extends Either {
map(f) {
return this; // Left는 map을 무시합니다.
}
inspect() {
return `Left(${inspect(this.$value)})`;
}
}
class Right extends Either {
map(f) {
return Either.of(f(this.$value)); // Right는 값을 함수에 적용합니다.
}
inspect() {
return `Right(${inspect(this.$value)})`;
}
}
// Left 값을 생성하는 헬퍼 함수
const left = (x) => new Left(x);
Left
와 Right
는 우리가 Either
라고 부르는 추상 타입의 두 하위 클래스입니다. Either
슈퍼클래스를 만드는 의식은 생략했습니다. 왜냐하면 우리는 그것을 결코 사용하지 않을 것이기 때문입니다. 하지만 알고 있는 것이 좋습니다. 이제, 여기에는 두 가지 타입 외에는 새로운 것이 없습니다. 그것들이 어떻게 작동하는지 봅시다:
Either.of("rain").map((str) => `b${str}`);
// Right('brain')
left("rain").map((str) => `It's gonna ${str}, better bring your umbrella!`);
// Left('rain') // Left는 map을 무시합니다.
Either.of({ host: "localhost", port: 80 }).map(prop("host"));
// Right('localhost')
left("rolls eyes...").map(prop("host"));
// Left('rolls eyes...') // Left는 map을 무시합니다.
Left
는 십대 같은 종류이며 그것을 map
하려는 우리의 요청을 무시합니다. Right
는 Container
(일명 Identity)처럼 작동합니다. 힘은 Left
내부에 오류 메시지를 포함할 수 있는 능력에서 나옵니다.
성공하지 못할 수도 있는 함수가 있다고 가정해 봅시다. 생년월일로부터 나이를 계산하는 것은 어떨까요? 실패를 알리고 프로그램을 분기하기 위해 Nothing
을 사용할 수 있지만, 그것은 우리에게 많은 것을 알려주지 않습니다. 아마도, 우리는 왜 실패했는지 알고 싶을 것입니다. Either
를 사용하여 이것을 작성해 봅시다.
const moment = require("moment"); // moment.js 라이브러리 필요
// getAge :: Date -> User -> Either(String, Number)
const getAge = curry((now, user) => {
const birthDate = moment(user.birthDate, "YYYY-MM-DD");
// 생년월일이 유효하면 Right(나이) 반환, 아니면 Left(오류 메시지) 반환
return birthDate.isValid()
? Either.of(now.diff(birthDate, "years"))
: left("Birth date could not be parsed");
});
getAge(moment(), { birthDate: "2005-12-12" });
// Right(9) // 현재 날짜에 따라 결과가 다를 수 있습니다.
getAge(moment(), { birthDate: "July 4, 2001" });
// Left('Birth date could not be parsed')
이제, Nothing
처럼, Left
를 반환할 때 앱을 단락시킵니다. 차이점은 이제 프로그램이 왜 탈선했는지에 대한 단서가 있다는 것입니다. 주목할 점은 Either(String, Number)
를 반환한다는 것입니다. 이것은 왼쪽 값으로 String
을, Right
값으로 Number
를 보유합니다. 이 타입 시그니처는 실제 Either
슈퍼클래스를 정의하는 데 시간을 들이지 않았기 때문에 약간 비공식적이지만, 타입으로부터 많은 것을 배웁니다. 오류 메시지나 나이를 돌려받을 것이라는 것을 알려줍니다.
// fortune :: Number -> String
const fortune = compose(
concat("If you survive, you will be "),
toString,
add(1)
);
// zoltar :: User -> Either(String, _)
const zoltar = compose(map(console.log), map(fortune), getAge(moment()));
zoltar({ birthDate: "2005-12-12" });
// 콘솔 출력: 'If you survive, you will be 10' // 현재 날짜에 따라 결과가 다를 수 있습니다.
// 반환 값: Right(undefined) // console.log의 반환 값
zoltar({ birthDate: "balloons!" });
// 반환 값: Left('Birth date could not be parsed') // 오류 메시지가 Left에 담겨 반환됨
birthDate
가 유효할 때, 프로그램은 우리가 볼 수 있도록 신비로운 운세를 화면에 출력합니다. 그렇지 않으면, 오류 메시지가 담긴 Left
를 받지만 여전히 컨테이너 안에 숨겨져 있습니다. 그것은 마치 오류를 던진 것처럼 작동하지만, 무언가 잘못되었을 때 화를 내고 아이처럼 비명을 지르는 대신 차분하고 온화한 방식으로 작동합니다.
이 예제에서는 생년월일의 유효성에 따라 제어 흐름을 논리적으로 분기하고 있지만, 조건문의 중괄호를 오르내리는 대신 오른쪽에서 왼쪽으로 하나의 선형적인 움직임으로 읽힙니다. 보통, 우리는 console.log
를 zoltar
함수 밖으로 옮기고 호출 시점에 map
하지만, Right
분기가 어떻게 다른지 보는 것이 도움이 됩니다. 오른쪽 분기의 타입 시그니처에서 _
를 사용하여 무시해야 하는 값임을 나타냅니다 (일부 브라우저에서는 console.log.bind(console)
을 사용하여 일급으로 사용해야 합니다).
이 기회를 빌어 여러분이 놓쳤을 수도 있는 점을 지적하고 싶습니다: 이 예제에서 Either
와 함께 사용되었음에도 불구하고 fortune
은 주변에 있는 어떤 펑터에 대해서도 완전히 무지합니다. 이것은 이전 예제의 finishTransaction
의 경우에도 마찬가지였습니다. 호출 시점에 함수는 map
으로 둘러싸일 수 있으며, 이는 비공식적인 용어로 비펑터 함수에서 펑터 함수로 변환합니다. 우리는 이 과정을 리프팅(lifting) 이라고 부릅니다. 함수는 컨테이너 타입보다는 일반 데이터 타입으로 작업하는 것이 더 나은 경향이 있으며, 필요에 따라 올바른 컨테이너로 리프팅됩니다. 이것은 필요에 따라 어떤 펑터와도 작동하도록 변경될 수 있는 더 간단하고 재사용 가능한 함수로 이어집니다.
Either
는 유효성 검사와 같은 가벼운 오류뿐만 아니라 누락된 파일이나 끊어진 소켓과 같은 더 심각하고 쇼를 중단시키는 오류에도 좋습니다. 더 나은 피드백을 제공하기 위해 일부 Maybe
예제를 Either
로 바꿔보세요.
이제, Either
를 단지 오류 메시지를 위한 컨테이너로 소개함으로써 불충분하게 다루었다는 느낌을 지울 수 없습니다. 이것은 타입에서 논리적 분리(일명 ||
)를 포착합니다. 또한 카테고리 이론의 코프로덕트(Coproduct) 라는 아이디어를 인코딩하는데, 이 책에서는 다루지 않지만 이용할 속성이 있으므로 읽어볼 가치가 있습니다. 이것은 포함된 두 타입의 합이기 때문에 표준적인 합 타입(또는 집합의 서로소 합집합)입니다 (이것이 약간 모호하다는 것을 알기 때문에 훌륭한 기사가 있습니다). Either
가 될 수 있는 것은 많지만, 펑터로서는 오류 처리를 위해 사용됩니다.
Maybe
와 마찬가지로, 작은 either
가 있으며, 비슷하게 작동하지만 하나의 함수와 정적 값 대신 두 개의 함수를 받습니다. 각 함수는 동일한 타입을 반환해야 합니다:
// either :: (a -> c) -> (b -> c) -> Either a b -> c
const either = curry((f, g, e) => {
let result;
switch (e.constructor) {
case Left: // Left인 경우 첫 번째 함수(f) 실행
result = f(e.$value);
break;
case Right: // Right인 경우 두 번째 함수(g) 실행
result = g(e.$value);
break;
// 기본값 없음
}
return result;
});
// zoltar :: User -> _
const zoltar = compose(console.log, either(id, fortune), getAge(moment()));
zoltar({ birthDate: "2005-12-12" });
// 콘솔 출력: 'If you survive, you will be 10' // 현재 날짜에 따라 결과가 다를 수 있습니다.
// 반환 값: undefined // console.log의 반환 값
zoltar({ birthDate: "balloons!" });
// 콘솔 출력: 'Birth date could not be parsed'
// 반환 값: undefined // console.log의 반환 값
마지막으로, 그 신비로운 id
함수를 사용할 곳이 생겼습니다. 이것은 단순히 Left
의 값을 그대로 반환하여 오류 메시지를 console.log
에 전달합니다. 우리는 getAge
내에서 오류 처리를 강제함으로써 운세 앱을 더 견고하게 만들었습니다. 우리는 손금 보는 사람의 하이파이브처럼 사용자에게 냉혹한 진실을 날리거나 프로세스를 계속 진행합니다. 그리고 이것으로, 우리는 완전히 다른 종류의 펑터로 넘어갈 준비가 되었습니다.
올드 맥도날드는 효과가 있었다... (Old McDonald Had Effects...)
순수성에 관한 장에서 우리는 순수 함수의 특이한 예를 보았습니다. 이 함수는 부수 효과를 포함했지만, 그 액션을 다른 함수로 감싸서 순수하다고 불렀습니다. 여기에 또 다른 예가 있습니다:
// getFromStorage :: String -> (_ -> String)
// key를 받아서 localStorage에서 해당 key의 값을 가져오는 *함수*를 반환
const getFromStorage = (key) => () => localStorage[key];
만약 우리가 그 내용을 다른 함수로 감싸지 않았다면, getFromStorage
는 외부 상황에 따라 출력이 달라졌을 것입니다. 견고한 래퍼가 제자리에 있으면, 우리는 항상 입력당 동일한 출력을 얻습니다: 호출될 때 localStorage
에서 특정 항목을 검색하는 함수입니다. 그리고 그렇게 (아마도 몇 번의 성모송을 외우면) 우리는 양심을 깨끗이 하고 모든 것이 용서됩니다.
하지만, 이것은 그다지 유용하지 않습니다, 그렇죠? 원래 포장 상태의 수집 가능한 액션 피규어처럼, 우리는 실제로 그것을 가지고 놀 수 없습니다. 만약 컨테이너 안으로 손을 뻗어 그 내용물에 접근할 방법이 있다면... IO
를 소개합니다.
class IO {
static of(x) {
// 값을 반환하는 함수로 감싸서 IO 생성
return new IO(() => x);
}
constructor(fn) {
// $value는 항상 함수여야 합니다. 이것이 부수 효과를 지연시키는 핵심입니다.
this.$value = fn;
}
map(fn) {
// 기존 함수($value) 실행 후, 새로운 함수(fn)를 실행하는 새 함수를 만듭니다.
// compose(fn, this.$value)는 this.$value()의 결과를 fn에 전달하는 함수입니다.
return new IO(compose(fn, this.$value));
}
inspect() {
// 실제로는 내부 함수를 보여주지만, 개념적으로는 IO(값)으로 표현합니다.
return `IO(${inspect(this.$value)})`;
}
}
IO
는 $value
가 항상 함수라는 점에서 이전 펑터들과 다릅니다. 그러나 우리는 그것의 $value
를 함수로 생각하지 않습니다 - 그것은 구현 세부 사항이며 무시하는 것이 가장 좋습니다. 일어나고 있는 일은 getFromStorage
예제에서 본 것과 정확히 같습니다: IO
는 함수 래퍼 안에 불순한 액션을 캡처하여 지연시킵니다. 따라서, 우리는 IO
가 래퍼 자체가 아니라 래핑된 액션의 반환 값을 포함한다고 생각합니다. 이것은 of
함수에서 명백합니다: 우리는 IO(x)
를 가지고 있으며, IO(() => x)
는 평가를 피하기 위해 필요할 뿐입니다. 참고로, 읽기를 단순화하기 위해 IO
에 포함된 가상의 값을 결과로 표시할 것이지만, 실제로는 효과를 실제로 발휘하기 전까지 이 값이 무엇인지 알 수 없습니다!
사용 예를 봅시다:
// ioWindow :: IO Window (window 객체를 감싼 IO)
const ioWindow = new IO(() => window);
ioWindow.map((win) => win.innerWidth);
// IO(1430) // 실제로는 IO(() => window.innerWidth) 와 유사
ioWindow.map(prop("location")).map(prop("href")).map(split("/"));
// IO(['http:', '', 'localhost:8000', 'blog', 'posts']) // 실제로는 복잡한 구성 함수
// $ :: String -> IO [DOM] (DOM 요소를 가져오는 IO 생성 함수)
const $ = (selector) => new IO(() => document.querySelectorAll(selector));
$("#myDiv")
.map(head) // 첫 번째 요소 선택
.map((div) => div.innerHTML); // innerHTML 가져오기
// IO('I am some inner html') // 실제로는 IO(() => document.querySelectorAll('#myDiv')[0].innerHTML) 와 유사
여기서, ioWindow
는 즉시 map
할 수 있는 실제 IO
인 반면, $
는 호출된 후에 IO
를 반환하는 함수입니다. IO
를 더 잘 표현하기 위해 개념적 반환 값을 작성했지만, 실제로는 항상 { $value: [Function] }
일 것입니다. IO
를 map
할 때, 우리는 그 함수를 합성의 끝에 붙이고, 그것이 차례로 새로운 $value
가 되는 식입니다. 우리의 매핑된 함수는 실행되지 않고, 우리가 함수별로 구축하고 있는 계산의 끝에 추가됩니다. 마치 우리가 감히 넘어뜨리지 못하는 도미노를 조심스럽게 놓는 것과 같습니다. 결과는 GoF(Gang of Four)의 커맨드 패턴이나 큐를 연상시킵니다.
잠시 펑터 직관을 발휘해 보세요. 구현 세부 사항을 넘어서 보면, 어떤 컨테이너든 그 특성이나 특이성에 관계없이 매핑하는 데 익숙함을 느껴야 합니다. 이 의사-심령 능력에 대해 이 장의 끝에서 탐구할 펑터 법칙에 감사해야 합니다. 어쨌든, 우리는 마침내 소중한 순수성을 희생하지 않고 불순한 값으로 놀 수 있습니다.
이제, 우리는 짐승을 가두었지만, 언젠가는 풀어줘야 할 것입니다. IO
를 매핑하는 것은 강력한 불순한 계산을 구축했으며 그것을 실행하는 것은 분명히 평화를 방해할 것입니다. 그렇다면 언제 어디서 방아쇠를 당길 수 있을까요? IO
를 실행하고도 결혼식에서 흰색 옷을 입는 것이 가능할까요? 답은 '예'입니다. 만약 우리가 책임을 호출 코드에 지운다면 말입니다. 우리의 순수한 코드는 사악한 계획과 음모에도 불구하고 순수함을 유지하며, 실제로 효과를 실행하는 부담을 지는 것은 호출자입니다. 이것을 구체적으로 만들기 위해 예를 봅시다.
// url :: IO String (현재 URL을 가져오는 IO)
const url = new IO(() => window.location.href);
// toPairs :: String -> [[String]] (쿼리 문자열을 키-값 쌍 배열로 변환)
const toPairs = compose(map(split("=")), split("&"));
// params :: String -> [[String]] (URL에서 쿼리 파라미터 추출)
const params = compose(toPairs, last, split("?"));
// findParam :: String -> IO Maybe [String] (특정 키의 파라미터를 찾는 IO)
const findParam = (key) =>
map(compose(Maybe.of, find(compose(eq(key), head)), params), url);
// -- 불순한 호출 코드 ----------------------------------------------
// $value()를 호출하여 실행!
findParam("searchTerm").$value();
// Just(['searchTerm', 'wafflehouse']) // URL에 ?searchTerm=wafflehouse 가 있다고 가정
우리 라이브러리는 url
을 IO
로 감싸고 책임을 호출자에게 넘김으로써 손을 깨끗하게 유지합니다. 또한 컨테이너를 쌓았다는 것을 눈치챘을 수도 있습니다; IO(Maybe([x]))
를 갖는 것은 완벽하게 합리적이며, 이는 세 개의 펑터 깊이(Array
는 확실히 매핑 가능한 컨테이너 타입입니다)이며 매우 표현력이 풍부합니다.
저를 괴롭히는 것이 있었고 즉시 수정해야 합니다: IO
의 $value
는 실제로 포함된 값이 아니며 비공개 속성도 아닙니다. 그것은 수류탄의 핀이며 가장 공개적인 방식으로 호출자에 의해 당겨지도록 되어 있습니다. 이 속성을 unsafePerformIO
로 이름을 변경하여 사용자에게 그 변동성을 상기시킵시다.
class IO {
static of(x) {
return new IO(() => x);
}
constructor(io) {
// 부수 효과를 실행하는 함수를 저장합니다. 이름으로 위험성을 알립니다.
this.unsafePerformIO = io;
}
map(fn) {
// compose를 사용하여 실행 순서를 유지하면서 새 IO를 만듭니다.
return new IO(compose(fn, this.unsafePerformIO));
}
inspect() {
// 내부 함수 대신 개념적 표현을 보여줍니다.
return `IO(${inspect(this.unsafePerformIO)})`;
}
}
자, 훨씬 낫습니다. 이제 우리의 호출 코드는 findParam('searchTerm').unsafePerformIO()
가 되며, 이는 애플리케이션의 사용자(및 독자)에게 명확합니다.
IO
는 충실한 동반자가 되어, 우리가 그 야생의 불순한 액션들을 길들이는 데 도움을 줄 것입니다. 다음으로, 정신은 비슷하지만 사용 사례가 완전히 다른 타입을 살펴볼 것입니다.
비동기 작업 (Asynchronous Tasks)
콜백은 지옥으로 가는 좁은 나선형 계단입니다. 그것들은 M.C. 에셔가 디자인한 제어 흐름입니다. 중괄호와 괄호의 정글짐 사이에 끼워진 각 중첩된 콜백과 함께, 그것들은 지하 감옥에서의 림보처럼 느껴집니다 (얼마나 낮아질 수 있을까요?!). 그것들을 생각만 해도 폐소 공포증 오한이 듭니다. 걱정 마세요, 비동기 코드를 다루는 훨씬 더 나은 방법이 있으며 그것은 "F"로 시작합니다.
내부 구조는 여기에 모두 쏟아내기에는 약간 너무 복잡하므로 Quildreen Motta의 환상적인 Folktale의 Data.Task
(이전 Data.Future
)를 사용할 것입니다. 사용 예를 보십시오:
// Folktale 라이브러리의 Task를 사용한다고 가정합니다.
// npm install folktale
const Task = require('folktale/concurrency/task');
const fs = require('fs');
const $ = require('jquery'); // jQuery 예시를 위해 가정
// -- Node readFile 예시 ------------------------------------------
// readFile :: String -> Task Error String
const readFile = (filename) =>
new Task((resolver) => { // Folktale v2 스타일
fs.readFile(filename, 'utf-8', (err, data) => {
if (err) {
resolver.reject(err); // 오류 발생 시 reject 호출
} else {
resolver.resolve(data); // 성공 시 resolve 호출
}
});
});
readFile("metamorphosis.txt") // 파일이 존재한다고 가정
.map(split("
"))
.map(head);
// Task('One morning, as Gregor Samsa was waking up from anxious dreams, he discovered that in bed he had been changed into a monstrous verminous bug.')
// 실제로는 Task({ _computation: [Function], ... }) 형태
// -- jQuery getJSON 예시 -----------------------------------------
// getJSON :: String -> {} -> Task Error JSON
const getJSON = curry(
(url, params) =>
new Task((resolver) => {
$.getJSON(url, params)
.done(resolver.resolve) // 성공 시 resolve
.fail(resolver.reject); // 실패 시 reject
})
);
getJSON("/video", { id: 10 }).map(prop("title"));
// Task('Family Matters ep 15') // 실제로는 Task({ ... })
// -- 기본 최소 컨텍스트 ----------------------------------------
// 일반적인, 미래가 아닌 값도 내부에 넣을 수 있습니다.
Task.of(3).map((three) => three + 1);
// Task(4) // 실제로는 Task({ ... })
제가 reject
와 resolve
(Folktale v2 스타일)라고 부르는 함수는 각각 오류 및 성공 콜백입니다. 보시다시피, 우리는 마치 미래의 값이 바로 우리 손아귀에 있는 것처럼 작업하기 위해 Task
를 map
합니다. 이제 map
은 익숙할 것입니다.
Promise에 익숙하다면, map
함수를 then
으로 인식하고 Task
가 Promise의 역할을 하는 것을 알 수 있습니다. Promise에 익숙하지 않더라도 걱정하지 마세요. 어쨌든 우리는 그것들을 사용하지 않을 것입니다. 왜냐하면 그것들은 순수하지 않기 때문입니다. 하지만 비유는 그럼에도 불구하고 유효합니다.
IO
처럼, Task
는 실행하라는 신호를 줄 때까지 참을성 있게 기다릴 것입니다. 사실, 우리의 명령을 기다리기 때문에, IO
는 모든 비동기적인 것에 대해 효과적으로 Task
에 흡수됩니다; readFile
과 getJSON
은 순수하기 위해 추가적인 IO
컨테이너가 필요하지 않습니다. 더욱이, Task
는 우리가 그것을 map
할 때 비슷한 방식으로 작동합니다: 우리는 타임캡슐 안의 집안일 차트처럼 미래를 위한 지침을 배치하고 있습니다 - 정교한 기술적 미루기의 행위입니다.
Task
를 실행하려면 fork
메서드를 호출해야 합니다. 이것은 unsafePerformIO
처럼 작동하지만, 이름에서 알 수 있듯이 프로세스를 분기(fork)하고 평가는 스레드를 차단하지 않고 계속됩니다. 이것은 스레드 등으로 수많은 방식으로 구현될 수 있지만, 여기서는 일반적인 비동기 호출처럼 작동하고 이벤트 루프의 큰 바퀴는 계속 돌아갑니다. fork
를 살펴봅시다:
// -- 순수 애플리케이션 -------------------------------------------------
// 가상의 Handlebars와 blogTemplate 사용
// const Handlebars = require('handlebars');
// const blogTemplate = `...`; // 블로그 템플릿 문자열
// blogPage :: Posts -> HTML
// const blogPage = Handlebars.compile(blogTemplate);
// renderPage :: Posts -> HTML
// const renderPage = compose(blogPage, sortBy(prop("date")));
// blog :: Params -> Task Error HTML
// const blog = compose(map(renderPage), getJSON("/posts"));
// -- 불순한 호출 코드 ----------------------------------------------
// blog({}).fork(
// (error) => $("#error").html(error.message), // 오류 콜백
// (page) => $("#main").html(page) // 성공 콜백
// );
// $("#spinner").show(); // fork는 비동기이므로 스피너를 즉시 표시
fork
를 호출하면, Task
는 게시물을 찾고 페이지를 렌더링하기 위해 서둘러 떠납니다. 한편, fork
는 응답을 기다리지 않기 때문에 스피너를 표시합니다. 마지막으로, getJSON
호출이 성공했는지 여부에 따라 오류를 표시하거나 페이지를 화면에 렌더링합니다.
여기서 제어 흐름이 얼마나 선형적인지 잠시 생각해 보세요. 프로그램이 실행 중에 실제로 약간 점프하더라도 우리는 단지 아래에서 위로, 오른쪽에서 왼쪽으로 읽습니다. 이것은 콜백과 오류 처리 블록 사이를 오가는 것보다 애플리케이션을 읽고 추론하는 것을 더 간단하게 만듭니다.
맙소사, 저것 좀 보세요, Task
는 Either
도 삼켜버렸습니다! 비동기 세계에서는 우리의 일반적인 제어 흐름이 적용되지 않기 때문에 미래의 실패를 처리하기 위해 그렇게 해야 합니다. 이것은 기본적으로 충분하고 순수한 오류 처리를 제공하기 때문에 모두 좋습니다.
Task
가 있더라도, 우리의 IO
와 Either
펑터는 일자리를 잃지 않았습니다. 좀 더 복잡하고 가상적인 측면으로 기울지만 설명 목적으로 유용한 빠른 예를 들어보겠습니다.
// 가상의 데이터베이스 관련 함수들
// const Postgres = { connect: (url) => new IO(() => ({ /* DbConnection */ })) };
// const runQuery = (dbConnection) => ({ /* ResultSet */ });
// -- 순수 애플리케이션 -------------------------------------------------
// dbUrl :: Config -> Either Error Url
const dbUrl = ({ uname, pass, host, db }) => {
if (uname && pass && host && db) {
return Either.of(`db:pg://${uname}:${pass}@${host}:5432/${db}`);
}
return left(new Error("Invalid config!")); // Error 객체 사용 권장
};
// connectDb :: Config -> Either Error (IO DbConnection)
const connectDb = compose(map(Postgres.connect), dbUrl);
// getConfig :: Filename -> Task Error (Either Error (IO DbConnection))
// readFile의 결과(문자열)를 JSON.parse하고, connectDb를 적용합니다.
const getConfig = compose(map(compose(connectDb, JSON.parse)), readFile);
// -- 불순한 호출 코드 ----------------------------------------------
// 가상의 logErr 함수
// const logErr = curry((msg, err) => console.error(msg, err));
// getConfig("db.json").fork(
// logErr("couldn't read file"), // Task 실패 시
// either(
// console.log, // Either가 Left일 때 (설정 오류)
// (ioDbConnection) => ioDbConnection.map(runQuery).unsafePerformIO() // Either가 Right일 때 IO 실행
// )
// );
이 예제에서는 readFile
의 성공 분기 내에서 여전히 Either
와 IO
를 사용합니다. Task
는 파일을 비동기적으로 읽는 불순함을 처리하지만, 우리는 여전히 Either
로 구성을 유효성 검사하고 IO
로 db 연결을 처리합니다. 그래서 보시다시피, 우리는 여전히 모든 동기적인 것에 대해 사업을 하고 있습니다.
계속할 수도 있지만, 이것이 전부입니다. map
만큼 간단합니다.
실제로, 하나의 워크플로우에 여러 비동기 작업이 있을 가능성이 높으며, 우리는 아직 이 상자 안의 세계에서 작업하기 위한 전체 컨테이너 API를 습득하지 못했습니다. 걱정 마세요, 곧 모나드 등을 살펴볼 것이지만, 먼저 이 모든 것을 가능하게 하는 수학을 검토해야 합니다.
약간의 이론 (A Spot of Theory)
앞서 언급했듯이, 펑터는 카테고리 이론에서 왔으며 몇 가지 법칙을 만족합니다. 먼저 이러한 유용한 속성을 탐색해 봅시다.
// 항등 법칙 (identity)
map(id) === id; // f.map(x => x) 와 f 가 같다는 의미
// 결합 법칙 (composition)
compose(map(f), map(g)) === map(compose(f, g)); // f.map(g).map(f) 와 f.map(x => f(g(x))) 가 같다는 의미
항등 법칙은 간단하지만 중요합니다. 이 법칙들은 실행 가능한 코드 조각이므로, 우리 자신의 펑터에서 그것들을 시도하여 합법성을 검증할 수 있습니다.
const idLaw1 = map(id);
const idLaw2 = id;
idLaw1(Container.of(2)); // Container(2)
idLaw2(Container.of(2)); // Container(2)
보시다시피, 그것들은 같습니다. 다음으로 결합 법칙을 살펴봅시다.
const compLaw1 = compose(map(append(" world")), map(append(" cruel")));
const compLaw2 = map(compose(append(" world"), append(" cruel")));
compLaw1(Container.of("Goodbye")); // Container('Goodbye cruel world')
compLaw2(Container.of("Goodbye")); // Container('Goodbye cruel world')
카테고리 이론에서, 펑터는 카테고리의 객체와 사상(morphism)을 가져와 다른 카테고리로 매핑합니다. 정의에 따라, 이 새로운 카테고리는 항등원과 사상을 합성하는 능력을 가져야 하지만, 앞서 언급한 법칙들이 이것들이 보존됨을 보장하기 때문에 확인할 필요가 없습니다.
아마도 카테고리에 대한 우리의 정의가 여전히 약간 모호할 수 있습니다. 카테고리를 객체들의 네트워크와 그것들을 연결하는 사상으로 생각할 수 있습니다. 따라서 펑터는 네트워크를 깨뜨리지 않고 한 카테고리를 다른 카테고리로 매핑할 것입니다. 객체 a
가 우리의 소스 카테고리 C
에 있다면, 펑터 F
로 카테고리 D
에 매핑할 때, 우리는 그 객체를 F a
라고 부릅니다 (합치면 어떤 단어가 될까요?!). 아마도, 다이어그램을 보는 것이 더 나을 것입니다:
예를 들어, Maybe
는 우리의 타입과 함수의 카테고리를 각 객체가 존재하지 않을 수 있고 각 사상이 null
검사를 갖는 카테고리로 매핑합니다. 우리는 코드에서 각 함수를 map
으로 감싸고 각 타입을 우리의 펑터로 감싸서 이것을 달성합니다. 우리는 우리의 일반적인 타입과 함수 각각이 이 새로운 세계에서 계속 합성될 것이라는 것을 압니다. 기술적으로, 우리 코드의 각 펑터는 타입과 함수의 하위 카테고리로 매핑되며, 이는 모든 펑터를 엔도펑터(endofunctor)라는 특정 브랜드로 만듭니다. 하지만 우리의 목적을 위해, 우리는 그것을 다른 카테고리로 생각할 것입니다.
우리는 또한 이 다이어그램으로 사상과 해당 객체의 매핑을 시각화할 수 있습니다:
펑터 F
하에서 한 카테고리에서 다른 카테고리로 매핑된 사상을 시각화하는 것 외에도, 다이어그램이 교환 법칙을 만족한다는 것을 알 수 있습니다. 즉, 화살표를 따라가면 각 경로가 동일한 결과를 생성합니다. 다른 경로는 다른 행동을 의미하지만, 우리는 항상 동일한 타입으로 끝납니다. 이 형식주의는 우리 코드에 대해 추론하는 원칙적인 방법을 제공합니다 - 우리는 각 개별 시나리오를 구문 분석하고 검사할 필요 없이 대담하게 공식을 적용할 수 있습니다. 구체적인 예를 들어 봅시다.
// topRoute :: String -> Maybe String
const topRoute = compose(Maybe.of, reverse); // 먼저 reverse하고 Maybe로 감싼다
// bottomRoute :: String -> Maybe String
const bottomRoute = compose(map(reverse), Maybe.of); // 먼저 Maybe로 감싸고 map(reverse)한다
topRoute("hi"); // Just('ih')
bottomRoute("hi"); // Just('ih')
또는 시각적으로:
우리는 모든 펑터가 보유한 속성을 기반으로 코드를 즉시 보고 리팩토링할 수 있습니다.
펑터는 쌓일 수 있습니다:
const nested = Task.of([Either.of("pillows"), left("no sleep for you")]);
// Task 안의 Array 안의 Either
map(map(map(toUpperCase)), nested);
// Task([Right('PILLOWS'), Left('no sleep for you')])
// 각 펑터 계층을 map으로 벗겨내며 함수 적용
여기 nested
에서 우리가 가진 것은 오류일 수 있는 요소들의 미래 배열입니다. 우리는 각 계층을 벗겨내고 요소에 함수를 실행하기 위해 map
합니다. 콜백, if/else 또는 for 루프는 보이지 않습니다; 단지 명시적인 컨텍스트만 있습니다. 그러나 우리는 map(map(map(f)))
를 해야 합니다. 대신 펑터를 합성할 수 있습니다. 제 말을 제대로 들으셨습니다:
// Compose 펑터 정의 (두 펑터 F, G를 합성)
class Compose {
constructor(fgx) {
// 내부에는 F(G(x)) 형태의 중첩된 펑터 값을 저장
this.getCompose = fgx;
}
static of(x) {
// of 메서드는 타입에 따라 다르게 구현되어야 하지만, 여기서는 단순화
// 실제로는 F.of(G.of(x)) 와 유사해야 함
// 이 예제에서는 이미 중첩된 값을 받는다고 가정
return new Compose(x);
}
map(fn) {
// map(map(fn))을 사용하여 내부의 두 펑터에 함수를 적용
return new Compose(map(map(fn), this.getCompose));
}
}
const tmd = Task.of(Maybe.of("Rock over London")); // Task(Maybe(String))
const ctmd = Compose.of(tmd); // Compose(Task(Maybe(String)))
// map 한 번으로 내부 값에 접근
const ctmd2 = map(append(", rock on, Chicago"), ctmd);
// Compose(Task(Just('Rock over London, rock on, Chicago')))
ctmd2.getCompose; // 내부의 중첩된 펑터 값 얻기
// Task(Just('Rock over London, rock on, Chicago'))
자, 한 번의 map
입니다. 펑터 합성은 결합 법칙을 만족하며, 이전에 우리는 실제로 Identity
펑터라고 불리는 Container
를 정의했습니다. 만약 항등원과 결합 법칙을 만족하는 합성이 있다면, 우리는 카테고리를 갖습니다. 이 특정 카테고리는 객체로서 카테고리를, 사상으로서 펑터를 가지며, 이는 뇌를 땀 흘리게 하기에 충분합니다. 우리는 이것에 너무 깊이 파고들지는 않겠지만, 건축적 함의나 패턴의 단순한 추상적 아름다움을 감상하는 것은 좋습니다.
요약 (In Summary)
우리는 몇 가지 다른 펑터를 보았지만, 무한히 많습니다. 몇 가지 주목할 만한 누락은 트리, 리스트, 맵, 페어와 같은 반복 가능한 데이터 구조입니다. 이벤트 스트림과 옵저버블은 모두 펑터입니다. 다른 것들은 캡슐화나 심지어 타입 모델링을 위한 것일 수 있습니다. 펑터는 우리 주변 어디에나 있으며 책 전체에서 광범위하게 사용할 것입니다.
여러 펑터 인수를 가진 함수를 호출하는 것은 어떨까요? 불순하거나 비동기적인 액션의 순서대로 작업하는 것은 어떨까요? 우리는 아직 이 상자 안의 세계에서 작업하기 위한 전체 도구 세트를 습득하지 못했습니다. 다음으로, 우리는 바로 본론으로 들어가 모나드를 살펴볼 것입니다.
연습 문제 (Exercises)
add
와 map
을 사용하여 펑터 내부의 값을 증가시키는 함수를 만드세요.
// incrF :: Functor f => f Int -> f Int
const incrF = undefined;
다음 User 객체가 주어졌습니다:
const user = { id: 2, name: "Albert", active: true };
safeProp
과 head
를 사용하여 사용자의 첫 번째 이니셜을 찾으세요.
// initial :: User -> Maybe String
const initial = undefined;
다음 헬퍼 함수들이 주어졌습니다:
// showWelcome :: User -> String
const showWelcome = compose(concat("Welcome "), prop("name"));
// checkActive :: User -> Either String User
const checkActive = function checkActive(user) {
return user.active ? Either.of(user) : left("Your account is not active");
};
{% exercise %}
checkActive
와 showWelcome
을 사용하여 접근을 허용하거나 오류를 반환하는 함수를 작성하세요.
// eitherWelcome :: User -> Either String String
const eitherWelcome = undefined;
이제 다음 함수들을 고려합니다:
// validateUser :: (User -> Either String ()) -> User -> Either String User
const validateUser = curry((validate, user) => validate(user).map((_) => user));
// save :: User -> IO User
const save = (user) => new IO(() => ({ ...user, saved: true }));
사용자 이름이 3자보다 긴지 확인하거나 오류 메시지를 반환하는 함수 validateName
을 작성하세요.
그런 다음 either
, showWelcome
, save
를 사용하여 유효성 검사가 통과되었을 때 사용자를 등록하고 환영하는 register
함수를 작성하세요.
either
의 두 인수는 동일한 타입을 반환해야 함을 기억하세요.
// validateName :: User -> Either String ()
const validateName = undefined;
// register :: User -> IO String
const register = compose(undefined, validateUser(validateName));