はじめに
高度な型へようこそ。この実験は、Rust Bookの一部です。LabExでRustのスキルを練習することができます。
この実験では、Rustの型システムにおける新しい型、型エイリアス、!
型、および動的にサイズ指定された型について説明します。
This tutorial is from open-source community. Access the source code
💡 このチュートリアルは英語版からAIによって翻訳されています。原文を確認するには、 ここをクリックしてください
高度な型へようこそ。この実験は、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
に到達すると終了するからです。
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でさらに実験を行って、スキルを向上させることができます。