😎

Zig探訪 - comptime編

2024/05/26に公開

イントロ

さあ、やって参りました。
第1回Zig探訪のお時間です。
今回担当するのは、Zigを使い始めて早くも半年・永遠のニートことsmallkirbyです。
Zig探訪では、Zigの機能や特徴の中で面白いんじゃないかと思うものをピックアップして紹介していきます。
紹介しないこともあります。
第1回のテーマは、Zigの中でも特に重要なコンセプトであるcomptimeについてです。

Zigとは - Everything is Explicit

Zigについておさらい

第1回ということで、最初に軽くZigについておさらいしておきましょう。

Zigは、2016年に開発が始まったコンパイル型汎用プログラミング言語です。
Rustが2015年に1.0リリースされた翌年に開発がスタートしたんですね。
最新のリリースはv0.12.0であり、大体1年くらいでマイナーアップデートされるようです。
まだ1.0に到達していないため、マイナーアップデートのたびにbreaking changeが入ります。
マイナーバージョンが上がると大体過去のコードはビルドが通らなくなります。可愛いですね。

現在の企業スポンサーは5社で、その中には時雨堂も含まれています。
どこぞのRustやGolangと違って名だたる大企業がバックについていません。
誰かお金が余りすぎて困っている人はスポンサーすると良いのではないでしょうか。

コミュニティは分散型です。
GitHubのIssueではQuestionが明示的に禁止されています。
その代わり、いくつも存在するコミュニティでの質問が推奨されています。
IRCが一番最初に来ているのには若干の古臭さを感じますが、DiscordやらSlackやら色々とあります。
それぞれのコミュニティは独立して存在しており、公式・非公式の区別がなく、それぞれのモデレータが存在しています。
ZigのLead DeveloperであるAndrew KelleyはGitHubのモデレータというだけであり、コミュニティ全体のモデレータというわけではありません。
ぼくの推しはZiggitです。

コミュニティこそ親切ですぐに答えが返ってきますが、Zigはまだ全体的にドキュメントが圧倒的に足りていません
公式のドキュメントは一通り網羅しているものの、痒いところに手が届かず、依然としてTODOとマークされた箇所も散見されます。
とりわけ、ビルドシステムのドキュメントは無いと言っても過言ではありません。
最近になって公式がドキュメントを出しましたが、やはりごく一部を説明しているに過ぎません。
現在のところ、Zigの一番の学習教材はZigのstdlibです。
もしくは、Bunも良い教材です(いくら良い言語とはいえ、まだ1.0リリースされてない言語でall-in-oneなJSランタイムを開発するって、イカれてますよね)。
あとはAndrew KellyのGitHubを見て個人的に書いてるプロジェクトを見たりするのも勉強になります。
とりあえずドキュメントが無いということだけ分かればOKです。

見たらわかる、それがZig

さて、少し話が逸れましたが、Zigの特徴は "全てが明示的" というところだと思っています。
言い換えるなら、ただのテキストエディタでも読みやすい言語です。

たとえば以下のようなC++のコードがあったとしましょう:

auto handle = device.open(desc + 2);

この時、僕のような迷えるC++ beginner達はこう思うわけです:

  • 🤔「えーと、まずdeviceの型はclass Deviceだからヘッダファイルに飛んで...」
    • 🤔「はーん、device::Deviceopen()っていうメソッド持ってるからこれのことだな〜」
      • 🤔「あれ、この関数ほぼ何もしてないやんけ!どうなってんねん!」
      • 🤔「あ、このclass UsbDeviceって子クラスあるやんけ、そっちを呼んでるのね」
        • 🤔「あれ、待てよ。このdeviceってもともとどっちとしてインスタンス化されてるんだ」
        • 🤔「おいおい、class MouseDeviceっていうさらに子供のクラスかよ!」
    • 🤔「あれ、descっていう変数は今のスコープで定義されてないけど...?」
      • 🤔「あ〜、はいはい。このクラスのメンバ変数ね。了解了解」
  • 🤔 「desc型はただintをラップしてるstruct Descriptor構造体っぽいな」
    • 🤔「あ?この構造体+オペレータをオーバーロードしてるな、ふざけやがって。」
  • 🤔 「MouseDevice::open()の定義2つあるやんけ、オーバーロードややこしいんじゃ。」
  • 🤔 「は?どっちの定義もstruct Descriptorなんて取らないやんけ」
    • 🤔「あー、はいはい。open()の引数になるクラスのコンストラクタにexplicitついてないわ。暗黙変換起こるのね」
  • 🤔 「この返り値になるクラスはコンストラクタ中でヒープからメモリ取るのね」
    • 🤔 「ところでこのヒープ領域はいつ解放すればいいの?」
      • 🤔 「まあ流石にデストラクタで勝手に解放するのかな。いや、でもFinalize()っていうメソッドもあるけど?」

まあちょっと冗長に書きすぎました。流石にオーバーですけど、慣れてない人だとこのくらい右往左往しちゃうわけです。
特にVSCode上で呼んでるときならまだしも、GitHub上で読んでいる時にはもうなんのこっちゃ分からなくなります。

対して、Zigは様々な機能を削っており、かつ全てが明示的になっています:

  • 関数のオーバーロード: できません。
  • デフォルト引数: ありません。
  • 暗黙的な型変換: 一切出来ません。i8からi16もできません(comptimeならできるけどね)。1trueもできません。勝手にクラスのコンストラクタが呼ばれることもありません。
  • 例外: ありません。エラーになったらearly returnするsyntax sugarはありますが、スタックをcatchされるまで遡るような鮭みたいな事もしません。
  • 継承: ありません。unionを使って似たようなことはできますが、もっといい方法があるでしょう。
  • オペレータのオーバーライド: あるわけないよね。
  • コンストラクタ・デストラクタ: ありません。勝手に関数呼ばれたら怖いよね。Golangみたいにdeferでスコープを抜ける際の処理を記述することは出来ます。
  • 暗黙的なメモリ確保: ありません。ヒープへのメモリ確保は全て明示的に行う必要があります。ヒープを使うstdlibの関数にはAllocatorを関数に渡す必要があります。
  • マクロ: ありません。型がないの、怖いと思わない?括弧をつけ忘れただけで意味わからないエラーとか出たこと無い?その代わりといっちゃなんですが、comptimeという機能があって、だいたいそれで実現できます。

これらの機能が削られた結果、Zigのコードは非常に読みやすくなります。
上の例の場合には、struct Deviceの唯一のopen()関数が呼ばれ、その引数の型(すなわちdescのまさにその型)も一意に定まり、そこには何の変換もありません。
deviceがアロケータを持っていないならばopen()でヒープが確保されることもありません。
desc + 2は読んで字のごとく数の足し算に他ならず、descは数(整数/浮動小数点数)の型であることも自明です。
スコープを抜けても勝手にデストラクタが呼ばれることもありません。
わかりやすいね!

そういうわけで、Zigは他の言語からいろいろな機能を削ぎ落としてしまっているわけですが、それでも強力な言語機能をいくつか持っています。
今回はその中でも重要であり、かつ勉強したての頃にはいまいち扱いづらいcomptimeについて紹介していきます。

comptime概略

Zigは、「コンパイル時に決まる値か否か」をめっちゃ気にします。神経質です。
コンパイル時に決まる式のことを、comptimeな値と呼びます。
値がcomptimeかどうかは、ある程度コンパイラがルールに基づいて決定します。
また、開発者が明示的に型にcomptime修飾をすることで、その値がcomptimeであることを強制することも出来ます。

ドキュメント的な説明はZig Language Referenceに譲ることとして、以下では面白い例を見ていきませふ。

Case 1: MMIO

ニートなので暇な時間にMikanOSZigに移植して遊んでいるんですが、その中でxHCI(USB)ドライバを書く必要がありました。
xHCは様々なレジスタを持っています:

pub const CapabilityRegisters = packed struct {
    cap_length: u8,
    hci_version: u16,
    ...
}

packed structはメンバ間のパディングをなくしてくれる構造体です。
また、Zigでは整数型として任意のビット幅をとることができるため、u8でもu16でもu99でも使うことが出来ます。
それは良いことですが、こういったMMIOレジスタはアクセスする際のビット幅が仕様で決まっていることが多々あります。
あるレジスタはWORD(16bit)でアクセスし、あるレジスタはDWORD(32bit)でアクセスする、といった具合です。
そんな時、cap_lengthフィールドだけを変更したいとなった時にめんどうです。
このレジスタがDWORDアクセスを強制する場合、cap_lengthだけアクセスするとアクセス違反になってしまいます。

そんな時にcomptimeが役に立ちます。
まずはアクセスできるビット幅を表すAccessWidth enumを定義します:

pub const AccessWidth = enum(u8) {
    QWORD = 8,
    DWORD = 4,
    WORD = 2,
    BYTE = 1,

    pub fn utype(comptime self: AccessWidth) type {
        return switch (self) {
            .QWORD => u64,
            .DWORD => u32,
            .WORD => u16,
            .BYTE => u8,
        };
    }

    pub fn size(comptime self: AccessWidth) usize {
        return @sizeOf(self.utype());
    }
};

非常にシンプル。
enum(u8)は、このenumのインスタンスが8bitで表現されることを明示します。
そのメンバーはビット幅を表す4つがあります。
utype()はアクセス幅に対応する整数型を返すユーティリティ関数です。
Zigでは、comptimeである限りtypeを関数の返り値とすることができます
size()もユーティリティで、enumインスタンスが表現するビット幅を返してくれます。

それを踏まえてMMIOレジスタを表現する構造体を定義します:

pub fn Register(
    comptime T: type,
    comptime access_width: AccessWidth,
) type {
    return packed struct {
        const Self = @This();
        const asize = access_width.size();
        const atype = access_width.utype();
        const len = @sizeOf(T) / asize;

        /// Underlying data
        _data: T,

        /// Read the data from the underlying register with the correct access width.
        pub fn read(self: *volatile Self) T {
            var ret: T = mem.zeroes(T);
            const ret_bytes: [*]volatile atype = @ptrCast(mem.asBytes(&ret));
            const val: [*]volatile atype = @ptrCast(mem.asBytes(&self._data));
            for (0..len) |i| {
                ret_bytes[i] = val[i];
            }

            return ret;
        }
    };
}

この関数は、comptimeな型TAccessWidthを受け取り、その型を持つレジスタ構造体を返します
ここでもやはり、関数が型を返しています。
しかも、返される型は_data: Tといった引数で取った型を持っています。
_dataメンバは、実際のレジスタの中身を表す型です。
先程の例で言うと、こんな感じで使います:

capability_regs: *volatile Register(CapabilityRegisters, .DWORD),

これでcapability_regs変数はCapabilityRegisters構造体を指すポインタでありながら、DWORDアクセスをすることができる型を持つことになります。

さてさて、Register()の定義に戻ります。
asize/atypeメンバは、アクセス幅に対応した型とそのサイズを表しています。
返される構造体型のstaticな変数のような扱いになります。
read()はコアの部分で、アクセス幅に対応した幅でアクセスしつつ、もとの構造体の型でデータを返します。
やっていることとしては:

  • MMIOレジスタをvalというアクセス幅に対応する型(atype)の配列とみなす。
  • 実際のレジスタ型(T)を持つ空の変数retを用意する。
  • 必要な回数(len)だけデータをコピーする

と言った感じです。まぁ実際にどんなことをしているかよりも、型を引数に取ることでその型に応じたメソッドを持つ構造体を作れるということがポイントです。

Case 2: Partial Type

TypeScriptのようなスクリプト言語には、ある型の一部だけを持つ型を表現する機能があります。
コンパイル型言語ではなかなか無い機能ですが、Zigではcomptimeを使うことで実現できます。

先程の例ではMMIOレジスタからの読み取りをしていましたが、ここでは逆に一部のフィールドのみ書き込みたいとしましょう。
この時、以下のように書くことが出来ます:

pub fn modify(self: *volatile Self, value: anytype) void {
    var new = self.read();
    const info = @typeInfo(@TypeOf(value));
    inline for (info.Struct.fields) |field| {
        @field(new, field.name) = @field(value, field.name);
    }

    self.write(new);
}

まず、read()によって現在の値を取得します。
その後、info変数にvalue引数の型情報を取得します。
valueanytype型を持ち、comptimeな任意の型を受け取ることができます(よってcomptimeとわざわざ書く必要はありません)。
この時、info.Struct.fieldsvalue構造体が持つ全てのフィールドを表す配列です。
この配列をforループで回すことで、new変数の対応するフィールドにvalueのフィールドをコピーしていきます。
ここで登場するinline forcomptimeな場合にのみ使うことができ、コンパイル時にアンローリングされます。
他にはinline switchとかinline whileとかがあります。

これで一部のフィールドだけを変更したいときには以下のように書けます:

capability_regs.modify(.{
    .cap_length = 0x10,
});

すごいね、めっちゃ便利だね!
(ちなみにxHCIのCapability Registerにおけるcap_lengthはROなので本当は書き込んじゃだめだYO)

Case 3: 関数も返しちゃえ

少し趣を変えて、今度は割り込み処理を書きたいとしましょう。
x64において、CPUは割り込みを受けるとIDTを探して割り込みベクターに対応するハンドラを呼びます。
このハンドラではレジスタの退避などを行うため、基本的には全割り込みで共通したものを使いたいです。
しかしながら、ここで問題が。
共通したハンドラを使うと、割り込みベクタ(割り込み番号)が何番かが分からなくなってしまいます
これでは、共通したハンドラからベクタに対応するハンドラを呼び出すことが出来ません。

そこで、Linuxなどではアセンブラで以下のように書いています:

SYM_CODE_START(early_idt_handler_array)
	i = 0
	.rept NUM_EXCEPTION_VECTORS
	(...)
	pushq $i		# 72(%rsp) Vector number
	jmp early_idt_handler_common
	i = i + 1
	.fill early_idt_handler_array + i*EARLY_IDT_HANDLER_SIZE - ., 1, 0xcc
	.endr
SYM_CODE_END(early_idt_handler_array)

割り込みベクタiを最初にpushqしたあと、jmp early_idt_handler_commonで共通ハンドラに飛ばしています。
この処理をマクロを使って必要な分だけ生成しているというわけです。

この処理、Zigで書くことも出来ます(一部省略):

pub const Isr = fn () callconv(.Naked) void;

pub fn generateIsr(comptime vector: usize) Isr {
    return struct {
        fn handler() callconv(.Naked) void {
            // If the interrupt does not provide an error code, push a dummy one.
            if (vector != 8 and !(vector >= 10 and vector <= 14) and vector != 17) {
                asm volatile (
                    \\pushq $0
                );
            }
            asm volatile (
                \\pushq %[vector]
                :
                : [vector] "n" (vector),
            );
            // Jump to the common ISR.
            asm volatile (
                \\jmp isrCommon
            );
        }
    }.handler;
}

export fn isrCommon() callconv(.Naked) void {
    asm volatile (
        \\(共通処理をここに書く)
    );
}

Zigではcallconvでcalling conventionを指定することが出来ます:

  • .Naked: 関数のプロローグ/エピローグを生成しない。アセンブラから呼び出す時。
  • .Win: Windowsの規則に従う。UEFIから呼び出すときとか。
  • .C: Cの規則に従う。Cから呼び出す時とか。

generateIsr()は、ベクタ番号を受取り、そのベクタをpushqした後に共通処理を呼び出すような関数を返します
アセンブラの.ifとかを使わず、卍高級言語卍の構文を使って返す関数の中身を制御することが出来ます。
Zigは、こんな感じのメタプロができる言語です。そう、comptimeならね。

返した関数は実際に関数として実体を持ち、以下のように生成することが出来ます:

pub fn init() void {
    inline for (0..num_system_exceptions) |i| {
        idt.setGate(
            ...,
            isr.generateIsr(i),
        );
    }
    ...
}

でました、inline for
これによって、コンパイル時に0~num_system_exceptions - 1までの分だけ割り込みハンドラ用の関数が実際に生成されます。
nmで見てみると、こんな感じです:

000000000011ce40 t arch.x86.isr.generateIsr__struct_2549.handler
000000000011ce50 t arch.x86.isr.generateIsr__struct_2552.handler
000000000011ce60 t arch.x86.isr.generateIsr__struct_2555.handler
000000000011ce70 t arch.x86.isr.generateIsr__struct_2558.handler
000000000011ce80 t arch.x86.isr.generateIsr__struct_2561.handler
000000000011ce90 t arch.x86.isr.generateIsr__struct_2564.handler
000000000011cea0 t arch.x86.isr.generateIsr__struct_2567.handler
000000000011ceb0 t arch.x86.isr.generateIsr__struct_2570.handler

もう意味わからないアセンブラのマクロなんて使わなくて良いんだ!やった〜〜〜〜〜。

アウトロ

ねこ〜〜〜〜〜〜〜〜。
しば犬飼いたい〜〜〜〜〜〜〜〜〜〜。
働きたくないよ〜〜〜〜〜〜〜〜〜〜〜〜。
いぬ〜〜。ねこ〜〜〜〜〜。
犬カフェ〜〜〜〜〜。ポメラニアン〜〜〜〜。
🐕
🐕
🐕
🐕
😺
🐕
🐕
🐕


次回、「Zig探訪 - エコシステム編」でお会いしましょう。

GitHubで編集を提案

Discussion