拡張ライブラリでキーワード引数を処理する定番プラクティス(ベストじゃないかも)

ruby 2.4 リリースおめでとうございます。つい先日まで「拡張モジュール」と「拡張ライブラリ」の、どちらの呼称がobsoletedなのかいまいちはっきり認識していなかったきしもとです(ClassクラスのスーパークラスであるModuleのほうのモジュールがあるので、「拡張モジュール」がobsoletedです)。

いつのまにやら内蔵ライブラリでも結構いろいろ使われているなど、一般的になっていたRubyのキーワード引数ですが、じつはつい先日まで、C API で提供されているユーティリティ関数にバグがあり、だいぶ悩ませられていた人もあったようです。

さいわい、良いタイミングで修正されて、本日リリースされた 2.4 からは普通に使えるようになっていますので、拡張ライブラリでキーワード引数を処理する定番プラクティス(ベストじゃないかも)をまとめておきます。

1. Rubyレイヤで処理できないか検討

いきなりタイトルから外れるようなことを書きますが、doc/extension.rdoc の英語版にはあるように(日本語版では省略されている)、

  Be warned, handling keyword arguments in the C API is less efficient
  than handling them in Ruby.  Consider using a Ruby wrapper method
  around a non-keyword C function.
  ref: https://bugs.ruby-lang.org/issues/11339

といったように効率上は、Rubyで実装されたメソッドに対するキーワード引数の処理のほうが効率がよく(参照先によれば数倍?)、プログラムの見やすさのこともありますから、キーワード引数はRubyレイヤで受けて、拡張ライブラリ側で処理するのは避ける、というのも一つの手です。

2. アリティの確認

ここから拡張ライブラリの話に入ります。

まずメソッド定義の C API であるrb_define_methodなどには、キーワード引数の扱いは現れてきません。rb_define_methodの場合、第4引数( argc )を -1 にして、可変長引数のメソッドとして定義します。

そして、Rubyでキーワード引数付きのメソッドを定義した場合、アリティ(引数の個数)が、定義側(仮引数)と呼出側(実引数)で一致しているか否かは、どちらの側もキーワード引数が無いものとしてチェックされています(必須なキーワードがある場合も)。ですから、拡張ライブラリの場合もそれに合わせます。rb_define_methodで登録すべき C 関数の骨組みは次のようになります。

/* 必須な引数の個数が 1 個で、キーワード引数も取るメソッド */
static VALUE
my_kw_method(int argc, VALUE argv[], VALUE obj)
{
    VALUE h;

    /* アリティチェックよりも前にキーワード引数の存在をチェックする */
    h = rb_check_hash_type(argv[argc-1]);
    if ( ! NIL_P(h)) {
        --argc;
    }
    rb_check_arity(argc, 1, 1);  /* 必須な引数は1個で */
        /* (キーワード引数以外の)オプショナルな引数は無し */

    if ( ! NIL_P(h)) {
        /*
         *
         * ここでキーワード引数取得の処理
         *
         */
    }

    /*
     * ここにメソッドの処理の本体
     */
}

3. rb_get_kwargs

キーワード引数の処理のために用意されている C API のrb_get_kwargsですが、先日までバグっていた上に、(仕様の変遷があったため)ドキュメントがコードと一致しなくなっていました。ですので、以前にドキュメントを読んで実装してハマったというようなかたは、まずドキュメントを読み直してみてください。

「ここでキーワード引数取得の処理」となっている部分のコードは、次のようになります。

    /* 必須キーワードが 3、省略可能が 2 個、余ったキーワードがあったらエラー */
    VALUE foo, bar, baz,  quux, hoge;
     :
     :
     :
        ID table[5];
        VALUE values[5];
        table[0] = rb_intern("foo");
        table[1] = rb_intern("bar");
        table[2] = rb_intern("baz");
        table[3] = rb_intern("quux");
        table[4] = rb_intern("hoge");
        rb_get_kwargs(h, table, 3, 2, values);  // ここの「2」は、余ったキーワードを無視するなら -3 に変える
        foo = values[0];
        bar = values[1];
        baz = values[2];
        quux = ( (values[3] != Qundef) ? (values[3]) : デフォルト値 ) ;
        hoge = ( (values[4] != Qundef) ? (values[4]) : デフォルト値 ) ;

ここで、デフォルト値についてはあらかじめ変数をその値に設定(初期化)しておいて、3項演算子ではなく if による分岐で書く、というようなバリエーションもありえますが、デフォルト値にオブジェクトの生成などがあると、無駄に余計なオブジェクトを作ってしまうことになります。

一方で、必須のキーワードが無い場合に、キーワード引数がまるごと無いと、この例のように書いてしまうと変数が未初期化のままになります。そういうわけで、そのあたりはケースバイケースで書き分ける感じになるでしょう。

4. 余ったキーワードの扱い

上記のコードでは、余ったキーワードがあると "unknown keyword" ArgumentError の例外が上がります。第4引数(省略可能なキーワードの個数)を 2 から -2-1 に変えると(-1は、ゼロ個の時のための調整用(でしょう))、余ったキーワードがあっても無視されるようになります。その場合、余ったキーワード(と値のペア)はハッシュ h の中に残されます(rb_get_kwargsは、処理できたキーワードを h から取り除きます)。

余ったキーワード(意図的に任意のキーワードを、意味をもって受け付けるような場合を除いて)の扱いをどうするかは、cruby開発陣の中で、まだプラクティスが確立していません(内蔵ライブラリや、標準添付ライブラリでもまちまちになっています)。もしこの件に関心があるようであれば、時々気にしてみてください(確かチケットには(まだ)なっていません)。

5. その他

values としてヌルポインタを渡すと、値を取り出さずにキーワード引数の存在のチェックのみを行います。