1. 介紹
函數式程式設計(FP)——是一種程式設計範式,其主要構建單位不是物件也不是程序/方法,而是數學意義上的 函數。在 FP 中主要關注的是描述 「要計算什麼」,而不是 「如何計算」。
你已經在使用 lambda 表達式和 LINQ 的時候接觸過 FP 的部分概念。但真正的差別在哪裡?實際上:OOP 描述物件及其交互,程序式編程是一系列步驟,而 FP 是 函數的組合、把行為當作值傳遞、拒絕改變狀態(immutability)並儘量避免副作用。
為什麼要學新的範式?
- 更乾淨、可預測且易測試的代碼。
- 簡化的多執行緒支援(「沒狀態就沒問題」)。
- 簡潔且富表現力(代碼越少,bug 越少)。
- 高階、易重用的抽象。
類比
想像餐廳接到一個訂單:「做一份歐姆蛋」。命令式的廚師會執行一串指令:拿蛋、打開、攪拌、煎。函數式的廚師會說:res = omlet(yajtsa) — 他用的是函數,抽象掉廚房內部狀態(嗯,差不多啦)。
在 C# 中兩種方式都能用。這讓語言非常靈活且強大——尤其適合真實項目。
FP 的關鍵概念
1. 高階函數
函數可以作為參數傳遞、從其他函數返回、或儲存在變數中。你之前用 lambda 和 delegate 就做過這類事。在 FP 裡,這種「對函數做操作的函數」是基礎。
2. 純函數
如果一個函數的結果僅依賴於參數、並且它不改變外部任何東西(沒有副作用),那它就是「純的」。用相同參數呼叫兩次會得到相同結果。
3. 不可變性 (Immutability)
資料不會「就地變更」:新狀態是新的物件。這大幅簡化對程式的推理,對多執行緒特別有幫助。
4. 無副作用
函數不寫檔、不改變全域變數、不在畫面上畫東西——只是回傳結果。在現實中副作用無法完全避免,但會盡量把它們隔離在系統邊界。
5. 函數組合
可以用其他函數拼出一個函數,就像積木一樣。例如:先過濾出正數,取平方再相加。每個操作都是單獨函數,容易組合(Where → Select → Sum)。
2. 在 C# 中的 FP:從理論到實作
C# 是多範式語言:它很好地支援 OOP、程序式以及強大的函數式風格(有 lambda、delegate、擴充方法和 LINQ)。
用我們的教學範例來看
假設我們在開發一個處理數字與字串清單的程式。我們的任務是以函數式風格對這些資料做各種操作。
範例 1:使用高階函數
// 對清單的所有元素應用一個 action
public static void ForEach<T>(List<T> items, Action<T> action)
{
foreach (var item in items)
{
action(item);
}
}
用法:
var numbers = new List<int> { 1, 2, 3, 4, 5 };
ForEach(numbers, n => Console.WriteLine(n * n)); // 函數當作參數
看到了嗎?函數可以放到變數裡或像普通值一樣傳遞——就像廚房裡的蘋果一樣!
範例 2:純函數
一個不改變程式狀態且只依賴輸入的函數:
int MultiplyByTwo(int x)
{
return x * 2;
}
- 不依賴外部任何東西。
- 不改變外部任何東西。
- 對於 x = 5 永遠回傳 10。
對比下面這個使用並改變全域變數的函數:
int total = 0;
int AddToTotal(int x)
{
total += x;
return total;
}
這就不是純函數——結果依賴外部狀態,且它還改變了那個狀態。
範例 3:資料不可變性
我們不直接改變輸入資料,而是創建新的:
List<int> AddOneToEach(List<int> numbers)
{
return numbers.Select(n => n + 1).ToList();
}
輸入的清單完全沒有被改變。在多執行緒程式裡這特別方便:鎖和資料競爭問題會少很多。
範例 4:函數組合
取得所有偶數的平方和:
int SumOfEvenSquares(List<int> numbers)
{
return numbers
.Where(n => n % 2 == 0) // 保留偶數
.Select(n => n * n) // 平方
.Sum(); // 相加
}
可讀且宣告式:每個操作都是單獨的函數。
3. 有用的小細節
FP、LINQ 與 C#
LINQ 幾乎就是「針對集合的 FP 在實作上長什麼樣」:你使用高階函數(Where、Select 等),得到新的序列而不改變原序列,每次轉換都是單獨的表達式。結果是 IEnumerable<T>,它描述的是 要取得什麼,而不是 如何迭代。
類比表
| 命令式(程序式/OO) | 函數式(LINQ/FP 風格) |
|---|---|
|
|
| 「修改」集合 | 取得新的集合 |
| 狀態(total += x) | 純函數(xs.Sum()) |
| 描述為:「做這件事」 | 描述為:「我們想得到什麼」 |
FP vs OOP:兩個世界 — 一個 C#
這不是對立的陣營。在真實的 C# 專案中通常會混用:領域模型(domain model)用類別(OOP)建模很方便,而集合處理、資料聚合和轉換則用函數式風格透過 LINQ、lambda 和擴充方法來做。
你對 delegate 的理解會直接有用:Func<T, TResult>、Predicate<T>、Action<T> 都是 FP 風格的常見基石。
通用的過濾函數:
List<T> Filter<T>(List<T> items, Predicate<T> predicate)
{
var result = new List<T>();
foreach (var item in items)
{
if (predicate(item))
result.Add(item);
}
return result;
}
呼叫例子:
var adults = Filter(people, person => person.Age >= 18);
var bigFiles = Filter(fileNames, name => name.EndsWith(".mp4") && name.Length > 10);
取代一大堆只有條件不同但功能相似的方法——用一個通用函數即可。
為什麼雇主和面試官喜歡 FP 開發者?
- FP 有助於在不啟動整個系統的情況下測試小的代碼塊。
- 邏輯更容易維護:更少的狀態意味著更少的錯誤來源。
- 更容易寫並行與非同步代碼——沒有全域狀態,競爭條件更少。
怎麼不過度迷信?
是的,FP 很強大。但 C# 並不是純函數式語言,不是所有任務都需要絕對的純度。別害怕在合適場合使用區域變數與合理的變更。重點是可讀性、可預測性與可測試性。FP 元素是工具,而不是教條。
4. 新手常犯的錯誤
很容易碰到看起來像函數式的代碼,但實際上並不符合函數式的原則。
例如,一個函數回傳了新的集合,但在處理過程中偷偷修改了原來的列表——這違反了不可變性的原則,會讓呼叫方的預期被打破。
再比如:lambda 關閉(capture)外部變數並修改它。在函數式觀點裡這就是副作用,會讓代碼行為難以預測。
C# 編譯器不會阻止你這麼做:語言允許這些寫法。因此在 FP 實踐中要注意保證函數「自給自足」,不要改變外部狀態,也不要讀取外部狀態,除了通過它的參數。
GO TO FULL VERSION