带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之三:原则

点击查看第一章
点击查看第二章

第3章

原则
我建议学生们把更多的精力放在学习基本思想上,而不是新技术上,因为新技术在他们毕业之前就有可能过时,而基本思想则永远不会过时。
—David L. Parnas
在本章,我将介绍设计良好的和精心制作的软件需要遵循哪些最基本的原则。这些基本原则的特别之处在于,它们并不是只针对某些编程案例或者编程语言,其中一些原则甚至并不是专用于软件开发的。例如,我们讨论的KISS原则可以适用于生活的很多方面,一般来说,不仅是软件开发,把生活中的一切事情变得尽可能简单并不一定都是坏事。
也就是说,下面这些原则我们不应该学一次就忘掉,建议熟练掌握它们。这些原则非常重要,理想情况下,它们会成为每个开发人员的第二天性。我在后面章节中即将讨论到的很多具体原则都是基于以下这些基本原则的。

3.1 什么是原则

在本书中,你会发现许多编写更好的C++代码和设计良好的软件的原则,但到底什么是原则呢?
许多人都有一些指导他们一生的原则。举个例子,如果你因为某些原因不吃肉,那么这可能就是原则;如果你想保护你的小孩,那么你会教他一些原则,指导他可以自己做出正确的决定,比如“不要和陌生人说话!”只要将这个原则记住,孩子就可以在特定的场合下做出正确的决定。
原则是一种规则、信仰或者指引你的观念,原则通常与价值观或价值体系直接联系。例如,我们不需要被告知同类相残是错误的,因为人们对人类生活有天生的价值观。更好的一个例子是Agile Manifesto [Beck01] 包含了12条原则,指导项目团队开展敏捷项目。
原则并不是不可改变的法律,更没有明文规定地刻在石头上。而且在编程的时候有时故意违背其中一些原则是有必要的,只要你有充分的理由违背原则,就可以去做,但是做的时候一定要小心!因为结果很可能会出乎你的意料。
以下几项基本原则,将会在本书后面的各个章节分别进行回顾及强化。

3.2 保持简单和直接原则(KISS)

任何事情都应该尽可能简单,而不是稍微简单一点。
—Albert Einstein, theoretical physicist, 1879—1955
KISS是“Keep it simple,stupid”或“Keep it simple and stupid”的缩写(对于这个缩写有很多其他的意思,这两个是最常用的)。在极限编程中(extreme programming),简称XP,这个原则有一个更有实践意义的名字“Do the simplest thing that could possibly work”(DTSTTCPW),即简单到只要能正常工作就好。KISS原则旨在在软件开发中,把简单当作一个主要的目标,避免做一些没有必要的复杂性的工作。
我认为在软件开发过程中,软件开发者经常会忘记KISS原则,程序员偏向以精心设计的方式编写代码,这样导致的结果是他们往往将问题复杂化。我知道,我们都是技术精湛、积极进取的开发人员,而且我们也了解设计和架构模式、框架、技术、工具以及其他酷炫和奇特的东西,开发很酷的软件不只是我们朝九晚五的一个工作而已—它已经成为了我们的使命,我们因我们的工作而变得更有成就感。
但是你必须记住,任何软件系统都有内在的复杂性,毫无疑问,复杂问题通常需要复杂的代码。内在的复杂性是不可避免的,由于系统需要满足需求,所以这种复杂性客观存在。但是,为这种内在的复杂性添加不必要的复杂性将是致命的。因此,建议不要仅因为你会用,就把一些花哨技巧或一些很酷的设计模式都用在你所使用的编程语言中。另一方面,也不要过分简单,如果在switch-case判断中有十个条件是必需的,那它就应该有十个条件。
保持代码尽可能简单!当然,如果对灵活性和可扩展性有很高的质量要求,则必须增加软件的复杂性以满足这些需求。例如,你可以使用众所周知的策略模式(请参阅第9章“设计模式”),如果需求需要的话,在代码中引入灵活的可变点。但要小心,只添加那些使事情整体变得更简单的复杂性的东西。
对于程序员来说,关注简单性可能是最困难的事情之一,并且这是一个终生的学习经验。
—Adrian Bolboaca (@adibolb), April 3, 2014, on Twitter

3.3 不需要原则(YAGNI)

总是在你真正需要的时候再实现它们,而不是在你只是预见到你需要它们的时候实现它们。
—Ron Jeffries, You抮e NOT gonna need it! [Jeffries98]
这一原则与之前讨论的KISS原则紧密相连。YAGNI是“You Aren抰 Gonna Need It!”的缩写,也可以看作“You Ain抰 Gonna Need It!”的缩写。YAGNI原则向投机取巧和过度设计宣战,它的主旨是希望你不要写目前用不上,但将来也许需要的代码。
几乎每个开发者在日常工作中都有这样一种冲动:“以后我们也许会用到这个功能……”错,你不会用到它!无论在什么情况下,你都要抵制开发以后可能用到的功能。毕竟,你可能根本不需要这个功能。如果你已经实现了这种无意义的功能,那么你花在那上面的时间就浪费了,并且你的代码也变得更加复杂!当然,你也破坏了KISS原则。更严重的是,这些为日后的功能做准备的代码,充满了bug并可能导致严重的后果!
我的建议是:在你确定真的有必要的时候再写代码,那时再重构仍然来得及。

3.4 避免复制原则(DRY)

复制和粘贴是一个设计错误。
—David L. Parnas
虽然这个原则是最重要的,但我确信开发人员经常有意或无意地违反这个原则。DRY是“Don抰 repeat yourself! ”的缩写。我们应该尽可能避免复制,因为复制是一个非常不好的行为。该原则也称为“Once And Only Once”(OAOO)原则。
复制是非常危险的,其原因显而易见:当一段代码被修改的时候,也必须相应地修改这段代码的副本,不要抱着不修改副本的期望,可以肯定的是,一定要修改副本。任何复制的代码片段迟早会被忘记,并且,会因为漏改代码的副本而产生bug。
就这样,没什么别的了吗?不是的,还有一些需要我们深入讨论的事情。
在Dave Thomas和Andy Hunt的出色的著作《The Pragmatic Programmer》[Hunt99]中陈述了DRY原则的含义,就是我们要保证“在一个系统内部,任何一个知识点都必须有一个单一的、明确的、权威的陈述。”值得注意的是,Dave和Andy并没有明确地提到代码,他们谈论的是知识点。一个系统的知识所影响的范围远比它的代码更广泛。例如,DRY原则同样也适用于文档、项目、测试计划和系统的配置数据。可以说,DRY原则影响了每一件事情!你可以想象一下,严格遵守这一原则并不像起初看起来那么容易。

3.5 信息隐藏原则

信息隐藏原则是软件开发中一个众所周知的基本原则,它首先记录在开创性论文“On the Criteria to Be Used in Decomposing Systems Into Modules”[Parnas72]中,由David L. Parnas于1972年撰写。
该原则指出,一段代码调用了另外一段代码,那么,调用者不应该知道被调用者的内部实现。否则,调用者就有可能通过修改被调用者的内部实现而完成某个功能,而不是强制性地要求调用者修改自己的代码。
David L. Parnas认为信息隐藏是把系统分解为模块的基本原则,Parnas同样认为系统模块化是为了隐藏困难的设计决策或可能改变的设计决策,应该涉及隐藏困难的设计决策或可能改变的设计决策,软件单元(例如,类或组件)暴露于其环境的内部构件越少,该单元的实现与其客户端之间的耦合就越低。因此,软件单元内部实现的更改将不会被其使用者所察觉。
信息隐藏有很多优点:
□限制了模块变更的范围。
□如果需要修复缺陷,对其他模块的影响最小。
□显著提高了模块的可复用性。
□模块具有更好的可测试性。
信息隐藏通常与封装混淆,但其实它们不一样,这两个术语在许多著名的书籍中是同义词,但我并不这么认为。信息隐藏是帮助开发人员找到好的设计模块的原则,该原则适用于多个抽象层次并能展现其正面效果,特别是在大型系统中。
封装通常是依赖于编程语言的技术,用于限制对模块内部的访问。例如,在C++中,你可以在private关键字后定义一些类成员,以确保类外部无法访问它们,但我们仅用这种防护方式进行访问控制,离自动隐藏信息还远着呢,封装有助于但不能保证信息隐藏。
以下代码示例展示了隐藏信息较差的封装类:

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之三:原则
带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之三:原则

这不是信息隐藏,因为类内部的实现部分暴露给了外部环境,尽管该类看起来封装得很好。注意getState返回值的类型,客户端用到的枚举类State用到了这个类,如下示例所示:

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之三:原则

枚举类(结构体)[C++11]
在C++11中,枚举类型也有了创新。为了向下兼容早期的C++标准,现在仍存在众所周知的枚举类型及其关键字enum。从C++11开始,我们还引入了枚举类和枚举结构体。
旧的C++枚举类型有一个坏处是,它们将枚举成员引入周围的命名空间,导致了名称冲突,如下示例所示:

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之三:原则


此外,旧的C++ enum会隐式转换为int,当我们不预期或不需要这样的转换时会导致难以察觉的错误:

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之三:原则

当使用枚举类(也称为“新枚举”或“强枚举”)时,这些问题将不再存在,它们的枚举成员对枚举来说是局部的,并且它们的值不会隐式转换为其他类型(比如另一个枚举或int类型)。

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之三:原则

对于现代C++程序,强烈建议使用枚举类而非普通的旧的枚举类型,因为它使代码更安全,并且因为枚举类也是类,所以它们可以前向声明。
如果必须更改AutomaticDoor的内部实现并从类中删除枚举类State,那么会发生什么呢?很容易看出它会对客户端的代码产生重大影响,它将导致使用成员函数AutomaticDoor::getState()的所有地方都要进行更改。
以下是具有良好的信息隐藏性的封装的AutomaticDoor类:

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之三:原则



带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之三:原则

现在,修改AutomaticDoor类的内部要容易实现得多。客户端代码不再依赖于类的内部实现。现在你可以在不引起该类任何用户注意的情况下,删除State枚举并将其替换为另一种实现。

3.6 高内聚原则

软件开发中的一条通用建议是,任何软件实体(如模块、组件、单元、类、函数等)应该具有很高的(或强的)内聚性。一般来讲,当模块实现定义确切的功能时,应该具有高内聚的特性。
为了深入研究该原则,让我们来看两个例子,这两个例子没有太多的关联,从图3-1开始。

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之三:原则

在上面的例子中,模块随意划分,业务领域三个不同的功能放在了一个模块内。功能A、功能B和功能C之间基本没有什么共同点,但是这三个功能却被放在MyModel模块中。阅读模块的代码就会发现,功能A、功能B和功能C在不同的、完全独立的数据上运行。
现在,观察图3-1中所有的虚线箭头,箭头指向的每一个模块都是一个被依赖者,箭头尾部的模块需要箭头指向的模块来实现。在这种情况下,系统中的其他模块想要使用功能A、功能B或功能C时,调用的模块就会依赖于整个MyModule模块。这样设计的缺点是显而易见的:这会导致太多的依赖,并且可维护性也会降低。
为了提高内聚性,功能A、功能B和功能C应该彼此分离(见图3-2)。

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之三:原则

现在,很容易地看出,每个模块的依赖项比旧的MyModule的依赖项少得多。很明显,模块A、模块B、模块C之间没有直接的关系。Module1是唯一依赖模块A、模块B和模块C的模块。
另外一个低内聚的形式是散弹枪反模式(Shot Gun Anti-Pattern)。我想大家应该都听说过,霰弹枪是一种能发射大量小铁沙的武器,这种武器通常有很大的散射性。在软件开发中,这种隐喻用于描述某个特定领域方面或单个逻辑思想是高度碎片化的,并且分布在许多模块中,图3-3描述了这种情况。
在这种低内聚方式下,出现了许多不利的依赖关系,功能A的各个片段必须紧密结合在一起。这就意味着,实现功能A子集的每个模块必须至少与一个包含功能A子集的模块交互。这会导致大量的依赖性交叉。最坏的情况是导致循环依赖;比如,模块1和模块3之间,或模块6和模块7之间。这再一次对可维护性和可扩展性产生了负面影响。当然,这种设计的可测试性也是非常差的。
这种设计将导致所谓的“霰弹枪手术”。对功能A的某种修改会导致很多模块进行或多或少的修改,这真的很糟糕,并且应该避免。我们应该把与功能A相关的所有代码都拿出来,把相同逻辑的代码放到一个高内聚的模块内。
一些其他的原则—例如,面向对象设计的单一职责(SRP)原则(详见第6章),会促进高内聚性。高内聚往往与松耦合相关,反之亦然。

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之三:原则

3.7 松耦合原则
考虑下面的示例代码:

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之三:原则
带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之三:原则

这段代码基本上可以正常运行。首先你需要创建Lamp类的实例,然后通过引用方式将Lamp的实例传递给Switch。这个小例子看起来像图3-4描述的那样。
这个设计有什么问题?

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之三:原则

问题就是,我们的Switch类直接包含了一个具体类Lamp的引用。换句话说,这个Switch类知道那是一个具体的Lamp类。
也许你会争辩说:“好吧,这就是开关的目的,开关必须能够开灯和关灯。”我会说:“是的,如果这是开关应该做的唯一的一件事情,那么这个设计就足够了。但是,请你去商店看看,卖开关的人知道灯的存在吗?”
你对这个设计的可测试性有什么看法?在单元测试中,SWitch类可以被单独测试吗?显然这是不可能的。当开关不仅需要打开灯、打开风扇、打开电动卷帘时,我们该怎么办?
在上面的例子中,灯和开关是紧耦合的。
在软件开发过程中,应该寻求模块间的松耦合(也称为低耦合或弱耦合)。这意味着你应该构建一个系统,在该系统中,每个模块都应该很少使用或不知道其他独立模块的定义。
软件开发中,松耦合的关键是接口。接口声明类的公共行为,而不涉及该类的具体实现。接口就像合同,而实现接口的类负责履行契约,也就是说,这些实现接口的类必须为接口的方法签名提供具体的实现。
在C++中,使用抽象类实现接口,如下所示:

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之三:原则

这个Switch类不再包含Lamp类的引用。相反,它持有了我们新定义的Switchable接口类。

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之三:原则

这个Lamp类实现了我们新定义的Switchable接口。

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之三:原则

用UML类图表示,我们新设计的类图看起来像下图3-5那样。

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之三:原则

这个设计的优点是显而易见的。Switch已经能完全独立于由它控制的具体类。而且,Switch可以通过实现Switchable接口的测试替身进行独立的测试。如果你想控制一个风扇而不是一盏灯呢?也没有问题,这个设计对扩展是开放的。只需要创建一个实现了Switchable接口的风扇类或者电气设备的其他类就可以了,详见图3-6。

带你读《C++代码整洁之道:C++17 可持续软件开发模式实践》之三:原则


松耦合可以为系统的各个独立的模块提供高度的自治性,该原理可以适用于很多不同的层次:可以用在最小的模块上,当然,还可以用在大型组件的体系结构上。高内聚会促进松耦合,因为具有明确定义责任的模块,通常会依赖于较少的其他模块。

3.8 小心优化原则

不成熟的优化是编程中所有问题(或者至少是大部分问题)的根源。
—Donald E. Knuth, American computer scientist [Knuth74]
我发现,开发人员只有一个模糊的想法就进行程序的优化,但他们并不确切知道性能瓶颈究竟在哪里。他们经常摆弄个别的指示,或尝试优化小的、局部的循环结构以挤出最后一点性能。我也是这些开发人员中的一员,其实这很浪费时间。
一般来讲,这些更改对性能的变化是微不足道的,通常不会出现预期的性能提升,这只会浪费宝贵的时间。相反的是,这种所谓的优化后的代码的可理解性和可维护性通常会受到严重影响。特别糟糕的是,有时在这种优化措施中,一些缺陷反而“巧妙”地被引入到了代码中。我的建议是:只要没有明确的性能要求,就避免优化。
代码的可理解性和可维护性应该是我们的第一个目标,正如我在第4章“调用开销”一节中所解释的那样,现代的编译器已经非常擅长优化代码了,每当你想优化某些代码时,想想YAGNI原则。
只有在不满足利益相关方明确要求的情况下才能采取行动。但是,你应该仔细分析影响性能的地方,不要仅凭直觉进行优化。例如,你可以使用Profiler找出软件的瓶颈所在。使用这样的工具后,开发人员常常会惊讶于影响性能的点与最初假设的位置相差甚远。
注意:Profiler是一种动态程序分析工具。除其他常用指标外,它还测量函数调用的频率和持续时间,它收集的分析信息还可用于程序优化。

3.9 最少惊讶原则(PLA)

最少惊讶原则(POLA / PLA),也称为最少惊喜原则(POLS),它在用户界面设计和人因工程学设计中很知名。该原则指出不应该让用户对用户界面的意外响应而感到惊讶,也不应该对出现或消失的控件、混乱的错误消息、公认的按键序列的异常响应(记住,Ctrl+C是在Windows操作系统中复制应用程序的标准事务,而不是退出程序)或其他意外行为而感到困惑。
这个原则也可以很好地应用到软件开发中的API设计中。调用函数不应该让调用者感知到异常行为或一些隐藏的副作用,函数应该完全按照函数名称所暗示的意义执行(请参阅第4章中4.3.3节“函数命名”)。例如,在类的实例上调用getter时不应该修改该对象的内部状态。

3.10 童子军原则

这个原则是关于你和你的行为的,其内容是:在离开露营地的时候,应让露营地比你来之前还要干净。
童子军非常有原则,其中一个原则是,一旦他们发现了一些不好的东西,就立即清理环境中的污染物或那些引起混乱的东西。作为一名负责任的软件工程师,我们应该将这一原则应用于我们的日常工作,每当我们在一段代码中发现需要改进的或者风格不好的代码时,我们应该立即修正它,与这段代码的原创作者是谁无关紧要。
这种行为的好处是我们能不断防止自己的代码被破坏。如果我们都那样做,代码就不会变糟,软件熵增加的趋势也就没有机会能占据我们系统的主导地位。改善代码并不一定要大刀阔斧地去做,也可能只是一次小小的清理。举例如下:
□重命名那些命名不佳的类、变量、函数或方法(请参阅第4章中的4.1节“良好的命名”和4.3.3节“函数命名”)。
□将大型函数分解为更小函数(请参阅第4章中4.3.2节“让函数尽可能小”)。
□让需要注释的代码不言自明,以避免注释(请参阅第4章中4.2.2节“不要为易懂的代码写注释”)。
□清理复杂而令人费解的if-else组合。
□删除一小部分重复的代码(请参阅本章中有关DRY原则的部分)。
由于这些改进大多数都是代码重构,因此如第2章所述,由良好的单元测试组成的坚固的安全体系是必不可少的。没有单元测试,你根本无法确定你是否破坏了某些东西。
除了良好的单元测试,我们仍然需要团队中的一种特殊的文化:代码所有权集体化(Collective Code Ownership)。
代码所有权集体化意味着我们应该真正地融入团队。每个团队成员在任何时候都可以对任何代码进行更改或扩展,不应该有这样的态度:“这是Peter的代码,这是Fred的模块, 我不会碰它们!”其他人可以接管我们写的代码,这应该被当作一种很高的衡量标准,团队中的任何人都不应该害怕,或者必须获得许可才能整理代码或添加新的功能。代码所有权集体化这种文化将使童子军原则很好地执行。

上一篇:带你读《企业安全建设指南:金融行业安全架构与技术实践》之二:金融行业的信息安全


下一篇:深入分析 ThreadLocal 内存泄漏问题