iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
®️

Implementing Rust-style Ownership in C#

に公開
9

First, understand Rust's Ownership

If Rust isn't the first programming language you've touched, it's better to completely forget the ownership system. Since the fundamentals of it as a programming language are no different from others, you only need to grasp Rust's specificities.

  • Ownership: Refers to the instance (entity)
  • Borrowing: Refers to the pointer
  • Rust's specificities
    1. ✅ All operations are "move-by-transfer = moving the entity" (implicit/semantic std::move)
      • ✅ Exchanges between local variables are not copies either
      • ❌ "Because it's a struct, it's passed by value (copy) and only the ownership is moved!" (Wrong)
      • ❌ "Does 'move' mean copying and then deleting the original, like a Dokodemo Door (Anywhere Door)?" (Not a technical discussion)
    2. ✅ Mutable pointers (&mut == T* T* const) can only exist one at a time.
      • ✅ Why? ← Because it's Rust.
    3. Extra: Rust does not have the concept of classes (reference types).

std::move is "copying and then setting the original data to nullptr (0)," which prevents unexpected data rewriting and reading at "runtime."

Rust is revolutionary because it can check this "statically" at "compile-time" rather than at runtime using the borrow checker (≈ linter/analyzer).

(So, if you built a borrow checker for C#, you should be able to achieve the same memory safety. Though you can't make it GC-less!)

Example) It doesn't matter if it's a struct or not using the heap

Copying between local variables is also impossible. (As stated in the error, you can do it by implementing the Copy trait)

Execution environment: 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
Example) Recursive move checks are performed
  • Note: If value implements the Copy trait like i32, the latter will succeed.
let foo = Foo { value: ... };

// Error: Attempting to access an instance that has been moved
baz(foo, foo.value);
         ~~~~~~~~~

// 👇 If you change the order...

// Error: Operating on 'foo', which has been partially moved
bar(foo.value, foo);
               ~~~
Other bullet points
  • Regarding "reference / pointer / and others," descriptions are not strictly precise across all programming languages and their contexts, but I will skip that here as it deviates from the main topic.[1]

--

  • Ownership always accompanies the entity / Ownership cannot be stripped from the entity.
    • In other words, Ownership = Instance (Entity).
  • Borrowing is a pointer.
    • Naturally, the instance (= ownership) itself does not move even if you exchange pointers (= memory address of the instance).
    • In short, we're talking about normal things.
  • Rust has no concept of classes / No concept of reference types.
    • The concepts of pass-by-reference / pass-by-value (pass-by-copy) have also disappeared.
      • This is a logical discussion and unrelated to technical matters (memory operations, etc.).
    • All exchanges between methods involve "move-by-transfer," which accompanies the movement of the instance.
      • 👉 This is the specific part that causes confusion, like "Ownership moves even though I'm passing a struct by value!? (Since it's a struct, shouldn't it be a copy?)".
        • The default is an implicit std::move.
        • Languages other than Rust have pass-by-value as the default for value types.
        • In Rust, everything is move-by-transfer.
          • By implementing the Copy trait, traditional pass-by-value (pass-by-copy = copy then move) becomes possible.
          • Since the instance is duplicated (= ownership is duplicated), it becomes possible to pass it.
            • Since it's not just the ownership being duplicated, editing the value won't automatically apply the value to another instance.
        • In short, we're talking about normal things.
  • "Borrowing," where ownership does not move, simply means pass-by-reference (pass-by-value of a reference).
    • As seen from the use of the & operator, it is nothing more than a simple pointer.
      • 👉 What's special about Rust is the constraint that only one pointer with write permission (T* / T* const) can exist at a time.
      • In Rust, strong restrictions are placed on various things/actions by default (pointers are const T* const by default).
    • Even if you move-transfer a pointer (reference to an entity), the instance itself does not move.
      • Ownership (= instance) does not move.
    • In short, we're talking about normal things.
  • Lifetime = (mostly) scope of a variable.
    • When a variable holding ownership goes out of scope, it is automatically destroyed (Drop::drop is called).
    • Even if you get an error saying "Lifetime annotation...", changing it to &'static won't "extend the life of a local variable."
      • In other words, a lifetime is nothing more than metadata for the compiler.
      • Lifetime = scope of a variable.
    • In short, we're talking about normal things.
        • Strictly speaking, it's "the maximum coincides with the variable's scope," and while it cannot be extended, it can be set shorter, so they are not perfectly equal.

Rust and std::move

Up to this point, we have looked at the semantic aspects of the programming language, but here we will delve into the machine-language side of Rust.

  • Q: I'm not interested in the coding concept of ownership.
  • Q: Is the process of clearing the original value performed by LLVM? Or is it skipped because it's guaranteed at compile-time?

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

The Rust compiler generates Intermediate Representation (IR) for LLVM. [2] This is similar to the relationship between TypeScript and JavaScript.

  • None of the type system information from TypeScript remains in the transcoded JavaScript.
  • Similarly, in Rust, information such as analysis by the borrow checker completely disappears from the compilation results.

Doing Rust in C#

Unfortunately, you cannot achieve perfect Rust.

AbsoluteOwnership and Borrow

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

Usage

Declare AbsoluteOwnership as a read-only field.

readonly AbsoluteOwnership<int[]> _arrayOwnership;

When foo acquires ownership, bar's ownership becomes invalid. (Attempting to access bar's contents results in a NullReferenceException.)

foo._arrayOwnership.Take(bar._arrayOwnership);

If you want to treat it as an int[], release the ownership once.

var rawData = foo._arrayOwnership.Move();  // Move ownership!
var result = DoSomething(rawData);

Once the process is finished, acquire ownership again.

foo._arrayOwnership.Take(ref result);  // result becomes null

Implicit conversion to Borrow is possible, but since it's a ref struct, there's a constraint that it cannot be held for a long period.

DoSomething(foo._arrayOwnership);

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

Loopholes

As a prerequisite, the following must be met:

  • Keep AbsoluteOwnership<T> in a readonly field
  • AbsoluteOwnership<T> must not be used as a method parameter

Cannot prevent copying

In Rust, exchanges between local variables are also move-transfers, but in C#, there is no way to prevent implicit copy creation.

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

// Re-acquire the moved ownership, while leaking the secretly created copy
foo._arrayOwnership.Take(ref array);
DoSomething(violation);

Cannot prevent copying 2

Even when ownership is received as a method parameter, it results in a duplicate.

So, I gave it the name AbsoluteOwnership<T>, which feels a bit heavy to use, and rely on operational conventions to handle it.

DoSomething(foo._arrayOwnership);

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

Cannot prevent copying 3

Since Read and Clone in Borrow<T> are purely symbolic, you can copy as much as you want.

void DoSomething(Borrow<T> array)
{
    // Both Read and Clone are only meant to add semantic meaning to the source code
    fooInstance.stolenArray = array.Read;
    barInstance.stolenArray = array.Clone();  // This one is at least as intended
}

Conclusion

Typing this article took longer than writing RustSharp.cs.

That's all. Thank you for your time.

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