函數(shù)式TypeScript
談到函數(shù)式編程時,我們常提到機制、方法,而不是核心原則。函數(shù)式編程不是關(guān)于 Monad、Monoid 和 Zipper 這些概念的,雖然它們確實很有用。從根本上來說,函數(shù)式編程就是關(guān)于如使用通用的可復(fù)用函數(shù)進(jìn)行組合編程。本文是我在重構(gòu) TypeScript 代碼時使用函數(shù)式的一些思考的結(jié)果。
首先,我們需要用到以下幾項技術(shù):
- 盡可能使用函數(shù)代替簡單值
- 數(shù)據(jù)轉(zhuǎn)換過程管道化
- 提取通用函數(shù)
來,開始吧!
假設(shè)我們有兩個類,Employee 和 Department。Employee 有 name 和 salary 屬性,Department 只是 Employee 的簡單集合。
- class Employee {
- constructor(public name: string, public salary: number) {}
- }
- class Department {
- constructor(public employees: Employee[]) {}
- works(employee: Employee): boolean {
- return this.employees.indexOf(employee) > -1;
- }
- }
我們要重構(gòu)的是 averageSalary 函數(shù)。
- function averageSalary(employees: Employee[], minSalary: number, department?: Department): number {
- let total = 0;
- let count = 0;
- employees.forEach((e) => {
- if(minSalary <= e.salary && (department === undefined || department.works(e))){
- total += e.salary;
- count += 1;
- }
- });
- return total === 0 ? 0 : total / count;
averageSalary 函數(shù)接收 employee 數(shù)組、***薪資 minSalary 以及可選的 department 作為參數(shù)。如果傳了 department 參數(shù),函數(shù)會計算該部門中所有員工的平均薪資;若不傳,則對全部員工進(jìn)行計算。
該函數(shù)的使用方式如下:
- describe("average salary", () => {
- const empls = [
- new Employee("Jim", 100),
- new Employee("John", 200),
- new Employee("Liz", 120),
- new Employee("Penny", 30)
- ];
- const sales = new Department([empls[0], empls[1]]);
- it("calculates the average salary", () => {
- expect(averageSalary(empls, 50, sales)).toEqual(150);
- expect(averageSalary(empls, 50)).toEqual(140);
- });
需求雖簡單粗暴,可就算不提代碼難以拓展,其混亂是顯而易見的。若新增條件,函數(shù)簽名及接口就不得不發(fā)生變動,if 語句也會也越來越臃腫可怕。
我們一起用一些函數(shù)式編程的辦法重構(gòu)這個函數(shù)吧。
使用函數(shù)代替簡單值
使用函數(shù)代替簡單值看起來似乎不太直觀,但這卻是整理歸納代碼的強大辦法。在我們的例子中,這樣做,意味著要將 minSalary 和 department 參數(shù)替換成兩個條件檢驗的函數(shù)。
- type Predicate = (e: Employee) => boolean;
- function averageSalary(employees: Employee[], salaryCondition: Predicate,
- departmentCondition?: Predicate): number {
- let total = 0;
- let count = 0;
- employees.forEach((e) => {
- if(salaryCondition(e) && (departmentCondition === undefined || departmentCondition(e))){
- total += e.salary;
- count += 1;
- }
- });
- return total === 0 ? 0 : total / count;
- }
- // ...
- expect(averageSalary(empls, (e) => e.salary > 50, (e) => sales.works(e))).toEqual(150);
我們所做的就是將 salary、department 兩個條件接口統(tǒng)一起來。而此前這兩個條件是寫死的,現(xiàn)在它們被明確定義了,并且遵循一致的接口。這次整合允許我們將所有條件作為數(shù)組傳遞。
- function averageSalary(employees: Employee[], conditions: Predicate[]): number {
- let total = 0;
- let count = 0;
- employees.forEach((e) => {
- if(conditions.every(c => c(e))){
- total += e.salary;
- count += 1;
- }
- });
- return (count === 0) ? 0 : total / count;
- }
- //...
- expect(averageSalary(empls, [(e) => e.salary > 50, (e) => sales.works(e)])).toEqual(150);
條件數(shù)組只不過是組合的條件,可以用一個簡單的組合器將它們放到一起,這樣看起來更加明晰。
- function and(predicates: Predicate[]): Predicate{
- return (e) => predicates.every(p => p(e));
- }
- function averageSalary(employees: Employee[], conditions: Predicate[]): number {
- let total = 0;
- let count = 0;
- employees.forEach((e) => {
- if(and(conditions)(e)){
- total += e.salary;
- count += 1;
- }
- });
- return (count == 0) ? 0 : total / count;
- }
值得注意的是,“and” 組合器是通用的,可以復(fù)用并且還可能拓展為庫。
提起結(jié)果
現(xiàn)在,averageSalary 函數(shù)已健壯得多了。我們可以加入新條件,無需破壞函數(shù)接口或改變函數(shù)實現(xiàn)。
數(shù)據(jù)轉(zhuǎn)換過程管道化
函數(shù)式編程的另外一個很有用的實踐是將所有數(shù)據(jù)轉(zhuǎn)換過程變成管道。在本例中,就是將 filter 過程提取到循環(huán)外面。
- function averageSalary(employees: Employee[], conditions: Predicate[]): number {
- const filtered = employees.filter(and(conditions));
- let total = 0
- let count = 0
- filtered.forEach((e) => {
- total += e.salary;
- count += 1;
- });
- return (count == 0) ? 0 : total / count;
- }
這樣一來計數(shù)的 count 就沒什么用了。
- function averageSalary(employees: Employee[], conditions: Predicate[]): number{
- const filtered = employees.filter(and(conditions));
- let total = 0
- filtered.forEach((e) => {
- total += e.salary;
- });
- return (filtered.length == 0) ? 0 : total / filtered.length;
- }
接下來,如在疊加之前將 salary 摘取出來,求和過程就變成簡單的 reduce 了。
- function averageSalary(employees: Employee[], conditions: Predicate[]): number {
- const filtered = employees.filter(and(conditions));
- const salaries = filtered.map(e => e.salary);
- const total = salaries.reduce((a,b) => a + b, 0);
- return (salaries.length == 0) ? 0 : total / salaries.length;
- }
提取通用函數(shù)
接著我們發(fā)現(xiàn),***兩行代碼和當(dāng)前域完全沒什么關(guān)系。其中不包含任何與員工、部門相關(guān)的信息。僅僅只是一個計算平均數(shù)的函數(shù)。所以也將其提取出來。
- function average(nums: number[]): number {
- const total = nums.reduce((a,b) => a + b, 0);
- return (nums.length == 0) ? 0 : total / nums.length;
- }
- function averageSalary(employees: Employee[], conditions: Predicate[]): number {
- const filtered = employees.filter(and(conditions));
- const salaries = filtered.map(e => e.salary);
- return average(salaries);
- }
又一次,提取出的函數(shù)是完全通用的。
***,將所有 salary 部分提出來之后,我們得到***方案。
- function employeeSalaries(employees: Employee[], conditions: Predicate[]): number[] {
- const filtered = employees.filter(and(conditions));
- return filtered.map(e => e.salary);
- }
- function averageSalary(employees: Employee[], conditions: Predicate[]): number {
- return average(employeeSalaries(employees, conditions));
- }
對比原始方案和***方案,我敢說,毫無疑問,后者更棒。首先,它更通用(我們可以不破壞函數(shù)接口的情況下添加新類型的判斷條件)。其次,我們從可變狀態(tài)(mutable state)和 if 語句中解脫出來,這使代碼更容易閱讀、理解。
何時收手
函數(shù)式風(fēng)格的編程中,我們會編寫許多小型函數(shù),它們接收一個集合,返回新的集合。這些函數(shù)能夠以不同方式組合、復(fù)用 —— 棒極了。不過,這種風(fēng)格的一個缺點是代碼可能會變得過度抽象,導(dǎo)致難以讀懂,那些函數(shù)組合在一起到底要干嘛?
我喜歡使用樂高來類比:樂高積木能夠以不同形式放在一起 —— 它們是可組合的。但注意,并不是所有積木都是一小塊。所以,在使用本文所述技巧進(jìn)行代碼重構(gòu)時,千萬別妄圖將一切都變成接收數(shù)組、返回數(shù)組的函數(shù)。誠然,這樣一些函數(shù)組合使用極度容易,可它們也會顯著降低我們對程序的理解能力。
小結(jié)
本文展示了如何使用函數(shù)式思維重構(gòu) TypeScript 代碼。我所遵循的是以下幾點規(guī)則:
- 盡可能使用函數(shù)代替簡單值
- 數(shù)據(jù)轉(zhuǎn)換過程管道化
- 提取通用函數(shù)
了解更多
強烈推薦以下兩本書: