Container是一个非常简单的类层次的例子,所谓类层次(class hierarchy)是指通过派生(如:public)创建的一组类,在格中有序排列。我们使用类层次表示具有层次关系的概念,比如“消防车是卡车的一种,卡车是车辆的一种”以及“笑脸是一个圆,圆是一个形状”。在实际应用中,巨大的类层次很常见,动辄包含上百个类,不论深度还是宽度都很大。不过在本节,我们只考虑一个半真实的例子,那就是屏幕上的形状。
箭头表示继承关系。例如,类Circle派生自类Shape。为了用代码表示这个简单的层次关系,我们需要首先声明一个定义了所有形状一般属性的类:
class Shape{
public:
virtual Point center() const = 0; //纯虚函数
virtual void move(Point to) = 0;
virtual void draw() const = 0; //在当前“画布”上绘制
virtual void rotate(int angle) = 0;
virtual ~Shape(){} //析构函数
//...
};
这个接口自然是一个抽象类:对于每种Shape来说,它们的表示各不相同(除了vtbl指针的位置)。基于上面的定义,我们就能编写函数来操纵形状指针的向量了:
void ratate_all(vector<Shape*>& v, int angle) //将v的元素旋转angle角度
{
for(auto p : v)
p->rotate(angle);
}
为了定义一种具体的形状,首先必须指明它是一个Shape,然后再规定其特有的属性(包括虚函数):
class Circle : public Shape{
public:
Circle(Point p, int rad); //构造函数
Point center() const override
{
return x;
}
void move(Point to) override
{
x = to;
}
void draw() const override;
void rotate(int) override{} //一个简单明了的示例算法
private:
Point x; //圆心
int r; //半径
};
到目前为止,Shape和Circle的例子与Container和Vector_container的例子相比并未涉及新的东西,但是我们可以继续构造:
class Smiley : public Circle{
public:
Smiley(Point p, int rad) : Circle{p, r}, mouth{nullptr}{}
~Smiley()
{
delete mouth;
for(auto p : eyes)
delete p;
}
void move(Point to)override;
void draw() const override;
void rotate(int) override;
void add_eye(Shape* s);
virtual void wink(int i); //眨眼数i
//...
private:
vector<Shape*> eyes; //通常有两只眼
Shape* mouth;
};
Vector的成员函数push_back()将其实参拷贝到vector(此处是eyes)中成为其最后一个元素,将向量的长度增加1。
现在可以通过调用Smiley的基类的draw()及其成员的draw()来定义Smiley::Draw():
void Smiley::Draw() const
{
Circle::draw();
for(auto p : eyes)
p->draw();
mouth->draw();
}
注意,Smiley是如何将它的eyes保存在一个标准库vector中,以及如何在析构函数中将它们释放掉。Shape的析构函数是个虚函数,Smiley的析构函数覆盖了它。对于抽象类来说,因为其派生类的对象通常是通过抽象基类的接口操纵的,所以基类中必须有一个虚析构函数。特别是,我们可能使用一个基类指针释放派生类对象。这样,虚函数调用机制能够确保我们调用正确的析构函数,然后该析构函数再隐式调用其基类的析构函数及其成员的析构函数。
在上面这个简单的例子中,程序员负责在表示人脸的圆圈中恰当地放置眼睛和嘴。
当我们通过派生的方式定义新类时,可以向其中添加数据成员和新的操作。这种机制一方面提供了巨大的灵活性,相应也可能带来混淆、导致糟糕的设计。
类层次结构的益处主要体现在两个方面:
具体类,尤其是表示简单的类,与内置类型非常相似:我们将其定义为局部变量,通过它们的名字访问它们,随意拷贝它们,等等。类层次中的类则有所区别:我们倾向于用new在自由存储中为其分配空间,然后通过指针或引用访问它们。例如,我们设计这样一个函数,它首先从输入流中读入描述形状的数据,然后构造对应的Shape对象:
enum class Kind{circle, triangle, smiley};
Shape* read_shape(istream& is) //从输入流is中读入形状描述信息
{
//...从is中读取形状描述信息,找到形状的类别k...
switch(k){
case Kind::circle:
//读取circle数据{Point, int}到p和r
return new Circle{p, r};
case Kind::triangle:
//读取triangle数据{Point, Point, Point}到p1, p2和p3
return new Triangle{p1, p2, p3};
case Kind::smiley:
//读取smiley数据{Point, int, Shape, Shape, Shape}到p,r,e1,e2和m
Smiley* ps = new Smiley{p, r};
ps->add_eye(e1);
ps->add_eye(e2);
ps->set_mouth(m);
return ps;
}
}
程序使用该函数的方式如下所示:
void user()
{
std::vector<Shape*> v;
while(cin)
v.push_back(read_shape(cin));
draw_all(v); //对每个元素调用draw()
rotate_all(v, 45); //对每个元素调用rotate(45)
for(auto p : v) //记得删除元素
delete p;
}
显然,这个例子非常简单,尤其是并没有做任何错误处理,但它淋漓尽致地展示了user()完全不知道它操纵的是哪种形状。user()的代码只需要编译一次,即可使用随后添加到程序中的新Shape。注意,在user()外没有任何指向这些形状的指针,因此user()应该负责释放掉它们。这项工作由delete运算符完成并且完全依赖于Shape的虚析构函数。因为该析构函数是虚函数,因此delete会调用最底层派生类的析构函数。这一点非常关键:因为派生类可能已经获取了很多资源(如文件句柄、锁、输出流等),这些资源都需要释放掉。此例中,Smiley释放了它的eyes和mouth对象。它一旦完成了这些工作,就继续调用Circle的析构函数。对象的构造是由构造函数“自顶向上的”进行的(基类优先),销毁则是由析构函数“自底向上”(派生类优先)进行的。
read_shape()函数返回shape*指针,从而我们可以按相似的方式处理所有的Shape。但是,如果我们想使用只有某个特定派生类才提供的成员函数,比如Smiley的wink(),则可以使用dynamic_cast运算符询问“这个Shape是Smiley吗?”:
Shape* ps{read_shape(cin)};
if(Smiley* p = dynamic_cast<Smiley*>(ps)){ //... ps指向一个Smiley吗?...
//...是Smiley,使用它
}
else{
//..不是Smiley,执行其他操作
}
如果在运行时dynamic_cast的参数(此处是ps)所指向对象的类型不是期望的类型(此处是Smiley)或其派生类,则dynamic_cast返回nullptr。
如果我们认为指向不同派生类对象的指针是合法参数,就可以对指针类型使用dynamic_cast,然后检查结果是否是nullptr。这种检验常被方便地用在条件语句中的变量初始化中。
如果我们不能接受不同类型,可以简单地对引用类型使用dynamic_cast。如果对象不是预期类型,dynamic_cast会抛出一个bad_cast异常:
Shape* ps{read_shape(cin)};
Smiley& r{dynamic_cast<Smiley&>(*ps)}; //要在某处捕获std::bad_cast
适度使用dynamic_cast能让代码变得更简洁。如果我们可以避免使用类型信息,就能写出更简洁、更高效的代码,不过类型信息偶尔会丢失,必须被恢复出来。典型场景是我们传递一个对象给某个系统,它接受的是由基类定义的接口。当该系统稍后将对象传回时,我们可能不得不恢复其原本类型。类似dynamic_cast的操作被称为“类型”(is kind of)或者“实例”(is instance of)操作。
有经验的程序员可能已经注意到,我在上面的程序中留下了三个可能导致错误的地方:
从这层意义上来看,在自由存储上分配的对象的指针是危险的:我们不应该用一个“普通老式指针”来表示所有权。例如:
void user(int x)
{
Shape* p = new Circle{Point{0,0}, 10};
//...
if(x<0)throw Bad_x{}; //潜在泄漏危险
if(x==0)return; //潜在泄漏危险
//...
delete p;
}
除非x是正数,否则这段代码就会发生泄漏。将new的结果赋予一个“裸指针”就是自找麻烦。
这种问题的一个简单解决方案是,如果需要释放资源,则不要使用“裸指针”,而是使用标准库unique_ptr(参见13.2.1节):
class Smiley : public Circle{
//...
private:
vector<unique_ptr<Shape>> eyes; //通常有两只眼
unique_ptr<Shape> mouth;
};
这是一个简单、通用且高效的资源管理技术的例子(参见5.3节)。
这一改变有一个令人愉快的副作用,我们不再需要为Smiley定义析构函数。编译器会隐式生成一个析构函数,它会对vector中的unique_ptr(参见5.3节)进行所需的析构操作。使用unique_ptr的代码与正确使用裸指针的代码具有完全相同的效率。
现在我们考虑read_shape()的使用者:
unique_ptr<Shape> read_shape(istream& is) //从输入流is读取形状描述信息
{
//...从is中读取形状描述信息,找到形状的类别k...
switch(k){
case Kind::circle:
//读取circle数据{Point, int}到p和r
return unique_ptr<Shape>{new Circle{p, r}}; //参见13.2.1节
//...
}
void user()
{
vector<unique_ptr<Shape>> v;
while(cin)
v.push_back(read_shape(cin));
draw_all(v); //对每个元素调用draw()
rotate_all(v, 45); //对每个元素调用rotate(45)
}//所有的形状被隐式销毁
现在每个对象都由unique_ptr所拥有了,当不再需要对象时,换句话说,当对象的unique_ptr离开了作用域时,unique_ptr将delete对象。
为了让unique_ptr版本的user()能够正确运行,我们需要能接受vector