程序员经常会碰到他们害怕改变的代码。通过提取纯函数,使用 curry 和 composition,并利用编译器,您可以以更安全的方式重构现有代码。我们将看到一个通过纯函数进行重构的例子,然后我们将看看一些设计模式,它们是如何在函数式编程中实现的,以及如何在重构中使用它们。
本章将涵盖以下主题:
- 如何看待遗留代码
- 如何使用编译器和纯函数来识别和分离依赖关系
- 如何从任意一段代码中提取 lambdas
- 如何使用 currying 和 composition 消除 lambdas 之间的重复,并将它们分组到类中
- 如何使用函数实现一些设计模式(策略、命令和依赖注入)
- 如何使用基于函数的设计模式来重构它们
您将需要一个支持 C++ 17 的编译器。我用的是 GCC 7.4.0c。
代码在的 GitHub 上。com/ PacktPublishing/动手-函数-用- Cpp 编程Chapter12
文件夹中的。它包括并使用doctest
,这是一个单头、开源的单元测试库。你可以在它的 GitHub 资源库上找到它。com/ onqtam/ doctest 。
重构是软件开发中重要且持续的部分。主要原因是需求的不断变化,这是由我们构建的应用周围世界的变化所驱动的。我们的客户不断了解产品工作的生态系统,并需要我们使这些产品适应他们发现的新现实。因此,我们的代码,即使结构完美,也几乎总是落后于我们当前对正在解决的问题的理解。
完美地构建我们的代码也不是一件容易的事。程序员是人,所以我们会犯错误,失去注意力,有时会找不到最好的解决方案。处理这种复杂情况的唯一方法是使用无情的重构;也就是说,在我们让事情运转起来之后,我们改进代码结构,直到代码在我们的约束下尽可能好。
说起来容易,做起来也容易,只要我们很早就重构并编写测试。但是如果我们继承一个没有测试的代码库呢?那我们怎么办?我们将讨论这个问题,以及稍后使用纯函数重构遗留代码的一个有前途的想法。
首先,让我们定义我们的术语。什么是重构?
重构是业内普遍使用的术语之一,但并不为人所知。不幸的是,这个词经常被用来证明大的重新设计是合理的。考虑以下关于给定项目的常见故事:
- 当项目开始时,功能会快速添加。
- 很快(几个月,一年,甚至几周),速度下降,但需求是一样的。
- 多年后,添加新功能变得如此困难,以至于客户很恼火,给团队带来压力。
- 最后,决定重写或改变代码的整个结构,希望它能加快速度。
- 六个月后,重写或重新设计(通常)失败,管理层面临一个不可能的情况——我们应该尝试重新设计,重新启动项目,还是做其他事情?
这个周期的大重新设计阶段经常被错误地称为重构,但这不是重构。
相反,为了理解重构的真正含义,让我们从思考我们可以对代码库做什么改变开始。我们通常可以将这些变化分类如下:
- 实施新要求
- 修复错误
- 以各种方式重新组织代码——重构、重新设计、重新设计和/或重新架构
我们可以将这些变化大致分为以下两大类:
- 影响代码行为的更改
- 不影响代码行为的更改
当我们谈论行为时,我们谈论的是输入和输出,比如“当我在一个用户界面 ( UI )表单中引入这些值并点击这个按钮,然后我看到这个输出,这些东西就被保存了”。我们通常不会在行为中包含跨功能的关注点,例如性能、可伸缩性或安全性。
有了这些清晰的术语,我们就可以定义重构——它只是对代码结构进行不影响程序外部行为的更改。大的重新设计或重写很少符合这个定义,因为通常情况下,进行大的重新设计的团队不会证明结果与原始的有相同的行为(包括已知的 bug,因为有人可能依赖它们)。
任何改变程序行为的改变都不是重构。这包括修复错误或添加功能。然而,我们可以将这些变化分成两个阶段——首先,重构到为变化腾出空间,然后进行行为上的变化。
这一定义提出了如下几个问题:
- 我们如何证明我们没有改变行为?我们知道只有一种方法可以做到这一点:自动回归测试。如果我们有一套我们信任的自动化测试,并且足够快,我们可以很容易地做出改变,而不需要改变任何测试,看看它们是否通过。
- 重构有多小?变化越大,越难证明什么都没有受到影响,因为程序员是人,也会犯错。我们更喜欢在重构中有非常小的步骤。以下是一些保持行为的小代码更改示例:重命名、向函数添加参数、更改函数的参数顺序以及将一组语句提取到函数中,等等。每一个微小的改变都可以很容易地实现,测试运行证明没有行为改变发生。每当我们需要进行更大的重构时,我们只需要进行一系列这些小的改变。
- 当我们没有测试时,我们如何证明我们没有改变代码的行为?这是我们需要讨论遗留代码和遗留代码困境的时候。
编程可能是唯一一个单词 legacy 有负面含义的领域。在任何其他上下文中,遗产意味着某人留下的东西和某人通常引以为豪的东西。在编程中,遗留代码指的是我们继承的独占代码,维护起来很麻烦。
程序员经常认为遗留代码是不可避免的,对此无能为力。然而,我们可以做很多事情。首先是澄清我们所说的遗留代码是什么意思。迈克尔·费哲在他的《遗留代码》一书中,将其定义为没有测试的代码。不过,我喜欢用一个更笼统的定义:你害怕更改的代码。你害怕改变的代码会让你慢下来,减少你的选择,并使任何新的发展成为一场磨难。但这绝不是不可避免的:我们可以改变它,我们将拭目以待。
我们能做的第二件事是理解遗留代码的困境。为了不那么害怕变化,我们需要重构它,但是为了重构代码,我们需要编写测试。为了编写测试,我们需要调整代码,使其可测试;这看起来像一个圆圈——为了改变代码,我们需要改变代码!如果我们一开始就害怕修改代码,我们该怎么做呢?
幸运的是,这个困境有了解决办法。如果我们可以对代码进行安全的修改——这些修改给我们留下了很少的出错机会,并允许我们测试代码——那么我们可以缓慢但肯定地改进代码。这些变化确实是重构,但它们甚至比重构步骤更小、更安全。他们的主要目标是打破代码中设计元素之间的依赖性,使我们能够编写测试,以便我们可以在之后继续重构。
因为我们的重点是使用纯函数和函数构造来重构代码,所以我们不会查看完整的技术列表。我可以举一个简单的例子叫做提取并覆盖。假设您需要为一个非常大的函数编写测试。如果我们可以只为函数的一小部分编写测试,那将是非常理想的。我们可以这样做的方法是将我们想要测试的代码提取到另一个函数中。然而,新的函数依赖于旧的代码,所以我们很难弄清楚所有的依赖关系。为了解决这个问题,我们可以创建一个派生类,用伪函数覆盖函数的所有依赖关系。在单元测试中,这被称为部分模拟。这允许我们用测试覆盖提取函数的所有代码,同时假设类的所有其他部分都像预期的那样工作。一旦我们用测试覆盖了它,我们就可以开始重构;我们经常最终提取一个新的类,这个类在本练习结束时被完全嘲笑或存根化。
这些技术是在我们的语言如此广泛地支持函数式编程之前编写的。我们现在可以利用纯函数来安全地重构我们编写的代码。但是,要做到这一点,我们需要了解依赖关系如何影响我们测试和更改代码的能力。
我们的用户和客户想要越来越多的功能,因为只要项目成功。然而,我们经常无法交付,因为随着时间的推移,代码往往会变得更加僵化。添加新功能变得越来越慢久而久之,当添加一个功能,新的 bug 弹出。
这就引出了十亿个问题——是什么让代码变得难以更改?我们如何编写保持变化速度,甚至提高变化速度的代码?
这是一个复杂的问题,有许多方面和各种解决方案。其中一个是业内基本认同的——依赖性往往会减缓开发速度。依赖较少的代码结构通常更容易更改,从而更容易添加特性。
我们可以在很多层面上看依赖关系。在更高的层次上,我们可以谈论依赖于其他可执行文件的可执行文件;例如,直接调用另一个 web 服务的 web 服务。通过使用基于事件的系统而不是直接调用,可以减少这一级别的依赖。在较低的层次上,我们可以谈论对库或操作系统例程的依赖;例如,依赖于特定文件夹或特定库版本的 web 服务。
虽然所有其他级别都很有趣,但对于我们的目标,我们将关注类/函数级别,特别是类和函数如何相互依赖。由于在任何不平凡的代码库中避免依赖是不可能的,我们将关注依赖的强度。
我们将使用我编写的一小段代码作为示例,该代码基于员工列表和诸如角色、资历、组织中的连续性和奖金水平等参数来计算工资。它从 CSV 文件中读取员工列表,根据一些规则计算工资,并打印计算出的工资列表。代码的第一个版本是天真地编写的,只使用了main
函数,并将所有内容放在同一个文件中,如下面的代码示例所示:
#include <iostream>
#include <fstream>
#include <string>
#include <cmath>
using namespace std;
int main(){
string id;
string employee_id;
string first_name;
string last_name;
string seniority_level;
string position;
string years_worked_continuously;
string special_bonus_level;
ifstream employeesFile("./Employees.csv");
while (getline(employeesFile, id, ',')) {
getline(employeesFile, employee_id, ',') ;
getline(employeesFile, first_name, ',') ;
getline(employeesFile, last_name, ',') ;
getline(employeesFile, seniority_level, ',') ;
getline(employeesFile, position, ',') ;
getline(employeesFile, years_worked_continuously, ',') ;
getline(employeesFile, special_bonus_level);
if(id == "id") continue;
int baseSalary;
if(position == "Tester") baseSalary= 1500;
if(position == "Analyst") baseSalary = 1600;
if(position == "Developer") baseSalary = 2000;
if(position == "Team Leader") baseSalary = 3000;
if(position == "Manager") baseSalary = 4000;
double factor;
if(seniority_level == "Entry") factor = 1;
if(seniority_level == "Junior") factor = 1.2;
if(seniority_level == "Senior") factor = 1.5;
double continuityFactor;
int continuity = stoi(years_worked_continuously);
if(continuity < 3) continuityFactor = 1;
if(continuity >= 3 && continuity < 5) continuityFactor = 1.2;
if(continuity >= 5 && continuity < 10) continuityFactor = 1.5;
if(continuity >=10 && continuity <= 20) continuityFactor = 1.7;
if(continuity > 20) continuityFactor = 2;
int specialBonusLevel = stoi(special_bonus_level);
double specialBonusFactor = specialBonusLevel * 0.03;
double currentSalary = baseSalary * factor * continuityFactor;
double salary = currentSalary + specialBonusFactor *
currentSalary;
int roundedSalary = ceil(salary);
cout << seniority_level << position << " " << first_name << "
" << last_name << " (" << years_worked_continuously <<
"yrs)" << ", " << employee_id << ", has salary (bonus
level " << special_bonus_level << ") " << roundedSalary <<
endl;
}
}
输入文件是使用专用工具用随机值生成的,如下所示:
id,employee_id,First_name,Last_name,Seniority_level,Position,Years_worked_continuously,Special_bonus_level
1,51ef10eb-8c3b-4129-b844-542afaba7eeb,Carmine,De Vuyst,Junior,Manager,4,3
2,171338c8-2377-4c70-bb66-9ad669319831,Gasper,Feast,Entry,Team Leader,10,5
3,807e1bc7-00db-494b-8f92-44acf141908b,Lin,Sunley,Medium,Manager,23,3
4,c9f18741-cd6c-4dee-a243-00c1f55fde3e,Leeland,Geraghty,Medium,Team Leader,7,4
5,5722a380-f869-400d-9a6a-918beb4acbe0,Wash,Van der Kruys,Junior,Developer,7,1
6,f26e94c5-1ced-467b-ac83-a94544735e27,Marjie,True,Senior,Tester,28,1
当我们运行程序时,为每个员工计算salary
,输出如下所示:
JuniorManager Carmine De Vuyst (4yrs), 51ef10eb-8c3b-4129-b844-542afaba7eeb, has salary (bonus level 3) 6279
EntryTeam Leader Gasper Feast (10yrs), 171338c8-2377-4c70-bb66-9ad669319831, has salary (bonus level 5) 5865
MediumManager Lin Sunley (23yrs), 807e1bc7-00db-494b-8f92-44acf141908b, has salary (bonus level 3) 8720
MediumTeam Leader Leeland Geraghty (7yrs), c9f18741-cd6c-4dee-a243-00c1f55fde3e, has salary (bonus level 4) 5040
JuniorDeveloper Wash Van der Kruys (7yrs), 5722a380-f869-400d-9a6a-918beb4acbe0, has salary (bonus level 1) 3708
SeniorTester Marjie True (28yrs), f26e94c5-1ced-467b-ac83-a94544735e27, has salary (bonus level 1) 4635
EntryAnalyst Muriel Dorken (10yrs), f4934e00-9c01-45f9-bddc-2366e6ea070e, has salary (bonus level 8) 3373
SeniorTester Harrison Mawditt (17yrs), 66da352a-100c-4209-a13e-00ec12aa167e, has salary (bonus level 10) 4973
那么,这段代码有依赖性吗?是的,它们藏在显眼的地方。
查找依赖项的一种方法是查找构造函数调用或全局变量。在我们的例子中,我们有一个对ifstream
的构造函数调用,以及一个对cout
的使用,如下例所示:
ifstream employeesFile("./Employees.csv")
cout << seniority_level << position << " " << first_name << " " <<
last_name << " (" << years_worked_continuously << "yrs)" << ", "
<< employee_id << ", has salary (bonus level " <<
special_bonus_level << ") " << roundedSalary << endl;
另一种识别依赖性的方法是创建一个想象练习。想象一下什么需求会在代码中产生变化。有几个。如果我们决定切换到员工数据库,我们将需要改变我们读取数据的方式。如果我们想输出到一个文件,我们需要更改打印工资的代码行。如果计算工资的规则改变,我们将需要改变计算salary
的线。
两种方法都得出相同的结论;我们依赖于文件系统和标准输出。让我们关注标准输出,问一个问题;我们如何更改代码,以便将工资输出到标准输出和文件中?答案很简单,由于标准模板库 ( STL )流的多态性,只需提取一个接收输出流并写入数据的函数。让我们看看这样的函数会是什么样子;为了简单起见,我们还引入了一个名为Employee
的结构,它包含了我们需要的所有字段,如下例所示:
void printEmployee(const Employee& employee, ostream& stream, int
roundedSalary){
stream << employee.seniority_level << employee.position <<
" " << employee.first_name << " " << employee.last_name <<
" (" << employee.years_worked_continuously << "yrs)" << ",
" << employee.employee_id << ", has salary (bonus level " <<
employee.special_bonus_level << ") " << roundedSalary << endl;
}
这个函数不再依赖于标准输出。依赖方面,可以说打破了员工打印和标准输出之间的依赖。我们是怎么做到的?我们从调用者那里传递了cout
流作为函数的参数:
printEmployee(employee, cout, roundedSalary);
这个看似很小的改变使得函数多态。printEmployee
的调用者现在控制函数的输出,而不改变函数内部的任何东西。
此外,我们现在可以为printEmployee
函数编写从不接触文件系统的测试。这一点很重要,因为文件系统访问很慢,并且在测试快乐路径时,由于缺少磁盘空间或部分损坏等原因,可能会出现错误。我们如何编写这样的测试?我们只需要使用内存流调用函数,然后将写入内存流的输出与我们期望的进行比较。
因此,打破这种依赖会导致我们代码的可变性和可测试性的巨大改善。这个机制如此有用和广泛,以至于它获得了一个名字——依赖注入 ( DI )。在我们的例子中,printEmployee
函数的调用者(main
函数、test
函数或另一个未来的调用者)将对输出流的依赖注入到我们的函数中,从而控制它的行为。
澄清关于 DI 的一件事很重要——它是一个设计模式,而不是一个库。许多现代库和 MVC 框架都支持 DI,但是您不需要任何外部的东西来注入依赖性。您只需要将依赖关系传递到构造函数、属性或函数参数中,您就万事大吉了。
我们学习了如何识别依赖关系,以及如何使用 DI 来打破它们。是时候看看我们如何利用纯函数重构这段代码了。
几年前,我学到了一条关于计算机程序的基本定律,它让我开始研究如何在重构中使用纯函数:
任何计算机程序都可以由两种类型的类/函数构建而成——有些是做 I/O 的,有些是纯的。
后来搜索类似的想法,我找到了加里·伯恩哈特对这类结构的简洁命名:功能核心,命令外壳(https://www . destroyallsoftware . com/screencasts/catalog/functional-core-command-shell)。
不管你怎么称呼它,这个想法对重构的影响是根本性的。如果任何程序都可以写成两种不同类型的类/函数,一些是不可变的,一些是输入输出的,那么我们可以利用这个属性重构遗留代码。高级流程如下所示:
- 提取纯函数(我们将看到这些步骤识别依赖性)。
- 测试并重构它们。
- 根据高内聚原则将它们重新分组。
我想给这条定律增加一条公理。我相信我们可以在代码的任何级别应用这一点,无论是函数、类、一组代码行、一组类还是整个模块,除了那些纯 I/O 的代码行。换句话说,这个定律是分形的;除了最基本的代码,它适用于任何级别的代码。
这条公理的重要性是巨大的。它告诉我们的是,除了最基本的以外,我们可以在代码的任何级别应用我们之前描述的相同方法。换句话说,我们从哪里开始应用该方法并不重要,因为它将在任何地方工作。
在接下来的部分中,我们将探索该方法的每一步。首先,让我们提取一些纯函数。
试图更改我们不理解且没有测试的代码可能会有风险。任何错误都会导致丑陋的 bug,任何改变都会导致错误。
幸运的是,编译器和纯函数可以帮助揭示依赖关系。记住什么是纯函数——对于相同的输入返回相同输出的函数。根据定义,这意味着纯函数的所有依赖都是可见的,要么作为参数、全局变量传递,要么通过变量捕获传递。
这就给我们带来了一个识别代码中依赖关系的简单方法:挑选几行代码,将它们提取到一个函数中,使其纯净,然后让编译器告诉你依赖关系是什么。此外,依赖项将不得不被注入,从而导致我们得到一个可测试的函数。
我们来看几个例子。简单的开始是下面几行代码,它们根据给定员工在公司中的职位计算基本工资:
int baseSalary;
if(position == "Tester") baseSalary = 1500;
if(position == "Analyst") baseSalary = 1600;
if(position == "Developer") baseSalary = 2000;
if(position == "Team Leader") baseSalary = 3000;
if(position == "Manager") baseSalary = 4000;
让我们把它提取为一个纯函数。名字暂时不重要,我们暂时称之为doesSomething
,我只是将代码行复制粘贴到新函数中,不从旧函数中移除,如下例所示:
auto doesSomething = [](){
int baseSalary;
if(position == "Tester") baseSalary = 1500;
if(position == "Analyst") baseSalary = 1600;
if(position == "Developer") baseSalary = 2000;
if(position == "Team Leader") baseSalary = 3000;
if(position == "Manager") baseSalary = 4000;
};
我的编译器立即抱怨这个位置没有被定义,所以它帮我计算了依赖关系。让我们将其添加为一个参数,如下例所示:
auto doesSomething = [](const string& position){
int baseSalary;
if(position == "Tester") baseSalary = 1500;
if(position == "Analyst") baseSalary = 1600;
if(position == "Developer") baseSalary = 2000;
if(position == "Team Leader") baseSalary = 3000;
if(position == "Manager") baseSalary = 4000;
};
这个函数缺少一些东西;纯函数总是返回值,但这不是。让我们添加return
语句,如下面的代码示例所示:
auto doesSomething = [](const string& position){
int baseSalary;
if(position == "Tester") baseSalary = 1500;
if(position == "Analyst") baseSalary = 1600;
if(position == "Developer") baseSalary = 2000;
if(position == "Team Leader") baseSalary = 3000;
if(position == "Manager") baseSalary = 4000;
return baseSalary;
};
该函数现在已经足够简单,可以单独测试。但是首先,我们需要把它提取到一个单独的.h
文件中,并给它一个合适的名称。baseSalaryForPosition
听起来不错;让我们在下面的代码中看看它的测试:
TEST_CASE("Base salary"){
CHECK_EQ(1500, baseSalaryForPosition("Tester"));
CHECK_EQ(1600, baseSalaryForPosition("Analyst"));
CHECK_EQ(2000, baseSalaryForPosition("Developer"));
CHECK_EQ(3000, baseSalaryForPosition("Team Leader"));
CHECK_EQ(4000, baseSalaryForPosition("Manager"));
CHECK_EQ(0, baseSalaryForPosition("asdfasdfs"));
}
测试编写起来相当简单。他们还从函数中复制了许多东西,包括职位字符串和薪资值。有更好的方法来组织代码,但这是遗留代码的期望。目前,我们很高兴用测试覆盖了部分初始代码。我们也可以向领域专家展示这些测试,并检查它们是否正确,但是让我们继续我们的重构。我们需要从main()
开始调用新函数,如下图所示:
while (getline(employeesFile, id, ',')) {
getline(employeesFile, employee_id, ',') ;
getline(employeesFile, first_name, ',') ;
getline(employeesFile, last_name, ',') ;
getline(employeesFile, seniority_level, ',') ;
getline(employeesFile, position, ',') ;
getline(employeesFile, years_worked_continuously, ',') ;
getline(employeesFile, special_bonus_level);
if(id == "id") continue;
int baseSalary = baseSalaryForPosition(position);
double factor;
if(seniority_level == "Entry") factor = 1;
if(seniority_level == "Junior") factor = 1.2;
if(seniority_level == "Senior") factor = 1.5;
...
}
虽然这是一个简单的案例,但它显示了基本过程,如下所示:
- 挑选几行代码。
- 将它们提取到函数中。
- 使函数纯净。
- 注入所有依赖项。
- 为新的纯函数编写测试。
- 验证行为。
- 重复,直到整个代码被测试覆盖。
如果您遵循这个过程,引入 bug 的风险会变得非常小。从我的经验来看,你最需要小心的是使功能纯粹。记住——如果它在一个类中,用const
参数使它静态,但是如果它在一个类之外,将所有参数作为const
传递,使它成为一个λ。
如果我们重复这个过程几次,我们最终会得到更纯的函数。首先,factorForSeniority
根据资历级别计算因子,如下例所示:
auto factorForSeniority = [](const string& seniority_level){
double factor;
if(seniority_level == "Entry") factor = 1;
if(seniority_level == "Junior") factor = 1.2;
if(seniority_level == "Senior") factor = 1.5;
return factor;
};
然后,factorForContinuity
根据——你猜对了——连续性计算因子:
auto factorForContinuity = [](const string& years_worked_continuously){
double continuityFactor;
int continuity = stoi(years_worked_continuously);
if(continuity < 3) continuityFactor = 1;
if(continuity >= 3 && continuity < 5) continuityFactor = 1.2;
if(continuity >= 5 && continuity < 10) continuityFactor = 1.5;
if(continuity >=10 && continuity <= 20) continuityFactor = 1.7;
if(continuity > 20) continuityFactor = 2;
return continuityFactor;
};
最后,bonusLevel
功能读取奖励等级:
auto bonusLevel = [](const string& special_bonus_level){
return stoi(special_bonus_level);
};
这些函数中的每一个都可以通过基于示例、数据驱动或基于属性的测试来轻松测试。提取所有这些函数后,我们的主要方法看起来像下面的例子(为了简洁起见,省略了几行):
int main(){
...
ifstream employeesFile("./Employees.csv");
while (getline(employeesFile, id, ',')) {
getline(employeesFile, employee_id, ',') ;
...
getline(employeesFile, special_bonus_level);
if(id == "id") continue;
int baseSalary = baseSalaryForPosition(position);
double factor = factorForSeniority(seniority_level);
double continuityFactor =
factorForContinuity(years_worked_continuously);
int specialBonusLevel = bonusLevel(special_bonus_level);
double specialBonusFactor = specialBonusLevel * 0.03;
double currentSalary = baseSalary * factor * continuityFactor;
double salary = currentSalary + specialBonusFactor *
currentSalary;
int roundedSalary = ceil(salary);
cout << seniority_level << position << " " << first_name << "
" << last_name << " (" << years_worked_continuously << "yrs)"
<< ", " << employee_id << ", has salary (bonus level " <<
special_bonus_level << ") " << roundedSalary << endl;
}
这是一个有点干净,更好的测试覆盖。不过,Lambdas 可以用于更多用途;让我们看看如何做到这一点。
除了纯度,lambdas 还提供了许多我们可以使用的操作:功能组合、部分应用、currying 和更高级的功能。我们可以在重构遗留代码时利用这些操作。
最简单的方法是从main
方法中提取整个salary
计算。这些是计算salary
的代码行:
...
int baseSalary = baseSalaryForPosition(position);
double factor = factorForSeniority(seniority_level);
double continuityFactor =
factorForContinuity(years_worked_continuously);
int specialBonusLevel = bonusLevel(special_bonus_level);
double specialBonusFactor = specialBonusLevel * 0.03;
double currentSalary = baseSalary * factor * continuityFactor;
double salary = currentSalary + specialBonusFactor *
currentSalary;
int roundedSalary = ceil(salary);
...
我们可以通过两种方式提取这个纯函数——一种是传入每个需要的值作为参数,结果如下所示:
auto computeSalary = [](const string& position, const string seniority_level, const string& years_worked_continuously, const string& special_bonus_level){
int baseSalary = baseSalaryForPosition(position);
double factor = factorForSeniority(seniority_level);
double continuityFactor =
factorForContinuity(years_worked_continuously);
int specialBonusLevel = bonusLevel(special_bonus_level);
double specialBonusFactor = specialBonusLevel * 0.03;
double currentSalary = baseSalary * factor * continuityFactor;
double salary = currentSalary + specialBonusFactor * currentSalary;
int roundedSalary = ceil(salary);
return roundedSalary;
};
第二种选择要有趣得多。与其传递变量,不如我们传递函数,并事先将它们绑定到所需的变量上。
这是一个有趣的想法。结果是一个函数接收多个函数作为参数,每个函数都没有任何参数:
auto computeSalary = [](auto baseSalaryForPosition, auto factorForSeniority, auto factorForContinuity, auto bonusLevel){
int baseSalary = baseSalaryForPosition();
double factor = factorForSeniority();
double continuityFactor = factorForContinuity();
int specialBonusLevel = bonusLevel();
double specialBonusFactor = specialBonusLevel * 0.03;
double currentSalary = baseSalary * factor * continuityFactor;
double salary = currentSalary + specialBonusFactor * currentSalary;
int roundedSalary = ceil(salary);
return roundedSalary;
};
main
方法需要先绑定函数,然后将它们注入到我们的方法中,如下所示:
auto roundedSalary = computeSalary(
bind(baseSalaryForPosition, position),
bind(factorForSeniority, seniority_level),
bind(factorForContinuity, years_worked_continuously),
bind(bonusLevel, special_bonus_level));
cout << seniority_level << position << " " << first_name << "
" << last_name << " (" << years_worked_continuously << "yrs)"
<< ", " << employee_id << ", has salary (bonus level " <<
special_bonus_level << ") " << roundedSalary << endl;
为什么这种方法很有趣?好吧,让我们从软件设计的角度来看。我们创建了小的纯函数,每个函数都有明确的职责。然后,我们将它们绑定到特定的值。之后,我们将它们作为参数传递给另一个 lambda,后者使用它们来计算我们需要的结果。
这在面向对象编程风格中意味着什么?函数是类的一部分。将函数绑定到值相当于调用类的构造函数。将对象传递给另一个函数称为 DI。
等一下!我们实际上在做的是分离责任和注入依赖,仅仅通过使用纯函数而不是对象!因为我们使用的是纯函数,编译器会使依赖关系变得明显。因此,我们有一种重构错误概率非常小的代码的方法,因为我们经常使用编译器。这是一个非常有用的重构过程。
我不得不承认结果没有我想的那么好。让我们重构我们的 lambda。
我对我们提取的λ不满意。由于接收的参数多,责任多,所以相当复杂。让我们仔细看看,看看如何改进:
auto computeSalary = [](auto baseSalaryForPosition, auto
factorForSeniority, auto factorForContinuity, auto bonusLevel){
int baseSalary = baseSalaryForPosition();
double factor = factorForSeniority();
double continuityFactor = factorForContinuity();
int specialBonusLevel = bonusLevel();
double specialBonusFactor = specialBonusLevel * 0.03;
double currentSalary = baseSalary * factor * continuityFactor;
double salary = currentSalary + specialBonusFactor *
currentSalary;
int roundedSalary = ceil(salary);
return roundedSalary;
};
所有迹象似乎都表明,该职能有多重责任。如果我们从中提取更多的函数呢?让我们从specialBonusFactor
计算开始:
auto specialBonusFactor = [](auto bonusLevel){
return bonusLevel() * 0.03;
};
auto computeSalary = [](auto baseSalaryForPosition, auto
factorForSeniority, auto factorForContinuity, auto bonusLevel){
int baseSalary = baseSalaryForPosition();
double factor = factorForSeniority();
double continuityFactor = factorForContinuity();
double currentSalary = baseSalary * factor * continuityFactor;
double salary = currentSalary + specialBonusFactor() *
currentSalary;
int roundedSalary = ceil(salary);
return roundedSalary;
};
我们现在可以注射specialBonusFactor
。然而,请注意specialBonusFactor
是唯一需要bonusLevel
的λ。这意味着我们可以将bonusLevel
λ替换为部分应用于bonusLevel
的specialBonusFactor
λ,如下例所示:
int main(){
...
auto bonusFactor = bind(specialBonusFactor, [&](){ return
bonusLevel(special_bonus_level); } );
auto roundedSalary = computeSalary(
bind(baseSalaryForPosition, position),
bind(factorForSeniority, seniority_level),
bind(factorForContinuity, years_worked_continuously),
bonusFactor
);
...
}
auto computeSalary = [](auto baseSalaryForPosition, auto factorForSeniority, auto factorForContinuity, auto bonusFactor){
int baseSalary = baseSalaryForPosition();
double factor = factorForSeniority();
double continuityFactor = factorForContinuity();
double currentSalary = baseSalary * factor * continuityFactor;
double salary = currentSalary + bonusFactor() * currentSalary;
int roundedSalary = ceil(salary);
return roundedSalary;
};
我们的computeSalary
λ现在变小了。通过内联临时变量,我们可以使它更小:
auto computeSalary = [](auto baseSalaryForPosition, auto
factorForSeniority, auto factorForContinuity, auto bonusFactor){
double currentSalary = baseSalaryForPosition() *
factorForSeniority() * factorForContinuity();
double salary = currentSalary + bonusFactor() * currentSalary;
return ceil(salary);
};
太好了!然而,我想让它更接近数学公式。首先,让我们重写行计算salary
(在代码中以粗体突出显示):
auto computeSalary = [](auto baseSalaryForPosition, auto
factorForSeniority, auto factorForContinuity, auto bonusFactor){
double currentSalary = baseSalaryForPosition() *
factorForSeniority() * factorForContinuity();
double salary = (1 + bonusFactor()) * currentSalary;
return ceil(salary);
};
然后,让我们用函数替换变量。然后,我们剩下下面的代码示例:
auto computeSalary = [](auto baseSalaryForPosition, auto
factorForSeniority, auto factorForContinuity, auto bonusFactor){
return ceil (
(1 + bonusFactor()) * baseSalaryForPosition() *
factorForSeniority() * factorForContinuity()
);
};
因此,我们有一个 lambda,它接收多个 lambda 并使用它们来计算一个值。我们仍然可以对其他功能进行改进,但是我们已经达到了一个有趣的点。
那我们从这里去哪里?我们已经注入了依赖项,代码更加模块化,更容易更改,也更容易测试。我们可以从返回我们想要的值的测试中注入 lambdas,这实际上是单元测试中的一个存根。虽然我们没有改进整个代码,但我们通过提取纯函数和使用函数操作来分离依赖关系和责任。如果我们愿意,我们可以这样留下代码。或者,我们可以采取另一个步骤,将函数重新组合成类。
在本书中,我们已经多次指出,一个类只不过是一组内聚的部分应用的纯函数。以我们迄今为止的技术,我们已经创建了一堆部分应用的纯函数。把它们变成类现在是一个简单的任务。
让我们看一个简单的baseSalaryForPosition
函数的例子:
auto baseSalaryForPosition = [](const string& position){
int baseSalary;
if(position == "Tester") baseSalary = 1500;
if(position == "Analyst") baseSalary = 1600;
if(position == "Developer") baseSalary = 2000;
if(position == "Team Leader") baseSalary = 3000;
if(position == "Manager") baseSalary = 4000;
return baseSalary;
};
我们在main()
中使用它,如下例所示:
auto roundedSalary = computeSalary(
bind(baseSalaryForPosition, position),
bind(factorForSeniority, seniority_level),
bind(factorForContinuity, years_worked_continuously),
bonusFactor
);
要把它变成一个类,我们只需要创建一个将接收position
参数的构造函数,然后把它变成一个类方法。让我们在下面的例子中看到它:
class BaseSalaryForPosition{
private:
const string& position;
public:
BaseSalaryForPosition(const string& position) :
position(position){};
int baseSalaryForPosition() const{
int baseSalary;
if(position == "Tester") baseSalary = 1500;
if(position == "Analyst") baseSalary = 1600;
if(position == "Developer") baseSalary = 2000;
if(position == "Team Leader") baseSalary = 3000;
if(position == "Manager") baseSalary = 4000;
return baseSalary;
}
};
我们可以简单地初始化并传递对象,而不是将部分应用的函数传递给computeSalary
lambda,如下面的代码所示:
auto bonusFactor = bind(specialBonusFactor, [&](){ return
bonusLevel(special_bonus_level); } );
auto roundedSalary = computeSalary(
theBaseSalaryForPosition,
bind(factorForSeniority, seniority_level),
bind(factorForContinuity, years_worked_continuously),
bonusFactor
);
为此,我们还需要更改我们的computeSalary
λ,如下所示:
auto computeSalary = [](const BaseSalaryForPosition&
baseSalaryForPosition, auto factorForSeniority, auto
factorForContinuity, auto bonusFactor){
return ceil (
(1 + bonusFactor()) *
baseSalaryForPosition.baseSalaryForPosition() *
factorForSeniority() * factorForContinuity()
);
};
现在,为了允许注入不同的实现,我们实际上需要从BaseSalaryForPosition
类中提取一个接口,并将其作为一个接口注入,而不是一个类。这对于从测试中注入双精度尤其有用,例如存根或模拟。
从现在开始,没有什么能阻止你把函数重组成你认为合适的类。我将把这个留给读者作为一个练习,因为我相信我们已经展示了如何使用纯函数来重构代码,即使当我们想要在最后获得面向对象的代码时。
到目前为止,我们学到了什么?嗯,我们经历了一个结构化的重构过程,它可以在代码的任何级别使用,降低了错误的概率,并支持可变性和测试。这个过程基于两个基本思想——任何程序都可以作为不可变函数和输入/输出函数的组合来编写,或者作为命令外壳中的功能核心来编写。此外,我们还证明了这个属性是分形的——我们可以将它应用于任何级别的代码,从几行代码到整个模块。
因为不可变函数可以是我们程序的核心,所以我们可以一点一点地提取它们。我们编写新的函数名,复制并粘贴主体,并使用编译器传递任何依赖项作为参数。当代码被编译时,如果我们小心而缓慢地改变它,我们相当确定代码仍然正常工作。这种提取揭示了我们功能的依赖性,从而允许我们做出设计决策。
接下来,我们将提取更多接收其他部分应用的纯函数作为参数的函数。这就导致了依赖关系和实际中断依赖关系之间的明显区别。
最后,由于部分应用的函数相当于类,我们可以基于内聚性轻松封装其中的一个或多个。不管我们是从类还是函数开始,这个过程都是有效的,不管我们是想以函数还是类结束。然而,它允许我们使用函数构造来打破依赖,并在代码中分离责任。
既然我们正在改进设计,是时候看看设计模式如何应用于函数式编程,以及如何面向它们进行重构了。我们将访问“四人帮”的一些模式,以及我们已经在代码中使用过的 DI。
软件开发中的许多好东西来自那些注意到程序员如何工作并从中吸取某些教训的人;换句话说,着眼于实际的方法,并从中吸取共同的、有用的教训,而不是猜测解决方案。
所谓的“四人帮”(埃里希·伽马、理查德·赫尔姆、拉尔夫·约翰逊和约翰·弗里西德斯)采用了这种精确的方法,他们用精确的语言记录了一系列设计模式。在注意到越来越多的程序员以相似的方式解决相同的问题后,他们决定将这些模式写下来,并将编程世界引入到清晰上下文中特定问题的可重用解决方案的思想中。
由于当时的设计范式是面向对象的,他们出版的设计模式一书展示了这些使用面向对象方法的解决方案。此外,非常有趣的是,他们尽可能记录了至少两种类型的解决方案——一种基于谨慎的继承,另一种基于对象组合。我花了很多时间学习设计模式书,我可以告诉你,这是软件设计中非常有趣的一课。
我们将在下一节探索一些设计模式以及如何使用函数来实现它们。
策略模式可以简单地描述为一种构建代码的方式,它允许在运行时选择算法。OOP 实现使用了 DI,您可能已经熟悉了 STL 的面向对象和功能设计。
我们来看看 STL sort
功能。它最复杂的形式需要一个 functor 对象,如下例所示:
class Comparator{
public:
bool operator() (int first, int second) { return (first < second);}
};
TEST_CASE("Strategy"){
Comparator comparator;
vector<int> values {23, 1, 42, 83, 52, 5, 72, 11};
vector<int> expected {1, 5, 11, 23, 42, 52, 72, 83};
sort(values.begin(), values.end(), comparator);
CHECK_EQ(values, expected);
}
sort
函数使用comparator
对象来比较向量中的元素,并对其进行适当排序。这是一种策略模式,因为我们可以用任何有相同界面的东西来交换comparator
;其实只是需要operator()
功能实现。例如,我们可以想象一个用户界面,其中用户选择比较函数,并使用它对值列表进行排序;我们只需要在运行时创建正确的comparator
实例,并将其发送到sort
函数。
您已经可以看到功能解决方案的种子。事实上,sort
函数允许一个简单得多的版本,如下例所示:
auto compare = [](auto first, auto second) { return first < second;};
TEST_CASE("Strategy"){
vector<int> values {23, 1, 42, 83, 52, 5, 72, 11};
vector<int> expected {1, 5, 11, 23, 42, 52, 72, 83};
sort(values.begin(), values.end(), compare);
CHECK_EQ(values, expected);
}
这一次,我们放下仪式,直接开始实现我们需要的东西——一个我们插入sort
的比较函数。不再有类,不再有操作符——策略只是一个函数。
让我们看看这在更复杂的环境中是如何工作的。我们将使用策略模式、https://en.wikipedia.org/wiki/Strategy_pattern上的维基百科页面中的问题,并使用功能方法编写它。
问题来了:我们需要为一家酒吧编写一个计费系统,可以为欢乐时光提供折扣。这个问题适用于策略模式的使用,因为我们有两个策略来计算账单的最终价格——一个返回完整价格,而第二个返回完整账单的快乐时光折扣(在我们的情况下,我们将使用 50%)。再一次,解决方案是简单地对这两种策略使用两个函数——只返回其收到的全部价格的normalBilling
函数和返回其收到的一半价值的happyHourBilling
函数。让我们在下面的代码中看到这一点(源自我的测试驱动开发 ( TDD )方法):
map<string, double> drinkPrices = {
{"Westmalle Tripel", 15.50},
{"Lagavulin 18y", 25.20},
};
auto happyHourBilling = [](auto price){
return price / 2;
};
auto normalBilling = [](auto price){
return price;
};
auto computeBill = [](auto drinks, auto billingStrategy){
auto prices = transformAll<vector<double>>(drinks, [](auto drink){
return drinkPrices[drink]; });
auto sum = accumulateAll(prices, 0.0, std::plus<double>());
return billingStrategy(sum);
};
TEST_CASE("Compute total bill from list of drinks, normal billing"){
vector<string> drinks;
double expectedBill;
SUBCASE("no drinks"){
drinks = {};
expectedBill = 0;
};
SUBCASE("one drink no discount"){
drinks = {"Westmalle Tripel"};
expectedBill = 15.50;
};
SUBCASE("one another drink no discount"){
drinks = {"Lagavulin 18y"};
expectedBill = 25.20;
};
double actualBill = computeBill(drinks, normalBilling);
CHECK_EQ(expectedBill, actualBill);
}
TEST_CASE("Compute total bill from list of drinks, happy hour"){
vector<string> drinks;
double expectedBill;
SUBCASE("no drinks"){
drinks = {};
expectedBill = 0;
};
SUBCASE("one drink happy hour"){
drinks = {"Lagavulin 18y"};
expectedBill = 12.60;
};
double actualBill = computeBill(drinks, happyHourBilling);
CHECK_EQ(expectedBill, actualBill);
}
我认为这表明策略最简单的实现是一个函数。我个人喜欢这种模式给战略模式带来的简单性;编写最少的有用代码让事情运转起来是一种解放。
命令模式是我在工作中广泛使用的一种模式。它非常适合 MVC 网络框架,允许将控制器分成多个功能块,同时允许与存储格式分离。它的目的是将请求与操作分开——这就是它如此通用的原因,因为任何调用都可以被视为请求。
命令模式使用的一个简单例子是在支持多个控制器和改变键盘快捷键的游戏中。这些游戏无法将 W 按键事件直接链接到将你的角色上移的代码;取而代之的是,你将 W 键绑定到一个MoveUpCommand
上,从而巧妙地将两者脱钩。我们可以轻松地更改与向上移动的命令或代码相关联的控制器事件,两者之间没有干扰。
当我们研究命令如何在面向对象的代码中实现时,功能解决方案变得同样明显。一个MoveUpCommand
类看起来像下面的例子:
class MoveUpCommand{
public:
MoveUpCommand(/*parameters*/){}
void execute(){ /* implementation of the command */}
}
我说很明显!我们实际上试图完成的事情很容易通过命名函数来完成,如下例所示:
auto moveUpCommand = [](/*parameters*/{
/* implementation */
};
最简单的命令模式是函数。谁会想到呢?
如果不涉及到 DI,我们就不能谈论广泛传播的设计模式。虽然在“四人帮”的书中没有定义,但这种模式在现代代码中已经变得如此普遍,以至于许多程序员将其视为框架或库的一部分,而不是设计模式。
DI 模式的目的是将类或函数的依赖创建与其行为分开。为了理解它所解决的问题,让我们看看这段代码:
auto readFromFileAndAddTwoNumbers = [](){
int first;
int second;
ifstream numbersFile("numbers.txt");
numbersFile >> first;
numbersFile >> second;
numbersFile.close();
return first + second;
};
TEST_CASE("Reads from file"){
CHECK_EQ(30, readFromFileAndAddTwoNumbers());
}
如果您只需要将从文件中读取的两个数字相加,这是一个相当公平的代码。不幸的是,在现实世界中,我们的客户很可能需要更多的来源来读取数字,例如控制台,如下所示:
auto readFromConsoleAndAddTwoNumbers = [](){
int first;
int second;
cout << "Input first number: ";
cin >> first;
cout << "Input second number: ";
cin >> second;
return first + second;
};
TEST_CASE("Reads from console"){
CHECK_EQ(30, readFromConsoleAndAddTwoNumbers());
}
在继续之前,请注意,只有当您从控制台引入两个总和为30
的数字时,该功能的测试才会通过。因为它们在每次运行时都需要输入,所以测试用例在我们的代码示例中被注释;请随意启用它并玩弄它。
这两个函数看起来非常相似。为了解决这种相似性,DI 可以提供帮助,如下例所示:
auto readAndAddTwoNumbers = [](auto firstNumberReader, auto
secondNumberReader){
int first = firstNumberReader();
int second = secondNumberReader();
return first + second;
};
现在我们可以实现使用文件的读取器:
auto readFirstFromFile = [](){
int number;
ifstream numbersFile("numbers.txt");
numbersFile >> number;
numbersFile.close();
return number;
};
auto readSecondFromFile = [](){
int number;
ifstream numbersFile("numbers.txt");
numbersFile >> number;
numbersFile >> number;
numbersFile.close();
return number;
};
我们还可以实现使用控制台的阅读器:
auto readFirstFromConsole = [](){
int number;
cout << "Input first number: ";
cin >> number;
return number;
};
auto readSecondFromConsole = [](){
int number;
cout << "Input second number: ";
cin >> number;
return number;
};
像往常一样,我们可以测试它们在各种组合中是否正常工作,如下所示:
TEST_CASE("Reads using dependency injection and adds two numbers"){
CHECK_EQ(30, readAndAddTwoNumbers(readFirstFromFile,
readSecondFromFile));
CHECK_EQ(30, readAndAddTwoNumbers(readFirstFromConsole,
readSecondFromConsole));
CHECK_EQ(30, readAndAddTwoNumbers(readFirstFromFile,
readSecondFromConsole));
}
我们正在注入通过 lambda 读取数字的代码。请注意,在测试代码中,使用这个方法允许我们混合和匹配我们认为合适的依赖关系——最后一个检查从文件中读取第一个数字,而第二个从控制台中读取。
当然,我们通常在面向对象语言中实现 DI 的方式是使用接口和类。然而,正如我们所看到的,实现 DI 最简单的方法是使用函数。
到目前为止,我们已经看到了一些经典的面向对象设计模式是如何变成功能变体的。但是我们能想象源于函数式编程的设计模式吗?
实际上我们已经用过一些了。map
/ reduce
(或 STL 中的transform
/ accumulate
)就是一个例子。大多数高阶函数(如filter
、all_of
和any_of
等)也是模式的例子。然而,我们可以走得更远,探索一种来自函数式编程的常见但不透明的设计模式。
最好的理解方法是从具体问题入手。首先,我们将看到如何在不可变的上下文中维护状态。然后,我们将学习设计模式。最后,我们将在另一个环境中看到它的实际应用。
如何在函数式编程中保持状态?考虑到函数式编程背后的思想之一是不变性,这似乎是一个奇怪的问题,反过来,这似乎阻止了状态的改变。
然而,这种限制是一种错觉。为了理解它,让我们想一想时间是如何流逝的。如果我戴上帽子,我会把我的状态从摘下帽子变成戴上帽子。如果我能一秒一秒地回顾从我伸手拿帽子到戴上帽子的时间,我就能看到我的动作是如何一秒一秒地朝着这个目标前进的。但是我不能改变过去的任何一秒。过去是不可改变的,不管我们喜不喜欢(毕竟,也许我戴着帽子看起来很傻,但我不能还原它)。所以自然让时间以这样一种方式运转,过去是不可改变的,但我们可以改变状态。
我们如何从概念上对此进行建模?嗯,这样想吧——首先,我们有一个初始状态,戴着帽子的亚历克斯,以及一个运动的定义,目的是到达帽子并戴上它。在编程术语中,我们用一个函数来模拟运动。函数接收手的位置和函数本身,并返回手的新位置加上函数。因此,通过复制自然,我们以下面例子中的状态序列结束:
Alex wants to put the hat on
Initial state: [InitialHandPosition, MovementFunction (HandPosition -> next HandPosition)]
State1 = [MovementFunction(InitialHandPosition), MovementFunction]
State2 = [MovementFunction(HandPosition at State1),MovementFunction]...
Staten = [MovementFunction(HandPosition at Staten-1), MovementFunction]
until Alex has hat on
通过反复应用MovementFunction
,我们最终得到一系列状态。每个状态都是不可变的,但是我们可以存储状态。
现在让我们看一个简单的 C++ 例子。我们可以使用的最简单的例子是自动增量索引。索引需要记住上次使用的值,并使用increment
函数从索引中返回下一个值。通常,当我们试图使用不可变代码来实现它时,我们会遇到麻烦,但是我们可以用前面描述的方法来实现吗?
我们来看看。首先,我们需要用第一个值初始化自动增量索引——假设它是1
。像往常一样,我希望检查该值是否被初始化为我期望的值,如下所示:
TEST_CASE("Id"){
const auto autoIncrementIndex = initAutoIncrement(1);
CHECK_EQ(1, value(autoIncrementIndex));
}
注意,既然autoIncrementIndex
不变,我们可以做成const
。
我们如何实现initAutoIncrement
?正如我们所说的,我们需要初始化一个既保存当前值(在这种情况下为1
)又保存增量函数的结构。我将从这样一对开始:
auto initAutoIncrement = [](const int initialId){
function<int(const int)> nextId = [](const int lastId){
return lastId + 1;
};
return make_pair(initialId, nextId);
};
至于之前的value
函数,只是从对中返回值;它是这对元素中的第一个元素,如下面的代码片段所示:
auto value = [](const auto previous){
return previous.first;
};
现在让我们计算自动增量索引中的下一个元素。我们初始化它,然后计算下一个值,并检查下一个值是否为2
:
TEST_CASE("Compute next auto increment index"){
const auto autoIncrementIndex = initAutoIncrement(1);
const auto nextAutoIncrementIndex =
computeNextAutoIncrement(autoIncrementIndex);
CHECK_EQ(2, value(nextAutoIncrementIndex));
}
请再次注意,两个autoIncrementIndex
变量都是const
,因为它们从不变异。我们已经有了价值函数,但是computeNextAutoIncrement
函数是什么样子的呢?它必须从对中获取当前值和函数,将函数应用于当前值,并在新值和函数之间返回一对:
auto computeNextAutoIncrement = [](pair<const int, function<int(const
int)>> current){
const auto currentValue = value(current);
const auto functionToApply = lambda(current);
const int newValue = functionToApply(currentValue);
return make_pair(newValue, functionToApply);
};
我们正在使用一个实用函数lambda
,它从这一对中返回λ:
auto lambda = [](const auto previous){
return previous.second;
};
这真的有用吗?让我们测试下一个值:
TEST_CASE("Compute next auto increment index"){
const auto autoIncrementIndex = initAutoIncrement(1);
const auto nextAutoIncrementIndex =
computeNextAutoIncrement(autoIncrementIndex);
CHECK_EQ(2, value(nextAutoIncrementIndex));
const auto newAutoIncrementIndex =
computeNextAutoIncrement(nextAutoIncrementIndex);
CHECK_EQ(3, value(newAutoIncrementIndex));
}
所有的测试都通过了,表明我们刚刚以不可变的方式存储了状态!
既然这个解决方案看起来很简单,那么下一个问题就是——我们能概括它吗?让我们试试。
首先,我们把pair
换成struct
。该结构需要有一个值和函数来计算下一个值作为数据成员。这将消除对我们的value()
和lambda()
功能的需求:
struct State{
const int value;
const function<int(const int)> computeNext;
};
int
类型会重复,但为什么要重复呢?一个状态可能比仅仅int
更复杂,所以让我们把struct
变成一个模板:
template<typename ValueType>
struct State{
const ValueType value;
const function<ValueType(const ValueType)> computeNext;
};
这样,我们可以初始化一个自动增量索引并检查初始值:
auto increment = [](const int current){
return current + 1;
};
TEST_CASE("Initialize auto increment"){
const auto autoIncrementIndex = State<int>{1, increment};
CHECK_EQ(1, autoIncrementIndex.value);
}
最后,我们需要一个函数来计算下一个State
。函数需要返回一个State<ValueType>
,所以最好将其封装到State
结构中。此外,它可以使用当前值,因此无需向其中传递值:
template<typename ValueType>
struct State{
const ValueType value;
const function<ValueType(const ValueType)> computeNext;
State<ValueType> nextState() const{
return State<ValueType>{computeNext(value), computeNext};
};
};
有了这个实现,我们现在可以检查自动增量索引的下两个值:
TEST_CASE("Compute next auto increment index"){
const auto autoIncrementIndex = State<int>{1, increment};
const auto nextAutoIncrementIndex = autoIncrementIndex.nextState();
CHECK_EQ(2, nextAutoIncrementIndex.value);
const auto newAutoIncrementIndex =
nextAutoIncrementIndex.nextState();
CHECK_EQ(3, newAutoIncrementIndex.value);
}
测试通过了,所以代码可以工作了!现在让我们再玩一会儿。
假设我们正在实现一个简单的井字游戏。我们希望使用相同的模式来计算移动后棋盘的下一个状态。
首先,我们需要一个可以容纳 TicTacToe 板的结构。为了简单起见,我将使用vector<vector<Token>>
,其中Token
是可以保存Blank
、X
或O
值的enum
:
enum Token {Blank, X, O};
typedef vector<vector<Token>> TicTacToeBoard;
然后,我们需要一个Move
结构。Move
结构需要包含移动的棋盘坐标和用于移动的令牌:
struct Move{
const Token token;
const int xCoord;
const int yCoord;
};
我们还需要一个函数,可以取一个TicTacToeBoard
,应用一个移动,返回新板。为了简单起见,我将使用局部变异来实现它,如下所示:
auto makeMove = [](const TicTacToeBoard board, const Move move) ->
TicTacToeBoard {
TicTacToeBoard nextBoard(board);
nextBoard[move.xCoord][move.yCoord] = move.token;
return nextBoard;
};
我们还需要一块空板来初始化我们的State
。我们就用手填充Token::Blank
吧:
const TicTacToeBoard EmptyBoard{
{Token::Blank,Token::Blank, Token::Blank},
{Token::Blank,Token::Blank, Token::Blank},
{Token::Blank,Token::Blank, Token::Blank}
};
我们想迈出第一步。但是,我们的makeMove
函数没有State
结构允许的签名;它需要一个额外的参数,Move
。对于第一个测试,我们可以将Move
参数绑定到硬编码值。假设X
移动到左上角,坐标 (0,0) :
TEST_CASE("TicTacToe compute next board after a move"){
Move firstMove{Token::X, 0, 0};
const function<TicTacToeBoard(const TicTacToeBoard)> makeFirstMove
= bind(makeMove, _1, firstMove);
const auto emptyBoardState = State<TicTacToeBoard>{EmptyBoard,
makeFirstMove };
CHECK_EQ(Token::Blank, emptyBoardState.value[0][0]);
const auto boardStateAfterFirstMove = emptyBoardState.nextState();
CHECK_EQ(Token::X, boardStateAfterFirstMove.value[0][0]);
}
如您所见,我们的State
结构在这种情况下运行良好。然而,它有一个限制:它只允许一次移动。问题是计算下一阶段的函数不能改变。但是如果我们将它作为参数传递给nextState()
函数呢?我们最终有了一个新的结构;姑且称之为StateEvolved
。它保存一个值和一个nextState()
函数,该函数接受计算下一个状态的函数,应用它,并返回下一个StateEvolved
:
template<typename ValueType>
struct StateEvolved{
const ValueType value;
StateEvolved<ValueType> nextState(function<ValueType(ValueType)>
computeNext) const{
return StateEvolved<ValueType>{computeNext(value)};
};
};
我们现在可以通过进入nextState``makeMove
函数进行移动,其中Move
参数绑定到实际移动:
TEST_CASE("TicTacToe compute next board after a move with
StateEvolved"){
const auto emptyBoardState = StateEvolved<TicTacToeBoard>
{EmptyBoard};
CHECK_EQ(Token::Blank, emptyBoardState.value[0][0]);
auto xMove = bind(makeMove, _1, Move{Token::X, 0, 0});
const auto boardStateAfterFirstMove =
emptyBoardState.nextState(xMove);
CHECK_EQ(Token::X, boardStateAfterFirstMove.value[0][0]);
}
我们现在可以做第二步了。假设O
在中心移动到坐标 (1,1) 。让我们检查前后状态:
auto oMove = bind(makeMove, _1, Move{Token::O, 1, 1});
const auto boardStateAfterSecondMove =
boardStateAfterFirstMove.nextState(oMove);
CHECK_EQ(Token::Blank, boardStateAfterFirstMove.value[1][1]);
CHECK_EQ(Token::O, boardStateAfterSecondMove.value[1][1]);
如您所见,使用这种模式,我们可以以不可变的方式存储任何状态。
我们之前讨论的设计模式对于函数式编程来说似乎非常有用,但是您可能已经意识到我已经避免给它命名了。
事实上,我们到目前为止讨论的模式是一个单子的例子,特别是State
单子。我一直避免告诉你它的名字,因为单子在软件开发中是一个特别不透明的话题。为了这本书,我在单子上看了几个小时的视频;我也看过博客和文章,出于某种原因,它们都不可理解。由于单子是范畴理论的数学对象,我提到的一些资源采用数学方法,并使用定义和运算符来解释它们。其他资源试图通过例子来解释,但是它们是用对 monad 模式有本地支持的编程语言编写的。它们都不符合我们本书的目标——一种处理复杂概念的实用方法。
为了更好地理解单子,我们需要看更多的例子。最简单的可能是Maybe
单子。
考虑尝试在 C++ 中计算如下表达式:
2 + (3/0) * 5
可能会发生什么?通常情况下,会抛出一个异常,因为我们试图除以0
。但是,在某些情况下,我们希望看到一个值,如None
或NaN
,或某种信息。我们已经看到,我们可以使用optional<int>
来存储可能是整数或值的数据;因此,我们可以实现一个返回optional<int>
的除法函数,如下所示:
function<optional<int>(const int, const int)> divideEvenWith0 = []
(const int first, const int second) -> optional<int>{
return (second == 0) ? nullopt : make_optional(first / second);
};
然而,当我们试图在一个表达式中使用divideEvenWith0
时,我们意识到我们也需要改变所有其他的运算符。例如,我们可以实现一个plusOptional
函数,当任一参数为nullopt
时返回nullopt
,否则返回值,如下例所示:
auto plusOptional = [](optional<int> first, optional<int> second) -
> optional<int>{
return (first == nullopt || second == nullopt) ?
nullopt :
make_optional(first.value() + second.value());
};
虽然它可以工作,但这需要编写更多的函数和大量的重复。但是,嘿,我们能不能写一个函数,把一个function<int(int, int)>
变成一个function<optional<int>(optional<int>, optional<int>)>
?当然,让我们按如下方式编写函数:
auto makeOptional = [](const function<int(int, int)> operation){
return [operation](const optional<int> first, const
optional<int> second) -> optional<int>{
if(first == nullopt || second == nullopt) return nullopt;
return make_optional(operation(first.value(),
second.value()));
};
};
这很好,如以下通过的测试所示:
auto plusOptional = makeOptional(plus<int>());
auto divideOptional = makeOptional(divides<int>());
CHECK_EQ(optional{3}, plusOptional(optional{1}, optional{2}));
CHECK_EQ(nullopt, plusOptional(nullopt, optional{2}));
CHECK_EQ(optional{2}, divideOptional(optional{2}, optional{1}));
CHECK_EQ(nullopt, divideOptional(nullopt, optional{1}));
然而,这并不能解决一个问题——我们在除以0
时仍然需要返回nullopt
。因此,以下测试将失败,如下所示:
// CHECK_EQ(nullopt, divideOptional(optional{2}, optional{0}));
// cout << "Result of 2 / 0 = " << to_string(divideOptional
(optional{2}, optional{0})) << endl;
我们可以用自己的divideEvenBy0
方法代替标准除法来解决这个问题:
function<optional<int>(const int, const int)> divideEvenWith0 = []
(const int first, const int second) -> optional<int>{
return (second == 0) ? nullopt : make_optional(first / second);
};
这一次,测试通过,如下所示:
auto divideOptional = makeOptional(divideEvenWith0);
CHECK_EQ(nullopt, divideOptional(optional{2}, optional{0}));
cout << "Result of 2 / 0 = " << to_string(divideOptional
(optional{2}, optional{0})) << endl;
此外,运行测试后的显示如下所示:
Result of 2 / 0 = None
我不得不说,逃避除以0
的暴政而得到结果,有一种奇怪的满足感。也许那只是我。
不管怎样,这就引出了Maybe
单子的定义。它存储一个值和一个名为apply
的函数。apply
函数接受一个操作(plus<int>()
、minus<int>()
、divideEvenWith0
或multiplies<int>()
)和我们应用该操作的第二个值,并返回结果:
template<typename ValueType>
struct Maybe{
typedef function<optional<ValueType>(const ValueType, const
ValueType)> OperationType;
const optional<ValueType> value;
optional<ValueType> apply(const OperationType operation, const
optional<ValueType> second){
if(value == nullopt || second == nullopt) return nullopt;
return operation(value.value(), second.value());
}
};
我们可以使用Maybe
单子进行如下计算:
TEST_CASE("Compute with Maybe monad"){
function<optional<int>(const int, const int)> divideEvenWith0 = []
(const int first, const int second) -> optional<int>{
return (second == 0) ? nullopt : make_optional(first / second);
};
CHECK_EQ(3, Maybe<int>{1}.apply(plus<int>(), 2));
CHECK_EQ(nullopt, Maybe<int>{nullopt}.apply(plus<int>(), 2));
CHECK_EQ(nullopt, Maybe<int>{1}.apply(plus<int>(), nullopt));
CHECK_EQ(2, Maybe<int>{2}.apply(divideEvenWith0, 1));
CHECK_EQ(nullopt, Maybe<int>{nullopt}.apply(divideEvenWith0, 1));
CHECK_EQ(nullopt, Maybe<int>{2}.apply(divideEvenWith0, nullopt));
CHECK_EQ(nullopt, Maybe<int>{2}.apply(divideEvenWith0, 0));
cout << "Result of 2 / 0 = " << to_string(Maybe<int>
{2}.apply(divideEvenWith0, 0)) << endl;
}
再一次,我们可以计算表达式,即使使用nullopt
。
A monad 是一种模拟计算的功能设计模式。它来自数学;更准确地说,来自名为范畴论的领域。
什么是计算?一个基本的计算是一个函数;然而,我们对在函数中添加更多的行为感兴趣。我们已经看到了两个用可选类型维护状态和允许操作的例子,但是单子在软件设计中非常普遍。
一个单子基本上有一个值和一个高阶函数。为了理解它们的作用,让我们比较一下下面代码中显示的State
单子:
template<typename ValueType>
struct StateEvolved{
const ValueType value;
StateEvolved<ValueType> nextState(function<ValueType(ValueType)>
computeNext) const{
return StateEvolved<ValueType>{computeNext(value)};
};
};
这里显示的Maybe
单子:
template<typename ValueType>
struct Maybe{
typedef function<optional<ValueType>(const ValueType, const
ValueType)> OperationType;
const optional<ValueType> value;
optional<ValueType> apply(const OperationType operation, const
optional<ValueType> second) const {
if(value == nullopt || second == nullopt) return nullopt;
return operation(value.value(), second.value());
}
};
它们都有价值。该值封装在 monad 结构中。它们都持有一个对该值进行计算的函数。apply
/ nextState
(文献中称为bind
)函数本身接收一个封装计算的函数;然而,单子除了计算之外还做一些事情。
单子不仅仅是这些简单的例子。然而,它们展示了如何封装某些计算以及如何移除某些类型的重复。
值得注意的是,C++ 中的optional<>
类型实际上是受到了Maybe
monad 的启发,以及承诺,所以您可能已经在代码中使用了等待被发现的 monad。
我们在这一章学到了很多东西,都是围绕着改进设计。我们了解到重构意味着在不改变程序外部行为的情况下重构代码。我们看到,为了确保行为的保存,我们需要进行非常小的步骤和测试。我们了解到遗留代码是我们害怕更改的代码,为了编写测试,我们需要首先更改代码,这导致了一个困境。我们还了解到,幸运的是,我们可以在代码中进行一些小的更改,这些更改可以保证保留行为,但会破坏依赖性,从而允许我们用测试插入代码。我们看到,我们可以使用纯函数来识别和打破依赖关系,从而产生 lambdas,我们可以根据内聚性将 lamb das 重组为类。
最后,我们了解到我们可以在函数式编程中使用设计模式,我们看到了几个例子。即使您不使用函数式编程中的任何其他东西,使用诸如策略、命令或注入的依赖项这样的函数将使您的代码更容易更改,而不会有太多麻烦。我们触及了一个非常抽象的设计模式,单子,我们看到了如何使用Maybe
单子和State
单子。这两者都有助于我们用更丰富的功能编写更少的代码。
我们已经讨论了很多关于软件设计的问题。但是函数式编程适用于架构吗?这就是我们将在下一章中访问的内容——事件源。