rustでのクロージャとの上手い付き合い方

Javascript(Typescript)をはじめとして、関数型言語のエッセンスを取り入れた多くのプログラミング言語では「クロージャ」を利用できる。実際に自分もTypescriptを書く際にはクロージャのおかげで疎結合を保った実装ができたり、大変便利なテクニックだと気に入っている。

先日からRustを書き始めたのだが、その際にクロージャの書き方・扱い方が随分と難しくて苦労してしまった。 そこで本記事では、クロージャとオブジェクトの関係性を理解し、Rustにおけるクロージャとの上手い付き合い方を探る。

TL; DR

  • クロージャ=環境+操作
  • クロージャはオブジェクトと機能的な変換が可能
  • クロージャで書こうとして5分迷ったらオブジェクトで書き直す

クロージャとは

まずはクロージャが一体何者なのかを理解する。

概念的にはクロージャは関数の一種にあたり、クロージャを使う側はそれがクロージャであるか否かを意識しない。 クロージャを特徴づける性質むしろクロージャを造る側・渡す側の視点から観察する必要がある。

クロージャを語るための大前提として、クロージャは自身の引数以外の変数(a.k.a. 自由変数)を字句的なスコープ(a.k.a レキシカルスコープ)解決*1によって参照する仕組みに立脚する点に触れておく。

次のコードにおいて、foo(に束縛される無名関数)は変数xを引数として受け取っていない。しかしレキシカルスコープに従って参照を解決するため、(main関数の先頭で10に束縛されている)変数xを見つけることができる。

Rust Playground

// rustはレキシカルスコープなので10が出力される
fn main() {
    let x = 10;

    let foo = || {
        dbg!(x)
    };
    
    let bar = || {
        let x = 20;
        foo();
    };

    bar();
}

ここで上記rustのコード例をよく見てみると、bar(に束縛された無名関数)は変数fooしか知らないのに、間接的にfooの外側にあるxを使っていることに気がつく*2

関数とは入力に対して出力が一意に定まる静的な存在のはずだ。それなのに関数(foo)に入力として与えていない情報(x)を使っている。なぜだろうか。

この挙動は「fooが環境を捉えている(変数xを束縛している)」と表現される。そしてまさに「環境を捉える」という性質こそがクロージャを理解する要点のひとつになる。

クロージャが”環境”を捉えることで、クロージャを使う側は”環境”の存在を意識する必要がなくなる。そうでなければ、クロージャ(もはやクロージャではなく純粋関数)を使う側がすべての情報を入力として与えなければいけない。

言い方を変えれば、クロージャをうまく利用することで情報(環境)を隠蔽しモジュールを疎結合に保つことができる。

蛇足だが字句的なスコープ解決を行わない言語ではクロージャを利用できない。具体的にはダイナミックスコープ*3しかサポートしないBashスクリプトではクロージャが書けない。

GDB online Debugger | Code, Compile, Run, Debug online C, C++

# bashスクリプトはダイナミックスコープなので20が出力される
x=10

foo () {
    echo $x
}

bar () {
    local x=20
    foo
}

bar

クロージャとオブジェクトの変換

クロージャを用いて情報を隠蔽する方法について触れたが、情報の隠蔽は何もクロージャの専売特許ではない。 オブジェクト指向の世界でも、不要な情報はprivateメンバとして外から隠し、振る舞い(メソッド)を用いてオブジェクトを操作する。

例えば以下において、関数recordは引数aの実体は知らないが、aがmake_soundを備えていることを知っていてそれを実行できる。 これは呼び出し側が知る必要のない情報(name)をCat構造体が隠蔽していることを意味する。

Rust Playground

trait Animal {
  fn make_sound(&self) -> String;
}

struct Cat { name: String }
impl Animal for Cat {
  fn make_sound(&self) -> String {
    format!("{} says \"meow\"", self.name)
  }
}

fn record (a: impl Animal) {  
  // 説明のためにAmimalトレイトを使っているがrecord(c: Cat)としても変わりはない
  println!("{}", a.make_sound());
}

fn main () {
  let c = Cat { name: "Einstein".to_string() };
  record(c);
}

ところで冒頭でも述べたとおり、クロージャを使う側はそれがクロージャであるか否かを意識せずに(必要であれば引数を受け取りながら)実行できる対象=関数であることだけを知っている。 この構造はたった今見たrecord関数とAnimalトレイト(あるいはCat構造体)の関係と相似している。

実際に上の例はクロージャを用いて書き換えることができる。 関数recordが引数aの実体を知らなくても済んだように、record_with_closure関数も変数cat_nameを認知することはない。

Rust Playground

fn record_with_closure(make_sound: impl Fn() -> String) {
    println!("{}", make_sound());
}

fn main() {
    let cat_name = "Einstein".to_string();
    let c = || { format!("{} says \"meow\"", cat_name) };
    record_with_closure(c);
}

じつはオブジェクトとクロージャはお互いに変換することが可能だ*4。 オブジェクトの状態(name変数)とクロージャの環境(cat_name変数)が対応し、オブジェクトのメソッド(make_soundメソッド)とクロージャ自身に相当する。 オブジェクトを渡す場合には実行側がメソッド名を知っている必要があるが、クロージャであればそもそも実行しかできないので迷う必要がない。

さて、一見するとクロージャを用いたコードのほうが短く簡単なように感じる。 ではオブジェクトを使ったコードをクロージャーを用いてシンプルに書き換える方法があればそれで十分なのだろうか。

じつはクロージャーがFutureを返す場合(非同期処理)*5や、マルチスレッド環境でクロージャーを使う場合など、他の条件が重なった途端に状況が一転する。

例えばクロージャーがFutureを返す場合は impl Fn() -> dyn Future<Output = String> のような型宣言が想像できるが、実際にはクロージャーを実行しawaitするコードを書いた途端にコンパイルエラーで弾かれる。なぜならrustにおいてローカル変数はすべて事前に大きさが分かっている必要があり、dyn Future<Output = String>だけではその情報が足りないからだ(Sizedトレイトを要求される。)かといって、impl Fn() -> Box<dyn Future<Output = String>>などと書けば今度はUnpinトレイトが必要だと指摘される…。

このように、クロージャーを自由に渡せるJavascriptの世界とは違い、rustでクロージャーを扱うには言語に関する広く深い理解が求められる。一方でオブジェクトを使った書き方であれば、基本的な言語知識だけでも簡単に表現できる。

だからこそ、「クロージャーとオブジェクトが変換可能」であるという知識と「クロージャーで書こうと思っていた処理をオブジェクトを使って書き直す」あるいは「オブジェクトを渡していた処理をクロージャーを使って簡潔に書き直す」テクニックを備えておくことが大事になる。

おまけ

オブジェクトを使って非同期処理を渡す際にはasync-traitライブラリが非常に便利だ。 本ライブラリがあればtrait定義でasyncキーワードが使えるようになる。 github.com

おわり

rustに十分に習熟していればいざ知らず、自分のようなビギナーにとってはコンパイルエラーは強敵だ。いとも簡単に作業時間が溶けていく。ランタイムのエラーと異なり、コンパイルエラーはプログラマの精神を削る。何しろ手元になにも動くものが生まれないから。

かっこよく・スマートに書けなくても、まずは迅速に動くものを造る試す届けるために本記事でみてきたテクニックを使ってもらいたい。

*1:簡単に言えば「コードに書かれているとおりにスコープ解決する」方法

*2:そしてbarの中で宣言され20に束縛されている変数xが実際には未使用だということも分かる

*3:コード実行時に「処理が当該行に達した時点でスコープ解決する」方法

*4:厳密にはオブジェクトが備えるメソッドが1つだけの場合に限る

*5:ちなみにクロージャー自体を非同期化することはできない。error[E0658]: async closures are unstableに遭遇する