探索匿名遞歸函數

匿名遞歸

在 C# 裏遞歸可以這麼定義嗎?

Func<int, int> fac = (x) => (x <= 1) ? 1 : x * fac(x - 1);

目前不行。因爲 C# 只認識下面這種寫法:

Func<int, int> fac = null;
fac = (x) => (x <= 1) ? 1 : x * fac(x - 1);

但這實際上並未使該函數匿名化,而是把變量 fac 的引用綁定到了匿名函數的上下文中。這在變量 fac 被修改後存在失效的風險。

將自己傳給自己

爲了使匿名遞歸可行,必須將自身作爲參數傳給自己。

這麼寫可行嗎?

var fac = (f, x) => (x <= 1) ? 1 : f(f, x - 1);
fac(fac, 5);

在 C# 裏不行。因爲 fac 的類型簽名無法被自動推斷,需要人工提供。

delegate int SelfFactorial(SelfFactorial f, int x);

寫成泛型,提高通用性:

delegate TResult SelfApplicable<T, TResult>(SelfApplicable<T, TResult> self, T arg);
SelfApplicable<int, int> fac = (f, x) => (x <= 1) ? 1 : f(f, x - 1);
fac(fac, 5);

更進一步,爲 fac 構建函數閉包,得到我們想要的函數形式:

Func<int, int> Fac = (x) => fac(fac, x);

綜合以上過程,給出一個通用形式的幫助函數,簡化類型推斷:

static Func<T, TR> Make<T, TR>(SelfApplicable<T, TR> self)
{
    return (x) => self(self, x);
}

var fac = Make<int, int>((f, x) => (x <= 1) ? 1 : x * f(f, x - 1));

推廣到兩個參數的情形:

delegate TR SelfApplicable<T1, T2, TR>(SelfApplicable<T1, T2, TR> self, T1 arg1, T2 arg2);

static Func<T1, T2, TR> Make<T1, T2, TR>(SelfApplicable<T1, T2, TR> self)
{
    return (x, y) => self(self, x, y);
}

var gcd = Make<int, int, int>((f, x, y) => (y == 0) ? x : f(f, y, x % y));

但類型推斷並不是什麼大問題,下文如因類型推斷而無法寫出,大可手動補上。

柯里化

柯里化即將多參數的函數轉化爲多個單參數函數的嵌套。

你可能會想到這種寫法:

var fac = Make2<int, int>((f) => (x) => (x <= 1) ? 1 : x * f(x - 1));

這需要配套怎樣的 Make2 呢?

static Func<T, TR> Make2<T, TR>(Func<Func<T, TR>, Func<T, TR>> g)
{
    // 建立一個新的上下文,在裏面用上 g 就能把 g 保存起來。
    var wrapped_k = (x) => {
        // 先跳過這行分析下面的,因爲 h 是爲了傳給 g 做參數的。
        // 這個操作必須在子函數體內,不然就死循環了。
        var h = Make2(g);
        // k 纔是想要的那個功能函數,但獲得這個 k 之前沒法傳給 g 做其參數 f,陷入了雞生蛋蛋生雞的矛盾。
        // g 的參數 f 無法是 k,但 Make2 能構造 k 的轉發函數,且轉發函數使用時纔會計算,不會死循環。
        var k = g(h);
        k(x);
    };
    // 這是一個 k 的轉發函數,用起來就跟 k 沒什麼區別。而且它的上下文裏有 g 的引用。
    return wrapped_k;
}

這是一個不動點組合子(將在下文中解釋其含義),讓我們先將其重命名爲 Fix

static Func<T, TR> Fix<T, TR>(Func<Func<T, TR>, Func<T, TR>> g)
{
    return (x) => g(Fix(g))(x);
}

Fix 要配合一種兩層的匿名函數寫法。其中遞歸函數自身作爲外層函數的參數,Fix 將其轉化爲了可以直接使用的函數對象。

兩個參數的 Fix 函數也可以順利寫出來了:


static Func<T1, T2, TR> Fix<T1, T2, TR>(Func<Func<T1, T2, TR>, Func<T1, T2, TR>> g)
{
    return (x, y) => g(Fix(g))(x, y);
}
// g0 也可稱爲單步函數
var g0 = (f) => (x) => (x <= 1) ? 1 : x * f(x - 1);
var fac = Fix<int, int>(g0);
fac(5);

var g1 = (f) => (x, y) => (y == 0) ? x : f(y, x % y);
var gcd = Fix<int, int, int>(g1);
gcd(10, 15);

先把單步函數抽象一下:

// use(x) 產生當次執行的計算結果
// next(f, x) 遞歸地產生 f(x),或是在沒有下一個 x 時及時終止
// reduce(a, b) 將當次與遞歸的結果合併爲最終結果
var g = (f) => (x) => reduce(use(x), next(f, x));

以一個參數的 Fix 函數爲例分析其過程。

先分析這個兩層匿名函數 g,並將內層函數單獨稱爲 k:

// 注意:這是方便理解而拆開的僞代碼,因爲不可能使 k 在沒有 f 的上下文中綁定到 f。類型推斷也是個問題。
var k = (x) => reduce(use(x), next(f, x));
var g = (f) => k;

var f0 = Fix(g) = (x) => g(Fix(g))(x);

這裏的參數 x 被直接轉發給了內部函數 h(Fix(g)),因此我們可以在分析時簡化。

var f0 = (x) => k(x), f=Fix(g);

雖然每次 f=Fix(g) 計算得到一個新的函數對象而不是複用已得到的 f0,但二者的效果是相同的。

這裏有一個等式:

g(Fix(g)) == Fix(g)

一般地,我們稱值 x 是函數 f 的一個不動點,當且僅當 f(x) = x

那麼根據上文中的兩個等式,值 Fix(g) 是函數 g 的一個不動點。

Y-組合子

Y-組合子定義爲:

Y = λf.(λx.f (x x)) (λx.f (x x))

注意:根據 α-變換,兩個 λx 是不同的變元,互不影響。即上式與下式等價:

Y = λf.(λx.f (x x)) (λy.f (y y))

但只要表達式相同,自由變元的名字無關緊要,所以在兩個不同的地方都用 λx 是沒問題的。

拆分一下,方便理解:

h = λx.f (x x)
Y = λf.h h

寫成 C# 是:

var Y = (f) => {
    // 這雖然寫得出代碼,但執行起來會死循環
    var H = (x) => f(x(x));
    return H(H);
};

因此這裏需要多一層轉發函數的嵌套,使 x(x) 被推遲執行。推遲執行最重要的目的是在遞歸到頭的時候不再計算從而能夠退出。

增加一層轉發函數,這對應於 λ-演算,即可以使用 η-變換。有兩個做法:

  • x x 展開爲 λv.(x x) v
  • f (x x) 展開爲 λv.(f (x x)) v

第二種變換對應的 C# 是:

var Y = (g) => {
    var H = (h) => {
        var wrapped_k = (x) => {
            // 每次 h(h) 都得到一個新的 wrapped_k
            var new_wrapped_k = h(h);
            // 在 wrapped_k 中使用了 g
            // 換取真正的功能函數,而 new_wrapped_k(next(x)) 是能遞歸下去的
            var k = g(new_wrapped_k);
            return k(x);
        };
        return wrapped_k;
    };
    // 巧妙的 H(H), h(h) 組合,創建對 g 的閉包
    return H(H);
};

簡寫爲:

var Y = (g) => {
    var H = (h) => (x) => g(h(h))(x);
    return H(H);
};

Θ-組合子

var H = (h) => (g) => (x) => g(h(h)(g))(x);
var Θ = H(H);

Θ-組合子 與 Y-組合子 的唯一區別就是變量 g 在多層函數的位置,以及因此而需要的一個重複傳參的步驟。

這兩個組合子的對比同時說明了以下等式:

λx.λy.((f x y) y0 x0) == λy.λx.((f x y) x0 y0)
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章