LoginSignup
3
4

こちらの記事で「ECMAScriptにパターンマッチングが来るかも!」ということを知りました。(リンク先は英語・Mediumメンバー限定記事です。ご注意を)

しかし、この提案について日本語で書かれた記事が無い! そこでECMAの提案ページを抄訳することでこの凄そうな機能について周知していきたいと思います。

パターンマッチングとは

オブジェクト指向プログラミングにおけるパターンマッチングとは何でしょう。WikiPediaの英語版記事を参考にすると「与えられたトークンシークエンスがパターン構成を満たしているかどうかをチェックすること」(the act of checking a given sequence of tokens for the presence of the constituents of some pattern)とあります。これをかみ砕くと、

  • 与えられたオブジェクトが所定の型か
  • 与えられたオブジェクトが所定の値か
  • 与えられたオブジェクトが所定のプロパティを持っているか

ということのチェックです。さらにプロパティに対しても再帰的に型や値のチェックが求められます。

Java(SE 12以降)やC#(7以降)、Ruby(2.7以降)といった言語にパターンマッチング機能が搭載されるようになりました。基本的にはswitch文の拡張として実装されることが多いようです。今回は雰囲気を知ってもらえるように、C#の例を掲載いたします。

public static void TestAnObject(object tested)
{
    switch(tested)
    {
        case int i when i == 42: // testedの値が42である。
            Console.WriteLine("42TH ZONE! BLACK HOLE!");
            break;
        case int _: // testedの型が42以外のint(整数)である。値を使わないので「_」で破棄する。
            Console.WriteLine("You Inputted an integer.");
            break;
        case string s when s.Length <= 5: // testedの型が5文字以下のstring(文字列)である。
            Console.WriteLine("You Inputted a short string.");
            break;
        case string _: // testedの型が(6文字以上の)stringである。値を使わないので「_」で破棄する。
            Console.WriteLine("You Inputted a long string.");
            break;
        default:
            Console.WriteLine("What did you input to me?");
            break;
    }
    
    string something = tested switch // 右辺として使えるswitch文
    { // ラムダ式(JSで言うアロー式)の右側の値を返す
        int i when i <= 5 => "very few", // 「<=」は矢印じゃない
        int i => "enough",
        string s => s,
        _ => "nothing meaningful" // defaultに相当
    };
}

パターンマッチングのおおよその雰囲気は知っていただけたかと思います。それではECMAScriptのパターンマッチング提案の現在を見ていきます。

提案のステージ

2024/5/7現在ではステージ1です。そのためブラウザでの実装はまだされておらず、基本仕様を検討している段階です。ここに書いてあることも変更する可能性が高いです(実際、最初に紹介した記事の書式はgithubのそれと違います)。

なお、ステージ1になったのが2018年5月なので、かなりの難産ですね。


(抄訳初め)

動機

既存の任意の変数を場合分けする方法はかなり限られていた。

  • 正規表現は文字列のみ評価できる。
  • switch 文は値に対する処理という構造が分かりやすいが、制約が大きい。式として使えない(関数にしなければ結果を受け取るのにlet が必要ということ)し、break を書かないとフォールスルーするし、スコープが分かりづらいし、比較は===しかできないなどなど。
  • if else はどんなことでもできる反面、長ったらしくて分かりづらい。同じ構造式を何度も書かないといけない。分かりづらい3項演算子を使わなければ式として使えない。テストする値を(大概一時)変数に入れないといけないとこちらも問題山積している。

これらの問題を解決すべく、チームは以下の方針を立てる。

パターンマッチング

パターンマッチング構成式(pattern matching construct、訳注:コンストラクタと混同しないようになじみのない単語を訳に充てた)は完全な条件ロジックとして、パターンマッチング以上のことができる。トレードオフが発生した場合は人間的な分かりやすさの方を取る。

switch 文の包摂(Subsumption)、および代替

この機能は簡単に検索できなければならない。したがって文法的にswitch とオーバーラップしないようにする。この提案はswitch の長所だけを残し、地雷(footgun)要素を排除したうえで、現在switch にはできないことができるようにする。

式として使えるようにする

パターンマッチング構成式は以下の書式で書けるようにする。

return match { ... }
let foo = match { ... }
() => match { ... }

網羅性(Exhaustiveness)と順序

ありうるケースを無視したいのなら、開発者は明示的にそうしなければならない。複数ケースでロジックを共有したい場合も、開発者は明示する。ケースは書かれた順、つまり上から下にチェックされなければならない。

拡張性

ユーザオブジェクトは自身のマッチング方法が使えるようにしなければならない。

仕様詳細

以下の3つのコンセプトをJavascriptに導入する。

  • マッチャーパターン
    • パターン分解に密接に関わる新しいドメイン固有言語。これのおかげで再帰的に構造や内容を様々な方法で一気にテストでき、同時に構造を変数に束縛(binding)できる。
  • match
    • switch式の代替。マッチャーパターンを使い、複数の選択肢から1つに解決できる。
  • is boolean式
    • 単一のマッチャーパターンをテストできる。さらに変数束縛も可能。

マッチャーパターン

分解パターンにインスパイアされたドメイン固有言語。構造と内容を再帰的に検査し、他のコードで使えるように値を変数束縛する。以下の3つに分類できる。

  • 値パターン(Value patterns)。マッチ対象(Subject)が値と一致するか調べる。例えば「引数が文字列の"foo"である」
  • 構造パターン(Structure patterns)。マッチ対象の構造を調べる。例えば「引数が"foo"というプロパティを含む」「引数の長さが3以上である」
  • 結合パターン(Combinator patterns)。同一のマッチ対象に並行してマッチをかけられる。andor による簡単な boolean ロジック

値マッチャー(Value Matchers)

プリミティブパターン(Primitive Patterns)

全てのプリミティブや変数名を直接マッチャーパターンに書き込める。この場合、基本的にはSameValue(Object.is())の比較方法になる。論理値リテラル、数値リテラル、文字列リテラル、タグ無しテンプレートリテラル、nullリテラルが使用可能。

  • 数値リテラルは+ -単項演算子を前に付与することができる。+0を除き何もしない。-は値の符号を反転させる。
  • テンプレートリテラルの補間については「束縛」の項目を参照。
  • 0の比較はSameValueZeroで行われる。即ち+0-0は同じ値となる1
  • NaNパターンはNaN値と一致する。
  • 1"1"と一致しない。
  • 型の強制(coersion)はマッチ内で行われない。つまり、文字列リテラルとマッチするならマッチ対象はすでに文字列である。文字列変換を内部で行っても反映されない。
変数パターン(Variable Patterns)

foofoo.barfoo[bar]というような「識別式」である。nullのようなプリミティブであるものは含まれない。は+ -単項演算子を前に付与することができる。

変数パターンは識別子に対し、明示的な束縛を与えることができる。+-があれば、toValueで数値変換され、(訳注:-の場合)符号が反転することがある。もし結果がSymbol.customMatcherプロパティを持つオブジェクトか、関数の場合は、カスタムマッチャーテストを表す。そうでない場合はSameValue比較が行われる。

注:

  • 例えば配列を持つ変数はきっかりオブジェクトが等しい配列でないとマッチしない。配列パターンは構造的な検査をする。
  • Infinityundefinedという「リテラルのように思われる値」は実際には束縛されている。プリミティブパターンと変数パターンがほとんど同じため、区別はあくまで学術的となる。
  • +-を除けば、やはり型の強制は発生しない。
カスタムマッチャー(Custom Matchers)

プロトタイプチェーンのどこかにSymbol.customMatcherプロパティを持つオブジェクト。

パターンマッチするかどうかを調べるのに使われるマッチャー関数(Matcher Function)はマッチ対象を第1引数、"matchType": "boolean"なオブジェクトを第2引数とする。この関数が真値となる場合にマッチし、負値の場合はマッチしない。関数が例外を投げた場合は上位に例外を投げる。

注:展開パターンと同じ仕組みだが、こちらではさらにマッチングができるように制約を緩くしている。

また、JSオブジェクトの中には組み込みのマッチャー(Built-in Custom Matchers)をもつものがある。

プリミティブ型のラッパークラス(Boolean, String, Number, BigInt, Symbol)はそれぞれ組み込みマッチャーを持っており、マッチ対象が当該プリミティブ型(もしくはそのラッパー)である場合にマッチ成功となる。マッチ成功の戻り値は(展開パターン用に)、(可能であればボックス化されていない)プリミティブ値を含むイテレーターである。

class Boolean {
    static [Symbol.customMatcher](subject) {
        return typeof subject == "boolean";
    }
}
/* 以下同様 */

Function.prototypeも組み込みマッチャーを持っており、その関数の[[IsClassConstructor]]スロット、要はクラスのコンストラクタかどうかを確認する。該当する場合、マッチ対象が当該クラスのオブジェクトかどうかを調べる(Array.IsArray()同様のブランドチェック2)。該当しない場合、関数を述語(predicate)としてみなし、マッチ対象を代入した戻り値で判定する。

/* 大体こんな感じ */
Function.prototype[Symbol.customMatcher] = function(subject) {
    if(isClassConstructor(this)) {
        return hasCorrectBrand(this, subject);
    } else {
        return this(subject);
    }
}

これにより、述語関数をx is upperAlphaとマッチャーに書くことができる(訳注:ただし、ここでupperAlpha(aString)という形の関数とする。引数の数が違っても、JSなので気にしないはず)。また、クラス内のオブジェクトに対するマッチャーもx is Option.Someという形で直接記述可能である。

RegExp.prototypeのマッチャーは特殊で、マッチ対象に対して正規表現を適用し、正規表現マッチが成功した場合にマッチとする。

RegExp.prototype[Symbol.customMatcher] = function(subject, {matchType}) {
    const result = this.exec(subject);
    if(matchType == "boolean") return result;
    if(matchType == "extractor") return [result, ...result.slice(1)];
}

ただしここで矛盾する要求が出てくる。

  1. 述語関数を簡単にカスタムマッチャーで使えるようにしたい。
  2. クラスを簡単にマッチャーとして使えるようにしたい。

1番を実現する唯一の方法はFunction.prototypeのカスタムマッチャーが関数を実行してくれることだが、それは2番の唯一の正規の方法でもある(プロトタイプに明示的にnullを持たないという前提)。それを解決するため、class{}構文のデフォルトマッチャーが存在しない場合、コンストラクタメソッドをコピーすることとしている。これが受け入れられない場合は「プレフィックスキーワードで"論理値述語パターン"かどうかを判定する」というような代案を検討するとしている。

正規表現パターン(Regex Patterns)

テスト対象を文字列化した時に当該正規表現とマッチするかどうかを調べる。技術的にはRegExp.prototype[Symbol.customMatcher]を呼び出している。展開パターン同様のカッコつきパターンリストが続く。

束縛パターン(Binding Patterns)

letconstvarキーワードの後に妥当な変数名がある場合、束縛パターンは常にマッチし、束縛が発生する。束縛は変数名に、キーワードに応じた束縛セマンティクスが与えられる。

letconstは一番近いブロックスコープ(訳注:以下letスコープとする)、varは一番近い関数スコープとなる。もしくはforwhileのヘッダーや関数の引数で使われた際のスコープに準じる。

束縛はパターンでの登場順(Presense)に応じるので、束縛パターンがマッチしたかどうかとは無関係である。例えば、[1, 2] is ["foo", let foo]とした場合、最初のマッチが失敗するにもかかわらずfoo変数束縛がブロックスコープで発生する。

通常の巻き上げ禁止領域(TDZ3)ルールは束縛パターンが実行される前に適用される。例えばwhen [x, let x]x変数が最初のパターン時点で初期化される前に参照しているのでReferenceErrorが発生する。

通常の束縛ルールと違い、トップレベル4パターンのスコープでは、束縛型キーワードが同じ限り、与えられた名前が複数の束縛パターンに出てもいい。ただし束縛パターンが複数実行される場合はエラーとなる(ただしorパターンを除く)。この振る舞いは優先度が高い(precedent)。この仕様が決まるまでは正規表現内の名有りのキャプチャグループをユニークにしなければならなかった。現在は分岐するのであれば同じ名前を使えるようになった。例としては/foo(?<part>.*)|(?<part>.*)foo/

(x or [let y]) and (z or {key: let y})

パース可能。両方でylet宣言されているので、y束縛がletスコープで発生する。xzのどちらか片方のみがマッチした場合、'y'は正しく束縛される。両方マッチしない場合はyは初期化されないので、参照すると実行時のReferenceErrorとなる。両方マッチすると、2度目のlet yパターンが実行時ReferenceErrorを投げる。これはすでに初期化されているためにおこる。

(x or [let y]) and (z or {key: const y})

これはyの宣言方法がちぐはぐなので実行前ReferenceErrorとなる。

x and let y and z and if(y == "foo")

パース可能。yがブロックスコープで束縛される。xがマッチしない場合はyが初期化されないものの、ガードパターンがスキップされるので、この時点では実行時エラーは発生しない。

[let x and String] or {length: let x}

パース可能でxが束縛される。

もしも直前のサブパターン(sub-pattern)が失敗しているならorパターンは既に初期化した束縛を上書き(Override)する。そのため、失敗したテストの後の束縛パターンの面倒な調整を必要としない。上のパターンに[5]をマッチさせた場合、最初の長さチェックにパスするので、いったんx5に束縛される。しかし、StringではなくNumberなのでマッチに失敗する。その後のor直後のサブパターンで(訳注:配列の長さが1のため)x1に束縛(上書き)される。

構造パターン(Structure Patterns)

配列パターン(Array Patterns)

角かっこでくくられ、コンマで区切られた0以上のパターン。このテストは以下を表す。

  1. マッチ対象は反復可能である。
  2. マッチ対象は配列パターンとちょうど同じ数の反復アイテムがある。
  3. その中身と対応するサブパターンがマッチする。

例えば["foo", {bar}]にマッチする対象は、

  1. 反復可能である。
  2. まさに2つのアイテムを持つ。
  3. 最初のアイテムはfooという文字列である。
  4. 2つ目のアイテムはbarというプロパティを持つオブジェクトである。

配列パターンの最後のアイテムは「レスト」形式で記述できる。例えば[a, b, ...]とすればマッチ対象の配列の長さが2以上の場合にマッチする。

パターンが続く場合、例えば[a, b, ...let c]とするとイテレータは最後まで実行され(fully exhausted)、残りのアイテムは配列とされ、レストパターンのマッチ対象となる。

注:つまり[a, b]はマッチ対象に対し3回検査される。サブパターンabとのマッチと、マッチ対象が3つ目のアイテムを持っていないことの検査である。一方[a, b, ...]は2つのアイテムに対するサブパターンのみが検査される。最後に、[a, b, ...c]はイテレータを最後まで実行し、少なくとも最初の2つのアイテムがあり、かつサブパターンと一致することを確認して、残りの反復とレストパターンとのマッチを試みる。

実行順
  1. マッチ対象からイテレータを取得する。取得できなかった場合はマッチは不可となる。
  2. それぞれのサブパターン分(ただしレストパターンを除く)のアイテムに対して、
    1. イテレータから値を取得する。失敗した場合はマッチは不可となる。
    2. 対応するパターンを実行する。マッチしなかった場合は不可となる。
  3. レストパターンが存在しない場合、イテレータからもう1度アイテムを取得し、{done: true}が取得できることを確認する。取得できた場合はマッチ成功とする。取得できなかった場合はマッチ不可とする。
  4. レストパターンが...の場合、マッチ成功とする。
  5. レストパターンが...<pattern>の場合、イテレータの残りのアイテムを新しいArrayに移し、それに対してパターンマッチを試みる。マッチが成功した場合はマッチ成功とする。失敗した場合はマッチ不可とする。

検討事項
最初に必要な値を取ってからマッチャーにかけた方がよいだろうか?

match (res) {
  when isEmpty: ...;
  when {data: [let page] }: ...;
  when {data: [let frontPage, ...let pages] }: ...;
  default: ...;
}

配列パターンは非明示的にマッチ対象の長さをチェックする。マッチャーはそれぞれ以下のとおりである。

  1. 変数パターンFunction.prototypeのカスタムマッチャーが呼び出されるので、isEmpty(res)が実行され、それがtrue`を返す時にマッチする。
  2. 配列パターンを含むオブジェクトパターンdataが1項目のみ持つ場合にマッチする。その場合pageを右辺値(RHS)としてpage変数に束縛する。
  3. 配列パターンを含むオブジェクトパターン。data最低1項目ある場合にマッチする。その場合frontPageに最初の項目を束縛して、配列の残りをpagesに束縛する。
  4. デフォルト句。
キャッシング(Array Pattern Caching)

ジェネレータを定型句的に利用したり、一発限りのイテレータをいくつかの配列パターンにマッチさせるために、イテレータとイテレート結果をマッチ構成式(match construct)内でキャッシュし、使いまわせるようにしている。具体的には、マッチ対象が配列パターンであれば、マッチ対象をキャッシュキーとして使い、その値をマッチ対象から取得したイテレーターとする。配列パターンによりすべての値が取得される。

配列パターンとマッチした場合は、キャッシュがまずチェックされ、すでに取られたアイテムがパターンで利用され、新しいアイテムは必要になるまで取得されない。

function* integers(to) {
  for(var i = 1; i <= to; i++) yield i;
}

const fiveIntegers = integers(5);
match (fiveIntegers) {
  when [let a]:
    console.log(`found one int: ${a}`);
    // ジェネレータに対して配列パターンが適用される。
    // イテレータ(つまりジェネレータ自身)を取得し、
    // 2つのアイテムを取得する。
    // 一つ目は`a`パターンとマッチ(成功)し、
    // 2つ目はイテレータのアイテムが1つだけであるかどうか調べる(これは失敗する)。
  when [let a, let b]:
    console.log(`found two ints: ${a} and ${b}`);
    // 再度配列パターンが適用される。
    // じぇてれーたオブジェクトはキャッシュされているので、
    // キャッシュされた結果を取得できる。
    // 取得するアイテムは3つで、
    // 2つはそれぞれのパターンマッチングが当てられ、
    // 最後の1つはイテレータの長さが2であることの確認に使われる。
    // すでに2つキャッシュされているので、あと一つ値を取得する(そしてパターン失敗する)。
  default: console.log("more than two ints");
}
console.log([...fiveIntegers]);
// [4, 5]が出力される。
// マッチ構成子内で3要素が消費されているため、残りは2つ。

マッチ構成子の実行が終了した時点でキャッシュは消去される。

オブジェクトパターン(Object Patterns)

波括弧でくくられた、0個以上の「オブジェクトパターン句」の集まり。オブジェクトパターン句とは<key>let/var/const <ident>もしくは<key>: <pattern>で、<key>は識別子、もしくは[Symbol.foo]のように計算されたキー式とする。この式はテスト対象の以下をテストする。

  1. すべての明示されたプロパティをプロトタイプチェインに持っている。
  2. キーにサブパターンがある場合はプロパティがサブパターンとマッチするかどうか調べる。

<key>オブジェクトパターン句は<key>: voidと書くのと同じである。同様にlet/var/const <ident>オブジェクトパターン句は<ident>: let/var/const <ident>と書くのに相当する。つまりwhen {foo, let bar, baz: "qux"}と書くとwhen {foo: void, bar: let bar, baz: "qux"}と書いたことになる。つまり、テスト対象がfoobarbazというプロパティを持ち、barプロパティの値はbar変数に束縛され、bazプロパティは"qux"という文字列であるかどうか調べられる。

さらに、オブジェクトパターンもレストパターンを使うことができる。ただし...のみのパターンを使うことはできず、マッチャーパターンとセットになる(訳注:もともとオブジェクトパターンが「すべての明示されたプロパティを…持っている」ことしか調べないため、余計なプロパティがある場合のチェックを外す必要が無い)。レストパターンがある場合は、すべてのまだマッチしていない、列挙可能な所有プロパティ(all enumerable own properties that aren't already matched by object pattern clauses)を新しいオブジェクトにコピーし、レストパターンとマッチ試行される(これはオブジェクト分割代入に対応する)。

検討事項

  • key?: patternパターン句は必要だろうか? この句はもしマッチ対象がそのプロパティを持っていたらパターンマッチするかどうかを検証する。プロパティが存在しない場合はスキップして、それ以外の束縛をさもスキップされたかorパターンが適用されたのと同様に扱う。
  • __proto__は使用禁止にするか?
実行順
  1. ソース順で、レストされていないオブジェクトパターン句のkey: sub-patternに対して、
    1. マッチ対象がkeyプロパティを持っているか調べる(inもしくはHasProperty()と同様の方法で)。失敗した場合はマッチは不可となる。
    2. keyプロパティの値を取得し、sub-patternとのマッチを試みる。マッチ不可の場合マッチ不可となる。
  2. レストパターン句がある場合、すべてのまだマッチしていない、列挙可能な所有プロパティを新しいObjectにコピーし、レストパターンとマッチを試行する。マッチ不可の場合マッチ不可となる。
  3. マッチ成功とする。
キャッシング(Object Pattern Caching)

何度も同じ値を取得しないように、配列パターンのキャッシング同様、マッチ構成子のスコープ内でオブジェクトパターンキャッシュが使いまわされる。

(配列パターンのキャッシング(必須だった)と違い、こちらは「あると便利」な機能である。冪等ではないゲッターの不可思議な挙動に対するガードとなり、冪等だが重たいゲッターをコントーション(contortions)5なく使用できる。しかし、基本的にはコンセプトの一貫性を保つためである。

マッチ対象がオブジェクトパターンとマッチした際に、オブジェクトパターンのプロパティ名ごとに、(<subject>, <property name>)というタプルをキャッシュキーとし、当該プロパティの値をキャッシュする。

オブジェクトパターンと何かしらマッチした場合、まずはキャッシュが調べられ、マッチ対象とプロパティ名がキャッシュに既にある場合、値が新規ゲッター取得の代わりにキャッシュから取得される。

const randomItem = {
  get numOrString() { return Math.random() < .5 ? 1 : "1"; }
};

match (randomItem) {
  when {numOrString: Number}:
    console.log("Only matches half the time.");
    // パターンがマッチするかどうかにかかわらず、
    // 結果から(randomItem, "numOrString")のペアをキャッシュする。
  when {numOrString: String}:
    console.log("Guaranteed to match the other half of the time.");
    // (randomItem, "numOrString")がすでにあるので、再利用する。
    // 最初の句の段階で文字列だったらここでも文字列である。
}

検討事項
これはさらにキャッシングを導入することとなり、その主な役目はイテレータキャッシングがトップレベルとオブジェクトでネストされた場合の両方が動作することである。高くつくかべき不当なゲッターは恩恵をあずかれるが、それは重要な利点ではない。キャッシングを廃止することもできるが、それだとトップレベルでしかキャッシングがされないことになってしまう。

展開パターン(Extractor Patterns)

括弧の「引数リスト」が後に続く、配列パターンのマッチャーと同様の書式を持つ、ドットで区切られた識別子(dotted-ident)。カスタムマッチャーパターンと配列パターンの複合形式である。

  1. ドットで区切られた識別子が束縛に引っかかり、その結果がSymbol.customMatcherプロパティを持つオブジェクトで、かつそのプロパティが関数だった場合、2に続く。そうでない場合はXXX(訳注:詳細が煮詰まっていない模様)エラーを投げる。
  2. マッチ対象を第1引数、"matchType": "extractor"なオブジェクトを第2引数としたカスタムマッチャー関数が実行される。その戻り値を戻り値とする。

注:カスタムマッチャーが真値か偽値かを調べるだけだったのに対し、展開パターンはその値がtruefalseArray、反復可能オブジェクトのいずれかでなければならない。

引数リストパターン(Arglist Patterns)

展開パターンのサブパターンで、ほぼ配列パターンと一緒である。角かっこの代わりにかっこで仕切られている。その動作は(訳注:配列パターンと)いくつか異なる。

  • マッチ対象がfalseの場合は常にマッチに失敗する。
  • マッチ対象がtrueの場合は空の配列のように扱われる。
  • マッチ対象がArray`の場合は、イテレータプロトコルを実行するのではなく、インデックスごとのマッチとなる。

マッチ対象がArray`の場合は以下のようにマッチが行われる。

  1. 引数リストパターンがレストパターンで終わらない場合は、マッチ対象のlengthプロパティが正しくパターンの長さと一致しなければマッチ成功とならない。
  2. 引数リストパターンがレストパターンで終わる場合は、マッチ対象のlengthプロパティがパターンの長さ以上でなければマッチ成功とならない。
  3. レストではない各サブパターンについて、対応する番号のマッチ対象のプロパティが取得され、対応するサブパターンとのマッチが試行される。
  4. もし最後のサブパターンが...<pattern>なら、残りの番号のマッチ対象のプロパティ(ただしlengthを除く)が取得され、新しいArrayにコピーされ、当該パターンに対してマッチが試行される。
  5. 上記のいずれかのマッチが失敗したらマッチ失敗。そうでなければマッチ成功とする。

上記を除けば引数リストパターンは配列パターンと同様に実行される。

検討事項
配列パターン同様のキャッシュは必要か?

注:Arrayをこのように扱うのは実装側のフィードバックで、パフォーマンスのためだ。イテレータープロトコルの実行はコストが高く、期待される利用方法が複雑な反復可能オブジェクトではなくただArrayを返すだけの場合のカスタムマッチャーを使うのを面倒に(discourage)したくない。(現在)イテレータープロトコルを配列マッチャーと結びつけて、マッチ分割代入させる案を取っているが、変更する可能性がある。

正規表現展開パターン(Regex Extractor Patterns)

正規表現パターンと同様、通常のカスタムマッチャーを実行する。正規表現リテラルの後に引数リストパターンをつなげると、通常の展開パターンが使われる。

この目的では、成功した場合の「戻り値」(引数リストパターンに対してマッチするもの)は正規表現結果オブジェクトの後に正の数で番号付けされた正規表現結果のグループ、つまり全体マッチを除くすべてのマッチ結果となる。

実行順は展開パターンと同じだが、1は正規表現パターンで、2のマッチ対象は上記で記した通りとなる。

したがってRegExp.prototypeの正式な定義は以下の通り。

RegExp.prototype[Symbol.customMatcher] = function(subject) {
    const result = this.exec(subject);
    if(result) {
        return [result, ...result.slice(1)];
    } else {
        return false;
    }
}
match (arithmeticStr) {
  when /(?<left>\d+) \+ (?<right>\d+)/({groups:{let left, let right}}):
    // 名前付きキャプチャグループを使用
    processAddition(left, right);
  when /(\d+) \* (\d+)/({}, let left, let right):
    // Using positional capture groups
    processMultiplication(left, right);
  default: ...
}

結合パターン

パターン同士をつなげるための構文。

and パターン

2つ以上のパターンをandでつなげると、すべてのパターンを満たすときのみにマッチ成功となるパターンを書くことができる。どんなパターンでも括弧でくくることができる(時々必須)。

実行順
  1. それぞれのサブパターンにおいて、ソースの順に、テスト対象をサブパターンにマッチさせる。そのどれかが失敗したらマッチ失敗とする。
  2. マッチ成功とする。
orパターン

2つ以上のパターンをorでつなげると、いずれかのパターンを満たした場合にマッチ成功となるパターンを書くことができる。どんなパターンでも括弧でくくることができる(時々必須)。

ショートサーキットが適用され、1度でもマッチが成功したらそれ以降の条件は読み飛ばされる。

実行順
  1. それぞれのサブパターンにおいて、ソースの順に、テスト対象をサブパターンにマッチさせる。そのどれかが成功したらマッチ成功とする。
  2. マッチ失敗とする。

注:束縛の詳細で説明した通り、失敗したサブパターンでの束縛パターンが上書きされる場合がある。例えば[let foo] or {length: let foo}はパース時も実行時にも妥当であるが、2回上書きされる可能性がある。[1, 2]を与えた場合など。

notパターン

パターンの前にnotを追加すると、サブパターンのマッチに失敗した場合にマッチ成功となるパターンを書くことができる。どんなパターンでも括弧でくくることができる(時々必須)。

結合パターンの結合(Combining Combinator Patterns)

複数の結合パターンを同じ「レベル」に入れてはいけない6。各パターンに優先度を設定していないため、優先度指定のために明示的にかっこでくくる必要がある。

foo and bar or bazは構文エラー。(foo and bar) or bazもしくはfoo and (bar or baz)としなければならない。
同様にnot foo and barも構文エラー。(not foo) and barもしくはnot (foo and bar)としなければならない。

ガードパターン(Guard Patterns)

if(<式>)とすると、式が真値を返す場合にマッチが成功する。ただしこの式はパターンを除く、任意のJS式とする。

match

パターンを使い、いくつかの式に解決する全く新しい式。文だと式として使えないので却下。

match(<マッチ対象の式>) {
    when <パターン>: <値の式>;
    when <パターン>: <値の式>;
    ...
    default: <値の式>;
}

つまり、matchの頭は<マッチ対象の式>を含んでいる。ここで<マッチ対象の式>はマッチ対象を評価する任意のJS式である。

matchブロックは0以上の「マッチ節(match arms)」を含む。マッチ節は以下から構成される。

  1. whenキーワード
  2. パターン
  3. コロン文字「:」
  4. 任意のJS式
  5. セミコロン(通常のJSと違い必須なので注意!)

マッチ節の後に、任意で「オプション節(default arm)」を含むことができる。内容は以下のとおり。

  1. defaultキーワード
  2. コロン文字「:」
  3. 任意のJS式
  4. セミコロン(同様に必須)

マッチ対象を取得した後、マッチ節とのマッチが試される。マッチに成功した場合、マッチ節の式が評価され、マッチ式の結果となる。すべてのマッチ節とのマッチに失敗した場合、デフォルト節があれば、その式が評価され、マッチ式の結果となる。(訳注:すべてのマッチ節とのマッチに失敗し、)デフォルト節が無い場合、match式はTypeErrorを返す。

matchを式として使えるようにするため、<値の式>は2行以上書くことができない。その解決策として「do式(do-expression)」を用いる。do式はIIFEの代替となる、任意の宣言を式として扱うための仕様。現在ステージ1のため、使うことができない。詳細はこちらの記事を参照。そちらの仕様の進捗が無ければ、何らかの形(単純にdo式の機能をこちらの仕様に移植するなど)で複数の文を書ける形にする予定。

束縛(Bindings)

<マッチ対象の式>はletスコープの一部である。マッチ節とデフォルト節はそれぞれ独立した、ネストされたブロックスコープを持ち、それはパターンと節の式部分に適用される。即ち、他の節の束縛を見ることはできないし、束縛がmatch式の外に漏れることも無い。それぞれのアームの中で、外側スコープの束縛はシャドーイングされる。

match (res) {
  when { status: 200, let body, ...let rest }: handleData(body, rest);
  when { const status, destination: let url } and if (300 <= status && status < 400):
    handleRedirect(url);
  when { status: 500 } and if (!this.hasRetried): do {
    retry(req);
    this.hasRetried = true;
  };
  default: throwSomething();
}

「レスポンス」オブジェクトに対しいくつかのパターンをテストする例。.statusプロパティにより分岐され、レスポンスの異なるパーツをそれぞれの節で抜き出して様々なハンドリング関数を呼び出している。


match (command) {
  when ['go', let dir and ('north' or 'east' or 'south' or 'west')]: go(dir);
  when ['take', /[a-z]+ ball/ and {let weight}]: takeBall(weight);
  default: lookAround()
}

テキストベースのアドベンチャーゲームにおけるパーサー。

最初のマッチ節はコマンドが2単語の場合からなる配列の場合にマッチする。1つ目は'go'でなければならない。2つ目は4方位のいずれかである必要がある。ここでandパターンを使い、2つ目の文字列を検証前に束縛パターンで使う前に束縛していることに注目。

2つ目のマッチ節はやや複雑。(訳注:与えられたコマンド配列の2つ目のアイテムのマッチング)まず正規表現パターンでオブジェクトをtoString()した値が「任意の英単語 ball」であることを確認し、オブジェクトパターン.weightプロパティを持っていることを確認してweight変数に束縛し、節の式でweightが使えるようにしている。

is 演算子(is operator)

新しい論理演算子で、<マッチ対象の式> is <パターン>という書式。戻り値はマッチ対象がパターンにマッチする場合にtrueとなる。

束縛

isによる束縛は束縛パターンでも説明した通りletスコープである。これはif()文で使われたときも同様。

function foo(x) {
    if(x is [let head, ...let rest]) {
        console.log(head, rest);
    } else {
        // `head`と`rest`はここでは定義されているが、
        // 参照するとReferenceErrorになる。
        // パターンが失敗すると束縛が実行されないため。
    }
}

function bar(x) {
    if(x is not {let necessaryProperty}) {
        // `x.necessaryPropertyが存在しない場合マッチ成功。
        return;
    }
    // `x.necessaryProperty`が存在するとマッチ失敗。
    // したがって束縛が発生する。
    // `necessaryProperty`の束縛は参照可能。
    console.log(necessaryProperty);
}

for()で使われた場合、通常通りの束縛スコープが適用される。束縛はfor()の先頭かっこ内とブロックにスコープされる。for-ofの場合は内部のイテレーションごとの束縛スコープにコピーされる。

現状ではwhiledo-whileのかっこに対しては特別なスコープルールを設けていない。for-ofと同様のルールにしたいと考えており、束縛がイテレーションごとの{}ブロックのスコープにコピーされる。do-whileの束縛はかっこ内が実行されるまで巻き上げ禁止とする。

実践的な例(Motivating examples)

パターンマッチングが使われそうな例を集めた。


JSONの構造検査。

単純な分割代入で、事前チェックが何もなされていない場合。ただ値を取ってきて問題無いことを祈る。

var json = {
  'user': ['Lily', 13]
};
var {user: [name, age]} = json;
print(`User ${name} is ${age} years old.`);

分割代入に、全部正しい期待する値が入っていることのチェックを追加したもの。

if ( json.user !== undefined ) {
  var user = json.user;
  if (Array.isArray(user) &&
      user.length == 2 &&
      typeof user[0] == "string" &&
      typeof user[1] == "number") {
    var [name, age] = user;
    print(`User ${name} is ${age} years old.`);
  }
}

このチェックをパターンマッチングに変更すると…

if( json is {user: [String and let name, Number and let age]} ) {
  print(`User ${name} is ${age} years old.`);
}

fetch()レスポンスにパターンマッチする。

const res = await fetch(jsonService)
match (res) {
  when { status: 200, headers: { 'Content-Length': let s } }:
    console.log(`size is ${s}`);
  when { status: 404 }:
    console.log('JSON not found');
  when { let status } and if (status >= 400): do {
    throw new RequestError(res);
  }
};

Reduxリデューサーの簡単で関数的なハンドリングの例(Redux公式サイトの例(アーカイブ版)と比較せよ)。

function todosReducer(state = initialState, action) {
  return match (action) {
    when { type: 'set-visibility-filter', payload: let visFilter }:
      { ...state, visFilter };
    when { type: 'add-todo', payload: let text }:
      { ...state, todos: [...state.todos, { text, completed: false }] };
    when { type: 'toggle-todo', payload: let index }: do {
      const newTodos = state.todos.map((todo, i) => {
        return i !== index ? todo : {
          ...todo,
          completed: !todo.completed
        };
      });

      ({
        ...state,
        todos: newTodos,
      });
    }
    default: state // ignore unknown actions
  }
}

JSXの簡単な条件分岐(Devid Singhさん提供)。

<Fetch url={API_URL}>
  {props => match (props) {
    when {loading}: <Loading />;
    when {let error}: do {
      console.err("something bad happened");
      <Error error={error} />
    };
    when {let data}: <Page data={data} />;
  }}
</Fetch>

改良の余地(Possible future enhancements)

void パターン

voidキーワードは常にマッチに成功し、何もしない。構造パターンで、プロパティの存在にしか興味が無いときに使える。

これはメイン提案に動かすための提案で、void 束縛との一貫性を保つために別項としている。

async match

もしawaitが許容されるコンテキストの中にmatchがあれば、do式内部と同様awaitは使うことができる。しかし、async do式と同様に、awaitを中で使ってPromiseを出力する使い方がある。それはasync functionの中でなくても。

関係パターン(Relational Patterns)

等価とクラスマッチャー(一種のinstanceof)は既に存在するが、演算子ベースのチェックを加えるかもしれない。例えば…

match(val) {
    when < 10: console.log("small");
    when >= 10 and < 20: console.log("mid");
    default: "large";
}

基本的には2項論理演算子が使えて、演算子の左辺(LHS)が省略された形。

(これはちょっと将来的なパターン拡張の足かせになるかもしれない。しかし既存の演算子を式のコンテキストで違う形で利用しようとは思えない)

デフォルト値

分割代入は= <式>という形でキーが存在しない場合のデフォルト値を指定できる。これはパターンマッチングで使えるだろうか?

オプションキーは十分にあり得る。現在は({a, b} or {a})bが存在しない場合は右辺でundefined扱いになる)という風にパターンを重複しなければならない。

フルでデフォルトするのは必要か(もしくはしたいか)? 任意のJS式をそこに書くと書式が複雑化しないか? 周りのパターンと区別するためのラッパー文字みたいな工夫が必要ではないか?

これがあれば分割代入とお揃いになるのはよい。

分割代入の改善(Destructuring enhancements)

分割代入とパターンマッチングは仕様を合わせなければならない。したがって一方を改善するともう一方を直さなければならない。

catchとの統合(Integration with catch

インデントを減らすためにcatch句の中で条件付きキャッチができたらいいのではないか。

try {
  throw new TypeError('a');
} catch match (e) {
  when RangeError: ...;
  when /^abc$/: ...;
  // マッチしないのでeを投げる。
}

あるいはcatchのカッコ内でisをかけたらいいのではないか。

try {
  throw new TypeError('a');
} catch (e is RangeError) {
    ...
} catch (e is /^abc$/) {
    ...
}

ガードの連結(Chaining guards)

パターンを繰り返さないと非効率的な場合がある。

match (res) {
  when { status: 200 or 201, let pages, let data } and if (pages > 1):
    handlePagedData(pages, data);
  when { status: 200 or 201, let pages, let data } and if (pages === 1):
    handleSinglePage(data);
  default: handleError(res);
}

マッチ構成子をつなげられたらいいと思う。子マッチ構成子は親のマッチから受け継いだ束縛を見て、サブクラスがマッチしなかったら親も失敗するというのはどうだろう。それだったら上の式はこう書ける。

atch (res) {
  when { status: 200 or 201, let data } match {
    when { pages: 1 }: handleSinglePage(data);
    when { pages: >= 2 and let pages }: handlePagedData(pages, data);
  };
  default: handleError(res);
  // ステータスがエラーを返した場合、もしくは
  // pages == 0 の場合など、
  // データが上記のケースにマッチしない場合にここに来る。
}

<マッチ対象の式>が子マッチに無いことに注意してほしい(match {...})。これにより内部に独立したマッチを作った場合(この場合だと内部のマッチがすべて失敗するとエラーになる)との違いが分かりやすくなる。

match (res) {
  when { status: 200 or 201, let data }: match(res) {
    when { pages: 1}: handleSinglePage(data);
    when { pages: >= 2 and let pages}: handlePagedData(pages, data);
    // ただの右辺値なので、pages == 0の場合は
    // 内部のマッチ構成子がどれにもヒットせず、TypeErrorとなる。
  };
  default: handleError(res);
}

後は区切りのコロンの有無でも見分けられる。

(以上、抄訳終わり)

個人的な感想

抄訳と書きましたが、ほぼ全項目をかなり細かく翻訳していました。

かなり野心的で、JavaScriptの書き方のかなりの部分を見直す形になる提案ではないでしょうか。特にオブジェクトの内容をJavaScriptらしい書き方で検査できる点が良いと思いました。パターンマッチングのチェックがあれば、バニラJSでも推論がやりやすかったり、安全なコードを書きやすいですね!

C#やJavaと違い、switchの拡張としなかった点が、過去の遺産に悩まされ続けているJavaScriptらしいというか。

個人的に気になるのはTypeScriptとの連携です。型の絞り込みに使えそうな構文ですし、どういう形で使えるようになるのかは注目です。

  1. そのため実際にはプリミティブパターンの比較はSameValueZeroであるが、翻訳元の記載通りにしている。過去の実装におけるごたごたがあるのでSameValueZeroを直接使いたくないため別口にしているようだ。

  2. クラスが当該のコンストラクタから生成されたことを確認すること。詳細

  3. Temporal Dead Zone

  4. この場合のトップレベルはマッチャー内の一番表層という意味。トップレベル関数とは別物であることに注意が必要。

  5. ねじれ、ひずみの意。この場合は取得ロジックが走った際に他の値の取得が止まってしまったり、途中で値が変わったりしないようにすることを指すか。意味をとらえかねたので敢えて訳さない方法を取った。

  6. ただし、以降の説明を見るに、同じパターンはかっこで無理やり区切らずにひとくくりにしてよいようだ。この辺りの説明が省略されているため分かりづらい。

3
4
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
4