unsafeなRust
Rustは非常にモダンですし、C/C++なみのかなり高速な言語であることはみなさんすでにご存知かとおもいます。開発者に広く愛されている言語でもあり、実際に色々なプロダクトに採用されはじめています。たとえば、MicrosoftのWindowsの一部のドライバやモジュール等への導入を検討している(参考)そうですし、Linux 6.1のリリース時にLinus Torvalds氏がRustの導入を認めたことで、Linux Kernel 6.1 でRustの“とりあえずの枠組み”が入り、徐々にRustで書かれたドライバがメインラインに取り込まれ始めている段階だそうです(参考)。LinusはLinux Kernel開発にC++の採用を拒否していることが知られていますが(参考)、あの気難しい(失礼…)Linusがそういう対応を取るのは衝撃でした。
僕自身も個人的な開発はほぼ100%Rustで書いています。Rustは「このコードをうごかしたらどうなるか?」が(他の言語と比較して)読んだだけでもわかりやすい言語だと思います。コンパイラも賢く、メンテ・リファクタリングもしやすいです。
ただ、そんなRustもunsafeなコードをたくさん書く必要があるプロジェクトだと…?
通常のRustコードは至高だけど…
このブログで述べられていますが、unsafeのRustの扱いは難しいです(だからといってC/C++のほうが簡単というわけでもないですが)。それはRustにはBorrow checkerがあり、そのためUndefined behaviorに微妙なちょっとわかりづらいルールがあるためです。これはRustのコンパイラーが、コードがメモリの所有権・借用ルールにしたがっていることを前提に最適化をかけるためであり、そのためunsafeなRustを書くのが難しくなっています。
unsafeなRustの例
fn main() {
let mut num = 5;
// raw pointer
let r1 = &num as *const i32; // Immutable raw pointer
let r2 = &mut num as *mut i32; // Mutable raw pointer
unsafe {
// raw pointerをDereferenceする(unsafe)
println!("r1 points to: {}", *r1);
*r2 = 10;
println!("r2 now points to: {}", *r2);
}
println!("num is now: {}", num);
}
上記のような単純な例ならいいですが、詳細はあまり書きませんけども、例えば、unsafeなRustで参照を扱うと、所有権・借用ルールに従ってその参照を扱わないといけません。そうしないとUndefined Behaviorになってしまいます。これを避けるために、raw pointerを使うとしましょう。これはraw pointerには参照と違って所有権・借用ルールに従う必要がないためです。
ではraw pointerに頼れば問題が解決するのでしょうか?そうではなく、raw pointerを参照にする際、その参照がその参照の型の所有権・借用ルールに(その参照のライフタイムの間)従う必要があります。
これを手動でコントロールする必要があるんですが、これを手動でするのが難しくなっています。
じゃあもう参照にするということをせずにraw pointerのままデータを扱ったらどうなのか?そうすると、 今度はunsafe Rustにraw pointerを便利に扱う機能があまりない、というのが問題になってきます。
このあたりがブログの筆者が指摘する問題で、unsafe Rustは手動の対応が難しくなってしまいます。
ただしunsafe Rustが「難しい」と言っても、最終的には安全なインターフェイスでカプセル化できるというメリットもあります。“UnsafeなRustはすべての側面でCより書きづらい”というわけではなく、ここで言いたいのは「Cはもともと安全性を何も保証しないので自由」「Rustはsafe/unsafeの境界が明確で、いったんunsafeに踏み込むと借用ルールと整合性を取るのが難しい」ということです。
また、冒頭でも述べたとおり、unsafeでない、通常部分のRustの素晴らしさはRustに触った人ならみなわかるとおもいます。Rustはメモリの対応を自動的に賢くやってくれます。しかも適当に書いても大抵は爆速で動作します。
ではunsafeなコードをどうしても書かないといけない場合はどうでしょうか。ブログの作者がつくっているのはmark-sweepのガベージコレクタを備えたとあるプログラミング言語のBytecode interpreter(VM)です。こういうraw pointerを扱うコードをどうしてもプロジェクトのあちらこちらに書かないといけない場合、unsafeなコードをCよりも簡単に扱える言語はないのでしょうか?
それができるのがZigという言語です。
Zigとはどんな言語か
ZigでHello World
const std = @import("std");
pub fn greet(name: []const u8) void {
std.debug.print("Hello, {s}!\n", .{name});
}
pub fn main() void {
greet("World");
}
Zigではメモリ管理は手動でやる。ただしモダンなやり方で。
Zigでは、メモリ確保を行う関数は必ずAllocatorを受け取るため、用途に応じて好みのアロケーション戦略を切り替えるのが非常に簡単です。たとえばガーベジコレクタを独自のAllocatorとして実装し、使用バイト数を追跡・閾値超過で自動的にGCを走らせることもできます。また、使い捨て領域に高速なアリーナアロケータを使ったり、メモリバグ検出用のアロケータ(use-after-freeやdouble-freeを検出しスタックトレースを表示)を有効にすることも、少ないコード変更で実現できます。
さらに Zig のポインタはデフォルトでnull不可なので、わざわざNonNull<T>のような型を使わなくても初期値を扱う際にnullチェックを強制されません。必要な場合のみ?*Tのようにnullを許可するので、デフォルトで安全性が高い設計になっています。
Cとの高い互換性
ZigはCのヘッダファイルを取り込んでそのまま使えるなど、Cとのインターフェイスが非常にシンプルにできています。もちろんRustもbindgenなどを用いてCとの連携を行えますが、Zigはコンパイラに同梱された機能としてCとのブリッジが提供されるのが強みです。またZigコンパイラは、クロスコンパイルを非常に簡単に行えるのも特徴です。
RustはC++の良き代替、ZigはCのモダンな後継かもしれない
Rustは「C++のような高機能な言語を、安全かつ効率的に扱いたい」というニーズにしっかり応えられるように設計されています。ジェネリクス、パターンマッチ、強力なマクロシステムなど、“大規模開発で活きる”要素が多く取り入れられ、かつコンパイラの仕組みを利用した安全性が売りです。
一方で、「Cのようにシンプルだけど、もう少し便利な機能や厳格な型チェックが欲しい」といったニーズに対しては、Zigのようなアプローチは非常に魅力的に見えます。Zigは所有権や借用などの概念がないため、低レベルに踏み込んだコードを書く際の煩わしさがありません。言い換えると、“Cをちょっとだけ安全・便利にしたモダンな言語"としての立ち位置を狙っているのだと思います。
もちろん、ZigにはRustほど強固な静的メモリ安全性はありません。すべてが“unsafe”と言えてしまうため、ポインタの扱いで足を踏み外せば普通に落ちます。ですが、Zigは「本当にメモリ管理やポインタ操作を自分でコントロールする必要がある」人たちにとっては、言語仕様がシンプルであるがゆえにむしろ書きやすく、扱いやすいのです。
まとめ
RustはC++の代替として、安全性と高機能さを両立しているところに強みがあります。
C寄りの低レベルなところをバリバリ書く必要がある場合、それが一部unsafeコードで済む程度ならRustで問題ありません。むしろsafeな部分はRustの恩恵が大きいでしょう。
しかし「ほぼ全体がunsafe前提のようなコードを書く必要がある」「大量のポインタ操作を要する」というケースでは、Zigのシンプルなアプローチが向いている場面もあるかもしれません。
さらにパフォーマンスの面から言ってもZigはunsafe前提で書く分、場合にもよりますが、うまい人が書けばRust以上に速く動作するコードが生まれる可能性もあります。
どちらの言語にも一長一短があるため、プロジェクトやチームの性質に応じて選ぶのが賢明です。