1次元データから多次元データへ
Note
本節のポイント
- Rust標準の配列 (
[T; N])、スライス (&[T])、ベクタ (Vec<T>) を多次元データの入口として整理する。- 1次元の連続データから、ベクトル、行列、格子データへ進む考え方を確認する。
- 次節で扱うmemory layoutや
ndarrayの必要性を理解する。Rustのこれらコレクション型に習熟している方は、本節を読み飛ばして次節の「多次元配列とメモリレイアウト」に進んでも構いません。
物理シミュレーションやデータ解析では、個々の数値を扱うだけでなく、多数の数値をひとまとめにしてベクトルや行列として扱う場面が頻繁に現れます。例えば、3次元空間における粒子の位置や速度、あるいは観測データの時系列などがそれに当たります。
Rustの標準ライブラリは、このようなデータの集合を扱うための基本的なツールを提供しています。本節では、その中でも特に重要な配列 ([T; N])、スライス (&[T])、そしてベクタ (Vec<T>) の3つを取り上げ、その特徴と基本的な使い方、そして数値計算における使い分けについて解説します。
これらの基本的なデータ構造を理解することは、より高度な科学技術計算ライブラリ(例えば後の節で学ぶndarray)を効果的に利用するための土台となります。
配列 [T; N]
配列は、最も基本的なコレクション型です。[T; N]という型で表され、Tは要素の型、Nは要素数を意味します。
特徴
- 固定長: 配列の長さ(要素数)はコンパイル時に決定され、後から変更することはできません。
- スタック確保: 配列のデータは、通常スタック上にメモリが確保されます。これにより、ヒープ割り当てのオーバーヘッドがなく、非常に高速にアクセスできます。
数値計算の文脈では、3次元ベクトルや4元数(クォータニオン)のように、コンパイル時に次元数が確定している小さなデータ構造を表現するのに適しています。
基本的な使い方
配列の宣言と初期化は、以下のように行います。
// 3次元の位置ベクトルを表す配列
let position: [f64; 3] = [1.0, 2.0, 3.0];
// すべての要素を0.0で初期化
let velocity: [f64; 3] = [0.0; 3];
// 要素へのアクセス
println!("x-coordinate: {}", position[0]);
println!("Initial velocity: {:?}", velocity);
// forループによるイテレーション
for component in &position {
println!("{}", component);
}
長所と短所
- 長所: 高速なアクセス。コンパイル時のサイズチェックによる安全性。
- 短所: サイズが固定されているため、実行時に要素数を変更したい場合には不向きです。
スライス &[T]
スライスは、それ自体がデータを所有するのではなく、配列やVecといった他のコレクション型の一部または全体を「参照」するための型です。この「参照する」という性質がスライスの最も重要な特徴です。
特徴
- 所有権を持たない (Borrowing): スライスはデータの所有権を持たず、データを借用(borrow)します。これにより、大きなデータブロックをコピーすることなく、関数にデータの部分集合を効率的に渡すことができます。
- 動的な長さ: スライスが参照する範囲の長さはコンパイル時には決まっておらず、実行時に決まります。
スライスは、あるデータ集合に対して操作を行う関数を定義する際に、非常に柔軟で効率的なインターフェースを提供します。
基本的な使い方
スライスは、配列やVecから簡単に作成できます。
fn process_data(data: &[f64]) {
// dataスライス内のすべての要素の平均値を計算する
let sum: f64 = data.iter().sum();
let count = data.len();
if count > 0 {
println!("Average: {}", sum / count as f64);
}
}
fn main() {
let array: [f64; 5] = [1.0, 2.0, 3.0, 4.0, 5.0];
let vector: Vec<f64> = vec![10.0, 20.0, 30.0];
// 配列全体をスライスとして渡す
process_data(&array);
// 配列の一部をスライスとして渡す
process_data(&array[1..4]); // インデックス1から3までの要素 ([2.0, 3.0, 4.0])
// Vec全体をスライスとして渡す
process_data(&vector);
}
process_data関数は、引数として&[f64](f64のスライス)を取ります。これにより、この関数は配列からもVecからも、その一部からでもデータを受け取ることができます。これは非常に強力な抽象化です。
また、&mut [T]を用いることで、スライスを通じて元のデータを変更することも可能です。
fn scale(data: &mut [f64], factor: f64) {
for element in data {
*element *= factor;
}
}
fn main() {
let mut values = vec![1.0, 2.0, 3.0];
scale(&mut values, 2.0);
println!("{:?}", values); // [2.0, 4.0, 6.0]
}
長所と短所
- 長所: 配列と
Vecの両方を同じインターフェースで扱える汎用性。データをコピーせずに部分参照が可能。 - 短所: 参照先のライフタイムを考慮する必要がある。所有権を持たないため、データを長期間保持する用途には使えない。
ベクタ Vec<T>
ベクタは、可変長のコレクション型です。データの実体はヒープ上に確保され、実行時に要素を追加したり削除したりすることができます。
特徴
- 可変長:
pushメソッドで要素を追加したり、popメソッドで末尾の要素を削除したりと、サイズを自由に変更できます。 - ヒープ確保: データがヒープ上に存在するため、コンパイル時にサイズが不明なデータを扱うことができます。
数値計算では、シミュレーションの各ステップで得られる結果を時系列データとして記録していく場合など、実行時に要素数が動的に変化する状況で非常に役立ちます。
基本的な使い方
// 空のベクタを作成
let mut time_series: Vec<f64> = Vec::new();
// vec!マクロで初期値を指定して作成
let positions = vec![[0.0, 0.0], [1.0, 1.0]];
println!("Initial positions: {:?}", positions);
// あらかじめ容量を確保しておくと再割り当てを回避できる
let results: Vec<f64> = Vec::with_capacity(1000);
// push: 要素を追加
time_series.push(0.0);
time_series.push(0.1);
time_series.push(0.2);
// 要素へのアクセス
println!("Latest data point: {}", time_series[2]);
// forループによるイテレーション
for data_point in &time_series {
println!("{}", data_point);
}
println!("Number of data points: {}", time_series.len());
println!("Results capacity: {}", results.capacity());
Tip
境界チェックについて
Rustでは、配列や
Vecへのインデックスアクセス(例:time_series[0])は実行時に境界チェックが行われます。範囲外のインデックスにアクセスしようとすると、プログラムはパニックして停止します。これは、C/C++で起こりうるバッファオーバーフローのような未定義動作を防ぐための安全機構です。
長所と短所
- 長所: 実行時にサイズを柔軟に変更できる高い柔軟性。
- 短所: ヒープ割り当てに伴うわずかなパフォーマンス上のオーバーヘッド。要素追加時に容量が不足すると、より大きなメモリ領域を確保してデータをコピーする「再割り当て(reallocation)」が発生する可能性があります。あらかじめ要素数の上限が分かっている場合は、
Vec::with_capacityで容量を確保しておくことで再割り当てを回避できます。
まとめと使い分け
これら3つのデータ構造の特性と、数値計算における主な用途をまとめます。
| 特性 | 配列 [T; N] | スライス &[T] | Vec<T> |
|---|---|---|---|
| サイズ | 固定長(コンパイル時) | 動的長 | 可変長(実行時) |
| メモリ | スタック | (参照先による) | ヒープ |
| 所有権 | あり | なし(借用) | あり |
| 主な用途 | 3次元ベクトルなど、サイズがコンパイル時に確定しているデータ。 | データ集合を所有権を移さずに扱う関数インターフェース。 | シミュレーション結果の記録など、実行時にサイズが変わるデータ。 |
これらの使い分けを理解することは、効率的で読みやすいコードを書くための第一歩です。
- 配列
[T; N]: 扱うデータの次元や要素数が初めから分かっている場合に最適です。 Vec<T>: データの数が実行中に変わる可能性がある場合に選択します。- スライス
&[T]: 関数にデータの集合を渡す際の引数の型として、ほとんどの場合に最良の選択です。これにより、関数がより汎用的になり、配列とVecの両方で再利用できます。
次のステップへ
本節では、Rust標準ライブラリが提供する基本的なデータ構造について学びました。これらを用いることで、基本的なベクトル演算やデータ管理は可能です。
しかし、より複雑な科学技術計算、例えば行列、格子、場のデータを扱うには、これらのプリミティブな型だけでは表現が冗長になりやすく、shapeやaxisの管理も難しくなります。
そこで次節では、多次元の添字がメモリ上でどのように並ぶかを確認します。その上で、Rustの科学技術計算エコシステムにおける中核的なクレートであるndarrayへ進みます。