[搬運]寫給C#開發人員的函數語言程式設計

張蘅水發表於2018-01-17

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


本文采用 知識共享署名-非商業性使用-相同方式共享 3.0 中國大陸許可協議

轉載請註明來源:張蘅水

我在開發者頭條中還會每日分享不錯的技術文章,搜尋 356194 即可檢視


相關文章