[搬運] 寫給 C# 開發人員的函數式編程

原文地址:http://www.dotnetcurry.com/csharp/1384/functional-programming-fsharp-for-csharp-developers

摘要:作爲一名 C# 開發人員,您可能已經在編寫一些函數式代碼而沒有意識到這一點。本文將介紹一些您已經在C#中使用的函數方法,以及 C# 7 中對函數式編程的一些改進。
儘管 .NET 框架的函數式編程語言是F#,同時,C# 是一個面向對象的語言,但它也有很多可以用於函數式編程技術的特性。你可能已經寫了一些功能的代碼而沒有意識到它!

函數式編程範例

函數式編程是相對於目前比較流行和通用的面向對象編程的另一種編程模式。
有幾個與其他編程範例不同的關鍵概念。我們首先爲最常見的定義提供闡述,以便我們在整個文章中看清這些定義。
函數式編程的基本組成是純函數。它們由以下兩個屬性定義:

  • 他們的結果完全取決於傳遞給它的參數。沒有內部或外部的狀態影響它。
  • 他們不會造成任何副作用。被調用的次數不會改變程序行爲。

由於這些屬性,函數調用可以被安全地替換其結果,例如函數每次執行的結果都緩存到一個鍵值對(被稱爲memoization的技術)。
純函數很適合形成 組合函數,將兩個或多個函數組合成一個新函數的過程,該函數返回相同的結果,就好像其所有的構成函數都按順序調用一樣。如果ComposedFn是Fn1和Fn2的函數組合,那麼下面的斷言將永遠正確:

Assert.That(ComposedFn(x), Is.EqualTo(Fn2(Fn1(x))));

作爲其他函數的參數可以進一步提高其可重用性。這樣的高階函數可以作爲通用的 輔助者 (helper) ,它應用多次作爲參數傳遞的另一個函數,例如一個數組的所有項目:

Array.Exists(persons, IsMinor);

在上面的代碼中,IsMinor 是一個在別處定義的函數。使之有效,語言必須支持其爲第一類對象,即允許函數像類型一樣用作參數的語言結構。

數據總是用不可變的對象來表示的,也就是在初始創建後不能改變狀態的對象。每當一個值發生變化,就必須創建一個新的對象,而不是修改現有的對象。因爲所有對象都保證不會改變,所以它們本質上是線程安全的,也就是說,它們可以安全地用於多線程程序中,而不會受到競爭條件的威脅。
由於函數是純粹的,對象是不可變的直接結果,在函數編程中沒有共享狀態
函數只能根據參數進行操作,而參數不能改變,從而影響其他接收相同參數的函數。他們可以影響程序的其餘部分的唯一方法是將返回的結果作爲參數傳遞給其他函數。
這樣可以防止函數之間的任何隱藏的交叉交互,使得它們可以安全地以任何順序甚至並行運行,除非一個函數直接依賴於另一個函數的結果。
有了這些基本的模塊,函數式編程最終會被比命令式更具聲明,即用 描述 代替 如何計算
以下兩個將字符串數組轉換爲小寫的函數清楚地表明瞭兩種方法之間的區別:

string[] Imperative(string[] words)
{
    var lowerCaseWords = new string[words.Length];
    for (int i = 0; i < words.Length; i++)
    {
        lowerCaseWords[i] = words[i].ToLower();
    }
    return lowerCaseWords;
}
 
string[] Declarative(string[] words)
{
    return words.Select(word => word.ToLower()).ToArray();
}

雖然你會聽到很多其他的函數式編程概念,比如 monads, functors, currying, referential transparency等,但是這些模塊應該足以讓你瞭解什麼是函數式編程,以及它與面向對象編程有什麼不同。

在 C# 中編寫函數式代碼

由於語言主要是面向對象的,所以默認並不總是引導你使用這樣的代碼,但是有了意圖和足夠的自律,你的代碼可以變得更加實用。

不可變類型

你很可能習慣於在C#中編寫可變類型,但只需很少的改變,就可以使它們不可變:

public class Person
{
    public string FirstName { get; private set; }
    public string LastName { get; private set; }
 
    public Person(string firstName, string lastName)
    {
        FirstName = firstName;
        LastName = lastName;
    }
}

私有屬性構造器使對象初始創建後不可能爲它們分配不同的值。爲了使對象真正不可變,所有的屬性也必須是不可變的類型。否則,它們的值將通過改變屬性來改變,而不是爲它們分配一個新的值。
上面的 Person 類型是不可變的,因爲 string 也是一個不可變的類型,也就是說它的值不能像其所有的實例方法一樣被改變,所以返回一個新的字符串實例。但是這是規則的一個例外,大多數 .NET 框架中類型都是可變的。
如果你希望你的類型是不可變的,你不應該使用除了原始類型以外的其他內建類型,而應該使用字符串作爲公共屬性。
要更改對象的屬性,例如更改人物的名字,需要創建一個新的對象:

public static Person Rename(Person person, string firstName)
{
    return new Person(firstName, person.LastName);
}

當一個類型有很多屬性時,編寫這樣的函數可能會變得非常繁瑣。因此,對於不可變類型來說,爲這樣的場景實現 With helper 函數是一個好習慣:

public Person With(string firstName = null, string lastName = null)
{
    return new Person(firstName ?? this.FirstName, lastName ?? this.LastName);
}

這個函數創建了修改了任意數量屬性的對象的副本。我們的 Rename 函數現在可以簡單地調用這個幫助器來創建修改後的 Person :

public static Person Rename(Person person, string firstName)
{
    return person.With(firstName: firstName);
}

只有兩個屬性的好處可能不是很明顯,但不管這個類型有多少個屬性,這個語法允許我們只列出我們想要修改的屬性作爲命名參數。

純函數

使函數變 "純" 需要更多的訓練,而不是使對象不可變。
沒有語言功能可以幫助程序員確保一個特定的功能是純粹的。不要使用任何內部或外部的狀態,不要引起副作用,不要調用任何不純的函數。
當然,也沒有什麼能阻止你使用函數參數和調用其他純函數,從而使函數變得純粹。上面的 Rename 函數是一個純函數的例子:它不調用任何非純函數,也不使用傳遞給它的參數以外的任何其他數據。

組合函數

通過定義一個新的函數,可以將多個函數合併成一個函數,該函數調用其所有組合函數(讓我們忽略不需要連續多次調用Rename的事實):

public static Person MultiRename(Person person)
{
    return Rename(Rename(person, "Jane"), "Jack");
}

重命名方法的簽名迫使我們嵌套調用,隨着函數調用次數的增加,這些調用會變得難以理解和理解。如果我們使用With方法,我們的意圖變得更清晰:

public static Person MultiRename(Person person)
{
    return person.With(firstName: "Jane").With(firstName: "Jack");
}

爲了使代碼更具可讀性,我們可以將調用鏈分成多行,保持可管理性,無論我們將多少個函數組合成一個:

public static Person MultiRename(Person person)
{
    return person
        .With(firstName: "Jane")
        .With(firstName: "Jack");
}

沒有好的方法來分割與重命名類似的嵌套調用函數。當然,With 方法允許鏈接語法,因爲它是一個實例方法。但是,在函數式編程規範中,函數應該和它們所作用的數據分開聲明,比如 Rename 函數。
雖然在 函數式語言 F# 中有一個流水線操作符(|>)來允許組合這些函數,但我們可以利用 C# 中的擴展方法:

public static class PersonExtensions
{
    public static Person Rename(this Person person, string firstName)
    {
        return person.With(firstName: firstName);
    }
}

這允許我們組合非實例方法調用,就像實例方法調用一樣:

public static Person MultiRename(Person person)
{
    return person.Rename("Jane").Rename("Jack");
}

.NET Framework中的函數式 API 示例

爲了體驗C#中的函數式編程,你不需要自己編寫所有的對象和函數。
在 .NET 框架中有一些可用的函數式 API 供您使用。

不變集合

我們已經提到,在.NET框架中,字符串和原始類型是不可變的類型。
但是,也有一些可選的 不可變集合類型 。從技術上講,它們並不是.NET框架的一部分,因爲它們是作爲獨立的 NuGet 包 System.Collections.Immutable 分發。
另一方面,它們是新的開源跨平臺 .NET 運行時 .NET Core 的一個組成部分。
命名空間包括所有常用的集合類型:數組,列表,集合,字典,隊列和堆棧。
顧名思義,它們都是不可改變的,即它們在創建之後不能被改變。相反,每個更改都會創建一個新實例。這使得不可變集合以與.NET框架基類庫中包含的併發集合不同的方式完全線程安全。
使用併發集合,多個線程不能同時修改數據,但仍可以訪問修改。對於不可變的集合,任何更改只對創建它們的線程可見,因爲原始集合保持不變。
儘管爲每個可變操作創建了一個新的實例,爲了保持集合的高性能,它們的實現利用了結構共享
這意味着在集合的新修改實例中,來自先前實例的未修改的部分儘可能被重用,因此需要較少的內存分配並且導致垃圾收集器的工作較少。
在函數式編程中這種常見的技術是可以實現的,即對象不能改變,因此可以安全地重用。

使用不可變集合和常規集合最大的區別在於它們的創建。

由於每次更改都創建一個新實例,因此您希望創建集合中已包含所有初始項目的集合。因此,不可變集合不具有公共構造函數,但提供了三種創建方法:

  • 工廠方法創建接受 0個 或 更多的項目來初始化集合:var list = ImmutableList.Create(1, 2, 3, 4);
  • Builder 是一個高效的可變集合,可以很容易地轉換爲不可變的集合:var builder = ImmutableList.CreateBuilder<int>(); builder.Add(1); builder.AddRange(new[] { 2, 3, 4 }); var list = builder.ToImmutable();</int>
  • 可以使用擴展方法從IEnumerable創建不可變集合:var list = new[] { 1, 2, 3, 4 }.ToImmutableList();

不可變集合的可變操作與常規集合中的可變操作類似,但它們都返回集合的新實例,表示將操作應用於原始實例的結果。
如果您不想丟失更改,則必須在此之後使用此新實例:

var modifiedList = list.Add(5);

執行上述語句後,列表的值仍然是 {1,2,3,4} 。得到的 modifiedList 將具有 {1,2,3,4,5} 的值。
無論對於一個非功能性程序員來說,不可變的集合看起來是多麼的不尋常,它們是編寫.NET框架功能代碼的一個非常重要的基石。創建你自己的不可變集合類型將是一個重大的努力。

LINQ - 語言集成查詢

.NET框架中一個更好的函數式的API是LINQ。
雖然它從來沒有被宣傳爲函數式,但它體現了許多以前引入的函數式性質。
如果我們在 LINQ 擴展方法仔細一看,很明顯幾乎所有的都表明其函數式:他們允許我們聲明我們想要獲得什麼,而不是如何做。

var result = persons
    .Where(p => p.FirstName == "John")
    .Select(p => p.LastName)
    .OrderBy(s => s.ToLower())
    .ToList();

以上查詢返回名爲 John 的姓氏的有序列表。我們只提供了預期的結果,而不是提供詳細的操作順序。可用的擴展方法也很容易使用鏈式語法進行組合。
儘管LINQ函數並不是作用於不可變的類型,但它們仍然是純函數,除非通過傳遞變異函數作爲參數來濫用。
它們被實現爲對只讀接口 IEnumerable 集合進行操作,而不修改集合中的項目。
他們的結果只取決於輸入參數,只要作爲參數傳遞的函數也是純的,它們不會產生任何全局副作用。在我們剛剛看到的例子中,人員集合以及其中的任何項目都不會被修改。
許多 LINQ 函數是 高階函數:它們接受其他函數作爲參數。在上面的示例代碼中,lambda表達式作爲函數參數傳入,但是它們可以很容易地在其他地方定義並傳入,而不是以內聯的方式創建:

public bool FirstNameIsJohn(Person p)
{
    return p.FirstName == "John";
}
 
public string PersonLastName(Person p)
{
    return p.LastName;
}
 
public string StringToLower(string s)
{
    return s.ToLower();
}
 
var result = persons
    .Where(FirstNameIsJohn)
    .Select(PersonLastName)
    .OrderBy(StringToLower)
    .ToList();

當函數參數和我們的情況一樣簡單時,代碼通常會更容易理解內聯 lambda 表達式而不是單獨的函數。然而,隨着實現的邏輯變得更加複雜和可重用,把它們定義爲獨立的函數,開始變得更有意義。

結論:

函數式編程範式當然有一些優點,這也促成了它近來日益普及。
在沒有共享狀態的情況下,並行和多線程變得更容易,因爲我們不必處理同步問題和競爭條件。純函數和不變性可以使代碼更容易理解。
由於函數只依賴於它們明確列出的參數,因此我們可以更容易地識別一個函數是否需要另一個函數的結果,以及何時這兩個函數是獨立的,因此可以並行運行。單個純函數也更容易進行單元測試,因爲所有的測試用例都可以通過傳遞不同的輸入參數和驗證返回值來覆蓋。沒有其他的外部依賴模擬和檢查。

如果所有這些都讓你想爲自己嘗試函數式編程,那麼首先在 C# 中執行它可能比在同一時間學習一種新語言更容易。您可以通過更多地利用現有的函數式 API 來緩慢起步,並以更具說明性的方式繼續編寫代碼。
如果你看到了足夠的好處,那麼你可以學習 F#,稍後再熟悉這些概念。

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