JavaScriptを大きく変えうる Dataflow Proposals の概要と論点(Call-this, Pipe Operator)
最近、TC39 で Dataflow Proposals と呼ばれる5つのプロポーザルが議論されている。これらはパラダイムをも変えうる大きな提案で、議論も結構おもしろいので紹介する。
この記事では2022/05時点での以下の内容を紹介する。
- 各プロポーザルの概要・モチベーション・論点
- 全体的な論点
以下のことは詳しく書かないので、ぜひ各自でディグってほしい。
- 各プロポーザルの詳細な仕様
- 過去の経緯
Dataflow Proposals とは
以下の5つのプロポーザルをまとめて Dataflow Proposals と呼んでいる。
- Stage 2: Pipe operator
- Stage 1: Call-this operator
- Stage 1: Partial application(PFA)
- Stage 1: Extensions
- Stage 0: Function.pipe and flow
例えば Pipe operator, Call-this operator, Partial application を組み合わせると、以下のように書けるようになる。(提案段階なので変わる可能性アリ)
import { getAuth, getIdToken } from "firebase/auth";
function isPublic(article) {
return article.isPublic;
}
async function getIdTokenFromAuth() {
return await getIdToken(this.currentUser);
}
// Before
const publicArticles1 = (await fetch("/articles", {
headers: { Authorization: await getIdTokenFromAuth.call(getAuth()) },
})).filter((a) => isPublic(a));
// After
const publicArticles2 = getAuth()
~> getIdTokenFromAuth()
|> await fetch("/articles", { headers: Authorization: @ })
.filter(isPublic~(?));
このコードは getAuth
→getIdTokenFromAuth
→fetch
→filter
という順番で実行される。
見慣れない演算子や記号があって驚くかもしれないが、コードを左から右、上から下へと自然に読んでいくと、それが関数の実行順と一致していて読みやすいと思う。
このように、コードの実行順やデータの流れ(Dataflow)を整理するようなプロポーザルが Dataflow Proposals としてまとめて議論されている。
各プロポーザルについて
Pipe operator
概要
このプロポーザルでは、パイプ演算子 |>
とトピックリファレンス @
を追加し、以下のように書けるようになる。
// Before
const publicArticles1 = (await fetch("/articles", {
headers: { Authorization: await getIdTokenFromAuth.call(getAuth()) },
})).filter((a) => isPublic(a));
// With Pipe operator
const publicArticles2 = getIdTokenFromAuth.call(getAuth())
|> await fetch("/articles", { headers: Authorization: @ })
.filter((a) => isPublic(a));
まず |>
の左辺式が評価される。そして、その結果が右辺のトピックリファレンス @
に束縛され、右辺式が評価される。
解決する課題
Pipe operator はネストした関数型スタイルの見通しの悪さを解決する。実際に React のコードベースで使われているコードを例に見てみる。
console.log(
chalk.dim(
`$ ${Object.keys(envars)
.map(envar =>
`${envar}=${envars[envar]}`)
.join(' ')
}`,
'node',
args.join(' ')
)
);
いくつかの関数やメソッドが呼び出されているが、その実行順がパッと分かるだろうか。これを中間変数を用いてわかりやすく並べると以下のようになる。
let _;
_ = Object.keys(envars)
.map(envar => `${envar}=${envars[envar]}`)
.join(' ');
_ = `$ ${_}`;
_ = chalk.dim(_, 'node', args.join(' '));
_ = console.log(_);
これでだいぶ読みやすくなったが、_
が可変であるため意図せず上書きされてしまう可能性があり、実際にこのようなコードを書くことはあまりない。
Pipe operator を用いると、このように中間変数を使うことなく、メソッドチェーン的に書くことができる。
Object.keys(envars)
.map(envar => `${envar}=${envars[envar]}`)
.join(' ')
|> `$ ${@}`
|> chalk.dim(@, 'node', args.join(' '))
|> console.log(@);
議論
Hack Style VS. F# Style
Hack Style と F# Style という2つの構文案があったが、Hack Style でほぼ確定した。
元々は F# Style の議論を進めていて、TC39 ミーティングで2回 Stage 2 へ進めようとした。しかし2回とも否決されたことから、F# Style は現実的でないと判断して、その代わりに後述する Function.pipe
が提案された。
Hack Style は無事に Stage 2 に到達したため、F# Style は止めることが TC39 内で合意されている。
さらに詳しい経緯は以下の記事を参照してほしい。
[ECMAScript] Pipe operator 論争まとめ – F# か Hack か両方か
構文
パイプ演算子は今のところ |>
で、TC39 内では特に議論がない。しかしコミュニティからは、将来 F# Style も導入する可能性のために |>
は残し、|:
にするべき、という意見が出ている(tc39/proposal-pipeline-operator#237)。
個人的には、開発者の混乱を考えるとパイプ演算子が2種類導入されるとは考えにくく、そのために構文を妥協するべきではないと感じている。
トピックリファレンスについてはパースのしやすさ、可読性、入力のしやすさなどの観点で議論されている。
元々は %
が有力だったが、剰余演算子と被ることからパーサー実装が複雑になる懸念がありボツになった。除算演算子と正規表現リテラルでも /
で同じことが起きているらしい。
現在は @
がやや人気のようだが、先日 Stage 3 に到達した Decorators との ASI Hazard が懸念されている。例えば以下のコードを考える。
const hello = (name) => `Hello ${name}`;
// Without semicolon
const result1 = hello |> @("Yuku")
class Example1 {
...
};
// With semicolon
const result2 = hello |> @("Bob");
class Example2 {
...
}
この時、@("Yuku")
は Example1
クラスへのデコレータだと解釈され、result1
には Example1
のコンストラクタが入ってしまう。セミコロンを付けると @
がトピックリファレンスと解釈され、result2
には Hello Bob
という文字列が入る。
ただしこれはレアなケースであり、セミコロンやカッコを正しく付けることで回避できることから、ボツには至っていない。
@
の他には ^^
%%
@@
#_
##
などが案にあり、特に ^^
は Bitwise XOR ^
の出現率が低いことから可読性への影響が小さく、 @
の次に支持を集めている。x |> f(^^, 0)
のように顔文字風でちょっとおもしろい。
Call-this operator
概要
このプロポーザルでは、Function.prototype.call
の糖衣構文となる二項演算子 ~>
を追加し、以下のように書けるようになる。
function wrapInTag(tag) {
return `<${tag}>` + this + `</${tag}>`;
}
const text = "Hello";
// Before
wrapInTag.call(text, "span");
// With Call-this operator
text~>wrapInTag("span");
これは元々 Bind-this parameter というプロポーザルだったが、後述する PFA との重複を避けるため、Function.prototype.bind
に関する仕様を落として Call-this parameter になった。以前の Bind-this parameter については以下の記事を参照してほしい。
JavaScript の Bind Operator プロポーザルが復活した
解決する課題
オブジェクト指向における tree-shaking
Call-this operator はオブジェクト指向における tree-shaking と開発者体験の両立を可能にする。
現在の JavaScript におけるクラスベースのオブジェクト指向は tree-shaking がしにくい。関数型では関数ごとにインポートの有無で tree-shaking できるが、クラスを用いたオブジェクト指向ではメソッドごとのインポートができないため、未使用メソッドの検出が難しく、クラスをインポートするとすべてのメソッドがバンドルされてしまう。
Function.prototype.call
を用いるとクラス(=prototype)を使わずに this を参照でき、TypeScript だと This parameter を用いてこのように書ける。
type Person = { firstName: string, lastName: string };
function getFullName(this: Person): string {
return `${this.firstName} ${this.lastName}`;
}
import { type Person, getFullName } from "./person";
const receiver: Person = { firstName: "Yuku", lastName: "Kotani" };
getFullName.call(receiver); // "Yuku Kotani"
これでオブジェクト指向っぽくレシーバに振る舞いを持たせることができるし[1]、getFullName
はただの関数であるため、未使用の場合は tree-shaking が効く。しかしクラスと比較すると、レシーバと関数が自然な語順にならず読みにくく、現実にこのようなコードが書かれることは少ないだろう。
読みにくいというと人間の主観のように思えるが、この場合は人間だけでなく機械にとっても読みにくい。レシーバが先に来ることで、TypeScript コンパイラはそのレシーバの型に合う関数のみを補完することができる。これが逆だと名前空間にある全ての関数を補完に列挙することになり、オブジェクト指向的なモデリングとの噛み合わせが悪い。
Call-this operator での体験は Golang での構造体とメソッドをイメージするとわかりやすい。
import { type Person, getFullName } from "./person";
const yuku: Person = { firstName: "Yuku", lastName: "Kotani" };
yuku~>getFullName(); // "Yuku Kotani"
これにより、クラスを捨てることによる tree-shaking のしやすさと、自然な語順による開発者体験を両立できる。
このあたりについては以前話したことがあるのでよかったら資料を参照してほしい。
Bundle Size Optimization in Future JavaScript - JSConf JP 2021
Function.prototype.call
の撲滅
Function.prototype.call
は現実には上述の OOP 目的ではなく、安全性を求めるライブラリのコード等で使われることが多い。
例えば、値の型判定をするときに typeof
演算子だと厳密でなく[2]、Object.prototype.toString
を呼ぶことがよくある。これが以下のように書ける。
// Without call-this
Object.prototype.toString.call(value);
// With call-this
value~>Object.prototype.toString();
まだ冗長だが、語順は自然になった。
汎用的な拡張メソッドとして
他言語における拡張関数、拡張メソッドと同じように使える。これによって、既存の API に対して別パッケージからさらに API を生やすようなことができる。
import { coreApi, doSomething } from "@example/core";
import { experimentalFunction } from "@example/experimental";
coreApi()
~>doSomething()
~>experimentalFunction();
そして個人的に最もおもしろいと思うのは、これによって文を式に変換できること。例えば現在 Stage 2 の Throw Expression は以下のように代替できるだろう。
function throwThis() {
throw this;
}
// With call-this
const notNull = nullableVar ?? new TypeError()~>throwThis();
// With stage-2 throw expression
const notNull = nullableVar ?? throw new TypeError();
さらにレシーバが null でも良いのでこういう実装もできるだろう。
function checkIsNotNull() {
if (this == null) {
throw new TypeError();
} else {
return this;
}
}
const notNull = nullableVar~>checkIsNotNull();
議論
構文
角カッコを使う構文 rec~[fn](arg)
や関数を先に持ってくる構文 fn@(rec, arg
) などさまざまな構文案があったが、メソッド呼び出し .
と同じように、スペースで囲わないレシーバファースト構文 rec~>fn(arg)
が支持されている。
演算子の記号についてはまだあまり議論がないが、暫定的に ~>
で進んでいる。他には :>
->
::
-.
:.
などの他、rec call fn(arg)
のようにキーワードを使う案もある。
エコシステムの分断
ライブラリなどから関数をインポートしたときに、それが全ての入力を引数で受け取る従来の関数なのか、Call-this parameter で呼ぶべき this を用いた関数なのか、見分ける手段がないという懸念がある。
ライブラリ作者側もどちらの手法でAPIを提供するのか考える必要がある。
個人的には、インポートする側の問題はエディタによる支援で解決できる感覚があるが、TC39 でこの懸念を払拭するにはまだ議論が必要そう。
Partial application (PFA)
概要
このプロポーザルは関数の部分適用を行う構文を追加し、以下のように書けるようになる。
function add(x, y) { return x + y; }
// Before: with Function.prototype.bind
const addOne = add.bind(null, 1);
// Before: with arrow function
const addOne = x => add(1, x);
// After
const addOne = add~(1, ?);
addOne(2); // 3
解決する課題
部分適用は上記の通り Function.prototype.bind
かアロー関数を使えばできないこともない。ただし、いくつかの制約がある。
-
Function.prototype.bind
では引数を先頭からしか固定できない -
Function.prototype.bind
ではthis
を明示する必要がある(多くの場合ボイラープレートとなる) - アロー関数の中身は実行するたびに評価されるため無駄なコストが発生しやすい
-
Function.prototype.bind
もアロー関数も冗長
PFA ではこれらの制約がなくなる。現実的なユースケースはこんな感じになりそう。
const onClickNumber = (num, event) => {
event.stopPropagation();
console.log(`Clicked ${num}`);
};
// Before
<div>
{[1, 2, 3].map(num => (
<button onClick={(e) => onClickNumber(num, e)}>{num}</button>
))}
</div>
// After
<div>
{[1, 2, 3].map(num => (
<button onClick={onClickNumber~(num, ?)}>{num}</button>
))}
</div>
議論
必要性
元々 PFA の背景として、Pipe Operator の F# Style を補完する目的が強かった。F# Style の解説は詳しくしないが、こんな感じに組み合わせるはずだった。
// F# style without PFA
num
|> (n) => add(n, 2)
|> (n) => divide(4, n)
|> (n) => console.log(n);
// F# style with PFA
num
|> add~(?, 2)
|> divide~(4, ?)
|> console.log~(?);
しかし前述の通り F# Style は現時点でボツになったため、PFA の意義も薄れている。F# Style の代替として後述する Function.pipe
が提案されているため、それの進捗にも寄りそうだ。
実際 F# Style がなくなってから PFA には動きがないが、PFA の Champion[3] である Ron Buckton (RBN) がそのうち進めると明言している。
Extensions
概要
このプロポーザルは拡張メソッドや拡張プロパティを定義し呼び出す構文を追加し、以下のように書けるようになる。
// Extension method
const ::toSet = function () { return new Set(this) };
// Extension property accessor
const ::allDivs = {
get() { return this.querySelectorAll('div') }
};
// Import extension method
import ::{ toArray } from "./toArray";
// Call extensions
const classCount = document::allDivs
.flatMap(el => el.classList::toArray())
::toSet()::size;
拡張メソッドや拡張プロパティは、通常の定数や関数とは別の名前空間を持つ。そのためにインポートなども独自の構文を持つ仕様になっている。
また、通常の関数も this
を束縛して拡張メソッドとして呼び出すことができる。これは Call-this parameter と同じ。
function getAllDivs() {
return this.querySelectorAll('div');
}
// Without extension
getAllDivs.call(document);
// With extension
document::getAllDivs();
他のオブジェクトや名前空間が持っているメソッドを呼ぶ構文もある。オブジェクトが prototype を持つ場合は特別にその prototype も参照される。
export function getAllDivs() {
return this.querySelectorAll('div');
}
import * as utils from "./utils";
// From namespace
const divs = document::utils:getAllDivs();
// From prototype
const size = divs::Array:size();
解決する課題
まず Extensions は実質的に Call-this operator のスーパーセットであるため、Call-this operator と同じ課題を解決する。
それに加え、Call-this ではできない拡張プロパティにより、さらに表現力が広がる。
const ::px = {
get() { return new CSSUnitValue(this, "px") }
};
1::px // CSSUnitValue {value: 1, unit: "px"}
また、上述の prototype を特別扱いする仕様により、Object.prototype.toString
などをよりシンプルに呼べる。
// Current
Object.prototype.toString.call(value);
// With call-this
value~>Object.prototype.toString();
// With extensions
value::Object:toString();
議論
複雑すぎる
Extensions 専用の名前空間を追加することについて、ブラウザやバンドラなどの実装者からエコシステムの負荷を懸念する声が挙がっている。
また、上述の prototype を特別扱いするような仕様も魔法的で難しいという意見が多い。
仕様
上述の声を受けてプロポーザルを小さくする作業が進んでいるが、まだあまり固まっていなく、最終的な仕様が見えない状態。そのため、あまり議論も進んでいない。
Function.pipe and flow
概要
このプロポーザルは関数適用と関数合成を行う4つの組み込み関数を追加する。
元々は Function helpers というプロポーザルだったが、範囲が広すぎて Stage 1 に進めなかったことから、このプロポーザルが切り出されて今に至る。
Function.pipe
Function.pipe
は第1引数に渡した値に対して、それ以降の引数で渡した関数を順に適用し、その結果を返す。
// Without Function.pipe
f2(f1(f0(1)));
// With Function.pipe
Function.pipe(1, f0, f1, f2);
Function.pipeAsync
Function.pipeAsync
は第1引数に渡した値に対して、それ以降の引数で渡した非同期関数を順に適用し、その結果を返す。
// Without Function.pipeAsync
Promise.resolve(5).then(f0).then(f1).then(f2);
// With Function.pipeAsync
Function.pipeAsync(5, f0, f1, f2);
Function.flow
Function.flow
は引数に渡した関数を合成した関数を返す。
// Without Function.flow
const composed = (v) => f2(f1(f0(v)));
// With Function.flow
const composed = Function.flow(f0, f1, f2);
composed(5);
Function.flowAsync
Function.flowAsync
は引数に渡した非同期関数を合成した関数を返す。
// Without Function.flowAsync
const composed = async (...args) => await f2(await f1(await f0(...args)));
// With Function.flowAsync
const composed = Function.flowAsync(f0, f1, f2);
await composed(5);
解決する課題
現在支持されている Hack Style Pipe Operator で単純な関数適用をすると以下のようになり、少し冗長。
// With Hack Style Pipe
value |> f0(@) |> f1(@) |> f2(@);
これは F# Style Pipe Operator だとシンプルになるが、F# Style は今のところボツになったので、それに近い体験を既存の構文で実現する。
// With F# Style Pipe
value |> f0 |> f1 |> f2;
// With Function.pipe
Function.pipe(value, f0, f1, f2);
議論
まだ Stage 1 に向けた議論も行われていないが、今のところ以下の理由であまり反対意見がないようだ。
- F# Style Pipe を求める声の他、fp-ts や lodash ですでに同様のユーティリティ関数が提供されていて、ニーズが見えている
- 構文に手を入れないため導入コストが低い
ただし、ユーティリティ的側面が強いことから、標準に入れるほど便利なのかという声はある。
全体的な議論
プロポーザルをまたぐ論点がいくつかあり、その調整のために Dataflow Proposals としてまとめて議論されている。
プロポーザル間の重複
プロポーザル間で一部機能の重複があり、仕様を小さくする等の調整が必要になっている。Bind-this が Call-this になったのもその一例。
重複部分に関しては Pipe operator や Call-this operator の Champion である J S. Choi (JSC) による以下の図がわかりやすい。
https://jschoi.org/22/es-dataflow/ より
もっとも、JavaScript は生みの親である Brendan Eich の方針で、 TOOWTDI(There's Only One Way To Do It) よりも TMTOWTDI(There's More Than One Way To Do it) を原則としている。そのためある程度の重複は問題ないという意見が多い。
例えば Pipe operator と Call-this operator の重複に反対する意見はあまり多くない。一方で、Extensions は Call-this の全体を内包するレベルで重複していて、このまま両方が採用されることはないだろう。
枯渇する構文空間
JavaScript は後方互換を切れないため、構文は膨らんでいく一方。無理に記号を使い回すと ASI Hazard などのリスクもあり、TC39 は新たな構文の追加により慎重になっている。
相互運用性
JavaScript 界には既に関数型やクラスベース OOP などのパラダイムがある。また、Call-this によって this を参照する関数を用いた OOP も出現するだろう。
そうなったときに、現実的にそれらのパラダイムをどう組み合わせるかという観点でも議論されている。例えば Pipe Operator と Call-this Operator の結合優先度などもここに影響するため、細部まで気を使っているようだ。
まとめ
パラダイムレベルで大きな変化をもたらすかもしれない Dataflow Proposals を紹介した。各プロポーザルの詳細については触れられなかったので、気になるプロポーザルについてはぜひ原文を読んでみてほしい。
個人的には Extensions 以外の4つは入ってほしいと思っている一方で、従来のパラダイムとの相互運用性はかなり気になっている。CommonJS と ES Modules のような分断は繰り返したくない。
まだまだ動きが激しいので適宜情報発信するつもりだが、特に Matrix チャットなどは追いきれていないので、誤っていたり漏れていたりするところがあったらぜひ教えてほしい。
参考資料
- 26 January, 2022 Meeting Notes
- Dataflow-proposals ad-hoc discussion overflow on January 27, 2022
- Pipeline Champions 2022-03-19
- 29 March, 2022 Meeting Notes
- Brief history of the JavaScript pipe operator
- Holistic JavaScript dataflow proposals by JSC
- Holistic Review of TC39 "Dataflow" Proposals by TAB
- About dataflow area by JHX
- TC39 Matrix log
-
理論上はできるが TS の標準ライブラリの型が弱いためレシーバの型チェックが効かない。better-typescript-lib などを用いて強い型をつけるといける。 ↩︎
-
そのプロポーザルを主体的に進める人。https://github.com/tc39/how-we-work/blob/main/champion.md ↩︎
Discussion
とありますが、Hack-styleのオペレータとして
で提案されているのは:>
ではなく、|:
だと思います。すみません、ありがとうございます!修正しました。
F#の本当のPipeOperator
|>
が導入されそうになるまでは、まさにそのとおりでした。f(x)
をx |> f
に書き換えるだけ、と表層的にしか理解していない人々がほとんどなので、こういうF#スタイルかHackスタイルか、という非常に愚かな二択議論が行われた結果Hackスタイルにしよう、とか表記スタイルの決定みたいになっているのですが、これは、数学の二項演算子であり、Monadという基本的な代数構造のうち、さらにもっとも基本的な構造です。では、なぜF#スタイルという本当の二項演算子がStage2で却下されたのか?というと、
async/await 構文にはまる、というのがコア要件であり、実際はまらないからです。
そもそも、async/await構文というのは、Promise then というこれも二項演算の代数構造を形成する式を、命令型パラダイムを模倣するために作られたSyntaxSugarです。
そういう、元々あった自然なPromiseの数学構造を単に人工的なSyntaxSugarにしたにすぎない命令型の構文にはまらない、という理由で、自然な数学構造であるF#スタイルの本当の二項演算子PipeOperatorを却下してしまった。
数学構造が人工的な構文にはまらない、破綻する、っていうことは、その構文のほうが数学的整合性がない出来の悪いもの、ということにすぎず、その不都合さを、PipeOperatorという二項演算子の本来の数学構造のあり方をゆがめることで都合をつけようとしているわけです。
パラダイムの問題ではありません。プログラミングは本質的には数学なんで、その構造を示す、Promise thenという代数構造の式をasyn-awaitという命令型構文のSyntaxSugarにしたければすればいいが、後々不都合が起こらないようにそれは上手にすべきです。しかし実際は失敗したことが証明された。
async-await構文という技術的負債が生まれたのだが、それを覆い隠すために、またHackPipeという新たな技術的負債を作る、一般社会でいえば、借金を補填するために新しい借金をつくる、嘘の上に嘘を塗り重ねる、という愚行と本質的には全く同等の事象です。
この事の重大性と決定の愚かさを理解しているコミュニティのメンバーは(自分も含めて)長大な反発、反論を行いましたが、単に上位下達の権威主義により聞き入れられなかったのはご覧のとおりです。
個人的にそのTC39に参加しているメンバーのコメントを見ましたが、関数型プログラミングのことなどナニも理解していませんでした。Googleの担当社員も含めてです。
彼らはHackスタイルのことそ a little bit なんちゃらとF#スタイルからの軽微な修正、というように捉えていますが、本質的にMonad構造を破壊しているのであり、これは実際スタイルの問題でもなんでもありません。
長大な論争であったので、TC39でのコメントは逐一引用できないですが、このF#PipeOperatorの却下からの得体の知れないHackOperatorは、基本的数学構造の破壊でしかないので、むしろ関数型スタイルにおいては甚大な後退である、と考えるコミュニティメンバーが多いです。
繰り返しになりますが、それにまともに反論できるような知見がある人らがいるわけでもなく、単にモデレータは、2回TC39にチャレンジしたけどF#スタイルは通らなかったのでどうしようもない、理由はasync/await構文にハマるように、というコア要件を満たせなかったからだ、というものです。
というのは、数学的にまるで別物の得体の知れないものを、本来のPipeOperatorのように騙るべきでない、という妥当な信念を共有する人からの紳士的なかろうじての妥協案です。
考えにくい、というかありえないのは、すでに関数型プログラミング界隈で一般的になっているPipeOperoator
|>
の表記をまるで別物のなにかがOverrideしてしまうことであり、単にasync/await構文で使いたいという人は、|:
でやればいい、そんなことに関与せず、従来のF#PipeOperatorを使いたい人は|>
を使えばいい、ということです。完全に同意。プロポーザルを応援しています。
あと、わかりやすい記事をありがとうございます!