ClojureScript

配列アクセスチェック

2017年7月14日
Mike Fikes

これはプレビューシリーズの3番目の投稿です。

ClojureScriptコンパイラの歴史の大部分は、実際的な便宜性、それに続く段階的な改良というテーマで特徴付けることができます。これは、agetの歴史によってうまく説明されています。

agetの最初の最小限の実装可能な実装は、単純な関数でした。6年前は次のようでした。

(defn aget [array i]
   (js* "return ~{array}[~{i}]"))

これは、内部のjs*特殊形式を使用して、要素アクセスに添字表記を使用するJavaScriptを直接出力します。おおよそ次のようになります。

function aget(array, i) {
  return array[i];
}

js*は、アプリケーションレベルのClojureScriptコードで使用することを意図していません。

コンパイラの初期の歴史では、js*はClojureScript標準ライブラリに付属するランタイム関数でかなり頻繁に使用されていました。時間の経過とともに、標準ライブラリではjs*の直接の使用が完全に削除され、再利用可能なマクロの背後に隠されました。

時間が経つにつれて、私たちの友人aget(およびaset)は、可変長引数を使用して入れ子になった配列構造にアクセスできるようにすることで、Clojureにもっと近づけられるように改良されました。

JavaScriptに精通している読者は、上記のaget実装はJavaScriptオブジェクトでも完全に機能することに気付くでしょう。この事実は、js*と同様に、標準ライブラリの初期においても悪用されていました。残念ながら、代替案に関するドキュメントが不十分だったため、ユーザーはこの内部の詳細を模倣し始めました。

しかし、agetはこの特定の使用をサポートするように設計されていませんでした。「a-」関数のファミリー(acloneamapareduceを含む)はすべて、オブジェクトではなく配列を対象としています。agetへの追加の引数は、文字列のプロパティ名ではなく、数値の配列インデックスです。それにもかかわらず、おそらく容易さという魅力のために、次のような形式

(aget #js {:foo 1} "foo")

は、ドット付きプロパティアクセスと:advancedコンパイルに伴う名前の変更を回避するために、広く使用されるようになりました。これは実装上の偶然で機能したのですが、それでも非常に人気が出ました。

これによって生じる1つの問題は、agetをその目的と一致するようにさらに発展させることにおける課題です。思い付くいくつかの例としては、

  • Clojureでは、非整数の配列インデックスをagetに渡すと、最も近い整数に切り捨てられます。ClojureScriptのagetにこの動作を合わせることができれば良いでしょう。これは、実装でintを使用することで簡単に実現でき、生成されたJavaScriptはarray[ndx|0]のようになります。しかし、これはオブジェクトのプロパティアクセスにagetを使用する既存のコードを壊してしまいます。

  • Clojureでは、負の配列インデックス、または範囲外のインデックスを渡すと、例外が発生します。ClojureScriptのagetにそのような安全機構を追加することを検討できれば良いでしょう。しかし、繰り返しますが、インデックスを盲目的に数値として扱う試みは、agetに文字列インデックスが渡されるという点で失敗します。

  • 将来、コアライブラリ関数は、仕様が記述される可能性があります。同じ問題が発生します。agetに渡されるインデックスはnumber?述語を満たす必要がありますが、そうすると、実際の多くのコードが非準拠とみなされることになります。

これはもちろん、言語メカニズムの内部が意図した以外の目的に合うことが発見される最初の例でも、おそらく最後の例でもありません。Guy L. Steele Jr.とRichard P. Gabrielによる『Lispの進化』には、MacLispのERRSETERRプリミティブがフロー制御メカニズムとして使用できることが発見されたことについての興味深い議論がありますが、残念ながら予期しないエラーもトラップしてしまうという問題がありました。これにより、1972年にMacLispにTHROWCATCHプリミティブが導入されました。著者は、「設計のパターン(慎重かそうでないか)、意図しない使用、そして後の再設計は一般的である」と述べています。

agetasetが配列用に予約されている場合、オブジェクトのプロパティアクセスには何を使用するべきでしょうか?ClojureScriptはGoogle Closureライブラリを容易にアクセスできるようにしており、goog.object名前空間にはチェックする価値のある優れた機能がいくつかあります。特に、goog.object/getgoog.object/setは、この目的のために適した適切なAPIです。たとえば、これはあなたが望むことを行います。

(goog.object/get #js {:foo 1} "foo")

実際、goog.object/getは、渡されるオブジェクトがnilではないこと、およびアクセスされるフィールドが実際にオブジェクトに存在することをチェックするため、そうでない場合は返す代替の「見つからない」値を指定できるため、より安全です。入れ子になったプロパティアクセスを行う必要がある場合は、可変長aget呼び出しの直接的な代替として考慮できるgoog.object/getValueByKeysがあります。

ClojureScript標準ライブラリ自体には、agetasetがオブジェクトアクセスで誤用されていた場所があり、これらはクリーンアップされました。goog.object/getは、オブジェクトアクセス用のagetのほとんどすべての使用を置き換えるのに十分なパフォーマンスがあります。そうではない比較的少ない場所(標準ライブラリの実装における非常にパフォーマンスクリティカルな領域)では、コンパイラは新しい内部unchecked-getマクロを使用して作業を行います。

agetasetを配列アクセスにのみ使用し、goog.object(直接、またはcljs-oopsなどのライブラリを介して間接的に)の機能を使用してオブジェクトプロパティにアクセスすることを検討することをお勧めします。

新しいコンパイラの機能強化

この目的のために、今後のClojureScriptリリースには、:warnまたは:errorに設定できる新しい:checked-arraysコンパイラオプションが含まれています。どちらの設定でも、コンパイラは新しい:invalid-array-access警告を出力します。これは、型推論によってわかっている場合に、agetまたはasetが非配列で動作しているか、非数値インデックスが提供されていることを示します。さらに、agetasetに渡されるランタイム値がチェックされます。:checked-arrays:warnに設定されている場合、型が正しくない値または範囲外の配列インデックスが渡されると、警告ログが生成されます。:errorに設定されている場合、代わりに例外がスローされます。

最大のパフォーマンスを得るために、そのようなチェックはすべて:advancedビルドでは削除され、配列アクセスは効率的なJavaScript配列添字表記にコンパイルされます。

この新しいコンパイラオプションを使用して、これらのAPIがオブジェクトプロパティアクセスに使用されているインスタンスを強調表示できます。たとえば、(aget #js {:foo 1} "foo")は、この警告を出力します。

WARNING: cljs.core/aget, arguments must be an array followed by numeric indices, got [object string] instead (consider goog.object/get for object access) at line 1

この新しい機能を有効にするには、単に

:checked-arrays :warn

をClojureScriptのコンパイラオプションに追加します。

ClojureScript標準ライブラリのAPIを意図したとおりに注意深く使用することで、正確さとパフォーマンスの両方に関して、ライブラリの進化が促進されます。これは私たち全員が恩恵を受けることができるものです!