😈

ebitengine/puregoとunsafeでC/C++の配列と仲良くする

2024/01/07に公開

ebitengine/puregoを用いてC/C++で書かれた共有ライブラリの関数を叩く際、配列(のポインタ)を返す関数の扱いが多少難しく、若干ハマってしまった。このあたりはC/C++がどういったメモリ配置を行うかというレイヤーが低めの知識が必要になってくるということもあるし、自分と同じようにハマってしまった人の助けになるよう備忘録として残しておく。

ちなみにC/C++において配列の長さを得るには sizeof(array) / sizeof(int) のような計算を行う必要があるが、ポインタからではこの計算が行えないため、以下では長さを取得する関数が別に存在する前提で話を進めている。

また、以下ではC/C++におけるintを一般的な32bitとして扱っている。返り値がintな関数はpuregoがよしなにしてくれるので気にかける必要はないが、配列のポインタを受け渡しする場合はそうもいかないので、Go側ではint32としている。ただこのあたりは環境などによっても変わってくるため、適切な型を選択すること。

1次元配列

以下のような定義を持つ共有ライブラリがあったとする。

// 配列の長さを返す
int get_array_length();
// 配列のポインタを返す
const int* get_array();

Go側ではこうなる。

// purego
var getArrayLength func() int
var getArray       func() uintptr
purego.RegisterLibFunc(&getArrayLength, lib, "get_array_length")
purego.RegisterLibFunc(&getArray, lib, "get_array")

// 配列の長さ
l := getArrayLength()
// 配列のポインタを返す関数を呼び出す
ptr := getArray()
// スライスにする
result := unsafe.Slice((*int32)(unsafe.Pointer(ptr)), l)

Go 1.17から追加された unsafe.Slice のお陰でかなり簡潔に書けている。

2次元配列

固定長な2次元配列の場合、実際のメモリ上では1次元的に並んでいるだけなので、まず上記のように1次元のスライスを作ってから詰め替えることで2次元のスライスにできる。

// 1次元目の長さと2次元目の長さ
l1, l2 := getArrayLength1(), getArrayLength2()
// 2次元配列のポインタを返す関数を呼び出す
ptr := getArray()
// 1次元のスライスにする
s := unsafe.Slice((*int32)(unsafe.Pointer(ptr)), l1 * l2)
// 2次元のスライスを用意して詰め替える
result := make([][]int32, l1)
for i := range result {
	result[i] = s[i*l2:(i+1)*l2]
}

もしくは、適宜ポインタをずらすことでも同様の結果を得ることができる。どちらの方法でもallocateの回数は一緒だし、実行時間もほとんど変わらないのでどちらの方法を選ぶかは好みの領域。

result := make([][]int32, l1)
size := unsafe.Sizeof(int32(0))
for i := range result {
	result[i] = unsafe.Slice((*int32)(unsafe.Pointer(ptr+uintptr(i)*uintptr(l2)*size)), l2)
}

3次元配列

おおむね2次元配列と同じ感じでいける。

// 各次元の長さ
l1, l2, l3 := getArrayLength1(), getArrayLength2(), getArrayLength3()
// 3次元配列のポインタを返す関数を呼び出す
ptr := getArray()
// 1次元のスライスにする
s := unsafe.Slice((*int32)(unsafe.Pointer(ptr)), l1 * l2 * l3)
// 3次元のスライスを用意して詰め替える
result := make([][][]int32, l1)
for i := range result {
	result[i] = make([][]int32, l2)
	for j := range result[i] {
		result[i][j] = s[i*l2*l3+j*l3 : i*l2*l3+(j+1)*l3]
	}
}

ポインタをずらす方法も同様に使える。コストも変わらない。

result := make([][][]int32, l1)
size := unsafe.Sizeof(int32(0))
for i := range result {
	result[i] = make([][]int32, l2)
	for j := range result[i] {
		result[i][j] = unsafe.Slice((*int32)(unsafe.Pointer(uintptr(ptr)+uintptr(i*l2*l3)*size+uintptr(j*l3)*size)), l3)
	}
}

ジャグ配列

以下のような定義を持つ共有ライブラリがあったとする。

// 1次元目の長さを返す
int get_array_length();
// 2次元目の各配列の長さを持つ配列のポインタを返す
const int* get_array_lengths();
// ジャグ配列のポインタを返す
const int** get_array();

自分の勝手な推測だとジャグ配列の中身は2次元配列などと同様1次元的に並んでいるのかと思ったのだが、実際は配列のポインタの配列といった構造らしい。ということで、以下のようになる。

// purego
var getArrayLength  func() int
var getArrayLengths func() uintptr
var getArray        func() uintptr
purego.RegisterLibFunc(&getArrayLength, lib, "get_array_length")
purego.RegisterLibFunc(&getArrayLengths, lib, "get_array_lengths")
purego.RegisterLibFunc(&getArray, lib, "get_array")

// 1次元目の長さ
l := getArrayLength()
// 2次元目の各配列の長さを持つ配列
ptr := getArrayLengths()
lengths := unsafe.Slice((*int32)(unsafe.Pointer(ptr)), l)

// ジャグ配列のポインタを返す関数を呼び出す
ptr = getArray()
// 各配列をスライスにして詰める
result := make([][]int32, l)
for i := range result {
	result[i] = unsafe.Slice(*(**int32)(unsafe.Pointer(ptr + uintptr(i)*unsafe.Sizeof(uintptr(0)))), int(lengths[i]))
}

取得したポインタにuintptr(i)*unsafe.Sizeof(uintptr(0))を足すことで2次元目の各配列のポインタを得て、そこからスライスを作っている。

また、スライスの基準となるのがポインタのポインタであることから、*(**int32)というキャストになっている。これを(*int32)と書いてもコンパイルエラーにはならないが、おかしな値になってしまうので注意。

ちなみに...

今回はintを使った例を示したが、メモリ上での構造が同じならどのような型でも同様の操作でポインタからスライスにできる。なんなら構造体もいける。
ただし、文字列が絡んだり構造体のメモリ配置がC/C++とGoで異なっていたりするとそうもいかないので、その場合はなんらか一工夫必要。

Discussion