⛑️

Rustで始める安全第一プログラミング【世はまさに大安全時代】

2024/01/25に公開
14

Rustを勉強中の、万年駆け出しエンジニアです。
学べば学ぶほど、プログラミングの大きな壁に阻まれ日々苦戦しております。

今回は「Rustで始める安全第一プログラミング」という主題で、各種安全の重要性やRustで何がどのように解決されているのかについて書こうと思っています。

※ この記事のコメント欄も、とても詳しい方々が書いてくださっているので是非読んでみてください!

プログラミングにおける"安全"の重要性

プログラミングには危険がいっぱいです。
動的型付け言語であれば、例えばnull/undefinded が関数に渡されてるが、メソッド内ではその中のプロパティにアクセスしようとしてる場合、実行してからエラーが発覚します。

以下は動的型付け言語であるJavaScriptでの例:

function exampleFunction(data) {
    return data.someProperty;
}

// undefinedを渡す
exampleFunction(undefined);

これが本番環境で実行されてしまったら悲劇ですが、静的型付け言語であればコンパイル時にその悲劇を防いでくれます。コンパイルとは主にコンパイラ言語に際し、実行可能な内容に翻訳することを指します。

以下は静的型付け言語であるTypeScript[1]での例:

function exampleFunction(data: { someProperty: string }) {
    return data.someProperty;
}

// 型が正しくないため、この行はコンパイル時にエラーとなる
exampleFunction(undefined);

とにかく、上で説明したかったのは、「プログラミングには危険がいっぱい」ということ。

先の例で出した、TypeScriptが注目を集めたのは、強力な型安全があったからです。つまり最近のエンジニアリングのトレンドは、悲劇を未然に防ぐことができる言語なのです。このようなものを型安全と呼びます。

Rustが持つ安全性

先述した内容では、"世はまさに大安全時代である" と説明しました。
例に出したのは型安全でしたが、Rustはその他の安全性もきっちり保証してくれる優秀な言語です。

Rustが持つ安全性は以下です:

  1. 型安全
  2. メモリ安全
  3. スレッド安全
  4. (null安全)

型安全

「プログラミングにおける"安全"の重要性」の章でも説明しましたが、要するに関数などにおいて期待する型(文字列、数値、オブジェクト、配列、etc...)を指定し、それ以外が渡された際に静的エラーを吐いてくれることを言います。

// Rustの文字リテラル型は&strなので型エラーを吐きます
let str: String = "foo";

// Rustの文字リテラル型は&strなので問題なく動作します
let str: &str = "foo";
// String型を使いたい場合は以下
let str: String = String::from("foo");
let str: String = "foo".to_string();

Rustでは(比較するもんではないかもですが)TypeScriptよりも強力な型安全が保証されており、例えばですがTypeScriptでは多用されるunion型がRustでは存在しません。union型とは、A || B(AまたはB)のような型です。
直和型[2]と呼ばれる型は存在するのですが、長くなるので(以下略)

null安全

また、型の章で説明する方がスムーズなのでnull安全についても触れますが、Rustにはそもそもnullが存在しません[3]。これについては、長くな(以下略)。nullが存在しないという奇抜な方法で、強力なnull安全を実現しています。

Rustのnull安全については、別の記事を書いてますので、そちらもどうぞ。
https://zenn.dev/hajimari/articles/37775311fbdbaa

整数型

Rustにおいて、整数型はTypeScriptよりも詳細に分類されています。正の整数の場合、使用可能なビット数に応じてu8, u16, u32, u64, u128などの型が用意されています。

型の範囲を超える値を入れた場合、Rustは型エラーではなくオーバーフローエラーを吐きます。

// u8の範囲(0 ~ 255)を超えるため、オーバーフローエラーを吐きます
let num: u8 = 300;

// u16の範囲(0 ~ 65535)以内のため、問題なく動作します
let num: u16 = 300;

型安全な言語では、本番環境で実行する前にエラーを吐いてくれるため、簡単に事前に修正することができるわけですね。

メモリ安全

型安全に関してはTypeScriptの登場で聴き馴染みのある方も多かったと思いますが、Rustではメモリも安全に守ってくれます。メモリ安全とは、バッファオーバーフローやダングリングポインタと言ったメモリ関連のバグやセキュリティホールから保護されている状態を指します。

バッファオーバーフローとは、確保している領域の外にアクセスしてしまうことで、

let numbers = vec![1, 2, 3, 4, 5];
    
// セーフなアクセス
println!("3番目の要素: {}", numbers[2]);

// 境界外アクセス(パニックを引き起こす)
println!("6番目の要素: {}", numbers[5]);

配列の存在しないインデックスへのアクセスが行われた場合、境界値チェックが行われ、メモリにアクセスする前にパニックを発生させてプログラムを安全に終了します。

また、ダングリングポインタとは、解放された、またはもはや有効でないメモリ領域を指しているポインタのことで、以下の場合はコンパイラが静的なエラーを吐いてくれます。

fn main() {
    let r;
    {
        let x = 5;
        r = &x;
    } // x のスコープが終わる

    println!("r: {}", r); // エラー: x はもう存在しない
}

x が破棄されると、r はもはや有効なメモリを指していません。しかし、Rustのコンパイラはこの問題を検出し、r が無効な参照になることを防ぎます。このため、最後の println! はコンパイルエラーになります。

このようにして、Rustはバッファオーバーフロー、ダングリングポインタと言ったメモリ関連のバグを防ぐことができます。ただし、自作または使用するライブラリのunsafeコードに未定義動作(UB)がないと仮定した場合に限ります。

メモリの適時解放

Rustでは不要になったメモリを自動的に解放する仕組みがあります。

メモリとは「倉庫」のようなもので、その中には「箱」のようなデータの入れ物が存在します。プログラムが動作する際、箱にデータを入れ、これを倉庫に格納します。
メモリリークとは、不要になったデータが倉庫内に残り続けることで、倉庫が溢れてしまう現象です。Rustでは、不要になった箱の中身(データ)を自動的に空にして倉庫のスペースを確保します。

Rustのメモリリークに関する補足

メモリの開放ですが、Rustではメモリリークを安全な範囲で許容しています。

コメント内でも説明されていますが、非同期や循環参照などが絡むとメモリリークを完全に防ぎ切ることはできません。

use std::rc::Rc;

struct Node {
    next: Option<Rc<Node>>,
}

fn main() {
    let node1 = Rc::new(Node { next: None });
    let node2 = Rc::new(Node { next: Some(node1.clone()) });

    // node1がnode2を参照するように設定
    Rc::get_mut(&mut node1).unwrap().next = Some(node2.clone());

    // ここで、node1とnode2が互いに参照し合っているため、
    // どちらの参照カウントも0にならず、メモリが解放されない状態(メモリリーク)になる。
}

Rustにおいてメモリリークは発生しない訳ではありませんのでご注意ください。

コレを可能にしているRustの仕組みが、所有権[4]ライフタイム[5]と呼ばれるものなのですが、詳しくは以下の記事を。

https://doc.rust-jp.rs/rust-nomicon-ja/ownership.html

この記事は非公式ながら公式サイトでも紹介されている日本語ドキュメントです。

Rustではこのメモリ安全を上記の方法で実現しているため、自動ガベージコレクション(以下GC)を使いません。自動GCはプログラムを走査し、解放できるメモリを解放するという手法ですが、この際に他のスレッドが全て停止している必要があります。

自動GCを利用していないので、一般的にメモリの解放にまつわるオーバヘッド[6]が小さいと言われています。

GCに関する補足

Rustでは自動GCを行っていないため、自動GCによるスレッドの停止は行われませんが、コメントにもある通り、

ヒープアロケータに依存している以上(加えてほとんどの場合OSからページを割り当てられている以上)、メモリの確保・解放のタイミングでは小さいながらも他のスレッドに対して影響を与えています。(例えば複数のスレッドが同時にメモリ確保を行なった場合は状況によっては片方が待たされてしまう場合があります)

ですので、「自動GCを利用していないので、一般的にメモリの解放にまつわるオーバヘッドが小さいと言われています。」と修正させていただきました。

スレッド安全

Rustは並行処理(マルチスレッド)が可能ですが、同じメモリ空間でメモリを確保します。その際、意図しない更新の衝突などが発生する可能性があります。

先ほどの「倉庫(メモリ)」と「箱」の例で説明しましょう。
箱の中身を出し入れする人が同じ倉庫に複数いて、Aさんが箱に「1」という値を入れたとします。

その後Bさんが同じ箱の中身を「2」に変更したとき、Aさんは箱に「1」が入っていると思いながら「2」を取り出してしまいます。

ではどのようにスレッド安全を実現しているのかというと、なんと「所有権」がここでも大活躍してくれます。

以下はRust はどのようにして安全な並列処理を提供するのかという神記事からの引用です。

  1. Rust はスレッドをつかってコードを並列で実行します
  2. 所有権の制約によりスレッド間でのデータの共有が行われないことが保証されるためデータ競合が起こり得ず安全です
  3. スレッド同士のデータの共有をチャンネルというメッセージの送受信器を経由して行う場合、スレッド内のデータはそのスレッドからしか変更されることがないため安全です
  4. スレッド間で可変なオブジェクトを共有する場合、データ競合が発生しない仕組みを利用していることをコンパイル時にチェックするので安全です

参照: Rust はどのようにして安全な並列処理を提供するのか

詳しくは上記の記事を読んでみてください。

Let's 安全第一プログラミング!

ここまでRustが超安全な言語であることが分かったことでしょう。
内部を知るまで、自分は「Rustって尖った言語なんだろうな〜」と思っていましたが、超安心・安全のまぁるい言語です。

Rustに関しては自分もまだ勉強中の身なので、皆で学んで安全第一プログラミングをしませんか?

脚注
  1. TypeScriptではコンパイラによるコンパイルではなく、トランスパイラによるトランスパイルが行われ、結果JavaScriptに翻訳されます。静的エラーはTypeScriptの場合トランスパイラが吐いてくれます。 ↩︎

  2. 直和型: Rustにおいてenumで宣言できる型で、代表的なものにはOption, Resultなどが存在します。とある型に他の型を入れることが可能です。 ↩︎

  3. Rustでnullのような振る舞いを実現したい場合は、Option::Noneを利用しますが、この際のNoneはnullではなくOptionという直和型の列挙子です。 ↩︎

  4. 所有権: Rustでは誰がなんのデータを持っているのかを明確にすることで、いつメモリ解放をすれば良いかコンパイラが検知できるようにします。 ↩︎

  5. ライフタイム: Rustの特殊な概念で、あるデータが解放されるまでの期間を指します。 ↩︎

  6. オーバーヘッド: 特定の作業やプロセスを遂行するために追加的に必要とされる時間やリソースのことです。例えば、データをデータベースに保存する際、実際のデータ保存処理以外にも、接続の確立やセキュリティチェックなどの追加的な処理が必要です。これらの追加的な処理がオーバーヘッドに該当します。 ↩︎

Hajimari Tech Media

Discussion

ピン留めされたアイテム
EvaEva

記事を読んでいただきありがとうございます。

こちらの記事は、コメントなどで指摘いただいたり、筆者が今後学習する中で「内容が適切ではない」と判断した場合、記事内容を編集していきます。

ですので、以前読んだ時と内容に相違があるかもしれませんが、ご了承ください。

higumachanhigumachan

型安全に関してはTypeScriptの登場で聴き馴染みのある方も多かったと思いますが、Rustではメモリも安全に守ってくれます。メモリ安全とは、自動でメモリ解放を行なってくれる特徴のことです。

ここは微妙に違う気がしています。

メモリ安全性 (メモリあんぜんせい、英語: Memory safety) は、バッファオーバーフローやダングリングポインタ(英語版)などの、RAMアクセス時に発生するバグやセキュリティホールなどから保護されている状態のことである[1]。例えば、Javaは実行時エラー検出(英語版)で配列の境界とポインタの参照外しを確認するので、メモリ安全であると言われている[1]。対照的に、CとC++は境界チェック(英語版)を行わないメモリアドレスを直接参照するポインタを使用した任意のポインタ演算が可能なので[2]、メモリ安全ではない[3]。

https://ja.wikipedia.org/wiki/メモリ安全性

に書いてあるとおりメモリ安全性というと、

バッファオーバーフロー: 確保している領域の外にアクセスしてしまう
ダングリングポインタ: 有効ではない型を指しているポインタ(例: 参照している値が解放済みのポインタ)

が存在しないことを指します。

その上でRustは(自分で書いているunsafeコードがUBを含んでいないかつ利用してるライブラリの中で利用しているコードのunsafeコード内にUBを含んでいないことを仮定した上で 以下 safe rust)メモリ安全ではあります。

その理由は、メモリエラーの内容によってさまざまなので一概にこれのおかげでメモリ安全を守っているというシンプルなものではないかと思います。

上の二つの例が守られている理由は

バッファオーバーフロー: unsafeではないAPIでは必ず境界値チェックと同等な前提が置かれている
ダングリングポインタ: ライフタイム、unsafeではないAPIでは未初期化の値を作成できない

かなと思います。


余談として、メモリ解放周りの話に着目すると、
Rustはメモリをあえて解放しないという処理はunsafeではないAPIでできます。
つまり、safe rust上でもメモリが解放されないという現象は起こせます。

例えばこの関数です。
https://doc.rust-lang.org/std/boxed/struct.Box.html#method.leak

この関数を使うと以下のように書くと、ヒープ領域に確保した領域を&'staticライフタイムつまりプログラム終了まで生き続けるライフタイムの借用として取り出すことができます。
上で述べたとおりこのAPIはunsafeがついていないのでsafe rustの前提があっても自分で呼ぶことができるし自分の利用してるライブラリで利用されていることがあるという形になります。

let x = Box::new(41);
let static_ref: &'static mut usize = Box::leak(x);
*static_ref += 1;
assert_eq!(*static_ref, 42);

長々と失礼しました。

EvaEva

コメントありがとうございます!
初心者の身でRustについての踏み込んだ記事を書いてしまった(本指摘に関してはメモリ安全の定義に関するものですが)ので、コメントを読ませていただき、適切に内容を修正いたします!

メモリ安全について、ご指摘を受けた上で調べてみたのですが、おっしゃる通りバッファオーバーフローとダングリングポインタが無いことをメモリ安全と位置付けており、記事内で書いている「自動でメモリを解放すること = メモリ安全」という表現は適切では無いようです。失礼いたしました。

メモリ安全とは、不正なメモリアクセス(バッファオーバーフロー、ダングリングポインタ)とメモリリークを防ぐ仕組みのことを指し、本記事でのメモリの自動解放は適切なメモリ管理(解放を含む)に該当し、間接的にメモリ安全に寄与している機能だと理解しました。

余談として、メモリ解放周りの話に着目すると、
Rustはメモリをあえて解放しないという処理はunsafeではないAPIでできます。
つまり、safe rust上でもメモリが解放されないという現象は起こせます。

上記については知らなかったので、とても勉強になります。ありがとうございます。
ただ、意図的にBox::leak()しない限り、またそれに相当するコードを記述しない限りはsafe rust前提でメモリ安全と位置付けられるのかなと思ったのですが、いかがでしょうか?
(その場合、メモリ管理に気をつけてコードを書けば、どんな言語でもメモリ安全になってしまうのでしょうか......?)

またその場合、言葉遊びかもしれませんが、Rustは「メモリ安全である」というより、「メモリ安全性が高い」と言った方が適切なのでしょうか?

higumachanhigumachan

上記については知らなかったので、とても勉強になります。ありがとうございます。
ただ、意図的にBox::leak()しない限り、またそれに相当するコードを記述しない限りはsafe rust前提でメモリ安全と位置付けられるのかなと思ったのですが、いかがでしょうか?
(その場合、メモリ管理に気をつけてコードを書けば、どんな言語でもメモリ安全になってしまうのでしょうか......?)

結論としては、Box::leakを呼んでもメモリ安全です。(もちろん、呼ばなくてもメモリ安全です)


これに関してなのですが、僕が議論を混ぜてしまったのが悪かった気がするので一旦整理します。

前提: メモリ安全は適時なメモリの解放とは独立の話である。
理由: 引用記事にはメモリリークの欄がありますがメモリを適時に解放しないという要件はありません。(そもそも、現実世界の上でメモリ上のリソースに対して解放する適時というのを厳密に定義するのが難しいと思っています。)

Box::leakの部分に関してはメモリの適時解放の部分に対しての議論だったため、メモリ安全とは独立の議論になります。

higumachanhigumachan

またその場合、言葉遊びかもしれませんが、Rustは「メモリ安全である」というより、「メモリ安全性が高い」と言った方が適切なのでしょうか?

基本的に言い切るのは難しい領域なので、無駄に仰々しい仮定をつけた上で断言するぐらいだったら「メモリ安全性が高い」にとどめておくほうがとっつきやすくて文句も少ないかもしれませんね。

higumachanhigumachan

Rustではこのメモリ安全を上記の方法で実現しているため、JavaやPythonとは違いガベージコレクション(以下GC)を使いません。GCはプログラムを走査し、解放できるメモリを解放するという手法ですが、この際に他のスレッドが全て停止している必要があります。

GCを使わず、コンパイル時にメモリ安全を実現しているため、Rustはスレッドを停止することなくプログラムを実行します。

あとこの部分にも注意が必要です。

RustはいわゆるmanagedなGCを利用していないのは事実ですが。

ヒープアロケータに依存している以上(加えてほとんどの場合OSからページを割り当てられている以上)、メモリの確保・解放のタイミングでは小さいながらも他のスレッドに対して影響を与えています。(例えば複数のスレッドが同時にメモリ確保を行なった場合は状況によっては片方が待たされてしまう場合があります)
ですので、一概に「GCを使っていない => スレッドの停止がない」という議論は難しいかと思います。

加えて、JavaもGCによるオーバヘッドを小さくする工夫がなされていますので、

「(managedな)GCを利用していないので、一般的にメモリの解放にまつわるオーバヘッドが小さいと言われています。」

ぐらいのほうが良いかと思います。

EvaEva

ヒープアロケータに依存している以上(加えてほとんどの場合OSからページを割り当てられている以上)、メモリの確保・解放のタイミングでは小さいながらも他のスレッドに対して影響を与えています。(例えば複数のスレッドが同時にメモリ確保を行なった場合は状況によっては片方が待たされてしまう場合があります)
ですので、一概に「GCを使っていない => スレッドの停止がない」という議論は難しいかと思います。

こちらに関してもご指摘ありがとうございます!
おっしゃる通り「複数のスレッドが同時にメモリ確保を行なった場合は状況によっては片方が待たされてしまう」と言ったような状況はあり、他スレッドに対して影響が出てくるので、「スレッドを停止しない」と断言するのは適切ではありませんでした、修正させていただきます。

EvaEva

ご丁寧にありがとうございます。

前提: メモリ安全は適時なメモリの解放とは独立の話である。
理由: 引用記事にはメモリリークの欄がありますがメモリを適時に解放しないという要件はありません。(そもそも、現実世界の上でメモリ上のリソースに対して解放する適時というのを厳密に定義するのが難しいと思っています。)

メモリ安全とメモリの適時解放を混同しておりました、失礼しました。
最初にhigumachanさんがおっしゃっていた、「Rustは(safe rust前提で)メモリ安全である」のは、

バッファオーバーフロー: unsafeではないAPIでは必ず境界値チェックと同等な前提が置かれている
ダングリングポインタ: ライフタイム、unsafeではないAPIでは未初期化の値を作成できない

が理由であり、メモリ適時解放とは独立したものであると理解できました。

こちらのコメントの内容を踏まえて記事内容も修正させていただきます!
ありがとうございます。

slozesloze

メモリの開放ですが、Rustではメモリリークを安全な範囲で許容しています。
これは非同期や循環参照が絡むとメモリリークをなくすことは不可能だからです。ほぼほぼ無くせるけど、特定の処理では不可能だから、許容しているスタンスですね。
Rustにおいてメモリリークは発生しないはよくある誤りですので、補足が必要になります。

また、他言語と違う特徴として、Rustにはメモリアライメントの最適化をしません。
C++では7byteのデータを余分に調整して8byteに整形する場合があります。8バイト境界にアライメントしたほうがメモリアクセスがよくなるからです。一方のRustはその機能がありません。そのかわり、プラットフォームの移植性を向上させています。
つまり、極端に最適化されたC++にRustは勝てないが、その分、移植性や借用という技が使えるということですね。ここは見えづらいRustの凄さだと思います。

EvaEva

ご指摘ありがとうございます!

Rustにおいてメモリリークは発生しないはよくある誤りですので、補足が必要になります。

こちらおっしゃる通り誤解を招く表現をしているため追記させていただきます。

また、他言語と違う特徴として、Rustにはメモリアライメントの最適化をしません。
C++では7byteのデータを余分に調整して8byteに整形する場合があります。8バイト境界にアライメントしたほうがメモリアクセスがよくなるからです。一方のRustはその機能がありません。そのかわり、プラットフォームの移植性を向上させています。
つまり、極端に最適化されたC++にRustは勝てないが、その分、移植性や借用という技が使えるということですね。ここは見えづらいRustの凄さだと思います。

これについては初めて知りました。単にパフォーマンスのみではなく、あらゆるプラットフォームで適応できることが考えられているのですね......。
教えていただきありがとうございます、勉強になりますmm

齊藤敦志齊藤敦志

アライメントの調整はあるようです。

特定のアライメントでメモリアクセスしないとセグフォになるアーキテクチャは珍しいものではなく特に RISC 系 CPU では不寛容なことも普通でした。 アライン調整されていないところから読みだそうとすれば読み書きするときにかなり回りくどい処理が必要になってしまい、アライン調整するよりだいぶんしんどいです。

今はほとんどのアーキテクチャでアライメントに寛容ですが、全く調整なしで運用できるほどではないはずはないです。 移植性のためにはそれぞれのアーキテクチャの事情に合わせてアライン調整する機能は必要です。

私は Rust についてそれほど明るくないのですが、最適化しないというのは必要以上には調整しないという意味なんでしょうか?

slozesloze

誤解がある説明をしてしまいました! 仰るとおりRustはタイプレイアウトを使用してアライメントの手動調節が可能です。C++のalignasと同様なものになります。

C++のコンパイラによっては最適化する際、自動でアライメントを調整することがあります。
しかしながら、Rustは最適化の際に、自動でアライメントを調整しません。

RISC/CPUのお話であったように、C++が勝手に最適化するために特定のアーキテクチャへの移植性が下がる可能性があります。
Rustは手動でのみ各アーキテクチャのアライメントを設計できるので、組み込みのような特殊なアライメント現場に強いと思っています。

つまり、借用を使って記載した通りの動きが保証されます。勝手な最適化を行って、余分なオーバーヘッドコストが発生しない。つまりRustの哲学であるゼロコスト抽象化を体現しているのです。

slozesloze

追記になりますが、とてもよくまとまった記事だと思います。Rustは難しく、私も挫折中です...。とっつきやすく解説することは、本当に大事だと思いました。

例にTSのコードがありましたが、最近はフロントエンドの人材が多いので、有益な比較になると思います。特にReactの関数型コンポーネントに理解があれば、Rustがclassを採用しなかった点に納得してくれるでしょう。

EvaEva

とても嬉しいお言葉ありがとうございます!

Rustは言語の学習難易度の問題もあり、広まりにくい点が残念です。
ユーザーが増えれば、コミュニティも活発になり、ライブラリやフレームワーク・ドキュメントが増え、さらにRustの人口が増えると思いますので、今後もRustの記事を投稿していきたいと思います。