Skip to content

Latest commit

 

History

History
417 lines (287 loc) · 21.4 KB

File metadata and controls

417 lines (287 loc) · 21.4 KB

八、使用类提高内聚性

我们之前讨论过如何使用函数和函数上的操作来组织代码。然而,我们不能忽视过去几十年软件设计的流行范式——面向对象编程 ( OOP )。OOP 能和函数式编程一起工作吗?两者有没有兼容性,还是完全脱节?

事实证明,我们可以很容易地在类和函数之间进行转换。我通过我的朋友兼导师 J.B. Rainsberger 了解到,类只不过是一组部分应用的、内聚的纯函数。换句话说,我们可以使用类作为一个方便的位置来将内聚函数组合在一起。但是,为了做到这一点,我们需要理解高内聚原则,以及如何将函数转换成类,反之亦然。

本章将涵盖以下主题:

  • 理解函数式编程和面向对象编程之间的联系
  • 理解类如何等同于内聚的、部分应用的纯函数集
  • 理解高凝聚力的必要性
  • 如何将纯函数分组到类中
  • 如何将一个类拆分成纯函数

技术要求

您将需要一个支持 C++ 17 的编译器。我用的是 GCC 7.3.0。

代码可以在的 GitHub 上找到。com/ PacktPublishing/动手-函数-用- Cpp 编程Chapter08文件夹中的。它包含并使用了doctest,这是一个单头开源单元测试库。你可以在它的 GitHub 资源库中找到它。com/ onqtam/ doctest

使用类提高凝聚力

作为一名年轻的软件工程学生,我花了大量时间阅读面向对象程序设计。我试图理解面向对象是如何工作的,以及为什么它对现代软件开发如此重要。当时,大多数书籍都提到 OOP 是关于将代码组织成具有三个重要属性的类——封装、继承和多态。

将近 20 年后,我意识到这种面向对象的愿景是相当有限的。面向对象程序主要是在施乐 PARC 开发的,该实验室以产生大量高质量的想法而闻名,例如图形用户界面、点击、鼠标和电子表格等。面向对象程序的创始人之一艾伦·凯(Alan Kay)从他的生物学专业知识中汲取了经验,同时面临着以支持新图形用户界面范式的方式组织大型代码库的问题。他提出了对象和类的概念,但是他在几年后声明这种代码组织风格的主要思想是消息传递。他对物体的看法是,它们应该以类似于细胞的方式交流,用代码模拟它们的化学信息。这就是为什么在他看来,OOP 语言中的方法调用应该是从一个单元或对象传递到另一个单元或对象的消息。

一旦我们忘记了封装、继承和多态的思想,并且更加重视对象而不是类,那么功能范式和面向对象之间的摩擦就消失了。让我们看看这个面向对象的基本观点会把我们带到哪里。

从功能角度看类

有多种方法来看待类。在知识管理方面,我将一个概念化为一个分类——这是一种将具有相似属性的实例(或对象)分组的方式。如果我们以这种方式来思考类,那么继承就成了一种自然属性——有些对象的类具有相似的属性,但它们也有各种不同的方式;说它们是相互继承的是解释它们的一个快速方法。

然而,这种类的概念适用于我们的知识是准完整的领域。在软件开发领域,我们经常在有限的应用领域知识下工作,并且该领域随着时间的推移不断扩展。因此,我们需要关注概念之间存在薄弱环节的代码结构,允许我们在了解更多领域知识时更改或替换它们。那我们应该怎么上课呢?

即使没有强大的关系,类在软件设计中也是一个强大的构造。它们提供了一种将方法分组,以及将方法与数据相结合的简洁方式。它们可以比函数更好地帮助我们导航更大的域,因为我们最终可以拥有成千上万个函数(如果不是更多的话)。那么,我们如何使用函数式编程的类呢?

首先,您可能已经从我们前面的例子中注意到,函数式编程将复杂性放在了数据结构中。类通常是定义我们需要的数据结构的一种简洁方式,尤其是在 C++ 这样的语言中,它允许我们覆盖常见的运算符。常见的例子包括虚数、可测量单位(温度、长度、速度等)和货币数据结构。它们中的每一个都需要用特定的运算符和转换对数据进行分组。

第二,我们编写的不可变函数倾向于自然地将自己分组到逻辑分类中。在我们的井字游戏示例中,我们有许多函数使用我们称之为的数据结构;我们的自然倾向是将这些功能组合在一起。虽然没有什么能阻止我们在头文件中对它们进行分组,但是类提供了一个自然的地方来组合函数,以便我们以后可以找到它们。这导致了另一种类型的类——一个不可变的对象,它被初始化一次,并且它的每个操作都返回一个值,而不是改变它的状态。

让我们更详细地看看面向对象设计和功能结构之间的等价性。

面向对象的等价性——功能

如果我们回到井字游戏结果解决方案,您会注意到有许多函数接收board作为参数:

auto allLines = [](const auto& board) {
...
};

auto allColumns = [](const auto& board) {
...
};

auto mainDiagonal = [](const auto& board){
...
};

auto secondaryDiagonal = [](const auto& board){
 ...
};

auto allDiagonals = [](const auto& board) -> Lines {
...
};

auto allLinesColumnsAndDiagonals = [](const auto& board) {
 ...
};

我们可以如下定义板,例如:

    Board board {
        {'X', 'X', 'X'},
        {' ', 'O', ' '},
        {' ', ' ', 'O'}
    };

然后,当我们把它传递到函数中时,就像我们把板绑定到函数的参数上一样。现在,让我们为我们的allLinesColumnsAndDiagonalsλ:

auto bindAllToBoard = [](const auto& board){
    return map<string, function<Lines  ()>>{
        {"allLinesColumnsAndDiagonals",   
            bind(allLinesColumnsAndDiagonals, board)},
    };
};

The preceding lambda and many other examples we have looked at in earlier chapters call other lambdas, yet they don't capture them. For example, how does the bindAllToBoard lambda know about the allLinesColumnsAndDiagonal lambda? The only reason this works is because the lambdas are in a global scope. Moreover, with my compiler, when trying to capture allLinesColumnsAndDiagonals, I get the following error message: <lambda> cannot be captured because it does not have automatic storage duration, so it actually will not compile if I try to capture the lambda I use. I hope what I am about to say is self-explanatory, but I will say it anyway—for production code, avoid having lambdas (and anything else, really) in the global scope. This will also force you to capture the variables, which is a good thing because it makes dependencies explicit.

现在,让我们看看我们如何称呼它:

TEST_CASE("all lines, columns and diagonals with class-like structure"){
    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 boardObject = bindAllToBoard(board);
    auto all = boardObject["allLinesColumnsAndDiagonals"]();
    CHECK_EQ(expected, all);
}

这让你想起什么了吗?让我们看看如何在课堂上写这个。我现在就给它取名BoardResult,因为我想不出更好的名字:

class BoardResult{
    private:
        const vector<Line> board;

    public:
        BoardResult(const vector<Line>& board) : board(board){
        };

         Lines allLinesColumnsAndDiagonals() const {
             return concatenate3(allLines(board), allColumns(board),  
                 allDiagonals(board));
        }
};

TEST_CASE("all lines, columns and diagonals"){
 BoardResult boardResult{{
 {'X', 'X', 'X'},
 {' ', 'O', ' '},
 {' ', ' ', 'O'}
 }};

 Lines expected {
 {'X', 'X', 'X'},
 {' ', 'O', ' '},
 {' ', ' ', 'O'},
 {'X', ' ', ' '},
 {'X', 'O', ' '},
 {'X', ' ', 'O'},
 {'X', 'O', 'O'},
 {'X', 'O', ' '}
 };

 auto all = boardResult.allLinesColumnsAndDiagonals();
 CHECK_EQ(expected, all);
}

让我们回顾一下我们所做的事情:

  • 我们看到了更多以board为参数的函数。
  • 我们决定使用单独的函数将board参数绑定到一个值,从而获得表示函数名的字符串和绑定到该值的λ之间的映射。
  • 要调用它,我们需要首先调用初始化函数,然后我们可以调用部分应用的 lambda。
  • 这看起来与类极其相似——使用构造函数传入类方法之间共享的值,然后在不传入参数的情况下调用方法。

因此,类只是一组部分应用的 lambda。但是我们如何对它们进行分组呢?

高凝聚力原则

在前面的例子中,我们根据函数采用相同参数board的事实,将函数分组到一个类中。我发现这是一个很好的经验法则。然而,我们可能会遇到更复杂的情况。

为了理解原因,让我们来看看另一组函数(为了讨论的目的,实现被忽略了):

using Coordinate = pair<int, int>;

auto accessAtCoordinates = [](const auto& board, const Coordinate& coordinate)
auto mainDiagonalCoordinates = [](const auto& board)
auto secondaryDiagonalCoordinates = [](const auto& board)
auto columnCoordinates = [](const auto& board, const auto& columnIndex)
auto lineCoordinates = [](const auto& board, const auto& lineIndex)
auto projectCoordinates = [](const auto& board, const auto& coordinates)

这些函数应该是先前定义的BoardResult类的一部分吗?或者他们应该是另一个阶级的一部分,Coordinate?或者我们应该把他们分开,一部分去BoardResult班,另一部分去Coordinate班?

我们以前的方法并不适用于所有的函数。如果我们只看它们的参数,所有前面的函数都取board。但也有一些是以coordinate / coordinates为参数的。projectCoordinates应该是BoardResult班的一部分,还是Coordinate班的一部分?

更重要的是,我们可以遵循什么基本原则来将这些函数分组到类中?

由于没有关于代码静态结构的明确答案,我们需要考虑代码的进化。我们需要问的问题如下:

  • 我们希望一起改变哪些功能?我们希望分别改变哪些功能?
  • 这条推理路线把我们引向了高衔接原则。但是,让我们先打开它。我们所说的凝聚力是什么意思?

作为一名工程师和科学极客,我在物理世界中遇到了凝聚力。例如,当我们谈论水时,组成液体的分子倾向于粘在一起。我也遇到了凝聚力这种社会力量。作为一个与试图采用现代软件开发实践的客户一起工作的变更代理人,我经常不得不处理团队凝聚力——人们围绕一个观点聚集在一起的趋势。

当我们谈论函数的内聚力时,没有物理力将它们推在一起,它们肯定不会坚持自己的观点。那么,我们在说什么?我们说的是一种神经力量。

人类的大脑有巨大的能力去寻找模式和将相关的项目分类,结合一种不可思议的快速导航方式。将各种功能结合在一起的力量在我们的大脑中——这是从看似不相关的功能组合中找到的统一目标。

高内聚是有用的,因为它允许我们理解和导航几个大的概念(比如板、线和记号),而不是几十个或几百个小函数。此外,当(不是如果)我们需要添加一个新的行为或改变一个现有的行为时,高内聚性将允许我们快速找到一个新行为的位置,并以最小的改变添加到网络的剩余部分。

内聚性是软件设计的一个度量标准,由拉里·康斯坦丁在 20 世纪 60 年代引入,作为他的结构化设计方法的一部分。通过经验,我们已经注意到,高凝聚力与低变革成本相关。

让我们看看如何应用这个原则来将我们的函数分组到类中。

将内聚函数分组到类中

如前所述,我们可以从类的统一目的或概念的角度来看待内聚性。然而,我通常发现更彻底的方法是查看代码的演变,并根据未来可能发生的变化以及它可能触发的其他变化来决定函数组。

你可能不会期望从我们的井字游戏结果问题中学到很多东西。它相当简单,而且似乎很有内涵。然而,在网上快速搜索,我们会发现很多井字游戏变体,包括以下几种:

  • m x n 棋盘,胜者由连续的 k 项决定。一个有趣的变体是 Gomoku,在 15 x 15 棋盘上玩,赢家必须连续 5 局。
  • 3D 版本。
  • 使用数字作为标记,并将数字的总和作为获胜条件。
  • 使用单词作为代币,获胜者必须将 3 个单词与 1 个常用字母排成一行。
  • 使用3×3的 9 板进行游戏,其中胜者必须连续赢 3 板。

这些甚至不是最奇怪的变体,如果你感兴趣,你可以在https://en.wikipedia.org/wiki/Tic-tac-toe_variants查看维基百科关于这个主题的文章。

那么,我们的实施会有什么变化呢?以下是一些建议:

  • 纸板尺寸
  • 玩家数量
  • 代币
  • 获胜规则(仍然连续,但条件不同)
  • 电路板拓扑—矩形、六边形、三角形或三维而非正方形

幸运的是,如果我们只是改变板的大小,那么在我们的代码中应该没有什么真正的改变。事实上,我们可以通过一个更大的董事会,一切仍将工作。改变玩家数量需要非常小的改动;我们将假设它们有不同的令牌,我们只需要将tokenWins函数绑定到不同的令牌值。

获胜规则怎么样?我们将假设规则仍然考虑线、列和对角线,因为这是井字游戏的基本要求,所有变体都使用它们。然而,我们可能没有考虑到一条完整的线、列或对角线;例如,在 Gomoku 中,我们需要在大小为 15 的行、列或对角线上连续查找 5 个令牌。看看我们的代码,这只是选择其他坐标组的问题;我们需要选择所有可能的五行坐标集,而不是搜索一整行来填充标记X。这意味着我们与坐标相关的功能发生了变化— lineCoordinatesmainDiagonalCoordinatescolumnCoordinatessecondaryDiagonalCoordinates。它们将返回一个五行坐标的向量,这将导致allLinesallColumnsallDiagonals的变化,并且以我们连接它们的方式。

如果令牌是一个单词,获胜条件是在单词之间找到一个公共字母,会怎么样?嗯,坐标是一样的,我们得到线、列和对角线的方式也是一样的。唯一的变化是在fill条件下,所以这个比较容易改变。

这就引出了最后一个可能的变化——电路板拓扑。改变电路板拓扑将需要改变电路板数据结构,以及所有坐标和相应的功能。但是它需要改变线条、列和对角线规则吗?如果我们切换到 3D,那么我们会有更多的线,更多的列,以及不同的处理对角线的方式——所有这些都是坐标的变化。矩形板本身没有对角线*;我们将需要使用部分对角线,例如在 Gomoku 的情况下。至于六角板还是三角板,目前还没有明确的变体,暂时可以忽略。*

*这向我们表明,如果我们想要为变革做准备,我们的职能应该围绕以下几条线进行分组:

  • 规则(也称为填充条件)
  • 坐标和投影-准备多组线、列和对角线的代码
  • 允许基于坐标访问的基本板结构

就这么定了——我们需要把坐标和棋盘本身分开。虽然坐标数据类型将与棋盘数据类型同时改变,但提供线、列和对角线坐标的函数可能会因游戏规则而改变。因此,我们需要将电路板与其拓扑结构分开。

面向对象设计 ( OOD )方面,我们需要在至少三个内聚类— RulesTopologyBoard之间分离程序的职责。Rules类包含游戏规则——基本上,当我们知道是平局或者游戏已经结束时,我们如何计算获胜条件。Topology课程是关于坐标和棋盘的结构。Board类应该是我们传递给算法的结构。

那么,我们的功能应该如何构建呢?让我们列个清单:

  • 规则 : xWinsoWinstokenWinsdrawinProgress
  • 拓扑 : lineCoordinatescolumnCoordinatesmainDiagonalCoordinatessecondaryDiagonalCoordinates
  • : accessAtCoordinatesallLinesColumnsAndDiagonals
  • 未定 : allLinesallColumnsallDiagonalsmainDiagonalsecondaryDiagonal

总有一些函数可以成为更多结构的一部分。在我们的案例中,allLines应该是Topology类还是Board类的一部分?我能为两者找到同样好的论据。因此,解决方案留给编写代码的程序员的直觉。

然而,这显示了您可以用来将这些函数分组到类中的方法——考虑什么可能会改变,并根据哪些函数将一起改变来对它们进行分组。

然而,实践这种方法有一个警告——避免过度分析的陷阱。代码相对容易更改;当您对可能发生的变化知之甚少时,让它工作起来,直到在代码的相同区域出现新的需求。然后,您将对函数之间的关系有更好的了解。这种分析不会超过 15 分钟;任何额外的东西都很可能是过度设计。

将一个类拆分成纯函数

我们已经学会了如何将函数分组到一个类中。但是我们如何将代码从类转换成纯函数呢?很明显,这是相当简单的——我们只需要使函数变得纯粹,将它们移出类,然后添加一个初始化器,将它们绑定到它们需要的数据上。

我们再举一个例子,一个用两个整数操作数执行数学运算的类:

class Calculator{
    private:
        int first;
        int second;

    public:
        Calculator(int first, int second): first(first), second(second){}

        int add() const {
            return first + second;
        }

        int multiply() const {
            return first * second;
        }

        int mod() const {
            return first % second;
        }

};

TEST_CASE("Adds"){
    Calculator calculator(1, 2);

    int result = calculator.add();

    CHECK_EQ(result, 3);
}

TEST_CASE("Multiplies"){
    Calculator calculator(3, 2);

    int result = calculator.multiply();

    CHECK_EQ(result, 6);
}

TEST_CASE("Modulo"){
    Calculator calculator(3, 2);

    int result = calculator.mod();

    CHECK_EQ(result, 1);
}

为了使它更有趣,让我们添加另一个函数来恢复第一个参数:

class Calculator{
...
    int negateInt() const {
        return -first;
    }
...
}

TEST_CASE("Revert"){
    Calculator calculator(3, 2);

    int result = calculator.negateInt();

    CHECK_EQ(result, -3);
}

如何把这个类拆分成函数?幸运的是,功能已经很纯了。很明显,我们可以将函数提取为 lambdas:

auto add = [](const auto first, const auto second){
    return first + second;
};

auto multiply = [](const auto first, const auto second){
    return first * second;
};

auto mod = [](const auto first, const auto second){
    return first % second;
};

auto negateInt = [](const auto value){
    return -value;
};

如果您真的需要,让我们添加初始化器:

auto initialize = [] (const auto first, const auto second) -> map<string, function<int()>>{
    return  {
        {"add", bind(add, first, second)},
        {"multiply", bind(multiply, first, second)},
        {"mod", bind(mod, first, second)},
        {"revert", bind(revert, first)}
    };
};

然后,可以进行检查以确定一切正常:

TEST_CASE("Adds"){
    auto calculator = initialize(1, 2);

    int result = calculator["add"]();

    CHECK_EQ(result, 3);
}

TEST_CASE("Multiplies"){
    auto calculator = initialize(3, 2);

    int result = calculator["multiply"]();

    CHECK_EQ(result, 6);
}

TEST_CASE("Modulo"){
    auto calculator = initialize(3, 2);

    int result = calculator["mod"]();

    CHECK_EQ(result, 1);
}

TEST_CASE("Revert"){
    auto calculator = initialize(3, 2);

    int result = calculator["revert"]();

    CHECK_EQ(result, -3);
}

这留给我们的只有一个悬而未决的问题——如何才能把不纯的函数变成纯函数?我们将在第 12 章重构到纯函数中详细讨论这个问题。现在,让我们记住本章的重要结论——一个类只不过是一组内聚的、部分应用的函数。

摘要

这一章我们经历了一段如此有趣的旅程!我们设法以一种非常优雅的方式将两种看似脱节的设计风格——面向对象和函数式编程——联系起来。纯函数可以根据内聚性的原则分组。我们只需要锻炼我们的想象力,考虑功能可能改变的场景,并决定将哪些功能组合在一起。相反,我们总是可以将函数从一个类转移到多个 lambdas 中,方法是使它们变纯并反转部分应用。

OOP 和函数式编程没有摩擦;它们只是构造实现特性的代码的两种不同方式。

我们使用功能进行软件设计的旅程还没有结束。在下一章中,我们将讨论如何使用测试驱动开发 ( TDD )来设计功能。*