🦁

Unity C#からふんわり理解した気になるRustのコンセプト

2023/01/01に公開

読んだ。

比較対象がC++とPythonだったのでC#から似たようなことをふんわり考えてみる。

また、100年ぶりくらいに技術記事を書いた気がする。

C#の暗黙的な処理

C#は言語仕様を含めコンパイラが空気を読んでくれることがある。ここにいくつか挙げる。ただし、C#のバージョンによって挙動が異なるかもしれないことに注意したい。

暗黙の型変換

var x = 1 + 2.0f;

1int2.0ffloatで型が異なっているが、コンパイルは通り、実行できる。明示的に書いた場合

float x = (float)1 + 2.0f;

こうなる。コンパイラはこう解釈してくれる。

Boxing

C#の全てのクラスはSystem.Object(object)からの派生だが、これがちょっと困ることがある。

C#は基本的に値型をスタック領域に、参照型をヒープ領域にそれぞれメモリ確保するように動作する。ところが、値型をヒープ領域に置くような状況が存在する。

object v = 1;

これだともしかしたらスタック領域で十分かもしれない。では、この場合はどうか

object Method() => 1;

これはちょっと逃げにくい。このように値をクラスっぽく扱うような処理をボックス化(Boxing)といい、その逆の処理をUnboxingという。

(int)v

ヒープ領域を確保するため、それが不必要になったときガーベッジコレクション(GC)が起動する。これらの処理をコンパイラは暗黙的にやってくれる。

動的ディスパッチ

適当なinterfaceを使って、適当なclassを作ってみる。

public interface Animal
{
    string Hello();
}

public class Dog : Animal
{
    public string Hello() => "Bow";
}

public class Cat : Animal
{
    public string Hello() => "Meow";
}

これに対して2つのメソッドを考えてみる。まず1つ目

string MyHello1(Cat cat) => cat.Hello();

もう1つは

string MyHello2(Animal animal) => animal.Hello();

これは実行段階になってから行うべき処理が決まる。メソッドを呼ぶ(call)とは

  • 次に実行すべきコードのアドレスがインストラクションポインタに格納されているとして
  • これをスタック領域に格納し、別の特定のアドレスをインストラクションポインタに格納することで、別のアドレスに飛び(jump)
  • メソッドに相当するコードの終端で、スタックを改めて参照することで、戻ってくる(return)

という処理を行う。1つ目のメソッドは飛ぶべきアドレスがわかっている、2つ目のメソッドは飛ぶべきアドレスがわからない。そのため、動的に呼ぶべきメソッドを探す必要がある。また、引数と戻り値はスタック領域に積まれるから、これらのサイズがわからないという状況は複雑さをもたらす。

別の形として

Cat MyAnimal1() => new Cat();

また、

Animal MyAnimal2() => new Cat();

実行前に処理を決定できるものを静的ディスパッチ、実行段階になって行うべき処理が決まるものを動的ディスパッチという。C#のコンパイラは、静的な処理か動的な処理かのどちらかが必要なのかを適切に解釈してくれる。

ラムダ式

ラムダ式はLINQでよく使われる気がする。

var y = new int[] { 1, 2, 3, 4, 5 };
var z = y.Select(x => x + 1).ToArray();

その実態はSystem.Funcである。

System.Func<int,int> f = x => x + 1;

仮想メモリ空間上で、これがどう扱われるか考えてみる。

  • x => x + 1自体は命令のかたまりなのでテキスト領域におかれる
  • System.Funcはクラスなのでfの中身はヒープ領域におかれる
  • 中身を参照するf自体はスタック領域におかれる

fの中身とは、それが包むローカル環境、すなわちクロージャを示す。上の場合は、そもそも変数をキャプチャするような式ではないので、キャプチャする式を考えてみる。

System.Func<int, int> f = x => x + b;

ここまでは良い。問題はこれがどう使われるかだ。

まず1つ目、GCが起動しないであろう状況をつくってみる。

public class NewBehaviourScript : MonoBehaviour
{
    System.Func<int, int> f = x => x + 1;

    void Update()
    {
        Debug.Log(f(5));
    }
}

コードはUnityを前提にしているが、60FPSなら1秒間にUpdate()が60回実行される。変数ではなく定数なのでこれならGCは動かないだろう。Profilerを見てみる。

ここでは、これをGC Allocの最小と捉える。次に2つ目、

public class NewBehaviourScript : MonoBehaviour
{
    int b = 1;
    System.Func<int, int> f;

    void Start() {
        f = x => x + b;
    }

    void Update()
    {
        Debug.Log(f(5));
    }
}

さっきと変わらない。これは奇妙と思う。明らかに外部の変数をキャプチャしているのに開放されるメモリサイズ(つまり確保したメモリサイズ)に変化がない。少し改造してみる。3つ目として

    void Update()
    {
        b = (b + 1) % 480;

        Debug.Log(f(5));
    }

毎フレーム、bを変化させたら4B増えた。bは32bitのintだからその分が増えたと考えるのが自然だ。これを考察するより先に別の状況を見てみる。4つ目は

public class NewBehaviourScript : MonoBehaviour
{
    int b = 1;

    void Update()
    {
        System.Func<int, int> f = x => x + b;

        Debug.Log(f(5));
    }
}

大きく増加している。毎フレーム、環境を作って破棄をしていることが見て取れる。さらに5つ目

public class NewBehaviourScript : MonoBehaviour
{
    void Update()
    {
        const int b = 1;
        System.Func<int, int> f = x => x + b;

        Debug.Log(f(5));
    }
}

・・? もとに戻った。System.Func<int, int>の分が毎フレーム、ヒープに確保されそうだがそうでもないらしい。最後に6つ目、

public class NewBehaviourScript : MonoBehaviour
{
    void Update()
    {
        int b = 1;
        System.Func<int, int> f = x => x + b;

        Debug.Log(f(5));
    }
}

こっちは増える。

答えとしては、C#のコンパイラは外部からのキャプチャが必要と判断する場合に、キャプチャするようなコードを生成する。逆に毎回必要ないのであれば、最初にキャッシュしそれを使い回すようなコードを生成する。これをコンパイラが暗黙的に努力する。

C#の振り返り

これまで見てきたとおり、C#は良くも悪くもコンパイラが暗黙的な努力をしてくれる。これが嬉しいこともあるし嬉しくないこともある。とっつきやすさや直感的という観点では嬉しいと思う。逆に、パフォーマンスを求められる場合は嬉しくないことのほうが多いだろう。

foreach (var x in xs.Where(x => x > a).Select(x => x - b))
{
    Method(x);
}

GCが動きそうな箇所が5箇所ある。C#のバージョンにもよるが古典的な書き方が最も速い。

for (var i = 0; i < xs.Length; i++)
{
    var x = x[i];
    if (x <= a) break;
    x = x - b;
    Method(x);
}

「GCがいつ動くかわからない」というのは少し語弊があると思う。リファレンスカウントによるGCはカウンタがゼロになったときにメモリを開放する(ここではメモリリークの問題は扱わない)。「いつ動くかはわかるはずだがそれをコンパイラが暗黙的にやってくれるからわかりにくい」という感じだろうか。

今回取り上げたC#の暗黙的な処理について、コンパイラは全てを理解している。人間が暗黙的に書いて、コンパイラがそれを明示的な形に変換する。コンパイラに任せずに人間が明示的に書いたほうが良いのでは? その可能性はある。そこでRustへ行く。

Rustの明示的な記法

Rustは暗黙的な型変換を認めない。

let x = 1 + 2.0; // error

未来の悲劇はこうして回避された。

let x: i32 = 1 + 2;

上記について、若干面倒に見えかもしれないが実際にはエディタのサポートが強力なので見た目ほど煩わしくはない(: i32は表示上こう見えるだけで実際には書いていない。この観点で、周辺のツールが強力でなければ暗黙的=サポートなしで書きやすい方向に流れやすいと思う)。

Boxing

Rustは明示的にBoxingを行う。

let x = Box::new(5);

xの中身(ヒープに確保された領域)はxのライフタイムが終了した時点で開放される。

動的ディスパッチ

適当なトレイトと構造体を用意してみる。

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

struct Dog {}

impl Animal for Dog {
    fn hello(&self) -> String {
        String::from("Bow")
    }
}

struct Cat {}

impl Animal for Cat {
    fn hello(&self) -> String {
        String::from("Meow")
    }
}

Stringを気軽に使ってしまっているがここは本筋でないので許してほしい。

これを使ってまず1つ目

fn hello1(cat: Cat) -> String {
    cat.hello()
}

これは静的に決まる。では動的ディスパッチをどう書くかというと

fn hello2(animal: Animal) -> String { // error
    animal.hello()
}

こう書くことは許されない。

the size for values of type (dyn Animal + 'static) cannot be known at compilation time

コンパイル時点で値のサイズがわからないと言われる。そのとおりでわからない。どうするかというと

fn hello2(animal: Box<dyn Animal>) -> String {
    animal.hello()
}

動的ディスパッチであることを明示する。

クロージャ

Rustのクロージャはこう書く。

let f = |x| x + 1;
let y = f(2);

これは良い。型はFn(i32) -> i32となる。

let b = 1;
let f = |x| x + b;

これも良い。ではこれは

let mut b = 1;
let f = |x| x + b;
b = 2;

かなり恣意的に書いてみたがこれは許されない。

cannot assign to b because it is borrowed

同じことをしたければ例えば

let f = |x, b| x + b;

そもそもキャプチャしなければシンプルになる。次に、関数を返す関数を考えてみよう。

fn func() -> impl Fn(i32) -> i32 {
    |x| x + 1
}

これは動作する。implBox<dyn >のsyntax sugarなのでBoxを使っても書ける。問題はこういう場合

fn func() -> impl Fn(i32) -> i32 {
    let b = 1;
    |x| x + b // error
}

これはコンパイルエラーとなる。

to force the closure to take ownership of b (and any other referenced variables), use the move

func()が呼び出し終わったとき、bのライフタイムは終了する。クロージャはbを借用しているが、所有権をもっていない。そこで

fn func() -> impl Fn(i32) -> i32 {
    let b = 1;
    move |x| x + b
}

Rustは明示的にクロージャの外の値をキャプチャする。

まとめ

総じて

  • 曖昧さをなくして明示的に書いていこう
  • 1つの変数に複数からwriteするのは事故るからやめよう
  • 事故の原因になりそうなものは本当に必要なとき以外は使わないようにしよう
  • 周辺ツールが強力なので書いていてあまり困らない

というマインドが見受けられるなあという感想。

Discussion