❶
“经典的用户定义算术类型”是complex:
class complex {
double re, im; // 表征数据:两个double
public:
complex(double r, double i) :re{r}, im{i} {} // 用两个标量构造complex
complex(double r) :re{r}, im{0} {} // 用一个标量构造complex
complex() :re{0}, im{0} {} // complex的默认值:{0,0}
double real() const { return re; }
void real(double d) { re=d; }
double imag() const { return im; }
void imag(double d) { im=d; }
complex& operator+=(complex z) {
re+=z.re; // 加至re和im
im+=z.im;
return *this; // 返回结果
}
complex& operator-=(complex z) {
re-=z.re;
im-=z.im;
return *this;
}
complex& operator*=(complex); // 定义在类外某处
complex& operator/=(complex); // 定义在类外某处
};
很多有用的运算无需直接访问complex的表征数据,因此可以与类定义分开:
complex operator+(complex a, complex b) { return a+=b; }
complex operator-(complex a, complex b) { return a-=b; }
complex operator-(complex a) { return {-a.real(), -a.imag()}; } // 一元负号
complex operator*(complex a, complex b) { return a*=b; }
complex operator/(complex a, complex b) { return a/=b; }
bool operator==(complex a, complex b) { // 相等
return a.real()==b.real() && a.imag()==b.imag();
}
bool operator!=(complex a, complex b) { // 不等
return !(a==b);
}
complex sqrt(complex); // 定义在别处
// ...
complex类可以这样用:
void f(complex z) {
complex a {2.3}; // 从 2.3 构造出 {2.3,0.0}
complex b {1/a};
complex c {a+z*complex{1,2.3}};
// ...
if (c != b)
c = -(b/a)+2*b;
}
编译器会把涉及complex数值的运算符转换成相应的函数调用。 例如:c!=b
对应operator!=(c,b)
,1/a
对应operator/(complex{1},a)
。
用户定义的运算符(“重载运算符(overloaded operator)”) 应该谨慎并且遵循约定俗成的规则使用。
❷
我们自定义的Vector有个致命的缺陷:它用new给元素分配空间,却从未释放它们。这就不太妙了,因为尽管C++定义了垃圾回收接口,但却不能确保有个垃圾回收器把未使用的内存供新对象使用。某些情况下你无法使用垃圾回收器,更常见的情形是:出于逻辑和性能原因,你倾向于更精细地控制销毁行为。我们需要一个机制确保把构造函数分配的内存释放掉;这个机制就是析构函数(destructor):
class Vector {
public:
Vector(int s) :elem{new double[s]}, sz{s} { // 构造函数:申请资源
for (int i=0; i!=s; ++i) // 初始化元素
elem[i]=0;
}
~Vector() { delete[] elem; } // 析构函数:释放资源
double& operator[](int i);
int size() const;
private:
double* elem; // elem指向一个数组,该数组承载sz个double
int sz;
};
析构函数的名称是取补运算符~
后跟类名;它跟构造函数互补。Vector的构造函数用new运算符在自由存储区 (也叫堆(heap)或动态存储区(dynamic store))里分配了一些内存。析构函数去清理——使用delete[]运算符释放那块内存。普通delete删除单个对象,delete[]删除数组。
这些操作都不会干涉到Vector用户。 用户仅仅创建并使用Vector,就像对内置类型一样。例如:
void fct(int n) {
Vector v(n);
// ... 使用 v ...
{
Vector v2(2*n);
// ... 使用 v 和 v2 ...
}
// ... 使用 v ...
}// v 在此被销毁
像int和char这些内置类型一样,Vector遵循相同的命名、作用域、内存分配、生命期等一系列规则。此处的Vector版本为简化而略掉了错误处理。
构造函数/析构函数 这对组合是很多优雅技术的根基。确切的说,它是C++大多数资源管理技术的根基。考虑如下的Vector图示:
构造函数分配这些元素并初始化Vector响应的成员变量。析构函数释放这些元素。这个数据操控器模型(handle-to-data model)常见于数据管理,管理那些容量在对象生命期内可能变化的数据。这个构造函数申请资源、析构函数释放资源的技术叫做 资源请求即初始化(Resource Acquisition Is Initialization)或者RAII,为我们消灭“裸的new操作”,就是说,避免在常规代码中进行内存分配,将其隐匿于抽象良好的实现中。与之类似,“裸的delete操作”也该竭力避免。避免裸new和裸delete,能大大降低代码出错的几率,也更容易避免资源泄漏。
❸
容器的作用是承载元素,因此很明显需要便利的方法把元素放入容器。 可以创建元素数量适宜的Vector,然后给这些元素赋值,但还有些更优雅的方式。 此处介绍其中颇受青睐的两种:
- 初始化列表构造函数(initializer-list constructor):用一个元素列表进行初始化。
- push_back():在序列的末尾(之后)添加一个新元素。
它们可以这样声明:
class Vector {
public:
Vector(std::initializer_list<double>);// 用一个double列表初始化
// ...
void push_back(double);// 在末尾新增元素,把容量加一
// ...
};
在输入任意数量元素的时候,push_back()很有用,例如:
Vector read(istream& is) {
Vector v;
for (double d; is>>d; ) // read floating-point values into d
v.push_back(d); // add d to v return v;
}
Vector v = read(cin);// 此处未对Vector的元素进行复制
用于定义初始化列表构造函数的std::initializer_list是个标准库中的类型, 编译器对它有所了解:当我们用{}列表,比如{1,2,3,4}的时候, 编译器会为程序创建一个initializer_list对象。 因此,可以这样写:
Vector v1 = {1,2,3,4,5}; // v1有5个元素
Vector v2 = {1.23, 3.45, 6.7, 8}; // v2有4个元素
Vector的初始化列表构造函数可能长这样:
Vector::Vector(std::initializer_list<double> lst) // 用列表初始化
:elem{new double[lst.size()]}, sz{static_cast<int>(lst.size())}
{
copy(lst.begin(),lst.end(),elem); // 从lst复制到elem(§12.6)
}
很遗憾,标准库为容量和下标选择了unsigned整数, 所以我需要用丑陋的static_cast把初始化列表的容量显式转换成int。
static_cast
不对它转换的值进行检查;它相信程序员能运用得当。 可它也总有走眼的时候,所以如果吃不准,检查一下值。 应该尽可能避免显式类型转换 (通常也叫强制类型转换(cast),用来提醒你它可能会把东西弄坏)。 尽量把不带检查的类型转换限制在系统底层。它们极易出错。
还有两种类型转换分别是:reinterpret_cast
,它简单地把对象按一连串字节对待;const_cast
用于“转掉const限制”。对类型系统的审慎运用以及设计良好的库,都有助于在顶层软件中消除不带检查的类型转换。
❹
complex和Vector这些被称为实体类型,因为表征数据是它们定义的一部分。 因此,它们与内置类型相仿。相反,抽象类型是把用户和实现细节隔绝开的类型。为此,要把接口和表征数据解耦,并且要摒弃纯局部变量。既然对抽象类的表征数据(甚至其容量)一无所知,就只能把它的对象分配在自由存储区,并通过引用或指针访问它们。
首先,我们定义Container类的接口,它将被设计成Vector更抽象的版本:
class Container {
public:
virtual double& operator[](int) = 0; // 纯虚函数
virtual int size() const = 0; // const 成员函数
virtual ~Container() {} // 析构函数
};
该类是一个用于描述后续容器的纯接口。virtual
这个词的意思是“后续可能在从此类派生的类中被重新定义”。用virtual声明的函数自然而然的被称为虚函数。从Container派生的类要为Container接口提供实现。古怪的=0
语法意思是:此函数是纯虚的;就是说,某些继承自Container的类必须定义该函数。因此,根本无法直接为Container类型定义对象。例如:
Container c; // 报错:抽象类没有自己的对象
Container* p = new Vector_container(10); // OK:Container作为接口使用
Container只能用做作接口,服务于那些给operator和size()函数提供了实现的类。带有虚函数的类被称为抽象类。
Container可以这样用:
void use(Container& c) {
const int sz = c.size();
for (int i=0; i!=sz; ++i)
cout << c[i] << '\n';
}
请注意use()在使用Container接口时对其实现细节一无所知。它用到size()和[ ],却完全不知道为它们提供实现的类型是什么。为诸多其它类定义接口的类通常被称为多态类型。
正如常见的抽象类,Container也没有构造函数。毕竟它不需要初始化数据。另一方面,Container有一个析构函数,并且还是virtual
的,以便让Container的派生类去实现它。这对于抽象类也是常见的,因为它们往往通过引用或指针进行操作,而借助指针销毁Container对象的人根本不了解具体用到了哪些资源。
抽象类Container仅仅定义接口,没有实现。想让它发挥作用,就需要弄一个容器去实现它接口规定的那些函数。为此,可以使用一个实体类Vector:
class Vector_container : public Container { // Vector_container 实现了 Container
public:
Vector_container(int s) : v(s) { } // s个元素的Vector
~Vector_container() {}
double& operator[](int i) override { return v[i]; }
int size() const override { return v.size(); }
private:
Vector v;
};
:public
可以读作“派生自”或者“是……的子类型”。 我们说Vector_container派生自Container, 并且Container是Vector_container的基类。 还有术语把Vector_container和Container分别称为 子类和亲类。 我们说派生类继承了其基类的成员,所以这种基类和派生类的关系通常被称为继承。
我们这里的operator和size()覆盖(override)了基类Container中对应的成员。这里明确使用override
表达了这个意向。这里的override可以省略,但是明确使用它,可以让编译器查错,比如函数名拼写错误,或者virtual函数和被其覆盖的函数之间的细微类型差异等等。在较大的类体系中明确使用override格外有用,否则就难以搞清楚覆盖关系。
这里的析构函数(~Vector_container()) 覆盖了基类的析构函数(~ Container())。请注意,其成员的析构函数(~Vector) 被该类的析构函数(~Vector_container())隐式调用了。
对于use(Container&)这类函数,使用Container时不必了解其实现细节,其它函数要创建具体对象供它操作的。例如:
void g() {
Vector_container vc(10);// 十个元素的Vector
// ... 填充 vc ...
use(vc);
}
由于use()只了解Container接口而非Vector_container,它就可以对Container的其它实现同样有效。例如:
class List_container : public Container { // List_container implements Container
public:
List_container() { } // empty List
List_container(initializer_list<double> il) : ld{il} { }
~List_container() {}
double& operator[](int i) override;
int size() const override { return ld.size(); }
private:
std::list<double> ld; // double类型的(标准库)列表 (§11.3)
};
double& List_container::operator[](int i) {
for (auto& x : ld) {
if (i==0)
return x;
--i;
}
throw out_of_range{"List container"};
}
❺
void use(Container& c) {
const int sz = c.size();
for (int i=0; i!=sz; ++i)
cout << c[i] << '\n';
}
void g() {
Vector_container vc(10);
use(vc);
}
void h() {
List_container lc = { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
use(lc);
}
use()中的c[i]调用是怎么解析到对应的operator呢?当h()调用use()时,List_container的operator必须被调用。g()调用use()时,Vector_container的operator必须被调用。
想要实现这种解析,Container对象必须包含某种信息,以便在运行时找到正确的待调用函数。常见的实现技术是:编译器把虚函数的名称转换成一个指向函数指针表的索引。这个表格通常被称为虚函数表(virtual function table),或者简称vtbl。每个带有虚函数的类都有自己的vtbl以确认其虚函数。这可以图示如下:
vtbl中的函数能够正确地使用其对象,即便调用者对该对象的容量以及数据布局全都一无所知。调用者的实现仅需要知道某个Container中指向vtbl指针的位置以及每个待用虚函数的索引。虚函数调用机制几乎能做到与“常规函数调用”机制同样高效(性能差别不到25%)。 其空间消耗是带有虚函数的类的每个对象一个指针,再加上每个类一个vtbl。
基类的析构函数往往需要定义为虚函数,如果一个基类指针指向一个 派生类对象,当调用delete操作符时,只会调用基类的析构函数而不会调用派生类的析构函数。如:
class ClxBase {// 基类
public:
ClxBase() {}
virtual ~ClxBase() {
cout << "Output from the destructor of class ClxBase!\n";
}
virtual void DoSomething() {
cout << "Do something in class ClxBase!\n";
}
}
class ClxDerived : public ClxBase {// 派生类
public:
ClxDerived() {}
~ClxDerived() {
cout << "Output from the destructor of class ClxDerived!\n";
}
void DoSomething() {
cout << "Do something in class ClxDerived!\n";
}
}
int main() {
ClxBase *p = new ClxDerived;// 有多态
// 当基类是虚函数时,基类的指针将表现为派生类的行为(非虚函数将表现为基类行为)
p->DoSomething();
delete p;
return 0;
}
// 运行结果
Do something in class ClxDerived!
Output from the destructor of class ClxDerived!
Output from the destructor of class ClxBase!
❻
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() {} // 析构函数
// ...
};
根据这个定义,可以写一个通用的函数操纵一个vector,其中的元素是指向图形的指针:
void rotate_all(vector<Shape*>& v, int angle) {// 把v的元素旋转给定角度
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相比还没有什么亮点, 请接着往下看:
class Smiley : public Circle { // 用圆圈作为笑脸的基类
public:
Smiley(Point p, int rad) : Circle{p,rad}, 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) {
eyes.push_back(s);
}
void set_mouth(Shape* s);
virtual void wink(int i); // 让第i只眼做“飞眼”
// ...
private:
vector<Shape*> eyes; // 一般是两只眼睛
Shape* mouth;
};
现在,可以利用Smiley的基类和成员函数draw()的调用来定义Smiley::draw()了:
void Smiley::draw() const {
Circle::draw();
for (auto p : eyes)
p->draw();
mouth->draw();
}
请注意,Smiley把它的眼睛保存在一个标准库的vector里, 并且会在析构函数中把它们销毁。 Shape的析构函数是virtual
的,而Smiley又覆盖了它。 虚析构函数对于抽象类来说是必须的,因为操控派生类的对象通常是借助抽象基类提供的接口进行的。具体地说,它可能是通过其基类的指针被销毁的。然后,虚函数调用机制确保正确析构函数被调用。该析构函数则会隐式调用其基类和成员变量的析构函数。
在这个简化过的例子中,把眼睛和嘴巴准确放置到代表脸的圆圈中,是程序员的的任务。
在以派生方式定义一个新类时,我们可以添加新的 成员变量 或/和 运算。 这带来了极佳的灵活性,同时又给逻辑混乱和不良设计提供了温床。
❼
类的层次结构有两个益处:
- 接口继承(interface inheritance):派生类对象可以用在任何基类对象胜任的位置。就是说,基类充当了派生类的接口。Container和Shape这两个类就是例子。这种类通常是抽象类。
- 实现继承(implementation inheritance):基类的函数和数据直接就是派生类实现的一部分。 Smiley对Circle的构造函数、Circle::draw()的调用就是这方面的例子。这种基类通常具有成员变量和构造函数。
enum class Kind { circle, triangle, smiley };
Shape* read_shape(istream& is) { // 从输入流is读取图形描述
// ... 从 is 读取图形概要信息,找到其类型(Kind) k ...
switch (k) {
case Kind::circle:
// 把圆圈的数据 {Point,int} 读取到p和r
return new Circle{p,r};
case Kind::triangle:
// 把三角形的数据 {Point,Point,Point} 读取到p1、p2、和p3
return new Triangle{p1,p2,p3};
case Kind::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用于引用类型。 如果该对象不是期望的类型,dynamic_cast抛出一个bad_cast异常:
Shape* ps {read_shape(cin)};
Smiley& r {dynamic_cast<Smiley&>(*ps)}; // 某处可以捕捉到 std::bad_cast
当“转换目标不属于所需的类”需要报错时,就把dynamic_cast
用于引用类型;如果“转换目标不属于所需的类”可接受,就把dynamic_cast
用于指针类型。
❽
经验丰富的程序员可能注意到了我有三个纰漏:
- 写Smiley的程序员可能忘记delete指向mouth的指针;
- read_shape()的用户可能忘记delete返回的指针;
- Shape指针容器的所有者可能忘记delete它们指向的对象。
从这个意义上讲,指向自由存储区中对象的指针是危险的: “直白老旧的指针(plain old pointer)”不该用于表示所有权。例如:
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
而非“裸指针”:
class Smiley : public Circle {
// ...
private:
vector<unique_ptr<Shape>> eyes; // 一般是两只眼睛
unique_ptr<Shape> mouth;
};
这是一个示例,展示简洁、通用、高效的资源管理技术。
这个修改有个良性的副作用:我们不需要再为Smiley定义析构函数了。 编译器会隐式生成一个,以便将vector中的unique_ptr
销毁。 使用unique_ptr
的代码跟用裸指针的代码在效率方面完全一致。
重新审视read_shape()的使用:
unique_ptr<Shape> read_shape(istream& is) {// 从输入流is读取图形描述
// ... 从 is 读取图形概要信息,找到其类型(Kind) k ...
switch (k) {
case Kind::circle:
// 把圆圈的数据 {Point,int} 读取到p和r
return unique_ptr<Shape>{new Circle{p,r}};
// ...
}
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)
} // 所有 Shape 都隐式销毁了
现在每个对象都被一个unique_ptr持有,当不再需要这个unique_ptr,也就是它离开作用域的时候,就会销毁持有的对象。
想让unique_ptr版本的user()正常运作, 就需要能够接受vector<unique_ptr<Shape>>版本的 draw_all()和rotate_all()。
忠告
- [1] 用代码直接表达意图;
- [2] 实体类型是最简单的类。情况许可的时候, 请优先用实体类,而非更复杂的类或者普通的数据结构;
- [3] 用实体类去表示简单的概念;
- [4] 对于性能要求严苛的组件,优先用实体类,而不是选择类层次;
- [5] 定义构造函数去处理对象的初始化;
- [6] 只在一个函数需要直接访问类的表征数据时,把它定义为成员函数;
- [7] 自定义运算符的主要用途应该是模拟传统运算;
- [8] 为对称运算符使用非成员函数;
- [9] 把不修改对象状态的成员函数定义为
const
; - [10] 如果构造函数申请了资源,这个类就需要虚构函数去释放这个资源;
- [11] 避免使用“裸的”
new
和delete
操作; - [12] 利用资源操控器和 RAII 去管理资源;
- [13] 如果类是容器,请给它定义一个初始化列表构造函数;
- [14] 需要接口和实现完全分离的时候,请用抽象类作为接口;
- [15] 请通过指针和引用访问多态对象;
- [16] 抽象类通常不需要构造函数;
- [17] 对于与生俱来就具有层次结构的概念,请使用类层次结构表示它们;
- [18] 带有虚函数的类,应该定义虚析构函数;
- [19] 在较大的类层次中,显式用
override
进行覆盖; - [20] 设计类层次的时候,要分清实现继承和接口继承;
- [21] 在不可避免要在类层次中进行辨别的时候,使用
dynamic_cast
; - [22] 当“转换目标不属于所需的类”需要报错时,就把
dynamic_cast
用于引用类型; - [23] 如果“转换目标不属于所需的类”可接受,就把
dynamic_cast
用于指针类型; - [24] 对于通过
new
创建的对象,用unique_ptr
和shared_ptr
避免忘记delete
。
网友评论