在实际项目中,经常能够看到容器被当作参数,在不同的对象间传递。这样做有什么问题?
缺乏内聚性
在进一步讨论之前,我们先来看看下面两个表达式之间有何区别?
int value;
std::list<int> values;
经常得到的答案是:前者是一个primitive
的数据,后者是一个对象。对于前者,你只能执行基本的数值演算;而后者的类型std::list<int>
是一个类,你可以调用它的方法,比如:
values.push_back(5);
这个答案并没有什么错。那我们再来看一个问题:下面两个表达式的区别在哪里?
Object object;
std::list<Object> objects;
对于这个问题,之前的答案就不再有效。因为在这个例子中,两者都是对象。其不同之处在于,对Object
对象的方法调用,是对一个具体的业务对象的操作;而后者却是对容器对象的操作。
现在,我们将问题变为:这两个例子中,前后两个表达式的共同差异是什么?
如果你比较敏锐,应该已经得到答案:前者代表一个数据(对象),后者代表一组数据(对象)。
所以,虽然容器本身是一个对象,但更本质地,它代表着一组数据,围绕着这组数据的业务逻辑,容器对象本身并没有涉及。
所以,直接访问一个容器,而不是将容器封装在一个业务对象里;这和直接操作一个数据,而不是将数据封装在一个抽象数据类型里,本质上没有任何区别。它们都违反了数据和操作它的行为应该放在一起的高内聚原则。
缺乏稳定性
现在,我们再问一个问题:下面的三个表达式的共同之处是什么?
Object objects[100];
std::vector<Object> objects;
std::list<Object> objects;
答案很简单:都代表多个Object
对象的集合。它们之间的实现技术上虽然不同,但从抽象层面上,这三个实现方式所要表达的概念并无任何不同。
实现技术可以随着约束的变化而变化,但只要用户的抽象需求没有发生变化,用户的代码就不应该受到具体实现技术变化的影响。
因此,直接让用户访问容器对象,不仅仅违反了高内聚的原则,还违反了“向着稳定的方向依赖”原则。
对容器进行封装
基于上述的讨论,我们可以得出如下结论:当系统中存在一个集合概念时,应考虑包含这个集合概念的单一概念是什么,并根据这个单一概念对集合进行封装。
比如:一个班包含许多学生。糟糕的做法是:
typedef std::list<Student> SchoolClass;
当在另外一个对象需要计算一个班的平均成绩时,就会出现类似于下面的代码:
struct Foo
{
void f(const SchoolClass& cls)
{
unsigned int averageScore = getAverageScoreOfClass(cls);
// ...
}
private:
unsigned int getAverageScoreOfClass(const SchoolClass& cls)
{
unsigned int totalScore = 0;
for( SchoolClass::const_iterator i=cls.begin(); i != cls.end(); ++i)
{
totalScore += (*i).getScore();
}
return totalScore/cls.size();
}
// ...
};
而一个合理的做法则是:
struct SchoolClass
{
unsigned int getAverageScore() const
{
// ...
}
// ...
private:
std::list<Student> students;
};
struct Foo
{
void f(const SchoolClass& cls)
{
unsigned int averageScore = cls.getAverageScore();
// ...
}
// ...
};
如果有一天,设计者认为使用定长数组是更好的选择(因为std::list
有可能因为内存问题而带来的不确定性),那么所有的修改都被控制在SchoolClass
内部,对于Foo
,以及任何其它SchoolClass
的客户都毫无影响(局部化影响)。
多级容器
另外,在实际项目中,经常能够看到类似于下面的定义:
typedef std::map<std::string, std::map<std::string, std::string>
> ConfigFile;
这还算轻微的。事实上,在我经历过的项目中,三级甚至四级容器也并不罕见。
相对于单级容器,多级容器带来的问题更多:这样复杂的数据结构定义本身就非常晦涩,而其处理代码也往往互相交织在一起,不仅难以理解,还极其脆弱:其中任何一个级别的容器发生变化都会给整个数据结构的处理代码带来影响。
比如,上述数据结构完全可以改变为:
typedef std::map<std::string, std::list<std::pair<std::string, std::string> >
> ConfigFile;
对于多级容器,其处理方法和单级容器的方法并没有什么两样:将每一级容器都进行封装。比如,对于刚才这个例子,至少可以进行类似于下面的封装:
struct ConfigFile
{
// ...
private:
std::map<std::string, ConfigSection> sections;
};
struct ConfigSection
{
// ...
private:
std::map<std::string, std::string> items;
};
用意不明的数据子集
当一个数据集合被封装在一个类中之后,对于这个数据集合的需求可能变化非常剧烈。比如,客户代码可以基于各种各样的目的,从数据集合中过滤出一个数据子集,并对这个数据子集执行自己所需的操作。
如果将所有客户的意图,都堆积在数据集合所在的类中实现,将会造成这个类极其不稳定,也容易造成上帝类。同时,也会降低客户代码的内聚度。
这种情况下,数据集合类提供查询接口,由客户自定义一个过滤条件,数据集合类根据客户自定义的过滤条件,得到客户所需的数据子集,由客户代码对数据子集定义所需的操作,反而是个更好的选择。
对于数据集合类而言,这些数据子集的语意是不明的,因为客户才知道它的用途。所以,如果需要对这些数据子集进行封装的话,也应该是客户的责任。如果客户将数据子集封装为语意明确的类,并将这个类作为输出参数传递给数据集合类的话,既会造成数据集合类对这些数据子集类型的依赖,同时仍然会造成数据集合类接口的不稳定。
所以,设计者们往往选择给数据集合类提供类似与下面的接口与实现:
struct SchoolClass
{
void getStudentsByFilter
( const Filter& filter // 输入参数:过滤器
, std::list<Student>& result // 输出参数:查询结果
) const
{
for( SchoolClass::const_iterator i=cls.begin() ; i != cls.end(); ++i)
{
if(filter.matches(*i))
{
result.push_back(*i);
}
}
}
// ...
private:
std::list<Student> students;
};
这样的方法,几乎可以保证数据集合类接口和实现的稳定。之所以说“几乎”,是因为std::list
作为双方交换数据的契约,仍然过于具体。一旦因为某种原因发生变化,则双方代码都会受到影响。
但是,我们之前已经得出过结论:std::list
虽然很具体,但也不能对其进行业务层面的封装。我们似乎陷入了黔驴技穷的处境。
5 Why
分析法告诉我们,如果我们多问几个为什么,就能找到更加稳定的抽象。
客户在拿到数据子集之后,一定有自己的意图,我们如果让接口反映的是用户自己的意图,而不是数据子集这么具体的实现细节,那么数据子集将会变成一个无用的中间层。
那客户的意图是什么呢?不知道。但我们有多态这门进行抽象的强大武器,借助于它,客户的确切意图对我们便不再重要。
所以,我们可以将上述代码修改为:
struct Visitor
{
virtual void visit(const Student& student) = 0;
virtual ~Visitor {}
};
struct SchoolClass
{
void visitStudentsByFilter
( const Filter& filter // 输入参数:过滤器
, Visitor& visitor // 输入参数:对过滤结果的处理
) const
{
for( SchoolClass::const_iterator i = cls.begin() ; i != cls.end(); ++i)
{
if(filter.matches(*i))
{
visitor.visit(*i);
}
}
}
// ...
private:
std::list<Student> students;
};
这样的实现方式,帮助我们更加直接的满足客户的意图。这不仅让双方的代码更加稳定,在很多场合下,由于客户并不需要存储查询的结果,绕开std::list
这样的数据集合,还可以提高性能,并降低内存管理方面的负担。
比如,一个客户想过滤出所有及格的学生,只是为了统计及格学生的数量,那么它就可以将Visitor
实现为:
unsigned int Foo::getNumOfPassStudents(const SchoolClass& cls) const
{
struct PassStudentFilter : Filter
{
bool matches(const Student& student) const
{
return student.isPass();
}
} filter;
struct PassStudentsCounter : Visitor
{
PassStudentsCounter() : numOfPassStudents(0) {}
void visit(const Student& student) { numOfPassStudents++; }
unsigned int numOfPassStudents;
} counter;
cls.visitStudentsByFilter(filter, counter);
return counter.numOfPassStudents;
}
通过这个实现,我们注意到一个重要事实:Filter
是不必要的,因为客户可以在Visitor
里自己进行过滤。所以,我们将之前数据集合类的实现修改为简化版本的访问者模式(由于没有多种类型的元素,所以不需要双重派发)。而这是一个更加通用的抽象,借助于它,可以简化双方的实现。
struct SchoolClass
{
void accept(Visitor& visitor) const
{
for( SchoolClass::const_iterator i=cls.begin() ; i != cls.end(); ++i)
{
visitor.visit(*i);
}
}
// ...
private:
std::list<Student> students;
};
而之前的客户代码也得到简化:
unsigned intFoo::getNumOfPassStudents(const SchoolClass& cls) const
{
struct PassStudentsCounter : Visitor
{
PassStudentsCounter() : numOfPassStudents(0) {}
void visit(const Student& student)
{
if(student.isPass()) numOfPassStudents++;
}
unsigned int numOfPassStudents;
} counter;
cls.accept(counter);
return counter.numOfPassStudents;
}
而对于确实需要保存下来过滤结果的客户,仍然可以轻松达到目标:
struct Bar : private Visitor
{
void savePassedStudents(const SchoolClass& cls)
{
cls.accept(*this);
}
// ...
private: // 对 visit 方法的实现
void visit(const Student& student)
{
if(student.isPass()) passedStudents.push_back(student);
}
private:
// 注意,这不是 std::list,而是用户根据自己需要而采用的数据结构
std::vector<Student> passedStudents;
// ...
};
在这个实现中,使用了私有继承。关于其用法的详细讨论,请参考《Virtues of Bastard》。
总结
本文探讨了直接暴露容器所带来的问题,以及如何进行封装,以提高可维护性。关于封装,请参考《类与封装》。