Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 28 · 臺南 AQI 24 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

程式語言演進

程式語言演進(進階):型別、記憶體與並行的三場拔河

為什麼語言演進不總是「更抽象」?從泛型、所有權到 async,解析現代語言設計真正的戰場

如果語言演進是「物競天擇」,那選擇壓力是什麼?

入門篇把程式語言的故事講成一座抽象階梯:從撥開關的機器碼,一路爬到 print("Hello, World!")。但如果你已經爬完那座階梯,會冒出一個更尖銳的問題:為什麼語言演進不是無限往「更抽象、更像自然語言」單向前進,反而在某些維度上「往回走」?

Rust 重新讓你操心記憶體的所有權(ownership),比起 Python 的「完全不用管」明明是退步;Go 刻意不放泛型(generics)進語言裡長達十年;TypeScript 給動態的 JavaScript 硬加上靜態型別。這些都不是「更抽象」的演化,而是針對特定選擇壓力(selection pressure)的回應——多核心硬體、超大型程式庫的維護成本、雲端的部署規模。

入門篇談的是「語言怎麼從機器碼長出來」。這篇進階要談的是語言內部三套機制的演化史:型別系統(type system)、記憶體管理(memory management)、並行模型(concurrency model)。這三條線才是現代語言設計真正的戰場,也是你讀完入門後,理解「為什麼有這麼多語言」的下一層答案。

程式語言演進進階概念示意圖

第一條線:型別系統,從「標籤」到「證明」

入門篇提過靜態型別與動態型別的分別。進階的問題是:型別系統本身怎麼從一個「防呆標籤」演化成「逼近數學證明」的工具?

關鍵的轉折點是參數多型(parametric polymorphism),也就是俗稱的泛型。早期的 C 沒有泛型,你想寫一個「對任何型別都能用的串列」,只能用 void* 配上強制轉型,把型別檢查整個交還給程式設計師——出錯就是執行期崩潰。

/* C 的 void*:型別資訊在編譯期被抹掉,全靠人工保證安全 */
void push(void* stack, void* item);
int* x = malloc(sizeof(int));
push(my_stack, x);
/* 編譯器無法阻止你之後把它當成 char* 取出來 */

泛型的出現,讓「對所有型別 T」這件事可以被編譯器精確追蹤。同一段邏輯只寫一次,型別安全卻不犧牲:

# Python 3.12+ 的泛型語法:T 是型別變數
def first[T](items: list[T]) -> T:
    return items[0]

n: int = first([1, 2, 3])      # 編譯期工具知道回傳 int
s: str = first(["a", "b"])     # 知道回傳 str

更深一層的演化是型別推論(type inference)。你寫 let x = 3 + 4,編譯器自己推出 x: int,不必你標註。這背後是 1970 年代的 Hindley–Milner 演算法:它把整個程式的型別關係收集成一組約束(constraint),再用「合一(unification)」一次解出所有變數的型別。Haskell、OCaml、Rust、Swift 的型別推論都源自這套理論。它的威力在於:你享受靜態型別的安全,卻幾乎不必付出標註的繁瑣成本——這正是動態與靜態兩派長年拉扯後,現代語言找到的折衷。

最近十年的前沿是漸進型別(gradual typing):讓一個原本動態的語言,可以「部分標註」型別,標到哪檢查到哪。TypeScript 之於 JavaScript、Python 的 type hints 都是這條路線。它承認一個務實的現實——大型既有程式庫不可能一夜全部加上型別,所以型別系統必須能「漸進地」滲透進去。

第二條線:記憶體管理,三個世代的拔河

入門篇沒細談的,是高階語言下面那層「記憶體誰來收」的問題。這是語言設計史上最劇烈的一場拔河,至今沒有單一贏家。

第一代:手動管理(manual management)。 C/C++ 讓你自己 malloc/freenew/delete。優點是完全可控、零額外開銷;代價是兩類經典災難——忘了釋放造成記憶體洩漏(memory leak),釋放後又使用造成懸空指標(dangling pointer),後者是無數資安漏洞的根源。

第二代:垃圾回收(Garbage Collection, GC)。 Java、Python、JavaScript、Go 改由執行環境自動偵測「不再被任何人引用的物件」並回收。程式設計師徹底解放,代價是不可預測的暫停(GC pause)與額外的記憶體與 CPU 開銷。GC 有兩大家族:

  • 引用計數(reference counting):每個物件記著「有幾個人指向我」,歸零就釋放(Python 的主力機制、Swift 的 ARC)。優點是回收即時、暫停短;致命弱點是循環引用(reference cycle)——A 指 B、B 指 A,計數永遠不為零,得靠額外的循環偵測器補救。
  • 追蹤式(tracing GC):定期從「根(root)」出發走訪所有可達物件,沒走到的就是垃圾(JVM、Go 的主力)。能處理循環引用,但需要 stop-the-world 或精巧的並行演算法來壓低暫停。

第三代:所有權與借用(ownership & borrowing)。 Rust 的革命在於:把記憶體安全的檢查完全移到編譯期,執行期既沒有 GC、也沒有引用計數的常態開銷。核心是三條規則——每個值只有一個擁有者(owner)、擁有者離開作用域值就被釋放、同一時間要嘛多個唯讀借用、要嘛唯一可寫借用。

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;            // 所有權「移動(move)」給 s2
    // println!("{}", s1);  // 編譯錯誤!s1 已失效,不能再用
    println!("{}", s2);     // OK
}

這套「借用檢查器(borrow checker)」在編譯期就把資料競爭(data race)與懸空指標擋下,等於用型別系統換取了 GC 的功能。代價是學習曲線陡峭——你得重新學會「跟編譯器談判」。這正是開頭那個「為什麼往回走」的答案:Rust 不是退步,而是把第一代的效能與第二代的安全,用編譯期理論縫在一起。

看一個例子:同一個 bug,三代如何處理

考慮「兩個物件互相引用」這個場景,看三代記憶體模型各自的命運:

# 第二代(引用計數):循環引用會讓樸素的計數失效
class Node:
    def __init__(self):
        self.partner = None

a = Node()
b = Node()
a.partner = b
b.partner = a          # a、b 互指,引用計數都 >= 1
del a
del b                  # 主程式不再引用,但計數永不歸零
# CPython 靠額外的「分代循環偵測器」才能回收這組垃圾
// 第三代(所有權):編譯器強迫你「想清楚誰擁有誰」
use std::rc::Rc;
use std::cell::RefCell;

// 想做互指,必須顯式用 Rc(引用計數)+ Weak(弱引用打破循環)
// 語言不讓你「不小心」造出循環——設計階段就被逼著面對

重點不在語法,而在心智負擔被轉移到哪裡:Python 把問題藏進執行期的偵測器,Rust 把問題攤在編譯期逼你解決。沒有免費的午餐,只有「你想在什麼時候付帳」。

第三條線:並行模型,硬體逼出來的演化

2005 年前後 CPU 時脈停止飆升,效能改靠堆核心數。這個硬體轉折,是過去二十年語言演化最強的選擇壓力。語言必須回答:多個任務同時跑,怎麼協調才不會出錯又不會太慢?

執行緒與鎖(threads & locks)。 最古老的模型:多條執行緒共享記憶體,用互斥鎖(mutex)保護共享資料。問題是人類極難正確使用——死鎖(deadlock)、競爭條件(race condition)防不勝防,且 bug 難以重現。

非同步與 async/await。 針對「等待 I/O 時不要空轉」的場景,現代語言(JavaScript、Python、Rust、C#)引入 async/await。它的本質是把函式變成可暫停、可恢復的狀態機(state machine):遇到 await 就讓出控制權,I/O 完成再回來。單執行緒就能處理上萬個並發連線。

import asyncio

async def fetch(name, delay):
    await asyncio.sleep(delay)   # 讓出控制權,不阻塞其他任務
    return f"{name} 完成"

async def main():
    # 三個任務「並發」進行,總耗時約 2 秒而非 1+2+3=6 秒
    results = await asyncio.gather(
        fetch("A", 1), fetch("B", 2), fetch("C", 3)
    )
    print(results)

asyncio.run(main())

訊息傳遞:CSP 與 Actor。 另一條哲學截然不同的路線:不共享記憶體,改用傳訊息溝通。Go 的 goroutine + channel 源自 CSP(Communicating Sequential Processes)理論,口號是「不要用共享記憶體來通訊,要用通訊來共享記憶體」:

func main() {
    ch := make(chan int)
    go func() { ch <- 42 }()   // 一個 goroutine 送值進 channel
    result := <-ch             // 主程式從 channel 取值,自動同步
    fmt.Println(result)        // 42
}

Erlang/Elixir 的 Actor 模型走得更遠:每個 actor 是完全隔離的單位,只能透過信箱收發訊息,一個 actor 崩潰不會拖垮別人。這套模型撐起了電信級的高可用系統。

這三種模型的演化,對應的是對「共享可變狀態」這個萬惡之源的不同態度:鎖試圖管好它,async 試圖在單執行緒上迴避它,訊息傳遞則乾脆消滅它。

第四條線:當「目標機器」本身也在演化

入門篇說編譯器把高階語言翻成「特定 CPU 的機器碼」。但近十年出現一個顛覆性的新目標——WebAssembly(Wasm)。它不是某顆真實 CPU 的指令集,而是一個可攜帶的、近乎原生速度的虛擬指令集。C、C++、Rust、Go 都能編譯成 Wasm,然後在瀏覽器、邊緣節點、甚至無伺服器(serverless)環境裡安全沙箱化執行。

這讓「跨平台」這件入門篇提過的老問題有了新解:過去靠「各平台分別編譯」或「到處都有直譯器」,現在多了第三條路——編譯一次成 Wasm,到處安全執行。它某種意義上是 Java「write once, run anywhere」承諾的重新實現,但這次目標更廣、沙箱更嚴、效能更接近原生。語言演進到這裡,連「機器」的定義都被重寫了。

重點回顧

  • 語言演進不是單向「更抽象」,而是回應選擇壓力:多核心硬體、巨型程式庫的維護成本、雲端部署規模。Rust「往回」讓你管記憶體,是用編譯期理論換效能與安全。
  • 型別系統void* 的人工保證,演化到泛型(參數多型)、Hindley–Milner 型別推論、再到漸進型別,讓安全與便利逐步兼得。
  • 記憶體管理三世代拔河:手動管理(可控但危險)、垃圾回收(解放但有暫停)、所有權系統(編譯期消除錯誤)。差別在於「心智負擔在何時付帳」。
  • 並行模型被多核心硬體逼出三條路線:執行緒加鎖、async/await 狀態機、以及 CSP/Actor 的訊息傳遞,各自對「共享可變狀態」抱持不同態度。
  • WebAssembly 重寫了「目標機器」的定義,提供「編譯一次、到處安全執行」的第三條跨平台路線。

深入探討(研究所視角)

型別系統的能力光譜與 System F。 入門篇提過 Curry–Howard 同構。更精確地說,參數多型的理論骨架是 Girard 與 Reynolds 各自獨立提出的 System F(二階 lambda 演算),它把型別本身變成可被量化的對象($\forall T. T \to T$)。Hindley–Milner 是 System F 的一個可判定(decidable)的子集——它放棄了完整 System F 的表達力,換來「型別推論一定會停且找到最一般型別」的保證。一旦你想要更強的東西(如完整的高階多型、依賴型別),型別推論立刻變得不可判定,這就是為什麼 Haskell 的某些進階擴充需要你補上型別標註。型別系統的設計,本質是在表達力可判定性之間畫一條工程上可接受的線。

記憶體安全的形式化:分離邏輯與線性型別。 Rust 借用檢查器不是憑經驗的 heuristic,它的理論根基是線性型別(linear types)affine types——一個值「至多被使用一次」的代數約束。學界的 RustBelt 計畫更用 分離邏輯(separation logic) 的變體 Iris,在 Coq 中機器驗證了 Rust 型別系統的健全性(soundness),連 unsafe 區塊的安全封裝都被形式化證明。這是把程式語言理論真正落地到主流系統語言的里程碑:你日常寫的 &mut 背後,是一條可被機器檢查的數學證明。

並行正確性與記憶體模型。 並行模型的選擇之下,藏著更底層的記憶體一致性模型(memory consistency model)問題。現代 CPU 與編譯器會為了效能而重排指令,使得多執行緒下「程式碼的書寫順序」未必等於「實際執行順序」。C++11、Java、Rust 都被迫定義精確的記憶體模型,規範 acquire/release/relaxed 等原子操作(atomic)的可見性保證。這連向了分散式系統的核心理論——線性化(linearizability)與順序一致性(sequential consistency)在單機多核心與跨機器叢集中是同一套思想的不同尺度。理解這層,你會發現「並行」與「分散式」其實是同一個問題在不同延遲下的展開。

演化的方向:可被驗證的程式語言。 把三條線收束起來看,現代語言設計有一個共同的暗流——把更多正確性保證從『執行期祈禱』搬到『編譯期證明』。型別推論讓你免費得到型別安全,借用檢查讓你免費得到記憶體安全,session types 這類研究甚至想讓你免費得到通訊協定的正確性。終極願景是依賴型別語言(Idris、Lean、Agda)所代表的方向:程式與其正確性證明合而為一。當這條路走到盡頭,「寫程式」與「做數學證明」的界線將徹底消融——而這,正是入門篇那座抽象階梯往上延伸時,最深的一級台階。

AI 共讀助教正在陪你讀:程式語言演進(進階):型別、記憶體與並行的三場拔河
嗨!我是這篇文章的共讀助教,只根據〈程式語言演進(進階):型別、記憶體與並行的三場拔河〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。