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

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

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

函數式編程範例

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

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

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

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#,稍後再熟悉這些概念。

相關文章
相關標籤/搜索