1. 程式人生 > >C# 函數語言程式設計:LINQ

C# 函數語言程式設計:LINQ

一直以來,我以為 LINQ 是專門用來對不同資料來源進行查詢的工具,直到我看了這篇十多年前的文章,才發現 LINQ 的功能遠不止 Query。這篇文章的內容比較高階,主要寫了用 C# 3.0 推出的 LINQ 語法實現了一套“解析器組合子(Parser Combinator)”的過程。那麼這個組合子是用來幹什麼的呢?簡單來說,就是把一個個小型的語法解析器組裝成一個大的語法解析器。當然了,我本身水平有限,暫時還寫不出來這麼高階的程式碼,不過這篇文章中的一段話引起了我的注意:

Any type which implements Select, SelectMany and Where methods supports (part of) the "query pattern" which means we can write C#3.0 queries including multiple froms, an optional where clause and a select clause to process objects of this type.

大意就是,任何實現了 SelectSelectMany 等方法的型別,都是支援類似於 from x in y select x.z 這樣的 LINQ 語法的。比如說,如果我們為 Task 型別實現了上面提到的兩個方法,那麼我們就可以不借助 async/await 來對 Task 進行操作:

// 請在 Xamarin WorkBook 中執行
var taskA = Task.FromResult(12);
var taskB = Task.FromResult(12);

// 使用 async/await 計算 taskA 跟 taskB 的和
var a = await taskA;
var b = await taskB;
var r = a + b;

// 如果為 Task 實現了 LINQ 拓展方法,就可以這麼寫:
var r = from a in taskA
        from b in taskB
        select a + b;

那麼我們就來看看如何實現一個非常簡單的 LINQ to Task 吧。

LINQ to Task

首先我們要定義一個 Select 拓展方法,用來實現通過一個 Func<TValue, TResult>Task<TValue> 轉換成 Task<TResult> 的功能。

static async Task<TR> Select<TV,TR>(this Task<TV> task, Func<TV, TR> selector) {
    var value = await task;    // 取出 task 中的值
    return selector(value);    // 使用 selector 對取出的值進行變換
}

這個函式非常簡單,甚至可以簡化為一行程式碼,不過僅僅這是這樣就可以讓我們寫出一個非常簡單的 LINQ 語句了:

var taskA = Task.FromResult(12);
var r = from a in taskA select a * a;

那麼實際上 C# 編譯器是如何工作的呢?我們可以藉助下面這個有趣的函式來一探究竟:

void PrintExpr<T1,T2>(Expression<Func<T1, T2>> expr) {
    Console.WriteLine(expr.ToString());
}

熟悉 LINQ 的人肯定對 Expression 不陌生,Expressing 給了我們在執行時解析程式碼結構的能力。在 C# 裡面,我們可以非常輕鬆地把一個 Lambda 轉換成一個 Expression,然後呼叫轉換後的 Expression 物件的 ToString() 方法,我們就可以在執行時以字串的形式獲取到 Lambda 的原始碼。例如:

var taskA = Task.FromResult(12);
PrintExpr((int _) => from a in taskA select a * a);
// 輸出: _ => taskA.Select(a => (a * a))

可以看到,Expression 把這段 LINQ 的真面目給我們揭示出來了。那麼,更加複雜一點的 LINQ 呢?

var taskA = Task.FromResult(12);
var taskB = Task.FromResult(12);
PrintExpr((int _) =>
    from a in taskA
    from b in taskB
    select a * b
    );

如果你嘗試執行這段程式碼,你應該會遇到一個錯誤——缺少對應的 SelectMany 方法,下面給出的就是這個 SelectMany 方法的實現:

static async Task<TR> SelectMany<TV, TS, TR>(this Task<TV> task, Func<TV, Task<TS>> selector, Func<TV,TS, TR> projector){
    var value = await task;
    var selected = await selector(value);
    return projector(value, selected);
}

這個 SelectMany 實現的功能就是,通過一個 Func<TValue, Task<TResult>>Task<TValue> 轉換成 Task<TResult>。有了這個之後,你就可以看到上面的那個較為複雜的 LINQ to Task 語句編譯後的結果:

_ => taskA.SelectMany(a => taskB, (a, b) => (a * b))

可以看到,當出現了兩個 Task 之後,LINQ 就會使用 SelectMany 來代替 Select。可是我想為什麼 LINQ 不像之前那樣,用兩個 Select 分別處理兩個 Task 呢?為了弄清楚這個問題,我試著推導了一番:

// 首先簡單粗暴的用兩個 Select 來實現這個功能
Task<Task<int>> r = taskA.Select(a => b.Select(b => a + b));

// r 被包裹了兩層 Task,我們可以用 SelectMany 來去掉一層 Task 包裝
// 這時 TValue 是 Task<int>, TResult 是 int
//
// 那麼 Task<Task<int>>
// 將通過 Func<Task<int>, Task<int>>
// 轉換成 Task<int>

Task<int> result = r.SelectMany(x => x, (_, x) => x);

結果比 LINQ 還多呼叫了兩次 Select。仔細看的話,就會發現,我們所寫的第二個 Select 其實就是 SelectMany,的第二個引數,而對於第一個 Select 來說,因為 b 是一個 Task,所以 b.Select(xxx) 的返回值肯定是一個 Task,而這又恰好符合 SelectMany 函式的第一個引數的特徵。

有了上面的經驗,我們不難推斷出,當 from x in y 語句的個數超過 2 個的時候,LINQ 仍然會只使用 SelectMany 來進行翻譯。因為 SelectMany 可以被看作為把兩層 Task 轉換成單層 Task,例如:

var taskA = Task.FromResult(12);
var taskB = Task.FromResult(12);
var taskC = Task.FromResult(12);
PrintExpr((int _) =>
    from a in taskA
    from b in taskB
    from c in taskC
    select a * b + c
    );

// 我的推斷:
var r = taskA.SelectMany(a => taskB, (a, b) => new {a, b}).SelectMany(temp => taskC, (temp, c) => temp.a * temp.b + c);

// 實際的輸出:
// _ => taskA.SelectMany(a => taskB, (a, b) => new <>f__AnonymousType0#1`2(a = a, b = b)).SelectMany(<>h__TransparentIdentifier0 => taskC, (<>h__TransparentIdentifier0, c) => ((<>h__TransparentIdentifier0.a * <>h__TransparentIdentifier0.b) + c))

這裡 LINQ 為第一個 SelectMany 的結果生成了一個匿名的中間型別,將 taskA 跟 taskB 的結果組合成了 Task<{a, b}>,方便在第二個 SelectMany 中使用。

至此,一個非常簡單的 LINQ to Task 就完成了,通過這個小工具,我們可以實現不使用 async/await 就對型別進行操作。然而這並沒有什麼卵用,因為 async/await 確實要比 from x in y 這種語法要來的更加簡單。不過舉一反三,我們可以根據上面的經驗來實現一個更加使用的小功能。

LINQ to Result

在一些比較函式式的語言(如 F#,Rust)中,會使用一種叫做 Result<TValue, TError> 的型別來進行異常處理。這個型別通常用來描述一個操作結果以及錯誤資訊,幫助我們遠離 Exception 的同時,還能保證我們全面的處理可能出現的錯誤。如果使用 C# 實現的話,一個 Result 型別可以被這麼來定義:

class Result<TValue, TError>
{
    public TValue Value {get; private set;}
    public TError ErrorMsg {get; private set;}
    public bool IsSuccess {get; private set;}
    public override string ToString()
    {
        if(this.IsSuccess)
            return "Success: " + Value.ToString();
        return "Error: " + ErrorMsg.ToString();
    }

    public static Result<TValue, TError> OK(TValue value)
    {
        return new Result<TValue, TError> {Value = value, ErrorMsg = default(TError), IsSuccess = true};
    }

    public static Result<TValue, TError> Error(TError error)
    {
        return new Result<TValue, TError> {Value = default(TValue), ErrorMsg = error, IsSuccess = false};
    }
}

接著仿照上面為 Task 定義 LINQ 拓展方法,為了 Result 設計 SelectSelectMany

static Result<TR, TE> Select<TV,TR, TE>(this Result<TV, TE> result, Func<TV, TR> selector) =>
    result.IsSuccess
    ? Result<TR, TE>.OK(selector(result.Value))
    : Result<TR, TE>.Error(result.ErrorMsg);

static Result<TR, TE> SelectMany<TV, TS, TR, TE>(this Result<TV, TE> result, Func<TV, Result<TS, TE>> selector, Func<TV, TS, TR> projector){
    if (result.IsSuccess)
    {
        var tempResult = selector(result.Value);
        if (tempResult.IsSuccess)
        {
            return Result<TR, TE>.OK(projector(tempResult.Value, tempResult.Value));
        }
        return Result<TR, TE>.Error(tempResult.ErrorMsg);
    }
    return Result<TR, TE>.Error(result.ErrorMsg);
}

那麼 LINQ to Result 在實際中的應用是什麼樣子的呢,接下來我用一個小例子來說明: 某公司為感謝廣大新老使用者對 “5 元 30 M”流量包的支援,準備給餘額在 350 元使用者的以上的使用者送 10% 話費。但是呢,如果使用者在收到贈送的話費後餘額會超出 600 元,就不送話費了。

using Money = Result<double, string>;

// 查詢指定 Id 的使用者是否存在
Result<int, string> GetUserById(int id)
{
    if(id % 7 == 0)
    {
        // 正常的使用者
        return Result<int,string>.OK(id);
    }
    if(id % 2 == 0)
    {
        return Result<int, string>.Error("使用者已被凍結");
    }
    return Result<int, string>.Error("使用者不存在");
}

// 查詢指定使用者的餘額
Money GetMoneyFromUser(int id)
{
    if (id >= 35)
    {
        return Money.OK(id * 10);
    }
    return Money.Error("窮逼使用者不參與這次活動");
}

// 給使用者轉賬
Money Transfer(double money, double amount)
{
    return  from canTransfer in CheckForTransfer(money, amount)
            select canTransfer ? money + amount : money;
}

// 檢查使用者是否滿足轉賬條件,如果轉賬後的餘額超過了 600 元,則終止轉賬
Result<bool, string> CheckForTransfer(double a, double b)
{
    if (a + b >= 600) {
        return Result<bool,string>.Error("超出餘額限制");
    }
    return Result<bool,string>.OK(true);
}

Money SendGift(int userId)
{
    return  // 查詢使用者資訊
            from user in GetUserById(userId)
            // 獲取該使用者的餘額
            from money in GetMoneyFromUser(user)
            // 給這個使用者轉賬
            from transfer in Transfer(money, money * 0.1)
            // 獲取結果
            select transfer;
}

SendGift(42)
// Success: 462

SendGift(56)
// Error: 超出餘額限制

SendGift(1)
// Error: 使用者不存在

SendGift(14)
// Error: 窮逼使用者不參與這次活動

SendGift(16)
// Error: 使用者已被凍結

可以看到,使用 Result 能夠讓我們更加清晰地用程式碼描述業務邏輯,而且如果我們需要向現有流程中新增新的驗證邏輯,只需要在合適地地方插入 from result in validate(xxx) 就可以了,換句話說,我們的程式碼變得更加“宣告式”了。

函數語言程式設計

細心的你可能已經發現了,不管是 LINQ to Task 還是 LINQ to Result,我們都使用了某種特殊的型別(如:Task,Result)對值進行了包裝,然後編寫了特定的拓展方法 —— SelectMany,為這種型別定義了一個重要的基本操作。在函數語言程式設計的裡面,我們把這種特殊的型別統稱為“Monad”,所謂“Monad”,不過是自函子範疇上的半么群而已。

範疇(Category)與函子(Functor)

在高中數學,我們學習了一個概念——集合,這是範疇的一種。

對於我們程式設計師來說,int 型別的全部例項構成了一個集合(範疇),如果我們為其定義了一些函式,而且它們之間的複合運算滿足結合律的話,我們就可以把這種函式叫做 int 類型範疇上的“態射”,態射講的是範疇內部元素間的對映關係,例如:

// f(x) = x * 2
Func<int, int> f = (int x) => x * 2;
// g(x) = x + 1
Func<int, int> g = (int x) => x + 1;
// h(x) = x + 10
Func<int, int> h = (int x) => x + 10;

// 將函式 g 與 f 複合,(g ∘ f)(x) = g(f(x))
Func<X, Z> Compose<X, Y, Z>(Func<Y, Z> g, Func<X, Y> f) => (X x) => g(f(x));

Compose(h, Compose(g, f))(42) == Compose(Compose(h, g), f)(42)
// true

fgh 都是 int 類型範疇上的態射,因為函式的複合運算是滿足結合律的。

我們還可以定義一種範疇間進行元素對映的函式,例如:

Func<int, double> ToDouble = x => Convert.ToDouble(x);

這裡的函式 Select 實現了 int 範疇到 double 範疇的一個對映,不過光對映元素是不夠的,要是有一種方法能夠幫我們把 int 中的態射(fgh),對映到 double 範疇中,那該多好。那麼下面的函式 F 就幫助我們實現了這了功能。

// 為了方便使用 Compose 進行演示,故定義了一個比較函式式的 ToInt 函式
Func<double, int> ToInt = x => Convert.ToInt32(x);
// 一個將 int -> int 轉換為 double -> double 的函式
Func<double, double> F(Func<int, int> selector) => x => Compose(Compose(ToDouble, selector), ToInt)(x);

// 在範疇間對映 f
var Ff = F(f);
Ff(42.0);
// 84.00

// 在範疇間對映 g
var Fg = F(g);
Fg(42.0);
// 43.00

// 在範疇間對映 h
var Fh = F(h);
Fh(42.0);
// 52.00

// Ff, Fg, Fh 之間仍然保持結合律,因為他們是 `double` 範疇上的態射
Compose(Fh, Compose(Fg, Ff))(42) == Compose(Compose(Fh, Fg), Ff)(42)

因為 F 能夠將一個範疇內的態射對映為另一個範疇內的態射,ToDouble 可以將一個範疇內的元素對映為另一個範疇內的元素,所以,我們可以把 FToDouble 的組合稱作“函子”。函子體現了兩個範疇間元素的抽象結構上的相似性。

相信看到這裡你應該對範疇跟函子這兩個概念有了一定的瞭解,現在讓我們更進一步,看看 C# 中泛型與範疇之間的關係。

型別與範疇

在之前,我們是以數值為基礎來理解範疇這個概念的,那麼現在我們從型別的層面來理解範疇。

泛型是我們非常熟悉的 C# 語言特性了,泛型型別與普通型別不一樣,泛型型別可以接受一個型別引數,看起來就像是型別的函式。我們把接受函式作為引數的函式稱為高階函式,依此類推,我們就把接受型別作為引數的型別叫做高階型別吧。這樣,我們就可以從這個層面把 C# 的型別分為兩類:普通型別(非泛型)和高階型別(泛型)。

前面的例子中,我列出的 fgh 能夠完成 int -> int 的轉換,因為它們是 int 範疇內的態射。而 ToDouble 能夠完成 int -> double 的轉換,那我們就可以將他看作是普通類型範疇的態射,類似的,我們還可以定義出 ToInt32ToString 這樣的函式,它們都能完成兩個普通型別之間的轉換,所以也都可以看作是普通類型範疇的態射。

那麼對於高階型別(也就是泛型)範疇來說,是不是也存在態射這樣的東西呢?答案是肯定的,舉個例子,用 LINQ 把 List<int> 轉換成 List<double>

Func<List<int>, List<double>> ToDoubleList = x => x.Select(ToDouble).ToList();

不難發現,這裡的 ToDoubleListList<T> 類型範疇內的一個態射。不過你可能已經注意到了我們使用的 ToDouble 函式,它是普通類型範疇內的一個態射,我們僅僅通過一個 Select 函式就把普通類型範疇內的一個態射對映成了 List<T> 範疇內的一個態射(上面的例子中,是把 (int -> double) 轉換成了 (List<int> -> List<double>)),而且 List<T> 還提供了能夠把 int 型別轉換成 List<int> 型別(type)的方法:new List<int>{ intValue },那麼我們就可以把 List<T> 類(class)稱為“函子”。事情變得有趣了起來。

自函子

List<T> 還有一個建構函式可以允許我們使用另一個 List 物件建立一個新的 List 物件:new List<T>(list),這完成了 List<T> -> List<T> 轉換,這看起來像是把 List<T> 範疇中的元素重新對映到了 List<T> 範疇中。有了這個建構函式的幫助,我們就可以試著使用 Select 來對映 List<T> 中的態射(比如,ToDoubleList):

// 這個對映後的 ToDoubleListAgain 仍然能夠正常的工作
Func<List<int>, List<List<double>>> ToDoubleListAgain = x => x.Select(e => ToDoubleList(new List<int>(){e})).ToList();

這裡的返回值型別看起來有些奇怪,我們得到了一個巢狀兩層的 List,如果你熟悉 LINQ 的話,馬上就會想到 SelectMany 函式——它能夠把巢狀的 List 拍扁:


Func<List<TV>, List<TR>> FF<TV, TR>(Func<List<TV>, List<TR>> selector)
{
    return xl => xl.SelectMany(x => selector(new List<int>() {x})).ToList();
}

var ToDoubleListAgain = FF(ToDoubleList);
ToDoubleListAgain(new List<int>{1})

這樣,我們就實現了 (List<T1> -> List<T2>) -> (List<T1> -> List<T2>) 的對映,雖然功能上並沒有什麼卵用,但是卻實現了把 List<T> 範疇中的態射對映到了 List<T> 範疇中的功能。現在看來,List<T> 類不僅是普通型別對映到 List<T> 的一個函子,它也是 List<T> 對映到 List<T> 的一個函子。這種能夠把一個範疇對映到該範疇本疇上的函子也被稱為“自函子”。

我們可以發現,C# 中大部分的自函子都通過 LINQ 拓展方法實現了 SelectMany 函式,其簽名是:

SomeType<TR> SelectMany<TV, TR>(SomeType<TV> source, Func<TV, SomeType<TR>> selector);

List<T> 還有一個不接受任何引數的建構函式,它會創建出一個空的列表,我們可以把這個函式稱作 unit,因為它的返回值在 List<T> 相關的一些二元運算中起到了單位 1 的作用。比如,concat(unit(), someList)concat(someList, unit()) 得到的列表,在結構上是等價的。擁有這種性質的元素被稱為“單位元”。

在函數語言程式設計中,我們把擁有 SelectMany(也被叫做 bind),unit 函式的自函子稱為“Monad”。

但是 C# 中並不是所有的泛型類是自函子,例如 Task<T>,如果我們不為它新增 Select 拓展方法,它連函子都算不上。所以如果把 C# 中全部的自函子型別放在一個集合中,然後把這些自函子型別之間用來做型別轉換的全部函式(例如,list.ToArray() 等)看作是態射,那麼我們就構建出來了一個 C# 中的“自函子範疇”。在這個範疇上,我們只能對 Monad 型別使用 LINQ 語法進行復合運算,例如上面的:

// 原版
var result =
from a in taskA
from b in taskB
from c in taskC
select a * b + c;

// 1. 滿足結合律
var left =
from a in taskA
    from t in (
        from b in taskB
        from c in taskC
        select new {b, c}
    )
select a * t.b + t.c;

var left =
from t in (
    from a in taskA
    from b in taskB
    select new {a, b}
)
from c in taskC
select t.a * t.b + c;

left == right
// true

// 2. 存在單位元

var left =  from a in Task.FromException(null)
            from b in taskB
            select a + b;

var right = from b in taskB
            from a in Task.FromException(null)
            select a + b;

// 因為 left right 得到的都是 Task.FromException(null) 的返回值,故 Task.FromException(null) 是單位元

由於這種作用在兩個 Monad 上面的二元運算滿足交換律且 Monad 中存在單位元,與群論中么半群的定義比較類似,所以,我們也把 Monad 稱為“自函子範疇上的么半群”。儘管這句話聽起來十分的高大上,但是卻並沒有說明 Monad 的特徵所在。就好比別人跟你介紹手機運營商,說這是一個提供簡訊、電話業務的公司,你肯定不知道他到底再說哪一家,不過他要是說,這是一個提供 5 元 30 M 流量包的手機運營商,那你就知道了他指的是中國移動。

個人體會

其實我一開始想寫的內容只有 LINQ to Result 跟 LINQ to Task 的,但是在編寫程式碼的過程中,種種跡象都表明著 LINQ 跟函數語言程式設計中的 Monad 有不少關係,所以就把剩下的函數語言程式設計這一部分給寫出來了。

Monad 作為函數語言程式設計中一種重要的資料型別,可以用來表達計算中的每一小步的功能,通過 Monad 之間的複合運算,我們可以靈活的將這些小的功能片段以一種統一的方式重組、複用,除此之外,我們還可以針對特定的需求(非同步、錯誤處理、懶惰計算)定義專門的 Monad 型別,幫助我們以一種統一的形式將這些特別的功能嵌入到程式碼之中。在傳統的面向物件的程式語言中 Monad 這個概念確實是不太好表達的,不過有了 LINQ 的幫助,我們可以比較優雅地將各種 Monad 組合起來。

用 LINQ 來對 Monad 進行運算的缺點,主要就是除了 SelectMany 之外的,我們沒辦法定義其他的能在 Query 語法中使用的函數了,要解決這個問題,請關注我的下一篇文章:“F# 函數語言程式設計:Computational Expression”(挖坑預備)。

參考資料