以重構角度探討 LINQ

LINQ 是 C# 3 實現 FP 重要里程碑,提供大量的 Operator,讓我們以 Pure Function 將 data 以 Dataflow 與 Pipeline 方式實現。本系列將先以 Imperative 實作,然後再重構成 FP,最後再重構成 LINQ Operator,並參考 LINQ source code 的實現方式。

首先從最基本的 ForEach Operator 談起。

Version


macOS High Sierra 10.13.6
.NET Core 2.1
C# 7.2
Rider 2018.1.4

User Story


在 List 中有一堆名字,想要在 console 顯示每個名字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using System.Collections.Generic;

namespace ConsoleApp
{
class Program
{
static void Main()
{

var names = new List<string>
{
"Ben",
"Jafar",
"Matt",
"Priya",
"Brian"
};
}
}
}

Imperative : for


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
using System;
using System.Collections.Generic;

namespace ConsoleApp
{
class Program
{
static void Main(string[] args)
{

var names = new List<string>
{
"Ben",
"Jafar",
"Matt",
"Priya",
"Brian"
};

for (var counter = 0; counter < names.Count; counter++)
{
Console.WriteLine(names[counter]);
}
}
}
}

19 行

1
2
3
4
for (var counter = 0; counter < names.Count; counter++)
{
Console.WriteLine(names[counter]);
}

最直覺的方式 (其實應該說被制約的方式), 就是透過 List 的 Indexer 將 List 的 item 取出來,並透過 for loop 去執行 Console.WriteLine()

Imperative : foreach


foreach000

此時 Rider 已經提出警告,建議改用 foreach

foreach001

  1. 按熱鍵 ⌥ + ↩,選擇 Convert to foreach
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
using System;
using System.Collections.Generic;

namespace ConsoleApp
{
class Program
{
static void Main(string[] args)
{

var names = new List<string>
{
"Ben",
"Jafar",
"Matt",
"Priya",
"Brian"
};

foreach (var name in names)
{
Console.WriteLine(name);
}
}
}
}

19 行

1
2
3
4
foreach (var name in names)
{
Console.WriteLine(name);
}

重構成 foreach 後,就不必使用 counter 變數,也不用考慮 counter++ 寫錯,for loop 的 counter 控制,是常見的 bug 來源,所以重構成 foreach 絕對比 for 來得好。

foreach 的語意也比 for 清楚 ,因為不用看到 counter 實作細節。

FP : Higher Order Function


實務上這種 foreach 天天都要用到,但用 foreach 這種 statement 寫法,重複使用能力為 0,就每天都要不斷的寫 foreach

若我們能將 foreach 抽成 ForEach() Higher Order Function,我們就能不斷 reuse ForEach(),只要將不同的商業邏輯以 function 傳進 ForEach() 即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
using System;
using System.Collections.Generic;

namespace ConsoleApp
{
static class Program
{
static void Main(string[] args)
{

var names = new List<string>
{
"Ben",
"Jafar",
"Matt",
"Priya",
"Brian"
};

names.MyForEach(name => Console.WriteLine(name));
}

private static void MyForEach<T>(this List<T> list, Action<T> action)
{
foreach (var iter in list)
{
action(iter);
}
}
}
}

22 行

1
2
3
4
5
6
7
private static void MyForEach<T>(this List<T> list, Action<T> action)
{
foreach (var iter in list)
{
action(iter);
}
}

自己以 MyForEach() 實作出 foreach 的 Higher Order Function 版本。

第 9 行

1
names.MyForEach(name => Console.WriteLine(name));

原來的 foreach statement 重構成 MyForEach() Higher Order Function 版本,只要將 Console.WriteLine() 改用 Lambda 傳入 MyForEach() 即可,如此 foreach 就能被 reuse 了。

不過 C# 在此可能感受不到 FP 所謂的 Dataflow 與 Pipeline,就語意而言,只能說我們為 List 寫了一個新 method : MyForEach() ,也就是 C# 所謂的 Extension Method,這符合傳統 C# 的 OOP 思維。

他山之石可以攻錯,我們來看看 F# 語法。

1
2
3
4
5
6
7
let names = ["Ben"; "Jafar"; "Matt"; "Priya"; "Brian"]

let MyForEach action list =
for iter in list do
action iter
names
|> MyForEach (printfn "%A ")

names 為 List,myForEach() 為自己實作的 ForEach() Higher Order function,若不懂 F# 語法先略過細節沒關係。

|> 為 pipeline,所以可以明顯看出其語意為 names data 以 Dataflow 與 Pipeline 方式傳給 myForEach() 執行 printfn()

但 C# 並沒有特別提供 |> 這種 pipeline 符號,而是繼續使用 OOP 的 .

. 雖然方便,但心裡要知道,這裡的 . 並不是 OOP 的 method,而是 FP 的 Dataflow 與 Pipeline,只是也使用了 . 為符號,其本質是 F# 的 |>. 算是 |> 的 syntax sugar。

1
2
3
4
5
6
7
private static void MyForEach<T>(this List<T> list, Action<T> action)
{
foreach (var iter in list)
{
action(iter);
}
}

再舉例另外一個例子證明 . 並非 OOP 的 method,而是 FP 的 Pipeline。

我們實現 MyForEach() 時,並不是寫在 List class 內,而是透過 static method + this 參數成為 Extenstion Method,也就是List data 與 MyForEach() logic 是徹底分離,而非如 OOP 將 data 與 logic 寫在 List class 內,也再次證明 Extension Method 是 C# 實踐 Pipeline 的手法,而 . 只是 syntax sugar,其本質是 FP 的 Pipeline。

IEnumrable


foreach002

當初我們只是想為了 List 重構,所以 Extension Method 的型別只用了 List,Rider 提出建議:List<T> 其實可以重構成 IEnumerable<T>,如此 MyForEach() 重複使用能力更高。

foreach003

  1. 將 cursor 放在 List 上,按熱鍵 ⌥ + ↩,選擇 Make parameter type IEnumerable<T>

foreach005

  1. List<T> 重構成 IEnumerable<T>

Method Group


foreach008

Rider 建議將 Lambda 重構成 Method Group。

MyForEach() 後接 Lambda 天經地義,但若 function 的型別定義的很清楚,讓 compiler 可以找到正確 signature 的 Overloading method,則不用寫 Lambda,傳入 method 名稱即可,這就是 Method Group,會讓 FP 寫法更加精簡。

MyForEach() 很明確定義了 Action<T>,就只有一個 input 參數的 function,因此 compiler 有足夠的資訊找到 Console.WriteLine() 正確的 Overloading 版本,因此適合重構成 Method Group。

foreach003

  1. 將 cursor 放在 WriteLine 上,按熱鍵 ⌥ + ↩,選擇 Replace with method group

foreach006

  1. 只要傳入 Method 名稱即可,不用寫 Lambda

Using static


其實目前的 Console.WriteLine 已經非常精簡,可讀性也高,但 C# 6 提供了 using static,可以讓你寫出更 FP 風格的 code。

foreach007

  1. 將 cursor 放在 Console 上,按熱鍵 ⌥ + ↩,選擇 Import static members

foreach009

  1. System.Console 使用 using static
  2. MyForEach() 只要傳入 WriteLine 即可,更像是 function

LINQ : ForEach


其實 ForEach() 這種很普遍的東西,在 LINQ 早已內建,我們改用 LINQ 版本。

foreach010

  1. 直接使用 LINQ 的 ForEach(),將自己寫的 MyForEach() 刪除

LINQ 如何實踐 ?


foreach011

我們來看看 LINQ 的 source code 如何實踐 ForEach() ?

一開始對 action 進行判斷,若沒有則拋出 exception。

action 也搭配 for 進行,跟我們使用 foreach 類似。

Q : 為什麼 ForEach() 是在 List class ? 而不是以 Extension Method 實現 ?

這的確是 LINQ 設計上的好問題,LINQ 並沒有選擇將 ForEach() 以 Extension Method 對 IEnumerable 實作,而是直接寫在 List class 內。

這個設計也使得我們要使用 ForEach() 時,一定得 ToList() 成 List 才能使用。

為了使用上方便,我還是會在自己的 library 為 IEnumerable 加上 ForEach() Extension Method,這樣就不需 ToList() 了。

使用時機


ForEach() 表面上看起來是 foreach statement 的 syntax sugar,但事實上 ForEach() 在 FP 的意義並非如此。

FP 將 data 以 Dataflow 與 Pipeline 方式處理,因此提供了眾多 operator,而 operator 則必須搭配 pure function,不能有 Side Effect。但 Side effect 總要有人處理,ForEach() 就是讓你統一處理 Side Effect 之處。

與 Imperative 寫法的差異是 : Imperative 總是不斷的在處理 Side Effect,因此造成結果難以預測、難以測試,bug 就是由此展開;但 FP 對於 data 處理堅持採用 Dataflow 與 Pipeline,不使用 Side Effect,因此對 data 處理是可預測且容易測試,直到最後 data 處理完,才不得已使用 ForEach() 處理 Side Effect。

Console.WriteLine() 就是 I/O,就是 Side Effect,這是無法避免的,最後使用 ForEach() 統一解決。

Conclusion


  • . 與 Extension Method 是 C# 的 syntax sugar,其本質就是 FP 的 data 與 logic 分離與 Pipeline
  • Method Group 讓 FP 寫法會更為精簡,也是 C# 很重要的發明
  • FP 的 operator 主要在處理 Dataflow 與 Pipeline,應該使用 pure function,但 ForEach() 是少數讓你處理 Side Effect 的 operator,應該將 side effect 集中在 ForEach() 處理

Sample Code


完整的範例可以在我的 GitHub 上找到

Reference


ReactiveX, Functional Programming in JavaScript

2018-08-04