KOTET'S PERSONAL BLOG

#dlang DとCのインターフェース:配列 Part 2【翻訳】

Created: , Last modified:
#dlang #tech #translation #d_blog #d_and_c #advent_calendar #cpplang

これは1年以上前の記事です

ここに書かれている情報、見解は現在のものとは異なっている場合があります。

Interfacing D with C: Arrays and Functions (Arrays Part 2) – The D Blog許可を得て 翻訳しました。

これは D言語 Advent Calendar 2020 の4日目の記事です。


この投稿はDとCを同じプロジェクトで動かすシリーズの一部です。 前回の投稿では配列の宣言と初期化に関するDとCの 違いを確認しました ( 訳注: 日本語訳はこちら ) 。

配列とCの関数宣言

CのライブラリをDから使うことはとても簡単です。 ほとんどの場合で期待したとおりに動作しますが、前回見てきたとおり少し違いがあります。 配列を受け取るCの関数を取り扱う場合は、その違いを深く理解する必要があります。

引数として配列を受け取るCの関数を宣言する最も素直で一般的な方法は、 引数リストの中でポインタを使うものです。 たとえば、このようなCの関数があるとします。

void f0(int *arr);

この関数にはintの配列ならば、それがどのように宣言されたかに関わらず渡すことができます。 たとえばポインタとしてでも、またCの配列構文(int a[]int b[3])を使っても同じです。 Cプログラマの言葉で言うと、配列はポインタに減衰されます。 つまりf0(a)f0(b)のような関数呼び出しにおいて、 関数には配列の最初の要素へのポインタが渡されます。

通常f0のような関数では、配列は文脈に応じた適切なマーカーで終了することが求められます。 たとえばCの文字列は文字\0で終了するcharの配列です (Dの文字列とCの文字列の差については将来の投稿で取り扱います) 。 もし終端文字がなければf0は配列のどの要素が最後の要素か知ることができないため、 このような文字が必要になります。 そうではなく、コメントや関数名によって、関数が特定の長さを期待していることを記述することもあります。 たとえばvector3f_addはちょうど3要素を期待します。 もう一つの選択肢は別の引数として配列の長さを要求することです。

void f1(int *arr, size_t len);

これらのアプローチにはフールプルーフがありません。 f0にマーカーが無い、または指示されたよりも短い配列が渡されたとき、 もしくはf1に渡された配列の実際の長さがlenよりも短いときには、 メモリ破壊への扉が開かれることになります。 Dの配列ではこの可能性を考慮し、そのような問題を簡単に回避できるようにしました。 しかしやはり、Cの関数をDから呼ぶときにおいてDの安全機能は100%のフールプルーフにはなりません。

少々一般的ではなくなりますが、他にもCにおける配列引数の宣言方法があります。

void f2(int arr[]);
void f3(int arr[9]);
void f4(int arr[static 9]);

しかしこれらのCの配列構文で宣言された引数は、 前述のポインタ減衰によってf0の関数シグネチャと同じになります。 f3の角括弧内の9はコンパイラに対して何ら制約を与えません。 arrは事実上、不明な長さのintの配列のままです。 9は関数がなにを期待するかのドキュメントとして役立ちますが、 その実装は配列が9つの要素を持つことに期待してはいけません。

f4だけ違いが生じます。 宣言に追加されたstaticは、 少なくとも9要素の配列を受け取らなければならないことをコンパイラに伝えます。 9要素よりも多くなることはできますが、少なくなることはできません。 ヌルポインタは例外です。 問題は、これが強制されないことです。 使っているCコンパイラによっては、有効にしたときにだけ警告が見られるようになっているかもしれません (エラーが出てくるのか警告が出てくるのか、この記事を書くにあたって最新のコンパイラでテストしてはいません) 。

Cコンパイラの挙動はD側には関係ありません。 関係あるのはこれらの関数が適切に宣言され、 クラッシュや予期しない結果がないようにDから呼び出せるようになっていることです。 なぜならこれらすべては事実上同じであり、Dではこのように宣言できるからです。

extern(C):
void f0(int* arr);
void f1(int* arr, size_t len);
void f2(int* arr);
void f3(int* arr);
void f4(int* arr);

しかしできるからといってそうしなければならないわけではありません。 このようなf2f3f4の宣言を考えてみましょう。

extern(C):
void f2(int[] arr);
void f3(int[9] arr);
void f4(int[9] arr);

このアプローチによって何か問題が起きるでしょうか? 答えはイエスですが、それは全部をint*に戻さなければならないということを意味しません。 その理由を理解するには、まずDの配列の内部にふみ込んでいく必要があります。

大解剖 Dの配列

前回、Dは動的配列と静的配列を区別するということを見てきました。

int[] a0;
int[9] a1;

a0は動的配列で、a1は静的配列です。 両者とも.ptr.lengthというプロパティを持ちます。 両者とも同じ構文でインデクシングができます。 しかし両者の間には重要な違いがあります。

動的配列

動的配列は通常ヒープに確保されます(必ずしもそうしないといけないわけではありません)。 上の例では、a0に関してメモリ割当は発生しません。 この配列はnewmalloc、その他アロケータや配列リテラルによってメモリを確保される必要があります。 a0が初期化されない場合、a0.ptrnullでありa0.length0です。

Dにおける動的配列は以下のような、2つのプロパティをメンバとして持つ複合型です。

struct DynamicArray {
    size_t length;
    size_t ptr;
}

言い換えると、動的配列は本質的に参照型であり、 ptrメンバに格納されたアドレスにある要素を参照するためのポインタと長さのペアを持ちます。 すべての組み込みのD型は.sizeofプロパティを持ち、 a0.sizeofとすると32bitシステムならsize_tが4バイトのuintのため8が、 64bitシステムならsize_tが8バイトのulongのため16が得られます。 とどのつまり、これは管理領域のサイズであり配列の要素全体のサイズではありません。

静的配列

静的配列は一般にスタックに確保されます。 a1の宣言で、9要素のint値のためのスタック領域が確保され、 それらすべてがデフォルトではint.init(0)に初期化されます。 a1の初期化時に、a1.ptrはその確保された領域を指し、a1.length9になります。 これら2つのプロパティは動的配列のそれと同じですが、その実装は異なります。

静的配列は値型であり、その値は配列の要素全体です。 したがって上のようなa1の宣言を考えると、9つのintの要素はa1.sizeof9 * int.sizeof、 つまり36であることを示しています。 .lengthプロパティは決して変化しないコンパイル時定数であり、 .ptrプロパティはコンパイル時に読むことはできないものの、 やはり決して変化しない定数です(左辺値ではないので、他の場所を指すようにすることは不可能です)。

この実装が、DのソースモジュールにCの配列の宣言をコピペするときに気をつけなければならない理由です。

Cの配列をDに渡す

Cにおけるf2の宣言を見返して、実装を与えてみます。

void f2(int arr[]) {
    for(int i=0; i<3; ++i)
     printf("%d\n", arr[i]);
}

Dにおける素直な宣言は以下のようになります。

extern(C) void f2(int[]);
void main() {
    int[] a = [10, 20, 30];
    f2(a);
}

素直な、と言ったのはこれが正しい答えではないからです。 f2.cdf2.dをWindowsでコンパイルし、 (Visual Studioの"x64 Native Tools"でcl /c f2.cとして、その後dmd -m64 df2.d f2.objです) df2.exeを実行すると、以下のような出力が得られます。

3
0
1970470928

f2の宣言はDにおいて完全に正しいためコンパイラエラーは発生しません。 extern(C)はこの関数がcdecl呼び出し規約を使うことを示します。 呼び出し規約は関数に引数が渡される方法と、関数のシンボルがマングリングされる方法に関わります。 この例では、シンボルは_f2f2になります (Dでextern(Windows)としたときのstdcallのような、 他の呼び出し規約はまた異なるマングリングスキームを持ちます)。 Dにおいて、宣言はやはり正しいままです (実際、任意のDの関数にextern(C)をつけることができます。 これは他の言語から呼ばれるDのライブラリを作る際に必要になります)。

リンカエラーも発生しません。 DMDはシステムのリンカを呼び出し (この場合Microsoftのlink.exeです) 、システムのC/C++コンパイラも同じリンカを使います。 つまりリンカはDの関数に関して特になにも知りません。 関数の実装をリンクするのに必要なこと、つまりシンボルf2_f2に対する呼び出しがあることが、 リンカの知るすべてです。 パラメータの型と個数はシンボル名にマングルされていないため、 リンカは愉快なことに名前がマッチしたもの全部を見境なくリンクしてくれます (ところで、正しくない引数リストで宣言されたCの関数をCのプログラムから呼び出しても、 これと同じことが起きます)。

Cの関数は引数としてポインタ1つを受け取ることを期待していますが、実際は2つです。 つまり、配列のポインタと長さです。

この話の教訓は、int[]のような配列構文を使って宣言された配列の引数をもつCの関数は、 D側では配列を受け取るように宣言すべきということです。 Dのソースを以下のように書き換え、先程と同じコマンドで再コンパイルしましょう (Cファイルを再コンパイルする必要はありません)。

extern(C) void f2(int*);
void main() {
    int[] a = [10, 20, 30];
    f2(a.ptr);
}

a.ptrを使っていますね。 ポインタを期待しているところにDの配列を渡してもエラーになります (特別な例外として文字列リテラルがありますが、これはこのシリーズの次の記事で取り扱います)。 したがって、代わりに配列の.ptrプロパティを使う必要があります。

f3f4にも似たようなことが言えます。

void f3(int arr[9]);
void f4(int arr[static 9]);

Dにおけるint[9]は動的配列でなく静的配列です。 以下のような宣言はCの宣言とマッチしません。

void f3(int[9]);

void f4(int[9]);

試してみましょう。

void f3(int arr[9]) {
    for(int i=0; i<9; ++i)
     printf("%d\n", arr[i]);
}

Dの実装は以下のとおりです。

extern(C) void f3(int[9]);
void main() {
    int[9] a = [10, 20, 30, 40, 50, 60, 70, 80, 90];
    f3(a);
}

これはシステムによってはクラッシュします。 配列へのポインタを渡す代わりに、このコードは配列の9つの要素すべてを値として渡しています! では、以下のようなことをするCのライブラリについて考えてみましょう。

typedef float[16] mat4f;
void do_stuff(mat4f mat);

一般に、Cのライブラリに対するDのバインディングを書くときは、 Cのライブラリと同じインターフェースを保つべきです。 しかし上のコードを以下のようなDのコードに翻訳すると問題が起きます。

alias mat4f = float[16];
extern(C) void do_stuff(mat4f);

do_stuffが呼ばれるたびに、16個の浮動小数点数が渡されます。 これはmat4f型の引数を受け取るすべての関数で同じです。 int[]の例と同じようにして、ポインタを受け取るように関数を宣言するのもひとつの方法です。 しかし、期待するよりも少ない要素数の配列を受け入れてしまう点でこれはCに劣ります。 int[]の例ではどうしようもありませんが、通常はC側で長さパラメータを付けます。 mat4fのようなtypedefされた型を受け取るCの関数は通常長さパラメータを持たず、 呼び出し側が正しく呼び出すことに依存します。

Dでは以下のようにしたほうが良いでしょう。

void do_stuff(ref mat4f);

API実装者の意図に沿うだけでなく、 コンパイラもdo_stuffに渡される配列が16の浮動小数点数を要素とする静的配列であると保証するようになります。 refパラメータは内部的にただのポインタなので、C側との連携はうまくいきます。

これをもとに、f3の例も書き直すことができます。

extern(C) void f3(ref int[9]);
void main() {
    int[9] a = [10, 20, 30, 40, 50, 60, 70, 80, 90];
    f3(a);
}

結論

ほとんどの場合、DからCへのインターフェーシングを行うときは、 CのAPIの宣言とサンプルコードをDにそのままコピーするだけですみます。 しかしほとんどの場合というのはすべての場合のことではないため、 そのような例外的ケースにおいては注意が必要となります。 前回見たように、配列変数における注意はコンパイラに受け持たせることができます。 今回見てきたように、Cの関数宣言においては同じようには行きません。 DとCとのインターフェーシングにはCのコードを書くときと同じような注意が必要です。

本シリーズの次回の記事では、Dの文字列とCの文字列を同じプログラムでまぜこぜにし、 落とし穴が生じるようすを見ていきます。 それまで、Steven Schveighofferの素晴らしい記事 “D Slices” がDの配列についてより深く掘り下げる よい開始地点となるでしょう

Walter BrightとÁtila Nevesのこの記事に関する価値あるフィードバックに感謝します。