C++程序设计语言 对 类的简要说明

本文摘抄自 C++程序设计语言
C++最核心的语言特性就是类。类是一种用户自定义的数据类型,用于在程序代码中表示某种概念。无论何时,只要我们想为程序设计一个有用的概念、想法或者实体,都应该设法把它表示为程序中的一个类,这样我们的想法就能够表达成为代码。

大多数编程技术依赖于某些特定类的设计与实现,本文只考虑对三种重要类的基本支持:

  • 具体类
  • 抽象类
  • 类层次中的类

1.具体类

具体类 的基本思想是它们的行为 “就像内置类型一样”。比如一个复数类型和一个无穷精度整数与内置的int非常相像,当然它们有自己的语义和操作集合。同样,vector和string也很想内置的数组,只不过在操作上更胜一筹。

具体类型的典型特征是,它的表现形式是其定义的一部分。在很多重要例子(如vector)中,表现形式只不过是一个或几个指向保存在别处的数据的指针,但这种表现形式出现在具体类的每一个对象中。这使得实现可以在时空上达到最优,尤其是它允许我们

  • 把具体类型的对象置于栈、静态分配的内存或者其他对象中
  • 直接引用对象(而非仅仅通过指针或引用)
  • 创建对象后立即进行完整的初始化 (比如使用构造函数)
  • 拷贝对象

类的表现形式可以被限定为私有的(就像 Vector 一样),只能通过成员函数访问,但它确实存在。因此,一旦表现形式发生了任何明显的改动,使用者就必须重新编译。这也是我们试图使具体类型尽可能接近内置类型而必须付出的代价。对于那些不常改动的类型和那些局部变量提供了迫切需要的清晰性和效率的类型来说,这种特性是可以接受的,而且通常会很理想。如果想提高灵活性,具体类型可以将其表现形式的主要部分放置在自由存储(动态内存、堆)中,然后通过存储在类对象内部的另一部分访问它们。vector 和 string 的实现机理正是如此,我们可以把它们看做是带有精致接口的资源管理器。

1.1 容器

容器是一个包含若干元素的对象,构造函数负责分配元素空间并正确地初始化 容器成员,析构函数则负责释放空间。这就是所谓的数据句柄模型 (handle-to-data model),常用来管理在对象生命周期中大小会发生变化的数据。在构造函数中请求资源,然后在析构函数中释放它们的技术称为资源获取即初始化(Resource Acquisition Is Initialization),简称 RAII,它使得我们得以规避“裸 new 操作”的风险;换句话说,该技术可以避免在普通代码中分配内存,而是把分配操作隐藏在行为良好的抽象的实现内部。同样,也应该避免“裸 delete 操作”。避免裸 new 和裸 delete 可以使我们的代码远离各种潜在风险,避免资源泄漏。

2.抽象类型

抽象类型将使用者和类的实现细节完全分离开,使用包含抽象函数的抽象类来做到这一点
例如:

class Container{
public:
	virtual double& operator[](int) = 0; //纯虚函数
	virtual int size() const = 0; //常量成员函数
	virtual~Container(); //析构函数
} 

对于后面定义的那些特定容器来说,上面这个类是个纯粹的接口。关键字 virtual 的意思是“可能随后在其派生类中重新定义”。意料之中,我们把这种用关键字 virtual 声明的函数称为虚函数(virtual function)。Container 的派生类负责为 Container 接口提供具体实现看起来有点奇怪的 =0 说明该函数是纯虚函数,意味着 Container 的派生类必须定义这个函数。因此,我们不能单纯定义一个 Container 的对象,Container 只是作为接口出现,它的派生类负责具体实现 operator[] () 和 size()。含有纯虚函数的类称为抽象类 (abstract class)

void use(Container& c){
	const int sz = c.size();
	for (int i=0; i!=sz; ++i){
		cout << c[i] << endl;
	}
}

请注意 use()是如何在完全忽视实现细节的情况下使用 Container 接口的。它使用了size()和[],却根本不知道是哪个类型实现了它们。如果一个类负责为其他一些类提供接口,那么我们把前者称为多态类型

作为一个抽象类,在 Container 中没有构造函数,毕竟它没有什么数据需要初始化。另一方面,Container 含有一个析构函数,而且该析构函数是 virtual 的。这也不难理解,因为抽象类需要通过引用或指针来操纵,而当我们试图通过一个指针销毁 Container 时,我们并不清楚它的实现部分到底拥有哪些资源。

一个容器为了实现抽象类 Container 接口所需的函数,可以使用具体类 Vector

class Vector_container : public Container{ // Vector container 实现了 Container
	Vector v;
public:
	Vector_container(int s) : v(s) {} //l 含有s个元素的Vector
	~Vector_container(){};
	double& operator[](int i){return v[i]};
 	int size() const { return v.size(); 
 	};

成员operator[] (int i)和 size() 覆盖(override)了基类 Container 中对应的成员。析构函数Vector container()则覆盖了基类的析构函数 ~ Container()。注意,成员v的析构函数(~ Vector())被其类的析构函数(~Vector_container())隐式调用。

对于像use(Container&)这样的函数来说,可以在完全不了解一个 Container 实现细节的情况下使用它,但还需另外某个函数(g)为其创建可供操作的对象。例如:

void g(){
	Vector_container vc (10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0);
	use(vc);
}

因为use() 只知道 Container 的接口而不了解 Vector_container,因此对于 Container的其他实现,use()仍能正常工作。例如:

class List_container : public Container { //List container 实现了Container
std::list<double> ld //一个double类型的标准库list
public:
	List_container(){}// 空列表
    List_container(initializer_list<double> il) : ld{il}{}
    ~List container(){}
    double& operator[](int i);
    int size() const { return ld.size(); }
 }
double& List_container::operator[](int i){
	for (auto& x : ld) {
		if (i==0) return x;
		--i;
	}
	throw out_of_range("List container");
}

上面一段代码继承了Container抽象类,做了一个新的实现
我们可以通过一个函数创建一个 List_container,然后让 use()使用它:

void h(){
	List_container lc = ( 1, 2, 3, , 5, 6, 7, 8, 9 );
	use(ic);
}

这段代码的关键点是 use(Container&)并不清楚它的实参是 Vector_container、Listcontainer,还是其他什么容器,它也根本不需要知道。它只要了解 Container 定义的接口就可以了。因此,不论List container 的实现发生了改变还是我们使用了 Container 的一个全新派生类,都不需要重新编译 use(Container&)。灵活性背后唯一的不足是,我们只能通过引用或指针操作对象。

2.1 虚函数

我们进一步思考 Container 的用法:

void use(Container& c){
	const int sz = c.size();
	for (int i=0; i!=sz; ++i){
		cout << c[i] << endl;
	}
}

一个有趣的问题是:use()中的 c 是如何解析到正确的 operator[] ()的?
当h()调用use()时,必须调用List_container 的 operator()[];
而当g()调用use()时,必须调用Vector_container 的 operator() []。
要想达到这种效果,Container 对象就必须包含一些有助于它在运行时选择正确函数的信息。常见的做法是编译器将虚函数的名字转换成函数指针表中对应的索引值,这张表就是所谓的虚函数表 (virtual function table)或简称为 vtbl。每个含有虚函数的类都有它自己的 vtbl 用于辨识虚函数,其工作机理如下图所示:
在这里插入图片描述
即使调用函数不清楚对象的大小和数据布局,vtbl 中的函数也能确保对象被正确使用调用函数的实现只需要知道 Container 中 vtbl 指针的位置以及每个虚函数对应的索引就可以了。这种虚调用机制的效率非常接近“普通函数调用”机制(相差不超过 25%),而它的空间开销包括两部分:如果类包含虚函数,则该类的每个对象需要一个额外的指针:另外每个这样的类需要一个 vtbl。

3 类层次

所谓类层次 (class hierarchy) 是指通过派生(如:public)创建的一组类,在框架中有序排列。我们使用类层次表示具有层次关系的概念,比如“消防车是卡车的一种,卡车是车辆的一种”以及“笑脸是一个圆,圆是一个形状”。类似于下图
在这里插入图片描述

箭头表示继承关系。例如,Circle 类派生自 Shape 类。要想把上面这个简单的图例写成代码,我们首先需要说明一个类,令其定义所有这些形状的公共属性:

class Shape {
public:
	virtual Point center() const =0virtual void move(Point to) =0; // 纯虚函数
	virtual void draw() const = 0; // 在当前“画布”上绘制
	virtual void rotate(int angle) = 0;
	virtual ~Shape() {} //析构函数
// ...
};

这个接口显然是一个抽象类:对于每种 Shape 来说,它们的表现形式基本上各不相同(除了 vtbl 指针的位置之外)。基于上面的定义,我们就能编写函数令其操纵由形状指针组成的向量了:

void rotate_all(vector<Shape*>& vintangle){ // v的元素按照指定角度旋转
	for (auto p : v)
		p->rotate(angle);
}

要定义一种具体的形状,首先必须指明它是一个 Shape,然后再规定其特有的属性(包括虚函数:


class Circle : public Shape {
public:
	Circle(Point p, int rr); //构造函数
	Point center() const { return x; }
	void move(Point to) { x=to; }
	void draw() const;
	void rotate(int) {} // 一个简单明了的示例算法

private:
	Point x; // 圆心
	int r; //半径
}

到目前为止,Shape 和 Circle 涉及的语法知识并不比 Container 和 Vector_container多多少,但是我们可以继续构造:

class Smiley: public Circle{ // 使用 Circle 作为笑脸的基类
public:
	Smiley(Point p, int r) : Circle{p,r}, mouth{nullptr} {}
	~Smiley(){
		delete mouth;
		for (auto p : eyes) delete p;
	}

	void move(Point to);
	void draw() const;
	void rotate(int);
	void add_eye(Shape* s){eyes.push_back(s);}
	void set_mouth(Shape* s);
	virtual void wink(int i);// 眨眼数 i
	// ..
private:
	vector<Shape*> eyes;// 通常包含两只眼晴
	Shape* mouth;
};

成员函数 push_back()把它的实参添加给 vector (此处是 eyes),每次将向量的长度加1。接下来通过调用 Smiley 的基类 (Circle)的 draw()和 Smiley 的成员(eyes)的 draw()来定义Smiley::draw():

void Smiley::draw(){
	Circle::draw();
	for (auto p : eyes)
		p->draw();
	mouth->draw();
}

请注意,Smiley 把它的eyes 放在标准库 vector 中,然后在析构函数里把它们释放掉了。Shape 的析构函数是个虚函数,Smiley 的析构函数覆盖了它。对于抽象类来说,因为其派生类的对象通常是通过抽象基类的接口操纵的,所以基类中必须有一个虚析构函数。当我们使用一个基类指针释放派生类对象时,虚函数调用机制能够确保我们调用正确的析构函数,然后该析构函数再隐式地调用其基类的析构函数和成员的析构函数。

类层次提供了两种便利:

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

类层次中的类我们倾向于通过 new 在自由存储中为其分配空间,然后通过指针或引用访问它们。例如,我们设计这样一个函数,它首先从输人流中读入描述形状的数据,然后构造对应的 Shape 对象

enum class Kind {circle, triangle, smiley};
Shape* read_shape(istream& is){ // 从输入流s 中读取形状指述信息
//... 从is 中读取形状描述信息,找到形状的种类 k...
switch (k) {
case Kind::circle:// 读入 circle 数据 fPoint,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 对象。
有经验的程序员可能已经发现,上面的程序有两处漏洞:
使用者有可能未能 delete 掉 read_shape()返回的指针。Shape 指针容器的拥有者可能没有 delete 指针所指的对象从这层意义上来看,函数返回一个指向自由存储上的对象的指针是非常危险的。一种解决方案是不要返回一个“裸指针”,而是返回一个标准库 unique_ptr,并且把unique_ptr 存放在容器中:


unique_ptr<Shape>(istream&is){//从输入流is读取形状描述信息read
// ... 从is 中读取形状描述信息,找到形状的种类 k...
switch (k) {
	case Kind::circle:
	//读入circle数据{Point,int}到p和r
	return unique_ptr<Shape>(new Circle{p,r;}}// 5.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 将释放掉所指的对象。
要令unique_ptr 版本的 user()能够正确运行,必须首先构建接受 vector<uniqueptr>的draw_all()和 rotate_all()。写太多这样的_all()函数过于繁和乏味,在将提供另一种可选的方案。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值