- Cコードのコンパイルとクロスコンパイル機能を標準で提供するZigコンパイラは、45年の経験を持つ筆者が触れてきた言語の中で最も驚くべき言語である
- コンパイル時実行、任意ビット幅の変数、テストブロック環境など独自の機能により、単なるC/C++代替を超えたまったく新しいプログラミング手法を提供する
- 型推論による変数宣言、無名構造体、ラベル付きブレークなど、簡潔で明快な文法により短時間で学習できる
- テストブロックによる独立したモジュールテストと
@breakpoint組み込み関数により、最適化済みコードのデバッグを支援する
- ビットフィールドとビット演算を活用した低レベルプログラミング支援により、効率性と堅牢性を同時に実現し、インタプリタ言語の利点をコンパイル言語に統合している
序文
- 45年のキャリアの中で、Zigほど驚かされた言語はなかった
- Zigは単なる新しい言語ではなく、プログラミングのやり方を根本から変える道具である
- CやC++の代替としてだけ見るのは大きな過小評価である
- この記事の目的は、Zigのシンプルで魅力的な機能を紹介し、プログラマが素早く始められるようにすること
- 業界でのZigの受容性に影響する機能は、ここで触れる以外にも多数存在する
Zigコンパイラ
- Cコードのコンパイルとクロスコンパイル機能を追加設定なしで標準提供し、産業界に大きな影響を与える
- インストールはZinglangダウンロードページからプロセッサ/OS別のコンパイラを取得し、展開して任意のディレクトリにコピーする
- Windows 10ではx86_64 zipファイルを"Program Files"にコピーし、ルートディレクトリ名を"zig-windows-x86_64"に変更しておけば、バージョン更新時にPath環境変数を修正する必要がない
- Path環境変数にルートディレクトリのパスを追加すれば、CLIモードでコンパイラを利用できる
- "Hello World!"プログラムのビルドは、公式サイトの"Getting Started"セクションを参照することが推奨される
主要な概念とコマンド
変数宣言
- 変数宣言はアクセス指定子(
pubまたは省略)、var/const、変数名から成る第1部、型宣言である第2部、初期化である第3部で構成される
- 第1部と第3部だけが必須で、型は初期値から推論できる
- 例:
var sum : usize = 0;
pubなしで宣言された変数はモジュール内部でのみアクセス可能(Cのstatic変数に近い)
pub変数の宣言は推奨されず、pub関数も最小限にして結合度を下げ凝集度を高めることが推奨される
構造体、無名構造体、テストブロック
.{と}で囲まれた無名構造体リテラルは、他の構造体要素の初期化や、要素が初期化された新しい構造体の生成に使われる
.{ }は空の無名構造体リテラル
struct { }形式は構造体宣言
- テストブロックは実行ファイルなしでコンパイルとテスト実行が可能
ビットフィールド
- ビットフィールドはpacked struct内で特定サイズの型を持つフィールドとして宣言される
- ポインタは特定のビットフィールドを指すことができる
Forループ
- Zigの文法はCより明快だが、
[0..8]ではなく開区間[0..9)を使う
- ループ変数
iの型宣言、初期化、判定、増加は自動で処理される
配列
[_]はサイズ不明の配列を定義し、その後に要素型と初期化が続く
- 例:
var grid = [_]u8{0} ** 81;は81個のu8要素を0で初期化する
- 配列サイズは初期化の繰り返し引数から推論される
- テスト環境では配列要素を走査して合計できる
- forループの
|の間で宣言された変数は、配列要素と同じ型であると自動的にみなされる
usizeはプラットフォームの自然な符号なし整数(64ビットではu64、32ビットではu32)
多項目ポインタ
- 配列ポインタがポインタ演算を使うには、
[*]const i32のように明示的に多項目ポインタとして宣言する必要がある
- 配列がconstでも、ポインタ自体はvarとして宣言できる
ポインタ逆参照
- 個々の配列位置のアドレスが代入されたポインタは、ポインタ演算で更新できない
- ポインタ逆参照には
ptr.*を使う
ラベル付きブレーク
- コンパイル時に配列初期化などさまざまな処理を実行できる
- ラベル付きブレークはブロック名の後に
:を付け、breakでブロックから値を返す
0..は0から始まる無限範囲
- forループでは変数は自動初期化・自動増加され、配列の最後の位置を処理するとループが終了する
- 配列は
undefinedで明示的に初期化しなくてもよい
Zigの関数
- **関数は
fnで宣言し、デフォルトではstatic(ファイル内部でのみ使用)**である
pub fnで宣言すると他ファイルからimport可能
- 関数は"inlined"可能である
- 関数ポインタは
constが先に来て、その後に関数プロトタイプが続く
Zigのオブジェクト指向プログラミング
- 構造体は関数を持てる
- スタックの例では最大81個の要素(
StkNode型)を格納できる
++および--演算子はZigには存在せず、+=と-=を使う
- スタックポインタは
stk配列のインデックスとして使われる整数
- ポインタ
selfは引数として明示的に渡されず、関数が呼ばれるスタックインスタンスへのポインタとして暗黙に扱われる
stack.pop()のように呼ぶと、selfはstackへのポインタになる(Java/C++のthisに近い)
init()関数はスタックのコンストラクタ
popとpush関数は"inlined"される
Zigプログラムのビルドと実行
実行ファイルのビルド
- 実行ファイルを生成するには、プログラムのエントリポイントを表す
main関数が必要
- 単純なプログラムならmain関数を同じファイルに含められる
- モジュール単体のデバッグのため、ファイル末尾にmain関数を挿入し、デバッグ完了後にコメントアウトすることもできる
- コンパイルコマンド:
zig build-exe -O ReleaseFast program.zig
モジュールのテストブロック実行
- Zigの最も優れた機能の1つで、テストとプロトタイピングに使える
- テストブロックは
test "message" {で始まり、}で終わる
- "message"はテスト実行時に表示される文字列
- テストブロックは実行ファイルとは独立して実行され、最終的な実行ファイルではテストは実行されない
- テストコマンド:
zig test module.zig
example.zigのテストブロックではset関数とprint関数をテストし、setは10進数字文字列を引数に取り、printは"Input Grid"ヘッダを出力した後にgridを表示する
Zigの出力
std.debug.print文は、標準Zigライブラリstdのdebug.zigにあるprint関数を呼び出す
- 第1引数はフォーマット文字列、第2引数は表示する変数リストを含む無名構造体
- フォーマットが不要な場合、構造体は空である
- デフォルトではstderrに表示される
- Cのprintfと違い、Zigはリテラル文字列と変数リストをコンパイル時に処理できる
実行ファイルのデバッグ
- デバッガの使用は、統合デバッガを備えたIDE(Eclipse、IntelliJ IDEA)または統合開発キット(w64devkit)以外では簡単ではない
- シンボル統合はコードを肥大化させ、Debugモードでのコンパイルを要求するため、効率が著しく低い実行コードを生む
- Zigはこうした問題を避けるための便利な解決策を提供する
@breakpoint組み込み関数
- ソースコードに
@breakpoint();を挿入すると、デバッガで実行した際にその地点でプログラムが停止する
- シンボルなしでも最適化済みZigコードをデバッグできる有用な機能
@breakpoint();の直前でstd.debug.printを使って追跡したい変数を出力すれば、その時点の変数値を確認できる
debug_example.zigの例では、set関数内部にgridと変数を出力するコードと@breakpoint();を挿入している
- ビルドコマンド:
zig build-exe debug_example.zig
- gdbのようなデバッガで
debug_example.exeを起動し、rコマンドでプログラムを実行する
cコマンドで継続しながらgrid内容と変数を追跡する
- Enterを繰り返し入力して進めると、gridの値が
example.zigのテストブロックと一致することを確認できる
Zigの低レベルプログラミング
行列表現
- 10進数字は標準の
u8整数として行列に保存される
- 入力gridは文字列形式だが、ASCII文字は内部的に
u8整数へ変換される
- 数字の保存は81個の位置を持つ配列
gridに、1行ずつ線形に構成される: var grid = [_]u8{0} ** 81;
- gridの正しさを検証するには、各行と列から要素にアクセスする必要がある
- 9個のポインタ配列を作成し、各ポインタが各行の先頭を指す
- ラベル付きブレークを使ってコードブロックから値を返す:
break :fill9x9 m;でmatrixをmで初期化する
- 要素アクセス表記:
element = matrix[i][j]
10進数字をビットで表現する
- 整数の10進数字
iを整数codeに置き換えるのが中核となる考え方
i ∈ [1,9] → code = 2ⁱ⁻¹
i = 0 → code = 0
codeで唯一1に設定されるビット位置はi-1(iが1〜9のとき)で、それ以外では全ビットが0
- 各数字に対するcode値の表が示される(1→1、2→2、3→4、...、9→256)
Zigでのcode計算
cが0でないときだけ左シフト演算子でcode値を計算する: code = @as(u9,1) << (c-1);
- Zigでは、演算をコンパイルして結果を変数に代入するには、定数に適切なサイズが必要
codeはu9型として宣言される(最大値256には最低9ビットが必要)
- Zigでは任意ビット幅の変数を持てる
- 組み込み関数
@asで定数1をu9型にキャストする
ビットフィールドを使ったgrid表現
行ごとのビットフィールドgrid
- 配列
linesは各行を9ビット整数として表現し、grid全体をミラーする: var lines = [_]u9{0} ** 9;
- 行
iで配列にアクセスすると、特定の数字がその行にすでにあるかをビットAND演算(&)で確認できる: lines[i] & code
- 結果が0なら数字は行iにまだなく、それ以外なら重複である
列ごとのビットフィールドgrid
- 配列
columnsは各列を9ビット整数として表現し、grid全体をミラーする: var columns = [_]u9{0} ** 9;
- 列
jで配列にアクセスすると、特定の数字がその列にすでにあるかをビットAND演算で確認できる: columns[j] & code
- 結果が0なら数字は列jにまだなく、それ以外なら重複である
数独のルール
- 空の数独gridに新しい数字を挿入するとき、その新要素を含む行・列・セルのいずれにも既に存在していてはならない
- セルとは太線で区切られた9個の3x3 gridそれぞれのこと
- 9x9 gridの各要素は、それを含む固有の行・列・セルを持つ
- 例のgridでは最初のセルに3、5、6、8、9が含まれ、1、2、4、7が欠けている
- 配列
linesとcolumnsは行と列の重複チェックを処理する
- セルの重複チェックには新しい配列が必要
セルごとのビットフィールドgrid
- 配列
cellsは各セルを9ビット整数として表現し、grid全体をミラーする: var cells = [_]u9{0} ** 9;
cellsを3x3行列として扱うほうが簡単である
- 9x9行列で行ったのと同様に配列
cellを埋める
- 元の9x9 gridの要素の行と列から、
cell行列の行と列を決める必要がある
- 整数除算は非常に遅いため、配列
cindx = [_]usize{ 0,0,0, 1,1,1, 2,2,2 };を使って除算結果を与える
- 9x9 gridの要素の行
iと列jで行列にアクセスすると、その要素のセルに特定の数字がすでにあるかをビットAND演算で確認できる: cell[cindx[i]][cindx[j]] & code
- 結果が0なら数字はセルにまだなく、それ以外なら重複である
要素の重複テスト
- 同じ行・列・セルのそれまでの全要素をビットOR(
|)で結合し、その後で要素のcodeとビットANDを取れば、要素の重複検証が完了する
if (((lines[i]|columns[j]|cell[cindx[i]][cindx[j]])&code) != 0) {
unreachable;
}
- 結果が0なら、その要素はまだ行・列・セルのいずれにも存在しない
- 結果が0でなければ、プログラムは
unreachable命令を実行して停止する
- Zigで実行時エラーを明示的に表す最も簡単な方法である
- 実際のコードでは、エラー発生位置の詳細も出力する
- 例: 入力文字列の最初の
'8'の直後にある'0'を'5'に置き換えると、列1の行3にすでに5があるためエラーになる
データ構造の更新
set関数では二重forループが行単位で連携し、入力文字列sの各新要素をgridへコピーする
- 変数
kは文字列s内の新しい入力文字のインデックスを保持する
- 文字は
'0'を引いてu4(変数c)へ変換される
- gridに挿入する新要素が0でなければ(
c != 0)、左シフト命令で計算したcodeを各ミラーgridへコピーする
- 該当するミラーgridに対してビットOR(
|=)を行う:
lines[i] |= code;
columns[j] |= code;
cell[cindx[i]][cindx[j]] |= code;
cが1〜9の範囲かどうかを明示的に検査する必要はない。シフト演算実行時にオーバーフローが発生するためである
- 例: 入力文字列の最初の
'8'の直後にある'0'を':'に置き換えると実行時エラーになる
- 同じ
'0'を'/'に置き換えても同様の実行時エラーになる
- プログラムは値が1〜9の範囲、すなわち入力gridが10進数字のみを含むときにだけ動作する
- Web上の多くの数独gridでは
'0'の代わりに.を使うため、set関数にはif (s[k] == '.') c = 0;という行がある
- これは
cが0になるため、シフト演算を都合よく回避できる
プロトタイピングと堅牢性
- 直前の2節で示した強制エラーは、Zigの重要な機能を示している
- 1つはZigの堅牢性であり、シフト演算のように不正な動作は許されず、実行時に捕捉される
- ここまでの工夫はすべて効率性に向けられているように見えるが、性能と堅牢性がトレードオフになった典型例でもある
- Cでは、シフト演算でビットが失われてもそれはプログラマの責任であり、それが特定のアセンブラ命令のより高い性能に結びつく
- もう1つの機能は、テストブロックをプロトタイピングに使える可能性である
- 応用可能性は無数にあり、ここで示した使い方は、エラー発生時の特定状況をデバッグすることにすぎない
- これらの機能だけでも、プログラミング言語としては非常にまれな驚くべき能力を提供する。とりわけコンパイル型言語ではなおさらである
結論
- ZigはC互換性、クロスコンパイル、簡単なインストールという3つの中核要素から成る
- こうした特性は、システムプログラミング言語の新たな標準となる可能性を示している
- インタプリタ言語にしか見られなかった多くの利点が、より高い性能を得るために徐々にコンパイル言語へ移りつつある
- Zigはコンパイル時実行という概念により、インタプリタ言語との類似性が非常に際立っている
- それがZigを特別に異質で強力なものにしている一方で、理解を難しくしている面もある
1件のコメント
Hacker Newsのコメント
この記事は冒頭で「Zigは単なる言語ではなく、まったく新しいプログラミングのやり方だ」と主張しているが、実際にはZig固有の機能をほとんど扱っていない
型推論、匿名構造体、labeled breakなどは、ずっと前から他の言語にも存在していた
本当に独特なのはcomptimeだが、この点にはまったく触れられていない
Lispマクロのように完全に新しい概念ではないが、Zigがこれをジェネリクスの代わりに使う方法は興味深い
とはいえ、記事の主張はかなり大げさに感じる
Rustはコードの実行時点を明確に表現でき、コード空間全体を探索するクエリエンジンのような設計が印象的だ
Dドキュメントへのリンクを参照
const-expressionなら自動的に実行される
Java/Scalaのように完全に別の言語だからだ
ZigはC++テンプレートよりすっきりしているが、革命的というより実用的な代替手段に感じる
個人的には、Rustのときのような過剰な熱狂は理解しにくい
Zigのドキュメントを全部読んでも驚くような点はなく、拍子抜けした
Zigの最大の問題は、エラーにデータを付けられないことだ
エラーは補助チャネルでしか伝えられないためデバッグが難しく、結局開発者はエラーデータを省略しがちになる
関連Issueを参照
AccessDeniedのような単純なコードだけでは原因を把握しにくい
実際、複雑な
Errorオブジェクトを使っていても、別個の診断チャネルが必要になることは多い性能オーバーヘッドやシステム状態の問題から、状況によっては遅延バインディングで処理するほうが安全なこともある
Zigはこうした精密さと決定性を優先する思想を持っている
関連Issueを参照
ただ、本当に必要なのは構造化ロギングと、コールスタックに基づく文脈追跡の機能だ
怠惰な開発者が何でもかんでもデータをべたべた付けるのを防げる
「Zigの開発スタイル自体が新しい言語開発のスタイルだ」という主張には共感する
機能を慎重に検討し、不要なものを削っていくゆっくりとした進化の過程が印象的だ
Zigならではの独自性がどこにあるのか、もっと具体的に聞きたい
ZigをPyPIからインストールできるのが気に入っている
ziglangパッケージを
pip install ziglangで入れれば、すぐ使えるuvxを使ってCコードをビルドすることもできるAda、Object Pascal、Modula-2のような言語ですでに存在していた機能を、Zigの「革新」として包装している点は残念だ
Cスタイルの構文で再包装されると、40年前のアイデアが新しく見える現象は興味深い
記事の導入部は良かったが、その後は単にZigの機能の列挙で終わっている
Zigの直感的な構文と明示的な制御フロー(deferなど)は魅力的だ
comptimeのおかげで、別個のマクロ構文を学ぶ必要もない
あらゆる構成要素が自然にかみ合っていて、初めて使っても長年使ってきた道具のように感じられる
Zigの
for (0..9)構文は直感的だが、開区間なのでしばしば紛らわしいPythonのrange(0, 9)のように、最後の値を含むかどうかを忘れやすい
0..9と0..=9を区別していて明快だ区間の大きさは単に差で計算でき、逆方向の走査も簡単になる
0..<5(開)と0...5(閉)で、より明示的に区別しているZigの識別子ルールは好きになれない
snake_caseとcamelCaseが混在していて不自然だ
それでも、ビルドシステム、メモリアロケータ、コンパイル体験などは素晴らしい
普段はRustを主に使っているが、Zigへの好奇心はずっと続いている
Cライブラリのプレフィックス規則も同じように面倒だ
Zigの魅力は何か一つの機能ではなく、実用的な判断の積み重ねにある
最初は急進的に見えた選択も、理解が深まるほど納得できるようになる
Zigは好奇心の強い開発者に報いてくれる言語だ
Zigが優れている理由の一つは、低水準システムコードの現実を認めていることだ
多くの言語は美学上の理由でこうした部分から目を背けるが、Zigはそうしない
page_allocatorドキュメントを参照