Rust解説動画の補足資料
この記事はなに?
私(lemolatoon)は、この度the bookを解説する動画を出しました。そこで、動画上で詳しく触れられなかった点について、この記事に補足として書いていきます。
参照の型とは
Rustでは全ての変数が一つの型を持っています。それがなにかの変数を借用したものであっても同じです。
let a: i32 = 5;
Rustでは上のように、変数の型(i32
)に注釈をつけられます。ただし、通常は推論されるため不要です。ここでは、不変借用や可変借用などの型を示すために、型注釈をすべてにつけてサンプルコードを示します。
fn main() {
let s: &str = "abc";
let mut a: String = String::from(s);
{
let b: &mut String = &mut a; // 型`T`の変数を可変借用した変数の型は`&mut T`
}
let b: &String = &a; // 型`T`の変数を不変借用した変数の型は`&T`
f(b);
}
fn f(s: &String) {
panic!("Not implemented.");
}
impl<T, U>
の読み方
次のようなコードの例を考えます。
struct Point<T, U> {
x: T,
y: U,
}
impl<T, U> Point<T, U> {
fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
Point {
x: self.x,
y: other.y,
}
}
}
fn main() {
let p1: Point<i32, f64> = Point { x: 5, y: 10.4 };
let p2: Point<&str, char> = Point { x: "Hello", y: 'c'};
let p3: Point<i32, char> = p1.mixup(p2);
println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}
ここで、Point
構造体を宣言していて、型引数はT
, U
の2つあります。すなわち、Point
構造体のメンバである、x
, y
は異なる型(たとえば、i32
とVec<f64>
など)にすることができます。
たとえば上のように、
Point { x: 5, y: 10.4 }
と書けば、Point<i32, f64>
型になり、
Point { x: Box::new("Hello"), y: vec!['c']}
と書けば、Point<Box<&str>, Vec<char>>
型になります。
impl<T, U>
が意味するもの
ここで、Associated Functionを定義している下の部分が何を意味しているのかについて見ていきます。
impl<T, U> Point<T, U> {
「任意の型引数T
, U
をとってきます。(T
とU
が固定されているもとで、)Point<T, U>
に関してimpl
します。」
と読むと分かりやすいです。
数学の集合周りの話に慣れている方は、
impl Point<T, U> {...}
と考えると分かりやすいと思います。
同様に考えると、下の部分は次のように読むことができます。
fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
- 「(
impl<T, U>
によってすでに、T
,U
が固定されているもとで、)任意の型引数V
,W
をとってきます。(V
とW
が固定されているもとで、)関数mixup
を定義します。」 -
に対して、関数\forall T, U \in H, \forall V, W \in H (Hは型全体の集合) mixup
を定義します。
ここで重要なのは、impl<T, U>
やmixup<V, W>
などによって、T
などの型引数が宣言されていおり、(型引数がPoint<T, U>
やPoint<V, W>
などは、具体的な型であるという点です。
Rustのthe bookの関数(with ライフタイム、ジェネリクス)の解説
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest_with_an_announcement(
string1.as_str(),
string2,
"Today is someone's birthday!",
);
println!("The longest string is {}", result);
}
use std::fmt::Display;
fn longest_with_an_announcement<'a, T>(
x: &'a str,
y: &'a str,
ann: T,
) -> &'a str
where
T: Display,
{
// "アナウンス! {}"
println!("Announcement! {}", ann);
if x.len() > y.len() {
x
} else {
y
}
}
ここでポイントになっているのは3つあります。
- ジェネリクス
T
-
where
によるトレイト境界 - ライフタイム
'a
T
ジェネリクスimpl<T, U>の読み方
で話した通りです。
where
によるトレイト境界
これまで、ジェネリクスは、
fn f<T>(x: T) {...}
であれば、
- 「任意の型引数
T
をとってきます。(T
が固定されているもとで、)関数f
を定義します。」 -
に対して、関数\forall T \in H (Hは型全体の集合) f
を定義します。
と解釈できました。
トレイト境界がついているような下の例は次のように解釈できます。
fn f<T>(x: T)
where
T: Display
{...}
- 「トレイト
Display
がimpl
されている任意の型引数T
をとってきます。(T
が固定されているもとで、)関数f
を定義します。」 -
(\forall T \in H はH Display
をimpl
している型の全体の集合) に対して、関数f
を定義します。
このように、宣言している型引数が選ばれてくる元(もと)の集合、つまり宣言している型引数が選ばれる可能性のある型全体の集合、が「この世にある型全体の集合」ではなく、「Display
をimpl
した型全体の集合」とすれば、これまでのジェネリクスと同じようにwhere
を解釈できます。
'a
ライフタイム実はライフタイムについても、これまでのジェネリクスに対して行っていた考えを同じように適用できます。
fn f<'a>(x: &'a str) {...}
という関数宣言は、次のように解釈できます。
- 「任意のライフタイム
'a
をとってきます。('a
が固定されているもとで、)関数f
を定義します。」 -
\forall 'a
に対して、関数\in L (Lはライフタイム全体の集合) f
を定義します。
ここで意識したいのは、「Rustにおいて、ライフタイム、つまり『いつまでその参照が有効なのか』というのが、すべての借用変数について明確である」ということです。ただ、借用を受け取る関数については、どんなライフタイムを持つ借用に対しても共通の処理をしたい(どんな型に対しても共通の処理をしたいという、ジェネリクスと同じ)という要望から、ライフタイムを書いています。
上で上げたライフタイム注釈を持つ関数f
ですが、本来ならばこの注釈は省略できます。ライフタイム注釈をつけなければいけないのは「複数の借用同士の関係を示すとき」です。
the book で出てきた例には以下のようなものがありました。
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
引数の型と引数の型に3つの借用が出てきており、その関係が示されています。上のlongest
という関数では、すべての借用が同じライフタイムを持つことが分かります。(正確にはより長いライフタイムが短いライフタイムへ強制されることがあります。参考)
ここまでで、
- ジェネリクス
T
-
where
によるトレイト境界 - ライフタイム
'a
について復習しました。もう一度はじめに提示して関数を提示します。
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest_with_an_announcement(
string1.as_str(),
string2,
"Today is someone's birthday!",
);
println!("The longest string is {}", result);
}
use std::fmt::Display;
fn longest_with_an_announcement<'a, T>(
x: &'a str,
y: &'a str,
ann: T,
) -> &'a str
where
T: Display,
{
// "アナウンス! {}"
println!("Announcement! {}", ann);
if x.len() > y.len() {
x
} else {
y
}
}
ここまでが理解できていれば、longest_with_an_announcement
関数が以前より簡単に見えていると思います。
'a
は任意のライフタイム、T
はDisplay
をimpl
した任意の型です。
引数は
- x:
'a
のライフタイムをもつ借用 - y:
'a
のライフタイムをもつ借用 - ann: 型
T
(Display
をimpl
しているので、println!("{}", ann)
で表示できる。)
です。
戻り値は「'a
のライフタイムをもつ借用」です。言い換えれば、引数のx
またはy
がdrop
するときに、戻り値もdrop
するということです。実際この関数では、x
またはy
が戻り値となっているので、意味的にもあっているように見えます。
トレイトオブジェクトとジェネリクスの違い
pub trait Draw {
fn draw(&self) {}
}
pub struct A(usize);
pub struct B(isize);
impl Draw for A {}
impl Draw for B {}
pub struct Screen1 {
pub components: Vec<Box<dyn Draw>>,
}
pub struct Screen2<T: Draw> {
pub components: Vec<T>,
}
fn main() {
let screen1 = Screen1 {
components: vec![Box::new(A(1)), Box::new(B(2))],
};
// !!! COMPILE ERROR
// let screen2 = Screen2 {
// components: vec![A(1), B(2)],
// };
}
この例がトレイトオブジェクトとジェネリクスの違いをよく表しています。
トレイトオブジェクトはあるトレイトを実装していれば、(Box
に包めば)どんな型でも受け付ける型です。
一方、上のScreen2
はあくまで、あるトレイト(ここではDraw
)を実装している型T
を元にコンパイル時に単相化します。
pub struct A(usize);
pub struct B(isize);
impl Draw for A {}
impl Draw for B {}
pub struct Screen2<T: Draw> {
pub components: Vec<T>,
}
fn main() {
let screen2: Screen2<A> = Screen2 {
components: vec![A(1), A(2)],
};
let screen2: Screen2<A> = Screen2 {
components: vec![B(1), B(2)],
};
}
上の例は以下のように単相化されます。
pub struct A(usize);
pub struct B(isize);
impl Draw for A {}
impl Draw for B {}
pub struct Screen2ForA {
pub components: Vec<A>,
}
pub struct Screen2ForB {
pub components: Vec<B>,
}
fn main() {
let screen2: Screen2ForA = Screen2ForA {
components: vec![A(1), A(2)],
};
let screen2: Screen2ForB = Screen2ForB {
components: vec![B(1), B(2)],
};
}
このような単相化の様子を見れば、トレイトオブジェクトとの違いが分かると思います。
一方、トレイトオブジェクト型はそれ自体は単一の型でありながら、複数の型を許容します。
let a: Box<dyn Draw> = Box::new(A(1)); // OK
let b: Box<dyn Draw> = Box::new(B(1)); // OK
つまり、Box<A>
型はBox<dyn Draw>
型になれるし、Box<B>
型はBox<dyn Draw>
型になることができます。
ダイナミックディスパッチとは
ダイナミックディスパッチは、トレイトオブジェクト型で、トレイトの関数をcallするときに起こるもので、具体的には以下のようなシチュエーションで起こります。
pub trait Print {
fn print(&self);
}
pub struct A {
a: usize
};
pub struct B {
b: isize
};
impl Print for A {
fn print(&self) { // print関数①
println!("{}", self.a);
};
}
impl Print for B {
fn print(&self) { // print関数②
println!("{}", self.b);
};
}
use rand;
fn main() {
let val: bool = rand::random::<bool>() // 5:5の確率で`true`, `false`を生成
let printable: Box<dyn Print> = if val {
Box::new(A {a: 1})
} else {
Box::new(B {b: -1})
};
printable.print();
}
上の例では、50%の確率で構造体A
が入っているBox
、50%の確率で構造体B
が入っているBox
が、変数printable
に代入されます。構造体A
のprint
関数が呼ばれるか、構造体B
のprint
関数が呼ばれるかは実行時にしかわかりません。つまり、実行するたびに結果が変わります。
では、実際上どのようにして、2つの異なるprint
関数を呼び分けているのでしょうか。
vtableというものを用いて実現しています。トレイトオブジェクト型の変数は、vtable(すべての代入されうるトレイトオブジェクトの元(もと)の型『ここではA
とB
』のそれぞれの関数の場所『ここでは、A
のprint
関数①とB
のprint
関数②のそれぞれの場所』の配列)を共有して持っています。
また、トレイトオブジェクトは自分の元の型の該当する表のオフセット(配列と考えれば添字のこと)を持っています(元が構造体A
だったならオフセット0, 元が構造体B
だったならオフセット1など)。
このように、
- vtableの場所
- vtable上の自分のトレイト関数の実装を示すオフセット
の2つの情報をトレイトオブジェクトは持つことで、実行時の動的な呼び分けを実現しています。また、このような関数の呼び分け方法を「ダイナミックディスパッチ」と呼びます。
Associated Function(関連する関数)の型の読み方
structやenumに対して、関連する関数を定義したいときには、impl
キーワードで書いていき、各関数で自分自身を参照するときには、self
キーワードを用います。以下は例です。
struct A {
x: String,
}
impl A {
// ①
fn jsut_move_me(self) -> String {
self.x
}
// ②: `&self`は`self: &Self`のシンタックスシュガー
fn use_this_as_immutable(&self) -> &String {
&self.x
}
// ③: `&mut self`は`self: &mut Self`のシンタックスシュガー
fn use_this_as_mutable(&mut self) -> &mut String {
&mut self.x
}
}
パターン1
パターン1では、引数にself
をとります。この関数を呼ぶと、呼び出し元のstructやenumはその時点でmoveします。
また、この関数内でself
と書いたときは、その型はA
です。(ここでは構造体A
にimpl
しているため)
パターン2
パターン2では、引数に&self
をとります。この関数を呼ぶと、呼び出し元のstructやenumの不変参照を取ります。
また、この関数内でself
と書いたときは、その型は&A
です。
ただし、&A
型のself
から、メンバのx
にアクセスしたいときは、
(*self).x
と書く必要はなく、
self.x
で実現できます。パターン3の&mut A
の場合も同じです。
パターン3
パターン2では、引数に&mut self
をとります。この関数を呼ぶと、呼び出し元のstructやenumの可変参照を取ります。
また、この関数内でself
と書いたときは、その型は&mut A
です。
MyBoxに対するDerefトレイト実装を再読
struct MyBox<T>(T);
use std::ops::Deref;
impl<T> Deref for MyBox<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
Deref
トレイトはある型U
に対して、別の型T
が存在して、&U
から&T
への変換方法を定めるものです。関連する関数としてみると、上の「パターン2」にあたります。
MyBox
に対してDeref
トレイトを実装する例では、&MyBox<T>
から、その中身の型への参照&T
への変換を記述してます。
「型に振る舞いを定義する」ということ
この章は、Rust解説動画の編集者でもあるbesshyさんによる執筆です。
この動画では、「implキーワード」や「トレイト境界」といった「型に特定の振る舞いを持たせる」ことを前提とした概念が出てきます。実際に講義動画の中でも「型に振る舞いを実装する」という言葉が自然に出てきます。プログラミング言語のオブジェクト指向にあまりしっくりきていない人は何を言っているのかよくわからないと思うので簡単に説明します。(執筆者: besshy)
そもそも「型」というのは、プログラムに登場するデータを分類するものです。例えば数値ならi32や文字列ならStringといった感じです。これは単にデータを分類するだけの一種の「目印」に違いありませんが、もっと大きな意味を持っています。例えば以下の(擬似)コードを見てください。
a = 1
b = 1
print(a + b)
## >> 2
c = 'Hello '
d = 'World'
print(c + d)
## >> 'Hello World'
この例はなんとなく当たり前のような例に見えますが、「+の演算子」についてよく見てみると整数a, bに関して「算数の足し算」であり、文字列c,dに関しては「文字列の左右の連結」になっています。このようにプログラムでは変数の型に合わせて演算子の記号の意味を変えています。「型」によって演算子の「振る舞い」が変化するわけですね。
オブジェクト指向型のプログラミング言語において、すべてのデータにはある型が割り当てられておりその型によってデータに対して働きかける演算子の振る舞いが変わります。なので基本的には異なる型同士の演算をすることはできません。演算子の振る舞いが異なるからです。
Rust以外の言語にあるクラスは「型の設計図」であり、その中にその型を持つデータの演算子の振る舞い方を記述します。(動画にもあるように、Rustではimplキーワードを使って振る舞いを記述します。)
##pythonの擬似コード
class Number:
def Add(self, a, b):
hoge ## 数値同士の足し算を行うような処理
class String:
def Add(self, a , b):
fuga ## 文字列同士の連結を行うような処理
上のような擬似コードをイメージするとわかりやすいです。例えばそのデータが数値の型に分類されるようなデータの場合はNumberクラス(Number型)の中に実装されているAddという処理、文字列の型に分類されるようなデータの場合はStringクラス(String型)の中に実装されているAddという処理を使うことができるといった感じです。このようにデータへの演算の振る舞いを持って型を分類するという考え方は動画にも出てくる「トレイト境界」とも通ずるものがあります。
Rustでは自分で新しく構造体に独自の振る舞いをimplキーワードを用いて実装することができます。その構造体に対して自分がしたい独自の演算を考えて、implキーワードで実装することがこの動画における「型に振る舞いを定義する」ということの意味になります。
(補足: 大学数学に詳しい人であれば、これは「集合」や「群」の考え方に近いものです。まずある特定の集合を考え、その上に許される演算を定義することが群論の出発点です。「集合」の部分を「クラス、型」と読み替えれば同じような考え方であることがなんとなくわかると思います。プログラムの「型」は英語に訳すとtypeですが、オブジェクト指向の立場に立つとどちらかというと意味的にはgroupの方が正しい訳なんじゃないかと個人的には思います。)
Discussion