あなたが誤解しているRustの「安全」について
- 2025/2/6
- 動作がわかりやすいようにほぼすべてのコードに標準出力を追加
最近(2025/1/18現在)、チェスプログラムの評価を通して、Rustをボロクソに叩いた記事がZennに投稿された。
zenn.dev
Rustはデバックモードでのスタックサイズが限られていて、これが非常に愚かだ。
安全な言語なのにスタックサイズに制限がある事は愚かだ。
こんな事を書いたら、後からクレームのコメントがバンバン来るだろうなとは思ってますが、この動画で私が共感したのは、Rustは糞だと言う事です。
一概に、Rustが早くて安全と言う事は、決してありません。
これについて、率直に感じたことは、「Rustの『安全』は、あなたが思っているようなものではない」ということだ。そしてRustaceanも含め、この辺りは本当に誤解が多いと感じているため、今回は多くが誤解しているRustの「安全」について書くことにした。
TL;DR
Rustの「安全」とは、「未定義動作を踏むことがない」ことであり、したがってスタックオーバーフローやメモリリーク、デッドロックは、未定義動作ではないのでRustにおいては「安全」である。
ただし、Rustには「バックドア」的機能としてunsafeが存在しており、これは「未定義動作を踏む可能性がある」ため「安全」ではない。しかしプログラマによって「安全」が保障されたunsafeを含むコードは、「未定義動作を踏むことがない」ため「安全」となる。
未定義動作を理解する
PythonやTypeScript、Javaといった高級すぎる言語をメインに書いている人にとってはあまり馴染みがないことだが、ハイパフォーマンスなC, C++には未定義動作があるということを知っておかなければならない。
未定義動作とは、たとえばISO/IEC 9899:2018(通称C17)では、以下のように定義されている(3.4.3 p1)。
behavior, upon use of a nonportable or erroneous program construct or of erroneous data, for which this document imposes no requirements
(移植性がないもしくは誤ったプログラム構成、または誤ったデータを使用したときの動作で、本文書のいかなる要件を満たしていないもの)
また、未定義動作は、以下の場合に発生するとある(4 p2)。
If a "shall" or "shall not" requirement that appears outside of a constraint or runtime-constraint is violated, the behavior is undefined. Undefined behavior is otherwise indicated in this document by the words "undefined behavior" or by the omission of any explicit definition of behavior. There is no difference in emphasis among these three; they all describe "behavior that is undefined".
(制約または実行時制約の外側に現れる「~すべき」または「~してはならない」要件が違反される場合、その動作は未定義である。未定義動作は、この文書ではそれ以外にも「undefined behavior」という言葉、または動作の明示的な定義の省略によって示す。これら3つの間に強調部分の違いはなく、これらはすべて「未定義である動作」を記述している。)
したがって、未定義動作は、「~すべき」または「~してはならない」を違反した場合の動作を指すのであるから、未定義動作を含むコードは書くべきではない。
では、具体的にはどういったものが未定義動作となるのか、例を見ていこう。まずは、有名な配列の領域外アクセスである。
#include <stdio.h> int main(void) { int foo[10] = {0}; printf("foo[10] = %d\n", foo[10]); // Undefined Behavior return 0; }
次に、ポインタへの不正なアクセス。これはダングリングポインタと呼ばれる危険な行為である。
#include <stdio.h> int main(void) { int *foo = NULL; { int bar = 42; foo = &bar; } printf("*foo = %d\n", *foo); // Undefined Behavior return 0; }
これ以外にも、未初期化変数へのアクセスや、データ競合、理解されにくいところだとStrict Aliasing Rules違反も未定義動作となる。
実際のところ、未定義動作となるコードは、基本書いてはいけないコードばかりである。
ではそれを気を付ければ良いのでは?という話であるが、それはそうなのだが、常に意識し続けるのも大変であり、特に、チーム開発となると、未定義動作に対する意識が低いC, C++プログラマが一定数おり、それを指摘することで解決できるなら良いが、頑固な人は、わが道を突き進んで未定義動作を踏もうとするので、本当に厄介極まりない。
Rustの「安全」について理解する
Rustは、こういった未定義動作となるような箇所を、コンパイルエラーでほぼすべてカバーした言語である。
まず、Rustでは参照先が解放済みである参照へのアクセスはコンパイルエラーとなるのでダングリングポインタが発生しない。
fn main() { let foo: &i32; { let bar = 42; foo = &bar; } println!("*foo = {}", *foo); // コンパイルエラー }
なぜダングリングポインタを防げるか
さらに驚くべきことに、Rustではデータ競合もコンパイルエラーで弾くことができる。
fn main() { use std::thread; let mut foo = false; thread::scope(|s| { let th1 = s.spawn(|| { foo = true; println!("th1: {foo}"); }); let th2 = s.spawn(|| { println!("th2: {foo}"); // `th1`で可変参照しているので参照できず // コンパイルエラー }); th1.join().unwrap(); th2.join().unwrap(); }); }
なぜデータ競合を防げるか
先ほど「コンパイルエラーでほぼすべてカバー」と書いたが、それはカバーできていない部分が「安全」でないからではない。
ここまでわざと書かなかったが、配列の領域外アクセスに関しては、静的に検査するのが技術的に難しく*2、Rustでもコンパイルエラーとすることはできなかった。代わりに、アクセスするごとに境界チェックを行い、領域外ならパニックとすることで解決している。
fn main() { use core::array; let foo: [i32; 10] = array::from_fn(|i| i as i32); for i in 0..=10 { // `i`は0~「10」の範囲なので領域外アクセスでパニック println!("foo[{i}] = {}", foo[i]); } }
これは実行時コストが生じてしまうのだが、実はRustでは上記の方法で配列を走査するのは一般的ではなく、それ以外の、安全かつ実行コストのない方法を用いるため、実用上において境界チェックがボトルネックとなることはまずないだろう。
Rustの「安全」で防げなかったもの
ここまで、Rustがいかに「安全」であることを書いてきたが、実はRustの「安全」では保障できないことがある。
Rustの「安全」で防げない例として、まずはメモリリークが挙げられる。これは何らかの要因によりメモリが解放されずそのまま残り続ける現象である。ちょっと待って、Rustには所有権システムがあるのだから、メモリリークなんて発生しないのでは?
しかし、残念ながらRustでもメモリリークは発生するのだ。代表的なのが、RefCell<Rc<T>>を使ったリスト構造による循環参照である。このリスト構造で循環参照をすると、値を破棄しても参照カウント*3が0になることはないため、結果としてメモリリークとなってしまうのである。
また、循環参照なんてしなくても、Rustでは容易にメモリリークを発生させることが可能である。そのための関数がmem::forget()である。これは後述するunsafeがなくとも呼び出すことのできる、「安全」な関数である。
fn main() { use std::mem; let foo = Box::new(42); // ヒープ上に割り当て mem::forget(foo); // これでメモリリークを起こせるが、これは「安全」である }
とは言え、Rustでは所有権システムにより、メモリリークが起こりにくい設計にはなっている。少なくとも、mem::forget()を使ったメモリリークは、意図的に起こさない限り起こらないことは明白である。故に、「Rustでは絶対にメモリリークが起こらない」は偽であるが、同時に「Rustではメモリリークが起こりやすい」もまた、偽なのである。
次に例として挙げられるのは、デッドロックである。一部の同期プリミティブは、ロック機構を用いるため、デッドロックが起こる可能性がある。
fn main() { use std::sync::Mutex; let foo = Mutex::new(42); // ミューテックス let bar = foo.lock().unwrap(); println!("mutex lock: bar"); let baz = foo.lock().unwrap(); // これでデッドロックになるが、これも「安全」である println!("mutex lock: baz"); }
ここまでRustの「安全」で防げなかったものの例を書いてきたが、Rustにおいては、これらも「安全」なのである。ではなぜ「安全」なのか?
答えは単純で、メモリリークもデッドロックも、プログラムのロジックに問題があるのであり、未定義動作ではないからである。Rustにおいて「安全」であるとは、あくまで「未定義動作が発生しないことを保障する」ものでしかない。
確かに、メモリリークやデッドロックなどが起こらないことも「安全」と定義しているところもある。しかし、「未定義動作が起こらない」という点では、Rustは「安全」なのである。
「Rustのunsafeは『安全』である」という詭弁
ここまで書いてきたことは、すべて安全なRustに関してである。実はRustでは、「安全」を一時的に解除する「バックドア」的な機能もある。
それがunsafeである。
Rustでunsafeコードを書く場合、それは未定義動作を書いてしまうかもしれないことを示している。つまりunsafeは、文字通り「安全」ではない。
ここで勘違いしないでほしいのが、これは未定義動作コードを許容するための機能ではないことである。やむを得ない事情で、プログラマ自身が「安全」を保障する必要がある場合に使う機能である。
先ほど挙げた領域外アクセス、ダングリングポインタも、unsafeを使えば起こすことができる。
fn main() { use core::array; let foo: [i32; 10] = array::from_fn(|i| i as i32); for i in 0..=10 { // `i`は0~「10」の範囲なので領域外アクセスでUndefined Behavior unsafe { println!("foo[{i}] = {}", foo.get_unchecked(i)); } } }
fn main() { let foo: *const i32; { let bar = 42; foo = &bar; } unsafe { println!("*foo = {}", *foo); // Undefined Behavior } }
無論、unsafeは極力使わないで書くことが望ましいが、Rustが保証した「安全」の代償で、「安全」なコードも「安全」でないとしてコンパイルエラーになることがあるため、それを「安全」なAPIとして提供する際によく使われている印象である。
その最たる例が、内部可変性パターンを用いた型である。これらは内部でunsafeを用いるが、使用する側はそれを意識することなく、「安全」に使用することができる。
ところで、このunsafeに関して、「unsafeは安全だ」と主張してくる人が一定数存在している。
だが、unsafeは「未定義動作を踏む可能性がある」以上、「安全」ではないので、そのような主張はまったくもって詭弁である。この主張がまかり通ることは、特にチーム開発の面で悪影響が出ると私は考えているため、それを見た人は信じないでほしいし、主張する人も、それは本当にやめていただきたい所存である。
終わりに
今回は、Rustの「安全」について、誤解されやすい部分も含め細かく書いた。
C, C++では、未定義動作を避けながら書くことが難しく、これが性能が求められるシステム開発におけるコード品質の面で課題となっていた。
Rustが登場したことで、「安全」でないコードは基本的にコンパイルエラーで弾けるようになったし、「安全」でないコードも、unsafeを付けることで「安全」なコードとそうでない可能性のあるコードの見分けがつきやすくなった。
Rustが評価されているのは、これ以外にもあるのだが、私はこの「『安全』を保障しやすくなった」というところが大きいと考えており、私自身も、そういった点から、Rustを高く評価しているつもりである。