高度な型
Rust の型システムには、これまで触れてはきたものの、まだ説明していない機能がいくつかあります。まず、newtype が型としてなぜ有用なのかを見ながら、一般的な newtype について説明します。次に、newtype に似ていますが意味論が少し異なる機能である型エイリアスに進みます。さらに、! 型と動的サイズ付き型についても説明します。
Newtype パターンによる型安全性と抽象化
この節は、前の節 [「Newtype パターンで外部トレイトを実装する」][newtype] を読んでいることを前提としています。newtype パターンは、これまでに説明したもの以外の用途にも役立ちます。たとえば、値が決して取り違えられないことを静的に保証したり、値の単位を示したりできます。リスト 20-16 では、単位を示すために newtype を使う例を見ました。Millimeters 構造体と Meters 構造体は、u32 の値を newtype として包んでいたことを思い出してください。型 Millimeters の引数を取る関数を書いた場合、その関数を誤って Meters 型の値や単なる u32 で呼び出そうとするプログラムはコンパイルできません。
また、newtype パターンを使うことで、型の実装詳細の一部を抽象化することもできます。新しい型は、非公開の内部型の API とは異なる公開 API を公開できます。
newtype は内部実装を隠すこともできます。たとえば、人の ID をその名前に関連付けて保存する HashMap<i32, String> を包む People 型を提供できます。People を使うコードは、People コレクションに名前文字列を追加するメソッドのような、私たちが提供する公開 API とのみやり取りします。そのコードは、内部的に名前へ i32 の ID を割り当てていることを知る必要はありません。newtype パターンは、実装の詳細を隠すためのカプセル化を実現する軽量な方法です。これは第 18 章の [「実装の詳細を隠すカプセル化」][encapsulation-that-hides-implementation-details]
節で説明しました。
型同義語と型エイリアス
Rust では、既存の型に別名を付けるための 型エイリアス を宣言できます。これには type キーワードを使います。たとえば、i32 に対するエイリアス Kilometers を次のように作成できます。
fn main() {
type Kilometers = i32;
let x: i32 = 5;
let y: Kilometers = 5;
println!("x + y = {}", x + y);
}
これで、エイリアス Kilometers は i32 の 同義語 になります。リスト 20-16 で作成した Millimeters 型や Meters 型とは異なり、Kilometers は独立した新しい型ではありません。型 Kilometers を持つ値は、型 i32 の値と同じように扱われます。
fn main() {
type Kilometers = i32;
let x: i32 = 5;
let y: Kilometers = 5;
println!("x + y = {}", x + y);
}
Kilometers と i32 は同じ型であるため、両方の型の値を加算できますし、i32 型の引数を受け取る関数に Kilometers 型の値を渡すこともできます。しかし、この方法では、先に説明した newtype パターンで得られる型チェックの利点は得られません。言い換えると、どこかで Kilometers と i32 の値を取り違えても、コンパイラはエラーを出しません。
型同義語の主な用途は、繰り返しを減らすことです。たとえば、次のような長い型を使うことがあります。
Box<dyn Fn() + Send + 'static>
この長い型を、関数シグネチャや型注釈としてコードのあちこちに書くのは、面倒でミスの原因にもなります。リスト 20-25 のようなコードで満たされたプロジェクトを想像してみてください。
fn main() {
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--
Box::new(|| ())
}
}
型エイリアスを使うと、繰り返しが減るため、このコードはより扱いやすくなります。リスト 20-26 では、この冗長な型に対して Thunk というエイリアスを導入し、その型のすべての使用箇所を、より短いエイリアス Thunk に置き換えています。
fn main() {
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--
Box::new(|| ())
}
}
このコードは、読むのも書くのもずっと簡単になります。また、型エイリアスに意味のある名前を選ぶことで、意図を伝えやすくなります(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 には次の型エイリアス宣言があります。
use std::fmt;
type Result<T> = std::result::Result<T, std::io::Error>;
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<()>;
}
この宣言は std::io モジュール内にあるため、完全修飾されたエイリアス std::io::Result<T> を使えます。つまり、E が std::io::Error に埋められた Result<T, E> です。Write トレイトの関数シグネチャは最終的に次のようになります。
use std::fmt;
type Result<T> = std::result::Result<T, std::io::Error>;
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> に対して使えるあらゆるメソッドをそれにも使えますし、? 演算子のような特別な構文も使えます。
決して返らない never 型
Rust には ! という特別な型があり、型理論の用語では値を持たないことから 空型 として知られています。私たちはこれを never 型 と呼ぶことを好みます。これは、関数が決して返らないときに、その戻り値型の位置に置かれるからです。以下に例を示します。
fn bar() -> ! {
// --snip--
panic!();
}
このコードは「関数 bar は決して返らない」と読みます。決して返らない関数は 発散関数 と呼ばれます。型 ! の値は作れないので、bar が返ることはありえません。
しかし、値を作れない型に何の用途があるのでしょうか。数当てゲームの一部であるリスト 2-5 のコードを思い出してください。その一部をここでリスト 20-27 として再掲します。
use std::cmp::Ordering;
use std::io;
use rand::Rng;
fn main() {
println!("Guess the number!");
let secret_number = rand::thread_rng().gen_range(1..=100);
println!("The secret number is: {secret_number}");
loop {
println!("Please input your guess.");
let mut guess = String::new();
// --snip--
io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
println!("You guessed: {guess}");
// --snip--
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => {
println!("You win!");
break;
}
}
}
}
そのとき、このコードのいくつかの詳細は飛ばしていました。第6章の[「`match`
制御フロー構文」][the-match-control-flow-construct]<!-- 無視 -->
節で、`match` のアームはすべて同じ型を返さなければならないと説明しました。
したがって、たとえば次のコードは動作しません。
```rust,ignore,does_not_compile
# fn main() {
# let guess = "3";
let guess = match guess.trim().parse() {
Ok(_) => 5,
Err(_) => "hello",
};
# }
このコードでは、guess の型は整数 かつ 文字列でなければならないことに
なりますが、Rust では guess は 1 つの型しか持てません。では、continue
は何を返すのでしょうか。リスト20-27では、どうして一方のアームから u32
を返し、もう一方のアームを continue で終わらせることが許されたのでしょうか。
予想どおりかもしれませんが、continue は ! の値を持ちます。つまり、Rust
が guess の型を計算するとき、u32 の値を持つ前者のアームと、!
の値を持つ後者のアームの両方を見ます。!
は決して値を持ちえないため、Rust は guess の型を u32 だと判断します。
この振る舞いを形式的に説明すると、型 !
の式は任意の他の型に型強制できる、ということです。この match アームを
continue で終えられるのは、continue
が値を返さないからです。代わりに、制御をループの先頭へ戻すため、Err
の場合には guess に値が代入されることはありません。
never 型は panic! マクロと組み合わせても便利です。この定義で、Option<T>
の値に対して呼び出して値を取り出すか panic する unwrap
関数を思い出してください。
enum Option<T> {
Some(T),
None,
}
use crate::Option::*;
impl<T> Option<T> {
pub fn unwrap(self) -> T {
match self {
Some(val) => val,
None => panic!("called `Option::unwrap()` on a `None` value"),
}
}
}
このコードでは、リスト20-27の match と同じことが起こっています。Rust
は、val の型が T であり、panic! の型が !
であることを見ているため、match 式全体の結果は T
になります。このコードが動作するのは、panic!
が値を生成しないからです。これはプログラムを終了させます。None
の場合、unwrap から値を返すことはないため、このコードは有効です。
最後に、型 ! を持つ式としてループがあります。
fn main() {
print!("forever ");
loop {
print!("and ever ");
}
}
ここでは、ループは決して終了しないため、式の値は !
です。しかし、break
を含めた場合はそうではありません。break
に到達した時点でループが終了するからです。
動的サイズ型と Sized トレイト
Rust は、その型について、特定の型の値にどれだけの領域を割り当てるべきかといった、 いくつかの詳細を知る必要があります。このため、その型システムの一角には、最初は少しわかりにくいものがあります。それが 動的サイズ型 の概念です。ときに DSTs や サイズ不定型 とも呼ばれるこれらの型によって、サイズが実行時になって初めてわかる値を使うコードを書けます。
本書を通して使ってきた、str
という動的サイズ型の詳細を掘り下げてみましょう。そうです、&str ではなく、
それ単体の str が DST です。ユーザーが入力したテキストを格納するような多く
の場合、文字列の長さは実行時になるまでわかりません。つまり、str
型の変数を作ることも、str 型の引数を取ることもできません。動作しない次の
コードを見てください。
fn main() {
let s1: str = "Hello there!";
let s2: str = "How's it going?";
}
Rust は、ある特定の型のどんな値についても、どれだけのメモリを割り当てるべき
かを知る必要があり、また、ある型のすべての値は同じ量のメモリを使わなければ
なりません。もし Rust がこのコードを書くことを許したなら、この 2 つの str
の値は同じ量の領域を占める必要があります。しかし、これらは長さが異なります。
s1 には 12 バイトの記憶領域が必要で、s2 には 15 バイト必要です。これが、
動的サイズ型を保持する変数を作れない理由です。
では、どうすればよいのでしょうか。この場合、答えはすでに知っています。s1
と s2 の型を str ではなく文字列スライス(&str)にするのです。第4章の
[「文字列スライス」][string-slices]
節で見たように、スライスのデータ構造が保持するのは、スライスの開始位置と長さ
だけです。つまり、&T は T
が配置されているメモリアドレスを保持する単一の値ですが、文字列スライスは
2 つ の値、すなわち str のアドレスとその長さです。そのため、文字列
スライス値のサイズはコンパイル時にわかります。これは usize
の長さの 2 倍です。つまり、参照先の文字列がどれほど長くても、文字列スライス
のサイズは常にわかります。一般に、Rust
で動的サイズ型が使われるのはこのような形です。動的な情報のサイズを保持する
追加のメタデータを持つのです。動的サイズ型の鉄則は、動的サイズ型の値は必ず
何らかのポインタの背後に置かなければならない、ということです。
str はさまざまな種類のポインタと組み合わせられます。たとえば、Box<str> や
Rc<str> です。実は、これは以前にも見たことがあります。ただし、そのときは
別の動的サイズ型でした。トレイトです。すべてのトレイトは、トレイト名を使って
参照できる動的サイズ型です。第18章の[「トレイトオブジェクトを使って共有される
振る舞いを抽象化する」][using-trait-objects-to-abstract-over-shared-behavior]節で、トレイトをトレイトオブジェクトとして使うには、&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 でない可能性があるため、何らかのポインタの背後で使う必要があり
ます。この場合は参照を選んでいます。
次は、関数とクロージャについて見ていきましょう! [encapsulation-that-hides-implementation-details]: ch18-01-what-is-oo.html#encapsulation-that-hides-implementation-details [string-slices]: ch04-03-slices.html#string-slices [the-match-control-flow-construct]: ch06-02-match.html#the-match-control-flow-construct [using-trait-objects-to-abstract-over-shared-behavior]: ch18-02-trait-objects.html#using-trait-objects-to-abstract-over-shared-behavior [newtype]: ch20-02-advanced-traits.html#implementing-external-traits-with-the-newtype-pattern