数据结构与程序架构(1)
我想“数据结构”这个术语对每个程序员都不陌生,像什么“栈”、“树”、“FIFO”、“List”、“Map”、“哈希表”之类的概念我们在学生时代就已掌握,我想应该没有哪个有经验的程序员没有应用过上面提到的那些数据结构吧!然而,通常我们应用这些数据结构,是把它当作存放待处理数据的容器,有多少人会把它和程序架构联系在一起呢?
很多时候,我们在设计程序时,考虑的是它的模块间的逻辑调用关系,比如,一个程序要调用哪些子模块?一个用户操作或输入文件会触发哪些功能被调用?等等……这种设计思路对于我们设计一个小规模的应用程序时是比较方便的(因为比较符合我们的习惯),然而如果我们要设计一个更复杂的程序,那么这种设计思路就会遇到一些问题,比如,当我们要加入一个新功能时,我们不得不修改大面积的代码,从而引入一些很难察觉的bug;或者我们要修正一个bug,而这个修正会改变函数的调用顺序时,这些修改也是极其容易引入新bug的;或者随着修改次数的增加,使得我们的代码越来越难读懂,越来越难维护,最后不得不使用一些work-around方法,而这些work-around反过来又进一步使得我们的程序更加难以掌控。
以上种种问题都是我们在实际开发中经常遇到的问题,而且往往这些问题才是困扰项目的最大问题,那么有没有什么办法可以改进这些问题呢?方法还是有的,但首先我们要改变我们的思维习惯,特别是在设计程序框架时,我们要放弃以逻辑(即调用关系)为主导的思维习惯,而要以数据为主导去思考我们的系统。这里的数据即包括我们需要处理的纯数据,也包括我们的处理函数,即把函数当作数据看待(在《C++语言中的元类编程》一文中,我们讨论“闭包”概念时,也提到过这一思想)。这样我们在做架构设计时,就只需要关注各种数据间的组织结构和处理算法(即数据结构)了。
上面的建议听起来似乎有些道理,但也颇令人费解,我们要如何把函数当作数据来看待呢?这样做真的能改进我们的程序质量吗?那就让我们来看一个实际的例子,当它的处理函数被数据化后能带来的好处吧。
很多时候,我们的程序都需要处理一组命令行的参数,而通常我们的处理逻辑看起来会像这样:
const char * OPTION_NAMES[] = {
...
};
bool GetOptionName(const char ** io_begin, const char * szEnd, char * out_buffer); // 这个函数用于解析一个命令行参数名bool GetOptionValue(const char ** io_begin, const char * szEnd, const char ** out_valueBegin, const char ** out_valueEnd); // 这个函数用于解析一个命令行参数值
void ParseOptions(const char * szOptions) // 假定我们的命令行参数通过一个字符串参数传入
{
size_t stringLength = strlen(szOptions);
const char* szbegin = szOptions;
const char* szEnd = szOptions + stringLength;
char szOptionName[32];
const char * szValueBegin;
const char * szValueEnd;
while (szbegin < szEnd) {
memset(szOptionName, 0, 32);
if ( GetOptionName(&szbegin, szEnd, szOptionName) ) {
if ( GetOptionValue(&szbegin, szEnd, &szValueBegin, &szValueEnd) ) {
for (int i = 0; i < sizeof(OPTION_NAMES)/sizeof(const char *); ++i) {if ( 0 == strcmp(szOptionName, OPTION_NAMES[i]) ) {
switch(i) {
case 0:
...
break;
case 1:
...
break;
...
}
break;
}
}
}
}
}
}
上面代码中的省略部分就是命令行参数名的定义和调用相关的处理函数,该代码的效率且不说,单考虑增加一个命令行参数及其处理函数,我们就需要修改两个不同地方,如果我们忘记修改任何一处地方,就会产生bug。另外,随着ParseOptions函数的多次修改,它也变得越来越长,给阅读带来不便。然而,如果我们考虑将命令行参数名的定义和它的处理函数放在一起,并将它们组织成一个map,那么ParseOptions函数就只需要考虑如何访问这个map,并且一次实现无需修改,而我们需要修改的仅仅是往这个map中增加一个命令值处理函数。如下例:
struct IOptionParser {
virtual ~IOptionParser() {}
virtual void ParseValue(const char * valueBegin, const char * valueEnd) = 0;
// 如果我们将命令行参数的内容放在这个接口的实现类中,我们只需要再增加一组操作来获取它的内容即可
};
typedef std::map<std::string, IOptionParser *> option_parser_map;
void ParseOptions(const char * szOptions, option_parser_map & parserMap) {
size_t stringLength = strlen(szOptions);
const char* szbegin = szOptions;
const char* szEnd = szOptions + stringLength;
char szOptionName[32];
const char * szValueBegin;
const char * szValueEnd;
while (szbegin < szEnd) {
memset(szOptionName, 0, 32);
if ( GetOptionName(&szbegin, szEnd, szOptionName) ) {
if ( GetOptionValue(&szbegin, szEnd, &szValueBegin, &szValueEnd) ) {
parserMap[ std::string(szOptionName) ]->ParseValue(szValueBegin, szValueEnd);
}
}
}
class CStrOpt: public IOptionParser {
virtual void ParseValue(const char * valueBegin, const char * valueEnd) {
...
}
...
public:
CStrOpt() {
...
}
virtual ~CStrOpt() {
...
}
class CIntOpt: public IOptionParser {
virtual void ParseValue(const char * valueBegin, const char * valueEnd) {
...
}
...
public:
CIntOpt() {
...
}
virtual ~CIntOpt() {
...
}
};
...RegOptionParsers(option_parser_map & out_parserMap) {
out_parserMap["opt1"] = new CStrOpt;
out_parserMap["opt2"] = new CIntOpt;
通过上面的简单例子,我们不难体会,将函数“数据化”后,能使得程序的核心函数“一次实现,无需修改”,而且可以单独进行优化(和原始函数比较一下),扩展更容易,程序可读性和可维护性也提高了。