fx
시퀀스를 지연 평가되는 체인 가능한 파이프라인으로 감쌉니다 — 타입이 살아 있는 FxDart의 심장부입니다.
강의
이 코스의 모든 내용은 결국 하나의 개념, 체인으로 모입니다.
fx(iterable)은 임의의 Iterable<T>를
Fx<T>로 감쌉니다 — .map(),
.filter(), .take()처럼 FxTS 스타일의 메서드가
달려 있는 객체입니다. 이 호출들은 하나같이 지연 계산을 조금씩 더 얹은
새로운 Fx를 반환할 뿐, 아직 아무것도 실행하지
않습니다. Fx가 실제로 일을 시작하는 시점은
종결 연산자를 호출할 때입니다 — toList(),
each(), consume(), reduce() 등이
체인 전체를 통해 값을 하나씩, 종결 지점에서 소스까지 거슬러 끌어당깁니다.
이 지연 평가 덕분에 FxDart는 아주 크거나 무한한 시퀀스
(range, cycle, repeat) 위에서도
안전하게 체인을 이어 갈 수 있습니다. 하류의 무언가가 — 보통은
take(n)이 — 실제로 몇 개를 끌어당길지 정해 주기만 하면,
상류 단계는 딱 그만큼만 실행됩니다.
fx는 체인의 동기 쪽 절반입니다. 비동기 짝으로는
FxAsyncIterable(toAsync,
fromStream, 혹은 *Async 계열 함수에서 얻는 것)을
감싸는 fxAsync, 그리고 Dart의 Stream을 바로
감싸는 단축 함수 fxStream이 있습니다. 둘 다
FxAsync<T> 체인을 반환하며, 이 체인의 메서드는
Future를 반환하는 함수도 받아들이고, 종결 연산자는 모두
await할 수 있는 Future를 반환합니다. 체인 중간에
동기에서 비동기로 넘어가려면 .toAsync()를 쓰면 됩니다.
그냥 map(f, iterable) 같은 최상위 함수를 호출하면 될 텐데
왜 이런 게 필요할까요? Dart로는 FxTS의 TypeScript처럼 가변 인자
pipe에 타입을 붙일 수 없기 때문입니다(다음 강의 참고).
fx() 체이닝은 그 대신 FxDart가 완전한 타입과 자동 완성이
살아 있는 파이프라인을 얻는 방법입니다.
Fx<T>는 이제 확장 타입이며, 런타임에는
감싸고 있던 Iterable<T>로 소거됩니다. 문서화된 API는
모두 그대로이고 체인도 이전과 똑같이 동작합니다. 깨지는 것은
x is Fx<T> 검사(런타임에 그 타입이 존재하지 않습니다),
그리고 Fx를 직접 extend하거나 implement하려던 코드입니다(대신
최상위 함수를 쓰세요). fx()를 평범하게 쓰고 있다면 고칠 것은
없습니다.
데모 1 · 종결 연산자 전까지는 아무것도 실행되지 않습니다
체인을 만든 직후에는 calls가 0에 머물러 있다가,
toList()가 실제로 값 5개를 끌어당기는 순간 올라가는 것을 보세요.
데모 2 · fxAsync와 fxStream
fxAsync는 FxAsyncIterable(여기서는
toAsync로 만든 것)을 감싸고, fxStream은
Stream을 바로 감쌉니다. 둘 다 같은 체인 메서드를 비동기
버전으로 제공합니다.
getter 표기
모든 진입점에는 getter 형태도 있습니다. Iterable,
FxAsyncIterable, Stream에는 .fx가,
Future의 이터러블에는 .fxAsync가, Stream에는
.fxEvents가 붙습니다. 만들어지는 체인은 완전히 같고, 차이는
표현식을 어느 쪽부터 읽느냐뿐입니다.
// 함수 표기: 괄호를 열려면 표현식 앞으로 되돌아가야 합니다
fx(orders.where(isPaid)).groupBy((o) => o.customerId);
// getter 표기: .toList()를 읽듯 왼쪽에서 오른쪽으로
orders.where(isPaid).fx.groupBy((o) => o.customerId);
이쪽이 Dart의 관용 표기이고, 비용은 없습니다. Fx는 extension
type이라 래퍼가 이터러블 자체로 소거되고, 본문이 this뿐인
getter는 컴파일러가 지워 버리는 정적 호출입니다. 100만 원소의
map + filter + sum에서 두 표기는
12.640 ms와 12.665 ms — 같은 숫자가 두 번 나옵니다.
이 문서는 전부 fx()로 씁니다. FxTS가 쓰는
이름이고, 타입 인자를 명시할 수 있는 쪽이기 때문입니다. getter는 후위로
타입 인자를 받을 수 없어서 fx<num>(xs)는 되지만
xs.fx<num>은 파싱되지 않습니다. 실제 코드에서는 더 잘
읽히는 쪽을 쓰세요 — 컴파일 결과는 동일합니다.
한 가지 비대칭은 알아 둘 만합니다.
Iterable<Future<T>>에 .fx를 붙이면
Fx<Future<T>>가 됩니다 — 값이 아니라 Future 자체를
훑는 체인이고, 컴파일은 되면서 조용히 엉뚱하게 동작합니다.
.fxAsync가 그래서 있습니다. Future를 await해 주므로
T는 결과 타입이 되고, concurrent(n)이 일할 거리를
갖게 됩니다.
await responses.fxAsync.map(parse).concurrent(4).toList();
Stream에는 두 getter가 모두 붙습니다. 양쪽 세계에 걸쳐 있는
유일한 소스이기 때문입니다. .fx는 pull 체인으로,
fxStream과 같습니다. .fxEvents는 push
체인으로, fxEvents와 같습니다 —
debounce, throttle, switch가 여기에 있습니다. push에서 pull로 건너올 때는
.pull()을 씁니다. 둘을 나란히 놓고 비교한 표는
Stream 다리에 있습니다.
keystrokes.fxEvents
.debounce(const Duration(milliseconds: 160))
.switchMap((q) => search(q).asStream())
.pull()
.toList();
getter 표기 전체
규칙은 하나입니다. 진입점 이름에는 fx가 들어갑니다. 어느
라이브러리로 들어가는지를 밝혀 주고, 맨 이름 — toAsync,
shuffle, debounce — 은 프로젝트가 그 타입에
붙일 몫으로 남겨 둡니다.
| 수신 타입 | getter | 같은 것 |
|---|---|---|
Iterable<T> | .fx | fx(xs) |
FxAsyncIterable<T> | .fx | fxAsync(it) |
Stream<T> | .fx | fxStream(s) |
Iterable<FutureOr<T>> | .fxAsync | toAsync(xs) |
Stream<T> | .fxEvents | fxEvents(s) |
Stream<T> | .fxLive | LiveValue.from(s) |
Stream<T> | .fxLiveSeeded | LiveValue.seededFrom(v, s) |
Iterable<T> | .fxShuffle | shuffle(xs) |
FxAsyncIterable<T> | .fxShuffle | shuffleAsync(it) |
void Function(T) | .fxDebounce | debounce(f, w) |
void Function(T) | .fxThrottle | throttle(f, w) |
연산자 자체는 이 목록에 없고, 앞으로도 넣지 않습니다. 그중 열다섯 개 —
map, where, take,
fold 등 — 은 Iterable이 이미 가진 멤버와 이름이
같고, 인스턴스 멤버는 항상 확장을 이기므로 그 호출은 fxdart에 닿을 수조차
없습니다. 연산자가 사는 곳은 체인입니다 — xs.map(f)가 아니라
xs.fx.map(f)입니다.
직접 해 보기
연습: 60점 이상인 점수만 남기고, 보너스 점수로 두 배를 한 뒤, 앞의 2개 결과만 가져오는 체인을 만들어 보세요.