はじめに
Rustは学習コストが高い言語として知られていますが、一度身につけると「コンパイルが通れば大抵動く」という圧倒的な安心感が手に入ります。しかし、所有権・ライフタイム・トレイト・非同期といった独自の概念は、一朝一夕には馴染みません。
この記事は、私が実務でRustを書きながら「これは早く知っておきたかった」と痛感したTipsを厳選してまとめたものです。全て手元で検証済みのコード付きで、コピペで動作確認できます。ブックマークして、コードレビュー前のチェックリストとしても使ってください。
対象: Rustの基本文法は知っているが、より実践的なテクニックを身につけたい方
検証環境: Rust 1.80 以上 (2021 edition)
目次
- 所有権と借用の落とし穴
- 文字列型の使い分け
- エラーハンドリングの型作法
- Option と Result を極める
- ライフタイムとトレイトの実践
- イテレータとクロージャの最適化
- 非同期処理の勘所
- パフォーマンスとメモリ
- テストとツールチェーン
1. 所有権と借用の落とし穴
Tip 1: clone() は「敗北」ではなく戦略的に使う
Rust初学者は借用チェッカと戦うために .clone() を避けがちですが、パフォーマンスクリティカルでない箇所では積極的に使って良いのです。大切なのは「どこがホットパスか」を見極めること。
// 設定値の読み込みなど、頻度の低い箇所では clone で読みやすさ優先
let name = config.name.clone();
spawn_worker(name);
// ホットパスなら参照や Arc を検討
let shared = Arc::new(config);
for _ in 0..1000 {
let s = Arc::clone(&shared);
tokio::spawn(async move { work(s).await });
}
Tip 2: 借用とムーブの区別を型で理解する
&T は共有参照、&mut T は排他参照、T はムーブ (所有権の移動)。関数シグネチャを見ればどう扱うつもりかが分かります。
fn read_only(s: &String) { /* 読むだけ */ }
fn modify(s: &mut String) { s.push_str("!"); }
fn consume(s: String) { /* この関数を抜けると s は解放 */ }
APIを設計する際は「呼び出し側にも所有権が必要か」で決めます。基本は借用、必要なときだけムーブ。
Tip 3: Non-Lexical Lifetime (NLL) を理解する
借用のスコープはブロックではなく「最後に使われた箇所」までです。
let mut v = vec![1, 2, 3];
let first = &v[0];
println!("{}", first); // ここで first の借用が終わる
v.push(4); // OK: firstを使い終わっているので可変借用できる
古いRustの知識で「同じスコープでは共有借用と可変借用は同居できない」と覚えていると、無駄な工夫をしがちです。
Tip 4: 部分借用を活用する
構造体のフィールドは個別に借用できます。ただしメソッド越しでは失われるので、フィールドに直接アクセスする形を選ぶことがあります。
struct Editor {
buffer: String,
cursor: usize,
}
impl Editor {
fn split_borrows(&mut self) {
let buf = &mut self.buffer;
let cur = &mut self.cursor;
buf.push('a');
*cur += 1;
}
}
2. 文字列型の使い分け
Tip 5: &str と String の使い分けを関数シグネチャで表現する
引数は原則 &str (借用) にすると柔軟性が上がります。所有権が必要なときだけ String を受け取りましょう。
// 良い: &str なら &String も &"literal" も渡せる
fn greet(name: &str) -> String {
format!("Hello, {}", name)
}
// 微妙: 呼び出し側に String を強制する
fn greet_bad(name: String) -> String {
format!("Hello, {}", name)
}
Tip 6: Cow で「借用か所有か」を実行時に決める
「大半は借用のまま返せるが、加工が必要なときだけ所有する」というケースに Cow<str> が便利です。
use std::borrow::Cow;
fn normalize(input: &str) -> Cow<'_, str> {
if input.contains('\r') {
Cow::Owned(input.replace('\r', ""))
} else {
Cow::Borrowed(input)
}
}
不要なアロケーションを避けつつ、API利用者にとっては透明に扱えます。
Tip 7: 文字列連結は format! より write! / push_str
format! は毎回新しい String を作ります。ループ内では避け、事前に容量を確保した String に書き込みましょう。
use std::fmt::Write;
let mut s = String::with_capacity(1024);
for i in 0..1000 {
write!(&mut s, "item {} ", i).unwrap();
// または s.push_str(...)
}
Tip 8: 文字列の「文字数」は bytes / chars / graphemes で違う
let s = "こんにちは";
assert_eq!(s.len(), 15); // バイト数
assert_eq!(s.chars().count(), 5); // Unicodeスカラ値の数
// 絵文字を含む場合は unicode-segmentation を検討
// "👨👩👧" は複数のスカラ値だが「1つの書記素クラスタ」
len() はバイト数であることを常に意識してください。
3. エラーハンドリングの型作法
Tip 9: ? 演算子で早期リターンを綺麗に
fn load_config(path: &str) -> Result<Config, ConfigError> {
let text = std::fs::read_to_string(path)?;
let cfg: Config = toml::from_str(&text)?;
Ok(cfg)
}
From トレイトが実装されていれば、異なるエラー型を自動変換してくれます。
Tip 10: ライブラリは thiserror、アプリは anyhow
エラーの粒度を型で表現したいライブラリでは thiserror、詳細を追いにくくてもコンテキストが欲しいアプリでは anyhow が定番です。
// ライブラリ側
#[derive(thiserror::Error, Debug)]
pub enum StoreError {
#[error("not found: {0}")]
NotFound(String),
#[error("io error")]
Io(#[from] std::io::Error),
}
// アプリ側
use anyhow::{Context, Result};
fn run() -> Result<()> {
let cfg = load_config("config.toml")
.context("failed to load config")?;
Ok(())
}
Tip 11: unwrap() と expect() は本番コードで避ける
unwrap() はメッセージが出ないので、どうしても panic させたい箇所でも expect("この不変条件は満たされているはず") を選びましょう。
// 悪い: panic時に情報がない
let port: u16 = env::var("PORT").unwrap().parse().unwrap();
// 良い: どこでpanicしたか一目瞭然
let port: u16 = env::var("PORT")
.expect("PORT must be set")
.parse()
.expect("PORT must be a valid u16");
Tip 12: panic は「バグ」、Result は「想定エラー」
ネットワーク断や入力不正は Result。プログラムの前提条件違反 (絶対にゼロにならないはずの値がゼロなど) は panic!。この区別が曖昧だとAPIが使いにくくなります。
4. Option と Result を極める
Tip 13: match より combinator を優先する
// match で書くと冗長
let name = match user {
Some(u) => u.name,
None => String::from("guest"),
};
// combinator で簡潔に
let name = user.map(|u| u.name).unwrap_or_else(|| String::from("guest"));
map / and_then / or_else / unwrap_or_default などを覚えると、ネストが激減します。
Tip 14: if let と let else でネストを潰す
// let else でハッピーパスをフラットに保つ
fn process(input: Option<&str>) -> Result<(), Error> {
let Some(s) = input else {
return Err(Error::MissingInput);
};
// 以降 s は &str として使える
println!("{}", s);
Ok(())
}
Tip 15: Option<&T> と &Option は違う
let x: Option<String> = Some(String::from("hi"));
// これは Option<&String> を返す (推奨)
let r: Option<&String> = x.as_ref();
// &Option<String> は基本的に扱いにくい
as_ref() / as_deref() を使うと、内部の参照に変換できて便利です。
5. ライフタイムとトレイトの実践
Tip 16: ライフタイムは「関係」を表す
'a は「何秒生きる」ではなく「これらの参照は同じスコープに属する」というマーカーです。
// 出力の参照は、入力のどちらかと同じ寿命
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
「返り値がどの引数の寿命に紐づくか」を型で示すのがライフタイムの役目です。
Tip 17: impl Trait で戻り値を隠す
// クロージャや複雑なイテレータの型を書かずに済む
fn evens(n: u32) -> impl Iterator<Item = u32> {
(0..n).filter(|x| x % 2 == 0)
}
引数側の impl Trait はジェネリクスの糖衣構文、戻り値側は「具体型は隠すけどこのトレイトは満たす」という宣言です。
Tip 18: dyn Trait と impl Trait の使い分け
- 同じ関数から複数の具体型を返したい →
Box<dyn Trait> - 単一の具体型で良い →
impl Trait(スタック上、動的ディスパッチなし)
fn make_handler(kind: &str) -> Box<dyn Handler> {
match kind {
"a" => Box::new(HandlerA),
_ => Box::new(HandlerB),
}
}
Tip 19: newtype パターンで型安全を強化
struct UserId(u64);
struct OrderId(u64);
fn fetch_user(id: UserId) { /* ... */ }
let uid = UserId(1);
let oid = OrderId(2);
fetch_user(uid); // OK
// fetch_user(oid); // コンパイルエラー: 型が違う
同じ u64 でも意味が違うものを混同するバグをコンパイル時に潰せます。
6. イテレータとクロージャの最適化
Tip 20: collect の型注釈を活用する
collect() は文脈から型を推論しますが、明示すると読み手にも優しくなります。
let squares: Vec<i32> = (1..=10).map(|x| x * x).collect();
let map: HashMap<_, _> = pairs.into_iter().collect();
// Result のベクタを Result<Vec<_>, _> に変換できる
let parsed: Result<Vec<i32>, _> =
strings.iter().map(|s| s.parse::<i32>()).collect();
Result を collect すると、最初のエラーで打ち切って Err を返せるという地味に強力な性質があります。
Tip 21: イテレータは基本的にゼロコスト
高階関数を連ねても、多くの場合ループと同等のアセンブリになります。可読性を優先して積極的にチェーンしましょう。
let sum: i32 = numbers.iter()
.filter(|&&n| n > 0)
.map(|&n| n * 2)
.sum();
ただし collect を挟むと中間バッファができるので、必要な箇所以外では避けます。
Tip 22: for ループと iter / iter_mut / into_iter の使い分け
let v = vec![1, 2, 3];
for x in &v { /* &i32 */ } // 借用: v は後で使える
for x in &mut v { /* &mut i32 */ }
for x in v { /* i32, v はムーブされる */ }
書き終わったら cargo clippy が最適な形を教えてくれます。
7. 非同期処理の勘所
Tip 23: async fn は「Future を返す関数」
async は魔法ではありません。async fn foo() -> T は実質 fn foo() -> impl Future<Output = T> の糖衣構文で、.await されるまで何も実行されません。
let fut = do_work(); // ここではまだ動かない
let result = fut.await; // ここで初めて実行
Tip 24: tokio::spawn には 'static が必要
タスクは他のスレッドで動く可能性があるので、借用は原則使えません。データを渡すには所有権ごとムーブするか Arc を使います。
let data = Arc::new(load_data());
for _ in 0..10 {
let d = Arc::clone(&data);
tokio::spawn(async move {
process(&d).await;
});
}
Tip 25: join! と select! を使い分ける
- すべての完了を待つ →
tokio::join!/futures::future::join_all - 最初に完了したものを取る →
tokio::select!
// 全て並行実行して待つ
let (a, b) = tokio::join!(fetch_a(), fetch_b());
// タイムアウト付き実行
tokio::select! {
result = fetch() => handle(result),
_ = tokio::time::sleep(Duration::from_secs(3)) => {
println!("timeout");
}
}
select! はキャンセルセーフでない Future を扱うと厄介なので、公式ドキュメントの注意点を必ず読みましょう。
8. パフォーマンスとメモリ
保存版: リリースビルドとプロファイリング
# 開発中でも最適化を有効にしたテスト
cargo test --release
# プロファイリングは release + debug symbols で
# Cargo.toml
# [profile.release]
# debug = true
# ベンチマーク (安定版で使える criterion がおすすめ)
cargo bench
# 実行時プロファイル
cargo install flamegraph
cargo flamegraph --bin myapp
Vec は事前に with_capacity
要素数が推定できるなら、再割り当てを避けられます。
let mut v = Vec::with_capacity(1000);
for i in 0..1000 {
v.push(i);
}
Box / Rc / Arc の使い分け
-
Box<T>: ヒープに単独所有 -
Rc<T>: シングルスレッドで複数所有 (参照カウント) -
Arc<T>: マルチスレッドで複数所有 (アトミック参照カウント)
Arc はアトミック操作のコストがあるので、シングルスレッドなら Rc を選びます。共有かつ書き換えが必要なら Arc<Mutex<T>> や Arc<RwLock<T>> に。
9. テストとツールチェーン
保存版チェックリスト
コードレビュー前に走らせるべきコマンドをまとめておきます。
# フォーマット
cargo fmt --all
# 静的解析 (警告をエラー扱いに)
cargo clippy --all-targets --all-features -- -D warnings
# テスト
cargo test --all-features
# ドキュメントテストを含めた実行確認
cargo test --doc
# 未使用依存の検出
cargo install cargo-udeps
cargo +nightly udeps
# セキュリティ監査
cargo install cargo-audit
cargo audit
# 依存の更新
cargo install cargo-outdated
cargo outdated
CIに組み込むと、レビュー時に本質的な議論に集中できます。
Tip: #[cfg(test)] モジュールでテストを近くに置く
pub fn add(a: i32, b: i32) -> i32 { a + b }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds() {
assert_eq!(add(2, 3), 5);
}
}
対象コードと同じファイルにテストを置けるのはRustの美点です。プライベート関数もテストできます。
Tip: ドキュメントコメントに実行可能な例を書く
/// 二つの数を足す
///
/// # Examples
///
/// ```
/// use mycrate::add;
/// assert_eq!(add(2, 3), 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 { a + b }
cargo test でドキュメントの例も検証されるので、ドキュメントが陳腐化しません。
まとめ
Rustは学習曲線が急な代わりに、身につけた知識が長く役立つ言語です。コンパイラが多くの間違いを事前に指摘してくれるので、一度パターンを覚えれば「安全で速いコード」を書き続けられます。
明日からのコーディングで、まずは3つほど取り入れてみてください。特に、
-
thiserrorとanyhowによるエラーハンドリングの型作法 -
Option/Resultの combinator とlet else -
clippyを CI に組み込むことによる品質の底上げ
の3点は、チーム開発でのバグと議論コストを大幅に減らしてくれるはずです。
さらに手を動かして学びたい方へ
この記事の内容を「読んだ」だけで終わらせず、実際に手を動かしながら体系的に学びたい方向けに、演習型のUdemy講座を用意しています。すべての講座がハンズオン形式で、章ごとに小さな課題を解きながら進める構成です。
以下のリンクから、通常価格よりも割引された価格で受講できます。
割引リンク: Udemy講座を割引価格で見る