Rustの配列型(Array)を完全ガイド!固定長データ構造の特徴と使いどころを徹底解説
生徒
「先生、Rustの勉強をしていると『配列(Array)』と『ベクタ(Vector)』という2つのリストっぽいものが出てきて混乱しています。どちらもデータを並べるものですよね?なぜ使い分ける必要があるんですか?」
先生
「それはとても鋭い視点ですね!実はその2つは、データが『メモリのどこに置かれるか』と『長さが変わるかどうか』という点で決定的に違うんです。」
生徒
「長さが変わるかどうか……ですか?普通の配列って、後から要素を追加したり削除したりできないんですか?」
先生
「はい、Rustの『配列』は固定長といって、一度決めたらサイズを変更できません。その代わり、コンピュータにとって扱いやすい『スタック領域』を使うため、非常に高速でメモリ安全なんです。今日はその配列型の仕組みと、プロが実践する使いどころを詳しく見ていきましょう!」
1. Rustにおける配列型(Array)とは何か?
Rustにおける配列(Array)とは、「同じ型のデータ」を「固定された長さ(要素数)」でメモリ上に連続して並べたデータ構造のことを指します。他のプログラミング言語、例えばPythonのリストやJavaScriptの配列とは異なり、Rustの基本の配列は、宣言した後に要素を増やしたり減らしたりすることが一切できません。
「不便ではないか?」と思われるかもしれませんが、これには大きなメリットがあります。サイズが固定されているため、Rustのコンパイラは実行時にメモリをどれだけ確保すればよいかを事前に正確に知ることができます。これにより、動的なメモリ割り当て(ヒープ領域へのアクセス)を行わず、高速なスタック領域にデータを配置することが可能になります。
Rustで配列の型は[T; N]という形式で表現されます。ここでTは要素の型、Nは要素数を表します。例えば、32ビット整数が5個入る配列の型は[i32; 5]となります。重要なのは、「要素数も型の一部である」という点です。つまり、[i32; 5]と[i32; 6]はRustにおいて全く別の型として扱われます。この厳密さが、Rustの堅牢なメモリ安全性を支える基盤の一つとなっているのです。
2. 配列の宣言と初期化の基本構文
それでは、実際にRustで配列をどのように宣言し、初期化するのかを見ていきましょう。最も直感的な方法は、角括弧[]の中にカンマ区切りで値を並べる方法です。Rustには強力な型推論があるため、明示的に型を書かなくてもコンパイラが自動的に判断してくれますが、学習のために型注釈を書いてみるのも良いでしょう。
また、すべての要素を同じ値で初期化したい場合(例えば、0で埋め尽くされたバッファを作りたい場合など)には、省略記法が用意されています。これは[初期値; 個数]という構文を使います。この機能は、画像処理や数値計算などで初期化された領域を確保する際に非常に便利です。
以下のサンプルコードで、基本的な宣言方法と、異なる型を持つ配列の作り方を確認してください。
fn main() {
// 1. カンマ区切りで初期化(型推論にお任せ)
let numbers = [1, 2, 3, 4, 5];
// 2. 明示的に型と要素数を指定して初期化
// i32型が5個入る配列であることを明示
let explicit_numbers: [i32; 5] = [10, 20, 30, 40, 50];
// 3. 特定の値で埋めて初期化(同じ値を繰り返す)
// "Zero" という文字列スライスを3回繰り返す
let repeated = ["Zero"; 3];
println!("カンマ区切り: {:?}", numbers);
println!("型指定あり: {:?}", explicit_numbers);
println!("繰り返し初期化: {:?}", repeated);
}
カンマ区切り: [1, 2, 3, 4, 5]
型指定あり: [10, 20, 30, 40, 50]
繰り返し初期化: ["Zero", "Zero", "Zero"]
このコードを実行すると、それぞれの方法で作成された配列の中身が表示されます。println!マクロの中で{:?}というフォーマット指定子を使っていることに注目してください。配列のような複合データ型を表示する場合、通常の{}ではなく、デバッグ用フォーマットである{:?}を使う必要があります。
3. 要素へのアクセスとメモリ安全性
配列内の特定のデータを取り出したり変更したりする場合は、インデックス(添字)を使用します。多くのプログラミング言語と同様に、Rustの配列インデックスも0から始まります。つまり、最初の要素にアクセスするには[0]を指定し、5番目の要素には[4]を指定します。
ここでRustの「メモリ安全性」が光るポイントがあります。それは境界値チェック(Bounds Checking)です。もし、要素数が5個しかない配列に対してindex[10]のように範囲外のアクセスを試みた場合、C言語などでは無関係なメモリ領域を読み取ってしまい、予期せぬバグやセキュリティホールの原因になることがありました。
しかし、Rustでは実行時にインデックスが配列の長さの範囲内に収まっているかを厳密にチェックします。もし範囲外であれば、プログラムは即座にパニック(panic)を起こして安全に停止します。これにより、バッファオーバーフローなどの致命的な脆弱性を防ぐことができるのです。
fn main() {
// mutをつけることで、後から内容を変更可能にする(可変配列)
let mut buffer = [0, 0, 0];
// インデックスを使って値を書き換える
buffer[0] = 100;
buffer[1] = 200;
buffer[2] = 300;
println!("1番目の要素: {}", buffer[0]);
println!("2番目の要素: {}", buffer[1]);
// 注意:以下のコードのコメントアウトを外すと、
// 実行時に 'index out of bounds' というパニック(エラー)が発生します。
// let overflow = buffer[10];
}
1番目の要素: 100
2番目の要素: 200
4. スタック領域とヒープ領域の違い
Rustの配列を理解する上で避けて通れないのが、メモリの「スタック(Stack)」と「ヒープ(Heap)」という概念です。初心者のうちは難しく感じるかもしれませんが、ここを理解するとRustのパフォーマンスの良さが分かります。
Rustの配列(固定長)は、基本的にスタック領域に確保されます。スタックは、本を積み上げるようにデータを管理する場所で、メモリの確保と解放が非常に高速に行われます。配列のサイズがコンパイル時(プログラムを作る段階)で決まっているからこそ、Rustは迷うことなくこの高速なスタック領域を使うことができるのです。
一方で、サイズが可変な「ベクタ(Vector)」型は、ヒープ領域を使用します。ヒープは広大なメモリ空間から空いている場所を探して使うため、柔軟性がありますが、スタックに比べるとアクセスや管理にわずかながらオーバーヘッド(処理の遅れ)が生じます。「サイズが決まっているなら配列を使う」ことで、このオーバーヘッドを回避し、最高速のプログラムを書くことができるのがRustの配列の強みです。
5. 配列の要素を繰り返し処理する
配列に入っているすべてのデータに対して順番に処理を行いたい場合、forループを使用するのが一般的です。Rustのループ処理は非常に強力かつ安全で、イテレータという仕組みを通じて効率的にデータにアクセスします。
配列をループで回す際、そのままだと配列の所有権が移動してしまうことがありますが(要素の型によります)、基本的には参照(&)を使って、配列を借用する形でループを回すことが多いでしょう。また、iter()メソッドを使うことで明示的にイテレータを取得することも可能です。
以下のコードは、配列内の数値を合計したり、インデックス付きで処理したりする例です。
fn main() {
let scores = [70, 85, 90, 60, 100];
let mut sum = 0;
// 配列の要素を順番に取り出して加算
// &scores とすることで、配列のデータを「借用」して参照する
for score in &scores {
sum += score;
}
println!("合計点数: {}", sum);
// インデックス番号も一緒に欲しい場合は .iter().enumerate() を使う
for (index, score) in scores.iter().enumerate() {
println!("生徒ID {} の点数: {}", index, score);
}
}
合計点数: 405
生徒ID 0 の点数: 70
生徒ID 1 の点数: 85
生徒ID 2 の点数: 90
生徒ID 3 の点数: 60
生徒ID 4 の点数: 100
6. 実践的な使いどころ:配列 vs ベクタ
初心者の方が最も迷うのが、「いつ配列を使い、いつベクタを使うべきか」という点です。基本的には、「要素数が後から変わる可能性があるならベクタ(Vec)、絶対に変わらないなら配列」というルールで問題ありませんが、もう少し踏み込んで具体的なシチュエーションを考えてみましょう。
配列型が適している場面:
- 固定長のバッファ:通信データのパケット処理や、ファイル読み込み時の一時的なデータ置き場として、例えば「常に1024バイト読み込む」といったケース。
- 座標や行列:2次元座標(x, y)や3次元座標(x, y, z)、あるいは変換行列のように、要素数が物理的・数学的に決まっている場合。
[f64; 3]などがよく使われます。 - ルックアップテーブル:月の日数(
[31, 28, 31, ...])や、特定のIDに対応する固定の設定値など、プログラム実行中に増減しない参照用データ。 - 組み込み開発:メモリ(RAM)が非常に少ない環境では、ヒープ割り当てを行わない配列が重宝されます。
ベクタ型が適している場面:
- ユーザー入力の保存:ユーザーが何件データを入力するか分からない場合。
- 動的なリスト:ToDoリストのタスク管理や、ログデータの蓄積など、実行してみないと数が確定しないものすべて。
7. スライス(Slice)との関係
配列を扱う上で切っても切り離せないのが「スライス(Slice)」という概念です。スライスは、配列全体、あるいは配列の一部分を切り取って参照するための「窓」のようなものです。型は&[T]と表記されます。
なぜスライスが重要かというと、関数に配列を渡す際に非常に便利だからです。例えば、[i32; 5]型の配列を受け取る関数を作ってしまうと、[i32; 10]型の配列を渡すことができません(型が違うため)。しかし、引数をスライス型&[i32]にしておけば、要素数が5個だろうが100個だろうが、どんな長さの配列でも受け取ることができる汎用的な関数を作ることができます。
Rustの標準ライブラリにある多くの関数は、配列そのものではなく、このスライスを受け取るように設計されています。これにより、配列(スタック)であってもベクタ(ヒープ)であっても、同じように処理できる柔軟性を獲得しているのです。
// どんな長さの配列(スライス)でも受け取れる関数
fn analyze_data(data: &[i32]) {
println!("データの個数: {}", data.len());
if let Some(first) = data.first() {
println!("最初のデータ: {}", first);
}
}
fn main() {
let small_array = [1, 2, 3];
let large_array = [10, 20, 30, 40, 50];
// 配列全体をスライスとして渡す(&をつける)
analyze_data(&small_array);
analyze_data(&large_array);
// 配列の一部だけを切り取って渡すことも可能(1番目から3番目の手前まで)
println!("--- 部分スライス ---");
analyze_data(&large_array[1..3]);
}
データの個数: 3
最初のデータ: 1
データの個数: 5
最初のデータ: 10
--- 部分スライス ---
データの個数: 2
最初のデータ: 20
8. 多次元配列の活用
最後に、配列の中に配列を入れる「多次元配列」について触れておきましょう。画像処理やゲーム開発、科学技術計算の分野では、データを2次元の表(グリッド)や3次元の空間として扱いたい場面が多々あります。
Rustでは[[T; N]; M]のように記述することで、2次元配列を作成できます。例えば、チェスボードのような8x8のマス目を表現したり、RGBカラー情報を持つピクセルの集合を表現したりするのに適しています。メモリ上ではこれらは一直線に並んで配置されるため、キャッシュ効率が良く、高速なアクセスが期待できます。
ただし、多次元配列はサイズが大きくなりがちです。スタック領域にはサイズの上限(環境によりますが、数メガバイト程度)があるため、あまりに巨大な多次元配列(例えば1000x1000の画像データなど)を関数内で宣言すると、スタックオーバーフローを起こしてプログラムが強制終了する可能性があります。そのような巨大なデータを扱う場合は、迷わずVec(ベクタ)やBoxを使ってヒープ領域にデータを逃がすように設計しましょう。