ASMATI・TATSUMIEXPERIMENT NOTE 03
Nimのref objectを生成Cのポインタと参照カウント処理まで確認する
アスマティ・辰巳「高水準言語は低水準で何をしているのか」第3回
本シリーズでは、NimコンパイラがNimのプログラムをCコードへ変換した結果を、生成されたCコードで確認します。第3回では、Nimのref objectを関数(proc)へ渡し、Cのポインタと、Nimのメモリ管理方式であるORC(Optimized Reference Counting)が生成する参照管理の実装を解説します。
1. 前提条件
今回は、Nimのref objectを受け取る二つの関数をdebugモードでコンパイルします。実験条件は次のとおりです。
- OS: macOS 26.5.2
- CPU: Apple Silicon
- Nim: 2.2.10
- Cコンパイラ: Apple clang 21.0.0
- メモリ管理: ORC(Optimized Reference Counting)
- ビルドモード: debug(
opt: none) - Nimコンパイラの実行形式: x86_64(Rosettaで実行)
- 生成バイナリ: arm64
2. Nimのメモリ管理 ORCについて
Nimのref[T]は、オブジェクト本体を直接格納せず、そのオブジェクトを参照する型です。今回の生成Cでは、ref objectの本体が構造体になり、その参照が構造体へのポインタになります。ポインタは参照先のメモリアドレスを保持し、nil参照はnullポインタとして表されます。
ARC(Automatic Reference Counting)は、コンパイラが参照カウントの更新処理を挿入するNimのメモリ管理方式です。オブジェクトへの参照が増えると参照カウントを増やし、参照が不要になると減らします。参照カウントがゼロになったオブジェクトは破棄されます。
ORC(Optimized Reference Counting)は、このARCへ循環参照の回収機構を加えたNimのメモリ管理方式です。単純な参照カウントでは、複数のオブジェクトが相互に参照すると、外部から使われなくなった後も参照カウントがゼロにならない場合があります。ORCは、この循環参照も回収します。Nim 2.2.10のDestructors and Move Semanticsでは、ARCとORCのref型がランタイムのフックと参照カウントで実装され、ORCが循環参照の回収機構を持つことを説明しています。公式ブログのIntroduction to ARC/ORC in Nimでは、ARCだけでは残る循環参照をORCが回収する仕組みを図とコードで紹介しています。
今回は、関数の引数と戻り値に現れるポインタを確認し、参照を返すときに生成されたC関数eqcopyを詳しく解説します。eqcopyは、コピー元の参照カウントを増やし、代入先が保持していた参照を減らして必要なら破棄し、最後に新しいポインタを代入します。
3. ref objectを受け取る二つの関数
値を一つ持つReadingをref objectとして定義します。関数readValueはReadingを受け取り、nilなら-1、値があればフィールドvalueを返します。関数keepReadingは、受け取ったReadingをそのまま戻り値にします。
type
Reading = ref object
value: int
proc readValue(reading: Reading): int {.noinline.} =
if reading.isNil:
-1
else:
reading.value
proc keepReading(reading: Reading): Reading {.noinline.} =
reading
let present = Reading(value: 42)
let missing: Reading = nil
echo "present=", readValue(present)
echo "missing=", readValue(missing)
echo "keptIsNil=", keepReading(missing).isNil型Readingは、整数フィールドvalueを持つオブジェクトへの管理された参照です。変数presentはvalueが42のReadingを指し、変数missingは同じ型のnil参照です。
関数readValueの引数readingには、値を持つ参照とnil参照の両方を渡します。関数keepReadingにはnil参照を渡し、その戻り値に対してisNilを実行します。これにより、同じソースコードで非nil時のフィールド参照、nil時の分岐、参照型の戻り値を確認できます。
4. コンパイルと実行
次の動画では、Nimのソースコードをコンパイルし、生成したバイナリを実行します。
動画では、次の二つのコマンドを順に実行しています。
nim c --nimcache:observed/nimcache \
-o:observed/bin/ref_proc \
src/ref_proc.nim./observed/bin/ref_proc実行結果は次のとおりです。
present=42
missing=-1
keptIsNil=true値を持つpresentからは42を取得し、nil参照のmissingからは分岐で指定した-1を取得しました。関数keepReadingへnil参照を渡した結果もnilです。
5. ref objectの本体と参照の型
生成Cでは、Readingのオブジェクト本体が構造体になり、Reading型の引数と戻り値は、その構造体を指すポインタになります。該当する型定義と関数宣言を示します。
typedef struct tyObject_ReadingcolonObjectType___8EkEo4jsvRYTKsMVQRmgZQ
tyObject_ReadingcolonObjectType___8EkEo4jsvRYTKsMVQRmgZQ;
struct tyObject_ReadingcolonObjectType___8EkEo4jsvRYTKsMVQRmgZQ {
NI value;
};
N_LIB_PRIVATE N_NOINLINE(NI, readValue__ref95proc_u4)(
tyObject_ReadingcolonObjectType___8EkEo4jsvRYTKsMVQRmgZQ* reading_p0);
N_LIB_PRIVATE N_NOINLINE(
tyObject_ReadingcolonObjectType___8EkEo4jsvRYTKsMVQRmgZQ*,
keepReading__ref95proc_u10)(
tyObject_ReadingcolonObjectType___8EkEo4jsvRYTKsMVQRmgZQ* reading_p0);構造体tyObject_Reading...は、Readingのオブジェクト本体に対応します。フィールドvalueの型はNIです。第1回で確認したとおり、今回の環境ではNIがint64_tへ解決されます。
関数readValue__ref95proc_u4の引数reading_p0は、tyObject_Reading...*です。関数keepReading__ref95proc_u10では、引数と戻り値の両方が同じポインタ型です。NimソースのReadingは、生成Cの関数境界でオブジェクト本体へのポインタとして現れています。
N_NOINLINEは、Nimソースで指定した{.noinline.}に対応し、二つの関数をインライン展開の対象から外します。この指定により、生成Cで各関数のシグネチャと本体を個別に確認できます。
6. nil判定とフィールド参照
関数readValueの生成Cには、nil判定とフィールド参照の両方が含まれます。
N_LIB_PRIVATE N_NOINLINE(NI, readValue__ref95proc_u4)(
tyObject_ReadingcolonObjectType___8EkEo4jsvRYTKsMVQRmgZQ* reading_p0) {
NI result;
if (!(reading_p0 == 0)) goto LA4_;
result = ((NI)-1);
goto LA2_;
LA4_: ;
result = (*reading_p0).value;
LA2_: ;
return result;
}Nimソースのreading.isNilは、生成Cでreading_p0 == 0というポインタの比較になります。引数がnilの場合は、戻り値resultへ-1を代入します。
引数がnilでない場合はラベルLA4_へ移り、(*reading_p0).valueでポインタの参照先にあるフィールドvalueを読み取ります。フィールド参照はnilではない側の分岐にだけ置かれています。最後に、どちらの分岐もreturn resultから値を返します。
isNilのref T向け定義は、参照がnilかを調べるコンパイラ組み込みの関数です。この例では、その判定が生成Cのnullポインタ比較として確認できます。
7. nilの表現
生成Cでは、明示的なnil値にNIM_NILを使います。Nim 2.2.10に同梱されたnimbase.hには、C向けに次の定義があります。
#ifndef __cplusplus
# include <stdbool.h>
# define NIM_NIL ((void*)0)
#endif今回生成されたファイルはCとしてコンパイルされるため、NIM_NILは((void*)0)です。Nimソースのlet missing: Reading = nilと、後に示す関数keepReadingの戻り値の初期化に、この値が使われます。
関数readValueの条件式はreading_p0 == 0、明示的なnil値はNIM_NILとして出力されています。表記は異なりますが、どちらもnullポインタを表します。
8. ORCで生成された参照コピー関数eqcopy
関数keepReadingは、引数のポインタを直接returnするのではなく、戻り値をnilで初期化し、生成された補助関数eqcopyを呼びます。
N_LIB_PRIVATE N_NOINLINE(
tyObject_ReadingcolonObjectType___8EkEo4jsvRYTKsMVQRmgZQ*,
keepReading__ref95proc_u10)(
tyObject_ReadingcolonObjectType___8EkEo4jsvRYTKsMVQRmgZQ* reading_p0) {
tyObject_ReadingcolonObjectType___8EkEo4jsvRYTKsMVQRmgZQ* result;
result = NIM_NIL;
eqcopy___ref95proc_u19(&result, reading_p0);
return result;
}局所変数resultはReadingのオブジェクト本体を指すポインタであり、最初にNIM_NILを代入します。続くeqcopy___ref95proc_u19には、代入先resultのアドレスと、コピー元の引数reading_p0を渡します。補助関数の処理は次のとおりです。
N_LIB_PRIVATE N_NIMCALL(void, eqcopy___ref95proc_u19)(
tyObject_ReadingcolonObjectType___8EkEo4jsvRYTKsMVQRmgZQ** dest_p0,
tyObject_ReadingcolonObjectType___8EkEo4jsvRYTKsMVQRmgZQ* src_p1) {
{
if (!src_p1) goto LA3_;
nimIncRef(src_p1);
}
LA3_: ;
{
NIM_BOOL T7_;
T7_ = (NIM_BOOL)0;
T7_ = nimDecRefIsLast((*dest_p0));
if (!T7_) goto LA8_;
eqdestroy___ref95proc_u34((&(*(*dest_p0))));
nimRawDispose((*dest_p0), ((NI)8));
}
LA8_: ;
(*dest_p0) = src_p1;
}関数eqcopy___ref95proc_u19は、生成CではN_NIMCALL(void, eqcopy___ref95proc_u19)の形で定義されています。N_NIMCALLの第1引数voidは戻り値の型、第2引数eqcopy___ref95proc_u19は関数名です。この部分は、値を返さない関数eqcopy___ref95proc_u19を表します。N_NIMCALL自体の定義と展開は、第1回で解説しています。
コピー元src_p1がnilでなければ、nimIncRefが参照カウントを増やします。代入先*dest_p0が最後の参照であれば、nimDecRefIsLastの判定後に、元の参照先を破棄してメモリを解放します。最後に、代入先へコピー元のポインタを代入します。
今回の関数keepReadingでは、代入先resultを直前にnilで初期化しています。この呼び出しでは破棄する既存の参照先はありませんが、生成されたeqcopy自体は、管理された参照のコピーに必要な増加・減少・代入を一つの処理として持っています。
第2章で紹介したORCの参照管理は、この生成Cではeqcopy、nimIncRef、nimDecRefIsLastとして現れています。
9. ポインタ表現と管理処理を分けて読む
今回の生成Cでは、ref objectの本体がCの構造体になり、関数の引数と戻り値がその構造体へのポインタになりました。nil判定はnullポインタ比較になり、非nil時はポインタが指す構造体からフィールドを読み取ります。
一方、関数keepReadingの戻り値は、ポインタの代入だけでは完了しません。生成Cに挿入された関数eqcopyから、ORCの実装を確認できました。今回の記事で、関数シグネチャでのポインタの表現と、そのライフサイクル管理についての実装が確認できました。
Nimのソースコード、生成Cの抜粋、実行結果、検証スクリプトは、asmati-labのexperiment 005にあります。CIでは、Readingの構造体、二つの関数シグネチャ、nil分岐、フィールド参照、参照型の戻り値、実行結果を検証しています。