クロージャと第1級関数
クロージャと第1級および、高階関数は、Rustの核心部分である。
CとC++には、関数ポインタ(とC++限定で、まったく要領のわからなかった奇妙なメンバ/メソッドポインタとかいうもの)があった。とはいうものの、比較的使われる機会は少なく、さほどプログラマーフレンドリーでもなかった。
C++11でラムダ式が導入され、こちらは、Rustのクロージャに瓜二つのものである。特に、実装方法が似通っているという点でね。
手始めに、これらの概念のさわりに触れておきたい。それから、詳細に移っていこう。
ここに
foo関数があるとしよう。定義は
pub fn foo() -> u32 { 42 }だ。
さらに、別の関数
barを思い浮かべよう。こちらは、引数に関数を取る(
bar関数の見た目は、後述する)。宣言は
fn bar(f: ...) { ... }。
foo関数を
bar関数に、Cで関数ポインタを渡すような感じで与えることができる -
bar(foo)。
bar関数の内部で引数
fを関数かのように呼び出すことができる -
let x = f();。
Rustには第1級関数が存在すると言う。理由は、関数を持ち回り、他の値同様に使うことができるからだ。
また、関数
barは高階関数であると言う。理由は、関数を引数に取る、つまり、関数を操作する関数だからだ。
Rustのクロージャは、書きやすい記法の無名関数だ。
|x| x + 2というクロージャは、引数を一つ取り、それに
2を足して返す。なお、クロージャの引数に対して型を明示する必要はない(大抵型推論される)。また、戻り値も然りだ。
クロージャ本体が式1つ以上になる場合は、大かっこを使う -
|x: i32| { let y = x + 2; y }。
クロージャも関数と同じように引数に渡せる -
bar(|| 42)。
クロージャとその他の関数の大きな違いは、クロージャが周りの環境を保持することにある。これはつまり、クロージャ内からクロージャ外の変数を参照できるということである。
let x = 42;
var(|| x);
変数
xがクロージャのスコープ内に存在するあり方に注目してほしい。
以前にもクロージャは見かけてきており、その時はイテレータとともに使用していた。これは、よくある使用方法である。具体例: ベクターの各要素に値を加える。
fn baz(v: Vec<i32>) -> Vec<i32> {
let z = 3;
v.iter().map(|x| x + z).collect()
}
ここで、引数
xはクロージャに対するものであり、変数
vの各要素が引数
xとして渡されてくる。変数
zは、クロージャの外部で定義されているが、クロージャであるがゆえに参照することができる。また、mapメソッドに関数を渡すこともできる。
fn add_two(x: i32) -> i32 {
x + 2
}
fn baz(v: Vec<i32>) -> Vec<i32> {
v.iter().map(add_two).collect()
}
ちなみに、Rustでは、関数内関数も定義することができる。こちらは、クロージャではなく、つまり、環境にはアクセスできない。スコープを限るためにあるようなものである。
fn qux(x: i32) {
fn quxx() -> i32 {
x // エラー: 変数xはスコープにない
}
let a = quxx();
}
関数タイプ
新しい例題関数を導入しよう。
fn add_42(x: i32) -> i64 {
x as i64 + 42
}
以前にも見かけたように、関数を変数に代入することができる。例:
let a = add_42;
この時、変数
aの厳密な型は、Rustでは記述できない。時折、コンパイラがこれをエラーメッセージ内で
fn(i32) -> i64 {add_42}と表現しているのを目撃するだろう。
各関数は、各々固有かつ匿名の型を持っている。宣言が同じ見た目でも、
fn add_41(x: i32) -> i64は、異なる型になる。
いささか正確でない
let a: fn(i32) -> i64 = add_42などの型名なら、記述することができる。宣言が同じ関数はすべて、
fn型に簡約化される(これなら、プログラマが記述できる)。
変数
aはコンパイラに関数ポインタとして表現されるが、厳密な型を把握している場合、コンパイラはその関数ポインタを実際には使用しない。a()のような呼び出しは、aの型に基づいて静的に行われる。もし、コンパイラが(fn型であることしか把握していないなど)厳密な型を把握していない場合、呼び出しは値に含まれる関数ポインタを使用して行われる。
Fn型(先頭のFに注目)というのもあり、trait同様、制約である(実際のところ、traitである。いずれわかるだろう)。
Fn(i32) -> i64はこのような宣言を持つ関数様オブジェクトの型に対する制約である。関数ポインタへの参照を取得すると、実際には、非正規化ポインタ(DSTの箇所を参照)で表されるtraitオブジェクトを生成していることになるのだ。
関数を別の関数に引き渡したり、フィールドに代入するには、型を記述しなければならない。書き方はいくつかあり、
fn型とも
Fn型とも書くことができる。
このうち、後者の方が望ましい。なぜなら、これにはクロージャ(や可能性として他の関数様のオブジェクト)も含まれるからだ。一方、
fn型には含まれない。
Fn型は動的サイズ付けである。つまり、値として使用することはできない。
関数オブジェクトを渡すか、ジェネリクスを使うかのどちらかにしなければならない。まず、ジェネリクスを使う方法を見てみよう。
fn bar<f>(f: F) -> i64
where F: Fn(i32) -> i64
{
f(0)
}
関数
barは、
Fn(i32) -> i64という宣言を持つ関数ならば、どんなものでも受け取ることができる(つまり、
Fという型引数に対して、あらゆる関数様の型で実体化することができる)。
bar(add_42)と呼び出して、関数
add_42を関数
barに渡せば、型引数
Fを
add_42の匿名型で実体化する。また、
bar(add_41)と呼び出しても動作する。
さらに、クロージャを関数
barに渡すこともできる。例:
bar(|x| x as i64)
これが動作するのは、クロージャの型も宣言に合致する
Fn型制約に紐づけられているからだ(関数のように、クロージャも各々、独自の匿名型を持っている)。
最後に、関数やクロージャへの参照を引き渡すこともできる。例:
bar(&add_42)や
bar(&|x| x as i64)
関数
barは、
fn bar(f: &Fn(i32) -> i64) ...とも書くことができる。これら2種のアプローチ法(ジェネリクスと関数/traitオブジェクト)は、全く異なる意味を持っている。
ジェネリクスの場合、関数
barは単態化(造語: polymorphize - 多態化の逆から。単射化でもいいか?)され、コード生成時には、コンパイラが
fの型を把握できるので、静的ディスパッチされる。
関数オブジェクトを使用しているなら、関数は単態化されない。
fの厳密な型がわからないので、コンパイラは仮想ディスパッチコードを生成せねばならない。
後者の方がスピードが遅いが、前者はコード生成量が多くなる(型引数インスタンス一つにつき単態化された関数が一つ)。
実は、
Fn型以外にも関数traitは存在する。
FnMutと
FnOnceである。使い方は、
Fn型と同じである。例:
FnOnce(i32) -> i64
FnMut型は、呼び出し中に可変化できるオブジェクトを表す。これは、普通の関数には適用されず、クロージャに作用してクロージャが環境を可変化できるようにする。
FnOnceは、(最大でも)1回しか呼び出せない関数を表し、こちらもまたクロージャにしか関連しない。
Fn型と
FnMut型、
FnOnce型は、継承関係にある。
Fn型は
FnMut型でもあり(
Fn型の関数を可変化許可を得た状態で呼び出しても何ら害はないが、逆は言えない)、
Fn型と
FnMut型は、
FnOnce型でもある(通常の関数を1回しか呼び出さなくても害はないが、逆は言えない)。
以上より、高階関数をなるべく柔軟にするには、
Fn型ではなく、
FnOnce型を使うべきだ(あるいは、この関数を2回以上呼び出す必然性があるなら、
FnMut型を使う)。
メソッド
メソッドは、関数と全く同じように使用できる。つまり、ポインタ化したり、変数に代入するなど。ドット演算子は使用できず、メソッド名をフルネーム(UFCS - universal function call syntax ~普遍的関数呼び出し記法~と呼ばれることもある)で記述しなければならない。
self引数がメソッドの第一引数になる。
struct Foo;
impl Foo {
fn bar(&self) {}
}
trait T {
fn baz(&self);
}
impl T for Foo {
fn baz(&self) {}
}
fn main() {
// 固有メソッド
let x = Foo::bar;
x(&Foo);
// traitメソッド。フルネームで記述していることに注目
let y = <foo as T>::baz;
y(&Foo);
}
汎用メソッド
汎用メソッドへのポインタを取得することはできず、汎用関数型を表現する手段も存在しない。しかし、全型引数がインスタンス化されていれば、関数への参照を取ることができる。
fn foo<T>(x: &T) {}
fn main() {
let x = &foo::<i32>;
x(&42);
}
汎用クロージャを定義する方法もない。複数の型に作用するクロージャが必要ならば、traitオブジェクトやマクロ(でクロージャを生成すること)を使ったり、クロージャを返すクロージャ(返ってくるクロージャごとに違う型に作用する)を渡せばいい。
汎用ライフタイム関数と超高位型(higher-ranked type)
ライフタイムについて汎用的な関数型やクロージャを存在させることができる。
無所有権参照を取るクロージャを想像してほしい。参照のライフタイムが何であれ、このクロージャは同じ挙動をするが(また、実際のところ、コンパイル済みのコードからは、ライフタイムは消去される)、その型定義はどんな感じだろうか?
fn foo<f>(x: &Bar, f: F) -> &Baz
where F: Fn(&Bar) -> &Baz
{
f(x)
}
この時、参照のライフタイムは何になるだろうか?この単純な例では、単一ライフタイムを使用しても構わない(汎用クロージャを使う必要性はない)。
fn foo<'b, F>(x: &'b Bar, f: F) -> &'b Baz
where F: Fn(&'b Bar) -> &'b Baz
{
f(x)
}
しかし、変数
fに異なるライフタイムを入力できるようにする必要があったらどうだろうか?その場合は、汎用的な関数型が必要になる。
fn foo<'b, 'c, F>(x: &'b Bar, y: &'c Bar, f: F) -> (&'b Baz, &'c Baz)
where F: for<'a> Fn(&'a Bar) -> &'a Baz
{
(f(x), f(y))
}
ここでの新規要素は、
for<'a>という箇所であり、これは、ライフタイムについて汎用的な関数型を記述し、「すべての'a, ...に対して」と読む。専門用語で言えば、この関数型は普遍定量化されているという。
なお、上述の例で
'aを
fooに捕らえさせることはできない。反例:
fn foo<'a, 'b, 'c, F>(x: &'b Bar, y: &'c Bar, f: F) -> (&'b Baz, &'c Baz)
where F: Fn(&'a Bar) -> &'a Baz
{
(f(x), f(y))
}
これはコンパイルが通らない。なぜなら、コンパイラが
fooの呼び出しに対してライフタイムを推論する際、
'aに対して単一のライフタイムを選択しなければならないが、
'bと
'cが異なる場合にはそれができないからである。
このような感じで汎用的な関数型は、超高位型(higher-ranked type)と呼ばれる。上層のスコープのライフタイム変数は、ランク1になる。上記の例の
'aは、上位スコープに移動できないため、このランクは2以上ということになる。
超高位関数型の引数を持つ関数を呼び出すのは簡単だ。コンパイラがライフタイム引数を推論してくれる。例:
foo(&Bar { ... }, &Bar {...}, |b| &b.field)
実際問題、たいていの場合、そのようなことを気にかける必要さえない。関数引数のライフタイムを省略できるのと同じようにして、コンパイラが定量化されたライフタイムを省略させてくれる。具体的には、上記の例を以下のように書き換えることができる。
fn foo<'b, 'c, F>(x: &'b Bar, y: &'c Bar, f: F) -> (&'b Baz, &'c Baz)
where F: Fn(&Bar) -> &Baz
{
(f(x), f(y))
}
(これは不適切な例なので、
'bと
'cしかライフタイムパラメータは必要ない)
Rustにおいて、無所有権参照を含む関数型が認識される箇所には、通常の省略ルールが適用され、この関数型(すなわち、超高位型)のスコープにおいて省略された変数の定量化が行われる。
このような非常に稀な使用例に対して、なぜこんなに悩まされなければならないのかと疑問に思っているかもしれないね。本当のきっかけは、外部の関数から渡されるデータに処理を施す関数を引数にする関数なのだ。
fn foo<f>(f: F)
where F: Fn(&i32) // 完全明示記法: for<'a> Fn(&'a i32)
{
let data = 42;
f(&data)
}
このようなケースの場合は、超高位型が必要不可欠になる。代替手段として関数
fooにライフタイム引数を追加したとしても、正常なライフタイムを推論することはできない。その理由を探るために、どのような挙動をするのか見てみよう。
fn foo<'a, F: Fn(&'a i32')> ...というものを考えてほしい。
Rustでは、いかなるライフタイム引数も、自分が定義されている文法項目より長生きすることが必須条件になる(このような条件がない場合、このライフタイムを持つ実引数が、その関数内で使用できるが、ここでの存在は保証されないことになってしまう)。
foo関数内で、
f(&data)という記述をしており、この参照のライフタイムはコンパイラにより推論され、(最大でも)変数
dataが定義された箇所から、この変数がスコープを抜けるまでの間存在する。ライフタイム変数
'aは、関数
fooよりも長生きせねばならないが、ここで推論されたライフタイムはそうはならないため、このような方法で関数様オブジェクト
fを呼び出すことはできない。
ただ、超高位型ライフタイムがあれば、オブジェクト
fはどんなライフタイムでも受け取れることになり、
&dataの無名ライフタイムも道理が通るので、この関数型もチェックが付くのだ。
Enumコンストラクタ
少し関係のない話をするが、役に立つこともある豆知識を紹介しょう。enumの状態はすべて、その状態のフィールドからenum自体にマッピングする関数を定義している。
enum Foo {
Bar,
Baz(i32),
}
これは二つ関数を定義する。
Foo::Bar: Fn() -> Fooと
Foo::Baz: Fn(i32) -> Fooというものだ。通常、各状態をこのように使うことはない。状態は関数というよりも、データ型として扱われるのだ。しかし、時として役に立つこともある。具体的には、
i32型のリストがあるとして、以下のようにしてenum
Fooのリストを作れたりすることだ。
list_of_i32.iter().map(Foo::Baz).collect()
クロージャの風味付け
クロージャは2種類の入力を持つ。明示的に引き渡される実引数と、環境から横取りする変数だ。普段なら、いずれの入力も推論されるので、(訳注: 特に気にかける必要はないが)、必要に応じて細かい制御を行うこともできる。
実引数に関しては、コンパイラに型推論させるのではなく、型を宣言することができる。これは戻り値の型にも適用できる。
|x| { ... }と書く代わりに
|x: i32| -> String { ... }と書けばいい。実引数が所有権ありになるか、所有権なしになるかは、(宣言されていようが、型推論だろうが)型によって決まる。
捕捉される変数については、ほとんどの場合、型はその環境からわかっているが、もう少し魔法の呪文があるのだ。捕捉変数が参照渡しになるか、値渡しになるか?
コンパイラは、クロージャ本体からこの情報を推論し、可能な限り、参照渡しをしてくれる。
fn foo(x: Bar) {
let f = || { ... x ... };
}
すべてがつつがなくいっていれば、クロージャ
f内で、引数
xは関数
fooの生存期間を持つ
&Bar型になる。
ところが、引数
xが可変な場合、捕捉変数は可変参照渡しと推論され、すなわち引数
xの型は
&mut Barになる。引数
xがクロージャ
f内でムーブ(変数や値型のフィールドに代入されるなど)されていると、捕捉変数は値渡しと推論され、すなわち型は
Bar型となる。
この挙動は、プログラマーが変更できる(クロージャがフィールドに代入されたり、戻り値になる場合には必要になることもある)。クロージャの前に
moveキーワードを付ければ、捕捉変数はすべて値渡しされるようになる。具体例:
let f = move || { ... x ... };と書くと、引数
xの型は常に
Bar型になる。
先刻、関数の種類について話をした。つまり、
Fn、
FnMut、
FnOnceの話だ。今なら、なぜこれらが必要なのか説明がつく。
クロージャにおいて、可変性と呼び出しの唯一性は、捕捉変数にかかるものである。捕捉によって、捕捉された変数が一つでも可変になったら、
FnMut型になる(なお、コンパイラにより推論されるものなので、宣言は必要ない)。
変数がクロージャにムーブされていたら、つまり、値渡しになっていたら(
moveの明示と型推論によるもの、両方の可能性がある)、クロージャは
FnOnce型になる。このようなクロージャを二度以上呼び出してしまうと、複数回捕捉変数がムーブされることになるので、危険である。
コンパイラは可能な限り、クロージャが柔軟な型になるよう、推論を行う。
実装
クロージャは、無名構造体として実装されている。この構造体が、クロージャの捕捉変数を各々フィールドとして格納しているのだ。この構造体は、単独のライフタイム引数を持ち、これが捕捉変数のライフタイムに制約としてかかる。また、この無名構造体は
callという名のメソッドを持ち、これを使ってクロージャを実行する。
fn main() {
let x = Foo { ... };
let f = |y| x.get_number() + y;
let z = f(42);
}
例として、上記のクロージャを考えよう。これをコンパイラは、以下のように解釈する。
struct Closure14<'env> {
x: &'env Foo,
}
// 実際の実装とは異なる。以下を参照
impl<'env> Closure14<'env> {
fn call(&self, y: i32) -> i32 {
self.x.get_number() + y
}
}
fn main() {
let x = Foo { ... };
let f = Closure14 { x: x }
let z = f.call(42);
}
前述したように、3つの異なる関数traitが存在する。
Fnと
FnMut、
FnOnceだ。現実的には、
callメソッドはこれらのtraitが必要とするものであって、実装に固有であるものではない。
Fn型の
callメソッドは、特殊変数
selfを参照で取り、
FnMut型の
call_mutメソッドは可変参照で、
FnOnce型の
call_onceメソッドは特殊変数
selfを値で取る。
ここまで見てきた関数型は、
Fn(i32) -> i32のような形をしており、あまりtrait型らしくない。ここには、少し秘密の呪文がかかっているのだ。コンパイラは、この丸括弧形砂糖を関数型にしか、かけさせてくれない。通常の型(山括弧型)に精製すると、引数の型がタプルとして扱われ、型パラメータで渡され、戻り値型も
Outputと呼ばれる結合型になる。
以上より、
Fn(i32) -> i32は、
Fn<(i32,), Output=i32>という型に精製されるので、
Fn traitの定義は以下のようになる。
pub trait Fn<args> : FnMut<args> {
fn call(&self, args: Args) -> Self.Output;
}
したがって、上記のClosure14型の実装はむしろこうなる。
impl<'env> FnOnce<(i32,)> for Closure14<'env> {
type Output = i32;
fn call_once(self, args: (i32,)) -> i32 {
...
}
}
impl<'env> FnMut<(i32,)> for Closure14<'env> {
fn call_mut(&mut self, args: (i32,)) -> i32 {
...
}
}
impl<'env> Fn<(i32,)> for Closure14<'env> {
fn call(&self, args: (i32,)) -> i32 {
...
}
}
この関数traitは、
core::opsモジュール内に存在する。
先ほど、ジェネリクスを使用すると、静的ディスパッチになり、traitオブジェクトを使用すると、仮想ディスパッチになる話をした。今なら、もう少しその理由について突っ込んで話すことができる。
callメソッドの呼び出しは、静的メソッドディスパッチになり、仮想ディスパッチにはならない。単態化された関数を渡しても、やはり型は静的に把握できるため、静的ディスパッチになる。
クロージャをtraitオブジェクトに押し込むことができる。具体例:
&fや
Box::new(f)で型は
&Fn(i32)->i32と
Box<Fn(i32)->i32>になる。
これらは、ポインタ型になり、traitを指しているので、非正規化ポインタである。要するに、データ自体を指すポインタとvtable(翻訳者注: C++などで使われる仮想メソッドルックアップテーブルのこと。これとメタデータを用いて、実際に呼び出すメソッドの実装を決定する)を指すポインタで構成されるということだ。vtableを使用して、
callメソッド(や
call_mutメソッドなど何でも)のアドレスを参照する。
時として、これら2種のクロージャの表現方法を箱詰め型クロージャや非箱詰め型クロージャなどと呼んでいるのを見かけるだろう。非箱詰め型クロージャは、静的ディスパッチによる値渡し型、箱詰め型クロージャは、動的ディスパッチによるtraitオブジェクト型のものをいう。
かつてRustには、箱詰め型のクロージャしか存在しなかった(また、システムも全く異なっていた)。
参考資料
注: 以下の資料はすべて、英語表記
翻訳者後記: お疲れ様でした。これにて、Rust for C++ programmersブログポストシリーズの翻訳は終わりです。原文にはTODOが記載されていて、今後加筆修正がありそうなため、適宜修正は行うつもりですが、原文とリンクしていなくても怒らないでください。
この記事を通して、一人でも多くのC++プログラマーがRustの利便性や動作原理などを理解していただけたら、翻訳者冥利に尽きます。