Rust開発 - 初日ガイド
最初のコミットからRustアプリをうまく書くための実践的な基礎。教科書ではありません。他の言語との違い、その後のすべてを形作る初日の決断、そしてほとんどの実際のアプリをカバーする少数のクレートに焦点を当てています。
使用すべきタイミング
- 新しいRustプロジェクト(CLI、サービス、ライブラリ)を始めるとき
- Python、JavaScript、Go、Java/C#、C++からRustに移行するとき
- 所有/借用型、スマートポインタ、トレイトオブジェクト対ジェネリクスの選択
- エラー処理戦略(
anyhow対thiserror)を選ぶとき - 使用するクレートを決めるとき
- 最小限で実践的な
Cargo.toml、clippy、rustfmtを設定するとき
初日セットアップ
# 1. ツールチェーンをインストール(rustupがツールチェーンマネージャ)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# 2. コンポーネントを確認(rustfmtとclippyはstableに同梱、rust-srcはIDE機能を有効化)
rustup component add rustfmt clippy rust-src
# 3. プロジェクトを作成
cargo new my-app # バイナリ(src/main.rs)
cargo new --lib my-lib # ライブラリ(src/lib.rs)
# 4. 開発ループ(これら4つを覚える)
cargo check # 高速型チェック、コード生成なし
cargo run # ビルドして実行(バイナリ)
cargo test # ビルドしてテストを実行(doctestを含む)
cargo clippy --all-targets # リント、テスト/ベンチマークを含む(プッシュ前に実行)
cargo fmt # フォーマット
# 5. Cargo.tomlを手動で編集せずに依存関係を管理
cargo add tokio --features full
cargo remove tokio
cargo update # 既存のセマンティックバージョニング範囲内でCargo.lockを再計算
cargo updateは、Cargo.tomlに既にあるバージョン範囲内でのみ移動します。メジャーバージョン(1.xから2.0)を跨ぐには、Cargo.tomlの編集かcargo add <crate>@2が必要です。
rust-analyzerは必須です。 これはすべてのエディタ(VS Code、Zed、Neovim、Helix、RustRoverは独自エンジンを使用しますが同等)が使用する言語サーバです。VS Codeでは、rust-analyzer拡張機能をインストールし、rust-analyzer.check.commandを"clippy"に設定すると、保存時にリントフィードバックが得られます。
後でファイル監視ツールが必要ですか? cargo install --locked baconを実行し、プロジェクト内でbaconを実行します。初日は必要ありません。
Rustのメンタルモデル:5つのアイデア
Rustは、ほとんどの言語で当たり前とされている2つのもの(ガベージコレクタと例外)を、メモリ、データ競合、エラー処理に関するコンパイル時の保証と引き換えにしています。言語の形状はこのトレードオフから生まれています。
1. 所有権:すべての値にはただ1つの所有者がいる
値を物理的なオブジェクトと考えてください。本、ファイル、ネットワーク接続など。どの瞬間においても、1つの変数がそれを所有しています。以下のことが可能です:
- ムーブする:
let b = a;で所有権がbに移ります。aは使えなくなります。 - 不変で借用する:
&aで他の人に覗かせることができます。複数の読み取りが許可されます。 - 可変で借用する:
&mut aで1人が変更できます。排他的アクセスです。 - クローンする:
a.clone()でディープコピーを作ります。両方がそれぞれのものを保持します。
所有者がスコープを外れると、値はドロップされます(メモリ解放、ファイルクローズ、ロック解放)。GCも手動のfreeもありません。これがRAIIであり、コンパイラによって強制されます。
2. エイリアシング XOR 可変性
どの瞬間においても、データは次のいずれかを持ちます:
- 1つの可変参照(
&mut T)、または - 任意の数の不変参照(
&T)、
両方は決してありません。この単一のルールがデータ競合とほとんどの使用後解放バグを排除します。借用チェッカーがこれを強制します。借用チェッカーが文句を言うとき、それは言語が難しいのではなく、データの所有権のストーリーが不明確であることを伝えています。
3. エラーは値であり、例外ではない
try/catchはありません。失敗する可能性のある関数はResult<T, E>を返します。有用なものを返さない関数はOption<T>を返します。コンパイラは両方を処理することを強制します。?演算子は、1文字でエラーをコールスタックに伝播させます:
fn read_config() -> Result<Config, anyhow::Error> {
let text = std::fs::read_to_string("config.toml")?; // ? = Errなら早期リターン
let config = toml::from_str(&text)?;
Ok(config)
}
nullはありません。Option<T>はNoneかSome(value)です。コンパイラはNoneのケースを忘れることを許しません。
4. トレイトはJavaのインターフェースではない
traitは振る舞いを定義します。型はトレイトをimplします。ここまでは似ています。違いは:
- 静的ディスパッチがデフォルトです。
fn f<T: Display>(x: T)と書くと、コンパイラはfのコピーを、呼び出し元の各具象型Tに対して個別に生成します(単相化、C++テンプレートと同様)。ランタイムオーバーヘッドゼロ。 - 動的ディスパッチはオプトインで、
dyn Trait(通常はBox<dyn Trait>または&dyn Trait)を使用します。呼び出しごとにvtableルックアップが1回。 - 継承はありません。 トレイトは合成します。型を「拡張」するために
Derefを使いたくなったら、やめて合成かenumを使用してください。 - 孤児ルール:
impl YourTrait for SomeoneElsesTypeまたはimpl SomeoneElsesTrait for YourTypeはできますが、両方が外部のものはできません。これにより依存関係の解決が健全に保たれます。
5. 借用チェッカーは設計の神託
最も一般的な初心者の間違いは、コンパイラエラーを沈黙させるべき障害と見なすことです。そうではありません。ほとんどすべての借用チェックエラーは、誰が何を所有するかに関する実際の問題を明らかにします。行き詰まったときの質問は、「どうやってこれをコンパイルするか」ではなく、「ここで本当に望んでいる所有権の関係は何か」です。エラーを読んでください。コンパイラは異常に情報量が多いです。
関数シグネチャのための3つの質問
関数を書く前に、入力に対して所有、読み取り、変更のどれが必要かを自問してください:
fn consume(s: String) // 所有:関数が責任を持ち、呼び出し元は失う
fn read(s: &str) // 読み取り:関数は見るだけ、呼び出し元は保持
fn modify(s: &mut String) // 変更:関数がその場で変更する
90%のケースで機能するデフォルト:
- 関数パラメータ:
Stringより&str、Vec<T>より&[T]を優先(これらはスライスで、所有型と借用型の両方の呼び出し元を受け入れます)。 - 関数の戻り値:所有型(
String、Vec<T>)を返します。参照を返すことはライフタイムを意味します。必要になるまで避けてください。 - 構造体フィールド:所有型(
String、Vec<T>)を優先。構造体に&strを保存することは、最も一般的な初心者の罠であり、あなたの構造体を保持するすべての型にライフタイム注釈を連鎖させます。
初日決定表
最初に遭遇する選択肢に対する1行の回答。
| 決定 | デフォルト | もう一方を選ぶとき |
|---|---|---|
String vs &str(構造体フィールド) | String | 本当に理由があり、ライフタイムを理解するまで、&strはほぼ決して使わない |
String vs &str(関数パラメータ) | &str | 内部で所有/保存する必要がある場合のみStringを使用 |
Vec<T> vs &[T](パラメータ) | &[T] | 所有が必要な場合のみVec<T> |
Box<T> vs Rc<T> vs Arc<T> | Box<T>(単一所有者、ヒープ) | スレッド間で共有所有権が必要な場合はArc<T>。デフォルトとしてRc<T>は避け、Arc<T>を使用して非同期に移行するときにリファクタリングしないようにする |
RefCell<T> vs Mutex<T> | Mutex<T>(またはRwLock<T>) | 同じ理由:async/スレッドで動作するが、RefCellは動作しない |
Option<T> vs Result<T, E> | 「値がない」場合はOption<T>、「理由があって失敗した」場合はResult<T, E> | 欠如が呼び出し元が処理すべき意味を持つ場合、Result |
dyn Trait vs impl Trait / <T: Trait> | ジェネリック(<T: Trait>またはimpl Trait)- 静的ディスパッチ | 異種コレクション(Vec<Box<dyn Animal>>)が必要な場合、Box<dyn Trait> |
| アプリケーションコードでのエラー | どこでもanyhow::Result<T> | - |
| ライブラリコードでのエラー | thiserror派生enum | 公開ライブラリAPIでBox<dyn Error>は絶対に使わない - 呼び出し元にダウンキャストを強制する |
&self vs &mut self vs self | ゲッターには&self、セッターには&mut self、ビルダー/消費操作にはself | - |
| モジュール構成 | ファイルが長くなるまでインラインモジュール、その後分割 | 1モジュール=1ファイルはJava/C#の習慣で、Rustの習慣ではない |
早いうちに内面化するイディオム
これらはほぼすべてのRustプログラムに現れます。1週目で学んでください。
?によるエラー伝播。 9行のmatchを1文字で置き換えます。
let body = reqwest::get(url).await?.text().await?;
手動ループよりもイテレータチェーン。 手書きのループと同じ機械語にコンパイルされます(LLVMがクロージャをインライン化)。慣用的なRustは関数型スタイルです。
let active_emails: Vec<String> = users
.iter()
.filter(|u| u.active)
.map(|u| u.email.clone())
.collect();
matchの網羅性。 enumに新しいバリアントを追加すると、それを処理しないすべてのmatchがコンパイルエラーになります。これを活用してください。これはどんな言語でも最も強力なリファクタリングツールの1つです。
コンパイラにリファクタを任せる。 同じトリックが構造体でも機能します:Defaultなしの必須フィールドを追加すると、既存のすべてのコンストラクタがコンパイルエラーになります - 更新すべきすべてのサイトの無料で網羅的なワークリストです。
if letとlet else は一般的な単一アームのmatchに。
if let Some(name) = user.name { println!("hi {name}"); }
let Some(name) = user.name else { return Err(anyhow!("no name")); };
// この時点から`name`はスコープ内にあり、ネストなし
From / Intoによる型変換。 Fromを実装すれば、Intoを無料で取得。?はFromを使用してエラー型を自動的に変換します。
Option / Resultのコンビネータ。 matchの前に、.map、.and_then、.unwrap_or、.unwrap_or_else、.ok_orに手を伸ばしてください。
派生マクロ。 #[derive(Debug, Clone, PartialEq)]で定型コードの80%を無料で得られます。JSONには#[derive(Serialize, Deserialize)]を追加。
最近の糖衣構文。 知っておく価値のある安定化機能、新しい順:bool::ok_orとok_or_else(1.98)は条件を直接Resultに変換します(ready.ok_or(Error::NotReady)?)。cfg_select!はcfg述語に対するコンパイル時のmatchで、cfg-ifクレートのほとんどの使用を置き換えます。if letガードはmatchアームで動作します(match x { Some(v) if let Ok(n) = v.parse::<i32>() => ... })- 両方とも1.95。assert_matches!とdebug_assert_matches!(1.96)は等価性ではなくパターンに対してアサーションを行い、enumの自然なアサーションです。頻繁に目にし、特殊なものと誤解すべきでない2つの古いもの:let-チェーン(if let Some(x) = a && x > 3 { ... }、エディション2024で1.88から安定)、および非同期クロージャ(async || { ... }、AsyncFnファミリーの境界付き、1.85から安定)。
Xからの移行:ここでつまずく
PythonまたはJavaScriptから:
- ヒープ値(
String、Vecなど)に対するlet b = a;はそれをムーブします。aは使用できなくなります。借用するには&a、コピーするにはa.clone()を使用します。 - nullはありません。
Option<T>が強制されます。 - 例外はありません。
Result<T, E>と?。コンパイラはエラーを無視することを許しません。 - 継承はありません。合成+トレイト+enum。
- 変数はデフォルトで不変です。変更するには
mutを追加します。参照も同様:&vs&mut。 - 名前の再宣言は許可されており、慣用的です:
let input = input.trim();は古いバインディングを新しいものでシャドウイングし、異なる型になる可能性があります。これはミューテーションではなく、input2を発明せずに値をその場で変換するRustの方法です。 - 整数型は明示的であり、インデックスには
usizeが必要です。また、ビルドによってオーバーフローの動作が異なります:デバッグは「プログラムを_ランタイムでパニック_させる整数オーバーフローのチェックを含む」一方、--releaseはそれらをドロップし、「2の補数のラッピング」を実行します。合格するテストスイートでも本番でラップする可能性があるため、デバッグパニックに依存するのではなく、実際に境界に達する可能性のある算術にはchecked_*/saturating_*/wrapping_*を使用してください。strict_addとその仲間(1.91から安定)はすべてのビルドでオーバーフロー時にパニックし、[profile.release]の下のoverflow-checks = trueはデバッグ動作を本番でも維持します。また、整数型間のasは暗黙的に切り捨てられます(300_u32 as u8は44)- 値が収まらない可能性がある場所ではu8::try_from(x)?を使用してください。 Stringはインデックス可能ではありません。s[0]はコンパイルされません - 代わりに何を使うべきかについてはreferences/ownership-and-types.mdを参照してください。
Goから:
- エラーを値として扱う - 同じ発想ですが、
if err != nilの代わりに?を使用します。 nilはありません。Option<T>。- GCもgoroutineもありません:所有権+借用、
tokioによるasync/await。非同期モデルは協調的(awaitは明示的な譲歩点)であり、プリエンプティブではありません。 interface{}はトレイトになります。静的ディスパッチにはジェネリクスをデフォルトとし、必要な場合のみBox<dyn Trait>を使用します。- 静的リンクがデフォルトです。バイナリは大きくなりますが、自己完結型です。
panic!はアプリケーションコードの回復不可能なバグのために予約すべきです。Goスタイルの「ログを取って続行」には使用しないでください。
JavaまたはC#から:
- トレイトはデフォルトで仮想ディスパッチを持つインターフェースではありません。
<T: Trait>は単相化されます。dyn Traitはオプトインの動的バージョンです。 - ヌル参照はありません。
Option<T>。 - 例外はありません。
Result<T, E>と?。 - クラス継承はありません。直和型にはenum、共有動作にはトレイトを使用します。
- GCはありません:所有権と借用がライフタイムを決定します。
Arc<T>はJavaの参照に最も近いものです。 - ジェネリクスは単相化され、型消去されません。
C++から:
- RAIIに似ていますが、借用チェッカーがコンパイル時にそれを強制します。
- コピー/ムーブコンストラクタはありません。
Cloneは明示的であり、Copyは安価なビット単位のコピーのためのマーカートレイトです。 - 安全なコードでは未定義動作はありません(理論上)。
&はコンパイル時にチェックされる借用であり、生ポインタではありません。生ポインタ(*const T、*mut T)は存在しますが、デリファレンスにはunsafeが必要です。- スマートポインタは
Box<T>(unique_ptr)、Rc<T>(shared_ptr、単一スレッド)、Arc<T>(shared_ptr、スレッドセーフ)です。 - マクロは衛生的です。手続き型マクロ(derive、attribute、function-like)は
serde、tokio::mainなどが機能する方法です。
厳選クレートリスト
これらはほとんどの実際のアプリをカバーします。必要に応じて追加してください。すべて必須ではありません。
| クレート | 提供するもの |
|---|---|
serde + serde_json | シリアライゼーション。#[derive(Serialize, Deserialize)]で完了 |
tokio | 非同期ランタイム。#[tokio::main]、tokio::spawn、非同期I/O |
anyhow | アプリケーションエラー型。anyhow::Result<T>、bail!、context() |
thiserror | ライブラリエラーenum。#[derive(thiserror::Error)] |
clap | CLI引数解析。#[derive(Parser)]、自動help/version |
axum | Webフレームワーク。tokio + tower上に構築、#[debug_handler]で良いエラーを提供 |
sqlx | 非同期SQL。cargo add sqlx --features runtime-tokio,postgres |
reqwest | HTTPクライアント。reqwest::get(url).await?.text().await? |
tracing | 構造化ログ。tracing-subscriberで設定。println!の代わりにinfo!、warn!、error!、debug!。非同期と一緒に動作 |
chrono | 日付/時刻 |
uuid | UUID |
regex | 正規表現 |
rand | 乱数生成 |
サンプルCargo.toml
ほとんどのCLIまたはサービスのための実用的な出発点:
[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
# - CLIのみの場合、tokioを削除
# - サービス:axum + sqlx + tower-http + reqwest を追加
# - テスト:ひとまずスキップ、ニーズが生じたら追加
[dev-dependencies]
# 必要になるまでは追加しない
[profile.release]
# デバッグ情報を維持して実用的なバックトレースを取得(サイズに大きな影響なし)
debug = 1
# 本番環境でもオーバーフローパニックを有効化
overflow-checks = true
# デフォルトの3レベルの代わりに単一レベルのLTO(サイズとパフォーマンスに役立つ)
lto = "thin"
clippy設定
.clippy.tomlで、チェックされないことを確認したいルールを設定します:
# ライブラリAPI以外では通常は不要。まだ気にしない
# disallowed-types = ["Box<dyn Error>"]
too-many-arguments-threshold = 10
# 許容引数が多い。毎回構造体を作るよりも悪い
# プロトタイピング段階ではok
clippy.tomlがない場合、clippyはすべてのデフォルトリントを許可して実行します。これは_プロジェクトを作成したとき_に適切です。後で調整します。
rustfmt設定
# rustfmt.toml(オプション、デフォルトはRust 2024スタイル)
# 本当に必要でない限り変更しない。Rustは一貫したフォーマットを重視
# チームが明確な好みを持っている場合のみ設定を追加
# 例:一貫性のあるマクロ呼び出しのために
# normalize_comments = true
初期テスト戦略
- ドキュメントテスト(
///ブロック内のコード)は無料でミニテストを提供します。cargo testで実行されます。使用してください。 - ユニットテストはテスト中のモジュールと同じファイルの
#[cfg(test)] mod tests { ... }に配置します。 - 統合テストは
tests/ディレクトリに配置します。cargo testが自動的に見つけます。各ファイルは独自のクレートとしてコンパイルされます。 - 統合テストのためのcommonモジュール:
tests/common/mod.rsに配置します(tests/common.rsは_テストとして実行_されるため避けてください)。 - ほとんどの場合、
assert_eq!、assert!、Result<()>を返すテスト(#[test] fn it_works() -> Result<()> { ... })で十分です。 - 必要な場合のみモック。代わりにトレイトを使用してテストでスタブを注入します。
エディタ設定
VS Code
拡張機能のsettings.json(またはワークスペース設定):
{
"rust-analyzer.check.command": "clippy",
"editor.formatOnSave": true,
"editor.inlayHints.enabled": "on"
}
Zed
Zedはコードアクションと言語サーバの設定を含むRustサポートを内蔵しています。
Neovim
rust-analyzer用のmason.nvim / nvim-lspconfig、フォーマット用のconform.nvim、リント用のnvim-lint。
トラブルシューティング
| 問題 | 最初に試すこと |
|---|---|
「cannot borrow x as mutable more than once at a time」 | xを別々のスコープで使用しているか確認。スコープを導入({ let y = &mut x; ... })するか、借用を並べ替える |
「cannot move out of x which is behind a shared reference」 | クローンするか、共有所有権(Arc)にリファクタリングする |
「expected &str, found String」 | &s(暗黙の参照外し、自動逆参照は文字列では機能しません - s.as_str()を使うか、*sが機能する場合があります)。通常は、文字列リテラルに対して&strを使用 |
「expected String, found &str」 | s.to_string()またはs.to_owned() |
| 「lifetime mismatch」 | おそらく構造体に&strを保存しています。Stringを使用する |
「the trait Iterator is not implemented for MyType」 | IntoIterator(.into_iter()で消費)と.iter()(コレクションへの参照)の違いを確認 |
「use of moved value: x」 | xは別の場所に移動されました。借用する(&x)か、クローンする(x.clone()) |
「Box<dyn Trait> cannot be sent between threads safely」 | コードにSendを追加(Box<dyn Trait + Send>)するか、Arc<Mutex<T>>に変更する。このエラーは非同期コードで、クロージャがSendでないものをキャプチャした場合に頻繁に発生します |
「recursion in async fn requires Box」 | async_recursionクレートを追加するか(#[async_recursion])、手動でPin<Box<dyn Future>>を返す |
「async block may outlive the current function」 | tokio::spawnされたタスクがスコープ外になる参照をキャプチャしています。.awaitするか、クローン/所有する |
次に読むべきもの
初日以降のための優先順位付きロードマップ:
- The Rust Book(chapter 4-6, 8-10, 13, 15, 16)- 章はあなたの進む方向に沿っている
std::iterのドキュメント - イテレータアダプタを理解することは第二言語を学ぶようなもの。Iteratorトレイトのドキュメントを読んで、関連するすべてのメソッドを理解するEffective Rust- 新しい言語を学ぶときにこれに相当するものは貴重Rust by Example- 9割のケースをカバーするコード例Rust API Guidelines- ライブラリを書く場合のみ
覚えておいてください:Rustはバグのクラスを排除する最初の主流言語であり、その代償は学習曲線です。その曲線は急ですが短いです。コンパイラと戦わず、耳を傾けてください。