从实例中学习设计模式

前言

设计模式一度被捧为程序员的圣经。但有不少人对设计模式只知其形不知其实,认为设计模式是一种很神秘很强大的咒语,似乎只要对着一堆乱糟糟的代码说一声“设计模式,急急如律令!”那代码就会突然变得很好很强大,一切毛病都消失无踪。

其实设计模式并不是这样的。设计模式是一些编程惯用法的总结和提炼,这些惯用法在各种优秀代码中被普遍的使用,无论你学过还是没学过设计模式,很多思想和做法你都在有意无意的被使用着。

学习设计模式,并不是背熟那23个名字,学会那几段范例代码。学习设计模式的关键,是学会如何在实际工作中使用那些良好的做法去解决实际的问题。知不知道每个模式的名字并不是最关键的,关键的是适当的使用这些做法可以对解决问题带来帮助。

下面我就用我一个在实际工作中开发并使用的日志框架作为一个例子,给大家展示一下如何适当的使用设计模式去解决实际问题。

这个框架的实现并称不上优秀,也没有用到所有的模式。只是希望能用这样的一个实际例子给大家一些启示。

缘起

我们公司有很多用C++开发的系统。大部分的系统,都需要在运行时输出一些特定格式的信息作为运行日志。另外,很多系统都具备一个“调试模式”,打开调试 模式时会输出一些有利于开发人员确定问题的详细调试信息。每个程序都单独的写priintf、fputs或者xxx.writeLine并不是一个十分好 的主意。因为这些信息有很多处理是可以共用的,所以我们需要一个统一的Log框架。对于这个框架的使用者来说,只要调用类似于Log("output message")的一个接口就够了。

而我们的任务,就是实现这样一个框架,有足够的扩展性可以完成刚才我们所说的需求。

第一版 ——Singleton模式

这个任务似乎并不困难,我顺手就写了一个:

class CLogger
{
public:
CLogger(bool isDebug)
: _debug(isDebug)
{}

void Log(const char *msg)
{
printf("%s/n", msg);
}

void Debug(const char *msg)
{
if (_debug)
{
Log(msg);
}
}

void Log(const std::string &msg)
{
Log(msg.c_str());
}

void Debug(const std::string &msg)
{
Debug(msg.c_str());
}
private:
bool _debug;
};

类的实现没有太大的问题。但是,在使用的时候,问题来了。

我们该如何使用这个类?

鉴于每个单独的系统中只应该存在一个log实例。我们应该要求我们的使用者保证只生成一个这样的实例。当然使用者并不是做不到,比如可以定义一个静 态全局变量,在头文件里做声明,在需要用的地方包含这个头文件。但是这些琐碎的工作让使用者去做似乎有些说不过去。一个好的库应该做到让用户尽量使用方便 简单。这种琐碎的事情,我们完全可以帮使用者搞定。

其实这里问题的关键就在于我们需要一个全局实例。而且我们需要对这个全局实例有完全的控制权。设计模式里正好有一个Singleton模式,是用来解决这个问题的。好,我们写出一个Singleton的实现:

class CLogger
{
public:
CLogger(bool isDebug)
: _debug(isDebug)
{}

//这些实现跟我们关注的无关,暂时就省略了
void Log();
void Debug();

static CLogger *getInstance()
{
static CLogger *_instance = NULL;
if (_instance == NULL)
_instance = new CLogger(false);
return _instance;
}
private:
bool _debug;
};

这样一来,别人只要使用类似这样的语句:CLogger::getInstance()->Log("msg"); 就可以方便的使用了。但是我们注意到,那个debug没有办法被设置。这肯定是不行的。还好,我们的配置项会最终形成一个CGlobalConfig类的 实例。大概的样子是这样:

class CGlobalConfig
{
public:
void LoadFromFile(const char *filename);
void LoadFromCommandLine(const char *opt_str);
std::string &getValue(const std::string &key);
static CGlobalConfig *getInstance(const char *filename="config.ini", const char *opt_str="")
{
static CGlobalConfig *_instance = NULL;
if (_instance == NULL)
{
_instance = new CGlobalConfig();
_instance->LoadFromFile(filename);
_instance->LoadFromCommandLine(opt_str); //如果命令行有设置,则用命令行设置覆盖ini设置
}
return _instance;
}
};

具体实现就不写了,总之我们有一个这样的类,而且它也用到了Singleton模式创建唯一实例。我们可以改写CLogger的getInstance实现,让它使用上这个CGlobalConfig:

class CLogger
{
public:
//其他省略

static CLogger *getInstance()
{
static CLogger *_instance = NULL;
if (_instance == NULL)
{
bool debug = false;
if (CGlobalConfig::getInstance()->getValue("debugMode") == "true")
debug = true;
else
debug = false;
_instance = new CLogger(debug);
}
return _instance;
}
};

这样一来,基本上CLogger就可以正常使用了。但是一些高手肯定要叫起来了:你的Singleton实现有很大的问题呀!

是的。这个实现问题还是不少的。但是,在实际工作中,如果够用了,我们没必要无谓的去增加复杂度。我的工作实现,就到此为止。但是,为了谨慎起见,我在这里还是给出几个常见问题的解决方法。

先提示一下问题的所在。

  • 第一个问题是,有些初学者程序员可能会不知道getInstance()函数的作用而自己去new出CLogger的实例,造成我们的Singleton失效。
  • 第二个问题是,引用而得的CLogger Singleton指针是可以被删除的:CLogger *p=CLogger::getInstance(); delete p; 这样删除之后,Singleton实例将无效且不等于NULL,以后我们再次调用的时候将会出错。
  • 第三个问题是,Singleton实例无法被自动析构,一般而言,Singleton的生命周期是整个程序的生命周期,当程序完全退出的时候,所 申请的内存将被自动回收,不做析构问题也不大。但有的时候Singleton里会打开一些系统无法自动回收的资源,这时候析构就显得很重要了。那么如何实 现对Singleton的析构呢?

第一和第二个问题其实说白了很简单:只要把构造和析构函数放到private段就OK了。

class CLogger
{
public:
void Log();
void Debug();

static CLogger *getInstance();
private:
CLogger(bool isDebug) //禁止创建
: _debug(isDebug)
{}

CLogger(const CLogger&); //禁止复制
~CLogger(); //禁止删除
};

第三个问题,我们可以利用STL中的auto_ptr来实现自动析构。改写getInstance()如下:

class CLogger
{
public:
//其它省略

static CLogger *getInstance()
{
static std::auto_ptr<CLogger> _instance;
if (_instance.get() == NULL)
{
bool debug = false;
if (CGlobalConfig::getInstance()->getValue("debugMode") == "true")
debug = true;
else
debug = false;
_instance.reset(new CLogger(debug));
}
return _instance.get();
}
private:
friend class std::auto_ptr<CLogger>;

CLogger(bool isDebug) //禁止创建
: _debug(isDebug)
{}

CLogger(const CLogger&); //禁止复制
~CLogger() //禁止删除
{}
};

为什么要加上那个friend声明?别忘记构造/析构函数已经被private了,如果没有friend的话,它也没办法调用到析构函数进行自动析构。另外,也是因为析构函数需要被调用的缘故,我们为析构做了一个空的实现,而不是像上面那段代码一样只是做一个声明。

当然这个实现的问题远不止这些。简单来说,这个实现是线程不安全的,但是我们的程序没有用到多线程,我没有仔细的研究这个问题。有兴趣的同学可以自行研究一下。

改进──Decorator模式和Abstract Factory模式

上面那个简单的原型其实并没有什么大用──没有一个真正的LOG系统会这样简陋的记录信息。我们必须为它加上点附加信息。最简单的要求是这样的:我 们要在信息的前面附加输出日期、时间戳之类的头信息,然后输出。当然,一个好的LOG系统是可以让人配置头信息的。我们同样使用上一章里提到的 GlobalConfig类。为了避免不必要的复杂性,我们假设所有的配置只用一个英文字母。当然可以扩展成更复杂的Parser,但那需要涉及一点字符 串分析的工作,这不是我们的重点,留待各位在工作中自行解决了。

我们先定义一下这个配置项LogFormat。假设我们有三种可选配置:D表示日期,T表示时间,X表示系统ticks。那么,我们的Log方法可能就会变成这样:

class CLogger
{
public:
void Log(const std::string &msg)
{
std::string logFormat = CGlobalConfig::getInstance()->getValue("LogFormat");
std::string prefix = "";
for (int i = 0; i < logFormat.size(); ++i)
{
switch(logFormat[i])
{
case 'D':
prefix += getDate(); //这些个辅助函数我就不实现了,关注重点,呵呵
break;
case 'T':
prefix += getTime();
break;
case 'X':
prefix += getCPUTicks();
break;
}
}
printf("%s/n", (prefix+msg).c_str());
}
};

嗯,加前缀的问题解决了。是不是很简单呢?

酒……酒豆麻袋……,设计模式呢?模式在哪里?

是的,这个实现什么模式也没用。可是,你不觉得这个实现很好的解决了我们的问题吗?我们写程序是为了满足需求解决问题,而不是为了炫耀设计模式。用简单的方式能解决的事情,为什么一定要去套用模式呢?

那这一章不会就酱紫结束了吧?

当然不会,因为很快老板就要来找我了。

—— 我们有个项目组需要增加客户端IP的内容放入前缀,你给加一下吧。哦对了,另一个项目组还想增加Windows客户端滴机器名……

—— 老板啊,让他们把信息写进msg不就可以了……

—— 什么?!你还让人家每次写调试都去增加一段获取客户端IP的代码?!那样的话我要你的Log库做什么用?所有的事情我一次做完不就得了!你还想不想在公司混啦!%%#&@*)……

谈判失败,那么加入扩展前缀是不可避免了。但是想让我去为开发组做什么"获取客户端IP"的活?我才不干呢。我是做框架的,做框架的!于是我决定为 我的CLogger加入用户自定义前缀的功能。但我显然不可能让别的项目组来动我的代码,在那段switch里增加case分支。看来这段代码非改不可 了。

如何自由组合可能扩展的前缀格式化?

我的做法是,定义一个IFormatter接口:

class IFormatter
{
public:
IFormatter(IFormatter *next)
:_nextFormatter(next)
{}

virtual ~IFormatter()
{
if (_nextFormatter != NULL)
delete _nextFormatter;
}

std::string output(const std::string &str)
{
if (_nextFormatter == NULL)
return _output(str);
else
return _output(_nextFormatter->output(str));
}
protected:
virtual std::string _output(const std::string &str) = 0;
private:
IFormatter *_nextFormatter;
};

这个接口实际上已经完成了框架性的工作。如果需要自定义格式前缀,只需要实现这个接口的 _output 纯虚方法就可以了。

class DateFormatter : public IFormatter
{
public:
DateFormatter(IFormatter *next)
: IFormatter(next)
{}
protected:
virtual std::string _output(const std::string &str)
{
return getDate() + str;
}
};

TimeFormatter、TicksFormatter甚至用户自己定义的ClientIPFormatter都与此类似,我就不浪费篇幅了。 假设我们已经得到了一系列的Formatter,我们就可以自由的组合这些Formatter,最终得到一个“组合实例”,调用它的output函数,就 可以得到结果了,例如:

IFormatter *p = new DateFormatter(new TimeFormatter(NULL));
printf("%s/n", p->output().c_str());

就会输出前面加上了Date和Time格式的文字。当然也可以自由组合其他的类,比如

IFormatter *p = new DateFormatter(new TimeFormatter(new ClientIPFormatter(NULL)));

这种可以自由组合,一层套一层叠加的实现手法,有一个专门的模式,叫做Decorator模式。以上就是一个decorator模式的实现。

可是,有人又要说了:这个Decorator模式除了套用了模式之外,完全没有解决我们刚才的问题啊!new DateFormatter(new TimeFormatter(NULL)) 这样的硬编码,还是没有解决灵活扩展的问题,而且看起来还不如switch case看得更清楚。

别急,Decorator模式并没有这么简单,我们接着往下看。

一步步来。首先,有了这个模式,我们就可以把Log函数改写成类似这样:

class CLogger
{
public:
void Log(const std::string &msg)
{
std::string logFormat = CGlobalConfig::getInstance()->getValue("LogFormat");
IFormatter *p = NULL;
for (int i = 0; i < logFormat.size(); ++i)
{
//FormatterGenerator根据字符产生对应的Formatter
p = FormatterGenerator::getInstance()->createFormatter(logFormat[i], p);
}
//最终会产生一个正确的decorator实例
printf("%s/n", p->output(msg).c_str());

delete p;
}
};

你看,这个想法很合理吧,这样我们就消除了switch...case的硬性选择编码,为灵活性打下了一个坚实的基础。现在的问题就变成了, FormatterGenerator如何实现?从代码中我们已经看到了,FormatterGenerator也使用了Singleton模式,这个模 式上一章我们已经讨论得够仔细了,相信每个人都能实现出来,我们把注意力集中在它的其他方面。

要做到FormatterGenerator应该做的事,我们需要两个接口,一个接口就是上面的代码所展示的,createFormatter,根 据字符生成对应的formatter,那么在此之前,应该有个register方法,将字符和生成formatter所需的类对应起来。

要是我们能传入一个类作为参数,就可以很漂亮的实现了呀:

//注意:这段代码是不能实际工作的
class FormatterGenerator
{
public:
//注册类
void register(indexStr, className)
{ _internalMap[indexStr] = className; }

//从存储的类中找出对应的,new出它的实例
IFormatter *createFormatter(indexStr, IFormatter *next)
{ return new (_internalMap[indexStr])(next); }
};

如果上述代码真的可行的话,我们就可以这样做:

//事先在某处注册我们的Formatter类
FormatterGenerator::getInstance()->register('D', DateFormatter);
FormatterGenerator::getInstance()->register('T', TimeFormatter);
FormatterGenerator::getInstance()->register('X', TicksFormatter);

//如果用户有需要,它也可以在某处注册自己的Formatter类
FormatterGenerator::genInstance()->register('I', ClientIPFormatter);

然后在需要的地方调用Log,Log就会自动处理前缀的问题。你看,这样一来,岂不是万事大吉。

可惜的是,在C++里我们并不能这样直接传入一个类名。Python倒是可以,但不能因为这样就把所有系统推倒重新用Python实现吧?

还好,我们在C++里还有一种间接的方式可以实现。

首先我们确认一下,上面的思路是完全可行的,只不过是C++语言限制不能直接传入类名。但是如果我们传入一个实例怎么样?这个实例的目的,就是专门产生Formatter的实例

没错,Abstract Factory模式正是为此而生。

我们在IFormatter之外,再实现一个IFormatterFactory。

class IFormatterFactory
{
public:
virtual IFormatter *create(IFormatter *next) = 0;
};

就这么简单?对,就这么简单。

然后我们每实现一个Formatter,就要顺便为它再实现一个FormatterFactory:

class DateFormatterFactory : public IFormatterFactory
{
public:
virtual IFormatter *create(IFormatter *next)
{ return new DateFormatter(next); }
};

就这么简单?对,就这么简单。

然后我们就可以实现我们真正可以工作的FormatterGenerator版本了:

class FormatterGenerator
{
public:
//注册类
void register(char indexStr, IFormatterFactory *factory)
{ _internalMap[indexStr] = factory; }

//从存储的类中找出对应的,new出它的实例
IFormatter *createFormatter(char indexStr, IFormatter *next)
{ return _internalMap[indexStr]->create(next); }
private:
std::map<char, IFormatterFactory *> _internalMap;
};

新的register调用范例:

//事先在某处注册我们的Formatter类
FormatterGenerator::getInstance()->register('D', new DateFormatterFactory());
FormatterGenerator::getInstance()->register('T', new TimeFormatterFactory());
FormatterGenerator::getInstance()->register('X', new TicksFormatterFactory());

//如果用户有需要,它也可以在某处注册自己的Formatter类
FormatterGenerator::genInstance()->register('I', new ClientIPFormatterFactory());

而我们那个Log的版本,是完全正确的,不需要做任何改动就能工作。这说明我一开始的思路的确是对的没有问题,我真是个天才,哇哈哈哈!

呼,看来面对越来越复杂的需求,懂得一点设计模式,对于问题的解决还是很有帮助的啊。

我们的实现并不完美,作为工作版本来说,还缺少了必要的异常处理和正确性检查,另外,FormatterGenerator的实现中仍然没有考虑到IFormatterFactory实例的析构工作。这些细节问题就留待各位读者在实际工作中解决了。

再次改进——Observer模式

上一个版本已经是“可以工作”的版本了。也在一些项目中实际使用了一段时间。不过使用者总是不满足的,不久之后就有了新的需求:LOG的输出不一定要在屏幕上,还可能要写到日志文件里。

考虑到我们的系统应用场合还是比较复杂的,有很多网络通讯操作,有GUI界面,还有服务程序。我非常合理的推测,既然有了写日志文件的需求,还有可 能会有“通过网络发送日志”的需求,或者“在GUI的专用调试窗口显示日志”的需求。有了上一章的教训,这次我没等老板来找我,从一开始我就考虑了通用 性。这种一个数据,多种处理方式,但各个处理之间相互独立的需求,正好适合使用Observer模式。所以,我构造了这一个接口:

class IOutput
{
public:
virtual void output(const std::string &msg) = 0;
};

类似于上一章最后我们的FormatterGenerator实现,我们也为CLogger增加一个register接口,并改写Log方法(其实我们的 CLogger还有好几个接口,比如Debug方法,不过我们把注意力集中在我们的目的——设计模式学习上,因此,我们重点讨论了Log方法,其他的方法 暂时被忽略了。这并不是我们CLogger的设计缺陷,呵呵。):

class CLogger
{
public:
CLogger()
{}

~CLogger()
{
for(TOutputList::iterator iter = _outputList.begin(); iter != _outpitList.end(); ++iter)
{
delete (*iter);
}
}

void register(IOutput *output)
{ _outputList.push_back(output); }

void Log(const std::string &msg)
{
//我们把上一章的产生最终输出结果字符串的动作(使用了decorator模式的那段代码)封装到一个函数中,这里就不重复了。
std::string outputString = getOutputString(msg);

//这里原来是一个printf调用,现在我们改写成使用IOutput
for (TOutputList::iterator iter = _outputList.begin(); iter != _outputList.end(); ++iter)
{
(*iter)->output(outputString);
}
}
private:
typedef std::list<IOutput *> TOutputList;
TOutputList _outputList;
};

然后,根据需求,实现两个Output——CConsoleOutput和CFileOutput:

class CConsoleOutput : public IOutput
{
public:
virtual output(const std::string &msg)
{ printf("%s/n", msg.c_str()); }
};

class CFileOutput : public IOutput
{
public:
CFileOutput(const std::string &filename)
{ _fp = fopen(filename.c_str(), "w"); }

~CFileOutput()
{ fclose(_fp); }

virtual output(const std::string &msg)
{ fputs(msg.c_str(), _fp); }
private:
FILE *_fp;
};

当然,未来如果有别的需求,完全可以实现出网络发送的Output,GUI显示的Output或者其他各种奇奇怪怪的Output。

Output类实现之后,我们只要注册好Output实例,CLogger就会调用所有的Output的output方法,一个都不会落下。而且这 个系统还是可扩展的——用户可以实现自己的Output并注册进CLogger。如果想使用配置文件配置也不难,可以使用类似第二章的那种 Abstract Factory实现。

上面这段代码很简单也很自然,可能很多人都能想到或者已经在程序中不止一次的使用了。其实这就是一个Observer模式的实现。模式就是这么简单的东西,一点也不神秘。

当然,我必须指出,这个Observer实现是经过简化了的。如果熟悉设计模式的人应该会看出来,Observer模式有四个要件: Publisher,ConcretePublisher,Subscriber,ConcreteSubscriber。而这里只有后三个。这是因为 CLogger框架太过简单,我们用不着为Publisher再抽象出一个接口。模式的运用也要应时而变,不能死套公式。

但是,Observer有四个要件并不是仅仅为了制造麻烦的。有Publisher接口,我们可以在一些要求更加复杂的需求中实现更好的灵活性。下面我就来简单的介绍一下为什么需要这个Publisher接口。

我们假设已经实现了几个很有用的IOutput,比如控制台输出、文件输出,还有一个很精妙的GUI信息输出窗口。现在我们有另外一个框架,比如叫 做Tracer吧(我没有想过这个东东到底干吗用的,只是举个例子),它也要做类似的输出工作,我们能不能复用现有的IOutput给它呢?

其实是可以的。最简单的做法是在CTracer的output方法中直接用上IOutput:

class CTracer
{
public:
void output(const std::string &traceMsg)
{
for (TOutputList::iterator iter = _outputList.begin(); iter != _outputList.end(); ++iter)
{
(*iter)->output(traceMsg);
}
}
//其余代码暂略
};

不过CTracer跟CLogger不一样,它会有另外一个接口,可以得到trace的状态,而在比如GUI中,需要用不同的颜色去表现这种状态。为了做到这个,并跟CLogger保持兼容,就必须做一点改动了。

首先,我们要为CLogger和CTracer抽象出一个共同的接口,这个接口有一个抽象方法:得到状态:

class IPublisher
{
public:
TPublisherStatus getState() const = 0;
};

然后改写IOutput的接口,让它接受一个IPublisher:

class IOutput
{
public:
virtual void output(const std::string &msg, const IPublisher *publisher) = 0;
};

然后CLogger和CTracer都去实现这个接口,CLogger因为没有状态,直接返回一个常量即可:

class CLogger : publish IPublisher
{
public:
TPublisherStatus getState() const
{ return TPublisherStatus::INFO; }

void Log()
{
std::string outputString = getOutputString(msg);

for (TOutputList::iterator iter = _outputList.begin(); iter != _outputList.end(); ++iter)
{
//注意这个调用传入了自身作为publisher
(*iter)->output(outputString, this);
}
}
};
class CTracer : publish IPublisher
{
public:
//给Tracer一个设置status的接口
void setState(const TPublisherStatus &status)
{
_status = status;
}

//这其实是IPublisher接口的实现
TPublisherStatus getState() const
{
return _status;
}

void output(const std::string &traceMsg)
{
for (TOutputList::iterator iter = _outputList.begin(); iter != _outputList.end(); ++iter)
{
//注意这个调用传入了自身作为publisher
(*iter)->output(traceMsg, this);
}
}
};

这样一来,IOutput的实现中就可以利用IPublisher的getState接口获得自己所需的状态做相应的处理了。

我们可以看到,有了IPublisher这个接口之后,被调用者IOutput和调用者CLogger或CTracer被进一步解耦合,两者的关系 更加松散,CLogger不依赖具体的IOutput实现,而且IOutput也不是CLogger的专属天使。这样就进一步的提高的复用性。

顺便说一句,设计模式一书里提到Observer模式有两种实现模型,Push模式和Get模式。我们使用的显而易见是Push模式。而我们后面这种范例,getState正是一种Get模式的雏形。我们完全可以将它整个变成Get模式的。这个就留待读者自己去思考吧。

大家可以看到,到了这里,我们的CLogger已经初具规模了。它很小,只有寥寥几百行代码,也只用到了3、4个模式,却有着相当好的可扩展性。用 户可以随意的添加自己的格式化前缀,也可以自由的定义Log的输出方式。为什么《设计模式》的小标题是“可复用面向对象软件的基础”,从这个简单的小例子 中我们也可以初见端倪。

但是不要以为模式这么好,就应该在项目中处处模式。耦合并不是解得越开越好的。过度解耦会使得程序结构复杂,增加出错的可能性和调试测试的难度。下一篇也是这个系列的最后一篇:总结陈词中,我会讲一些关于模式和设计的题外话。

终结篇——总结陈词

前面用了一个可以说简单的不能再简单的Log框架原型(确实是原型,为什么这么说呢,下面会解释),为大家演示了一下如何在实际的工作环境中应用设计模式。

看完实际例子之后,我想说一些跟代码无关的话。这些话也许本该在开头说,但我想在经历过实际代码锻炼之后再说会更好一些。

为了行文方便,下面我将“模式”和“设计模式”混用,根据上下文,绝大多数的“模式”等同于“设计模式”,下面就不再一一说明了。

首先我们要搞清楚:什么是设计模式?我谈谈我自己的看法。设计模式有两个含义,一是设计,二是模式。设计,就意味着它是应用在设计阶段的,是具体语 言无关的,所有的语言编码,只是对模式的实现,而非模式本身。而模式,指的是一种“惯用手法”,是对以往种种优秀设计思想的总结和提炼。因此,总是现有具 体思想,才会诞生模式的,而不是模式导致这种思想的诞生。而那些设计思想,最初总是被用来解决具体的实际问题的。因为这种问题经常会重现,而且解决手法都 类似,于是才可以抽象出一个模式来。所以我们学习模式,一定要注意每个模式的适用场景。

设计模式到底有没有用?我从接触设计模式到现在的几年中,感受到了对这个问题看法的变化。刚开始的时候发现模式的价值,恨不得将设计模式奉为圣经, 言必称模式。后来,在一堆因为滥用模式而变得复杂无比的系统中折腾得够呛的人们,又开始反思,觉得模式除了将代码变得复杂之外,并没有为系统带来任何额外 的好处,因此,有些人开始反对模式,认为模式一无是处。

我的个人看法呢?设计模式有两方面的作用。第一是对初学者而言的,初学者可以在模式学习中学到优秀的设计思想。这里要注意一点的是,学习到的应该是思想,而不是代码。我刚刚已经提到,设计模式是设计阶段的 模式,而不是编码阶段的,在实际编码过程中,遇到的实际问题会比书上,甚至我前面演示的代码复杂的多,要处理各种例外情况,要防止漏洞,要精心选择数据类 型以便在安全性和扩展性上取得平衡……。很多初学者看《设计模式》,以为设计模式的实现就是书上的代码,结果实际中根本用不上,有些人就以为设计模式只是 好看,实际当中根本用不上。实际不是这样的,设计模式是展现给你一种思路,具体的实现,是根据实际情况自己调整的,语言的不同,系统的不同,都会影响到模 式的实现。设计模式的第二个作用是对精通设计模式的人而言的,对他们来说,设计模式的存在,可以大大的简化他们之间的交流。以前也许描述一个系统,需要这 样说:“这里我们可以抽象出一个接口类,这个接口含有一个xxxx方法,然后我们可以实现几个具体的类,然后在这里放一个列表,保存接口类实例,然后在这 里用一个循环以此调用这个xxxx方法。”而现在就可以说:“我们可以在这里用一个Observer模式。”如果对方了解模式的话,就可以听明白你的意思 了。

那么具体的,我们应该如何学习设计模式呢?首先,学习模式第一点要注意的,就是一定要弄清楚模式的适用场景。翻一遍设计模式的书,我们会发现,绝大部分的模式,其核心就是两个字:接口。 那么为什么还会诞生出这么多形形色色的模式呢?关键就是适用场景的不同。比如前面演示过的decorator模式和observer模式,实质性的区别其 实就一句话,但是他们的适用场景是完全不同的,本质意义也是完全不同的,不能互相替代。再举个例子来说,observer的适用场景是对同一份数据施用独立的,前后无关的行为。 那么,如果施用的行为是有前后顺序的怎么办呢?你也许会说,observer中我们使用的list本身就是有顺序的啊,所以仍然是observer就好 了。其实不对,因为这个只是我们对observer的一种实现而已。如果你表明这里是observer,那么你的意思就是这些行为的前后顺序是无关紧要 的,实现者可以用非顺序的容器来实现,也是符合要求的。如果你在设计时就要清楚的表明“顺序”这个因素,就得考虑具有前后顺序关系Chain of Responsibility模式

学会了设计模式还只是一部分,最终我们还是要把设计模式转换成代码。也就是我们最终要实现模式。上面我已经说到了,具体实现的时候,并没有那么简单,我们 要考虑非常多的细节问题。很容易有一种情况,就是我们写着写着代码,渐渐的就迷失到细节里面去了,最后根本就忘记了我们原来的目的,包括我们原本想得很清 楚的模式。所以我的经验是,写原型代码。就像我前面演示的那样,用尽可能少,尽可能简单的代码,首先把我们要实现的模式的轮廓勾勒出来。当然这个原型代码离可以实际工作还差的很远。但是没关系,我们可以逐步的丰富它,最终变成一个可以实际工作的版本。在这个中间,为了保证对代码改善的时候不会不小心改掉已有的功能,我们需要用单元测试作保证,并且最好用重构的手法来进行。这又是一个新的话题了,这个方面我也在探索中,也许以后可以写一点有价值的东西跟大家分享。

说完了模式的学习,接下来就是在实际项目中对模式的应用了。根据我在实际工作中的体会,基本思想非常简单:能不用就不用,要用就用好。我前面已经说过,模式并不是为了炫耀,而是为了解决实际问题。我们构建软件系统也一样,并不是为了炫耀技术,而是为了解决实际问题。对于用户来说,你是不是用了模式,他们是毫不关心的,他们关心的是软件系统是否实现了他们想要的功能。因此,我们在做设计的时候,第一要素不是应用模式,而是解决问题。而解决问题的方法是越简单越好的,越简单的系统越不容易错。所以,如果在可以预见的将来,系统只会加入3种前缀,我就会选择使用switch case去硬编码这3种前缀,而不是去设想一种支持无限类型前缀的模型。但是,我们还有一个原则,就是当系统变得越来越复杂的时候,我们仍然要维持简洁性,不能让系统随之变得越来越混乱。当前缀需求不断增加,我们的switch case就会越来越庞大,应对这种越来越多的需求也会让开发者越来越疲于奔命。这个时候,原先简洁的设计就变得不简洁了。这时我们引入模式,是增加一定量的复杂性,而去维护长远角度的简洁性。但是,作为设计者,应当保持一种敏锐的嗅觉,很多时候,等到设计真的不简洁了再来整理,面对庞大的代码往往大家都会望而却步不肯动手。设计者应该在系统“将会”变得复杂而还没有变得复杂之前做出一定的应变措施,改变架构来适应未来的变化。说得很玄乎是不是?其实也很简单,每当需求有增加的时候,作为设计者,我们就应该想一想,原先的架构是不是应该做些变化了。 就像我在第二章演示的那种情形,原先有3种前缀,我们就使用了最简单的switch case模式。而当新的需求到来时,我们就要反思一下,现在有新的需求,将来会不会还有其他的需求?我当时的结论是,当然会有,凭什么前缀只能是IP?为 什么不能是别的?既然有人提IP,肯定以后还会有人提别的。这个时候,我们就应该考虑一下,switch case模式足以应付将来的扩展吗?似乎很难。所以,我们决定引入模式,将原先的代码改写。这个时候,是从一个比较简单的代码开始改写,难度比改动一大堆 垃圾自然要轻松许多。这种改动,实质上是一种不改变功能的重构,按理说应该使用单元测试和标准重构手法来做,保证改变不会带来副作用。但这是另一个话题 了。但是一旦你决定使用设计模式了,你就应该仔细的对照模式的要求,看看你想使用的模式是否真的符合适用场景是否能找到模式中的几大要素, 如果像第三章那样要对模式做简化,也要仔细想想是不是可以简化,会带来什么问题?并不是说模式就不能做改动不能做裁剪,但是你一定要考虑清楚你是不是真的 要这样做,有什么好处有什么后果。模式毕竟还是有一定的标准性的,它抽象出的那几个要素并不是没有理由的,你想清楚表示你真的理解了这个模式,在没有理解 之前不要随意使用,以免误用。错误使用模式带来的麻烦比不用模式还要多。因为每个模式都有自己的一些要素,错误使用模式,就意味着你必须生硬的造出一些原本不需要存在的东西,增加复杂度不说,这种生造出来的东西在以后会带来什么样的麻烦谁也说不清。

我一遍又一遍的强调着能不用模式就不要用的思想。滥用模式真的有那么可怕吗?我想以我实际工作中碰到的一个教训来给大家一点启示。我以前在一家公司,接到 的一个工作是开发一个数据库应用,叫做BulkCopy,简单的说,就是将不同数据库之间的数据,互相复制。我们使用了公司内部自己开发的一套框架 FDO,这套框架类似与微软的ADO,是将不同的数据库抽象成一个标准接口(顺便说一句,设计模式里,这种手法叫做Proxy), 我们是利用这个标准接口操作。而这个标准接口对具体的数据库操作,是通过各个数据库自己提供的provider。这是一种非常典型的设计思想:将工作分 层。底层的开发者都只关心自己需要做的事情,而不需要关心业务逻辑,而高层的开发者,不需要跟具体的数据库打交道,只要专注于业务逻辑即可。看上去,这是 一个非常漂亮的设计。

但实际工作的时候,远非如此浪漫。因为工作被分成了数据库API、provider、FDO和业务逻辑四层,每次当功能实现失败的时候,任何一层都 可能是问题所在。我们很难定位问题到底出在了哪里。更糟糕的是,因为4个层次是4个不同的开发小组在工作,而且工作地点有些在中国,有些在美国,有些在欧 洲,有些在印度……,交流起来那痛苦就别提了。我们做业务逻辑的当然希望问题是出在底层,所以一出问题就给他们写信,而底层的人当然希望业务逻辑能严格按 他们的设想去使用接口……,于是,各种问题纷至沓来,有些是业务逻辑实现的问题,有些是底层的bug,还有一些是谁都没问题,只不过因为调用不够规范,属 于集成的问题……。一个项目做下来,所有人都精疲力竭。

这就是分层增加复杂性带来的非常现实的问题。所以我一直强调在做设计的时候,一定要将异常处理放在非常重要的位置,我们这个项目实际上是有考虑过这 一点的,我们有一个异常基类,所有的出错都必须抛出异常,所有的异常都由统一的基类派生……,这样的确解决了一部分的问题,在问题发生的时候,我们通过异 常中得到的调试信息,可以定位这个异常的发生地点。但是C++有个问题,它并没有将异常作为自己的唯一错误处理形式,因此,一旦因为开发者疏忽漏抛了异 常,或者有些开发者完全想象不到的问题发生,你完全无法得到任何信息,还是只能在几个模块之间集成调试。关于这一点,Java、Python、C#之类的 语言考虑得就完善的多了。

这个从实例学习设计模式系列短文到这里就完全结束了。并不是一个特别优秀的文章,用于演示的系统也因为太小只能演示到极少数的几个模式,不过我还是希望这个文章能给大家带来一些有用的启发和帮助。这就是我最开心的事情了。

(全文完)

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值