Rust言語の文法解説
基本文法から所有権・借用・ライフタイム、データ構造・アルゴリズムの実装、並行処理まで、基本情報技術者試験の合格を目指せるレベルまで深掘りして解説します。
文法解説
1. コメント・変数と可変性・定数
行コメントは//、複数行コメントは/* */です。Rustの変数はデフォルトで不変(イミュータブル)で、値を変更したい場合はmutを付けます。定数はconstで宣言し、型注釈が必須です。
// これは行コメント
/* これは
複数行コメント */
fn main() {
let age = 20; // 不変(デフォルト)
let mut score = 80; // mutを付けると可変になる
score += 10;
const MAX_SCORE: i32 = 100; // 定数(型注釈必須、コンパイル時に確定する値のみ)
println!("age={}, score={}, max={}", age, score, MAX_SCORE);
}
2. 静的型付けと基本型
Rustは静的型付け言語で、コンパイル時にすべての型が確定します(型推論も強力)。整数(i8〜i128, u8〜u128)、浮動小数点(f32, f64)、真偽値(bool)、文字(char)が基本型(スカラー型)で、タプル・配列は複合型です。
let num: i32 = 10; // 32ビット符号付き整数
let pi: f64 = 3.14; // 64ビット浮動小数点
let flag: bool = true;
let ch: char = '桜';
let name: &str = "太郎"; // 文字列スライス(借用)
let owned: String = String::from("花子"); // 所有権を持つ文字列
let point: (i32, i32, &str) = (10, 20, "point"); // タプル(異なる型を混在できる)
let arr: [i32; 3] = [1, 2, 3]; // 配列(要素数は固定・同じ型のみ)
println!("{} {} {} {} {} {}", num, pi, flag, ch, name, owned);
3. 制御構文:if式・loop・while・for
Rustのifは式であり値を返せます。繰り返しには無限ループのloop、条件付きのwhile、範囲やイテレータを回すforがあります。
let score = 75;
// ifは式なので変数に代入できる
let grade = if score >= 80 { "優" } else if score >= 60 { "良" } else { "再履修" };
println!("{}", grade);
// loop: breakに値を渡してループの結果として受け取れる
let mut count = 0;
let result = loop {
count += 1;
if count == 5 {
break count * 10;
}
};
println!("result={}", result);
// while: 条件が真の間繰り返す
let mut n = 3;
while n > 0 {
println!("n={}", n);
n -= 1;
}
// for: 範囲やコレクションを反復する(最もよく使われる)
for i in 0..3 { // 0, 1, 2(0..=3なら0,1,2,3)
println!("i={}", i);
}
4. match式によるパターンマッチング
matchはすべての場合分けを網羅(exhaustive)しなければならない強力な分岐構文です。値・範囲・enumのバリアントなど様々なパターンにマッチでき、C言語のswitchより安全で表現力があります。
fn describe(n: i32) -> &'static str {
match n {
0 => "ゼロ",
1..=9 => "1桁の正の数", // 範囲パターン
n if n < 0 => "負の数", // ガード条件
_ => "10以上の数", // _はどのパターンにも当てはまらない残り全て(網羅性のため必須)
}
}
fn main() {
println!("{}", describe(5));
println!("{}", describe(-3));
}
5. 関数とクロージャ
関数はfnで定義し、戻り値の型は->の後に書きます(最後の式の値がそのまま戻り値になる)。クロージャは|引数| 処理の形で書く無名関数で、周囲の変数をキャプチャできます。
fn add(a: i32, b: i32) -> i32 {
a + b // セミコロンなし=この式が戻り値
}
fn main() {
println!("{}", add(2, 3));
let base = 10;
// クロージャ:baseを周囲の環境からキャプチャする
let add_base = |x: i32| x + base;
println!("{}", add_base(5)); // 15
// 高階関数の引数としてクロージャを渡す
let numbers = vec![1, 2, 3, 4];
let doubled: Vec = numbers.iter().map(|n| n * 2).collect();
println!("{:?}", doubled);
}
6. 所有権(Ownership)
Rust最大の特徴です。すべての値には所有者となる変数がただ1つ存在し、所有者がスコープを抜けると値は自動的に解放されます。Stringのようなヒープ確保を伴う値を別の変数に代入すると、値のコピーではなく所有権の「ムーブ」が起こり、元の変数は無効になります。これによりガベージコレクタ(GC)なしで二重解放やダングリングポインタを防ぎます。
fn main() {
let s1 = String::from("hello");
let s2 = s1; // s1の所有権がs2にムーブする(s1は無効になる)
// println!("{}", s1); // コンパイルエラー:s1はムーブ済みで使えない
println!("{}", s2);
// i32などスタックに収まる型はCopyトレイトを実装しているためムーブされずコピーされる
let x = 5;
let y = x;
println!("{} {}", x, y); // 両方使える
{
let s3 = String::from("temporary");
println!("{}", s3);
} // s3はここでスコープを抜け、自動的にメモリが解放される(drop)
}
7. 借用(Borrowing)と参照
所有権を移動せずに値を「借りる」ことができます。&Tは不変参照、&mut Tは可変参照です。コンパイラは「不変参照は同時に複数持てるが、可変参照は同時に1つしか持てず、不変参照と同時に持てない」という借用規則を静的にチェックし、データ競合をコンパイル時に排除します。
fn print_length(s: &String) { // 不変参照で借用するだけ
println!("長さ: {}", s.len());
}
fn append_world(s: &mut String) { // 可変参照で借用し変更する
s.push_str(" world");
}
fn main() {
let mut s = String::from("hello");
print_length(&s); // 借用のため所有権は移動しない
append_world(&mut s);
println!("{}", s); // "hello world" 引き続き使用できる
}
8. ライフタイム(Lifetime)
参照が指す値が、参照自体より先に無効化されないことをコンパイラが検証するための仕組みがライフタイムです。多くの場合コンパイラが自動推論しますが、複数の参照が関わる関数シグネチャでは'aのようなライフタイム注釈が必要になることがあります。これにより「ダングリング参照(解放済みメモリへの参照)」を実行前に検出できます。
// 'a は「戻り値の参照は、xとyのうち短い方の生存期間以上は生きられない」ことを示す
fn longer<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() >= y.len() { x } else { y }
}
fn main() {
let s1 = String::from("long string");
let result;
{
let s2 = String::from("short");
result = longer(s1.as_str(), s2.as_str());
println!("longer = {}", result); // s2が生きているスコープ内なのでOK
}
// ここでresultをprintするとs2は解放済みなのでコンパイルエラーになる
}
9. struct・enumとimpl
structは複数のデータをまとめて1つの型として扱う仕組みで、implブロックでメソッドや関連関数を定義します。enumは「いずれか1つ」を表す型で、各バリアントにデータを持たせることもできます(代数的データ型)。
struct Animal {
name: String,
age: u32,
}
impl Animal {
// 関連関数(コンストラクタ相当、Self::newのように呼ぶ)
fn new(name: &str, age: u32) -> Self {
Animal { name: name.to_string(), age }
}
// &selfを取るのでメソッド(インスタンスに対して呼ぶ)
fn speak(&self) -> String {
format!("{}({}歳)が鳴きました", self.name, self.age)
}
}
enum Shape {
Circle(f64), // 半径を持つ
Rectangle(f64, f64), // 幅・高さを持つ
}
fn area(shape: &Shape) -> f64 {
match shape {
Shape::Circle(r) => std::f64::consts::PI * r * r,
Shape::Rectangle(w, h) => w * h,
}
}
fn main() {
let dog = Animal::new("ポチ", 3);
println!("{}", dog.speak());
println!("面積: {:.2}", area(&Shape::Circle(2.0)));
}
10. トレイト(trait)とジェネリクス
traitは他言語のインターフェースに相当し、複数の型に共通の振る舞い(メソッド)を定義します。ジェネリクス(<T>)を使うと、型を抽象化して同じロジックを複数の型に適用できます。T: Speakerのような記法はトレイト境界(trait bound)と呼ばれ、「Tはこのトレイトを実装している型に限る」という制約です。
trait Speaker {
fn speak(&self) -> String;
}
struct Cat { name: String }
impl Speaker for Cat {
fn speak(&self) -> String {
format!("{}: ニャー", self.name)
}
}
// トレイト境界を持つジェネリック関数:Speakerを実装した型なら何でも受け取れる
fn announce(s: &T) {
println!("{}", s.speak());
}
fn main() {
let tama = Cat { name: String::from("タマ") };
announce(&tama);
}
11. Option型・Result型によるエラー処理
Rustにはnullが存在しません。値が「あるかもしれないし、ないかもしれない」ことはOption<T>(Some(T) / None)で表現し、処理の「成功か失敗か」はResult<T, E>(Ok(T) / Err(E))で表現します。コンパイラがmatchなどでの網羅的な処理を強制するため、null参照エラーや例外の握りつぶしをコンパイル時に防げます。
fn find_even(numbers: &[i32]) -> Option {
for &n in numbers {
if n % 2 == 0 {
return Some(n);
}
}
None
}
fn divide(a: i32, b: i32) -> Result {
if b == 0 {
return Err(String::from("ゼロ除算はできません"));
}
Ok(a / b)
}
fn main() {
match find_even(&[1, 3, 4, 5]) {
Some(n) => println!("最初の偶数: {}", n),
None => println!("偶数が見つかりません"),
}
match divide(10, 0) {
Ok(result) => println!("結果: {}", result),
Err(e) => println!("エラー: {}", e),
}
// ?演算子:Errなら即座にその関数からErrとして返す(エラー伝播)
fn calc() -> Result {
let a = divide(10, 2)?;
let b = divide(a, 0)?; // ここでErrになりcalc全体がErrを返す
Ok(b)
}
println!("{:?}", calc());
}
12. コレクション:VecとHashMap
Vec<T>は可変長配列、HashMap<K, V>はキーと値のペアを保持するハッシュテーブルです。どちらもヒープにデータを確保し、所有権システムに従って安全に扱えます。
use std::collections::HashMap;
fn main() {
let mut numbers: Vec = Vec::new();
numbers.push(1);
numbers.push(2);
numbers.push(3);
let sum: i32 = numbers.iter().sum();
println!("合計: {}", sum);
let mut scores: HashMap = HashMap::new();
scores.insert(String::from("math"), 90);
scores.insert(String::from("english"), 80);
if let Some(score) = scores.get("math") {
println!("math: {}", score);
}
for (subject, point) in &scores {
println!("{}: {}", subject, point);
}
}
13. データ構造の実装:スタック(Stack)
スタックはLIFO(Last In First Out:後入れ先出し)のデータ構造です。RustではVecをそのまま使うことで、所有権を意識した安全なスタックを簡潔に実装できます。pop()がOption<T>を返す点が、値の所有権を呼び出し元に安全に渡す仕組みになっています。
struct Stack {
items: Vec,
}
impl Stack {
fn new() -> Self {
Stack { items: Vec::new() }
}
fn push(&mut self, item: T) {
self.items.push(item); // 計算量 O(1)(ならし計算量)
}
fn pop(&mut self) -> Option {
self.items.pop() // 所有権ごと呼び出し元に返す。空ならNone
}
fn peek(&self) -> Option<&T> {
self.items.last()
}
fn is_empty(&self) -> bool {
self.items.is_empty()
}
}
fn main() {
let mut stack: Stack = Stack::new();
stack.push(1);
stack.push(2);
stack.push(3);
while let Some(top) = stack.pop() {
println!("pop: {}", top); // 3, 2, 1の順に出力される
}
}
14. アルゴリズムと計算量(Big-O):二分探索
線形探索は先頭から順に比較するため計算量はO(n)です。ソート済み配列に対しては、探索範囲を毎回半分に絞り込む二分探索(binary search)が使え、計算量はO(log n)と非常に高速です。データ件数が増えるほど二分探索の優位性が大きくなります。
// ソート済みスライスに対する二分探索。計算量 O(log n)
fn binary_search(arr: &[i32], target: i32) -> Option {
let mut low = 0;
let mut high = arr.len();
while low < high {
let mid = low + (high - low) / 2; // オーバーフローを避ける書き方
if arr[mid] == target {
return Some(mid);
} else if arr[mid] < target {
low = mid + 1;
} else {
high = mid;
}
}
None // 見つからなかった
}
fn main() {
let data = [1, 3, 5, 7, 9, 11, 13];
match binary_search(&data, 7) {
Some(idx) => println!("見つかりました: index={}", idx),
None => println!("見つかりませんでした"),
}
}
15. 並行処理の基礎(スレッド・Mutex・Arc)
std::thread::spawnでOSスレッドを起動できます。複数スレッドで同じデータを安全に共有するには、排他制御を行うMutex<T>と、スレッド間で所有権を共有するための参照カウント型Arc<T>(Atomic Reference Counted)を組み合わせます。所有権システムのおかげで、コンパイラがデータ競合をコンパイル時に検出してくれます。
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0)); // Arcで共有し、Mutexで排他制御
let mut handles = vec![];
for _ in 0..5 {
let counter = Arc::clone(&counter); // 参照カウントを増やして共有
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap(); // ロックを取得
*num += 1;
}); // ここでロックが自動的に解放される
handles.push(handle);
}
for handle in handles {
handle.join().unwrap(); // 全スレッドの終了を待つ
}
println!("合計: {}", *counter.lock().unwrap()); // 5
}
16. パッケージ管理(Cargo)とメモリ安全性のまとめ
Rustの標準ビルドツール兼パッケージマネージャがCargoです。依存クレート(ライブラリ)はCargo.tomlに記述し、cargo buildでビルド、cargo runで実行、cargo testでテスト実行、cargo build --releaseで最適化ビルドを行います。
Rustはガベージコレクタ(GC)を持たないにもかかわらず、次の仕組みでメモリ安全性を実現します。
- 所有権:値の所有者は常に1つで、スコープを抜けると自動的に解放される(RAII)。
- 借用チェッカー:コンパイル時に参照の有効期間(ライフタイム)と可変・不変の同時アクセスを検証し、データ競合やダングリング参照を排除する。
- ムーブセマンティクス:代入時に所有権が移動するため、二重解放(double free)が起こらない。
[package]
name = "my_app"
version = "0.1.0"
edition = "2021"
[dependencies]
rand = "0.8"
処理速度改善のポイント
Rustはゼロコスト抽象化を掲げ、C/C++に匹敵する速度を目指せる言語ですが、書き方次第でさらに差が出ます。
- リリースビルドでコンパイルする:
cargo build --releaseを使うと最適化が有効になり、デバッグビルドより大幅に高速化されます。 - ゼロコスト抽象化を活かす:トレイトやジェネリクス、イテレータはコンパイル時に具体的な型へ単相化(モノモーフィゼーション)されるため、抽象化しても実行時コストがほぼゼロです。
- 不要なclone()を避ける:所有権や借用を意識して設計し、値のコピーを最小限に抑えましょう。
- Vecの容量を事前確保する:
Vec::with_capacity()を使うと、要素追加時の再確保(メモリコピー)を減らせます。 - イテレータチェーンを積極的に使う:
mapやfilterのチェーンはコンパイラが最適化しやすく、手書きループと同等かそれ以上の速度になることが多いです。 - Rc/Arcは必要な場合のみ使う:参照カウントには実行時コストがあるため、所有権設計で回避できる場合は避けましょう。
- 並列処理でマルチコアを活用する:rayonクレートやstd::threadを使い、計算量の大きい処理を複数コアに分散できます。
- ベンチマークで計測してから最適化する:criterionクレートなどで実測し、推測ではなくデータに基づいてボトルネックを特定しましょう。