一些C++概念辨析
const:指针常量与常量指针
const与指针的结合方式有时候令人迷惑,如:
int * a0;
int * const a1;
int const * a2;
const int * a3;
int const * const a4;
const int * const a5;
const int const * const a6;
相比于一个裸指针 a0,const 限定了指针的可变性,分为两层:顶层指针的可变性、底层数据的可变性。
顶层指针的可变性,指的是指针本身的指向是否可变,是否能从指向一个元素变成指向另一个元素,比如是否能用该类型的指针遍历一个数组。
底层数据的可变性,指的是指针指向的数据是否可变,是否能从一个值原地改变成另一个值,比如常用的qsort传入的cmp函数就限制了不能修改底层数据。
区分const限制的是顶层指针还是底层数据,要看 const
相对于 *
的位置。 *
将一个基础类型的裸指针分为两部分,左边描述了底层数据的类型,右边描述了这个裸指针的名字。如果 const
靠近左边底层数据类型,则限制的是底层数据不可变;如果 const
靠近右边顶层指针,则限制的是顶层指针不可变。
所以,a1 是顶层指针不可变,a2 和 a3 是底层数据不可变,a4、a5 和 a6 是指针数据都不可变。当然,最常用的还是 a3 这种形式,用于qsort函数的参数。
C++里的引用相当于不可变的顶层指针,C++里的const引用,则相当于指针数据都不可边的指针,广泛用于 sort 和 priority_queue。
也就是说:
int b;
int &a = b; // eq. int * const pa = &b; a = *pa;
const int &a = b; // eq. const int * const pa = &b; a = *pa;
但是引用有很多优点:写法简单、省掉一次解引用、不会发生解空指针、不会有野指针……
同名函数:override、overload、hide
- 函数重载(overload) 指的是函数名相同、参数类型和数量不同的情况。由于历史原因,返回值的不同无法作为区分不同函数的判断依据,所以不允许函数名相同、参数类型数目也相同、仅仅返回值类型不同的函数。此外,重载和是否是虚函数无关,虚函数可以和其他成员函数一起参与重载。如例子中
class A
的几个函数之间,是overload。 - 函数重写/覆盖(override) 指的是派生类中覆盖了基类中的同名虚函数,二者函数签名完全相同,只有函数体不同。C++关键字
override
可以添加到派生类函数后面,以便利用编译器完成这一检查。例子中,double fun(double, double)
是override。 - 隐藏(hide) 指的是派生类中的函数屏蔽了基类中的同名函数。包括两种情况:
- 第一种情况是参数列表相同,但是基类函数不是虚函数,此时 hide 与 override 的差别在于基类函数是否是虚函数。例子中,
void A::fun(int)
被void B::fun(int)
hide。 - 第二种情况是参数列表不同,无论基类函数是不是虚函数,都会被隐藏,此时 hide 和 overload 的区别在于两个函数不在同一个类中。例子中,
void A::fun()
和virtual void A::fun(const char*)
被void B::fun(int)
hide。
- 第一种情况是参数列表相同,但是基类函数不是虚函数,此时 hide 与 override 的差别在于基类函数是否是虚函数。例子中,
class A {
public:
int fun() { cout << "fun" << endl; }
void fun(int a) { cout << a << endl; }
virtual double fun(double x, double y) { return x + y; }
virtual void fun(const char* str) { cout << str << endl; }
static void fun(char);
};
void A::fun(char c) { cout << c << c << c << endl; }
class B : public A {
public:
void fun(int b) { cout << b << ' ' << b << endl; }
virtual double fun(double x, double y) override { return x * y; }
};
int main() {
A a;
a.fun();
a.fun(3);
cout << a.fun(2.718, 3.14) << endl;
a.fun("fun");
a.fun('c');
B b;
b.fun(); // 编译错误,int A::fun() 已被 void B::fun(int) 隐藏
b.fun(3);
cout << b.fun(2.718, 3.14) << endl;
b.fun("fun"); // 编译错误,virtual void A::fun(const char*) 已被 void B::fun(int) 隐藏
b.fun('c'); // 匹配 void B::fun(int b), 而非 static void A::fun(char);
};
return 0;
}
可以看到,overload 是同一层级内部的水平关系,override 和 hide 是不同层级之间的垂直关系。
"=":拷贝与赋值
C++ 中,利用同类型的一个对象来构造另一个对象,有两种不同的形成方式:赋值和拷贝。一个简答的例子如下:
class A {
private:
int x, y;
public:
A (const A& a) {}
A& operator= (const A& a) { return *this; }
};
int main() {
A a;
A a1 = a;
A a2;
a2 = a;
return 0;
}
在这里例子中,a1是调用的拷贝构造函数,直接凭空生成一个新对象;a2调用了赋值操作符,在已有对象的基础上进行赋值,修改了原有的对象。
构造函数:拷贝、移动、转换、委托
class T {
private:
int x, y;
public:
T () : T(0, 0) {} // 默认构造函数,无参
explicit T (int r) : T (r, 0) {} // 转换构造函数,单参,且参数是其他类型
T (int a, int b) : x(a), y(b) {} // 初始化构造函数,有参
T (const T&); // 拷贝构造函数
T (T&&); // 移动构造函数
T& operator= (const T&); // 拷贝赋值
T& operator= (T&&); // 移动赋值
}
单个参数的构造函数称为转换构造函数,使用 explicit
修饰之后,可以禁止隐式类型转换,只允许以显式转换构造。建议的做法是总是使用 explicit,这样,T t = 10;
之类的语句就无法通过编译了。
拷贝和赋值的区别前面已经提到过,这里另外介绍两个关键字 default
和 delete
。当不指定构造函数的时候,编译器默认提供无参构造函数,以及浅拷贝的拷贝构造函数。当给定了构造函数,编译器就不再提供默认构造函数,但是可以通过使用 T() = default;
来启用编译器提供的构造函数。与之相对的,可以通过 T& operator= (const T&) = delete;
来取消编译器提供的赋值操作。典型应用场景是 unique_ptr
以及单例。
委托构造函数是另一种特例。早期的时候,每个构造函数都需要手动初始化类的每一个成员,无法一次性指定每个成员的默认值。委托构造函数的写法是,使用一个构造函数写好所有的构造逻辑,其他构造函数只需要调用这个构造函数,达到 委托
的效果。上面的例子中,单参和无参的构造函数,就是委托了双参构造函数来完成工作。
移动构造,是配合右值引用来实现的。主要使用场景是对象的“搬运”,也就是利用一个将要消失的对象,来创建一个仍要使用的对象。可用于代替RVO。
结构化绑定 + 右值引用 踩坑
C++17引入了结构化绑定(structured binding),这种好东西一方面为我们简化代码带来了方便,比如不用写 pairA.first
这种;另一方面与其他特性的结合有时会有一些意想不到的情况发生。
比如下面这个BFS的例子,类型推导 + 右值引用 + 结构化绑定
会导致内存出错:
queue<pair<int,int>> q;
q.push({0, 0});
while (!q.empty()) {
auto&& [i, j] = q.front();
q.pop();
...
}
问题出在 auto&& [i, j] = q.front(); q.pop();
这两句上,我们使用的是一个引用,但是后来这个元素被 pop 释放掉了,所以会报内存错误。
使用 auto& [i, j]
也会出错,因为也是引用类型;只有使用 auto [i, j]
做拷贝赋值才不会引起内存错误。
什么时候用 auto&&
是安全的呢?在变量被使用前不会释放内存的时候,比如 for (auto&& x : vec)
;或者对象的所有权被直接转移给右值引用的时候 auto&& res = func();
。
结构化绑定与原地构造的适用对象
除了结构化绑定(structured binding),可以将一个结构体拆解开分别赋值之外;C++还加入了一堆 emplace/emplace_back/emplace_front 函数,分别对应于 push/push_back/push_front 系列函数,与之不同的是不适用拷贝赋值的方式,而是直接在容器内“原地构造”省掉了临时对象的开销,基本相当于 push + move 操作。
什么对象才能用结构化绑定和原地构造呢?
答案是可以准确知道结构的类型,比如 struct、pair、tuple 等,而 vector 这一类不定长的则不能使用结构化绑定来获取,也不能适用emplace来存入。如以下写法是错误的:
vector<vector<int>> res; res.emplace_back(2, 3); // can't get { {2,3} } but get { {3, 3} } for it calls vector<int>(n, val)
auto [start, end] = res[0]; // CE, can't decompose vector with structured binding
与结构化绑定类似,原地构造也是需要知道对象的内存布局,才能调用 统一初始化函数,不然可能调用的某个构造函数。比如上面的例子,vector