CodeGym /課程 /C# SELF /純函數與不可變性

純函數與不可變性

C# SELF
等級 51 , 課堂 1
開放

1. 介紹

純函數— 是指給定相同輸入時永遠回傳相同結果,並且不會產生任何副作用的函數。簡單說:純函數不會「破壞」周遭,也不會被外部狀態「感染」。

函數被視為純的,當且僅當:

  • 它只是根據輸入參數計算值。
  • 不會修改外部變數或程式的狀態。
  • 不依賴可能會變動的外部變數或狀態。

兩條純性的黃金法則

  1. 確定性:相同輸入 — 相同輸出。
  2. 無副作用:函數不改變自己之外的任何東西:沒有檔案改寫、沒有全域變數改變、沒有更動使用者介面(哈囉,Console.WriteLine)。

不是教條,而是常識

看起來好像學術上的玩意兒,但實務證明它很有用:

  • 純函數是可預測的。測試起來很簡單 — 傳入特定參數就會得到特定結果。
  • 純函數可以安全地在程式中隨意重排,不用擔心哪裡會被弄壞。
  • 在多核心環境下,純函數保證安全:可以並行執行而不怕資料競爭。

2. 純函數與非純函數的範例

純函數:一切可預測

// 純函數:不碰外面任何東西
int Add(int a, int b)
{
    return a + b;
}

int Square(int x)
{
    return x * x;
}

呼叫 Add(2, 3) 一百次,永遠得到 5。乏味但可靠。

非純函數:違反規則

// 破壞純性:依賴外部狀態(靜態變數)
int counter = 0;

int Increase()
{
    counter++;
    return counter;
}

這裡呼叫 Increase() 每次會回傳不同的值 — 因此不具有確定性。

// 破壞純性:產生外部效果(印到畫面)
int AddAndPrint(int a, int b)
{
    int sum = a + b;
    Console.WriteLine(sum); // 副作用!
    return sum;
}

隨機性與時間怎麼辦?

任何使用 DateTime.NowRandom 的函數就不再是純的:

// 不是純的!
int GetRandomNumber()
{
    return new Random().Next();
}

差異表

特性 純函數 非純函數
對相同參數總是回傳相同結果
副作用 沒有
依賴外部狀態 沒有

3. 資料不可變性:理論與實務

不可變性 (immutability)— 指物件一旦建立後就不能被改變。需要新值時,就建立一個新的物件。

為什麼重要?

  • 應用程式更能抵抗因意外修改資料而導致的錯誤。
  • 沒有偷偷改動的「洩漏」:如果你擁有一個物件,沒有人可以暗中改變它的欄位。
  • 不可變性是許多自動優化與平行計算的基礎。

在 C# 的簡單範例

.NET 裡的不可變型別

在 C# 裡字串 (string) 是不可變的!每次你做 string.Concat(s, "world") 都會建立新的字串。

string s = "Hello";
string t = s;
s = s + " World";
Console.WriteLine(t); // t == "Hello"

陣列和集合:預設是可變的

int[] numbers = { 1, 2, 3 };
numbers[0] = 42; // 陣列被改變了!

不可變性「說白了」:編寫產生新物件的程式

不要改變既有物件/值,回傳新的:

// 比起這樣:
void AddToList(List<int> list, int value)
{
    list.Add(value); // 變異!
}

// 更好的做法:
List<int> AddToList(List<int> list, int value)
{
    var newList = new List<int>(list) { value }; // 新清單
    return newList;
}

示意:改變 vs 不改變

flowchart LR
    A[原始物件] --"變異"--> B[同一個物件,但內部改變]
    A --"不可變性"--> C[新的物件]

4. 在真實 C# 程式中的用途

  • 現代 C# 的函式庫像是 LINQ、Entity Framework 與 ASP.NET Core 都偏好純函數與不可變性。
  • 不可變性降低了那些「神祕」錯誤的數量,像是某人哪裡把重要值覆蓋掉。
  • 純函數讓單元測試變得簡單:測試只需關心輸入與輸出,不用操心外部世界。

範例:處理字串

string s = "Hello";
string newS = s.Replace("H", "J"); // s 還是 "Hello"; newS 是 "Jello"

範例:LINQ 與集合

WhereSelect 等方法會回傳新的集合,不會碰原來的。

var numbers = new List<int> { 1, 2, 3, 4 };
var evenNumbers = numbers.Where(n => n % 2 == 0).ToList();

// numbers 保持不變!

真實情境範例:用不可變物件做配置

許多現代的 .NET API 使用不可變物件來做配置,例如 JsonSerializerOptions

var options = new JsonSerializerOptions
{
    WriteIndented = true
};
// 這個物件在流程中不會被任意改變,提升可靠度。

5. 常見錯誤與陷阱

危險常發生在你「不小心」在號稱純的程式裡改變了資料的時候。

集合特別容易出事:你本來想過濾清單,結果在過程中改到原始清單。

或者忘了某個方法像 List<T>.Add 是會就地修改物件的。

陰險的範例:

List<int> DoubleTheNumbers(List<int> xs)
{
    // 錯誤!我們在變異原本的清單,並回傳同一個物件。
    foreach (var i in xs)
        xs.Add(i * 2);
    return xs;
}

這段程式甚至會丟出執行時例外(InvalidOperationException),因為我們在遍歷時修改了集合——經典的變異問題。

正確寫法:

List<int> DoubleTheNumbers(List<int> xs)
{
    var newList = new List<int>(xs.Select(x => x * 2));
    return newList;
}
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION