Rust面接の質問
ここに集めたのは、ジュニアからシニアまで、Rustの面接で何度も聞かれる質問です。どの答えも声に出して言える長さにまとめてあり、テーマをくわしく説明しているレッスンへのリンクも付けています。
一字一句暗記するのはやめましょう。面接官にはすぐにわかりますし、本当に理解しているかを確かめるために、たいてい追加の質問をしてきます。このページで自分の理解度をチェックして、あやふやなところはリンク先のレッスンで復習してください。答えを自分の言葉で説明できて、小さな例を書けるなら準備は万全です。
Q: Cargoとは何ですか?
Section titled “Q: Cargoとは何ですか?”CargoはRustの公式ビルドツール兼パッケージマネージャーで、Rustと一緒にインストールされます。このひとつのツールで、次のことができます。
- プロジェクトを作る(
cargo new) - ビルドして実行する(
cargo build、cargo run) - テストを実行する(
cargo test) - クレートと呼ばれるライブラリを crates.io から追加する(
cargo add serde)
プロジェクトの設定と依存関係は Cargo.toml に書きます。正確なバージョンは Cargo.lock に固定されるので、何度ビルドしても同じ結果になります。プラスアルファで言うと良いポイント: ほかの言語ではこうした作業に複数のツールが必要になりがちですが、Rustでは誰もがCargoを使います。だから、どのプロジェクトを開いても見慣れた構成になっています。
さらに詳しく: Cargoとクレート、Rustを始める
Q: シャドーイングと mut の違いは何ですか?
Section titled “Q: シャドーイングと mut の違いは何ですか?”mut を使うと、同じ変数の値を変更できます。型は変えられません。
シャドーイング(shadowing)は、同じ名前でもう一度 let を書くことです。これで古い変数を隠す新しい変数が作られます。新しい変数は、型が違っていてもかまいません。
fn main() { let spaces = " "; let spaces = spaces.len(); // シャドーイング: 新しい変数で、今度は数値 println!("{spaces}");
let mut count = 1; count += 1; // mut: 同じ変数に新しい値が入る println!("{count}");}実行結果:
32シャドーイングは、値を段階的に変換したいとき(たとえばテキストを数値にパースするとき)に便利です。input_str や input_num のような名前をわざわざ考えなくて済みます。しかも、最後の変数は不変のままです。
さらに詳しく: Rustの変数
Q: マクロと関数の違いは何ですか?
Section titled “Q: マクロと関数の違いは何ですか?”関数は実行時に、決まった数の型付き引数で呼び出されます。マクロはコンパイル時に動きます。コードを受け取り、コンパイラがチェックする前に、さらにコードを書き出すのです。マクロは println!、vec!、format! のように ! が付いているので見分けられます。
マクロには、関数にはできないことができます。たとえば可変個の引数を受け取ったり(println!("{} {}", a, b))、フォーマット文字列をコンパイル時にチェックしたりできます。また、Rustのマクロはただのテキストではなくコードの構造を扱うので、C言語のマクロでおなじみのバグを避けられます。
macro_rules! square { ($x:expr) => { $x * $x };}
fn main() { // C言語のようなテキストベースのマクロだと、2 + 1 * 2 + 1 = 5 になってしまう。 println!("{}", square!(2 + 1));}実行結果:
9正直なところ、トレードオフもあります。マクロは読むのも書くのもデバッグするのも大変です。関数で済むなら関数を使いましょう。
さらに詳しく: マクロ
Q: 「ゼロコスト抽象化」とはどういう意味ですか?
Section titled “Q: 「ゼロコスト抽象化」とはどういう意味ですか?”高レベルな機能を使っても、手で書いた低レベルなコードよりプログラムが遅くならない、という意味です。「使わないものにはコストを払わない。使うものについては、自分で書いてもこれ以上うまくは書けない」とよく言われます。
たとえば .iter().filter(...).map(...).sum() のようなイテレータのチェーンは、たいてい手書きのループと同じ機械語にコンパイルされます。ジェネリクスも一例です。コンパイラは使われた型ごとに専用のコピーを作るので(これを単相化、monomorphization と呼びます)、実行時に何かを探す必要がありません。
「コスト」の部分は正直に説明しましょう。ゼロなのは実行時のコストです。コンパイル時間というコストは払いますし、ジェネリクスによってプログラムのファイルサイズが大きくなることもあります。
Q: Rustのイテレータは遅延評価ですか?
Section titled “Q: Rustのイテレータは遅延評価ですか?”はい。.map() や .filter() のようなメソッドは、それだけでは何の処理もしません。やるべき処理の説明を組み立てるだけです。.collect()、.sum()、for ループなどがイテレータを消費するまで、何も起こりません。
fn main() { let numbers = vec![1, 2, 3]; let doubled = numbers.iter().map(|n| { println!("{n} を2倍にします"); n * 2 });
println!("まだ何も実行されていません。"); let result: Vec<i32> = doubled.collect(); println!("{:?}", result);}実行結果:
まだ何も実行されていません。1 を2倍にします2 を2倍にします3 を2倍にします[2, 4, 6]numbers.iter().map(...); と書いて結果を使わないとコンパイラが警告を出すのも、これが理由です。そのままでは何もしないからです。
さらに詳しく: イテレータ
所有権と借用
Section titled “所有権と借用”Q: 所有権のルールとは何ですか?
Section titled “Q: 所有権のルールとは何ですか?”ルールは3つです。
- Rustの値はそれぞれ、1つの所有者(変数)を持つ。
- 所有者は同時に1つだけ。
- 所有者がスコープを抜けると、値は破棄(drop)される(メモリが解放される)。
このルールのおかげで、Rustはガベージコレクタなしで、しかも free を手で呼ばなくても、ちょうど良いタイミングでメモリを解放できます。ルールはコンパイラがチェックするので、安全なコードでは「解放後の使用(use after free)」や「二重解放(double free)」のような間違いは起こりえません。
Q: ムーブ、コピー、クローンの違いは何ですか?
Section titled “Q: ムーブ、コピー、クローンの違いは何ですか?”- ムーブ(Move): 値を代入したり関数に渡したりすると、所有権が移ります。元の変数はもう使えません。
StringやVecのような型ではこれがデフォルトです。コピーされるのは、小さなポインタ、長さ、容量だけです。 - コピー(Copy):
i32、f64、bool、charのような小さくて単純な型では、代入すると安上がりなビット単位のコピーが作られ、両方の変数がそのまま使えます。 - クローン(Clone):
.clone()は明示的なディープコピーです。Stringなら、テキストをすべて新しいメモリにコピーします。コストが高くなることがあるので、Rustはわざわざ書かせるのです。
fn main() { let x = 5; let y = x; // コピー: x はまだ使える println!("{x} {y}");
let a = String::from("hi"); let b = a.clone(); // クローン: テキストを丸ごとコピー let c = a; // ムーブ: この行のあと `a` は使えない println!("{b} {c}");}実行結果:
5 5hi hiさらに詳しく: 所有権
Q: Copy トレイトとは何ですか? どの型が持っていますか?
Section titled “Q: Copy トレイトとは何ですか? どの型が持っていますか?”Copy は「この型はビットをコピーするだけで複製できる」ことを示すマーカートレイトです。Copy な型は、代入したときにムーブではなくコピーされます。
整数、浮動小数点数、bool、char、共有参照(&T)、そして Copy な型だけでできたタプルや配列は、すべて Copy です。String、Vec、Box は Copy ではありません。これらはどこか別の場所にあるメモリを所有しているので、ポインタをコピーすると所有者が2つになってしまうからです。
自分の型にも derive できますが、すべてのフィールドが Copy の場合に限ります。
#[derive(Clone, Copy)]struct User { name: String, age: u32,}error[E0204]: the trait `Copy` cannot be implemented for this type --> src/main.rs:2:8 |1 | #[derive(Clone, Copy)] | ---- in this derive macro expansion2 | struct User { | ^^^^3 | name: String, | ------------ this field does not implement `Copy`コンパイラは「この型には Copy を実装できない。name フィールドが Copy を実装していないから」と言っています。
Copy には必ず Clone も必要です。また、Drop を実装している型は Copy にできません。
Q: String と &str の違いは何ですか?
Section titled “Q: String と &str の違いは何ですか?”Stringは、ヒープに置かれた、所有された伸び縮みできるテキストです。たとえばpush_strで変更できます。&str(「文字列スライス」)は、どこかにあるテキストを借用して見ているだけのものです。中身はポインタと長さだけです。"hello"のような文字列リテラルは&strです。
良いルールがあります。テキストを読むだけの関数は &str を受け取るようにしましょう。そうすれば、&String は自動的に &str に変換されるので、どちらも受け付けられます。
fn greet(name: &str) { println!("こんにちは、{name}さん!");}
fn main() { let owned: String = String::from("Ana"); let literal: &str = "Ben";
greet(&owned); // &str が必要なところに &String を渡せる greet(literal);
let mut full = owned; full.push_str(" Smith"); // 伸ばせるのは String だけ greet(&full);}実行結果:
こんにちは、Anaさん!こんにちは、Benさん!こんにちは、Ana Smithさん!Q: 借用のルールとは何ですか?
Section titled “Q: 借用のルールとは何ですか?”ある値に対して、どの瞬間にも次のどちらか一方だけを持てます。
- 共有参照(
&T)をいくつでも、または - 可変参照(
&mut T)をちょうど1つ
両方を同時に持つことはできません。そして、参照は常に有効なデータを指していなければなりません。
なぜでしょうか。誰かがデータを読んでいる最中に別の誰かがそれを変更できると、バグが生まれます。たとえば、ポインタを持っている間にベクタがメモリの場所を移してしまう、といったバグです。スレッドの世界では、これがまさにデータ競合です。Rustはこれをコンパイル時に防ぎます。2018エディション以降、コンパイラは借用がいつ終わるかを賢く判断します(これを NLL、non-lexical lifetimes と呼びます)。借用が続くのはブロックの終わりまでではなく、最後に使われるところまでです。
さらに詳しく: 借用
Q: ライフタイムとは何ですか?
Section titled “Q: ライフタイムとは何ですか?”ライフタイムとは、「参照がどれだけの期間有効か」を表すコンパイラ側の呼び名です。すべての参照にライフタイムがあります。ほとんどの場合はコンパイラが自分で推論してくれるので(これをライフタイムの省略と呼びます)、目にすることはありません。
自分で書く必要があるのは、出力の参照が入力とどう関係しているのかをコンパイラが判断できないときだけです。たとえば、借用した2つの文字列のどちらかを返す関数です。
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str { if a.len() >= b.len() { a } else { b }}
fn main() { let first = String::from("borrow checker"); let second = String::from("lifetimes"); println!("{}", longest(&first, &second));}実行結果:
borrow checker(この例は .len() でバイト数を比べているので、文字列は英語のままにしています。日本語の文字は1文字3バイトなので、結果が変わってしまうことがあります。)
面接で言っておきたい大事なポイント: ライフタイム注釈は、何かが生きる期間を変えません。関係を説明するだけです。それによってコンパイラは、参照が指しているデータより長生きしていないかをチェックできます。
さらに詳しく: ライフタイム
型とトレイト
Section titled “型とトレイト”Q: Rustには null がありません。代わりに何を使いますか?
Section titled “Q: Rustには null がありません。代わりに何を使いますか?”Option<T> を使います。これは Some(value) と None の2つのバリアントを持つ列挙型です。T とは別の型なので、値を使う前に None の場合を処理するよう、コンパイラが強制します。null チェックを忘れることはできません。忘れるとコンパイルが通らないからです。
fn find_user(id: u32) -> Option<&'static str> { match id { 1 => Some("Ana"), 2 => Some("Ben"), _ => None, }}
fn main() { for id in [1, 3] { match find_user(id) { Some(name) => println!("{name} が見つかりました"), None => println!("ID が {id} のユーザーはいません"), } }}実行結果:
Ana が見つかりましたID が 3 のユーザーはいません付け加えると良い細かい話: Option<&T> や Option<Box<T>> は、ただのポインタと比べて余分なメモリを使いません。Rustは内部で「null」のビットパターンを None の意味に使っているので、ここでは安全性のコストがゼロなのです。
さらに詳しく: RustのOption、if let / while let
Q: トレイトとは何ですか? インターフェースと同じですか?
Section titled “Q: トレイトとは何ですか? インターフェースと同じですか?”トレイトは、型が実装できるメソッドの集まりを定義するものです。Java、C#、Go のインターフェースに似ていて、答えの出だしとしてはそれで十分です。そのうえで、違いを挙げましょう。
- トレイトはメソッドのデフォルト実装を持てます。
- 型を書いたあとからその型にトレイトを実装できます。
i32に自分のトレイトを実装するなど、自分が書いていない型にも実装できます。(「孤児ルール(orphan rule)」により、トレイトか型のどちらかは自分のものでなければなりません。) - トレイトはジェネリクスの境界(
T: Display)として使われ、コンパイル時に解決されます。また、実行時のトレイトオブジェクト(dyn Display)としても使われます。 - Rustにはクラスの継承がありません。トレイトとコンポジションがその代わりになります。
Q: ジェネリクスとトレイトオブジェクトの違いは何ですか?
Section titled “Q: ジェネリクスとトレイトオブジェクトの違いは何ですか?”どちらも、1つの関数をいろいろな型で使えるようにするものです。違いは、型がいつ決まるかです。
- ジェネリクス(
fn f<T: Speak>(x: &T))は静的ディスパッチを使います。コンパイラが型ごとに関数の別々のコピーを作ります。呼び出しはふつうの関数呼び出しと同じくらい速いですが、プログラムが大きくなることがあります。 - トレイトオブジェクト(
&dyn Speak、Box<dyn Speak>)は動的ディスパッチを使います。関数のコピーは1つだけで、正しいメソッドは実行時にテーブル(「vtable」)を引いて探します。ほんの少し遅くなりますが、違う型を1つのコレクションに混ぜられます。
trait Speak { fn speak(&self) -> String;}
struct Dog;struct Cat;
impl Speak for Dog { fn speak(&self) -> String { String::from("ワン") }}
impl Speak for Cat { fn speak(&self) -> String { String::from("ニャー") }}
fn speak_static<T: Speak>(animal: &T) -> String { animal.speak()}
fn speak_dynamic(animal: &dyn Speak) -> String { animal.speak()}
fn main() { println!("{}", speak_static(&Dog)); println!("{}", speak_dynamic(&Cat));
// 1つの Vec に違う型を混ぜられるのはトレイトオブジェクトだけ: let animals: Vec<Box<dyn Speak>> = vec![Box::new(Dog), Box::new(Cat)]; for animal in &animals { println!("{}", animal.speak()); }}実行結果:
ワンニャーワンニャー目安: まずはジェネリクスから始めましょう。違う型のコレクションが必要なときや、コンパイル時間やバイナリサイズを抑えたいときに、トレイトオブジェクトを使います。
さらに詳しく: ジェネリクス、トレイトオブジェクト (dyn)
Q: Box<dyn Trait> と impl Trait の違いは何ですか?
Section titled “Q: Box<dyn Trait> と impl Trait の違いは何ですか?”どちらも、型の名前を書かずに「このトレイトを実装している何かの型」と言うための書き方です。違いは、具体的な型をいくつ許すかです。
impl Traitは、コンパイラは知っているけれど書かなくてよい、ただ1つの具体的な型を表します。静的ディスパッチなので、ヒープ確保もvtableもなく、型を自分で書いたのと同じ速さです。引数の位置(fn f(x: impl Display))では、ジェネリクスを短く書く方法にすぎません。戻り値の位置では、クロージャやイテレータをつなげたものなど、型の名前が長すぎたり書けなかったりする値を返すのに使います。Box<dyn Trait>は、ヒープに置かれたトレイトオブジェクトです。実際の型は毎回違っていてもよく、実行時に決まります。メソッドはvtableを通して呼び出されます。ヒープ確保と間接呼び出しのコストがかかりますが、複数の型のどれかを返したり、1つのVecにまとめて入れたりするには、これしか方法がありません。
use std::fmt::Display;
// impl Trait: 隠れた型は1つだけで、コンパイル時に決まるfn evens(limit: u32) -> impl Iterator<Item = u32> { (0..limit).filter(|n| n % 2 == 0)}
// Box<dyn Trait>: 実際の型は実行時に変わってもよいfn label(as_number: bool) -> Box<dyn Display> { if as_number { Box::new(42) } else { Box::new("四十二") }}
fn main() { let v: Vec<u32> = evens(10).collect(); println!("{v:?}"); println!("{}", label(true)); println!("{}", label(false));}実行結果:
[0, 2, 4, 6, 8]42四十二触れておくとよい細かい点があります。label の戻り値を impl Display に変えて Box を外すと、コンパイルが通りません。if は i32 を、else は &str を返しますが、impl Trait はただ1つの型でなければならないからです。目安: ふだんは impl Trait を使い、実行時に本当に型を選ぶ必要があるときだけ Box<dyn Trait> に切り替えましょう。
さらに詳しく: トレイトオブジェクト (dyn)、ジェネリクス
Q: なぜ match は網羅的でなければならないのですか?
Section titled “Q: なぜ match は網羅的でなければならないのですか?”match はありうるすべての値を処理しなければならず、そうでないとコンパイルが通りません。つまり、列挙型に新しいバリアントを追加すると、更新が必要な場所をすべてコンパイラが教えてくれるということです。ほかの言語では、switch に case を書き忘れても、何も言われずに素通りしてしまいます。
_ を使えば「その他すべて」をまとめて受け止められますが、注意が必要です。あとで追加したバリアントもそこに隠れてしまうので、この安全網を失うことになります。
さらに詳しく: Rustのmatch、パターンマッチング、Rustの列挙型
Q: 例外がないのに、Rustはどうやってエラーを扱うのですか?
Section titled “Q: 例外がないのに、Rustはどうやってエラーを扱うのですか?”回復可能なエラーは、Result<T, E> 型のふつうの値です。Ok(value) か Err(error) のどちらかになります。失敗する可能性がある関数はそのことを戻り値の型で示し、呼び出し側はそれに対処しなければなりません。例外のように、見えないところでスタックをさかのぼって飛んでいくものはありません。
利点は、関数のシグネチャを見るだけで失敗する可能性がわかることと、Result を無視するとコンパイラが警告してくれることです。代わりにタイプ量が少し増えますが、それも ? 演算子でほとんど解消できます。
さらに詳しく: RustのResult
Q: Result を返す代わりに panic! を使うべきなのはどんなときですか?
Section titled “Q: Result を返す代わりに panic! を使うべきなのはどんなときですか?”- 失敗が想定内で、呼び出し側がそれなりに対処できるなら、
Resultを返します。ファイルがない、ユーザーの入力がおかしい、ネットワークがタイムアウトした、などです。 - それがバグである場合、つまりプログラムが「ありえないはず」の状態になっていて、そのまま続けるのが間違いであるときは、パニックさせます。ベクタの末尾を越えてインデックスアクセスするとパニックするのは、これが理由です。
テスト、サンプルコード、手早いプロトタイプでもパニックしてかまいません。ライブラリでは、呼び出し側が渡す入力が原因でパニックするのは避けましょう。エラーを返して、どうするかは呼び出し側に決めてもらいます。
さらに詳しく: Rustのpanic、RustのResult
Q: unwrap、expect、? の違いは何ですか?
Section titled “Q: unwrap、expect、? の違いは何ですか?”unwrap()はOk/Someの中身を取り出します。そうでなければ、汎用的なメッセージでパニックします。expect("msg")も同じですが、自分で書いたメッセージでパニックします。なぜ失敗しないと考えているのかを説明するのに使いましょう。?はパニックしません。Err/Noneのときは、今の関数からエラーを返し、呼び出し側に処理を任せます。その関数自体がResultかOptionを返す必要があります。
use std::num::ParseIntError;
fn double(text: &str) -> Result<i32, ParseIntError> { let n: i32 = text.parse()?; // エラーなら、呼び出し側に返す Ok(n * 2)}
fn main() { println!("{:?}", double("21")); println!("{:?}", double("abc"));
let n: i32 = "7".parse().expect("コードに直接書いた数値なので、必ずパースできるはず"); println!("{n}");}実行結果:
Ok(42)Err(ParseIntError { kind: InvalidDigit })7ParseIntError { kind: InvalidDigit } は、"abc" に数字ではない文字があったことを表す、Rust自身のエラー値です。
本番のコードでは ? を優先しましょう。失敗したらバグだと言える場面では expect を使い、unwrap はテストやちょっとした実験用に取っておきます。
さらに詳しく: RustのResult、RustのOption
Q: 独自のエラー型はどうやって作りますか?
Section titled “Q: 独自のエラー型はどうやって作りますか?”ふつうは、コードが失敗するパターンを並べた列挙型を作り、std::fmt::Display と std::error::Error を実装します。ほかのエラー型からの From を実装しておくと、? が自動的に変換してくれます。
実際には、多くのプロジェクトが補助的なクレートを使っています。ライブラリでエラー型を定義するには thiserror(英語)、エラーに文脈を付けて上に渡すだけで良いアプリケーションには anyhow(英語)がよく使われます。手早く書くプログラムなら、Box<dyn Error> でどんなエラーでも保持できます。
さらに詳しく: 独自のエラー型
Q: anyhow と thiserror はどう使い分けますか?
Section titled “Q: anyhow と thiserror はどう使い分けますか?”この2つは同じ問題の半分ずつを解決するものなので、正直な答えは「両方使うことが多い」です。
thiserrorはライブラリ向けです。呼び出し側が特定のエラーに応じて動きを変える必要があるコードにも向いています。自分でエラー用の列挙型を書くと、#[derive(Error)]がDisplayとErrorの決まりきったコードを埋めてくれます。呼び出し側は、ConfigError::MissingFieldのようにmatchできる本物の型を受け取れます。anyhowはアプリケーション向けです。アプリケーションでは、ほとんどのエラーは1つずつ対処するより、報告できれば十分です。どの関数もanyhow::Result<T>を返し、?はほぼどんなエラー型でも受け付けます。さらに.context("設定の読み込み中")で、人が読んでわかる説明を上乗せできます。その代わり、呼び出し側がエラーをmatchするのは難しくなります。
よくある構成は、ライブラリのクレートの中では thiserror、main とバイナリのそれ以外の部分では anyhow を使うというものです。どちらのクレートも魔法ではありません。どちらも標準の std::error::Error トレイトの上で決まりきったコードを減らしているだけなので、同じものはいつでも手で書けます。
さらに詳しく: anyhowとthiserror、独自のエラー型
メモリとスマートポインタ
Section titled “メモリとスマートポインタ”Q: Rustにはガベージコレクタがありますか?
Section titled “Q: Rustにはガベージコレクタがありますか?”ありません。メモリは所有者がスコープを抜けたときに解放され、そのタイミングはコンパイラが事前に把握しています(これを RAII と呼び、後片付けをするメソッドが Drop です)。そのため、GCによる停止がなく、性能を予測しやすくなります。
どうしても所有権を共有したいときは、参照カウント(Rc や Arc)を自分で選んで使います。最後の所有者がいなくなったときに値が解放されます。
Q: Box、Rc、Arc、RefCell はそれぞれどんなときに使いますか?
Section titled “Q: Box、Rc、Arc、RefCell はそれぞれどんなときに使いますか?”Box<T>: 所有者は1つで、値はヒープに置かれます。再帰的な型(木構造や連結リストなど)、大きな値、トレイトオブジェクト(Box<dyn Trait>)に使います。Rc<T>: 1つのスレッドの中で複数の所有者を持てます。参照の数を数えて、カウントが0になったら値を解放します。データは読み取り専用です。Arc<T>:Rcと似ていますが、カウントがアトミックなので、スレッドをまたいで安全に共有できます。Rcより少し遅くなります。RefCell<T>: 共有参照を通してデータを変更できるようにします(「内部可変性」)。借用のルールはコンパイル時ではなく実行時にチェックされ、ルールを破るとパニックします。
これらは組み合わせて使えます。Rc<RefCell<T>> は「1つのスレッドの中で、共有できて変更もできる」もので、Arc<Mutex<T>> はそのスレッドセーフ版です。
use std::cell::RefCell;use std::rc::Rc;
fn main() { let shared = Rc::new(RefCell::new(vec![1, 2])); let other_owner = Rc::clone(&shared);
other_owner.borrow_mut().push(3);
println!("{:?}", shared.borrow()); println!("所有者の数: {}", Rc::strong_count(&shared));}実行結果:
[1, 2, 3]所有者の数: 2さらに詳しく: スマートポインタ
Q: 安全なRustでもメモリリークは起こりますか?
Section titled “Q: 安全なRustでもメモリリークは起こりますか?”はい、起こります。正直にそう答えましょう。Rustが保証しているのはメモリ安全性(解放後の使用がない、ダングリングポインタがない、データ競合がない)であって、メモリリークは「安全でない」ものとは見なされていません。よくある原因は2つです。
Rcの循環参照:Rcでお互いを指し合っている2つの値は、カウントがいつまでも0になりません。片方向をWeak参照にすれば解決します。Box::leakやstd::mem::forgetのような、わざとリークさせる関数。
ふだんのコードでリークが起きることはまれです。所有権の仕組みが、ほとんどすべてを自動で解放してくれるからです。
さらに詳しく: スマートポインタ
Q: unsafe とは何ですか? どんなときに使いますか?
Section titled “Q: unsafe とは何ですか? どんなときに使いますか?”unsafe は、コンパイラがチェックできない5つの追加操作を行えるブロック(または関数)であることを示します。たとえば、生ポインタの参照外し、unsafe 関数の呼び出し(FFI経由のCのコードも含む)、可変な static へのアクセスなどです。その中では、ルールが守られていることをあなた自身が約束します。
大事な点: unsafe は借用チェッカーやその他のチェックを止めるわけではありません。いくつかの追加操作ができるようになるだけです。
使われるのは、Cのライブラリとやり取りするとき、低レベルなデータ構造を書くとき、そしてコンパイラには安全だと証明できない部分で性能を絞り出すときです。標準ライブラリ自体も、Vec のような安全なAPIの内側で unsafe を使っています。良い習慣は、unsafe ブロックを小さく保ち、安全なAPIで包み、なぜ正しいのかを説明する // SAFETY: コメントを書くことです。ほとんどのアプリケーションコードでは、まったく必要ありません。
さらに詳しく: unsafe Rust
並行処理とasync
Section titled “並行処理とasync”Q: Send と Sync はどういう意味ですか?
Section titled “Q: Send と Sync はどういう意味ですか?”どちらも、スレッドを安全に保つためにコンパイラが使うマーカートレイトです。
Send: この型の値は、別のスレッドへムーブできる。Sync: この型の値は、スレッド間で参照によって共有できる(&TがSendである)。
ほとんどの型はその両方で、コンパイラが自動的に実装してくれます。有名な例外が Rc で、そのカウントはスレッドセーフではありません。Rc をスレッドに送ろうとすると、コンパイルが通りません。
use std::rc::Rc;use std::thread;
fn main() { let data = Rc::new(5); let handle = thread::spawn(move || { println!("{data}"); }); handle.join().unwrap();}error[E0277]: `Rc<i32>` cannot be sent between threads safely ... = help: within `{closure@src/main.rs:6:32: 6:39}`, the trait `Send` is not implemented for `Rc<i32>`このエラーは「Rc<i32> はスレッド間で安全に送れない。Rc<i32> は Send を実装していないから」という意味です。
Rc を Arc に変えれば動きます。Rustの「恐れのない並行性(fearless concurrency)」と言われるものの大部分はこれです。スレッド安全性の間違いが、コンパイルエラーになるのです。
さらに詳しく: 並行処理
Q: 変更できるデータを、スレッド間でどうやって共有しますか?
Section titled “Q: 変更できるデータを、スレッド間でどうやって共有しますか?”定番の答えは Arc<Mutex<T>> です。Arc によって、複数のスレッドが値を所有できます。Mutex は、一度に1つのスレッドしか値を変更できないようにします。.lock() を呼ぶとガードが手に入り、そのガードはスコープを抜けると自動的にロックを解除します。
use std::sync::{Arc, Mutex};use std::thread;
fn main() { let counter = Arc::new(Mutex::new(0)); let mut handles = Vec::new();
for _ in 0..4 { let counter = Arc::clone(&counter); handles.push(thread::spawn(move || { for _ in 0..1000 { *counter.lock().unwrap() += 1; } })); }
for handle in handles { handle.join().unwrap(); }
println!("合計: {}", *counter.lock().unwrap());}実行結果:
合計: 4000別のやり方にも触れておきましょう。メモリを共有する代わりに、チャネル(std::sync::mpsc)でスレッド間にメッセージを送る方法です。また、単純なカウンターなら、AtomicUsize のようなアトミック型を使えばロックが要りません。
さらに詳しく: 並行処理
Q: チャネルと、Mutex で状態を共有するやり方はどう違いますか?
Section titled “Q: チャネルと、Mutex で状態を共有するやり方はどう違いますか?”どちらも、スレッド間で仕事を分け合うための書き方です。
- チャネル(
std::sync::mpsc)はメッセージを渡します。値はチャネルにムーブされて反対側から出てくるので、ある時点でその値を所有しているスレッドは1つだけです。パイプラインやワーカープール、つまり「ワーカーが結果を作り、1つのスレッドがそれを集める」形に向いています。2つのスレッドが同じデータに触れることがないので、たいていは考えやすくなります。 - 状態の共有(
Arc<Mutex<T>>)では、複数のスレッドが同じデータを、ロックで順番を守りながら使います。キャッシュやカウンターの表のように、多くのスレッドが1つのデータ構造を読んだり更新したりする必要があるときに向いています。危険なのは、デッドロックと、スレッドがロックを待ってばかりで遅くなることです。
前の答えのカウンターを、チャネルを使って書き直してみましょう。それぞれのワーカーはロックなしで自分の分を数え、結果を送ります。
use std::sync::mpsc;use std::thread;
fn main() { let (tx, rx) = mpsc::channel();
for _ in 0..4 { let tx = tx.clone(); thread::spawn(move || { let mut count = 0; for _ in 0..1000 { count += 1; } tx.send(count).unwrap(); }); } drop(tx); // 元の送信側を破棄する。そうしないと rx.iter() が終わらない
let total: i32 = rx.iter().sum(); println!("合計: {total}");}実行結果:
合計: 4000drop(tx) の行は、ぜひ触れておきたい細かい点です。rx.iter() は送信側がすべてなくなるまで待ち続けるので、これを忘れるとプログラムが止まったままになります。どちらのやり方でも、Rustはスレッド安全性をコンパイル時にチェックします。どちらを選ぶかは、その問題に対してどちらの設計がわかりやすいかで決めましょう。両方を使うプログラムもたくさんあります。
Q: Rustは並行処理のバグをすべて防げますか?
Section titled “Q: Rustは並行処理のバグをすべて防げますか?”いいえ。そう言えることが、本当に理解している証拠になります。安全なRustが防ぐのはデータ競合(2つのスレッドが同じメモリに同時にアクセスし、少なくとも一方が書き込んでいる状態)です。次のものは防げません。
- デッドロック: たとえば、2つのスレッドがお互いに相手の持っているロックを待ち続ける状態。
- ロジック上の競合状態(レースコンディション): たとえば、値をチェックしてからそれに基づいて動く間に、別のスレッドがその値を変えてしまう場合。
つまり、Rustは厄介なバグをまるごと1種類なくしてくれますが、ロックの設計は自分で注意深く行う必要があります。
さらに詳しく: 並行処理
Q: async とスレッドの違いは何ですか?
Section titled “Q: async とスレッドの違いは何ですか?”- スレッドはOSが動かします。それぞれが自分のスタックを持ち、OSがスレッドを切り替えます。CPUをたくさん使う処理に向いています。ただし、スレッドが何千もあると大量のメモリを使います。
- async のタスクはずっと軽量です。
async fnはFutureを返し、ランタイム(たいていは Tokio)が少数のスレッドの上で多くの Future を動かします。タスクが何か(ネットワーク、タイマー、ファイル)を待っている間は、.awaitによって別のタスクが動けます。何千もの接続をさばくWebサーバーのように、待ち時間の多いプログラムに最適です。
use tokio::time::{sleep, Duration};
async fn fetch(name: &str, ms: u64) -> String { sleep(Duration::from_millis(ms)).await; // ネットワークを待っているふりをする format!("{name}が完了しました")}
#[tokio::main]async fn main() { // 2つは同時に動くので、かかる時間は300msではなく約200ms。 let (a, b) = tokio::join!(fetch("1つ目の処理", 200), fetch("2つ目の処理", 100)); println!("{a}"); println!("{b}");}実行結果:
1つ目の処理が完了しました2つ目の処理が完了しました面接官が好む、正直な補足を2つ。Rustの Future は遅延評価で、await されるまで何もしません。そして標準ライブラリにはランタイムが含まれていないので、自分で選びます。さらに、asyncタスクの中でブロッキングするコード(長い計算や std::thread::sleep など)を書くと、同じスレッド上のほかのタスクまで止めてしまいます。
さらに詳しく: async/await、並行処理
面接で答えるときのコツ
Section titled “面接で答えるときのコツ”- シンプルに始めて、だんだん深く。 まず1文で答えます(「
Rcは、1つのスレッドの中で所有権を共有するための参照カウント付きポインタです」)。相手が興味を示したら、詳しい話を足しましょう。 - 「なぜ」を説明する。 Rustのルールは、特定のバグを防ぐために存在します。ルールをただ暗唱するより、「借用のルールはデータ競合とダングリングポインタを防ぎます」と言うほうが説得力があります。
- 小さな例を使う。 3行のコードを書いたり説明したりするほうが、長い話よりも伝わることがよくあります。
- トレードオフについて正直に。 Rustにもコストはあります。コンパイルが遅い、学習曲線が急、エラー処理のタイプ量が多い。それを認めるのは弱さではなく、経験の証です。
- わからないときは「わかりません」と言う。 そのうえで、声に出して考えましょう。「
Pinを直接使ったことはありませんが、メモリ上で動いてはいけない Future に関係するものだと知っています」というのは素晴らしい答えです。 - エピソードを用意しておく。 面接官はよく「借用チェッカーに止められたときの話を聞かせてください」と聞いてきます。自分のコードから実際の例を1つ思い出し、そこから何を学んだかを考えておきましょう。
- コードを書いて練習する。 Rust練習問題のページや、数当てゲーム、ToDoリストCLIのプロジェクトが、良いウォーミングアップになります。
理解度チェック
Section titled “理解度チェック”理解度チェック
1. 読み取り専用のデータをスレッド間で共有するには、どのスマートポインタを使いますか?
2. ? 演算子は Err に出会うと何をしますか?
3. 安全なRustがコンパイル時に防いでくれるのはどれですか?
4. ライフタイム注釈は、値が生きる期間を変えますか?
スコア: 0 / 4
さらに詳しく
Section titled “さらに詳しく”- The Rust Programming Language 日本語版(通称「the book」):公式の本です。このページの答えのほとんどは、所有権、トレイト、エラー、並行処理の章にそのまま書かれています。
- Rust By Example 日本語版:実行できる短い例がたくさんあり、面接の直前に構文をおさらいするのに便利です。
- The Rust Reference(英語):言語の正確なルールです。面接官に「では、正確にはいつそうなるのですか?」と聞かれたときに役立ちます。
- The Rustonomicon(英語):
unsafeRust のガイドです。Send、Sync、メモリレイアウトについて深く知ることができ、上級者向けの質問の対策になります。 - Asynchronous Programming in Rust(英語):Future、
async/.await、ランタイムがどう組み合わさるのかを説明した、Rustプロジェクト公式の async の本です。