Rust: 低レベルデータ(全6回中1回目)
ここまで、静的保証の文脈で Rust の型システムについて議論してきました。 具体的には、可変エイリアシングの防止と UB の排除です。
しかし、日々の開発の大半においては、これらは副次的な利点だと主張することもできます。 そして、Rust の型システムの真の価値は、その表現力、つまり問題領域を柔軟な構成要素に対応付ける能力にある、と。
このような議論はすぐに主観的なものになるため、Rust については時間をかけて自分自身の意見を形成すべきです。 とはいえ、プログラミング上の問題を解く最初の一歩は、通常、処理するデータを表現することです。 そこで、Rust が提供する選択肢をいくつか見ていきます。
プリミティブ型
Rust のプリミティブ型は、あなたがよく知っているほぼどのプログラミング言語とも似ています。一般的なブール値、整数、浮動小数点数、文字、文字列などがあります。
高水準のインタープリター型言語と比べた場合の重要な違いの1つは、整数と浮動小数点数が固定幅であることです。 これは高性能なシステム言語の特徴であり、個々の数値は(Python のように)ヒープメモリ上の構造体としてではなく、(C のように)CPU レジスタに格納される必要があります。
このハードウェアレベルの関心事には、境界のある範囲とホスト固有の幅という、2つの重要な含意があります。
1) 境界のある数値範囲
Rust には12個のプリミティブな数値型があります。
-
符号なし整数型が5つ:
u8、u16、u32、u64、u128、およびusize。 -
符号付き整数型が5つ:
i8、i16、i32、i64、i128、およびisize。 -
IEEE 準拠の浮動小数点数が2つ:
f32(少なくとも10進6桁の精度)とf64(少なくとも10進15桁)。
型名の接尾辞はビット幅を示します。たとえば u128 は幅が128ビット(16バイト)です。
重要な含意は次のとおりです。ある整数型が表現できる値の範囲は有限です。
上限と下限は、符号の有無と幅の両方によって決まります。
以下の表を見てください(網羅的ではありません)。
| 型 | 幅 | 下限 | 上限 |
|---|---|---|---|
| u8 | 1バイト | 0 | 255 |
| i8 | 1バイト | -128 | 127 |
| u32 | 4バイト | 0 | 4,294,967,295 |
| i64 | 8バイト | -263 | 263-1 |
Rust の標準ライブラリは、上限と下限のための便利な制限定数を提供しているため、これらの範囲をそらで覚えたり、網羅的な表を参照したりする必要はありません。
assert_eq!(0, u8::MIN);
assert_eq!(255, u8::MAX);
型の範囲を超えると「ラップアラウンド」が発生します。
まれに、それが望ましい動作である場合もあります。
私たちは RC4 暗号の実装時に、剰余算術をシミュレートするため wrapping_add を慎重かつ意図的に使用しました。
これがどのように機能するかを示すために、u8 の上限である 255 を超えた場合に何が起こるかを考えてみましょう。
let x: u8 = 200;
let y: u8 = 100;
assert_eq!(x.wrapping_add(y), 44);
44 は 300 % 256、つまり全体を範囲サイズで割った余りです。
暗号の文脈以外では、サイレントなラップアラウンドは 整数オーバーフロー のバグと見なされます。
もし 200 が銀行口座のドル残高を表し、口座の所有者がさらに 100 ドルを預け入れたなら、レシートに 44 の口座残高が表示されていて驚くことでしょう!
ここで、Rust におけるいくつかの微妙な点に踏み込みます。
assert_eq!(x.wrapping_add(y), 44); の代わりに assert_eq!(x + y, 44); と書いていた場合、プログラムはオーバーフローを警告するエラーを吐き出していたでしょう。
error: this arithmetic operation will overflow
--> src/main.rs:8:12
|
8 | assert_eq!(x + y, 44);
| ^^^^^ attempt to compute `200_u8 + 100_u8`, which would overflow
|
= note: `#[deny(arithmetic_overflow)]` on by default
ここでは、x と y の両方が定数であるため、オーバーフローをコンパイル時に検出できたという意味で運が良かったのです。
Rust は、値が事前には分からない変数については、オーバーフローを捕捉するために任意のランタイムチェックを使用します。この話題には、安全性について詳しく議論する第4章で戻ってきます。
Rust の整数オーバーフローについて、もう1つ覚えておくべき詳細があります。C/C++ とは異なり、それは UB の潜在的な原因ではありません。 ラップアラウンドの規則は明確に規定されており、ターゲットプラットフォーム全体で普遍的です1。
2) ホスト固有の整数
usize 型と isize 型は、それぞれ符号なし整数と符号付き整数ですが、対応する他の型のようにビット幅を指定していないことに気づいたでしょう。
それは、それらのサイズがプログラムのコンパイル先となる特定のマシンに依存するためです。
どちらも、32ビットシステム向けにコンパイルする場合は4バイト長であり、現代的な64ビットシステムでは8バイト長です。 理論上は、128ビットシステムであれば16バイト長にもなり得ますが、商用プロセッサで128ビットアーキテクチャを使用しているものはありません。
範囲とオーバーフローについて述べたことを踏まえると、マシン依存(別名ホスト固有)の型は曖昧だと感じるかもしれません。 危険ですらあるかもしれません。 MISRA によれば、その認識は正しいです。
[RR, Directive 4.6] サイズと符号の有無が明示された数値型を使用する2
Rust では可能な場合に明示的な数値型を使用できますが、インデックス指定は例外です。コレクションのインデックス指定には usize 型が必要です(たとえば my_vec[i] = j では、i は usize でなければなりません)。
これは、内部的にはコンテナーへのインデックス指定にメモリアドレスの計算が関わることが多いためです3。
そして、アドレスの幅はターゲットマシンに依存します。
現在の Rust では、u64 のような明示的な数値型から usize へキャストできます。
おそらく、上記の MISRA ルールの精神に従うために、インデックス指定の前にこの操作を行う必要があるでしょう。
数値型間のキャストは、Rust が型キャストを許可するごく少数のケースの1つです。 これは別のルールにも役立ちます。
[AR, Rule 11.3] ある型への参照から別の型への参照へキャストしてはならない2
Rust では、トレイト という概念を介して、型間(参照間ではありません)の安全で明示的な変換を許可しています。具体的には、From4 と Into5 と呼ばれるトレイトです。
次のセクションでトレイトを説明し、後の章で From を使用します。
型推論
Rust は強く静的に型付けされます。 すべての値はコンパイル時に既知の型を持ちます。 ジェネリックパラメーターでさえそうです。その最終的な型はコンパイル中に決定されます(これについては後で詳しく説明します)。
古い静的型付け言語とは異なり、Rust は特定の場合に式の型を自動的に検出するために 型推論6 を使用します。経験則として、型注釈を明示的に書き出すことは次のとおりです。
-
関数シグネチャ(例: パラメーターや戻り値の型)、グローバル変数、またはエクスポートされる型(例: ライブラリの公開 API の一部)では、常に必須です。
-
関数本体の中では、ときどき必須です。
次の例を考えてみましょう。
#![feature(type_name_of_val)]
use std::any::type_name_of_val;
fn sum(x: u128, y: u128) -> u128 {
x + y
}
fn main() {
let a = 1;
let b = 3;
let c = sum(a, b);
println!("a is a {} with value {:?}", type_name_of_val(&a), a);
println!("b is a {} with value {:?}", type_name_of_val(&b), b);
println!("c is a {} with value {:?}", type_name_of_val(&c), c);
let mut list = Vec::new();
list.push(a);
list.push(b);
list.push(c);
println!("list is a {} with value {:?}", type_name_of_val(&list), list);
}
このスニペットは次のように出力します。
a is a u128 with value 1
b is a u128 with value 3
c is a u128 with value 4
list is a alloc::vec::Vec<u128> with value [1, 3, 4]
ここでは自動推論が 2 回発生しています。
まず、プリミティブ型が関数シグネチャから推論されました。
もし関数 sum がプログラムの一部でなかったなら、let a = 1; は let a: i32 = 1; と等価になります。
4 バイトの符号付き整数である i32 は、Rust のデフォルト整数型です。
しかし、let c = sum(a, b) という行があるため、コンパイラーは a が実際には 16 バイトの符号なし整数である u128 だと判断しました。
次に、動的コレクションの型が、格納された要素の型から推論されました。 以下の 3 つの文はすべて等価です。
let mut list = Vec::new();- 推論された型(上記と同様)。let mut list: Vec<u128> = Vec::new();- 明示的な型注釈。let mut list = Vec::<u128>::new();- 明示的なコンストラクター。
この便利な推論による短縮記法を使えたのは、サンプルプログラムに少なくとも 1 つの list.push() 文があったからです。
コンパイラーはベクターにプッシュされる要素の型、この場合は u128 整数を見て、ベクターの型を決定しました。
異種コレクションについてはどうでしょうか?
変化はあるものの論理的に関連する型の要素をベクターに格納したい場合、型推論に頼ることはできません。
dynキーワードと「トレイトオブジェクト」と呼ばれるものを明示的に使う必要があります。 これは、この本で必要になったり扱ったりする言語機能ではありません。
タプル vs. 配列
Rust は、順序付きで固定サイズの値のシーケンスを表す方法として、タプルと配列の 2 つを提供します。
-
タプルは、異なる型の複数の値をグループ化できますが、定数でしかインデックス指定できません。
-
配列は、同じ型の複数の値だけをグループ化できますが、変数でインデックス指定できます。
タプル
どちらをいつ使うべきかについて厳密な規則はありませんが、タプルは戻り値の型として特に便利です。 関数が複数の値を返すべき場合に使います。
[少し不自然な] 例: 最短辺に基づいて 30-60-90 三角形(特殊な「直角三角形」7)の各辺の長さを計算する必要があるとします。 既知の公式があります。
// 辺の比率は 1 : 2 : square_root(3)
fn compute_30_60_90_tri_side_len(short_side: f64) -> (f64, f64, f64) {
(
short_side,
short_side * 2.0,
short_side * 3_f64.sqrt() // "_f64" は省略可能な型接尾辞構文
)
}
fn main() {
let tri_sides = compute_30_60_90_tri_side_len(10.0);
// タプルの定数インデックス指定
assert_eq!(tri_sides.0, 10.0);
assert_eq!(tri_sides.1, 20.0);
assert_eq!(tri_sides.2, 17.32050807568877);
// タプルの分配束縛
let (a, b, c) = compute_30_60_90_tri_side_len(10.0);
assert_eq!(a, 10.0);
assert_eq!(b, 20.0);
assert_eq!(c, 17.32050807568877);
}
関数 compute_30_60_90_tri_side_len は 3 つの値、つまり三角形の 3 辺の長さを返します。
この関数を最初に呼び出したとき、変数 tri_sides に推論される型は (f64, f64, f64) です。
各浮動小数点数には定数位置でアクセスできますが、変数ではアクセスできません(例: tri_sides.1 は機能しますが、tri_sides.i や tri_sides[i] は機能しません)。
名前付きフィールドを持つ構造体を定義することもできましたが、タプルは簡潔な短縮記法を提供します。
そして、compute_30_60_90_tri_side_len の 2 回目の呼び出しで示している、分配束縛と呼ばれる手法で名前を設定できます。
単一のタプル変数に代入する代わりに、分配束縛を行い、各タプル要素をそれぞれ独自の名前付き変数(例: a、b、c)に代入します。
配列
配列は、他のプログラミング言語でもおそらく見たことがある汎用データ構造なので、ここでは深く掘り下げません。
Rust における配列宣言の構文は [T; N] です。
格納される各値の型は T で、N は配列の長さです。
次のように動作します。
// 明示的な配列型宣言
let numbers: [u64; 3] = [42, 1337, 0];
// 推論された配列型(`[&str; 4]`、読み取り専用文字列参照の配列)
let operating_systems = ["Linux", "FreeBSD", "Tock", "VxWorks"];
// すべての要素(1,000 個)を単一の値(0)で初期化
let mut buffer = [0; 1_000];
// インデックスベースの書き込みアクセス
for i in 0..1_000 {
assert_eq!(buffer[i], 0); // ゼロ初期化されているはず
buffer[i] = i; // 新しい値で上書き
}
assert_eq!(buffer[0], 0);
assert_eq!(buffer[1], 1);
assert_eq!(buffer[2], 2);
// イテレーターベースの書き込みアクセス
for num in buffer.iter_mut() {
*num += 7; // "*" は書き込みのためのデリファレンス
}
assert_eq!(buffer[0], 7);
assert_eq!(buffer[1], 8);
assert_eq!(buffer[2], 9);
上記では、2 つのループを使って 1,000 要素の配列の内容を変更しています。
1 つ目は従来のインデックスベースのアクセス(例: buffer[i])を使っています。
2 つ目はイテレーター(例: buffer.iter_mut())を使って同様の操作を実行しています。
イテレーターは、map や filter のような関数型プログラミングの構成要素を可能にします。
多くの言語ではそれに性能上のペナルティが伴いますが、慣用的な Rust ではこれらの構成要素がよく使われているのを目にするでしょう。
実際にはより高速なコードにつながる可能性があるからです。
なぜでしょうか?
上記の 1 つ目のループには暗黙の契約があります。i は配列の長さより小さくなければなりません。
そうでなければ、配列の末尾を越えて範囲外書き込みを行うことになります。
安全性を確保するため、コンパイラーは 1 つ目のループに実行時の境界チェックを追加しなければなりません(ただし 2 つ目には追加しません)。
そのチェックにはコストがあります。
この章の後半でエラーハンドリングについて説明するときに、このチェックに失敗するとどうなるかを見ていきます。
配列 vs. ベクター
型推論について説明したときに要素を追加した
Vecとは異なり、配列は動的に拡張できません。 その容量は固定されています。 この制約は不便な場合がありますが、配列をポータブルにします。配列を使うために動的メモリアロケーション用のランタイムライブラリに頼る必要がありません。
Rust と C の配列における大きな違いの 1 つは、前者では長さが型の一部として明示的にエンコードされることです。 これにはいくつかの利点があり、その 1 つが次への準拠です。
[AR, Rule 17.5] 関数パラメーターとして使用される配列は、正しい数の要素を持たなければなりません2
参照
前の章では、整数をインクリメントする関数の文脈で、すでに参照を紹介しました。 参照は生ポインターに代わる現代的な手段です。
```rust,noplaypen
fn incr(a: &mut isize, b: &isize) {
*a += *b;
}
fn main() {
let mut x = 3;
let y = 5;
incr(&mut x, &y);
assert_eq!(x, 8);
assert_eq!(y, 5);
}
参照はシステムプログラミングに不可欠です。 参照は、値渡し(値全体をコピーする)の代わりに、参照渡し のセマンティクス(「ポインター」を渡す)を可能にすることを思い出してください。 このレベルの制御は不可欠であり、大きな値を高性能に操作できるようにします。 プログラマーは、いつ シャローコピー(参照のみを複製する)を行い、いつ ディープコピー(すべてのデータを複製する)を行うかを選択できます。 前者は、バイトのコピーに費やす時間が少なくなり、使用される総メモリ量も少なくなることを意味します。
この章の後半で所有権について説明するときに、参照の話題に戻ります。 所有権エラーを扱うと、Rust がこの MISRA ルールを強く推奨していることにすぐ気づくでしょう。
[AR, Rule 8.13] 参照は、可能な限り不変であるべきです2
スライス
スライスは参照と密接に関連する概念であり、これも不変と可変のバリアントがあります。
-
&[T]は、Tの不変な共有スライスです。 -
&mut [T]は、Tの可変な排他的スライスです。
どちらのスライス型も、何らかの別の、より大きな値の中に格納されている値のシーケンスへの「部分的なビュー」です。 例を使って、この文の意味を理解しましょう。
// 5項目の配列
let mut buffer_overflow_defenses = [
"stack canary",
"ASLR",
"NX bit",
"CFI",
"Intel CET",
"ARM MTE",
];
// 最初の3つの不変スライスを作成
// [..=2] は包括的な範囲記法で、[..3] と等価
let basic_defenses = &buffer_overflow_defenses[..=2];
assert_eq!(basic_defenses, &["stack canary", "ASLR", "NX bit"]);
// 最後の2つの可変スライスを作成
let advanced_defenses = &mut buffer_overflow_defenses[4..];
assert_eq!(advanced_defenses, &mut ["Intel CET", "ARM MTE"]);
// スライス経由で変更
advanced_defenses[1] = "safe Rust!";
// スライスとその「バッキングストレージ」の両方が更新されることに注目
assert_eq!(advanced_defenses, &mut ["Intel CET", "safe Rust!"]);
assert_eq!(buffer_overflow_defenses[5], "safe Rust!");
より大きなシーケンスを部分分割することは、上で示したように、スライスの便利な使い方の1つです。
前の章でも、スライス範囲記法(例: [..=2] や [3..])を見たことを思い出すかもしれません。
これは IETF テストベクトルの検証で、RC4 キーストリームから 16 バイトのチャンクを取り出すために使いました。
スライスは、イディオマティックな API を作成する際にも役立ちます。
RC4 関数(new や apply_keystream など)のパラメーターを定義するときにこのアプローチを活用しましたが、その根拠については詳しく説明しませんでした。
以下を考えてみましょう。
fn count_total_bytes(byte_slice: &[u8]) -> usize {
let mut cnt = 0;
// アンダースコアは未使用変数を示す
for _ in byte_slice {
cnt += 1;
}
// おっと - ループする必要はなかった。組み込みの長さ取得メソッドがある!
assert_eq!(cnt, byte_slice.len());
cnt
}
fn main() {
let byte_arr: [u8; 4] = [0xC, 0xA, 0xF, 0xE];
// Vec初期化の短縮記法
let mut byte_vec = vec![0xB, 0xA, 0xD];
// 動的にさらにデータをプッシュ
byte_vec.push(0xF);
byte_vec.push(0x0);
byte_vec.push(0x0);
byte_vec.push(0xD);
// どちらの型も &[u8] として借用できることに注意
assert_eq!(count_total_bytes(&byte_arr), 4);
assert_eq!(count_total_bytes(&byte_vec), 7);
}
パラメーターシグネチャでスライスを使う利点は、さまざまな種類のコレクションを スライスとして借用 できることです。 上の例では、バイトの動的ベクターとバイトの固定サイズ配列の両方で動作する関数を1つ書きました。
最後に、文字列(String 型)と文字列スライス(&str 型)の関係に触れないわけにはいきません。
このトピックを適切に議論するにはかなりの複雑さが伴い、また文字列は、この本で書くコードには特に関係しません。
私たちが構築するデータ構造はもちろん文字列を格納できますが、詳細な議論は省き、興味がある場合は公式 Rust book8 の 8.2 節「Storing UTF-8 Encoded Text with Strings」をお勧めします。
vec!マクロ上のコードには、要素のベクターを初期化するための短縮記法が含まれています。
let mut byte_vec = vec![0xB, 0xA, 0xD];は、次と等価です。let mut byte_vec = Vec::new(); byte_vec.push(0xB); byte_vec.push(0xA); byte_vec.push(0xD);実際、上の
main関数は、次のようにすればpush呼び出しを完全に避けることもできました。let mut byte_vec = vec![0xB, 0xA, 0xD, 0xF, 0x0, 0x0, 0xD];この構文は
byte_arrの初期化に似て見えるかもしれませんが、両者を混同しないでください。配列は固定容量を持つため、初期化後に新しい項目を配列へpushすることはできません。
まとめ
プリミティブ(整数に焦点を当てました)、タプル、配列、参照、スライスについて簡単に取り上げました。 そしてその過程で、型推論の感覚もつかみました。 これで、Rust でデータを表現し操作するための低レベルな手法を見てきたことになります。
細かな複雑さにさらに何十ページも費やす代わりに、この言語のより刺激的で興味深い機能、つまりより高レベルな構造を表現する方法へ進みます。
本書を読み進めながら実践経験を積むことで、これらすべてのトピックを習得していくことになります。 現在の目標は、Rust の基礎をすばやく概観することです。
-
Myths and Legends about Integer Overflow in Rust. Huon Wilson (2016). ↩
-
MISRA C: 2012 Guidelines for the use of the C language in critical systems (3rd edition). MISRA (2019). ↩ ↩2 ↩3 ↩4
-
ええ、
Vecの場合はそれが当てはまります。内部的には、Vecはヒープ上に割り当てられた配列への ファットポインター(メモリアドレス、長さ、容量)です。my_vec[i]によるインデックスアクセスでは、メモリ位置へのオフセットを計算します。しかし、自分で定義するカスタムコンテナーでは、インデックス演算子をオーバーロードすることで、そのコンテナーの文脈で論理的に意味のある任意の操作を実行できます。本書の後半で、順序付きマップとセットのために独自のインデックスアクセスロジックを実装します。 ↩ -
Trait
std::convert::From. The Rust Team (Accessed 2022). ↩ -
Trait
std::convert::Into. The Rust Team (Accessed 2022). ↩ -
Type inference. Guide to Rustc Development (Accessed 2022). Rust は Hindley-Milner 型推論アルゴリズム9を拡張したものを使用します。 ↩
-
30° - 60°- 90° Triangle. Math Open Reference (Accessed 2022). ↩
-
文字列で UTF-8 エンコードされたテキストを格納する。Steve Klabnik、Carol Nichols 著(2022年参照)。 ↩
-
Hindley–Milner type system. Wikipedia (Accessed 2022). ↩