Skip to content

Latest commit

 

History

History
469 lines (341 loc) · 21.3 KB

File metadata and controls

469 lines (341 loc) · 21.3 KB

七、通过函数操作消除重复

软件设计的一个关键原则是减少代码重复。功能性结构为减少代码重复提供了额外的机会。

本章将涵盖以下主题:

  • 如何以及为什么要避免重复代码
  • 如何识别代码相似性
  • 使用 currying 删除某些类型的代码相似性
  • 使用组合消除某些类型的代码相似性
  • 使用 lambdas 或 composition 移除某些类型的代码相似性

技术要求

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

代码可以在网站上的Chapter07文件夹中找到。它包括并使用doctest,这是一个单头开源单元测试库。你可以在 https://github.com/onqtam/doctest的 GitHub 储存库中找到它。

通过功能操作消除重复

当我们只需要在一个地方更改代码,并且可以重组现有的代码片段时,长时间维护代码要容易得多。实现这一理想的最有效的方法之一是识别并消除代码中的重复。来自函数式编程的操作——部分应用、currying 和函数式组合——提供了许多机会来使代码变得更干净,并且具有有限的重复。

但是首先,让我们了解什么是重复,为什么我们需要减少重复。首先我们来看看不重复自己 ( DRY )原理,再来看看重复和代码相似度的关系。最后,我们将研究消除代码相似性的方法。

干燥原理

软件开发核心书籍的数量出乎意料的低。当然,有很多关于细节和帮助人们更好地理解想法的书,但是关于核心想法的书非常少,而且很旧。在核心书籍的名单上是作者的荣誉,也暗示了这个主题是极其重要的。许多程序员会把安德鲁·亨特和戴维·托马斯的《实用程序员》这本书放在这样的名单上。这本书出版于 1999 年,详细介绍了一个对长期使用大型代码库的人来说非常有意义的原则——DRY。

DRY 原则的核心是基于这样一种理解,即代码是存储知识的一种方式。每个函数和每个数据成员都代表关于一个问题的知识。理想情况下,我们希望避免知识在系统中重复。换句话说,无论你在寻找什么,都应该只在一个地方。不幸的是,大多数代码库是 WET (缩写为要么把所有东西都写两遍我们喜欢打字,要么浪费大家的时间),而不是 DRY。

然而,消除重复的想法由来已久。肯特·贝克曾在 20 世纪 90 年代将它作为极限编程 ( XP )实践的一部分提到过。肯特·贝克描述了简单设计的四个要素,简单设计是获得或改进软件设计的思维工具。

简单的设计意味着它可以做到以下几点:

  • 通过测试
  • 揭示意图
  • 减少重复
  • 元素较少

我从 J.B .兰斯伯格那里学到了这些规则,他也致力于简化这些规则。他告诉我,在大多数情况下,专注于三件事就足够了——测试代码、改进名称和减少重复。

但这并不是唯一提到消除重复的地方。该原则以各种方式出现在 Unix 设计哲学中,出现在领域驱动设计 ( DDD )技术中,作为对测试驱动开发 ( TDD )实践的帮助,以及许多其他方面。可以肯定地说,这是优秀软件设计的一个普遍原则,每当我们谈论在一个模块中构建代码时,使用它都是有意义的。

重复和相似

后来在我学习好的软件设计的旅程中,我意识到术语复制对于表达我们试图实现的理念非常有用,但是很难理解如何将其付诸实践。当我试图改进设计时,我为我所寻找的东西找到了一个更好的名字——我寻找代码相似性。一旦我发现相似之处,我会问它们是否表现出更深层次的重复,或者它们只是一个意外。

我也及时注意到我在寻找一些特定类型的相似之处。这里有几个例子:

  • 相似的名称,要么是全名,要么是嵌入在函数、参数、方法、变量、常数、类、模块、命名空间等更长名称中的名称
  • 类似的参数列表
  • 类似的函数调用
  • 不同的代码试图达到相似的结果

一般来说,我遵循这两个步骤:

  1. 首先,注意相似之处。
  2. 其次,决定是否移除相似性。

当不确定相似性是否说明了设计更深层次的东西时,最好保留它。一旦你见过三次相似之处,最好也开始消除它们;这样,你就可以确定它违反了 DRY 原则,而不仅仅是一个意外。

接下来,我们将看看通过功能操作可以消除的几类相似之处。

解决部分应用的参数相似性

在我们前面的章节中,您已经看到了这样的情况:一个函数被多次调用,其中一个参数的值相同。例如,请看我们井字游戏结果问题中的代码;我们有一个函数负责检查一行是否填充了令牌:

auto lineFilledWith = [](const auto& line, const auto tokenToCheck){
    return all_of_collection(line, [&tokenToCheck](auto const token){   
        return token == tokenToCheck;});
};

由于井字游戏使用了两个标记XO,很明显我们会重复调用这个函数,其中tokenToCheck要么是X要么是O。消除这种相似性的通常方法是实现两个新功能,lineFilledWithXlineFilledWithO:

auto lineFilledWithX = [](const auto& line){
    return lineFilledWith(line, 'X');
};

这是一个可行的解决方案,但它仍然需要我们编写一个单独的函数和三行代码。正如我们已经看到的,我们在函数式编程中有另一种选择;我们可以简单地使用部分应用来获得相同的结果:

auto lineFilledWithX = bind(lineFilledWith, _1, 'X'); 
auto lineFilledWithO = bind(lineFilledWith, _1, 'O');

我更喜欢在可能的情况下使用部分应用,因为这种类型的代码只是管道,我需要编写的管道越少越好。但是,在团队中使用部分应用时需要小心。每个团队成员都应该熟悉部分应用,并精通理解这种类型的代码。否则,使用部分应用只会让开发团队更难理解代码。

用函数组合替换另一个函数相似性输出上的调用函数

您可能已经注意到了以下代码中显示的模式:

int processA(){
    a  = f1(....)
    b = f2(a, ...)
    c = f3(b, ...)
}

通常,如果你足够努力,你会在你的代码库中找到另一个做类似事情的函数:

int processB(){
    a  = f1Prime(....)
    b = f2(a, ...)
    c = f3(b, ...)
}

这种相似性似乎有更深层次的原因,因为应用的复杂性会随着时间的推移而增长。我们通常从实现一个经过多个步骤的简单流程开始。然后,我们实现相同流程的变体,其中一些步骤重复,而其他步骤发生变化。有时,流程的变化包括改变步骤的顺序,或者调整一些步骤。

在我们的实现中,这些步骤被转换成以各种方式组合在其他函数中的函数。但是,如果我们使用上一步的输出,并将其输入到下一步,我们在代码中有一个相似性,它不依赖于每个步骤做什么。

为了消除这种相似性,我们通常会提取代码的相似部分并传递结果,如以下代码所示:

int processA(){
    a  = f1(....)
    return doSomething(a)
}

int processB(){
    a = f1Prime(....)
    return doSomething(a)
}

int doSomething(auto a){
    b = f2(a, ...)
    return f3(b, ...)
}

然而,在提取函数时,代码往往变得更难理解,也更难更改,如前面的代码所示。提取函数的公共部分没有考虑到这样一个事实,即代码实际上是一个链式调用。

为了使这一点可见,我倾向于将这种代码模式重新格式化为一条语句,如下面的代码所示:

processA = f3(f2(f1(....), ...), ...)
processB = f3(f2(f1Prime(....), ...), ...)

虽然不是每个人都喜欢这种格式,但这两种调用之间的相似性和差异更明显。同样显而易见的是,我们有一个使用功能组合的解决方案——我们只需要用f2组合f3,并用f1f1Prime组合结果,就可以得到我们想要的结果:

C = f3 ∘ f2
processA = C ∘ f1
processB  = C ∘ f1Prime

这是一个非常强大的机械师!我们可以通过函数组合,在几行代码中创建无数的链调用组合。我们可以用一些表达我们代码真实性质的组合语句来替换隐藏的管道伪装成函数中语句的顺序。

然而,正如我们在第 4 章功能组合的思想中所看到的,这在 C++ 中并不一定是一项容易的任务,因为我们需要编写自己的compose函数来满足我们的特定情况。在 C++ 为函数组合提供更好的支持之前,我们被迫将这种机制保持在最低限度,并且只在相似性不仅很明显,而且我们预计它会随着时间的推移而增加的地方使用它。

用高级函数消除结构相似性

到目前为止,在我们的讨论中一直有一种模式——函数式编程帮助我们从代码中移除管道,并表达代码的真实结构。命令式编程使用一系列语句作为基本结构;函数式编程减少了序列,专注于函数的有趣玩法。

当我们讨论结构相似性时,这一点最为明显。作为一种普遍的模式,结构相似性是指代码结构重复的情况,尽管不一定是通过调用相同的函数或使用相同的参数。为了看到它的实际应用,让我们从井字游戏代码中一个非常有趣的相似之处开始。这是我们在第 6 章函数思维中编写的代码——从数据输入到数据输出:

auto lineFilledWith = [](const auto& line, const auto& tokenToCheck){
    return allOfCollection(line, [&tokenToCheck](const auto& token){  
        return token == tokenToCheck;});
};

auto lineFilledWithX = bind(lineFilledWith, _1, 'X'); 
auto lineFilledWithO = bind(lineFilledWith, _1, 'O');

auto xWins = [](const auto& board){
    return any_of_collection(allLinesColumnsAndDiagonals(board), 
        lineFilledWithX);
};

auto oWins = [](const auto& board){
    return any_of_collection(allLinesColumnsAndDiagonals(board), 
        lineFilledWithO);
};

xWinsoWins函数看起来非常相似,因为它们都调用同一个函数作为第一个参数,而lineFilledWith函数的变体作为它们的第二个参数。让我们消除它们的相似性。首先,让我们移除lineFilledWithXlineFilledWithO,并用它们的等效物lineFilledWith替换它们:

auto xWins = [](const auto& board){
    return any_of_collection(allLinesColumnsAndDiagonals(board), []  
        (const auto& line) { return lineFilledWith(line, 'X');});
};

auto oWins = [](const auto& board){
    return any_of_collection(allLinesColumnsAndDiagonals(board), []
        (const auto& line) { return lineFilledWith(line, 'O');});
};

既然相似性很明显,我们可以很容易地提取一个共同的函数:

auto tokenWins = [](const auto& board, const auto& token){
    return any_of_collection(allLinesColumnsAndDiagonals(board),  
        [token](auto line) { return lineFilledWith(line, token);});
};
auto xWins = [](auto const board){
    return tokenWins(board, 'X');
};

auto oWins = [](auto const board){
    return tokenWins(board, 'O');
}

我们还注意到xWinsoWins只是tokenWins的部分应用,让我们明确一下:

auto xWins = bind(tokenWins, _1, 'X');
auto oWins = bind(tokenWins, _1, 'O');

现在,让我们关注tokenWins:

auto tokenWins = [](const auto& board, const auto& token){
    return any_of_collection(allLinesColumnsAndDiagonals(board),  
        [token](auto line) { return lineFilledWith(line, token);});
};

首先,我们注意到我们传递到any_of_collection中的 lambda 是一个带有固定令牌参数的部分应用,因此让我们替换它:

auto tokenWins = [](const auto& board, const auto& token){
    return any_of_collection(
            allLinesColumnsAndDiagonals(board), 
            bind(lineFilledWith, _1, token)
    );
};

现在这是一个相当小的功能,由于我们的部分应用,封装了大量的功率。然而,我们已经可以提取一个更高级别的函数,这将允许我们在不编写任何代码的情况下创建更多类似的函数。还不知道叫什么,就叫foo:

template <typename F, typename G, typename H>
auto foo(F f, G g, H h){
    return [=](auto first, auto second){
    return f(g(first), 
    bind(h, _1, second));
    };
}
auto tokenWins = compose(any_of_collection, allLinesColumnsAndDiagonals, lineFilledWith);

我们的foo函数显示了代码的结构,但它相当不可读,所以让我们更好地命名一下:

template <typename CollectionBooleanOperation, typename CollectionProvider, typename Predicate>
auto booleanOperationOnProvidedCollection(CollectionBooleanOperation collectionBooleanOperation, CollectionProvider collectionProvider, Predicate predicate){
    return [=](auto collectionProviderSeed, auto predicateFirstParameter){
      return collectionBooleanOperation(collectionProvider(collectionProviderSeed), 
              bind(predicate, _1, predicateFirstParameter));
  };
}
auto tokenWins = booleanOperationOnProvidedCollection(any_of_collection, allLinesColumnsAndDiagonals, lineFilledWith);

我们引入了更高层次的抽象,这会使代码更难理解。另一方面,我们在一行代码中创建了f(g(first), bind(h, _1, second))表单的函数。

代码更好吗?这取决于背景、你的判断以及你和你的同事对更高级功能的熟悉程度。然而,请记住——抽象虽然非常强大,但也是有代价的。抽象更难理解,但是如果你在抽象中说*,你可以用非常强大的方式组合它们。使用这些更高层次的功能就像从头开始构建一门语言——它使你能够在不同的层次上交流,但它也为其他人创造了进入的障碍。谨慎使用抽象概念!*

*# 使用高级函数移除隐藏循环

代码中经常会遇到结构重复的一个特殊例子,我最终将其称为隐藏循环。隐藏循环的思想是我们在一个序列中多次使用相同的代码结构。然而,诀窍是被调用的函数或参数不必相同;因为函数编程的基本思想是函数也是数据,所以我们可以将这些结构视为数据结构上的循环,这些数据结构也可能存储我们调用的函数。

我通常在一系列if语句中看到这种模式。事实上,我是在使用井字游戏结果问题来促进实践的过程中开始看到它们的。这个问题的通常解决方案,在一个面向对象编程 ( OOP )或命令式语言中,看起来像下面的代码所示:

enum Result {
    XWins,
    OWins,
    GameNotOverYet,
    Draw
};

Result winner(const Board& board){ 
    if(board.anyLineFilledWith(Token::X) ||    
        board.anyColumnFilledWith(Token::X) || 
        board.anyDiagonalFilledWith(Token::X)) 
    return XWins; 

    if(board.anyLineFilledWith(Token::O) ||  
        board.anyColumnFilledWith(Token::O) ||  
        board.anyDiagonalFilledWith(Token::O)) 
    return OWins; 

    if(board.notFilledYet()) 
    return GameNotOverYet; 

return Draw; 
}

在前面的示例中,enum标记包含三个值:

enum Token {
    X,
    O,
    Blank
};

Board类看起来是这样的:

using Line = vector<Token>;

class Board{
    private: 
        const vector<Line> _board;

    public: 
        Board() : _board{Line(3, Token::Blank), Line(3, Token::Blank),  
            Line(3, Token::Blank)}{}
        Board(const vector<Line>& initial) : _board{initial}{}
...
}

anyLineFilledWithanyColumnFilledWithanyDiagonalFilledWithnotFilledYet的实现非常相似;anyLineFilledWith的一个非常简单的实现,假设一个 3×3 的板,如下所示:

        bool anyLineFilledWith(const Token& token) const{
            for(int i = 0; i < 3; ++ i){
                if(_board[i][0] == token && _board[i][1] == token &&  
                    _board[i][2] == token){
                    return true;
                }
            }
            return false;
        };

然而,我们对底层实现不太感兴趣,而对前面 winner 函数的相似性更感兴趣。首先,if语句中的条件用不同的参数重复。但是,更有趣的是,有一个结构重复如下:

if(condition) return value;

如果您看到这样一个结构,它使用数据而不是不同的函数,您会立即注意到它是一个隐藏的循环。当涉及到函数调用时,我们不会注意到这种类型的重复,因为我们没有受过将函数视为数据的训练。但事实就是如此。

在我们消除这种相似性之前,让我们简化一下条件。我将使所有条件函数都不带参数,通过部分函数的神奇应用:

auto tokenWins = [](const auto board, const auto& token){
    return board.anyLineFilledWith(token) ||   
board.anyColumnFilledWith(token) || board.anyDiagonalFilledWith(token);
};

auto xWins = bind(tokenWins, _1, Token::X);
auto oWins = bind(tokenWins, _1, Token::O);

auto gameNotOverYet = [](auto board){
    return board.notFilledYet();
};

Result winner(const Board& board){ 
    auto gameNotOverYetOnBoard = bind(gameNotOverYet, board);
    auto xWinsOnBoard = bind(xWins, board);
    auto oWinsOnBoard = bind(oWins, board);

    if(xWins()) 
        return XWins; 

    if(oWins())
        return OWins; 

    if(gameNotOverYetOnBoard()) 
        return GameNotOverYet; 

    return Draw; 
}

我们的下一步是移除四个不同条件之间的差异,并用循环替换相似性。我们只需要有一个 (lambda,result) 的对列表,并使用一个更高级的函数如find_if为我们做循环:

auto True = [](){
    return true;
};

Result winner(Board board){
    auto gameNotOverYetOnBoard = bind(gameNotOverYet, board);
    auto xWinsOnBoard = bind(xWins, board);
    auto oWinsOnBoard = bind(oWins, board);

    vector<pair<function<bool()>, Result>> rules = {
        {xWins, XWins},
        {oWins, OWins},
        {gameNotOverYetOnBoard, GameNotOverYet},
        {True, Draw}
    };

    auto theRule = find_if(rules.begin(), rules.end(), [](auto pair){
            return pair.first();
            });
    // theRule will always be found, the {True, Draw} by default.
    return theRule->second;
}

拼图的最后一块是确保我们的代码返回Draw如果没有其他的工作。由于find_if返回符合规则的第一个元素,我们只需要在最后有Draw,关联一个总是返回true的函数。我把这个函数恰当地命名为True

这段代码对我们有什么用?嗯,它有几个优点。首先,我们可以很容易地添加一对新的条件和结果,例如,如果我们曾经得到在多维度或有更多玩家的情况下实现井字游戏变体的请求。第二,代码更短。第三,经过一些修改,我们获得了一个简单的规则引擎,尽管它非常通用:

auto True = [](){
    return true;
};

using Rule = pair<function<bool()>, Result>;

auto condition = [](auto rule){
    return rule.first();
};

auto result = [](auto rule){
    return rule.second;
};

// assumes that a rule is always found
auto findTheRule = [](const auto& rules){
    return *find_if(rules.begin(), rules.end(), [](auto rule){
 return condition(rule);
 });
};

auto resultForFirstRuleThatApplies = [](auto rules){
    return result(findTheRule(rules));
};

Result winner(Board board){
    auto gameNotOverYetOnBoard = bind(gameNotOverYet, board);
    vector<Rule> rules {
        {xWins, XWins},
        {oWins, OWins},
        {gameNotOverYetOnBoard, GameNotOverYet},
        {True, Draw}
    };

    return resultForFirstRuleThatApplies(rules);
}

上一个示例中唯一的特定代码是规则列表。其他的都很一般,可以在多个问题上重用。

像往常一样,进入更高的抽象层次是要付出代价的。我们花时间尽可能清楚地命名事物,我相信这段代码非常容易阅读。然而,很多人可能并不熟悉它。

另一个可能的问题是内存使用。代码的初始版本虽然重复相同的代码结构,但不需要为函数和结果对的列表分配内存;然而,衡量这些事情很重要,因为即使是初始代码也需要一些进程内存来存储额外的指令。

这个例子向我们展示了如何通过一个非常简单的代码示例将重复的结构变成循环。这只是表面现象;这种模式非常普遍,我相信一旦你开始寻找,你会在代码中注意到它。

摘要

在本章中,我们研究了不同类型的代码相似性,以及如何通过各种函数式编程技术来减少它们。从可以用部分应用替换的重复参数,到可以转换成函数组合的链式调用,一直到可以通过更高级函数移除的结构相似性的奇妙复杂世界,您现在已经做好了充分的准备,可以注意和减少您使用的任何代码库中的相似性。

正如你所注意到的,我们开始讨论代码结构和软件设计。这就引出了设计的另一个核心原则——高内聚、低耦合。我们如何使用函数来增加凝聚力?事实证明,这就是类非常有用的地方,这也是我们将在下一章讨论的内容。*