CPS 與程式語言的表現
我居然復活了,為了有地方可以寫在公司打算用在分享會的主題
線上編輯器還是太方便了
---------------------------
因為最近玩了 [Gleam 語言](https://gleam.run),其中對於 CPS 有很有趣的處理,所以想說可以介紹一下。
# 什麼是 CPS
CPS (Continuation-Passing Style) 指的是函式執行的結果不直接回傳,而是要求傳入 continuation ,就是一個 function 去接收執行結果並執行接下來的動作。
讓我們用一個超簡單的例子:1 + 2 - 3 * 4 (這裡我們從前到後順序進行,乘法不優先,因為我懶得寫括號)。加減乘法我們用 add/sub/mul 函數表示。
如果是一般的寫法,大概會像是這樣(用類似 js 的語法)
```js
var a = add(1, 2)
var b = sub(a, 3)
var result = mul(b, 4)
// 用 result 來做接下來的事
```
而如果是 CPS 的話大概會像是這樣
```js
add(1, 2, (a) => {
sub(a, 3, (b) => {
mul(b, 4, (result) => {
// 用 result 來做接下來的事
})
})
})
```
# CPS 與 Callback
「這不就是 Callback 嗎?」某方面來說或許這麼講也沒錯。
但是在這裡,我想把兩者區分開來。CPS 是一種寫作方式,而 Callback 則是在支援「以函數作為參數」功能的程式語言中可以做出 CPS 的一種實踐。
為什麼我想做出這種區分呢?因為接下來我想討論一下在不同的程式語言中,如何表現/實踐 CPS。
# CPS 的不同程式語言表現
## Callback(與匿名函式)
Callback 應該是最常見的吧,上面的例子就是使用 Callback 的形式,配合 js 的箭頭函式就不用將 continuation 分開撰寫。
然而,這種方法的問題是顯而易見的,就是我們會有超深的 indent,被稱為 indentation/callback hell。我們甚至有對應的梗圖:

## Promise (js)
JS 中的 Promise 是另一種表示法,上面的 code 如果 add/sub/mul 使用 promise 技術的話,則可以寫成這樣:
```js
add(1, 2)
.then((a) => sub(a, 3))
.then((b) => mul(b, 4))
.then((result) => {
// 用 result 來做接下來的事
})
```
透過這種方式,不像前面直接傳入 function 一樣每層會越來越深,而是使用 then 「重置」了深度。
這其實是一種很聰明的作法。使用其原來就有的語言特性去串接而不用擴展語言本身或者增加語法糖。
## Async/Await (js)
我們接下來要進入到會影響到語言的層面了。
async/await 在 js 中是 promise 的語法糖,也就是說雖然從 code 上面看不到 promise 的 then 跟 callback 了,但是其實用的還是 promise。
如果 add/sub/mul 都是 async function 的話,那麼上面的 code 可以寫成這樣
```js
var a = await add(1, 2)
var b = await sub(a, 3)
var result = await mul(b, 4)
// 用 result 做接下來的事
```
有趣的是,雖然這是也是在做 continuation passing (畢竟他還是 promise),但是我們透過擴展語言本身將看起來的樣子變回了普通的(direct style)樣子。
> Note: 關於 Async/Await
> 這裡只將討論限制在 js 當中。
> 其原因在於 async/await 其實是值得作為一個單獨的概念模型描述的,只是在 js 中他正好是建立在 promise 上,因此跟 CPS 產生了聯繫。
> 實際上在其他的語言中,async/await 完全有可能是另外的做法。至於 async/await 模型的概念,便不在這篇文章的範圍內了。
## use (gleam)
終於來到本文的主題!
在 gleam 中,use 是一個專門用來對應「在最後以一個函式作為參數的函式」的語法糖。
如果不使用 use 的話,那麼前面的例子只能寫成這樣(呃對,就是差不多原來的樣子)
```gleam
add(1, 2, fn (a) {
sub(a, 3, fn (b) {
mul(b, 4, fn (result) {
// 用 result 來做接下來的事
})
})
})
```
但是使用 use 的話則可以變成這樣
```gleam
use a <- add(1, 2)
use b <- sub(a, 3)
use result <- mul(b, 4)
// 用 result 來做接下來的事
```
那麼 use 到底做了什麼呢?他其實就是把 use 之後的部分(注意到了嗎?「之後的部分」就是一般寫法下的 continuation)變成了一個匿名函式,然後作為最後一參傳入。
讓我們把上面的例子一步步還原:
```gleam
use a <- add(1, 2)
use b <- sub(a, 3)
use result <- mul(b, 4)
// 用 result 來做接下來的事
```
->
```gleam
use a <- add(1, 2)
use b <- sub(a, 3)
mul(b, 4, fn(result) {
// 用 result 來做接下來的事
})
```
->
```gleam
use a <- add(1, 2)
sub(a, 3, fn(b) {
mul(b, 4, fn(result) {
// 用 result 來做接下來的事
})
})
```
->
```gleam
add(1, 2, fn(a) {
sub(a, 3, fn(b) {
mul(b, 4, fn(result) {
// 用 result 來做接下來的事
})
})
})
```
在 gleam 的例子中,是透過直接增加一個語言層面的關鍵字和轉換來處理 callback hell 的問題。是一個純粹的語法糖。
實際上,正因為他只是一個轉換,所以使用的場景並不被限制在 continuation 上面,只要函式的最後一參接受的是一個函式就行了。
## 加映:call/cc (scheme)
說到 continuation,似乎總是繞不開 call/cc。但是其語法和前面比較常見的差有點多,因此寫在最後,並且只淺淺地進行說明吧。
首先,我們需要先熟悉一下這種 lisp 方言的寫法。
scheme 的語法是使用 () 表達程式,以 1 + 2 - 3 * 4 這個例子來說,他會寫成
```scheme
(mul (sub (add 1 2) 3) 4)
```
這個東西 evaluate 出來的結果就是 1 + 2 - 3 * 4。可以簡單的想像成 () 的第一個東西是函式,或者說要做的事情,後面則是其參數。
你大概可以猜出 scheme 的程式會長什麼樣子(如果你喜歡 S-expression 的話就抱歉了,我好喜歡語言笑話):

而匿名函式則是長這樣:
```scheme
(lambda (arg) expression)
```
所以如果我們的 add/sub/mul 是用 callback 的方法寫的話,那麼前面的例子會長這樣:
```scheme
(add 1 2 (lambda (a)
(sub a 3 (lambda (b)
(mul b 4 (lambda (result)
#|用 result 來做接下來的事|# ))))))
```
(希望你看了之後有更喜歡前面的梗圖,也希望我沒有漏掉任何一個括號)
那麼,如果使用 call/cc 的話呢?這裡我們用一個就好,免得事情變得太瘋狂
```scheme
(mul (sub
(call/cc (lambda (k) (k (add 1 2))))
3) 4)
```
在這裡,當呼叫 call/cc 時,作為 call/cc 參數的匿名函式的 k 實際上是他周遭的東西(也就是接下來要做的事):
```scheme
(mul (sub _ 3) 4)
```
使用 (add 1 2) 作為參數呼叫 k 其實就是把 (add 1 2) 填入洞中的意思。
你可以注意到 call/cc 和之前所寫的都不同。前面的匿名函式中(如果有的話),都是「接受結果,決定接下來要做什麼」,匿名函式即 continuation 本身。
但是在這裡 call/cc 的匿名函式中,continuation 其實是參數 k,我們做的是「控制 continuation 什麼時候、以什麼結果來執行」。
2026-10-03 20:15:41
留言
Last fetch: --:--
現在還沒有留言!