®️

C# で Rust する

に公開
9

まずは Rust の所有権について理解する

Rust が初めて触るプログラミング言語でない場合、所有権システムは完全に忘れたほうが良いです。プログラミング言語としての根本は他の言語と変わらないので Rust の特殊性のみ抑えればおkです。

  • 所有権: インスタンス(実体)のこと
  • 借用: ポインターのこと
  • Rust の特殊性
    1. ✅ 全ての操作が「移動渡し=実体を移動する」(暗黙的・意味的 std::move
      • ✅ ローカル変数間でのやり取りもコピーじゃない
      • ❌ 構造体だから値渡し(コピー)して所有権だけを移動しているって訳よ!(違う)
      • ❌ 移動ってどこでもドアと同じでコピーしてから元を消すってことだよね?(技術的な話じゃない)
    2. ✅ 可変ポインター(&mut == T* T* const)は同時に一つしか存在できない
      • ✅ どうして? ← Rust だから
    3. 番外: Rust にクラス(参照型)の概念はない

std::move は「コピーしてから元のデータを nullptr(0)にする」もので、これにより「実行時に」予期せぬデータの書き換えと読み取りを防ぐことが出来ます。

Rust はボローチェッカー(≒リンター/アナライザー)によってコレを実行時ではなく「コンパイル時に」「静的に」完全にチェックできるのが革新的。

(なので C# でもボローチェッカーを作れば同じメモリ安全性を手に入れられるハズ。GC レスにはできないが!)

例)構造体だとかヒープ使ってないだとか関係ない

ローカル変数間のコピーも不可能。(エラーに書いてある通り Copy トレイトを実装すればできる)

実行環境: https://play.rust-lang.org/

struct Test {
}

fn main() {
    let foo = Test { };
    let _bar = foo;
    let _baz = foo;
}
error[E0382]: use of moved value: `foo`
 --> src/main.rs:7:16
  |
5 |     let foo = Test { };
  |         --- move occurs because `foo` has type `Test`, which does not implement the `Copy` trait
6 |     let _bar = foo;
  |                --- value moved here
7 |     let _baz = foo;
  |                ^^^ value used here after move
  |
note: if `Test` implemented `Clone`, you could clone the value
例)再帰的に移動チェックが行われる

valuei32 のように Copy トレイトを実装している場合、後者は成功する。

let foo = Foo { value: ... };

// エラー: 移動したインスタンスへのアクセスを試みています
baz(foo, foo.value);
         ~~~~~~~~~

// 👇 順番を変えると、、、

// エラー: 一部が移動された 'foo' を操作しています
bar(foo.value, foo);
               ~~~
その他箇条書き

※ 「参照/ポインター/その他類するもの」に関しては、全てのプログラミング言語とその文脈において厳密な表現になっていませんが、本筋から外れるためココでは割愛します。[1]

--

  • 実体には常に所有権が付きまとう/実体から所有権を剥がすことは出来ない
    • つまり所有権=インスタンス(実体)のこと
  • 借用はポインターのこと
    • ポインター(=インスタンスのメモリアドレス)をやり取りしても当然インスタンス(=所有権)そのものは移動しない
    • 要するに普通の話をしている
  • Rust にはクラスの概念が無い/参照型の概念が無い
    • 参照渡し/値渡し(コピー渡し)の概念もまた無くなっている
      • 論理的な話で技術的な話(メモリー操作等)とは関係ない
    • メソッド間でのやり取りは全てインスタンスの移動を伴う「移動渡し」になっている
      • 👉 コレが特殊で「struct(構造体)を値渡ししてるのに所有権が移るの!?(構造体だからコピーでしょ?)」で分けわからんくなる
        • 既定が暗黙的 std::move
        • Rust 以外の言語は値型は値渡しがデフォ
        • Rust は全てが移動渡し
          • Copy トレイトを実装すると従来通りの値渡し(コピー渡し=コピーして移動)が可能
          • インスタンスが複製される(=所有権が複製される)ので渡せるようになる
            • 所有権のみ複製しているわけではないので、値を編集したら別のインスタンスに自動的に値が適用されるといったことは無い
        • 要するに普通の話をしている
  • 所有権が移らない「借用」とはつまりは参照渡し(参照の値渡し)を意味している
    • & 演算子を使っていることから分かるように単なるポインターでしかない
      • 👉 Rust が特殊なのは、書き込み権限アリのポインター(T*T* const)を同時に一つしか作れない制約があること
      • Rust では様々なモノ/コトに既定で強い制限がかかっている(ポインターは const T* const がデフォ)
    • ポインター(実体の参照)を移動渡ししてもインスタンスそのものは移動しない
      • 所有権(=インスタンス)は移動しない
    • 要するに普通の話をしている
  • ライフタイム=(ほぼ)変数のスコープ
    • 所有権を持つ変数がスコープを抜けると自動的に破棄(Drop::drop が呼ばれる)
    • 「ライフタイム注釈が~~」というエラーが出たからと言って &'static に変えたら「ローカル変数の延命が出来る」とかは無い
      • つまりライフタイムは単なるコンパイラー向けのメタデータでしかない
      • ライフタイム=変数のスコープ
    • 要するに普通の話をしている
      • ※ 厳密には「最長は変数のスコープと一致」で、伸ばすことは出来ないが短く設定することは可能なので完全にイコールではない

Rust と std::move

ここまでプログラミング言語上の意味的なことろを見てきましたが、ここでは Rust の機械語的側面を掘り下げて見ていきます。

  • Q: 所有権というコーディング上の概念に興味は無いです
  • Q: 元の値をクリアするという処理は LLVM で行われている? それともコンパイル時に保証できているからスキップしている?

https://zenn.dev/sator_imaging/scraps/c91c377005c257

Rust コンパイラーは LLVM 向けの中間表現(Intermediate Representation)を生成します。[2] これは TypeScript と JavaScript の関係に似ています。

  • TypeScript の型システム情報の一切がトランスコードされた JavaScript に残らない
  • Rust も同様にボローチェッカーによる解析情報等はコンパイル結果からは完全に消滅する

C# で Rust する

残念ながら完全には Rust 出来ません。

AbsoluteOwnershipBorrow

https://github.com/sator-imaging/Unity-Fundamentals/blob/adaa8a433284481407ac5c9cdfb88d7cf6dca3f0/Runtime/FancyStuff/RustSharp.cs

つかいかた

読み取り専用フィールドとして AbsoluteOwnership を宣言。

readonly AbsoluteOwnership<int[]> _arrayOwnership;

foo が所有権を取得すると bar の所有権は無効になる。(bar の中身にアクセスしようとするとぬるぽ)

foo._arrayOwnership.Take(bar._arrayOwnership);

int[] として扱いたい場合は一度所有権を手放す。

var rawData = foo._arrayOwnership.Move();  // 所有権を移動!
var result = DoSomething(rawData);

処理が終わったら再度所有権を取得する。

foo._arrayOwnership.Take(ref result);  // result はヌルになる

Borrow へは暗黙的に変換可能だけど ref 構造体なので長い期間保持できない制約がある。

DoSomething(foo._arrayOwnership);

void DoSomething(Borrow<T> array)
{
    foreach (var value in array.Read) { }
}

抜け穴

前提として以下を満たす必要があります。

  • AbsoluteOwnership<T>readonly フィールドに保持する
  • AbsoluteOwnership<T> をメソッドパラメーターにしてはならない

コピーを防げない

Rust はローカル変数間でのやり取りも移動渡しですが、C# ではどうやっても暗黙的コピー作成を防げません。

var array = foo._arrayOwnership.Move();
var violation = array;

// 移動した所有権を再取得し、コッソリ作ったコピーを漏出する
foo._arrayOwnership.Take(ref array);
DoSomething(violation);

コピーを防げない2

メソッドパラメーターとして所有権を受け取った場合も複製になってしまう。

なので使用に抵抗感のある AbsoluteOwnership<T> という名前にして運用でカバー。

DoSomething(foo._arrayOwnership);

void DoSomething(AbsoluteOwnership<T> implicitCopy)
{
    //...
}

コピーを防げない3

Borrow<T>ReadClone は完全に雰囲気モノなのでコピーし放題。

void DoSomething(Borrow<T> array)
{
    // Read と Clone どちらもソースコードに意味付けする為だけのモノ
    fooInstance.stolenArray = array.Read;
    barInstance.stolenArray = array.Clone();  // こっちは意図通りではある
}

おわりに

RustSharp.cs よりこの記事のタイピングの方が時間かかりました。

以上です。お疲れ様でした。

脚注
  1. https://zenn.dev/sator_imaging/articles/e6428d4f1c8158#discuss ↩︎

  2. https://prev.rust-lang.org/ja-JP/faq.html#how-fast-is-rust ↩︎

Discussion

junerjuner

(C# の参照渡しは参照の値渡し)

それは参照渡しではなくて 参照型の話ではないでしょうか?

shiracamusshiracamus

参照渡しは変数共有、参照の値渡しは値共有(オブジェクト共有)します。
C# は ref, in, out をつければ参照渡し、つけなければ値渡し(参照の値渡しを含む)です。
なので、C# の参照渡しは参照の値渡しではありません。

  1. 値型変数の値渡し: 値コピー
  2. 値型変数の参照渡し: 変数共有
  3. 参照型変数の値渡し(参照の値渡し): 値共有(オブジェクト共有)
  4. 参照型変数の参照渡し: 変数共有

https://zenn.dev/shiracamus/articles/7d0b9e65e92e67

shiracamusshiracamus

参照渡し、参照の値渡し、ポインタ渡しはそれぞれ違う動作なので、適切に使い分けませんか?
参照渡し:値型にもポインタ型にも参照型にも使える(Rustにはない)
参照の値渡し:参照型に使う(オブジェクトは参照型、Rustにはない)
ポインタ渡し:ポインタ型に使う(ポインタ型は値型の一種)
https://ja.wikipedia.org/wiki/引数

C#
using System;

class Program
{
    unsafe static void call_by_value(int x, int *y) {
        *y = 2;         // 呼出し元に影響する
        x = 3;          // 呼出し元に影響しない
        int four = 4;
        y = &four;      // 呼出し元に影響しない
    }

    unsafe static void call_by_reference(ref int x, ref int *y) {
        *y = 5;         // 呼出し元に影響する
        x = 6;          // 呼出し元に影響する
        int seven = 7;
        y = &seven;     // 呼出し元に影響する
    }

    unsafe static void Main() {
        int value = 1;                                  // 値型変数value
        int *pointer = &value;                          // ポインタ型変数pointer
        call_by_value(value, pointer);                  // 値型変数、ポインタ型変数の値渡し
        Console.WriteLine("{0} {1}", value, *pointer);  // 2 2
        call_by_reference(ref value, ref pointer);      // 値型変数、ポインタ型変数の参照渡し
        Console.WriteLine("{0} {1}", value, *pointer);  // 6 7
    }
}
junerjuner

参照渡し(参照の値渡し)

その表現だと括弧の方は値渡しでは……?

サトー™ @sator_imagingサトー™ @sator_imaging

C# の ref に対応するのは C の T&(エイリアス)だと思うんですが、その概念は Rust にはなく(多分)、& こそ同じですが Rust の & &mut mut foo: &mut はスマートポインター的で C のソレとは完全に互換性のある表現ではないという別の問題もアリ、、、(C の & もコードの文脈によってエイリアスまたはポインターと意味が変わってくる)

Rust はこの辺りの似ているようでちょっと違う、を全て &(Unsafe は *)にまとめたことで分かりやすくなっているのが Linux のコアに近い部分を Rust で書くぜ! まで普及した理由かも?

shiracamusshiracamus

参照渡し(参照の値渡し)

参照渡しと参照の値渡し(参照型変数の値渡し)は別物だということは認識していますでしょうか?
参照の参照渡し(参照型変数の参照渡し)もあります。
(C++の参照型は「変数参照=変数共有=エイリアス」なので混乱の元)

C#
using System;

class Program
{
    int value;

    Program(int value) {
        this.value = value;
    }

    static void call_by_value(Program x) {
        x.value = 2;         // 呼出し元に影響する
        x = new Program(3);  // 呼出し元に影響しない
    }

    static void call_by_reference(ref Program x) {
        x.value = 4;         // 呼出し元に影響する
        x = new Program(5);  // 呼出し元に影響する
    }

    static void Main() {
        Program program = new Program(1);         // 参照型変数program
        call_by_value(program);                   // 参照の値渡し
        Console.WriteLine("{0}", program.value);  // 2
        call_by_reference(ref program);           // 参照の参照渡し
        Console.WriteLine("{0}", program.value);  // 5
    }
}
NobkzNobkz

ライフタイム=変数のスコープ

NLL(非レキシカルライフタイム)により「参照の有効期間は字句スコープ完全一致ではない」ことも強調したいです。なのでここは厳密には誤りかと。