Skip to content

Latest commit

 

History

History
442 lines (265 loc) · 23.6 KB

File metadata and controls

442 lines (265 loc) · 23.6 KB

三、要有原则

我建议学生们把更多的注意力放在基本的想法上,而不是最新的技术上。这些技术在他们毕业前就会过时。基本思想永远不会过时。—戴维·l·帕纳斯

在这一章中,我介绍了设计良好和制作精良的软件的最重要和最基本的原则。这些原则的特别之处在于,它们不依赖于特定的编程范式或编程语言。其中一些甚至不是专门针对软件开发的。例如,所讨论的 KISS 原则可能与生活的许多领域相关:一般来说,尽可能简化生活中的一切并不是一个坏主意——不仅仅是软件开发。

也就是说,你不应该把下面的原则学了一遍就忘了。这些建议是给你内在化的。这些原则非常重要,理想情况下,它们应该成为每个开发人员的第二天性。我在本书后面讨论的许多更具体的原则都源于下面的基本原则。

什么是原则?

在这本书里,你会发现更好的 C++ 代码和设计良好的软件的各种原则。但是一般来说什么是原则呢?

许多人都有指导他们一生的原则。例如,如果你因为几个原因反对吃肉,那将是一个原则。如果你想保护你的孩子,你就给他一些原则,引导他自己做出正确的决定,例如“小心,不要和陌生人说话!”记住这个原则,孩子就能在某些特定的情况下推断出正确的行为。

原则是一种指导你的规则、信念或想法。原则通常与价值观或价值体系直接相关。例如,我们不需要被告知同类相食是错误的,因为人类对于生命有一种与生俱来的价值。作为进一步的例子,敏捷宣言[Beck01]包含了十二条指导项目团队实施敏捷项目的原则。

原则不是不可改变的法律。他们不是被刻在石头上的。在编程中,故意违反原则有时是必要的。如果你有非常充分的理由违反原则,那就去做,但是要非常小心!应该是个例外。

下面的一些基本原则,在本书后面的不同地方,会被重新讨论和深化。

一切都应该尽可能简单,但不能更简单。—阿尔伯特·爱因斯坦,理论物理学家,1879 - 1955 年

KISS 是“保持简单,愚蠢”或“保持简单,愚蠢”的缩写(好吧,我知道,这个缩写还有其他意思,但这两个是最常见的)。在极限编程(XP)中,这个原则由一个名为“做最简单的工作”(DTSTTCPW)的实践来代表。KISS 原则声明简单性应该是软件开发的主要目标,并且应该避免不必要的复杂性。

我认为 KISS 是开发人员在开发软件时通常会忘记的原则之一。软件开发人员倾向于以某种复杂的方式编写代码,让事情变得更加复杂。我知道,我们都是技术高超、积极性很高的开发人员,我们知道关于设计和架构模式、框架、技术、工具以及其他很酷很有趣的东西的一切。制作酷软件不是我们朝九晚五的工作——这是我们的使命,我们通过工作来获得成就感。

但是你必须记住,任何软件系统都有一个内在的复杂性,这个复杂性本身就具有挑战性。毫无疑问,复杂的问题往往需要复杂的代码。固有的复杂性无法降低。由于系统要满足的需求,这种复杂性就在那里。但在这种内在的复杂性上增加不必要的、自制的复杂性将是致命的。因此,明智的做法是不要因为可以就使用语言的每一个花哨功能或很酷的设计模式。另一方面,不要过分强调简单。如果在一个开关盒中有十个决定是必要的,那就是它的方式。

尽可能保持你的代码简单!当然,如果有关于灵活性和可扩展性的高优先级质量需求,您必须增加复杂性来满足这些需求。例如,当需求需要时,你可以使用众所周知的策略模式(参见第 9 章关于设计模式)在你的代码中引入一个灵活的变化点。但是要小心,只增加使事情变得简单的复杂性。

For programmers, focusing on simplicity may be one of the most difficult things. This is a lifelong learning experience. —— Adrian Bolboaca (@ adibalb), on April 3rd, 2014, on Twitter.

亚吉

总是在你真正需要的时候实施,而不是在你预见到你需要的时候。-罗恩·杰弗里斯,你不会需要它的!【杰弗里斯 98】

这个原则与之前讨论的 KISS 原则紧密相关。YAGNI 是“你不会需要它的”的首字母缩写有时它被翻译成“你不需要它!”YAGNI 是对投机泛化和过度工程的宣战。它声明您不应该编写目前不需要但将来可能需要的代码。

可能每个开发人员在日常工作中都知道这种诱人的冲动:“也许我们以后会用到它…”或者“我们会需要…”不,你不会需要它的!在任何情况下,你都应该抵制生产某种东西以备后用。你可能根本不需要它。但是如果你实现了那些不必要的东西,你就浪费了你的时间,代码变得比它应该变得更复杂了!当然,你也违反了接吻原则。更糟糕的后果可能是,这些未来的代码片段充满错误,并导致严重的问题!

我的建议是:相信重构的力量,不要在你知道它们实际上是必要的之前就开始构建。

干燥的

复制粘贴是一个设计错误。—戴维·l·帕纳斯

虽然这个原则是最重要的原则之一,但我很确定它经常被无意或有意地违反。DRY 是“不要重复自己!”并指出我们应该避免重复,因为重复是邪恶的。有时这个原则也被称为“一次且仅一次”(OAOO)。

复制非常危险的原因是显而易见的:当一个部分被改变时,它的副本必须相应地改变。而且不要抱太大希望。变化肯定会发生。我觉得没有必要提任何抄袭的作品迟早会被遗忘,我们可以和 bug 打个招呼。

好了,就这样——没什么要说的了?等等,还有一些东西,我们需要更深入。

在他们杰出的著作《务实的程序员[Hunt99]》中,迪夫·托马斯和安迪·亨特指出,应用 DRY 原则意味着我们必须确保“每项知识在系统中必须有一个单一的、明确的、权威的表示。”值得注意的是,戴夫和安迪没有明确提到代码,但他们谈到了知识。一个系统的知识远不止是它的代码。例如,DRY 原则也适用于文档、项目和测试计划,或者系统的配置数据。干影响一切!也许你可以想象,严格遵守这个原则并不像乍看起来那么容易。

信息隐蔽

信息隐藏是软件开发中一个众所周知的基本原则。著名的 David L. Parnas 在 1972 年写的开创性论文“关于将系统分解成模块的标准”[Parnas72]中首次记载了这一点。

该原则规定,调用另一段代码的一段代码不应该知道另一段代码的内部情况。这使得更改被调用代码的内部部分成为可能,而不必被迫相应地更改调用代码。

David L. Parnas 将信息隐藏描述为将系统分解为模块的基本原则。Parnas 认为,系统模块化应该关注隐藏困难的设计决策或可能改变的设计决策。软件单元(例如,类或组件)暴露给它的环境的内部越少,单元的实现和它的客户之间的耦合就越少。因此,软件单元内部实现的变化不会传播到它的环境中。

信息隐藏有许多优点:

  • 模块变化后果的限制
  • 如果需要修复错误,对其他模块的影响最小
  • 显著提高了模块的可重用性
  • 更好的模块可测试性

信息隐藏经常与封装混淆,但并不相同。我知道这两个术语在许多著名的书中被当作同义词使用,但我不同意。信息隐藏是一个帮助开发者找到好的模块的设计原则。该原则在多个抽象层次上起作用,并展现其积极效果,尤其是在大型系统中。

封装通常是一种依赖于编程语言的技术,用于限制对模块内部的访问。例如,在 C++ 中,你可以在类成员列表前加上关键字private,以确保它们不能从类外被访问。但是正因为我们使用了这种访问控制的安全措施,我们离自动隐藏信息还很远。封装有助于信息隐藏,但不能保证信息隐藏。

下面的代码示例显示了一个具有较差信息隐藏的封装类:

class AutomaticDoor {

public:

  enum class State {
    closed = 1,
    opening,
    open,
    closing
  };

private:

  State state;
  // ...more attributes here...

public:

  State getState() const;
  // ...more member functions here...
};

Listing 3-1.A class for automatic door steering

(excerpt)

这不是信息隐藏,因为该类的部分内部实现暴露在环境中,即使该类看起来封装得很好。注意getState返回值的类型。使用此类的客户端需要枚举类State,如下例所示:

#include "AutomaticDoor.h"

int main() {
  AutomaticDoor automaticDoor;
  AutomaticDoor::State doorsState = automaticDoor.getState();
  if (doorsState == AutomaticDoor::State::closed) {
    // do something...
  }
  return 0;
}

Listing 3-2.An example how AutomaticDoor

must be used to query the door’s current state

Enumeration Class (Struct) [C++11]

在 C++11 中,枚举类型也有了创新。为了向下兼容早期的 C++ 标准,仍然有著名的带有关键字enum的枚举。从 C++11 开始,也有了枚举类和枚举结构。

这些旧 C++ 枚举的一个问题是,它们将其枚举文字导出到周围的命名空间,从而导致名称冲突,如下例所示:

const std::string bear;
// ...and elsewhere in the same namespace...

enum Animal { dog, deer, cat, bird, bear }; // error: 'bear' redeclared as different kind of symbol

此外,旧的 C++ 枚举隐式转换为int,当不期望或不想进行这种转换时,会导致微妙的错误:

enum Animal { dog, deer, cat, bird, bear };
Animal animal = dog;

int aNumber = animal; // Implicit conversion: works

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

const std::string bear;
// ...and elsewhere in the same namespace...

enum class Animal { dog, deer, cat, bird, bear }; // No conflict with the string named 'bear'
Animal animal = Animal::dog;

int aNumber = animal; // Compiler error!

对于现代 C++ 程序,强烈建议使用枚举类而不是普通的旧枚举,因为这样会使代码更安全。因为枚举类也是类,所以它们可以被前向声明。

如果AutomaticDoor的内部实现必须改变,枚举类State从类中移除,会发生什么?显而易见,它将对客户端的代码产生重大影响。这将导致所有使用成员函数AutomaticDoor::getState()的地方都发生变化。

下面是一个具有良好信息隐藏的封装AutomaticDoor:

class AutomaticDoor {

public:

  bool isClosed() const;
  bool isOpening() const;
  bool isOpen() const;
  bool isClosing() const;
  // ...more operations here...

private:

  enum class State {
    closed = 1,
    opening,
    open,
    closing
  };

  State state;
  // ...more attributes here...
};

Listing 3-3.A better designed class for automatic door steering
#include "AutomaticDoor.h"

int main() {
  AutomaticDoor automaticDoor;
  if (automaticDoor.isClosed()) {
    // do something...
  }
  return 0;
}

Listing 3-4.An example how elegant class AutomaticDoor can be used after it was changed

现在改变AutomaticDoor的内部结构容易多了。客户端代码不再依赖于类的内部部分。现在您可以移除枚举State并用另一种实现替换它,而该类的任何用户都不会注意到这一点。

强大的凝聚力

软件开发中的一个普遍建议是,任何软件实体(同义词:模块、组件、单元、类、函数……)都应该具有强(或高)内聚性。总的来说,当模块完成定义明确的工作时,内聚性就很强。

为了更深入地探究这个原理,让我们从图 3-1 开始,看两个内聚性较弱的例子。

A429836_1_En_3_Fig1_HTML.jpg

图 3-1。

MyModule has too many responsibilities, and this leads to many dependencies from and to other modules

在这个任意系统模块化的例子中,业务领域的三个不同方面被放在一个模块中。方面 A、B 和 C 没有任何共同点,或者几乎没有共同点,但是这三个方面都放在MyModule中。查看模块的代码可以发现,A、B 和 C 的函数是对不同的、完全独立的数据块进行操作的。

现在看一下图中所有的虚线箭头。每一个都是依赖。这种箭头尾部的元素需要箭头头部的元素才能实现。在这种情况下,想要使用由 A、B 或 C 提供的服务的系统的任何其他模块将使自己依赖于整个模块MyModule。这种设计的主要缺点是显而易见的:它将导致太多的依赖性,并且可维护性会下降。

为了增加内聚性,A、B 和 C 的方面应该相互分离,并移动到它们自己的模块中(图 3-2 )。

A429836_1_En_3_Fig2_HTML.jpg

图 3-2。

High cohesion : The previously mixed aspects A, B, and C have been separated into discrete modules

现在很容易看出,这些模块中的每一个都比我们以前的MyModule有更少的依赖性。很明显,A、B 和 C 彼此之间没有直接关系。唯一依赖于所有三个模块 A、B 和 C 的模块是名为Module 1的模块。

另一种形式的弱内聚被称为 Shot Gun 反模式。我想众所周知,猎枪是一种能发射大量小球状弹丸的武器。这种武器通常散布很广。在软件开发中,这个比喻用来表达某个领域方面,或者单个逻辑思想,是高度分散的,分布在许多模块中。图 3-3 描绘了这样一种情况。

A429836_1_En_3_Fig3_HTML.jpg

图 3-3。

The Aspect A was scattered over five modules

即使有这种形式的弱内聚力,也会产生许多不利的依赖性。方面 A 的分布式片段必须紧密合作。这意味着实现方面 A 的子集的每个模块必须至少与包含方面 A 的另一个子集的另一个模块交互。这导致了设计中大量的交叉依赖。在最坏的情况下,它会导致循环依赖,比如模块 1 和 3 之间,或者模块 6 和 7 之间。这又一次对可维护性和可扩展性产生了负面影响。当然这种设计的可测试性非常差。

这种设计将导致所谓的猎枪手术。关于方面 A 的某种类型的改变导致对许多模块进行许多小的改变。那确实不好,应该避免。我们必须通过将相同逻辑方面的所有代码片段整合到一个单一的内聚模块中来解决这个问题。

还有一些其他的原则——例如,面向对象设计的单一责任原则(SRP )(见第 6 章)——可以培养高内聚。高内聚通常与松散耦合相关,反之亦然。

松耦合

考虑下面这个小例子:

class Lamp {

public:

  void on() {
    //...
  }

  void off() {
    //...
  }
};

class Switch {

private:

  Lamp& lamp;
  bool state {false};

public:

  Switch(Lamp& lamp) : lamp(lamp) { }

  void toggle() {
    if (state) {
      state = false;
      lamp.off();
    } else {
      state = true;
      lamp.on();
    }
  }
};

Listing 3-5.A switch that can power on and off a lamp

基本上,这段代码是可行的。您可以首先创建一个类Lamp的实例。然后在实例化类Switch时通过引用传递。用 UML 可视化,这个小例子看起来如图 3-4 所示。

A429836_1_En_3_Fig4_HTML.jpg

图 3-4。

A class diagram of Switch and Lamp

这个设计有什么问题?

问题是我们的Switch包含了对具体类Lamp的直接引用。换句话说:开关知道有灯。

也许你会争辩说:“好吧,但这就是开关的目的。它必须打开和关闭灯。”我会说:是的,如果这是交换机应该做的唯一一件事,那么这种设计可能就足够了。但是请去 DIY 商店看看你能在那里买到的开关。他们知道灯的存在吗?

还有你怎么看待这个设计的可测性?因为单元测试需要开关,所以开关可以独立测试吗?不,这是不可能的。当开关不仅要打开灯,还要打开风扇或电动卷帘时,我们该怎么办?

在上面的例子中,开关和灯紧密耦合。

在软件开发中,应该寻求模块之间的松散耦合(也称为低耦合或弱耦合)。这意味着您应该构建一个系统,其中的每个模块都很少或根本不知道其他独立模块的定义,或者利用这些知识。

软件开发中松耦合的关键是接口。一个接口声明了一个类的公共可访问的行为特征,而不需要提交该类的特定实现。接口就像一个契约。实现接口的类被承诺履行契约,也就是说,这些类必须为接口的方法签名提供实现。

在 C++ 中,接口是使用抽象类实现的,就像这样:

class Switchable {

public:

  virtual void on() = 0;
  virtual void off() = 0;
};
Listing 3-6.The Switchable interface

Switch不再包含对Lamp的引用。相反,它包含了对我们的新接口类Switchable的引用。

class Switch {

private:

  Switchable& switchable;
  bool state {false};

public:

  Switch(Switchable& switchable) : switchable(switchable) {}

  void toggle() {
    if (state) {
      state = false;
      switchable.off();
    } else {
      state = true;
      switchable.on();
    }
  }
};

Listing 3-7.The modified Switch class, where Lamp is gone

Lamp类实现了我们的新接口。

class Lamp : public Switchable {

public:

  void on() override {
    // ...
  }

  void off() override {
    // ...
  }
};

Listing 3-8.Class ‘Lamp’ implements the ‘Switchable’ interface

用 UML 表达,我们的新设计看起来如图 3-5 所示。

A429836_1_En_3_Fig5_HTML.jpg

图 3-5。

Loosely coupled Switch and Lamp via an interface

这种设计的优点是显而易见的。完全独立于受其控制的具体类。此外,Switch可以通过提供一个实现Switchable接口的测试替身来独立测试。你想控制风扇而不是灯?没问题:这个设计可以扩展。创建一个类Fan或者其他表示实现接口Switchable的电气设备的类,如图 3-6 所示。

A429836_1_En_3_Fig6_HTML.jpg

图 3-6。

Via an interface, a Switch is able to control different classes for electrical devices

关注松散耦合可以为系统的各个模块提供高度的自主性。这个原则可以在不同的层次上有效:既可以在最小的模块上有效,也可以在大型组件的系统架构层次上有效。高内聚促进了松散耦合,因为具有明确定义的职责的模块通常依赖于较少的合作者。

小心优化

过早的优化是编程中所有罪恶(或者至少是大部分罪恶)的根源。—唐纳德·e·克努特,美国计算机科学家【克努特 74】

我见过开发人员开始浪费时间的优化,只是对开销有模糊的概念,但并不真正知道性能损失在哪里。他们经常篡改个别指令;或者试图优化小的局部循环,以挤出哪怕是最后一滴性能。作为一个脚注,我说的这些程序员中有一个就是我。

这些活动的成功通常是微不足道的。预期的性能优势通常不会出现。最终这只是浪费了宝贵的时间。相反,所谓的优化代码的可理解性和可维护性通常会受到严重影响。特别糟糕的是:有时在这样的优化措施中,甚至会出现微妙的错误。我的建议是:只要没有明确的性能需求需要满足,就不要去做优化。

我们代码的可理解性和可维护性应该是我们的首要目标。正如我在“但是调用时间开销!”在第 4 章中,编译器现在非常擅长优化代码。每当你想优化某样东西的时候,想想 YAGNI。

只有当利益相关者明确要求的明确的性能需求没有得到满足时,你才应该采取行动。但是你应该首先仔细分析性能在哪里丢失了。不要仅凭直觉就做任何优化。例如,您可以使用一个分析器来找出瓶颈在哪里。使用这种工具后,开发人员通常会惊讶地发现,性能在一个与最初假设的位置完全不同的位置丢失了。

Note

分析器是一种动态程序分析工具。它测量函数调用的频率和持续时间等指标。收集的剖析信息可用于帮助程序优化。

最小惊讶原则

最小惊讶原则(PLA 解放军),也被称为最小惊讶原则(POLS),是用户界面设计和人类工程学中众所周知的。该原则指出,用户不应该对用户界面的意外响应感到惊讶。用户不应被出现或消失的控件、令人困惑的错误消息、对已建立的按键序列的异常反应(记住:Ctrl + C是在 Windows 操作系统上复制应用程序的事实标准,而不是退出程序)或其他意外行为所迷惑。

这个原理也可以很好的移植到软件开发中的 API 设计上。调用一个函数不应该用意想不到的行为或神秘的副作用让调用者感到惊讶。一个函数应该完全按照它的函数名所暗示的那样去做(参见第 4 章中关于“函数命名”的章节)。例如,在一个类的实例上调用 getter 不应该修改该对象的内部状态。

童子军规则

这个原则是关于你和你的行为的。它是这样写的:永远让露营地比你发现它的时候更干净。

童子军很有原则。他们的一个原则是,一旦他们发现了这样的不好的事情,他们应该立即清理环境中的垃圾或污染。作为负责任的软件工匠,我们应该将这个原则应用到我们的日常工作中。每当我们在一段代码中发现需要改进的地方,或者有不好的代码味道时,我们应该立即修复它。这段代码的原作者是谁并不重要。

这种行为的优点是我们不断地防止我们的代码崩溃。如果我们都这样做,代码就不会腐烂。软件熵增长的趋势很难控制我们的系统。这种改善不一定很大。这可能是一个非常小的清理,例如:

  • 重命名命名不当的类、变量、函数或方法(参见第 4 章中的“良好名称和函数命名”一节)。
  • 将一个大函数的内部分解成更小的部分(参见第 4 章中的“让它们变小”一节)。
  • 通过使被注释的代码段不言自明来删除注释(参见第 4 章中的“避免注释”一节)。
  • 清理一个复杂而令人困惑的 if-else-compound。
  • 删除一小段重复的代码(参见本章中关于 DRY 原理的部分)。

由于这些改进大部分是代码重构,如第 2 章所述,由良好的单元测试组成的稳固的安全网是必不可少的。没有适当的单元测试,你不能确定你没有破坏某些东西。

除了良好的单元测试覆盖率,我们还需要团队中的特殊文化:集体代码所有权。

集体代码所有权意味着我们应该真正像一个团体一样工作。任何时候,每个团队成员都可以对任何一段代码进行修改或扩展。不应该有“这是彼得的代码,那是弗雷德的模块”这样的态度。我不碰他们!”别人能接手我们写的代码,应该算是很高的价值了。在一个真正的团队中,任何人都不应该害怕清理代码或添加新功能,或者必须获得许可。有了集体代码所有权的文化,童子军规则将会运行良好。