Rust: 高レベルデータ(全6回中2回)
前のセクションでは、低レベルの基礎を見てきました。 それらは重要であり、一般的です。 しかし、Rust が本当に輝き始めるのは、より高レベルの構成要素、つまり問題領域により密接に対応する「カスタム」データ型です。
Rust は、ML、OCaml、Haskell などの関数型言語から影響を受けています1。 Rust は、興味深く、おそらくエキゾチックとも言える構成要素をいくつかもたらしています。 高性能なシステム言語ではあまり見かけない機能です。
このセクションでは、関数型言語に関する事前知識がないことを前提に、これらの構成要素のいくつかに少しずつ慣れていきます。
列挙型
列挙型、略して「enum」は、取り得る値が名前付き定数の集合である型を定義できるようにします。 最も基本的な使い方では、Rust の enum は他のほとんどの言語に存在する enum と似ています。
これからいくつかのセクションにわたって、実行例として、複数のプロセス(メモリ上に存在する、プログラムの分離されたインスタンス)を実行できるオペレーティングシステム(OS)を使います。 Rust コードの構成要素が特定の領域にどのように対応できるかを示すためです2。 そして、その過程でいくつかの OS の概念を学ぶ、または復習します。
あるプロセスは、任意の時点で次の 3 つの状態のいずれかにあると仮定しましょう。
-
Running - 現在 CPU コア上で実行中。
-
Stopped - 無期限に一時停止中(たとえば、ユーザーが
Ctrl+Zを押した可能性があります)。 -
Sleeping - 一時的に停止中(たとえば、ユーザー入力のようなデータが利用可能になるのを待っている可能性があります)。
enum は、相互に排他的でありながら関連する可能性を表現する自然な方法です。
3 つのバリアント(名前付き定数 Running、Stopped、Sleeping)を持つ State enum を宣言できます。
pub enum State {
Running,
Stopped,
Sleeping,
}
OS は、プロセスが現在どの状態にあるかに応じて異なるアクションを取る必要があります。 たとえば、内部タイマーが切れたとき(例: 「割り込みが発生する」)、現在実行中のプロセスを停止し、その状態を保存し、別のプロセスを実行または復元するタイミングかもしれません。 CPU 時間は共有リソースであり、プロセスは順番に使う必要があります。
Rust は、どのロジックを実行すべきかを条件付きで決定する手段として、パターンマッチングをサポートしています。 一般的な用途の 1 つは、enum のバリアントに対してマッチングすることです。 たとえば、OS はプロセスの状態に応じて異なる関数を実行できます。
fn manage_process(curr_state: State) {
match curr_state {
State::Running => stop_and_schedule_another_process(),
State::Stopped => assign_to_available_cpu_core(),
State::Sleeping => check_if_data_ready_and_wake_if_so(),
}
}
match の括弧内の各行はアームと呼ばれます。
パターンは矢印演算子(=>)の左側にあり、そのパターンにマッチした場合に実行されるコードは右側にあります。
制御フローを扱う次のセクションで、パターンマッチングについてさらに詳しく説明します。
Rust の enum が C、C++、その他多くの言語の enum と異なる点は、さまざまな型の追加データをカプセル化できることです。 この能力により、Rust の enum は関数型言語における「直和型」(これは「代数的データ型」の特定の一種です)に似たものになります。 実際には、これは各バリアントに任意のデータを格納できる柔軟性があることを意味します。 そのデータは、さらに別の enum であることさえあります。
より細かなプロセス状態表現に対する設計要件があったとしましょう。 具体的には、OS が次のことを行う必要があるとします。
-
2 種類の停止要求を追跡する: プロセスが無視できるものと、無視できないもの。
-
sleeping 状態のプロセスについて開始タイムスタンプを記録し、後で sleeping 状態のプロセスがどれだけ長く非アクティブだったかを計算する。
State enum を、新しい要件を反映した DetailedState に置き換えることができます。
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum StopKind {
Mandatory, // Linux SIGSTOP
Ignorable, // Linux SIGTSTP
}
pub enum DetailedState {
Running,
Stopped { reason: StopKind },
Sleeping { start_time: u64 },
}
Stopped バリアントには別の enum(StopKind - その上の #[derive(... は今は無視してください)が含まれるようになり、Sleeping バリアントには u64 タイムスタンプ(UNIX のエポック表現に似たもの3)が含まれるようになったことに注目してください。
それでも、Running バリアントは空のままです。
バリアント内にカプセル化されるデータ型は自由に選択でき、マッチング時には内部の型を「取り出す」こともできます。
以下のスニペットは、最初のアームが Stopped バリアントの内部データをチェックするテストです。
2 番目のアームはワイルドカード(_)を使用して、このテストが他のどのバリアントにもマッチしないことを表明しています(state がハードコードされているためです)。
#[test]
fn test_detailed_stop_match() {
let state = DetailedState::Stopped {
reason: StopKind::Mandatory,
};
match state {
DetailedState::Stopped { reason } => {
assert_eq!(reason, StopKind::Mandatory);
}
_ => unreachable!(), // 到達した場合、実行時にパニックする
}
}
厄介な細部が 1 つあります。enum のメモリ上のサイズは、最大のバリアントによって決まります。
Running バリアントのインスタンスは、後者の方がより多くの情報を保持しているにもかかわらず、Sleeping バリアントのインスタンスと同じサイズです。
メモリレイアウトは頻繁に考える必要があるものではありませんが、注目に値します。
私たちは高機能な直和型を使っているかもしれませんが、それでも低レベルのコードを書いているのです。
構造体
構造体、具体的には以下のような名前付きフィールドを持つ struct は、ほとんどの Rust プログラムでデータを表現する主要な方法です。 Rust の struct は、Python のクラスや Java のオブジェクトと同じ目的を果たします。つまり、データと、そのデータを操作する関数をまとめる方法です4。
OS カーネルの主な責務の 1 つはタスクスケジューリング、つまりどのプロセス(またはそのスレッド)をどの CPU コア上でどれだけの時間実行すべきかを決定することです。 多くのプログラムは複数のプロセスで構成されており、親プロセスは 1 つ以上の子プロセスを作成できます。
OS を実装しているとしたら、プロセスに関連するデータを struct にまとめたいと思うでしょう。 単純化した例5は、次のようになります。
pub struct Proc {
pid: u32, // プロセス ID(符号なし整数)
state: State, // 現在の状態(enum)
children: Vec<u32>, // 子 ID(動的リスト)
}
マルチプロセスプログラムはどのように動作するのか?
あるプログラム(親プロセス)は、2つ目の補助プログラム(子プロセス)を起動(たとえば「spawn」)できます。 補助プログラムが独立した作業を行っている場合、最新のマルチコアシステムでは、両方を同時に実行できます。 親はあるコア上で実行され、子は別のコア上で実行されます。
これが、Webブラウザーをより高速で応答性が高いように感じさせる要因です。 デフォルトでは、Chromium は接続先のWebサイトごとに1つのプロセスを実行します6。
Proc 構造体は、問題領域における概念(OSが管理するプロセスという考え方)を型付きデータとして表します。
データを扱いやすくするために、前の章で Rc4 構造体に対して行ったのと同じように、メソッド(self パラメーターを持つ)と関連関数(self パラメーターを持たない)を追加することになるでしょう。
どちらの種類の関数も、構造体の impl ブロック内で定義する必要があります。
例:
impl Proc {
/// 関連関数(コンストラクター)
pub fn new(pid: u32) -> Self {
Proc {
pid,
state: State::Stopped,
children: Vec::new()
}
}
/// メソッド(self を受け取る。この場合は可変セッター)
pub fn set_state(&mut self, new_state: State) {
self.state = new_state;
}
// ...ここにさらにメソッド/関数
}
名前付きフィールド(pid、state、children)はデフォルトで非公開であることに注意してください。
それらには、その構造体が定義されているモジュール内のコードからしかアクセスできません。
モジュールは関連するコードをまとめる方法であり、Rust における名前空間のようなものだと考えてください。
このコードが Proc をインポートした別のモジュールにある場合、非公開フィールド state に代入できないため、コンパイルされません。
use my_os_module::Proc;
let mut my_proc = Proc::new(0);
my_proc.state = State::Running;
そのためセッターメソッドを定義しました。以下は動作します。
use my_os_module::Proc;
let mut my_proc = Proc::new(0);
my_proc.set_state(State::Running);
このようなデータのカプセル化7は、公開APIにおけるベストプラクティスと見なされています。
ただし必須ではなく、常に適切であるとも限りません。
外部コードから state を書き込み可能にしたい場合(たとえば my_proc.state = State::Running; が動作するようにしたい場合)は、宣言時に pub 可視性指定子を使用できます。
pub struct Proc {
pid: u32, // プロセスID(符号なし整数)
pub state: State, // 現在の状態(列挙型)
children: Vec<u32>, // 子ID(動的リスト)
}
モジュールと可視性については、この章の後半で説明します。
Rust が保守的なアプローチを取っていることに注目してください。外部への可視性、可変性、安全でない操作はいずれも、明示的なオプトインを必要とします。 これは、大規模なプログラムにおける潜在的なエラー要因を減らすのに役立つ、意識的な設計上の選択です。
ジェネリクス
私たちはすでにジェネリックなライブラリを使っています。標準ライブラリの Vec です。
これは Vec<T> として定義されており、T はジェネリック型です。
そのため、格納したい要素の型ごとに異なるライブラリAPIを使う必要なく、符号なし整数のベクター(Vec<usize>)と文字列のベクター(Vec<String>)の両方を持つことができます。
自分のために単一の趣味OSを書いているのではなく、実際には再利用可能なスケジューリングライブラリ、つまりOSを書く誰もが利用できる可能性のあるコードを書いていると想像してください。 ここでジェネリクスが登場します。 構造体や関数の特定のインスタンスを作成する代わりに、あなたのコードの利用者が型を差し込めるテンプレートを定義できます。 将来書かれる外部コードで定義されたカスタム型も含めることができます!
あなたの利用者の中には、同時に100個を超えるプロセスが実行されることが決してない、小さな組み込みデバイス向けのOSを書いている人がいるかもしれません。
彼らは pid を表すために u32 ではなく u8 を使うことで、貴重なメモリを節約する必要があります。
しかし、単に pid の型を u8 に変更することはできません。他の利用者は数千のプロセスを表す必要があるからです。
Proc の定義と実装をジェネリックに更新すれば、両方のグループに対応できます。
pub struct Proc<T> {
pid: T, // プロセスID(ジェネリック)
pub state: State, // 現在の状態(列挙型)
children: Vec<T>, // 子ID(動的リスト、ジェネリック)
}
impl<T> Proc<T> {
// 関連関数(コンストラクター)
pub fn new(pid: T) -> Self {
Proc {
pid,
state: State::Stopped,
children: Vec::new()
}
}
// ...ここにさらにメソッド/関数
}
リソース制約のある利用者は let mut my_proc: Proc<u8> = Proc::new(0); を指定でき、他の利用者は let mut my_proc: Proc<u32> = Proc::new(0); を使えます。
私たちのコードは、どちらにも対応できるだけの柔軟性を持つようになります。
最終的なバイナリ内でジェネリクスはどのように動作するのか?
Rust コンパイラーは、単相化によってジェネリクスを実装します。 各呼び出し箇所で使用される具体的な型(
u8など)ごとに、コンパイラーは出力バイナリ内に特殊化されたコードを生成します。 そのため、ジェネリクスには実行時コストがありません。各一意なTの「テンプレート」が、最終的な実行可能ファイル内に1つの「スタンプ」(一意なコード)を作成します。
ジェネリクスは Rust の中核的な機能であり、頻繁に目にすることになります。 トレイトと組み合わせることで、再利用可能で保守しやすいソフトウェアコンポーネントの作成が可能になります。
トレイト
ここまで説明してきた構成要素は、主流の言語からそれほど大きく外れたものではありません。 Rust の列挙型とパターンマッチングは、すでに馴染みのある言語機能の拡張のように感じられるでしょう。 多くの読者にとって、Rust がかなり違って感じられ始めるのはトレイトからです。
以前、Rust の構造体は Python のクラスや Java のオブジェクトと同じ役割を果たすと述べました。 しかし、これら2つの言語とは異なり、Rust は継承をサポートしていません。 クラス階層は存在せず、構造体が親からフィールドやメソッドを継承することはできません。
その代わり、共有される振る舞いは合成によって、つまりトレイトを通じて定義されます。 このアプローチは、オブジェクト指向言語においてさえベストプラクティスだと考える人もいます8。
コードレベルの仕組みとしては、トレイトはオブジェクト指向言語における「抽象基底クラス」に似ています。 つまり、トレイトを実装する任意の型がサポートしなければならないインターフェース(APIの集合)を定義するということです。
型は1つ以上のトレイトを実装でき、そうすることで、そのトレイトが適切な任意のコンテキストでその型を使用できるようになります。
そもそも継承とは何でしょうか?
継承は「サブタイプ多相」の一種で、2つの型の限定的な置換を可能にします。
たとえば、
Vehicleクラスにaccelerate(int speed_mph)メソッドがあり、CarとPlaneの両方のサブクラスがそれを継承しているとします。Vehicleの派生型の配列を処理し、CarとPlaneの両方でaccelerateを呼び出すコードを書きたいとします。 継承がその目標を達成する方法は2つあり、ほとんどの言語はその両方を提供しています。
インターフェイス継承:
CarとPlaneはVehicleの公開メソッドインターフェイスを共有しますが、実際のaccelerateの実装はそれぞれ独自にオーバーライドします。ここで、Vehicleは「抽象基底クラス」として機能します。Rust のトレイトはこのベストプラクティスを体現しています。実装継承:
CarとPlaneはVehicleの汎用的なaccelerateメソッドのデータと実装を共有します。このパターンは実際のプログラムで広く使われていますが、基底クラスと派生クラスが密結合になるため、コードの保守や拡張が難しくなる可能性があります。
では、トレイトはどのような振る舞いを指定できるのでしょうか?
また、それらをどのように利用するのでしょうか?
それを確認するために、Proc 構造体に2つのトレイトを追加してみましょう。
トレイト Debug を導出する
構造体のテキスト表現を出力できることは、デバッグに役立ちます。
実際、これは非常によくあるニーズであるため、Rust はこの目的専用のデフォルトのフォーマット指定子 {:?} を提供しています。
これを使って、元の非ジェネリックな Proc 構造体を出力してみましょう。
pub enum State {
Running,
Stopped,
Sleeping,
}
pub struct Proc {
pid: u32, // プロセスID(符号なし整数)
state: State, // 現在の状態(列挙型)
children: Vec<u32>, // 子ID(動的リスト)
}
fn main() {
let my_proc = Proc {
pid: 1,
state: State::Stopped,
children: Vec::new(),
};
println!("{:?}", my_proc);
}
次のエラーが発生します(いくつかの行は省略しています)。
error[E0277]: `Proc` doesn't implement `Debug`
--> src/main.rs:20:22
|
20 | println!("{:?}", my_proc);
| ^^^^^^^ `Proc` cannot be formatted using `{:?}`
|
= help: the trait `Debug` is not implemented for `Proc`
= note: add `#[derive(Debug)]` to `Proc` or manually `impl Debug for Proc`
{:?} を使いたい場合、コンパイラは Proc が Debug トレイト9を実装していることを必要とします。
このトレイトは、実装者をコンソールへどのように出力すべきかを定義するもので、一般的かつ望ましい振る舞いです。
この時点で選択肢は2つあります。
-
std::fmt::Debugのドキュメント9を確認し、それが要求するインターフェイスを理解して(この場合は関数1つだけです)、impl Debug for Proc { ... }ブロック内でそのインターフェイスを実装する。 -
derive マクロ
#[derive(Debug)]を使って、トレイトを自動的に導出してみる。
後者の選択肢のほうが簡単で、ドキュメント9でも推奨されている方法です。
Rust のドキュメントに慣れる
まだ行っていない場合は、ここで少し時間を取って
Debugトレイトのドキュメント9を確認してください。 関数シグネチャ全体はまだ理解できないかもしれませんが、それでも大まかなところはつかめるはずです。ライブラリのドキュメントを理解することは、どんな開発者にとっても重要なスキルですが、Rust プログラミングでは特に役立ちます。 Rust には組み込みのファーストパーティ製ドキュメントジェネレーターがあるため、人気のあるライブラリは十分に文書化されている傾向があります(これについては後ほど取り上げます!)。
提案された更新を行ってみましょう。
#[derive(Debug)]
pub struct Proc {
pid: u32, // プロセスID(符号なし整数)
state: State, // 現在の状態(列挙型)
children: Vec<u32>, // 子ID(動的リスト)
}
今度は新しいエラーが発生します(別名、プログラマーにとっての「進捗」)。
error[E0277]: `State` doesn't implement `Debug`
--> src/main.rs:10:5
|
7 | #[derive(Debug)]
| ----- in this derive macro expansion
...
10 | state: State, // 現在の状態(列挙型)
| ^^^^^^^^^^^^ `State` cannot be formatted using `{:?}`
|
= help: the trait `Debug` is not implemented for `State`
= note: add `#[derive(Debug)]` to `State` or manually `impl Debug for State`
まあ、完全に新しいわけではありません。これは先ほどと同じエラーですが、今回は Proc の state フィールドに対するものです。
合成によって振る舞いを定義するという考え方を覚えていますか?
構造体の個々のフィールドすべてが Debug トレイトを実装しているなら、構造体全体に対してそれを導出するのは簡単です。その振る舞いは、各フィールドの個別の振る舞いを合成したものにすぎません。
すべてを厳格な階層に押し込む必要なく、強力な抽象化を構築し、既存のコードを再利用できます。
この2つ目のエラーによると、残っている唯一の障害は State 型が Debug を実装していないことです。
それを修正しましょう。
#[derive(Debug)]
pub enum State {
Running,
Stopped,
Sleeping,
}
これでプログラムはコンパイルされ、実行できるようになります。 期待どおりの出力が得られます。
Proc { pid: 1, state: Stopped, children: [] }
Debug出力のクイックヒントシステムコードのデバッグでは、構造体を16進数値で、かつ1フィールドにつき1行で出力すると便利なことがよくあります。
mainの最後の行をprintln!("{:#x?}", my_proc);に更新すると、プログラムは次のように出力します。Proc { pid: 0x1, state: Stopped, children: [], }
トレイト Ord を実装する
トレイトは常に自動導出できるとは限りません。
たとえば、Aead トレイト10を考えてみましょう。
これはサードパーティライブラリで定義されており、Associated Data 付き認証暗号(Authenticated Encryption with Associated Data、AEAD)暗号のための[非公式な]インターフェイスを指定しています。
前の章で見たように、これはメッセージの機密性と完全性の両方を提供する暗号アルゴリズムのファミリーです11。
Rust のトレイトシステムは強力ですが、derive マクロが暗号コードを合成してくれるわけではありません。 トレイトは単なるインターフェイスであり、背後にあるロジックは自分で実装しなければならないことがよくあります。
さらに、トレイトが導出可能であっても、デフォルトの振る舞いが望むものとは限りません。
たとえば、OS がプロセス構造体のソート済みリストを維持する必要があるとします。
ソートには「順序」の概念が必要です。
数学者が「全順序」12と呼ぶものです。
根底にある考え方は、論理比較演算子(==、>、<= など)を使ってソートしたいということであり、これらの比較を曖昧さなく行える必要があります。
Rust の標準ライブラリには、順序付け専用のトレイト Ord13 が含まれています。
これを実装した型は、同じ型の項目と比較可能になり、そのコレクションはソート可能になります。
多くの文脈で、これはサポートすると非常に有用な振る舞いです。
Proc に対して Ord を導出できるでしょうか?
はい、できます。
ただし、ドキュメント13によると、Ord は他のトレイト、すなわち PartialEq、Eq、PartialOrd に依存しています。
なぜなら、トレイト自体も合成によって定義できるからです!
これら4つの順序関連トレイトの違いについて細かくこだわるのはやめましょう。 代わりに、それらを導出すると何が起こるかを考えてみましょう。
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum State {
Running,
Stopped,
Sleeping,
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Proc {
pid: u32, // プロセスID(符号なし整数)
state: State, // 現在の状態(enum)
children: Vec<u32>, // 子ID(動的リスト)
}
fn main() {
let my_proc_stopped = Proc {
pid: 1,
state: State::Stopped,
children: Vec::new(),
};
let my_proc_sleeping = Proc {
pid: 3,
state: State::Sleeping,
children: Vec::new(),
};
let my_proc_running = Proc {
pid: 2,
state: State::Running,
children: Vec::new(),
};
let mut proc_queue = vec![
my_proc_stopped,
my_proc_sleeping,
my_proc_running,
];
proc_queue.sort();
println!("{:#?}", proc_queue);
}
上記では、3つのプロセスからなる Vec(proc_queue)を作成し、それをソートしています。
なぜ proc_queue.sort() を呼び出せるのでしょうか?
Vec<T> のドキュメント14にある sort の関数シグネチャを考えてみましょう。
pub fn sort(&mut self)
where
T: Ord,
{
// ...ここにコード
}
where T: Ord は トレイト境界 です。
これは、その関数が機能するために T がどのような振る舞いをサポートする必要があるかを規定します。
つまり、sort は任意の Vec<T> で利用できますが、T が Ord を実装する型である場合に限られます。
上記のコードが動作する理由は次のとおりです。
-
型推論により
let mut proc_queue: Vec<Proc> = ...が補完されました。 -
Proc構造体がOrdトレイトを導出しました。
トレイト境界は、コードの再利用とライブラリの組み合わせやすさに大きな影響を及ぼします。
Vec はジェネリックなコンテナであり(まだ発明されていない型に対しても動作します)、特定の振る舞いをサポートする要素に対して追加の機能を提供します(たとえば、順序付け可能な型をソートするなど)。
しかし、Vec は公式の標準ライブラリだけが実装できるような一回限りの特別なものではありません。
任意の Rust 開発者も同様に、ジェネリクスとトレイトを使って、同じくらい有用なデータ構造を実装できます。
本書では、別の標準ライブラリコレクションと API 互換の代替実装を書きます。
トレイト境界により、異なるコンポーネントを迅速かつ自信を持って組み合わせ、大規模で調和の取れたシステムを構築できます。 これは強力な高レベルの構成要素です。
Rust 構文を読む
Rust を読むことに慣れるには時間がかかります。構文が複雑だからです。
whereキーワードは実際には可読性のための便宜的なもので、上記のsortシグネチャは次と同等です。pub fn sort<T: Ord>(&mut self) { // ...ここにコード }しかし、
Tはどこから来たのでしょうか? どちらのsortのバリエーションも単独の関数ではなく、どちらもVec<T>のimplブロック内に存在します。 簡潔にするためその詳細は省略しましたが、これは重要な点です。impl<T> Vec<T> { pub fn sort<T: Ord>(&mut self) { // ...ここにコード } // ...ここに他の関数 }
したがって、トレイト境界のおかげで、sort() の呼び出しは機能します。
しかし、それはうまく機能しているのでしょうか?
これは議論の余地があります。出力を見ると、pid でソートされていることがわかります。
[
Proc {
pid: 1,
state: Stopped,
children: [],
},
Proc {
pid: 2,
state: Running,
children: [],
},
Proc {
pid: 3,
state: Sleeping,
children: [],
},
]
導出された複合的な振る舞いは、構造体の1番目のフィールド(pid)でソートしようとします。
値がたまたま等しい場合は、2番目のフィールド(state。これも Ord を導出しています)でソートします。
それらの値もたまたま等しい場合は、3番目のフィールド(children)でソートします。以降も同様です。
これはコンパイルされて実行されましたが、私たちが望む振る舞いとは少し異なります。
私たちの OS がこのプロセス一覧をスケジューリングキューとして使い、次にどのプロセスを実行するかを決めると想像してください。
その場合、pid 優先ではなく、何らかの優先度の概念に基づいてソートする必要があります。
現実世界のスケジューリングアルゴリズムは複雑になり得ます15。
簡単にするため、ここでは現在の State のみに基づく3つの優先度があると仮定します。
Sleeping のプロセスは実行対象として最も高い優先度を持つべきで、その次に Stopped のプロセスが続きます。
Running のプロセスは、定義上すでに実行中であり、最も低い優先度です。
それらはリストの末尾に置きたいとします。
いよいよ Ord を難しい方法で実装する時です!
まず、State enum が内部でどのように機能するかをもう少し理解する必要があります。
メモリ上では、各バリアントは 判別子、つまり整数値で始まります。
これはそのバリアントに固有のタグのようなものです。
もし2つの pid が等しかった場合、state を見てソートの同順位を解決する必要がありました。
したがって、この判別子の整数値がソートに関わることになります。
State に導出された Ord はそのまま残しつつ、デフォルト値を上書きして、選択した優先度を反映しましょう。
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum State {
Running = 3, // デフォルトでは0
Stopped = 2, // デフォルトでは1
Sleeping = 1, // デフォルトでは2
}
Proc 構造体については、各ドキュメントに従って、Ord16、PartialOrd17、PartialEq18 トレイトで必要とされる実際の関数を実装します。
Eq19 は引き続き導出できます。これは PartialEq によって暗黙に示され、独自のメソッドを持たないためです(これは他のトレイトには一般化できない技術的な事情です)。
use std::cmp::Ordering;
#[derive(Debug, Eq)]
pub struct Proc {
pid: u32, // プロセスID(符号なし整数)
state: State, // 現在の状態(enum)
children: Vec<u32>, // 子ID(動的リスト)
}
impl Ord for Proc {
fn cmp(&self, other: &Self) -> Ordering {
self.state.cmp(&other.state)
}
}
impl PartialOrd for Proc {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl PartialEq for Proc {
fn eq(&self, other: &Self) -> bool {
self.state == other.state
}
}
上記のコードの詳細そのものよりも、その含意の方が重要です。これで言語は非常に根本的なレベルで、Proc 構造体の順序付けを行う際に state フィールドだけを考慮するようになります。
いくつかの特定のトレイトを実装することで、ソートや比較のようなさまざまなコンテキストにおいて、その構造体がどのように振る舞うかを定めたのです。
この新しい Ord の実装と、それが依存するトレイトにより、println!("{:#?}", proc_queue); は、私たちが望む state 優先の順序を出力するようになります。
[
Proc {
pid: 3,
state: Sleeping,
children: [],
},
Proc {
pid: 1,
state: Stopped,
children: [],
},
Proc {
pid: 2,
state: Running,
children: [],
},
]
注意してください。トレイトは強力です!
トレイトを手動で実装することで、ソートのために
Proc構造体をどのように順序付けるべきかだけでなく、2つのProc構造体が等しいとは何を意味するのかも変更しました!これで、同じ
stateを持つ任意の2つの構造体は、たとえ異なるpidとchildrenを持っていても、==演算子に関する限り論理的に同等であると見なされます。トレイトを手動で実装するときは常に、その実装がもたらすすべての影響が、あなたのプログラムにとって本当に適切であることを確認することが重要です。
この場合、トレイトの実装は実際には過剰です(重要な概念を説明するためだけに行いました)。 代わりに、enum の判別子を更新した後で、
Vecのsort_by_key関数20を使うこともできました。proc_queue.sort_by_key(|p| p.state);
要点
Rust が高レベルの構成要素を表現するために備えている機能には、enum、構造体、ジェネリクス、トレイトがあります。 振り返ると、次のとおりです。
-
enum は、取り得る値の有限集合を表現するのに役立ちますが、追加のデータを保持することもできます。
-
構造体は、関連するデータと、それに対して動作する関数をまとめる方法であり、他の言語におけるクラスやオブジェクトに似ています。
-
ジェネリクスはコードの再利用を可能にします。関数や構造は一度だけ書けばよく、それでいて異なる型をサポートできます。コード重複を避けるのに便利というだけでなく、ライブラリ設計にとっては本当に強力な機能です。
-
トレイトは、コンポジションを通じて共有される振る舞いを可能にします。トレイトは特定のインターフェイスを定義し、derive したり実装したりでき、ジェネリックパラメーターに境界として指定されたときに特に有用になります。
所有権に踏み込む前に、より単純なトピックである制御フローについて話して、少し一息つきましょう。
ドメイン固有の不変条件を型システムに直接エンコードできるでしょうか?
限定的ながらも強力な形で、できます。 ときには、重要なドメイン固有の振る舞いを状態機械としてモデル化できます。 それは一連の状態を遷移する構造であり、特定の操作は特定の状態でのみ実行できます。 そして、合法な遷移も特定のものだけです。
typestate パターンは、構造体が取り得る実行時状態をコンパイル時にエンコードする方法です。 これにより、状態に関連するエラー(静的な正しさ)と、一部の実行時チェックの必要性(性能)の両方を排除できます。前者の利点は、次のものに適しています。
[RR、Directive 4.13] リソースに対して動作する関数は、正しい順序で呼び出されなければならない21
typestate パターンの Rust における実装については、将来の付録セクションで扱います。
-
The Rust Reference: Influences. The Rust Team (2021). ↩
-
Tock. Tock OS (2022年参照)。オペレーティングシステムは、おそらくシステムソフトウェアの典型的な例であり、Rust がよく適している領域です。Rust で書かれた OS はいくつかあり、Tock はその1つです。 ↩
-
The Current Epoch Unix Timestamp. Dan’s Tools (2022年参照)。 ↩
-
Rust では、enum に対してメソッドや関連関数を定義することもできます。構造体に限定されているわけではありません。しかし、構造体のほうがより一般的に使われており、多くのプログラミング問題では、複数の異なるバリアントを持つデータのグループを表現する必要はありません。 ↩
-
実際の OS ははるかに複雑なタスク構造を持っており、このセクションの例は大幅に簡略化されています。興味があれば、Linux の
task_structのソースコードをこちらで確認できます。 ↩ -
Process Models. The Chromium Project (2022年参照)。 ↩
-
Data encapsulation. Wikipedia (2022年参照)。 ↩
-
Composition over inheritance. Wikipedia (2022年参照)。 ↩
-
トレイト
std::fmt::Debug. The Rust Team (2022年参照)。 ↩ ↩2 ↩3 ↩4 -
トレイト
aead::Aead. RustCrypto organization (2022年参照)。 ↩ -
Authenticated encryption. Wikipedia (2022年参照)。 ↩
-
トレイト
std::cmp::Ord. The Rust Team (2022年参照)。 ↩ ↩2 -
Scheduling Algorithms. OSDev Wiki (2021)。 ↩
-
トレイト
std::cmp::Ord. The Rust Team (2022年参照)。 ↩ -
トレイト
std::cmp::PartialOrd. The Rust Team (2022年参照)。 ↩ -
トレイト
std::cmp::PartialEq. The Rust Team (2022年参照)。 ↩ -
トレイト
std::cmp::Eq. The Rust Team (2022年参照)。 ↩ -
sort_by_key. The Rust Team (2022年参照)。 ↩ -
MISRA C: 2012 Guidelines for the use of the C language in critical systems (3rd edition). MISRA (2019). ↩