C# 11 對 ref 和 struct 的改進

来源:https://www.cnblogs.com/hez2010/archive/2022/04/21/ref-struct-improvements-in-csharp-11.html
-Advertisement-
Play Games

前言 C# 11 中即將到來一個可以讓重視性能的開發者狂喜的重量級特性,這個特性主要是圍繞著一個重要底層性能設施 ref 和 struct 的一系列改進。 但是這部分的改進涉及的內容較多,不一定能在 .NET 7(C# 11)做完,因此部分內容推遲到 C# 12 也是有可能的。當然,還是很有希望能在 ...


前言

C# 11 中即將到來一個可以讓重視性能的開發者狂喜的重量級特性,這個特性主要是圍繞著一個重要底層性能設施 refstruct 的一系列改進。

但是這部分的改進涉及的內容較多,不一定能在 .NET 7(C# 11)做完,因此部分內容推遲到 C# 12 也是有可能的。當然,還是很有希望能在 C# 11 的時間點就看到完全體的。

本文僅僅就這一個特性進行介紹,因為 C# 11 除了本特性之外,還有很多其他的改進,一篇文章根本說不完,其他那些我們就等到 .NET 7 快正式發佈的時候再說吧。

背景

C# 自 7.0 版本引入了新的 ref struct 用來表示不可被裝箱的棧上對象,但是當時局限性很大,甚至無法被用於泛型約束,也無法作為 struct 的欄位。在 C# 11 中,由於特性 ref 欄位的推動,需要允許類型持有其它值類型的引用,這方面的東西終於有了大幅度進展。

這些設施旨在允許開發者使用安全的代碼編寫高性能代碼,而無需面對不安全的指針。接下來我就來對 C# 11 甚至 12 在此方面的即將到來的改進進行介紹。

ref 欄位

C# 以前是不能在類型中持有對其它值類型的引用的,但是在 C# 11 中,這將變得可能。從 C# 11 開始,將允許 ref struct 定義 ref 欄位。

readonly ref struct Span<T>
{
    private readonly ref T _field;
    private readonly int _length;
    public Span(ref T value)
    {
        _field = ref value;
        _length = 1;
    }
}

直觀來看,這樣的特性將允許我們寫出上面的代碼,這段代碼中構造了一個 Span<T>,它持有了對其他 T 對象的引用。

當然,ref struct 也是可以被 default 來初始化的:

Span<int> span = default;

但這樣 _field 就會是個空引用,不過我們可以通過 Unsafe.IsNullRef 方法來進行檢查:

if (Unsafe.IsNullRef(ref _field))
{
    throw new NullReferenceException(...);
}

另外,ref欄位的可修改性也是一個非常重要的事情,因此引入了:

  • readonly ref:一個對對象的只讀引用,這個引用本身不能在構造方法或 init 方法之外被修改
  • ref readonly:一個對只讀對象的引用,這個引用指向的對象不能在構造方法或 init 方法之外被修改
  • readonly ref readonly:一個對只讀對象的只讀引用,是上述兩種的組合

例如:

ref struct Foo
{
    ref readonly int f1;
    readonly ref int f2;
    readonly ref readonly int f3;

    void Bar(int[] array)
    {
        f1 = ref array[0];  // 沒問題
        f1 = array[0];      // 錯誤,因為 f1 引用的值不能被修改
        f2 = ref array[0];  // 錯誤,因為 f2 本身不能被修改
        f2 = array[0];      // 沒問題
        f3 = ref array[0];  // 錯誤:因為 f3 本身不能被修改
        f3 = array[0];      // 錯誤:因為 f3 引用的值不能被修改
    }
}

生命周期

這一切看上去都很美好,但是真的沒有任何問題嗎?

假設我們有下麵的代碼來使用上面的東西:

Span<int> Foo()
{
    int v = 42;
    return new Span<int>(ref v);
}

v 是一個局部變數,在函數返回之後其生命周期就會結束,那麼上面這段代碼就會導致 Span<int> 持有的 v 的引用變成無效的。順帶一提,上面這段代碼是完全合法的,因為 C# 之前不支持 ref 欄位,因此上面的代碼是不可能出現逃逸問題的。但是 C# 11 加入了 ref 欄位,棧上的對象就有可能通過 ref 欄位而發生引用逃逸,於是代碼變得不安全。

如果我們有一個 CreateSpan 方法用來創建一個引用的 Span

Span<int> CreateSpan(ref int v)
{
     // ...
}

這就衍生出了一系列在以前的 C# 中沒問題(因為 ref 的生命周期為當前方法),但是在 C# 11 中由於可能存在 ref 欄位而導致用安全的方式寫出的非安全代碼:

Span<int> Foo(int v)
{
    // 1
    return CreateSpan(ref v);

    // 2
    int local = 42;
    return CreateSpan(ref local);

    // 3
    Span<int> span = stackalloc int[42];
    return CreateSpan(ref span[0]);
}

因此,在 C# 11 中則不得不引入破壞性更改,不允許上述代碼通過編譯。但這並沒有完全解決問題。

為瞭解決逃逸問題, C# 11 制定了引用逃逸安全規則。對於一個在 e 中的欄位 f

  • 如果 f 是個 ref 欄位,並且 ethis,則 f 在它被包圍的方法中是引用逃逸安全的
  • 否則如果 f 是個 ref 欄位,則 f 的引用逃逸安全範圍和 e 的逃逸安全範圍相同
  • 否則如果 e 是一個引用類型,則 f 的引用逃逸安全範圍是調用它的方法
  • 否則 f 的引用逃逸安全範圍和 e 相同

由於 C# 中的方法是可以返回引用的,因此根據上面的規則,一個 ref struct 中的方法將不能返回一個對非 ref 欄位的引用:

ref struct Foo
{
    private ref int _f1;
    private int f2;

    public ref int P1 => ref _f1; // 沒問題
    public ref int P2 => ref _f2; // 錯誤,因為違反了第四條規則
}

除了引用逃逸安全規則之外,同樣還有對 ref 賦值的規則:

  • 對於 x.e1 = ref e2, 其中 x 是在調用方法中逃逸安全的,那麼 e2 必須在調用方法中是引用逃逸安全的
  • 對於 e1 = ref e2,其中 e1 是個局部變數,那麼 e2 的引用逃逸安全範圍必須至少和 e1 的引用逃逸安全範圍一樣大

於是, 根據上述規則,下麵的代碼是沒問題的:

readonly ref struct Span<T>
{
    readonly ref T _field;
    readonly int _length;

    public Span(ref T value)
    {
        // 沒問題,因為 x 是 this,this 的逃逸安全範圍和 value 的引用逃逸安全範圍都是調用方法,滿足規則 1
        _field = ref value;
        _length = 1;
    }
}

於是很自然的,就需要在欄位和參數上對生命周期進行標註,幫助編譯器確定對象的逃逸範圍。

而我們在寫代碼的時候,並不需要記住以上這麼多的規則,因為有了生命周期標註之後一切都變得顯式和直觀了。

scoped

在 C# 11 中,引入了 scoped 關鍵字用來限制逃逸安全範圍:

局部變數 s 引用逃逸安全範圍 逃逸安全範圍
Span<int> s 當前方法 調用方法
scoped Span<int> s 當前方法 當前方法
ref Span<int> s 調用方法 調用方法
scoped ref Span<int> s 當前方法 調用方法
ref scoped Span<int> s 當前方法 當前方法
scoped ref scoped Span<int> s 當前方法 當前方法

其中,scoped ref scoped 是多餘的,因為它可以被 ref scoped 隱含。而我們只需要知道 scoped 是用來把逃逸範圍限制到當前方法的即可,是不是非常簡單?

如此一來,我們就可以對參數進行逃逸範圍(生命周期)的標註:

Span<int> CreateSpan(scoped ref int v)
{
    // ...
}

然後,之前的代碼就變得沒問題了,因為都是 scoped ref

Span<int> Foo(int v)
{
    // 1
    return CreateSpan(ref v);

    // 2
    int local = 42;
    return CreateSpan(ref local);

    // 3
    Span<int> span = stackalloc int[42];
    return CreateSpan(ref span[0]);
}

scoped 同樣可以被用在局部變數上:

Span<int> Foo()
{
    // 錯誤,因為 span 不能逃逸當前方法
    scoped Span<int> span1 = default;
    return span1;

    // 沒問題,因為初始化器的逃逸安全範圍是調用方法,因為 span2 可以逃逸到調用方法
    Span<int> span2 = default;
    return span2;

    // span3 和 span4 是一樣的,因為初始化器的逃逸安全範圍是當前方法,加不加 scoped 都沒區別
    Span<int> span3 = stackalloc int[42];
    scoped Span<int> span4 = stackalloc int[42];
}

另外,structthis 也加上了 scoped ref 的逃逸範圍,即引用逃逸安全範圍為當前方法,而逃逸安全範圍為調用方法。

剩下的就是和 outin 參數的配合,在 C# 11 中,out 參數將會預設為 scoped ref,而 in 參數仍然保持預設為 ref

ref int Foo(out int r)
{
    r = 42;
    return ref r; // 錯誤,因為 r 的引用逃逸安全範圍是當前方法
}

這非常有用,例如比如下麵這個常見的情況:

Span<byte> Read(Span<byte> buffer, out int read)
{
    // .. 
}

Span<int> Use()
{
    var buffer = new byte[256];

    // 如果不修改 out 的引用逃逸安全範圍,則這會報錯,因為編譯器需要考慮 read 是可以被作為 ref 欄位返回的情況
    // 如果修改 out 的引用逃逸安全範圍,則就沒有問題了,因為編譯器不需要考慮 read 是可以被作為 ref 欄位返回的情況
    int read;
    return Read(buffer, out read);
}

下麵給出一些更多的例子:

Span<int> CreateWithoutCapture(scoped ref int value)
{
    // 錯誤,因為 value 的引用逃逸安全範圍是當前方法
    return new Span<int>(ref value);
}

Span<int> CreateAndCapture(ref int value)
{
    // 沒問題,因為 value 的逃逸安全範圍被限製為 value 的引用逃逸安全範圍,這個範圍是調用方法
    return new Span<int>(ref value)
}

Span<int> ComplexScopedRefExample(scoped ref Span<int> span)
{
    // 沒問題,因為 span 的逃逸安全範圍是調用方法
    return span;

    // 沒問題,因為 refLocal 的引用逃逸安全範圍是當前方法、逃逸安全範圍是調用方法
    // 在 ComplexScopedRefExample 的調用中它被傳遞給了一個 scoped ref 參數,
    // 意味著編譯器在計算生命周期時不需要考慮引用逃逸安全範圍,只需要考慮逃逸安全範圍
    // 因此它返回的值的安全逃逸範圍為調用方法
    Span<int> local = default;
    ref Span<int> refLocal = ref local;
    return ComplexScopedRefExample(ref refLocal);

    // 錯誤,因為 stackLocal 的引用逃逸安全範圍、逃逸安全範圍都是當前方法
    // 在 ComplexScopedRefExample 的調用中它被傳遞給了一個 scoped ref 參數,
    // 意味著編譯器在計算生命周期時不需要考慮引用逃逸安全範圍,只需要考慮逃逸安全範圍
    // 因此它返回的值的安全逃逸範圍為當前方法
    Span<int> stackLocal = stackalloc int[42];
    return ComplexScopedRefExample(ref stackLocal);
}

unscoped

上述的設計中,仍然有個問題沒有被解決:

struct S
{
    int _field;

    // 錯誤,因為 this 的引用逃逸安全範圍是當前方法
    public ref int Prop => ref _field;
}

因此引入一個 unscoped,允許擴展逃逸範圍到調用方法上,於是,上面的方法可以改寫為:

struct S
{
    private int _field;

    // 沒問題,引用逃逸安全範圍被擴展到了調用方法
    public unscoped ref int Prop => ref _field;
}

這個 unscoped 也可以直接放到 struct 上:

unscoped struct S
{
    private int _field;
    public unscoped ref int Prop => ref _field;
}

同理,嵌套的 struct 也沒有問題:

unscoped struct Child
{
    int _value;
    public ref int Value => ref _value;
}

unscoped struct Container
{
    Child _child;
    public ref int Value => ref _child.Value;
}

此外,如果需要恢復以前的 out 逃逸範圍的話,也可以在 out 參數上指定 unscoped

ref int Foo(unscoped out int r)
{
    r = 42;
    return ref r;
}

不過有關 unscoped 的設計還屬於初步階段,不會在 C# 11 中就提供。

ref struct 約束

從 C# 11 開始,ref struct 可以作為泛型約束了,因此可以編寫如下方法了:

void Foo<T>(T v) where T : ref struct
{
    // ...
}

因此,Span<T> 的功能也被擴展,可以聲明 Span<Span<T>> 了,比如用在 byte 或者 char 上,就可以用來做高性能的字元串處理了。

反射

有了上面那麼多東西,反射自然也是要支持的。因此,反射 API 也加入了 ref struct 相關的支持。

實際用例

有了以上基礎設施之後,我們就可以使用安全代碼來造一些高性能輪子了。

棧上定長列表

struct FrugalList<T>
{
    private T _item0;
    private T _item1;
    private T _item2;

    public readonly int Count = 3;

    public unscoped ref T this[int index] => index switch
    {
        0 => ref _item1,
        1 => ref _item2,
        2 => ref _item3,
        _ => throw new OutOfRangeException("Out of range.")
    };
}

棧上鏈表

ref struct StackLinkedListNode<T>
{
    private T _value;
    private ref StackLinkedListNode<T> _next;

    public T Value => _value;
    public bool HasNext => !Unsafe.IsNullRef(ref _next);
    public ref StackLinkedListNode<T> Next => HasNext ? ref _next : throw new InvalidOperationException("No next node.");

    public StackLinkedListNode(T value)
    {
        this = default;
        _value = value;
    }

    public StackLinkedListNode(T value, ref StackLinkedListNode<T> next)
    {
        _value = value;
        _next = ref next;
    }
}

除了這兩個例子之外,其他的比如解析器和序列化器等等,例如 Utf8JsonReaderUtf8JsonWriter 都可以用到這些東西。

未來計劃

高級生命周期

上面的生命周期設計雖然能滿足絕大多數使用,但是還是不夠靈活,因此未來有可能在此基礎上擴展,引入高級生命周期標註。例如:

void M(scoped<'a> ref MyStruct s, scoped<'b> Span<int> span) where 'b >= 'a
{
    s.Span = span;
}

上面的方法給參數 sspan 分別聲明瞭兩個生命周期 'a'b,並約束 'b 的生命周期不小於 'a,因此在這個方法里,span 可以安全地被賦值給 s.Span

這個雖然不會被包含在 C# 11 中,但是如果以後開發者對相關的需求增長,是有可能被後續加入到 C# 中的。

總結

以上就是 C# 11(或之後)對 refstruct 的改進了。有了這些基礎設施,開發者們將能輕鬆使用安全的方式來編寫沒有任何堆記憶體開銷的高性能代碼。儘管這些改進只能直接讓小部分非常關註性能的開發者收益,但是這些改進帶來的將是後續基礎庫代碼質量和性能的整體提升。

如果你擔心這會讓語言的複雜度上升,那也大可不必,因為這些東西大多數人並不會用到,只會影響到小部分的開發者。因此對於大多數人而言,只需要寫著原樣的代碼,享受其他基礎庫作者利用上述設施編寫好的東西即可。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • JVM(Java虛擬機) 學習String類前,先瞭解一下JVM,也稱為Java虛擬機。 JVM記憶體分有幾大區域,其中,常見有堆、桟、方法區、常量池。 堆是運行時數據區,類通過new指令創建的對象會在堆記憶體里分配空間。堆記憶體的數據是由java垃圾回收器自動回收。堆的優勢是可以動態地分配記憶體大小。缺點 ...
  • 前言 今天給大家透露了我們這篇要說的牛逼利器 selenium + phantomjs Python學習交流Q群:906715085### 如果你知道 selenium 是什麼了 它能做到自動操作 比如我們上次說的自動百度蒼老師 將帶你爬取b站上的NBA形象大使蔡徐坤和他的球友們 但有時候 我們不想 ...
  • 來啦,老弟 python 我們已經知道怎麼使用 Requests 進行各種請求騷操作 也知道了對伺服器返回的數據如何使用 正則表達式 來過濾我們想要的內容 … 那麼接下來 Python學習交流Q群:906715085### 我們就使用 requests 和 re 來寫一個爬蟲 作為一個愛看書的你(說 ...
  • 公用包 將常用功能,業務,核心代碼封裝成一個獨立的包,然後部署到私服上,讓其它開發人員去使用,很好的進行了版本的控制,代碼也更安全,在maven中部署時,使用plugin將資源文件帶上。 <build> <plugins> <!-- 發佈源碼,需要這個插件 --> <plugin> <groupId ...
  • 之前曾在《C# 中容易忽視的 Encoding.GetByteCount 記憶體問題》中提到過,可以使用 Encoding.Default.GetByteCount 方法來判斷字元是全寬(寬度為 2)還是半寬(寬度為 1)。 這個方法實際上是計算對字元編碼後產生的位元組數,只是在中文環境下,寬字元在使用 ...
  • 在樹莓派上部署ASP.NET環境(樹莓派做ASP.NET項目伺服器),之後Windows上開發的ASP.NET項目可以部署在樹莓派上。 ...
  • 最近想深入學習ES,想在它的基礎上定製日誌收集中間件,所以特定把它記錄下來 創建ES配置文件 elasticsearch.yml,把文件放到 config目錄下 # 集群名稱 cluster.name: kite-es-cluster # 節點名稱 node.name: kite-es-node-1 ...
  • 使用背景: 關於滑動驗證碼的使用場所還是非常多的,如: 調取簡訊介面之前,和 註冊請求之前 或者 頻繁會調用的介面 都需要加這個拼圖驗證。這裡先上一下效果圖吧(心中無碼,自然高清)。 話不多說,開擼! 實現分析: 滑動驗證碼的邏輯也很簡單。大概說一下: 1,伺服器生成主圖+附圖(從主圖裁剪下來的不需 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...