在我理解函数式编程的旅程中,我遇到了一个困难的障碍——我的思维被训练成一种完全不同的编程风格。让我们称之为命令式面向对象编程。那么,我该如何将思维模式从对象思维转变为功能思维呢?我怎样才能把这两者很好地结合起来呢?
我首先研究了函数式编程资源。不幸的是,他们中的大多数人都专注于数学和概念的内在美——这对任何已经能用这些术语思考的人来说都是很棒的。但是如果你只是想学习它们呢?复习数学理论是学习的唯一途径吗?虽然我喜欢数学,但我对它已经生疏了,我宁愿找到更实用的方法。
然后,我接触了各种通过 Coderetreats、Coding Dojos 等事件编写代码的方法,或者与来自欧洲各地的程序员进行结对编程。我逐渐意识到,有一种简单的方法可以解决这个问题——只关注输入和输出,而不是关注它们之间的模型。这是一种更具体、更实用的学习函数思维的方法,这也是我们接下来要探索的。
本章将涵盖以下主题:
- 功能思维的基础
- 重新学习如何识别特征的数据输入和数据输出,并利用类型推断
- 将数据转换定义为纯函数
- 如何使用典型的数据转换,如映射、简化、过滤等
- 如何用功能思维解决问题
- 为围绕函数设计的代码设计错误管理
您将需要一个支持 C++ 17 的编译器。我用的是 GCC 7.3.0。
代码可以在网站上的Chapter06文件夹中找到。它包括并使用doctest,这是一个单头开源单元测试库。你可以在 https://github.com/onqtam/doctest的 GitHub 储存库中找到它。
我的计算机编程教育,以及我作为程序员的重点,主要是写代码,而不是深入理解输入和输出数据。当我学习测试驱动开发 ( TDD )时,这个焦点改变了,因为这个实践迫使程序员从输入和输出开始。通过应用一种叫做 TDD 的极端形式,我对程序的核心定义有了新的理解——它接受输入数据并返回输出数据。
不过,这并不容易。我的训练促使我重新思考构成这个项目的东西。但是后来,我意识到那些东西只能是纯粹的功能。毕竟,任何程序都可以写成如下形式:
- 一组纯函数,如前所述
- 一组与输入/输出 ( 输入/输出)交互的功能
如果我们把程序减少到最小,把所有的输入输出分开,计算出程序其余部分的输入输出,并尽可能编写纯函数,我们就完成了用函数思考的第一步。
下一个问题是——这些功能应该是什么?在本章中,我们将探讨使用函数进行设计的最简单方法:
- 从中的数据开始。
- 定义数据输出。
- 定义一系列转换(纯函数),将数据一步一步地转换成数据。
让我们看几个例子来对比两种编写程序的方法。
为了显示方法之间的差异,我们需要使用一个问题。我喜欢用游戏中的问题来练习新的编程技巧。一方面,这是一个我不常接触的有趣领域。另一方面,游戏提供了许多普通商业应用所没有的挑战,从而让我们可以探索新的想法。
在接下来的部分,我们将看一个让人们学会如何在函数中开始思考的问题——井字游戏结果 问题。
井字游戏结果问题有以下要求——给定一个空的或者已经有招式的井字游戏板,打印出游戏结果,如果游戏已经结束,或者打印出还在进行中的游戏。
看起来这个问题相当简单,但是它将向我们展示函数式和命令式面向对象 ( OO )方法之间的根本区别。
如果我们从面向对象的角度来处理这个问题,我们已经在考虑一些要定义的对象——一个游戏、一个玩家、一个棋盘,也许还有一些X和O(我称之为代币)的表示,等等。然后,我们可能会看到如何连接这些对象——一个游戏有两个玩家和一个棋盘,棋盘上有代币或空字段等等。正如你所看到的,这涉及到很多代表性。然后,我们需要在某个地方实现一个返回GameState的computeResult方法,要么是XWon、OWon、draw,要么是InProgress。乍一看computeResult好像很适合Game类。该方法可能需要在Board内部循环,使用一些条件语句,并返回相应的GameState。
我们将使用一些严格的步骤来帮助我们对代码结构进行不同的思考,而不是使用面向对象的方法:
- 明确定义输入;举个例子。
- 明确定义输出;举个例子。
- 确定一系列可以应用于输入数据的功能转换,以将其转化为输出数据。
在我们继续之前,请注意这种心态的改变需要一点知识和实践。我们将研究最常见的转换,为您提供一个良好的开端,但是您需要自己尝试这种方法。
作为程序员,我们学到的第一课是任何程序都有输入和输出。然后,我们继续关注代码本身中输入和输出之间发生的事情。
然而,输入和输出应该得到程序员更多的关注,因为它们定义了我们软件的需求。我们知道,软件中最大的浪费是实现了一些完美的工作,但没有做它应该做的事情。
我注意到程序员很难回到输入输出的角度去思考。给定特性的输入和输出应该是什么,这个看似简单的问题常常让他们困惑不解。所以,让我们详细看看问题的输入和输出数据。
在这一点上,我们会做一些意想不到的事情。我从业务分析师那里学到了一个巧妙的技巧——在分析一个特性时,最好从输出开始,因为输出往往比输入数据更小、更清晰。所以,我们开始吧。
我们期望产出是什么?考虑到董事会可以有任何东西,或者什么都没有,我们正在考虑以下可能性:
- 游戏未开始
- 游戏进行中
X赢了O赢了- 画
看,输出很简单!现在,我们可以看到输入数据是如何与这些可能性相关联的。
在这种情况下,输入数据在问题陈述中——我们的输入是一个上面有移动的板。但是让我们看一些例子。最简单的例子是一块空板子:
_ _ _
_ _ _
_ _ _为了清楚起见,我们用_来表示棋盘上的一个空位。
空棋盘当然对应于游戏未开始输出。
这很简单。现在,让我们看一个有一些动作的:
X _ _
O _ _
_ _ _X和O都已经出手了,但是游戏还在进行中。我们可以提供许多正在进行的游戏的例子:
X X _
O _ _
_ _ _这里还有一个例子:
X X O
O _ _
_ _ _有几个在井字游戏中永远不会发生的例子,比如这个:
X X _
O X _
X _ _在这种情况下,X走了四步,O只走了一步,这是井字游戏规则不允许的。我们暂时忽略这种情况,只返回一个正在进行的游戏。但是,一旦我们完成了代码的剩余部分,您就可以为此实现自己的算法。
来看看X赢的一局:
X X X
O O _
_ _ _X获胜是因为第一行被填满。X还有其他赢的方法吗?是的,在一个专栏上:
X _ _
X O O
X _ _它也可能在主对角线上获胜:
X O _
O X _
_ _ X第二条对角线上X赢了:
_ O X
O X _
X _ _类似地,我们有这样的例子O通过填充一行获胜:
X X _
O O O
X _ _这里有一个填充栏的胜利:
X O _
X O X
_ O _以下是O的主对角线获胜:
O X _
_ O X
X _ O这是通过第二对角线获得的胜利:
X X O
_ O X
O _ _以平局告终的比赛怎么样?这很简单——所有的方块都填满了,但是没有赢家:
X X O
O X X
X O O我们已经查看了所有可能输出的示例。现在,是时候看看数据转换了。
如何将输入转化为输出?为此,我们必须首先选择一个可能的输出来处理。目前最简单的是X赢的情况。那么,X怎么才能赢呢?
根据游戏规则,如果棋盘上的一条线、一列或一条对角线被X填满,则X获胜。让我们写下所有可能的情况。X如果发生以下任一情况,则获胜:
- 任何一行都用
X或填充 - 任何一列都用
X或填充 - 主对角线用
XOR 填充 - 次对角线填充
X
要实现这一点,我们需要一些东西:
- 把黑板上所有的线都拿过来。
- 从黑板上取下所有的柱子。
- 从板子上取下主对角线和次对角线。
- 如果其中任何一个填了
X,X赢了!
我们可以用另一种方式来写:
board -> collection(all lines, all columns, all diagonals) -> any(collection, filledWithX) -> X wonfilledWithX是什么意思?我们举个例子;我们正在寻找这样的线路:
X X X我们不是在找X O X或X _ X这样的线。
听起来我们在检查线、列或对角线上的所有标记是否都是'X'。让我们把这个检查想象成一个转换:
line | column | diagonal -> all tokens equal X -> line | column | diagonal filled with X所以,我们的一系列转换变成了这样:
board -> collection(all lines, all columns, all diagonals) -> if any(collection, filledWithX) -> X won
filledWithX(line|column|diagonal L) = all(token on L equals 'X')还有一个问题——我们怎样才能得到线、柱和对角线?我们可以分开来看这个问题,就像我们看大问题一样。我们的投入绝对是板子。我们的输出是由第一行、第二行和第三行、第一列、第二列和第三列、主对角线和次对角线组成的列表。
下一个问题是,什么定义了一条线?嗯,我们知道如何获得第一条线——我们使用[0, 0]、[0, 1]和[0, 2]坐标。第二行有[1, 0]、[1, 1]和[1, 2]坐标。专栏怎么样?嗯,第一列有[1, 0]、[1, 1]和[2, 1]坐标。正如我们将看到的,对角线也是由特定的坐标集定义的。
那么,我们学到了什么?我们了解到,要获得线条、列和对角线,我们需要以下转换:
board -> collection of coordinates for lines, columns, diagonals -> apply coordinates to the board -> obtain list of elements for lines, columns, and diagonals我们的分析到此结束。是时候开始实施了。前面所有的转换都可以通过函数构造用代码来表达。事实上,有些转换非常常见,已经在标准库中实现了。让我们看看如何使用它们!
我们要看的第一个变换是all_of。给定一个集合和一个返回布尔值(也称为逻辑谓词)的函数,all_of将谓词应用于集合的每个元素,并返回结果之间的逻辑与。让我们看几个例子:
auto trueForAll = [](auto x) { return true; };
auto falseForAll = [](auto x) { return false; };
auto equalsChara = [](auto x){ return x == 'a';};
auto notChard = [](auto x){ return x != 'd';};
TEST_CASE("all_of"){
vector<char> abc{'a', 'b', 'c'};
CHECK(all_of(abc.begin(), abc.end(), trueForAll));
CHECK(!all_of(abc.begin(), abc.end(), falseForAll));
CHECK(!all_of(abc.begin(), abc.end(), equalsChara));
CHECK(all_of(abc.begin(), abc.end(), notChard));
}all_of函数使用两个迭代器定义一个范围的开始和结束以及一个谓词作为参数。当您想要将转换应用于集合的子集时,迭代器非常有用。因为我通常在全本上使用它,所以我觉得反复写collection.begin()和collection.end()很烦人。因此,我实现了自己的简化all_of_collection版本,该版本负责整个集合并处理其余部分:
auto all_of_collection = [](const auto& collection, auto lambda){
return all_of(collection.begin(), collection.end(), lambda);
};
TEST_CASE("all_of_collection"){
vector<char> abc{'a', 'b', 'c'};
CHECK(all_of_collection(abc, trueForAll));
CHECK(!all_of_collection(abc, falseForAll));
CHECK(!all_of_collection(abc, equalsChara));
CHECK(all_of_collection(abc, notChard));
}知道了这个转换,就很容易编写我们的lineFilledWithX函数——我们将令牌集合转换成布尔集合,指定令牌是否为X:
auto lineFilledWithX = [](const auto& line){
return all_of_collection(line, [](const auto& token){ return token == 'X';});
};
TEST_CASE("Line filled with X"){
vector<char> line{'X', 'X', 'X'};
CHECK(lineFilledWithX(line));
}就这样!我们可以确定我们的线路是否充满X。
在继续之前,让我们做一些简单的调整。首先,让我们通过命名我们的vector<char>类型来使代码更加清晰:
using Line = vector<char>;然后,让我们检查代码是否也适用于负面场景。如果Line没有装满X令牌,lineFilledWithX应该返回false:
TEST_CASE("Line not filled with X"){
CHECK(!lineFilledWithX(Line{'X', 'O', 'X'}));
CHECK(!lineFilledWithX(Line{'X', ' ', 'X'}));
}最后,精明的读者会注意到,对于O获胜条件,我们将需要相同的函数。我们现在知道如何做到这一点——记住参数绑定的力量。我们只需要提取一个lineFilledWith函数,通过将tokenToCheck参数分别绑定到X和O标记值来获取lineFilledWithX和lineFilledWithO函数:
auto lineFilledWith = [](const auto line, const auto tokenToCheck){
return all_of_collection(line, [&tokenToCheck](const auto token){
return token == tokenToCheck;});
};
auto lineFilledWithX = bind(lineFilledWith, _1, 'X');
auto lineFilledWithO = bind(lineFilledWith, _1, 'O');让我们回顾一下——我们有一个Line数据结构,并且我们有一个可以检查该行是被X还是O填充的函数。我们用all_of功能为我们做了举重;我们只需要定义我们井字游戏的逻辑。
是时候向前看了。我们需要把我们的棋盘变成一个线条集合,由三条线、三列和两条对角线组成。为此,我们需要访问另一个功能转换,map,它作为transform功能在标准模板库 ( STL )中实现。
我们现在需要写一个函数,把板子变成一个行、列和对角线的列表;因此,我们可以使用一个转换,将一个集合转换成另一个集合。这种转换在一般函数式编程中称为map,在 STL 中实现为transform。为了理解它,我们将使用一个简单的例子;给定一个字符向量,让我们用'a'替换每个字符:
TEST_CASE("transform"){
vector<char> abc{'a', 'b', 'c'};
// Not the best version, see below
vector<char> aaa(3);
transform(abc.begin(), abc.end(), aaa.begin(), [](auto element){return
'a';});
CHECK_EQ(vector<char>{'a', 'a', 'a'}, aaa);
}虽然它是有效的,但是前面的代码示例是幼稚的,因为它用随后被覆盖的值来初始化aaa向量。我们可以通过首先在aaa向量中保留3元素,然后使用back_inserter来避免这个问题,这样transform会自动调用aaa向量上的push_back:
TEST_CASE("transform-fixed") {
const auto abc = vector{'a', 'b', 'c'};
vector<char> aaa;
aaa.reserve(abc.size());
transform(abc.begin(), abc.end(), back_inserter(aaa),
[](const char elem) { return 'a'; }
);
CHECK_EQ(vector{'a', 'a', 'a'}, aaa);
}如您所见,transform基于迭代器的工作方式与all_of相同。到现在,你会注意到我喜欢保持简单,专注于我们正在努力完成的事情。没有必要一直写这个;相反,我们可以实现我们自己的简化版本,在一个完整的集合上工作,并处理围绕这个功能的所有仪式。
让我们尝试以最简单的方式实现transform_all功能:
auto transform_all = [](auto const source, auto lambda){
auto destination; // Compilation error: the type is not defined
...
}不幸的是,当我们试图以这种方式实现它时,我们遇到了一个障碍——我们需要一个目标集合的类型。这样做的自然方式是使用 C++ 模板并传入Destination类型参数:
template<typename Destination>
auto transformAll = [](auto const source, auto lambda){
Destination result;
result.reserve(source.size());
transform(source.begin(), source.end(), back_inserter(result),
lambda);
return result;
};
这对于任何具有push_back功能的集合都很有效。一个不错的副作用是我们可以用它来连接string中的结果字符:
auto turnAllToa = [](auto x) { return 'a';};
TEST_CASE("transform all"){
vector abc{'a', 'b', 'c'};
CHECK_EQ(vector<char>({'a', 'a', 'a'}), transform_all<vector<char>>
(abc, turnAllToa));
CHECK_EQ("aaa", transform_all<string>(abc,turnAllToa));
}使用transform_all和string可以让我们做一些事情,比如把小写字符变成大写字符:
auto makeCaps = [](auto x) { return toupper(x);};
TEST_CASE("transform all"){
vector<char> abc = {'a', 'b', 'c'};
CHECK_EQ("ABC", transform_all<string>(abc, makeCaps));
}但这还不是全部——输出类型不必与输入相同:
auto toNumber = [](auto x) { return (int)x - 'a' + 1;};
TEST_CASE("transform all"){
vector<char> abc = {'a', 'b', 'c'};
vector<int> expected = {1, 2, 3};
CHECK_EQ(expected, transform_all<vector<int>>(abc, toNumber));
}因此,每当我们需要将一个集合转换成另一个集合时,transform函数非常有用,无论是相同类型还是不同类型。在back_inserter的支持下,它还可以用于string输出,从而实现任何类型集合的字符串表示。
我们现在知道如何使用转换。所以,让我们回到我们的问题。
我们的转换从计算坐标开始。所以,让我们先定义它们。STL pair类型是坐标的简单表示:
using Coordinate = pair<int, int>;假设我们为一条线、一列或一条对角线建立了坐标列表,我们需要将代币集合转换为Line参数。这可以通过我们的transformAll功能轻松实现:
auto accessAtCoordinates = [](const auto& board, const Coordinate&
coordinate){
return board[coordinate.first][coordinate.second];
};
auto projectCoordinates = [](const auto& board, const auto&
coordinates){
auto boardElementFromCoordinates = bind(accessAtCoordinates,
board, _1);
return transform_all<Line>(coordinates,
boardElementFromCoordinates);
};projectCoordinatesλ取棋盘和一个坐标列表,并从棋盘返回对应于这些坐标的元素列表。我们在坐标列表中使用transformAll,以及一个包含两个参数的变换——一个board参数和一个coordinate参数。然而,transformAll需要一个带有单个参数的λ,Coordinate值。因此,我们要么获取电路板的价值,要么使用部分应用。
我们现在只需要为线、柱和对角线建立我们的坐标列表!
使用前面的函数projectCoordinates,我们可以很容易地从板子上得到一条线:
auto line = [](auto board, int lineIndex){
return projectCoordinates(board, lineCoordinates(board, lineIndex));
};lineλ取board和lineIndex,建立线坐标表,用projectCoordinates回线。
那么,我们如何建立线坐标呢?好吧,既然我们有lineIndex和Coordinate一对,我们需要在(lineIndex, 0)、在(lineIndex, 1)和在(lineIndex, 2)上呼叫make_pair。这看起来也像是transform呼叫;输入为{0, 1, 2}集合,变换为make_pair(lineIndex, index)。让我们写下来:
auto lineCoordinates = [](const auto board, auto lineIndex){
vector<int> range{0, 1, 2};
return transformAll<vector<Coordinate>>(range, [lineIndex](auto
index){return make_pair(lineIndex, index);});
};但是{0, 1, 2}是什么呢?在其他编程语言中,我们可以使用范围的概念;例如,在 Groovy 中,我们可以编写以下内容:
def range = [0..board.size()]范围非常有用,并且在 C++ 20 标准中采用了它们。我们将在第 14 章、使用范围库的延迟求值中讨论它们。在此之前,我们将编写自己的函数,toRange:
auto toRange = [](auto const collection){
vector<int> range(collection.size());
iota(begin(range), end(range), 0);
return range;
};toRange以集合为输入,创建从0到collection.size()的range。所以,让我们在代码中使用它:
using Board = vector<Line>;
using Line = vector<char>;
auto lineCoordinates = [](const auto board, auto lineIndex){
auto range = toRange(board);
return transform_all<vector<Coordinate>>(range, [lineIndex](auto
index){return make_pair(lineIndex, index);});
};
TEST_CASE("lines"){
Board board {
{'X', 'X', 'X'},
{' ', 'O', ' '},
{' ', ' ', 'O'}
};
Line expectedLine0 = {'X', 'X', 'X'};
CHECK_EQ(expectedLine0, line(board, 0));
Line expectedLine1 = {' ', 'O', ' '};
CHECK_EQ(expectedLine1, line(board, 1));
Line expectedLine2 = {' ', ' ', 'O'};
CHECK_EQ(expectedLine2, line(board, 2));
}我们已经准备好了所有的元素,所以是时候看看这些列了。
获取列的代码与获取行的代码非常相似,只是我们保留了columnIndex而不是lineIndex。我们只需要将它作为参数传递:
auto columnCoordinates = [](const auto& board, const auto columnIndex){
auto range = toRange(board);
return transformAll<vector<Coordinate>>(range, [columnIndex](const
auto index){return make_pair(index, columnIndex);});
};
auto column = [](auto board, auto columnIndex){
return projectCoordinates(board, columnCoordinates(board,
columnIndex));
};
TEST_CASE("all columns"){
Board board{
{'X', 'X', 'X'},
{' ', 'O', ' '},
{' ', ' ', 'O'}
};
Line expectedColumn0{'X', ' ', ' '};
CHECK_EQ(expectedColumn0, column(board, 0));
Line expectedColumn1{'X', 'O', ' '};
CHECK_EQ(expectedColumn1, column(board, 1));
Line expectedColumn2{'X', ' ', 'O'};
CHECK_EQ(expectedColumn2, column(board, 2));
}很酷吧?有了一些函数,在标准函数转换的帮助下,我们可以在代码中构建复杂的行为。对角线现在轻而易举。
主对角线由相等的线和列坐标定义。使用与以前相同的机制来阅读它相当容易;我们构建相等的指数对,并将它们传递给projectCoordinates函数:
auto mainDiagonalCoordinates = [](const auto board){
auto range = toRange(board);
return transformAll<vector<Coordinate>>(range, [](auto index)
{return make_pair(index, index);});
};
auto mainDiagonal = [](const auto board){
return projectCoordinates(board, mainDiagonalCoordinates(board));
};
TEST_CASE("main diagonal"){
Board board{
{'X', 'X', 'X'},
{' ', 'O', ' '},
{' ', ' ', 'O'}
};
Line expectedDiagonal = {'X', 'O', 'O'};
CHECK_EQ(expectedDiagonal, mainDiagonal(board));
}二次对角线呢?嗯,坐标之和总是等于board参数的大小。在 C++ 中,我们还需要考虑基于 0 的索引,所以在构建坐标列表时,我们需要通过1进行适当的调整:
auto secondaryDiagonalCoordinates = [](const auto board){
auto range = toRange(board);
return transformAll<vector<Coordinate>>(range, [board](auto index)
{return make_pair(index, board.size() - index - 1);});
};
auto secondaryDiagonal = [](const auto board){
return projectCoordinates(board,
secondaryDiagonalCoordinates(board));
};
TEST_CASE("secondary diagonal"){
Board board{
{'X', 'X', 'X'},
{' ', 'O', ' '},
{' ', ' ', 'O'}
};
Line expectedDiagonal{'X', 'O', ' '};
CHECK_EQ(expectedDiagonal, secondaryDiagonal(board));
}说到这里,我们现在可以建立一个所有线、列和对角线的集合。有多种方法可以做到这一点;既然我要找一个用函数式风格写的通用解决方案,我就再用一次transform。我们需要将(0..board.size())范围分别转换为行列表和列列表。然后,我们需要返回一个包含主对角线和次对角线的集合:
typedef vector<Line> Lines;
auto allLines = [](auto board) {
auto range = toRange(board);
return transform_all<Lines>(range, [board](auto index) { return
line(board, index);});
};
auto allColumns = [](auto board) {
auto range = toRange(board);
return transform_all<Lines>(range, [board](auto index) { return
column(board, index);});
};
auto allDiagonals = [](auto board) -> Lines {
return {mainDiagonal(board), secondaryDiagonal(board)};
};我们只需要一件事——一种连接三个集合的方法。由于向量没有实现这一点,推荐的解决方案是使用insert和move_iterator,从而在第一个集合的末尾移动第二个集合中的项目:
auto concatenate = [](auto first, const auto second){
auto result(first);
result.insert(result.end(), make_move_iterator(second.begin()),
make_move_iterator(second.end()));
return result;
};
然后,我们将这三个集合合并为两个步骤:
auto concatenate3 = [](auto first, auto const second, auto const third){
return concatenate(concatenate(first, second), third);
};我们现在可以从板上获取线、列和对角线的完整列表,如您在以下测试中所见:
auto allLinesColumnsAndDiagonals = [](const auto board) {
return concatenate3(allLines(board), allColumns(board),
allDiagonals(board));
};
TEST_CASE("all lines, columns and diagonals"){
Board board {
{'X', 'X', 'X'},
{' ', 'O', ' '},
{' ', ' ', 'O'}
};
Lines expected {
{'X', 'X', 'X'},
{' ', 'O', ' '},
{' ', ' ', 'O'},
{'X', ' ', ' '},
{'X', 'O', ' '},
{'X', ' ', 'O'},
{'X', 'O', 'O'},
{'X', 'O', ' '}
};
auto all = allLinesColumnsAndDiagonals(board);
CHECK_EQ(expected, all);
}查明X是否获胜只剩下最后一步了。我们有所有线条、列和对角线的列表。我们知道如何检查一行是否充满X。我们只需要检查列表中是否有任何一行被X填充。
类似于all_of,另一个函数构造帮助我们表达应用于集合的谓词之间的或条件。在 STL 中,这个构造在any_of函数中实现。让我们看看它的实际效果:
TEST_CASE("any_of"){
vector<char> abc = {'a', 'b', 'c'};
CHECK(any_of(abc.begin(), abc.end(), trueForAll));
CHECK(!any_of(abc.begin(), abc.end(), falseForAll));
CHECK(any_of(abc.begin(), abc.end(), equalsChara));
CHECK(any_of(abc.begin(), abc.end(), notChard));
}像我们在本章中看到的其他高级函数一样,它在集合的开头和结尾使用迭代器。和往常一样,我喜欢把事情简单化;因为我通常在完整集合上使用any_of,所以我喜欢实现我的助手函数:
auto any_of_collection = [](const auto& collection, const auto& fn){
return any_of(collection.begin(), collection.end(), fn);
};
TEST_CASE("any_of_collection"){
vector<char> abc = {'a', 'b', 'c'};
CHECK(any_of_collection(abc, trueForAll));
CHECK(!any_of_collection(abc, falseForAll));
CHECK(any_of_collection(abc, equalsChara));
CHECK(any_of_collection(abc, notChard));
}我们只需要在我们的列表中使用它来检查X是否是赢家:
auto xWins = [](const auto& board){
return any_of_collection(allLinesColumnsAndDiagonals(board),
lineFilledWithX);
};
TEST_CASE("X wins"){
Board board{
{'X', 'X', 'X'},
{' ', 'O', ' '},
{' ', ' ', 'O'}
};
CHECK(xWins(board));
}这就是我们对X获胜条件的解决方案。在我们继续之前,最好能在控制台上显示该板。现在是时候使用map/transform——reduce的近亲了,或者,正如在 STL 中所知的那样,accumulate。
我们想在控制台上显示该板。通常情况下,我们会使用一个可变函数,比如cout来实现;然而,请记住我们是如何讨论的,虽然我们需要保持程序的某些部分可变,比如那些调用cout的部分,但我们应该将它们限制在最低限度。那么,还有什么选择呢?嗯,我们需要再次考虑输入和输出——我们想写一个函数,它以board为输入,并返回一个string表示,我们可以通过使用一个可变函数(如cout)来显示它。让我们以测试的形式写下我们想要的:
TEST_CASE("board to string"){
Board board{
{'X', 'X', 'X'},
{' ', 'O', ' '},
{' ', ' ', 'O'}
};
string expected = "XXX\n O \n O\n";
CHECK_EQ(expected, boardToString(board));
}为了获得这个结果,我们首先需要将每一行从board转换成它的string表示。我们的线路是vector<char>,我们需要把它变成string;虽然有很多方法可以做到这一点,但请允许我使用带有string输出的transformAll功能:
auto lineToString = [](const auto& line){
return transformAll<string>(line, [](const auto token) -> char {
return token;});
};
TEST_CASE("line to string"){
Line line {
' ', 'X', 'O'
};
CHECK_EQ(" XO", lineToString(line));
}写了这个函数,我们可以很容易的把一块板变成vector<string>:
auto boardToLinesString = [](const auto board){
return transformAll<vector<string>>(board, lineToString);
};
TEST_CASE("board to lines string"){
Board board{
{'X', 'X', 'X'},
{' ', 'O', ' '},
{' ', ' ', 'O'}
};
vector<string> expected{
"XXX",
" O ",
" O"
};
CHECK_EQ(expected, boardToLinesString(board));
}最后一步是将这些弦与它们之间的\n结合起来。我们经常需要以各种方式组合一个集合的元素;这就是reduce发挥作用的地方。在函数式编程中,reduce是取一个集合、一个初始值(例如,空strings)和一个累加函数的运算。该函数接受两个参数,对它们执行运算,并返回一个新值。
让我们看几个例子。首先,有一个添加数字向量的经典例子:
TEST_CASE("accumulate"){
vector<int> values = {1, 12, 23, 45};
auto add = [](int first, int second){return first + second;};
int result = accumulate(values.begin(), values.end(), 0, add);
CHECK_EQ(1 + 12 + 23 + 45, result);
}下面向我们展示了如果我们需要将向量与初始值相加,应该怎么做:
int resultWithInit100 = accumulate(values.begin(), values.end(),
100, add);
CHECK_EQ(1oo + 1 + 12 + 23 + 45, resultWithInit100);同样,我们可以串联strings:
vector<string> strings {"Alex", "is", "here"};
auto concatenate = [](const string& first, const string& second) ->
string{
return first + second;
};
string concatenated = accumulate(strings.begin(), strings.end(),
string(), concatenate);
CHECK_EQ("Alexishere", concatenated);或者,我们可以添加一个前缀:
string concatenatedWithPrefix = accumulate(strings.begin(),
strings.end(), string("Pre_"), concatenate);
CHECK_EQ("Pre_Alexishere", concatenatedWithPrefix);像往常一样,我更喜欢一个简化的实现,它可以在一个完整的集合上工作,并使用默认值作为初始值。有了一点decltype魔法,就很容易实现了:
auto accumulateAll = [](auto source, auto lambda){
return accumulate(source.begin(), source.end(), typename
decltype(source)::value_type(), lambda);
};这就给我们留下了一个任务——编写一个使用换行符组合string行的 concatenate 实现:
auto boardToString = [](const auto board){
auto linesAsString = boardToLinesString(board);
return accumulateAll(linesAsString,
[](string current, string lineAsString) { return current + lineAsString + "\n"; }
);
};
TEST_CASE("board to string"){
Board board{
{'X', 'X', 'X'},
{' ', 'O', ' '},
{' ', ' ', 'O'}
};
string expected = "XXX\n O \n O\n";
CHECK_EQ(expected, boardToString(board));
}我们现在可以使用cout << boardToString来显示我们的棋盘。再一次,我们使用了一些函数转换和很少的定制代码来把所有的东西放在一起。真不错。
map / reduce组合,或者说,如 STL 中所知的transform / accumulate组合,非常强大,在函数式编程中非常常见。我们经常需要从一个集合开始,多次将其转换为另一个集合,然后组合集合的元素。这是一个如此强大的概念,以至于它是大数据分析的核心,使用 Apache Hadoop 等工具,尽管是在机器级别上扩展的。这表明,通过掌握这些转换,你可能会在意想不到的情况下应用它们,使你成为一个不可或缺的问题解决者。很酷,不是吗?
我们很高兴我们已经解决了X的井字游戏结果问题。然而,一如既往,需求会改变;我们现在不仅要说X是否赢了,还要说怎么赢的——在哪条线上,还是列上,还是对角线上。
幸运的是,我们已经具备了大部分要素。因为它们是非常小的功能,我们只需要以一种有助于我们的方式重组它们。让我们从数据的角度再考虑一下——我们的输入数据现在是线条、列和对角线的集合;我们的结果应该是像X在第一线赢了这样的事情。我们只需要增强我们的数据结构,以包含每一行的信息;我们用map吧:
map<string, Line> linesWithDescription{
{"first line", line(board, 0)},
{"second line", line(board, 1)},
{"last line", line(board, 2)},
{"first column", column(board, 0)},
{"second column", column(board, 1)},
{"last column", column(board, 2)},
{"main diagonal", mainDiagonal(board)},
{"secondary diagonal", secondaryDiagonal(board)},
};我们知道如何找到X获胜的地方——通过我们的lineFilledWithX谓词函数。现在,我们只需要在地图中搜索符合lineFilledWithX谓词的行并返回相应的消息。
同样,这是函数式编程中常见的操作。在 STL 中,是用find_if函数实现的。让我们看看它的实际效果:
auto equals1 = [](auto value){ return value == 1; };
auto greaterThan11 = [](auto value) { return value > 11; };
auto greaterThan50 = [](auto value) { return value > 50; };
TEST_CASE("find if"){
vector<int> values{1, 12, 23, 45};
auto result1 = find_if(values.begin(), values.end(), equals1);
CHECK_EQ(*result1, 1);
auto result12 = find_if(values.begin(), values.end(),
greaterThan11);
CHECK_EQ(*result12, 12);
auto resultNotFound = find_if(values.begin(), values.end(),
greaterThan50);
CHECK_EQ(resultNotFound, values.end());
}find_if基于谓词查找集合,并返回一个指向结果的指针,如果没有找到,则返回一个指向end()迭代器的指针。
像往常一样,让我们实现允许在整个集合中进行搜索的包装器实现。我们需要用一种方式来表示not found值;幸运的是,我们可以使用 STL 中的可选类型:
auto findInCollection = [](const auto& collection, auto fn){
auto result = find_if(collection.begin(), collection.end(), fn);
return (result == collection.end()) ? nullopt : optional(*result);
};
TEST_CASE("find in collection"){
vector<int> values {1, 12, 23, 45};
auto result1 = findInCollection(values, equals1);
CHECK_EQ(result1, 1);
auto result12 = findInCollection(values, greaterThan11);
CHECK_EQ(result12, 12);
auto resultNotFound = findInCollection(values, greaterThan50);
CHECK(!resultNotFound.has_value());
}现在,我们可以轻松实现新的要求。使用我们新实现的findInCollection函数,可以找到用X填充的行,并返回相应的描述。因此,我们可以告诉用户X是如何获胜的——在线上、柱上或对角线上:
auto howDidXWin = [](const auto& board){
map<string, Line> linesWithDescription = {
{"first line", line(board, 0)},
{"second line", line(board, 1)},
{"last line", line(board, 2)},
{"first column", column(board, 0)},
{"second column", column(board, 1)},
{"last column", column(board, 2)},
{"main diagonal", mainDiagonal(board)},
{"secondary diagonal", secondaryDiagonal(board)},
};
auto found = findInCollection(linesWithDescription,[](auto value)
{return lineFilledWithX(value.second);});
return found.has_value() ? found->first : "X did not win";
};当然,我们应该从板子上生成地图,而不是硬编码。我将把这个练习留给读者;只需再次使用我们最喜欢的transform功能。
虽然我们已经实现了X获胜的解决方案,但是我们现在需要研究其他可能的输出。先拿最简单的一个吧——O赢了。
检查O是否获胜很容易——我们只需要对我们的功能进行一个小小的改变。我们需要一个新的函数oWins,它检查是否有任何一行、一列或一条对角线填充了O标记:
auto oWins = [](auto const board){
return any_of_collection(allLinesColumnsAndDiagonals(board),
lineFilledWithO);
};
TEST_CASE("O wins"){
Board board = {
{'X', 'O', 'X'},
{' ', 'O', ' '},
{' ', 'O', 'X'}
};
CHECK(oWins(board));
}我们使用与xWins相同的实现,只是作为参数传递的λ略有变化。
draw呢?嗯,当board参数满了,而X和O都没有赢的时候,就会出现平局:
auto draw = [](const auto& board){
return full(board) && !xWins(board) && !oWins(board);
};
TEST_CASE("draw"){
Board board {
{'X', 'O', 'X'},
{'O', 'O', 'X'},
{'X', 'X', 'O'}
};
CHECK(draw(board));
}全板是什么意思?意思是每一行都写满了:
auto full = [](const auto& board){
return all_of_collection(board, fullLine);
};我们如何知道线路是否已满?嗯,我们知道,如果该行中没有一个令牌是空的(' ')令牌,则该行是满的。正如您现在可能已经预料到的那样,STL 中有一个名为none_of的函数可以为我们检查这一点:
auto noneOf = [](const auto& collection, auto fn){
return none_of(collection.begin(), collection.end(), fn);
};
auto isEmpty = [](const auto token){return token == ' ';};
auto fullLine = [](const auto& line){
return noneOf(line, isEmpty);
};最后一种情况是游戏还在进行的时候。最简单的方法就是检查游戏没有赢,棋盘还没有满:
auto inProgress = [](const auto& board){
return !full(board) && !xWins(board) && !oWins(board);
};
TEST_CASE("in progress"){
Board board {
{'X', 'O', 'X'},
{'O', ' ', 'X'},
{'X', 'X', 'O'}
};
CHECK(inProgress(board));
}恭喜,我们成功了!我们已经使用许多函数转换实现了井字游戏结果问题;一些我们自己的小羊羔。但是,更重要的是,我们已经学会了如何作为一个函数式程序员开始思考——清楚地定义输入数据,清楚地定义输出数据,并弄清楚可以将输入数据转化为所需输出数据的转换。
到目前为止,我们已经有了一个用函数风格编写的小程序。但是错误案例呢?我们如何应对他们?
很明显,我们仍然可以使用 C++ 机制——返回值或异常。但是函数式编程也着眼于另一种方式——将错误视为数据。
当我们实现我们的find_if包装器时,我们已经看到了这个技术的一个例子:
auto findInCollection = [](const auto& collection, auto fn){
auto result = find_if(collection.begin(), collection.end(), fn);
return (result == collection.end()) ? nullopt : optional(*result);
};我们没有抛出异常或返回collection.end(),这是一个局部值,而是使用了optional类型。如其名称所述,可选类型表示一个变量,该变量可能有值,也可能没有值。可选值可以初始化,可以用基础类型支持的值,也可以用nullopt—可以说是默认的非值。
当在我们的代码中遇到一个可选值时,我们需要考虑它,就像我们在检查X如何获胜的函数中所做的那样:
return found.has_value() ? found->first : "X did not win";因此,没有发现的情况不是错误;相反,它是我们代码和数据的正常部分。实际上,处理这种情况的另一种方法是增强findInCollection以在没有发现任何东西时返回指定的值:
auto findInCollectionWithDefault = [](auto collection, auto
defaultResult, auto lambda){
auto result = findInCollection(collection, lambda);
return result.has_value() ? (*result) : defaultResult;
}; 我们现在可以使用findInCollectionWithDefault在X未获胜的棋盘上呼叫howDidXWin时获取X did not win信息:
auto howDidXWin = [](auto const board){
map<string, Line> linesWithDescription = {
{"first line", line(board, 0)},
{"second line", line(board, 1)},
{"last line", line(board, 2)},
{"first column", column(board, 0)},
{"second column", column(board, 1)},
{"last column", column(board, 2)},
{"main diagonal", mainDiagonal(board)},
{"secondary diagonal", secondaryDiagonal(board)},
{"diagonal", secondaryDiagonal(board)},
};
auto xDidNotWin = make_pair("X did not win", Line());
auto xWon = [](auto value){
return lineFilledWithX(value.second);
};
return findInCollectionWithDefault(linesWithDescription, xDidNotWin, xWon).first;
};
TEST_CASE("X did not win"){
Board board {
{'X', 'X', ' '},
{' ', 'O', ' '},
{' ', ' ', 'O'}
};
CHECK_EQ("X did not win", howDidXWin(board));
}我最好的建议是这样的——对所有异常情况使用异常,并使其他一切都成为数据结构的一部分。使用可选类型或带有默认值的转换。你会惊讶于错误管理变得如此简单和自然。
我们在这一章已经讲了很多内容!我们经历了一个发现之旅——我们从列出问题的输出和相应的输入开始,分解它们,并找出如何将输入转换为必需的输出。我们看到了小功能和功能操作如何在需要新功能时给我们带来灵活性。我们看到了如何使用any、all、none、find_if、map / transform、reduce / accumulate以及如何使用可选类型或默认值来支持代码中所有可能的情况。
既然我们已经知道了如何以函数式的方式编写代码,那么在下一章中就该看看这种方法是如何与 OO 编程相适应的了。