C++语言导学 第四章 类 - 4.5 类层次

C++语言导学 第四章 类 - 4.5 类层次

  • 4.5 类层次
    • 4.5.1 层次结构的益处
    • 4.5.2 层次漫游
    • 4.5.3 避免资源泄露

4.5 类层次

Container是一个非常简单的类层次的例子,所谓类层次(class hierarchy)是指通过派生(如:public)创建的一组类,在格中有序排列。我们使用类层次表示具有层次关系的概念,比如“消防车是卡车的一种,卡车是车辆的一种”以及“笑脸是一个圆,圆是一个形状”。在实际应用中,巨大的类层次很常见,动辄包含上百个类,不论深度还是宽度都很大。不过在本节,我们只考虑一个半真实的例子,那就是屏幕上的形状。

C++语言导学 第四章 类 - 4.5 类层次_第1张图片
箭头表示继承关系。例如,类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的析构函数覆盖了它。对于抽象类来说,因为其派生类的对象通常是通过抽象基类的接口操纵的,所以基类中必须有一个虚析构函数。特别是,我们可能使用一个基类指针释放派生类对象。这样,虚函数调用机制能够确保我们调用正确的析构函数,然后该析构函数再隐式调用其基类的析构函数及其成员的析构函数。

在上面这个简单的例子中,程序员负责在表示人脸的圆圈中恰当地放置眼睛和嘴。

当我们通过派生的方式定义新类时,可以向其中添加数据成员和新的操作。这种机制一方面提供了巨大的灵活性,相应也可能带来混淆、导致糟糕的设计。

4.5.1 层次结构的益处

类层次结构的益处主要体现在两个方面:

  • 接口继承(Interface inheritance):派生类对象可以用在任何要求基类对象的地方。即,基类担当了派生类接口的角色。Container和Shape就是很好的例子,这样的类通常是抽象类。
  • 实现继承(Implementation inheritance):基类负责提供可以简化派生类实现的函数或数据。Smiley使用Circle的构造函数和Circle::draw()就是例子,这样的基类通常含有数据成员和构造函数。

具体类,尤其是表示简单的类,与内置类型非常相似:我们将其定义为局部变量,通过它们的名字访问它们,随意拷贝它们,等等。类层次中的类则有所区别:我们倾向于用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的析构函数。对象的构造是由构造函数“自顶向上的”进行的(基类优先),销毁则是由析构函数“自底向上”(派生类优先)进行的。

4.5.2 层次漫游

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)操作。

4.5.3 避免资源泄露

有经验的程序员可能已经注意到,我在上面的程序中留下了三个可能导致错误的地方:

  • Smiley的实现者可能未能delete指向mouthd的指针。
  • read_shape()的使用者可能未能delete返回的指针。
  • Shape指针容器的拥有者可能未能delete指针所指向的对象。

从这层意义上来看,在自由存储上分配的对象的指针是危险的:我们不应该用一个“普通老式指针”来表示所有权。例如:

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的draw_all()和rotate_all()。写太多这样的_all()函数过于乏味,因此6.3.2节提供了一种替代技术。

你可能感兴趣的:(C++语言导学,c++,开发语言)