天天看點

#函數式程式設計 Functional Programming in C# [31]

用函數建構應用程式

本章涵蓋
  • 部分應用和凝結
  • 繞過方法類型推理的限制
  • 考慮每個函數級别的依賴關系
  • 子產品化的編寫應用程式
  • 将清單減少為單個值

  建構一個複雜的、真實的應用程式并非易事。有很多關于這個主題的書,是以本章的目的絕不是提供一個全面的觀點。我們将重點介紹可用于子產品化群組合完全由函數組成的應用程式的技術,以及結果與 OOP 中通常如何完成的比較。

  我們将逐漸達到這個目的。 首先,你需要了解一個經典但相當低級的函數技術,即部分應用。 這允許你編寫高度通用的函數,其行為是參數化的,然後提供這些參數,獲得更多的專業函數,這些函數到目前為止已經把所給的參數 "烘培"了。

  然後我們将看看如何在實踐中使用部分應用程式來首先指定在啟動時可用的配置參數,然後在接收到時指定純運作時參數。

  最後,我們将看看你如何進一步采取這種方法,使用部分應用程式進行依賴注入,以至于用函數組成整個應用程式,而不失去任何你期望在用對象組成時的顆粒度或解耦性。

7.1 部分應用:零散地提供參數

  想象一下,你正在重新裝修你的房子。您的室内設計師 Ada 打電話給她信任的塗料供應商 Fred:

  顯然,商店需要知道顧客想買什麼以及買多少才能完成訂單,而在這個案例中,資訊是在不同的時間點上提供的。 為什麼? 好吧,選擇顔色和品牌是 Ada 的責任(她不相信 Fred 能記住她想要的确切顔色和品牌)。另一方面,Fred 的任務是測量表面,計算出所需的油漆量,并從供應商那裡拿貨。

  我剛才所描述的是一個現實生活中的部分應用的類比。 在程式設計中,這意味着零散地給一個函數提供其輸入參數。就像我在現實生活中的例子一樣,這與關注點的分離有關:最好是在應用生命周期的不同階段,從不同的元件中提供一個函數所需的各種參數。

  讓我們在代碼中看到這一點。這裡的想法是,你有一個函數,需要幾條資訊來完成它的工作(類似于Fred,油漆供應商)。 例如,在下面的清單中,我們有一個函數greet,它接收一個一般的問候語和一個名字,并産生一個針對給定名字的個性化問候語。

清單 7.1 映射到清單上的二進制函數
using Name = System.String;
using Greeting = System.String;
using PersonalizedGreeting = System.String;
Func < Greeting, Name, PersonalizedGreeting > greet 
	= (gr, name) => $ "{gr}, {name}";
Name[] names = { "Tristan", "Ivan" };
names.Map(g => greet("Hello", g)).ForEach(WriteLine); 
// prints: Hello, Tristan
//         Hello, Ivan
           
在 REPL 中嘗試 如果您以前從未使用過部分應用程式,那麼将本節中的示例輸入到 REPL 中以親身體驗它的工作原理非常重要。

  清單7.1頂部的using語句隻是允許我們對字元串類型的特定使用附加一些語義,進而使函數簽名更有意義。 你可以再接再厲,定義特定的類型(正如第3章中所讨論的那樣),進而確定,比如說,一個PersonalizedGreeting不能被意外地作為greet函數的輸入。但在目前的讨論中,我并不太擔心強制執行業務規則–隻是擔心有意義的、明确的簽名,因為我們會經常看簽名的。 是以這就是greet的簽名。

(Greeting, Name) -> PersonalizedGreeting

  然後我們有一個名字的清單,将greet映射到這個清單上,以獲得清單中每個名字的問候語。注意,greet函數總是以 "Hello "作為它的第一個參數被調用,而第二個參數則随清單中的每個名字而變化。

  這感覺有點奇怪。我們有一個問候語和 n 個名字,但是我們要重複那個問候語 n 次。不知何故,我們似乎在重複自己。在 Map 的範圍之外将問候語“修複”為“你好”不是更好嗎?我們如何表達這樣一個事實,即決定将“Hello”作為我們将用于清單中所有名稱的一般問候語是一個更一般的決定,可以首先做出,并且傳遞給 Map 的函數應該隻使用名稱?

  在清單7.1中,我們不能這樣做,因為greet期望有兩個參數,而我們正在使用正常的函數應用。 也就是說,我們用它所期望的兩個參數調用greet。 (它被稱為 “應用”,因為我們将函數greet應用到它的參數上)

  我們可以通過部分應用來解決這個問題。這個想法是讓一些代碼來決定一般的問候語,把問候語作為它的第一個參數(就像 Ada 決定顔色一樣)。這将生成一個新函數,其中“Hello”已經作為要使用的問候語。然後,其他一些代碼可以使用要問候的人的姓名調用此函數。

  有幾種方法可以做到這一點。你将首先看到如何以支援部分應用的方式編寫一個特殊的函數,然後如何定義一個普通的Apply函數,使任何給定的函數都能部分應用。

7.1.1 手動啟用部分應用

  獨立提供參數的一種方法是重寫函數,如下所示:

Func<Greeting, Func<Name, PersonalizedGreeting>> greetWith
	= gr => name => $"{gr}, {name}";
           

  這個新的函數,greetWith,接受一個參數,一般的問候語,并傳回一個新的NameGreeting類型的函數。 注意,當函數被調用時,它的第一個參數gr被捕獲在一個閉包中,是以被 "記住 "了,直到傳回的函數被調用第二個參數name。 你會像這樣使用它:

var greetFormally = greetWith("Good evening");
names.Map(greetFormally).ForEach(WriteLine); 
// prints: Good evening, Tristan
//         Good evening, Ivan
           

  我們已經實作了修複 Map 範圍之外的問候語的目标

  注意,greet和greetWith依賴于相同的實作,但它們的符号性質不同。讓我們來比較一下它們:

greet : (Greeting, Name) -> PersonalizedGreeting

greetWith : Greeting -> (Name -> PersonalizedGreeting)

  greetWith是一個接收一個Greeting并傳回一個函數的函數,這一點從前面的簽名中應該很清楚。 事實上,箭頭符号是右聯的,是以第二種情況下的括号是多餘的,greetWith的類型通常會被寫成如下:

greetWith : Greeting -> Name -> PersonalizedGreeting

  greetWith被說成是curried形式的;也就是說,所有的參數都是在函數調用時逐一提供的(注意在簽名中沒有逗号分隔的參數清單)。

  同樣,greet和greetWith依靠的是相同的實作。 變化的是簽名以及參數被獨立提供并被捕獲在閉包中的事實。 這是一個很好的名額,我們應該能夠機械地進行部分應用,而不需要重寫函數,是以接下來讓我們看看如何做到這一點。

7.1.2 歸納部分應用

  在下面的例子中,你可以看到一個一般的Apply函數的實作,它提供了一個給定的值作為二進制和三元函數的第一個參數,分别:

public static Func < T2, R > Apply < T1, T2, R > 
	(this Func < T1, T2, R > f, T1 t1) 
	=> t2 => f(t1, t2);
public static Func < T2, T3, R > Apply < T1, T2, T3, R > 
	(this Func < T1, T2, T3, R > f, T1 t1) 
	=> (t2, t3) => func(t1, t2, t3);
           

  在第一個重載中,Apply接收一個二進制函數,将其部分應用于給定參數,并傳回一個接受第二個參數的單元函數。正如你所看到的,這很簡單:提供的輸入參數,t1,被捕獲在一個閉包中,産生一個新的函數,隻要提供第二個參數,就會調用原始函數f。

  請注意表達式方法和lambda符号是如何給我們提供良好的文法支援來定義這種函數轉換的。 第二個重載對三元函數做了同樣的處理,類似的重載也可以為更大數的函數定義。

  你現在已經知道你不需要手動建立一個像greetWith這樣的函數,而是可以使用Apply給原始的greet函數提供它的第一個參數:

var greetInformally = greet.Apply("Hey");
names.Map(greetInformally).ForEach(WriteLine);
// prints: Hey, Tristan
//         Hey, Ivan
           

  那麼這裡的模式是什麼? 我們基本上是從一個一般的函數(如greet)開始,然後使用部分應用來建立這個函數的一個專門版本(如greetInformally)。 現在這是一個可以被傳遞的單選函數,使用它的代碼甚至不需要意識到這個新函數被部分應用了。

  圖7.1以圖示方式總結了我們到目前為止所涉及的步驟。

  總之,部分應用總是從一般到具體。 它允許你定義非常一般的函數,然後通過給它們參數來微調它們的行為。 最終,編寫這樣的通用函數提高了抽象水準,并有可能允許更大的代碼重用。

#函數式程式設計 Functional Programming in C# [31]

7.1.3 參數的順序很重要

  greet函數顯示了一般情況下參數的良好順序:更一般的參數,即可能在應用程式的早期應用,應該放在前面,然後是更具體的參數。我們在生活中很早就學會了說 “你好”,但是我們一直在認識和問候新的人,直到我們老了。

  根據經驗,如果您将函數視為操作,則其參數通常包括以下内容:

  • 操作将影響的對象。這些很可能會遲到,應該留到最後。
  • 一些決定功能如何運作的選項,或功能完成工作所需的依賴性,這些都可能在早期确定,應該放在第一位。

  當然,要确定參數的最佳順序并不總是容易的。你很快就會看到,即使參數的順序對你的預期用途是錯誤的,你也可以使用部分應用程式。

  總之,隻要你有一個多參數的函數,并且希望将提供不同參數的責任分開,你就有理由使用部分應用。

  然而,在進行部分應用的更實際的使用之前,我們應該解決一個問題。這個問題與類型推理有關,我們接下來會解決這個問題。