返回

《深度探索C++14》笔记

C++基础

第一个程序

#include <iostream>

int main()
{
    std::cout << "The answer to the ultimate question of Life,\n"
              << "the Universe,and Everthing is:"
              << std::endl
              << 6 * 7 << std::endl;
    return 0;
}
  • 中定义了 std::cout 和 std::endl 。

    std::cout 是一个输出流,用于屏幕打印文本

    std::endl 则用于结束一行(可用 \n 代替)

  • 操作符 « 用于将对象传递到 std::cout 这样的输出流中以输出对象内容

  • std:: 前缀表示其后类型或函数来自标准命名空间。

操作符

访问操作符

操作符 表达式
成员选择 x.m
延迟成员选择 p->m
下标 x[i]
解引用 *x
成员解引用 x.*q
延迟成员解引用 p->*q

类型处理操作符

操作符 表达式
运行期类型识别 typeid(x)
类型标识 typeid(t)
对象大小 sizeof(x)或sizeof x
类型大小 sizeof(t)
参数数量 sizeof…(p)
类型参数数量 sizeof…(p)
对齐 alignof(x)
类型对齐 alignof(t)

函数

传引用

void increment (int& x)
{
	x++;
}

内联

函数调用是一件很昂贵的事项,需要做保护寄存器、复制参数到栈上等一系列操作。为了避免这些开销,编译器可以内联一个函数调用。此时编译器会直接使用内部的操作来代替函数调用。

关键字:inline

inline double square (double x) {return x*x;}

编译器不确保一定会进行内联。与之相反,如果编译器任务内联一个函数能够提高性能,它就可能会去内联它,即便这个函数没有关键字提示。

内联声明还有一个作用就是可以让多个编译单元包含同一个函数。

错误处理

断言

中有一个宏 assert,这个宏来自C语言,它会对一个表达式求值,如果为假,则立刻退出程序。这个宏通常被用来检测程序错误。

#include <cassert>

double square_root(double x)
{
    check_somehow(x >= 0);
    ...
    assert(result>=0.0);
    return result;
}

assert 的另一个优点在于我们可以通过定义一些宏让断言失效。包含头文件之前,先定义一个宏 NDEBUG:

#define NDEBUG
#include <cassert>

那么所有的断言就被禁用了,也就是说在程序执行过程中它什么都不会做。

捕获、抛出异常

try{

}catch (cannot_open_file e)
{
	throw;
}

和C#一样,try 用于捕获异常,throw 用于抛出异常

禁止抛出异常

C++11提供了修饰符 noexcept,表示指定函数一定不会抛出异常:

double square_root (double x) noexcept {……}

函数如果罔顾修饰符而抛出异常,程序就会被终止。

##I/O

控制台输入与输出

输出

std::cout << "11 * 19 = " << 11 * 19 << std::endl;

输入

int age;
std::cin >> age;
int width;
std::cin >> width >> length;

std::cin 从输入设备中读取字符,并根据变量的类型(例子中int) 解析读入的字符并存储到变量中。没敲击一次回车键,键盘输入都会被处理一次。

文本的输入与输出

C++提供了以下类,可以将字符输入或输出到文件中:

类名 注释
ofstream 写入到文件
ifstream 从文件读取
fstream 支持文件的读写

数组、指针和引用

数组

C++11中初始化列表不支持窄化(浮点字面量转成整数会损失精度),例如如下代码;但在C++03中是合法的。

int v[] = {1.0, 2.0, 3.0}	//C++11中会报错

指针

示例一:

void pointers_1()
{
    ifstream ifs("some_array.dat");
    int size;
    ifs >> size;
    float *v = new float[size];
    for (int i = 0; i < size; i++)
    {
        ifs >> v[i];
    }
}

指针和数组同样危险,超出范围的数据访问可能会导致程序崩溃或无征兆地使数据失效。在处理动态分配数组时,保存数组大小是用户的责任。

用户有责任在不再需要内存时释放它:

delete[] v;

分配单个数据条目

int* ip= new int;

释放内存

delete ip;

注意分配和释放是成对的。单个对象的分配需要单个对象的释放,数组的分配要求使用数组的释放。否则在运行时,系统可能会错误处理释放并导致在释放内存时崩溃。

指针指向其他变量

int i= 3;
int* ip2= &i;

操作符“&”和一个对象运算并返回对象的地址。还有一个相对应的操作符“*”,它接受一个地址并返回一个对象:

int j= *ip2;

这被称为解引用。因为操作符优先级不同,语法规则也不同,所以符号的解引用和乘法的含义并不会混淆——起码编译器不会弄错。

空指针

未经初始化的指针的值是随机的(具体取决于对应的内存)。使用为初始化的指针可以导致各种问题。明确地说,如果一个指针不指向任何东西,应该进行如下设置:

int* ip3= nullptr;		// >= C++11
int* ip4{};		//同上

或者在老版本的编译器上:

int* ip3= 0;		//不要再C++11中使用
int* ip4= Null;		//同上

可以切确的是地址 0 一定不会被应用程序所使用,所以用它来代表指针为空是安全的(不指向任何东西)。但是仅使用字面上的 0 即不能清楚地表达意图,也会在函数重载时导致二义性。宏 Null 也没有好到哪里去:它就是个 0

C++11开始引入了关键字 nullptr 作为指针,也能和任意类型的指针比较。它不会与其他类型混淆,而且也有自描述的能力,所以要好于其他表示法。使用空的初始化列表来初始化指针也就是经指针设置为 nullptr

内存泄漏

如果我们觉得之前开的数组太小,所以要开一个新数组并赋值给y:

int* y= new int[15];

这样y就有了更多的空间。但是我们之前分配的内存会怎么样呢?它仍然存在,但是我们再也无法访问它了。甚至也没办法释放它,因为释放也需要地址。所以在后面的程序运行过程中,这块内存就相当于丢失了。知道整个程序结束,操作系统才会释放它。

智能指针

指针有两个作用:

  • 援引【指涉】一个对象
  • 管理动态内存

裸指针的问题在于我们无法区分这个指针是否仅仅用于援引一个数据,还是当指针不在使用时,需要负责内存的释放。为了从类型层面上区分两者,可以使用智能指针

C++11中引入了三种智能指针:unique_ptrshared_ptrweak_ptr

独占指针 unique_ptr

故名思意,这个指针援引的数据是独占所有权的,它的使用基本上和普通指针是类似的:

#include <memory>

int main()
{
	unique_ptr<double> dp{new double};
	*dp= 7;
	...
}

它和裸指针最大的区别在于,指针生命期结束后,其中的内存会自动释放。因此,将一个不是通过分配得到的内存赋予 unique_ptr 是错误的行为。

double d;
unique_ptr<double> dd{&d};	//错误:非法的删除

dd的析构函数会尝试删除d。

我们也不能将独占执政赋给或转换成其他类型。如果要把它赋给一个裸指针,可以使用成员函数get:

double* raw_dp= dp.get();

甚至也不能把一个unique_ptr赋值给另一个unique_ptr:

unique_ptr dp2{move(dp)}; //错误:不允许复制

dp2= dp; //同上

它只能被移动:

unique_ptr<double> dp2{move(dp)}, dp3;
dp3= move(dp2);

移动语义后续讨论,通俗理解:copy会复制数据,而move则将源数据转移到目标中。在当前例子中,涉及内存的所有权,先是从dp转移到dp2中,再转移到dp3中。所有权转移后dp和dp2都是nullptr,而dp3的析构函数会释放内存。

对于数组,unique_ptr有一个特殊实现,应为它需要正确地释放数组的内存。此外,对数组的特化版本还提供了和数组类似的元素访问方式:

unique_ptr<double[]> da{new double[3]};
for (unsigned i= 0; i < 3; i++)
	da[i]= i+2;

unique_ptr有一个很重要的福利,那就是无论时间还是内存的占用上它相对裸指针没有任何额外的开销。

共享指针 shared_ptr

shared_ptr 通常用于管理被多个客户使用的内存(每个客户都持有它)。当不在有shared_ptr引用这块内存时,内存就会被自动释放。这可以显著简化程序,特别是那些复杂的数据结构。shared_ptr还有一个重要的应用领域是并发:当所有访问某块内存的线程都退出后,这块内存就可以被自动释放了。

和 unique_ptr 相比,shared_ptr可以被随意复制。例如:

shared_ptr<double> f()
{
    shared_ptr<double> p1{new double};
    shared_ptr<double> p2{new double}, p3 = p2;
    cout << "P3.use_count() = " << p3.use_count() << endl;
    return p3;
}

int main()
{
    shared_ptr<double> p = f();
    cout << "p.use_count() = " << p.use_count() << endl;
}

在这个例子中,我们为两个double值分配分配了内存,并存储在p1和p2中。然后p2指针复制到p3,因此这两个指针都指向了同一块内存。

如果可以的话,尽量使用make_shared创建shared_ptr:

shared_ptr <double> p1= make_shared<double>();

此时用于管理的内存和业务数据会被存于同一处,它的高速缓存效率会更高。因为make_shared返回一个共享指针,所以可以使用自动类型侦测来简化程序实现。

auto p1= make_shared<double>();

弱指针 weak Pointer

使用 shared_ptr 可能会出现一个问题——循环引用,它会阻止内存的释放。可以使用 weak_ptr 打破引用循环。weak_ptr 并不持有内存,甚至也不会分享内存的使用权。这里提到它只是为了完整地介绍全部的智能指针类型。

引用

如果是为了管理动态内存,那指针自然是不可替代的。但是如果只是为了援引某个对象,我们可以使用另一个称之为引用的语言特性。

void references()
{
    int i = 5;
    int& j = i;
    j = 4;
    std::cout << "j = " << i << '\n';
}

变量j援引了i。如例所示,修改j会导致i的变化,反之亦然。i和j永远是相同的值。我们可以认为引用是一种别名,它为已有的对象或子对象引入了一个新的名字。再定义对象的同时,必须声明它引用了哪个对象(这点和指针不同)。在定义后再决定引用哪个对象是不可行的。

指针和引用的比较

特性 指针 引用
援引已定义的位置
强制初始化
避免内存泄漏
类似对象的记号
内存管理
地址计算
构建它的容器

不要引用过期数据

double& square_ref(double d)
{
    double s= d * d;
    return s;
}

此处我们的函数结构引用了一个已经不存在的局部变量s。存储s的内存任然存在,如果幸运的话,也许其中的值任未被改写,但是我们不能指望这一点。

double* square_ref(double d)
{
    double s= d * d;
    return &s;
}

这个问题对指针同样存在,这个指针指向一个局部变量地址,在出了作用域之后就失效了。这样的指针我们称为悬挂指针。

数组容器

标准向量

std::vector 属于标准库,并用类模板来实现。

#include <vector>

int main()
{
    std::vector<float> v(3), w(3);
    v[0] = 1;
    v[1] = 2;
    v[2] = 3;
    w[0] = 7;
    w[1] = 8;
    w[2] = 9;
}

c++11开始,允许使用初始化列表完成向量初始化。

std::vector<float> v= {1,2,3}, w= {7,8,9};

valarray

valarry 是一个拥有逐元素操作的以为数组,甚至连乘法也都是逐元素的。当操作标量时,会将其与数组中的每个元素进行运算。因此,一个浮点的valarray是一个向量空间。

#include <iostream>
#include <valarray>

int main()
{
    std::valarray<float> v = {1, 2, 3}, w = {7, 8, 9}, s = v + 2.0f + w;
    v = sin(s);
    for (float x : v)
        std::cout << x <<'';
    std::cout << '\n';
}

注意,valarray运算仅限与和自己,或float之间。例如2*w会导致错误,因为int和valarray的相乘是不被支持的。

习题

  1. 编写如下声明:字符的指针,十个整数的数组、十个整数的数组的指针、字符串的数组的指针、字符的指针的指针、整数常量的指针、整数的常量指针,并初始化这些对象。
#include <string>

char* 字符指针 = new char('a');
int 十个整数数组[10] = { 0,1,2,3,4,5,6,7,8,9 };
int* 十个整数数组的指针 = 十个整数数组;
std::string* 字符串数组的指针 = new std::string[3];
char** 字符指针的指针 = &字符指针;
const int 整数常量 = 10;
const int* 整数常量的指针 = &整数常量;
int const* 整数的常量指针 = 整数常量的指针;
  1. 编写程序,从键盘接受用户输入,输出到屏幕上并同时写入文件中。这个问题是“What is your age?(你的年龄是多少)”
#include <iostream>
#include <fstream>

int main()
{
	int age;
	//输出问题
	std::cout << "What is your age" << std::endl;
	//等待用户输入
	std::cin >> age;
	std::cout << "Age is " << age << std::endl;

	//读取文本
	std::fstream infile("data.txt");//需要提前在目录下建文本
	if (infile.is_open())//检查文本是否正常打开
	{
		//写入文本
		infile << age;
		infile.close();//关闭文本
	}
	else
	{
		std::cout << "无法打开文本" << std::endl;
	}

	return 0;
}

成员

可访问性

class rational
{
public:
	void Fun_1() {
	}
	void Fun_2() {
	}

private:
	int q;
	int p;
};

使用访问修饰符可以控制类成员的可访问性。比如上所示,该类的方法是公开的,数据是私有的。

我们在语言上对说明符修饰符做了区分:前者为单个条目声明一个属性,而后者则为下一个修饰符前的所有方法和成员变量添加了描述。相比与打乱类成员的顺序,我们更加建议使用多个访问修饰符。在第一个修饰符之前的类成员都是私有的。

struct

struct与class的区别在于,struct中所有成员默认都是公开的。

struct xyz
{
	...
};
//等同于:
class xyz
{
public:
	...
};

友元

当我们想让一些自由函数和类型拥有访问私有和保护成员的特殊许可:

class complex
{
	friend std::ostream& operator<<(std::ostream&, const complex&);
	friend class complex_akgebra;
};

此案例中我们允许输出操作符和一个叫做 complex_akgebra 的类访问我们内部数据和函数。友元(friend)声明可以任意放置于public、private或protected段中。当然,我们应该尽可能地少使用友元,因为必须要保证每一个友元都能够维护内部数据的完整性。

访问操作符

一共有四个访问操作符。第一个已经见过了:"." 用来选择成员,如 x.m 。其他的操作符主要用来处理指针。

如何使用指针访问成员变量:

void Fun_1() {
	complex c;
	complex* p = &c;

	*p.r = 3.5; //错误:它等价于*(p.r)
	(*p).r = 3.5; //正确

	(*(*p).pm).m2 = 11; //过于麻烦

	//更好的替代方案
	p->r = 3.5;
	p->pm->m2 = 11;
}

如上所示,通过成员选择操作符 “.” 来访问指针的成员不是一个好的选择,因为 “.” 的操作符优先级高于解引用操作符 “*” 。为了更方便地从对象指针访问成员,c++提供了 “->” 操作符。

成员函数

class complex
{
public:
	double get_r() { return r; }
	void set_r(double newr) { r = newr; }
	double get_i() { return i; }
	void set_i(double newi) { i = newi; }
private:
	double r, i;
};

int main() {
	complex c1, c2;
	c1.set_r(3.0);
	c1.set_i(2.0);

	c2.set_i(c1.get_i());
	c1.set_r(c2.get_r());

	return 0;
}

类中的函数称之为成员函数或方法。 getter 和 setter 是面向对象软件中典型的成员函数。和成员一样,类中的方法默认也是私有的。也就是说,他们只能被内部的函数调用。私有的 getter 和 setter 显然没什么用处,因此可以把他们的访问性改成公有 public 。这样就可以将代码写成 c.get_r() 而不是c.r。

设置值

构造函数

构造函数是一类成员方法,他们会初始化对象并为成员函数创建工作环境。

class solver {
public:
	solver(int nrows, int ncols)
	{
		A(nrows, ncols); //错误:不能在这里调用构造函数
	}

private:
	complex A;
};

假设矩阵类型有一个构造函数,用于设置两个维度的大小,那么在构造函数的函数体内是无法调用这个矩阵的构造函数的。实际上上例表达式会被解释成一个函数调用A.operator()(noprws, ncols)而不是构造函数。正确调用矩阵构造函数的方式应该是下面这样。

class solver {
public:
	solver(int nrows, int ncols) :A(nrows, ncols)
	{
	}

private:
	complex A;
};

虚假的构造函数

class complex
{
public:
	complex(double r, double i) :r(r), i(i) {	}
	complex(double r) :r(r), i(0) {	}
private:
	double r, i;
};

int main() {
	complex z1,
		z2(),
		z3(4),
		z4 = 4,
		z5(0, 1);

	return 0;
}

在上面的main方法中,z2的定义是一个陷阱,虽然看起来它是调用了默认的构造函数,但事实是它被解释成了一个函数声明,这个函数名称为z2,没有参数,返回complex。

复制构造函数

class complex
{
public:
	complex(const complex& c) :i(c.i), r(c.r) {	}// 复制构造函数
	complex(double r, double i) :r(r), i(i) {	}
	complex(double r) :r(r), i(0) {	}
private:
	double r, i;
};

int main() {
	complex z1(3.0, 2.0),
		z2(z1),
		z3{ z1 };
}

如果用户没有编写符之构造函数,编译器会生成一个标准的复制构造函数:和我们在例子中所作的类似,按照定义的顺序调用所有成员(与基类)的复制构造函数。

一般我们不建议使用可变引用作为复制构造函数的参数:

complex(complex& c) : i(c.i), r(c.r) {}

如果以传值的方式传递参数,就需要一个复制构造函数,而这正是我们正在定义的函数。我们创造了一个自我依赖的怪物,这可能导致编译器陷入无限的循环之中。

类型转换与显示构造函数

c++中对隐式构造函数和显式构造函数进行了区分。隐式构造函数允许在构造过程中使用隐式转换和赋值操作。当提供的类型和所需要的类型不同时,隐式转换就会参与其中。比如我们在一个需要 complex 的地方放上一个 double。

double inline complex_abs(complex c)
{
	return std::sqrt(real(c)) * real(c) + imag(c) * image(c);
}

并使用double作为参数来调用这个方法,如:

std::cout << "|7| = " << complex_abs(7.0) << '\n';

字面量7.0是个double类型,complex_abs函数并没有重载形式来接受这个参数。但是,这个函数有一个complex参数的重载,并且这个complex还有一个接受double的构造函数。印次,编译器会通过double类型的字面量隐式构造一个complex的值。

在声明构造函数时使用explicit修饰可以禁止隐式转换:

explicit	complex(double r) :r(r), i(0) {	}

这时complex_abs就无法用double来调用了。如果非要使用double,那么提供double的重载,要么显式地用double构造一个complex来调用它。

std::cout << "|7| = " << complex_abs(complex{ 7.0 }) << '\n';

委托

class complex
{
public:
	complex(const complex& c) :i(c.i), r(c.r) {	}
	complex(double r, double i) :r(r), i(i) {	}
	explicit	complex(double r) :r(r), i(0) {	}
	complex() :complex{ 0.0 } {}	//委托
private:
	double r, i;
};

委托构造函数可以调用其他构造函数,这里我们用这个特性代替默认参数实现complex类的构造函数。

赋值

我们希望用下面的表达式完成赋值:

	complex x, y, z, w, u, v;
	x = y;
	u = v = w = x;

要做到这一点需要类型提供赋值操作符。我们任然用complex类为例。将要给complex赋值给另外一个complex需要在complex类中添加如下操作:

complex& operator=(const complex& src)
{
	r = src.r; i = src.i;
	return *this;
}

在隐式转换方面和隐式构造一样,再就是和赋值构造一样,编译器为vector自动生成的赋值操作符也存在问题,因为默认实现仅赋值了数据的指针而不是数据自身。

初始化器列表

初始化器列表是c++11的新特效——注意不要把它与“成员初始化列表”相混淆。使用时需要先包含头文件 <initializer_list> 。

class vector {
public:
	vector(std::initializer_list<double> values) :my_size(values.size()), data(new double[my_size]) {
		std::copy(std::begin(valus), std::end(values), data.get());
	}

	vector& operator=(std::initializer_list<double> values) {
		assert(my_size) == values.size());
		std::copy(std::begin(valus), std::end(values), data.get());
		return *this;
	}
};

我们使用了标准库中的std::copy函数将数据从列表复制到对象中。这个函数接受三个迭代器作为参数,分别表示输入的请示位置、结束位置和输出的起始位置。

移动语义

以vector为例来说,它只复制数据的地址而不是数据本身。也就是说在赋值操作以后:v = w; 两个变量中指针都这项同样的数据。如果我们修改v[7],那么w[7]就会被秀嘎四,繁殖依然。以千夫指为主的软甲通常会提供要给单独的函数用于深复制(deep copy):copy(v,w);

那么问题来:如何区分临时数据和持久数据呢?有个好消息时,编辑器可以帮助我们区分。在C++的术语中,又把零食变量称作右值,因为它们通常只能出现在复制语句的右侧。C++引入右值引用的记号:&&。有名字的值,也叫左值,这些值不能传递给右值引用。

移动构造函数

在提供了移动构造函数和移动赋值函数以后,就可以不再对右值高成本的复制了:

class vector {
public:
	vector(vector&& v) :my_size(v.my_size), data(v.data)
	{
		v.data = 0;
		v.my_size = 0;
	}

private:
	double my_size, data;
};

移动构造函数会从源对象中把数据偷取出来,并置空源对象的状态。

移动赋值

移动赋值的实现很简单,只需要交换两个数据指针就可以了:

	vector& operator=(vector&& scr)
	{
		assert(my_size == 0 || may_size == src.my_size);
		std::swap(data, src.data);
		return *this;
	}

这样在源对象被销毁时,他就会释放掉当前对象的数据。

复制消除

编译器会省略数据的复制,让数据直接在复制操作目标对象的地址上生成。这一优化最终要的用例时返回值优化,特别是当变量使用函数返回值初始化时:

inline vector ones(int n) {
	vector v(n);
	for (size_t i = 0; i < n; i++)
	{
		v[i] = 1.0;
	}
	return v;
}
...
vector w(ones(7));

编译器不会先创建v,然后在函数结束时将它移动或复制到w,而是会直接创建w并在w中完成所有操作,因此并不会调用复制操作符。

何处需要移动语义

有 std::move 就一定会使用移动构造函数。实际上这个函数本身没有移动对象,它只是将一个左值转换成右值。也就是说它会将一个变量认定为临时两,或者说该变量可移动。然后构造函数或者复制函数就会调用右值对应的重载,比如下面代码:

vector x(std::move(w));
v = std::move(u);

第一行的x会从w中偷取数据,并将w置为空象来,第二条语句会将v和u互换。

析构函数

当一个对象被销毁时就会调用析构函数,例如:

	~complex()
	{
		std::cout << "so long and thanks for the fish .\n";
	}

实现准则

析构函数的实现有两条非常重要的准则:

  1. 绝不要再析构函数中抛出异常!这会导致程序崩溃,并且异常将无法捕捉。
  2. 如果类中有虚(virtual)函数,那么析构函数也必须时虚函数。

适当处理资源

因为析构函数不能抛出异常,所以很多程序员将释放资源作为析构函数中唯一的操作。

自动生成方法清单

C++共有六种方法具有默认行为:

  • 默认构造函数
  • 复制构造函数
  • 移动构造函数
  • 复制赋值函数
  • 移动赋值函数
  • 析构函数

尽可能少实现,并尽可能多地声明上面六种方法。任何没有自定义实现的方法都应标记为默认(defult)或者删除(delete)。

成员变量访问

访问函数

我们可以创建一个引用变量:

double &rr= real(c);

它能存货到当前作用域末尾。即便在该例子中,rr和c在同一个作用域定义C++也保证了C的存活期长于rr,应为对象的析构顺序与构造顺序相反。

在同一个表达式中使用临时对象的成员函数是安全的,如:

double r2= real(complex(3, 7)) * 2.0;	//ok

临时的 complex 对象将仅在语句内存或,但起码会比它的实部引用的生存期长,因此这个例子是正确的。但如果我们持有实部的引用,就会导致引用过期:

	const double& rr = real(complex(3, 7));
	cout << "The real part is " << rr << '\n';

临时创建的实数变量仅能存活到第一个语句结束,而它的实部的引用则一直要存活到当前作用域的末尾。

不要持有一个临时表达式的引用!

下标操作符

为了迭代访问 vector,我们写了下面这个函数:

class vector {
public:
	double at(int i) {
		assert(i >= 0 && i < my_size);
		return data[i];
	}
};

以计算v中各项之和:

	double sum = 0.0;
	for (int i = 0; i < v.size(); i++)
	{
		sum += v.at(i);
	}

C++和C可以通过下标操作符访问固定大小的数组。因此,对于(动态大小的)向量来说,指向下标操作也很自然的。我们可以重写前面的例子:

	double sum = 0.0;
	for (int i = 0; i < v.size(); i++)
	{
		sum += v.[i];
	}

这个操作符重载的接口类似赋值操作符,实现上和at函数相同:

class vector {
public:
	double& operator[](int i) {
		assert(i >= 0 && i < my_size);
		return data[i];
	}
};

通过这个操作符我们就可以使用中括号访问向量中的元素,不过此时只能访问可变(非常量)的向量。

常量成员函数

自由函数可以对每个参数都提供恒常性说明。成员函数所在的对象并没有出现在函数的签名中,我们如何确定当前对象是否为常量呢?C++提供了一个特殊语法,在函数头增加一个限定符:

class vector {
public:
	double& operator[](int i)  const
	{
		assert(i >= 0 && i < my_size);
		return data[i];
	}
};

这个const属性并不只是要给随随便便的标记,编译器会非常重视这个标记,并验证函数到底会不会修改对象(的某些成员)。同时,还要求该对象仅能作为const参数传递给其他函数。因此在const方法中调用对象中的其他方法时,也只能待用const方法。

引用限定的变量

除对象的常量性之外,在C++11中我们还可以要求对象是一个左值或者右值。假设我们又要给向量加法,它的结果是一个非常量的临时对象,那么我们可以给这个对象中的元素赋值:

(v + w)[i]= 7.3; //没有意义

问题在于,(v+w)[i] 是左值,而 v+w 不是左值。这里我们少了一个要求,那就是让下标操作只能作用与左值。

class vector {
public:
	double& operator[](int i)  &{	}	//#1
	const double& operator[](int i)  const& {	}	//#2
};

当我们通过引用符号限定成员函数的一个重载形式时,我们也必须要用它限制其他重载。此时,重载#1无法用于临时向量,重载#2则会返回一个不可赋值的常量引用。这样编译器就会为赋值代码生成错误。

与之类似,我们还可以使用引用限定符禁用向量的临时对象的赋值操作符:

v + w = u;	//没有意义,所以应当禁止

以此类推,符号&&可以限定成员函数仅在右值对象上起作用,也就是说,只能在临时对象上调用该方法:

class my_class {
	somethig_good donate_my_data()&& {}
};

它可以用于类型转换,此时需要禁止大型对象(如矩阵)的赋值。

成员函数和自由函数

大多数的操作符既可以定义为成员函数也可以定义为自由函数。有一些操作符——全部的赋值操作符、operator[]、operator()——都必须是非静态成员函数,以确保它们的首个参数是一个左值对象。与之相反,以内建类型为首个参数的二元操作符都要定义为自由函数。

成员函数和自由函数的主要区别在于,前者只允许隐式转换第二个参数,而后者则两个参数都可以隐式转换。如果认为代码简洁比性能更重要,那么可免去所有double为参数的重载而仅依赖于隐式转换。

Licensed under CC BY-NC-SA 4.0
0