Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

程式語言演進

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

為什麼語言演進不總是「更抽象」?從泛型、所有權到 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 共讀助教正在陪你讀:程式語言演進(進階):型別、記憶體與並行的三場拔河
嗨!我是這篇文章的共讀助教,只根據〈程式語言演進(進階):型別、記憶體與並行的三場拔河〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。