coffeescriptの(別の)パターンマッチング

はじめに



私が座って、研究の一環として書かれたエルランジア語コードを悲しげに見たとき。 三目並べよりももっと便利なものを書きたかったのですが、運が良ければそれは私には起こりませんでした。 しかし、1次関数、カリー化、マップ/フィルター/フォールドがあり、最も重要なことには、タスクの作成がはるかに簡単なJavaScriptがあります。 ただし、パターンマッチングはありません。 簡単な検索でいくつかのライブラリが得られましたが、彼らが提案した構文は重く思えました。 ネイティブのErlang構文により近く、より簡潔にすることは可能ですか?



ネタバレ:coffeescriptを使用する場合、これを行うことができます:



fn = Match -> [ When {command: “draw”, figure: @figure = {type: “circle”, radius: @radius}}, -> console.log(@figure, @radius) When {command: “draw”, figure: @figure = {type: “polygon”, points: [@first, @second | @rest]}}, -> console.log(@figure, @first, @second, @rest); ] fn {command: “draw”, figure: {type: “circle”, radius: 5, color: “red”}} #output: {type: “circle”, radius: 5, color: “red”} 5
      
      





誰がこれが起こったか気にします-猫へようこそ。



簡単な説明



実際、ここで何が起こるか、要するに:

  1. Matchは、パターンの配列とそれに対応するアクションを返す関数を取ります。
  2. 呼び出されると、コンテキストが変更され、これらすべての&コンマ;(== this.a)が特別に準備されたプロパティ(プロパティ)を指し、パーサーがバインドする値を理解できるようになります。
  3. 次に、特定の値で呼び出すと、テンプレートとの比較が行われます(今のところ、テンプレートと値と特定の値の比較の再帰的な走査があると仮定できます。これについてはもう少し詳しく説明します)。
  4. 値がパターンと一致する場合、アクション関数が呼び出されます。 彼女では、コンテキストも変更し、適切な値に置き換えます。


したがって、上記の例を使用する場合、最初のWhen引数の&コンマ;半径は、入力オブジェクトのどの部分を削除する必要があるかを示し(この場合は.figure.radius)、2番目の引数(関数)にも特定の値が含まれます。



配列を操作する



Erlangには、リストを先頭(最初の要素)と末尾(その他すべて)に分割するための便利な構文があり、さまざまな再帰アルゴリズムに広く使用されています。



 case List of [Head | Tail] -> Head; [] -> {empty}; end.
      
      





javascript(およびcoffeescript)では、演算子をオーバーライドする方法がないため、標準ツールを使用すると次のようなことしかできません。



 When [@head, @tail…], -> console.log(@head, @tail)
      
      





原則として、悪くはありませんが、アーランではなんとなくきれいです。 少なくともいくつかのシナリオでは、どういうわけかそれは可能ですか?

ここで、javascriptが一般的に次のような操作を実行する方法を思い出す価値があります。



 var object1 = {x:1}, object2 = {y: 2}; console.log(object1 | object2);
      
      





0を取得します。これは、javascriptが最初に型を数値にキャストしようとし、オブジェクトのvalueOfメソッドを呼び出すために機能します。 メソッドをオブジェクトに置き換えて2のべき乗を返すと、結果として、操作が適用されたオブジェクトを見つけることができます。



 var object1 = {x:1}, object2 = {y: 2}, object3 = {z: 3}; object1.valueOf = function() { return 2; } object2.valueOf = function() { return 4; } object3.valueOf = function() { return 8; } console.log(object1 | object2); //6 == 2 | 4 == object1 and object2
      
      





誰もがテンプレートで特定の数値の配列を使用することは非常にまれであるという大胆な仮定が行われたため、パーサーが配列の最後で数値に遭遇すると、これが2つのオブジェクトのor操作の結果であるかどうかを判断しようとします。 その結果、次のように書くことが可能になりました。



 When [@head | @tail], -> console.log(@head, @tail)
      
      





良さそうに見えますが、このタスク以外では、この方法をどこでも使用しません。



クラスマッピング



次に私が望んだのは、Erlangのような構造マッピングを行い、タイプとコンテンツを示すことでした。



 #person{name = Name}
      
      





もちろん、直接成功するわけではありませんが、同様のことができます。 最終的に、私は3つの解決策に落ち着きました:



 When ObjectOf(Point1, {x: 1, y: 2}), -> … When Point2(x:1, y:2), -> … When Point3$(x:1, y:2), -> ...
      
      





1つ目はそのまま使用でき、2つ目はほとんどscalaのcaseクラスのように見えますが、そのような行をコンストラクターに挿入する必要があります。



 class Point2 constructor: (@x, @y) -> return m if m = ObjectOf(@, Point2, arguments)
      
      





これは、関数がコンストラクターとして呼び出されたかどうか、コンストラクターと引数がテンプレートに該当するかどうかを理解するために必要です。



3番目のオプションは、最初のテーマのバリエーションです。事前に関数を準備するだけです。



 Point3$ = ObjectOf(Point3)
      
      





性能



最初の単純なバージョンでは、テンプレートと値の比較を実行し、それらを再帰的に渡しました。 原則として、オブジェクトの単純な解析と比較すると、パフォーマンスは標準に達しないと予想していました。 しかし、確認する価値がありました。



手動解析
 coffeeDestruct = (demo) -> {user} = demo return if not user.enabled {firstname, group, mailbox, settings} = user return if group.id != "admin" notifications = settings?.mail?.notify ? [] return if mailbox?.kind != 'personal' mailboxId = mailbox?.id ? null {unreadmails, readmails} = mailbox; return if unreadmails.length < 1 firstUnread = unreadmails?[0] ? [] restUnread = unreadmails?.slice(1) ? [] return if readmails?.length < 1 return if readmails?[0]?.subject != "Hello" rest = readmails?.slice(1) return {firstname, notifications, firstUnread, restUnread, rest, mailboxId}
      
      







模様
 singlePattern = Match -> [ When {user: { firstname: @firstname, enabled: true, group: {id: "admin"}, settings: {mail: {notify: @notifications}}, mailbox: { id: @mailboxId, kind: "personal", unreadmails: [ @firstUnread | @restUnread ], readmails: [ {subject: "Hello"}, Tail(@rest) ] } }}, -> "ok" ]
      
      









10,000件の比較結果:



レギュラー:5ms
単一パターン:140ms
複数のパターン:429ms


はい、実稼働環境で見たいものではありません。 最初の例に近いコードにテンプレートを変換してみませんか?



すぐに言ってやった。 テンプレートに従って、条件と中間変数のリストを作成します。



興味深い機能がここに登場しました。 コンパイルされた関数の最初のバージョンは、手書きの解析とほとんど同じでしたが、パフォーマンスは1.5倍よりも劣っていました。 違いは、結果のオブジェクトの作成方法にありました。指定されたフィールドを使用してオブジェクトを作成する方が、空のオブジェクトを作成して後で入力するよりも安価であることがわかりました。 検証のために、ここでそのようなベンチマークを作成しました。 その後、私はこの主題に関する2つの記事を見つけました-そして、 ここにあります-そして、 ハブの翻訳も



実行された最適化、実行:



レギュラー:5ms
単一パターン:8ms
複数のパターン:164ms


2番目の数字は良いように見えますが、3番目の数字は何で、なぜそれがまだ大きいのですか? 3番目は、最後のパターンのみが起動する複数のパターンを持つ一致式です。 テンプレートは独立してコンパイルされるため、テンプレートの数に線形に依存します。



しかし、実際には、テンプレートは非常に似ています。詳細が異なるオブジェクトを逆アセンブルし、同時に同じ構造を持ちます。 ここで言いましょう:



 fn = Match -> [ When ["wait", "infinity"], -> console.log("wait forever") When ["wait", @time = Number], -> console.log("wait for " + this.time + "s") ]
      
      





どちらの場合も、配列は2つの要素で構成され、最初の要素は「待機」です。違いは2番目の要素のみです。 そして、パーサーは2つのほぼ同一の関数を作成し、それらを1つずつ呼び出します。 それらを組み合わせてみましょう。



意味は簡単です:

  1. パーサーは代わりに「コマンド」のチェーンを発行します。
  2. その後、すべてのチームが編成され、ブランチを持つ1つのチェーンにまとめられます。
  3. 現在、チームはコードに変わっています。


1つのチェーンに入った場合、失敗した場合は外に出てはならず、次のチェーンを試してください。 これを達成する3つの方法を見ました。



1.ネストされた用語



 if (Array.isArray(val)) { if (val.length === 2) { if (val[0] === 'wait') { if (val[1] === 'infinity') { return {when: 0}; } if (val[1].constructor.name === 'Number') { return {when: 1}; } } } }
      
      





それはひどく見え、コードを生成するときでさえ、括弧で混乱することはないでしょう。 いや



2.入れ子関数



 if (!Array.isArray(val)) return false; if (val.length !== 2) return false; if (val[0] !== 'wait') return false; if (res = function fork1() { if (val[1] !== 'infinity') return false; return {when: 0} }()) return res; if (res = function fork2() { if (val[1].constructor.name !== 'Number') return false; return {when: 1}; }()) return res;
      
      





良く見えます。 しかし、外部関数からすぐに戻る方法がないため、追加のチェックとリターンは負担になります(例外を除いて、ただしこれは重大ではありません)。



3. 不正なラベルの破壊



 if (!Array.isArray(val)) return false; if (val.length !== 2) return false; if (val[0] !== 'wait') return false; fork1: { if (val[1] !== 'infinity') break fork1; return {when: 0} } fork2: { if (val[1].constructor.name !== 'Number') break fork2; return {when: 1}; }
      
      





それはよさそうだし、私にとっては、古いオープナーとして、このオプションはより速くなるとすぐに思われました。 jsperfの簡単なチェックにより、私の思いが裏付けられました。 したがって、このオプションで停止します。



パフォーマンスを見てみましょう。



レギュラー:5ms
単一パターン:8ms
複数のパターン:12ms


まったく問題ありません。 そのままにしておきます。



アーキテクチャとプラグイン



2つの異なる場所に新しいifを追加して別の機能を追加した後、アーキテクチャを再設計することにしました。 現在、解析とレンダリングという2つの大きな関数の代わりに、解析とレンダリングという小さな関数があり、これら自体は実際には何もしませんが、テンプレートの各部分にはプラグインのチェーンが送信されます。 各プラグインでできること:



たとえば、コンストラクタと一致するプラグインは次のようになります。



 function pluginConstructor() { return { //        //    ,       parse_object //  parse,       parse_function: function(part, f) { //    "constructor" //        - .   f.addCheck("constructor", part.name); }, //    ""    "constructor" //  ,     if. render_constructor: function(command, varname) { return varname + ".constructor.name === " + JSON.stringify(command.value); } }; }
      
      





これにより、一方では新機能の追加が簡単になり、他方ではユーザーが独自のプラグインを追加してテンプレートの構文を拡張できるようになりました。 たとえば、次のように記述できるように、正規表現のサポートを追加できます。



 fn = Match -> [ When @res = /(\d+):(\d+)/, -> hours: @res[1], mins: @res[2] # or When RE(/(\d+):(\d+)/, @hours, @min), -> hours: @hours, mins: @mins ]
      
      





他のソリューションとの比較



冒頭で書いたように、私は同様の解決策を探してみましたが、見つかったのは次のとおりです。



matches.js、pun、および手動解析とのパフォーマンスの比較はこちらにあります



おわりに



それだけです。 コード自体はここで見ることができます 。 coffeescriptによって構文が強化されているという事実にもかかわらず、ライブラリ自体はjavascriptで記述されており、jsでコンパイルされた他の言語で使用できます。



後のマイナス:

  1. 配列を「ヘッド」と「テール」に分割することは再帰アルゴリズムに役立ちますが、テール再帰の最適化を行わないと、大容量のパフォーマンスとスタックオーバーフローの問題が発生する可能性があります。

    解決策:まだ発明されていません



  2. テンプレートで関数を使用することはできません-または、使用することはできますが、テンプレートをコンパイルするときに一度だけ呼び出されます。

    解決策:ガードを使用する



  3. このコンテキスト置換のため、アクション関数をコンテキストに添付できません。 一方、機能的なスタイルで記述している場合、メソッド呼び出しは必要ないようです。

    解決策:昔ながらの方法、self = this



  4. 同じ理由で、おそらくecmascript 6の矢印関数を使用することはできません-呼び出し/適用を介した呼び出しでも影響しないように、コンテキストを緊密にバインドします。

    解決策:まだ発明されていません



何かが役に立つことを願っています。 ご清聴ありがとうございました。



All Articles