跳到主要內容
技術

平行運算的理想與現實:Functional Programming 真的能解決所有麻煩嗎?

平行運算、副作用、不可變資料——函數語言的這些特性常被過度神話。本文拆解三大常見誤解,還原 Functional Programming 典範的真正價值。

隨著多核心處理器的普及,如何高效且安全地實現平行運算,已成為現代軟體工程無法迴避的挑戰。在此背景下,函數式程式設計因其「無副作用」(Side-effect free)的設計哲學備受推崇,甚至讓不少人產生了「只要切換到函數式語言,就能徹底免去平行程式煩惱」的錯覺。不可否認,FP 確實大幅降低了並行開發的心智負擔,但這種過度樂觀的說法,只強調了美好的結果,卻隱去了許多嚴苛的先決條件。這篇文章將從務實的角度出發,重新審視函數式語言與平行運算的高度契合度,並探討在享受這些紅利之前,我們究竟需要先確立哪些架構上的認知。


函數語言的核心:一級函式

在討論這些特性之前,先回答一個問題:「什麼是函數語言?」

答案比多數 Tutorial 說的簡單——函數語言最核心、最根本的特性只有一個:函數是 first-class citizen(一等公民),也就是「函式可以被當成變數值」。

這衍生出了所有你在函數語言裡看到的技巧:

// 函式作為參數傳遞(高階函式)
const numbers = [1, 2, 3, 4, 5];
const evens = numbers.filter(n => n % 2 === 0); // [2, 4]

// 函式作為回傳值
function multiplier(factor) {
  return n => n * factor;
}
const double = multiplier(2);
const triple = multiplier(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15

// 函式組合(Function composition)
const compose = (f, g) => x => f(g(x));
const addOne = x => x + 1;
const square = x => x * x;
const squareThenAddOne = compose(addOne, square);
console.log(squareThenAddOne(4)); // 17

// 部分套用(Partial application)
function add(a, b) {
  return a + b;
}
const add10 = add.bind(null, 10);
console.log(add10(5));  // 15
console.log(add10(20)); // 30

這些特性不只存在於 Lisp、Scheme、Clojure 這些「正統」函數語言。JavaScript、Python、Perl 都有。C++11 和 C# 這幾年也相繼引入,因為這個典範實在太適合精簡描述邏輯了。


副作用:真正的問題是區分,不是消滅

「函數語言沒有副作用」這句話本身就站不住腳,因為所有具備實用價值的程式都必然有副作用

一個程式執行完畢後,如果沒有改變任何記憶體狀態、沒有在螢幕上顯示資訊、沒有寫入檔案或資料庫——那這個程式等於什麼都沒做。

所以問題從來不是「怎麼消滅副作用」,而是「怎麼區分純函式與非純函式」。

純函式(Pure function) 保證:給相同的輸入,永遠得到相同的輸出,且不讀取或寫入廣域狀態。

// 純函式:輸出完全由輸入決定
function calculateTax(price, rate) {
  return price * rate;
}

// 非純函式:依賴外部狀態
let taxRate = 0.05;
function calculateTaxImpure(price) {
  return price * taxRate; // taxRate 可能在任何時候被改變
}

// 非純函式:產生副作用
function saveOrder(order) {
  database.save(order); // I/O 副作用
  console.log('Saved'); // 另一個副作用
}

純函式非常適合單元測試,在平行運算時也不會與其他執行緒產生交互影響。更重要的是,它讓程式邏輯可預測——你不需要知道程式在呼叫這個函式之前的狀態,只需要看輸入就能推斷輸出。

實際上,好的做法是把純邏輯與有副作用的邊界明確切開:

// 把純邏輯抽出來
function processOrders(orders) {
  return orders
    .filter(order => order.amount > 0)
    .map(order => ({ ...order, tax: order.amount * 0.05 }))
    .reduce((total, order) => total + order.amount + order.tax, 0);
}

// 副作用集中在外層
async function run() {
  const orders = await fetchOrdersFromDB(); // 副作用:I/O
  const total = processOrders(orders);       // 純函式:可測試
  await sendReport(total);                   // 副作用:I/O
}

不同語言對「副作用區分」的保證程度差很多:

C++:語言沒有強制,但開發者可以透過自我紀律,把純計算邏輯與有副作用的輸出邏輯拆開。做得到,但靠約定而非語言保證。

Erlang:透過「單一賦值(Single assignment)」——變數一旦設定便不可更改——從語言層面避免了廣域狀態被隨意竄改。但你仍然可以呼叫有 I/O 副作用的函式,語言不會阻止你。

Haskell:少數能在型別系統層面嚴格區別純函式與非純函式的語言。型別宣告裡沒有 IO,就代表語言掛保證這個函式沒有副作用。你若試圖在純函式裡印出東西,直接觸發型別錯誤——不是 runtime 警告,是編譯就過不了。

這個設計很有啟發性:它讓「副作用的邊界」變成可被型別系統驗證的合約,而非只靠開發者的自律。


不可變資料:語言保證與開發紀律之間的差距

「用了函數語言就自動得到不可變資料」——這是另一個常見的錯誤期待。

在語言層面強制保證不可變的函數語言,其實非常少。Haskell 是其中之一:資料一旦建立便無法修改,需要「改變」時只能產生一個新的資料結構,舊的留給垃圾回收。

但不可變性(Immutability)根本不是函數語言的專利。用 JavaScript 來看,你可以選擇不同層級的保證:

// 層級一:沒有任何保護,資料隨時可被修改
const user = { name: 'Alice', age: 30 };
user.age = 31; // 完全合法

// 層級二:Object.freeze — 淺層不可變
const frozenUser = Object.freeze({ name: 'Alice', address: { city: 'Taipei' } });
frozenUser.name = 'Bob';         // 靜默失敗(strict mode 下會報錯)
frozenUser.address.city = 'NYC'; // 仍然可以改!freeze 不是遞迴的

// 層級三:展開運算子產生新物件(函數式慣用寫法)
function birthday(user) {
  return { ...user, age: user.age + 1 }; // 原始物件不被修改
}
const alice = { name: 'Alice', age: 30 };
const olderAlice = birthday(alice);
console.log(alice.age);      // 30,原始物件不變
console.log(olderAlice.age); // 31

// 層級四:使用 Immer 等函式庫提供結構共享的不可變資料結構
// (類似 Haskell 的 persistent data structure)
import produce from 'immer';
const nextState = produce(state, draft => {
  draft.user.age += 1; // 看起來像 mutation,實際上產生新物件
});

要達到不可變資料,有三條路,且並非函數語言的專利:

  1. 語言保證 像 Haskell 從底層限制,沒有商量餘地。
  2. 善用語言特性 Java 的 final、Scala 的 val、JavaScript 的 Object.freeze 搭配嚴謹的寫法——只要確保參照到的物件同樣不可變,就能在各種語言中達到等效效果。
  3. 開發紀律與約定 透過文件明訂某個資料結構不應被改變。即使是 C 語言甚至組合語言,也能「擁有」不可變的資料結構,只是靠約定而非編譯器幫你守。

你選的語言與你的實踐方式,共同決定了你實際得到多少保證。


真正的收穫:思維比工具更重要

函數語言不是平行運算的萬靈丹,但它仍然值得學。學習它的過程,能讓你對「純函式」與「不可變資料」有更透徹的理解——不是停在口號層面,而是真正知道這些特性在哪裡成立、在哪裡需要你自己補上。而這份理解,在你回到 C++ 或 Java 的時候同樣適用,讓你寫出更清楚的邊界、更容易測試的邏輯。

不過,即便你選了 Haskell 這樣對副作用與不可變性有最嚴格保證的語言,平行運算的根本挑戰並不會憑空消失:

  1. 記憶體與 GC 壓力 不可變資料意味著每次「修改」都是產生新物件。高並行場景下,短命物件大量累積,垃圾回收器的 Stop-the-world 暫停可能直接拖垮效能。
  2. 平行化的額外開銷 建立執行緒、分配任務、合併結果都有成本。對粒度太小的工作盲目平行化,反而跑得比單執行緒慢。
  3. 資料依賴無法迴避 步驟 B 若依賴步驟 A 的結果,這兩步就永遠無法平行。這是邏輯本身的限制,語言選擇改變不了因果關係。
  4. 架構層級的死結 FP 幫你擋掉了低層的資料鎖問題,但 Channel、Actor、async/await 的邏輯若設計不當,「A 等 B、B 等 A」的死結依然會發生。

函數式語言移除了一整類常見的錯誤來源,讓你能把注意力放在真正難的問題上。但它能幫你做到這一步,前提是你先把這些特性在你選的語言裡實際能保證什麼、不能保證什麼,搞清楚。

學習一門新技術時,最常見的陷阱是把特定語言的設計選擇,誤認為整個典範的普遍特性。批判性地看待每一種特性帶來的好處,以及它背後的代價——這個思考過程本身,才是最值得帶走的東西。