原文:Clean C++
六、面向对象
面向对象(OO)的历史根源可以在 20 世纪 50 年代末找到。挪威计算机科学家克利斯登·奈加特和奥利·约翰·达尔在军事研究所挪威国防研究所(NDRE)为挪威第一个核反应堆的开发和建造进行了模拟计算。在开发模拟程序时,两位科学家注意到,用于该任务的过程编程语言不太适合要解决的问题的复杂性。达尔和尼加德感到需要在这些语言中找到合适的可能性来抽象和再现现实世界的结构、概念和过程。
1960 年,尼加德搬到了两年前在奥斯陆建立的挪威计算中心(NCC)。三年后,奥利·约翰·达尔也加入了 NCC。在这个私人的、独立的、非盈利的研究基金会,两位科学家为一种——从今天的观点来看——面向对象编程语言开发了第一个想法和概念。尼加德和达尔在寻找一种适用于所有领域的语言,而不是专门针对某些应用领域的语言,例如,用于数值计算和线性代数的 Fortran 或 COBOL,它是专门为商业用途设计的。
他们研究活动的结果最终是编程语言 Sim ula-67,它是过程编程语言 ALGOL 60 的扩展。新语言引入了类、子类、对象、实例变量、虚方法,甚至垃圾收集器。Simula-67 被认为是第一种面向对象的编程语言,并影响了以下许多其他编程语言,例如,由 Alan Kay 及其团队在 20 世纪 70 年代初设计的完全面向对象的编程语言 Smalltalk。
当丹麦计算机科学家比雅尼·斯特劳斯特鲁普于 1970 年末在剑桥大学完成他的博士论文《分布式计算机系统中的通信和控制》时,他使用了 Simula-67,并发现它非常有用,但对于实际应用来说太慢了。因此,他开始寻找将 Simula-67 数据抽象的面向对象概念与低级编程语言的高效率相结合的可能性。当时最有效的编程语言是 C 语言,它是由美国计算机科学家丹尼斯·里奇于 20 世纪 70 年代早期在贝尔电话实验室开发的。Stroustrup 于 1979 年加入贝尔电话实验室的计算机科学研究中心,他开始在 C 语言中加入面向对象的特性,如类、继承、强类型检查和许多其他东西,并将其命名为“带类的 C”1983 年,该语言的名称改为 C++,这是 Stroustrups 的合伙人 Rick Mascitti 创造的一个词,其中++ 的灵感来自于该语言的后增量运算符。
在接下来的几十年里,面向对象成为了主导的编程范式。
面向对象的思维
有一点非常重要,我们需要牢记在心。仅仅因为市场上有几种支持面向对象概念的编程语言,就绝对不能保证使用这些语言的开发人员会自动产生面向对象的软件设计。尤其是那些长期使用过程语言的开发人员,在向这种编程范式过渡时经常会遇到困难。面向对象不是一个容易理解的概念。它要求开发者以一种新的方式看待世界。
艾伦·库尔蒂斯·凯博士在 20 世纪 70 年代早期和 PARC 施乐公司的一些同事一起开发了面向对象的编程语言 Smalltalk,他被誉为“面向对象”一词的创始人之一在通过电子邮件与德国大学讲师迪平的讨论中。来自柏林自由大学的 Stefan Ram 从 2003 年开始,Kay 为他解释了什么是面向对象:
I think that objects are like biological cells and/or a single computer on the network, which can only communicate with messages (so messaging appeared from the very beginning-it took some time to understand how to use messaging effectively in programming languages). (…) OOP to me only means messaging, local reservation, protecting and hiding state processes, and extreme late binding of everything. —Dr. Alan Curtis Kay, American computer scientist, July 23, 2003 [ram 03]
生物细胞可以被定义为所有生物体的最小结构和功能单位。它们通常被称为“生命的基石”艾伦·凯认为软件就像生物学家看待复杂的活的有机体一样。艾伦·凯的这种视角应该不会令人惊讶,因为他拥有数学和分子生物学学士学位。
艾伦·凯的细胞就是我们在 OO 中所说的对象。一个对象可以被认为是一个有结构和行为的“东西”。生物细胞有一层膜包裹着它。这也适用于面向对象的对象。对象应该被很好地封装,并通过定义良好的接口提供服务。
此外,Alan Kay 强调“消息传递”对他来说在面向对象中起着核心作用。然而,他并没有确切地解释他这么说的意思。在一个对象上调用一个名为foo()
的方法是否等同于向那个对象发送一个名为“foo”的消息?或者 Alan Kay 有消息传递基础设施的想法,比如 CORBA(公共对象请求代理体系结构)和类似的技术?Kay 博士也是一名数学家,所以他也可以指一个著名的消息传递数学模型,名为 Actor 模型,在并发计算中非常流行。
在任何情况下,不管 Alan Kay 在谈到消息传递时有什么想法,我认为这种观点很有趣,并且大体上适用于在抽象层次上解释面向对象程序的典型结构。但是凯先生的解释肯定不足以回答下列重要问题:
- 我如何找到并形成“细胞”(物体)?
- 我如何设计这些单元的公共可用接口?
- 我如何管理谁可以与谁通信(依赖)?
面向对象(OO)主要是一种思维方式,而不是所用语言的问题。它也可能被滥用和误用。
我见过许多用 C++ 或纯面向对象语言(如 Java)编写的程序,其中使用了类,但这些类只构成了包装过程程序的大型名称空间。或者略带讽刺地表达:显然,类似 Fortran 的程序可以用几乎任何编程语言编写。另一方面,每一个已经内化了面向对象思想的开发人员将能够用面向对象的设计开发软件,即使是用像 ANSI-C、汇编程序这样的语言,或者使用 shell 脚本。
抽象——掌握复杂性的关键
面向对象背后的基本思想是我们在软件中从我们领域的相关部分建模事物和概念。因此,我们将自己仅仅局限于那些必须在我们的软件系统中表现出来以满足涉众需求的东西,也称为需求。抽象是以适当的方式对这些事物和概念建模的最重要的工具。我们不想模仿整个真实世界。我们只需要从现实世界中摘录,减少到与实现系统用例相关的细节。
例如,如果我们想在一个书店系统中代表一个顾客,那么这个顾客的血型是很有可能的,而且是绝对没有意义的。另一方面,对于医学领域的软件系统来说,人的血型可能是一个重要的细节。
对我来说,面向对象是关于数据抽象、责任、模块化,以及分而治之。如果我必须把它归结为,我会说 OO 是关于对复杂性的掌握。我用一个小例子来解释一下。
以汽车为例。汽车是由几个部分组成的,例如,车身、发动机、齿轮、车轮、座椅等。这些部分本身也由更小的部分组成。以汽车的发动机为例(让我们假设它是内燃机,而不是电动机)。发动机由气缸体、汽油点火泵、传动轴、凸轮轴、活塞、发动机控制单元(ECU)、冷却液子系统等组成。冷却液子系统同样由热交换器、冷却液泵、冷却液储罐、风扇、恒温器和加热器芯组成。汽车的分解理论上可以延续到最小的螺丝钉。每个确定的子系统或部分都有明确定义的职责。但是,只有所有的零件组合在一起,并以正确的方式组装,才能制造出一辆能提供司机所期望的服务的汽车。
复杂的软件系统可以用同样的方式来考虑。它们可以分层分解成粗粒度到细粒度的模块。这有助于应对系统的复杂性,提供更多的灵活性,并培养可重用性、可维护性和可测试性。进行这种分解的指导原则主要如下:
- 信息隐藏(参见第三章中的同名章节),
- 凝聚力强(见第三章中的同名章节),
- 松散耦合(参见第三章中的同名章节),以及
- 单一责任原则(SRP 请参阅本章后面的同名部分)。
优秀课堂设计的原则
在面向对象语言中,形成前面描述的模块的广泛和众所周知的机制是类的概念。类被认为是封装的软件模块,它将结构特征(同义词:属性、数据成员、字段)和行为特征(同义词:成员函数、方法、操作)结合成一个内聚单元。
在像 C++ 这样具有面向对象功能的编程语言中,类是高于函数的下一个更高级的结构化概念。它们通常被描述为对象的蓝图(同义词:实例)。这足以成为进一步研究类的概念的理由。在这一章中,我给出了一些用 C++ 设计和编写好类的重要线索。
保持小班教学
在我作为软件开发人员的职业生涯中,我见过很多非常大的班级。成千上万行代码并不罕见。经过仔细观察,我注意到这些大型类通常只被用作或多或少过程化程序的名称空间,这些程序的开发人员通常不理解面向对象。
我认为这种大班的问题是显而易见的。如果类包含几千行代码,那么它们很难理解,并且它们的可维护性和可测试性通常很差,更不用说可重用性了。根据几项研究,大班通常包含更多的缺陷。
The God Class Anti-Pattern
在许多系统中,有非常大的类,有许多属性和几百个操作。这些类的名称通常以“…控制器”、“管理器”或“…助手”结尾开发人员经常争辩说,系统中的某个地方必须有一个中心实例来拉动字符串并协调一切。这种思维方式的结果是这样的巨型类,它们的内聚性非常差(参见第三章中关于强内聚性的部分)。他们就像一个便利店,提供五颜六色的商品。
这种类被称为 God 类,Go d 对象,有时也称为 Blob(Blob 是 1958 年的一部美国恐怖/科幻电影,讲述了一种外星变形虫吃掉一个村庄的居民)。这就是所谓的反模式,被认为是糟糕设计的同义词。一个神类是一个不可驯服的野兽,难以维护,难以理解,不可测试,容易出错,并且对其他类有很大的依赖性。在系统的生命周期中,这样的类越来越大。这让问题变得更糟。
什么被证明是函数大小的好规则(参见第四章中的“让它们小一点”),这似乎也是类大小的好建议:类应该小!
如果小尺寸是类设计的一个目标,那么接下来的问题是:多小?
对于函数,我已经在第章第 4 中给出了一些代码行。难道没有可能为被认为是好的或合适的类定义一些行吗?
在 ThoughtWorks 选集[ThoughtWorks08]中,Jeff Bay 提交了一篇题为“对象健美操:今天更好的软件设计的 9 个步骤”的文章,建议一个类的代码不要超过 50 行。
对于许多开发人员来说,大约 50 行的上限似乎是不可能的。他们似乎对阶级的产生有一种无法解释的抵触情绪。他们经常这样争论:“不超过 50 行?但这将导致大量的小类,只有很少的成员和函数。”然后他们肯定会变出一个不能简化到如此小规模的班级的例子。
我确信那些开发商完全错了。我很确定每一个软件系统都可以被分解成如此小的基本构件。
是的,如果班级很小,你会有更多的班级。但那是 OO!在面向对象的软件开发中,类是一种同样自然的语言元素,如函数或变量。换句话说:不要害怕创建小班。小班更容易使用、理解和测试。
尽管如此,这引出了一个基本问题:代码行数上限的定义基本上是正确的吗?我认为代码行数(LOC)是一个有用的指标。太多的 LOC 是一种气味。你可以仔细看看 50 行以上的课。但是并不一定是多行代码总是有问题。一个更好的标准是一个类的责任量。
单一责任原则
单一责任原则(SRP)声明每个软件单元——包括组件、类和功能——应该只有一个单一的和明确定义的责任。
SRP 基于我在第三章中讨论过的内聚性的一般原则。如果一个类有明确的职责,通常它的凝聚力也很强。
但是责任到底是什么?在文学作品中,我们经常可以找到这样的解释:改变阶级必须只有一个原因。一个经常提到的例子是,当类由于系统不同方面的新的或改变的需求而需要改变时,这个规则被违反了。
这些方面可以是例如设备驱动程序和 UI。如果必须改变同一个类,或者因为设备驱动程序的接口已经改变,或者必须实现关于图形用户界面的新需求,那么这个类显然有太多的责任。
另一类方面与系统的领域有关。如果必须改变同一个类,或者因为有关于客户管理的新要求,或者有关于发票的新要求,那么这个类有太多的责任。
遵循 SRP 的类通常很小,并且很少依赖。它们很清楚,容易理解,也很容易测试。
责任是比一个类的代码行数更好的标准。可以有 100 行、200 行甚至 500 行的类,如果这些类不违反单一责任原则,这是完全可以的。尽管如此,高锁定计数可能是一个指标。这是一条提示:“你应该看看这些课程!也许一切都很好,但也许他们这么大,是因为他们的责任太多了。”
开闭原则(OCP)
所有系统在其生命周期内都会发生变化。当开发预期比第一个版本持续时间更长的系统时,必须记住这一点。—Ivar Jacobson,瑞典计算机科学家,1992 年
对于任何类型的软件单元,尤其是对于类设计,另一个重要的指导原则是开闭原则(OCP)。它陈述了软件实体(模块、类、函数等)。)应该对扩展开放,但对修改关闭。
一个简单的事实是,软件系统会随着时间的推移而发展。必须不断满足新的需求,现有的需求必须根据客户需求或技术进步进行更改。这些扩展不仅应该以优雅的方式进行,而且应该尽可能地省力。它们应该特别地以这样一种方式制作,即不需要改变现有的代码。如果任何新的需求会导致软件现有的和经过良好测试的部分发生一连串的变化和调整,那将是致命的。
在面向对象中支持这一原则的一种方式是继承的概念。通过继承,可以在不修改类的情况下向类中添加新的功能。此外,有许多面向对象的设计模式正在培育 OCP,比如 Strategy,或者 Decorator(参见第九章关于设计模式)。
在第三章中关于松耦合的部分,我们已经讨论了一个非常支持 OCP 的设计(见图 3-6 )。这里,我们通过一个接口将开关和灯解耦。通过这一步,设计不会被修改,但可以愉快地扩展。我们可以很容易地添加更多可切换的设备,并且我们不需要触摸类别开关、灯和可切换的接口。你可以很容易地想象,这种设计的另一个优点是现在很容易提供一个测试替身(例如,一个模拟对象)用于测试目的(参见第二章中关于测试替身(模拟对象)的部分)。
利斯科夫替代原理
里斯科夫替代原理基本上是说,你不能通过给一只狗增加四条假腿来创造一只章鱼。——马里奥·富斯科(@mariofusco),2013 年 9 月 15 日,在推特上
初看起来,面向对象的继承和多态的关键概念似乎相对简单。继承是一个分类学概念,应该用来构建类型的专门化层次结构,也就是说,子类型是从更一般的类型派生出来的。多态性通常意味着提供一个单一的接口作为对不同类型对象的访问可能性。
目前为止,一切顺利。但是有时你会遇到这样的情况,子类型并不真的想要适合类型层次结构。让我们讨论一个经常用来说明问题的非常流行的例子。
正方形和长方形的困境
假设我们正在开发一个类库,其中包含用于在画布上绘图的基本形状类型,例如,Circle
、Rectangle
、Triangle
和TextLabel
。可视化为 UML 类图,这个库可能看起来如图 6-1 所示。
图 6-1。
A class library of diff erent shapes
抽象基类Shape
具有对于所有特定形状都相同的属性和操作。例如,对于所有形状来说,它们如何在画布上从一个位置移动到另一个位置是相同的。然而,Shape
不知道如何显示(同义词:绘制)或隐藏(同义词:删除)特定的形状。因此,这些操作是抽象的,也就是说,它们不能(完全)在Shape
中实现。
在 C++ 中,抽象类Shape
(以及Shape
所需的类Point
)的实现可能如下所示:
class Point final {
public:
Point() : x { 5 }, y { 5 } { }
Point(const unsigned int initialX, const unsigned int initialY) :
x { initialX }, y { initialY } { }
void setCoordinates(const unsigned int newX, const unsigned int newY) {
x = newX;
y = newY;
}
// ...more member functions here...
private:
unsigned int x;
unsigned int y;
};
class Shape {
public:
Shape() : isVisible { false } { }
virtual ∼Shape() = default;
void moveTo(const Point& newCenterPoint) {
hide();
centerPoint = newCenterPoint;
show();
}
virtual void show() = 0;
virtual void hide() = 0;
// ...
private:
Point centerPoint;
bool isVisible;
};
void Shape::show() {
isVisible = true;
}
void Shape::hide() {
isVisible = false;
}
Listing 6-1.This is what the two classes Point and Shape look like
Final Specifier [C++11]
从 C++11 开始就有的final
说明符有两种用法。
一方面,您可以使用此说明符来避免在派生类中重写单个虚拟成员函数,如下例所示:
class AbstractBaseClass {
public:
virtual void doSomething() = 0;
};
class Derived1 : public AbstractBaseClass {
public:
virtual void doSomething() final {
//...
}
};
class Derived2 : public Derived1 {
public:
virtual void doSomething() override { // Causes a compiler error!
//...
}
};
另外,你也可以将一个完整的类标记为final
,就像我们形状库中的类Point
一样。这确保了开发人员不能将这样的类用作继承的基类。
class NotDerivable final {
// ...
};
在 Shapes 库中的所有具体类中,我们可以对一个类进行示例性的研究,这个类就是Rectangle
:
class Rectangle : public Shape {
public:
Rectangle() : width { 2 }, height { 1 } { }
Rectangle(const unsigned int initialWidth, const unsigned int initialHeight) :
width { initialWidth }, height { initialHeight } { }
virtual void show() override {
Shape::show();
// ...code to show a rectangle here...
}
virtual void hide() override {
Shape::hide();
// ...code to hide a rectangle here...
}
void setWidth(const unsigned int newWidth) {
width = newWidth;
}
void setHeight(const unsigned int newHeight) {
height = newHeight;
}
void setEdges(const unsigned int newWidth, const unsigned int newHeight) {
width = newWidth;
height = newHeight;
}
unsigned long long getArea() const {
return static_cast<unsigned long long>(width) * height;
}
// ...
private:
unsigned int width;
unsigned int height;
};
Listing 6-2.The important
parts of class Rectangle
客户端代码希望以相似的方式使用所有形状,不管是哪种特定的实例(Rectangle
、Circle
等)。)它面临着。例如,所有 sha pes 应该一次性显示在画布上,这可以使用以下代码实现:
#include "Shapes.h" // Circle, Rectangle, etc.
#include <memory>
#include <vector>
using ShapePtr = std::shared_ptr<Shape>;
using ShapeCollection = std::vector<ShapePtr>;
void showAllShapes(const ShapeCollection& shapes) {
for (auto& shape : shapes) {
shape->show();
}
}
int main() {
ShapeCollection shapes;
shapes.push_back(std::make_shared<Circle>());
shapes.push_back(std::make_shared<Rectangle>());
shapes.push_back(std::make_shared<TextLabel>());
// ...etc...
showAllShapes(shapes);
return 0;
}
现在让我们假设用户为我们的库制定了一个新的需求:他们想要一个正方形!
大概每个人都会马上想起自己小学的几何课。那时你的老师可能也说过,正方形是一种特殊的矩形,它有四条等长的边和四个等角(90 度角)。因此,第一个显而易见的解决方案似乎是我们从Rectangle
派生出一个新的类Square
,如图 6-2 所示。
图 6-2。
Deriving a Square from class Rectangle – a good idea?
乍一看,这似乎是一个可行的解决方案。Square
继承了Rectangle
的接口和实现。这有助于避免代码重复(参见我们在第三章中讨论的 DRY 原则),因为Square
可以很容易地重用在Rectangle
中实现的行为。
一个正方形只需要满足一个额外的简单需求,这个需求在上面的 UML 图中显示为类Square
: {width = height}
中的一个约束。这个约束意味着Square
类型的实例在任何情况下都保证它的边都是一样长的。
因此,我们首先通过从我们的Rectangle
派生来实现我们的Square
:
class Square : public Rectangle {
public:
//...
};
但其实并不是一个好的解决办法!
请注意,Square
继承了Rectangle
的所有操作。这意味着我们可以用一个Square
实例做以下事情:
Square square;
square.setHeight(10); // Err...changing only the height of a square?!
square.setEdges(10, 20); //
Uh oh!
首先,Square
的用户会非常困惑,它提供了一个带有两个参数的设置器(记住第三章中的最小惊讶原则)。他们认为:为什么有两个参数?哪个参数用于设置所有边的长度?我必须把两个参数设为相同的值吗?如果我不这么做会怎么样?
当我们执行以下操作时,情况会更加戏剧性:
std::unique_ptr<Rectangle> rectangle = std::make_unique<Square>();
// ...and somewhere else in the code...
rectangle->setEdges(10, 20);
在这种情况下,客户端代码使用有意义的 setter。矩形的两条边都可以独立操作。这并不奇怪;这正是期望。然而,结果可能是怪异的。在这样的调用之后,Square
类型的实例实际上不再是正方形,因为它有两个不同的边长。所以我们再次违反了最小惊讶原则,更糟糕的是:违反了Square
的类不变量。
然而,现在有人可能会争辩说,我们可以在类Rectangle
中将setEdges()
、setWidth()
和setHe
、声明为virtual
,并使用替代实现覆盖类Square
中的这些成员函数,这在未经请求使用的情况下会引发异常。此外,我们在类Square
中提供了一个新的成员函数setEdge()
,如下所示:
#include <stdexcept>
// ...
class IllegalOperationCall : public std::logic_error
{
public:
explicit IllegalOperationCall(const std::string& message) : logic_error(message) { }
virtual ∼IllegalOperationCall() { }
};
class Square : public Rectangle {
public:
Square() : Rectangle { 5, 5 } { }
explicit Square(const unsigned int edgeLength) : Rectangle { edgeLength, edgeLength } { }
virtual void setEdges([[maybe_unused]] const unsigned int newWidth,
[[maybe_unused]] const unsigned int newHeight) override {
throw IllegalOperationCall { ILLEGAL_OPERATION_MSG };
}
virtual void setWidth([[maybe_unused]] const unsigned int newWidth) override {
throw IllegalOperationCall { ILLEGAL_OPERATION_MSG };
}
virtual void setHeight([[maybe_unused]] const unsigned int newHeight) override {
throw IllegalOperationCall { ILLEGAL_OPERATION_MSG };
}
void setEdge(const unsigned int length) {
Rectangle::setEdges(length, length);
}
private:
static const constexpr char* const ILLEGAL_OPERATION_MSG { "Unsolicited call of a prohibited "
"operation on an instance of class Square!" };
};
Listing 6-3.A really bad implementation of Square that tries to “erase” unwanted inherited features
嗯,我认为这显然是一个非常糟糕的设计。它违反了面向对象的基本原则,即派生类不能删除基类的继承属性。这绝对不是解决我们问题的办法。首先,如果我们想使用一个Square
的实例作为一个Rectangle
,那么新的 setter setEdge()
将是不可见的。此外,所有其他的 setters 在被使用时都会抛出一个异常——这真是糟糕透了!它破坏了面向对象。
那么,这里的根本问题是什么?为什么从一个Rectangle
中派生出一个类Square
会引起这么多的困难?
解释是这样的:从Rectangle
派生Square
违背了面向对象软件设计中的一个重要原则——利斯科夫替代原则(LSP)!
美国计算机科学家芭芭拉·利斯科夫(Barbara Liskov)是麻省理工学院(MIT)的学院教授,Jeannette Wing 是卡内基梅隆大学的计算机科学教授,直到 2013 年,他们在 1994 年的一篇论文中阐述了该原理如下:
Let q(x) be the provable property of object x of type T. Then q(y) should be a provable attribute of object y of type S, where S is a subtype of T. —barbarbara lis kov,Jeanette Wing [Liskov94]
嗯,这不一定是日常使用的定义。罗伯特·c·马丁(Robert C. Martin)在 1996 年的一篇文章中把这个原则表述如下:
Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it. -Robert Martin [Martin 96]
事实上,这意味着:派生类型必须完全可以替换它们的基本类型。在我们的例子中,这是不可能的。类型为Square
的实例不能替换为Rectangle
。原因在于约束{width = height}
(所谓的类不变量)将由Square
强制执行,但是Rectangle
不能满足该约束。
Liskov 替换原则分别为类型和类层次结构规定了以下规则:
- 基类的前提条件(参见第五章关于前提条件的“预防胜于治疗”一节)不能在派生类中得到加强。
- 基类的后置条件(参见第五章中的“预防胜于治疗”一节)不能在派生类中被削弱。
- 基类的所有不变量都不能通过派生的子类来改变或违反。
- 历史约束(也称为“历史规则”):对象的(内部)状态只能通过在它们的公共接口(封装)上的方法调用来改变。由于派生类可以引入基类中不存在的新属性和方法,这些方法的引入可以允许基类中不允许的派生类的对象的状态改变。所谓的历史约束禁止这样做。例如,如果基类被设计成一个不可变对象的蓝图(见第九章关于不可变类),派生类不应该在新引入的成员函数的帮助下使这个不变性属性无效。
上图(图 6-2 )类图中泛化关系的解释(Square
和Rectangle
之间的箭头)经常翻译成“……是一个……”:Square
是一个Rectangle
。但这可能会产生误导。在数学中,可以说正方形是一种特殊的矩形,但在编程中却不是!
为了处理这个问题,客户必须知道他正在处理的是哪种特定的类型。一些开发人员现在可能会说,“没问题,这可以通过使用运行时类型信息(RTTI)来完成。”
Run-Time Type Information (RTTI)
术语运行时类型信息(有时也称为运行时类型标识)表示在运行时访问对象数据类型信息的 C++ 机制。RTTI 背后的一般概念叫做类型自省,在其他编程语言中也可以使用,比如 Java。
在 C++ 中,typeid
运算符(在头文件<typeinfo>
中定义)和dynamic_cast<T>
(参见第四章中关于 C++ 强制转换的章节)属于 RTTI。例如,要在运行时确定对象的类,您可以编写:
const std::type_info& typeInformationAboutObject = typeid(instance);
类型std::type_info
(也在 header <typeinfo>
中定义)的 const 引用现在保存关于对象的类的信息,例如,类名。从 C++11 开始,哈希代码也是可用的(std::type_info::hash_code()
),它对于引用相同类型的std::type_info
对象是相同的。
重要的是要知道 RTTI 只适用于多态的类,也就是说,至少有一个直接或通过继承的虚函数的类。此外,在某些编译器上可以打开或关闭 RTTI。例如,当使用 gcc (GNU 编译器集合)时,可以通过使用-fno-
rtti
选项禁用 RTTI。
using ShapePtr = std::shared_ptr<Shape>;
using ShapeCollection = std::vector<ShapePtr>;
//...
void resizeAllShapes(const ShapeCollection& shapes) {
try {
for (const auto& shape : shapes) {
const auto rawPointerToShape = shape.get();
if (typeid(*rawPointerToShape) == typeid(Rectangle)) {
Rectangle* rectangle = dynamic_cast<Rectangle*>(rawPointerToShape);
rectangle->setEdges(10, 20);
// Do more Rectangle-specific things here...
} else if (typeid(*rawPointerToShape) == typeid(Square)) {
Square* square = dynamic_cast<Square*>(rawPointerToShape);
square->setEdge(10);
} else {
// ...
}
}
} catch (const std::bad_typeid& ex) {
// Attempted a typeid of NULL pointer!
}
}
Listing 6-4.Just another “hack”: Usi
ng RTTI to distinguish between different types of shape during runtime
别这样!这不可能也不应该是合适的解决方案,尤其是在一个干净现代的 C++ 程序中。面向对象的许多好处,比如动态多态,都被抵消了。
Caution
每当你被迫在你的程序中使用 RTTI 来区分不同的类型时,它就是一种明显的“设计气味”,也就是说,一个糟糕的面向对象软件设计的明显标志!
此外,我们的代码会被糟糕的if-else
结构严重污染,可读性也会下降。似乎这还不够,try-catch
构造还表明可能会出错。
但是我们能做什么呢?
首先,我们应该再仔细看看正方形到底是什么。
从纯数学的观点来看,正方形可以看作是等边长的矩形。目前为止,一切顺利。但是这个定义不能直接转移到面向对象的类型层次结构中。正方形不是矩形的子类型!
相反,正方形仅仅是矩形的一种特殊状态。如果一个矩形有相同的边长,这仅仅是矩形的一种状态,那么我们通常用我们的自然语言给这样一个特殊的矩形一个特殊的名字:然后我们谈论一个正方形!
这意味着我们只需要向我们的类Rectangle
添加一个 inspector 方法来查询它的状态,允许我们放弃一个显式的类Square
。根据 KISS 原则(参见第三章),该解决方案可能完全足以满足新的要求。此外,我们可以为客户提供一个方便的设置方法,用于相等地设置两个边的长度。
class Rectangle : public Shape {
public:
// ...
void setEdgesToEqualLength(const unsigned int newLength) {
setEdges(newLength, newLength);
}
bool isSquare() const {
return width == height;
}
//...
};
Listing 6-5.A simple solution without an explicit class Square
偏爱合成而非遗传
但是,如果明确的类 s Square
是不折不扣地必需的,例如,因为有人需要它,我们能做什么呢?嗯,如果是这样的话,那么我们永远不应该从Rectangle
继承,而是从Shape
类继承,如图 6-3 所示。为了不违反 DRY 原则,我们将使用类Rectangle
的一个实例作为Square
的内部实现。
图 6-3。
The Square uses and delegates to an embedded instance of Rectangle
用源代码表示,这个类Square
的实现看起来像这样:
class Square : public Shape {
public:
Square() {
impl.setEdges(5, 5);
}
explicit Square(const unsigned int edgeLength) {
impl.setEdges(edgeLength, edgeLength);
}
void setEdge
(const unsigned int length) {
impl.setEdges(length, length);
}
virtual void moveTo(const Point& newCenterPoint) override {
impl.moveTo(newCenterPoint);
}
virtual void show() override {
impl.show();
}
virtual void hide() override {
impl.hide();
}
unsigned lomg longgetArea() const {
return impl.getArea();
}
private:
Rectangle impl;
};
Listing 6-6.The Square delegates all method calls to an embedded instance of Rectangle
也许你已经注意到了moveTo()
方法也被覆盖了。为此,moveTo()
方法也必须在Shape
类中成为虚拟的。我们必须覆盖它,因为从Shape
继承的moveTo()
在基类Shape
的centerPoint
上操作,而不是在使用的Rectangle
的嵌入式实例上操作。这是这个解决方案的一个小缺点:从基类Shape
继承的一些部分闲置着。
显然,使用这种解决方案,我们将失去将Square
的实例分配给Rectangle
的可能性:
std::unique_ptr<Rectangle> rectangle = std::make_unique<Square>(); // Compiler error!
这种解决面向对象中继承问题的方法背后的原理被称为“优先组合优先继承”(FCoI),有时也被称为“优先委托优先继承”。对于功能的重用,面向对象编程基本上有两种选择:继承(“白盒重用”)和组合或委托(“黑盒重用”)。有时候,更好的方法是将另一个类型视为黑盒,也就是说,只通过其明确定义的公共接口来使用它,而不是从该类型派生一个子类型。通过组合/委托的重用比通过继承的重用促进了类之间的松散耦合。
接口隔离原则(ISP)
我们已经知道接口是促进类之间松散耦合的一种方式。在前面关于开闭原则的章节中,我们已经看到接口是在代码中拥有扩展和变化点的一种方式。一个接口就像一个契约:类可以通过这个契约请求服务,而服务可以由履行这个契约的其他类提供。
但是,当这些契约变得太广泛时,也就是说,如果一个接口变得太宽或太“胖”,会出现什么问题呢?用一个例子可以很好地说明其后果。假设我们有以下接口:
class Bird {
public:
virtual ∼Bird() = default;
virtual void fly() = 0;
virtual void eat() = 0;
virtual void run() = 0;
virtual void tweet() = 0;
};
Listing 6-7.An interface for Birds
这个接口是由几个具体的 birds 实现的,比如由一个Sparrow
。
class Sparrow : public Bird {
public:
virtual void fly() override {
//...
}
virtual void eat() override {
//...
}
virtual void run() override {
//...
}
virtual void tweet() override {
//...
}
};
Listing 6-8.The class Sparrow overrides and implements all pure virtual member functions of Bird
目前为止,一切顺利。现在假设我们有另一个具体的Bird
: a Penguin
。
class Penguin : public Bird {
public:
virtual void fly() override {
// ???
}
//...
};
Listing 6-9.The class Penguin
虽然企鹅肯定是鸟,但它不会飞。虽然我们的接口相对较小,因为它只声明了四个简单的成员函数,但是这些声明的服务显然不能由每一种鸟提供。
接口分离原则(ISP)声明接口不应该被实现类不需要的成员函数所膨胀,或者这些类不能以有意义的方式实现。在我们上面的例子中,类Penguin
不能为Bird::fly()
提供有意义的实现,但是Penguin
被强制覆盖那个成员函数。
接口分离原则认为,我们应该将一个“胖接口”分离成更小的、高度内聚的接口。由此产生的小接口也称为角色接口。
class Lifeform {
public:
virtual void eat() = 0;
virtual void move() = 0;
};
class Flyable {
public:
virtual void fly() = 0;
};
class Audible {
public:
virtual void makeSound() = 0;
};
Listing 6-10.The three role interfaces as a better alternative to the broad Bird interface
这些小角色接口现在可以非常灵活地组合。这意味着实现类只需要为那些声明的成员函数提供有意义的功能,它们能够以合理的方式实现这些功能。
class Sparrow : public Lifeform, public Flyable, public Audible {
//...
};
class Penguin : public Lifeform, public Audible {
//...
};
Listing 6-11.The classes Sparrow and Penguin respectively implement the relevant interfaces
非循环依赖原则
有时需要两个类互相“了解”。例如,让我们假设我们正在开发一个网上商店。为了能够实现某些用例,在这个网上商店中代表客户的类必须知道它的相关帐户。对于其他用例,帐户必须能够访问其所有者,即客户。
在 UML 中,这种相互关系如图 6-4 所示。
图 6-4。
The association relationships between class Customer and class Account
这就是所谓的循环依赖。这两个阶层直接或间接地相互依赖。在这种情况下,只有两个类。涉及几个软件单元时也会出现循环依赖。
让我们看看图 6-4 所示循环依赖是如何在 C++ 中实现的。
在 C++ 中肯定行不通的是:
#ifndef CUSTOMER_H_
#define CUSTOMER_H_
#include "Account.h"
class Customer {
// ...
private:
Account customerAccount;
};
#endif
Listing 6-12.The content of file Customer.h
#ifndef ACCOUNT_H_
#define ACCOUNT_H_
#include "Customer.h"
class Account {
private:
Customer owner;
};
#endif
Listing 6-13.The content of file Account.h
我认为这个问题在这里是显而易见的。只要有人使用类Account
,或者类Customer
,他就会在编译时引发连锁反应。比如账户拥有一个客户的实例谁拥有一个账户的实例谁拥有一个客户的实例,等等等等……由于 C++ 编译器严格的处理顺序,上述实现会导致编译器错误。
例如,通过将引用或指针与前向声明结合使用,可以避免这些编译器错误。前向声明是对标识符(例如,类型,如类)的声明,而不定义该标识符的完整结构。因此,这种类型有时也被称为不完整类型。因此,它们只能用于指针或引用,而不能用于实例成员变量,因为编译器不知道它的大小。
#ifndef CUSTOMER_H_
#define CUSTOMER_H_
class Account;
class Customer {
public:
// ...
void setAccount(Account* account) {
customerAccount = account;
}
// ...
private:
Account* customerAccount;
};
#endif
Listing 6-14.The modified Customer with a forward-declared Account
ifndef ACCOUNT_H_
#define ACCOUNT_H_
class Customer;
class Account {
public:
//...
void setOwner(Customer* customer) {
owner = customer;
}
//...
private:
Customer* owner;
};
#endif
Listing 6-15.The modified Account with a forward-declared Customer
手放在心上:你对这个解决方案有没有感到有点不舒服?如果是的话,那是有充分理由的!编译器错误消失了,但是这个“修复”产生了一种不好的直觉。让我们看看这两个类是如何使用的:
#include "Account.h"
#include "Customer.h"
// ...
Account* account = new Account { };
Customer* customer = new Customer { };
account->setOwner(customer);
customer->setAccount(account);
// ...
Listing 6-16.Creating the instances of Customer and Account, and wiring them circularly together
我敢肯定,一个严重的问题是显而易见的:例如,如果 Account 的实例将被删除,但 Customer 的实例仍然存在,会发生什么情况?那么,Customer 的实例将包含一个悬空指针,即一个指向无人区的指针!使用或取消引用这样的指针会导致严重的问题,比如未定义的行为或应用程序崩溃。
前向声明对于某些事情非常有用,但是使用它们来处理循环依赖是一个非常糟糕的做法。这是一个令人毛骨悚然的变通方法,它应该隐藏一个基本的设计问题。
问题在于循环依赖本身。这是糟糕的设计。客户和账户这两个类别不能分开。因此,它们不能彼此独立使用,也不能彼此独立测试。这使得单元测试变得相当困难。
如果出现图 6-5 中描述的情况,问题会变得更加严重。
图 6-5。
The impact of circular dependencies between classes in different components
我们的类Customer
和Account
分别位于不同的组件中。也许在每个组件中有更多的类,但是这两个类有一个循环依赖。其结果是,这种循环依赖对架构级别也有负面影响。类级的循环依赖导致组件级的循环依赖。CustomerManagement
和Accounting
是紧耦合的(记住第三章中关于松耦合的部分),不能单独(重复)使用。当然,独立元件测试也不再可能。架构层次上的模块化实际上已经被降低到荒谬的程度。
非循环依赖原则声明组件或类的依赖图应该没有循环。循环依赖是紧密耦合的一种糟糕形式,应该不惜一切代价避免。
别担心!打破循环依赖总是可能的,下一节将分别展示如何避免打破它们。
从属倒置原则
在上一节中,我们了解到循环依赖是不好的,在任何情况下都应该避免。和许多其他有不必要的依赖的问题一样,接口的概念(在 C++ 中,接口是用抽象类模拟的)是我们处理类似前一种情况的朋友。
因此,我们的目标应该是打破循环依赖,同时不丧失 class Customer 访问 Account 的必要可能性,反之亦然。
第一步是我们不再允许两个类中的一个直接访问另一个类。相反,我们只允许通过接口进行这样的访问。基本上,从两个类(Customer
或Account
)中的哪一个提取接口并不重要。我决定从Customer
中提取一个名为Owner
的接口。例如,Owner
接口只声明了一个纯虚拟成员函数,这个函数必须被实现这个接口的类覆盖。
#ifndef OWNER_H_
#define OWNER_H_
#include <memory>
#include <string>
class Owner {
public:
virtual ∼Owner() = default;
virtual std::string getName() const = 0;
};
using OwnerPtr = std::shared_ptr<Owner>;
#endif
Listing 6-17.An exemplary implementation of the new interface Owner
(File: Owner.h)
#ifndef CUSTOMER_H_
#define CUSTOMER_H_
#include "Owner.h"
#include "Account.h"
class Customer : public Owner {
public:
void setAccount(AccountPtr account) {
customerAccount = account;
}
virtual std::string getName() const override {
// return the Customer's name here...
}
// ...
private:
AccountPtr customerAccount;
// ...
};
using CustomerPtr = std::shared_ptr<Customer>;
#endif
Listing 6-18.The class Customer that implements i
nterface Owner (File: Customer.h)
从上面显示的 Customer 类的源代码中可以很容易地看出,Customer 仍然知道它的帐户。但是,当我们现在看一看更改后的 class Account 实现时,就不再依赖于客户了:
#ifndef ACCOUNT_H_
#define ACCOUNT_H_
#include "Owner.h"
class Account {
public:
void setOwner(OwnerPtr owner) {
this->owner = owner;
}
//...
private:
OwnerPtr owner;
};
using AccountPtr = std::shared_ptr<Account>;
#endif
Listing 6-19.The changed implementation of
class Account (File: Account.h)
作为一个 UML 类图,在类层次上改变的设计如图 6-6 所示。
图 6-6。
The introduction of the interface has eliminated the circular dependency on class level
太棒了!通过重新设计的第一步,我们现在已经实现了在类级别上不再有循环依赖。现在,类Account
对类Customer
一无所知。但是当我们爬上图 6-7 所示的组件层时,情况会是怎样的呢?
图 6-7。
The circular dependency between the components is still there
不幸的是,组件之间的循环依赖还没有被打破。这两个关联关系仍然是从一个组件中的一个元素到另一个组件中的一个元素。然而,实现这个目标的步骤非常简单:我们只需要将接口Owner
重新定位到其他组件中,如图 6-8 所示。
图 6-8。
Relocating the interface also fixes the circular dependency problem on an architecture level
太好了!现在组件之间的循环依赖已经消失了。Accounting
组件不再依赖于CustomerManagement
,因此模块化的质量得到了显著提高。此外,Accounting
组件现在可以独立测试。
事实上,两个组件之间的不良依赖并没有真正消除。相反,通过引入接口Owner
,我们甚至在类级别上获得了更多的依赖。我们真正做的是颠倒依赖性。
依赖倒置原则(DIP)是一种面向对象的设计原则,用于分离软件模块。该原则指出,面向对象设计的基础不是具体软件模块的特殊属性。相反,它们的公共特性应该被整合到一个共享的抽象中(例如,一个接口)。罗伯特·c·马丁(Robert c . Martin)a . k .“鲍勃大叔”将原则表述如下:
A. advanced modules should not depend on low-level modules. Both should rely on abstraction. B. Abstraction should not depend on details. Details should depend on abstraction. -Robert Martin [Martin 03]
Note
此引用中的术语“高级模块”和“低级模块”可能会引起误解。它们不一定指它们在分层架构中的概念位置。在这种特定情况下,高级模块是需要来自另一个模块(所谓的低级模块)的外部服务的软件模块。高级模块是调用动作的模块,低级模块是执行动作的模块。在一些情况下,这两类模块也可以位于软件架构的不同级别(例如,层),或者如在我们的示例中位于不同的组件中。
依赖倒置的原则是被认为是好的面向对象设计的基础。它通过抽象(例如接口)来定义所提供和所需的外部服务,从而促进了可重用软件模块的开发。一致地应用于我们上面讨论的情况,我们也必须相应地重新设计Customer
和Account
之间的直接依赖,如图 6-9 所示。
图 6-9。
Dependency Inversion Principle applied
两个组件中的类都完全依赖于抽象。因此,对于Accounting
组件的客户端来说,哪个类需要Owner
接口或提供Account
接口已经不再重要了(还记得第三章中关于信息隐藏的部分)——我已经通过引入一个名为AnyClass
的类暗示了这种情况,这个类实现了Account
并使用了Owner
。
例如,如果我们现在必须更改或替换Customer
类,例如,因为我们想要将Accounting
安装到测试夹具上进行组件测试,那么不需要更改AnyClass
类来实现它。这也适用于相反的情况。
依赖倒置原则允许软件开发人员有目的地设计模块之间的依赖关系,即定义依赖关系指向哪个方向。你想反转组件之间的依赖关系,即Accounting
应该依赖CustomerManagement
?没问题:只需将两个接口从Accounting
重新定位到CustomerManagement
,依赖性就会改变。降低代码的可维护性和可测试性的不良依赖可以被优雅地重新设计和减少。
不要和陌生人说话(德米特里定律)
你还记得我在本章前面谈到的那辆车吗?我把这辆车描述为几个部分的组合,例如,车身、发动机、齿轮等等。我已经解释过,这些部分又可以由部分组成,而部分本身又可以由几个部分组成,等等。这就导致了一辆车的分层自上而下的分解。当然,一辆车可以有一个想要使用它的司机。
可视化为 UML 类图,汽车分解的摘录可以如图 6-10 所示。
图 6-10。
The hierarchical decomposition of a simple car
根据第五章讨论的单一责任原则,一切都很好,因为每个单一类都有明确定义的责任。
现在我们假设司机想驾驶汽车。这可以在类Driver
中实现如下:
class Driver {
public:
// ...
void drive(Car& car) const {
Engine& engine = car.getEngine();
FuelPump& fuelPump = engine.getFuelPump();
fuelPump.pump();
Ignition& ignition = engine.getIgnition();
ignition.powerUp();
Starter& starter = engine.getStarter();
starter.revolve();
}
// ...
};
Listing 6-20.An excerpt from the implementation of class Driver
这里有什么问题?嗯,作为一名汽车司机,你会期望你必须直接接触你的汽车发动机,打开燃油泵,打开点火系统,并让起动机旋转吗?我甚至更进一步:如果你只是想驾驶你的汽车,你甚至对你的汽车由这些部件组成的事实感兴趣吗?!
我很确定你的明确答案会是:不!
现在让我们看一下图 6-11 ,它描绘了 UML 类图中的相关部分,看看这个实现对设计有什么影响。
图 6-11。
The bad dep endencies of class Driver
从上图中可以很容易地看出,类Driver
有许多令人尴尬的依赖关系。Driver
并不仅仅依赖于Engine
。这个类与Engine
的某些部分也有一些依赖关系。很容易想象这有一些不利的后果。
例如,如果内燃机被电力传动系统取代,会发生什么?电力驱动没有燃油泵、点火系统和起动机。因此,结果将是类驱动程序的实现必须进行调整。这违反了开闭原则(见前面一节)。此外,所有将Car
和Engine
的内部暴露给环境的公共 getters 都违反了信息隐藏原则(参见第三章)。
本质上,上述软件设计违反了德米特里定律(LoD),也称为最少知识原则。得墨忒耳定律可以被认为是一个原则,它说的是“不要和陌生人说话”,或者“只和你的近邻说话。”这个原则表明你应该进行害羞编程,目标是控制面向对象设计中的通信结构。
德米特里定律假设了以下规则:
- 成员函数可以直接调用自己类范围内的其他成员函数。
- 允许成员函数直接调用其类范围内的成员变量上的成员函数。
- 如果成员函数有参数,则允许该成员函数直接调用这些参数的成员函数。
- 如果成员函数创建局部对象,则允许该成员函数调用这些局部对象上的成员函数。
如果上述四种成员函数调用中的一种返回的对象在结构上比该类的近邻更远,则禁止对这些对象调用成员函数。
Why This Rule Is Named Law Of Demeter
这个原则的名字可以追溯到关于面向方面软件开发的 Demeter 项目,在那里制定并严格应用了这些规则。Demeter 项目是 20 世纪 80 年代后期的一个研究项目,主要关注于通过自适应编程使软件更易于维护和扩展。德米特里定律是由伊恩·m·霍兰德和卡尔·利伯海尔发现并提出的,他们在那个项目中工作。在希腊神话中,得墨忒耳是宙斯和农业女神的妹妹。
那么,在我们的例子中,现在消除不良依赖的解决方案是什么呢?很简单,我们应该问自己:一个司机到底想做什么?答案很简单:他想发动汽车!
class Driver {
public:
// ...
void drive(Car& car) const {
car.start();
}
// ...
};
汽车用这个启动命令做什么?也很简单:它将这个方法调用委托给它的引擎。
class Car {
public:
// ...
void start() {
engine.start();
}
// ...
private:
Engine engine;
};
最后但同样重要的是,引擎知道如何通过在它的部件上以正确的顺序调用适当的成员函数来执行启动过程,这些部件在软件设计中是它的近邻。
class Engine {
public:
// ...
void start() {
fuelPump.pump();
ignition.powerUp();
starter.revolve();
}
// ...
private:
FuelPump fuelPump;
Ignition ignition;
Starter starter;
};
在图 6-12 中描绘的类图中,可以非常清楚地看到这些变化对面向对象设计的积极影响。
图 6-12。
Less dependencies a fter the application of the Law of Demeter
司机对汽车零部件令人讨厌的依赖消失了。相反,驾驶员可以启动汽车,而不用考虑汽车的内部结构。类Driver
不再知道有一个Engine
,一个FuelPump
等等。所有那些糟糕的公共 getter 函数,那些向所有其他类显示汽车内部或引擎的函数,都消失了。这也意味着Engine
及其部件的变化只会产生非常局部的影响,不会直接导致整个设计的级联变化。
在设计软件时遵循德米特定律可以显著减少依赖的数量。这导致了松散耦合,并助长了信息隐藏原则和开闭原则。与许多其他原则和规则一样,也可能有一些合理的例外,在这些例外中,开发人员必须出于非常充分的理由偏离这一原则。
避免贫血类
在几个项目中,我见过如下的类:
class Customer {
public:
void setId(const unsigned int id);
unsigned int getId() const;
void setForename(const std::string& forename);
std::string getForename() const;
void setSurname(const std::string& surname);
std::string getSurname() const;
//...more setters/getters here...
private:
unsigned int id;
std::string forename;
std::string surname;
// ...more attributes here...
};
Listing 6-21.A class without functionality that serves only as a bucket for a bunch of data
这个域类代表任意软件系统中的客户,不包含任何逻辑。逻辑在不同的地方,甚至是代表Customer
专有功能的逻辑,也就是说,只对Customer
的属性进行操作。
这样做的程序员使用对象作为一堆数据的袋子。这只是数据结构的过程化编程,与面向对象无关。所有这些设置器/获取器都非常愚蠢,严重违反了信息隐藏原则——实际上我们可以在这里使用一个简单的 C 结构(关键字:struct
)。
这样的班级被称为贫血班,应该不惜一切代价避免。它们经常出现在一个反模式的软件设计中,Martin Fowler 称之为贫血域模型。它与面向对象设计的基本思想完全相反,面向对象设计是将数据和与数据一起工作的功能组合成内聚的单元。
只要你不违反德米特定律,你应该把逻辑也插入到(域)类中,如果这个逻辑是在那个类的属性上操作或者只与那个类的近邻合作。
告诉,不要问!
“告诉,不要问”的原则与前面讨论的得墨忒耳定律有一些相似之处。这个原则是对所有这些公共 get 方法的“宣战”,它揭示了一个对象的内部状态。也告诉不要问促进封装,加强信息隐藏(见第三章),但首要原则是关于强内聚力。
让我们来看一个小例子。假设前面例子中的成员函数Engine::start()
实现如下:
class Engine {
public:
// ...
void start() {
if (! fuelPump.isRunning()) {
fuelPump.powerUp();
if (fuelPump.getFuelPressure() < NORMAL_FUEL_PRESSURE) {
fuelPump.setFuelPressure(NORMAL_FUEL_PRESSURE);
}
}
if (! ignition.isPoweredUp()) {
ignition.powerUp();
}
if (! starter.isRotating()) {
starter.revolve();
}
if (engine.hasStarted()) {
starter.openClutchToEngine();
starter.stop();
}
}
// ...
private:
FuelPump fuelPump;
Ignition ignition;
Starter starter;
static const unsigned int NORMAL_FUEL_PRESSURE { 120 };
};
Listing 6-22.A possible, but not recommendable implementation of the Engine::start() member function
显而易见,类Engine
的start()
方法从其部分查询许多状态,并做出相应的响应。此外,Engine
还会检查燃油泵的燃油压力,如果压力过低,则会进行调整。这也意味着Engine
必须知道正常燃油压力的值。由于 if 分支众多,圈复杂度很高。
“告诉不要问”原则提醒我们,如果一个对象能够自己做出决定,我们就不应该要求这个对象发布关于其内部状态的信息,并决定在这个对象之外做什么。基本上,这个原则提醒我们,在面向对象中,数据和对这些数据的操作,是要被组合成内聚的单元的。
如果我们将这个原则应用到我们的例子中,Engine::start()
方法将只告诉它的部分它们应该做什么:
class Engine {
public:
// ...
void start() {
fuelPump.pump();
ignition.powerUp();
starter.revolve();
}
// ...
private:
FuelPump fuelPump;
Ignition ignition;
Starter starter;
};
Listing 6-23.Delegating of stages of the starting procedure to the responsible parts of the engine
零件可以自己决定如何执行该命令,因为他们有相关的知识,例如,FuelPump
可以做所有它必须做的事情来增加燃油压力:
class FuelPump {
public:
// ...
void pump() {
if (! isRunning) {
powerUp();
setNormalFuelPressure();
}
}
// ...
private:
void powerUp() {
//...
}
void setNormalFuelPressure() {
if (pressure != NORMAL_FUEL_PRESSURE) {
pressure = NORMAL_FUEL_PRESSURE;
}
}
bool isRunning;
unsigned int pressure;
static const unsigned int NORMAL_FUEL_PRESSURE { 120 };
};
Listing 6-24.An excerpt from the FuelPump class
当然,并不是所有的吸气剂天生就是坏的。有时有必要从对象中检索信息,例如,如果该信息应该显示在图形用户界面上。
避免静态类成员
我可以想象很多读者现在在想:静态成员变量和静态成员函数到底有什么问题?
嗯,也许您还记得我在前面关于小班的章节中描述的上帝类反模式。在那里,我已经描述了实用程序类通常会变成如此巨大的“神类”此外,这些实用程序类通常也由许多静态成员函数组成,甚至经常没有例外。对此,一个很容易理解的理由是:我为什么要强迫实用程序类的用户创建它的一个实例?因为这样的类为不同的目的提供了五颜六色的不同功能,顺便说一下,这是弱内聚的标志,我为这些杂乱的东西创建了一个特殊的模式名:垃圾商店反模式。根据在线百科全书维基百科,旧货店是一种零售商店,类似于廉价商店,以低廉的价格提供各种各样的常用商品。
class JunkShop {
public:
// ...many public utility functions...
static int oneOfManyUtilityFunctions(int param);
// ...more public utility functions...
};
Listing 6-25.Excerpt from some utility class
#include "JunkShop.h"
class Client {
// ...
void doSomething() {
// ...
y = JunkShop::oneOfManyUtilityFunctions(x);
// ...
}
};
Listing 6-26.Another class that uses the Utility class
第一个问题是,您的代码变得与这些“垃圾商店”中的所有静态助手函数紧密相连从上面的例子可以很容易地看出,在另一个软件模块的实现中的某个地方使用了来自实用程序类的这种静态函数。因此,没有简单的方法可以用别的方法来替换这个函数调用。但是在单元测试中(见第二章),这正是你想要做的。
此外,静态成员函数培养了过程化编程风格。将它们与静态变量结合使用会使面向对象变得荒谬。借助静态成员变量在类的所有实例中共享相同的状态本质上不是 OOP,因为它打破了封装,因为对象不再完全控制它的状态。
当然,C++ 不是 Java 或者 C# 那样的纯面向对象的编程语言,基本不禁止用 C++ 写过程化代码。但是当你想这样做的时候,你应该对自己诚实,因此使用简单的独立过程,分别是函数、全局变量和名称空间。
我的建议是尽量避免静态成员变量和成员函数。
这个规则的一个例外是类的私有常量,因为它们是只读的,不代表对象的状态。另一个例外是工厂方法,即创建对象实例的静态成员函数,通常是类类型的实例,也作为静态成员函数的命名空间。
七、函数式编程
几年来,编程范式经历了一次复兴,这通常被视为面向对象的一种逆流。这个演讲是关于函数式编程的。
最早的函数式编程语言之一是 LISP(大写的“Lisp”是一种更古老的拼写,因为该语言的名称是“列表处理”的缩写),它是由美国计算机科学家和认知科学家约翰·麦卡锡于 1958 年在麻省理工学院(MIT)设计的。麦卡锡还创造了“人工智能”(AI)这个术语,他使用 Lisp 作为人工智能应用程序的编程语言。Lisp 基于所谓的 Lambda 演算(λ calculus),一种由美国数学家阿隆佐·邱奇在 20 世纪 30 年代提出的正式模型(见下面的侧栏)。
事实上,Lisp 是计算机编程语言的一个家族。Lisp 的各种方言在过去已经出现。例如,每个使用过著名的 Emacs 文本编辑器家族成员(例如 GNU Emacs 或 X Emacs)的人都知道 Emacs Lisp 这种方言,它被用作扩展和自动化的脚本语言。
值得注意的是,在 Lisp 之后开发的函数式编程语言包括:
- Scheme:一种带有静态绑定的 Lisp 方言,在 20 世纪 70 年代由 MIT 人工智能实验室(AI Lab)开发。
- 米兰达:第一个被商业支持的纯粹懒惰的函数式语言。
- Haskell:一种通用的纯函数式编程语言,以美国逻辑学家和数学家哈斯凯尔·布鲁克斯·加里的名字命名。
- Erlang:由瑞典电信公司 Ericsson 开发,主要致力于构建大规模可伸缩和高可靠性的实时软件系统。
- F#(读作 F sharp):一种多种编程语言,也是微软的成员。NET 框架。F# 的主要范例是函数式编程,但它也允许开发人员切换到。网络生态系统。
- Clojure:由 Rich Hickey 创建的 Lisp 编程语言的现代方言。Clojure 是纯功能性的,运行在 Java 虚拟机和公共语言运行时(CLR 微软的运行时环境。NET 框架)。
The Lambda Calculus
很难找到对 Lambda 微积分的无痛介绍。许多关于这个主题的文章都是非常科学的,需要很好的数学和逻辑知识。甚至我也不会试图在这里解释 Lambda 微积分,因为这不是本书的主要重点。但是你可以在网上找到无数的解释;只要问一下你信任的搜索引擎,你就会得到数百个搜索结果。
仅此而已:Lambda 演算可以被认为是可能的最简单和最小的编程语言。它仅由两部分组成:一个单一的函数定义模式和一个单一的转换规则。这两个组件足以为函数式编程语言(如 LISP、Haskell、Clojure 等)的形式描述提供一个通用模型。
到今天为止,函数式编程语言仍然没有像它们的命令式亲戚一样被广泛使用,例如像面向对象的语言,但是它们的传播在增加。例如 JavaScript 和 Scala,它们都是多种语言(也就是说,它们不是纯粹的函数式语言),但是由于它们的函数式编程能力,它们变得越来越流行,尤其是在 web 开发中。
这足以让我们更深入地研究这个主题,探索这种编程风格到底是怎么回事,以及现代 C++ 在这个方向上提供了什么。
什么是函数式编程?
很难为函数式编程(有时缩写为 FP)找到一个普遍接受的定义。通常,人们认为函数式编程是一种编程风格,其中整个程序完全由纯函数构建而成。这立即引出了一个问题:在这种情况下,“纯函数”是什么意思?好吧,我们将在下一节讨论这个问题。然而,基本上这是正确的:函数式编程的基础是数学意义上的函数。程序是由函数的组合、函数的求值和函数链构成的。
就像面向对象一样(见第六章),函数式编程也是一种编程范式。这意味着它是一种思考软件构建的方式。然而,函数式编程范式也经常被所有那些归因于它的积极属性所定义。与其他编程范例(尤其是面向对象)相比,这些被认为是有利的属性如下:
- 通过避免(全局)共享的可变状态,没有副作用。在纯函数式编程中,函数调用没有任何副作用。纯函数的这一重要性质将在下一节“什么是函数”中详细讨论
- 不可变的数据和对象。在纯函数式编程中,所有的数据都是不可变的,也就是说,一旦数据结构被创建,就永远不能被改变。相反,如果我们将一个函数应用于一个数据结构,那么就会创建一个新的数据结构,这个新的数据结构或者是旧数据结构的变体。一个令人愉快的结果是,不可变数据具有线程安全的巨大优势。
- 函数合成和高阶函数。在函数式编程中,函数可以像数据一样对待。你可以在变量中存储一个函数。您可以将一个函数作为参数传递给其他函数。函数可以作为其他函数的结果返回。函数可以很容易地链接起来。换句话说:函数是语言的一等公民。
- 更好、更容易的并行化。并发基本上很难。软件设计师必须注意多线程环境中的许多事情,而当只有一个执行线程时,她通常不必担心这些事情。在这样的程序中寻找漏洞是非常痛苦的。但是,如果函数的调用从来没有任何副作用,如果没有全局状态,如果我们只处理不可变的数据结构,那么使一个软件并行就容易得多。相反,对于命令式语言,如面向对象的语言,以及其经常可变的状态,您需要锁定和同步机制来保护数据不被几个线程同时访问和操作(参见第九章中的“不变性的力量”一节,了解如何在 C++ 中创建不可变的类或对象)。
- 容易测试。如果纯函数具有上面提到的所有积极性质,它们也非常容易测试。没有必要在测试用例中考虑全局可变状态或其他副作用。
我们将会看到,用 C++ 中的函数式风格编程不能自动地完全保证所有这些积极的方面。例如,如果我们需要一个不可变的数据类型,我们必须这样设计它,如第九章所解释的。但现在让我们更深入地探讨这个话题,让我们讨论一下中心问题:函数式编程中的函数是什么?
什么是函数?
在软件开发中,我们可以找到许多被命名为“功能”的东西例如,软件应用程序提供给用户的一些功能通常也称为程序的功能。在 C++ 中,类的方法有时被称为成员函数。计算机程序的子程序通常被认为是函数。毫无疑问,这些例子在某种程度上也是“函数”,但不是我们在函数式编程中处理的函数。
当我们谈论函数式编程中的函数时,我们谈论的是真正的数学函数。这意味着我们将函数视为一组输入参数和一组允许的输出参数之间的关系,由此每组输入参数与一组输出参数正好相关。作为一个简单而通用的公式,函数是一个表达式,如图 7-1 所示。
图 7-1。
The function f maps x to y
这个简单的公式定义了任何函数的基本模式。它表示 y 的值依赖于,并且仅仅依赖于,x 的值,还有一点很重要,对于相同的 x 值,y 的值也总是相同的!换句话说,函数 f 将 x 的任何可能值映射到 y 的唯一值。在数学和计算机编程中,这也称为引用透明性。
Referential Transparency
函数式编程的一个重要优点是纯函数总是透明的。
术语“参照透明性”起源于分析哲学,这是自 20 世纪初以来发展起来的某些哲学运动的总称。分析哲学基于一种传统,最初主要通过理想语言(形式逻辑)或通过分析日常使用的日常语言来运作。“参照透明”这个术语是由美国哲学家和逻辑学家威拉德·范·奥曼·奎因(1908-2000)提出的。
如果一个函数是引用透明的,这意味着无论何时我们用相同的输入值调用这个函数,我们将总是得到相同的输出。用真正的函数式语言编写的函数,计算一个表达式并返回它的值,除此之外什么也不做。换句话说,我们在理论上能够用函数调用的结果值直接替换它,这种改变不会有任何影响。这使我们能够将功能链接在一起,就像它们是黑盒一样。
参照透明性直接把我们引向纯函数的概念。
纯函数与不纯函数
下面是一个简单的 C++ 纯函数的例子:
double square(const double value) noexcept {
return value * value;
};
Listing 7-1.A simple example of a pure function in C++
很容易看出,square()
的输出值完全取决于传递给函数的参数value
,所以用同一个参数value
调用square()
两次,每次都会产生相同的结果。我们没有副作用,因为如果这个函数的任何调用被完成,它不会留下任何“污垢”来影响对square()
的后续调用。这种函数完全独立于外部状态,没有任何副作用,对于相同的输入总是产生相同的输出,具体地说,是指涉透明的,这种函数被称为纯函数。
相反,命令式编程范例(如过程式或面向对象的编程)不提供这种无副作用的保证,如下例所示:
#include <iostream>
class Clazz {
public:
int functionWithSideEffect(const int value) noexcept {
return value * value + someKindOfMutualState++;
}
private:
int someKindOfMutualState { 0 };
};
int main() {
Clazz instanceOfClazz { };
std::cout << instanceOfClazz.functionWithSideEffect(3) << std::endl; // Output: "9"
std::cout << instanceOfClazz.functionWithSideEffect(3) << std::endl; // Output: "10"
std::cout << instanceOfClazz.functionWithSideEffect(3) << std::endl; // Output: "11"
return 0;
}
Listing 7-2.An example demonstrating that member functions of classes can cause side effects
在这种情况下,每次调用名字为Clazz::functionWithSideEffect()
的成员函数都会改变类Clazz
实例的内部状态。因此,尽管函数参数的给定参数总是相同的,但是每次调用这个成员函数都会返回不同的结果。在使用由过程操作的全局变量的过程编程中,也可以产生类似的效果。即使调用时总是使用相同的参数,也能产生不同输出的函数称为不纯函数。一个函数是不纯函数的另一个明显标志是,在不使用返回值的情况下调用它是有意义的。如果你能做到这一点,这个功能一定有任何副作用。
在单线程执行环境中,全局状态可能会导致一些问题和痛苦。但是现在想象一下,你有一个多线程的执行环境,有几个线程在运行,以不确定的顺序调用函数?在这样的环境中,实例的全局状态或对象范围的状态通常是有问题的,并且可能导致不可预测的行为或细微的错误。
现代 C++ 中的函数式编程
信不信由你,函数式编程一直是 C++ 的一部分!有了这种多种语言,即使是用 C++98,你也总能用函数式风格编程。我可以凭良心断言这一点的原因是,自从 C++ 开始以来,就存在已知的模板元编程(TMP)(顺便说一下,TMP 是一个非常复杂的主题,因此对许多开发人员来说是一个挑战,即使是技术娴熟、经验丰富的开发人员也是如此)。
用 C++ 模板进行函数式编程
许多 C++ 开发人员都知道,模板元编程是一种技术,在编译器将源代码转换为目标代码之前的一个步骤中,编译器使用所谓的模板来生成 C++ 源代码。许多程序员可能没有意识到的事实是,模板元编程是函数式编程,并且它是图灵完备的。
Turing Completeness
术语“图灵全集”以著名的英国计算机科学家、数学家、逻辑学家和密码分析学家艾伦·图灵(1912-1954)的名字命名,通常用于定义什么使一种语言成为“真正的”编程语言。如果你可以用一种编程语言解决任何可能的问题,并且理论上可以用图灵机来计算,那么这种编程语言就是图灵完备的。图灵机是艾伦·图灵发明的一种抽象的理论机器,作为计算的理想化模型。
实际上,没有一个计算机系统是真正的图灵完备的。原因是理想的图灵完备性需要无限的内存和无限的递归,而这是今天的计算机系统所不能提供的。因此,一些系统通过模拟无界存储器来近似图灵完全性,但是受到底层硬件的物理限制的约束。
作为证明,我们将只使用 TMP 计算两个整数的最大公约数(GCD)。两个都不为零的整数的 GCD 是两个给定整数相除的最大正整数。
01 #include <iostream>
02
03 template< unsigned int x, unsigned int y >
04 struct GreatestCommonDivisor {
05 static const unsigned int result = GreatestCommonDivisor< y, x % y >::result;
06 };
07
08 template< unsigned int x >
09 struct GreatestCommonDivisor< x, 0 > {
10 static const unsigned int result = x;
11 };
12
13 int main() {
14 std::cout << "The GCD of 40 and 10 is: " << GreatestCommonDivisor<40u, 10u>::result <<
15 std::endl;
16 std::cout << "The GCD of 366 and 60 is: " << GreatestCommonDivisor<366u, 60u>::result <<
17 std::endl;
18 return 0;
19 }
Listing 7-3.Calculating the greatest common divisor using template metaprogramming
这是我们的程序生成的输出:
The GCD of 40 and 10 is: 10
The GCD of 366 and 60 is: 6
这种在编译时使用模板计算 GCD 的方式的显著之处在于,它是真正的函数式编程。使用的两个类模板完全没有状态。没有可变变量,这意味着一旦变量被初始化,就不能改变它的值。在模板实例化过程中,一个递归过程被启动,当第 9 - 11 行的专门化类模板开始起作用时,这个过程停止。而且,正如上面已经提到的,我们在模板元编程中有图灵完备性,这意味着任何可以想到的计算都可以在编译时使用这种技术来完成。
嗯,模板元编程无疑是一个强大的工具,但也有一些缺点。特别是,如果使用大量的模板元编程,代码的可读性和可理解性会受到严重影响。TMP 的语法和习惯用法很难理解,更不用说当出现问题时那些广泛的、经常是模糊的错误消息了。当然,随着模板元编程的广泛使用,编译时间也会增加。因此,TMP 当然是一种设计和开发泛型库的适当方法(参见 C++ 标准库),但是如果需要这种类型的泛型编程(例如,最小化代码重复),则应该只在现代和精心制作的应用程序代码中使用。
顺便说一下,从 C++11 开始,不再需要在编译时使用模板元编程进行计算。借助常量表达式(constexpr
);参见第五章中关于编译时计算的部分)GCD 可以很容易地作为一个普通的递归函数来实现,如下例所示:
constexpr unsigned int greatestCommonDivisor(const unsigned int x,
const unsigned int y) noexcept {
return y == 0 ? x : greatestCommonDivisor(y, x % y);
}
Listing 7-4.A GCD function using recursion that can be evaluated at compile time
对了,这背后的数学算法叫做欧几里德算法,或者欧几里德算法,以古希腊数学家欧几里德命名。
在 C++17 中,数值算法std::gcd()
已经成为 C++ 标准库的一部分(在头文件<numeric>
中定义),因此不再需要自己实现它。
#include <iostream>
#include <numeric>
int main() {
constexpr auto result = std::gcd(40, 10);
std::cout << "The GCD of 40 and 10 is: " << result << std::endl;
return 0;
}
Listing 7-5.Using function std::gcd from header <numeric>
类似函数的对象(仿函数)
在 C++ 中,从一开始就有可能定义和使用所谓的类函数对象,简称为函子(Functors)。从技术上讲,函子或多或少只是一个定义括号运算符的类,也就是operator()
。在实例化这些类之后,它们就可以像函数一样使用了。
根据operator()
是没有参数、一个参数还是两个参数,函子被称为生成器、一元函数或二元函数。我们先来看一个发电机。
发电机
正如“生成器”这个名字所揭示的,这种类型的函子是用来产生某种东西的。
class IncreasingNumberGenerator {
public:
int operator()() noexcept { return number++; }
private:
int number { 0 };
};
Listing 7-6.An example of a Generator, a functor that is called with no argument
工作原理相当简单:每调用一次IncreasingNumberGenerator::operator()
,就将成员变量number
的实际值返回给调用者,然后将这个成员变量的值加 1。以下使用示例在标准输出中打印数字 0 到 2 的序列:
int main() {
IncreasingNumberGenerator numberGenerator { };
std::cout << numberGenerator() << std::endl;
std::cout << numberGenerator() << std::endl;
std::cout << numberGenerator() << std::endl;
return 0;
}
还记得我在第五章的算法部分引用的肖恩·帕伦特的话吗:没有原始循环!为了用一定数量的递增值填充std::vector<T>
,我们不应该实现自己手工制作的循环。相反,我们可以使用在 header <algorithm>
中定义的std::generate
,这是一个函数模板,它为特定范围内的每个元素分配一个由给定的生成器对象生成的值。因此,我们可以编写以下简单易读的代码,使用我们的IncreasingNumberGenerator
用递增的数字序列填充一个向量:
#include <algorithm>
#include <vector>
using Numbers = std::vector<int>;
int main() {
const std::size_t AMOUNT_OF_NUMBERS { 100 };
Numbers numbers(AMOUNT_OF_NUMBERS);
std::generate(std::begin(numbers), std::end(numbers), IncreasingNumberGenerator());
// ...now 'numbers' contain values from 0 to 99...
return 0;
}
Listing 7-7.Filling a vector with an increasing number sequence using std::generate
不难想象,这些函子并不满足纯函数的严格要求。生成器通常有一个可变状态,也就是说,当调用operator()
时,这些函子通常会产生一些副作用。在我们的例子中,可变状态由私有成员变量IncreasingNumberGenerator::number
表示,它在每次调用括号操作符后递增。
Tip
头<numeric>
已经包含了一个函数模板std::iota()
,它以编程语言 APL 中的函数符号⍳ (Iota)命名,它不是一个生成器函子,但它可以用来以一种优雅的方式用一个升序的值序列填充一个容器。
Generator 类型的类似函数的对象的另一个例子是下面的随机数生成器仿函数模板。该函子封装了初始化和使用伪随机数发生器(PRNG)所必需的所有东西,该伪随机数发生器基于所谓的 Mersenne Twister 算法(在标题<random>
中定义)。
#include <random>
template <typename NUMTYPE>
class RandomNumberGenerator {
public:
RandomNumberGenerator() {
mersenneTwisterEngine.seed(randomDevice());
}
NUMTYPE operator()() {
return distribution(mersenneTwisterEngine);
}
private:
std::random_device randomDevice;
std::uniform_int_distribution<NUMTYPE> distribution;
std::mt19937_64 mersenneTwisterEngine;
};
Listing 7-8.A generator functor class template, encapsulating a pseudorandom number generator
这就是仿函数RandomNumberGenerator
的用法:
#include "RandomGenerator.h"
#include <algorithm>
#include <functional>
#include <iostream>
#include <vector>
using Numbers = std::vector<short>;
const std::size_t AMOUNT_OF_NUMBERS { 100 };
Numbers createVectorFilledWithRandomNumbers() {
RandomNumberGenerator<short> randomNumberGenerator { };
Numbers randomNumbers(AMOUNT_OF_NUMBERS);
std::generate(begin(randomNumbers), end(randomNumbers), std::ref(randomNumberGenerator));
return randomNumbers;
}
void printNumbersOnStdOut(const Numbers& randomNumbers) {
for (const auto& number : randomNumbers) {
std::cout << number << std::endl;
}
}
int main() {
Numbers randomNumbers = createVectorFilledWithRandomNumbers();
printNumbersOnStdOut(randomNumbers);
return 0;
}
Listing 7-9.Filling a vector with 100 random numbers
一元函数
接下来,让我们看一个一元函数类对象的例子,它是一个仿函数,其 paranthesis 运算符只有一个参数。
class ToSquare {
public:
constexpr int operator()(const int value) const noexcept { return value * value; }
};
Listing 7-10.An example for a unary functor
顾名思义,这个仿函数对圆括号运算符中传递给它的值进行平方运算。将operator()
声明为const
,这是一个指示符,表明它的行为像一个纯函数,也就是说,一个调用不会有副作用。这并不一定总是如此,因为,当然,一元函子也可以有私有成员变量,因此也可以有可变状态。
使用ToSquare
仿函数,我们现在可以扩展上面的例子,并将其应用于带有升序整数序列的向量。
#include <algorithm>
#include <vector>
using Numbers = std::vector<int>;
int main() {
const std::size_t AMOUNT_OF_NUMBERS = 100;
Numbers numbers(AMOUNT_OF_NUMBERS);
std::generate(std::begin(numbers), std::end(numbers), IncreasingNumberGenerator());
std::transform(std::begin(numbers), std::end(numbers), std::begin(numbers), ToSquare());
// ...
return 0;
}
Listing 7-11.All 100 numbers in a vector are squared
所使用的算法std::transform
(在标题<algorithm>
中定义)将给定的函数或函数对象应用于一个范围(由前两个参数定义),并将结果存储在另一个范围(由第三个参数定义)。在我们的例子中,两个范围是相同的。
述语
一种特殊的函子是谓词。如果一元函子有一个参数和一个指示某个测试的结果true
或false
的布尔返回值,则称为一元谓词,如下例所示:
class IsAnOddNumber {
public:
constexpr bool operator()(const int value) const noexcept { return (value % 2) != 0; }
};
Listing 7-12.An example for a Predicate
这个谓词现在可以应用于我们的数字序列,使用std::remove_if
算法来去除所有奇数。问题是这个算法的名字有误导性。实际上,它并没有移除任何东西。任何与谓词不匹配的元素(在我们的例子中是所有偶数)都被移动到容器的开头,这样要删除的元素就在末尾。然后std::remove_if
返回一个迭代器,指向要删除的范围的开始。这个迭代器可以被std::vector::erase()
成员函数用来真正从向量中删除不需要的元素。顺便说一下,这种非常有效的技术被称为擦除-删除习惯用法。
#include <algorithm>
#include <vector>
using Numbers = std::vector<int>;
int main() {
const std::size_t AMOUNT_OF_NUMBERS = 100;
Numbers numbers(AMOUNT_OF_NUMBERS);
std::generate(std::begin(numbers), std::end(numbers), IncreasingNumberGenerator());
std::transform(std::begin(numbers), std::end(numbers), std::begin(numbers), ToSquare());
numbers.erase(std::remove_if(std::begin(numbers), std::end(numbers), IsAnOddNumber()),
std::end(numbers));
// ...
return 0;
}
Listing 7-13.All odd numbers from the vector are deleted using the Erase-remove idiom
为了能够以更灵活和通用的方式使用仿函数,它通常被实现为类模板。因此,我们可以将我们的一元函子IsAnOddNumber
重构为一个类模板,这样它就可以用于所有的整数类型,比如short
、int
、unsigned int
等。由于 C++11 语言提供了所谓的类型特征(在头文件<type_traits>
中定义),我们可以确保模板只用于整型,如下例所示:
#include <type_traits>
template <typename INTTYPE>
class IsAnOddNumber {
public:
static_assert(std::is_integral<INTTYPE>::value,
"IsAnOddNumber requires an integer type for its template parameter INTTYPE!");
constexpr bool operator()(const INTTYPE value) const noexcept { return (value % 2) != 0; }
};
Listing 7-14.Ensuring that the template parameter is an integral data type
从 C++11 开始,该语言提供了static_assert()
,一种在编译时执行的断言检查。在我们的例子中,static_assert()
用于在模板实例化期间检查模板参数INTTYPE
是否是使用类型特征std::is_integral<T>
的整型。main()
函数体内使用谓词的位置(erase-remove 构造)现在需要稍微调整一下:
// ...
numbers.erase(std::remove_if(std::begin(numbers), std::end(numbers),
IsAnOddNumber<Numbers::value_type>()), std::end(numbers));
// ...
如果我们现在无意中使用了带有非整数数据类型的模板,比如double
,我们会从编译器那里得到一条引人注目的错误消息:
[...]
../src/Functors.h: In instantiation of ‘class IsAnOddNumber<double>’:
../src/Main.cpp:13:94: required from here
../src/Functors.h:42:3: error: static assertion failed: IsAnOddNumber requires an integer type for its template parameter INTTYPE!
[...]
Type Traits
模板是泛型编程的基础。C++ 标准库中的容器,还有迭代器和算法,都是使用 C++ 模板概念的非常灵活的泛型编程的杰出例子。但是从技术的角度来看,如果用模板参数实例化一个模板,那么只发生一个简单的文本查找和替换过程。例如,如果一个模板参数被命名为T
,那么每次出现的T
都会被替换为在模板实例化期间作为模板参数传入的数据类型。
问题是:不是每个数据类型都适合每个模板的实例化。例如,如果您将一个数学运算定义为 C++ 仿函数模板,以便它可以用于不同的数字数据类型(short
、int
、double
等)。),用std::string
实例化这个模板绝对没有意义。
C++ 标准库头文件<type_traits>
(从 C++11 开始可用)提供了一个全面的检查集合,用于检索编译时作为模板参数传入的类型信息。换句话说,在类型特征的帮助下,您能够定义模板参数必须满足的编译器可验证的要求。
例如,您可以通过使用类型特征std::is_nothrow_copy_constructible<T>
来确保用于模板实例化的类型必须是可复制构造的,并结合无抛出异常安全保证(参见第五章中的“无抛出保证”一节)。
template <typename T>
class Clazz {
static_assert(std::is_nothrow_copy_constructible<T>::value,
"The given type for T must be copy-constructible and may not throw!");
// ...
};
类型特征不仅可以与static_assert()
一起使用,以通过错误消息中止编译。例如,它们也可以用于一个叫做 SFINAE(替换失败不是错误)的习语,在第九章中关于习语的章节中会有更详细的讨论。
最后,让我们看看二元函子。
二元函子
如上所述,二元仿函数是一个类似函数的对象,它接受两个参数。如果这种函子对其两个参数进行运算以执行某种计算(例如加法)并返回该运算的结果,则称为二元运算符。如果这样一个函子有一个布尔返回值作为某个测试的结果,如下例所示,它被称为二元谓词。
class IsGreaterOrEqual {
public:
bool operator()(const auto& value1, const auto& value2) const noexcept {
return value1 >= value2;
}
};
Listing 7-15.An example for a binary predicate that compares its two parameters
Note
在 C++11 之前,函子是一个很好的实践,根据它们的参数数量,分别从模板std::unary_function
和std::binary_function
中派生出来(两者都在头文件<functional>
中定义)。这些模板在 C++11 中被标记为不推荐使用,并且在最新的 C++17 标准中从标准库中删除。
绑定器和函数包装器
随着 2005 年 C++ 技术报告草案 1 (TR 1)的发布,C++ 中函数式编程方面的下一个发展步骤已经完成,这是标准 ISO/IEC TR 19768:2007 C++ 库扩展的通用名称。TR 1 指定了一系列对 C++ 标准库的扩展,其中包括对函数式编程的扩展。这份技术报告是后来的 C++11 标准的库扩展建议,事实上,13 个建议库中的 12 个(稍作修改)也将其纳入了 2011 年发布的新语言标准。
在函数式编程方面,TR 1 引入了两个函数模板std::bind
和std::function
,它们在库头<functional>
中定义。
函数模板std::bind
是函数及其参数的绑定包装器。您可以使用一个函数(或函数指针,或仿函数),并将实际值“绑定”到函数的一个或所有参数。换句话说,您可以从现有的函数或仿函数创建新的类似函数的对象。让我们从一个简单的例子开始:
#include <functional>
#include <iostream>
constexpr double multiply(const double multiplicand, const double multiplier) noexcept {
return multiplicand * multiplier;
}
int main() {
const auto result1 = multiply(10.0, 5.0);
auto boundMultiplyFunctor = std::bind(multiply, 10.0, 5.0);
const auto result2 = boundMultiplyFunctor();
std::cout << "result1 = " << result1 << ", result2 = " << result2 << std::endl;
return 0;
}
Listing 7-16.Using std::bind to wrap binary function multiply()
在这个例子中,multiply()
函数和两个浮点数文字(10.0
和5.0
)一起使用std::bind
包装。数字文字表示绑定到两个函数参数multiplicand
和multiplier
的实际参数。结果,我们得到了一个新的类似函数的对象,它存储在变量boundMultiplyFunctor
中。然后可以像普通的函子一样使用括号运算符调用它。
也许你现在会问自己:很好,但我不明白。那有什么用?活页夹功能模板的实际好处是什么?
嗯,std::bind
在编程中允许一些被称为部分应用(或部分函数应用)的东西。部分应用是一个过程,其中只有函数参数的子集绑定到值或变量,而其他部分尚未绑定。未绑定的参数由占位符_1
、_2
、_3
等替换,这些占位符在名称空间std::placeholders
中定义。
#include <functional>
#include <iostream>
constexpr double multiply(const double multiplicand, const double multiplier) noexcept {
return multiplicand * multiplier;
}
int main() {
using namespace std::placeholders;
auto multiplyWith10 = std::bind(multiply, _1, 10.0);
std::cout << "result = " << multiplyWith10(5.0) << std::endl;
return 0;
}
Listing 7-17.An example of partial function application
在上面的例子中,multiply()
函数的第二个参数被绑定到浮点数文字10.0
,但是第一个参数被绑定到一个占位符。类函数对象是std::bind()
的返回值,存储在变量multiplyWith10
中。这个变量现在可以像函数一样使用,但是我们只需要传递一个参数:要乘以10.0
的值。
部分函数应用是一种适应技术,它允许我们在各种情况下使用函数或仿函数,在这些情况下,我们需要它们的功能,但我们只能提供一些参数,而不是所有参数。此外,在占位符的帮助下,函数参数的顺序可以适应客户端代码期望的顺序。例如,参数表中的multiplicand
和multiplier
的位置可以互换,方法是将它们映射到一个新的类似功能的对象,如下所示:
auto multiplyWithExchangedParameterPosition = std::bind(multiply, _2, _1);
在我们使用multiply()
函数的情况下,这显然是没有意义的(记住乘法的可交换性),因为新的函数对象将产生与原始multiply()
函数完全相同的结果,但是在其他情况下,参数顺序的调整可以提高函数的可用性。部分功能应用是界面适配的工具。
顺便说一下,特别是结合作为返回参数的函数,带有关键字auto
的自动类型演绎(参见第五章中的“自动类型演绎”一节)可以提供有价值的服务,因为如果我们考察 GCC 编译器从上面的std::bind()
调用返回的是什么,它是下面复杂类型的对象:
std::_Bind_helper<bool0,double (&)(double, double),const _Placeholder<int2> &,const _Placeholder<int1> &>::type
很可怕,不是吗?在源代码中显式地写下这样一个类型不仅有点帮助,而且代码的可读性也会大打折扣。多亏了关键字auto
,没有必要明确定义这些类型。但是在那些罕见的情况下,当你必须这么做的时候,类模板std::function
就会发挥作用,它是一个通用的多态函数包装器。这个模板可以包装任意的可调用对象(普通函数、仿函数、函数指针等)。),并管理用于存储该对象的内存。例如,要将我们的乘法函数multiply()
包装成一个std::function
对象,代码如下所示:
std::function<double(double, double)> multiplyFunc = multiply;
auto result = multiplyFunc(10.0, 5.0);
既然我们已经讨论了std::bind
、std::function
和部分应用的技术,我有一个可能令人失望的消息要告诉你:自从 C++ 11 和 lambda 表达式的引入以来,大多数来自 C++ 标准库的模板材料已经很少需要了。
λ表达式
随着 C++11 的出现,这种语言有了一个值得注意的新特性:lambda 表达式!它们的其他常用术语是 lambda 函数、函数文字或只是 lambda。有时它们也被称为闭包,这实际上是函数式编程中的一个通用术语,顺便提一下,这也不完全正确。
Closure
在命令式编程语言中,我们习惯了这样一个事实:当程序执行离开了定义变量的范围时,变量就不再可用了。例如,如果一个函数完成并返回给它的调用者,那么该函数的所有局部变量都将从调用堆栈中移除并从内存中删除。
另一方面,在函数式编程中,我们可以构建一个闭包,它是一个具有持久局部变量作用域的函数对象。换句话说,闭包允许将一个具有部分或全部局部变量的作用域绑定到一个函数上,只要这个函数存在,这个作用域对象就会一直存在。
在 C++ 中,由于 lambda 导入器中的捕获列表,这样的闭包可以在 lambda 表达式的帮助下创建。闭包不同于 lambda 表达式,就像面向对象中的对象(实例)不同于它的类一样。
lambda 表达式的特殊之处在于,它们通常是内联实现的,也就是说,是在应用时实现的。这有时可以提高代码的可读性,编译器可以更有效地应用它们的优化策略。当然,lambda 函数也可以被视为数据,例如,存储在变量中,或者作为函数参数传递给所谓的高阶函数(参见下一节关于该主题的内容)。
lambda 表达式的基本结构如下所示:
capture list -> return_type_declaration { lambda body }
由于这本书不是 C++ 语言入门,所以我在这里不解释 lambda 表达式的所有基础知识。即使您是第一次看到这样的东西,也应该相对清楚返回类型、参数列表和 lambda 主体与普通函数非常相似。乍一看,有两件事似乎不同寻常。例如,lambda 表达式没有像普通函数或类似函数的对象那样的名称。这就是为什么在这种情况下也谈论匿名函数的原因。另一个显眼的是开头的方括号,也叫λ导入器。顾名思义,lambda 导入器标记了 lambda 表达式的开始。此外,介绍者还可选地包含被称为捕获列表的东西。
这个捕获列表之所以如此重要,是因为这里列出了来自外部范围的所有变量,这些变量应该在 lambda 主体内部可用,以及它们应该通过值(复制)还是通过引用来捕获。换句话说,这些是 lambda 表达式的闭包。
一个示例 lambda 表达式定义如下:
[](const double multiplicand, const double multiplier) { return multiplicand * multiplier; }
这是我们的λ乘法函数。介绍者有一个空白的捕获列表,这意味着没有使用来自周围范围的任何内容。此外,在这种情况下,也没有指定返回类型,因为编译器可以很容易地推断出它。
通过将 lambda 表达式赋给一个变量,就创建了一个相应的运行时对象,即所谓的闭包。这实际上是正确的:编译器从 lambda 表达式中生成一个未指定类型的仿函数类,它在运行时被实例化并赋给变量。捕获列表中的捕获被转换成函子对象的构造函数参数和成员变量。lambda 的参数列表中的参数被转换成仿函数的圆括号运算符(operator()
)的参数。
#include <iostream>
int main() {
auto multiply = [](const double multiplicand, const double multiplier) {
return multiplicand * multiplier;
};
std::cout << multiply(10.0, 50.0) << std::endl;
return 0;
}
Listing 7-18.Using the lambda expression to multiply two doubles
然而,整个事情可以做得更短,因为 lambda 表达式可以通过在 lambda 体后面附加带有参数的括号,在它的定义处直接调用。
int main() {
std::cout <<
[](const double multiplicand, const double multiplier) {
return multiplicand * multiplier;
}(50.0, 10.0) << std::endl;
return 0;
}
Listing 7-19.Defining and calling of a lambda expression in one go
当然,前面的例子只是为了演示,因为以这种方式使用 lambda 毫无意义。以下示例使用了两个 lambda 表达式。一个由算法std::transform
使用,用尖括号将字符串向量quote
中的单词封装起来,并将它们存储在另一个名为result
的向量中。另一个λ表达式被std::for_each
用来在标准输出上输出result
的内容。
#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> quote { "That's", "one", "small", "step", "for", "a", "man,", "one",
"giant", "leap", "for", "mankind." };
std::vector<std::string> result;
std::transform(begin(quote), end(quote), back_inserter(result),
[](const std::string& word) { return "<" + word + ">"; });
std::for_each(begin(result), end(result),
[](const std::string& word) { std::cout << word << " "; });
return 0;
}
Listing 7-20.Putting every single word in a list in angle brackets
这个小程序的输出是:
<That's> <one> <small> <step> <for> <a> <man,> <one> <giant> <leap> <for> <mankind.>
泛型 Lambda 表达式(C++14)
随着 C++14 的发布,lambda 表达式经历了一些进一步的改进。从 C++14 开始,允许使用auto
(参见第五章中关于自动类型推导的章节)作为函数的返回类型,或者 lambda。换句话说,编译器将推导出类型。这种 lambda 表达式称为泛型 lambda 表达式。
这里有一个例子:
#include <complex>
#include <iostream>
int main() {
auto square = [](const auto& value) noexcept { return value * value; };
const auto result1 = square(12.56);
const auto result2 = square(25u);
const auto result3 = square(-6);
const auto result4 = square(std::complex<double>(4.0, 2.5));
std::cout << "result1 is " << result1 << "\n";
std::cout << "result2 is " << result2 << "\n";
std::cout << "result3 is " << result3 << "\n";
std::cout << "result4 is " << result4 << std::endl;
return 0;
}
Listing 7-21.Applying a generic lambda expression on values of different data type
在编译函数时,参数类型和结果类型是根据具体参数(文字)的类型自动派生的(在前面的示例中是double
、unsigned int
、int
和类型为std::complex<T>
的复数)。广义 lambdas 在与标准库算法的交互中非常有用,因为它们是普遍适用的。
高阶函数
函数式编程的一个核心概念是所谓的高阶函数。它们是一流功能的附属品。高阶函数是以一个或多个其他函数作为参数的函数,或者它们可以返回一个函数作为结果。在 C++ 中,任何可调用的对象,例如,std::function
包装器的实例、函数指针、从 lambda 表达式创建的闭包、手工制作的仿函数以及任何其他实现operator()
的对象都可以作为参数传递给高阶函数。
我们可以保持这个介绍相对简短,因为我们已经看到并使用了几个高阶函数。C++ 标准库中的许多算法(参见第五章中关于算法的部分)都是这类函数。根据它们的用途,它们采用一元运算符、一元谓词或二元运算符将其应用于容器或容器中元素的子范围。
当然,尽管 header <algorithm>
和 header <numeric>
为不同的目的提供了强大的高阶函数的全面选择,您也可以分别实现高阶函数,或者自己实现高阶函数模板,如下例所示:
#include <functional>
#include <iostream>
#include <vector>
template<typename CONTAINERTYPE, typename UNARYFUNCTIONTYPE>
void myForEach(const CONTAINERTYPE& container, UNARYFUNCTIONTYPE unaryFunction) {
for (const auto& element : container) {
unaryFunction(element);
}
}
template<typename CONTAINERTYPE, typename UNARYOPERATIONTYPE>
void myTransform(CONTAINERTYPE& container, UNARYOPERATIONTYPE unaryOperator) {
for (auto& element : container) {
element = unaryOperator(element);
}
}
template<typename NUMBERTYPE>
class ToSquare {
public:
NUMBERTYPE operator()(const NUMBERTYPE& number) const noexcept {
return number * number;
}
};
template<typename TYPE>
void printOnStdOut(const TYPE& thing) {
std::cout << thing << ", ";
}
int main() {
std::vector<int> numbers { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
myTransform(numbers, ToSquare<int>());
std::function<void(int)> printNumberOnStdOut = printOnStdOut<int>;
myForEach(numbers, printNumberOnStdOut);
return 0;
}
Listing 7-22.An example for self-made higher-order functions
在这种情况下,我们自制的两个高阶函数模板myTransform()
和myForEach()
只适用于整个容器,因为与标准库算法不同,它们没有迭代器接口。然而,关键的一点是,开发人员可以提供 C++ 标准库中不存在的自定义高阶函数。
我们现在将更详细地研究这些高阶函数中的三个,因为它们在函数式编程中起着重要的作用。
映射、过滤和减少
每一种严肃的函数式编程语言都必须提供至少三个有用的高阶函数:map、filter 和 reduce(同义词:fold)。即使根据编程语言的不同,它们有时可能会有不同的名称,但您可以在 Haskell、Erlang、Clojure、JavaScript、Scala 和许多其他具有函数式编程功能的语言中找到这种三足鼎立的形式。因此,我们可以理直气壮地宣称,这三个高阶函数形成了一个非常常见的函数式编程设计模式。
因此,这些高阶函数也包含在 C++ 标准库中,您应该不会感到惊讶。也许你也不会惊讶,我们已经使用了其中的一些功能。
让我们依次看看这些函数。
地图
地图可能是三者中最容易理解的。在这个高阶函数的帮助下,我们可以对列表中的每个元素应用一个操作函数。在 C++ 中,这个函数是由标准库算法std::transform
(在头文件<algorithm>
中定义)提供的,你已经在前面的一些代码示例中看到过了。
过滤器
过滤也很容易。顾名思义,这个高阶函数接受一个谓词(参见本章前面关于谓词的部分)和一个列表,它从列表中删除任何不满足谓词条件的元素。在 C++ 中,这个函数是由标准库算法std::remove_if
(在头文件<algorithm>
中定义)提供的,你已经在前面的一些代码示例中看到过了。
然而,这里有另一个很好的例子来分别过滤std::remove_if
。如果你患有一种叫做“aibohphobia”的疾病,这是一种对回文的非理性恐惧的幽默术语,你应该从单词列表中过滤出回文,如下所示:
#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
class IsPalindrome {
public:
bool operator()(const std::string& word) const {
const auto middleOfWord = begin(word) + word.size() / 2;
return std::equal(begin(word), middleOfWord, rbegin(word));
}
};
int main() {
std::vector<std::string> someWords { "dad", "hello", "radar", "vector", "deleveled", "foo",
"bar", "racecar", "ROTOR", "", "C++", "aibohphobia" };
someWords.erase(std::remove_if(begin(someWords), end(someWords), IsPalindrome()),
end(someWords));
std::for_each(begin(someWords), end(someWords), [](const auto& word) {
std::cout << word << ",";
});
return 0;
}
Listing 7-23.Removing all palindromes from a vector of words
这个程序的输出是:
hello,vector,foo,bar,C++,
减少(折叠)
Reduce(同义词:Fold,Collapse,Aggregate)是三个高阶函数中最强大的,乍一看可能有点难以理解。“分别减少折叠”是一个高阶函数,通过对值列表应用二元运算符来获得单个结果值。在 C++ 中,这个函数由标准库算法std::accumulate
(在头文件<numeric>
中定义)提供。有人说std::accumulate
是标准库中最强大的算法。
从一个简单的例子开始,你可以很容易地得到一个向量中所有整数的和:
#include <numeric>
#include <iostream>
#include <vector>
int main() {
std::vector<int> numbers { 12, 45, -102, 33, 78, -8, 100, 2017, -110 };
const int sum = std::accumulate(begin(numbers), end(numbers), 0);
std::cout << "The sum is: " << sum << std::endl;
return 0;
}
Listing 7-24.Building the sum of all values in a vector using std::accumulate
这里使用的版本std::accumulate
在参数列表中不需要显式的二元运算符。使用这个版本的函数,只需计算所有值的总和。当然,您可以通过 lambda 表达式提供自己的二元运算符,如下例所示:
int main() {
std::vector<int> numbers { 12, 45, -102, 33, 78, -8, 100, 2017, -110 };
const int maxValue = std::accumulate(begin(numbers), end(numbers), 0,
[](const int value1, const int value2) {
return value1 > value2 ? value1 : value2;
});
std::cout << "The highest number is: " << maxValue << std::endl;
return 0;
}
Listing 7-25.Finding the highest number in a vector using std::accumulate
Left and Right Fold
函数式编程通常区分两种折叠元素列表的方式:左折叠和右折叠。
如果我们将第一个元素与递归组合其余元素的结果相组合,这称为右折叠。相反,如果我们将递归组合除最后一个元素之外的所有元素的结果与最后一个元素组合,则此操作称为左折叠。
例如,如果我们取一列要用+
运算符折叠成一个和的值,那么左折叠运算的括号如下:((A + B) + C) + D
。相反,对于右折叠,括号应该这样设置:A + (B + (C + D))
。在简单的结合+运算的情况下,无论它是由左折叠还是右折叠形成,结果都没有任何区别。但是对于非关联的二元函数,元素组合的顺序可能会影响最终结果的值。
同样在 C++ 中,我们可以区分左折叠和右折叠。如果我们对普通迭代器使用std::accumulate
,我们会得到一个左折叠:
std::accumulate(begin, end, init_value, binary_operator)
相反,如果我们将std::accumulate
与反向迭代器一起使用,我们会得到一个右折叠:
std::accumulate(rbegin, rend, init_value, binary_operator)
C++17 中的折叠表达式
从 C++17 开始,这种语言获得了一个有趣的新特性,叫做折叠表达式。C++17 fold 表达式被实现为所谓的可变模板(从 C++11 开始可用),即以类型安全的方式接受可变数量的参数的模板。这个任意数量的参数保存在一个所谓的参数包中。
C++17 增加了在二元运算符的帮助下直接减少参数包的可能性,即执行折叠。C++17 fold 表达式的一般语法如下:
( ... operator parampack ) // left fold
( parampack operator ... ) // right fold
( initvalue operator ... operator parampack ) // left fold with an init value
( parampack operator ... operator initvalue ) // right fold with an init value
让我们看一个例子,一个带有初始值左文件夹:
#include <iostream>
template<typename... PACK>
int subtractFold(int minuend, PACK... subtrahends) {
return (minuend - ... - subtrahends);
}
int main() {
const int result = subtractFold(1000, 55, 12, 333, 1, 12);
std::cout << "The result is: " << result << std::endl;
return 0;
}
Listing 7-26.An example for a left fold
注意,由于缺乏operator–
的关联性,在这种情况下不能使用右折叠。Fold 表达式支持 32 种运算符,包括逻辑运算符==
、&&
和||
。
下面是另一个测试参数包至少包含一个偶数的示例:
#include <iostream>
template <typename... TYPE>
bool containsEvenValue(const TYPE&... argument) {
return ((argument % 2 == 0) || ...);
}
int main() {
const bool result1 = containsEvenValue(10, 7, 11, 9, 33, 14);
const bool result2 = containsEvenValue(17, 7, 11, 9, 33, 29);
std::cout << std::boolalpha;
std::cout << "result1 is " << result1 << "\n";
std::cout << "result2 is " << result2 << std::endl;
return 0;
}
Listing 7-27.Checking whether a parameter pack contains an even value
这个程序的输出是:
result1 is true
result2 is false
函数式编程中的干净代码
毫无疑问,函数式编程运动在 C++ 之前就没有停止过,这基本上是好的。许多有用的概念已经被整合到我们有些陈旧的编程语言中。
但是以函数式风格编写的代码并不一定是好的或干净的代码。在过去几年中,函数式编程语言越来越受欢迎,这可能会让您相信函数式代码本身比面向对象代码更易于维护、可读性更好、可测试性更强,并且更不容易出错。但那不是真的!相反,精心编写的功能性代码做着重要的事情,可能很难理解。
例如,我们来看一个简单的折叠操作,它与前面的一个示例非常相似:
// Build the sum of all product prices
const Money sum = std::accumulate(begin(productPrices), end(productPrices), 0.0);
如果你在没有解释源代码注释的情况下阅读这篇文章……这种意图会泄露代码吗?请记住我们在第四章中学到的关于注释的内容:每当你感到有写源代码注释的冲动时,你应该首先思考如何改进代码,使注释变得多余。
所以,我们真正想读或分别写的东西是这样的:
const Money totalPrice = buildSumOfAllPrices(productPrices);
所以,让我们先做一个基本声明:
无论您将使用何种编程风格,优秀软件设计的原则仍然适用!
比起面向对象,你更喜欢函数式编程风格?好吧,但我相信你会同意 KISS、DRY 和 YAGNI(见第三章)也是函数式编程中非常好的原则!你以为在函数式编程中可以忽略单责任原则(见第六章)?算了吧!如果一个函数做不止一件事,它将导致与面向对象类似的问题。我想我不必提及好的和富有表现力的命名(见第四章关于好的名字)对于函数式环境中代码的可理解性和可维护性也是非常重要的。永远记住,开发人员花在阅读代码上的时间比写代码要多得多。
因此,我们可以得出结论,面向对象软件设计者和程序员使用的大多数设计原则也可以被函数式程序员使用。
我个人更喜欢两种编程风格的平衡混合。有许多设计挑战可以使用面向对象的范例完美地解决。多态性是面向对象的一大好处。我可以利用依赖倒置原则(参见第六章中的同名章节),这允许我倒置源代码和运行时依赖。
相反,使用函数式编程风格可以更好地解决复杂的数学计算。如果必须满足高而雄心勃勃的性能和效率要求,这将不可避免地要求某些任务的并行化,那么函数式编程就可以发挥出它的王牌。
不管你是喜欢用面向对象的方式,还是用函数式的方式,或者两者的适当混合来编写软件,你都应该永远记住下面这句话:
Always write the person who maintains your code as a violent psychopath who knows where you live. —John F. Woods, 1991, in comp.lang.c++ newsgroup
的一篇文章中