Zig探訪 - comptime編
イントロ
さあ、やって参りました。
第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::Device
はopen()
っていうメソッド持ってるからこれのことだな〜」- 🤔「あれ、この関数ほぼ何もしてないやんけ!どうなってんねん!」
- 🤔「あ、この
class UsbDevice
って子クラスあるやんけ、そっちを呼んでるのね」- 🤔「あれ、待てよ。この
device
ってもともとどっちとしてインスタンス化されてるんだ」 - 🤔「おいおい、
class MouseDevice
っていうさらに子供のクラスかよ!」
- 🤔「あれ、待てよ。この
- 🤔「あれ、
desc
っていう変数は今のスコープで定義されてないけど...?」- 🤔「あ〜、はいはい。このクラスのメンバ変数ね。了解了解」
- 🤔「はーん、
- 🤔 「
desc
型はただint
をラップしてるstruct Descriptor
構造体っぽいな」- 🤔「あ?この構造体
+
オペレータをオーバーロードしてるな、ふざけやがって。」
- 🤔「あ?この構造体
- 🤔 「
MouseDevice::open()
の定義2つあるやんけ、オーバーロードややこしいんじゃ。」 - 🤔 「は?どっちの定義も
struct Descriptor
なんて取らないやんけ」- 🤔「あー、はいはい。
open()
の引数になるクラスのコンストラクタにexplicit
ついてないわ。暗黙変換起こるのね」
- 🤔「あー、はいはい。
- 🤔 「この返り値になるクラスはコンストラクタ中でヒープからメモリ取るのね」
- 🤔 「ところでこのヒープ領域はいつ解放すればいいの?」
- 🤔 「まあ流石にデストラクタで勝手に解放するのかな。いや、でも
Finalize()
っていうメソッドもあるけど?」
- 🤔 「まあ流石にデストラクタで勝手に解放するのかな。いや、でも
- 🤔 「ところでこのヒープ領域はいつ解放すればいいの?」
まあちょっと冗長に書きすぎました。流石にオーバーですけど、慣れてない人だとこのくらい右往左往しちゃうわけです。
特にVSCode上で呼んでるときならまだしも、GitHub上で読んでいる時にはもうなんのこっちゃ分からなくなります。
対して、Zigは様々な機能を削っており、かつ全てが明示的になっています:
- 関数のオーバーロード: できません。
- デフォルト引数: ありません。
- 暗黙的な型変換: 一切出来ません。
i8
からi16
もできません(comptime
ならできるけどね)。1
とtrue
もできません。勝手にクラスのコンストラクタが呼ばれることもありません。 - 例外: ありません。エラーになったら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
ニートなので暇な時間にMikanOSをZigに移植して遊んでいるんですが、その中で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
な型T
とAccessWidth
を受け取り、その型を持つレジスタ構造体を返します。
ここでもやはり、関数が型を返しています。
しかも、返される型は_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
引数の型情報を取得します。
value
はanytype
型を持ち、comptime
な任意の型を受け取ることができます(よってcomptime
とわざわざ書く必要はありません)。
この時、info.Struct.fields
はvalue
構造体が持つ全てのフィールドを表す配列です。
この配列をfor
ループで回すことで、new
変数の対応するフィールドにvalue
のフィールドをコピーしていきます。
ここで登場するinline for
はcomptime
な場合にのみ使うことができ、コンパイル時にアンローリングされます。
他には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探訪 - エコシステム編」でお会いしましょう。
Discussion