0%

简化

首先可以简化一些不必要的常数项,聊胜于无的优化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
double hit_sphere(const point3& center, double radius, const ray& r) {
vec3 oc = r.origin() - center;
auto a = r.direction().length_squared();
//默认直接把2除掉
auto half_b = dot(oc, r.direction());
auto c = oc.length_squared() - radius*radius;
//这个discriminant是之前的四分之一。
auto discriminant = half_b*half_b - a*c;

if (discriminant < 0) {
return -1.0;
} else {
//分子分母都是简化前的一半。
return (-half_b - sqrt(discriminant) ) / a;
}
}

抽象

现在有了球,或许需要一个球类去描述它,但是未来可能还会有更多的其他种类的物体,最容易想到的方法是首先编写一个物体基类。

这个抽象基类需要抽象出所有物体的共性:

一个物体一定可以被光线感知到,它可以被光线照到,并且光线可以通过这次碰撞获取一些关于物体的信息。即,它需要一个抽象的碰撞函数,就和我们之前在main所在文件中写的球简易碰撞函数那样。这个函数的返回值设计成bool,即返回是否碰撞到。至于其他的碰撞信息,我们可以通过一个结构体返回,其中包括:

  1. t值。除了判断t值可以确定是否碰撞到物体,t值在之后会有更大的作用,可以说,t值是光线和物体碰撞中最重要的信息之一,应当让外界知道这个值以方便其他的运算。
  2. 法线。前面的元气弹就是使用了法线实现的效果,但是这还不是全部,法线的作用还有很多,比如镜面反射,比如通过法线去计算反射方向。总之,我们需要返回法线信息。
  3. 碰撞点坐标。很多情况下都需要用到p点坐标,我们不希望每次用到的时候都去计算一遍,而且我们理应把这些计算放在更底层的地方,而且我们也不应该把这种底层计算放在用户看得到的地方。

创建hittable.h文件,写入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#ifndef HITTABLE_H
#define HITTABLE_H

#include "ray.h"

//我们把需要返回的数据封装成结构体,按照上面分析的,暂时我们需要这三个东西。
struct hit_record {
point3 p;
vec3 normal;
double t;
};

//可碰撞物体类。
class hittable {
public:
//需要这样一个纯虚函数,所有继承自这个类的子类(如球),都需要实现这个函数。
virtual bool hit(const ray& r, double t_min, double t_max, hit_record& rec) const = 0;
};

#endif

在类中多了两个参数,分别为允许t的最小、最大值,有了它我们就可以剔除t小于0的情况(即物体在相机后后面的情况),同时它还有更多的用处。

实现

基于这个物体类来实现球类的代码,创建sphere.h文件,写入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#ifndef SPHERE_H
#define SPHERE_H

#include "hittable.h"
#include "vec3.h"

class sphere : public hittable {
public:

//构造
sphere() {}
sphere(point3 cen, double r) : center(cen), radius(r) {};

//声明要override纯虚函数
virtual bool hit(
const ray& r, double t_min, double t_max, hit_record& rec) const override;

public:
point3 center;
double radius;
};

bool sphere::hit(const ray& r, double t_min, double t_max, hit_record& rec) const {
//老代码
vec3 oc = r.origin() - center;
auto a = r.direction().length_squared();
auto half_b = dot(oc, r.direction());
auto c = oc.length_squared() - radius*radius;

auto discriminant = half_b*half_b - a*c;

//发现方程没有根,直接退出。
if (discriminant < 0) return false;

auto sqrtd = sqrt(discriminant);

//因为我们引入了tmax和tmin,所以这里还需要格外的运算。

//检查较小的根
auto root = (-half_b - sqrtd) / a;
if (root < t_min || t_max < root) {
//检查较大的根
root = (-half_b + sqrtd) / a;
if (root < t_min || t_max < root)
return false;
}

//封入结构体
rec.t = root;
rec.p = r.at(rec.t);
//不用麻烦使用unit_vector函数,直接利用已经存好的半径进行单位化,实现加速。
rec.normal = (rec.p - center) / radius;

return true;
}

#endif

第40行开始的if语句非常的巧妙,因为只有在较小的根不满足img的时候,才会检查较大的根,换句话说,如果较小的根在我们的许可范围内,我们会直接采纳他。一般来说,较小的根是光线和物体的首次交汇点,所以优先返回较小根是非常合理的。

那么什么场景才能用到较大的根呢?非常明显,是相机在物体的里面的时候,但这种情况也会导致之前计算的法线方向相反,接下来就来解决它。

法线修正

通过什么方式判断法线是否反了呢?可以注意到的是,法线与入射光线的夹角一定小于90度的,所以可以使用向量的点乘来进行判断,并且这部分判断可以直接放在hit_record内,同时在碰撞函数中调用它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct hit_record {
point3 p;
vec3 normal;
double t;

//光线打到的是不是物体的外面?
bool front_face;

//一个结构体内的函数,他判断法线的里外,并且在光线打到物体内面时取反法线。
inline void set_face_normal(const ray& r, const vec3& outward_normal) {
front_face = dot(r.direction(), outward_normal) < 0;
normal = front_face ? outward_normal :-outward_normal;
}
};

sphere.h中修改hit函数:

1
2
3
4
5
6
7
8
9
10
11
bool sphere::hit(const ray& r, double t_min, double t_max, hit_record& rec) const {
...

rec.t = root;
rec.p = r.at(rec.t);
vec3 outward_normal = (rec.p - center) / radius;

//用这一个函数设置hit_record中的法线和front_face
rec.set_face_normal(r, outward_normal);
return true;
}

参考文献

https://raytracing.github.io/books/RayTracingInOneWeekend.html

参考自《RayTracingInOneWeekend》第6.2节到第6.4节。

基础概念

接下来总要让一个对象让光线碰撞,这里选择使用最简单的物体:球

一个非常标准的球的数学表达式,这个公式表示以原点为球心,半径是R的球。所有坐标是(x,y,z)的点满足上面的表达式都在球上。接下来是点在球内和求外的公式:

假定球心为$(C_x,C_y,C_z)$

但是这个公式没有办法在我们的项目中使用,我们的底层使用的vec3类,更希望看到向量而不是标量表示,我们需要简单的改变一下这个公式。现在假设球形所在的坐标用C这个vec3类常量表示,即$C=(C_x,C_y,C_z)$。同样的令$P=(x,y,z)$有:

上述式子的左侧是一个向量模的平方,右侧是一个距离公式。它们都表示P点和C点之间的距离的平方。所以有:

所有满足这样要求的P——它到C点的距离为r,这样的点一定在以C为球心,r为半径的球上。

现在引入光线,如果光线曾在某一个时刻打在球上,则表示有一个t,使得$P(t)=A+tb$正好传播到了球的位置。带入它之后:

展开:

把左侧括号乘开,这里我们把$(A-C)$看作一个整体,再把右侧的移$r^2$到左侧。

变成了一个t的一元二次方程。(b表示光线方向,A是光源位置,C是球心,全是常量)。

碰撞函数

在.cpp文件中main函数外写入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//简易的球的碰撞检测函数,吃球心,半径和一根光线,吐出光线是否击中球的bool值。
bool hit_sphere(const point3& center, double radius, const ray& r) {
// 这个oc就是上面函数里的(A-C).
vec3 oc = r.origin() - center;
// 对应上面公式里的b的平方。即平方项的系数。
auto a = dot(r.direction(), r.direction());
// 对应上面公式里的2*(A-C)点乘b,即一次项的系数
auto b = 2.0 * dot(oc, r.direction());
// (A-C)点乘(A-C)减去r的平方,即常数项。
auto c = dot(oc, oc) - radius*radius;
//高中最爱的Δ,b的平方减4ac。
auto discriminant = b*b - 4*a*c;
//返回方程有没有根,即光线有没有碰撞到球体。
return (discriminant > 0);
}

color ray_color(const ray& r) {
// 如果我们击中了这个球心在(0,0,1)且半径是0.5的球,就直接返回颜色为红色。
if (hit_sphere(point3(0,0,-1), 0.5, r))
return color(1, 0, 0);
// 如果没有击中的话你就继续画蓝天吧。
vec3 unit_direction = unit_vector(r.direction());
auto t = 0.5*(unit_direction.y() + 1.0);
return (1.0-t)*color(1.0, 1.0, 1.0) + t*color(0.5, 0.7, 1.0);
}

最终得到了一个不那么美丽的太阳!

image-20220306223618931

可视化法线

如果我们得知光线和球的碰撞点为P,我们需要得到这一点的法线,它应该是从球心发射,穿过这一点指向球的外侧,所以是img

image-20220306223737173

这里需要考虑两个问题:

  • 它应该是单位向量吗?是的,它应该是,单位化法线可能会在某些方面为我们的渲染提供便利,但是不强制,并不要求法线一定是单位向量,如果必须是单位化的地方我们进行单位化即可。为了省去这一步骤这里选择永远单位化它。
  • 之前我们给的hit_sphere函数的框架已经不足以满足我们获取法线的需要了,因为这里不仅仅需要了解球和光线是否碰撞,我们还得知道光线和球的第一个焦点的位置,因为只要不是极端的相切的情况,我们总能找到两个焦点,所以我们需要t较小的那个焦点。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//它不再返回bool,而是返回一个浮点数,表示光线第一次打在球上的时候的时间t。
double hit_sphere(const point3& center, double radius, const ray& r) {
vec3 oc = r.origin() - center;
auto a = dot(r.direction(), r.direction());
auto b = 2.0 * dot(oc, r.direction());
auto c = dot(oc, oc) - radius*radius;
auto discriminant = b*b - 4*a*c;
if (discriminant < 0) {
// Δ小于0,别看了,光线没打到球,直接返回一个负值。
return -1.0;
} else {
// 求根公式,我们返回了较小的那个根。
return (-b - sqrt(discriminant) ) / (2.0*a);
}
}

//通过得到的法线返回颜色
color ray_color(const ray& r) {
auto t = hit_sphere(point3(0,0,-1), 0.5, r);

//如果光线击中了球。
if (t > 0.0) {
//拿到法线,嘿嘿,我们之前写过很久的at函数终于派上用场了。我们这次单位化它。
vec3 N = unit_vector(r.at(t) - vec3(0,0,-1));
//返回法线可视化之后的颜色值,注意我们做了一个[-1,1]到[0,1]的映射。
return 0.5*color(N.x()+1, N.y()+1, N.z()+1);
}

//如果没打中?继续画蓝天吧。
vec3 unit_direction = unit_vector(r.direction());
t = 0.5*(unit_direction.y() + 1.0);
return (1.0-t)*color(1.0, 1.0, 1.0) + t*color(0.5, 0.7, 1.0);
}

这里将hit_shpere函数的返回值由bool改为第一次碰撞的时间t,并在取色函数中通过时间t获取碰撞点,并计算出法线,最后将法线映射为颜色,最终我们可以看到一个元气弹!

image-20220307085406164

拓展问题

  1. 在红太阳阶段的代码中如果将太阳放在相机后面会发生什么呢?
1
2
if (hit_sphere(point3(0,0,1), 0.5, r))
return color(1, 0, 0);

image-20220306223618931

可以看到与放在相机前没有任何变化,我认为是主要是因为这段代码虽然判断了碰撞函数是否有解,但是忽略了t小于0 的情况,以至于放在相机前后没有区别,用第二段代码就没有任何问题。

  1. 元气弹的颜色遵循什么规律呢?

按照代码逻辑应该是碰撞法线x,y,z分量分别对应r,g,b的值。那么应该是向右越来越红,向上越来越绿,由中心向外越来越蓝。

  1. 使用元气弹版本的代码,更改hit_sphere函数,这次返回较大的那个根,生成图片另外保存。
1
2
// 求根公式,这次返回较大的那个根。
return (-b + sqrt(discriminant)) / (2.0 * a);

image-20220307092334496

参考文献

https://raytracing.github.io/books/RayTracingInOneWeekend.html

参考自《RayTracingInOneWeekend》第5节和第6.1节。

光线

如何将抽象的光线具体的表现出来并不困难,在初中物理的时候我们就使用一个点加上一个射线的方式来表示光线,于是我们可以用这样的公式描述它:P(t)=A+t*b

image.png

A表示光线的原点(光源位置),b为一个单位向量表示一个方向,t则表示单位时间,通过给t取不同的值,我们可以得到沿路上所有的点的三维坐标,当然,这个值一般来说不会为负数,我们新建ray.h,输入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#ifndef RAY_H
#define RAY_H

#include "vec3.h"

class ray {
public:
//空构造。
ray() {}
//带参构造,显然我们需要一个原点和一个方向。
ray(const point3& origin, const vec3& direction)
: orig(origin), dir(direction)
{}

//通过这个函数拿取原点值。
point3 origin() const { return orig; }
//通过这个函数拿取方向值。
vec3 direction() const { return dir; }

//这个函数就对应了上方数学公式中的P(t),通过传入一个时间t,能得到当前光线传播到的坐标位置。
point3 at(double t) const {
return orig + t * dir;
}

public:
point3 orig;
vec3 dir;
};

#endif

相机

我们把“相机”固定在一个位置,并且固定一下它观察的方向。

接下来需要解决两个问题:

  1. 光线从哪里射出呢?相机位置。按照常理来说,太阳发出的光线从物体上弹射了多次,最终会被摄像机(或者人眼)捕捉。相机位置应该是光线的终点才对啊,怎么会是起点呢?原因是我们需要逆光路取色,这是路径追踪的经典光线模型,重点是我们的光线和现实中的光线是反过来的,如果正向光路进行光线追踪会极其困难,几乎寸步难行。
  2. 光线朝哪个方向射出呢?这就要引出一个“虚拟视口”的概念。它就像一个相框,摆在相机的前面,相机发射的密集光铺满相框,就得到图像,具体方法为:按照行优先的顺序,从左上角开始,一排一排的射出光线,射出光线的数目就是像素的数目,换句话说,我们对每一个像素都会射出一根光线

将相机位置定位(0,0,0)这个场景是这样的:

image.png

假设图中的虚拟视口上有800个像素,每个像素长宽都是0.1,最终得到一张4020的图片,我们只需要瞄准*每一个像素的中心

按照行优先左上角开始,发生的第一根光线应该是从(0,0,0)射向(-2+0.05,1-0.05,-1)方向。不必要求这个方向向量是一个单位向量,保持方向向量是单位向量并不能给项目提供更多便利。如此类推:第二个是(-2+0.15,1-0.05,-1)方向、第四十个是(-2+3.95,1-0.05,-1)方向、第八百个是(-2+3.95,1-1.95,-1)方向。

改写main函数,并且为其所在的文件添加一个全局函数,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include "color.h"
#include "ray.h"
#include "vec3.h"

#include <iostream>

//这是一个简单的决定光线所带回颜色的函数。
color ray_color(const ray& r) {
//先把这个光线的方向向量单位化。
vec3 unit_direction = unit_vector(r.direction());

//再根据这个单位化向量的y分量给他设定颜色,得保证t在[0,1]之间。
auto t = 0.5*(unit_direction.y() + 1.0);

// 插值函数,t靠近0它就越靠近白色,越靠近1它就越靠近一种蓝色。
return (1.0-t)*color(1.0, 1.0, 1.0) + t*color(0.5, 0.7, 1.0);
}

int main() {

//图片数据,这次我们换一种角度去定义图片的长宽,我们定义一个长宽比,再把它的宽度定义出来。
//长度就可以通过简单的计算得到。
const auto aspect_ratio = 16.0 / 9.0;
const int image_width = 400;
const int image_height = static_cast<int>(image_width / aspect_ratio);

//虚拟视口数据,我们保持它的高度(宽度)为2,长度同样通过长宽比得到。
auto viewport_height = 2.0;
//保持视口和实际图片的长宽比一致。
auto viewport_width = aspect_ratio * viewport_height;
//这是视口离相机的距离,保持为1就好,我们暂时把它写死。
auto focal_length = 1.0;

//相机位置
auto origin = point3(0, 0, 0);
//相机水平方向,即X轴正方向。
auto horizontal = vec3(viewport_width, 0, 0);
//相机头顶方向,即Y轴正方向。
auto vertical = vec3(0, viewport_height, 0);
//这个是虚拟视口左下角所在位置的坐标,在上面那个图片例子里,它就是(-2,-1,-1)。
//注意因为长宽比不是2/1而是16/9,所以本例子里这个值和图片中的值不同。
auto lower_left_corner = origin - horizontal/2 - vertical/2 - vec3(0, 0, focal_length);

// 渲染循环(render loop)

std::cout << "P3\n" << image_width << " " << image_height << "\n255\n";

for (int j = image_height-1; j >= 0; --j) {
std::cerr << "\rScanlines remaining: " << j << ' ' << std::flush;
for (int i = 0; i < image_width; ++i) {
//这个uv就是当前像素位置的横纵坐标偏移。
auto u = double(i) / (image_width-1);
auto v = double(j) / (image_height-1);
//创造射线。
ray r(origin, lower_left_corner + u*horizontal + v*vertical - origin);
//通过全局函数取到本像素颜色。
color pixel_color = ray_color(r);
//写颜色到输出流。
write_color(std::cout, pixel_color);
}
}

std::cerr << "\nDone.\n";
}

image.png

通过光线在y轴方向上的分量得到一种类似于蓝天的效果,这个图片也将在后来成为我们的背景。

拓展

1:循环渲染射出光线瞄准的是哪里呢?

仔细计算可以发现,是每个像素的左上角,并不是中心点,其实左上角又或是中心点这一点的颜色都不能代表整个像素的颜色,这个问题后续将会解决。

2:更改ray_color函数可以制作一些更加炫酷的背景:

1
2
3
4
5
color ray_color(const ray& r) {
auto vec = r.direction() * 1 - r.origin();
auto absVec = vec3(std::abs(vec.x()), std::abs(vec.y()), std::abs(vec.z()));
return unit_vector(absVec);
}

image.png

参考文献

https://raytracing.github.io/books/RayTracingInOneWeekend.html

参考自《RayTracingInOneWeekend》第4节

既然是光追,肯定离不开点和方向于颜色,在大多数工程中,颜色和方向是四维的向量,但是对于这个工程,三位向量可以满足需求,我们定义一个类来表示它们:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
//防止被重复引用,用ifndef去包裹所有代码。
#ifndef VEC3_H
#define VEC3_H

//需要一些cmath类里面的数学函数
#include <cmath>

#include <iostream>

//精确引用,只需要开方函数就只引入最小的命名空间
using std::sqrt;

class vec3 {
public:
// 空构造,默认构造一个(0,0,0)向量
vec3() : e{0,0,0} {}

// 传入三个参数的构造。
vec3(double e0, double e1, double e2) : e{e0, e1, e2} {}

//定义x,y,z分量。这样就可以用 向量.x() 直接拿取x分量。
double x() const { return e[0]; }
double y() const { return e[1]; }
double z() const { return e[2]; }

//下面进行运算符重载。

//单目 '-' 运算符 ,会对三维向量的每一维取反。
vec3 operator-() const { return vec3(-e[0], -e[1], -e[2]); }

// '[]' 运算符的const版本,返回右值。
double operator[](int i) const { return e[i]; }

// '[]' 运算符普通版本,返回左值
double& operator[](int i) { return e[i]; }

// '+='运算符,const保护参数不被修改,返回引用允许操作符嵌套。
vec3& operator+=(const vec3 &v) {
e[0] += v.e[0];
e[1] += v.e[1];
e[2] += v.e[2];
return *this;
}

// '*='运算符
vec3& operator*=(const double t) {
e[0] *= t;
e[1] *= t;
e[2] *= t;
return *this;
}

// '/='运算符,直接使用*=去定义'/='。
vec3& operator/=(const double t) {
return *this *= 1/t;
}


//模相关。

// 模的平方。
double length_squared() const {
return e[0]*e[0] + e[1]*e[1] + e[2]*e[2];
}

// 模
double length() const {
return sqrt(length_squared());
}

public:
// 向量中的数组也直接暴露出去了,这里是float也没问题,看你的喜好。
double e[3];
};

// 给vec3类多起几个名字。
using point3 = vec3; // 3D point,在指定三维空间中的点的时候使用这种别名。
using color = vec3; // RGB color,在指定颜色的时候使用这种别名。

#endif

类外补充

接下来是一些加减乘除等操作,其中的有些操作并不可以写在类内,比如double类型*vec3类型,所以统一写在类外:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// vec3 类外函数

//重载输出流符号"<<"
inline std::ostream& operator<<(std::ostream &out, const vec3 &v) {
//当用户使用 cout << vec3的时候,输出vec3中的各个分量值。
return out << v.e[0] << ' ' << v.e[1] << ' ' << v.e[2];
}
//重载+运算符,它的返回值不是引用,这很合理,我们永远不会把 “a + b”这样的东西放在赋值符号左侧
inline vec3 operator+(const vec3 &u, const vec3 &v) {
return vec3(u.e[0] + v.e[0], u.e[1] + v.e[1], u.e[2] + v.e[2]);
}
//重载-运算符
inline vec3 operator-(const vec3 &u, const vec3 &v) {
return vec3(u.e[0] - v.e[0], u.e[1] - v.e[1], u.e[2] - v.e[2]);
}
//重载*运算符,向量*向量
inline vec3 operator*(const vec3 &u, const vec3 &v) {
return vec3(u.e[0] * v.e[0], u.e[1] * v.e[1], u.e[2] * v.e[2]);
}
//重载*运算符,标量*向量
inline vec3 operator*(double t, const vec3 &v) {
return vec3(t*v.e[0], t*v.e[1], t*v.e[2]);
}
//还是*运算符,但这次参数中标量和向量的顺序是反过来的。
inline vec3 operator*(const vec3 &v, double t) {
return t * v;
}
//用*去定义/
inline vec3 operator/(vec3 v, double t) {
return (1/t) * v;
}
//向量点乘,计算方法严格遵循数学定义。
inline double dot(const vec3 &u, const vec3 &v) {
return u.e[0] * v.e[0]
+ u.e[1] * v.e[1]
+ u.e[2] * v.e[2];
}
//向量叉乘,计算方法严格遵循数学定义。
inline vec3 cross(const vec3 &u, const vec3 &v) {
return vec3(u.e[1] * v.e[2] - u.e[2] * v.e[1],
u.e[2] * v.e[0] - u.e[0] * v.e[2],
u.e[0] * v.e[1] - u.e[1] * v.e[0]);
}

// 单位化这个向量,就是把它的各个分量除以它的长度,正好,我们可以用上面刚刚写完的/运算符去定义它。
inline vec3 unit_vector(vec3 v) {
return v / v.length();
}

抽离与简化

在第一章时,有三行将0-1的通道映射到0-255的代码,这部分代码是固定的,可以统一将它们抽离出去,创建、color.h文件,以后的关于渲染颜色的代码统一放到这里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#ifndef COLOR_H
#define COLOR_H

#include "vec3.h"

#include <iostream>

//一个全局的函数,接受一个输出流参数,和一个color参数
void write_color(std::ostream &out, color pixel_color) {
//把之前的代码两步并作一步,直接转到[0,255]区间然后直接输出出去。
out << static_cast<int>(255.999 * pixel_color.x()) << ' '
<< static_cast<int>(255.999 * pixel_color.y()) << ' '
<< static_cast<int>(255.999 * pixel_color.z()) << '\n';
}

#endif

接下来main函数的代码将相当简洁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include "color.h"
#include "vec3.h"

#include <iostream>

int main() {
const int image_width = 256;
const int image_height = 256;

std::cout << "P3\n" << image_width << ' ' << image_height << "\n255\n";

for (int j = image_height-1; j >= 0; --j) {
std::cerr << "\rScanlines remaining: " << j << ' ' << std::flush;
for (int i = 0; i < image_width; ++i) {

//直接调用vec3类有参构造构造一个对象
color pixel_color(double(i)/(image_width-1), double(j)/(image_height-1), 0.25);

//写颜色!如此的简单!
write_color(std::cout, pixel_color);
}
}

std::cerr << "\nDone.\n";
}

参考文献

https://raytracing.github.io/books/RayTracingInOneWeekend.html

参考自《RayTracingInOneWeekend》第3节

前言:前段时间在学习C++ primer的同时,我还有幸接触到了一本非常有趣的书,它通过我们的vs写出的代码直接模拟光线追踪生成图像。地址:Ray Tracing in One Weekend。它非常的有趣也具有很高的学习价值,所以我想把学习的过程记录下来,也欢迎大家来一起学习并找出其中的错误,废话不多说,直接开始!

图片格式

首先明确我们这里使用的是ppm格式的图片格式,它是通过类似于vector<vector<int>>的方式记录下每个色素的RGB值。

image.png

此外在开头还需要做一些说明:

  • P3#代表颜色使用ASCII码表示
  • 3 2#代表3行两列,它使得我们接下来的数字不需要按照行列摆放工整,而是可以
  • 255#表示使用0-255来表示一个通道的颜色

可以试着通过代码来生成一张这样的图片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>

int main() {

//定义图片的宽度和高度
const int image_width = 256;
const int image_height = 256;

//打印ppm文件的开头
std::cout << "P3\n" << image_width << ' ' << image_height << "\n255\n";

//以左下角为原点,从左上方依次打印像素
for (int j = image_height-1; j >= 0; --j) {
for (int i = 0; i < image_width; ++i) {
//将r和g通道控制在0-1内
auto r = double(i) / (image_width-1);
auto g = double(j) / (image_height-1);
auto b = 0.25;
//之后统一将它们映射到0-255
int ir = static_cast<int>(255.999 * r);
int ig = static_cast<int>(255.999 * g);
int ib = static_cast<int>(255.999 * b);
//依次输出rgb,注意中间空格
std::cout << ir << ' ' << ig << ' ' << ib << '\n';
}
}
}

写完代码之后,我们以release方式运行,之所以不选择debug模式,主要还是因为太耗费时间,在保证代码不出现错误的情况下,使用release可以节省一些时间。

命令行

以上代码只能让信息输出到控制台,而我们希望将其输出到一个文件内,所以我们可以使用命令行实现:

  • 打开命令行输入项目所在硬盘加冒号之后回车,如:D:
  • 接着输入,cd+空格+项目文件下的release文件夹地址后回车,如:cd D:\C++\RayTracing\x64\Release同时还要注意是X86还是X64。
  • 之后输入raycast.exe > image.ppm,raycast.exe为我们的程序,使用>重定向将数据输出到文件名为image1.1的文件中,同时指定文件格式为ppm

最后依次回车之后我们就可以在release文件夹下看到一个image的图片文件,如果打不开,可以下载一个极速看图软件。

image-20220819094425458

一张非常绚丽的图,正如我们代码中的那样,从左到右越来越红,从下到上越来越绿。

加载进度

现在我们并不知道图片能够多久加载完成,当所需要的图片像素点非常多时,这个问题会更加明显,所以我们使用std::cerr来显式加载的进度,它主要用于显示错误消息,且不被缓冲,可以发送到显示器,并且不被重定向。

1
2
3
4
5
6
7
8
9
10
11
12
   for (int j = image_height-1; j >= 0; --j) {

// 提示还有多少行数据没有处理完。
std::cerr << "\rScanlines remaining: " << j << ' ' << std::flush;

for (int i = 0; i < image_width; ++i) {
//.....
}
}

//提示已经完全搞定。
std::cerr << "\nDone.\n";

同时使用“\r”可以把光标强行移回本行开头,这样这次输出的内容就会覆盖掉这一行原本的内容,就好像每次到来的新东西会“冲洗”掉之前输出的东西。

注意这一行的结尾是std::flush,它表示再输出完这一行之后,会强行把内存中缓冲区内的数据打出到错误输出流里(清空缓冲区)。endl、ends和flush的区别

再次使用命令行就可以看到

image.png

参考文献

https://raytracing.github.io/books/RayTracingInOneWeekend.html

参考自《RayTracingInOneWeekend》第2节。

第十六章

定义模板

1
2
3
4
5
6
7
//如果两个值相等,返回0,如果v1小返回-1,如果v2小返回1
int compare(const string& v1, const string& v2) {
if (vl < v2) return -l; if (v2 < v1) return 1; return 0;
}
int compare(const double& v1, const double& v2) {
if (v1 < v2) return -1; if (v2 < v1) return 1; return 0;
}

对于像这样除了类型意外一模一样的函数,我们可以使用模板来适配各种各样的类型。

函数模板

我们可以定义一个通用的函数模板,而不是为每一个类型定义一个新的函数。则compare的模板可能像下面这样:

1
2
3
4
5
6
template <typename T>
int compare(const T& v1, const T& v2) {
if (v1 < v2) return -1;
if (v2 < vl) return 1;
return 0;
}

模板定义以关键字template开始,后跟一个模板参数列表(template parameter list),这是一个逗号分隔的一个或多个模板参数(template parameter)的列表,用小于号(<)和大于号(>)包围起来。

在模板定义中,模板参数列表不能为空。

实例化函数模板

调用一个函数模板时,编译器用函数的实参来推断模板实参。

1
cout <<compare(1,0)<< endl; // T为int

编译会推断出实参int,并绑定到T,这样推断出来的为我们实例化一个特定的函数。

1
2
3
4
5
//实例化出int compare(const int&, const int&)
cout << compare(1,0) << endl; // T为int
//实例化出int compare (const vector<int>&,const vector<int>&)
vector<int> vec1{123}, vec2{4,5,6};
cout << compare(vec1, vec2) <<endl; // T为vector<int>

这里编译器会实例化两个不同版本的compare。其中一个T为int,另一个T为vector,编译器生成的版本成为模板的实例。

模板类型参数

我们的compare函数有一个模板类型参数(type parameter)。一般来说,我们可以将类型参数看作类型说明符,就像内置类型或类类型说明符一样使用。特别是,类型参数可以用来指定返回类型或函数的参数类型,以及在函数体内用于变量声明或类型转换:

1
2
3
4
5
//正确:返回类型和参数类型相同
template <typename T> Tfoo (T* p){
T tmp = *p; // tmp 的类型将是指针p指向的类型/ / ...
return tmp;
}

类型参数前必须使用关键字class或typename:

1
2
3
4
//错误:U之前必须加上class或typename
template <typename T, U> T calc (const T&,const U&) ;
//正确:在模板参数列表中,typename和class没有什么不同
template <typename T, class U> calc (const T&,const U&) ;

因为可以使用非类的类型作为模板实参,所以使用typename更为直观。

非类型模板参数

除了模板参数,我们还可以定义非类型参数,简言之就是一个固定的值,当模板被实例化后,非类型模板参数就被这个值所取代,这个值也必须时常量表达式,例如:

1
2
3
4
template<unsigned N, unsigned M>
int compare(const char (&p1)[N], const char (&p2)[M]){
return strcmp (p1, p2) ;
}

当调用时:

1
compare ( "hi", 'mom")

最后编译器使用字面常量大小代替N和M:

1
int compare(const char (&p1) [3],const char (&p2)[4])

一个非类型参数可以是一个整型,或者是一个指向对象或函数类型的指针或(左值)引用。绑定到非类型整型参数的实参必须是一个常量表达式。绑定到指针或引用非类型参数的实参必须具有静态的生存期。我们不能用一个普通(非static)局部变量或动态对象作为指针或引用非类型模板参数的实参。指针参数也可以用nullptr或一个值为0的常量表达式来实例化。

通常在需要常量表达式的地方需要用到此参数。

inline和constexpr的函数模板

1
2
3
4
//正确:inline说明符跟在模板参数列表之后
template <typename T> inline T min (const T&,const T&);
//错误:inline说明符的位置不正确
inline template <typename T> T min (const T&,const T&);

我们最初的compare函数虽然简单,但它说明了编写泛型代码的两个重要原则:

  • 模板中的函数参数是const的引用。
  • 函数体中的条件判断仅使用<比较运算。

通过const+引用的方式,我们保证了函数可以用于不能拷贝的类型。

此外,我们没必要即使用<又使用>运算符。

1
2
3
4
5
6
7
8
//期望的比较操作
if (v1 < v2) return -l;
if (v1 > v2) return l;
return 0;
//即使用于指针也正确的compare版本;参见14.8.2节(第510页)
template <typename T> int compare (const T &v1,const T &v2)
if ( less<T>()(vl, v2)) return -1;
if ( less<T>() (v2, v1)) return l;return 0;

原始版本存在的问题是,如果用户调用它比较两个指针,且两个指针未指向相同的数组,则代码的行为是未定义的(据查阅资料,less的默认实现用的就是<,所以这其实并未起到让这种比较有一个良好定义的作用—译者注)。

模板程序应该尽量减少对实参类型的要求。

模板编译

编译器遇到模板时,只有当实例化除模板特例时,才会生成代码。

通常,当我们调用一个函数时,编译器只需要掌握函数的声明。类似的,当我们使用一个类类型的对象时,类定义必须是可用的,但成员函数的定义不必已经出现。因此,我们将类定义和函数声明放在头文件中,而普通函数和类的成员函数的定义放在源文件中。

模板则不同:为了生成一个实例化版本,编译器需要掌握函数模板或类模板成员函数的定义。因此,与非模板代码不同,模板的头文件通常既包括声明也包括定义。

关键概念:模板和头文件

模板包含两种名字:

  • 那些不依赖于模板参数的名字
  • 那些依赖于模板参数的名字

当使用模板时,所有不依赖于模板参数的名字都必须是可见的,这是由模板的提供者来保证的。而且,模板的提供者必须保证,当模板被实例化时,模板的定义,包括类模板的成员的定义,也必须是可见的。

用来实例化模板的所有函数、类型以及与类型关联的运算符的声明都必须是可见的,这是由模板的用户来保证的。

通过组织良好的程序结构,恰当使用头文件,这些要求都很容易满足。模板的设计者应该提供一个头文件,包含模板定义以及在类模板或成员定义中用到的所有名字的声明。模板的用户必须包含模板的头文件,以及用来实例化模板的任何类型的头文件。

实例化器件错误报告

模板直到实例化时才会生成代码,这一特性影响了我们何时才会获知模板内代码的编译错误。通常,编译器会在三个阶段报告错误。

  • 第一个阶段是编译模板本身时。在这个阶段,编译器通常不会发现很多错误。编译器可以检查语法错误,例如忘记分号或者变量名拼错等,但也就这么多了。
  • 第二个阶段是编译器遇到模板使用时。在此阶段,编译器仍然没有很多可检查的。对于函数模板调用,编译器通常会检查实参数目是否正确。它还能检查参数类型是否匹配。对于类模板,编译器可以检查用户是否提供了正确数目的模板实参,但也仅限于此了。
  • 第三个阶段是模板实例化时,只有这个阶段才能发现类型相关的错误。依赖于编译器如何管理实例化,这类错误可能在链接时才报告。

例如原始版本的if (vl < v2) return -l;//要求类型T的对象支持<操作其中如果调用者传入类型没有<运算符,则会在第三个阶段报错。

类模板

类模板是用来蓝图的,编译器不能为类推断参数类型。必须在尖括号中提供额外的信息,用来代替参数的模板实参列表。

定义类模板

我们实现StrBlib的模板版本,为Bolb,不在针对string,使用时用户需要指出元素类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template <typename T> class Blob {
public:
t ypedef T value_type;
typedef typename std: :vector<T> : :size_type size_type;
//构造函数
Blob();
Blob(std: : initializer_list<T> il);
// Blob中的元素数目
size_type size() const { return data->size(); }
bool empty() const { return data->empty(); }
//添加和删除元素
void push_back(const T& t) { data->push_back(t); }
//移动版本,参见13.6.3节(第484页)
void push_back(T&& t) { data->push_back(std : : move(t)); }
void pop_back();
//元素访问
T& back();
T& operator[] (size_type i); // 在14.5节(第501页)中定义
private:
std: : shared ptr<std : : vector<T>> data;//若data[i]无效,则抛出msg
void check(size_type i, const std: :string & msg) const;
};

我们的Blob模板有一个名为T的模板类型参数,用来表示Blob保存的元素的类型。例如,我们将元素访问操作的返回类型定义为T&。当用户实例化Blob时,T就会被替换为特定的模板实参类型。

实例化类模板

使用类模板时,提供额外信息,这些信息实际上是显示模板实参列表,它们被绑定到模板参数。

1
2
Blob<int> ia;					//空Blob<int>
Blob<int> ia2 = {0,1,2,3,4}; //有5个元素的Blob<int>

于是编译器生成一个类似这样的类:

1
2
3
4
5
6
7
8
9
template <> class Blob<int> {
typedef typename std: :vector<int>: :size_type size_type;
Blob();
Blob(std: :initializer_list<int> il); // ...
int& operator[](size_type i);
private:
std: :shared ptr<std: :vector<int>> data;
void check(size_type i, const std : : string & msg) const;
};

一个类模板的每个实例都形成一个独立的类。类型Blob与任何其他Blob类型都没有关联,也不会对任何其他B1ob类型的成员有特殊访问权限。

在模板作用域中引用模板类型

一个类模板中的代码如果使用了另外一个模板,通常不将一个实际类型(或值)的名字用作其模板实参。相反的,我们通常将模板自己的参数当作被使用模板的实参。例如,我们的data 成员使用了两个模板,vector和 shared_ptr。我们知道,无论何时使用模板都必须提供模板实参。在本例中,我们提供的模板实参就是Blob的模板参数。因此,data的定义如下:

std: :shared_ ptr<std: : vector<T>> data;

类模板的成员函数

因此,类模板的成员函数具有和模板相同的模板参数。因而,定义在类模板之外的成员函数就必须以关键字template开始,后接类模板参数列表。

当我们定义一个成员函数时,模板实参与模板形参相同。对于strBlob的一个给定的成员函数

ret-type StrBlob : : member-name(parm-list)

对应的Blob的成员应该是这样的:

template <typename T> ret-type Blob<T>: :member-name(parm-list)

check和元素访问成员

我们首先定义check成员,它检查一个给定的索引:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template <typename T>
void Blob<T>: :check(size_type i,const std::string &msg) const
{
if(i >=data->size ())
throw std: :out_of_range(msg) ;
}
//用模板参数指出返回类型
template <typename T>
T& Blob<T>: : back() {
check(0, "back on empty Blob"); return data->back();
}
template <typename T>
T& Blob<T> : : operator[](size_type i) {
//如果i太大,check会抛出异常,阻止访问一个不存在的元素
check (i, "subscript out of range" ) ;
return (*data)[i];
}
//pop_back函数与原StrBlob的成员几乎相同:
template <typename T> void Blob<T>: :pop_back() {
check(0,"pop_back on empty Blob");
data->pop_back ();
}

Blob构造函数

与类模板外函数一样,构造函数先定义模板参数:

1
2
template <typename T>
Blob<T>::Blob() : data (std: :make_shared<std: :vector<T>>()){ }

分配一个空vcector,并将指向vector的指针保存在data中,还要有接受一个initializer_list参数的构造函数将其类型参数工作为initializer list参数的元素类型:

1
2
3
template <typename T>
Blob<T>::Blob(std: :initializer_list<T> il):
data(std: :make_shared<std: :vector<T>>(il)){ }

为了使用这个构造函数,我们必须传递给它一个initializer_list,其中的元素必须与Blob的元素类型兼容:

Blob<string> articles = { "a", "an", "the" };

类模板成员的实例化

只有当程序用到它时才会进行实例化

1
2
3
4
5
//实例化Blob<int>和接受initializer_list<int>的构造函数
Blob<int> squares = {0, 1,2,3,4,5,6,7,8,9};
//实例化Blob<int> : :size ( ) const
for (size_t i = 0; i != squares.size(); ++i)
squares[i] = i*i;//实例化Blob<int> : : operator[] (size_t)

实例化了 Blob类和它的三个成员函数: operator[ ] 、 size和接受initializer_list的构造函数。

默认情况下,对于一个实例化了的类模板,其成员只有在使用时才被实例化。

在类内简化模板类名

当我们使用一个类模板类型时必须提供模板实参,但这一规则有一个例外。在类模板自己的作用域中,我们可以直接使用模板名而不提供实参:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//若试图访问一个不存在的元素,BlobPtr抛出一个异常template <typename T> class BlobPtr {
public:
BlobPtr() : curr(0) {}
BlobPtr(Blob<T>& a, size_t sz - 0):
wptr(a.data), curr(sz) {}
T& operator*() const
{
auto p = check(curr, "dereference past end");
return (*p)[curr];
// (*p)为本对象指向的vector
}
//递增和递减
BlobPtr& operator++(); //前置运算符
BlobPtr& operator--( );
private:
//若检查成功,check 返回一个指向vector的shared_ptr
std: :shared_ptr<std: :vector<T>>
check(std : : size_t, const std : : string&) const;
// 保存一个weak ptr,表示底层vector可能被销毁
std : : weak_ptr<std : : vector<T>> wptr;
std : : size_t curr; //数组中的当前位置
};

在递增和递减函数中,我们返回的是BlobPtr&,而不使用BlobPtr&,因为当处于一个类模板的作用域时,自身引用时就等价于

1
2
BlobPtr<T>& operator++();
BlobPtr<T>& operator--();

在类模板外使用类模板名

由于在类外,只有遇到类名才代表进入类的作用域,所以类外函数返回自身需要使用BlobPtr

1
2
3
4
5
6
7
8
//后置:递增/递减对象但返回原值
template <typename T>
BlobPtr<T> BlobPtr<T>: :operator++(int){
//此处无须检查;调用前置递增时会进行检查
BlobPtr ret = *this; //保存当前值
++*this;//推进一个元素;前置++检查递增是否合法
return ret; //返回保存的状态
}

由于函数体已经进入类内,所以可以直接使用BlobPtr。

在一个类模板的作用域内,我们可以直接使用模板名而不必指定模板实参。

类模板和友元

当一个类包含一个友元声明时,类与友元各自是否是模板是相互无关的。如果一个类模板包含一个非模板友元,则友元被授权可以访问所有模板实例。如果友元自身是模板,类可以授权给所有友元模板实例,也可以只授权给特定实例。

一对一友好关系

我们的Blob类应该将BlobPtr类和模板版本的Blob相等运算符定义为友元,此外我们在Blob加入可以用==运算符的友元函数:

1
2
3
4
5
6
7
8
9
10
11
12
//前置声明,在 Blob中声明友元所需要的
template <typename> class BlobPtr;
template <typename> class Blob;// 运算符==中的参数所需要的
template <typename T>
bool operator== (const Blob<T>&, const Blob<T>&);
template <typename T>class Blob {
//每个Blob实例将访问权限授予用相同类型实例化的BlobPtr和相等运算符
friend class BlobPtr<T>;
friend bool operator==<T>
(const Blob<T>&, const Blob<T>&);
//其他成员定义,与12.1.1(第405页)相同
};

这里在Blob类中出现的5个T,说明它将对应的类与函数声明为友元,如:

1
2
Blob<char> ca;// BlobPtr<char>和operator==<char>都是本对象的友元
Blob<int> ia;// BlobPtr<int>和operator==<int>都是本对象的友元

通过和特定的模板友好关系

一个类也可以将另一个模板的每个实例都声明为自己的友元,或者限定特定的实例为友元:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//前置声明,在将模板的一个特定实例声明为友元时要用到
template <typename T> class Pal;
class c {//C是一个普通的非模板类
friend class Pal<C>;//用类c实例化的Pal是c的一个友元/l Pal2的所有实例都是c的友元;这种情况无须前置声明
template <typename T> friend class Pal2;
};
template <typename T> class c2 {// C2本身是一个类模板
// C2的每个实例将相同实例化的Pal声明为友元
friend class Pal<T>; // Pal的模板声明必须在作用域之内
// Pal2的所有实例都是C2的每个实例的友元,不需要前置声明
template <typename x> friend class Pal2;
// Pal3是一个非模板类,它是C2所有实例的友元
friend class Pal3;//不需要Pal3的前置声明
};

为了让所有实例成为友元,友元声明中必须使用与类模板本身不同的模板参数。

令模板自己的类型参数成为友元

1
2
3
4
template <typename Type> class Bar {
friend Type; //将访问权限授予用来实例化Bar的类型
//...
};

此处我们将用来实例化Bar的类型声明为友元。因此,对于某个类型名Foo,Foo将成为Bar的友元,sales data将成为Bar的友元,依此类推。

模板的类型别名

可以给已经实例化的类起别名:typedef Blob<string> StrBlob;模板起别名的方式为则不同:

1
2
template<typename T> using twin = pair<T,T>;
twin<string> authors; // authors是一个pair<string,string>

也可以固定多个模板参数:

1
2
3
4
template <typename T> using partNo = pair<T, unsigned>;
partNo<string> books; // books是一个pair<string,unsigned>
partNo<Vehicle> cars; // cars是一个pair<Vehicle,unsigned>
partNo<Student> kids; // kids是一个pair<Student,unsigned>

类模板的static成员

类模板也可以声明static成员:

1
2
3
4
5
6
7
template <typename T> class Foo{
public:
static std: :size_t count (){ return ctr; }
//其他接口成员
private:
static std: : size_t ctr;//其他实现成员
};

这样的static成员会在同一个类型内共享,如Foo这个类型的所有对象,共享这两个函数:

1
2
3
4
//实例化static成员Foo<string> ::ctr和Foo<string> : :count
Foo<string> fs;
//所有三个对象共享相同的Foo<int> : :ctr和Foo<int> : : count成员
Foo<int> fi, fi2,fi3;

数据成员也同样如此,且必须有且仅有一个定义,所有该特定类的对象共享此成员。

1
2
template <typename T>
size_t Foo<T> : : ctr = 0; //定义并初始化ctr

与非模板类的静态成员相同,我们可以通过类类型对象来访问一个类模板的static成员,也可以使用作用域运算符直接访问成员。当然,为了通过类来直接访问static成员,我们必须引用一个特定的实例:

1
2
3
4
Foo<int> fi;						//实例化Foo<int>类和static数据成员ctr
auto ct = Foo<int> : :count () ; //实例化Foo<int> ; :count
ct = fi.count (); //使用Foo<int> : : count
ct= Foo: :count (); //错误:使用哪个模板实例的count?

static成员函数只有在使用时才会实例化。

模板参数

模板参数的名字不仅可以是T还可以是其他任何命名。

模板参数与作用域

一个模板参数可用范围是在其声明之后,至模板声明或定义结束之前。与其他任何名字一样的是模板参数隐藏外层作用域声明的相同的名字,不同的是,在模板内不能重用模板参数名:

1
2
3
4
5
typedef double A;
template <typename A,typename B> void f(A a,B b)
A tmp = a;// tmp的类型为模板参数A的类型,而非double
double B;//错误:重声明模板参数B
}

由于模板名字不能重用,所以在模板参数列表也只能出现一次:

1
2
//错误:非法重用模板参数名v
template <typename v, typename v> // ...

模板声明

模板的声明必须包括模板参数:

1
2
3
//声明但不定义compare和 Blob
template <typename T> int compare (const T&,const T&);
template <typename T> class Blob;

个特定文件所需要的所有模板的声明通常一起放置在文件开始位置,出现于任何使用这些模板的代码之前。

使用类的类型成员

由于使用::运算符在模板参数上就会有困难,如:T::men,它不知道men声明类型成员和static数据成员,所以必须知道这个T是否表示一个类型。

1
T: :size_type * p;

它需要知道我们是正在定义一个名为p的变量还是将一个名为size_type的static数据成员与名为p的变量相乘。

如果希望使用一个类型成员就必须显示使用typename关键字:

1
typename T: :size_type * p;

当我们希望通知编译器一个名字表示类型时,必须使用关键字typename,而不能使用 class。

默认的模板实参

就像函数的默认实参一样,我们也可以为模板参数提供实参:

1
2
3
4
5
6
7
//compare有一个默认模板实参less<T>和一个默认函数实参F()
template <typename T, typename F = less<T>>
int compare(const T &v1,const T &v2,Ff = F()){
if (f(vl, v2) ) return -l;
if (f(v2, v1) ) return 1;
return 0;
}

用户调用时,可以自己提供,也可以使用默认的:

1
2
3
4
bool i = compare (0,42);//使用less; i为-1
//结果依赖于item1和item2中的isbn
sales_data item1(cin), item2(cin) ;
bool j = compare (iteml, item2,compareIsbn);

与函数默认实参一样,对于一个模板参数,只有当它右侧的所有参数都有默认实参时它才可以有默认实参。

模板默认实参与类模板

同样的,类可以使用默认模板参数

1
2
3
4
5
6
7
8
9
template <class T = int> class Numbers {// T默认为int
public:
Numbers(T V = 0) : val(v) { }
//对数值的各种操作
private:
T val;
};
Numbers<long double> lots_of_precision;
Numbers<> average_precision; // 空<>表示我们希望使用默认类型

成员模板

普通类的成员模板

若一个普通类中有一个模板函数,便被称为成员模板:

1
2
3
4
5
6
7
8
9
10
11
12
//函数对象类,对给定指针执行delete
class DebugDelete {
public:
DebugDelete(std::ostream& s = std::cerr) :os(s) { }
//与任何函数模板相同,T的类型由编译器推断
template <typename T> void operator ()(T* p) const
{
os << "deleting unique_ptr" << std : : endl; delete p;
}
private:
std::ostream & os;
};

这是一个类似unique_ptr的使用的默认删除器,根据不同的类型进行销毁操作:

1
2
3
4
5
6
double* p = new double;
DebugDelete d; //可像delete表达式一样使用的对象
d(p); //调用 DebugDelete: :operator() (double*),释放p
int* ip = new int;
//在一个临时 DebugDelete对象上调用operator()(int*)
DebugDelete()(ip) ;

我们就可以用这个类型替换unique_ptr中的删除器:

1
2
3
4
5
6
//销毁p指向的对象
//实例化 DebugDelete: :operator ( )<int> (int *)
unique_ptr<int,DebugDelete> p(new int,DebugDelete());
//销毁sp指向的对象
//实例化 DebugDelete: :operator ( )<string> (string* )
unique _ptr<string,DebugDelete> sp(new string,DebugDelete() );

当unique_ptr析构函数调用时,De—类便会实例化:

1
2
3
// DebugDelete的成员模板实例化样例
void DebugDelete: :operator() (int *p) const { delete p; }
void DebugDelete: :operator()(string *p)const { delete p; }

类模板的成员模板

类和成员有各自的模板,即可以像这样定义:

1
2
3
4
template <typename T>class Blob {
template <typename It>Blob (It b, It e);
//...
};

也可以

1
2
3
4
template <typename T>//类的类型参数
template <typename It>//构造函数的类型参数
Blob<T>::Blob (It b, It e):
data(std::make_shared<std::vector<T>>(b,e)) { }

实例化与成员模板

为了实例化上一个模板类的成员模板,我们必须同时提供类和函数的实参:

1
2
3
4
5
6
7
8
9
int ia[] ={ 0,1,2,3,4,5,6,7,8,9};
vector<long> vi = { 0,1,2,3,4,5,6,7,8,9};
list<const char*> w = { "now" , "is", "the", "time" };
//实例化Blob<int>类及其接受两个int*参数的构造函数
Blob<int> al (begin(ia), end (ia));
//实例化Blob<int>类的接受两个vector<long> : :iterator的构造函数
Blob<int> a2(vi.begin (), vi.end ());
//实例化Blob<string>及其接受两个list<const char*> : :iterator参数的构造函数
Blob<string> a3 ( w.begin (), w.end ());

定义a1时就实例化了如下版本:

1
Blob<int> : :Blob (int* , int* );

控制实例化

当模板被使用时才会进行实例化,这一特性意味着,相同的实例可能出现在多个对象文件中。当两个或多个独立编译的源文件使用了相同的模板,并提供了相同的模板参数时,每个文件中就都会有该模板的一个实例。

在新标准中,我们可以通过显式实例化(explicit instantiation)来避免这种开销。一个显式实例化有如下:

1
2
3
4
5
6
extern template declaration;		//实例化声明
template declaration; //实例化定义
//declaration是一个类或函数声明,其中所有模板参数已被替换为模板实参。例如,
//实例化声明与定义
extern template class Blob<string>; //声明
template int compare (const int&, const int&); // 定义

编译器遇到extern声明时,它不会在本文件中生成实例化代码,而是承诺其他地方有这样的实例化,我现在只是使用它,对于一个给定的实例化版本,可能有多个extern声明,但必须只有一个定义。

extern声明必须在任何使用此实例之前:

1
2
3
4
5
6
7
8
9
//Application.cc
//这些模板类型必须在程序其他位置进行实例化
extern template class Blob<string>;
extern template int compare (const int&,const int&);
Blob<string> sal,sa2; //实例化会出现在其他位置
// Blob<int>及其接受initializer_list的构造函数在本文件中实例化
Blob<int> al = {0,1,2,3,4,5,6,7,8,9} ;
Blob<int> a2(al); //拷贝构造函数在本文件中实例化
int i = compare(a1 [0],a2[0]);//实例化出现在其他位置

上面的这些使用extern的实例必须在其他地方有定义:

1
2
3
4
// templateBuild.cc
//实例化文件必须为每个在其他文件中声明为extern 的类型和函数提供一个(非extern)的定义
template int compare(const int&, const int&) ;
template class Blob<string>;//实例化类模板的所有成员

对每个实例化声明,在程序中某个位置必须有其显式的实例化定义。

实例化定义会实例化所有成员

在一个类模板的实例化定义中,所用类型必须能用于模板的所有成员函数。

效率与灵活性

unique_ptr避免了间接调用删除其的运行时开销,而shared_ptr使用户可以重载删除器。前者有效率,后者有灵活度。

模板实参推断

类型转换与模板类型参数

与往常一样,顶层const无论是在形参中还是在实参中,都会被忽略。在其他类型转换中,能在调用中应用于函数模板的包括如下两项。

  • const转换:可以将一个非 const对象的引用(或指针)传递给一个const的引用(或指针)形参。
  • 数组或函数指针转换:如果函数形参不是引用类型,则可以对数组或函数类型的实参应用正常的指针转换。一个数组实参可以转换为一个指向其首元素的指针。类似的,一个函数实参可以转换为一个该函数类型的指针。

其他类型转换,如算术转换、派生类向基类的转换以及用户定义的转换,都不能应用于函数模板。

1
2
3
4
5
6
7
8
9
10
11
12
template <typename T> T fobj(T,T);//实参被拷贝
template <typename T> T fref (const T&,const T&);// 引用
string s1("a value" );
const string s2 ("another value");
fobj(s1,s2);//调用fobj(string,string); const被忽略
fref(s1,s2);//调用fref(const string&, const string&)
//将s1转换为const是允许的
int a[10],b[42];
fobj(a, b) ;
//调用f(int*, int*)
fref(a,b);
//错误:数组类型不匹配

在最后一对调用中,我们传递了数组实参,两个数组大小不同,因此是不同类型。在fobj调用中,数组大小不同无关紧要。两个数组都被转换为指针。fobj中的模板类型为int*。但是,fref调用是不合法的。如果形参是一个引用,则数组不会转换为指针(参见6.2.4节,第195页)。a和 b的类型是不匹配的,因此调用是错误的。

将实参传递给带模板类型的函数形参时,能够自动应用的类型转换只有const转换及数组或函数到指针的转换。

使用相同模板参数类型的函数形参

模板的参数只允许几种有限的类型转换,因此传递的这些形参必须具有相同的类型。我们的compare函数接受两个const T&参数,其实参必须是相同类型:

1
2
long lng;
compare(lng,1024);//错误:不能实例化compare (long, int)

如果希望可以类型转换,可以将函数模板定义为两个类型参数

1
2
3
4
5
6
7
//实参类型可以不同,但必须兼容
template <typename A,typename B>
int flexibleCompare (const A& v1,const B& v2){
if (vl< v2) return -1;
if (v2< v1) return 1;
return 0;
}

正常类型转换应用于普通函数实参

1
2
3
4
5
6
7
template <typename T> ostream &print (ostream &os,const T &obj){
return os <<obj;
}

print (cout,42); //实例化print(ostream&, int)
ofstream f ( "output");
print (f,10);//使用print (ostream&, int);将f转换为ostream&

第一个函数参数是一个已知类型ostream&。第二个参数 obj 则是模板参数类型。由于os 的类型是固定的,因此当调用print时,传递给它的实参会进行正常的类型转换:

如果函教参教类型不是模板参数,则对实参进行正常的类型转换。

函数模板显式实参

某些时候模板参数无法推断除类型,允许用户控制模板实例化。

指定显式模板实参

我们可以定义表示返回类型的第三个模板参数,从而允许用户控制返回类型:

1
2
3
//编译器无法推断T1,它未出现在函数参数列表中
template <typename Tl,typename T2, typename T3>
Tl sum(T2,T3);

没有任何参数可供推断出T1的类型,所以调用时必须提供一个显示模板实参。

1
2
// T1是显式指定的,T2和T3是从函数实参类型推断而来的
auto val3 = sum<long long>(i, lng);// long long sum(int,long)

显式模板实参时一一对应的,只有右边的可以忽略,但必须可以从函数参数推断出来。

1
2
3
//糟糕的设计:用户必须指定所有三个模板参数
template <typename T1, typename T2, typename T3>
T3 alternative_sum (T2,T1);

则我们总是必须为所有三个形参指定实参:

1
2
3
//错误:不能推断前几个模板参数
auto val3 = alternative_sum<long long> (i, lng) ;//正确:显式指定了所有三个参数
auto val2 = alternative_sum<long long,intlong>(i, lng);

所以把需要显示提供的参数放在最前面

正常类型转换应用于显式指定的实参

如果模板类型参数已经显式指定,页可以进行类型转换

1
2
3
4
long lng;
compare(lng,1024); //错误:模板参数不匹配
compare<long> (lng,1024); //正确:实例化compare (long, long)
compare<int> (lng,1024); //正确:实例化compare(int,int)

第一个调用由于类型不匹配错误,后面的调用由于显式指定,而可以进行正常的类型转换。

尾置返回类型与类型转换

1
2
3
4
5
template <typename It>
??? &fcn (It beg, It end){
//处理序列
return *beg; //返回序列中一个元素的引用
}

由于我们不知道返回结果的准确类型,但所需类型是所处理的序列的元素类型

1
2
3
4
vector<int> vi = { 1,2,3,4,5};
Blob<string> ca = {"hi", "bye" };
auto &i = fcn (vi.begin (), vi.end() ); //fcn应该返回int&
auto &s = fcn (ca.begin (), ca.end()); // fcn应该返回string&

我们知道函数应该返回*beg,而且知道我们可以用decltype (*beg)来获取表达式类型。但是,在编译器遇到函数的参数列表之前,beg都是不存在的。为了定义此函数,我们必须使用尾置返回类型。由于尾置返回出现在参数列表之后,它可以使用函数的参数:

1
2
3
4
5
6
//尾置返回允许我们在参数列表之后声明返回类型
template <typename It>
auto fcn(It beg,It end) -> decitype (*beg){
//处理序列
return *beg; //返回序列中一个元素的引用
}

进行类型转换的标准库模板类

如果并不想返回引用而是返回其中的值,可以使用标准库类型转换模板。在头文件type_traits中,如果我们用一个引用类型实例化remove_reference,则type将表示被引用的类型。例如,如果我们实例化 remove_reference<int&>,则type 成员将是int。类似的,如果我们实例化remove_reference<string&>,则type成员将是string,依此类推。更一般的:

1
remove_reference<decltype (*beg) >::type

组合使用它们就可以在函数中返回元素值的拷贝:

1
2
3
4
5
6
7
8
//为了使用模板参数的成员,必须用typename,参见16.1.3节(第593页)
template <typename It>
auto fcn2 (It beg,It end) ->
typename remove_reference<decltype (*beg)>::type
{
//处理序列
return *beg; //返回序列中一个元素的拷贝
}

image.png

函数指针和实参推断

可以用一个函数模板对一个函数指针进行赋值,可根据形参生成一个实例,被指针所指:

1
2
3
template <typename T> int compare (const T&,const T&);
// pf1指向实例int compare(const int&, const int&)
int ( *pf1)(const int&, const int&) = compare;

如果不能从函数指针类型却低估模板实参,则产生错误:

1
2
3
4
// func的重载版本;每个版本接受一个不同的函数指针类型
void func (int(*)(const string&,const string&));
void func(int (*) (const int&,const int&));
func (compare); //错误:使用compare的哪个实例?

由于既可以接受int和string版本的compare,所以调用失败。不过可以显式的指出:

1
2
//正确:显式指出实例化哪个compare版本
func (compare<int>);//传递compare (const int&,const int&)

当参数是一个函数模板实例的地址时,程序上下文必须满足:对每个模板参数,能唯一确定其类型或值。

模板实参推断和引用

左值引用函数参数推断类型

一个函数参数是模板类型参数的普通引用时,只能传递给它一个左值:

1
2
3
4
5
template <typename T> void f1(T&);
//实参必须是一个左值//对f1的调用使用实参所引用的类型作为模板参数类型
f1(i);// i是一个int;模板参数类型T是int
f1(ci); // ci是一个const int;模板参数T是const int
f1(5); //错误:传递给一个&参数的实参必须是一个左值

如果是const T&,则推断结果不会是一个const类型

1
2
3
4
5
6
template <typename T> void f2(const T&);// 可以接受一个右值
//f2中的参数是const & ;实参中的const是无关的
//在每个调用中,f2的函数参数都被推断为const int&
f2(i);//i是一个int;模板参数T是int
f2(ci); //ci是一个const int,但模板参数T是int
f2(5);//一个const&参数可以绑定到一个右值;T是int

从右值引用函数参数推断类型

如果函数参数是右值引用,如T&&,推断出T的类型是该右值实参的类型:

1
2
template <typename T> void f3(T& &);
f3(42); //实参是一个int类型的右值;模板参数T是int

引用折叠和右值引用参数

如果一个函数参数是指向模板参数类型的右值引用(如,T&&),则可以传递给它任意类型的实参。如果将一个左值传递给这样的参数,则函数参数被实例化为一个普通的左值引用(T&)。

编写接受右值引用参数的模板函数

1
2
3
4
5
template <typename T> void f3(T&& val){
Tt = val; //拷贝还是绑定一个引用?
t = fcn (t) ; //赋值只改变t还是既改变t又改变val?
if (val == t){/* ...*/ }//若T是引用类型,则一直为true
}

上面的函数,如果传入42,则T会推断为int,但如果传入int的左值,则T会推断为int&,则如果修改t的同时也会修改val。

1
2
template <typename T> void f(T&& ) ;		//绑定到非const右值
template <typename T> void f(const T&); //左值和const右值

通常是这样重载模板函数,与非模板函数一样,第一个版本将绑定到可修改的右值,而第二个版本将绑定到左值或const右值。

理解std::move

在13.6.2节中我们注意到,虽然不能直接将一个右值引用绑定到一个左值上,但可以用move获得一个绑定到左值上的右值引用。

std::move如何定义

标准库的的move

1
2
3
4
5
template <typename T>
typename remove_reference<T>::type&& move(T&& t) {
// static_cast是在4.11.3节(第145页)中介绍的
return static_cast<typename remove_reference<T>::type&&> (t);
}

这段代码很短,但其中有些微妙之处。首先,move的函数参数T& &是一个指向模板类型参数的右值引用。通过引用折叠,此参数可以与任何类型的实参匹配。特别是,我们既可以传递给move一个左值,也可以传递给它一个右值:

1
2
3
string s1 ( "hi! "), s2;
s2 = std: :move(string ( "bye ! ") );//正确:从一个右值移动数据
s2 = std: :move(sl);//正确:但在赋值之后,s1的值是不确定的

std::move如何工作

如我们已经见到过的,当向一个右值引用函数参数传递一个右值时,由实参推断出的类型为被引用的类型。因此,在std: : move (string ( "bye ! "))中:

  • 推断出的T的类型为string。
  • 因此,remove_reference用string进行实例化。
  • remove_reference的type成员是string。
  • move的返回类型是string&& 。
  • move的函数参数t的类型为string& &。

因此,这个调用实例化move,即函数string&& move(string &t)

左值static_cast到右值引用时允许的

我们可以用static_cast显式地将一个左值转换为一个右值引用。

转发

某些函数需要将其一个或多个实参连同类型不变地转发给其他函数。在此情况下,我们需要保持被转发实参的所有性质,包括实参类型是否是const的以及实参是左值还是右值。

1
2
3
4
5
6
7
//接受一个可调用对象和另外两个参数的模板//对“翻转”的参数调用给定的可调用对象
// flip1是一个不完整的实现:顶层const和引用丢失了
template <typename F, typename T1,typename T2>
void flip1(F f,T1 t1,T2 t2)
{
f(t2,t1);
}

如果调用一个接受引用的参数就会出问题:

1
2
3
4
void f(int v1, int &v2)//注意v2是一个引用
{
cout << v1 <<" " <<++v2 <<endl;
}

函数第二个参数为引用,说明我们希望通过函数改变原变量的值,但是使用模板调用就会丢失这个引用的属性:

1
2
f(42,i);			//f改变了实参i
flip1(f,j,42); //通过flip1调用f不会改变j

问题在于j被传递给flip1的参数t1。此参数是一个普通的、非引用的类型int,而非int&。因此,这个flip1调用会实例化为

1
void flip1(void(*fcn) (int,int& ), int t1,int t2);

定义能保持类型信息的函数参数

通过将一个函数参数定义为一个指向模板类型参数的右值引用,我们可以保持其对应实参的所有类型信息。而使用引用参数(无论是左值还是右值〉使得我们可以保持const属性,因为在引用类型中的const是底层的。如果我们将函数参数定义为T1&&和 T2&&,通过引用折叠(参见 16.2.5节,第608页)就可以保持翻转实参的左值/右值属性(参见16.2.5节,第608页):

1
2
3
4
template <typename F, typename T1, typename T2>void flip2(F f,T1 &&t1,T2 &&t2)
{
f(t2, t1) ;
}

这样调用就传递给t1一个左值j,t1会折叠为int&,则t1会绑定到j上,就可以通过函数改变j的值。

如果一个函数参数是指向模板类型参数的右值引用(如 T&&),它对应的实参的const属性和左值/右值属性将得到保持。

但是不能用于接受右值引用参数的函数:

1
2
3
void g(int & &i, int& j){
cout << i <<" " <<j << endl;
}

如果我们试图通过flip2调用g,则参数t2将被传递给g的右值引用参数。即使我们传递一个右值给flip2:

1
flip2(g, i, 42);//错误:不能从一个左值实例化int& &

std::forward保持类型信息

1
2
3
4
template <typename Type> intermediary(Type &&arg) {
finalFcn (std::forward<Type>(arg));
// ...
}

当用于一个指向模板参数类型的右值引用函数参数(T&&)时,forward会保持实参类型的所有细节。

于是我们可以重写前面的函数:

1
2
3
4
template <typename F, typename Tl,typename T2>
void flip(F f,T1 & &t1,T2 &&t2){
f (std: :forward<T2>(t2), std::forward<T1>(t1)) ;
}

重载与模板

编写重载模板

首先编写俩个不同的函数模板

1
2
3
4
5
6
//打印任何我们不能处理的类型
template <typename T> string debug_rep (const T &t){
ostringstream ret; //参见8.3节(第287页)
ret << t; //使用T的输出运算符打印t的一个表示形式
return ret.str(); //返回ret绑定的string的一个副本
}

再定义一个对象对应string表示:

1
2
3
4
5
6
7
8
9
10
11
//打印指针的值,后跟指针指向的对象
//注意:此函数不能用于char*;参见16.3节(第617页)
template <typename T> string debug_rep(T *p) {
ostringstream ret;
ret << "pointer: " << p; //打印指针本身的值
if (p)
ret <<" " <<debug_rep (*p); //打印p指向的值
else
ret <<" null pointer" ;//或指出 p为空
return ret.str();//返回ret绑定的string 的一个副本
}

函数内容并不重要,我们先看使用它们:

1
2
string s ( "hi" );
cout << debug_rep (s) << endl;

这个调用只有第一个版本是可行的,第二个版本要求一个指针参数,但在此调用中我们传递的是一个非指针对象。因此编译器无法从一个非指针实参实例化一个期望指针类型参数的函数模板,因此实参推断失败。

如果用一个指针调用debug_rep

1
cout << debug_rep(&s) <<endl;

两个函数都生成可行的实例:

  • debug rep(const string*&),由第一个版本的debug_rep实例化而来,T被绑定到string*。
  • debug rep(string*),由第二个版本的 debug_rep实例化而来,T被绑定到string。

但第二个版本更加精确,第一个版本需要进行普通指针到const的转换,编译器也会选择第二个版本。

多个可行模板

1
2
const string *sp = &s;
cout << debug_rep (sp) << endl;

此例中的两个模板都是可行的,而且两个都是精确匹配:

  • debug rep(const string*&),由第一个版本的 debug_rep 实例化而来,T被绑定到string*
  • debug rep(const string*),由第二个版本的 debug_rep 实例化而来,T被绑定到const string。

再这种时候编译器会选择最特例化的,我的理解是,最简洁的,则选择第一个版本。

非模板和模板重载

1
2
3
4
//打印双引号包围的string
string debug_rep(const string &s){
return ' "" +s + '"';
}

当同时匹配同样好的模板和非模板函数的时候,编译器一定会选择非模板版本。

重载模板和类型转换

如果使用这个调用:

1
2
3
4
5
cout << debug_rep("hi world!") << endl;//调用debug_rep(T*)
//本例中所有三个debug _rep版本都是可行的:
debug rep (const T&)//T被绑定到char [10]。
debug rep(T*)//T被绑定到const char。
debug rep (const strina&)//要求从const char*到string 的类型转换。

前两个版本都是匹配的,而第二个版本会被认为是精确匹配的,非模板版本是可行的,但需要一次用户定义的类型转换。所以选择第二个。

如果更希望使用字符版本,可以定义:

1
2
3
4
5
6
7
//将字符指针转换为string,并调用string版本的 debug_reg
string debug_rep (char *p){
return debug_rep (string(p));
}
string debug_rep (const char *P){
return debug_rep (string(p));
}

缺少声明可能导致程序行为异常

为了使用char*版本的函数,必须提前准备好其中的模板函数声明:

1
2
3
4
5
6
7
template <typename T> string debug_rep (const T &t);template <typename T> string debug_rep(T *P);
//为了使debug_rep(char*)的定义正确工作,下面的声明必须在作用域中string debug_rep (const string &);
string debug_rep (char *p)
i
//如果接受一个const string&的版本的声明不在作用域中,
//返回语句将调用debug_rep(const T&)的T实例化为string 的版本return debug_rep(string(p) );

在定义任何函数之前,记得声明所有重载的函数版本。这样就不必担心编译器由于未遇到你希望调用的函数而实例化一个并非你所需的版本。

可变参数模板

一个可变参数模板就是一个接受可变数目参数的模板函数或模板类。可变数目的参数被称为参数包。存在两种参数包:模板参数包template parameter packet),表示零个或多个模板参数;函数参数包(function parameteroacket),表示零个或多个函数参数。

1
2
3
4
5
// Args是一个模板参数包; rest是一个函数参数包
// Args表示零个或多个模板类型参数
// rest表示零个或多个函数参数
template <typename T, typename... Args>
void foo(const T &t, const Args& ... rest);

与往常一样,编译器从函数的实参推断模板参数类型。对于可变参数模板,编译器会推断保重的参数数目:

1
2
3
4
5
6
7
8
9
int i = 0; double d = 3.14; string s = "how now brown cow" ;
foo(i, s, 42,d); //包中有三个参数
foo(s, 42, "hi"); //包中有两个参数
foo(d,s); //包中有一个参数
foo ( "hi"); //空包
//编译器会为foo实例化出四个不同的版本:
void foo(const int&,const string&,const int&,const double&);void foo(const string&, const int&, const char[3]&);
void foo (const double&, const string&) ;
void foo (const char[3]&);

sizeof…运算符

若需要知道包中的运算符,使用sizeof…运算符:

1
2
3
4
template<typename ... Args> void g(Args ... args){
cout << sizeof...(Args) <<endl; //类型参数的数目
cout<< sizeof...(args) << endl; //函数参数的数目
}

编写可变参数函数模板

1
2
3
4
5
6
7
8
9
10
11
12
//用来终止递归并打印最后一个元素的函数
//此函数必须在可变参数版本的print定义之前声明
template<typename T>
ostream & print(ostream &os, const T &t){
return os << t; // 包中最后一个元素之后不打印分隔符
}
//包中除了最后一个元素之外的其他元素都会调用这个版本的print
template <typename T, typename. . . Args>
ostream& print(ostream& os, const T& t, const Args&...rest) {
os << t << ","; //打印第一个实参
return print(os, rest...);//递归调用,打印其他实参
}

我们使用print (cout, i, s,42); //包中有两个参数来调用上面的函数,首先会匹配到第二个函数,然后递归调用第二个函数,直至最后一个参数由第一个函数打印。

当定义可变参数版本的 print时,非可变参数版本的声明必须在作用域中。否则,可变参数版本会无限递归。

包扩展

除了获取其大小以外,我们还可以扩展,我们还要提供扩展元素的模式。就时分解为构成的元素,在模式右边放一个省略号(…)触发扩展。

1
2
3
4
5
6
template <typename T, typename...Args>ostream &
print (ostream &os,const T &t,const Args&... rest)//扩展Args
{
os << t << ",";
return print(os, rest...); //扩展rest
}

第一个扩展操作扩展模板参数包,为 print生成函数参数列表。第二个扩展操作出现在对print的调用中。此模式为print调用生成实参列表。

理解包扩展

1
2
3
4
5
6
//在print调用中对每个实参调用debug_rep
template <typename... Args>
ostream &errorMsg(ostream &os, const Args&... rest){
//print (os,debug_rep(a1), debug_rep(a2), ..., debug_rep(an)
return print (os,debug_rep (rest)... ) ;
}

在看这样的调用:

1
2
//将包传递给debug_rep; print (os,debug_rep(a1,a2, ..., an))
print(os,debug_rep(rest.. .) );//错误:此调用无匹配函数

它们的区别就是第一个对扩展包中的每一个调用函数,第二个是在调用中展开:

转发参数包

1
2
3
4
5
class strVec {
public:
template <class... Args> void emplace_back(Args&&...);
//其他成员的定义,同13.5节(第465页)
};
1
2
3
4
5
6
7
template <class... Args>
inline
void StrVec::emplace_back (Args&&... args)
{
chk_n_alloc(); //如果需要的话重新分配 StrVec内存空间
alloc.construct(first_free++, std::forward<Args>(args)...);
}

emplace_back的函数体调用了chk_n_alloc(参见13.5节,第465页)来确保有足够的空间容纳一个新元素,然后调用了construct在first_free 指向的位置中创建了一个元素。construct调用中的扩展为

std: : forward<Args>(args) ...
它既扩展了模板参数包Args,也扩展了函数参数包args。此模式生成如下形式的元素

std::forward<T>(t)

其中T,表示模板参数包中第i个元素的类型,t表示函数参数包中第i个元素。例如.假定svec是一个strVec,如果我们调用

svec.emplace_back (10,'c');//将cccccccccc添加为新的尾元素

construct调用中的模式会扩展出

std::forward<int> (10), std::forward<char>(c)

通过在此调用中使用forward,我们保证如果用一个右值调用emplace back,则construct也会得到一个右值。例如,在下面的调用中:

svec.emplace back (s1 + s2);//使用移动构造函数

传递给emplace_back的实参是一个右值,它将以如下形式传递给construct

std: :forward<string> (string ( "the end" ))

forward的结果类型是string&&,因此construct将得到一个右值引用实参。construct会继续将此实参传递给string 的移动构造函数来创建新元素。

建议:转发和可变参数模板

可变参数函数通常将它们的参数转发给其他函数。这种函数通常具有与我们的emp lace_ back函数一样的形式:

1
2
3
4
5
6
7
// fun有零个或多个参数,每个参数都是一个模板参数类型的右值引用
template<typename... Args>
void fun (Args&&... args) //将Args扩展为一个右值引用的列表
{
// work的实参既扩展Args又扩展args
work(std: : forward<Args>(args)...);
}

这里我们希望将fun的所有实参转发给另一个名为work的函数,假定由它完成函数的实际工作。类似emplace_back中对 construct的调用,work调用中的扩展既扩展了模板参数包也扩展了函数参数包。
由于 fun的参数是右值引用,因此我们可以传递给它任意类型的实参;由于我们使用std:: forward传递这些实参,因此它们的所有类型信息在调用work时都会得到保持。

模板特例化

1
2
3
4
5
//第一个版本;可以比较任意两个类型
template <typename T> int compare (const T&, const T& );
//第二个版本处理字符串字面常量
template<size_t N, size_t M>
int compare(const char (&)[N], const char (&)[M]);

我们定义了另一个版本的compare,当传递给compare一个字符串字面常量或者一个数组时,编译器才会调用,而传递给它字符指针,就会调用第一个

1
2
3
const char *p1 = "hi", *p2 = "mom" ;
compare(p1, p2);//调用第一个模板
compare( "hi","mom" );//调用有两个非类型参数的版本

因为无法将指针转换为数组的引用,因此参数是p1和p2时,第二个版本compare不可行。

为了处理字符指针(而不是数组),可以为第一个版本的compare定义一个模板特例化( template specialization)版本。一个特例化版本就是模板的一个独立的定义,在其中一个或多个模板参数被指定为特定的类型。

定义函数模板特例化

特例化一个函数模板时,必须为每个模板参数提供实参,在template后跟<>,指出正在实例化:

1
2
3
4
5
//compare的特殊版本,处理字符数组的指针
template <>
int compare (const char* const &p1,const char* const &p2){
return strcmp(p1, p2);
}

定义一个特例化版本时,函数的参数类型必须与先前模板中对应类型匹配:

1
template <typename T> int compare (const T&,const T&);

特例化中T对应的为const char*,模板函数中为一个常量指针,而我们需要一个指向常量的指针,我们需要在特例化版本中使用的类型是const char * const &,即一个指向const char的const指针的引用。

函数重载与模板特例化

特例化的本质是实例化一个模板,而非重载它。因此,特例化不影响函数匹配。

关键概念:普通作用域规则应用于特例化

为了特例化一个模板,原模板的声明必须在作用域中。而且,在任何使用模板实例的代码之前,特例化版本的声明也必须在作用域中。

对于普通类和函数,丢失声明的情况(通常)很容易发现——编译器将不能继续处理我们的代码。但是,如果丢失了一个特例化版本的声明,编译器通常可以用原模板生成代码。由于在丢失特例化版本时编译器通常会实例化原模板,很容易产生模板及其特例化版本声明顺序导致的错误,而这种错误又很难查找。

如果一个程序使用一个特例化版本,而同时原模板的一个实例具有相同的模板实参集合,就会产生错误。但是,这种错误编译器又无法发现。

模板及其特例化版本应该声明在同一个头文件中。所有同名模板的声明应该放在前面,然后是这些模板的特例化版本。

类模板特例化

我们将has模板定义一个特例化版本,用它保存Sale_data对象,为了让我们自己的数据类型能使用hash,必须定义hash模板的一个特例化版本。一个特例化hash类必须定义:

  • 一个重载的调用运算符(参见14.8节,第506页),它接受一个容器关键字类型的对象,返回一个size_t。
  • 两个类型成员,result type和 argument_type,分别调用运算符的返回类型和参数类型。
  • 默认构造函数和拷贝赋值运算符(可以隐式定义,参见13.1.2节,第443页)。

首先打开命名空间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//打开std命名空间,以便特例化std::hash
namespace std{
template <>//我们正在定义一个特例化版本,模板参数为sales_data
struct hash<Sales_data>
{

//用来散列一个无序容器的类型必须要定义下列类型
typedef size_t result_type;
typedef sales_data argument_type;// 默认情况下,此类型需要==
size_t operator ( ) (const sales_data& s) const;
//我们的类使用合成的拷贝控制成员和默认构造函数
};
size_t
hash<Sales_data>::operator () (const Sales_data& s) const
{
return hash<string>() (s.bookNo) ^
hash<unsigned>() (s.units_sold) ^
hash<double>() (s.revenue);
}
}//关闭std命名空间;注意:右花括号之后没有分号

面向对象程序设计

OOP:概述

面向对象程序设计(object-oriented programming)的核心思想是数据抽象继承动态绑定

  • 使用数据抽象,我们可以将类的接口与实现分离;
  • 使用继承,可以定义相似的类型并对其相似关系建模;
  • 使用动态绑定,可以在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象。

继承

通过继承(inheritance)联系在一起的类构成一种层次关系。通常在层次关系的根部有一个**基类(base class),其他类则直接或间接地从基类继承而来,这些继承得到的类称为派生类( derived class)**。基类负责定义在层次关系中所有类共同拥有的成员,而每个派生类定义各自特有的成员。

C++中,基类将类型相关的函数与派生类不做改变直接继承的函数区分对待。对于某些函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明成虚函数( virtual function):

1
2
3
4
5
class Quote {
public:
std::string isbn() const;
virtual double net_price(std: :size_t n) const;
};

派生类必须通过使用类派生列表(class derivation list)明确指出它是从哪个(哪些)基类继承而来的。类派生列表的形式是 : 首先是一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有访问说明符:

1
2
3
4
5
class Bulk_quote : public Quote {
//Bulk_quote继承了Quote
public:
double net_price(std::size_t) const override;
};

因为使用public派生,所以可以使用基类成员,可以把Bulk quote的对象当成Quote的对象来使用。

动态绑定

用它可以分别处理Quote和Bulk_quote的对象。例如,当要购买的书籍和购买的数量都已知时,下面的函数负责打印总的费用:

1
2
3
4
5
6
7
//计算并打印销售给定数量的某种书籍所得的费用
double print_total(ostream &os, const Quote &item, size_t n) {
//根据传入item形参的对象类型调用Quote::net_price
//或者Bulk_quote::net_price
double ret = item.net_price (n);
os << "ISBN:" << item.isbn ()//调用Quote: :isbn
<<" # sold: " << n << " total due: " << ret << endl;return ret;

函数形参中的item是基类的引用,我们既可以使用基类的该函数,又可以使用派生类中的该函数,这回根据对象的类型决定执行哪一个版本:

1
2
3
// basic的类型是Quote; bulk的类型是Bulk_quote
print_total (cout, basic,20); //调用Quote的net_price
print_total (cout, bulk,20); //调用Bulk quote的net price

函数的运行版本由实参决定,所以动态绑定又被称为运行时绑定。

在C++语言中,当我们使用基类的引用(或指针)调用一个虚函数时将发生动态绑定。

定义基类和派生类

定义基类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Quote {
public:
Quote() = default; //关于=default请参见7.1.4节(第 237页)
Quote(const std::string &book,double sales_price)
: bookNo (book), price(sales_price){ }
std::string isbn () const { return bookNo;}
//返回给定数量的书籍的销售总额
//派生类负责改写并使用不同的折扣计算算法
virtual double net_price(std::size_t n) const
{ return n * price; }
virtual ~Quote () = default; //对析构函数进行动态绑定
private:
std::string bookNo; //书籍的ISBN 编号
protected:
double price = 0.0; //代表普通状态下不打折的价格
};

基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此。

成员函数与继承

派生类需要提供自己新定义覆盖从基类而来的虚函数。任何希望在派生类中改变的函数定义为virtual,而不希望改变的直接定义为函数。

任何构造函数之外的非静态函数都可以是虚函数。关键字virtual只能出现在类内部的声明语句之前而不能用于类外部的函数定义。如果基类把一个函数声明成虚函数,则该函数在派生类中隐式地也是虚函数。

访问控制与继承

派生类可以继承定义在基类中的成员,但是派生类的成员函数不一定有权访问从基类继承而来的成员。和其他使用基类的代码一样,派生类能访问公有成员,而不能访问私有成员。不过在某些时候基类中还有这样一种成员,基类希望它的派生类有权访问该成员,同时禁止其他用户访问。我们用受保护的( protected)访问运算符说明这样的成员。

定义派生类

派生类必须使用类派生列表指出从哪继承而来。派生类必须将继承类的虚函数重新声明:

1
2
3
4
5
6
7
8
9
10
class Bulk_quote : public Quote {		//Bulk_quote继承自Quote
public:
Bulk_quote() = default;
Bulk_quote (const std::string&,double,std::size_t, double);
//覆盖基类的函数版本以实现基于大量购买的折扣政策
double net_price(std: :size_t) const override;
private:
std::size_t min_qty = 0; //适用折扣政策的最低购买量
double discount = 0.0 ; //以小数表示的折扣额
};

再派生类中使用public继承,所以回隐式包含isbn函数

派生类中的虚函数

派生类可以不覆盖它继承的虚函数,但必须重新声明:如果没有覆写,其行为类似其他的普通成员。

派生类可以再它覆盖的函数前使用virtual,可以在函数const关键字后添加override。

派生类对象及派生类向基类转换

派生类大致可以认为是这样分布:

image.png

因为在派生类对象中含有与其基类对应的组成部分,所以我们能把派生类的对象当成基类对象来使用,而且我们也能将基类的指针或引用绑定到派生类对象中的基类部分上。

1
2
3
4
5
Quote item;				//基类对象
Bulk_quote bulk; //派生类对象
Quote *p = &item; //p指向Quote对象
p = &bulk ; // p指向bulk 的 Quote部分
Quote &r = bulk; // r绑定到bulk 的Quote部分

这种派生类到基类的转换回隐式的进行,所以可以将派生类的引用或指针用在基类引用或者指针上。

派生类构造函数

派生类构造函数可以调用基类的构造函数帮助建立自己的构造函数:

1
2
3
4
5
Bulk quote(const std::string& book,double p,
std::size_t qty,double disc):
Quote(book, p), min_qty(qty), discount (disc){ }
//与之前一致
};

除非特别指出,否则派生类的所有成员都会执行默认初始化。编译器会首先初始化基类的部分,然后按顺序声明派生类的成员。

派生类使用基类成员

派生类可以访问基类的公有成员和受保护成员:

1
2
3
4
5
6
7
//如果达到了购买书籍的某个最低限量值,就可以享受折扣价格了
double Bulk_quote::net_price(size_t cnt) const{
if(cnt >= min_qty)
return cnt *(1 - discount) * price;
else
return cnt * price;
}

派生类的作用域嵌套在基类的作用域之内。因此,对于派生类的一个成员来说,它使用派生类成员(例如min_qty和discount)的方式与使用基类成员(例如price)的方式没什么不同。

关键概念:遵循基类的接口
必须明确一点:每个类负责定义各自的接口。要想与类的对象交互必须使用该类的接口,即使这个对象是派生类的基类部分也是如此。
派生类对象不能直接初始化基类的成员。尽管从语法上来说我们可以在派生类构造函数体内给它的公有或受保护的基类成员赋值,但是最好不要这么做。和使用基类的其他场合一样,派生类应该遵循基类的接口,并且通过调用基类的构造函数来初始化那些从基类中继承而来的成员。

继承与静态成员

如果基类定义了静态成员,则它在整个继承体系中只存在该成员的唯一定义。不论有几个派生,每个静态成员都只存在唯一实例:

1
2
3
4
5
6
7
class Base {
public:
static void statmem () ;
};
class Derived : public Base {
void f (const Derived&) ;
};

静态成员遵循通用的访问控制规则,如果基类中的成员是private的,则派生类无权访问它。假设某静态成员是可访问的,则我们既能通过基类使用它也能通过派生类使用它

1
2
3
4
5
6
7
8
void Derived::f(const Derived &derived_obj)
{
Base::statmem (); //正确:Base定义了statmem
Derived::statmem (); //正确:Derived继承了statmem
//正确:派生类的对象能访问基类的静态成员
derived_obj.statmem () ;//通过Derived对象访问
statmem (); //通过this对象访问
}

派生类的声明

派生类声明方式和一般类一样:

1
2
class Bulk_quote : public Quote;//错误:派生列表不能出现在这里
class Bulk_quote; //正确:声明派生类的正确方式

被用作基类的类

如果想将派生类作为基类,则必须已经被定义:

1
2
3
class Quote;				//声明但未定义
//错误:Quote必须被定义
class Bulk_quote : public Quote { ... };

派生类包含从基类中来的成员,为了使用它们,必须先知道它们。也表明了一个类不能派生它本身。

一个类是基类,同时也可以是派生类:

1
2
3
class Base {/* ...*/ };
class D1: public Base {/* ...*/ };
class D2: public D1 {/* ...*/ };

在这个继承关系中,Base是D1的**直接基类( direct base),同时是D2的间接基类( indirectbase)**。直接基类出现在派生列表中,而间接基类由派生类通过其直接基类继承而来。

每个类都会继承直接基类的所有成员。对于一个最终的派生类来说,它会继承其直接基类的成员;该直接基类的成员又含有其基类的成员;依此类推直至继承链的顶端。

防止继承的发生

类名后加上final可防止类被继承:

1
2
3
4
5
6
class NoDerived final { /** / };			//NoDerived不能作为基类
class Base {/* */ };
// Last是final的;我们不能继承
Lastclass Last final : Base {/**/ }; // Last不能作为基类
class Bad : NoDerived{/**/}; //错误:NoDerived是final的
class Bad2 : Last {/* */ }; //错误: Last是final的

类型转换与继承

在继承关系的类中,基类的指针和引用可以绑定到派生类对象上,所以使用基类指针或者引用时,并不清楚绑定对象的真实类型。

和内置指针一样,智能指针类也支持派生类向基类的类型转换,这意味着我们可以将一个派生类对象的指针存储在一个基类的智能指针内。

静态类型与动态类型

在继承关系中必须区分两种类型,表达式的静态类型在编译时总是已知的,它是变量声明时的类型或表达式生成的类型,动态类型则是变量或表达式表示的内存中的对象的类型。动态类型直到运行时才可知。

1
2
//当print_total调用net_price 时(参见15.1节,第527页):
double ret = item.net_price (n) ;

item在前面时用Quote&去定义的,那么Quote&就是item的静态类型,动态类型知道在运行时调用该函数才会知道。如果传递一个Bulk_quote对象给print_total,则item的动态类型于静态类型不一致。只有引用和指针动态和静态类型才会不一致。

不存在基类想派生类的隐式类型转换

派生类可以向基类转换是因为每一个派生类对象都包含一个基类的部分。所以一个基类对象既可以独立存在可以作为派生类的一部分存在。但任何派生类对象都不可以向基类隐式的转换,包括引用和指针。

总结:要想理解在具有继承关系的类之间发生的类型转换,有三点非常重要

  • 从派生类向基类的类型转换只对指针或引用类型有效。
  • 基类向派生类不存在隐式类型转换。
  • 和任何其他成员一样,派生类向基类的类型转换也可能会由于访问受限而变得不可行。我们将在15.5节(第544页)详细介绍可访问性的问题。

尽管自动类型转换只对指针或引用类型有效,但是继承体系中的大多数类仍然(显式或隐式地)定义了拷贝控制成员。因此,我们通常能够将一个派生类对象拷贝、移动或赋值给一个基类对象。不过需要注意的是,这种操作只处理派生类对象的基类部分。

虚函数

在我们使用基类的引用或指针调用虚函数时会执行动态绑定。因为知道运行时才知道调用了哪个版本,所以所有的虚函数都必须有定义,不论它是否被用到。

对虚函数的调用可能在运行时才被解析

虚函数执行哪个版本完全依赖于运行时绑定到其对象的实际动态类型:

1
2
3
4
Quote base("0-201-82470-1", 50);
print_total(cout, base, 10); //调用Quote::net price
Bulk_quote derived("0-201-82470-1", 50, 5, .19);
print_total(cout, derived, 10); //调用Bulk_quote::net_price

依据调用对象的不同,会调用两种不同的函数。此外动态绑定也只有当通过指针或引用调用虚函数才会发生:

1
2
base = derived;			//把derived的Quote部分拷贝给base
base.net_price (20); //调用Quote::net price

关键概念:C++的多态性
OOP的核心思想是多态性(polymorphism)。多态性这个词源自希腊语,其含义是“多种形式”。我们把具有继承关系的多个类型称为多态类型,因为我们能使用这些类型的“多种形式”而无须在意它们的差异。引用或指针的静态类型与动态类型不同这一事实正是C++语言支持多态性的根本所在。

当我们使用基类的引用或指针调用基类中定义的一个函数时,我们并不知道该函数真正作用的对象是什么类型,因为它可能是一个基类的对象也可能是一个派生类的对象。如果该函数是虚函数,则直到运行时才会决定到底执行哪个版本,判断的依据是引用或指针所绑定的对象的真实类型。

另一方面,对非虚函数的调用在编译时进行绑定。类似的,通过对象进行的函数(虚函数或非虚函数)调用也在编译时绑定。对象的类型是确定不变的,我们无论如何都不可能令对象的动态类型与静态类型不一致。因此,通过对象进行的函数调用将在编译时绑定到该对象所属类中的函数版本上。

当且仅当对通过指针或引用调用虚函数时,才会在运行时解析该调用,也只有在这种情况下对象的动态类型才有可能与静态类型不同。

派生类中的虚函数

当我们在派生类中可以再一次使用virtual关键字指出函数的性质。一个派生类的函数如果覆盖了某个继承而来的虚函数,则它的形参、返回类型必须与被覆盖的基类函数完全一致。有一个例外是当类的虚函数返回类型是类本身的指针和引用时,规则无效:也就是说,如果D由B派生得到,则基类的虚函数可以返回B*而派生类的对应函数可以返回D*,只不过这样的返回类型要求从D到B的类型转换是可访问的。

final和override

我们使用override来覆盖继承而来的虚函数,但该函数并没有覆盖已存在的虚函数,此时编译器将会报错:

1
2
3
4
5
6
7
8
9
10
11
struct B{
virtual void f1 (int) const;
virtual void f2 ();
void f3();
};
struct D1 : B {
void f1(int) const override; //正确:f1与基类中的f1匹配
void f2(int) override; //错误:B没有形如2 (int)的函数
void f3() override; //错误:f3不是虚函数
void f4() override; //错误:B没有名为f4的函数
};

我们还能把某个函数指定为final,如果我们已经把函数定义成final了,则之后任何尝试覆盖该函数的操作都将引发错误:

1
2
3
4
5
6
7
8
9
10
struct D2 : B {
//从B继承f2()和f3 (),覆盖f1 (int)
void f1(int) const final;//不允许后续的其他类覆盖f1(int)
};
struct D3 : D2 {
void f2();
// 正确:覆盖从间接基类B继承而来的f2
void f1(int) const;
//错误:D2已经将f2声明成final
};

final和 override说明符出现在形参列表(包括任何const或引用修饰符)以及尾置返回类型之后。

虚函数与默认实参

虚函数可以有默认实参,如果某次函数调用使用默认实参,则实参值由静态类型决定:

换句话说,如果我们通过基类的引用或指针调用函数,则使用基类中定义的默认实参,即使实际运行的是派生类中的函数版本也是如此。此时,传入派生类函数的将是基类函数定义的默认实参。

如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。

回避虚函数的机制

如果希望虚函数调用不要进行动态绑定,而是强迫其执行某个版本,可以使用作用域运算符:

1
2
//强行调用基类中定义的函数版本而不管baseP的动态类型到底是什么
double undiscounted = baseP->Quote::net_price(42);

此代码在编译时就可以完成解析

通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制。

如果一个派生类虚函数需要调用它的基类版本,但是没有使用作用域运算符,则在运行时该调用将被解析为对派生类版本自身的调用,从而导致无限递归

抽象基类

我们可以将一个函数定义为纯虚函数,这样做的目的时为了告诉用户,当前此函数没有任何意义,所以它无需定义,通过在函数体位置添加=0就可以声明一个纯虚函数,它只能出现在类内部虚函数声明语句处:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//用于保存折扣值和购买量的类,派生类使用这些数据可以实现不同的价格策略
class Disc_quote : public Quote {
public:
Disc_quote() = default;
Disc_quote(const std : : string & book, double price,
std : : size_t qty, double disc) :
Quote(book, price),
quantity(qty), discount(disc) { }
double net_price(std : : size_t) const = 0;
protected:
std : : size_t quantity = 0;
//折扣适用的购买量
double discount - 0.0;
//表示折扣的小数值
};

我们也可以为纯虚函数提供定义,不过函数体必须定义在类的外部。也就是说,我们不能在类的内部为一个=0的函数提供函数体。

含有纯虚函数的类是抽象基类

含有纯虚函数的类是抽象基类,这种类只负责定义接口,后续的其他类可以覆盖接口,我们不可以直接创建一个抽象基类的对象,因为此时的其中的纯虚函数并没有被定义,我们可以在派生类中覆盖此函数,然后就可以创建派生类的对象。

1
2
3
// Disc_quote声明了纯虚函数,而Bulk_quote将覆盖该函数
Disc_quote discounted; //错误:不能定义 Disc quote的对象
Bulk_quote bulk; //正确:Bulk quote中没有纯虚函数

如果不给出纯虚函数的定义,它任然是抽象基类。

派生类构造函数只初始化它的直接基类

重新实现Bulk_quote,让它继承Disc_quote:

1
2
3
4
5
6
7
8
9
10
11

//当同一书籍的销售量超过某个值时启用折扣
//折扣的值是一个小于1的正的小数值,以此来降低正常销售价格
class Bulk_quote : public Disc_quote {
public:
Bulk_quote() = default;
Bulk_quote(const std : : string & book, double price,
std : : size_t qty, double disc) :
Disc_quote(book, price, qty, disc) { };//覆盖基类中的函数版本以实现一种新的折扣策略
double net_price(std : : size_t) const override;
};

这个版本的 Bulk_quote 的直接基类是 Disc_quote,间接基类是 Quote。每个Bulk quote对象包含三个子对象:一个(空的)Bulk_quote部分、一个 Disc_quote子对象和一个Quote子对象。

每个类各自控制其对象的初始化过程。因此,即使Bulk_quote没有自己的数据成员,也必须提供构造函数,在构造函数中调用直接基类的构造函数,进而继续调用间接基类 的构造函数。

关键概念:重构
在Quote的继承体系中增加Disc_quote类是重构(refactoring)的一个典型示例。

重构负责重新设计类的体系以便将操作和/或数据从一个类移动到另一个类中。对于面向对象的应用程序来说,重构是一种很普遍的现象。

值得注意的是,即使我们改变了整个继承体系,那些使用了Bulk_quote或Quote的代码也无须进行任何改动。不过一旦类被重构(或以其他方式被改变),就意味着我们必须重新编译含有这些类的代码了。

访问控制与继承

每个类分别控制自己的成员初始化过程,与之类似,每个类还分别控制着其成员对于派生类来说是否可访问( accessible)。

受保护的成员

如前所述,一个类使用protected关键字来声明那些它希望与派生类分享但是不想被其他公共访问使用的成员。protected说明符可以看做是 public和 private 中和后的产物:

  • 和私有成员类似,受保护的成员对于类的用户来说是不可访问的。
  • 和公有成员类似,受保护的成员对于派生类的成员和友元来说是可访问的。
  • 派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问特权。
1
2
3
4
5
6
7
8
9
10
11
12
13
class Base {
protected:
int prot_mem; // protected成员
};
class Sneaky : public Base {
friend void clobber(Sneaky&); //能访问Sneaky: :prot_mem
friend void clobber(Base&); //不能访问Base: :prot_mem
int j; // j默认是private
};
//正确:clobber能访问Sneaky对象的private和protected成员
void clobber(Sneaky &s) { s.j = s.prot_mem = 0; }
//错误:clobber不能访问Base的protected成员
void clobber(Base& b) { b.prot_mem = 0; }

例子中派生类成员函数使用基类对象访问受保护的成员是不可行的。

公有、私有、受保护继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Base {
public:
void pub_mem();
// public成员
protected:
int prot_mem;
// protected成员
private:
char priv_mem;
// private成员
};
struct Pub_Derv : public Base {
// 正确:派生类能访问protected成员int f() { return prot_mem; }
//错误:private成员对于派生类来说是不可访问的char g() { return priv_mem;}
};
struct Priv_Derv : private Base {
// 依旧不能访问
int f1() const { return prot_mem; }
};

派生访问说明符对派生类的成员(及友元)能否访问直接基类成员没有影响。访问权限只与基类中的访问说明符有关。派生类只能访问直接基类的受保护的与共有的成员。

派生访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限,概括来说:

  • 如果是共有继承,那么继承而来的成员访问等级不变。
  • 受保护继承,继承而来的访问等级上升一级,public变为protect。
  • 私有继承,所有继承成员访问等级上升到最高,及全部为private

派生类向基类转换的可访问性

派生类向基类的转换(参见15.2.2 节,第530页)是否可访问由使用该转换的代码决定,同时派生类的派生访问说明符也会有影响。假定D继承自B:

  • 只有当D 公有地继承B时,用户代码才能使用派生类向基类的转换;如果D继承B的方式是受保护的或者私有的,则用户代码不能使用该转换。
  • 不论D以什么方式继承B,D的成员函数和友元都能使用派生类向基类的转换;派生类向其直接基类的类型转换对于派生类的成员和友元来说永远是可访问的。
  • 如果D继承B的方式是公有的或者受保护的,则D的派生类的成员和友元可以使用D向B的类型转换;反之,如果D继承B的方式是私有的,则不能使用。

对于代码中的某个给定节点来说,如果基类的公有成员是可访问的,则派生类向基类的类型转换也是可访问的;反之则不行。

关键概念:类的设计与受保护的成员
不考虑继承的话,我们可以认为一个类有两种不同的用户:普通用户和类的实现者。

其中,普通用户编写的代码使用类的对象,这部分代码只能访问类的公有(接口)成员;实现者则负责编写类的成员和友元的代码,成员和友元既能访问类的公有部分,也能访问类的私有(实现)部分。

如果进一步考虑继承的话就会出现第三种用户,即派生类。基类把它希望派生类能够使用的部分声明成受保护的。普通用户不能访问受保护的成员,而派生类及其友元仍旧不能访问私有成员。

和其他类一样,基类应该将其接口成员声明为公有的;同时将属于其实现的部分分成两组:一组可供派生类访问,另一组只能由基类及基类的友元访问。对于前者应该声明为受保护的,这样派生类就能在实现自己的功能时使用基类的这些操作和数据;对于后者应该声明为私有的。

友元与继承

就像友元关系不能传递一样,友元关系同样也不能继承。基类的友元在访问派生类成员时不具有特殊性,类似的,派生类的友元也不能随意访问基类的成员:

1
2
3
4
5
6
7
8
9
10
11
class Base {
//添加friend声明,其他成员与之前的版本一致
friend class Pal; // Pal在访问Base的派生类时不具有特殊性
};
class Pal {
public:
int f(Base b) { return b.prot_mem; } //正确: Pal是 Base的友元
int f2(Sneaky s) { return s.j; } //错误: Pal不是Sneaky的友元
//对基类的访问权限由基类本身控制,即使对于派生类的基类部分也是如此
int f3(Sneaky s) { return s.prot_mem; } //正确: Pal是Base的友元
};

在f2中j是Sneaky成员而不是Base成员,由于pal只是Base的友元所以不能访问其派生类成员。而f3访问的是Sneaky中的Base成员所以可以访问。

当一个类将另一个类声明为友元时,这种友元关系只对做出声明的类有效。对于原来那个类来说,其友元的基类或者派生类不具有特殊的访问能力:

1
2
3
4
5
6
7
8
// D2对Base 的 protected和private成员不具有特殊的访问能力class D2 : public Pal {
public:
int mem(Base b)
{
return b.prot_mem;
}
//错误:友元关系不能继承
};

改变个别成员的可访问性

使用using声明可以改变和继承某个名字的访问级别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Base {
public:
std : : size_t size() const { return n; }
protected:
std : : size_t n;
};
class Derived : private Base {
//注意:private继承
public:
//保持对象尺寸相关的成员的访问级别
using Base : : size;
protected:
using Base : : n;
};

using使得不论如何继承而来的成员在保持using前一个访问说明符的访问等级,如size是public,n是protect。但是派生的类只能为可以访问的名字提供using声明

默认的继承保护级别

默认情况下class定义派生类是私有继承,而struct是共有继承。但更建议显示的声明出来。

继承中的类作用域

派生类的作用域嵌套在基类内,例如:

1
2
Bulk_quote bulk;
cout <<bulk.isbn () ;

名字isbn的解析将按照下述过程所示:

  • 因为我们是通过Bulk_quote的对象调用isbn的,所以首先在Bulk_quote中查找,这一步没有找到名字isbn
  • 因为Bulk quote是 Disc quote 的派生类,所以接下来在 Disc_quote 中查找,仍然找不到。
  • 因为 Disc_quote是 Quote的派生类,所以接着查找Quote;此时找到了名字isbn,所以我们使用的isbn最终被解析为Quote中的isbn。

编译时进行名字查找

一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。即使静态类型与动态类型可能不一致(当使用基类的引用或指针时会发生这种情况),但是我们能使用哪些成员仍然是由静态类型决定的。

这是因为当使用基类指针时,可使用的成员已经确定,如果使用它绑定一个派生类对象,且派生类对象又新增了几个成员,此时对于这个指针来说,它并不知道这些多出来的成员,自然也无法使用它们。

名字冲突与继承

如果派生类中定义了其基类同名的成员,那么此成员将会隐藏基类中的同名成员,就像局部变量被优先使用。主要还是因为派生类的作用域嵌套在基类内。

通过作用域运算符使用隐藏成员

通过在同名成员前加上作用域就可以调用它:

1
2
3
struct Derived : Base {
int get_base_mem() { return Base : :mem;} //...
};

建议还是不要同名。

*关键概念:名字查找与继承
理解函数调用的解析过程对于理解C++的继承至关重要,假定我们调用p->mem ()(或者obj .mem()),则依次执行以下4个步骤:

  • 首先确定p(或obj)的静态类型。因为我们调用的是一个成员,所以该类型必然是类类型。
  • 在p(或obj)的静态类型对应的类中查找mem。如果找不到,则依次在直接基类中不断查找直至到达继承链的顶端。如果找遍了该类及其基类仍然找不到,则编译器将报错。
  • 一旦找到了mem,就进行常规的类型检查(参见6.1节,第183页)以确认对于当前找到的 mem,本次调用是否合法。
  • 假设调用合法,则编译器将根据调用的是否是虚函数而产生不同的代码:
    • 如果 mem是虚函数且我们是通过引用或指针进行的调用,则编译器产生的代码将在运行时确定到底运行该虚函数的哪个版本,依据是对象的动态类型。
    • 反之,如果mem不是虚函数或者我们是通过对象(而非引用或指针)进行的调用,则编译器将产生一个常规函数调用。

一如既往,名字查找先于类型检查

如前所述,声明在内层作用域的函数并不会重载声明在外层作用域的函数。因此,定义派生类中的函数也不会重载其基类中的成员。和其他作用域一样,如果派生类(即内层作用域)的成员与基类(即外层作用域)的某个成员同名,则派生类将在其作用域内隐藏该基类成员。即使派生类成员和基类成员的形参列表不一致。

虚函数与作用域

现在可以理解为什么基类与派生类的虚函数必须有相同的形参列表了,如果不相同就会隐藏同名成员,而不是覆写。不然就无法通过基类引用或指针访问派生类的对象了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Base {
public:
virtual int fcn();
};
class D1 : public Base {
public:
//隐藏基类的fcn,这个fcn不是虚函数
// D1继承了Base : :fcn ()的定义
int fcn(int); //形参列表与Base 中的fcn不一致
virtual void f2(); //是一个新的虚函数,在 Base中不存在
};
class D2 : public D1 {
public:
int fcn(int); //是一个非虚函数,隐藏了D1 : : fcn (int)
int fcn(); //覆盖了Base的虚函数fcn
void f2(); //覆盖了D1的虚函数f2
}

通过基类调用隐藏的虚函数

1
2
3
4
5
6
7
8
9
Base bobj; D1 d1obj; D2 d2obj;
Base* bp1l = &bobj, * bp2 = &dlobj, * bp3 = &d2obj;
bp1->fcn(); //虚调用,将在运行时调用 Base : : fcn
bp2->fcn(); //虚调用,将在运行时调用 Base : : fcn
bp3->fcn(); //虚调用,将在运行时调用D2 : :fcn
D1* dlp = &dlobj; D2* d2p = &d2obj;
bp2->f2(); //错误:Base没有名为f2的成员
dlp->f2(); //虚调用,将在运行时调用D1 : :f2()
d2p->f2(); // 虚调用,将在运行时调用D2: : f2()

对于调用非虚函数,并不会发生动态绑定,由指针的类型决定。

1
2
3
4
Base *pl = &d2obj; D1 *p2 = &d2obj; D2 *p3 = &d2obj;
p1->fcn (42); //错误:Base中没有接受一个int的fcn
p2->fcn (42); //静态绑定,调用D1 : :fcn(int)
p3->fcn ( 42); //静态绑定,调用D2 : :fcn (int)

覆盖重载函数

和其他函数一样,成员函数无论是否是虚函数都能被重载。派生类可以覆盖重载函数的0个或多个实例。如果派生类希望所有的重载版本对于它来说都是可见的,那么它就需要覆盖所有的版本,或者一个也不覆盖。

有时一个类仅需覆盖重载集合中的一些而非全部函数,此时,如果我们不得不覆盖基类中的每一个版本的话,显然操作将极其烦琐。

一种好的解决方案是为重载的成员提供一条using声明语句(参见15.5节,第546页),这样我们就无须覆盖基类中的每一个重载版本了。using声明语句指定一个名字而不指定形参列表,所以一条基类成员函数的using声明语句就可以把该函数的所有重载实例添加到派生类作用域中。此时,派生类只需要定义其特有的函数就可以了,而无须为继承而来的其他函数重新定义。

构造函数与拷贝控制

虚析构函数

继承关系对基类拷贝控制最直接的影响是基类通常应该定义一个虚析构函数(参见15.2.1节,第528页),这样我们就能动态分配继承体系中的对象了。

当delete一个动态分配的对象的指针时执行析构函数,但如果指针指向了其“子孙”,则有可能出现指针静态类型与被删除对象的动态类型不符。所以必须在基类中将析构函数定义为析构函数保证函数的执行:

1
2
3
4
5
class Quote {
public:
//如果我们删除的是一个指向派生类对象的基类指针,则需要虚析构函数
virtual ~Quote() = default; // 动态绑定析构函数
};

如果基类的析构函数不是虚函数,则delete一个指向派生类对象的基类指针将产生未定义的行为。

在之前的准则中:如果一个类需要一个析构函数,那么它同样也需要拷贝和赋值操作。但基类的析构是一个重要的例外。它的虚函数并没有内容所以可以没有其他操作。

虚析构函数阻止合成移动

基类需要一个虚析构函数这一事实还会对基类和派生类的定义产生另外一个间接的影响:如果一个类定义了析构函数,即使它通过=default的形式使用了合成的版本,编译器也不会为这个类合成移动操作。

合成拷贝控制与继承

这里的合成的拷贝,赋值或析构与普通类似,

基类或派生类的合成拷贝控制成员的行为与其他合成的构造函数、赋值运算符或析构
函数类似:它们对类本身的成贝依城优对一个对象的直接基类部分进行初始化、赋值或销成员还负责使用直接基类中对应的操作对一个对象的直接基类部分进行初始化、赋值或销毁的操作。例如,

  • 合成的Bulk quote默认构造函数运行Disc_quote 的默认构造函数,后者又运行Quote的默认构造函数。
  • Quote 的默认构造函数将bookNo成员默认初始化为空字符串,同时使用类内初始值将price初始化为0。
  • Quote的构造函数完成后,继续执行Disc_quote的构造函数,它使用类内初始值初始化 qty和discount。
  • Disc_quote的构造函数完成后,继续执行Bulk_quote的构造函数,但是它什么具体工作也不做。

拷贝构造也是类似,此外基类成员是合成或自定义都没有影响,但唯一的要求是成员应该可以访问比关切不是被删除的。

如前所述,Quote因为定义了析构函数而不能拥有合成的移动操作,因此当我们移动Quote对象时实际使用的是合成的拷贝操作。如我们即将看到的那样,Quote没有移动操作意味着它的派生类也没有。

派生类中删除的拷贝控制与基类的关系

  • 如果基类中基础操作(构造、拷贝。。。)是删除的函数或不可访问,那么派生类对应的成员也是被删除的,因为派生类不能通过这些基类成员来为基类执行这些操作。
  • 如果在基类中有一个不可访问或删除掉的析构函数,则派生类中合成的默认和拷贝构造函数将是被删除的,因为编译器无法销毁派生类对象的基类部分。
  • 和过去一样,编译器将不会合成一个删除掉的移动操作。当我们使用=default请求一个移动操作时,如果基类中的对应操作是删除的或不可访问的,那么派生类中该函数将是被删除的,原因是派生类对象的基类部分不可移动。同样,如果基类的析构函数是删除的或不可访问的,则派生类的移动构造函数也将是被删除的。
1
2
3
4
5
6
7
8
9
10
11
12
class B {
public:
B();
B(const B&) = delete;
//其他成员,不含有移动构造函数
};
class D : public B {
//没有声明任何构造函数
};
D d; //正确:D的合成默认构造函数使用B的默认构造函数
D d2(d); //错误:D的合成拷贝构造函数是被删除的
D d3(std: : move(d)); //错误:隐式地使用D的被删除的拷贝构造函数

移动操作与继承

如前所述,大多数基类都会定义一个虚析构函数。因此在默认情况下,基类通常不含有合成的移动操作,而且在它的派生类中也没有合成的移动操作。

因为基类缺少移动操作会阻止派生类拥有自己的合成移动操作,所以当我们确实需要执行移动操作时应该首先在基类中进行定义。我们的Quote可以使用合成的版本,不过前提是Quote必须显式地定义这些成员。一旦 Quote定义了自己的移动操作,那么它必须同时显式地定义铂贝操作(参见13.6.2节,第476页):

1
2
3
4
5
6
7
8
9
10
11
12
class Quote {
public:
Quote() = default;
//对成员依次进行默认初始化
Quote(const Quote&) = default;
//对成员依次拷贝
Quote(Quote&&) = default;
//对成员依次拷贝
Quote& operator=(const Quote&) = default;//拷贝赋值
Quote& operator=(Quote&&) = default;//移动赋值virtual ~Quote() = default;
//其他成员与之前的版本一致
};

派生类的拷贝控制成员

派生类的拷贝和移动构造函数不仅仅要负责自己的成员初始化还有基类成员。而析构函数只负责销毁派生类自己分配的资源

当派生类定义了拷贝或移动操作时,该操作负责铂贝或移动包括基类部分成员在内的整个对象。

定义派生类的拷贝或移动构造函数

派生类拷贝或移动构造通常是应用对应的基类构造来初始化对象的基类部分:

1
2
3
4
5
6
7
8
9
10
11
12
class Base {/* ...*/ };
class D : public Base {
public:
//默认情况下,基类的默认构造函数初始化对象的基类部分
//要想使用拷贝或移动构造函数,我们必须在构造函数初始值列表中
//显式地调用该构造函数
D(const D& d) : Base(d)
//拷贝基类成员
/* D的成员的初始值*/ {/* ...*/ }
D(D&& d) : Base(std : : move(d))//移动基类成员
/* D的成员的初始值*/ {/* ... */}
};

将D类型的对象d传递给基类的拷贝构造函数。这个构造负责将d的基类部分拷贝给要创建的对象。如果d并没有基类初始值,则默认初始化。

在默认情况下,基类默认构造函数初始化派生类对象的基类部分。如果我们想拷贝(或移动)基类部分,则必须在派生类的构造函数初始值列表中显式地使用基类的拷贝(或移动)构造函数。

派生类赋值运算符

派生类的赋值运算符也必须显示地为其基类部分赋值。

1
2
3
4
5
6
7
// Base: : operator=(const Base&)不会被自动调用
D D: :operator=(const D &rhs)
{
Base: :operator=(rhs); //为基类部分赋值
//按照过去的方式为派生类的成员赋值
//酌情处理自赋值及释放已有资源等情况return *this;
}

调用基类的赋值操作无论基类的构造函数或赋值运算符是合成还是自定义,派生类的对应操作都能够使用它们。

派生类析构函数

派生类析构函数只负责销毁由派生类自己分配的资源:

1
2
3
4
5
class D : public Base {
public:
//Base : : ~Base被自动调用执行
~D() {/*该处由用户定义清除派生类成员的操作*/ }
};

对象销毁的顺序与创建的顺序相反。从子孙到祖先。

在构造和析构函数中调用虚函数

继承的构造函数

一个类只能初始化的直接基类,一个类也只能继承其直接类的构造函数。类不能默认继承默认的拷贝和移动构造函数。如果派生类没有直接定义这些构造函数,则编译器会为派生类合成。

派生类继承基类构造函数的方式是提供一条注明了(直接)基类名的using声明语句。举个例子,我们可以重新定义Bulk_quote类,令其继承Disc_quote类的构造函数:

1
2
3
4
5
class Bulk_quote : public Disc_quote {
public:
using Disc_quote : : Disc_quote;//继承Disc_quote的构造函数
double net_price(std: :size_t) const;
};

通常情况下,using声明语句只是令某个名字在当前作用域内可见。而当作用于构造函数时,using声明语句将令编译器产生代码。对于基类的每个构造函数,编译器都生成一个与之对应的派生类构造函数。换句话说,对于基类的每个构造函数,编译器都在派生类中生成一个形参列表完全相同的构造函数。

生成的构造函数为:

1
2
3
4
5
derived (parms) : base (args){ }
//在我们的 Bulk_quote类中,继承的构造函数等价于:
Bulk quote(const std : : string & book, double price,
std : : size_t qty, double disc) :
Disc quote(book, price, qty, disc) {}

derived是派生类的名字,base是基类的名字,parms是构造函数的形参列表,args将派生类构造函数的形参传递给基类的构造函数。

继承的构造函数的特点

和普通using不一样的是,构造函数的using声明不会改变构造函数的访问级别,且using声明也不能指定exlicit或者constexpr。继承的构造函数会具有相同的属性。

当基类的构造函数含有默认的实参时,这些实参并不会被继承,相反的是,派生类会获得多个继承的构造函数,每个构造函数分别省略掉由默认实参的形参。

容器与继承

当派生类对象被赋值给基类对象时,其中的派生类部分将被“切掉”,因此容器和存在继承关系的类型无法兼容。

在容器中放置智能指针而非对象

容器中防止继承关系的对象时,通常存放的是基类的指针(智能指针更好)。

编写Basket类

我们定义一个表示购物篮的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Basket {
public:
//Basket使用合成的默认构造函数和拷贝控制成员
void add_item(const std : : shared_ptr<Quote>& sale) { items.insert(sale); }
//打印每本书的总价和购物篮中所有书的总价
double total_receipt(std : : ostream&)const;
private:
//该函数用于比较shared_ptr,multiset成员会用到它
static bool compare(const std : : shared_ptr<Quote>& lhs,
const std : : shared_ptr<Quote>& rhs)
{
return lhs->isbn() < rhs->isbn();
}
//multiset保存多个报价,按照compare成员排序
std::multiset<std::shared ptr<Quote>, decltype (compare)*> items{ compare };
};

我们的类使用一个multiset(参见11.2.1节,第377页)来存放交易信息,这样我们就能保存同一本书的多条交易记录,而且对于一本给定的书籍,它的所有交易信息都保存在一起(参见11.2.2节,第 378页)。

这个声明看起来不太容易理解,但是从左向右读的话,我们就能明白它其实是定义了一个指向Quote对象的shared ptr的multiset。这个multiset将使用一个与compare成员类型相同的函数来对其中的元素进行排序。multiset成员的名字是 items,我们初始化items并令其使用我们的compare函数。

定义Basket的成员

这个成员的名字是total_receipt,它负责将购物篮的内容逐项打印成清单,然后返回购物篮中所有物品的总价格

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
double Basket : : total_receipt(ostream& os) const
{
double sum = 0.0; //保存实时计算出的总价格
// iter指向ISBN相同的一批元素中的第一个
// upper_bound返回一个迭代器,该迭代器指向这批元素的尾后位置
for (auto iter = items.cbegin();
iter != items.cend();
iter = items.upper_bound(*iter)){
//我们知道在当前的Basket中至少有一个该关键字的元素
//打印该书籍对应的项目
sum += print_total(os, **iter, items.count(*iter));
}
os << "Total sale: " << sum << endl; //打印最终的总价格
return sum;
}

这里的for循环中upper函数会跳过相同的书直接指到下一种书。此外在print_total函数中,第二个参数第一次解引用得到一个指向该对象的指针指针,再一次解引用才能得到这个Quto对象(或派生对象)。使用multiset统计有多少个相同元素。

隐藏指针

接下的需要定义add_item成员,先看使用:

1
2
3
Basket bsk;
bsk.add_item (make_shared<Quote> ( "123",45));
bsk,add_item (make_shared<Bulk_quote> ("345"453.15));

下一步重新定义add_item使它接受一个Quote对象而非shared_ptr,新版本的add_item将负责内存的分配。定义两个版本,一个拷贝它给定的对象,另一个采取移动操作。

1
2
void add_item (const Quote& sale) ;			//拷贝给定的对象
void add_item (Quote&& sale) ; //移动给定的对象

此时遇到的问题是,函数不知道分配的类型,若new Quote(sale),则可能不正确,传入其派生类的对象时,会被切掉一部分。

模拟虚拷贝

我们给Quote添加一个虚函数,函数申请一份当前对象的拷贝。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Quote {
public:
//该虚函数返回当前对象的一份动态分配的拷贝
//这些成员使用的引用限定符参见13.6.3节(第483页)
virtual Quote* clone () const & {return new Quote (*this);}
virtual Quote* clone() &&
{ return new Quote(std : : move (*this)); }
//其他成员与之前的版本一致
};
class Bulk_quote : public Quote {
Bulk_quote* clone () const & (return new Bulk_quote (*this); }
Bulk_quote* clone () &&
{return new Bulk_quote (std: : move ( *this)) ;}
//其他成员与之前的版本一致
};

因为我们拥有add_item的拷贝和移动版本,所以我们分别定义clone的左值和右值版本。

使用clone写出新版的add_item:

1
2
3
4
5
6
7
8
9
10
11
12
class Basket {
public:
void add_item(const Quote& sale) //拷贝给定的对象
{
items.insert(std: :shared_ptr<Quote>(sale.clone()));
}void add_item(Quote&& sale) //移动给定的对象
{
items.insert(
std: :shared_ptr<Quote>(std: :move(sale).clone()));
}
//其他成员与之前的版本一致
};

clone根据作用与左值右值分不同的版本,add_item也有调用不同版本的重载。Sale的动态类型决定了运行Quote还是Bulk_quote函数。然后为这个对象绑定智能指针放在容器内。

​ 此为C++ Primer中第十二章中的一个文本查询程序,我想通过自己的分析来更加透彻的理解整个程序的设计,于是将此节单独作为一章重点去看:

文本查询程序

设计思路

需求

  • 当程序读取输入文件时,它必须记住单词出现的每一行。因此,程序需要逐行读取输入文件,并将每一行分解为独立的单词
  • 当程序生成输出时,
    • 它必须能提取每个单词所关联的行号
    • 行号必须按升序出现且无重复
    • 它必须能打印给定行号中的文本。

例子

当输入element时,得到的是:

image.png

分析

​ 首先看这里我认为最重要一个对应关系,单词→行号。每一个单词都有n行与之对应,那么容易想到的方法是用容器将每个单词的行号记录下来,由于每个行号只出现一次且需要升序排列,所以最优解就是使用set容器,接着如何实现通过行号找到那一行文字呢?我们可以想到使用vector<string>按行号保存文本。此外使用一个map可以将单词与它对应的set容器绑定起来,就可以实现,单词对应行号,行号对应文本。

​ 还有一个问题是:如何建立起这个map和set,我们定义一个函数,命名为istringstream,思路大致是从vector读取每个元素(每行文本),访问每个单词前首先判断map中没有这个单词,如果有:就将对应的set中加入这一次遍历的下标(即行号),如果没有:就创建一个set,并将单词与set加入map中。

大致的类

​ 我们需要将这些结构与操作串联起来,从定义一个保存输入文件的类开始,将类命名为TextQuery,它包含一个vector和一个map。我们用这个类来构造vector和map,并且执行查询操作。

​ 之后如果我们查到了到底要返回什么呢?返回那么一大串的东西最好的方法就是定义另一个类,这个类内应该有一个打印操作。我们将它命名为QueryResult。

类之间的数据共享

​ 再仔细看一下这个QueryResult类,由于我们必须要返回文本,所以要用到提到过的保存整个文本的vector,然而它是第一类的成员,我们拷贝一份貌似没有必要,因为我们只需要调用其中很小的一部分,这样会造成大量的浪费,那么使用一个类内的迭代器(或指针)可以嘛?答案也是不行,因为如果第一个类在第二个类之前销毁,那么打印操作就会访问一个不存在的对象中的数据。最好的办法就是使用我们“最牛逼”的shared_ptr来反映数据结构中的共享关系。

设计类之前先使用类

​ 当我们设计一个类时,在真正实现成员之前先编写程序使用这个类,是一种非常有用的方法。通过这种方法,可以看到类是否具有我们所需要的操作。例如,下面的程序使用了TextQuery和 QueryResult类。这个函数接受一个指向要处理的文件的ifstream,并与用户交互,打印给定单词的查询结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void runQueries(ifstream &infile)
{
//infile是一个ifstream,指向我们要处理的文件
TextQuery tq(infile); //保存文件并建立查询map
//与用户交互:提示用户输入要查询的单词,完成查询并打印结果
while (true){
cout << "enter word to look for, or q to quit: ";
string s;
//若遇到文件尾或用户输入了'q'时循环终止
if(!(cin >> s) || s == "q") break;
//指向查询并打印结果
print(cout, tq.query(s)) << endl;
}
}

类的定义

TextQuery框架

​ 首先是TextQuery,用用户提供的一个istream读取文件,类中应该还有query操作,接受string,返回QueryResult表示string出现的行。我们创建一个头文件命名为textquery,在其中输入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#ifndef TEXTQUERY_H
#define TEXTQUERY_H

#include <vector>
#include <string>
#include <memory>
#include <map>
#include <set>
using std::vector;
using std::string;
using std::shared_ptr;

class QueryResult;// 为了定义函数query的返回类型,这个定义是必需的
class TextQuery {
public:
//简化行下标
using line_no = vector<string>::size_type;
//通过用户提供的文本的构造函数
TextQuery(std::ifstream&);
//返回查询结果的函数,由于只是查询所以应该加上const
QueryResult query(const string&) const;
private:
//输入的文件
shared_ptr<vector<string>> file;
//每个单词到它所在的行号的集合的映射
std::map<string, shared_ptr<std::set<line_no>>> wm;
};

#endif

​ 至于这里为什么出现这么多的std,而不是直接引入整个命名空间:在头文件这样做是非常危险的,由于C++头文件通常与源文件分开存放,对于不知道这个头文件里有什么的用户来说,使用这个头文件就有可能会导致很多的命名冲突。这里建议在任何文件中都不要这样做。

QueryResult框架

​ QueryResult类有三个数据成员:一个string,保存查询单词:一个shared_ptr,指向保存输入文件的vector;一个shared ptr,指向保存单词出现行号的set。它唯一的一个成员函数是一个构造函数,初始化这三个数据成员。在刚才的类之后,紧接着写入以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class QueryResult {
//将ostream声明为友元函数,之后用来打印结果
friend std::ostream& print(std::ostream&, const QueryResult&);
public:
//构造函数
QueryResult(string s,
shared_ptr<set<TextQuery::line_no>> p,
shared_ptr<vector<string>> f) :
sought(s), lines(p), file(f) {}
private:
// 查询单词
string sought;
//出现的行号
shared_ptr<set<TextQuery::line_no>> lines;
//输入文件
shared_ptr<vector<string>> file;
};

这里为什么不在TextQuery类之前那里写呢?因为这个类中我们用到了TextQuery中的line_no,这两个类其实是你中有我,我中有你的关系,所以我们在开头需要一个声明,在之后去定义他。

类的实现

TextQuery构造函数

接下来是类的实现,我们创建一个源文件,取名为textquery.cpp,首先是构造函数,直接写入代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//读取输入文件并建立单词到行号的映射
TextQuery::TextQuery(std::ifstream& is) : file(new vector<string>)
{
string text;
//对文件中每一行
while (getline(is, text)) {
//保存此行文本
file->push_back(text);
//当前行号
int n = file->size() - 1;
//将行文本分解为单词
std::istringstream line(text);
string word;
//对行中每个单词
while (line >> word) {
//如果单词不在 wm 中,以之为下标在wm中添加一项
// lines是一个shared_ptr
auto& lines = wm[word];
if (!lines)
//分配一个新的set
lines.reset(new set<line_no>);
lines->insert(n);//将此行号插入set中
}
}
}

这里可以看到在函数体之前就已经为file分配了动态内存,同时由于file是指针类型这里需要使用->来使用push函数,之后使用istringstream处理当前这一行的字符,按空格存入word内,在第二个while循环中,首先定义lines获取在map中的这个单词的set容器,注意这里line是一个只能指针类型,由于有可能是第一次录入改单词,则需要对line进行判断,若不存在则需要新分配一个set,这里同样需要动态的分配内存。最后不论一定要将则一行的行号传入这个set容器中。

query函数

它接受一个string参数,用它在map中对应行号,如果找到就构造一个QueryResult返回。这里有一个问题是如果没有找到返回什么呢?我们可以定义一个局部的static对象,它指向空的行号set的shared_ptr,没有找到就返回它:

1
2
3
4
5
6
7
8
9
10
QueryResult TextQuery::query(const string& sought) const {
//如果未找到sought,我们将返回一个指向此set的指针
static shared_ptr<set<line_no>> nodata(new set<line_no>);
//使用find而不是下标运算符来查找单词,避免将单词添加到wm中!
auto loc = wm.find(sought);
if (loc == wm.end())
return QueryResult(sought, nodata, file);//未找到
else
return QueryResult(sought, loc->second, file);
}

print函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//如果ctr的值大于1,返回word的复数形式
string make_plural(size_t ctr, const string &word, const string &ending)
{
return (ctr > 1) ? word + ending : word;
}

std::ostream& print(std::ostream& os, const QueryResult& qr) {
//如果找到了单词,打印出现次数和所有出现的位置
os << qr.sought << " occurs " << qr.lines->size() << " "
<< make_plural(qr.lines->size(), "time", "s") << std::endl;
for (auto num : *qr.lines)
//避免行号从0开始给用户带来的困惑
os << "\t (line " << num + 1 << ")"
//使用标准库:文本查询程序
<< *(qr.file->begin() + num) << std::endl;
return os;
}

文本查询程序2.0

利用C++继承与多态的性质改进该类, 以实现更多功能。

新的需求

  • 单词查询,用于得到匹配某个给定string的所有行:

    Executing Query for: Daddy
    Daddy occurs 3 times
    (line 2) Her Daddy says when the wind blows
    (line 7) “Daddy , shush, there is no such thing , “
    (line 10) shyly, she asks,”I mean,Daddy ,is there?”

  • 逻辑非查询,使用~运算符得到不匹配查询条件的所有行:

    Executing Query for: ~(Alice)
    ~(Alice) occurs 9 times
    ( line 2) Her Daddy says when the wind blows
    (line 3) through her hair, it looks almost alive,( line 4) like a fiery bird in flight.

  • 逻辑或查询,使用|运算符返回匹配两个条件中任意一个的行:

    Executing Query for: (hair l Alice)
    (hair \ Alice) occurs 2 times
    (line 1) Alice Emma has long flowing red hair.(line 3) through her hair, it looks almost alive,

  • 逻辑与查询,使用&运算符返回匹配全部两个条件的行:

    Executing query for: (hair & Alice)
    (hair &Alice) occurs 1 time
    (line 1)Alice Emma has long f1owing red hair.

  • 此外,我们还希望能够混合使用这些运算符,比如:fiery & bird l wind

    Executing Query for: ((fiery & bird)l wind) ( (fiery & bird) l wind) occurs 3 times
    (line 2) Her Daddy says when the wind blows (line 4) like a fiery bird in flight.
    (line 5) A beautiful fiery bird, he tells her ,

解决方案

我们可以将集中不同的查询建模成相互独立的类,它们共享一个公共基类:

1
2
3
4
wordQuery		// Daddy
NotQuery // ~Alice
orQuery // hair l Alice
AndQuery // hair & Alice

这些类将只包含两个操作:

  • eval,接受一个TextQuery对象并返回一个 QueryResult,eval函数使用给定的TextQuery对象查找与之匹配的行。
  • rep,返回基础查询的string表示形式,eval函数使用rep创建一个表示匹配结果的QueryResult,输出运算符使用rep打印查询表达式。

关键概念:继承与组合

继承体系的设计本身是一个非常复杂的问题,已经超出了本书的范围。然而,有一条设计准则非常重要也非常基础,每个程序员都应该熟悉它。

当我们令一个类公有地继承另一个类时,派生类应当反映与基类的“是一种(IsA)”关系。在设计良好的类体系当中,公有派生类的对象应该可以用在任何需要基类对象的地方。

类型之间的另一种常见关系是“有一个(Has A)”关系,具有这种关系的类暗含成员的意思。

在我们的书店示例中,基类表示的是按规定价格销售的书籍的报价。Bulk_quote“是一种”报价结果,只不过它使用的价格策略不同。我们的书店类都“有一个”价格成员和ISBN成员。

抽象基类

由于这四种查询并不存在继承关系,从概念上来说互为熊底,它们都共享同一个接口,我们定义一个抽象基类Query_base,并把eval和rep定义为纯虚函数。

image.png

将层次关系隐藏于接口类中

我们的程序将致力于计算查询结果,而非仅仅构建查询的体系。为了使程序能正常运行,我们必须首先创建查询命令,最简单的办法是编写C++表达式。例如,可以编写下面的代码来生成之前描述的复合查询:

Query q = Query("fiery") & Query ("bird") | Query ( "wind" ) ;

用户不会直接使用这些类,而是使用Query保存一个Query_base指针,用户将通过 Query 对象的操作间接地创建并处理Query _base对象。我们定义Query对象的三个重载运算符以及一个接受string参数的Query构造函数,这些函数动态分配一个新的Query base派生类的对象:

  • &运算符生成一个绑定到新的AndQuery对象上的Query对象;
  • |运算符生成一个绑定到新的orQuery对象上的Query对象;
  • ~运算符生成一个绑定到新的NotQuery对象上的Query对象;
  • 接受string 参数的Query构造函数生成一个新的wordQuery对象。

image.png

Query_base类和Query类

首先定义Query_base类

1
2
3
4
5
6
7
8
9
10
11
//这是一个抽象基类,具体的查询类型从中派生,所有成员都是private的
class Query_base {
friend class Query;
protected:
using line_no = TextQuery: :line_no; //用于eval函数virtual ~Query_base() = default;
private:
// eval返回与当前Query 匹配的QueryResult
virtual QueryResult eval(const TextQuery&) const = 0;
// rep是表示查询的一个string
virtual std: :string rep() const = 0;
};

把所有操作都纯虚函数,因此Query_base是一个抽象基类,因为不直接使用它,所有没有public成员。

Query类

它负责提供外界的接口,并隐藏继承体系。每个Query对象都含有一个指向Query_base对象的智能指针。

接受一个string参数的Query构造函数将创建一个新的wordQuery对象,然后将它的 shared_ prt成员绑定到这个新创建的对象上。&、|和~运算符分别创建AndQuery、OrQuery和 NotQuery对象,这些运算符将返回一个绑定到新创建的对象上的Query对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//这是一个管理Query base继承体系的接口类
class Query {
//这些运算符需要访问接受shared _ptr的构造函数,而该函数是私有的
friend Query operator~ (const Query &);
friend Query operator/ (const Query&, const Query&);
friend Query operator& (const Query&, const Query&);
public:
Query(const std: :string&);
//构建一个新的wordouery
//接口函数:调用对应的Query base操作
QueryResult eval(const TextQuery& t) const
{
return q->eval(t);
}
std: :string rep() const { return q->rep(); }
private:
Query(std: :shared_ptr<Query_base> query) : q(query){ }
std: :shared_ptr<Query_base> q;
};

Query 的输出运算符

输出运算符可以很好地解释我们的整个查询系统是如何工作的:std: :ostream &
operator<<(std: :ostream &os,const Query &query)
/ / Query: :rep通过它的Query base指针对rep ()进行了虚调用return os<<query.rep (;
当我们打印一个 Query时,输出运算符调用Query类的公有rep成员。运算符函数通过指针成员虚调用当前Query所指对象的rep成员。也就是说,当我们编写如下代码时:
Query andq = Query (sought1) &Query (sought2 );
cout << andq << endl;
输出运算符将调用andq 的 Query: :rep,而 Query: :rep通过它的Query base指针虚调用Query _base版本的rep函数。因为andq指向的是一个AndQuery对象,所以本次的函数调用将运行AndQuery: : rep。

重载运算与类型转换

基本概念

它们由关键字operato和其后要定义的运算符号组成,其他和函数相同。当一个重载的运算符是成员函数时,this绑定到左侧运算对象。成员运算符函数的(显式)参数数量比运算对象的数量少一个。

我们只能重载一部分已有运算符。对于重载的运算符,优先级和结合律与对应内置运算符(原先的)保持一致(不考虑运算对象)。

调用重载运算符

1
2
3
//一个非成员运算符函数的等价调用
datal + data2 ;//普通的表达式
operator+(data1, data2);//等价的函数调用

也可以像调用成员函数一样调用它们

1
2
datal +=data2;				//基于“调用”的表达式
data1.operator+= (data2); //对成员运算符函数的等价调用

某些运算符不应被重载

通常情况下,不应该重载逗号、取地址、逻辑与和逻辑或运算符。

使用与内置类型一致的含义

如果类的操作与运算符相关,才适合重载它们:

  1. 如果类执行IO操作,则定义移位运算符使其与内置类型的IO保持一致。
  2. 如果类的某个操作是检查相等性,则定义 operator==;如果类有了operator==,意味着它通常也应该有operator!=。
  3. 如果类包含一个内在的单序比较操作,则定义 operator<;如果类有了operator<,则它也应该含有其他关系操作。
  4. 重载运算符的返回类型通常情况下应该与其内置版本的返回类型兼容:逻辑运算符和关系运算符应该返回bool,算术运算符应该返回一个类类型的值,赋值运算符和复合赋值运算符则应该返回左侧运算对象的一个引用。

尽量明智的重载它们,使操作符保持原有的操作逻辑

赋值和复合赋值运算符

例如类中有+和=运算符那么最好也重载+=。

选择成员或非成员

下面的准则有助于我们在将运算符定义为成员函数还是普通的非成员函数做出抉择:·

  1. 赋值(=)、下标([ ])、调用(( ))和成员访问箭头(->)运算符必须是成员。
  2. 复合赋值运算符一般来说应该是成员,但并非必须,这一点与赋值运算符略有不同。
  3. 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用运算符,通常应该是成员。
  4. 具有对称性的运算符可能转换任意一端的运算对象,例如算术、相等性、关系和位运算符等,因此它们通常应该是普通的非成员函数。

如果希望在含有混合类型的表达式中使用对称性运算符,例如求int和doubie的和,因为它们都可以是第一个运算对象。所以必须定义为非成员函数。

1
2
3
string s = "world";
string t = s + "!";//正确:我们能把一个const char*加到一个string对象中
string u = "hi" + s; //如果+是string 的成员,则产生错误

输入和输出

重载输出运算符<<

输出运算符的第一个形参是非常量的ostream对象的引用。非常量是因为向流写入内容会改变其状态;使用引用是我们无法赋值一个ostream对象。

Sales_data的输出运算符

1
2
3
4
5
6
ostream &operator<<(ostream &os,const Sales_data &item)
{
os << item.isbn () <<" " << item.units_sold << " "
<< item.revenue << " " << item.avg_price();
return os;
}

与之前类内的print函数一样,打印一个Sales_data类意味着打印三个数据成员。

输出运算符尽量减少格式化

通常,输出运算符应该主要负责打印对象的内容而非控制格式,输出运算符不应该打印换行符。

输入输出运算符必须是非成员函数

与iostream标准库兼容的输入输出运算符必须是普通的非成员函数,而不能是类的成员函数。否则,它们的左侧运算对象将是我们的类的一个对象:

1
2
sales data data;
data << cout; //如果 operator<<是sales_data的成员

重载输入运算符>>

通常情况下,输入运算符的第一个形参是运算符将要读取的流的引用,第二个形参是将要读入到的(非常量)对象的引用。该运算符通常会返回某个给定流的引用。第二个形参之所以必须是个非常量是因为输入运算符本身的目的就是将数据读入到这个对象中。

Sale_data的输入运算符

1
2
3
4
5
6
7
8
9
istream &operator>>(istream &is, Sales_data &item)
{
double price; // 不需要初始化,因为我们将先读入数据到price,之后才使用它
is >> item.bookNo >> item.units_sold >> price;
if(is)//检查输入是否成功
item. revenue = item. units_sold * price;
else
item = Sales_data () ;//输入失败:对象被赋予默认的状态return is;
}

if的判断检查读取操作是否成功,这样如果发生了错误,则运算符将给定的对象重置为空。

输入运算符必须处理输入可能失败的情况,而输出运算符不需要。

输入时的错误

执行输入时有可能发生错误:

  • 当流含有错误类型的数据时读取操作可能失败。例如在读取完bookNo后,输入运算符假定接下来读入的是两个数字数据,一旦输入的不是数字数据,则读取操作及后续对流的其他使用都将失败。
  • 当读取操作到达文件末尾或者遇到输入流的其他错误时也会失败。

前面的函数中,没有诸葛检查读取操作,而是读取了所有数据后赶在使用这些数据前一次性检查。失败后的price的值是未定义的。所以只需要在错误时默认初始化这个对象

当读取操作发生错误时,输入运算符应该负责从错误中恢复。

标示错误(略)

算术和关系运算符

通常情况下,我们把算术和关系运算符定义成非成员函数以允许对左侧或右侧的运算对象进行转换。因为这些运算符一般不需要改变运算对象的状态,所以形参都是常量的引用。

如果类定义了算术运算符,则它一般也会定义一个对应的复合赋值运算符。此时,最有效的方式是使用复合赋值来定义算术运算符:

1
2
3
4
5
6
7
//假设两个对象指向同一本书
sales_data operator+(const sales_data &lhs,const Sales_data &rhs)
{
sales_data sum = lhs; //把lhs的数据成员拷贝给sum
sum += rhs; //将rhs加到sum中
return sum;
}

相等运算符

例子:

1
2
3
4
5
6
7
8
9
10
bool operator==(const Sales_data &lhs,const sales_data &rhs)
{
return lhs.isbn () == rhs.isbn () &&
lhs.units_sold == rhs.units_sold &&
lhs.revenue == rhs.revenue;
)
bool operator!=(const Sales_data &lhs,const Sales_data &rhs)
{
return !( lhs==rhs ) ;
}

从中体现的设计准则:

  • 如果一个类含有判断两个对象是否相等的操作,则它显然应该把函数定义成operator==而非一个普通的命名函数:因为用户肯定希望能使用==比较对象,所以提供了==就意味着用户无须再费时费力地学习并记忆一个全新的函数名字。此外,类定义了==运算符之后也更容易使用标准库容器和算法。
  • 如果类定义了operator==,则该运算符应该能判断一组给定的对象中是否含有重复数据。
  • 通常情况下,相等运算符应该具有传递性,换句话说,如果a==b和 b==c都为真,则a==c也应该为真。
  • 如果类定义了operator==,则这个类也应该定义operator!=。对于用户来说,当他们能使用==时肯定也希望能使用!=,反之亦然。
  • 相等运算符和不相等运算符中的一个应该把工作委托给另外一个,这意味着其中一个运算符应该负责实际比较对象的工作,而另一个运算符则只是调用那个真正工作的运算符。

关系运算符

如果存在唯一一种逻辑可靠的<定义,则应该考虑为这个类定义<运算符。如果类同时还包含==,则当且仅当<的定义和==产生的结果一致时才定义<运算符。

赋值运算符

除了拷贝赋值和移动赋值外,还可以定义其他赋值运算吧别的对象作为右侧对象。例如vector接受花括号元素列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
vector<string> v;
= { "a", "an", "the" };
//可以把运算符添加到StrVec
class strVec {
public:
strVec &operator=(std::initializer_list<std::string>);
//其他成员与13.5节(第465页)一致
};

strVec &StrVec::operator=(initializer_list<string> il)
{
//alloc_n_copy分配内存空间并从给定范围内拷贝元素
auto data = alloc_n_copy(il.begin (), il.end () );
free ( ); //销毁对象中的元素并释放内存空间
elements = data.first; //更新数据成员使其指向新空间
first_free = cap = data.second;
return *this;
}

和其他赋值函数一样需要先释放当前内存空间,创建一片新的空间,不同之处是,无需检查对象向自身的赋值。

我们可以重载赋值运算符。不论形参的类型是什么,赋值运算符都必须定义为成员函数。

复合赋值运算符

复合运算符不一定是类成员,不过倾向于把包括复合在内的所有运至运算都定义在类的内部,与内置类型保持一致。复合赋值也要返回左侧对象的引用:

1
2
3
4
5
6
7
//作为成员的二元运算符:左侧运算对象绑定到隐式的this指针//假定两个对象表示的是同一本书
sales_data& Sales_data::operator+=(const sales_data &rhs)
{
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this;
}

下标运算符

它必须是成员函数。它通常以访问元素的引用作为返回值,这样可以出现在赋值运算的任意一端。此外,如果一个类包含下标运算符,则它通常会定义两个版本:一个返回普通引用,另一个是类的常量成员并且返回常量引用。

1
2
3
4
5
6
7
8
9
10
class strVec {
public:
std::string& operator[] (std::size_t n)
{ return elements[n]; }
const std::string& operator[] (std::size_t n) const
{ return elements[n]; }
//其他成员与13.5(第465页)一致
private:
std::string *elements; //指向数组首元素的指针
};

上面这两个下标运算符用法类似vector或者数组中的下标,非常量可以赋值,而常量不可以赋值:

1
2
3
4
5
6
7
8
//假设svec是一个StrVec对象
const Strvec cvec = svec; //把svec 的元素拷贝到cvec中
//如果svec中含有元素,对第一个元素运行string 的 empty函数
if (svec.size() & & svec[0].empty()){
{
svec[0]= "zero"; //正确:下标运算符返回string的引用
cvec[0]= "Zip"; //错误:对cvec取下标返回的是常量引用
}

递增和递减运算符

因为这两个运算符改变的是操作对象的状态,所以建议将其设定为成员函数。

定义递增和递减运算符的类应该同时定义前置版本和后置版本。这些运算符通常应该被定义成类的成员。

定义前置递增/递减

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class StrBlobPtr {
public:
//递增和递减运算符
StrBlobPtr& operator++(); //前置运算符
StrBlobPtr& operator--();
//其他成员和之前的版本一致
};

//前置版本:返回递增/递减对象的引用
StrBlobPtr& StrBlobPtr::operator++() {
//如果curr已经指向了容器的尾后位置,则无法递增它
check (curr, "increment past end of StrBlobPtr" );++curr;
//将curr在当前状态下向前移动一个元素
return *this;
}

StrBlobPtr& StrBlobPtr::operator--(){
//如果curr是0,则继续递减它将产生一个无效下标--curr;
//将curr在当前状态下向后移动一个元素
check (curr,"decrement past begin of StrBlobPtr");
return *this;
}

一定要注意检查递增递减后的值是否还有意义,且最好返回引用。

区分前置和后置

因为普通的重载无法区分前置和后置版本,所以后置版本接受一个额外的int类型的形参,使用时编译器提供值为0的形参。

1
2
3
4
5
6
7
class strBlobPtr{
public:
//递增和递减运算符
strBlobPtr operator++(int) ; //后置运算符
StrBlobPtr operator--(int) ;
//其他成员和之前的版本一致
}

为了与内置版本保持一致,后置运算符应该返回对象的原值(递增或递减之前的值),返回的形式是一个值而非引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
//后置版本:递增/递减对象的值但是返回原值
StrBlobPtr StrBlobPtr::operator++(int){
//此处无须检查有效性,调用前置递增运算时才需要检查
strBlobPtr ret = *this; //记录当前的值
++*this; //向前移动一个元素,前置++需要检查递增的有效性
return ret; //返回之前记录的状态
}
StrBlobPtr StrBlobPtr::operator--(int){
//此处无须检查有效性,调用前置递减运算时才需要检查
StrBlobPtr ret = *this; //记录当前的值
-―*this; //向后移动一个元素,前置--需要检查递减的有效性
return ret; //返回之前记录的状态
}

后置运算符依然调用前置运算符完成,此外由于不需要用到int形参,所以不需要命名。

显示调用后置运算符

可以通过函数调用方式调用后置版本,但必须传递一个值:

1
2
3
StrBlobPtr p(al);		//p指向a1中的vector
p.operator++(O); //调用后置版本的operator++
p.operator++(); //调用前置版本的operator++

成员访问运算符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class strBlobPtr {
public:
std::string& operator* () const
{
auto p = check (curr, "dereference past end");
return (*p)[curr]; //(*p)是对象所指的vector
}
std::string* operator-> () const
{
//将实际工作委托给解引用运算符
return & this->operator* ();
}
//其他成员与之前的版本一致
}

这两个运算符用法与指针或者vector迭代器操作完全一致,需要检查curr是否在范围内,是的话返回curr所指元素的引用。

箭头运算符必须是类的成员。解引用运算符通常也是类的成员,尽管并非必须如此。

对箭头返回值的限定

箭头运算符永远不能丢掉成员访问这个最基本的含义。对于对于形如point->mem的表达式来说,point必须是指向类对象的指针或者是一个重载了operator->的类的对象。根据point类型的不同,point->mem分别等价于

1
2
(*point).mem;			// point是一个内置的指针类型
point.operator()->mem; // point是类的一个对象

此外,代码都会发生错误,它的执行过程如下:

  1. 如果point是指针,则我们应用内置的箭头运算符,表达式等价于(*point) .mem.首先解引用该指针,然后从所得的对象中获取指定的成员。如果point所指的类型没有名为mem的成员,程序会发生错误。
  2. 如果point是定义了operator->的类的一个对象,则我们使用point.operator->()的结果来获取mem。其中,如果该结果是一个指针,则执行第1步;如果该结果本身含有重载的 operator-> (),则重复调用当前步骤。最终,当这一过程结束时程序或者返回了所需的内容,或者返回一些表示程序错误的信息。

重载的箭头运算符必须返回类的指针或者自定义了箭头运算符的某个类的对象。

函数调用运算符

它使我们可以像调用函数一个调用对象,例如:

1
2
3
4
5
struct absInt {
int operator( ) (int val) const {
return val <0 ? -val : val;
}
};

调用的过程非常像函数的调用:

1
2
3
int i = -42;
absInt absobj; //含有函数调用运算符的对象
int ui = absobj(i); //将i传递给abs0bj.operator ( )

函数调用运算符必须是成员函数。一个类可以定义多个不同版本的调用运算符,相互之间应该在参数数量或类型上有所区别。

含有状态的函数对象类

1
2
3
4
5
6
7
8
class PrintString {
public:
Printstring(ostream &o = cout, char c = ' ') : os(o) , sep(c) {}
void operator () (const string &s) const { os<<s << sep; }
private:
ostream &os; //用于写入的目的流
char sep; //用于将不同输出隔开的字符
}

这个类有默认构造函数接受一个输出流引用和用于分隔的字符,之后调用运算符使用这些成员来协助打印给定的string。

当定义PrintString的对象时,对于分隔符及输出流既可以使用默认值也可以提供我们自己的值:

1
2
3
4
Printstring printer;		//使用默认值,打印到cout
printer (s); //在cout中打印s,后面跟一个空格
Printstring errors (cerr, '\n');
errors(s); //在cerr中打印s,后面跟一个换行符

函数对象常常作为泛型算法的实参。例如,可以使用标准库for_each 算法(参见10.3.2节,第348页)和我们自己的 PrintString类来打印容器的内容:

1
for each(vs.begin (), vs.end(), PrintString (cerr, '\n') );

lambda是函数对象

编写lambda后,编译器就将它翻译成一个未命名的对象。

1
2
3
4
5
6
7
8
9
10
11
12
//根据单词的长度对其进行排序,对于长度相同的单词按照字母表顺序排序
stable_sort (words. begin () , words.end (),
[](const string &a, const string &b)
{freturn a.size () < b.size();});
//其行为类似于下面这个类的一个未命名对象
class shorterstring {
public:
bool operator () (const string &s1,const string &s2) const
{ return sl.size() < s2.size(); }
};
//用这个类替代 lambda表达式后,我们可以重写并重新调用
stable_sort:stable_sort(words.begin(), words.end() ,Shorterstring());

表示lambda及相应的捕获行为的类

当lambda表达式通过引用捕获变量时,程序确保lambda执行时引用所引用对象存在。这个lambda产生的类未每个值建立对应的数据成员,同时创建构造函数,捕获的值用于初始化变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//获得第一个指向满足条件元素的迭代器,该元素满足size() is >= Sz
auto wc = find_if (words.begin() , words.end(),[sz] (const string &a)
{ return a.size () >= sz;} );
//该lambda表达式产生的类将形如:
class SizeComp {
SizeComp(size_t n): sz(n){ //该形参对应捕获的变量
//该调用运算符的返回类型、形参和函数体都与lambda一致
bool operator ( ) (const string &s) const
{ return s.size() >= sz; }
private:
size_t sZ;
//该数据成员对应通过值捕获的变量
};
//调用时必须提供一个实参用于构造函数。
//获得第一个指向满足条件元素的迭代器,该元素满足size() is >= sz
auto wc = find_if (words.begin (), words.end () , sizeComp(sz) );

标准库定义的函数对象

C++语言中有几种可调用的对象:函数、函数指针、lambda表达式(参见10.3.2节,第346页)、bind创建的对象(参见10.3.4节,第354页)以及重载了函数调用运算符的类

不同类型可以有相同的调用形式

对于几个可调用对象共享同一种调用形式的情况,有时我们会希望把它们看成具有相同的类型。例如,考虑下列不同类型的可调用对象:

1
2
3
4
5
6
7
8
9
10
//普通函数
int add (int i, int j){ return i +j; }
//lambda,其产生一个未命名的函数对象类
auto mod = [ ](int i, int j){ return i % j; };
//函数对象类
struct divide {
int operator () (int denominator, int divisor) {
return denominator / divisor;
}
};

虽然各不相同但共享一种调用形式:int(int,int)

我们可能希望使用这些可调用对象构建一个简单的桌面计算器。为了实现这一目的,需要定义一个函数表(function table)用于存储指向这些可调用对象的“指针”。当程序需要执行某个特定的操作时,从表中查找该调用的函数。

假定我们的所有函数都相互独立,并且只处理关于 int的二元运算,则map可以定义成如下的形式:

1
2
3
4
5
//构建从运算符到函数指针的映射关系,其中函数接受两个int、返回一个int
map<string, int(*)(int ,int) > binops;
//我们可以按照下面的形式将add的指针添加到binops 中:
//正确:add是一个指向正确类型函数的指针
binops.insert( { "+",add} );//{ "+",add}是一个pair(参见11.2.3节,379页)

但是我们不能将mod或者divide存入 binops,因为他们时类类型,所以类型并不匹配。

标准库function类型

我们可以使用一个名为function的新的标准库类型解决上述问题,function定义在functional头文件中。

image.png

声明:

1
function<int (int, int)>

使用:

1
2
3
4
5
6
function<int (int, int) > f1 = add;				//函数指针
function<int (int, int) > f2= divide() ; //函数对象类的对象
function<int (int,int)> f3 = [](int i, int j) {return i * j; }; //lambda
cout << f1 (4,2)<<endl;
cout << f2 (4,2) <<endl;
cout << f3 (4,2) <<endl;

使用function重新定义map

1
2
3
4
//列举了可调用对象与二元运算符对应关系的表格
//所有可调用对象都必须接受两个int、返回一个int
//其中的元素可以是函数指针、函数对象或者lambda
map<string, function<int (int, int)>> binops;

把可调用对象都添加到这个map中:

1
2
3
4
5
6
map<string,function<int (int,int)>> binops = {
{ "+", add} , //函数指针
{ "-", std::minus<int> () } , //标准库函数对象
{ "/", divide () }, //用户定义的函数对象
{ "*", [] (int i, int j) { return i * j; H},//未命名的lambda
{ "%", mod} }; //命名了的lambda对象

最后在这个map中使用索引调用:

1
2
3
4
5
binops [ "+"](105); //调用add (10,5)
binops [ "-"](10,5); //使用minus<int>对象的调用运算符
binops [ "/"](10,5); //使用divide对象的调用运算符
binops [ "*"](10,5); //调用lambda函数对象
binops [ "%"](105); //调用lambda函数对象

重载函数与function

我们不可以直接向重载的函数名存入function类型的对象

1
2
3
4
int add (int i, int j){ return i +j;}
sales_data add (const sales_data&, const sales_data&);
map<string, function<int (int,int)>> binops;
binops.insert( { "+", add} );//错误:哪个add?

解决方法是储存一个函数的指针,而不是函数名字

1
2
3
4
5
int (*fp)(int,int) = add;			//指针所指的add是接受两个int的版本
binops.insert( { "+", fp} ); //正确:fp指向一个正确的add版本
//同样,我们也能使用lambda来消除二义性:
//正确:使用lambda来指定我们希望使用的add版本
binops.insert({"+",[](int a, int b) (return add(a,b);} } );

重载、类型转换与运算符

转换构造函数和类型转换运算符共同定义了类类型转换(class-type conversions),这样的转换有时也被称作用户定义的类型转换(user-defined conversions)。

类型转换运算符

它是类的特殊成员函数,负责将一个类类型转换成其他类型,型式为:operator type() const;

其中 type表示某种类型。类型转换运算符可以面向任意类型(除了void之外)进行定义,只要该类型能作为函数的返回类型(参见6.1节,第184页)。因此,我们不允许转换成数组或者函数类型,但允许转换成指针(包括数组指针及函数指针)或者引用类型。

一个类型转换函数必须是类的成员函数;它不能声明返回类型,形参列表也必须为空。类型转换函数通常应该是const。

定义含类型转换的类

令表示0到255之间的整数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class SmallInt {
public:
SmallInt (int i = 0) : val(i)
{
if (i < 0 || i >255)
throw std::out_of_range ( "Bad SmallInt value");
}
operator int() const { return val; }
private:
std::size_t val;
};

SmallInt si;
si = 4; //首先将4隐式地转换成SmallInt,然后调用SmallInt : :operator=
si + 3; //首先将si隐式地转换成int,然后执行整数的加法

这个类即定义了类类型向其他类型的转换,也有其他类型向类类型的转换(通过内置转为int再转为类类型)。

1
2
3
4
//内置类型转换将double实参转换成int
SmallInt si = 3.14; //调用SmallInt (int)构造函数
//SmallInt的类型转换运算符将si转换成int
si + 3.14; //内置类型转换将所得的int继续转换成double

编译器一次只能执行一个用户定义的类型转换,但是隐式的用户定义类型转换可以置于一个标准(内置)类型转换之前或之后,并与其一起使用。因此,我们可以将任何算术类型传递给SmallInt的构造函数。类似的,我们也能使用类型转换运算符将一个SmallInt对象转换成int,然后再将所得的int转换成任何其他算术类型。

因为类型转换运算符是隐式执行的,所以无法给这些函数传递实参,当然也就不能在类型转换运算符的定义中使用任何形参。同时,尽管类型转换函数不负责指定返回类型,但实际上每个类型转换函数都会返回一个对应类型的值:

1
2
3
4
5
6
7
8
9
class SmallInt;
operator int(SmallInt& ) ;
//错误:不是成员函数
class SmallInt {
public:
int operator int() const; //错误:指定了返回类型
operator int(int = 0) const; //错误:参数列表不为空
operator int*() const { return 42;}//错误:42不是一个指针
};

避免过度使用类行转换函数

类型转换可能产生意外的结果

例如一个像bool类型的转换

1
2
int i = 42;
cin << i; //如果向bool的类型转换不是显式的,则该代码在编译器看来将是合法的!

这段程序试图将输出运算符作用于输入流。因为istream本身并没有定义<<,所以本来代码应该产生错误。然而,该代码能使用istream的 bool类型转换运算符将cin转换成bool,而这个bool值接着会被提升成int并用作内置的左移运算符的左侧运算对象。这样一来,提升后的bool值(1或0)最终会被左移42个位置。这一结果显然与我们的预期大相径庭。

显示的类型转换运算符

为防止上述情况,C++11引入此运算符:

1
2
3
4
5
6
class SmallInt {
public:
//编译器不会自动执行这一类型转换
explicit operator int() const { return val; }
//其他成员与之前的版本一致
};

和显示构造函数一样,编译器不会将显示的类型转换用于隐式类型转换,使用时就必须显示的进行强制类型转换:

1
2
3
SmallInt si = 3;		//正确:SmallInt的构造函数不是显式的
si + 3; //错误:此处需要隐式的类型转换,但类的运算符是显式的
static_cast<int>(si) + 3;//正确:显式地请求类型转换

该规定存在一个例外,即如果表达式被用作条件,则编译器会将显式的类型转换自动应用于它。换句话说,当表达式出现在下列位置时,显式的类型转换将被隐式地执行:

  • if、 while及do语句的条件部分
  • for语句头的条件表达式
  • 逻辑非运算符(!)、逻辑或运算符()、逻辑与运算符(&&)的运算对象
  • 条件运算符(?:)的条件表达式。

转换为bool

无论我们什么时候在条件中使用流对象,都会使用为IO类型定义的operatorbool。例如:
while (std: :cin >> value)
while语句的条件执行输入运算符,它负责将数据读入到value并返回cin。为了对条件求值,cin被istream operator bool类型转换函数隐式地执行了转换。如果cin的条件状态是good(参见8.1.2节,第280页),则该函数返回为真;否则该函数返回为假。

向bool的类型转换通常用在条件部分,因此 operator bool 一般定义成explicit的。

避免有二义性的类型转换

如果类中包含一个或多个类型转换,则必须确保在类类型和目标类型之间只存在唯一一种转换方式。否则的话,我们编写的代码将很可能会具有二义性。

在两种情况下可能产生多重转换路径。

  • 第一种情况是两个类提供相同的类型转换:例如,当A类定义了一个接受B类对象的转换构造函数,同时B类定义了一个转换目标是A类的类型转换运算符时,我们就说它们提供了相同的类型转换。
  • 第二种情况是类定义了多个转换规则,而这些转换涉及的类型本身可以通过其他类型转换联系在一起。最典型的例子是算术运算符,对某个给定的类来说,最好只定义最多一个与算术类型有关的转换规则。

通常情况下,不要为类定义相同的类型转换,也不要在类中定义两个及两个以上转换源或转换目标是算术类型的转换。

实参匹配和相同的类型转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//最好不要在两个类之间构建相同的类型转换
struct B;
struct A{
A() = default;
A (const B&); //把一个B转换成A
//其他数据成员
};
struct B {
operator A()const; //也是把一个B转换成A
//其他数据成员
} ;
A f (const A&);
Bb;
A a= f(b); //二义性错误:含义是f(B:: operator A())
//还是f(A: :A(const B&))?

代码中同时存在两种B获得A的方法,造成编译器无法判断,此时必须显示调用:

1
2
A a1 = f(b.operator A() ) ;		//正确:使用B的类型转换运算符
A a2 = f(A(b)); //正确:使用A的构造函数

但最好的办法就是避免此情况

二义性与转换目标的为内置类型的多重转换

1
2
3
4
5
6
7
8
9
10
11
12
13
struct A{
A (int = 0); //最好不要创建两个转换源都是算术类型的类型转换
A (double);
operator int () const; //最好不要创建两个转换对象都是算术类型的类型转换
operator double() const;
//其他成员
};
void f2 ( long double);
A a;
f2(a); //二义性错误:含义是f(A::operator int ())
//还是f(A::operator double ()) ?
long lg;
A a2(1g); //二义性错误:含义是A::A(int)还是A::A(double)?

在对f2的调用中,哪个类型转换都无法精确匹配long double。然而这两个类型转换都可以使用,只要后面再执行一次生成long double的标准类型转换即可。因此,在上面的两个类型转换中哪个都不比另一个更好,调用将产生二义性。

正确操作

要想正确地设计类的重载运算符、转换构造函数及类型转换函数,必须加倍小心。尤其是当类同时定义了类型转换运算符及重载运算符时特别容易产生二义性。以下的经验规则可能对你有所帮助:

  • 不要令两个类执行相同的类型转换:如果Foo类有一个接受Bar类对象的构造函数,则不要在Bar类中再定义转换目标是Foo类的类型转换运算符。
  • 避免转换目标是内置算术类型的类型转换。特别是当你已经定义了一个转换成算术类型的类型转换时,接下来
    • 不要再定义接受算术类型的重载运算符。如果用户需要使用这样的运算符,则类型转换操作将转换你的类型的对象,然后使用内置的运算符。
    • 不要定义转换到多种算术类型的类型转换。让标准类型转换完成向其他算术类型转换的工作。

一言以蔽之:除了显式地向bool类型的转换之外,我们应该尽量避免定义类型转换函数并尽可能地限制那些“显然正确”的非显式构造函数。

重载函数与转换构造函数

举个例子,当几个重载函数的参数分属不同的类类型时,如果这些类恰好定义了同样的转换构造函数,则二义性问题将进一步提升:

1
2
3
4
5
6
7
8
9
10
11
struct C {
c (int);
//其他成员
) ;
struct D{
D (int);
//其他成员
};
void manip (const C&);
void manip (const D&);
manip (10);//二义性错误:含义是manip (c(10))还是manip(D(10) )

如果在调用重载函数时我们需要使用构造函数或者强制类型转换来改变实参的类型,则这通常意味着程序的设计存在不足。

重载函数与用户定义的类型转换*

函数匹配与重载运算符

调用对象函数与普通函数不同,如果a时一种类型,则a sym b可能是

1
2
a. operatorsym(b);	//a有一个operatorsym成员函数
operatorsym(a,b); //operatorsym是一个普通函数

但重载的运算符并不能通过调用形式区分成员与非成员函数

当我们使用重载运算符作用于类类型的运算对象时,候选函数中包含该运算符的普通非成员版本和内置版本。除此之外,如果左侧运算对象是类类型,则定义在该类中的运算符的重载版本也包含在候选函数内。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class SmallInt {
friend
SmallInt operator+(const SmallInt&, const SmallInt&);
public:
SmallInt (int = 0 ) ; //转换源为int的类型转换
operator int ( ) const { return val; }//转换目标为int的类型转换
private:
std::size_t val;
};
//可以使用这个类将两个 smallInt对象相加,但如果我们试图执行混合模式的算术运算,就将遇到二义性的问题:

SmallInt s1, s2;
SmallInt s3 = s1 + s2; //使用重载的operator+
int i = s3 +0 ; //二义性错误

第二条加法语句具有二义性:因为我们可以把0转换成smallInt,然后使用smallInt 的+,或者把s3转换成int,然后对于两个int执行内置的加法运算。

如果我们对同一个类既提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会遇到重载运算符与内置运算符的二义性问题。

拷贝控制

拷贝、赋值与销毁

拷贝构造函数

如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。

1
2
3
4
5
6
7
class Foo {
public:
Foo() ;
//默认构造函数
Foo (const Foo&);//拷贝构造函数
// ...
};

第一个参数必须是引用,且通常都是const的,拷贝构造通常是隐式使用,不应该是explicit的。

合成的拷贝构造

无论我们有没有定义其他拷贝构造,编译器都会自动和合成一个拷贝构造函数。合成的拷贝构造函数会从给定对象中依次将每个非static成员拷贝到正在创建的对象中。

每个成员的类型决定了它如何拷贝:对类类型的成员,会使用其拷贝构造函数来拷贝;内置类型的成员则直接拷贝。虽然我们不能直接拷贝一个数组,但合成拷贝构造函数会逐元素地拷贝一个数组类型的成员。如果数组元素是类类型,则使用元素的拷贝构造函数来进行拷贝。

拷贝初始化

1
2
3
4
5
string dots (10, '.');//直接初始化
string s (dots) ;//直接初始化
string s2 = dots;//拷贝初始化
string null_book = "9-999-99999-9";//拷贝初始化
string nines = string (100,'9') ;//拷贝初始化

如果类中有一个移动构造函数,则拷贝初始化有时会使用移动构造而非拷贝构造,所谓移动构造就是指的就是以移动而非深拷贝的方式初始化含有指针成员的类对象。简单的理解,移动语义指的就是将其他对象(通常是临时对象)拥有的内存资源“移为已用”。所以我们应了解何时发生拷贝构造:

拷贝初始化不仅在我们用=定义变量时会发生,在下列情况下也会发生·

  • 将一个对象作为实参传递给一个非引用类型的形参

  • 从一个返回类型为非引用类型的函数返回一个对象

  • 用花括号列表初始化一个数组中的元素或一个聚合类中的成员

此外当初始化标准容器或调用insert或push时,会使用拷贝初始化,而emplace成员创建的元素都是直接初始化。

参数和返回值

函数调用过程中,具有非引用类型的参数都要进行拷贝初始化,函数返回值为非引用时,返回值也会被用来做为接受对象拷贝初始化的参数。

所以拷贝初始化的参数必须是引用类型,不然就一直调用也不会成功。

拷贝初始化的限制

值初始化和拷贝初始化不是一模一样的,如果使用explicit构造函数,我们就不能隐式的调用它:

1
2
3
4
5
vector<int> v1(10);//正确:直接初始化
vector<int> v2 = 10;//错误:接受大小参数的构造函数是explicit的
void f(vector<int>);// f的参数进行拷贝初始化
f(10); //错误:不能用一个explicit的构造函数考贝一个实参
f(vector<int> (10));//正确:从一个int直接构造一个临时vector

必须显示的调用explicit函数。

编译器可以绕过拷贝构造函数

拷贝/移动构造可以被忽略,直接创建对象:

1
2
3
string null_book = "9-999-99999-9"; //拷贝初始化
//改写为
string null_book("9-999-99999-9"); //编译器略过了拷贝构造函数

可以跳过拷贝/移动构造,但必须有且可访问。

拷贝赋值运算符

重载赋值运算符

重载运算符本质上是函数,其名字由 operator 关键字后接表示要定义的运算符的符号组成。因此,赋值运算符就是一个名为operator=的函数。类似于任何其他函数,运算符函数也有一个返回类型和一个参数列表。

重载运算符的参数表示运算符的运算对象。某些运算符,包括赋值运算符,必须定义为成员函数。如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的this参数(参见7.1.2节,第231页)。对于一个二元运算符,例如赋值运算符,其右侧运算对象作为显式参数传递。

1
2
3
4
5
class Foo {
public:
Foo& operator= (const Foo&); // 赋值运算符
// ...
} ;

赋值运算符应该返回一个引用!

合成的拷贝赋值运算符

和前几个构造函数一样,如果类内未定义,就会自动生成。作为一个例子,下面的代码等价于sales_data的合成拷贝赋值运算符:

1
2
3
4
5
6
sales_data& Sales_data::operator= (const Sales_data &rhs) {
bookNo = rhs.bookNo;//调用string : : operator=
units_sold = rhs.units_sold;//使用内置的int赋值
revenue = rhs.revenue;//使用内置的double赋值
return *this;//返回一个此对象的引用
}

那么它和拷贝构造函数的区别是,拷贝构造是从无到有,而拷贝赋值时本来就有,只是值发生改变。

析构函数

它与构造函数相反,构造函数初始化非static数据成员,还有其他工作,析构函数释放对象使用资源,销毁对象非static数据成员。它没有返回值,也不接受参数:

1
2
3
4
5
class Foo {
public:
~Foo();//析构函数
//...
};

由于没有参数,所以不能被重载。

函数任务

它所有顺序都与构造函数相反,先执行函数体,然后销毁成员,且按出现次序逆序销毁。且析构部分时隐式的,销毁完全取决与类型。

隐式销毁内置指针类型的成员不会delete指向的对象

何时调用析构

无论何时一个对象被销毁,就会自动调用其析构函数:

  • 变量在离开其作用域时被销毁。
  • 当一个对象被销毁时,其成员被销毁。
  • 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁。
  • 对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁。
  • 对于临时对象,当创建它的完整表达式结束时被销毁。
1
2
3
4
5
6
7
8
9
10
{//新作用域
// p和p2指向动态分配的对象
Sales_data *p = new sales_data; // p是一个内置指针
auto p2 = make_shared<Sales_data>();// p2是一个shared_ptr
Sales_data item(*p); //拷贝构造函数将*p拷贝到item中
vector<sales_data> vec; //局部对象
vec.push_back (*p2); //拷贝 p2指向的对象
delete p; //对p指向的对象执行析构函数
}//退出局部作用域;对item、p2和vec调用析构函数
//销毁p2会递减其引用计数;如果引用计数变为0,对象被释放//销毁vec会销毁它的元素

当指向一个对象的引用或指针离开作用域时,析构函数不会执行。

合成的析构函数

概念如前,下面等同合成析构函数:

1
2
3
4
5
6
class sales_data {
public:
//成员会被自动销毁,除此之外不需要做其他事情
~Sales_data() { }
//其他成员的定义,如前
};

三/五法则

需要析构函数的类也需要拷贝和拷贝赋值

如果一个类需要析构函数,那么肯定也需要一个拷贝函数和一个拷贝赋值运算符。例子:

1
2
3
4
5
6
7
8
class HasPtr {
public:
HasPtr(const std: : string &s = std: :string () ):
ps(new std: :string(s)), i(0){ }
~HasPtr() { delete ps; }
//错误:HasPtr需要一个拷贝构造函数和一个拷贝赋值运算符
//其他成员的定义,如前
};

如果使用合成拷贝,则会简单的拷贝指针成员,则有可能多个对象指向相同内存。

需要拷贝操作的类也需要赋值,反之亦然

作为一个例子,考虑一个类为每个对象分配一个独有的、唯一的序号。这个类需要一个铂贝构造函数为每个新创建的对象生成一个新的、独一无二的序号。除此之外,这个拷贝构造函数从给定对象拷贝所有其他数据成员。这个类还需要自定义拷贝赋值运算符来避免将序号赋予目的对象。但是,这个类不需要自定义析构函数。

使用=defult

这段代码可以显示的要求编译生成合成版本:

1
2
3
4
5
6
7
8
9
10
class Sales_data {
public:
//拷贝控制成员;使用default
Sales_data() = default;
sales_data(const sales_data&) = default;
sales_data& operator= (const sales_data &);
~Sales_data() = default;
//其他成员的定义,如前
};
Sales_data& Sales_data::operator=(const Sales_data&) = default;

使用此语句,合成函数将隐式声明为内联,如果不希望是内联的,应该对类外使用它(如上面的拷贝赋值)。

阻止拷贝

对于某些类来说,拷贝和赋值时没有意义的,如iostream,所以组织拷贝,以避免多个对象的写入过读取相同的IO缓冲。

定义删除的函数

通过将拷贝和拷贝赋值函数定义为删除的函数来组织拷贝,这是一种我们虽然声明,但不能使用的函数:

1
2
3
4
5
6
7
struct NoCopy {
Nocopy () = default; //使用合成的默认构造函数
NoCopy(const NoCopy&) = delete; //阻止拷贝
NoCopy &operator=(const Nocopy&) = delete; //阻止赋值
~NoCopy() = default; //使用合成的析构函数
//其他成员
};

与=default不同:

  • =delete必须在函数第一次声明的时候出现,而=default知道编译器生成代码时才需要,可以出现在定义处。
  • 另一个是可以对任意函数使用(虽然主要是阻止拷贝),但=default只可以使用在有合成版本的函数。

析构函数不能删除

对于删除了析构函数的类型,虽然我们不能定义这种类型的变量或成员,但可以动态分配这种类型的对象。但是,不能释放这些对象。所以不可删除。

合成的拷贝控制成员可能是删除的

本质上,这些规则的含义是:如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。

一个成员有删除的或不可访问的析构函数会导致合成的默认和拷贝构造函数被定义为删除的,这看起来可能有些奇怪。其原因是,如果没有这条规则,我们可能会创建出无法销毁的对象。

private拷贝控制

新标准以前,组织是通过将函数放在private里的,但现在应该使用=delete。

拷贝控制和资源管理

类行对象有两种拷贝语意,一种像值:拷贝像值对象,副本和源对象完全独立,改变副本不会对源对象有影响,如string。一种像指针:拷贝这种对象,共同使用底层数据,改变自己也会改变源对象,如shared_ptr。

行为像值的类

像值的行为,每个对象应该拥有一份自己的拷贝。HasPtr

  • 定义一个拷贝构造函数,完成string的拷贝,而不是拷贝指针
  • 定义一个析构函数来释放string
  • 定义一个拷贝赋值运算符来释放对象当前的 string,并从右侧运算对象拷贝string
1
2
3
4
5
6
7
8
9
10
11
12
13
class HasPtr{
public:
HasPtr(const std::string &s = std::string()):
ps (new std::string(s)), i(0){}
//对ps指向的string,每个HasPtr对象都有自己的拷贝
HasPtr (const HasPtr &p):
ps (new std::string(*p.ps)) , i(p.i) {}
HasPtr& operator=(const HasPtr &);
~HasPtr() { delete ps; }
private:
std::string *ps;
int i;
};

类值拷贝赋值运算符

赋值类运算符通常是组合了析构和构造函数,赋值的操作其实会销毁左侧运算对象的资源,其次需要保证再异常发发生时代码也是安全的:

1
2
3
4
5
6
7
HasPtr& HasPtr::operator= (const HasPtr &rhs){
auto newp = new string ( *rhs.ps); //拷贝底层string
delete ps;//释放旧内存
ps = newp;//从右侧运算对象拷贝数据到本对象
i = rhs.i;
return *this;//返回本对象
}

编写赋值运算符时,有两点需要记住:

  • 如果将一个对象赋予它自身,赋值运算符必须能正确工作。
  • 大多数赋值运算符组合了析构函数和铂贝构造函数的工作。

当你编写一个赋值运算符时,一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中。当拷贝完成后,销毁左侧运算对象的现有成员就是安全的了。一旦左侧运算对象的资源被销毁,就只剩下将数据从临时对象拷贝到左侧运算对象的成员中了。

如果直接删除自身数据,然后将指针指向赋予对象的数据,那么在将自身赋予自身时就会出现访问无效内存的异常。

定义行为像指针

这个类拷贝指针成员本身不是它指向的string,我们的类拷贝时拷贝的是指针而不是指向的对象。同时在析构时也需要在最后一个指向对象的HasPtr销毁时,销毁对象。

这时就需要一个类似引用计数的东西,类似shared_ptr。

引用计数

它的工作方式:

  • 除了初始化对象外,每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。当我们创建一个对象时,只有一个对象共享状态,因此将计数器初始化为1。
  • 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器,指出给定对象的状态又被一个新用户所共享。
  • 析构函数递减计数器,指出共享状态的用户少了一个。如果计数器变为0,则析构函数释放状态。
  • 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器。如果左侧运算对象的计数器变为0,意味着它的共享状态没有用户了,拷贝赋值运算符就必须销毁状态。

计时器不可以放在类中,否则无法正确更新它,最好的办法就是保存在动态内存中,把它当作底层数据,多个对象共享,同样在最后一个指向它的对象销毁时销毁。

定义使用引用计数的类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class HasPtr {
public:
//构造函数分配新的string和新的计数器,将计数器置为1
HasPtr(const std::string &s = std::string ()):
ps(new std::string(s)), i(0), use(new std::size_t(1)){}
//拷贝构造函数拷贝所有三个数据成员,并递增计数器
HasPtr(const HasPtr &p) :
ps(p.ps) , i(p.i), use(p.use) {++*use;}
HasPtr& operator= (const HasPtr&);
~HasPtr ();
private:
std::string *ps;
int i;
std::size_t *use; //用来记录有多少个对象共享*ps 的成员
};

拷贝与析构

当拷贝时,应该复制指针本身,并且递增关联的计数器。析构不能无脑delete,必须注意计数器数量,到0才可以delete。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
HasPtr::~HasPtr(){
if (―-*use ==0){//如果引用计数变为0
delete ps; //释放string内存
delete use; //释放计数器内存
}
}

HasPtr& HasPtr::operator= (const HasPtr &rhs){
++*rhs.use; //递增右侧运算对象的引用计数
if(--*use == 0){//然后递减本对象的引用计数
delete ps; //如果没有其他用户
delete use;//释放本对象分配的成员
}
ps = rhs.ps; //将数据从rhs拷贝到本对象
i = rhs.i;
use = rhs.use;
return *this; //返回本对象
}

交换操作

通常的资源管理类都会有swap函数。如果类定义了自己的swap,算法将使用自定义版本,否则会使用标准库的swap,一次交换操作实际上包含了一次拷贝和两次赋值。如:

1
2
3
HasPtr temp = v1;	//创建v1的值的一个临时副本
v1 = v2.; //将v2的值赋予v1
v2 = temp; //将保存的v1的值赋予v2

但理论上可以省取这些内存分配的过程,直接交换指针:

1
2
3
string *temp = v1.ps;	//为v1.ps中的指针创建一个副本
vl.ps = v2.ps; //将v2.ps 中的指针赋予v1.ps
v2.ps = temp; //将保存的v1.ps中原来的指针赋予v2.ps

编写swap函数

1
2
3
4
5
6
7
8
9
10
class HasPtr {
friend void swap(HasPtr&, HasPtr& ) ;
//其他成员定义,与13.2.1节(第 453页)中一样
};
inline void swap (HasPtr &lhs,HasPtr &rhs)
{
using std::swap;
swap (lhs.ps, rhs.ps); //交换指针,而不是string数据
swap (lhs.i, rhs.i) ; //交换int成员
}

首先将swap定义为friend,一遍能够访问HasPtr的数据成员。swap不是必要的,但是重要的优化手段。

与std::swap不同

使用时不应该加上std::

1
2
3
4
5
6
void swap (Foo &lhs, Foo &rhs)
{
using std::swap;
swap ( lhs.h, rhs.h) ; //使用HasPtr版本的swap
//交换类型Foo的其他成员
}

在赋值运算中使用swap

定义swap后会用来用它定义赋值运算符。是将左侧对象与右侧对象的副本进行交换:

1
2
3
4
5
6
7
8
//注意rhs是按值传递的,意味着HasPtr的拷贝构造函数
//将右侧运算对象中的string拷贝到rhs
HasPtr& HasPtr::operator= (HasPtr rhs)
{
//交换左侧运算对象和局部变量rhs的内容
swap (*this, rhs); // rhs现在指向本对象曾经使用的内存
return *this; //rhs被销毁,从而delete了rhs中的指针
}

此版本参数不是引用,因此右侧传递进来的是一个副本,所以不需要额外的拷贝操作,它保证异常安全的同时也与原来的赋值运算实现一样。

拷贝控制示例(单独成章)

动态内存管理类(单独成章)

对象移动

新标准中有可以移动而非拷贝的能力。很多时候对象拷贝完立刻被销毁了,移动可以大大提升性能。移动的另一个原因是源于IO类或unique_ptr这样的类包含不能被共享的资源,所以可以移动不能拷贝。

标准库容器、string和shared ptr类既支持移动也支持拷贝。IO类和unique ptr类可以移动但不能拷贝。

右值引用

符号为&&,它必须绑定到右值且只能绑定到一个将要销毁的对象,所以可以自由的移动到另一个对象中。

回忆左值和右值:一般而言,一个左值表达式表示的是一个对象的身份,而一个右值表达式表示的是对象的值。

右值引用也不过是对象的另一个名字,对于常规引用,我们可以称之为左值引用。

区别:我们不能将其绑定到要求转换的表达式、字面常量或是返回右值的表达式。右值引用有着完全相反的绑定特性:我们可以将一个右值引用绑定到这类表达式上,但不能将一个右值引用直接绑定到一个左值上:

1
2
3
4
5
int i =42;int &r n i;	//正确:r引用i
int &&rr = i; //错误:不能将一个右值引用绑定到一个左值上
int &r2 = i * 42; //错误:i*42是一个右值
const int &r3 = i * 42;//正确:我们可以将一个const的引用绑定到一个右值上
int &&rr2 = i * 42; //正确:将rr2绑定到乘法结果上
  • 返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值的表达式的例子。
    • 我们可以将一个左值引用绑定到这类表达式的结果上。
  • 返回非引用类型的函数,连同算术、关系、位以及后置递增/递减运算符,都生成右值。
    • 我们不能将一个左值引用绑定到这类表达式上,但我们可以将一个 const的左值引用或者一个右值引用绑定到这类表达式上。

左值持久:右值短暂

考察左值和右值表达式的列表,两者相互区别之处就很明显了:左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。所以

  • 所引用的对象将要被销毁
  • 该对象没有其他用户

这两个特性意味着:使用右值引用的代码可以自由地接管所引用的对象的资源。

变量是左值

变量可以看作只有一个运算对象而没有运算符的表达式,虽然我们很少这样看待变量。类似其他任何表达式,变量表达式也有左值/右值属性。变量表达式都是左值。带来的结果就是,我们不能将一个右值引用绑定到一个右值引用类型的变量上,这有些令人惊讶:

1
2
int &&rr1 = 42;//正确:字面常量是右值
int &&rr2 = rr1; //错误:表达式rr1是左值!

其实有了右值表示临时对象这一观察结果,变量是左值这一特性并不令人惊讶。毕竟,变量是持久的,直至离开作用域时才被销毁。

标准库move函数

虽然不能将右值引用绑定到左值,但可以显示将左值转换为对应右值引用类型,我们可以调用move来获得绑定到左值上的右值引用,在头文件utility中。

1
int &&rr3 = std::move (rr1); l l ok

move对左值使用之后,可以像右值一样处理,但之后除了赋值或者销毁它外,但不能使用该对象的值。且应该直接使用std::move。

移动构造和移动赋值函数

我们可以为自己的类定义移动操作,他们就是从给定对象窃取而不是拷贝资源。除了完成资源移动,移动构造函数还必须确保移后源对象处于这样一个状态—–销毁它是无害的。特别是,一旦资源完成移动,源对象必须不再指向被移动的资源—–这些资源的所有权已经归属新创建的对象。

1
2
3
4
5
6
7
strVec::strVec (strVec &&s) noexcept//移动操作不应抛出任何异常
//成员初始化器接管s中的资源
: elements (s.elements), first_free (s.first_free), cap(s.cap)
{
//令s进入这样的状态——对其运行析构函数是安全的
s.elements = s.first_free = s.cap = nullptr;
}

与拷贝构造函数不同,移动构造函数不分配任何新内存;它接管给定的Strvec中的内存。在接管内存之后,它将给定对象中的指针都置为nullptr。这样就完成了从给定对象的移动操作,此对象将继续存在。最终,移后源对象会被销毁,意味着将在其上运行析构函数。strVec的析构函数在first_free 上调用deallocate。如果我们忘记了改变s.first free,则销毁移后源对象就会释放掉我们刚刚移动的内存。

移动操作与异常

由于移动操作不分配任何资源,所以不会抛出任何异常,我们应该将此事通知给标准库,提升一些性能消耗。

方法就是在小括号之后冒号之前加上noexcept

1
2
3
4
5
class strvec {
public:
Strvec(strvec& &)noexcept; //移动构造函数//其他成员的定义,如前
};
StrVec::StrVec (StrVec &&s) noexcept : /*成员初始化器*/{/*构造函数体*/ }

移动赋值运算符

它与移动构造函数一个,应该标记为noexcept:

1
2
3
4
5
6
7
8
9
10
11
12
13
strVec &StrVec::operator=(StrVec &&rhs) noexcept
{
//直接检测自赋值
if (this != &rhs){
free(); //释放已有元素
elements = rhs.elements; //从rhs接管资源
first_free = rhs.first_free;
cap = rhs.cap;
//将rhs置于可析构状态
rhs.elements = rhs.first_free = rhs.cap = nullptr;
}
return *this;
}

这里多了一步操作就是检测this与rhs地址是否相同,也就是是否是同一个对象(这也是赋值运算需要重点考虑的:将自身赋予自身时能否不出错)。如果相同什么都不用做。

移后源可以析构

编写移动操作必须保证移后对象可析构,在strVec中,将移后源对象的指针成员设置为nullptr来实现。

合成的移动操作

编译器不会为某些类生成合成的移动函数,如果没有移动函数,类会使用对应的拷贝操作来代替移动。

只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。编译器可以移动内置类型的成员。如果一个成员是类类型,且该类有对应的移动操作,编译器也能移动这个成员:

1
2
3
4
5
6
7
8
9
10
//编译器会为X和hasx合成移动操作
struct x {
int i; //内置类型可以移动
std::string s; //string定义了自己的移动操作
};
struct hasx {
X mem; //×有合成的移动操作
};
X x,x2 = std::move(x) ;//使用合成的移动构造函数
hasx hx,hx2 = std::move (hx) ;//使用合成的移动构造函数

移动操作只有当我们显示要求编译器生成=default的移动操作而却不是所有成员都可以移动时才会将移动操作定义为删除的函数。

移动右值,拷贝左值

如果一共类既有移动函数也有拷贝构造函数,那么会根据匹配规则使用,如在strvec类中,拷贝构造函数接受一个 const strvec的引用。因此,它可以用于任何可以转换为strVec的类型。而移动构造函数接受一个strVec&&,因此只能用于实参是(非static)右值的情形:

1
2
3
4
StrVec v1, v2 ;
v1 = v2; // v2是左值;使用拷贝赋值
Strvec getvec(istream &); // getvec返回一个右值
v2 = getvec (cin) ; // getVec (cin)是一个右值;使用移动赋值

如果没有移动构造,会调用拷贝

由于不会默认合成移动构造,所以用拷贝代替,且是绝对安全的

拷贝赋值和移动赋值合并

如果为类添加一个移动构造函数,实际上也会获得移动赋值运算符:

1
2
3
4
5
6
7
8
9
class HasPtr {
public:
//添加的移动构造函数
HasPtr(HasPtr &&p) noexcept : ps (p.ps), i(p.i){p.ps = 0;}
//赋值运算符既是移动赋值运算符,也是拷贝赋值运算符
HasPtr& operator=(HasPtr rhs)
{ swap (*this, rhs); return *this; }
//其他成员的定义,同13.2.1节(第453页)
};

此运算符有一个非引用参数,这意味着此参数要进行拷贝初始化。依赖于实参的类型,拷贝初始化要么使用拷贝构造函数,要么使用移动构造函数——左值被拷贝,右值被移动。因此,单一的赋值运算符就实现了拷贝赋值运算符和移动赋值运算符两种功能。
例如,假定hp和 hp2都是HasPtr对象:

1
2
hp = hp2; // hp2是一个左值;hp2通过拷贝构造函数来拷贝
hp = std::move (hp2);//移动构造函数移动hp2

建议:更新三/五法则
所有五个拷贝控制成员应该看作一个整体:一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。如前所述,某些类必须定义持贝构造函数、拷贝赋值运算符和析构函数才能正确工作。一般来说拷贝一个资源会导致一些额外开销。在这种拷贝并非必要的情况下,定义了移动构造函数和移动赋值运算符的类就可以避免此问题。