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.
大意就是,任何實現了 Select
,SelectMany
等方法的型別,都是支援類似於 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 設計 Select
跟 SelectMany
:
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
f
,g
,h
都是 int
類型範疇上的態射,因為函式的複合運算是滿足結合律的。
我們還可以定義一種範疇間進行元素對映的函式,例如:
Func<int, double> ToDouble = x => Convert.ToDouble(x);
這裡的函式 Select
實現了 int
範疇到 double
範疇的一個對映,不過光對映元素是不夠的,要是有一種方法能夠幫我們把 int
中的態射(f
,g
,h
),對映到 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
可以將一個範疇內的元素對映為另一個範疇內的元素,所以,我們可以把 F
與 ToDouble
的組合稱作“函子”。函子體現了兩個範疇間元素的抽象結構上的相似性。
相信看到這裡你應該對範疇跟函子這兩個概念有了一定的瞭解,現在讓我們更進一步,看看 C# 中泛型與範疇之間的關係。
型別與範疇
在之前,我們是以數值為基礎來理解範疇這個概念的,那麼現在我們從型別的層面來理解範疇。
泛型是我們非常熟悉的 C# 語言特性了,泛型型別與普通型別不一樣,泛型型別可以接受一個型別引數,看起來就像是型別的函式。我們把接受函式作為引數的函式稱為高階函式,依此類推,我們就把接受型別作為引數的型別叫做高階型別吧。這樣,我們就可以從這個層面把 C# 的型別分為兩類:普通型別(非泛型)和高階型別(泛型)。
前面的例子中,我列出的 f
,g
,h
能夠完成 int -> int
的轉換,因為它們是 int
範疇內的態射。而 ToDouble
能夠完成 int -> double
的轉換,那我們就可以將他看作是普通類型範疇的態射,類似的,我們還可以定義出 ToInt32
,ToString
這樣的函式,它們都能完成兩個普通型別之間的轉換,所以也都可以看作是普通類型範疇的態射。
那麼對於高階型別(也就是泛型)範疇來說,是不是也存在態射這樣的東西呢?答案是肯定的,舉個例子,用 LINQ 把 List<int>
轉換成 List<double>
:
Func<List<int>, List<double>> ToDoubleList = x => x.Select(ToDouble).ToList();
不難發現,這裡的 ToDoubleList
是 List<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”(挖坑預備)。