开闭原理

哈Ha! 这是罗伯特·马丁(Robert Martin)于1996年1月发表的《 开放式原则》的译文。 温和地说,这篇文章不是最新的。 但是在RuNet中,鲍伯叔叔关于SOLID的文章仅以截短的形式转载,因此我认为完整的翻译不会是多余的。



我决定以字母O开头,因为事实上,封闭性原则很重要。 除其他事项外,还有许多重要的细微之处值得关注:


  • 没有任何程序可以“关闭” 100%。
  • 面向对象的编程(OOP)并非针对现实世界中的物理对象,而是针对概念(例如“排序”的概念)进行操作。

这是“ C ++报告”的“ 工程师说明”专栏中的第一篇文章。 本专栏中发表的文章将重点介绍C ++和OOP的使用,并探讨软件开发中的困难。 我将尽力使这些材料实用且对实践工程师有用。 对于这些文章中的面向对象设计的文档,我将使用Buch的表示法。


有许多与面向对象编程相关的试探法。 例如,“所有成员变量必须是私有的”,或“应避免使用全局变量”,或“在运行时确定类型是危险的”。 这种启发式的原因是什么? 为什么是真的? 他们总是对的吗? 本专栏探讨了启发式设计的基本原理-开放性-封闭性的原理。
Ivar Jacobson说:“所有系统在生命周期中都会发生变化。 在设计预期具有多个版本的系统时必须牢记这一点。” 我们如何设计一个系统,使其在面对变化时能够保持稳定,并具有多个版本? 伯特兰·迈耶(Bertrand Meyer)早在1988年就向我们介绍了这一点,当时他提出了现在著名的开放性-封闭性原则:


程序实体(类,模块,功能等)必须打开才能扩展,而关闭则不能更改。


如果程序中的一个更改需要在从属模块中进行一系列更改,则该程序将显示不良设计的不良迹象。


该程序变得脆弱,僵化,不可预测和未使用。 开放性-封闭性原理非常简单地解决了这些问题。 他说必须设计永远不变的模块。 当需求改变时,您需要通过添加新代码而不是更改已经运行的旧代码来扩展此类模块的行为。


内容描述


满足开放-封闭原则的模块具有两个主要特征:


  1. 开放扩展。 这意味着可以扩展模块的行为。 也就是说,我们可以根据不断变化的应用程序需求或满足新应用程序的需求向模块添加新行为。
  2. 关闭以进行更改。 此类模块的源代码不可修改。 没有人有权对其进行更改。

这两个迹象似乎并不吻合。 扩展模块行为的标准方法是对其进行更改。 通常将无法更改的模块视为行为固定的模块。 如何满足这两个相反的条件?


解决方案的关键是抽象。


在C ++中,使用面向对象设计的原理,可以创建可以表示无限可能行为的固定抽象。


抽象是抽象的基类,并且所有可能的后继类都表示了无限可能的行为集。 模块可以操纵抽象。 由于依赖固定的抽象,因此此类模块无法更改。 同样,可以通过创建新的抽象后代来扩展模块的行为。


下图显示了不符合开放-封闭原理的简单设计选项。 ClientServer这两个类都不是抽象的。 不能保证Server类成员的功能是虚拟的。 Client类使用Server类。 如果我们希望Client类对象使用其他服务器对象,则必须更改Client类以引用新的服务器类。


图片
封闭客户


下图显示了符合开放性-封闭性原理的相应设计方案。 在这种情况下, AbstractServer类是一个抽象类,其所有成员函数都是虚拟的。 Client类使用抽象。 但是, Client类的对象将使用Server后继类的对象。 如果我们希望Client类的对象使用其他服务器类,则将引入AbstractServer类的新后代。 Client类将保持不变。


图片
公开客户


Shape摘要


考虑一个应在标准GUI中绘制圆形和正方形的应用程序。 圆形和正方形必须以特定顺序绘制。 按照相应的顺序,将编译一个圆和正方形的列表,程序应按顺序浏览该列表并绘制每个圆或正方形。


在C语言中,使用不符合开闭原理的过程编程技术,我们可以解决此问题,如清单1所示。在这里,我们看到许多具有相同第一个元素的数据结构。 该元素是一种类型代码,用于将数据结构标识为圆形或正方形。 DrawAllShapes函数通过指向这些数据结构的指针数组传递,识别出类型代码,然后调用相应的函数( DrawSquareDrawSquare )。


 // 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; }; // //     // void DrawSquare(struct Square*) void DrawCircle(struct Circle*); typedef struct Shape *ShapePointer; void DrawAllShapes(ShapePointer list[], int n) { int i; for (i=0; i<n; i++) { struct Shape* s = list[i]; switch (s->itsType) { case square: DrawSquare((struct Square*)s); break; case circle: DrawCircle((struct Circle*)s); break; } } } 

DrawAllShapes函数DrawAllShapes符合开放性-封闭性原则,因为它不能从新型形状中“封闭”。 如果我想通过从包含三角形的列表中绘制形状来扩展此功能,则需要更改该功能。 实际上,我必须为需要绘制的每种新型形状更改功能。


当然,该程序只是一个例子。 在现实生活中, DrawAllShapes函数中的switch运算符会在整个应用程序的各种函数中反复出现,并且每个函数都会做不同的事情。 在这样的应用程序中添加新形状意味着找到使用此类switch (或if/else链)的所有位置,并为每个位置添加新形状。 此外,所有switch以及if/else链的结构if/else不太可能像DrawAllShapes结构一样。 谓词if将与逻辑运算符组合的可能性更大,或者switch case block将以“简化”代码中特定位置的方式组合。 因此,查找和理解所有需要添加新图形的地方的问题可能并非易事。


在清单2中,我将显示代码,该代码演示符合开放度-封闭度原理的正方形/圆形解决方案。 介绍了一个抽象的Shape类。 此抽象类包含一个纯虚拟Draw函数。 CircleSquare类是Shape类的后代。


 // 2 //  /  - 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<Shape*>& list) { for (Iterator<Shape*>i(list); i; i++) (*i)->Draw(); } 

注意:如果我们想扩展清单2中的DrawAllShapes函数的行为以绘制一种新的形状,我们要做的就是添加Shape类的新后代。 无需更改DrawAllShapes函数。 因此, DrawAllShapes符合开放性-封闭性原则。 无需更改功能本身即可扩展其行为。


在现实世界中, Shape类将包含许多其他方法。 但是,向应用程序添加新形状仍然非常简单,因为您需要做的就是输入新继承人并实现这些功能。 无需搜索整个应用程序以查找需要更改的地方。


因此,通过添加新代码而不是通过更改现有代码来更改符合开放性-紧密性原理的程序;它们不会级联不符合该原理的程序的更改特性。


封闭进入策略


显然,没有一个程序可以100%关闭。 例如,如果我们决定先绘制圆形然后再绘制正方形,清单2中的DrawAllShapes函数会发生什么? DrawAllShapes不会关闭DrawAllShapes函数。 一般而言,模块有多“关闭”都没有关系,总有某种类型的更改没有关闭。


由于关闭无法完成,因此必须从战略上引入它。 也就是说,设计人员必须选择将关闭程序的更改类型。 这需要一些经验。 经验丰富的开发人员非常了解用户和行业,可以计算出各种变化的可能性。 然后,他确保最有可能发生的变化都遵循开放性-封闭性原则。


使用抽象实现额外的亲密关系


我们如何关闭绘图顺序中的DrawAllShapes函数? 请记住,闭包是基于抽象的。 因此,要关闭DrawAllShapes的排序,我们需要某种“排序抽象”。 上面介绍的一种特殊订购方式是在另一种类型的图形前面绘制一种类型的图形。


排序策略意味着可以通过两个对象来确定应该首先绘制哪个对象。 因此,我们可以为Shape类定义一个名为Precedes的方法,该方法将另一个Shape对象作为参数,如果接收到此消息的Shape对象需要在Shape对象之前排序,则返回布尔值true作为参数传递。


在C ++中,此函数可以表示为“ <”运算符的重载。 清单3显示了带有排序方法的Shape类。


现在我们有了确定Shape类对象顺序的方法,我们可以对它们进行排序,然后绘制它们。 清单4显示了相应的C ++代码。 它使用我的书(使用Booch方法设计面向对象的C ++应用程序,使用Booch方法,Robert C. Martin,Prentice Hall,1995年)中开发的Components类别的SetOrderedSetIterator类。


因此,我们实现了Shape类对象的排序,并以适当的顺序绘制它们。 但是我们仍然没有顺序抽象的实现。 显然,每个Shape对象都必须重写Precedes方法才能确定顺序。 这怎么工作? 在Circle::Precedes需要编写什么代码,以便将圆圈绘制为正方形? 注意清单5。


 // 3 //  Shape    . class Shape { public: virtual void Draw() const = 0; virtual bool Precedes(const Shape&) const = 0; bool operator<(const Shape& s) {return Precedes(s);} }; 

 // 4 // DrawAllShapes   void DrawAllShapes(Set<Shape*>& list) { //    OrderedSet  . OrderedSet<Shape*> orderedList = list; orderedList.Sort(); for (Iterator<Shape*> i(orderedList); i; i++) (*i)->Draw(); } 

 // 5 //    bool Circle::Precedes(const Shape& s) const { if (dynamic_cast<Square*>(s)) return true; else return false; } 

显然,此功能不符合开放性-封闭性原则。 无法从Shape类的新后代中关闭它。 每次出现Shape类的新后代时,都需要更改此功能。


使用数据驱动的方法来实现封闭


Shape类的继承者的紧密性可以使用表格方法来实现,该方法不会在每个继承的类中引起更改。 清单6给出了这种方法的示例。


使用这种方法,我们成功地关闭了DrawAllShapes函数, DrawAllShapes不涉及与顺序相关的更改以及Shape类的每个后代–根据Shape引入了新的后代或对Shape类的对象的排序策略进行了更改(例如, Squares类的对象应首先绘制)。


 // 6 //     #include <typeinfo.h> #include <string.h> 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 }; //      . //   ,    //  . ,    , //      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; } 

不能更改图形形状顺序的唯一元素是表格。 该表可以放置在与所有其他模块分开的单独模块中,因此对其所做的更改不会影响其他模块。


进一步关闭


这还不是故事的结局。 我们关闭了Shape类和DrawAllShapes函数的层次结构, DrawAllShapes根据形状的类型更改排序策略。 但是, Shape类的后代不会关闭与Shape类型不相关的排序策略。 似乎我们需要根据更高层次的结构来安排形状的绘制。 对此类问题的全面研究超出了本文的范围。 但是,有兴趣的读者可能会想如何使用OrderedShape类中包含的抽象OrderedObject类(从ShapeOrderedObject继承)来解决此问题。


启发式和约定


如本文开头所述,开放性-封闭性原则是在OOP范式发展多年后出现的许多启发式和惯例背后的主要动机。 以下是最重要的。


将所有成员变量设为私有


这是巴解组织最持久的公约之一。 成员变量仅应在定义它们的类的方法中知道。 变量成员不应为任何其他类(包括派生类)所了解。 因此,必须使用private访问修饰符声明它们,而不是publicprotected
根据开放性-封闭性的原则,这种约定的原因是可以理解的。 当类成员变量更改时,依赖于它们的每个函数都必须更改。 即,该函数不会因这些变量的更改而关闭。


在OOP中,我们希望类的方法不会因此类成员变量的变化而封闭。 但是,我们希望所有其他类(包括子类)都不会对这些变量进行更改而关闭。 这称为封装。


但是,如果您有一个确定不会改变的变量,该怎么办? 将其设为private是否有意义? 例如,清单7显示了包含变量成员bool statusDevice类。 它存储上次操作的状态。 如果操作成功,则status变量的值为true ,否则为false


 // 7 //   class Device { public: bool status; }; 

我们知道此变量的类型或含义永远不会改变。 那么,为什么不public它并让客户直接访问它呢? 如果该变量确实从未更改过,如果所有客户都遵循规则并且仅从该变量中读取,则该变量是公共的事实没有任何问题。 但是,请考虑如果其中一个客户端借此机会写入此变量并更改其值,将会发生什么情况。


突然,此客户端可以影响Device类的任何其他客户端的操作。 这意味着无法关闭Device类的客户端,以免对该错误模块进行更改。 这太冒险了。


另一方面,假设我们有Time类,如清单8所示。公开该类成员的变量有什么危险? 它们极不可能改变。 此外,客户端模块是否更改这些变量的值都没有关系,因为假定这些变量已更改。 继承的类也不太可能依赖于特定成员变量的值。 那有问题吗?


 // 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中的代码唯一的抱怨是时间变化不是原子的。 即,客户端可以更改minutes变量的值而无需更改hours变量的值。 这可能导致Time类的对象包含不一致的数据。 我希望引入一个用于设置时间的函数,该函数需要三个参数,这将使时间设置成为原子操作。 但这是一个微不足道的论点。


很容易提出其他条件,在这些条件下这些变量的公开会导致问题。 然而,归根结底,没有令人信服的理由将它们设为private 。 我仍然认为将此类变量公开是一种不好的风格,但是也许这并不是一个不好的设计。 我认为这是一种不好的风格,因为进入用于访问这些成员的适当功能几乎不需要花费什么,并且绝对值得保护自己免受与关闭问题可能发生相关的小风险。


因此,在这种罕见的情况下,当不违反开放-封闭原则时,对public变量和protected变量的禁止更多地取决于样式而不是内容。


根本没有全局变量!


反对全局变量的论点与反对公共成员变量的论点相同。 依赖于全局变量的模块不能从可以对其写入数据的模块中关闭。 任何以其他模块不希望使用的方式使用此变量的模块都会破坏这些模块。 拥有多个模块的风险太大,具体取决于单个恶意模块的变化情况。
另一方面,在全局变量具有依赖于它们的模块数量很少或不能以错误的方式使用的情况下,它们不会造成危害。 设计人员必须评估牺牲了多少隐私,并确定全局变量提供的便利是否值得。


同样,样式问题在这里起作用。 使用全局变量的替代方法通常不昂贵。 在这种情况下,使用引入的技术虽然很小,但会带来关闭风险,而不是完全消除这种风险的技术的使用,这是不良风格的标志。 但是,有时使用全局变量确实很方便。 一个典型的例子是全局变量cout和cin。 在这种情况下,如果不违反开放性-紧密性原则,则可以为了方便而牺牲样式。


RTTI很危险


另一个常见的禁止条件是使用dynamic_cast 。 通常, dynamic_cast或某种其他形式的运行时类型确定(RTTI)被指控是一种极其危险的技术,因此应避免使用。 同时,他们经常提供清单9中的示例,这显然违反了开放性-紧密性原则。 但是,清单10显示了一个类似的程序示例,该程序使用dynamic_cast而不违反开闭原理。


两者的区别在于,在第一种情况下,如清单9所示,每次Shape类的新后代出现时,都需要更改代码(更不用说这是绝对荒谬的解决方案)。 但是,在清单10中,在这种情况下不需要更改。 因此,清单10中的代码不违反开闭原理。
在这种情况下,经验法则是,如果不违反开放性-封闭性原则,则可以使用RTTI。


 // 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<Shape*>& ss) { for (Iterator<Shape*>i(ss); i; i++) { Circle* c = dynamic_cast<Circle*>(*i); Square* s = dynamic_cast<Square*>(*i); if (c) DrawCircle(c); else if (s) DrawSquare(s); } } 

 // 10 //RTTI,    -. class Shape { public: virtual void Draw() cont = 0; }; class Square : public Shape { // . }; void DrawSquaresOnly(Set<Shape*>& ss) { for (Iterator<Shape*>i(ss); i; i++) { Square* s = dynamic_cast<Square*>(*i); if (s) s->Draw(); } } 

结论


关于开放性-封闭性的原则,我可以讨论很长时间。 在许多方面,该原理对于面向对象的编程最重要。 遵守这一特定原则提供了面向对象技术的关键优势,即重用和支持。


, - -. , , , , , .

Source: https://habr.com/ru/post/zh-CN472186/


All Articles