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”(挖坑預備)。


參考資料

  1. https://zh.wikipedia.org/zh-hans/函子
  2. https://en.wikipedia.org/wiki/Monad_(functional_programming)
  3. http://hongjiang.info/understand-monad-4-what-is-functor/


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM