Richie

Sometimes at night when I look up at the stars, and see the whole sky just laid out there, don't you think I ain't remembering it all. I still got dreams like anybody else, and ever so often, I am thinking about how things might of been. And then, all of a sudden, I'm forty, fifty, sixty years old, you know?

翻译 - The Open Closed Principle 开闭原则

   作者: Robert C. Martin
   原文连接: The Open Closed Principle
   本翻译未经授权,仅做为私人笔记记录,转载传播责任自负

   这是我在C++报导工程笔记专栏的第四篇。这个专栏的主题是C++和OOD的使用,以及解决软件工程方面的问题。我尽力写一些实用性的文章,为奋斗中的软 件工程师提供一些帮助。这些文章将使用Booch和Rumbaugh新的统一建模语言(UML版本0.8)描述面向对象设计,侧栏图片显示了主要表的达元素
   面向对象设计有许多指导性规则,例如"所有数字变量必须是私有的"、"应当避免全局变量"、"使用运行时类型标识(RTTI, run time type identification)是危险的"等,这些规则是怎么来的?它们为什么正确?它们总是正确的吗?这个专栏将研究得出这些指导性规则的设计原则-开闭原则
   就像Ivar Jacobson说的:"任何系统在它们生命周期内都会改变,如果你希望部署的系统比上一版本使用得更久,就必须牢记这一点"。如何使我们的设计在面对变更时保持稳定,能够比上一版本使用更久呢?Bertrand Meyer早在1988年时就给出了指导方法,即现在著名的开闭原则,引用如下:
   SOFTWARE ENTITIES(CLASSES,MODULES,FUNCTIONS,ETC.) SHOULD BE OPEN FOR EXTENSION, BUT CLOSED FOR MODIFICATION. 软件实体(类、模块、函数等)应当对扩展开放,而对修改闭合
   译注: 参考数学上的闭合、闭包概念,例如实数减去实数结果仍然是实数,就说实数集合对减法操作是闭合的;模块的行为对修改闭合,可以解释为对模块的修改不应当改变模块(设计时)的行为,要改变模块行为应当通过添加新代码扩展实现
   当单一的变更给相关模块造成一系列修改时,我们把这种不好的现象归为糟糕的设计,程序变得脆弱、笨拙、无法预估、不能复用。开闭原则很直接的解决这一问题,它指出应当设计永远不会改变的模块,当需求变化时通过添加新的代码扩展模块的功能,而不是修改已有代码实现

描述
   符合开闭原则的模块有2个主要特征
   1. "对扩展开放"
   这意味着模块的行为可扩展,这样应用程序需求变化时模块可以采用新的不同的方式应对,或者模块可以满足其它应用的需求
   2. "对修改闭合"
   这种模块的代码是单纯的,不允许修改
   这两个特征看起来不一致,通常扩展模块的行为是修改这个模块,不允许修改的模块通常认为其行为是确定的,这2个对立的特征如何处理呢?

关键是抽象
   C++中可以创建确定的抽象来表示一组不定数量的可能行为,来运用面向对象设计原则。抽象通过抽象基类表示,而不定数量的可能行为则由所有可能的派生类表示。模块可以只使用抽象,仅依赖确定的抽象,模块可以实现"对修改闭合",而模块的行为可以通过创建新的派生类实现扩展
   图1展示了一个不符合开闭原则的简单设计。Client和Server都是具体类,Server类的方法不一定是虚方法,而Client类使用了Server类。如果希望Client对象使用另外一个不同的Server对象,那么必须修改Client类以指定新的Server类名
  
   图2展示了另外一种符合开闭原则的设计。这次AbstractServer是一个抽象类只包含纯虚成员方法,Client类只使用抽象,而Client类的对象可以使用派生的Server对象。如果希望Client对象使用不同的Server类,只需要从AbstractServer类创建一个新的派生类,Client类可以不用修改
  

形状的抽象
   看下面的示例。假如有个应用程序必须在标准GUI上绘制圆和正方形,圆和正方形必须按照特定的顺序进行绘制,一个列表按顺序存放着圆和正方形,程序必须遍历列表,按照列表中的顺序绘制圆和正方形
   在C中使用不符合开闭原则的过程化方法,我们可能使用列表1所示方法解决这个问题。这里我们看到有2个数据结构第一个元素一样,而其它元素不相同,第一个元素是一个类型代码,用来确定是圆形还是正方形数据结构。函数DrawAllShapes遍历一个包含这些数据结构指针的数组,检查类型代码然后调用相关函数(DrawCircle或者DrawSquare)
   列表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;
};
//
// 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; 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函数中的switch语句将一遍又一遍的在应用程序的不同函数中重复出现,每处都是需要处理一些不同的东西。在这样的应用程序中添加一种新的形状,需要搜索每一处switch语句(或者if else),添加新形状的处理。此外并非所有switch或if else语句都会像DrawAllShapes中那样清晰,很可能if判断中夹杂了其它逻辑操作,或者case子句中混入了其它逻辑以"简化"决策处理。因此查找并理解新形状需要添加的地方可能并不那么容易
   列表2展示了一种符合开闭原则解决正方形、圆形问题的解决方案代码。这次创建了一个抽象的Shape类,这个抽象类有个叫做Draw的纯虚函数,Circle和Square都是派生自Shape
   列表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<Shape*>& list)
{
    
for (Iterator<Shape*>i(list); i; i++)
        (
*i)->Draw();
}
   注意如果我们需要扩展DrawAllShapes函数的行为来绘制一个新的形状,只需要添加一个新的Shape派生类,函数DrawAllShapes不需要修改,因此DrawAllShapes符合开闭原则,可以扩展它的行为而不用修改
   实际中Shape类将包含更多的方法,然而为应用添加一个新的形状仍然很简单,只需创建一个新的派生类并实现这些方法,避免了搜索整个应用程序查找需要修改的地方
   符合开闭原则的程序通过添加新代码实现扩展,而不用修改已有代码,因此他们不会像不符合这一原则的其它程序那样出现单一变更带来一系列修改

策略性闭合
   应当明确没多少程序能够100%闭合,例如列表2中如果我们要在绘制正方形之前绘制所有的圆,DrawAllShapes函数需要怎样应对?DrawAllShapes函数对这样的修改不是闭合的。通常不管一个模块闭合程度怎样,总存在某些变更它是无法闭合的
   闭合不可能是完全的,因此必定存在策略性,即设计者必须确定针对哪些变化进行闭合,这相当程度上来自于经验。有经验的设计者非常了解用户和行业,可以判断出不同变化的可能性,因此可以确保将开闭原则运用在那些最可能的变化上

使用抽象获得明确的闭合
   怎样使DrawAllShapes函数面向有序绘制这样的变化闭合起来呢?别忘了闭合是基于抽象的,因此为了使DrawAllShapes面向有序绘制闭合,我们需要某种"排序抽象"。上面这种特定的排序规则是必须在绘制某种类型的形状之前先绘制其它类型的形状
   一种排序策略意味着给定任意2个对象,它可以确定其中哪一个应当先绘制。因此我们可以在Shape中定义一个名为Precedes的方法,它接受另外一个Shape参数,返回bool类型的结果,如果接收消息的Shape对象应当排在入参Shape对象之前则返回true
   C++中这个函数可以通过重载操作符<来表示,列表3展示了添加排序方法后Shape类的大致样子
   现在我们有了一个方案确定两个对象的相关排序,可以对它们排序然后按顺序绘制,列表4展示了C++代码的实现。代码中使用了Set, OrderedSet和Iterator类,它们来自我的书中组件这一章节(如果你需要组件章节代码的免费副本,请发送邮件到rmartin at oma.com)
   虽然我们有了排序形状对象的方法,能够以正确的顺序绘制它们,但仍不是优雅的排序抽象。情况是每个形状对象必须重写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)
{
    
// copy elements into OrderedSet and then sort.
    OrderedSet<Shape*> orderedList = list;
    orderedList.Sort();
    
for (Iterator<Shape*> i(orderedList); i; i++)
        (
*i)->Draw();
}
   列表5: Circle的排序
bool Circle::Precedes(const Shape& s) const
{
    
if (dynamic_cast<Square*>(s))
        
return true;
    
else
        
return false;
}
   应当清楚Precedes函数不符合开闭原则,面对新的Shape派生类无法闭合,每次创建Shape的派生类,就需要修改这个函数
   译注: DrawAllShapes函数符合开闭原则了,但新的Precedes违背了开闭原则,这就是设计决策"使用抽象获得明确的闭合"

使用"数据驱动"方式实现闭合
   Shape派生类的闭合(译注: 上面的Precedes方法)可以使用表驱动(table driven)方式实现,使得排序变化时不必修改每个派生类,列表6展示了这种可行性
   使用这种方式,在排序问题上我们成功的使DrawAllShapes函数实现闭合,并且在创建新的Shape派生类或者修改基于Shape对象类型的排序策略时(例如将排序规则修改为先绘制正方形),每个Shape派生类也实现了闭合
   列表6: 表驱动的类型排序机制
#include <typeinfo.h>
#include 
<string.h>
enum {falsetrue};
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;
}
   修改不同形状的绘制顺序时唯一不闭合的是顺序表本身,顺序表可以放在自己模块中,与其它模块分离,这样修改这个表不会影响其它模块

进一步扩展闭合
   问题还没有结束。以形状类型作为排序规则我们实现了Shape类体系和DrawAllShapes函数的闭合,但对那些与形状类型无关的排序规则Shape的派生类不闭合。就是说我们可能要根据更高层次的一些结构确定形状的绘制顺序,对这方面问题的完整研究超出了这篇文章的范围,热心的读者可以尝试这样来解决,从Shape和抽象类OrderedObject派生一个OrderedShape类,通过使用OrderedObject实现排序

指导性规则和惯例
   正如文章一开始就提到的,开闭原则是多年来OOD方面很多指导性规则和惯例的根本动因,下面是一些更重要的规则

将所有成员变量设为私有类型
   这是OOD中最常见的惯例,类的成员变量只应当由类上面定义的方法所了解,永远不要暴露给其它类包括派生类,因此它们应当定义为私有的而不是公有或者保护类型
   根据开闭原则这个惯例的原因应当是显而易见的。当类的成员变量改变时,所有依赖这些成员变量的函数都需要修改,因此这些函数都不是闭合的
   面向对象设计中,我们并不指望类的方法相对类成员变量闭合,但我们希望其它类包括子类相对类成员变量是闭合的,这有一个专门的名字叫做封装
   如果我知道某个成员变量永远不会改变呢,有必要设为私有的吗?例如列表7显示了一个Device类,它有一个bool status的变量,用于放置最后一次操作的状态,如果操作成功status值为true,否则为false
   列表7: non-const public variable
class Device
{
    
public:
        
bool status;
};
   我们知道这个变量的类型和意义永远都不会改变,那为什么不干脆设置为公有类型,让客户端直接检查它的内容呢?如果这个变量真的永远不会变,并且其它客户端也遵守规则只是查询它的内容,那么将这个变量设为公有类型事实上是没有害处的。但如果某个客户端投机取巧的利用status变量的可写性修改了它的值,Device类的其它客户端就会受到影响,这意味着Device类的其它客户端对这个模块的错误行为不可能闭合,这可能是难以承受的巨大风险
   另一方面假如我们有一个列表8所示的Time类,这个类中的公有变量有什么危害呢?当然它们可能不会变化,即使某个客户端修改了变量值也没什么关系,因为变量值本来就允许客户端修改,并且派生类也不大可能需要跟踪变量值的变化情况,那这样的公有变量有什么危害吗?
   列表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对象值的不一致。我倾向于使用一个具有3个参数的函数设置时间值,使操作具备原子性。不过这一观点不具备多少说服性
   不难考虑其它情况下公有变量导致的危害,系统经历长期运行之后就没有理由将这些变量重写为私有形式了。我仍然认为设为公有形式是一种糟糕的风格,但不一定是一种糟糕的设计。之所以这样认为是因为很容易创建inline成员函数来避免,这样的成本相对于轻微的闭合问题的风险也是非常值得的
   因此对于那些少数不违背开闭原则的情况下,避免使用公有和保护形式的变量更多依赖于风格

永远不要使用全局变量
   在全局变量方面的争论与公有成员变量方面的争论类似,当一个模块依赖全局变量,而其它模块可能改写这些变量时,这个模块不可能是闭合的。如果不按照其它模块的约定使用全局变量,就会破坏那些模块。让大量模块相互间严重依赖、影响是非常危险的
   另一方面那些对全局变量依赖很少或者能确保使用一致性,全局变量的危害就很小。设计者必须清楚全局变量的闭合性,与全局变量带来的便利性进行权衡
   另外还有编程风格方面的问题。通常去掉全局变量而采用替代方案是很容易的,这些情况采用那些损失闭合性的方案哪怕是一点点都是非常糟糕的编程风格。而有的情况下全局带来的便利性非常明显,全局变量cout和cin就是常见的例子,这些情况下如果没有违背开闭原则,违背编程风格带来的便利性也是只得的

RTTI是危险的
   另一个常被限制使用的是dynamic_cast,通常认为dynamic_cast或者任何形式的运行时类型标识(RTTI)都非常危险,应当避免使用。常被提到的例子类似列表9,很明显它违背了开闭原则,而列表10的程序使用了dynamic_cast但没有违背开闭原则
   列表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
{
    
// as expected.
};
void DrawSquaresOnly(Set<Shape*>& ss)
{
    
for (Iterator<Shape*>i(ss); i; i++)
    {
        Square
* s = dynamic_cast<Square*>(*i);
        
if (s)
            s
->Draw();
    }
}
   它们之间的区别是任何时候派生一个新的Shape类就必须修改列表9(当然这种方式很傻),而列表10不用改动,因此列表10没有违背开闭原则
   通常经验表明,对RTTI的使用如果没有违背开闭原则,它就是安全的

结论
   开闭原则还有很多其它值得讨论的地方,这个原则是面向对象设计的核心,符合这一原则可以带来面向对象最大的优点,例如复用性和维护性。然而并不是简单的使用面向对象编程语言就可以符合这一原则,它需要设计者的努力,在程序中设计者认为最可能改变的部分上运用抽象手段
   这篇文章是我的新书《面向对象设计原则与模式》(The Principles and Patterns of OOD)中一个章节的压缩版,它即将由Prentice Hall出版。随后的文章中我们将探讨面向对象设计的其它原则;基于C++实现研究几种设计模式以及它们的优点和缺点;研究一下Booch对C++中class分类的作用以及与C++命名空间的关系;定义面向对象设计中"内聚"和"耦合"的意义,制定衡量面向对象设计质量的度量标准。另外也包括很多其它有意思的方面

附录

posted on 2009-02-10 17:11  riccc  阅读(1773)  评论(0编辑  收藏  举报

导航