🥥

Rustのnull安全性におけるunwrap(), unsafe()

2024/01/28に公開
11

Rustを勉強中の万年駆け出しエンジニアです。
日頃はTSやPHPを書いたり、PMをやっています。

先日、こちらの記事を出したのですが、
https://zenn.dev/hajimari/articles/618c900799fe90
この中でnull安全について触れている箇所があるんです。

要約すると、

Rustにはnullが存在しないため、他の言語にはない強力なnull安全を実現しています。

といった内容を書いているのですが、この記事を投稿した後にとある記事を読んで、そのコメント欄にこのような内容が書かれていました。一部を抜粋していますので、気になる方は元記事をお読みください。

Rustのnull安全を宣伝したら、「簡単にunwrapできるけどそれで安全性を保証しているの?」と言われました。メモリ安全性やスレッド安全性もunsafeを使えば簡単に壊せますが、unsafeは普段は使わないのでセーフと言えるかもしれませんが、unwrapは比較的カジュアルに使われているので、「保証」とまで言い切れるのか自信がなく、少なくとも「メモリ安全性」と比べると安全性は1段劣ってしまうのかなと感じました。

参照: Rustの実用性が理解できる図を作成してみた

確かに、Rustのunwrap()はかなりカジュアルに使用されています。
オライリーのRust本の例などでも、頻出するメソッドです。

前回投稿した記事では、Rustの表層部分についてのみ触れているので、今回はRustのnull安全性とunwrap/unsafeについて深掘りたいと思います。

null安全とは

unwrapやunsafeがnull安全に及ぼす影響を深掘る前に、「そもそもnull安全とは?」ついてもう少し詳しい定義を抑えておきます。

null値による問題(例えばnull参照エラーやnullポインタのデリファレンス)を防ぐための言語機能やプログラミング手法のことを指します。

まずはnull参照エラーについてです。
null安全を保証していないJavaScriptでの例を出します。

function getPropertyValue(obj) {
    return obj.property;
}

let obj = null;
let value = getPropertyValue(obj); // TypeError: Cannot read property 'property' of null

objはnullなのにも関わらず、その中のプロパティにアクセスしようとしてTypeErrorが発生しています。
こちらはコンパイラで検出できず、実行時にエラーが発生してプログラムがクラッシュします。

また、以下はnullポインタのデリファレンスについて、C++で例を出します。

#include <iostream>

int main() {
    int* ptr = nullptr;
    int& ref = *ptr; // 未定義動作: Nullポインタのデリファレンス
    std::cout << ref << std::endl; // この行は未定義動作の結果としてどんな挙動も起こり得る
    return 0;
}

上記の例では、C++ではnullポインタのデリファレンスを行っており、これは未定義動作(Undefined Behavior, UB)と呼ばれています。
C/C++などの言語において、"プログラムの動作が言語仕様によって定義されていない状況"のことを指します。

この場合C++のプログラムは、ここでプログラムはエラーメッセージを表示せずにクラッシュするかもしれませんし、何の問題もなく動作するように見えるかもしれません。
これはデバッグなどが困難になる原因となります。


「じゃあ、Rustにはnullが無いので最強だね、は〜い解散解散」とは問屋が卸しません。
(これは浅はかな過去の自分の姿です。戒め)

それがunwrapとunsafeの存在です。

unwrap()とは

Rustを学び始めると、高確率でこのunwrapにを見かけます。
先述しましたが、Rustのチュートリアルなどでも頻出する、かなり常連メソッドです。

エラーハンドリング周りで利用されることが多いメソッドなのですが、今回はunwrapのみに着目するためRustのエラーハンドリングについて深く言及しません。詳しくは以下の記事をお読みください。

https://qiita.com/nirasan/items/321e7cc42e0e0f238254

この記事でも取り扱っている例で説明します。

fn main() {
    let result: Result<i32, String> = Ok(2);
    println!("{:?}", result.unwrap()); //=> 2
    
    let result: Result<i32, String> = Err("error".to_string());
    println!("{:?}", result.unwrap()); //=> panic!
}

上記の例でResult.unwrap()がしていることは、Okが返却されてきた場合はOk()の中身を返却し、Errが発生した場合にはパニック[1]を起こしてプログラムを終了させます。

つまり、unwrapを使用すると、明示的にエラーハンドリングをスキップすることを意味します。
これは以下のコードの場合、プログラムがクラッシュします。

fn main() {
    let option: Option<i32> = None;

    // `unwrap`を使用して、`Option`の中身を取り出す。
    // この場合、`option`は`None`なので、パニックが発生する。
    let value = option.unwrap();

    println!("Value is: {}", value);
}

unsafe()とは

Rustは各種安全が保証されています。詳しくは下の記事をお読みください(宣伝)。
https://zenn.dev/hajimari/articles/618c900799fe90

ただ、unsafe()を使用すると、これらの安全装置を外した状態になります。
よって、以下のような記述が可能になってしまいます。

fn main() {
    let ptr: *const i32 = std::ptr::null();
    unsafe {
        println!("{}", *ptr); // nullポインタの参照
    }
}

上記の例だと、ptrが nullポインタであるため、*ptrの逆参照は「nullアドレスにアクセスしようとする」ことになります。

自分はここで初めて、「え!?Rustでもnull存在するやないか!」と思った訳ですが、どうやらRustのnull安全性は、「nullが存在しないから」というよりも、言語の設計によって「nullが安全に扱われるようになっている」から成り立っているようです。

つまり、let ptr: *const i32 = std::ptr::null();では、*const i32型のnullポインタが生成されています。ただし、このnullポインタを扱う場合には、unsafeを利用する必要があるため、基本的には安全、ということなのだとか。

冒頭の引用した内容について

さて、ではようやく冒頭部分で挙げた内容に戻ってきます。

Rustのnull安全を宣伝したら、「簡単にunwrapできるけどそれで安全性を保証しているの?」と言われました。メモリ安全性やスレッド安全性もunsafeを使えば簡単に壊せますが、unsafeは普段は使わないのでセーフと言えるかもしれませんが、unwrapは比較的カジュアルに使われているので、「保証」とまで言い切れるのか自信がなく、少なくとも「メモリ安全性」と比べると安全性は1段劣ってしまうのかなと感じました。

上記の部分ですね。
これを部分部分に分けて考えてみようかなと思います。

順番は前後しますが、先にunsafeについてです。

unsafeを使えば簡単に壊せますが

これはこの記事の筆者様と同じ意見で、「メモリ安全性やスレッド安全性もunsafeを使えば簡単に壊せます」。
なので、そもそもすべての安全性が、プログラマが意図して破壊しない限りは安全、といった前提があります。

安全なRustは真のRustプログラミング言語です。もしあなたが安全なRustだけでコードを書くなら、型安全やメモリ安全性などを心配する必要はないでしょう。ヌルポインタやダングリングポインタ、馬鹿げた「未定義な挙動」などに我慢する必要はないのです。

Rust 裏本より

他のメモリやスレッドなどの安全性も、安全なRust(以前コメントしてくださった方は、「safe rust」と呼んでいました)が前提のもとで成り立っているのです。

ということで、たとえunsafeがあるという理由でnull安全性を脅かすことはできないでしょう。

「簡単にunwrapできるけどそれで安全性を保証しているの?」

fn main() {
    let option: Option<i32> = None;

    // `unwrap`を使用して、`Option`の中身を取り出す。
    // この場合、`some_option`は`None`なので、パニックが発生する。
    let value = option.unwrap();

    println!("Value is: {}", value);
}

上記のコード例では、unwrap()によって結果がErrの時にパニックが発生します。その場合、valueに値は入ることなく、つまりUBは起こっていません。
valueに値が入る前に安全にクラッシュするため、C++に比べてデバッグが容易になります。

Rustではnullに相当する値としてOption::Noneを利用しますが、Option::Noneを使うことを強制しているため、unwrap()使わせてUBが発生する前にクラッシュさせているのです。

Rustのnull安全性とは

自分の記事を書いてみて、他の人の記事を見て生まれた疑問をまた記事にすることで、個人的にはより深い洞察が得られたと感じています。

このあたりの議論は、結構人によっても意見が分かれる部分だと思いますので、あくまでここに書かれているのは一個人の考えです。もし他の考えをお持ちの方は、コメントなどで教えていただけますと幸いです。

脚注
  1. パニック: プログラムを強制終了させるエラーのこと ↩︎

Hajimari Tech Media

Discussion

齊藤敦志齊藤敦志

Rust で安全ではないとするのは未定義動作を引き起こしうるような操作のことで、おそらく C/C++ があまりにも簡単に未定義の挙動を引き起こしてしまうのをもうちょっとマシに出来るだろうくらいの感じなんだと思います。 駄目なら止まってくれるのは暴走してデタラメな動作をするのと比べれば確かに安全です。

EvaEva

コメントありがとうございます。

C/C++ があまりにも簡単に未定義の挙動を引き起こしてしまうのをもうちょっとマシに出来るだろうくらいの感じなんだと思います。 駄目なら止まってくれるのは暴走してデタラメな動作をするのと比べれば確かに安全です。

自分はC/C++に明るくないのですが、未定義動作を防ぐ仕組みなのだと考えたら、納得がいきました。他の方のコメントにも、未定義動作が怒るとデバッグが困難になると書かれており、そういった観点で考えられておりませんでした。

白山風露白山風露

うーん、 Option<T> は「null値の代入を許容しない型」ではないですね。むしろ Option<T> こそがRustにおけるnull許容型です。
というか、Wikibooks(Wikipediaではないですね)から引用されている「変数がNull値を持つことができるかどうかを示す概念」という定義もだいぶ変ですね……。そもそも、Null値に相当する値を扱えない言語なんて、少なくとも一般的に使われている言語にはないと思います。
もちろんRustの場合はOpthion::NoneがNull値に相当する値として使われる訳です。敢えてこの言いまわしを修正するとすれば、むしろ「Null値になり得ない変数を定義できるかどうか」がNull安全の一つの条件、と言えると思います。

ただ、これだけではNull安全とは言えません。例えば、C++の参照型はNullを許容しない型ですが、nullポインタからデリファレンスして参照型に変換するのは未定義動作です。例えば以下のようなケースですね。

int* ptr = nullptr;
int& refer = *ptr; // この時点で未定義動作
std::cout << refer << std::endl; // 未定義動作なので何が起きても不思議ではないが、このあたりでメモリのアクセス違反で落ちる可能性が高い

同じようなコードをRustで書くとこうなるでしょう。

let opt: Option<&i32> = None;
let refer: &i32 = opt.unwrap(); // Noneをunwrapしようとしたのでここでpanicして落ちる
println!("{refer}"); // ここには来ない

同じようなコードで、最終的にはどちらも落ちるのに、どうしてC++はNull安全でなく、RustはNull安全なのか、と疑問に思う人が恐らく多いのだと思います。
ここで重要なのは、実際に変数 refer に何が入るか、ということです。
C++の場合を見てみると、 ptr のデリファレンスによって未定義動作になりますが、未定義動作というのはその場で落ちる訳ではありません。処理系は未定義動作の後はどのような動きをしても良いのですが、とは言え典型的なケースを想像することはできます(実際の動作と同じになるとは限りません)。処理系は未定義動作が発生しない前提でバイナリを生成するので、 refer は有効なメモリアドレスが入っているものとしてバイナリ生成を行います。ところが実際にはNullが入ってきてしまうので、非Null許容型のはずの参照にNullが入っているというちぐはぐな状態になり得ます。これが「安全ではない」という訳です。特に参照は、その中身にアクセスしようとしない限り不正な状態であっても気づかれず、落ちた時に取得できたスタックフレームが未定義動作が発生した場所とまったく異なる、なんてこともあり得る訳です。そうなるとデバッグも困難になります。

ではRustの場合はどうかというと、例示したコードでは refer に値が入ることがありません。その前に必ずpanicを起こして落ちるためです。つまり、safe Rustの範囲では refer が不正な状態になることがありません。このように unwrap を呼んだことでプログラムが不正な状態になることがないので「安全である」と言えるわけです。

Null安全は「プログラムがクラッシュしないこと」を保証するものではなく、むしろ「Null許容型とNull非許容型の境界でNullだった場合にプログラムが即座にクラッシュすること(それが嫌ならNullの場合をきちんとハンドリングしないといけない)」が保証されているのがNull安全だ、と言えると思います。その観点で言うと、 unwrap を多用したところでNull安全が崩れる訳ではありません。
もちろん、Noneになり得る状況でunwrapを利用すればpanicが発生しますし、Noneに対する処理ができるはずなのにそうしていなかったのであればバグと言うことはできるでしょう。ですがそれは「Null安全でないからバグが生まれた」のではなく、「そもそもプログラムの取りうる状態についての考慮が足りなかった」というだけの話であり、どのような言語を使っていても同じことです(強いて言うなら、常に unwrap を使わなければいけないことによってNull許容型とNull非許容型の違いを意識することになるので、そうでない言語よりは同様のバグを生みにくくはある)。

EvaEva

コメントありがとうございます!

Wikibooks(Wikipediaではないですね)

こちら確かにWikibooksですね......失礼いたしました、修正させていただきます。

もちろんRustの場合はOpthion::NoneがNull値に相当する値として使われる訳です。敢えてこの言いまわしを修正するとすれば、むしろ「Null値になり得ない変数を定義できるかどうか」がNull安全の一つの条件、と言えると思います。

nullのような振る舞いをさせたい場合、変数がnull値を持つことをデフォルトで許容せず、Option::Noneを利用を強制することでnull安全性を確保しているということでしょうか?

Null安全は「プログラムがクラッシュしないこと」を保証するものではなく、むしろ「Null許容型とNull非許容型の境界でNullだった場合にプログラムが即座にクラッシュすること(それが嫌ならNullの場合をきちんとハンドリングしないといけない)」が保証されているのがNull安全だ、と言えると思います。

こちらについてもありがとうございます。
つまり

  • C++は、ポインタをデリファレンスして参照を生成するときに、nullチェックが行われておらず、いつどこでクラッシュするか分からない(デバッグが難しくなる)。
  • Rustはunwrapでパニックが起こった場合、Noneが帰ってきたからクラッシュした、とすぐ判別できる。

ということでしょうか?

またデバッグだけではなく、提示いただいた例ではRustはパニックを起こして正常にプログラムを終了できるから安全ということでしょうか?

質問ばかりになってしまい、申し訳ございませんmm

EvaEva

すみません、少し言葉足らずでした!

Option::Noneを利用を強制することで
Rustはunwrapでパニックが起こった場合、Noneが帰ってきたからクラッシュした、とすぐ判別できる。

と言う意味合いです。

白山風露白山風露

nullのような振る舞いをさせたい場合、変数がnull値を持つことをデフォルトで許容せず、Option::Noneを利用を強制することでnull安全性を確保しているということでしょうか?

そうですね。Null許容型とNull非許容型を分けて扱える、というのがNull安全のための第一歩です。ただしこれだけでは「Null安全性を確保できた」とは言えず、Null許容型からNull非許容型への変換時にNullチェックを挟むことも含めてNull安全と言えると思います。

Rustはunwrapでパニックが起こった場合、Noneが帰ってきたからクラッシュした、とすぐ判別できる。

単に言い回しの話ですが、パニックが起きたら帰ってくることがないので、「パニックでクラッシュしたからその時点でOptionの値がNoneだったとすぐ判別できる」みたいな感じですかね。

EvaEva

ご丁寧に教えていただきありがとうございます!
理解できました。

単に言い回しの話ですが、パニックが起きたら帰ってくることがないので、「パニックでクラッシュしたからその時点でOptionの値がNoneだったとすぐ判別できる」みたいな感じですかね。

こちらのご意見をもとに、記事を修正させていただきます。

齊藤敦志齊藤敦志

C++ だとヌルポインタや無効なポインタ (ポインタより先にオブジェクトの寿命が尽きる) を簡単に作れますし、それを通じてチェックなくメモリアクセスしようとすることは出来ますが結果は未定義です。 スマートポインタを使った場合は親切な処理系 (に付随する標準ライブラリ) ならチェックすることもありますが言語仕様としてはあくまでも未定義です。

C++ の事情として、無効なポインタを通じて変な場所にアクセスした結果としてどうなるかわからないというだけでなくプログラマは未定義なことをしないと処理系が仮定して最適化することがあるというのもプログラマにとって負担になっています。 未定義なことをしたら何が起きても良いので未定義なことをしたときのことを全く考慮から外してしまうことによってとんでもないアクロバティックな解釈で最適化してしまうのです。 C++ の言語仕様が定めるルールから外れると無茶苦茶になるのにルールから外れているかどうか充分に検証してくれないということです。

ただ、 Rust は Rust で安全な (unsefe を使わない) 範囲で出来ることは保守的です。 安全にも使えるけれど安全かどうか機械的に判定できないようなものは全部 unsafe に入れてしまいました。 典型的なものは標準ライブラリの形で提供されているので普段はそれほど困りませんが C++ を問題なく扱えているような人にとっては Rust の制約はもどかしく感じることもあるかもしれません。

EvaEva

ただ、 Rust は Rust で安全な (unsefe を使わない) 範囲で出来ることは保守的です。 安全にも使えるけれど安全かどうか機械的に判定できないようなものは全部 unsafe に入れてしまいました。 典型的なものは標準ライブラリの形で提供されているので普段はそれほど困りませんが C++ を問題なく扱えているような人にとっては Rust の制約はもどかしく感じることもあるかもしれません。

ありがとうございます。

気になったのですがunsafe内に記述すれば、いわゆるC++のような柔軟性を持った(UBなどを許容する)コードが書けるので、そこはプログラマが選択できる認識です。

そのような場合でも、RustはC++に比べて制約がもどかしく感じるのは、わざわざunsafeを書かなければそのようなコードを記述することができないからでしょうか?

むしろUBが起こり得る範囲がわかりやすいので、メリットしかないのではと思ったのですが......。
(Rustの哲学からは逸脱してしまうので、そもそもunsafe rustを書く倫理的な問題でしょうか?)

齊藤敦志齊藤敦志

そうですね。 原理的に出来るかどうかよりも規範というか言語の習慣です。

基本的には unsafe を使わずにやった上で駄目だったときに unsafe を使う (全く unsafe を使わなくても普通) ので最初から緩めな C++ に比べれば危険を避ける遠回りの程度は少し大きいだろうというくらいの話です。

EvaEva

わかりやすくご解説いただきありがとうございます。

原理的に出来るかどうかよりも規範というか言語の習慣です。

よく理解できました。
確かに言語的にもsafe rustを推奨しているでしょうし、クレートなどもsafe rustかは確認されると思うので、C++に比べるとやりにくそうではありますね。