CodeGym /課程 /C# SELF /Memory<T> 和 ReadOnlyMemory<T>

Memory<T> 和 ReadOnlyMemory<T>

C# SELF
等級 65, 課堂 4
開放

1. 介紹

一切的起點是 .NET 的開發者想要加速大量資料的處理,並提供既方便又安全的 API。最先出現的是 Span<T>,它是一個指向連續記憶體區段的「視窗」,可以表示陣列的一部分、字串,甚至是 .NET 以外分配的記憶體(例如透過 P/Invoke)。

不過 Span<T> 有一個強而有力的限制:它必須只存在於 stack(只能放在 stack 上)。不能把它放到類別的欄位、不能從方法返回、也不能在非同步方法之間傳遞。原因是安全性:如果有人保留了一個已經無效的記憶體參考,整個應用程式就會崩潰。

有時候我們需要從方法返回切片、把它們放到集合或類別欄位,或者在非同步 API 中使用。這時候 Memory<T> 就派上用場——它本質上是 Span<T> 的「長期存活」安全版本,可以存在於 heap,跨執行緒傳遞,放在屬性或物件裡,表現得像個正常的 .NET 物件。

另外一個「哥哥」是 ReadOnlyMemory<T>,顧名思義,它不允許透過該物件修改底下的資料,但可以在任何需要的地方讀取它。

2. Span<T> 和 Memory<T> 的差別

下面是一個簡單的比較表:

Span<T>
Memory<T>
存在於哪裡 只在 stack 上 (stack only) 可以在 stack 或 heap (heap/stack)
可以放在欄位中嗎 ❌ 否 ✅ 是
可以從方法返回嗎 ❌ 否 ✅ 是
非同步 / await 方法 ❌ 不行 ✅ 可以
可否變更 ✅ 有對應的 ReadOnlySpan<T> ✅ 有對應的 ReadOnlyMemory<T>
允許切片資料 ✅ 可以 ✅ 可以

如果你只是想在方法內快速遍歷或處理資料——用 Span<T>。如果需要把結果返回給呼叫端或放到類別欄位裡——用 Memory<T>。如果資料只讀,則選 ReadOnlyMemory<T>。

3. Memory<T> 的簽章與基本構造

一如既往:Memory<T> 是泛型(可建立 Memory<int>、Memory<byte>、Memory<char>,甚至 Memory<MyType>)。Memory<T> 內部保存著對陣列、字串或其他資料來源的參考,以及一個範圍(從哪個索引開始、長度是多少)。

要從 Memory<T> 取得快速處理的介面,可以使用它的 Span 屬性——這會立刻給你一個 Span<T>,可以在同步方法內使用。

4. 怎麼建立 Memory<T>:實作範例

範例 1. 從陣列建立

int[] numbers = { 1, 2, 3, 4, 5, 6 };
Memory<int> memory = new Memory<int>(numbers); // 整個陣列

// 可以取一個切片 — 陣列的一部分
Memory<int> slice = memory.Slice(2, 3); // 索引 2、3、4 的元素

範例 2. 從字串建立(透過 Memory<char>)

string text = "嗨,世界!";
Memory<char> charMemory = text.AsMemory(); // 整個文字當作記憶體
Memory<char> subMemory = charMemory.Slice(7, 3); // 從第 7 個字元開始,3 個字元("世界")

範例 3. 使用 ReadOnlyMemory<T>

完全一樣,但不能修改底下的資料:

int[] data = { 10, 20, 30, 40 };
ReadOnlyMemory<int> readOnly = data; // 透過這個物件無法修改資料

5. 在 Memory<T> 和 Span<T> 之間轉換

不能像直接使用 Span<T> 那樣彈性又快地在所有地方操作 Memory<T>,因為它們的用途不同。不過當你真的需要快速處理記憶體的一段時,可以透過屬性 Span 從 Memory<T> 取得即時的 Span<T>:

void ProcessData(Memory<int> memory)
{
    Span<int> span = memory.Span;
    for (int i = 0; i < span.Length; i++)
    {
        span[i] += 100;
    }
}

注意:Span<T> 只在方法內有效。如果嘗試把它返回出去,編譯器會報錯。

對於 ReadOnlyMemory<T> 也是類似,但你會得到 ReadOnlySpan<T>,它不允許修改資料:

void PrintData(ReadOnlyMemory<int> memory)
{
    ReadOnlySpan<int> roSpan = memory.Span;
    foreach (var item in roSpan)
        Console.WriteLine(item);
}

6. 在真實任務中的使用情境

非同步資料處理

這時候 Memory<T> 真正發光了。它可以用在非同步方法裡!例如,非同步讀檔:

using System.IO;
using System.Threading.Tasks;

public async Task ReadFileAsync(string path)
{
    byte[] buffer = new byte[4096];
    using var stream = File.OpenRead(path);
    int bytesRead = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length));
    // 現在你可以對 buffer 做處理
}

這裡 AsMemory 將 buffer 傳給非同步方法,沒有像 Span<T> 那樣的作用域或記憶體失效問題。

在屬性與欄位中儲存切片

有時候你會想做個類別,把大型陣列的一小段保存起來供後續使用:

class DataChunk
{
    public Memory<byte> Data { get; }

    public DataChunk(Memory<byte> data)
    {
        Data = data;
    }
}

7. 與集合、字串、陣列的搭配使用

搭配陣列

最常見的情況:

byte[] bytes = { 1, 2, 3, 4, 5 };
Memory<byte> mem = bytes;          // 整個陣列
Memory<byte> part = mem.Slice(2);  // 從第三個元素到結尾

搭配字串

透過 AsMemory():

string hello = "Hello, Memory!";
ReadOnlyMemory<char> mem = hello.AsMemory(6, 6); // "Memory"

搭配集合(例如 List<T>)

不能直接從 List<T> 建立 Memory<T>。只能透過陣列來做:

List<int> list = new List<int> { 1, 2, 3 };
Memory<int> mem = list.ToArray(); // 這會複製資料,而不是參考原本的陣列!

如果你想避免複製,就把資料維持在陣列裡。

8. 使用 Memory<T> 時常見的錯誤

錯誤 #1:嘗試在類別欄位使用 Span<T> 而不是 Memory<T>。 不能把 Span<T> 放到類別欄位,因為它綁定在 stack。編譯器會報錯。要在 heap 上儲存請用 Memory<T>。

錯誤 #2:以為切片會複製資料。 Memory<T> 並不會複製資料,它只是建立一個指向既有陣列的「視窗」。如果透過一個 Memory<T> 修改資料,所有參考同一記憶體的物件都會看到變化。

錯誤 #3:嘗試直接從 List<T> 建立 Memory<T>。 Memory<T> 只能和陣列直接搭配,因為 List<T> 可能會改變底層資料的記憶體位置。請透過 ToArray() 轉成陣列。

錯誤 #4:忽略對於不可變資料使用 ReadOnlyMemory<T>。 如果資料不需要被修改,使用 ReadOnlyMemory<T> 比 Memory<T> 更安全。

2
任務
C# SELF, 等級 65, 課堂 4
上鎖
使用 Memory 進行非同步操作
使用 Memory 進行非同步操作
1
問卷/小測驗
C# 記憶體,等級 65,課堂 4
未開放
C# 記憶體
.NET 記憶體架構
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION