一些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) 指的是派生类中的函数屏蔽了基类中的同名函数。包括两种情况:
    1. 第一种情况是参数列表相同,但是基类函数不是虚函数,此时 hide 与 override 的差别在于基类函数是否是虚函数。例子中,void A::fun(int)void B::fun(int) hide。
    2. 第二种情况是参数列表不同,无论基类函数是不是虚函数,都会被隐藏,此时 hide 和 overload 的区别在于两个函数不在同一个类中。例子中,void A::fun()virtual void A::fun(const char*)void B::fun(int) hide。
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;之类的语句就无法通过编译了。

拷贝和赋值的区别前面已经提到过,这里另外介绍两个关键字 defaultdelete。当不指定构造函数的时候,编译器默认提供无参构造函数,以及浅拷贝的拷贝构造函数。当给定了构造函数,编译器就不再提供默认构造函数,但是可以通过使用 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 的单参和双参构造函数都有其特殊含义,和统一初始化的结构并不一致。

posted @ 2021-02-24 11:53  与MPI做斗争  阅读(171)  评论(0编辑  收藏  举报