JavaScriptを大きく変えうる Dataflow Proposals の概要と論点(Call-this, Pipe Operator)

74331 ワード

最近、TC39 で Dataflow Proposals と呼ばれる5つのプロポーザルが議論されている。これらはパラダイムをも変えうる大きな提案で、議論も結構おもしろいので紹介する。

この記事では2022/05時点での以下の内容を紹介する。

  • 各プロポーザルの概要・モチベーション・論点
  • 全体的な論点

以下のことは詳しく書かないので、ぜひ各自でディグってほしい。

  • 各プロポーザルの詳細な仕様
  • 過去の経緯

Dataflow Proposals とは

以下の5つのプロポーザルをまとめて Dataflow Proposals と呼んでいる。

例えば 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~(?));

このコードは getAuthgetIdTokenFromAuthfetchfilter という順番で実行される。
見慣れない演算子や記号があって驚くかもしれないが、コードを左から右、上から下へと自然に読んでいくと、それが関数の実行順と一致していて読みやすいと思う。

このように、コードの実行順やデータの流れ(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 を用いてこのように書ける。

person.ts
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 も参照される。

utils.js
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 チャットなどは追いきれていないので、誤っていたり漏れていたりするところがあったらぜひ教えてほしい。

参考資料

脚注
  1. 理論上はできるが TS の標準ライブラリの型が弱いためレシーバの型チェックが効かない。better-typescript-lib などを用いて強い型をつけるといける。 ↩︎

  2. https://qiita.com/amamamaou/items/ef0b797156b324bb4ef3 ↩︎

  3. そのプロポーザルを主体的に進める人。https://github.com/tc39/how-we-work/blob/main/champion.md ↩︎