开闭原则 by Robert Martin

开闭原则 by Robert Martin,第1张

The Open-Closed Principle

    这是我为《C++报告》撰写的第五篇《工程笔记》。本专栏中的文章将重点介绍C++和OOD的使用,并将讨论软件工程的问题。我将努力撰写实用且对奋战中的软件工程师直接有用的文章。在这些文章中,我将使用Booch的符号来记录面向对象的设计。侧边栏提供了布赫符号的简短词汇。

    有许多与面向对象设计相关的启发式方法。例如,“所有成员变量都应该是私有的”,或者“应该避免使用全局变量”,或者“使用运行时类型标识(即RTTI)是危险的”。这些启发法的来源是什么?

    是什么让它们成为现实?它们总是真的吗?本专栏探讨了这些启发法背后的设计原则——开闭原则

    正如伊瓦尔·雅各布森所说:“所有系统在其生命周期中都会发生变化。在开发预期寿命比第一个版本更长的系统时,必须牢记这一点。”

    我们如何才能创造出在变化面前稳定的设计,并且比第一个版本更持久?Bertrand Meyer2早在1988年就为我们提供了指导,当时他创造了现在著名的开闭原则。套用他的话:

    软件实体(类、模块、函数等)应为扩展开放,但为修改关闭。

    当对一个程序的一次更改导致对相关模块的一系列更改时,该程序会表现出我们认为与“糟糕”设计有关的不良属性。程序变得脆弱、僵化、不可预测且无法使用。开闭原理以一种非常直接的方式来攻击这一点。它说你应该设计永不改变的模块。当需求发生变化时,您可以通过添加新代码来扩展这些模块的行为,而不是通过更改已经运行的旧代码。

1、详细描述

    符合开闭原则的模块有两个主要属性。

    1)为扩展开放

    这意味着模块的行为可以扩展。随着应用需求的变化,或者为了满足新应用的需求,我们可以使模块以新的、不同的方式运行。

    2)为修改关闭

    这样一个模块的源代码是不可侵犯的。任何人都不允许对其进行源代码更改。

    这两个属性似乎相互矛盾。扩展模块行为的正常方法是对该模块进行更改。无法更改的模块通常被认为具有固定的行为。如何解决这两个对立的属性?

    抽象是关键。

    在C++中,使用面向对象设计的原则,可以创建固定的抽象,但可以表示一组无限的可能行为。抽象是抽象的基类,无界的可能行为群由所有可能的派生类表示。模块可以 *** 纵抽象。这样的模块可以关闭进行修改,因为它依赖于一个固定的抽象。然而,该模块的行为可以通过创建新的抽象派生来扩展。

    图1显示了一个不符合开闭原理的简单设计。客户端和服务器类都是具体的。不能保证Server类的成员函数是虚拟的。客户机类使用服务器类。如果我们希望客户机对象使用不同的服务器对象,那么必须将客户机类更改为新服务器类的名称。

    图2显示了符合开闭原则的相应设计。在本例中,AbstractServer类是一个具有纯虚拟成员函数的抽象类。客户机类使用这种抽象。但是,客户端类的对象将使用派生服务器类的对象。如果我们希望客户机对象使用不同的服务器类,那么可以创建AbstractServer类的新派生。客户端类可以保持不变。

图2显示了符合开闭原则的相应设计。在本例中,AbstractServer类是一个具有纯虚拟成员函数的抽象类。客户机类使用这种抽象。但是,客户端类的对象将使用派生服务器类的对象。如果我们希望客户机对象使用不同的服务器类,那么可以创建AbstractServer类的新派生。客户端类可以保持不变。

2、形状抽象

    考虑下面的例子。我们有一个应用程序,必须能够在标准的GUI上绘制圆形和方形。圆圈和正方形必须按特定顺序绘制。将以适当的顺序创建圆和正方形的列表,程序必须按照该顺序遍历列表并绘制每个圆或正方形。

    在C语言中,使用不符合开闭原则的过程技术,我们可以解决这个问题,如清单1所示。在这里,我们看到了一组数据结构,它们具有相同的第一个元素,但除此之外又有所不同。每个元素的第一个元素是一个类型代码,它将数据结构标识为圆形或方形。函数DrawAllShapes遍历一组指向这些数据结构的指针,检查类型代码,然后调用相应的函数(DrawCircle或DrawSquare)。

2.1、清单1

    方形/圆形问题的程序解决方案

enum ShapeType {circle, square};
struct Shape 
{
 ShapeType itsType;
};
struct Circle 
{
 ShapeType itsType;
 double itsRadius; 
 Point itsCenter;
};
struct Square 
{
 ShapeType itsType;
 double itsSide; 
 Point itsTopLeft;
};

struct Square 
{
 ShapeType itsType;
 double itsSide; 
 Point itsTopLeft;
};
//
// These functions are implemented elsewhere
//
void DrawSquare(struct Square*)
void DrawCircle(struct Circle*);

typedef struct Shape *ShapePointer;

void DrawAllShapes(ShapePointer list[], int n)
{
 int i;
 for (i=0; iitsType)
 {
 case square:
 DrawSquare((struct Square*)s);
 break;
 case circle:
 DrawCircle((struct Circle*)s);
 break;
 }
 }
}

    DrawAllShapes函数不符合开闭原则,因为它不能针对新类型的形状关闭。如果我想扩展这个函数,以便能够绘制包含三角形的形状列表,我必须修改这个函数。事实上,我必须修改函数,以便绘制任何新类型的形状。

    当然,这个程序只是一个简单的例子。在现实生活中DrawAllShapes函数中的switch语句会在应用程序中的各种函数中反复出现;每个人做的事情都有点不同。向这样的应用程序添加一个新形状意味着查找存在这样的switch语句(或if/else链)的每个位置,并向每个位置添加新形状。此外,不太可能所有的switch语句和if/else链都像DrawAllShapes中的语句一样结构良好。if语句的谓词更有可能与逻辑运算符相结合,或者switch语句的case子句相结合,以“简化”局部决策。因此,找到并理解需要添加新形状的所有位置的问题可能非常重要。

    清单2显示了符合开闭原则的方形/圆形问题解决方案的代码。在本例中,将创建一个抽象形状类。这个抽象类有一个名为Draw的纯虚拟函数。圆和方都是形状类的导数。

2.2、清单2

    方/圆问题的OOD解法

class Shape 
{
 public: 
 virtual void Draw() const = 0;
};
class Square : public Shape
{
 public: 
 virtual void Draw() const;
};
class Circle : public Shape
{
 public: 
 virtual void Draw() const;
};
void DrawAllShapes(Set& list)
{
 for (Iteratori(list); i; i++)
 (*i)->Draw();
}

    请注意,如果我们想扩展清单2中DrawAllShapes函数的行为来绘制一种新的形状,我们只需要添加shape类的一个新派生。DrawAllShapes函数不需要更改。因此,DrawAllShapes符合开闭原则。它的行为可以在不修改的情况下进行扩展。

    在现实世界中,Shape类将有更多的方法。然而,向应用程序中添加一个新形状仍然非常简单,因为所需的只是创建新的派生并实现其所有功能。不需要在所有应用程序中寻找需要更改的地方。由于符合开闭原则的程序是通过添加新代码而不是通过更改现有代码来更改的,因此它们不会经历不一致程序所表现出的一连串更改。

    应该清楚的是,任何重大项目都不可能100%完成。例如,考虑一下清单2中的DrawAllShapes函数会发生什么,如果我们决定所有的圆都应该在任何正方形之前绘制。DrawAllShapes函数不会针对这样的更改关闭。总的来说,不管一个模块有多“封闭”,总会有一些变化,它不会关闭。由于关闭不可能完成,因此必须具有战略意义。

    也就是说,设计师必须选择要关闭其设计的变更类型。这需要从经验中获得一定的先见之明。经验丰富的设计师对用户和行业非常了解,能够判断各种变化的可能性。然后,他确保对最可能的变化调用开闭原则。

    使用抽象获得显式闭包。

    我们如何关闭DrawAllShapes功能,以防止绘图顺序发生变化?记住,闭包是基于抽象的。因此,为了使DrawAllShapes与有序性保持一致,我们需要某种“有序抽象”。上述排序的具体情况与在其他类型的形状之前绘制某些类型的形状有关。

    排序策略意味着,给定任意两个对象,可以发现应该首先绘制哪个对象。因此,我们可以定义一个名为preceds的Shape方法,该方法将另一个Shape作为参数,并返回bool结果。如果接收消息的形状对象应该在作为参数传递的形状对象之前排序,则结果为真。

    在C++中,这个函数可以用重载运算符<函数来表示。清单3显示了使用适当的排序方法时Shape类的外观。

    现在我们有了一种方法来确定两个形状物体的相对顺序,我们可以对它们进行排序,然后按顺序绘制它们。清单4显示了实现这一点的C++代码。此代码使用my book3中开发的Components类别中的Set、OrderedSet和迭代器类(如果您想要Components类别的源代码的免费副本,请发送电子邮件至rmartin@oma.com).

    这为我们提供了一种对形状对象进行排序,并以适当的顺序绘制它们的方法。但我们仍然没有一个像样的排序抽象。目前,独立的个人形状对象必须覆盖Precedes方法才能指定顺序。这是怎么回事?我们将在Circle::Prefers中编写什么样的代码来确保在正方形之前绘制圆?考虑清单5。

2.3、清单3

    使用排序方法进行形状调整。

class Shape
{
 public:
 virtual void Draw() const = 0;
 virtual bool Precedes(const Shape&) const = 0;
 bool operator<(const Shape& s) {return Precedes(s);}
};
2.4、清单4

    按顺序绘制各种形状。

void DrawAllShapes(Set& list)
{
 // copy elements into OrderedSet and then sort.
 OrderedSet orderedList = list; 
 orderedList.Sort();
 for (Iterator i(orderedList); i; i++)
 (*i)->Draw();
}
2.5、清单5

    预订一个圆。

bool Circle::Precedes(const Shape& s) const
{
 if (dynamic_cast(s))
 return true;
 else
 return false;
}

    应该非常清楚的是,这个函数不符合开闭原则。没有办法阻止形状的新衍生物。每次创建新的形状衍生工具时,都需要更改此功能。

    使用“数据驱动”的方法实现关闭。

    形状衍生物的闭合可以通过使用表驱动的方法来实现,该方法不会强制每个派生类进行更改。清单6显示了一种可能性。

    通过采用这种方法,我们成功地解决了DrawAllShapes函数的一般排序问题,以及每个形状衍生物的创建问题,或者改变了按形状对象类型重新排序的策略。(例如,更改顺序,以便首先绘制正方形。)

2.6、清单6

    表驱动型排序机制。

#include 
#include 
enum {false, true};
typedef int bool;
class Shape
{
 public:
 virtual void Draw() const = 0;
 virtual bool Precedes(const Shape&) const;
 bool operator<(const Shape& s) const 
 {return Precedes(s);}
 private:
 static char* typeOrderTable[];
};
char* Shape::typeOrderTable[] =
{
 “Circle”,
 “Square”,
 0
};
// This function searches a table for the class names.
// The table defines the order in which the 
// shapes are to be drawn. Shapes that are not
// found always precede shapes that are found.
//
bool Shape::Precedes(const Shape& s) const
{
 const char* thisType = typeid(*this).name();
 const char* argType = typeid(s).name();
 bool done = false;
 int thisOrd = -1;
 int argOrd = -1;
 for (int i=0; !done; i++)
 {
   const char* tableEntry = typeOrderTable[i];
   if (tableEntry != 0){
     if (strcmp(tableEntry, thisType) == 0)
     thisOrd = i;
     if (strcmp(tableEntry, argType) == 0)
     argOrd = i;
     if ((argOrd > 0) && (thisOrd > 0))
         done = true;
     }else // table entry == 0
         done = true;
     }
  }
}
 return thisOrd < argOrd;
}

    唯一没有按照各种形状的顺序关闭的项目是表格本身。该表可以放在它自己的模块中,与所有其他模块分开,这样对它的更改就不会影响任何其他模块。

    进一步对扩展关闭:

    这还不是故事的结尾。我们已经成功地关闭了形状层次结构,DrawAllShapes的功能针对依赖于形状类型的顺序。但是,形状衍生工具不会针对与形状类型无关的排序策略关闭。看起来我们很可能希望根据更高层次的结构来订购形状的绘制。对这些问题的全面探讨超出了本文的范围;然而,雄心勃勃的读者可能会考虑如何使用OrderedShape类包含的抽象OrderedObject类来解决这个问题,该类派生自Shape和OrderedObject。

3、启发和约定

    正如本文开头提到的,开闭原则是多年来关于OOD的许多启发式和约定背后的根本动机。以下是其中一些重点。

    1)将所有成员变量设置为私有。

    这是世界上最常见的习俗之一。类的成员变量应该只为定义它们的类的方法所知。任何其他类(包括派生类)都不应该知道成员变量。因此,它们应该被宣布private,而不是public的或protected。

    根据开闭原则,这项公约的理由应该是明确的。当类的成员变量改变时,依赖于这些变量的每个函数都必须改变。因此,任何依赖于变量的函数都不能相对于该变量闭合。

    在面向对象设计中,我们希望一个类的方法不受该类成员变量变化的影响。然而,我们确实希望任何其他类,包括子类,都不会因这些变量的变化而关闭。我们为这种期望取了一个名字,我们称之为:封装。

    现在,如果你有一个你知道永远不会改变的成员变量呢?有什么理由保密吗?例如,清单7显示了一个具有bool状态变量的类设备。此变量包含上次 *** 作的状态。如果该 *** 作成功,则状态为true;否则它将是错误的。

3.1、清单7

    非常量公共变量。

class Device
{
 public:
 bool status;
};

    我们知道这个变量的类型或意义永远不会改变。那么,为什么不公开它,让客户端代码简单地检查它的内容呢?如果这个变量真的永远不会改变,如果所有其他客户机都遵守规则,只查询status的内容,那么这个变量是public这一事实就没有任何坏处。但是,请考虑一下,如果哪怕一个客户机利用状态的可写性,并更改其值,会发生什么情况。突然之间,这一个客户端可能会影响设备的所有其他客户端。这意味着不可能关闭设备的任何客户端,以防对这一行为不端的模块进行更改。这可能是一个太大的风险。

    另一方面,假设我们有如清单8所示的Time类。这个类中的公共成员变量造成了什么危害?当然,它们不太可能改变。此外,如果任何客户端模块对变量进行了更改,这并不重要,这些变量应该由客户端进行更改。派生类也不太可能希望捕获特定成员变量的设置。那么有什么弊端吗?

3.2、清单8
class Time
{
 public:
 int hours, minutes, seconds;
 Time& operator-=(int seconds);
 Time& operator+=(int seconds);
 bool operator< (const Time&);
 bool operator> (const Time&);
 bool operator==(const Time&);
 bool operator!=(const Time&);
};

    关于清单8,我可能会抱怨时间的修改不是原子的。也就是说,客户端可以在不更改小时变量的情况下更改分钟变量。这可能会导致时间对象的值不一致。我更希望有一个函数来设置需要三个参数的时间,从而使时间的设置原子化。但这是一个非常薄弱的论点。

    不难想象这些变量的公共性会导致一些问题的其他条件。然而,从长远来看,没有压倒性的理由将这些变量私有化。我仍然认为把它们公之于众是一种糟糕的风格,但这可能不是一种糟糕的设计。我认为这是一种糟糕的风格,因为创建适当的内联成员函数非常便宜;而且廉价的成本几乎肯定值得保护,以防出现关闭问题的轻微风险。

    因此,在不违反开闭原则的罕见情况下,公共和受保护变量的产生更多地取决于风格而非实质。

    从来没有全局变量:

    反对全局变量的论点与反对公共成员变量的论点相似。依赖于全局变量的任何模块都不能或可能写入该变量的任何其他模块关闭。任何以其他模块不期望的方式使用该变量的模块都会破坏其他模块。让许多模块受制于一个表现糟糕的模块的突发奇想,风险太大了。

    另一方面,如果一个全局变量的依赖项很少,或者不能以不一致的方式使用,那么它们几乎没有什么害处。设计师必须评估为一个全球性项目牺牲了多少闭包,并确定全球性项目提供的便利是否值得。

    同样,风格问题也在起作用。使用globals的替代品通常非常便宜。在这些情况下,使用哪怕是少量关闭都有风险的技术,而使用不具有这种风险的技术,是一种糟糕的风格。然而,在某些情况下,全球网络的便利性非常重要。全局变量cout和cin是常见的例子。在这种情况下,如果不违反开闭原则,那么这种便利是可能值得违反的方式。

    RTTI很危险:

    另一个非常常见的禁令是反对动态特性。人们经常声称,动态类型转换或任何形式的运行时类型识别(RTTI)本质上是危险的,应该避免。经常被引用的案例与清单9类似,这显然违反了开闭原则。然而,清单10显示了一个类似的程序,它使用动态强制转换,但不违反开闭原则。

3.3、清单9

    RTTI违反了开闭原则。

class Shape {};
class Square : public Shape
{
 private:
 Point itsTopLeft;
 double itsSide;
 friend DrawSquare(Square*);
};
class Circle : public Shape
{
 private:
 Point itsCenter;
 double itsRadius;
 friend DrawCircle(Circle*);
};
void DrawAllShapes(Set& ss)
{
 for (Iteratori(ss); i; i++)
 {
 Circle* c = dynamic_cast(*i);
 Square* s = dynamic_cast(*i);
 if (c)
 DrawCircle(c);
 else if (s)
 DrawSquare(s);
 }
}
3.4、清单10

    不违反开闭原则的RTTI。

class Shape
{
 public:
 virtual void Draw() cont = 0;
};
class Square : public Shape
{
 // as expected.
};
void DrawSquaresOnly(Set& ss)
{
 for (Iteratori(ss); i; i++)
 {
 Square* s = dynamic_cast(*i);
 if (s)
 s->Draw();
 }
}

    这两者之间的区别在于,无论何时派生新类型的形状,都必须更改清单9中的第一个。(更不用说这完全是愚蠢的)。然而,当创建了一个新的形状衍生工具时,清单10中没有任何变化。因此,清单10没有违反开闭原则。一般来说,如果RTTI的使用没有违反开闭原则,那么它是安全的。

4、结论

    关于开闭原则,还有很多可以说的。在许多方面,这一原则是面向对象设计的核心。遵循这一原则是面向对象技术最大的好处;例如:可重用性和可维护性。然而,仅仅使用面向对象编程语言并不能实现对这一原则的遵从。相反,它需要设计师的奉献精神,将抽象应用到程序中设计师认为会发生变化的部分。

    这篇文章是我的新书《面向对象设计的原则和模式》中一章的浓缩版,即将由普伦蒂斯·霍尔出版。在接下来的文章中,我们将探讨面向对象设计的许多其他原则。我们还将研究各种设计模式,以及它们在C++实现方面的优缺点。我们将研究Booch类别在C++中的作用,以及它们作为C++名称空间的适用性。我们将定义面向对象设计中“内聚”和“耦合”的含义,并开发度量面向对象设计质量的指标。在那之后,还有许多其他有趣的话题。

关注我:获取更多知识!

原版英文文档    [提取码:mjx9]

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/langs/873177.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-05-13
下一篇 2022-05-13

发表评论

登录后才能评论

评论列表(0条)

保存