はじめに
高度な型へようこそ。この実験は、Rust Bookの一部です。LabEx で Rust のスキルを練習することができます。
この実験では、Rust の型システムにおける新しい型、型エイリアス、!型、および動的にサイズ指定された型について説明します。
高度な型
Rust の型システムには、これまでに言及はしたもののまだ説明していない機能がいくつかあります。まずは、新しい型がなぜ型として役立つのかを調べながら、新しい型について一般的に説明します。次に、新しい型と似ているが意味がやや異なる機能である型エイリアスに移ります。また、!型と動的にサイズ指定された型についても説明します。
型の安全性と抽象化のための新しい型パターンの使用
注:このセクションでは、以前のセクション「外部のトレイトを実装するための新しい型パターンの使用」を読んだことを前提としています。
新しい型パターンは、これまでに議論したタスク以外のタスクにも役立ちます。それには、値が混同されないように静的に強制することや、値の単位を示すことが含まれます。19-15 のリストで新しい型を使って単位を示す例を見ました。MillimetersとMetersの構造体がu32の値を新しい型でラップしていることを思い出してください。Millimeters型のパラメータを持つ関数を書いた場合、型Metersの値や単純なu32でその関数を誤って呼び出そうとしたプログラムをコンパイルすることはできません。
また、新しい型パターンを使って型の一部の実装詳細を抽象化することもできます。新しい型は、内部のプライベートな型の API とは異なるパブリック API を公開することができます。
新しい型は内部の実装を隠すこともできます。たとえば、人の ID と名前を関連付けて格納するHashMap<i32, String>をラップするPeople型を提供することができます。Peopleを使ったコードは、私たちが提供するパブリック API、たとえばPeopleコレクションに名前の文字列を追加するメソッドとのみ相互作用します。そのコードは、内部的に名前にi32の ID を割り当てていることを知る必要はありません。新しい型パターンは、実装の詳細を隠すためのカプセル化を実現する軽量な方法であり、「実装の詳細を隠すカプセル化」で議論しました。
型エイリアスを使った型のエイリアスの作成
Rust は、既存の型に別名を付けるための 型エイリアス を宣言する機能を備えています。これには type キーワードを使います。たとえば、Kilometers を i32 にエイリアス付けするには、次のようにします。
type Kilometers = i32;
これで、エイリアス Kilometers は i32 の エイリアス になります。19-15 のリストで作成した Millimeters や Meters 型とは異なり、Kilometers は別の新しい型ではありません。Kilometers 型の値は、i32 型の値と同じように扱われます。
type Kilometers = i32;
let x: i32 = 5;
let y: Kilometers = 5;
println!("x + y = {}", x + y);
Kilometers と i32 は同じ型なので、両方の型の値を加算でき、Kilometers 型の値を i32 型のパラメータを持つ関数に渡すことができます。ただし、この方法を使うと、先ほど議論した新しい型パターンから得られる型チェックの恩恵は得られません。つまり、Kilometers と i32 の値をどこかで混同した場合、コンパイラはエラーを表示しません。
型エイリアスの主な使い道は、繰り返しを減らすことです。たとえば、次のような長い型がある場合があります。
Box<dyn Fn() + Send + 'static>
この長い型を関数のシグネチャやコード全体の型アノテーションに書くのは面倒で、エラーが発生しやすいです。19-24 のリストのようなコードがたくさんあるプロジェクトを想像してみてください。
let f: Box<dyn Fn() + Send + 'static> = Box::new(|| {
println!("hi");
});
fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
--snip--
}
fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
--snip--
}
リスト 19-24: 多くの場所で長い型を使用する
型エイリアスを使うことで、繰り返しを減らしてコードをより管理しやすくすることができます。19-25 のリストでは、冗長な型に対して Thunk というエイリアスを導入し、型のすべての使用箇所を短いエイリアス Thunk に置き換えています。
type Thunk = Box<dyn Fn() + Send + 'static>;
let f: Thunk = Box::new(|| println!("hi"));
fn takes_long_type(f: Thunk) {
--snip--
}
fn returns_long_type() -> Thunk {
--snip--
}
リスト 19-25: 繰り返しを減らすための型エイリアス Thunk の導入
このコードははるかに読みやすく書きやすくなっています!型エイリアスに意味のある名前を付けることで、意図を伝えるのに役立ちます(thunk は後で評価するコードのことを指す言葉なので、保存されるクロージャに適切な名前です)。
型エイリアスはまた、繰り返しを減らすために Result<T, E> 型と共に頻繁に使用されます。標準ライブラリの std::io モジュールを考えてみてください。I/O 操作は、操作が失敗した場合の状況を処理するために、しばしば Result<T, E> を返します。このライブラリには、すべての可能な I/O エラーを表す std::io::Error 構造体があります。std::io の多くの関数は、E が std::io::Error である Result<T, E> を返します。たとえば、Write トレイトのこれらの関数です。
use std::fmt;
use std::io::Error;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
fn flush(&mut self) -> Result<(), Error>;
fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
fn write_fmt(
&mut self,
fmt: fmt::Arguments,
) -> Result<(), Error>;
}
Result<..., Error> がたくさん繰り返されています。そのため、std::io には次のような型エイリアスの宣言があります。
type Result<T> = std::result::Result<T, std::io::Error>;
この宣言が std::io モジュールにあるため、完全修飾エイリアス std::io::Result<T> を使うことができます。つまり、E が std::io::Error として埋め込まれた Result<T, E> です。Write トレイトの関数のシグネチャは、次のようになります。
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize>;
fn flush(&mut self) -> Result<()>;
fn write_all(&mut self, buf: &[u8]) -> Result<()>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}
型エイリアスは 2 つの点で役立ちます。コードを書きやすくし、std::io 全体で一貫したインターフェイスを提供します。エイリアスなので、Result<T, E> と同じ Result<T, E> であり、Result<T, E> で機能するすべてのメソッドや ? 演算子のような特殊な構文を使用できます。
決して戻らないネバー型
Rust には、型理論の用語では 空の型 と呼ばれる特別な型 ! があります。これは値を持たないためです。私たちはこれを ネバー型 と呼ぶのが好ましいです。なぜなら、関数が決して戻らない場合、この型は戻り型の代わりに使われるからです。以下は例です。
fn bar() ->! {
--snip--
}
このコードは「関数 bar は決して戻らない」と読みます。決して戻らない関数は 発散関数 と呼ばれます。型 ! の値を作成することはできないので、bar は決して戻ることはできません。
では、値を決して作成できない型が何の役に立つのでしょうか?2-5 のリストのコードを思い出してください。これは数字当てゲームの一部です。19-26 のリストに一部を再掲します。
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
リスト 19-26: continue で終わるアームを持つ match
当時、このコードのいくつかの詳細を飛ばしました。「match 制御フロー構文」では、match のアームはすべて同じ型を返さなければならないことを説明しました。たとえば、次のコードは動作しません。
let guess = match guess.trim().parse() {
Ok(_) => 5,
Err(_) => "hello",
};
このコードでの guess の型は整数 かつ 文字列になりますが、Rust では guess は 1 つの型のみを持つ必要があります。では、continue は何を返しますか?19-26 のリストで、1 つのアームから u32 を返し、もう 1 つのアームが continue で終わるように許されたのはなぜでしょうか?
おそらく想像通り、continue は ! の値を持っています。つまり、Rust が guess の型を計算するとき、両方の match アームを見ます。前者は u32 の値を持ち、後者は ! の値を持ちます。! は決して値を持つことができないので、Rust は guess の型が u32 であると判断します。
この動作を形式的に説明すると、型 ! の式は他の任意の型に強制変換できるということです。この match のアームを continue で終えることができるのは、continue が値を返さないからです。代わりに、制御をループの先頭に戻します。したがって、Err の場合、guess に値を割り当てることはありません。
ネバー型は panic! マクロでも役立ちます。Option<T> の値に対して呼び出して値を生成するか、この定義でパニックする unwrap 関数を思い出してください。
impl<T> Option<T> {
pub fn unwrap(self) -> T {
match self {
Some(val) => val,
None => panic!(
"called `Option::unwrap()` on a `None` value"
),
}
}
}
このコードでは、19-26 のリストの match と同じことが起こります。Rust は、val が型 T で、panic! が型 ! であることを見て、全体の match 式の結果が T であることを判断します。このコードが動作するのは、panic! が値を生成しないからです。つまり、プログラムを終了します。None の場合、unwrap から値を返さないので、このコードは有効です。
最後に、型 ! を持つ式は loop です。
print!("forever ");
loop {
print!("and ever ");
}
ここでは、ループは決して終わりません。したがって、! が式の値になります。ただし、break を含めるとこれは当てはまりません。なぜなら、ループは break に到達すると終了するからです。
動的にサイズ指定された型と Sized トレイト
Rust は、特定の型に関するいくつかの詳細を知る必要があります。たとえば、特定の型の値に割り当てるメモリ量を知る必要があります。このため、型システムの一部が最初は少し混乱します。それが 動的にサイズ指定された型 の概念です。時には DST または サイズ指定されていない型 と呼ばれます。これらの型を使うと、実行時にのみサイズを知ることができる値を使ってコードを書くことができます。
本書で頻繁に使ってきた str という動的にサイズ指定された型の詳細を掘り下げてみましょう。そうです、&str ではなく、それ自体の str が DST です。文字列の長さが実行時までわからないため、型 str の変数を作成することはできず、型 str の引数を取ることもできません。以下のコードを見てください。これは動作しません。
let s1: str = "Hello there!";
let s2: str = "How's it going?";
Rust は、特定の型の任意の値に割り当てるメモリ量を知る必要があり、型のすべての値は同じ量のメモリを使用する必要があります。もし Rust がこのコードを許した場合、これらの 2 つの str 値は同じ量のスペースを占有する必要があります。しかし、それらの長さは異なります。s1 には 12 バイトのストレージが必要で、s2 には 15 バイトが必要です。これが、動的にサイズ指定された型を保持する変数を作成できない理由です。
では、どうすればよいでしょうか?この場合、既に答えを知っています。s1 と s2 の型を &str にして、str ではなくします。「文字列スライス」で思い出してください。スライスデータ構造は、スライスの開始位置と長さを保存します。したがって、&T は T が格納されているメモリアドレスを保存する単一の値ですが、&str は 2 つ の値です。str のアドレスとその長さです。したがって、コンパイル時に &str 値のサイズを知ることができます。それは usize の長さの 2 倍です。つまり、参照する文字列がどれだけ長くても、常に &str のサイズを知ることができます。一般的に、Rust で動的にサイズ指定された型を使う方法はこれです。動的にサイズ指定された型には、動的情報のサイズを保存する追加のメタデータがあります。動的にサイズ指定された型の黄金律は、動的にサイズ指定された型の値を必ず何らかのポインタの後ろに置かなければならないということです。
str をさまざまな種類のポインタと組み合わせることができます。たとえば、Box<str> や Rc<str> です。実際、これは以前見たことがありますが、異なる動的にサイズ指定された型です。トレイトです。すべてのトレイトは、トレイトの名前を使って参照できる動的にサイズ指定された型です。「異なる型の値を許すトレイトオブジェクトの使用」では、トレイトをトレイトオブジェクトとして使うには、&dyn Trait や Box<dyn Trait> (Rc<dyn Trait> も機能します) のようなポインタの後ろに置かなければならないことを述べました。
DST を使うには、Rust は Sized トレイトを提供して、型のサイズがコンパイル時にわかるかどうかを判断します。このトレイトは、コンパイル時にサイズがわかるすべてのものに自動的に実装されます。また、Rust は暗黙的にすべてのジェネリック関数に Sized の制約を追加します。つまり、このようなジェネリック関数の定義は、
fn generic<T>(t: T) {
--snip--
}
実際はこのように書いたかのように扱われます。
fn generic<T: Sized>(t: T) {
--snip--
}
デフォルトでは、ジェネリック関数はコンパイル時に既知のサイズの型のみで動作します。ただし、次の特殊な構文を使ってこの制約を緩和することができます。
fn generic<T:?Sized>(t: &T) {
--snip--
}
?Sized のトレイト制約は、「T は Sized であるかもしれないし、そうでないかもしれない」という意味で、この表記はジェネリック型はコンパイル時に既知のサイズを持たなければならないというデフォルトを上書きします。この意味の ?Trait 構文は、Sized にのみ利用可能で、他のトレイトにはありません。
また、t パラメータの型を T から &T に切り替えたことにも注意してください。型が Sized でない可能性があるため、何らかのポインタの後ろで使う必要があります。この場合、参照を選択しました。
次に、関数とクロージャについて話しましょう!
まとめ
おめでとうございます!Advanced Types の実験を完了しました。LabEx でさらに実験を行って、スキルを向上させることができます。