abonglog logoabonglog

Chapter 10: Applicative Functors [번역] 의 썸네일

Chapter 10: Applicative Functors [번역]

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

해당 게시글은 mostly-adequate-guide 의 시리즈들을 번역했습니다.

해당 게시글의 저작권은 mostly-adequate-guide 의 저작권인 Attribution-ShareAlike 4.0 International 을 준수합니다.

🤖 AI가 요약한 글이에요!
이 장에서는 어플리커티브 펑터(Applicative Functor) 의 개념과 필요성을 설명합니다.
어플리커티브 펑터는 펑터 내부에 있는 함수를 다른 펑터 내부의 값에 적용하는 ap 메서드를 제공합니다.
이는 여러 펑터 인자를 받는 함수를 펑터 컨텍스트 내에서 처리할 때 유용하며, 모나드와 달리 병렬 실행이 가능할 수 있습니다.
ofap를 사용하여 함수와 인자를 모두 펑터로 감싼 후 적용하는 패턴과, 이를 추상화한 liftA2, liftA3 등의 헬퍼 함수를 소개합니다.
어플리커티브 펑터는 모나드보다 덜 강력하지만, 필요한 기능만 제공하여 코드를 더 명확하게 만들 수 있습니다.

Chapter 10: 어플리커티브 펑터 (Applicative Functors)

어플리커티브 적용하기

어플리커티브 펑터(applicative functor) 라는 이름은 그 함수적 기원을 고려할 때 기분 좋게 설명적입니다. 함수형 프로그래머들은 mappendliftA4와 같은 이름을 생각해내는 것으로 악명 높은데, 이는 수학 실험실에서 볼 때는 완벽하게 자연스러워 보이지만 다른 어떤 맥락에서는 드라이브 스루에서 우유부단한 다스 베이더만큼이나 명확성이 떨어집니다.

어쨌든, 이름은 이 인터페이스가 우리에게 무엇을 제공하는지에 대한 비밀을 누설해야 합니다: 펑터를 서로에게 적용하는 능력.

이제, 당신과 같은 정상적이고 이성적인 사람이 왜 그런 것을 원할까요? 하나의 펑터를 다른 펑터에 적용한다는 것은 도대체 무엇을 의미할까요?

이 질문들에 답하기 위해, 당신이 함수형 여정에서 이미 마주쳤을 수도 있는 상황으로 시작하겠습니다. 가설적으로, 두 개의 (같은 타입의) 펑터가 있고 그들의 값을 인수로 사용하여 함수를 호출하고 싶다고 가정해 봅시다. 두 Container의 값을 더하는 것과 같은 간단한 것입니다.

Container 값 직접 더하기 시도
// 숫자가 병 속에 담겨 있기 때문에 이렇게 할 수 없습니다.
add(Container.of(2), Container.of(3));
// NaN
 
// 우리의 믿음직한 map을 사용합시다
const containerOfAdd2 = map(add, Container.of(2));
// Container(add(2))

우리는 내부에 부분적으로 적용된 함수를 가진 Container를 가지고 있습니다. 더 구체적으로, 우리는 Container(add(2))를 가지고 있고, 그 add(2)Container(3) 안의 3에 적용하여 호출을 완료하고 싶습니다. 즉, 하나의 펑터를 다른 펑터에 적용하고 싶습니다.

이제, 우연히도 우리는 이미 이 작업을 수행할 도구를 가지고 있습니다. 다음과 같이 부분적으로 적용된 add(2)chain한 다음 map할 수 있습니다:

chain과 map을 사용한 펑터 적용
Container.of(2).chain((two) => Container.of(3).map(add(two)));

여기서 문제는 이전 모나드가 작업을 완료할 때까지 아무것도 평가될 수 없는 모나드의 순차적인 세계에 갇혀 있다는 것입니다. 우리는 두 개의 강력하고 독립적인 값을 가지고 있으며, 단지 모나드의 순차적 요구 사항을 만족시키기 위해 Container(3)의 생성을 지연시키는 것은 불필요하다고 생각합니다.

사실, 만약 우리가 이 곤경에 처한다면 이러한 불필요한 함수와 변수 없이 하나의 펑터의 내용을 다른 펑터의 값에 간결하게 적용할 수 있다면 좋을 것입니다.

병 속의 배 (Ships in Bottles)

병 속의 배병 속의 배

ap는 하나의 펑터의 함수 내용을 다른 펑터의 값 내용에 적용할 수 있는 함수입니다. 다섯 번 빨리 말해보세요.

ap 메서드 사용 예시
Container.of(add(2)).ap(Container.of(3));
// Container(5)
 
// 이제 모두 함께
 
Container.of(2).map(add).ap(Container.of(3));
// Container(5)

거기 있습니다, 멋지고 깔끔합니다. Container(3)에게는 좋은 소식입니다. 중첩된 모나딕 함수의 감옥에서 풀려났기 때문입니다. 이 경우 add가 첫 번째 map 동안 부분적으로 적용되므로 이것은 add가 커링되었을 때만 작동한다는 점을 다시 언급할 가치가 있습니다.

ap를 다음과 같이 정의할 수 있습니다:

Container의 ap 메서드 구현
Container.prototype.ap = function (otherContainer) {
  return otherContainer.map(this.$value);
};

기억하세요, this.$value는 함수가 될 것이고 우리는 다른 펑터를 받을 것이므로 그것을 map하기만 하면 됩니다. 그리고 그것으로 우리는 인터페이스 정의를 얻습니다:

어플리커티브 펑터(applicative functor)ap 메서드를 가진 포인티드 펑터입니다.

포인티드(pointed) 에 대한 의존성에 주목하세요. 포인티드 인터페이스는 다음 예제들 전반에 걸쳐 보게 될 것처럼 여기서 매우 중요합니다.

이제, 당신의 회의론(또는 아마도 혼란과 공포)을 감지하지만, 열린 마음을 가지세요; 이 ap 캐릭터는 유용하다는 것이 증명될 것입니다. 그것에 들어가기 전에, 좋은 속성을 탐색해 봅시다.

map과 of/ap의 동등성
F.of(x).map(f) === F.of(f).ap(F.of(x));

적절한 영어로, f를 매핑하는 것은 f의 펑터를 ap하는 것과 동일합니다. 또는 더 적절한 영어로, 우리는 x를 우리 컨테이너에 넣고 map(f)하거나 또는 fx를 모두 우리 컨테이너로 끌어올려(lift) ap할 수 있습니다. 이것은 왼쪽에서 오른쪽으로 작성할 수 있게 해줍니다:

여러 ap 호출 예시
Maybe.of(add).ap(Maybe.of(2)).ap(Maybe.of(3));
// Maybe(5)
 
Task.of(add).ap(Task.of(2)).ap(Task.of(3));
// Task(5)

눈을 가늘게 뜨고 보면 일반적인 함수 호출의 모호한 형태를 인식할 수도 있습니다. 이 장의 뒷부분에서 포인트프리 버전을 살펴볼 것이지만, 지금은 이것이 그러한 코드를 작성하는 선호되는 방법입니다. of를 사용하여 각 값은 컨테이너라는 마법의 땅으로 운반됩니다. 이 병렬 우주에서는 각 애플리케이션이 비동기적이거나 null이거나 무엇이든 될 수 있으며 ap는 이 환상적인 장소 내에서 함수를 적용할 것입니다. 마치 병 속에 배를 만드는 것과 같습니다.

거기 보셨나요? 예제에서 Task를 사용했습니다. 이것은 어플리커티브 펑터가 제 역할을 하는 주요 상황입니다. 더 심층적인 예제를 살펴봅시다.

협업 동기 (Coordination Motivation)

여행 사이트를 구축하고 있고 관광 목적지 목록과 지역 이벤트 목록을 모두 검색하고 싶다고 가정해 봅시다. 이들은 각각 별도의 독립적인 API 호출입니다.

Task와 ap를 사용한 비동기 작업 병렬 처리
// Http.get :: String -> Task Error HTML
 
const renderPage = curry((destinations, events) => {
  /* 페이지 렌더링 */
});
 
Task.of(renderPage).ap(Http.get("/destinations")).ap(Http.get("/events"));
// Task("<div>목적지와 이벤트가 있는 페이지</div>")

Http 호출은 즉시 발생하며 renderPage는 둘 다 해결될 때 호출됩니다. 이것을 하나의 Task가 완료되어야 다음 Task가 시작되는 모나딕 버전과 대조해 보세요. 이벤트를 검색하기 위해 목적지가 필요하지 않으므로 순차적 평가에서 자유롭습니다.

다시 말하지만, 이 결과를 얻기 위해 부분 적용을 사용하고 있기 때문에 renderPage가 커링되었는지 확인해야 합니다. 그렇지 않으면 두 Task가 완료될 때까지 기다리지 않을 것입니다. 부수적으로, 만약 당신이 수동으로 그런 일을 해야 했다면, 이 인터페이스의 놀라운 단순함에 감사할 것입니다. 이것은 우리를 특이점에 한 걸음 더 다가가게 하는 아름다운 코드 종류입니다.

다른 예제를 살펴봅시다.

IO와 ap를 사용한 동기 작업 처리
// $ :: String -> IO DOM
const $ = (selector) => new IO(() => document.querySelector(selector));
 
// getVal :: String -> IO String
const getVal = compose(map(prop("value")), $);
 
// signIn :: String -> String -> Bool -> User
const signIn = curry((username, password, rememberMe) => {
  /* 로그인 중 */
});
 
IO.of(signIn).ap(getVal("#email")).ap(getVal("#password")).ap(IO.of(false));
// IO({ id: 3, email: 'gg@allin.com' })

signIn은 3개의 인수를 받는 커링된 함수이므로 그에 따라 ap해야 합니다. 각 ap마다 signIn은 완료되어 실행될 때까지 하나의 인수를 더 받습니다. 필요한 만큼 많은 인수로 이 패턴을 계속할 수 있습니다. 주목할 또 다른 점은 두 개의 인수는 자연스럽게 IO로 끝나지만 마지막 인수는 ap가 함수와 모든 인수가 같은 타입에 있기를 기대하기 때문에 IO로 끌어올리기 위해 of의 약간의 도움이 필요하다는 것입니다.

브로, 리프팅은 하니? (Bro, Do You Even Lift?)

이러한 어플리커티브 호출을 작성하는 포인트프리 방법을 살펴봅시다. mapof/ap와 동일하다는 것을 알고 있으므로, 지정한 횟수만큼 ap할 일반적인 함수를 작성할 수 있습니다:

liftA2, liftA3 헬퍼 함수 정의
const liftA2 = curry((g, f1, f2) => f1.map(g).ap(f2));
 
const liftA3 = curry((g, f1, f2, f3) => f1.map(g).ap(f2).ap(f3));
 
// liftA4, 등등

liftA2는 이상한 이름입니다. 낡은 공장의 까다로운 화물 엘리베이터 중 하나나 값싼 리무진 회사의 허영 번호판처럼 들립니다. 그러나 깨달음을 얻으면 자명합니다: 이 조각들을 어플리커티브 펑터 세계로 끌어올리십시오.

처음 이 2-3-4 헛소리를 보았을 때 그것은 추하고 불필요하다고 생각했습니다. 결국, 우리는 자바스크립트에서 함수의 인자 개수(arity)를 확인하고 이것을 동적으로 구축할 수 있습니다. 그러나 종종 liftA(N) 자체를 부분적으로 적용하는 것이 유용하므로 인수 길이가 달라질 수 없습니다.

이것이 사용되는 것을 봅시다:

liftA2 사용 예시 (Either)
// checkEmail :: User -> Either String Email
// checkName :: User -> Either String String
 
const user = {
  name: "John Doe",
  email: "blurp_blurp",
};
 
//  createUser :: Email -> String -> IO User
const createUser = curry((email, name) => {
  /* 생성 중... */
});
 
Either.of(createUser).ap(checkEmail(user)).ap(checkName(user));
// Left('invalid email')
 
liftA2(createUser, checkEmail(user), checkName(user));
// Left('invalid email')

createUser는 두 개의 인수를 받으므로 해당 liftA2를 사용합니다. 두 문장은 동일하지만, liftA2 버전에는 Either에 대한 언급이 없습니다. 이것은 더 이상 특정 타입에 얽매이지 않기 때문에 더 일반적이고 유연하게 만듭니다.

이전 예제들이 이 방식으로 작성된 것을 봅시다:

liftA를 사용한 이전 예제 리팩토링
liftA2(add, Maybe.of(2), Maybe.of(3));
// Maybe(5)
 
liftA2(renderPage, Http.get("/destinations"), Http.get("/events"));
// Task('<div>목적지와 이벤트가 있는 페이지</div>')
 
liftA3(signIn, getVal("#email"), getVal("#password"), IO.of(false));
// IO({ id: 3, email: 'gg@allin.com' })

연산자 (Operators)

Haskell, Scala, PureScript, Swift와 같이 자신만의 중위 연산자를 만들 수 있는 언어에서는 다음과 같은 구문을 볼 수 있습니다:

Haskell/PureScript의 어플리커티브 연산자
-- Haskell / PureScript
add <$> Right 2 <*> Right 3
JavaScript에서의 동일한 표현
// JavaScript
map(add, Right(2)).ap(Right(3));

<\$>map(즉, fmap)이고 <*>가 단지 ap라는 것을 아는 것이 도움이 됩니다. 이것은 더 자연스러운 함수 적용 스타일을 가능하게 하고 일부 괄호를 제거하는 데 도움이 될 수 있습니다.

무료 캔 따개 (Free Can Openers)

무료 병 따개무료 병 따개

파생 함수에 대해 많이 이야기하지 않았습니다. 이 모든 인터페이스가 서로를 기반으로 구축되고 일련의 법칙을 따르므로, 더 강한 인터페이스 측면에서 일부 약한 인터페이스를 정의할 수 있습니다.

예를 들어, 어플리커티브가 먼저 펑터라는 것을 알고 있으므로, 어플리커티브 인스턴스가 있다면 확실히 우리 타입에 대한 펑터를 정의할 수 있습니다.

이러한 종류의 완벽한 계산 조화는 우리가 수학적 프레임워크 내에서 작업하고 있기 때문에 가능합니다. 모차르트가 어렸을 때 Ableton을 토렌트로 다운로드했더라도 더 잘할 수는 없었을 것입니다.

앞서 of/apmap과 동일하다고 언급했습니다. 이 지식을 사용하여 map을 무료로 정의할 수 있습니다:

of/ap로부터 map 파생
// of/ap에서 파생된 map
X.prototype.map = function map(f) {
  return this.constructor.of(f).ap(this);
};

모나드는 말하자면 먹이 사슬의 최상위에 있으므로, chain이 있다면 펑터와 어플리커티브를 무료로 얻습니다:

chain으로부터 map과 ap 파생
// chain에서 파생된 map
X.prototype.map = function map(f) {
  return this.chain((a) => this.constructor.of(f(a)));
};
 
// chain/map에서 파생된 ap
X.prototype.ap = function ap(other) {
  return this.chain((f) => other.map(f));
};

모나드를 정의할 수 있다면 어플리커티브와 펑터 인터페이스를 모두 정의할 수 있습니다. 이 모든 캔 따개를 무료로 얻을 수 있다는 것은 매우 놀랍습니다. 타입을 검사하고 이 프로세스를 자동화할 수도 있습니다.

ap의 매력 중 일부는 작업을 동시에 실행할 수 있는 능력이라는 점을 지적해야 하므로 chain을 통해 정의하는 것은 해당 최적화를 놓치는 것입니다. 그럼에도 불구하고, 최상의 구현을 작업하는 동안 즉시 작동하는 인터페이스를 갖는 것이 좋습니다.

왜 그냥 모나드를 사용하고 끝내지 않느냐고 물으실 수 있습니다. 필요한 만큼의 힘으로 작업하고, 그 이상도 그 이하도 아닌 것이 좋은 습관입니다. 이것은 가능한 기능을 배제함으로써 인지 부하를 최소화합니다. 이러한 이유로 모나드보다 어플리커티브를 선호하는 것이 좋습니다.

모나드는 하향 중첩 구조 덕분에 계산 순서를 정하고, 변수를 할당하고, 추가 실행을 중단하는 고유한 능력을 가지고 있습니다. 어플리커티브가 사용되는 것을 볼 때, 그 어떤 비즈니스에도 신경 쓸 필요가 없습니다.

이제, 법률 문제로 넘어가서...

법칙 (Laws)

우리가 탐구한 다른 수학적 구조와 마찬가지로, 어플리커티브 펑터는 일상 코드에서 의존할 수 있는 몇 가지 유용한 속성을 가지고 있습니다. 우선, 어플리커티브는 "합성 하에 닫혀 있다(closed under composition)"는 것을 알아야 합니다. 즉, ap는 절대 우리에게 컨테이너 타입을 변경하지 않을 것입니다 (모나드보다 선호하는 또 다른 이유). 그렇다고 해서 여러 다른 효과를 가질 수 없다는 의미는 아닙니다 - 우리는 우리의 타입이 애플리케이션 전체에서 동일하게 유지될 것이라는 것을 알고 타입을 쌓을 수 있습니다.

증명하기 위해:

중첩된 어플리커티브 타입 유지
const tOfM = compose(Task.of, Maybe.of);
 
liftA2(
  liftA2(concat),
  tOfM("Rainy Days and Mondays"),
  tOfM(" always get me down")
);
// Task(Maybe(Rainy Days and Mondays always get me down))

보세요, 다른 타입이 섞이는 것에 대해 걱정할 필요가 없습니다.

우리가 가장 좋아하는 카테고리 법칙인 항등(identity) 을 살펴볼 시간입니다:

항등 (Identity)

어플리커티브 항등 법칙
// identity
A.of(id).ap(v) === v;

맞습니다, 펑터 내에서 id를 적용하는 것은 v의 값을 변경해서는 안 됩니다. 예를 들어:

Identity 펑터 항등 법칙 예시
const v = Identity.of("Pillow Pets");
Identity.of(id).ap(v) === v;

Identity.of(id)는 그 헛됨에 웃음이 나옵니다. 어쨌든, 흥미로운 점은 이미 확립했듯이 of/apmap과 동일하므로 이 법칙은 펑터 항등(map(id) == id)에서 직접 파생된다는 것입니다.

이러한 법칙을 사용하는 것의 아름다움은, 전투적인 유치원 체육 코치처럼, 우리의 모든 인터페이스가 함께 잘 작동하도록 강제한다는 것입니다.

준동형 (Homomorphism)

어플리커티브 준동형 법칙
// homomorphism
A.of(f).ap(A.of(x)) === A.of(f(x));

준동형(homomorphism) 은 단지 구조를 보존하는 맵입니다. 사실, 펑터는 매핑 하에서 원래 카테고리의 구조를 보존하기 때문에 카테고리 간의 준동형일 뿐입니다.

우리는 정말로 우리의 일반적인 함수와 값을 컨테이너에 넣고 거기서 계산을 실행하고 있으므로, 컨테이너 내부에서 전체를 적용하든(등식의 왼쪽) 외부에서 적용한 다음 거기에 넣든(오른쪽) 동일한 결과를 얻게 될 것이라는 것은 놀라운 일이 아닙니다.

빠른 예시:

Either 준동형 법칙 예시
Either.of(toUpperCase).ap(Either.of("oreos")) ===
  Either.of(toUpperCase("oreos"));

교환 (Interchange)

교환(interchange) 법칙은 함수를 ap의 왼쪽 또는 오른쪽에 넣기로 선택하든 상관없다는 것을 명시합니다.

어플리커티브 교환 법칙
// interchange
v.ap(A.of(x)) === A.of((f) => f(x)).ap(v);

예시는 다음과 같습니다:

Task 교환 법칙 예시
const v = Task.of(reverse);
const x = "Sparklehorse";
 
v.ap(Task.of(x)) === Task.of((f) => f(x)).ap(v);

합성 (Composition)

그리고 마지막으로 합성은 컨테이너 내부에서 적용할 때 우리의 표준 함수 합성이 유지되는지 확인하는 방법일 뿐입니다.

어플리커티브 합성 법칙
// composition
A.of(compose).ap(u).ap(v).ap(w) === u.ap(v.ap(w));
IO 합성 법칙 예시
const u = IO.of(toUpperCase);
const v = IO.of(concat("& beyond"));
const w = IO.of("blood bath ");
 
IO.of(compose).ap(u).ap(v).ap(w) === u.ap(v.ap(w));

요약

어플리커티브의 좋은 사용 사례는 여러 펑터 인수가 있을 때입니다. 그들은 펑터 세계 내에서 함수를 인수에 적용하는 능력을 제공합니다. 모나드로 이미 그렇게 할 수 있었지만, 모나드 특정 기능이 필요하지 않을 때는 어플리커티브 펑터를 선호해야 합니다.

컨테이너 API에 거의 다 왔습니다. 함수를 map, chain, 그리고 이제 ap하는 방법을 배웠습니다. 다음 장에서는 여러 펑터를 더 잘 다루고 원칙적인 방식으로 분해하는 방법을 배울 것입니다.

연습 문제

다음 연습 문제에서는 다음 헬퍼를 고려합니다:

localStorage 및 헬퍼 함수
const localStorage = {
  player1: { id: 1, name: "Albert" },
  player2: { id: 2, name: "Theresa" },
};
 
// getFromCache :: String -> IO User
const getFromCache = (x) => new IO(() => localStorage[x]);
 
// game :: User -> User -> String
const game = curry((p1, p2) => `${p1.name} vs ${p2.name}`);