2
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【保存版】現場で本当に効くRust言語のTips 25選 - 所有権・エラー処理・非同期の勘所

2
Posted at

はじめに

Rustは学習コストが高い言語として知られていますが、一度身につけると「コンパイルが通れば大抵動く」という圧倒的な安心感が手に入ります。しかし、所有権・ライフタイム・トレイト・非同期といった独自の概念は、一朝一夕には馴染みません。

この記事は、私が実務でRustを書きながら「これは早く知っておきたかった」と痛感したTipsを厳選してまとめたものです。全て手元で検証済みのコード付きで、コピペで動作確認できます。ブックマークして、コードレビュー前のチェックリストとしても使ってください。

対象: Rustの基本文法は知っているが、より実践的なテクニックを身につけたい方
検証環境: Rust 1.80 以上 (2021 edition)


目次

  1. 所有権と借用の落とし穴
  2. 文字列型の使い分け
  3. エラーハンドリングの型作法
  4. Option と Result を極める
  5. ライフタイムとトレイトの実践
  6. イテレータとクロージャの最適化
  7. 非同期処理の勘所
  8. パフォーマンスとメモリ
  9. テストとツールチェーン

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講座を割引価格で見る

2
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?