파이프라인 뒤에 있는 생각들 — 현업 Dart 개발자를 위한 교과서
이 책은 FxDart 101의 이론 짝입니다. 튜토리얼이 이 함수를 어떻게 쓰는가에 답한다면, 이 책은 왜 함수가 그런 모양인가, 그리고 그 모양이 무엇을 보장하는가에 답합니다.
독자는 현업 Dart 개발자로 가정합니다. 그 말은 세 가지를 뜻합니다.
Dart 외의 사전 지식은 요구하지 않습니다. 하스켈도, 범주론도, "함수는 입력을 출력에 대응시킨다" 이상의 수학도 필요 없습니다. 형식적 정의가 있는 개념은 정의도 함께 싣지만, 언제나 그 개념을 먼저 써 본 뒤에 나옵니다.
모든 예제는 실행됩니다. ▶ 실행 버튼이 붙은 코드는 실제 Dart 컴파일러로 컴파일되어 이 페이지 안에서 실행됩니다. 첫 실행은 컴파일러 런타임을 내려받느라 몇 초 걸리고, 그다음부터는 즉시 실행됩니다. 프로그램이 무엇을 출력하는가에 대한 이 책의 주장은 믿으라고 쓴 것이 아니라 확인하라고 쓴 것입니다.
선전이 아니라 정직함. 여기 담긴 아이디어 중 어떤 것은 첫 한 시간 안에 본전을 뽑습니다. 어떤 것은 우아하지만 Dart에서는 마찰이 더 큽니다 — Dart가 아예 표현하지 못하는 것도 여럿 있고, 그런 경우에는 그렇다고 말한 뒤 FxDart가 대신 무엇을 하는지 보여 줍니다.
코드에 대하여. 예제 코드는 어느 언어판에서든 동일합니다(주석 포함). 같은 프로그램을 두 벌 유지하면 반드시 어긋나고, 실행으로 검증되는 것은 한 벌뿐이기 때문입니다. 번역되는 것은 산문입니다.
화살표 버튼, ← → 키, 또는 차례로 원하는 장으로 이동하세요. 각 장은 연습문제로 끝나고, 정답은 다음 펼침면에 있습니다 — 넘기기 전에 먼저 생각해 볼 수 있도록.
표기.
A,B는 평범한 타입(int,User)입니다.M<A>는 어떤 구조M안에 들어 있는A값 —List<A>,Future<A>,Either<E, A>— 입니다.A → M<B>라고 쓴 함수는 평범한 값을 받아 구조 안에 든 값을 돌려줍니다. 1부의 대부분은 바로 이 하나의 모양에 관한 이야기입니다.
이 장에서 다루는 것
- 이미 쓰고 있는 Dart의 모나드 셋과, 그 셋을 하나의 모양으로 만드는 것
- 두 연산 —
of와flatMap— 그리고 왜 "납작하게 만들기"가 핵심인가- 세 법칙을 실행 가능한 코드로, 그리고 법칙을 어기면 무엇이 깨지는가
- Dart가
Monad인터페이스를 선언할 수 없는 이유와, FxDart의 대안
그 유명한 정의 — 모나드는 자기함자 범주 위의 모노이드다 — 는 참이고, 동시에 최악의 첫 문장입니다. 인스턴스를 하나도 만나 보지 못한 사람에게 일반형부터 설명하는 셈이니까요. 그래서 인스턴스 셋을 먼저 봅니다. 셋 다 이미 써 보셨을 겁니다.
import 'package:fxdart/fxdart.dart'; Either<String, int> parsePort(String text) => either((r) { final n = r.ensureNotNull( int.tryParse(text), () => 'not a number: $text'); r.ensure(n > 1023, () => 'privileged port: $n'); return n;}); Future<int> fetchTimeout(int port) async => port + 100; void main() async { // List<A>: many values in one structure. print([1, 2, 3].expand((x) => [x, x * 10]).toList()); // Either<String, int>: a value, or a failure instead of it. print(parsePort('8080')); print(parsePort('80')); // Future<A>: a value that is not here yet. print(await Future.value(8080).then(fetchTimeout));}서로 아무 관계 없는 세 타입입니다. List는 값을 여러 개 담고, Either는 값 하나 또는 실패 하나를 담고, Future는 아직 도착하지 않은 값을 담습니다. 셋이 공유하는 것은 무엇을 담느냐가 아니라 그것으로 무엇을 할 수 있느냐 입니다.
그림 1-1. 내용물은 다르지만 배선은 같다. 어느 것이든 평범한 값을 하나 받아들일 수 있고, 어느 것이든 "같은 종류의 상자를 돌려주는 함수"를 이어 붙일 수 있다.
세 타입 모두 다음 두 연산을 제공합니다.
| 값을 넣기 | 상자를 돌려주는 단계를 잇기 | |
|---|---|---|
List<A> | [a] | expand |
Future<A> | Future.value(a) | then |
Either<E, A> | Either.right(a) | flatMap |
Fx<A> (FxDart) | fx([a]) | flatMap |
이 두 연산을 갖추고, 곧 볼 세 법칙을 지키는 타입이 모나드입니다. 정의는 이게 전부입니다. 이 단어가 위압적인 이유는 개념이 커서가 아니라, 범주론에서 어휘를 통째로 달고 건너왔기 때문입니다.
구조 M 안에 든 A 값을 M<A>라고 씁시다. 모나드는 타입 생성자 M과 다음 두 연산입니다.
pure, return, unit이라고도 합니다): A → M<A>. 평범한 값 하나를 받아, 그 값을 담은 가장 심심한 상자를 돌려줍니다. "심심함"은 기술적 요구 조건입니다 — Either.right(3)은 실패를 더하지 않고, Future.value(3)은 기다림을 더하지 않고, [3]은 원소를 더하지 않습니다.bind 또는 >>=): M<A> × (A → M<B>) → M<B>. 상자 하나와, 그 안의 값을 또 다른 상자로 바꾸는 함수를 받아, 상자 하나를 돌려줍니다 — 상자 속 상자가 아니라.마지막 문장의 뒷부분이 전부이고, 그 이유는 그 부분을 빼 보면 바로 보입니다. map만으로는 부족합니다.
void main() { // The step returns a List, so map gives a List of Lists. final nested = [1, 2, 3].map((x) => [x, x * 10]).toList(); print(nested); print(nested.runtimeType); // flatMap (Dart spells it `expand`) joins the inner lists // into the outer one. final flat = [1, 2, 3].expand((x) => [x, x * 10]).toList(); print(flat); print(flat.runtimeType);}그림 1-2. 두 연산은 같은 함수를 적용한다. map은 함수가 돌려준 상자를 원래 상자 안에 그대로 넣고, flatMap은 두 겹을 하나로 잇는다.
왜 이게 그렇게 중요할까요? 실패할 수 있거나, 기다리거나, 답을 여러 개 내는 단계는 정확히 A → M<B> 타입의 함수이기 때문입니다. 실제 프로그램은 그런 단계의 나열입니다. map만 있으면 단계마다 겹이 하나씩 늘어납니다 — 세 단계면 Either<E, Either<E, Either<E, A>>>가 되고, 이 값으로는 세 번 벗겨 내기 전에 아무것도 할 수 없습니다. flatMap은 몇 단계를 잇든 깊이를 영원히 1로 유지합니다. 모나드는 문맥을 돌려주는 함수들을 합성하는 방법입니다.
용어.
map만 있고 (자기 몫의 법칙 두 개를 지키는) 타입은 펑터(functor) 입니다 — 5장. 모든 모나드는 펑터입니다.map(f)를flatMap((a) => of(f(a)))로 정의할 수 있으니까요. 역은 성립하지 않고, 그래서 이 탑에는 층이 하나 이상 있습니다.
Dart는 flatMap을 매일 쓰는 문법 뒤에 숨겨 둡니다. await가 곧 Future의 flatMap입니다 — future에서 값을 꺼내 나머지 함수를 그 값으로 실행하고, 결과는 future 하나입니다. 절대 Future<Future<T>>가 되지 않죠. 리스트에 덧붙이는 for-in 루프는 List의 flatMap이고, 14장의 either { } 블록은 Either의 flatMap입니다.
같은 계산을 두 가지로 써 봅시다. 먼저 명시적인 체인, 그다음 FxDart의 either 스코프.
import 'package:fxdart/fxdart.dart'; Either<String, int> parseAge(String text) => either((r) { final n = r.ensureNotNull( int.tryParse(text), () => 'not a number: $text'); r.ensure(n >= 0, () => 'negative age: $n'); return n;}); Either<String, String> lookup(String id) => id == 'u1' ? Either.right('Ada') : Either.left('no such user: $id'); // Explicit chaining: every dependent step nests// one level deeper.Either<String, String> greetChained(String id, String ageText) => lookup(id).flatMap((name) => parseAge(ageText).flatMap((age) => Either.right('$name is $age'))); // The same steps in a Raise scope: straight-line code,// with the same short-circuiting.Either<String, String> greetScoped(String id, String ageText) => either((r) { final name = r.bind(lookup(id)); final age = r.bind(parseAge(ageText)); return '$name is $age';}); void main() { print(greetChained('u1', '36')); print(greetScoped('u1', '36')); print(greetScoped('u9', '36')); print(greetScoped('u1', 'old'));}두 버전은 첫 실패에서 멈추는 것까지 똑같이 동작합니다. 첫 단계가 실패하면 둘째 단계는 실행되지 않습니다. 차이는 체인 버전이 단계마다 오른쪽으로 한 칸씩 밀려난다는 것 — 모나드를 가진 언어라면 결국 이 모양을 감추는 문법을 발명하게 됩니다. 하스켈은 do 표기법, 스칼라는 for 컴프리헨션, Dart는 그 특수한 경우인 async/await를 만들었습니다. FxDart의 either 블록은 같은 생각에 다른 방식으로 도달한 것이고, 그 이야기는 15장입니다.
연산 두 개만으로는 부족합니다. of와 flatMap을 정의하고도 놀라운 방식으로 동작하는 타입을 만들 수 있으니까요. 그래서 모나드는 세 법칙을 지켜야 합니다. 읽어 보면 당연한 말을 굳이 적어 둔 것 같은데, 바로 그 점이 가치입니다 — 여러분이 코드를 정리할 때 이미 가정하고 있는 보장이거든요.
of(a).flatMap(f) = f(a). 값을 상자에 넣고 곧바로 단계를 이으면, 그냥 그 단계를 부른 것과 같습니다.m.flatMap(of) = m. 상자를 열어 값을 꺼내고 그대로 다시 담으면 아무것도 달라지지 않습니다.m.flatMap(f).flatMap(g) = m.flatMap((a) => f(a).flatMap(g)). 단계들을 어떻게 묶든 결과는 같습니다.그림 1-3. 세 법칙 모두 같은 종류의 말을 한다 — 그림을 가로지르는 서로 다른 두 경로가 같은 값에 도착해야 한다는 것. 법칙은 어느 경로로 가도 된다는 허가증이다.
FxDart의 Either로 직접 확인해 봅시다.
import 'package:fxdart/fxdart.dart'; Either<String, int> half(int n) => n.isEven ? Either.right(n ~/ 2) : Either.left('odd: $n'); Either<String, int> minusOne(int n) => Either.right(n - 1); void main() { final m = Either<String, int>.right(20); print( Either<String, int>.right(20).flatMap(half) == half(20)); print(m.flatMap((a) => Either<String, int>.right(a)) == m); print(m.flatMap(half).flatMap(minusOne) == m.flatMap((a) => half(a).flatMap(minusOne))); // The laws hold on the failure side too — that is what makes // short-circuiting composable rather than a special case. final bad = Either<String, int>.left('boom'); print(bad.flatMap(half).flatMap(minusOne) == bad.flatMap((a) => half(a).flatMap(minusOne)));}법칙은 장식이 아닙니다. 하나라도 어기면 평범한 리팩터링이 동작을 조용히 바꿉니다. 아래는 단계 수를 세는 상자입니다 — 있을 법한 설계이고, 법칙을 어깁니다.
class Logged<A> { const Logged(this.value, this.steps); final A value; final int steps; static Logged<A> of<A>(A value) => Logged(value, 0); // The `+ 1` is the bug: chaining charges for // the chaining itself. Logged<B> flatMap<B>(Logged<B> Function(A) f) { final next = f(value); return Logged(next.value, steps + next.steps + 1); } @override String toString() => 'Logged($value, steps: $steps)';} Logged<int> double_(int n) => Logged(n * 2, 1); void main() { // Left identity: of(a).flatMap(f) should equal f(a). // It does not. print(Logged.of(21).flatMap(double_)); print(double_(21)); // Right identity: chaining a step that does nothing // should be invisible. final m = double_(21); print(m); print(m.flatMap(Logged.of));}여기서 결합 법칙은 우연히 살아남습니다 — 체인을 다시 묶어도 개수는 그대로죠. 하지만 항등 법칙 두 개가 깨졌고, 그것만으로 치명적입니다. 사소한 단계를 별도 flatMap으로 빼내거나, 거꾸로 인라인해 없애는 일은 어느 리뷰어라도 통과시킬 리팩터링인데, 이 타입에서는 그것이 답을 바꿉니다.
고치는 방법은 특수 처리를 더하는 것이 아니라, steps를 모노이드 — 결합적인 결합 연산과 항등원을 가진 타입(8장) — 로 만들고 of가 그 항등원을 내놓게 하는 것입니다. + 1을 지우면 Logged는 Writer 모나드가 되어 법칙을 지키고 쓸모도 생깁니다. 대부분의 법칙 위반이 이 패턴입니다 — 무해해 보이지만 항등원이 없는 연산.
🎓 기록을 위한 형식적 정의. 범주론에서 범주 C 위의 모나드는 자기함자
T : C → C와 두 자연변환η : Id ⇒ T(이것이of),μ : T² ⇒ T(이것이flatten이고flatMap(f) = μ ∘ T(f))의 조합이며, 단위·결합 정합성 조건 — 위의 세 법칙을 가환 그림으로 그린 것 — 을 만족합니다. "자기함자 범주 위의 모노이드"라는 말도 같은 이야기의 반복입니다:μ가 곱셈,η가 단위원이죠. 이 문단은 Dart를 쓰는 데 아무 도움이 되지 않고, 그래서 상자 안에 있고, 그래서 제자리는 20장입니다.
이제 정직한 부분입니다. 방금 설명한 그 인터페이스를 Dart는 표현하지 못합니다. 적어 두려면 타입 매개변수 자체가 제네릭이어야 하는데 — 고차 타입 (higher-kinded type) — Dart에는 없습니다.
// Does not compile. `M` is a type, and a type cannot take// arguments here.abstract class Monad<M> { M<A> of<A>(A value); M<B> flatMap<A, B>(M<A> box, M<B> Function(A) f);}
FxDart의 타입 있는 오류가 이식해 온 코틀린 Arrow는 컴파일러 플러그인과 컨텍스트 리시버로 이 벽을 우회합니다. 스칼라는 종(kind) 시스템을 언어에 갖고 있습니다. Dart는 둘 다 없고, 어떤 기교로도 되찾을 수 없습니다 — 시도는 결국 dynamic 캐스트로 끝나고, 그러면 애초에 추상화가 지키려던 타입 안전성을 그대로 내놓게 됩니다.
그래서 FxDart는 유일하게 정직한 선택을 합니다. 모양을 타입마다 구현하되, 그것을 추상화한 척은 하지 않는 것.
Either<L, R> 에는 flatMap이 있고 Either.right가 그 of입니다. 법칙은 성립합니다 — 두 쪽 앞에서 직접 확인했죠.either((r) { … }) 은 do 표기법의 실용적 대체물입니다. 문법 설탕(desugaring)이 아닙니다 — r.bind는 스코프로 raise 하여 단락 평가합니다(15장). 모나드 재작성이 아니라 제한된 연속(delimited continuation) 기법이죠. 같은 직선형 코드, 다른 메커니즘이고, 왜 Raise 모나드 인스턴스가 없느냐는 질문에서 이 구분이 중요해집니다.Fx<A> 는 지연 Iterable 체인이고, Iterable이 곧 리스트 모나드입니다 — flatMap이 bind, fx([a])가 of. 지연 평가는 법칙을 흔들지 않습니다. 왜 평가 순서가 법칙에 보이지 않는지는 11장에서 봅니다.FxAsyncIterable<A> 는 비동기 소스 위의 같은 모양이고, 여기에 concurrent(n)이 원소를 언제 계산할지만 바꾸고 무엇을 계산할지는 바꾸지 않는다는 성질이 더해집니다 — 법칙이 뒷받침하는 등식 추론입니다.Monad 인터페이스가 없어서 잃는 것은 모든 모나드에 한꺼번에 통하는 제네릭 코드입니다 — traverse 하나, sequence 하나, Either·Fx·Future에 공통으로 재사용되는 조합자 한 벌. FxDart는 대신 구체 버전을 각각 씁니다. 라이브러리 쪽 코드는 늘고 여러분 프로그램의 추상화는 줄어드는 거래이며, 이 거래를 고른 것은 라이브러리가 아니라 언어입니다.
await를 쓰려고 "모나드"라는 단어가 필요하지는 않습니다. 이 단어가 값을 하기 시작하는 때는 같은 문제를 세 군데에서 알아볼 때입니다 — 중첩된 콜백, null 검사의 피라미드, Either의 연쇄. 그리고 그것이 해법의 모양이 하나뿐인 한 문제임을 알아챌 때. 또 한 번 값을 하는 때는, 어떤 라이브러리가 flatMap이 달린 타입을 줬을 때 소스를 읽지 않고도 그것을 이어 붙이면 무슨 일이 벌어질지 예측할 수 있을 때입니다.
설계를 고를 때도 값을 합니다. 여러분의 타입에 of와 flatMap이 있고 법칙이 성립한다면, 사용자는 체인을 자유롭게 리팩터링할 수 있습니다. 둘은 있는데 법칙이 성립하지 않는다면, 여러분이 만든 것은 함정입니다. 5장은 한 층 아래 펑터로 내려가고, 6장은 어플리커티브로 갑니다 — 실무의 검증 코드가 아주 많이 사는 층입니다.
Set<A>에는 expand가 있고 {a}가 있습니다. 결과가 겹치는 단계 — 예를 들어 {1, 2, 3, 4} 위의 (x) => {x % 3} — 로 세 법칙을 확인해 보세요. Set은 모나드인가요? 그 답은 무엇에 달려 있나요?Either의 map을 flatMap과 Either.right만으로 작성하고, Right와 Left 양쪽에서 내장 map과 결과가 같은지 확인하세요.Future에는 then이 있습니다. Future.value(a).then(f)는 정말로 f(a)와 같은가요 — 값으로서 같은가요, 아니면 결국 만들어 내는 것만 같은가요? 이 질문은 법칙이 어떤 등호 위에서 서술되는지에 대해 무엇을 알려 주나요?Logged를 세 법칙이 모두 성립하도록 고치고, 두 단계를 두 가지 묶음으로 이어 개수가 일치함을 보이세요.Set은 법칙의 양변에서 똑같이 순서와 중복을 버리기 때문입니다. {1,2,3,4}.expand((x) => {x % 3})은 어떻게 묶어도 {1, 2, 0}입니다. 단서가 곧 요점입니다 — 법칙은 언제나 어떤 등호 위에서 서술되고, 한 등호에서는 법칙을 지키는 타입이 다른 등호에서는 어길 수 있습니다. 집합 상등에서 List는 법칙을 지키고, "삽입 순서까지 같음"에서 Set은 지키지 못합니다.Either<L, B> mapViaFlatMap<L, A, B>(Either<L, A> e, B Function(A) f) => e.flatMap((a) => Either.right(f(a)));. Left에서는 두 버전 모두 f를 부르지 않는데, 이것이 실패 쪽에 남은 왼쪽 항등 법칙의 지문입니다.==는 동일성 비교이므로, 법칙은 관찰적 등호 위에서 서술됩니다 — 두 프로그램이 같은 값과 같은 효과를 낳는다는 뜻이죠. 모든 모나드 법칙이 실제로 말하는 등호가 이것입니다. 앞의 Either 확인에서 ==를 쓸 수 있었던 것은 Either가 구조적 등호를 정의해 두었기 때문입니다.flatMap에서 + 1을 지우고 각 단계가 자기 비용만 보고하게 하면 됩니다: Logged(next.value, steps + next.steps). 이제 of는 +의 항등원을 내놓고 이어 붙이기 자체는 아무것도 더하지 않으므로 세 법칙이 모두 성립합니다 — Logged.of(1).flatMap(f).flatMap(g)와 Logged.of(1).flatMap((x) => f(x).flatMap(g))가 둘 다 2를 보고합니다.이 장에서 다루는 것
- 어떤 식에도 적용할 수 있는 기계적 검사로서의 참조 투명성
- 순수함이 사 주는 네 가지 능력 — 메모이제이션, 순서 바꾸기, 병렬화, 테스트
- 평범한 Dart 코드에서 효과가 숨는 자리 (효과처럼 보이지 않는 것들까지)
- 이음매: 순수한 핵심과 가장자리로 밀어낸 효과, 그리고 그 이음매에서 FxDart가 주는 것
함수가 순수(pure) 하다는 것은, 그 호출을 결과값으로 바꿔 놓아도 프로그램이 하는 일이 달라지지 않는다는 뜻입니다. 이 성질에는 이름이 있습니다 — 참조 투명성(referential transparency) — 그리고 이것은 취향의 문제가 아니라 기계적인 검사입니다.
int double_(int n) => n * 2; var log = <String>[];int doubleAndLog(int n) { log.add('doubled $n'); return n * 2;} void main() { // Substitution holds: double_(21) and 42 are the same thing. print([double_(21), double_(21)]); print([42, 42]); // Substitution fails: the two programs differ in `log`. print([doubleAndLog(21), doubleAndLog(21)]); print(log);}두 함수 모두 같은 수를 돌려줍니다. 하지만 그중 하나만이 그 주위의 프로그램을 다시 쓸 수 있게 해 줍니다. 그 차이가 — void라는 단어가 붙었는지도, 린터가 불평하는지도 아니라 — 바로 "순수함"의 뜻입니다.
그림 2-1. 순수함이란 왼쪽 그림을 오른쪽 그림으로 다시 그려도 된다는 허가증이다. 손으로 하는 모든 리팩터링은 이 허가증에 기대고 있다.
네 가지 능력이고, 여러분은 이미 넷 다 기대고 있습니다.
| 능력 | 왜 순수함이 필요한가 |
|---|---|
| 메모이제이션 | 결과를 캐시한다는 것은 두 번째 호출도 같은 일을 했을 것이라 가정하는 것 |
| 순서 바꾸기 | 한 줄을 옮긴다는 것은 그것이 언제 실행됐는지 아무도 안 본다고 가정하는 것 |
| 병렬화 | 두 호출을 동시에 돌린다는 것은 서로를 볼 수 없다고 가정하는 것 |
| 테스트 | 반환값만 검사한다는 것은 그 값이 이야기의 전부라고 가정하는 것 |
FxDart의 memoize가 가장 날카로운 예입니다. 순수한 함수에 대해서는 옳고, 순수하지 않은 함수에 대해서는 조용한 버그입니다.
import 'package:fxdart/fxdart.dart'; int calls = 0;int slowSquare(int n) { calls++; return n * n;} void main() { final fast = memoize(slowSquare); print([fast(9), fast(9), fast(9)]); print('underlying calls: $calls');}세 번 호출, 한 번 계산. memoize 안에는 slowSquare가 순수한지 확인하는 코드가 없습니다 — 그냥 가정합니다. 함수형 도구 대부분이 이런 모양입니다. 라이브러리는 장치를 주고, 법칙은 허가증을 주며, 약속을 지키는 것은 여러분 몫입니다.
효과(effect) 란 호출자가 반환값 외에 관찰할 수 있는 모든 것, 또는 결과가 인자 외의 무언가에 의존하는 모든 것입니다. Dart는 그것을 여럿 눈앞에 숨겨 둡니다.
List도 포함됩니다.DateTime.now(), Platform.isIOS. 같은 인자, 다른 답.createSeededRandom을 제공하는 이유입니다. 시드는 효과를 다시 인자로 되돌립니다.print와 IO — 출력은 정의상 관찰 가능합니다.List의 상등은 참조 기준이므로, 새 리스트를 돌려주는 것과 공유된 리스트를 돌려주는 것은 identical로 구분됩니다.import 'package:fxdart/fxdart.dart'; void main() { // A seed makes randomness reproducible: same input, same // output, so a shuffle becomes testable. final a = shuffle([1, 2, 3, 4, 5], 7); final b = shuffle([1, 2, 3, 4, 5], 7); print(a); print('reproducible: ${a.toString() == b.toString()}');}🎓 "순수함"은 우주가 아니라 언어의 관찰에 관한 것입니다. 순수한 함수도 CPU를 태우고, 메모리를 할당하고, 방을 덥힙니다. 순수함은 프로그램이 무엇을 관찰할 수 있는지를 기준으로 정의됩니다 — 어떤 Dart 코드도 두 식을 구분할 수 없다면 둘은 서로 바꿔 쓸 수 있습니다. 시간과 메모리는 그 렌즈 바깥에 있고, 그래서 14장은 그것들을 따로 측정해야 하며, "순수함"은 결코 "공짜"라는 뜻이 아닙니다.
효과가 하나도 없는 프로그램을 내놓는 사람은 없습니다. 목표는 효과가 어디에 있는지 아는 것입니다. 표준적인 배치는 순수한 핵심과 효과가 있는 껍질 입니다 — 파싱하고, 판단하고, 계산하는 일은 순수한 함수 안에서, 읽고 쓰는 일은 가장자리에서.
파이프라인은 이 이음매를 눈에 보이게 만듭니다. 지연 파이프라인은 일 자체가 아니라 일에 대한 서술이기 때문입니다. 효과가 어디 있는지 비교해 보세요.
import 'package:fxdart/fxdart.dart'; class Order { const Order(this.id, this.total, this.status); final String id; final int total; final String status;} const orders = [ Order('a', 120, 'paid'), Order('b', 40, 'refunded'), Order('c', 260, 'paid'),]; // Pure core: data in, data out. No printing, no clock, no IO.List<String> receipts(Iterable<Order> all) => fx(all) .filter((o) => o.status == 'paid') .sortByDesc((o) => o.total) .map((o) => '${o.id}: ${o.total}') .toList(); void main() { // Effectful shell: the one place that touches the world. receipts(orders).forEach(print);}receipts는 상등 비교만으로 테스트할 수 있습니다. 그리고 그 성질을 깨지 않고 파이프라인을 들여다봐야 할 때를 위해 peek이 선언된 이음매를 줍니다 — 숨겨진 효과가 아니라 이름표가 붙은 효과입니다.
import 'package:fxdart/fxdart.dart'; void main() { final seen = <int>[]; final result = fx(range(1, 6)) // the effect is named, and it is the only one .peek(seen.add) .filter((n) => n.isEven) .toList(); print(result); print(seen);}순수함은 쌓아 두는 미덕이 아니라 써서 없애는 지렛대입니다. 캐시하고, 재시도하고, 순서를 바꾸고, 동시에 실행하고, 픽스처 없는 테스트를 쓰고 싶을 때 값을 합니다 — 즉 3부와 4부가 다루는 바로 그 상황들입니다. concurrent(n)(13장)이 안전한 이유는 그것이 순서 없이 실행하는 콜백들이 서로를 볼 수 없기 때문입니다.
효과 자체가 요점일 때는 비용만 듭니다. 로거, 마이그레이션 스크립트, UI 이벤트 핸들러 — 이런 것들을 격식으로 감싸 봐야 얻는 게 없습니다. 22장이 그 이야기를 길게 합니다.
List.of(items)는 순수한가요? 호출자가 결과를 관찰하는 방법으로 ==와 identical을 모두 고려해 보세요.final을 떼는 순간 무엇이 깨지나요?int Function(int) 타입 함수에 대한 memoize는 안전합니다. 인자 타입이 가변 List<int>라면 무엇이 잘못되나요?receipts 파이프라인에 요구사항을 하나 더합니다: 걸러진 주문을 모두 기록할 것. receipts를 순수하지 않게 만들지 말고 해 보세요.== 기준으로는 순수하고, identical 기준으로는 아닙니다. 같은 인자로 두 번 호출하면 서로 같은(equal) 리스트를 돌려주지만 같은 객체는 결코 아니므로, identical로 비교하는 프로그램은 두 호출을 구분할 수 있습니다. 이래서 "순수함"은 언제나 어떤 관찰을 기준으로 말해야 합니다 — 1장의 Future 상등 연습문제와 같은 미묘함입니다.class Rate { const Rate(this.pct); final int pct; int apply(int n) => n * pct ~/ 100; }. pct가 바뀔 수 없으므로 참조 투명합니다. 인스턴스는 인자의 일부이고, 다만 인자가 아니라 수신자로 적혔을 뿐입니다. final을 떼면 같은 호출이 두 가지 답을 돌려줄 수 있으므로 치환이 깨집니다.memoize는 인자를 키로 삼는데, 가변 리스트의 내용은 키로 쓰인 뒤에도 바뀔 수 있습니다 — 호출자가 리스트를 바꾸고 다시 호출하면 예전 내용에 대한 답을 받습니다. 캐시가 틀린 게 아니라 가정이 틀린 것입니다.fork나 partition 형태의 분리를 쓰면 함수가 보고하는 내용이 완전해지고, 무엇을 출력할지는 호출자(껍질)가 정합니다. 관찰만 하면 된다면 걸러진 가지에 .peek(rejected.add)를 쓰세요. 여전히 이름 붙은 이음매의 선언된 효과이고, 핵심 안에는 IO가 없습니다.이 장에서 다루는 것
- 곱과 합: 타입이 결합하는 두 가지 방식과 그 값의 개수 세기
sealed+switch가 왜 합 타입을 쓸 가치가 있게 만드는 기능인가- 리팩터링: nullable 필드 한 뭉치를 거짓말할 수 없는 타입으로 바꾸기
- 레코드가 맞는 자리와, 타입이 잘못된 도구인 자리
타입은 값의 집합이고, 셀 수 있습니다. bool은 2개. Null은 1개. 상수가 셋인 enum은 3개. 셀 수 있게 되면 타입을 결합하는 두 방식에 이름이 붙습니다.
(bool, bool)은 2 × 2 = 4개의 값을 갖습니다. 클래스의 필드가 곱입니다.bool | Null은 2 + 1 = 3개입니다. Dart에서는 sealed 계층이 합이고, (엄밀하진 않지만) T?도 그렇습니다.설계 버그는 거의 언제나 같은 버그입니다. 타입이 도메인보다 많은 값을 갖는 것. 가장 전형적인 모양이 이것입니다.
// Four fields; 2 × 2 × 2 × 2 = 16 representable combinations…class Request { Request( {this.loading = false, this.data, this.error, this.cancelled = false}); final bool loading; final String? data; final String? error; final bool cancelled;} void main() { // …but this one is nonsense, and it compiles. final broken = Request(loading: true, data: 'ok', error: 'boom'); print([broken.loading, broken.data, broken.error]);}의미 있는 상태는 넷입니다 — 로딩 중, 로딩 완료, 실패, 취소 — 그런데 타입은 열여섯을 허용합니다. 나머지 열둘이 버그가 사는 곳이고, 코드베이스에 있는 모든 if (r.error != null && !r.loading) 는 그중 하나를 손으로 덧댄 반창고입니다.
그림 3-1. 왼쪽 타입은 플래그 네 개의 곱이고, 도메인은 네 경우의 합이다. 대각선 바깥의 모든 칸은 코드가 처리하거나, 아니면 절대 일어나지 않기를 바라야 하는 상태다.
sealed class Request { const Request();} class Loading extends Request { const Loading();} class Loaded extends Request { const Loaded(this.data); final String data;} class Failed extends Request { const Failed(this.message); final String message;} class Cancelled extends Request { const Cancelled();} String render(Request r) => switch (r) { Loading() => 'spinner', Loaded(:final data) => 'showing $data', Failed(:final message) => 'error: $message', Cancelled() => 'cancelled',}; void main() { const all = [ Loading(), Loaded('42 rows'), Failed('timeout'), Cancelled() ]; all.map(render).forEach(print);}네 경우, 정확히 네 상태, nullable 필드 없음, default 갈래 없음. 마지막 항목이 핵심입니다. sealed는 switch를 빠짐없게(exhaustive) 만들어 주므로, 다섯째 경우를 추가하면 그 타입을 다루는 모든 자리가 컴파일 오류가 되어 아직 생각하지 않은 것이 무엇인지 정확히 알려 줍니다. 빠짐없음 검사가 없는 합 타입은 그냥 단계를 더 거친 클래스 계층일 뿐이고, Dart 3이 그 빠진 절반을 채웠습니다.
Either가 쓰는 것도 같은 장치입니다 — Left와 Right의 sealed 합(16장)이고, 그래서 Either에 대한 switch에도 대비용 갈래가 필요 없습니다.
클래스를 만들기엔 격식이 과할 때, 레코드가 익명의 곱을 줍니다.
import 'package:fxdart/fxdart.dart'; void main() { // `attach` pairs each value with something derived from it: // a product, produced lazily, with no class to declare. final priced = fx(['apple', 'fig', 'banana']) .attach((name) => name.length) .toList(); print(priced); final total = fx(priced).sumBy((row) => row.$2); print('total letters: $total');}짝지음이 국소적일 때 — 파이프라인의 중간 단계, 값 두 개짜리 반환 — 레코드가 맞는 도구입니다. 그 짝지음에 도메인 안의 이름이 생기고 규칙이 붙는 순간부터는 잘못된 도구인데, 레코드는 불변식을 실을 수 없기 때문입니다. (String, int)는 int가 음수가 아님을 약속하지 못하지만, class Money { Money(this.cents) : assert(cents >= 0); } 는 할 수 있습니다.
🎓 왜 "대수적(algebraic)" 자료형인가. 곱은 크기를 곱하고 합은 더하며, 이 대수는 계속됩니다.
Either<A, B>는 |A| + |B|개의 값을,A?는 |A| + 1개를, 함수A → B는 |B|^|A|개를 갖습니다 — 문헌에서 화살표를 지수로 쓰는 이유죠. 동형(A, B) → C ≅ A → (B → C)— 커링, 4장 — 은(c^b)^a = c^(b×a)를 타입 수준에서 말한 것입니다. 이름은 장식이 아닙니다. 산수는 실제이고, 어떤 리팩터링이 의미를 보존하는지 예측해 줍니다.
sealed 하위 클래스 하나씩, 각각 그 경우에 필요한 데이터만 싣습니다 — Loaded에는 data가 있고 message는 없으며, nullable은 아무것도 없습니다.if (x != null && !y)는 전부 사라지고, 컴파일러가 지켜보는 switch 갈래로 바뀝니다.보상은 우아함이 아니라 다음 변경이 검사된다는 것입니다. Request에 Retrying을 추가하면 컴파일 오류 목록이 나오는데, 그것은 컴파일러가 써 준, 잊어버릴 수 없는 할 일 목록입니다.
잘못된 조합이 진짜 결함이 되는 곳, 그리고 경우가 늘어날 곳에 쓰세요. 요청/응답 상태, 파싱 결과, 프로토콜 메시지, 명세에 "또는"이 들어가는 모든 것.
정말로 열려 있는 데이터, 독립적인 숫자 셋뿐인 구조체에는 쓰지 마세요. 그리고 중요한 것 — JSON과 맞닿는 경계에서는 어차피 세상이 nullable 한 뭉치를 건네줍니다. 거기서 합 타입은 파싱해 들어갈 대상입니다. 한 곳에서 모양 없는 맵을 거짓말할 수 없는 값으로 바꾸면, 그 아래 모든 코드가 보장을 물려받습니다. 그 파싱이 4부의 주제입니다.
String의 값이 n개일 때 (bool, String?)은 값이 몇 개인가요? Either<bool, bool>은요?Request 클래스에서 말이 안 되는 열두 상태를 적어 보세요. 그중 여러분의 코드베이스가 지금 크래시할 것은 어느 것이고, 조용히 잘못된 화면을 그릴 것은 어느 것인가요?Either<String, int>와 (String?, int?) 모두 "실패 또는 숫자"를 표현할 수 있습니다. 앞의 것을 택할 구체적인 이유를 하나 대세요.(bool, String?)은 2와 (n + 1)의 곱이므로 2n + 2개입니다. Either<bool, bool>은 합이므로 2 + 2 = 4개 — (bool, bool)과 개수는 같지만 서로 다른 타입이고, 이 둘을 헷갈리는 것이 바로 이 장이 다루는 모델링 오류입니다.String reason을 싣는 경우 하나: sealed class Light에 Red, Amber, Green, FlashingAmber(reason). 타입은 3 + r 개의 상태를 허용하고 (r 은 가능한 이유 문자열의 수), 이는 정직합니다 — 점멸 노랑은 실제로 빨강보다 더 많은 정보를 싣고 있으니까요.loading과 data, loading과 error, data와 error, cancelled와 그 밖의 무엇이든, 그리고 넷 다 null/false인 빈 상태. 빈 상태는 보통 크래시(그릴 것이 없음)이고, 조합들은 보통 조용한 버그입니다. 렌더 함수의 첫 if가 이기고 나머지 상태는 읽히지도 않은 채 버려지니까요.Either는 합이므로 컴파일러가 정확히 한쪽만 존재함을 증명할 수 있고, switch가 대비 갈래 없이 양쪽을 덮습니다. (String?, int?)는 옵셔널 둘의 곱입니다. 네 상태 중 둘 — 둘 다 null, 둘 다 non-null — 은 말이 안 되며 모든 사용처에서 손으로 처리해야 합니다.이 장에서 다루는 것
- 함수 둘을 하나로 만드는 연산으로서의 합성
- 부분 적용과 커링, 그리고 둘의 차이
- 왜 충실한 커리드
pipe를 Dart에서 타입 지어 쓸 수 없는가- FxDart가 대신 내놓은 것과 그 선택의 대가
한 함수의 출력이 다른 함수의 입력과 맞아떨어질 때, 둘을 합성하면 중간값을 전혀 언급하지 않는 세 번째 함수가 나옵니다.
import 'package:fxdart/fxdart.dart'; String trim(String s) => s.trim();String upper(String s) => s.toUpperCase(); // Dart has no composition operator, so composition is a// three-line helper. Its shortness is the point: the concept// is small, only the notation is missing.C Function(A) compose2<A, B, C>( B Function(A) f, C Function(B) g) => (a) => g(f(a)); void main() { // By hand. String shout(String s) => upper(trim(s)); print(shout(' hello ')); // As a value: the composition is itself passable. final shout2 = compose2(trim, upper); print(shout2(' hello ')); print([' a ', ' b'].map(shout2).toList()); // pipe1 is the same idea with the value supplied first. print(pipe1(' hi ', shout2));}합성은 결합적입니다 — (f ∘ g) ∘ h와 f ∘ (g ∘ h)가 같습니다 — 그리고 항등 함수가 그 단위원입니다. 그것이 모노이드이고(8장), 파이프라인의 단계를 어떻게 묶어도 결과가 같은 이유입니다. "헬퍼로 빼내기"가 언제나 안전한 이유이기도 합니다. 체인의 세 단계를 이름 붙은 함수로 뽑아내는 것이 바로 그 법칙이 허락하는 재그룹핑이니까요.
두 말이 섞여 쓰이지만 같은 것이 아닙니다.
add(2, _)가 인자 하나짜리 함수가 되는 것이죠.int Function(int, int)이 int Function(int) Function(int)이 됩니다. 부분 적용은 그중 첫 겹을 호출하는 것일 뿐입니다.import 'package:fxdart/fxdart.dart'; int addTwo(int a, int b) => a + b; void main() { // Currying: one call per argument. final curriedAdd = addTwo.curried; final add10 = curriedAdd(10); print([add10(5), add10(32)]); // Partial application without currying: a closure does it too. int Function(int) addAlso(int a) => (b) => a + b; print(addAlso(10)(32)); // Uncurrying goes back. print(curriedAdd.uncurried(40, 2));}그림 4-1. 합성은 기계 둘을 끝과 끝으로 잇고 그 이음매를 감춘다. 커링은 입력이 둘인 기계 하나를, 입력이 하나인 기계 둘로 다시 끼운다.
pipe를 왜 그대로 옮길 수 없었나FxTS는 커리된 pipe 위에 서 있습니다. 모든 연산자가 콜백을 받아 데이터를 기다리는 함수를 돌려주고, pipe가 값을 그 목록에 통과시킵니다. TypeScript는 그것을 손으로 쓴 오버로드 20여 개(항수마다 하나)와, 그것들을 엮는 가변 튜플 타입으로 타입 짓습니다.
Dart에는 오버로드도 가변 제네릭도 없습니다. 단계 개수를 가리지 않는 pipe는 dynamic으로 후퇴할 수밖에 없습니다.
// FxDart ships this for FxTS parity — and every stage boundary// is an unchecked cast.final result = pipe( [1, 2, 3, 4], (dynamic xs) => map((dynamic n) => (n as int) * 2, xs as Iterable), (dynamic xs) => toList(xs as Iterable<int>),);
모든 단계 경계가 검사되지 않는 캐스트입니다. 컴파일러가 잡아 주길 바랐던 타입 오류 — int 파이프라인에 String 단계 — 가 이제 런타임에, 지연 이터레이터 한복판에서, 라이브러리 내부를 가리키는 스택 트레이스와 함께 도착합니다.
그래서 FxDart는 같은 아이디어에 다른 모양을 골랐습니다.
import 'package:fxdart/fxdart.dart'; void main() { final result = fx([1, 2, 3, 4, 5, 6]) .map((n) => n * 2) .filter((n) => n > 4) .take(3) .toList(); print(result);}체인은 타입이 붙은 합성입니다. 각 메서드가 새 원소 타입의 Fx<R>을 돌려주므로 컴파일러가 값을 끝까지 따라가고, 에디터가 자동완성을 해 줍니다. 포기한 것은 단계 하나를 일급 값으로 붙들고 여기저기 넘기는 능력입니다. FxTS에서는 map(f) 자체가 값이지만, FxDart에서는 수신자가 필요한 메서드 호출입니다. 저장소의 WHY_CURRIED.md에 그 거래가 자세히 적혀 있습니다.
🎓 커링은 관습이 아니라 동형입니다.
(A, B) → C와A → (B → C)는 정확히 같은 정보를 담습니다 — 손실 없이 양방향으로 변환할 수 있고,.curried/.uncurried가 런타임에 그것을 보여 줍니다. 기본이 커링인 언어(하스켈, OCaml)는 동형의 한쪽을 원시로 골랐고, Dart는 다른 쪽을 골랐습니다. 한쪽에서 표현되는 것 중 다른 쪽에서 표현 못 할 것은 없습니다 — 다른 것은 편의성뿐이고, 편의성이야말로 이 선택이 중요한 이유입니다.
함수를 받거나 돌려주는 함수를 고차(higher-order) 함수라고 하고, 파이프라인의 어휘는 고차 함수 그 자체입니다. map, filter, fold, sortBy 모두 행동을 인자로 받습니다. 이름으로 알아 둘 만한 FxDart의 두 가지를 더 봅시다.
import 'package:fxdart/fxdart.dart'; bool small(int n) => n < 10;bool odd(int n) => n.isOdd; void main() { // juxt: one input, several functions, all their results. final stats = juxt([ (Iterable<int> xs) => xs.length, (Iterable<int> xs) => xs.reduce((a, b) => a + b), ]); print(stats([3, 1, 4, 1, 5])); // Predicates are values too, so they combine. final both = (int n) => small(n) && odd(n); print(fx([3, 12, 7, 20]).filter(both).toList()); print(fx([3, 12, 7, 20]).filter(negate(small)).toList());}함수를 값으로 다루는 것은 행동은 달라지지만 구조는 그대로일 때 값을 합니다 — 파이프라인 하나에 정책 넷을 넘기기, 작고 이름 붙은 규칙들로 조립한 검증기 하나. 테스트할 때도 값을 합니다. 함수 파라미터는 가장 값싼 이음매이고 모킹 프레임워크가 필요 없습니다.
합성이 그것이 대체한 것보다 길어지는 순간부터는 값을 못 합니다. 독자가 머릿속에서 값에 적용해 봐야 하는 포인트프리 조합자 여섯 개의 사슬은, 이름이 좋은 for 루프보다 나쁩니다. Dart에는 합성 연산자가 없어서 그 임계점이 하스켈보다 빨리 오고, 그렇지 않은 척하는 것이 FP 코드가 악명을 얻는 방식입니다.
compose2(f, g)는 f를 먼저 적용합니다. 하스켈의 . 연산자는 오른쪽 함수를 먼저 적용합니다. fx(...).map(f).map(g)는 어느 순서를 쓰고, 왜 그것이 메서드 체인에서 유일하게 온전한 선택인가요?compose2를 두 번 써서 인자 하나짜리 함수 셋을 위한 compose3를 작성하세요. 그런 다음 두 가지 묶는 방식이 같은 함수를 준다고 논증하세요.addTwo.curried(10)은 함수를 돌려줍니다. 그 타입을 온전히 적으면 무엇인가요? Dart는 왜 임의 항수의 함수에 대한 curried 게터를 추론하지 못하나요?fx(xs).filter(small).filter(odd)를 filter 하나로 다시 쓰세요. 그것은 언제나 안전한 리팩터링인가요? filter의 어떤 성질에 기대고 있나요?map(f) 다음 map(g), 읽는 순서와 같습니다. 메서드 체인은 그럴 수밖에 없습니다 — 수신자가 왼쪽에 있으니, 먼저 적힌 것이 먼저 적용됩니다. 하스켈의 .이 오른쪽에서 왼쪽으로 읽히는 것은 수학의 f ∘ g를 그대로 옮겼기 때문입니다. 둘 다 일관되며, 진짜 위험은 한 코드베이스에서 둘을 섞는 것입니다.D Function(A) compose3<A, B, C, D>(...)를 compose2(compose2(f, g), h) 또는 compose2(f, compose2(g, h))로 만듭니다. 합성이 결합적이므로 같은 함수입니다 — 파이프라인 단계를 재그룹핑할 수 있게 해 주는 그 법칙이고, 1장 모나드 결합 법칙과 같은 모양입니다.int Function(int). Dart는 "임의 항수의 함수"를 타입 매개변수로 표현하지 못하므로 curried는 항수마다 하나씩 적혀 있습니다 — R Function(A, B), R Function(A, B, C) … 에 대한 Curry2부터 Curry5까지의 확장입니다. pipe에서 가변 제네릭이 없던 그 벽이고, 10장의 고차 타입과도 같은 벽입니다. Dart의 타입 시스템은 의도적으로 1차입니다.fx(xs).filter((n) => small(n) && odd(n)). 술어들이 순수할 때 안전합니다 — 합쳐진 버전도 같은 원소에 같은 순서로 small과 odd를 부르고, 단락 평가도 똑같습니다. 술어에 부수효과가 있다면(본 원소 수를 센다든지) 두 버전은 달라집니다. 체인 형태도 살아남은 원소에만 odd를 부르고 합친 형태도 그렇지만, 두 필터 사이에 순서가 정해진 효과가 있었다면 그 위치가 달라집니다. 융합을 재작성이 아니라 리팩터링으로 만들어 주는 것이 순수함입니다.이 장에서 다루는 것
- 펑터: 연산 하나
map, 법칙 둘- 법칙이 금지하는 것 — 그것을 어기는 타입으로 보이기
- 왜 합성 법칙이 파이프라인의 단계 융합을 허가하는가
- 컨테이너가 아닌 펑터들,
Function안에 숨은 것까지
펑터(functor) 는 연산이 하나뿐인 타입 F입니다.
map : F<A> × (A → B) → F<B>
A를 담은 구조와 평범한 함수 A → B를 받아, B를 담은 같은 구조를 돌려줍니다. 그 문장에서 진짜 일을 하는 것은 "같은 구조"이고, 두 법칙이 그 뜻을 못 박습니다.
Dart는 펑터로 가득한데 저마다 다른 이름을 붙여 부릅니다.
import 'package:fxdart/fxdart.dart'; void main() { print([1, 2, 3].map((n) => n * 2).toList()); // List print(Either<String, int>.right(20).map((n) => n * 2)); print(Either<String, int>.left('nope').map((n) => n * 2)); print(fx([1, 2, 3]).map((n) => n * 2).toList()); // Fx}셋째 줄을 보세요. Left에 map을 걸면 아무 일도 일어나지 않고, 이는 덧붙인 특수 처리가 아니라 강제된 결과입니다. map은 구조를 바꿀 수 없고, Either에서는 어느 쪽인가가 곧 구조입니다. Left를 Right로 바꾸는 map은 그 이름을 달고 있는 다른 함수일 뿐입니다.
m.map((x) => x) == m. 항등 함수를 map 하면 아무것도 달라지지 않습니다 — 값도, 모양도, 관찰 가능한 그 무엇도.m.map(f).map(g) == m.map((x) => g(f(x))). 함수 둘로 두 번 훑는 것은 둘의 합성으로 한 번 훑는 것과 같습니다.import 'package:fxdart/fxdart.dart'; int addOne(int n) => n + 1;int triple(int n) => n * 3; void main() { final m = Either<String, int>.right(7); // identity print(m.map((x) => x) == m); // composition print(m.map(addOne).map(triple) == m.map((x) => triple(addOne(x)))); // both hold on the other side too final bad = Either<String, int>.left('boom'); print(bad.map((x) => x) == bad);}그림 5-1. 항등 법칙은 그 고리가 아무 일도 하지 않는다고 말한다. 합성 법칙은 정사각형을 가로지르는 두 경로가 같은 값에 닿는다고 말한다 — 그래서 파이프라인은 단계 사이 어디서든 다시 자를 수 있다.
법칙은 함수를 적용하는 것 외에 무언가를 더 하는 map을 배제합니다. 그럴듯하지만 법칙을 어기는 타입을 봅시다.
// A box that remembers how many times it was mapped.class Counted<A> { const Counted(this.value, this.maps); final A value; final int maps; Counted<B> map<B>(B Function(A) f) => Counted(f(value), maps + 1); @override bool operator ==(Object other) => other is Counted && other.value == value && other.maps == maps; @override int get hashCode => Object.hash(value, maps); @override String toString() => 'Counted($value, maps: $maps)';} void main() { final m = Counted(7, 0); // Identity fails: mapping "nothing" is observable. print(m.map((x) => x) == m); // Composition fails: two passes cost two, one pass costs one. print(m.map((x) => x + 1).map((x) => x * 3)); print(m.map((x) => (x + 1) * 3));}이 타입이 틀린 것은 아닙니다 — map 횟수를 세는 것이 정확히 원하던 바일 수도 있죠. 다만 이것은 펑터가 아니며, 그 실무적 귀결은 명확합니다. 이제 독자는 이 타입의 map 호출을 합치거나 나눌 수 없습니다. 그렇게 하면 결과가 바뀌니까요. 법칙은 허가증이고, 이 타입은 그 허가증을 주지 않습니다.
합성 법칙을 오른쪽에서 왼쪽으로 읽으면 그것은 더 이상 철학이 아닙니다.
m.map(f).map(g) — 두 번 순회 — 가 m.map(g ∘ f), 즉 한 번 순회와 같습니다. 그러므로 라이브러리는 언제든 앞의 것을 뒤의 것으로 다시 쓸 수 있고, 여러분은 알아채지도 못합니다.
FxDart에서 이것은 가정이 아닙니다. 지연 파이프라인은 단계를 융합해 값 하나가 단계마다 재료화되지 않고 사슬 전체를 한 번에 흘러가게 하는데, 그 재작성의 허가증이 바로 펑터 법칙입니다.
import 'package:fxdart/fxdart.dart'; void main() { final seen = <String>[]; final result = fx([1, 2, 3]) .map((n) => n + 1) .peek((n) => seen.add('after +1: $n')) .map((n) => n * 3) .peek((n) => seen.add('after *3: $n')) .toList(); print(result); // Interleaved, not staged: element by element through the // whole chain — the one-pass reading of the composition law. seen.forEach(print);}map 둘, 중간 리스트 없음. 즉시 평가 언어라면 단계마다 리스트 하나씩 값을 치렀을 텐데, 여기서는 법칙이 그럴 필요 없다고 말하고 구현이 그 말을 받아들입니다. 11장이 이 평가 이야기를 명시적으로 다룹니다.
🎓 펑터, 형식적으로. 펑터는 범주와 범주 사이의 사상으로, 대상을 대상으로 화살표를 화살표로 보내면서 항등과 합성을 보존합니다 — 이것이 바로 위의 두 법칙을 일반적인 경우에 대해 한 번에 서술한 것입니다. 프로그래밍에서 우리가 쓰는 것은 언제나 타입의 범주 위의 자기펑터(endofunctor)뿐입니다.
F는 타입A를 타입F<A>로 보내고,map은 화살표A → B를 화살표F<A> → F<B>로 들어 올립니다. 20장이 그 그림을 그립니다. 위의 어떤 내용도 거기에 기대고 있지 않습니다.
"펑터는 값을 담는다"는 쓸모 있는 거짓말입니다. 펑터가 실제로 가진 것은 함수가 작용할 자리이고, 그중에는 아무것도 들어 있지 않은 자리도 있습니다.
Future<A> — 값이 아직 여기 없습니다. then이 그 map입니다.map은 앞으로 일어날 파싱이 무엇을 내놓을지를 바꿉니다.Function(X) → A — 리더(reader) 펑터. 함수 위의 map은 그 결과에 합성하는 것입니다.void main() { int Function(String) length = (s) => s.length; // map for functions IS composition: apply, then transform. int Function(String) doubledLength = (s) => length(s) * 2; print([length('functor'), doubledLength('functor')]);}마지막 것은 곱씹어 볼 만합니다. 합성과 map은 같은 연산을 두 각도에서 본 것이고, 그래서 4장의 결합 법칙과 이 장의 합성 법칙이 같은 문장을 두 번 말하는 것처럼 느껴집니다. 실제로 같습니다.
이 단어는 예측 도구로서 값을 합니다. map이 달린 낯선 타입을 만나면 이미 세 가지를 압니다. 모양을 바꾸지 않는다는 것, 항등을 map 하면 아무 일도 없다는 것, 호출을 나누거나 합쳐도 된다는 것. 단어 하나치고는 많은 지식입니다.
타입이 거짓말하고 있을 때도 알려 줍니다. 재시도, 순서 변경, 캐시를 문서에 언급하는 map은 펑터의 map이 아니며, 그 주위를 리팩터링하기 전에 소스를 읽어야 합니다.
Either.map이 항등 법칙을 만족함을 경우를 나눠 (비형식적으로) 증명하세요. 경우는 몇 개이고, 왜 그 개수가 증명의 전부인가요?Set에도 map이 있습니다. f가 서로 다른 두 원소를 같은 값으로 보낼 때도 합성 법칙을 만족하나요? {1, 2}에 f = (x) => 0, g = (x) => x + 1로 해 보세요.map이 있다면 그 map은 유일한가요? 즉 같은 타입에 서로 다른 적법한 map이 둘 있을 수 있나요 — List와 Either에서 답이 다른가요?peek은 같은 원소 타입을 돌려줍니다. peek은 map인가요? 어떤 법칙을 어기며, 왜 아무도 문제 삼지 않는지는 어느 장의 어휘로 설명되나요?Left(e).map(id)는 정의상 Left(e)를 돌려주고, Right(a).map(id)는 Right(id(a)) = Right(a)입니다. Either는 생성자가 정확히 둘인 합이므로, 둘을 덮는 것이 곧 모든 값을 덮는 것입니다 — 3장이 sealed에서 얻었던 그 빠짐없음입니다.{1, 2}.map(f)는 {0}이고 여기에 g를 map 하면 {1}입니다. 합친 g ∘ f도 {1}을 줍니다. 어느 경로로 가든 중복 제거는 나가는 길에 일어납니다. Set이 깨는 것은 합성이 아니라 "펑터는 크기를 보존한다"는 직관인데, 법칙은 그런 약속을 한 적이 없습니다.map을 못 박는다는 것이고, 이는 List와 Either 모두에 해당합니다. 실무적으로 이 타입들의 적법한 map은 유일하며, 그 유일성이 이름을 믿을 수 있는 이유입니다.peek은 map이 아닙니다 — 효과가 딸린 map이므로, 콜백이 관찰 가능한 일을 하는 순간 항등 법칙을 어깁니다(peek((_) {})는 무연산이지만 peek(print)는 아닙니다). 설명은 2장의 어휘입니다. peek은 효과를 선언된 것으로 만들려고 존재하며, 선언된 효과는 위반이 아니라 문서화된 예외입니다.이 장에서 다루는 것
- 의존적인 단계와 독립적인 단계의 차이를, 타입으로
- 어플리커티브: 서로를 보지 않는 여러 구조를 결합하기
- 왜 모든 오류를 모으는 일이 모나드에게는 불가능하고 여기서는 자연스러운가
- FxDart의
map2,zipOrAccumulate, 그리고accumulate스코프
1장의 flatMap은 두 번째가 첫 번째에 의존하는 단계들을 합성합니다. 사용자를 찾기 전에는 그 사용자의 주문을 조회할 수 없죠. 그 의존성은 타입에 적혀 있습니다 — A → M<B>는 첫 상자에서 값을 꺼내야 합니다.
하지만 실제 코드의 상당수에는 그런 의존성이 없습니다. 폼 검증을 보세요. 이름 검사는 나이를 필요로 하지 않고, 나이 검사는 이름을 필요로 하지 않습니다. 둘은 독립적이고, 그 둘을 결합하는 연산의 타입이 그렇다고 말합니다.
map2 : F<A> × F<B> × ((A, B) → C) → F<C>
A에서 두 번째 구조로 들어가는 화살표가 없습니다. 둘 다 이미 거기 있고, 함수는 결과를 합치기만 합니다. map2를 갖춘 타입은 (여기에 평범한 값을 들어 올리는 방법, 즉 1장의 of를 더해) 어플리커티브 펑터(applicative functor) 입니다.
그림 6-1. flatMap은 첫 단계가 값을 내놓기 전에는 둘째 단계를 시작할 수 없다. map2는 처음부터 둘 다 갖고 있다 — 그래서 동시에 실행하는 것도, 두 실패를 함께 보고하는 것도 애초에 가능해진다.
map2로 빨리 실패하기import 'package:fxdart/fxdart.dart'; class User { const User(this.name, this.age); final String name; final int age; @override String toString() => 'User($name, $age)';} Either<String, String> vName(String s) => s.isEmpty ? Either.left('name is empty') : Either.right(s); Either<String, int> vAge(String s) { final n = int.tryParse(s); if (n == null) return Either.left('age is not a number'); if (n < 0) return Either.left('age is negative'); return Either.right(n);} void main() { print(vName('Ada').map2(vAge('36'), User.new)); print(vName('').map2(vAge('36'), User.new)); // Both wrong — but only the leftmost failure is reported. print(vName('').map2(vAge('nope'), User.new));}마지막 줄이 이 장이 존재하는 이유입니다. 사용자는 두 칸을 잘못 채웠는데 폼은 하나만 알려 줬습니다. 구조가 그렇게 강제한 것이 아닙니다 — Either 둘 다 계산됐으니까요. 빨리 실패하는 것은 보고 방식이고, map2는 가장 왼쪽 실패만 보고합니다.
flatMap만으로 누적 버전을 써 보려 하면, 노력의 문제가 아닌 벽에 부딪힙니다.
name.flatMap((n) => age.flatMap((a) => Either.right(User(n, a))));
name이 Left라면 바깥 flatMap이 단락 평가됩니다 — 그리고 age를 들여다볼 함수는 콜백 안에 있으므로 아예 실행되지 않습니다. flatMap의 타입은 둘째 단계가 첫 값의 함수라고 말하므로, 첫 값이 없으면 둘째 단계도 없습니다. 여기서 단락 평가는 정책 선택이 아니라 타입의 뜻입니다.
어플리커티브는 엄밀히 더 약하고, 그 약함이 곧 기능입니다. map2는 결합하기 전에 두 구조를 데이터로 들고 있으므로, 구현은 자유롭게 둘 다 들여다보고 실패를 이어 붙일 수 있습니다.
Dart에는 Validated 타입이 없습니다. FxDart는 Arrow 2.x를 따라 대신 누적 스코프를 제공합니다. either 안에서 하나 요청하세요.
import 'package:fxdart/fxdart.dart'; class User { const User(this.name, this.age); final String name; final int age; @override String toString() => 'User($name, $age)';} Either<Nel<String>, User> parse(String name, String age) => either((r) => r.zipOrAccumulate2( (br) { if (name.isEmpty) br.raise('name is empty'); return name; }, (br) { final n = int.tryParse(age); if (n == null) br.raise('age is not a number'); if (n! < 0) br.raise('age is negative'); return n; }, User.new, )); void main() { print(parse('Ada', '36')); print(parse('', '36')); print(parse('', 'nope')); // both failures, in branch order}모든 가지가 실행되고, 실패는 NonEmptyList로 이어 붙습니다(왜 평범한 List가 아닌지는 8장이 설명합니다). 가지가 다섯을 넘거나, 앞선 가지에 의존하는 규칙이 있다면 전체 스코프로 내려가세요.
import 'package:fxdart/fxdart.dart'; Either<Nel<String>, String> checkout( String item, String qty, String coupon,) => either((r) => r.accumulate((acc) { final i = acc.accumulating((br) { if (item.isEmpty) br.raise('item required'); return item; }); final q = acc.accumulating((br) { final n = int.tryParse(qty); if (n == null) br.raise('qty is not a number'); return n ?? 0; }); // Dependent rule: only meaningful once qty parsed. final c = acc.dependent((br) { if (coupon.isNotEmpty && q.value > 10) { br.raise('coupon not valid in bulk'); } return coupon; }); return '${q.value} x ${i.value} ${c.value}'.trim(); })); void main() { print(checkout('mug', '2', '')); print(checkout('', 'x', 'SAVE5')); print(checkout('mug', '99', 'SAVE5'));}accumulating은 독립적인 가지를 실행하며 그 오류를 기록하고, dependent는 아직 아무 실패도 없을 때만 실행됩니다. 다른 가지의 값을 읽는 규칙은 그 값이 존재하지 않을 때 실행될 수 없으니까요. 독립과 의존이라는 이 장의 구분이 그대로 API가 된 것입니다.
🎓 법칙과 진짜 정의. 어플리커티브는 보통
pure : A → F<A>와ap : F<A → B> × F<A> → F<B>(구조 안의 함수를 구조 안의 값에 적용)로 주어집니다.map2와ap은 서로 정의할 수 있고, 기본이 커링이 아닌 언어에서는map2쪽이 더 잘 읽히므로 FxDart는 그 얼굴을 내놓습니다. 네 법칙 — 항등, 합성, 준동형, 교환 — 은 예상대로의 말을 합니다.pure는 아무것도 더하지 않고, 적용은 합성이 그러하듯 결합적이라는 것. 모든 모나드는 어플리커티브이지만(flatMap으로map2를 만들 수 있습니다) 역은 성립하지 않으며, 이 장의 검증이 그 표준 반례입니다.
| 필요한 것 | 쓸 것 | 이유 |
|---|---|---|
| 2단계가 1단계의 값을 필요로 함 | flatMap / either 스코프 | 의존성이 실재함 |
| 단계가 독립적이고 첫 실패면 충분 | map2 | 가장 값싸고 단락 평가됨 |
| 단계가 독립적이고 모든 실패를 보고 | zipOrAccumulate / accumulate | 어플리커티브 모양만이 할 수 있음 |
| 단계가 독립적이고 느림 | 어플리커티브 + 동시성 | 독립성이 겹쳐 실행을 합법으로 만듦 |
마지막 줄을 사람들이 놓칩니다. concurrent(n)(13장)은 단계들이 서로에게 의존하지 않을 때 정확히 적용 가능하며, 그것은 오류 누적을 가능하게 하는 조건과 같습니다. 독립성이 둘 다 사 주고, flatMap은 그것을 써 버립니다.
폼과 페이로드 검증은 당연하고, 그 밖에도 설정 로딩(첫 개가 아니라 빠진 키를 모두 한 번에 보고), CSV 임포트(7행이 아니라 잘못된 모든 행), 그리고 사람이 오류를 읽고 한 번에 고칠 모든 곳. 판단 기준은 단순합니다 — 사용자가 모든 문제를 한꺼번에 보고 싶어 할까? 그렇다면 어플리커티브가 필요합니다.
실패가 진짜로 순차적일 때(사용자가 존재하기 전에는 주문을 검사할 수 없음), 또는 잘못될 수 있는 일이 정확히 하나뿐일 때는 건너뛰세요. 규칙 하나짜리 accumulate는 보상 없는 격식입니다.
Future에도 표준 라이브러리에 map2 모양의 조합자가 있습니다. 이름을 대고, 왜 그것은 두 future를 동시에 실행할 수 있는데 f1.then((_) => f2)는 못 하는지 설명하세요.flatMap과 map만으로 Either의 map2를 작성하세요. 그런 다음 여러분이 쓴 버전이 왜 오류를 모을 수 없는지 타입에 관한 한 문장으로 설명하세요.checkout 예제에서 쿠폰 규칙의 dependent를 accumulating으로 바꾸면 checkout('', 'x', 'SAVE5')가 무엇을 출력할지 예측하세요. 형제 가지를 읽는 규칙에 왜 dependent가 더 안전한 기본값인가요?Set은 어플리커티브인가요? map2는 무슨 뜻이 될 것이며, "두 집합을 결합한다"에 대한 여러분의 직관과 맞나요?Future.wait([a, b]) 입니다 — 이미 만들어진 두 future를 받으므로, 호출되기 전에 둘 다 실행 중입니다. a.then((_) => b)는 b를 콜백 안에서 만들므로 a가 끝나기 전에는 b가 존재조차 할 수 없습니다. 그 차이가 정확히 map2와 flatMap의 차이이고, 벽시계 시간으로 눈에 보입니다.a.flatMap((x) => b.map((y) => f(x, y))). 오류를 모을 수 없는 이유는 b.map이 x의 함수 안에 들어앉아 있기 때문입니다. a가 Left면 그 함수는 결코 적용되지 않으므로 b의 실패는 검사조차 되지 않습니다. 둘째 값을 손댈 수 없게 만드는 것이 바로 A → Either<E, C>라는 타입입니다.accumulating이면 qty가 실패했는데도 쿠폰 가지가 실행되고, 그 안에서 q.value를 읽는 순간 폭발합니다 — 스코프 끝이 아니라 가지 안에서 누적된 오류가 raise 되는 것이죠. dependent는 그것을 불가능하게 만들려고 존재합니다. 이미 오류가 있으면 블록을 통째로 건너뛰며, 형제의 .value를 읽는 모든 규칙에 그것이 옳은 기본값입니다.map2는 결과를 중복 제거한 데카르트 곱입니다 — {1,2}와 {10,20}을 +로 결합하면 {11, 21, 12, 22}입니다. 이는 비결정성 해석(각 집합은 "이 값들 중 하나")과 맞고, 1장에서 List를 모나드로 만든 그 해석과 같습니다. zip 방식 직관과는 맞지 않으며, 그 둘 중 무엇을 고르느냐가 하스켈에 []와 ZipList라는 별개의 어플리커티브가 있는 이유입니다.이 장에서 다루는 것
- 클라이슬리 합성: 왜
A → M<B>함수에는 자기만의∘가 필요한가- 피라미드, 그리고 모든 언어가 그것을 펴려고 만드는 문법
async/await를 모나드 하나짜리 do 표기법으로 읽기- 진짜 한계인 "한 번에 모나드 하나", 그리고 Dart에서 그 대가
4장은 A → B와 B → C를 합성해 A → C를 얻었습니다. 실패할 수 있는 단계로 같은 일을 해 봅시다.
parseId : String → Either<E, int>loadUser : int → Either<E, User>둘은 맞아떨어지지 않습니다. parseId의 출력은 Either<E, int>인데 loadUser는 맨 int를 원합니다. 평범한 합성은 불가능하고, 이것은 예외적인 경우가 아닙니다 — 모든 효과 있는 단계가 이 모양입니다.
flatMap이 그 해법이고, 여기에 합성 연산자를 만들어 주면 패턴이 눈에 보입니다.
import 'package:fxdart/fxdart.dart'; // Kleisli composition: compose two "returns a box" functions.Either<E, C> Function(A) kleisli<E, A, B, C>( Either<E, B> Function(A) f, Either<E, C> Function(B) g,) => (a) => f(a).flatMap(g); Either<String, int> parseId(String s) { final n = int.tryParse(s); return n == null ? Either.left('bad id: $s') : Either.right(n);} Either<String, String> loadUser(int id) => id == 1 ? Either.right('Ada') : Either.left('no user $id'); void main() { final lookup = kleisli(parseId, loadUser); print(lookup('1')); print(lookup('2')); print(lookup('x'));}kleisli는 A → M<B>와 B → M<C>를 합성해 A → M<C>를 만듭니다. 이 화살표들은 자기만의 범주를 이루고 — 그 모나드의 클라이슬리 범주(Kleisli category) — 1장의 세 법칙이 정확히 범주가 필요로 하는 것입니다. of가 항등 화살표(왼쪽·오른쪽 항등)이고, flatMap이 결합적인 합성입니다.
"모나드는 효과 있는 함수를 합성하는 방법"이라는 말의 내용이 이게 전부입니다. 효과가 합성을 깨뜨린 자리를 flatMap이 복원합니다.
그림 7-1. 평범한 함수는 딸깍 맞물린다. 효과 있는 함수는 그러지 못한다 — 출력에 다음 입력이 받아 줄 수 없는 포장이 씌워져 있다. flatMap이 그 어댑터이고, 법칙은 그 어댑터가 보이지 않는다고 말한다.
의존적인 단계 서넛을 손으로 합성하면 코드가 오른쪽으로 흘러갑니다.
parseId(raw).flatMap((id) => loadUser(id).flatMap((user) => loadOrders(user).flatMap((orders) => Either.right(summarise(user, orders)))));
모나드가 있는 언어는 결국 이것을 펴는 문법을 갖게 됩니다. 같은 계산, 네 가지 표면입니다.
| 언어 | 문법 | 컴파일러가 내놓는 것 |
|---|---|---|
| 하스켈 | do { id <- parseId raw; … } | >>= 사슬 |
| 스칼라 | for { id <- parseId(raw) } yield … | flatMap/map 사슬 |
| 코틀린 (Arrow) | either { val id = parseId(raw).bind() } | 비국소 탈출이 있는 스코프 |
| Dart | either((r) { final id = r.bind(parseId(raw)); … }) | 비국소 탈출이 있는 스코프 |
앞의 둘은 문법 설탕 해제(desugaring) 입니다. 컴파일러가 블록을 메서드 호출로 다시 쓰며, 타입 검사기가 이름 붙일 수 있는 어떤 모나드에도 통합니다. 뒤의 둘은 아닙니다 — 다시 쓰는 일은 없고, 블록을 포기할 수 있는 bind를 가진 스코프 객체가 있을 뿐입니다. 15장이 그 장치와 Dart가 왜 그것을 강제했는지를 다룹니다.
결과는 어느 쪽이든 이렇게 읽힙니다.
import 'package:fxdart/fxdart.dart'; Either<String, int> parseId(String s) { final n = int.tryParse(s); return n == null ? Either.left('bad id: $s') : Either.right(n);} Either<String, String> loadUser(int id) => id == 1 ? Either.right('Ada') : Either.left('no user $id'); Either<String, List<String>> loadOrders(String user) => user == 'Ada' ? Either.right(['mug', 'book']) : Either.left('none'); Either<String, String> summary(String raw) => either((r) { final id = r.bind(parseId(raw)); final user = r.bind(loadUser(id)); final orders = r.bind(loadOrders(user)); return '$user bought ${orders.length} things'; }); void main() { print(summary('1')); print(summary('2')); print(summary('nope'));}직선형 코드, 의존적인 세 단계, 실패 타입 하나, 피라미드 없음.
async/await는 모나드 하나짜리 do 표기법이다Dart는 이미 이 아이디어를 싣고 있습니다 — Future에 대해서만.
Future<int> parseId(String s) async => int.parse(s);Future<String> loadUser(int id) async => id == 1 ? 'Ada' : 'nobody'; Future<String> summary(String raw) async { final id = await parseId(raw); // r.bind, spelled `await` final user = await loadUser(id); return 'user: $user';} void main() async { print(await summary('1')); print(await summary('7'));}한 줄 한 줄, 이것은 위의 either 블록에서 r.bind를 await로 바꾼 것입니다. async가 스코프를 표시하고, await가 한 겹을 벗기고, 컴파일러가 본문을 연속(continuation)으로 다시 쓰는데 그것이 이름만 다른 flatMap입니다. 이것이 마법이 아니라 모나드라는 증거 — Future<Future<T>>에 await를 걸면 Future<T>가 나옵니다. 1장이 요구한 그 납작하게 만들기죠.
Dart가 하지 않은 것은 그것을 일반화하는 일입니다. await는 Future에(그리고 구조적 우연으로 then이 있는 것에) 통하고, Either용 await도, Iterable용 await도, 직접 만들 방법도 없습니다. 위 표의 모든 언어가 처음엔 같은 선택을 했다가 나중에 일반화했습니다. Dart의 async는 그 일반화가 멈춘 지점입니다.
🎓 모나드는 포개지지 않습니다.
Future<Either<E, A>>가 있으면 모나드가 둘인데 그 쌍을 위한flatMap은 없습니다. 스칼라는 모나드 트랜스포머 (EitherT[Future, E, A])를 꺼내 듭니다 — 조합마다 래퍼 하나에 리프트가 탑처럼 쌓이죠. 코틀린과 Dart는 스코프에게 두 몫을 맡겨 그 탑을 피합니다.eitherAsync는async본문 안에서Raise스코프를 주므로,await가 시간을 맡고r.bind가 실패를 맡으며 제3의 타입은 없습니다. 트랜스포머보다 강력한 것은 아닙니다 — 덜 일반적이고 훨씬 읽기 쉬우며, 누가 무엇을 치렀는지는 21장에 적혀 있습니다.
import 'package:fxdart/fxdart.dart'; Future<Either<String, int>> fetchPort(String key) async => key == 'http' ? Either.right(8080) : Either.left('unknown: $key'); Future<Either<String, String>> describe(String key) => eitherAsync((r) async { // `await` sequences time; `r.bind` sequences failure. final port = r.bind(await fetchPort(key)); return 'listening on $port'; }); void main() async { print(await describe('http')); print(await describe('gopher'));}효과 둘, 직선형 블록 하나, EitherT 없음. 대가는 FxDart가 손으로 써 둔 조합에서만 통한다는 것입니다 — eitherAsync, nullable, catching. 여러분이 확장할 수 있는 일반적인 장치는 없는데, "임의의 모나드"를 표현하려면 Dart에 없는 타입 기능이 필요하기 때문입니다. 그것이 10장입니다.
같은 오류 타입으로 실패할 수 있는 의존적인 단계가 셋 이상일 때 스코프를 쓰세요 — 파싱하고, 불러오고, 권한을 확인하고, 계산하기. 피라미드가 나타나는 모양이고, 손으로 쓴 if (x == null) return null 사슬이 실패 이유를 조용히 잃어버리는 모양입니다.
단계가 독립적일 때는 쓰지 마세요(6장: 누적과 동시성을 잃습니다). 단계가 하나뿐일 때도(평범한 Either.map이 더 많은 것을 말해 줍니다), 실패가 진짜로 예외적이고 호출자가 손쓸 수 없을 때도 마찬가지입니다(18장).
Future용 kleisli를 작성하세요 — A → Future<B>와 B → Future<C>의 합성. 기존 Dart 메서드 중 무엇을 얇게 감싼 것인가요?Either의 클라이슬리 항등 화살표는 Either.right입니다. kleisli(Either.right, f)와 kleisli(f, Either.right)가 모두 f처럼 동작함을 보이고, 방금 쓴 두 모나드 법칙의 이름을 대세요.summary 블록을 flatMap만으로 다시 쓴 뒤, 두 버전의 줄 수와 최대 들여쓰기 깊이를 세어 보세요. 몇 단계부터 스코프 버전이 이기기 시작하나요?await는 Future<Future<T>>를 납작하게 만듭니다. 그것은 5장의 map과 비교해 Future.then의 시그니처에 대해 무엇을 말해 주나요?Future<C> Function(A) k<A, B, C>(Future<B> Function(A) f, Future<C> Function(B) g) => (a) => f(a).then(g);. then을 감싼 것이고, then이 곧 Future의 flatMap입니다 — 그리고 그 메서드는 map 노릇도 하는데, 그것이 4번 문제의 주제입니다.kleisli(Either.right, f)를 a에 적용하면 Either.right(a).flatMap(f)이고, 왼쪽 항등에 의해 f(a)입니다. kleisli(f, Either.right)를 a에 적용하면 f(a).flatMap(Either.right)이고, 오른쪽 항등에 의해 f(a)입니다. 이 두 법칙이 정확히 "of는 클라이슬리 범주의 항등 화살표다"라는 진술입니다.flatMap 버전은 줄 수는 비슷하지만 세 겹으로 중첩되고 닫는 괄호가 줄줄이 달립니다. 스코프 버전은 평평하게 유지됩니다. 갈림길은 두 단계이고, 세 단계면 승부가 나지 않으며, 네 단계쯤이면 피라미드 버전은 괄호 안에 버그를 모으기 시작합니다.then은 map이 하지 않는 방식으로 오버로드되어 있습니다. B Function(A)와 Future<B> Function(A) 둘 다 받고, 후자의 경우 납작하게 만듭니다. 즉 then은 map과 flatMap을 한 메서드로 합친 것이고, 그래서 편리하며, 동시에 Future만 써서는 탑의 두 층 차이를 결코 배우지 못하는 이유이기도 합니다.이 장에서 다루는 것
- 두 법칙 — 결합과 항등 — 그리고 각각이 따로 사 주는 것
- 왜
reduce는 빈 컬렉션에서 던지고fold는 그러지 않는가- 병렬 축약과 덩어리 축약이 같은 답을 주게 하는 성질
- 반군으로서의
NonEmptyList, 그리고 FxDart의 오류가 그리로 쌓이는 이유
반군(semigroup) 은 결합적인 이항 연산을 가진 타입입니다.
combine(a, combine(b, c)) == combine(combine(a, b), c)
모노이드(monoid) 는 항등원을 가진 반군입니다.
combine(empty, a) == a == combine(a, empty)
그게 전부입니다. +와 0을 가진 int, *와 1을 가진 int, +와 ''를 가진 String, +와 []를 가진 List, &&와 true를 가진 bool. 오늘도 그 전부를 쓰셨을 겁니다.
void main() { // associativity: grouping does not matter print((1 + 2) + 3 == 1 + (2 + 3)); print(('a' + 'b') + 'c' == 'a' + ('b' + 'c')); // identity: the neutral element changes nothing print(0 + 7 == 7 && 7 + 0 == 7); print(''.length + 'abc'.length == 3); // subtraction is neither associative nor unital print((10 - 3) - 2 == 10 - (3 - 2));}마지막 줄이 이 정의의 요점입니다. "둘을 결합한다"만으로는 부족합니다. 뺄셈도 int 둘을 결합하지만 아래의 어떤 일에도 쓸모가 없습니다.
항등은 빈 경우를 줍니다. Dart에 폴드 메서드가 둘 있고 빈 컬렉션에서 다르게 동작하는 이유가 이것입니다.
import 'package:fxdart/fxdart.dart'; void main() { // fold carries the identity element as a seed — total, always. print(fx(<int>[]).fold<int>(0, (a, b) => a + b)); print(fx([1, 2, 3]).fold<int>(0, (a, b) => a + b)); // reduce has no seed, so the empty case has no answer to give. try { print(fx(<int>[]).reduce((a, b) => a + b)); } catch (e) { print('reduce on empty: ${e.runtimeType}'); }}reduce는 반군만 요구하므로 부분 함수입니다. fold는 모노이드를 요구하고 — empty를 씨앗으로 넘겨 주죠 — 전 함수입니다. 여러분이 백 번쯤 만난 그 예외는 항등원이 없다는 사실이 런타임에 나타난 것입니다.
결합은 묶는 자유를 줍니다. 이것은 들리는 것보다 값어치가 큽니다. 같은 연산을 다음과 같이 실행할 수 있다는 뜻이니까요.
네 가지가 모두 같은 답을 주고, 그것을 보장하는 것은 오직 결합 법칙입니다.
그림 8-1. 결합 법칙은 같은 수열을 어떻게 묶어도 같은 값에 닿는다고 말한다. 그것이 덩어리 나누기, 병렬 축약, 누적 합 이어 가기의 허가증이다.
import 'package:fxdart/fxdart.dart'; void main() { final data = List.generate(12, (i) => i + 1); // Sequential. final straight = fx(data).fold(0, (a, b) => a + b); // Chunked, then the chunk results combined — legal because + // is associative and 0 is its identity. final chunked = fx(data) .chunk(5) .map((c) => fx(c).fold(0, (a, b) => a + b)) .fold(0, (a, b) => a + b); print([straight, chunked, straight == chunked]); // Order does NOT come free: subtraction disagrees with itself. final subStraight = fx(data).fold(0, (a, b) => a - b); final subChunked = fx(data) .chunk(5) .map((c) => fx(c).fold(0, (a, b) => a - b)) .fold(0, (a, b) => a - b); print([subStraight, subChunked, subStraight == subChunked]);}결합은 묶는 방식이 상관없다고 말합니다. 교환(commutativity) — a + b == b + a — 은 순서가 상관없다고 말하는데, 쓸모 있는 모노이드 대부분은 그 성질이 없습니다. 문자열 이어 붙이기, 리스트 덧붙이기, 함수 합성 모두 결합적이지만 어느 것도 교환적이지 않습니다.
이 구분은 FxDart의 비동기 장에서 실제로 이빨을 드러냅니다. concurrent(n)은 원소를 순서 없이 평가하지만 소스 순서대로 내보내는데, 바로 그래야 하류의 fold가 교환이 아니라 결합만 요구하기 때문입니다. 완료 순서대로 결과를 내주는 라이브러리라면 여러분의 코드에 더 강한 법칙을 말없이 요구하는 셈입니다.
NonEmptyList, 그리고 오류가 반군인 이유6장에서 검증 오류를 모았습니다. 그것들이 무엇으로 쌓이는지 물으면 대수가 먼저 답합니다. 결합적으로 합칠 수 있는 것이 필요하고(실패한 두 가지가 이어 붙습니다), 실패를 합친 결과는 결코 비어 있지 않습니다 — 그러니 항등원은 불필요할 뿐 아니라 거짓말이 됩니다.
그것이 모노이드가 아닌 반군이고, FxDart는 그것을 NonEmptyList라 부릅니다.
import 'package:fxdart/fxdart.dart'; void main() { final a = NonEmptyList.of('name is empty'); final b = NonEmptyList.of( 'age is negative', ['age is not a number']); // Combining failures is list concatenation: associative, // and the result cannot be empty. final all = NonEmptyList.of(a.first, [...a.skip(1), ...b]); print(all.toList()); print('length: ${all.length}'); // Nel is an extension type over List, so it costs nothing at // runtime — and `orNull` is the only way in from a plain list. print(NonEmptyList.orNull(<String>[]));}그러므로 Either<Nel<E>, A>는 정확한 주장으로 읽힙니다. 이것이 실패했다면 이유가 적어도 하나 있고, 이유들은 합쳐진다. List<E>였다면 "오류 0개로 실패함"이라는 말이 안 되는 상태를 허용했을 것입니다 — 3장의 논지를 오류 통로에 적용한 것이죠.
🎓 모노이드는 합성되고, 그래서 어디에나 있습니다.
A와B가 모노이드면(A, B)도 모노이드입니다. 성분별로 결합하고(emptyA, emptyB)가 항등원이죠 — 그래서 "합계, 개수, 최댓값을 한 번에"는 곱 모노이드에 대한 fold 하나이고, 평균은 그 fold에 나눗셈 하나입니다. 모노이드로 가는 함수들도 모노이드를 이루고((f + g)(x) = f(x) + g(x)), 자기함수들은 합성 아래identity를 단위원 삼아 모노이드를 이룹니다 — "모나드는 자기함자 범주 위의 모노이드다"라는 문장 안에 숨은 말이 이것입니다.flatten이 결합이고of가 항등원이며, 1장의 세 모나드 법칙이 이 두 법칙을 변장시킨 것입니다.
fold를 쓸 때마다 여러분은 모노이드를 고르고 있고, 그것을 소리 내어 이름 붙이면 코드가 맞는지 알 수 있습니다. 항등원이 있는가(빈 경우엔 무엇을 돌려줘야 하지?), 결합적인가(작업을 쪼개도 되나?)
규모가 클수록 값을 합니다 — 덩어리 처리, 병렬 집계, 데이터베이스의 점진적 합계. API 설계에서도 그렇습니다. "씨앗과 결합 연산을 달라"는 인터페이스는 라이브러리가 여러분에게 묻지 않고도 작업을 배치로 묶을 수 있게 해 줍니다.
원소 열 개짜리 reduce 하나가 전부인 코드베이스에서는 어휘로서의 값은 없습니다. 거기서는 그냥 "합계"라고 말하세요.
max는 int 위에서 반군인가요? 모노이드인가요? 항등원은 무엇이어야 하고, Dart에 그것이 있나요?empty가 "뻔한 빈 값"이 아닌 모노이드를 하나 드세요 — 독자가 틀리게 짐작할 만한 것으로.fx(xs).fold(0, (a, b) => a + b.length)는 문자열 길이를 더합니다. fold에 넘긴 함수는 결합적인가요? 왜 그것이 문제가 되지 않나요?Either 누적과 Future.wait 모두 독립적인 결과를 결합합니다. Future.wait가 쓰는 모노이드는 무엇이고, 실패는 어떻게 처리하나요?max는 결합적이고 교환적이며, 그 항등원은 음의 무한대인데 Dart의 int에는 그런 값이 없습니다 — 그래서 max는 int 위에서는 반군이고, double(double.negativeInfinity)이나 null을 항등원으로 삼는 int? 위에서만 모노이드입니다. max에는 reduce가 자연스럽고 fold에는 어색한 씨앗이 필요한 정직한 이유가 그것입니다.&& 아래의 bool은 항등원이 false가 아니라 true이고, * 아래의 int는 0이 아니라 1이며, "첫 non-null" 모노이드의 항등원은 null입니다. 교훈은 empty가 타입이 아니라 언제나 연산에 의해 결정된다는 것입니다 — 타입만 보고 짐작하는 것이 fold가 모든 것에 0을 곱하게 되는 길입니다.int가 원소 타입 String과 다르기 때문입니다. Dart의 fold는 더 일반적인 카타모피즘 (B, A) → B이고, B == A일 때에만 모노이드 질문이 생깁니다. 순차 fold는 결코 재그룹핑하지 않으므로 문제가 되지 않습니다. 덩어리로 나누고 싶어지는 순간 문제가 되고, 그때는 연산을 진짜 모노이드로 분해해야 합니다(String → int, 그다음 합계).Future.wait는 결과에 대한 리스트 모노이드를 씁니다 — 인자 순서대로 이어 붙이고, []가 항등원입니다(아무것도 기다리지 않으면 빈 리스트). 실패는 누적되지 않습니다. 기본값에서는 첫 오류가 이기고 나머지는 버려지는데, 6장이 zipOrAccumulate와 대비했던 그 빨리 실패 동작입니다. eagerError: false는 언제 보고할지를 바꿀 뿐 몇 개를 보고할지는 바꾸지 않습니다.이 장에서 다루는 것
- 맞바꾸기:
List<Either<E, A>>→Either<E, List<A>>, 그리고 왜 계속 필요해지는가traverse= map + sequence, 그리고 어플리커티브가 기여하는 것- 빨리 실패하는 버전과 천천히 실패하는 버전, 각각의 정직한 대가
- 비동기 쌍둥이, 그리고
traverse가concurrent(n)을 만나는 지점
행 열 개를 검증하면 List<Either<E, Row>>가 손에 남습니다. 하류의 어떤 코드도 그것을 원하지 않습니다. 호출자가 원하는 것은 모든 행이거나, 아니면 그것을 줄 수 없는 이유들입니다. 손으로 쓰면 매번 같은 열다섯 줄입니다 — 누산기 하나, 루프 하나, 이른 반환 하나.
이 연산에는 sequence 라는 이름이 있고, 먼저 map 하고 나서 sequence 하는 일반화가 traverse 입니다.
sequence : List<F<A>> → F<List<A>>traverse : List<A> × (A → F<B>) → F<List<B>>
두 구조를 맞바꾸는 것으로 읽으세요. 리스트는 리스트로 남고 효과는 효과로 남습니다. 바뀌는 것은 어느 쪽이 바깥에 있느냐입니다.
그림 9-1. 맞바꾸기 전에는 원소마다 자기 몫의 작은 효과를 지고 있고, 맞바꾼 뒤에는 효과 하나가 리스트 전체를 진다. 값은 그대로이고 중첩만 달라진다.
import 'package:fxdart/fxdart.dart'; Either<String, int> parsePort(String s) { final n = int.tryParse(s); if (n == null) return Either.left('not a number: $s'); if (n < 1024) return Either.left('privileged: $n'); return Either.right(n);} void main() { // traverse: map each element to an Either, then swap. print(fx(['8080', '9000']).map(parsePort).sequence()); print(fx(['8080', 'x', '80']).map(parsePort).sequence());}값 하나가 나오고, 그것이 프로그램의 나머지가 원하는 값입니다. 모든 포트를 담은 Right, 아니면 애초에 리스트가 없는 첫째 이유를 담은 Left.
map만으로는 이것을 할 수 없습니다. 리스트 위에 map 하면 효과는 안쪽에 남고, map에는 그것을 밖으로 옮길 수단이 전혀 없습니다. F<List<A>>를 만들려면 원소들의 효과를 서로 결합해야 하고, 그것이 6장의 map2를 반복 적용하는 것입니다.
sequence([a, b, c]) = map2(a, map2(b, map2(c, of([]), cons), cons), cons)
여기서 두 가지 동작이 곧바로 설명됩니다. 결합 연산이 어플리커티브의 것이므로, 어떤 어플리커티브로 순회하느냐가 실패 정책을 결정합니다.
Either의 빨리 실패 어플리커티브로 순회 → 첫 Left에서 멈춤.Left를 수집.같은 순회, 다른 대수, 다른 보고서. FxDart는 둘 다 내놓습니다.
import 'package:fxdart/fxdart.dart'; Either<String, int> parsePort(String s) { final n = int.tryParse(s); if (n == null) return Either.left('not a number: $s'); if (n < 1024) return Either.left('privileged: $n'); return Either.right(n);} void main() { final raw = ['8080', 'x', '80', '9000']; // Fail fast: the first reason, and nothing after it ran. print(fx(raw).map(parsePort).sequence()); // Fail slow: every reason, in order. print(fx(raw).map(parsePort).flattenOrAccumulate()); // And the map-and-swap in one step, with the accumulating // applicative doing the combining. print(mapOrAccumulate( (r, String s) => r.bind(parsePort(s)), raw));}원할 법한 세 번째가 있습니다 — 멀쩡한 행은 남기고 잘못된 행은 보고하기 — 그리고 그것은 아예 순회가 아닙니다. 결과가 효과 하나가 아니라 리스트 둘이니까요. 그것은 자기 이름을 갖고 있습니다.
import 'package:fxdart/fxdart.dart'; Either<String, int> parsePort(String s) { final n = int.tryParse(s); return n == null ? Either.left('bad: $s') : Either.right(n);} void main() { final results = ['8080', 'x', '9000'].map(parsePort).toList(); final (bad, good) = separateEither(results); print('kept: $good'); print('dropped: $bad'); // …or take just one side. print(rights(results)); print(lefts(results));}셋 중 무엇을 고를지는 기술이 아니라 제품의 결정입니다. 임포트 도구는 separateEither를, 설정 로더는 flattenOrAccumulate를, API 핸들러는 sequenceEither를 원합니다.
Either 자리에 Future를 넣으면 같은 연산이 Dart 자신의 옷을 입고 나타납니다. Future.wait가 곧 future에 대한 sequence입니다. 그러니 흥미로운 버전은 순회하면서 동시에 작업량을 제한하는 쪽입니다.
import 'package:fxdart/fxdart.dart'; Future<int> fetchSize(String url) async { await Future.delayed(const Duration(milliseconds: 20)); return url.length;} void main() async { final urls = ['a.com', 'bb.com', 'ccc.com', 'dddd.com']; // Sequence with unbounded concurrency: Future.wait. print(await Future.wait(urls.map(fetchSize))); // Traverse with *bounded* concurrency: three in flight, // results still in source order. final bounded = fx(urls).toAsync().mapConcurrent(3, fetchSize); print(await bounded.toList());}Future.wait는 제동 장치가 없는 어플리커티브 순회입니다. 전부 시작해 버리죠. mapConcurrent(n)은 제한이 걸린 같은 순회이고, 요청 수 제한이 있는 API를 상대할 때 실제로 원하는 것입니다. 그 제한이 권고가 아니라 진짜가 되게 하는 백채널은 13장이 설명합니다.
🎓 traverse는 리스트보다 일반적입니다. 온전한 시그니처는 임의의 순회 가능한 컨테이너
T와 임의의 어플리커티브F에 대해traverse : T<A> × (A → F<B>) → F<T<B>>입니다 — 트리도, 맵도,Option도 순회 가능합니다. 법칙이 둘(펑터처럼 항등과 합성) 있고, 유명한 따름정리가 하나 있습니다. 항등 어플리커티브로traverse하면 그냥map이고, 상수 어플리커티브로 하면fold입니다.map,fold,traverse는 한 연산의 세 얼굴입니다 — 아름다운 결과이고, 한 번이라도 서술하려면 고차 타입이 필요합니다. 그것이 10장의 주제이며, FxDart가 일반적인 순회 하나 대신 구체적인 순회 넷을 싣는 이유입니다.
위 코드에 나온 버전을 세어 보세요. sequenceEither, flattenOrAccumulate, mapOrAccumulate, separateEither — 여기에 비동기 체인을 위한 sequenceEitherAsync, flattenOrAccumulateAsync, mapOrAccumulateAsync까지. 고차 타입이 있는 언어가 하나로 쓰는 자리에 함수 일곱 개입니다.
이것은 무능이 아니라 언어의 천장이고, 여러분에게 실제 비용을 물립니다. FxDart에 새 효과 타입이 추가되면, 누군가 일곱 번째, 여덟 번째, 아홉 번째 변종을 손으로 쓰기 전까지 여러분의 기존 순회는 그 타입과 함께 동작하지 않습니다.
독립적이고 실패할 수 있는 것들의 모음이 하나의 결정이 되어야 하는 모든 경계. 설정 파일 파싱, 임포트 검증, 레코드 N개 로딩, 서비스 N개로 팬아웃. for (final x in xs) { final r = f(x); if (r.isLeft) return r; out.add(...); } 를 두 번 넘게 쓰셨다면 그것이 순회이고, 그렇게 말해야 합니다.
모음이 원소 하나뿐이면 건너뛰세요(그냥 Either를 쓰면 됩니다). 부분 성공 의미가 필요할 때도(그건 separateEither입니다), 루프가 순수한 map이 아닌 무언가를 원소마다 정말로 하고 있을 때도 마찬가지입니다 — 부수효과를 감춘 순회는 그것이 대체한 루프보다 나쁩니다.
sequence는 무엇인가요 — Either에 대해, 그리고 Future에 대해? 8장의 어떤 법칙이 답을 정하나요?traverse(xs, f)와 xs.map(f) 후 sequence는 같은 결과를 줍니다. Dart에서 어느 쪽이 더 싸고, FxDart는 왜 그래도 두 철자를 다 싣나요?Either<E, List<A>>가 있고 List<Either<E, A>>를 원합니다 — 반대 방향의 맞바꾸기. 언제나 가능한가요? Left에 대해 해 보세요.Future.wait는 모든 future를 즉시 시작합니다. 그것이 정확히 옳은 상황 둘과, mapConcurrent(n)만이 옳은 상황 둘을 적어 보세요.Right([])와 Future.value([]) — 어플리커티브의 pure로 감싼 빈 리스트입니다. 8장 리스트 모노이드의 항등원이 []이고, 아무것도 없는 것의 sequence는 반드시 항등원을 내놓아야 합니다. 그렇지 않으면 이어 붙인 입력에 대한 두 순회의 합성이 깨집니다.traverse는 중간의 List<F<B>>를 만들지 않습니다. 규모가 크면 의미가 있고 원소 열 개에서는 아닙니다. FxDart가 둘 다 싣는 이유는 두 단계 형태(.map(f).sequenceEither())가 기존 지연 체인에 합성되기 때문이고, 융합된 형태는 소스가 이미 재료화되어 있을 때 원하는 것이기 때문입니다.Left(e)입니다. [Left(e)]로 갈까요, []로 갈까요? 둘 다 변호할 수 있고, 그것이 이 방향은 순회가 아니라는 신호입니다 — 답을 강제하는 법칙이 없으니까요. 일반적인 맞바꿈 F<T<A>> → T<F<A>>는 분배 법칙(distributive law) 이라 불리며 특정한 구조 쌍에 대해서만 존재합니다.Future.wait를 걸면 소켓 10만 개가 열리고, 무너지는 것은 상대가 아니라 여러분의 프로세스입니다.이 장에서 다루는 것
- 종(kind): 타입의 타입, 그리고 인자 없는
List가 서 있는 자리- 컴파일되지 않는 정확한 Dart 선언, 그리고 어떤 기교로도 되찾을 수 없는 이유
- 스칼라, 하스켈, 코틀린 Arrow는 대신 무엇을 하는가
- FxDart가 치르는 청구서, 함수 개수로 세어 보기
값에는 타입이 있습니다. 타입에는 종(kind) 이 있습니다.
int는 완성된 타입입니다. 그 타입의 변수를 선언할 수 있죠. 그 종은 *라고 씁니다. List 자체는 완성된 타입이 아닙니다 — List<int>가 완성된 타입입니다. List는 타입에서 타입으로 가는 함수이고, 그 종은 * → *입니다.
| 대상 | 종 | 완성? |
|---|---|---|
int, String, List<int> | * | 예 |
List, Future, Fx | * → * | 인자 하나 필요 |
Either, Map | * → * → * | 인자 둘 필요 |
5장부터 9장까지는 전부 종이 * → *인 타입에 관한 이야기였습니다. 펑터, 어플리커티브, 모나드, 순회 가능성은 타입이 아니라 타입 생성자의 성질입니다. List<int>는 모나드가 아니고, List가 모나드입니다.
그 한 문장이 이 장의 전부입니다. 그 인터페이스를 적으려면 그 자체로 종이 * → *인 타입 매개변수 — 고차 타입(higher-kinded type) — 가 필요합니다.
// Does not compile. Dart type parameters are always kind `*`,// so `M` is a complete type and cannot take an argument.abstract class Monad<M> { M<A> of<A>(A value); M<B> flatMap<A, B>(M<A> box, M<B> Function(A) f);}
이 오류는 문법의 문제가 아닙니다. Dart의 타입 변수는 완성된 타입만을 범위로 하므로, M<A>는 3(4)가 무의미한 것과 같은 방식으로 무의미합니다. 이는 의도된 설계 지점이고 — 1차 제네릭은 추론을 결정 가능하게, 오류 메시지를 읽을 만하게 유지합니다 — 우회할 버그가 아니라 천장입니다.
그림 10-1. 탑의 모든 층은 타입 생성자에 관한 진술이다. Dart는 층을 한 타입씩 이야기할 수 있다. 그 모두를 한 번에 지탱할 들보에는 이 언어에 없는 종이 필요하다.
우회로들은 모두 같은 방식으로 실패합니다 — 컴파일은 되고, 그다음 거짓말을 합니다.
// The "defunctionalisation" trick: erase the constructor to a// marker, then cast it back. It type-checks. It is not typed.abstract class Kind<F, A> {} class ListK<A> implements Kind<ListK<Never>, A> { ListK(this.value); final List<A> value;} abstract class Monad<F> { Kind<F, A> of<A>(A value); Kind<F, B> flatMap<A, B>( Kind<F, A> fa, Kind<F, B> Function(A) f);} class ListMonad implements Monad<ListK<Never>> { @override Kind<ListK<Never>, A> of<A>(A value) => ListK([value]); @override Kind<ListK<Never>, B> flatMap<A, B>( Kind<ListK<Never>, A> fa, Kind<ListK<Never>, B> Function(A) f, ) { // The cast is the whole problem: nothing checks it. final list = (fa as ListK<A>).value; return ListK(list .expand((a) => (f(a) as ListK<B>).value) .toList()); }} void main() { final m = ListMonad(); final r = m.flatMap<int, int>( m.of(3), (a) => ListK([a, a * 10])); print((r as ListK<int>).value);}동작하긴 합니다. 그 값을 보세요. 캐스트 셋, Never 유령 타입 하나, 그리고 어떤 호출자도 원하지 않는 반환 타입 Kind<ListK<Never>, int>. 모든 사용처가 진짜 타입으로 다시 캐스트하므로, 이 추상은 타입 오류가 런타임에 드러나는 제네릭 코드를 여러분 손에 쥐여 줍니다. Arrow 초기 버전이 코틀린에서 정확히 이렇게 했다가 버렸습니다. FxDart의 ARROW_MIGRATION_BLOCKER.md에 Dart에 대한 같은 결론이 적혀 있습니다.
class Monad m where (>>=) :: m a → (a → m b) → m b 는 평범한 코드이고, 모든 인스턴스가 그것에 대해 검사됩니다.F[_])를 가지므로, Cats는 Traverse[F[_]]를 한 번 정의하고 모든 조합자를 공짜로 얻습니다.Kind 인코딩을 썼습니다. Arrow 2.x는 그것을 지웠습니다. 편의성이 충분히 나빴기에 팀은 구체 타입에 컨텍스트 리시버와 Raise 스코프를 더한 쪽을 택했고, FxDart가 이식한 것이 그 설계입니다.🎓 실제로 잃는 것. 표현력이 아닙니다 — 고차 타입 추상으로 쓸 수 있는 모든 프로그램은 타입마다 손으로도 쓸 수 있습니다. 잃는 것은 추상에 대한 추상 입니다.
traverse일곱 개가 아니라 하나,sequence하나, 한 번만 검사하면 되는 법칙 한 벌. 고차 타입이 있는 언어에서는 새 효과 타입이 라이브러리 전체를 이미 갖춘 채로 도착하고, Dart에서는 텅 빈 채로 도착해 누군가 채워야 합니다. 차이는 프로그램의 능력이 아니라 라이브러리 유지 비용이고 — 그래서 합리적인 언어 설계 선택이면서 동시에 성가신 것입니다.
2부의 모든 추상은 고차 타입이 있는 언어가 한 번 쓰는 것을 FxDart는 타입마다 씁니다. 구체적으로, 연산 하나 — 순회 — 를 위해 라이브러리가 싣는 것은 이렇습니다.
import 'package:fxdart/fxdart.dart'; void main() { final xs = <Either<String, int>>[ Either.right(1), Either.left('bad'), Either.right(3), ]; // Four spellings of "swap the structures", because there is no // way to write one that works for every effect type. print(sequenceEither(xs)); print(flattenOrAccumulate(xs)); print(separateEither(xs)); print(fx(xs).sequence()); // …plus sequenceEitherAsync, flattenOrAccumulateAsync, // mapOrAccumulateAsync for the async chain.}거래를 정직하게 보려면 반대쪽도 봐야 합니다. 이것들은 구체적이기 때문에 빠르고 타입이 정확합니다. sequenceEither는 Either<L, List<R>>를 돌려줍니다 — Kind<F, List<R>>도 아니고, 손으로 풀어야 하는 래퍼도 아닙니다. Dart의 추론이 동작하고, 에디터가 자동완성하며, 오류가 여러분의 코드를 가리킵니다. Kind 인코딩의 제네릭 버전이라면 캐스트 없이는 아무도 쓸 수 없는 무언가를 돌려줄 것입니다.
대개는 중요하지 않습니다 — "당연히 있어야 할" 제네릭 조합자를 찾아 나서기 전까지는. 이 장이 그 검색의 답입니다. 그것은 없고, 있을 수 없으며, 구체적인 버전이 저기 있습니다.
라이브러리를 설계할 때는 중요합니다. "map이 있는 아무 컨테이너"를 추상화하려고 애쓰는 자신을 발견하면 멈추세요. Dart에서는 구체적인 두세 버전을 쓰고 이름을 잘 붙이는 편이 낫습니다. 손을 뻗고 있는 그 추상은 돌려주는 것보다 비쌉니다.
하스켈이나 스칼라를 아이디어를 얻으려 읽을 때도 중요합니다 — 읽을 가치가 있습니다. 다만 글자가 아니라 구조로 번역하세요. 그들의 한 줄짜리 제네릭 정의는 여러분의 구체적인 메서드가 되고, 다형성은 살아남지 못해도 법칙은 번역을 견딥니다.
Map의 종은 무엇인가요? Map<String, dynamic>은요? 가상의 Traverse 인터페이스는요?Kind 인코딩을 Either로 확장하고 그것의 flatMap을 작성하세요. 캐스트가 몇 개 필요하고, 잘못된 캐스트라면 어디서 터지나요?Fx<T>에는 map, flatMap, sequence 형태의 종결자가 있습니다. dynamic이나 공통 상위 타입 없이 "map이 있는 아무 FxDart 타입"을 받는 함수를 쓸 수 있나요? 답을 종의 언어로 설명하세요.T extends Comparable<T>를 허용합니다. 그것이 왜 이 장의 반례가 아닌가요?Map은 * → * → *(인자 둘)이고, Map<String, dynamic>은 *이며, Traverse는 (* → *) → * 일 것입니다 — 타입 생성자를 받아 타입을 내놓죠. 마지막 종이 정확히 Dart가 적을 수 없는 것이고, 그 괄호가 언어가 멈추는 지점입니다.Kind<F, A>를 EitherK<E, A>로 풀 때 하나, f의 결과에 하나. 터지는 곳은 런타임이고, 누군가 ListK를 Either 모나드 인스턴스에 넘기는 순간입니다. 둘 다 Kind<F, _>로 지워지므로 타입 시스템은 애초에 보고 있지 않았습니다.* → *인 매개변수("F<A>에 map이 있는 어떤 F")가 필요한데, Dart의 매개변수는 전부 종이 *입니다. 남은 우회로는 정확히 나쁜 셋뿐입니다. dynamic, 공통 상위 타입(Fx와 Either에는 없고 있어서도 안 됩니다), 그리고 Kind 캐스트 인코딩.T extends Comparable<T>는 완성된 타입을 제약합니다 — T는 여전히 종이 *이고, Comparable<T>는 그 위의 한계(bound)이지 고차 매개변수가 아닙니다. F-한정 다형성은 다른 문제를 푸는 다른 기능이고, "대부분의 코드에 충분히 제네릭한 것"과 "고차인 것"이 별개의 축임을 잘 보여 주는 예입니다.이 장에서 다루는 것
- 서술과 실행, 그리고 어떤 연산자가 어느 쪽인가
- 비용 모델: 일의 양은 쓴 것이 아니라 소비한 것에 비례한다
- 왜 지연 평가는 2부의 어떤 법칙도 바꿀 수 없는가
- 진짜 위험 둘: 파이프라인 안의 효과, 그리고 한 번만 읽히는 소스
체인을 쓰면 아무 일도 일어나지 않습니다.
import 'package:fxdart/fxdart.dart'; void main() { var calls = 0; final chain = fx([1, 2, 3, 4, 5]).map((n) { calls++; return n * 2; }); print('after building the chain: $calls calls'); print(chain.toList()); print('after consuming it: $calls calls');}map, filter, take, chunk, zip은 지연(lazy) 연산자입니다 — 각각 단계 하나가 더 붙은 새 서술을 돌려줍니다. toList, each, fold, first, sum은 종결(terminal) 연산자입니다 — 값을 당겨 오고, 그제야 무언가가 실행됩니다.
둘을 구분하는 규칙은 반환 타입이고 결코 거짓말하지 않습니다. Fx가 다시 돌아왔다면 아직 아무 일도 없었습니다.
그림 11-1. 점선 사슬은 계획이다. 단계들이 이어져 있을 뿐 움직이는 값은 없다. 당기는 것은 종결 연산자이고, 값 하나가 사슬 전체를 지나간 뒤에야 다음 값이 출발한다.
얼마나 당길지는 종결 연산자가 정하므로, 일의 양은 소비한 것에 비례합니다.
import 'package:fxdart/fxdart.dart'; void main() { var evaluated = 0; final result = fx(range(1, 1000000)) .map((n) { evaluated++; return n * n; }) .filter((n) => n.isOdd) .take(3) .toList(); print(result); print('elements evaluated: $evaluated of 999,999');}백만 개 후보에서 세 개의 결과를 얻는 데 다섯 번의 계산. 같은 프로그램의 즉시 평가 버전은 백만 원소짜리 리스트를 만들고, 그것을 걸러 또 다른 리스트를 만들고, 셋만 남기고 전부 버립니다.
이 차이가 FxDart 자체 벤치마크에서 파이프라인이 손으로 쓴 루프를 이기는 경우로 나타납니다 — 루프가 원소당으로는 대개 더 빠르지만, 지연 사슬은 아예 그 일을 하기를 거부합니다. 14장이 양방향 모두에 숫자를 붙입니다.
같은 모델에서 두 가지 귀결이 더 떨어져 나옵니다.
first, any, find는 답을 얻는 즉시 당김을 멈추며, 상류 단계의 특별한 지원이 필요 없습니다.import 'package:fxdart/fxdart.dart'; void main() { // An endless cycle, consumed finitely. print(fx([1, 2, 3]).cycle().take(7).toList()); // `some` stops pulling at the first match. var checked = 0; final found = fx(range(1, 1000)).some((n) { checked++; return n > 4; }); print([found, checked]);}이 부분은 분명히 해 둘 가치가 있습니다. 지연 평가를 믿고 쓸 수 있게 해 주는 것이 바로 이것이니까요. 2부의 모든 법칙은 값 사이의 등식입니다. 펑터 법칙은 m.map(f).map(g)가 m.map(g ∘ f)와 같다고 말하고, 두 파이프라인이 같다는 것은 같은 원소를 같은 순서로 내놓는다는 뜻입니다.
평가가 언제 일어나는지는 그 등식의 일부가 아닙니다. 그러므로,
map 두 단계를 융합하는 것은 합법입니다(펑터 합성 법칙);filter를 map 앞으로 옮기는 것은 술어가 매핑에 의존하지 않을 때 합법입니다 — 이는 진짜 전제 조건이지 지연 평가의 문제가 아닙니다;그 마지막 조건이 전부이고, 그것은 2장의 조건입니다. 순수한 파이프라인에서 지연 평가는 청구서 말고는 보이지 않습니다. 순수하지 않은 파이프라인에서는 어디서나 보이는데, 효과가 관찰하게 해 주는 것이 정확히 언제 일어났는가이기 때문입니다.
import 'package:fxdart/fxdart.dart'; void main() { final log = <String>[]; // Built, never consumed: the effect never happens. final unused = fx([1, 2, 3]).map((n) { log.add('mapped $n'); return n; }); print('log after building: $log'); // Same chain, consumed twice: the effect happens twice. final used = fx([1, 2]).map((n) { log.add('mapped $n'); return n; }); used.toList(); used.toList(); print('log after two pulls: $log'); print(unused.take(0).toList());}두 놀라움은 같은 놀라움입니다. 지연 사슬은 레시피이고, 레시피는 0번 요리될 수도 두 번 요리될 수도 있습니다.
🎓 지연, 엄격, 그리고 하스켈이 말하는 지연. 하스켈은 모든 식 수준에서 기본이 지연입니다. 값은 강제되기 전까지 썽크(thunk)이고, 그래서 무한 자료구조와 쓰지 않으면 비용이 0인
where절을 얻으며 — 강제되지 않은 썽크 사슬이 자라면 공간 누수를 얻습니다. Dart는 엄격하고, 지연 파이프라인은 수열 수준에서 지연을 다시 만들어 낸 것이며, 그 장치는 썽크가 아니라 당김 프로토콜입니다. 실무적 차이는 이렇습니다. 단락 평가와 스트리밍의 이득은 얻고, 한없이 쌓이는 썽크는 얻지도(디버그하지도) 않으며, 두 번 합성한fx(xs).map(f)는 계획인 반면let y = f x는 이미 썽크입니다.
List 위의 파이프라인은 여러 번 당길 수 있습니다 — 리스트는 다시 읽을 수 있으니까요. 읽는 즉시 소비되는 소스 위의 파이프라인은 그럴 수 없습니다.
import 'package:fxdart/fxdart.dart'; Iterable<int> readOnce() sync* { // A generator: iterating it again starts over, but a *stream* // or a socket would not — that is the shape to watch for. yield 1; yield 2;} void main() { final chain = fx(readOnce()).map((n) => n * 10); print(chain.toList()); print(chain.toList()); // fine here — the generator restarts // The rule that always holds: if you need the values twice, // materialise once and re-read the list. final materialised = chain.toList(); print([materialised.length, materialised.first]);}지침은 짧습니다. 한 번 소비하거나, 재료화하라. 사슬을 소비자 둘이 쓴다면 toList()를 부르고 그 리스트를 공유하거나, 소스를 다시 실행하지 않고 당김 하나를 여럿으로 쪼개려고 존재하는 fork/tee를 쓰세요.
파이프라인이 필요한 것보다 많이 만들어 낼 수 있을 때 지연 평가가 값을 합니다. 상위 N개 가져오기, 첫 일치 찾기, 도중에 그만둘 파일 스트리밍, 합쳐 놓으면 선택도가 높은 필터 조합. 메모리에도 값을 합니다 — 단계마다 리스트 하나가 아니라 원소 하나만 흐릅니다.
어차피 전부 소비되고 소스도 작을 때는 비용만 듭니다. 그때 원소당 프로토콜은 평범한 루프에 대한 순수 오버헤드이고, 14장이 그 양을 정확히 측정합니다. 디버깅 가능성에도 비용이 듭니다 — 지연 사슬 안의 스택 트레이스는 여러분의 파이프라인이 아니라 이터레이터 프레임을 보여 주며, 그것이 간접성의 값입니다.
fx(xs).map(f).toList()와 xs.map(f).toList()는 같은 일을 합니다. FxDart 버전이 이기기 시작하는 지점은 어디이고, 사슬의 어느 연산자가 그 원인인가요?take(2) 앞에서 peek(print)를 부르는 사슬의 출력을 예측하세요. 몇 줄이 출력되고, 왜인가요?fx(range(1, 1000000)).map(expensive).first — expensive는 몇 번 실행되나요? .first를 .last로 바꾸면요?take, first, some, find, 또는 비싼 map 앞에서 대부분을 걸러 내는 filter. 그런 단계가 없으면 둘은 똑같은 일을 하고, 즉시 평가 버전 쪽이 원소당 기계 장치가 적습니다.take(2)가 둘째 원소 뒤로는 당기지 않으므로 peek은 셋째 원소부터는 질문조차 받지 않습니다 — 상류를 움직이는 것은 당김이고, 그 당김이 멈춘 것입니다.final xs = chain.toList(); 하고 xs를 두 번 쓰기. 둘째 해법: fork/tee로 당김 하나를 소비자 둘로 쪼개어 소스는 여전히 한 번만 읽히게 하기..first면 한 번입니다 — 당김 한 번으로 충족되니까요. .last면 999,999번 전부입니다. last는 끝까지 가야 하므로 건너뛸 것이 남지 않습니다. 같은 사슬, 같은 지연 평가, 정반대의 비용이고, 그것을 정하는 것은 전적으로 종결 연산자입니다.이 장에서 다루는 것
- 쌍대성:
Iterator와Stream은 누가 호출하느냐가 다르다- 거기서 떨어져 나오는 것들 — 배압, 취소, 시간
- 왜 FxDart의 비동기 모델은
Stream이 아니라 당김 프로토콜인가- 다리들, 그리고 주어진 문제에서 어느 편을 고를 것인가
pull: consumer asks → producer answers iterator.moveNext()push: producer calls → consumer receives stream.listen(onData)
차이는 이게 전부이고, 이 장의 나머지는 전부 그 귀결입니다. 풀 소스는 여러분이 부르는 함수이고, 푸시 소스는 여러분이 등록하는 콜백입니다.
풀 (Iterable, FxAsyncIterable) | 푸시 (Stream) | |
|---|---|---|
| 속도를 정하는 쪽 | 소비자 | 생산자 |
| 배압 | 공짜 — 그냥 요청하지 않으면 됨 | 따로 마련해야 함 |
| 일찍 멈추기 | 당김을 멈춤 | 구독을 취소함 |
| 시간 | 모델에 없음 | 본질적으로 있음 |
| 잘 맞는 곳 | 컬렉션, 파일, 페이지 API | UI 이벤트, 소켓, 타이머 |
그림 12-1. 같은 값, 반대 방향의 화살표. 풀 사슬에서는 요청이 상류로 올라가고 값이 되돌아온다. 푸시 사슬에서는 값이 하류로 내려가고, 상류의 누구도 허락을 기다리지 않는다.
형식적으로 이 둘은 쌍대(dual)입니다 — 한쪽의 화살표를 모두 뒤집으면 다른 쪽이 됩니다 — 그래서 연산자 어휘가 그토록 비슷하고(map, filter, take, scan이 양쪽에 다 있습니다) 실패 방식은 정반대입니다.
소비자가 생산자보다 느리면 무언가가 양보해야 합니다.
풀 사슬에서는 아무것도 양보하지 않습니다. 소비자의 next 호출이 곧 시계이기 때문입니다. 느린 소비자는 그저 덜 자주 요청하고, 그사이 생산자는 놀고 있습니다.
import 'package:fxdart/fxdart.dart'; void main() async { var produced = 0; final source = fx(range(1, 1000)).map((n) { produced++; return n; }).toAsync(); // The consumer takes three and stops asking. final taken = await source.take(3).toList(); print(taken); print('produced: $produced'); // not 999}푸시 사슬에서는 생산자가 아랑곳없이 계속 갑니다. Dart의 Stream은 이를 지원하는 소스에 대해서는 일시정지/재개로, 그렇지 않은 소스에 대해서는 버퍼로 처리합니다 — 속도 불일치가 컴파일 오류가 아니라 메모리 증가가 되는 것이죠. 전형적인 버그는 느린 리스너가 붙은 브로드캐스트 스트림입니다. 큐가 자라고, 지연이 자라고, 타입 어디에도 그런 말은 없었습니다.
Stream 위에 서 있지 않은가FxDart의 비동기 수열은 FxAsyncIterable — 당김 프로토콜 — 입니다. 이 라이브러리의 간판 기능이 소비자가 주도권을 쥐고 있어야만 성립하기 때문입니다.
concurrent(n)은 상류에게 원소 n개를 한 번에 평가해 달라고 요청합니다. 그 요청은 소비자에게서 소스 쪽으로, 뒤로 거슬러 올라가야 하는데, 그것이 바로 당김 프로토콜에 이미 화살표가 나 있는 방향입니다. FxDart는 iterator.next(concurrent)로 표식을 넘깁니다. 소비자가 "다음 것 하나 주세요, 그리고 참고로 이런 걸 n개 병렬로 돌리세요"라고 말하고, 상류의 모든 단계가 그것을 이행하거나 위로 전달합니다.
이것을 Stream으로 표현할 방법은 없습니다. 푸시 소스는 이미 달리고 있고, 소비자는 멈춰 달라고 요청할 수 있을 뿐 더 넓게 가라고는 할 수 없습니다. 옆 통로를 따로 만들어야 하는데 — Rx 계열 라이브러리의 여러 parallel 연산자가 그것입니다 — 그러고 나면 버퍼링과 순서를 손으로 다시 맞춰야 합니다.
import 'package:fxdart/fxdart.dart'; Future<String> fetch(String id) async { await Future.delayed(const Duration(milliseconds: 40)); return 'data-$id';} void main() async { final ids = ['a', 'b', 'c', 'd', 'e', 'f']; final sw = Stopwatch()..start(); // One at a time: six 40ms waits, serially. await fx(ids).toAsync().map(fetch).toList(); final serial = sw.elapsedMilliseconds; sw.reset(); // Three at a time, results still in source order. final out = await fx(ids).toAsync().map(fetch).concurrent(3).toList(); final concurrent = sw.elapsedMilliseconds; print(out.first); print('serial ~${serial}ms, concurrent(3) ~${concurrent}ms');}두 번째 숫자가 첫 번째의 대략 3분의 1이 되게 하면서도 버퍼링 없이, 순서를 잃지 않고, 두 번째 API 없이 해내는 것이 당김 프로토콜입니다. 거기 딸려 오는 보장들이 13장의 주제입니다.
풀 라이브러리라고 해서 푸시를 무시한다는 뜻은 아닙니다. 실제 프로그램에는 둘 다 있습니다 — UI 이벤트는 진짜 푸시이고, 데이터베이스 페이지는 진짜 풀입니다 — 그래서 FxDart는 양방향으로 건너갑니다.
import 'package:fxdart/fxdart.dart'; void main() async { // push → pull: a Stream becomes a pull chain. final ticks = Stream.fromIterable([1, 2, 3, 4, 5]); final doubled = await fxStream(ticks).map((n) => n * 2).take(3).toList(); print(doubled); // pull → push: a chain becomes a Stream for the framework. final asStream = fx([1, 2, 3]).toAsync().map((n) => n + 10).toStream(); print(await asStream.toList());}FxDart는 명시적으로 푸시 모양인 계층도 싣습니다 — 평범한 Stream 위의 Rx 스타일 연산자인 fxEvents — 진짜로 시간과 브로드캐스트에 관한 문제를 위해서입니다.
import 'package:fxdart/fxdart.dart'; void main() async { final clicks = Stream.fromIterable(['a', 'a', 'b', 'b', 'b', 'c']); // Push-side operators: same names, producer-driven semantics. final out = await fxEvents(clicks) .map((s) => s.toUpperCase()) .where((s) => s != 'B') .toList(); print(out);}고르는 기준은 이것입니다. 다음 값이 언제 존재하는지를 누가 정하는가? 답이 "바깥세상"이라면 여러분은 푸시 쪽에 있고 거기 머물러야 합니다. 답이 "소비하는 쪽"이라면 풀이 더 단순하고 배압을 공짜로 줍니다.
🎓 정확히 쌍대라는 뜻. 이터레이터는
() → Option<(A, Iterator<A>)>이고 — 소비자가 적용합니다. 옵저버는((A) → Unit) → Unit이고 — 생산자가 여러분의 콜백을 적용합니다. 한쪽의 화살표를 모두 뒤집으면 다른 쪽이 나옵니다. Rx를 만든 사람들이 "Rx는IEnumerable의 쌍대"라고 말한 것이 이 뜻입니다. 쌍대성은 어느 쪽에서 어떤 연산자가 어려운지도 예측합니다.zip은 풀에서 쉽고(양쪽에 요청하고 둘 다 기다리면 됩니다) 푸시에서는 버퍼링이 필요합니다. 반대로debounce는 푸시에서 자연스럽고(흘러간 시간에 관한 것이니까요) 풀에서는 무의미합니다. 요청과 요청 사이에는 아무 일도 일어나지 않으니까요.
풀은 데이터가 이미 거기 있고 속도를 여러분이 정할 때입니다. 컬렉션, 파일, 페이지 단위 HTTP, 데이터베이스 커서, 도중에 그만 읽을 수도 있는 모든 것, "한 번에 N개"가 여러분이 선언하고 싶은 정책인 모든 것.
푸시는 여러분의 준비와 무관하게 데이터가 도착할 때입니다. 사용자 입력, 웹소켓, 센서, 타이머, 그리고 여러 소비자가 같은 이벤트를 봐야 하는 모든 곳. 클릭 스트림을 풀 수열로 모델링하려 들면 버퍼를 손으로, 그것도 형편없이 쓰게 됩니다.
피해야 할 실수는 익숙한 연산자 이름을 쓰려고 반대편으로 변환하는 것입니다. 어휘가 마음에 든다고가 아니라 문제의 모양이 바뀔 때 다리를 건너세요.
take(3)은 생산자를 멈춥니다. Stream에서 그에 해당하는 것은 무엇이고, 이미 날아가고 있던 값들은 어떻게 되나요?debounce가 왜 불가능한가요? 그것이 무슨 뜻이 될지 서술하고, 어느 부분이 앞뒤가 맞지 않는지 말하세요.Stream에는 asBroadcastStream이, 풀 사슬에는 fork/tee가 있습니다. 둘 다 소비자 둘이 소스 하나를 보게 해 줍니다. 한 소비자가 느릴 때 무엇이 본질적으로 다른가요?subscription.cancel() 입니다. 이미 내보내진 값은 사라졌고, 생산자가 계산 중이던 값은 끝까지 계산된 뒤 버려집니다 — 생산자는 애초에 허락을 기다리고 있지 않았으므로 취소는 장벽이 아니라 요청입니다. 풀 사슬에서 "멈춤"은 그저 다음 호출이 없는 것이므로, 버려질 무언가가 날아가고 있지 않습니다.debounce는 "X 시간 안에 다른 것이 오지 않았을 때만 내보내라"는 뜻입니다. 풀 사슬에서는 스스로 오는 것이 없습니다. 다음 값은 여러분이 요청하는 바로 그때 존재하므로 그 시간 창은 언제나 비어 있고, 연산자는 map으로 퇴화합니다. 생산자의 타이밍에 관한 연산자, 즉 푸시에만 있는 연산자의 가장 명확한 예입니다.FxAsyncIterable. 푸시: 가능한 한 빨리 페이지를 가져오는 Stream. "첫 일치에서 멈추기"는 일치가 1페이지에 있다면 풀 모델에서 정확히 요청 한 번이 듭니다. 푸시 버전은 그때쯤 보통 여러 페이지를 이미 가져왔고, 차이는 상한이 없으며 지연 시간에 비례해 커집니다.asBroadcastStream은 모든 리스너에게 생산자의 속도로 같은 이벤트를 줍니다. 느린 리스너는 버퍼링하거나 흘리며, 생산자를 늦출 수 없습니다. fork/tee는 당김을 쪼개므로 공유된 소스는 두 소비자가 모두 요청했을 때만 전진합니다 — 느린 소비자가 빠른 쪽을 붙잡는 것이고, 이는 설계대로 동작하는 배압이며, 활성보다 정확성이 중요할 때 옳은 기본값입니다.이 장에서 다루는 것
- 보장: 언제이지 무엇이 아니다 — 그리고 그래서 합성된다
- "n만큼 넓게 가라"를 상류로 실어 나르는 백채널
- 순서 보존, 그리고 그것을 포기했을 때의 대가
- 틀리는 두 가지 방법: 상태 공유, 그리고 제한 없는 팬아웃
import 'package:fxdart/fxdart.dart'; Future<int> slowSquare(int n) async { await Future.delayed(const Duration(milliseconds: 40)); return n * n;} void main() async { final input = [1, 2, 3, 4, 5, 6]; final sw = Stopwatch()..start(); final serial = await fx(input).toAsync().map(slowSquare).toList(); final serialMs = sw.elapsedMilliseconds; sw.reset(); final wide = await fx(input) .toAsync() .map(slowSquare) .concurrent(3) .toList(); final wideMs = sw.elapsedMilliseconds; print(serial); print(wide); print('same result: ${serial.toString() == wide.toString()}'); print('serial ~${serialMs}ms, concurrent(3) ~${wideMs}ms');}똑같은 리스트 둘, 그중 하나는 3분의 1의 시간에 만들어졌습니다. 그것이 concurrent(n)의 주장이고, 정확히 적어 둘 가치가 있습니다.
concurrent(n)은 원소가 언제 계산되는지를 바꿉니다. 어떤 원소가 계산되는지, 그것이 무엇으로 계산되는지, 어떤 순서로 도착하는지는 바꾸지 않습니다.
하류의 모든 것 — fold, filter, 호출자 — 은 시계를 보지 않는 한 차이를 알 수 없습니다. 동시성은 다른 프로그램이 아니라 평가에 대한 효과로 더해집니다.
그래서 합성됩니다. 순서가 보존되므로 하류 fold에는 8장의 결합 법칙이면 충분하고, 겹쳐 실행을 애초에 합법으로 만드는 것은 6장의 독립성이며, 그것을 안전하게 만드는 것은 2장의 순수함입니다. 탑의 각 부분이 여기서 전제 조건으로 나타납니다.
12장은 당김 프로토콜에 상류를 가리키는 화살표가 있다고 했습니다. FxDart가 그 길로 보내는 것이 이것입니다.
평범한 당김은 "다음 원소를 달라"입니다. FxDart의 비동기 이터레이터는 인자를 받습니다. iterator.next(concurrent), 여기서 concurrent는 너비를 실은 표식입니다. 그것을 받은 단계는 이렇게 할 수 있습니다.
map은 혼자서 아무것도 병렬화할 수 없으므로 요청을 더 위로 넘긴다.그래서 요청은 소비자에게서 실제로 넓힐 수 있는 단계까지 올라가고, 값들은 순서를 지키며 내려옵니다.
그림 13-1. concurrent(3)은 사슬 한복판의 버퍼가 아니라, 무언가 처리할 수 있을 때까지 상류로 올라가는 메시지다. 원소 셋이 동시에 날아가고 있지만 소비자는 여전히 1, 2, 3을 받는다.
연산자를 비싼 단계 뒤에 놓았는데도 그 단계에 영향을 주는 이유도 이것입니다. 표식이 위로 올라가니까요. map(fetch).concurrent(3)은 "이것들을 한 번에 세 개씩 가져다 달라"로 읽히고, 정확히 그렇게 동작합니다. mapConcurrent(3, fetch)는 그것을 미리 합쳐 둔 것입니다.
순서 보존은 공짜가 아닙니다. 2번 원소가 1번보다 먼저 끝나면 그 결과는 기다립니다. 그 대가로 순차 버전과 같은 수열을 얻고, 그래서 기존 파이프라인에 concurrent(n)을 끼워 넣으면서 나머지를 다시 읽지 않아도 됩니다.
정말로 상관없을 때는 완료 순서를 요청해 결과를 더 빨리 받으세요.
import 'package:fxdart/fxdart.dart'; Future<String> job(String name, int ms) async { await Future.delayed(Duration(milliseconds: ms)); return name;} void main() async { final jobs = [('slow', 90), ('quick', 10), ('mid', 45)]; // Source order: 'slow' first, however long it takes. final ordered = await fx(jobs) .toAsync() .map((j) => job(j.$1, j.$2)) .concurrent(3) .toList(); print(ordered); // Completion order: whoever finishes first. final asDone = await fx(jobs) .toAsync() .map((j) => job(j.$1, j.$2)) .concurrentPool(3) .toList(); print(asDone);}concurrentPool은 "결정성을 지연 시간과 맞바꾸겠다"의 정직한 이름입니다. 결과를 각각 독립적으로 처리할 때 — 싱크에 쓰기, UI 갱신 — 쓰고, 하류 단계가 입력과의 위치 대응을 가정할 때는 절대 쓰지 마세요.
🎓 동시성은 병렬성이 아니고, Dart는 그것을 문자 그대로 만듭니다. 위의 모든 일은 아이솔레이트 하나에서 일어납니다. IO가 기다리는 동안 스레드 하나가 연속을 번갈아 실행할 뿐이죠. 위 어느 것도 CPU 바운드 코드를 빠르게 만들지 않습니다. 40ms짜리 계산 여섯 개는
concurrent가 있든 없든 240ms입니다. 두 번째 코어가 없으니까요. 겹치는 것은 기다림입니다. 진짜 병렬성이 필요하면 아이솔레이트가 필요하고, 아이솔레이트는 가변 상태를 공유할 수 없으므로 2장의 순수함이 규율이 아니라 기계적 요구 조건이 됩니다. 어휘는 똑바로 유지할 가치가 있습니다.concurrent(n)은 진행 중인 작업량을 제한하고, 아이솔레이트는 코어를 사 줍니다.
콜백 사이에 가변 상태를 공유하기. concurrent(n)에서는 콜백 n개가 동시에 날아가고 있고 그 뒤섞임 순서는 명세되지 않습니다. map 안에서 카운터를 하나 올리는 것은 (한 아이솔레이트에서는 문장 사이에 선점이 없으므로) 괜찮지만, await를 가로지르는 읽기-수정-쓰기는 그렇지 않습니다.
import 'package:fxdart/fxdart.dart'; void main() async { var balance = 100; // Each callback reads, awaits, then writes — the read is stale // by the time the write happens. await fx([1, 2, 3]) .toAsync() .map((n) async { final read = balance; await Future.delayed(const Duration(milliseconds: 10)); balance = read - 10; return n; }) .concurrent(3) .toList(); print('balance: $balance (serial answer would be 70)');}해법은 잠금이 아니라 그런 코드를 쓰지 않는 것입니다. 동시성 단계 안에서 공유 상태를 바꾸는 대신, 값을 돌려주고 순서가 보장되는 하류에서 접으세요.
제한 없는 팬아웃. Future.wait(items.map(fetch))는 전부 시작합니다. 열 개면 괜찮고, 만 개면 장애입니다. 너비 매개변수의 요점은 그 너비를 여러분이 고른다는 것이고, 맞는 숫자는 리스트 길이가 아니라 상대편의 한계에서 나옵니다.
원소당 작업이 IO인 모든 파이프라인. HTTP 요청, 파일 읽기, 데이터베이스 왕복. 이득은 대체로 너비만큼이고, 상대편이 병목이 되는 지점까지입니다 — 그리고 첫 예제의 측정은 그 비율을 믿는 대신 여러분의 서비스에 대고 다시 해 봐야 할 측정입니다.
아이솔레이트 하나에서의 CPU 바운드 작업에는 아무 도움이 안 되고, 소스가 싸고 짧을 때는 오히려 해롭습니다. 제곱 여섯 개를 계산하려고 future 셋을 더 만드는 것은 오버헤드니까요. 지연 평가와 마찬가지로, 모델이 이득이 어디 있는지 알려 줍니다. 계산이 아니라 기다림입니다.
concurrent(3)에서 약 90ms였습니다. concurrent(6) 과 concurrent(2)의 시간을 예측한 뒤 실행해 보세요.map(fetch) 뒤에 놓인 concurrent(n)이 왜 fetch에 영향을 주나요? 요청이 가는 방향으로 답하세요.concurrentPool(4) 뒤에 .chunk(10)이 따라옵니다. 무엇이 깨지나요? concurrent(4)였다면 같은 문제가 있을까요?concurrent(6)은 한 라운드, 즉 약 45ms여야 합니다. 여섯 기다림이 모두 겹치니까요. concurrent(2)는 세 라운드, 약 130ms입니다. 공식은 ceil(items / n) × latency이고, 너비를 고를 때 기억해 둘 만합니다.concurrent(3)은 자기에게 도착하는 값을 처리하는 것이 아니라 소스에 한 번에 셋을 요청하고, 그 소스가 map(fetch) 단계이므로 요청 셋이 시작됩니다. 푸시 모델이었다면 요청할 대상이 없습니다 — 요청은 이미 달리고 있을 테니까요..map((n) async { …; return -10; }).concurrent(3) 다음 fold(100, (a, d) => a + d). 상태가 동시성 영역 밖, 순서가 보장되는 영역으로 옮겨 갔습니다 — 이것이 일반적인 해법이고, fold가 파이프라인 안이 아니라 뒤에서 실행되는 이유입니다.chunk는 도착하는 대로 묶습니다 — 하지만 덩어리가 더 이상 입력 위치와 대응하지 않으므로, "0번 덩어리는 처음 열 개 입력"이라고 가정한 코드는 이제 틀렸습니다. concurrent(4)였다면 순서가 보존되므로 그 대응이 성립합니다. 결정성을 지연 시간과 맞바꾼 구체적인 대가입니다.이 장에서 다루는 것
- 실제 과제 53개에서 측정된 거래의 모양
- 파이프라인을 느리게 만드는 두 가지 장치와 빠르게 만드는 한 가지
- 왜 여기서 거의 모든 성능 질문의 답이 "할당"인가
- 남의 비율을 믿지 않고 여러분의 코드에서 판단하는 법
FxDart는 나란히 놓인 예제 53개 각각을, 같은 과제를 손으로 쓴 Dart 버전과 비교하는 벤치마크 스위트를 싣습니다. AOT 컴파일, 반복 측정의 중앙값입니다. 각 케이스의 가장 큰 스케일에서:
| 대표 스케일에서의 결과 | 케이스 수 |
|---|---|
| 무승부 (5% 또는 0.6ms 이내) | 38 |
| 손으로 쓴 Dart가 더 빠름 | 12 |
| FxDart가 더 빠름 | 3 |
53개 전체의 중앙값 비율은 1.06× — 파이프라인이 대체로 6% 정도 느리고, 그보다 자주 잡음 구간 안에 있습니다.
중앙값보다 흥미로운 것은 양극단입니다.
| 케이스 | 비율 | 의미 |
|---|---|---|
top-expenses | 0.27× | 파이프라인이 거의 4배 빠름 |
price-drop-detection | 0.52× | 파이프라인이 2배 빠름 |
smoothed-zone-changes | 2.23× | 파이프라인이 2.2배 느림 |
anomaly-context | 1.76× | 파이프라인이 1.8배 느림 |
같은 라이브러리, 같은 기계, 최고와 최악 사이에 여덟 배 차이. 그러므로 "Dart에서 FP는 n배 느리다" 형태의 문장은 전부 거짓입니다. 그 수는 아래 세 장치 중 무엇이 지배하느냐에 전적으로 달려 있습니다.
손으로 쓴 루프는 원소를 읽고 여러분의 코드를 인라인으로 적용합니다. 파이프라인은 모든 원소를 단계마다 이터레이터에 통과시킵니다. 가상 moveNext, current 게터, 클로저 호출. Dart의 AOT 컴파일러가 그중 상당수를 인라인하지만 다형적 이터레이터 경계를 넘어서는 못 하고, 그 비용은 원소마다, 단계마다 지불됩니다.
위 표의 패배자들이 균일한 데이터 위에 값싼 단계가 많은 예제인 이유가 그것입니다. 윈도 평활화, 이웃 비교, 작은 수치 계산. 원소당 오버헤드는 고정인데 원소당 유용한 일은 아주 작으니 비율이 나빠집니다.
import 'package:fxdart/fxdart.dart'; void main() { final data = List.generate(200000, (i) => i); final sw = Stopwatch()..start(); var loop = 0; for (final n in data) { if (n.isEven) loop += n * 2; } final loopMs = sw.elapsedMicroseconds; sw.reset(); final piped = fx(data).filter((n) => n.isEven).map((n) => n * 2).sum(); final pipeMs = sw.elapsedMicroseconds; print([loop, piped, loop == piped]); print('loop ${loopMs}us, pipeline ${pipeMs}us'); // In the browser this is JIT-compiled JS, so treat the ratio // as indicative; the book's table is AOT.}스위트에서 나타나는 큰 차이 대부분은 CPU가 아니라 쓰레기입니다. 그룹핑 과제의 손으로 쓴 버전은 중간 List와 Map을 만들고, 파이프라인 버전은 하나도 안 만들 수도, 원소마다 레코드를 만들 수도 있습니다. 어느 쪽이 더 많이 할당하는지는 과제에 달렸고, 그것이 다르면 할당이 측정 시간을 지배합니다.
이것이 가장 쓸모 있는 진단입니다. 양쪽의 할당을 세어 보세요. 같다면 두 버전은 잡음 범위 안에서 서로 비슷합니다. 한쪽이 다른 쪽은 만들지 않는 중간 컬렉션을 재료화한다면, 그쪽 루프가 아무리 촘촘해도 집니다.
11장의 비용 모델이 점수판에 나타난 것입니다. top-expenses가 파이프라인 버전에서 3.7배 빠른 이유는 파이프라인이 빨라서가 아니라 리스트 전체를 정렬하지 않기 때문입니다. 필요한 만큼만 가져오고 멈추는데, 네이티브 버전은 상위 다섯을 읽으려고 원소 10,000개를 정렬합니다.
FxDart가 이긴 세 케이스는 전부 이 장치입니다. 과제가 "이 데이터의 대부분은 상관 없다"는 모양일 때, 지연 평가는 어차피 전부 해 버리는 더 빠른 루프를 이깁니다.
그림 14-1. 서로 독립인 세 힘. 간접성은 원소마다, 단계마다 붙는 고정 세금이고, 할당은 중간물을 더 많이 만드는 쪽이 무는 것이며, 거부한 일은 종결 연산자가 일찍 멈출 때 파이프라인이 받는 환급이다.
스위트 자신의 규칙이고, 베껴 둘 만합니다.
🎓 빅오는 그대로이고, 상수는 아닙니다. 위의 어떤 것도 점근 복잡도를 바꾸지 않습니다. 지연
filter+map+fold는 루프와 똑같이 O(n)이고,top-expenses가 빠른 것은 상수가 좋아져서가 아니라 지연 평가가 알고리즘을 바꿨기 때문입니다(전체 정렬 대신 부분 선택). 큰 승리를 발견하면 둘 중 무엇이었는지 물으세요. 30%의 상수 배수 승리는 튜닝 결과이고, 점근적 승리는 설계 결과이며, 입력 크기가 바뀌어도 살아남는 것은 후자뿐입니다.
take, first, find, 또는 일찍 빠져나가는 any는 파이프라인이 이길 것이라 기대할 이유입니다.핫 패스이고, 단계가 값싸고, 소스가 전부 소비될 때 for 루프를 쓰세요. 정확히 지는 모양입니다. 연산이 진짜로 명령형일 때도 그렇습니다. 버퍼 변경, 크기가 정해진 리스트 채우기, 인덱스 기반 알고리즘 돌리기. 나머지 경우들은 22장이 모아 두었고, 이 장의 기여는 이제 여러분이 짐작 대신 답을 예측할 수 있고 10분 만에 확인할 수 있다는 것입니다.
.first로 끝나는 파이프라인이 있습니다. 세 장치 중 무엇이 지배하고, 같은 일을 하는 루프 대비 예상 비율은 얼마인가요?smoothed-zone-changes는 파이프라인이 2.2배 느립니다. 코드를 보기 전에, 이 장을 근거로 그 원인이 될 과제의 두 특징을 예측하세요..first는 원소 하나를 다섯 단계에 통과시키므로 파이프라인이 하는 일은 대략 클로저 호출 다섯 번인 반면, 루프 버전은 — 순진하게 썼다면 — 리스트 전체를 처리합니다. 예상 비율은 엄청나며 파이프라인 쪽이 유리합니다. 루프도 일찍 빠져나온다면 둘은 무승부에 파이프라인의 고정 단계 오버헤드를 더한 값으로 수렴합니다.이 장에서 다루는 것
- 장치: 실패했을 때
r.bind가 실제로 하는 일- 제한된 연속(delimited continuation)과 모나드 desugaring, 그리고 Dart가 왜 그 선택을 강제했는가
- 누출 규칙 — 스코프를 잘못 쓰는 유일한 방법과, 라이브러리가 그것을 잡는 법
flatMap사슬 대비 얻는 것과 잃는 것
either란 무엇인가7장은 스코프를 쓰기만 하고 열어 보지는 않았습니다. 모양은 이렇습니다.
import 'package:fxdart/fxdart.dart'; Either<String, int> half(int n) => n.isEven ? Either.right(n ~/ 2) : Either.left('odd: $n'); void main() { final result = either<String, int>((r) { final a = r.bind(half(20)); // 10 final b = r.bind(half(a)); // 5 final c = r.bind(half(b)); // odd → exits here return c * 100; // never reached }); print(result);}either는 여러분의 블록을 Raise<E> 객체와 함께 실행합니다. r.bind는 Either를 들여다봅니다. Right면 값을 돌려주고, Left면 블록 전체를 포기하고 either가 그 Left를 돌려주게 만듭니다. 피라미드도 flatMap도 없고, 블록은 위에서 아래로 읽힙니다.
장치는 제어 흐름 탈출입니다. raise가 비공개 표식을 던지고, either가 그것을 경계에서 잡아 Left로 바꿉니다. 던지기와 잡기가 모두 라이브러리 안에 있으므로 그 탈출은 제한(delimited) 되어 있습니다 — 감싸고 있는 either까지만 갈 수 있고 그 너머로는 못 갑니다.
그림 15-1. 모든 bind가 탈출구가 될 수 있고, 모든 탈출은 같은 자리에 착지한다. r을 만든 스코프의 경계다. 제어 흐름의 점프를 다시 평범한 값으로 바꿔 주는 것이 그 경계다.
스칼라의 for와 하스켈의 do는 다시 쓰기입니다. 컴파일러가 타입 검사 전에 블록을 flatMap 호출로 바꿉니다. 그것은 어떤 모나드에도 통하고, "어떤 모나드든"이라고 말하려면 고차 타입이 필요한데 — 10장이 설명했듯 Dart에는 없습니다.
FxDart의 스코프는 다시 쓰기가 아닙니다. 변환되는 것은 없고, 진짜 객체가 넘어가며, 제어는 언어가 이미 가진 장치로 블록을 떠납니다. 거래는 이렇습니다.
Desugaring (do, for) | 스코프 (either, Arrow의 Raise) | |
|---|---|---|
| 통하는 대상 | 타입이 이름 붙일 수 있는 모든 모나드 | 라이브러리가 써 둔 효과들 |
| 필요한 것 | 고차 타입 | 특별한 것 없음 |
| 실패 탈출 | 단락 평가된 값을 반환 | 비국소 점프, 경계에서 잡힘 |
async와의 합성 | 트랜스포머 필요 | 자연스러움 — eitherAsync |
| 사용자가 확장 가능 | 예, 모나드를 정의하면 | 아니오 |
마지막 두 줄이, 이 선택이 그저 강제된 것이 아니라 변호 가능한 이유입니다. 트랜스포머 탑(EitherT[Future, E, A])이 일반적인 답이고 진짜로 읽기 어렵습니다. 스코프는 사람들이 실제로 쓰는 그 한 조합 — async 안의 실패 — 을 새 타입 없이 처리합니다.
import 'package:fxdart/fxdart.dart'; Future<Either<String, int>> lookup(String key) async { await Future.delayed(const Duration(milliseconds: 10)); return key == 'port' ? Either.right(8080) : Either.left('missing: $key');} void main() async { final ok = await eitherAsync<String, String>((r) async { final port = r.bind(await lookup('port')); final host = r.bind(await lookup('port')); return 'http://$host:$port'; }); print(ok); final bad = await eitherAsync<String, String>((r) async { final port = r.bind(await lookup('nope')); return 'never: $port'; }); print(bad);}await가 시간을 이어 붙이고 r.bind가 실패를 이어 붙이며, 둘은 서로를 모릅니다. 그것이 보상의 전부입니다.
FxDart는 실패 표현마다 스코프를 하나씩 싣습니다 — 또다시 10장 때문에, 그 전부를 덮는 하나를 쓸 방법이 없기 때문입니다.
import 'package:fxdart/fxdart.dart'; int? parseTeen(String s) { final n = int.tryParse(s); return (n != null && n >= 13 && n <= 19) ? n : null;} void main() { // Failure as a typed value. print(either<String, int>((r) { final n = r.ensureNotNull( parseTeen('15'), () => 'not a teen'); return n * 2; })); // Failure as null — no error value to carry. print(nullable((r) { final n = r.bind(parseTeen('15')); return n * 2; })); print(nullable((r) { final n = r.bind(parseTeen('42')); return n * 2; })); // Failure as a thrown exception, handled at the boundary. print(catching<int>(() => int.parse('nope'), (e, _) => -1)); // …or converted straight into a Left. print(eitherCatching<String, int>( (r) => int.parse('nope'), (e, _) => 'not a number'));}스코프 셋, 아이디어 하나. 직선형 코드를 실행하고, 첫 실패에서 빠져나오고, 그 탈출을 호출자의 타입이 말하는 것으로 바꾼다.
스코프를 잘못 쓰는 방법은 정확히 하나뿐이고, 그것은 장치에서 따라 나옵니다. r은 자기 스코프가 실행 중일 때만 쓸 수 있습니다. 블록보다 오래 사는 클로저가 그것을 붙잡으면, 그 탈출은 착지할 곳이 없습니다.
import 'package:fxdart/fxdart.dart'; void main() { late Raise<String> escaped; final result = either<String, int>((r) { escaped = r; // capturing the scope object… return 1; }); print(result); try { escaped.raise('too late'); // …and using it after it closed } catch (e) { print('caught: ${e.runtimeType}'); }}라이브러리는 그것을 감지해 RaiseLeakedError를 던집니다. 길 잃은 제어 흐름 점프가 무관한 코드로 새어 나가게 두는 대신에요. 실무에서 이 규칙이 물리는 자리는 하나입니다. 나중에 실행되는 콜백 안에서 r을 쓰지 마세요 — await 하지 않은 future, 타이머, 스트림 리스너. eitherAsync 안에서는 await 사슬 위에 머무르세요. 같은 규칙을 비동기로 말한 것입니다.
🎓 오래된 아이디어에 새 이름. 제한된 연속은 "경계까지의 나머지 계산"을 붙잡아 두었다가 포기하거나 재개할 수 있게 해 줍니다. Scheme의
shift/reset, 하스켈의Cont, OCaml 5와 Koka의 대수적 효과 핸들러가 모두 이 장치입니다.Raise는 그중 포기하는 절반만 쓰고, 그래서 진짜 스택 캡처가 아니라 비공개 예외로 구현할 수 있습니다. 그 제약이 이것을 싸고 예측 가능하게 만들기도 합니다 — 재진입 없음, 재개 없음, 놀라운 재실행 없음. 탈출 하나, 경계 하나, 값 하나.
같은 오류 타입을 공유하는 실패 가능 단계가 셋 이상일 때, 특히 중간에 이른 반환과 가드 조건이 있을 때. r.ensure(cond, () => err)가 if (!cond) return Left(...)를 대신하는데, flatMap 사슬은 그것을 중첩 한 겹 없이는 표현하지 못합니다.
독립적인 검증에는(6장 — 누적을 원할 겁니다), 실패 가능 호출이 하나뿐일 때는 (Either를 그냥 돌려주세요), 그리고 블록이 나중에 실행될 코드에 r을 넘기는 곳에는 잘못된 도구입니다. 마지막 것은 누출 규칙이 금지합니다.
flatMap 사슬로 다시 쓰세요. "값이 3 미만이면 실패"라는 가드를 중간에 넣기가 어느 버전에서 더 쉬운가요?either는 무엇을 돌려주나요? 해 보고, 왜 그것이 옳은 기본값인지 설명하세요.r.recover(...)가 왜 없나요? 장치의 언어로 답하세요.nullable에는 오류 값이 아예 없습니다. 그 E 타입은 무엇이고, 그것은 Either<E, A>와 A?의 관계에 대해 무엇을 말해 주나요?half(20).flatMap(half).flatMap(half).map((c) => c * 100) 입니다. 가드를 넣으려면 flatMap((v) => v < 3 ? Left(...) : Right(v))를 끼워야 하고 — 중첩 한 겹과 람다 하나가 늘어납니다 — 스코프 버전은 한 줄이면 됩니다. r.ensure(a >= 3, () => 'too small'). 가드야말로 스코프가 결정적으로 앞서는 자리입니다.either 밖으로 그대로 전파됩니다. 던져진 예외는 "이 오류 타입이 서술하지 않는 무언가가 일어났다"는 뜻이므로 옳습니다. 그것을 조용히 Left로 바꾸면 버그를 도메인 실패로 세탁하게 됩니다. 그 변환을 원할 때를 위해 eitherCatching이 있고, 선택이 명시적이도록 별도 함수인 것입니다. 18장이 이 경계를 발전시킵니다.either가 실패를 보는 시점에는 블록의 스택 프레임이 이미 풀려 있고 지역 변수도 사라졌습니다. 재개하려면 풀리기 전에 연속을 붙잡아야 하는데, 그것이 Raise가 의도적으로 구현하지 않는 제한된 연속의 나머지 절반입니다. 그래서 복구는 바깥에서, 돌려받은 Either에 대해 일어납니다 — result.fold(...) 또는 getOrElse.E는 사실상 void/Null 입니다 — 실을 것이 없습니다. A?는 실패 쪽이 아무 정보도 싣지 않는 Either<Unit, A>이므로, 모든 nullable 계산은 왜 실패했는지를 잊어버린 Either입니다. 그것이 18장이 살펴보는 거래입니다. 널 허용은 공짜지만 말이 없고, 타입 있는 오류는 타입 매개변수 하나를 치르고 무엇이 잘못됐는지 말해 줍니다.이 장에서 다루는 것
- 두 선로 그림, 그리고 선로 사이를 오가는 연산은 무엇인가
- 실패 쪽 매핑, 그리고 오류 타입이 합성되려면 왜 그것이 필요한가
- 복구:
fold,getOrElse, 그리고 프로그램이 다시 전체가 되는 지점- 파이프라인 안의
Either—sequence,separate, 그리고 실제 임포트의 모양
성공 선로와 실패 선로를 나란히 그리세요. 실패할 수 있는 모든 단계는 분기입니다. 성공 선로를 계속 가거나, 아니면 한 번 실패 선로로 갈라져 나가 거기 머뭅니다.
그림 16-1. map은 초록 선로에서만 실행된다. flatMap이 분기다. 빨간 선로를 건드리는 것은 mapLeft뿐이고, 명시적인 fold 없이는 아무것도 다시 합류하지 않는다.
그 그림이 의미론의 전부입니다.
| 연산 | 초록 선로 (Right) | 빨간 선로 (Left) |
|---|---|---|
map(f) | f를 적용 | 그대로 통과 |
flatMap(f) | f를 적용, 갈라질 수 있음 | 그대로 통과 |
mapLeft(g) | 그대로 통과 | g를 적용 |
fold(l, r) | r을 적용 | l을 적용 |
import 'package:fxdart/fxdart.dart'; Either<String, int> parseQty(String s) { final n = int.tryParse(s); return n == null ? Either.left('not a number') : Either.right(n);} void main() { final ok = parseQty('12'); final bad = parseQty('twelve'); print([ok.map((n) => n * 2), bad.map((n) => n * 2)]); print(ok.mapLeft((e) => 'qty: $e')); print(bad.mapLeft((e) => 'qty: $e')); print(bad.fold((e) => 'failed — $e', (n) => 'got $n'));}빨간 선로에 올라선 값은 불활성입니다. 이후의 모든 map과 flatMap이 무연산입니다. 그것이 단락 평가이고, 하류 어디에도 특별한 지원이 필요 없습니다 — 그래서 나머지를 건드리지 않고 사슬 중간에 단계를 추가할 수 있습니다.
오류 타입이 다른 두 단계는 이어지지 않고, 실제 코드는 대개 여기서 멈춰 섭니다.
import 'package:fxdart/fxdart.dart'; class ParseError { const ParseError(this.input); final String input; @override String toString() => 'ParseError($input)';} class RangeError2 { const RangeError2(this.value); final int value; @override String toString() => 'RangeError2($value)';} // A common error type for the pipeline to speak.sealed class OrderError { const OrderError();} class BadInput extends OrderError { const BadInput(this.detail); final String detail; @override String toString() => 'BadInput($detail)';} Either<ParseError, int> parse(String s) { final n = int.tryParse(s); return n == null ? Either.left(ParseError(s)) : Either.right(n);} Either<RangeError2, int> inStock(int n) => n <= 5 ? Either.right(n) : Either.left(RangeError2(n)); void main() { // mapLeft lifts both into the pipeline's own error type. Either<OrderError, int> order(String raw) => either((r) { final n = r.bind( parse(raw).mapLeft((e) => BadInput('$e'))); final ok = r.bind( inStock(n).mapLeft((e) => BadInput('$e'))); return ok; }); print(order('3')); print(order('nine')); print(order('9'));}실패 타입을 국소적으로 만들어 주는 것이 mapLeft입니다. 각 모듈은 자기가 아는 오류를 올리고, 호출자가 경계에서 번역합니다. 그것이 없으면 프로그램의 모든 오류를 담은 신(神) enum 하나로 끝나는데, 그것은 Exception을 잡는 것의 타입 있는 오류 버전입니다.
sealed 오류 타입(3장)이 여기서 값을 합니다. 프로그램 꼭대기에서 OrderError에 대한 switch가 빠짐없으므로, 경우를 하나 추가하면 모든 핸들러에서 컴파일 오류가 납니다.
기찻길은 선로가 결국 호출자가 쓸 수 있는 무언가로 합류할 때에만 쓸모가 있습니다. 그 합류가 fold이고, 실패가 무엇을 뜻하는지 결정해야 하는 지점입니다.
import 'package:fxdart/fxdart.dart'; Either<String, int> configPort(String? raw) => raw == null ? Either.left('missing') : (int.tryParse(raw) == null ? Either.left('not a number: $raw') : Either.right(int.parse(raw))); void main() { // Substitute a default — the failure was recoverable. print(configPort(null).fold((_) => 8080, (n) => n)); // Keep the reason and report it — the failure was not. print(configPort('x') .fold((e) => 'config error: $e', (n) => '$n')); // Switch on it, exhaustively, when the type is sealed. final result = configPort('9000'); final message = switch (result) { Left(:final value) => 'no port ($value)', Right(:final value) => 'port $value', }; print(message);}끝맺음 셋, 규칙 하나. 프로그램은 fold에서 다시 전체(total)가 됩니다. 그 전까지 실패는 선로를 따라 흐르는 데이터이고, 그 뒤로는 결정이 내려진 것입니다. 그 지점을 최대한 늦게 — HTTP 핸들러, UI, CLI의 종료 코드까지 — 미루는 것이 이 장이 주는 가장 쓸모 있는 습관입니다.
실제 작업에는 행이 여럿이고, 9장의 순회가 이 기찻길을 값 하나 너머로 확장하는 방법입니다.
import 'package:fxdart/fxdart.dart'; Either<String, int> parseRow(String s) { final n = int.tryParse(s); return n == null ? Either.left('bad row: $s') : Either.right(n);} void main() { final rows = ['10', 'x', '30', 'y']; // All or nothing. print(fx(rows).map(parseRow).sequence()); // Everything that failed, and everything that did not. final (errors, values) = separateEither(rows.map(parseRow)); print('imported ${values.length}, rejected: $errors'); // Keep going, but report every reason at the end. print(fx(rows).map(parseRow).flattenOrAccumulate());}정책 셋, 파서 하나. 그 분리 — 행 단위 함수는 정책을 전혀 모르고, 파이프라인이 정책을 고른다 — 가 두 선로 모양이 규모에서 사 주는 것입니다.
🎓 철도 지향 프로그래밍, 그리고 그 비유가 새는 곳. 대부분의 사람이 이 그림을 만나는 곳은 Scott Wlaschin의 "railway-oriented programming" 강연이고, 좋은 그림입니다 — 다만 그것은
Either를 모나드적으로 쓴 이야기의 절반입니다. 이 그림에는 두 열차가 모두 탈선한 경우(6장의 누적)를 그릴 방법이 없고, 실패가 드문 탈선인 것처럼 암시하지만 대부분의 시스템에서 실패는 자기 논리를 가진 평범한 결과입니다. 순차 처리에는 이 그림을 쓰고, 독립적인 결과를 결합해야 할 때는 내려놓으세요.
호출자가 손쓸 수 있는 도메인 실패. 검증, 파싱, 권한 확인, 비즈니스 규칙, 그리고 그렇지 않았다면 nullable 반환에 주석을 붙여 표현했을 모든 것. 실패가 정보를 실어야 하는 곳에서 가장 값을 합니다 — 어느 규칙, 어느 필드, 어느 id인지.
아무도 손쓸 수 없는 실패에는(메모리 부족, 버그) 값을 하지 않고, 유일한 실패 방식이 "없음"인 호출 하나에도(A?가 더 작습니다), 아직 이름 붙일 수 없는 오류 타입에도 마찬가지입니다 — 문자열 보간으로 만든 Either<String, T>는 단계만 더 거친 문자열 예외입니다.
Left에 대한 map은 아무 일도 하지 않습니다. 어느 펑터 법칙이 그것을 강제하며, 라이브러리가 "친절하게" 함수를 실행해 버린다면 무엇이 깨지나요?fold로 Either의 getOrElse를 작성하세요. 그런 다음 대체 값 대신 대체 Either를 받는 orElse를 작성하세요.Either<A, T>가, 다른 모듈에서 Either<B, T>가 오고, 호출자는 Either<C, T>를 원합니다. mapLeft 세 번을 스케치하고, 계층형 애플리케이션에서 그것이 어디에 놓여야 하는지 말하세요.separateEither는 (errors, values)를 돌려줍니다. 왜 그 순서이고, 그 선택이 코드를 훑어볼 때 어떤 결과를 낳나요?left.map(id)는 반드시 left와 같아야 합니다. map이 실패 값에 f를 실행한다면 그 결과를 어딘가에 넣어야 하고 — Left의 타입이나 내용이 바뀌겠죠 — 그러면 항등을 map 하는 것이 더 이상 무연산이 아닙니다. 구체적으로 깨지는 것은 합성입니다. map(f).map(g)가 둘 중 어느 것도 위해 쓰이지 않은 오류에 두 함수를 적용하게 되고, 대개 성공 값을 가정한 코드 안에서 크래시합니다.T getOrElse<T>(Either<Object?, T> e, T fallback) => e.fold((_) => fallback, (v) => v); 그리고 Either<E, T> orElse<E, T>(Either<E, T> e, Either<E, T> other) => e.fold((_) => other, (_) => e);. 두 번째 것은 첫 성공을 지키는 Either 위의 반군이고, 항등 실패가 있다면 모노이드가 되겠지만 대개는 없습니다.moduleA().mapLeft(toC)와 moduleB().mapLeft(toC)를 두 모듈이 만나는 이음매에 둡니다 — 보통 유스케이스나 서비스 계층이지, 두 모듈 내부도 아니고 HTTP 경계도 아닙니다. 너무 일찍 번역하면 모듈이 호출자의 어휘에 묶이고, 너무 늦으면 신 enum이 됩니다.(Left, Right)와 맞습니다 — 타입 매개변수 순서, switch 갈래 순서와 같으므로 코드베이스의 누구도 "어느 게 먼저였지?"를 묻지 않습니다. 여기서는 어느 쪽이 더 중요한가에 대한 어떤 논쟁보다 일관성이 값어치가 큽니다. 순서를 한 번 확인해야 하는 독자는 매번 확인하게 됩니다.이 장에서 다루는 것
- 빨리 실패냐 천천히 실패냐를 결정하는 제품 질문
- 네 가지 도구, 그리고 어떤 모양에 어느 것이 맞는가
- 독립 규칙과 의존 규칙, 그리고 그 둘을 잘못 섞는 전형적 버그
- 원시 문자열에서 도메인 타입까지, 완전한 폼 검증 하나
6장에서 어플리커티브 모양만이 누적을 할 수 있다는 것을 확인했습니다. 이 장은 언제 해야 하는가에 관한 것이고, 그 기준은 타입과는 아무 상관이 없습니다.
사람이 이 오류들을 읽고 한 번에 고칠까?
그렇다면 — 폼, 설정 파일, 임포트, API 요청 본문 — 전부 모으세요. 우편번호가 틀렸다고 알려 주고, 왕복 한 번을 기다린 뒤, 이번엔 전화번호가 틀렸다고 알려 주는 것은 나쁜 프로그램이 아니라 나쁜 제품입니다.
아니라면 — 내부 단계의 사슬, 권한 확인, 두 번째 실패가 첫 번째의 귀결인 모든 것 — 빨리 실패하세요. 뿌리 하나에서 파생된 오류 열 개는 잡음이고, 정작 중요했던 하나를 감춥니다.
import 'package:fxdart/fxdart.dart'; Either<String, int> parseAge(String s) { final n = int.tryParse(s); return n == null ? Either.left('age: not a number') : Either.right(n);} void main() { final raw = ['31', 'x', '44', 'y']; // 1. zipOrAccumulate2..5 — a fixed set of independent branches. print(either<Nel<String>, String>((r) => r.zipOrAccumulate2( (br) { if (raw[1] != '0') br.raise('second must be 0'); return raw[1]; }, (br) { if (raw[3] != '0') br.raise('fourth must be 0'); return raw[3]; }, (a, b) => '$a/$b', ))); // 2. mapOrAccumulate — the same rule over many items. print(mapOrAccumulate( (r, String s) => r.bind(parseAge(s)), raw)); // 3. flattenOrAccumulate — you already have the Eithers. print(fx(raw).map(parseAge).flattenOrAccumulate()); // 4. accumulate — the general scope, any number of branches, // and the only one that supports dependent rules. print(either<Nel<String>, int>((r) => r.accumulate((acc) { final first = acc.accumulating( (br) => br.bind(parseAge(raw[0]))); final third = acc.accumulating( (br) => br.bind(parseAge(raw[2]))); return first.value + third.value; })));}고르는 일은 기계적입니다.
| 모양 | 도구 |
|---|---|
| 이름이 있는 독립 필드 2~5개 | zipOrAccumulate2..5 |
| 규칙 하나, 항목 여럿 | mapOrAccumulate |
이미 Either를 갖고 있음 | flattenOrAccumulate / .flattenOrAccumulate() |
| 가지가 다섯을 넘거나 의존 규칙이 있음 | accumulate |
누적을 옳게 만들어 주는 규칙은 6장의 구분이고, 그것에는 정확한 API 모양이 있습니다.
acc.accumulating(...) — 독립적인 가지. 언제나 실행되고, 그 실패는 전파되지 않고 기록됩니다.acc.dependent(...) — 의존적인 규칙. 아직 아무 실패도 없을 때만 실행됩니다. 다른 가지의 값을 읽기 때문입니다.실패한 가지의 Accumulated.value를 읽으면 일부러 폭발합니다. 누적된 목록 전체를 한 번에 raise 하죠. 그것이 마지막 return을 안전하게 만듭니다 — 값을 결합하는 시점이면 모든 가지가 성공했거나, 아니면 거기까지 오지도 못합니다.
그림 17-1. 독립적인 가지는 모두 실행되어 실패를 한 바구니에 떨어뜨린다. 의존 규칙은 그 바구니의 하류에 있다. 존재하지 않을 수도 있는 값을 읽으므로, 바구니가 비어 있을 때만 실행된다.
import 'package:fxdart/fxdart.dart'; class Signup { const Signup(this.email, this.age, this.plan); final String email; final int age; final String plan; @override String toString() => 'Signup($email, $age, $plan)';} Either<Nel<String>, Signup> validate(Map<String, String> form) => either((r) => r.accumulate((acc) { final email = acc.accumulating((br) { final v = form['email'] ?? ''; if (!v.contains('@')) { br.raise('email: must contain @'); } return v; }); final age = acc.accumulating((br) { final n = int.tryParse(form['age'] ?? ''); if (n == null) br.raise('age: not a number'); if (n != null && n < 18) br.raise('age: must be 18+'); return n ?? 0; }); final plan = acc.accumulating((br) { final v = form['plan'] ?? ''; if (v != 'free' && v != 'pro') { br.raise('plan: unknown "$v"'); } return v; }); // Dependent: only meaningful once age and plan parsed. acc.dependent((br) { if (plan.value == 'pro' && age.value < 21) { br.raise('plan: pro requires 21+'); } return null; }); return Signup(email.value, age.value, plan.value); })); void main() { print(validate( {'email': 'a@b.co', 'age': '30', 'plan': 'pro'})); print(validate( {'email': 'nope', 'age': 'x', 'plan': 'gold'})); print(validate( {'email': 'a@b.co', 'age': '19', 'plan': 'pro'}));}함수 하나에서 세 가지 모양의 답이 나옵니다. 값 하나, 독립적인 문제 전부, 그리고 필요한 값이 존재할 때만 입을 여는 의존 규칙.
둘째 경우가 세 필드에서 오류 셋을 보고하고, 셋째는 하나 — 의존 규칙 — 만 보고한다는 점을 보세요. 독립적인 가지들이 모두 통과했기 때문입니다. 사용자가 기대하는 동작이고, 이 장치가 존재하는 이유입니다.
age는 최대 둘을 올립니다..value를 읽지 마세요. 그러라고 dependent가 있으며, 일찍 읽으면 스코프 전체가 폭발합니다.dependent 또는 빨리 실패 스코프에 속하지 가지에 속하지 않습니다.🎓 왜
Validated타입이 없는가. Arrow 1.x에는 있었습니다 — 어플리커티브가 누적하는 별도의Validated<E, A>를, 모든 경계에서Either와 오가며 변환해야 했죠. Arrow 2.x는 그것을 지웠고 FxDart는 애초에 갖지 않았습니다. 같은 효과를Either<Nel<E>, A>위의 스코프로 얻을 수 있으니까요. 그 결과 도메인 시그니처에는 결과 타입이 둘이 아니라 하나만 있고, 코드 곳곳에 흩어진toEither()호출도 없습니다. 이론적으로 잃은 것은 없습니다 —Validated는 애초에 어플리커티브 인스턴스만 다른Either였고, 어차피 Dart는 타입으로 인스턴스를 고를 수 없으므로(10장) 호출 지점에서 동작에 이름을 붙이는 편이 엄밀히 더 정직합니다.
모든 종류의 사용자 입력. 한 번 더 돌리는 수고를 아껴 주는 배치 임포트. 프로세스가 종료하기 전에 빠진 키를 모두 보고해야 하는 설정. 위반 사항을 전부 나열하는 400 응답 하나가 하나씩 알려 주는 다섯 번보다 나은 API 페이로드.
실패를 다시 알아내는 것이 값쌀 때(빠른 로컬 재시도), 오류가 사람이 아니라 기계를 위한 것일 때(코드 하나면 충분합니다), 그리고 모든 가지를 실행하는 것이 비쌀 때는 비용이 듭니다 — 누적은 단락 평가가 없다는 뜻이므로, 첫째가 이미 실패했어도 느린 독립 검사 다섯이 전부 실행됩니다.
dependent에서 accumulating으로 옮기고 {'age': 'x', 'plan': 'pro'}의 출력을 예측하세요.mapOrAccumulate가 행 10,000개에 대해 모든 실패를 모읍니다. 그것의 메모리 모양은 어떻고, 1,000만 행 임포트라면 무엇을 다르게 하시겠어요?List<String>이 아니라 Nel<String>인가요? List가 허용하고 Nel이 금지하는 상태를 대세요.accumulating이어야 할까요 dependent여야 할까요? 두 가지가 같은 API를 부른다면 무엇이 달라지나요?age.value를 읽고 폭발합니다 — 스코프 끝이 아니라 가지 안에서 누적된 오류가 raise 되죠. 출력은 여전히 파싱 오류를 담은 Left이지만, 장치는 깔끔한 누적이 아니라 이른 탈출이고, 그 뒤에 실행되어야 했을 규칙은 건너뛰어집니다. dependent는 그것을 구조적으로 불가능하게 만들려고 존재합니다.List<String>은 Left([]) — "실패했는데 이유가 없음" — 을 허용하는데, 이는 정확히 3장이 다룬 종류의 표현 불가능해야 할 헛소리입니다. Nel은 그 보장을 구조로 만듭니다. 실패는 언제나 이유를 적어도 하나 싣고 있으므로, 어떤 소비자도 "오류가 없나?" 갈래를 둘 필요가 없습니다.accumulating 입니다 — 그 실패가 다른 것들과 나란히 보고되기를 원하니까요. 두 가지가 같은 API를 부르면 스코프 안에서 순차로 실행되어 두 번 값을 치릅니다. 호출을 스코프 위로 끌어올려 결과를 넘기고, 가지는 순수하게 유지하세요. 그러면 네트워크 없이 검증을 테스트할 수도 있는데, 그것은 2장의 논지가 다른 방향에서 다시 도착한 것입니다.이 장에서 다루는 것
- Dart의 세 가지 실패 통로와, 각각이 말할 수 있는 것과 없는 것
- 고르는 규칙: 호출자가 이것에 대해 무언가 할 수 있는가?
- 왜
Either는 프로그램을 예외 없는 것으로 만들지 못하며, 왜 그래도 괜찮은가- 가장자리에서의 변환, 양방향으로
Dart에서 함수는 세 가지 방식으로 실패할 수 있고, 그것들은 서로 바꿔 쓸 수 없습니다.
| 통로 | 타입이 말하는 것 | 호출자가 해야 하는 것 | 싣는 것 |
|---|---|---|---|
throw | 아무것도 | 아무것도 (검사되지 않음) | 임의의 객체 + 스택 트레이스 |
A? | 없을 수도 있음 | null 처리 | 이유 없음 |
Either<E, A> | E로 실패할 수 있음 | 양쪽 처리 | 타입 있는 이유 |
각각이 맞는 자리가 있습니다.
import 'package:fxdart/fxdart.dart'; // 1. Nullable: absence is the whole story.int? findIndexOf(List<String> xs, String needle) { final i = xs.indexOf(needle); return i == -1 ? null : i;} // 2. Either: the caller needs to know *why*.Either<String, int> parsePort(String s) { final n = int.tryParse(s); if (n == null) return Either.left('not a number: $s'); if (n < 1024) return Either.left('privileged: $n'); return Either.right(n);} // 3. Throw: the caller cannot act, and the program is broken.int divide(int a, int b) { if (b == 0) throw ArgumentError('b must not be zero'); return a ~/ b;} void main() { print(findIndexOf(['a', 'b'], 'z')); print(parsePort('80')); try { divide(1, 0); } catch (e) { print('threw: $e'); }}고르는 규칙은 질문 하나입니다. 호출자가 이 실패에 대해 구체적으로 무언가 할 수 있는가? 그렇다면 타입에 넣으세요 — 이유가 중요하면 Either, 아니면 A?. 아니라면 던지세요. 버그이거나, 깨진 불변식이거나, 그 호출 지점에서 아무도 복구할 수 없는 환경 실패입니다.
그림 18-1. 질문은 실패가 얼마나 나쁜가가 아니라 호출자에게 반응이 있는가이다. 호출자가 손쓸 수 있는 모든 것은 반환 타입에 속하고, 나머지는 예외 통로에 속한다. 거기라면 여기서 꼭대기까지의 모든 시그니처를 어지럽히지 않는다.
여기가 불편한 부분이고, 이 장이 존재하는 이유입니다.
시그니처의 Either<E, A>는 "이 함수는 E로만 실패한다"는 뜻이 아닙니다. Dart의 예외는 검사되지 않으므로 어떤 코드든 — 여러분의 코드, SDK, 의존성 — 언제든 던질 수 있습니다. Either를 돌려주는 함수도 여전히 StateError, RangeError, OutOfMemoryError, 또는 이행 의존성의 버그로 터질 수 있습니다.
그러니 정직한 진술은 더 좁고, 그래도 값어치가 큽니다.
Either<E, A>가 말하는 것: 이 함수가 모델링한 실패는E이고, 그것은 타입에 있다. 아무도 모델링하지 않은 실패에 대해서는 아무 말도 하지 않습니다.
전체 집합을 약속하는 대신 모든 것에 throws 절을 붙이는 값을 치르는 검사 예외 언어와 비교해 보세요. Dart는 검사되지 않는 쪽을 골랐고, 라이브러리가 그 선택을 되돌릴 수는 없습니다. FxDart가 더하는 것은 여러분이 생각해 둔 실패를 위한 통로이고, 버그는 실제로 거기서 나옵니다.
import 'package:fxdart/fxdart.dart'; Either<String, int> risky(String s) => either((r) { if (s.isEmpty) r.raise('empty'); // Not modelled, and not caught by the signature: return int.parse(s); // throws on 'abc' }); void main() { print(risky('')); try { print(risky('abc')); } catch (e) { print('escaped the Either: ${e.runtimeType}'); } // If you want throws folded into the failure channel, say so. print(eitherCatching<String, int>( (r) => int.parse('abc'), (e, _) => 'not a number'));}eitherCatching이 명시적인 변환이고, 명시적이라는 것이 설계입니다. 모든 던지기를 말없이 삼키면 진짜 버그가 도메인 실패로 바뀌고, 여러분은 프로덕션에서 Left('Bad state: no element')를 하나씩 받으며 알게 될 것입니다.
프로그램에는 바깥세상의 실패 방식이 여러분의 방식과 만나는 경계가 있습니다. 양방향 모두 한 줄이고, 양쪽 모두 흩어지지 않고 그 경계에 있어야 합니다.
import 'package:fxdart/fxdart.dart'; class Config { const Config(this.port); final int port; @override String toString() => 'Config($port)';} // Inbound: a throwing API becomes a typed failure.Either<String, Config> loadConfig(Map<String, String> env) => eitherCatching( (r) { final raw = env['PORT']; r.ensureNotNull(raw, () => 'PORT is not set'); return Config(int.parse(raw!)); }, (e, _) => 'PORT is not a number', ); // Outbound: a typed failure becomes the framework's exception.Config loadOrThrow(Map<String, String> env) => loadConfig(env).fold( (e) => throw StateError('bad config: $e'), (c) => c, ); void main() { print(loadConfig({'PORT': '8080'})); print(loadConfig({})); print(loadConfig({'PORT': 'abc'})); try { loadOrThrow({}); } catch (e) { print('at the edge: $e'); }}들어오는 변환은 여러분이 통제하지 않는 코드를 부르는 자리에서 일어납니다. 나가는 변환은 프레임워크가 던지기를 요구하는 자리 — Flutter의 build 메서드, 테스트 헬퍼, main — 에서 일어납니다. 그 사이에서 실패는 값입니다.
🎓 Error와 Exception, 그리고 Dart SDK 자신의 뜻. Dart의 관례는
Error(ArgumentError,StateError,RangeError)가 프로그래밍 실수를 알린다는 것입니다 — 호출자가 계약을 어겼으니 처리할 게 아니라 고쳐야 하죠. 반면Exception은 올바른 프로그램도 마주칠 수 있는 상황을 알립니다 (FormatException,IOException). 그것이 이 장에 그대로 대응됩니다.Error는 잡아서Left로 바꾸면 안 됩니다. 버그를 감추니까요.Exception은eitherCatching의 훌륭한 후보입니다. 라이브러리를 쓸 때 이 관례를 지키는 것이, 여러분의 호출자가 애초에 이 구분을 할 수 있게 해 주는 일입니다.
A?는 Dart가 가진 가장 값싼 실패 통로이고, 타입 있는 오류 애호가들이 인정하는 것보다 훨씬 자주 진짜로 옳은 답입니다 — 검사는 하나입니다. "없음"이 메시지의 전부인가? 맵 조회, 첫 일치 검색, 선택적 필드: 그렇습니다. 파싱, 검증, 권한 확인: 아닙니다. 호출자가 무엇이 잘못됐는지 말하고 싶어 할 테니까요.
FxDart의 nullable 스코프는 null 모양의 사슬도 같은 직선형 대접을 받도록 존재합니다.
import 'package:fxdart/fxdart.dart'; class User { const User(this.name, this.managerId); final String name; final String? managerId;} final users = <String, User>{ 'u1': User('Ada', 'u2'), 'u2': User('Grace', null),}; String? managerName(String id) => nullable((r) { final user = r.bind(users[id]); final managerId = r.bind(user.managerId); final manager = r.bind(users[managerId]); return manager.name; }); void main() { print(managerName('u1')); print(managerName('u2')); // no manager print(managerName('u9')); // no such user}없을 수 있는 방식이 셋, 나오는 null은 하나, ?.와 ??의 피라미드는 없음. 출력에서 빠진 것을 보세요. 셋 중 무엇이었는지 알 수 없습니다. 그것이 정확히 Either가 타입 매개변수 하나를 치르고 지키는 정보입니다.
이 장의 규칙은 새 함수를 쓸 때마다 값을 하고, 그래서 이 책에서 가장 자주 내리는 결정입니다. 이것을 제대로 하면 시그니처가 정직해지고 try 블록이 드물면서 의미 있는 것이 됩니다.
교조적으로 적용하면 값을 못 합니다. 모든 비공개 헬퍼에 Either<String, T>를 붙이는 것은 정보 없이 잡음만 더하고, main이 유일한 try인 코드베이스는 누군가에게 필요했던 스택 트레이스를 떨어뜨릴 코드베이스입니다. 경계에서 변환하고, 호출자가 손쓸 수 있는 것을 모델링하고, 진짜 버그는 요란하게 죽게 두세요.
A? / Either로 분류하세요. 파싱에 실패한 JSON, 없는 선택적 쿼리 파라미터, 여러분의 함수에 넘어온 음수 배열 길이, 결제사가 거절한 결제.int.parse는 던지고 int.tryParse는 null을 돌려줍니다. Either라면 무엇을 줬을 것이며, 무엇을 발명해야 했을까요?eitherCatching은 왜 either의 기본 동작이 아니라 별도 함수인가요? 다른 선택을 했다면 따라올 버그를 서술하세요.Either<E, A>를 돌려주면서 일부 입력에서는 던지기도 합니다. 그것을 어떻게 발견하고, 코드와 시그니처 중 무엇을 바꾸겠어요?Either, 호출자가 유효성만 보고 분기한다면 A?. 없는 선택적 파라미터: A? — 없음이 곧 메시지입니다. 음수 길이: throw ArgumentError — 호출자가 계약을 어겼고, 고칠 곳은 그쪽 코드입니다. 결제 거절: 타입 있는 이유를 실은 Either. 호출자가 사람에게 보여 주고 어쩌면 재시도해야 하니까요.Either<FormatException, int>를 줬을 것이고 — 오류 타입을 발명해야 했을 겁니다. 그것이 세 번째 통로의 값 전부입니다. 누군가 실패가 무엇인지 정하고, 이름 붙이고, 유지해야 합니다. tryParse는 "아니오"만 말함으로써 그것을 비껴가고, 그래서 더 흔히 쓰이는 호출입니다.Left로 접으면 버그를 도메인 실패로 세탁하기 때문입니다. 라이브러리 버그에서 온 StateError가 검증 오류로 도착하고, 호출자는 그것을 우편번호 칸 옆에 그리고, 스택 트레이스는 아무도 못 보게 됩니다. 명시성이란 그 변환이 이름을 가진 결정이라는 뜻입니다.parse, !, first, 리스트의 [])을 찾아 읽어서 발견합니다. 바꿀 것은 코드입니다. 던지는 호출을 eitherCatching으로 감싸 실패를 모델링하거나, 버그라면 의도적으로 전파시키세요. 하지 말아야 할 한 가지는 주석으로 적어 두고 시그니처는 거짓말하게 두는 것입니다.이 장에서 다루는 것
- 치환의 연쇄로서의 리팩터링, 각 단계마다 이름 붙은 법칙
- 실제 변환 하나: 다섯 단계를 둘로, 종이 위에서
- 법칙을 속성 테스트로 — 생성기 하나, 테스트 프레임워크 없음
- 이 방법 전체를 유효하게 하는 전제 조건들과, 그것이 무너지는 방식
2장은 참조 투명성을 정의했습니다. 호출을 결과로 바꿔 놓을 수 있다는 것. 그것의 더 큰 형제가 등식 추론(equational reasoning) 입니다. 어떤 식이든 그와 같은 식으로, 어디서든 바꿔 놓고, 프로그램이 그대로임을 아는 것.
2부의 모든 법칙이 그런 등식입니다.
| 법칙 | 등식 |
|---|---|
| 펑터 합성 | m.map(f).map(g) = m.map(g ∘ f) |
| 펑터 항등 | m.map(id) = m |
| 모나드 왼쪽 항등 | of(a).flatMap(f) = f(a) |
| 모나드 결합 | m.flatMap(f).flatMap(g) = m.flatMap((x) => f(x).flatMap(g)) |
| 모노이드 결합 | (a + b) + c = a + (b + c) |
왼쪽에서 오른쪽으로 읽으면 최적화이고, 오른쪽에서 왼쪽으로 읽으면 명료화입니다. 양방향 모두 합법이며, 그래서 이것들은 사실이 아니라 도구입니다.
아무도 변호하지 않을 코드에서 시작합니다.
import 'package:fxdart/fxdart.dart'; void main() { final source = [3, 8, 2, 9, 4]; // Before: five stages, two of them pointless. final before = fx(source) .map((n) => n) .map((n) => n * 2) .map((n) => n + 1) .filter((n) => n > 5) .fold(0, (a, b) => a + b); // After: two stages. Same value, by four substitutions. final after = fx(source) .map((n) => n * 2 + 1) .filter((n) => n > 5) .fold(0, (a, b) => a + b); print([before, after, before == after]);}네 단계와 각각의 허가증입니다.
map((n) => n)은 map(id)이므로 지웁니다. 펑터 항등.map(f).map(g) → map(g ∘ f), 즉 map((n) => n * 2 + 1). 펑터 합성.filter를 가로질러 옮긴 것은 없습니다. 술어가 매핑된 값을 읽기 때문이고, 그 재배치에는 우리에게 없는 전제 조건이 필요합니다.fold는 그대로입니다. int의 +는 결합적이고 항등원이 0이므로, 그 씨앗은 진짜로 모노이드의 empty입니다. 모노이드 법칙.두 가지를 눈여겨볼 만합니다. 첫째, 변환이 기계적입니다 — 영리함도, 믿기 위한 테스트도 필요 없습니다. 둘째, 3번이 부주의한 "단순화"가 버그를 넣었을 자리이고, 멈추라고 말해 주는 것이 법칙입니다.
그림 19-1. 화살표 하나하나가 이름을 가진 재작성이다. 법칙 이름을 댈 수 없다면 그것은 리팩터링이 아니라 다시 쓰고 기도하는 것이다.
법칙은 속성이고, 속성은 여러 입력에 대해 돌릴 수 있는 테스트입니다. 요점을 보이는 데 프레임워크는 필요 없습니다.
import 'package:fxdart/fxdart.dart'; // A tiny generator: deterministic, so a failure is reproducible.List<int> sample(int n, int seed) { final rnd = createSeededRandom(seed); return List.generate(n, (_) => (rnd() * 200).floor() - 100);} Either<String, int> half(int n) => n.isEven ? Either.right(n ~/ 2) : Either.left('odd: $n'); Either<String, int> dec(int n) => Either.right(n - 1); void main() { var checked = 0; var failures = 0; for (final x in sample(200, 42)) { final m = Either<String, int>.right(x); // functor identity if (m.map((v) => v) != m) failures++; // monad left identity if (Either<String, int>.right(x).flatMap(half) != half(x)) { failures++; } // monad right identity if (m.flatMap((v) => Either<String, int>.right(v)) != m) { failures++; } // associativity final lhs = m.flatMap(half).flatMap(dec); final rhs = m.flatMap((v) => half(v).flatMap(dec)); if (lhs != rhs) failures++; checked += 4; } print('$checked properties checked, $failures failures');}입력 이백 개, 법칙 넷, 출력 한 줄. 실제 스위트에서는 이것이 package:test 파일(축소가 필요하면 package:glados)이 되지만 모양은 그대로입니다. 입력을 생성하고, 등식을 단언하고, 매 커밋마다 돌린다.
이것을 할 이유는 FxDart의 Either가 틀렸을지도 모른다는 것이 아닙니다. 여러분의 타입에도 법칙이 있기 때문입니다 — 절대 음수가 되면 안 되는 Money, put 뒤의 get이 넣은 것을 돌려줘야 하는 Cache — 그리고 그것들은 똑같이 테스트할 수 있으며 찾을 버그는 훨씬 많습니다.
// A property test for a type of your own.class Money { const Money(this.cents); final int cents; Money operator +(Money other) => Money(cents + other.cents); static const zero = Money(0); @override bool operator ==(Object o) => o is Money && o.cents == cents; @override int get hashCode => cents;} void main() { final values = [0, 1, 99, 100, -50, 123456].map(Money.new).toList(); var bad = 0; for (final a in values) { // identity if (a + Money.zero != a) bad++; if (Money.zero + a != a) bad++; for (final b in values) { for (final c in values) { // associativity if ((a + b) + c != a + (b + c)) bad++; } } } print('monoid violations: $bad');}등식 추론은 같은 것이 진짜로 같을 때 통하고, 그것이 깨지는 방식은 정확히 셋입니다.
Either는 구조적, Future는 관찰적, Set은 집합 상등. 한 등호에서 성립하는 법칙이 다른 등호에서는 깨질 수 있고, 1장의 연습문제가 그것을 구체적으로 보여 줬습니다.Counted와 1장의 Logged 둘 다 적법해 보이는 map/flatMap을 갖고도 법칙을 어겼습니다. 이름을 읽는 것만으로는 부족합니다. 법칙은 누군가 확인했어야 하는 주장입니다.세 번째 때문에 이 장의 테스트가 학술적이지 않습니다. 확인하지 않은 법칙은 주석입니다.
🎓 이 방법이 어디까지 가는가. 전(total)이고 순수한 언어에서는 이 방법이 증명까지 이어집니다. 하스켈의
foldr/build융합, Coq의 추출, GHC의 재작성 규칙은 모두 기계가 여러분을 대신해 수행하는 등식 추론입니다. Dart는 전 함수 언어도 순수 언어도 아니므로, 이 방법은 사람의 도구에 테스트를 더한 것으로 남습니다. 그것은 종류가 아니라 강도의 차이입니다. 같은 등식을, 증명이 아니라 표본으로 확인하는 것이고, 속성 테스트가 증명과 맺는 관계는 어디서나 그렇습니다.
파이프라인을 단순화하거나, 헬퍼를 뽑아내거나, 성능을 위해 두 단계를 융합할 때마다 — 법칙 이름을 대든 안 대든 그것이 이 장의 방법입니다. 이름을 대는 것이 "같은 것 같은데"를 "같고, 이유는 이것이다"로 바꿔 줍니다.
리뷰에서 가장 값을 합니다. "어떤 법칙이 그 filter를 map 앞으로 옮겨도 된다고 말하나요?"는 답이 있거나, 버그를 찾아낸 질문입니다.
기댈 법칙이 없는 코드에서 격식으로는 값을 못 합니다. 명령형 초기화, IO 순서 잡기, UI 콜백. 거기서 추론은 상태와 순서에 관한 것이고, 등식은 할 말이 없습니다.
fx(xs).filter(p).map(f)와 fx(xs).map(f).filter(p)는 같은가요? 전제 조건을 정확히 서술한 뒤, 그것을 깨뜨리는 p와 f를 드세요.xs.map(f).toList().map(g).toList() → xs.map((x) => g(f(x))).toList() 를 단계별로 정당화하세요. 어느 단계가 비용도 바꾸나요?map과 flatMap이 일치함을 검사하세요. m.map(f) == m.flatMap((x) => Either.right(f(x))). 어느 법칙이 이것을 모든 적법한 모나드에 대해 참으로 만드나요?Cache는 put 뒤의 get이 넣은 값을 돌려줍니다. 그것을 등식으로 쓰고, 그 등식이 put의 반환 타입에 대해 무엇을 함의하는지 말하세요.p가 매핑되지 않은 값에 대한 술어일 때 — 즉 맞바꾼 뒤의 버전이 같은 것을 검사할 때 — 만 성립합니다. f = (n) => n * 2, p = (n) => n > 5로 깨뜨릴 수 있습니다. 먼저 거르면 6, 7, 8…이 남고, 먼저 매핑하면 3, 4…의 두 배가 남습니다. p가 다른 종류의 값을 위해 쓰였기 때문에 두 답이 다릅니다.toList()를 지우고(재료화이지 의미 단계가 아닙니다), 펑터 합성으로 두 map을 융합하고, 마지막 toList()는 남깁니다. 비용이 바뀌는 것은 첫 단계입니다. 중간 리스트 하나가 사라지는데, 그것이 14장의 할당 장치가 리팩터링에 나타난 모습입니다.flatMap으로 map을 정의한 것에 왼쪽 항등을 더한 것입니다. Right(a)에 flatMap((x) => of(f(x)))를 적용하면 of(f(a)), 즉 Right(f(a))이고, 그것이 map(f)입니다. 모든 적법한 모나드가 이것을 만족하며, 그래서 "모든 모나드는 펑터다"가 관습이 아니라 정리입니다.cache.put(k, v).get(k) == v — 그리고 이 등식은 put이 캐시를 돌려줄 때에만 타입 검사를 통과한다는 점을 보세요. void put이면 변경과 순서를 이야기하지 않고는 이 속성을 서술할 수조차 없고, 그것이 불변 API가 테스트하기 쉬운 이유와 같습니다. 등식에는 양변에 값이 필요합니다.이 장에서 다루는 것
- 범주란 무엇인가, 네 줄로, Dart를 예로 들어
- 함자와 자연변환, 그리고 어떤 Dart 코드가 어느 쪽인가
- 원래 형태의 모나드 정의, 그리고 그것이
flatMap에 대응되는 방식- 그 유명한 문장의 해독 — 그리고 왜 그것이 필요하지 않았는가
이 장은 건너뛰어도 됩니다. 여기서 이름 붙이는 모든 것을 여러분은 이미 써 봤고, 이 장의 어떤 내용도 Dart 쓰는 방식을 바꾸지 않습니다. 여러 부분을 잇는 지도를 원하거나, 논문의 표기법에 튕겨 나가지 않고 읽고 싶다면 읽으세요.
범주(category) 란,
f : A → B와 g : B → C가 주어지면 화살표 g ∘ f : A → C;id_A : A → A.여기에 법칙 둘이 붙습니다. 합성은 결합적이고, 항등은 중립적이다.
정의는 이게 전부이고, Dart가 그 한 예입니다. 대상은 타입, 사상은 함수, 합성은 4장이 compose2로 쓴 것, 항등은 (x) => x입니다. 범주의 두 법칙은 4장이 격식 없이 기대고 있던 두 사실입니다.
그림 20-1. 범주란 합성되는 화살표들이다. 정의 어디에도 "원소"라는 말이 없다 — 그래서 같은 이론이 타입도, 집합도, 공간도, 순서도 덮는다.
사람들이 걸려 넘어지는 지점은 범주가 대상이 무엇으로 이루어졌는지를 잊는다는 것입니다. 여기서 int는 수의 집합이 아니라 화살표가 뻗어 나오는 점입니다. 그러므로 이 분야의 모든 정리는 합성의 모양에 관한 진술이고, 그래서 애초에 프로그래밍으로 옮겨 올 수 있습니다.
두 범주 사이의 함자(functor) F는 대상을 대상으로, 화살표를 화살표로 보내면서 항등과 합성을 보존합니다.
F(id_A) = id_F(A)F(g ∘ f) = F(g) ∘ F(f)
이것이 정확히 5장의 두 법칙입니다. 프로그래밍에서는 자기함자(endofunctor)만 씁니다. F는 Dart 타입의 범주를 자기 자신으로 보냅니다. List는 대상 int를 대상 List<int>로 보내고, 화살표 int → String을 화살표 List<int> → List<String>으로 보냅니다 — 후자가 map입니다.
그러므로 map은 함자의 화살표 쪽 절반이고, 그것이 구조를 바꾸면 안 되는 이유는 함자가 애초에 구조를 보존하는 것으로 정의되기 때문입니다.
두 함자 F와 G가 주어졌을 때, 자연변환(natural transformation) α : F ⇒ G는 타입마다 하나씩 있는 화살표들의 족 α_A : F(A) → G(A)이며 다음을 만족합니다.
α_B ∘ F(f) = G(f) ∘ α_A
Dart로 말하면, 내용물을 건드리지 않고 컨테이너만 바꾸며 map과 교환되는 제네릭 함수입니다. 여러분은 이미 여럿 써 봤습니다.
import 'package:fxdart/fxdart.dart'; // A natural transformation: Either<E, _> ⇒ Option-ish (_?)A? toNullable<E, A>(Either<E, A> e) => e.fold((_) => null, (a) => a); void main() { int f(int n) => n * 3; final r = Either<String, int>.right(7); final l = Either<String, int>.left('nope'); // naturality: map then transform == transform then map print([toNullable(r.map(f)), toNullable(r)?.let(f)]); print([toNullable(l.map(f)), toNullable(l)?.let(f)]);} extension Let<T> on T { R let<R>(R Function(T) f) => f(this);}두 경우 모두 양변이 일치합니다 — 그것이 자연성이고, "이 변환은 값을 들여다보지 않는다"의 형식적 진술입니다. toList(), toAsync(), first, sequence, flatten 모두 자연변환이며, 그래서 어느 것도 놀라울 수 없습니다. 옮기고 있는 내용물에 의존할 수가 없으니까요.
범주 C 위의 모나드는 세 쌍 (T, η, μ) 입니다.
T — 자기함자;η : Id ⇒ T — 자연변환, "단위(unit)";μ : T² ⇒ T — 자연변환, "곱셈(multiplication)" 또는 "결합(join)";여기에 세 정합성 조건이 붙습니다.
μ ∘ T(μ) = μ ∘ μ_T (associativity)μ ∘ T(η) = id = μ ∘ η_T (unit, both sides)
번역하면,
| 범주론 | Dart |
|---|---|
T | 타입 생성자 — Either<E, _>, List, Future |
η (단위) | of / Either.right / [x] / Future.value |
μ (결합) | flatten — List<List<A>> → List<A> |
flatMap(f) | μ ∘ T(f) — map 한 다음 flatten |
| 정합성 조건 | 1장의 세 법칙 |
import 'package:fxdart/fxdart.dart'; void main() { // μ: T² ⇒ T. Dart spells it `flat` / `expand(id)`. final nested = [ [1, 2], [3], [4, 5] ]; print(fx(nested).flat().toList()); // flatMap = μ ∘ T(f): map to a nested structure, then join. int f(int n) => n; final viaMapThenJoin = fx([1, 2, 3]).map((n) => [n, n * 10]).flat().toList(); final viaFlatMap = fx([1, 2, 3]).flatMap((n) => [n, n * 10]).toList(); print([viaMapThenJoin, viaFlatMap, f(1)]);}두 정의 — flatMap이냐 map + join이냐 — 는 서로 바꿔 쓸 수 있고, 그래서 어떤 언어는 이것을 주고 어떤 언어는 저것을 주며, 1장이 μ를 한 번도 언급하지 않고도 모나드를 정의할 수 있었던 것입니다.
모나드는 자기함자 범주 위의 모노이드다.
이제 조각이 다 모였습니다.
List, Future 같은 함자이고 화살표는 그것들 사이의 자연변환입니다.F 다음 G) 이고, 곱셈 역할을 합니다.μ : T ∘ T ⇒ T(결합)와 η : Id ⇒ T(단위)를 갖고 결합·항등 법칙을 지키는 대상 T입니다 — 8장의 두 법칙을 한 층 위에서 말한 것이죠.그것이 정확히 위의 정의입니다. 이 문장은 참이고, 정확하며, 첫 설명으로는 쓸모없습니다. 특수한 경우를 일반적인 경우를 가리켜 정의하는 것이니까요. 수학에는 옳은 순서이고, 배우는 데는 틀린 순서입니다.
🎓 이론이 사 주는 것, 정직하게. 코드는 아닙니다 — 이 책의 모든 구성물을 그것 없이 써 왔으니까요. 사 주는 것은 전이입니다. 같은 정리가 파서, 확률 분포, 빌드 시스템, 상태 기계에 그대로 적용되므로 한 번 증명한 결과를 어디서나 쓸 수 있습니다. 그리고 두 사람이 생산적으로 이견을 나눌 만큼 정밀한 어휘를 사 줍니다. 더 나아가고 싶다면 다음으로 쓸모 있는 대상은 수반(adjunction) (
flatMap과map이 왜 쌍으로 오는지 설명합니다)과 자유 모나드(인터프리터를 설명합니다)입니다. 1부에서 4부까지의 어떤 것에도 필요하지 않습니다.
읽을 때입니다. 논문, 하스켈 라이브러리, 스칼라 Cats, 그리고 누군가 "그건 그냥 자연변환이잖아"라고 말하는 모든 대화 — 이 장이 그것들의 해독기입니다.
이름 붙일 때도 그렇습니다. "이 변환은 자연적이다"라고 말할 수 있게 되면, 문서 주석 아무리 길게 써도 못 하는 방식으로 설계 규칙("내용물을 들여다봐서는 안 된다")을 정확히 진술할 수 있습니다.
코드 리뷰에서, 커밋 메시지에서, 이 장을 읽지 않은 동료와의 대화에서는 값을 하지 않습니다. 이 어휘는 생각하기 위한 도구이고, 그것을 자격증처럼 쓰는 것이 이 분야가 악명을 얻은 방식입니다.
compose2에 대해 무엇을 가정하고 있나요?List.reversed는 List에서 List로 가는 자연변환인가요? f = (n) => n * 2로 자연성을 확인한 뒤, 순서를 바꾸는데도 왜 자연적인지 말하세요.first는 List<A>를 A?로 보냅니다. 자연적인가요? List<A>를 List<A>로 보내는 sortBy는요?Either<E, _>에 대해 flatMap을 μ ∘ T(f)로 풀어 쓰세요. Either의 μ는 구체적으로 무엇인가요?compose2(compose2(f, g), h)와 compose2(f, compose2(g, h)) 둘 다 h(g(f(x)))를 부릅니다. 항등: compose2(id, f)와 compose2(f, id) 둘 다 f(x)를 부릅니다. 여러분이 가정하는 것은 compose2가 오직 적용만 한다는 것입니다 — 로깅도, 메모이제이션도, 그 밖의 무엇도 없이. 순수하지 않은 compose2는 범주 법칙을 깨뜨리는데, 2장이 치환에 대해 한 관찰과 같습니다.xs.map(f).reversed와 xs.reversed.map(f)는 같은 리스트를 줍니다. reversed는 값을 참고하지 않고 위치만 재배열하니까요. 자연적이라는 것은 순서를 보존한다는 뜻이 아니라 내용물과 무관하다는 뜻이고, 순열도 거기에 해당합니다.first는 자연적입니다. 비어 있지 않을 때 xs.map(f).first는 f(xs.first)와 같고, 비어 있으면 양쪽 다 없음입니다. sortBy는 자연적이지 않습니다. 순서를 정하려고 값을 들여다보므로 xs.map(f).sortBy(k)와 xs.sortBy(k).map(f)가 일반적으로 다릅니다. 자연성을 재는 가장 명확한 한 줄짜리 검사가 그것입니다. 내용물을 들여다보는가?μ : Either<E, Either<E, A>> → Either<E, A> 가 두 겹을 무너뜨립니다. Left(e)는 Left(e)로, Right(Left(e))는 Left(e)로, Right(Right(a))는 Right(a)로. 그다음 flatMap(f)는 map(f) 뒤에 그 무너뜨리기를 붙인 것이고, 구현이 두 수준을 패턴 매칭할 때 하는 일이 정확히 그것입니다.이 장에서 다루는 것
- 이 책의 각 아이디어가 어디서 발명됐고 거기서 어떤 문제를 풀었는가
- 네 번의 번역, 그리고 각각이 잃은 구체적인 것
- FxDart의 API가 왜 이렇게 생겼는가 — FxTS의 이름, Arrow의 오류
- 조상들의 문서를 읽을 때 각각에서 무엇을 가져올 것인가
| 도입/대중화 | 번역에서 잃은 것 | |
|---|---|---|
| 하스켈 (1990) | 타입클래스, 인터페이스로서의 모나드, do | 없음 — 원천이다. 대가는 언어 그 자체 |
| 스칼라 (2004) | OO 언어의 모나드, for 컴프리헨션, Cats | implicit 해결의 복잡도, 모든 것에 두 가지 문법 |
| 코틀린 + Arrow (2017) | 고차 타입 없는 타입 있는 오류, Raise, 컨텍스트 리시버 | 효과에 대한 제네릭 추상 — Arrow 2에서 삭제 |
| FxTS (2021) | 지연 파이프라인, concurrent(n), TypeScript에서 | 계약으로서의 법칙, TS 타입은 런타임에 지워짐 |
| FxDart (2025) | FxTS 모델 + Arrow의 오류, Dart에서 | 커리된 pipe, 그리고 모든 고차 타입 추상 |
오른쪽 열을 한 문장으로 읽으세요. 각 번역은 모양을 지키고, 새 언어가 실을 수 없는 것은 무엇이든 버렸다.
그림 21-1. 아이디어는 옮겨 가고 장치는 옮겨 가지 않는다. 화살표 하나하나가 어휘는 보존하고 기계 장치는 새 언어가 가진 것으로 다시 구현한 이식이다.
모나드가 하스켈에 도입된 것은 특정한 문제를 풀기 위해서였습니다 — 순수한 언어가 어떻게 IO를 할 것인가 — 그리고 답은 효과를 공통 인터페이스를 가진 값으로 만드는 것이었습니다. 그 인터페이스가 타입클래스이고, 그래서 2부의 모든 장이 그 모양입니다. 타입 생성자 하나, 연산 두엇, 그리고 인스턴스가 지켜야 할 법칙들.
어디서나 살아남은 하스켈의 기여는 이것입니다. 법칙이 곧 계약이다. 결합을 어기는 Monad 인스턴스는 변종이 아니라 버그입니다. 이 계보의 모든 라이브러리가, 강제할 수 없을 때에도 그 기준을 물려받았습니다.
번역되지 않는 것은 기본 지연 평가, 컴파일러가 강제하는 순수함, 타입클래스 해결입니다. 아이디어를 얻으려 하스켈을 읽는 것은 가치 있지만, 그 시그니처를 Dart에 베껴 오는 것은 아닙니다.
스칼라는 이 어휘가 서브타이핑과 메서드가 있는 언어에서도 통한다는 것을 보였습니다. 자유 함수가 아니라 메서드로서의 flatMap, 문법 설탕 해제로서의 for, 그리고 (Cats에서) implicit과 고차 타입으로 재현한 타입클래스 탑 전체.
동시에 후대 설계자들을 조심스럽게 만든 실패 방식도 보여 줬습니다. EitherT[Future, E, A] 같은 것들이죠. 모나드 트랜스포머는 "모나드는 합성되지 않는다"에 대한 일반적인 답이고, 실무에서는 층마다 리프트가 붙고 신참은 도저히 읽을 수 없는 오류 메시지를 낳습니다. Arrow와 FxDart가 왜 이 길을 거부했는지는 7장의 깊이 상자에 적혀 있습니다.
Arrow 1.x는 코틀린의 Cats가 되려 했고, 10장이 보여 준 Kind 인코딩까지 포함했습니다. Arrow 2.x는 그중 거의 전부를 지우고 하나의 아이디어를 중심으로 다시 지었습니다. 비국소 탈출이 있는 스코프.
either { val x = parse(raw).bind(); ... } // Kotlin, Arrow 2either((r) { final x = r.bind(parse(raw)); ... }) // Dart, FxDart
그것이 15장의 직계 조상이고, FxDart의 타입 있는 오류 API가 새 이름을 만들지 않고 Arrow의 어휘 — Raise, bind, ensure, accumulate, NonEmptyList, zipOrAccumulate — 를 쓰는 이유입니다. Arrow의 문서가 누적에 관한 미묘함을 설명할 때, 그것은 여기에도 그대로 적용됩니다.
코틀린에는 Dart에 없는 것이 하나 있습니다. 컨텍스트 리시버 덕분에 bind()가 스코프 안에서 암묵적으로 쓸 수 있는 확장이 됩니다. Dart는 명시적인 r. 접두사가 필요합니다. 그것은 실제 편의성 손실이고, FxDart의 스코프가 "스코프 우선 설계"인 이유이기도 합니다. r.을 치면 에디터가 어휘 전체를 보여 줍니다.
1부와 3부의 모든 것은 다른 한쪽 부모에게서 왔습니다. FxTS가 가져온 것은,
concurrent(n)과 그 백채널 — 13장의 장치이고, 거기서 발명됐습니다;이식할 수 없었던 것은 4장의 주제입니다. FxTS의 커리된 pipe에는 가변 제네릭이 필요한데, TypeScript는 오버로드로 흉내 내고 Dart는 흉내조차 못 냅니다. FxDart의 타입 붙은 체인이 그 대체물이고, WHY_CURRIED.md가 그 결정의 기록입니다 — 이식이 어긋난 지점을 없는 척하지 않고 문서화한 예로 읽을 가치가 있습니다.
import 'package:fxdart/fxdart.dart'; Either<String, int> parse(String s) { final n = int.tryParse(s); return n == null ? Either.left('bad: $s') : Either.right(n);} void main() async { // FxTS ancestry: lazy chain, bounded concurrency. final ports = await fx(['8080', '9000', '7000']) .toAsync() .mapConcurrent(2, (s) async => s) .toList(); // Arrow ancestry: typed failures, scope, accumulation. final parsed = fx(ports).map(parse).flattenOrAccumulate(); print(parsed); print(fx(['1', 'x']).map(parse).flattenOrAccumulate());}두 계보, 한 라이브러리, 그리고 그 사이의 이음매는 의도된 것입니다. 파이프라인 절반은 Either를 결코 언급하지 않고, 오류 절반은 지연 평가를 결코 언급하지 않습니다. 둘이 만나는 곳은 9장의 순회뿐입니다.
🎓 이 모두보다 오래된 아이디어들. 모나드는 Eugenio Moggi의 1989년 프로그램 범주적 의미론 연구를 통해 컴퓨팅에 들어왔고, Philip Wadler의 논문들이 그것을 프로그래밍 기법으로 바꿨습니다.
NonEmptyList, 어플리커티브 검증, "기찻길" 그림은 같은 시기 ML과 하스켈 실무에서 왔습니다. 제한된 연속 — 15장의 장치 — 은 더 오래되어 1980년대 Scheme에서 왔습니다. 이 책의 거의 어떤 것도 최근 10년에 발명되지 않았습니다. 달라진 것은 주류 언어들이 이 아이디어를 실을 만큼 타입 시스템을 갖추게 됐다는 것이고, 그래서 같은 묶음이 코틀린, TypeScript, 스위프트, Dart에 몇 년 간격으로 도착했습니다.
막혔을 때입니다. 이 어휘에 대해 물을 수 있는 거의 모든 질문은 네 조상 커뮤니티 중 하나에서 길게 답변돼 있고, 어디를 검색할지 아는 것이 일의 대부분입니다.
예방주사로서도 값을 합니다. 같은 아이디어에 각 언어가 서로 다른 값을 치렀다는 것을 보면, 그 아이디어가 어떤 문법의 소유물도 아니라는 것과 — Dart 이식이 하스켈 기능을 거부한 것이 대개 부족함이 아니라 설계 결정이라는 것이 분명해집니다.
Kind 인코딩과 Validated 타입을 지웠습니다. 각 삭제가 무엇을 치르고 무엇을 샀나요?pipe는 값 하나와 커리된 연산자 목록을 받고, FxDart의 체인은 메서드입니다. FxTS가 표현할 수 있고 FxDart가 못 하는 것 하나, 그리고 FxDart가 얻고 FxTS는 못 얻는 것 하나를 대세요.Future + Either를 EitherT로 풀고, FxDart는 eitherAsync로 풉니다. 세 번째 효과로 일반화되는 쪽은 어디이고, 다른 쪽은 대신 무엇을 하나요?Kind 삭제는 효과에 대한 제네릭 추상을 치렀습니다 — 단일 traverse도, 공유 조합자도 없어졌죠 — 그리고 읽을 수 있는 타입과 오류 메시지, 그리고 신참이 인코딩을 배우지 않고도 쓸 수 있는 API를 샀습니다. Validated 삭제는 전용 누적 타입을 치르고 모든 시그니처에 결과 타입 하나를 샀습니다. 누적 동작은 스코프로 옮겨 갔고, 호출 지점에서 엄밀히 더 명시적입니다.map(f)를 값으로 붙들고 넘길 수 있습니다 — 연산자가 일급이므로 단계 목록으로 파이프라인을 동적으로 조립할 수 있죠. FxDart는 콜백 안까지 추론이 들어가는, 사슬 전체에 걸친 완전한 정적 타입을 얻는데, FxTS의 pipe는 손으로 쓴 오버로드의 벽으로만 그것을 흉내 냅니다.EitherT가 일반화됩니다. 모나드마다 래퍼 하나이므로 세 번째 효과는 스택에 트랜스포머 하나를 더하는 것입니다(대가는 도처의 리프트). eitherAsync는 일반화되지 않습니다 — FxDart는 쓸모 있는 조합을 손으로 하나씩 쓰고, 사람들이 실제로 쓰는 조합은 몇 개 되지 않기 때문에 그걸로 충분합니다.이 장에서 다루는 것
- 명령형 버전이 그냥 더 나은 다섯 가지 모양
- README에 아무도 적지 않는 비용: 읽기, 디버깅, 채용
- 파이프라인을 쓰기 전에 적용할 체크리스트
- 나머지를 다 버려도 남겨 둘 것
이 책의 모든 것은 값이 붙은 도구입니다. 스물한 개 장이 도구를 옹호했으니, 이 장은 값을 매깁니다. 반대 논증을 할 수 없는 기법은 공학적 선택이 아니라 믿음이기 때문입니다.
1. 뜨겁고, 균일하고, 전부 소비됨. 14장의 지는 모양입니다. 값싼 단계가 여럿, 모든 원소가 쓰이고, 계속 도는 경로. 원소당 간접성은 순수 오버헤드이고 그것을 벌충할 거부한 일이 없습니다.
void main() { final xs = List.generate(8, (i) => i); // Sometimes this is just the right code. var sum = 0; for (final x in xs) { if (x.isOdd) sum += x * x; } print(sum);}2. 인덱스 산술. 보폭이 불규칙한 슬라이딩 비교, 제자리 변환, 위치로 정의된 알고리즘(이진 탐색, 투 포인터, 동적 계획법 표). 파이프라인 어휘는 값의 수열을 서술합니다. 알고리즘이 버퍼 안의 위치에 관한 것일 때, 그것을 번역하면 명료함을 잃고 얻는 것이 없습니다.
3. 단계가 하나. 원소 다섯 개짜리 리스트에 대한 map 하나는 list.map(f).toList() 입니다. fx(...)로 감싸면 배울 이름 하나와 설명할 타입 하나가 늘고 이득은 0입니다. 실패 가능 호출 하나도 마찬가지입니다. null을 돌려주는 int.tryParse로 이야기가 끝나는데, 그것을 Either<String, int>로 만들면 아무도 읽지 않을 오류 메시지를 발명하게 됩니다.
4. 진짜로 명령형인 작업. 버퍼 만들기, 상태 기계 돌리기, 소켓에 바이트 쓰기, 마이그레이션 스크립트 지휘하기. 이것들은 효과의 수열이고, 효과를 가장자리로 밀라는 2장의 조언은 그 가장자리가 존재하며 그것답게 보여야 한다는 뜻입니다.
5. 푸시 모양의 문제. 12장의 규칙입니다. UI 이벤트, 소켓, 타이머, 그리고 여러 소비자가 같은 이벤트를 봐야 하는 모든 것. Stream(또는 fxEvents 계층)을 쓰고 당기려 들지 마세요.
읽기 비용은 실재하고 고르게 분포하지 않습니다. 열 단계짜리 사슬은 그것이 대체한 루프보다 조밀합니다. 조밀함은 독자가 어휘를 알 때 좋고 모를 때 나쁘며, 어느 쪽인지를 정하는 변수는 여러분의 팀입니다.
디버깅이 더 나쁩니다. 지연 파이프라인 안의 스택 트레이스는 여러분의 단계 이름이 아니라 이터레이터 프레임을 보여 줍니다. 콜백의 중단점은 다른 단계와 뒤섞여 걸립니다(5장의 융합이 설계대로 동작하는 것이죠). print 디버깅에는 peek이 필요합니다. 어느 것도 치명적이지 않고, 전부 루프를 한 줄씩 밟는 것보다 느립니다.
추상이 문제보다 커질 수 있습니다. 실패 방식은 영리한 사슬 하나가 아니라, 세 줄짜리 함수를 따라가려고 독자가 타입클래스 이름 넷을 머리에 이고 있어야 하는 코드베이스입니다. 추상에 이름 붙이는 데 걸리는 시간이 그것이 아껴 주는 코드보다 길다면, 그 추상은 진 것입니다.
팀 비용은 복리로 붙습니다. 여기 나온 모든 구성물은 가르쳐야 할 대상입니다. 어느 Dart 개발자나 아는 map/filter/fold는 괜찮고, accumulate, traverse, Raise 스코프는 진짜 투자입니다. 값을 하는 곳에 쓰고, 아무 데나 쓰지 마세요.
그림 22-1. 파이프라인을 쓰기 전에 던지는 네 질문. "아니오" 셋이면 루프가 옳은 답이다 — 그것은 정상적인 결과이지 용기의 부족이 아니다.
파이프라인 어휘에 손을 뻗기 전에,
take, first, 선택적인 filter, 이른 탈출. 있다면 지연 평가가 값을 하고 있습니다(11장).concurrent(n) 하나만으로 사슬 값어치를 합니다(13장).셋 또는 넷이 "예"라면 도구를 쓰세요. 하나면 그 부분에만 쓰세요. 0이면 루프를 쓰고, 사과하지 마세요.
FxDart를 다시 쓰지 않더라도 이 책에서 넷은 살아남습니다.
sealed와 레코드이고, 나머지 전부를 합친 것보다 많은 버그를 막습니다.A?, 호출자가 손쓸 수 있는 모든 것은 타입 있는 값으로.이 넷은 스타일과 무관합니다. 나머지는 도구 상자이고, 도구 상자는 일마다 고르는 것입니다.
🎓 반대 논증의 가장 강한 형태. 그것은 "FP는 느리다"도 아니고(14장이 측정했습니다. 대개 무승부입니다), "어렵다"도 아닙니다(어휘는 열 몇 단어입니다). 그것은 국소성입니다. 명령형 루프는 독자에게 필요한 모든 것을 연속된 여덟 줄 안에 놓지만, 파이프라인은 행동을 콜백과 법칙과 독자가 이미 알아야 하는 라이브러리 의미론에 흩어 놓습니다. 추상은 국소적 명료함을 전역적 구조와 맞바꿉니다. 코드베이스가 얻을 전역 구조가 별로 없을 때 — 스크립트, 일회성, 작은 도구 — 그 거래는 그냥 나쁘고, 아무리 우아해도 산수는 바뀌지 않습니다.
더 짧거나 더 안전해서가 아니라 세련돼 보여서 파이프라인을 쓰려는 자신을 발견할 때마다. 체크리스트는 10초가 걸리고, 여러분이 돌릴 수 있는 가장 값싼 코드 리뷰입니다.
for로 충분했을 작은 고정 리스트에 대한 map이고, 그것을 다시 쓰는 것은 작지만 진짜인 개선이지 패배가 아닙니다.for (final x in xs) if (x.isEven) sum += x; 대 사슬에 씨앗 있는 fold. 새벽 두 시의 디버깅은 모든 값이 지역 변수로 보이는 쪽을 편들고 — 이는 양보가 아니라 진짜 논거입니다.take/first를 썼죠. 여러분의 핫 패스가 만들어 내는 것을 전부 소비한다면 그 장치는 쓸 수 없고, 비율은 파이프라인 편이 아닐 것입니다.Either로 모델링하면 모든 호출자가, 유일하게 합리적인 처리가 "포기"인 경우를 처리하도록 강요당하고 — 버그의 위치를 알려 줬을 스택 트레이스를 감춥니다.책에서 굵게 표시된 모든 용어를, 다른 곳에서 불리는 이름과 Dart에서 적히는 자리와 함께 정리했습니다. 장 번호는 그 용어가 소개되는 곳입니다.
| 용어 | 다른 이름 | Dart / FxDart에서 | 장 |
|---|---|---|---|
| 펑터(Functor) | 함자 | 적법한 map을 가진 모든 타입 | 5 |
| 어플리커티브(Applicative) | 어플리커티브 펑터 | map2, zipOrAccumulate, Future.wait | 6 |
| 모나드(Monad) | — | of와 적법한 flatMap을 가진 모든 타입 | 1 |
| 모노이드(Monoid) | — | fold의 씨앗 + 결합적 결합 연산 | 8 |
| 반군(Semigroup) | — | 항등원 없는 결합적 결합 — Nel | 8 |
| 순회 가능(Traversable) | — | sequence, mapOrAccumulate, Future.wait | 9 |
| 클라이슬리 합성 | 모나드 합성, >=> | (a) => f(a).flatMap(g) | 7 |
| 자연변환 | — | 내용물을 무시하는 제네릭 변환 | 20 |
| 고차 타입(HKT) | 타입 생성자 다형성 | Dart에서 표현 불가 | 10 |
| 용어 | 다른 이름 | Dart / FxDart에서 | 장 |
|---|---|---|---|
| of | pure, return, unit, η | Either.right, [x], Future.value, fx([x]) | 1 |
| map | fmap, <$> | map, Future.then | 5 |
| flatMap | bind, >>=, chain | flatMap, expand, Future.then, r.bind | 1 |
| join | flatten, μ | flat(), expand(id) | 20 |
| map2 | zipWith, liftA2 | map2, zipOrAccumulate2 | 6 |
| traverse | 순회 | mapOrAccumulate, .map(f).sequence() | 9 |
| sequence | — | sequenceEither, Future.wait | 9 |
| fold | 카타모피즘, 씨앗 있는 reduce | fold, Either.fold | 8 |
| 용어 | 다른 이름 | Dart / FxDart에서 | 장 |
|---|---|---|---|
| 지연(Lazy) | 유예, 비엄격 | 모든 Fx 단계 — 종결자 전까지 아무것도 실행 안 됨 | 11 |
| 종결 연산자 | 소비자, 싱크 | toList, each, fold, first, sum | 11 |
| 풀(Pull) | 대화형, Iterable 모양 | Iterable, FxAsyncIterable | 12 |
| 푸시(Push) | 리액티브, 옵저버블 | Stream, FxEvents | 12 |
| 배압(Backpressure) | 흐름 제어 | 다음 값을 요청하지 않는 것 | 12 |
| 융합(Fusion) | 단계 융합, deforestation | 사슬 전체를 한 번에 통과 | 5 |
| 동시성 | — | concurrent(n), mapConcurrent — 기다림을 겹침 | 13 |
| 병렬성 | — | 아이솔레이트 — 계산을 겹침 | 13 |
| 용어 | 다른 이름 | Dart / FxDart에서 | 장 |
|---|---|---|---|
| Either | Result, Validation, 분리합 | Either<L, R>, Left, Right | 16 |
| Raise 스코프 | 컨텍스트 리시버 스코프, 효과 스코프 | either((r) { … }), r.bind, r.ensure | 15 |
| 제한된 연속 | shift/reset, 효과 핸들러 | either 안의 비국소 탈출 | 15 |
| 단락 평가 | 빨리 실패 | 첫 Left가 사슬을 끝냄 | 16 |
| 누적 | 천천히 실패, 어플리커티브 검증 | accumulate, zipOrAccumulate, mapOrAccumulate | 17 |
| NonEmptyList | Nel | NonEmptyList<E> — List 위의 확장 타입 | 8 |
| 모나드 트랜스포머 | EitherT, OptionT | 쓰지 않음 — 대신 eitherAsync | 7 |
| 용어 | 다른 이름 | Dart / FxDart에서 | 장 |
|---|---|---|---|
| 순수 함수 | — | 같은 입력, 같은 출력, 관찰 가능한 것 없음 | 2 |
| 참조 투명성 | 치환 가능성 | 호출을 결과로 바꿔 놓기 | 2 |
| 효과(Effect) | 부수효과 | 반환값 외에 관찰 가능한 모든 것 | 2 |
| 전 함수(Total function) | — | 모든 입력에 정의됨 — fold는 그렇고 reduce는 아님 | 8 |
| 곱 타입 | 레코드, 튜플, 구조체 | (A, B), 클래스 필드 | 3 |
| 합 타입 | 태그 유니온, 배리언트, 쌍대곱 | sealed class + switch | 3 |
| 대수적 자료형(ADT) | — | 합과 곱을 합쳐서 | 3 |
| 커링 | — | .curried / .uncurried | 4 |
| 부분 적용 | — | 인자 일부를 잡은 클로저 | 4 |
| 고차 함수 | — | 함수를 받거나 돌려줌 | 4 |
| 등식 추론 | — | 법칙에 따라 같은 것을 같은 것으로 바꾸기 | 19 |
| 법칙(Law) | 속성, 계약 | 인스턴스가 만족해야 하는 등식 | 1, 5, 8 |
| 범주(Category) | — | 대상 + 합성되는 화살표 + 항등 | 20 |
다른 언어의 글을 읽을 때 쓸 짧은 해독표입니다.
flatMap = bind = >>= = chain = SelectMany(C#) = expand(Dart의 Iterable).of = pure = return = unit = just = Right = Future.value.map = fmap = <$> = Select(C#) = then(Dart의 Future, 이것은 flatMap이기도 합니다).Either<E, A> = Result<A, E>(러스트 — 매개변수 순서가 뒤바뀐 것에 주의) = Validation(어플리커티브가 누적할 때).NonEmptyList = Nel = NonEmptyChain(Cats).각 법칙을 코드 한 줄과, 그것이 허락하는 리팩터링과 함께 정리했습니다. 여기 있는 모든 것은 19장이 검사한 방식 그대로 검사할 수 있습니다. 입력을 생성하고 등식을 단언하기.
| 법칙 | 등식 |
|---|---|
| 항등 | m.map((x) => x) == m |
| 합성 | m.map(f).map(g) == m.map((x) => g(f(x))) |
허가하는 것: 무연산 map 지우기, map 둘을 한 번의 순회로 융합하기, 읽기 좋으라고 map 하나를 둘로 쪼개기.
깨지는 경우: map이 함수를 적용하는 것 외의 일을 할 때 — 세기, 로깅, 캐싱, 순서 바꾸기(5장의 Counted).
| 법칙 | 등식 |
|---|---|
| 왼쪽 항등 | of(a).flatMap(f) == f(a) |
| 오른쪽 항등 | m.flatMap(of) == m |
| 결합 | m.flatMap(f).flatMap(g) == m.flatMap((x) => f(x).flatMap(g)) |
허가하는 것: 감싼 값을 인라인하기, 무연산 단계 지우기, 사슬 재그룹핑하기 — "이걸 헬퍼로 빼자"가 하는 일이 그것입니다.
깨지는 경우: 이어 붙이는 것 자체에 타입이 기록하는 비용이 있을 때(1장의 Logged).
따름정리: m.map(f) == m.flatMap((x) => of(f(x))) — 모든 적법한 모나드는 적법한 펑터입니다.
| 법칙 | 등식 |
|---|---|
| 항등 | of(id).ap(m) == m |
| 준동형 | of(f).ap(of(a)) == of(f(a)) |
| 교환 | u.ap(of(a)) == of((f) => f(a)).ap(u) |
| 합성 | of(compose).ap(u).ap(v).ap(w) == u.ap(v.ap(w)) |
map2의 말로 하면 쓸모 있는 귀결은 이것입니다. map2는 두 구조를 모두 실행하고 결합해야 하며, 한쪽을 들여다보고 다른 쪽을 결정해서는 안 됩니다.
허가하는 것: 독립적인 가지를 동시에 실행하기, 그 실패를 누적하기, 독립적인 가지의 순서를 바꾸기(결과는 같은 방식으로 결합됩니다).
깨지는 경우: "독립적"인 가지가 사실은 서로 의존할 때 — 검증 가지 안의 공유 가변 상태가 흔한 범인입니다.
| 법칙 | 등식 |
|---|---|
| 결합 | (a + b) + c == a + (b + c) |
| 왼쪽 항등 | empty + a == a |
| 오른쪽 항등 | a + empty == a |
허가하는 것: fold를 덩어리로 나누기, 병렬·점진적 축약, 항등원을 fold의 씨앗으로 삼아 빈 경우를 전 함수로 만들기.
깨지는 경우: 연산이 뺄셈 모양일 때, 또는 "항등원"을 연산이 아니라 타입에서 짐작했을 때(곱셈에 0).
함의하지 않는 것: 교환 — a + b == b + a는 별개의 더 강한 법칙이고, 쓸모 있는 모노이드 대부분에는 없습니다.
| 법칙 | 진술 |
|---|---|
| 항등 | 항등 어플리커티브로 순회하면 map이다 |
| 합성 | 어플리커티브 둘로 차례로 순회한 것 == 그 둘의 합성으로 한 번 순회한 것 |
| 자연성 | 자연변환은 traverse와 교환된다 |
허가하는 것: 사슬의 어디에서 순회할지 고르기, 원소 단위 함수를 건드리지 않고 빨리 실패를 누적으로 바꾸기.
| 법칙 | 등식 |
|---|---|
| 자연성 | α(m.map(f)) == α(m).map(f) |
허가하는 것: 변환(toList, toAsync, toNullable, first)을 map을 가로질러 양방향으로 옮기기.
깨지는 경우: 변환이 값을 들여다볼 때 — sortBy가 표준 반례입니다.
| 법칙 | 등식 |
|---|---|
| 결합 | (h ∘ g) ∘ f == h ∘ (g ∘ f) |
| 항등 | id ∘ f == f == f ∘ id |
허가하는 것: 순수 함수의 어떤 합성이든 뽑아내거나 인라인하기, 파이프라인 단계 포함.
Either와 Money는 구조적, Future는 관찰적, Set은 집합 상등. 한 등호에서 성립하는 법칙이 다른 등호에서는 깨질 수 있습니다(19장).import 'package:fxdart/fxdart.dart'; // Generate → assert the equation → report. The seed is fixed so// a failure can be reproduced exactly.void main() { final rnd = createSeededRandom(7); final inputs = List.generate(100, (_) => (rnd() * 100).floor()); Either<String, int> f(int n) => n.isEven ? Either.right(n ~/ 2) : Either.left('odd'); Either<String, int> g(int n) => Either.right(n + 1); var violations = 0; for (final x in inputs) { final m = Either<String, int>.right(x); if (m.map((v) => v) != m) violations++; final lifted = Either<String, int>.right(x); if (lifted.flatMap(f) != f(x)) violations++; if (m.flatMap(f).flatMap(g) != m.flatMap((v) => f(v).flatMap(g))) { violations++; } } print('violations: $violations');}쓸모 있어지는 순서대로 정렬했고, 난이도를 솔직히 적었습니다. 필수인 것은 없고, 이 책의 모든 장은 그 자체로 섭니다.
| 자료 | 무엇에 좋은가 | 난이도 |
|---|---|---|
| FxDart 101 튜토리얼 | API 표면을 함수 하나씩, 실행되는 데모와 함께 | 쉬움 |
| Dart vs FxDart (예제 52개) | 주어진 과제에 파이프라인이 맞는 도구인지, 판정과 함께 | 쉬움 |
| RxDart vs FxDart | 12장의 풀/푸시 결정을 실제 문제 50개에 적용 | 쉬움 |
WHY_CURRIED.md | 4장 뒤의 추론 — 이식이 원본에 지는 빚 | 보통 |
ARROW_MIGRATION_BLOCKER.md | 10장의 고차 타입 벽을, 부딪힌 그대로 기록한 문서 | 보통 |
benchmark/AUTHORING.md | 14장의 숫자를 만드는 방법과 케이스 추가법 | 보통 |
| 자료 | 무엇에 좋은가 | 난이도 |
|---|---|---|
| Arrow (코틀린) — 타입 있는 오류 가이드 | 사실상 4부의 명세. Raise 스코프, 누적, 설계 근거 | 보통 |
| FxTS 문서 | 연산자 목록과 concurrent(n). 이름이 FxDart와 거의 일치 | 쉬움 |
| Cats (스칼라) — 타입클래스 문서 | 탑을 제네릭하게: functor → applicative → monad → traverse | 스칼라 없이는 어려움 |
하스켈 Data.Functor / Control.Monad | 법칙의 원래 형태를, 간결하게 | 어려움 |
| 알고 싶은 것 | 갈 곳 |
|---|---|
| "X를 하는 FxDart 함수는 뭐지?" | 101 튜토리얼 |
| "여기서 파이프라인을 쓰긴 해야 하나?" | Dart vs FxDart, 그리고 22장 |
| "이 실패를 어떻게 모델링하지?" | 18장, 그다음 Arrow의 타입 있는 오류 가이드 |
"왜 제네릭 traverse가 없지?" | 10장, 그다음 ARROW_MIGRATION_BLOCKER.md |
| "내 타입은 적법한가?" | 19장과 부록 B, 그다음 속성 테스트를 쓰세요 |
| "모나드가 정말 뭐지?" | 1장, 그다음 Wadler, 그다음 20장 |
통하는 순서는 써 보고, 이름 붙이고, 형식화하기 입니다. 이 책의 모든 장이 그 순서로 쓰였고, 위의 자료들도 같은 방식으로 접근하는 것이 가장 좋습니다. 이미 쓰고 있던 구성물을 찾고, 그것에 이름을 붙이는 절을 읽고, 다음에 다시 만날 때까지 거기서 멈추세요.
코드를 앞에 두지 않고 이론서를 처음부터 끝까지 읽는 방식이 그 악명을 만들어 냈고, 그 방식은 통하지 않습니다.