C++ 基础难点梳理

梨子2021-10-13
本文全长 4581 字,全部读完大约需要 14 分钟。

本文整理了个人认为 C++ 中比较难,经常混淆不清,但又是很基础的一些难点。供本人和大家参考学习。

本章中的示例输出全部使用 clang++ -std=c++17 命令编译。

const 修饰符

const 变量

C/C++ 中 const 修饰变量时表示这个变量初始化后其值不再改变。注意 const 变量是变量而非常量。在其他语言里类似的有 JavaScript 中的 const,Rust 和 Swift 中的 let

#include <cstdlib>

int main(int argc, char *argv[])
{
    const int i = argc;
    i = 2021; // 错误,不能给 const 变量赋值
    return EXIT_SUCCESS;
}
1.cc:6:7: error: cannot assign to variable 'i' with const-qualified type 'const int'
    i = 2021;
    ~ ^
1.cc:5:15: note: variable 'i' declared const here
    const int i = argc;
    ~~~~~~~~~~^~~~~~~~
1 error generated.

const 指针

当说到指针的时候,事情就稍稍复杂了一些。这里就牵扯到我记不住的顶层 const 和底层 const 的问题。

底层 const:const int*,指针是只读指针,不能通过指针向地址写入值(指向 const 变量的指针必须是底层 const 指针)

顶层 const:int* const,指针本身是一个值不能改变的 const 变量

写一些简单的示例,

底层 const:

#include <cstdlib>

int main(int argc, char *argv[])
{
    int i = argc;
    const int* p1 = &i;
    *p1 = 2021; // 错误,p1 为只读指针
    return EXIT_SUCCESS;
}
2.cc:7:9: error: read-only variable is not assignable
    *p1 = 2021;
    ~~~ ^
1 error generated.
#include <cstdlib>
#include <iostream>

using namespace std;

int main(int argc, char *argv[])
{
    int i = argc;
    const int* p1 = &i;
    cout << *p1 << endl;
    i = 2021; // const 指针的只读属性不影响所指对象
    cout << *p1 << endl;
    p1 = nullptr; // 底层 const 指针本身可以被赋值(换指)
    cout << p1 << endl;
    return EXIT_SUCCESS;
}
1
2021
0x0
#include <cstdlib>

int main(int argc, char *argv[])
{
    const int i = argc;
    int* p1 = &i; // 错误,指向 const 变量的指针必须是底层 const 指针
    *p1 = 2021;
    return EXIT_SUCCESS;
}
6.cc:6:10: error: cannot initialize a variable of type 'int *' with an rvalue of type 'const int *'
    int* p1 = &i;
         ^    ~~
1 error generated.

顶层 const:

#include <cstdlib>

int main(int argc, char *argv[])
{
    int i = argc;
    int* const p1 = &i;
    p1 = nullptr; // 错误,顶层 const 指针不可换指
    return EXIT_SUCCESS;
}
4.cc:7:8: error: cannot assign to variable 'p1' with const-qualified type 'int *const'
    p1 = nullptr;
    ~~ ^
4.cc:6:16: note: variable 'p1' declared const here
    int* const p1 = &i;
    ~~~~~~~~~~~^~~~~~~
1 error generated.
#include <cstdlib>
#include <iostream>

using namespace std;

int main(int argc, char *argv[])
{
    int i = argc;
    int* const p1 = &i;
    cout << *p1 << endl;
    *p1 = 2021; // 顶层 const 指针可以向地址写入
    cout << *p1 << endl;
    return EXIT_SUCCESS;
}
1
2021

其实记起来很好记,只要记住从右往左读就好,也就是说 const 全部都是修饰在被修饰对象的右边,只有最左边的可以反过来,比如

// int const *** const shit = nullptr; 是等价的
const int *** const shit = nullptr;

读作 declare shit as const pointer to pointer to pointer to const int,这样就知道是什么含义了。

const 引用

所谓“引用”就是一个自动解引用的指针。在那些区分引用类型的语言里,比如 Java,对一个引用变量赋值即表示换绑到新值。而在 C++ 里,引用一旦绑定就不能换绑,对引用赋值就会直接写到它指向的对象的地址上。因此引用不存在顶级 const 的概念,如果硬要这样写,clang 就会抱怨说你或许不该这样写:

#include <cstdlib>

int main(int argc, char *argv[])
{
    int i = argc;
    const int& const a1 = i;
    return EXIT_SUCCESS;
}
7.cc:6:16: error: 'const' qualifier may not be applied to a reference
    const int& const a1 = i;
               ^
1 error generated.

对引用赋值会直接写入到被引用变量里。const 引用表示该引用为只读引用。

#include <cstdlib>
#include <iostream>

using namespace std;

int main(int argc, char *argv[])
{
    int i = argc;
    const int& a1 = i;
    int& a2 = i;
    cout << a1 << endl;
    a2 = 2021;
    cout << a1 << endl;
    return EXIT_SUCCESS;
}
1
2021

右值引用

C++ 中的右值分为两种,纯右值和将亡值。纯右值包括直接硬编码在程序里的字面量,和中间表达式求值的结果。将亡值则是一个快要离开作用域了的左值。

我们来做一个小练习,说说下面哪些是左值,哪些是右值。以及程序的输出。

#include <cstdlib>
#include <iostream>

using namespace std;

int main(int argc, char *argv[])
{
    int i = argc;
    int& lvf = i + 1;
    const int& cf = i + 1;
    int&& rvf = i + 1;
    const int&& crvf = i + 1;
    int i2 = rvf;
    i2++;
    rvf++;
    crvf++;
    cout << cf << ' ' << rvf << ' ' << endl;
    return EXIT_SUCCESS;
}

答案:

#include <cstdlib>
#include <iostream>

using namespace std;

int main(int argc, char *argv[])
{
    int i = argc; // argc 和 i 都是左值
    int& lvf = i + 1; // i + 1 是右值,错误:不能把左值引用绑定到右值
    const int& cf = i + 1; // 可以把 const 左值引用绑定到右值
    int&& rvf = i + 1; // 可以把右值引用绑定到右值
    const int&& crvf = i + 1; // 可以把右值引用绑定到右值
    int i2 = rvf;
    i2++; // 可以,i2 是左值
    rvf++; // 可以,虽然是右值引用但是也可以被赋值
    crvf++; // 错误,声明为 const
    // <2 3>,i2 的改变不会影响原来的右值
    cout << cf << ' ' << rvf << ' ' << endl;
    return EXIT_SUCCESS;
}

那么右值引用有什么用呢,你可能听说过 std::move。实际上,需要澄清的是,std::move 其实并没有“move”任何东西,它所做的仅仅是强行把一个左值变成右值引用。为什么要这样呢,因为在给一个对象赋值的时候,右值引用会调用新对象的移动构造函数,在那里新对象就可以重用这一块的内存而不需要拷贝。所以,如果是下面这样的代码,std::move 是没有移动的效果的。

#include <cstdlib>
#include <iostream>

using namespace std;

int main(int argc, char *argv[])
{
    int i1 = 1;
    int i2 = move(i1); // 和直接赋值无异
    i1 = 2021; // i1 仍在原来的地址上
    i2++; // i2 仍在原来的地址上
    cout << i1 << ' ' << i2 << endl;
    return EXIT_SUCCESS;
}
2021 2

下面的代码演示了 std::move 的工作原理。注意右值引用如果赋值给一个变量,那么它本身是一个左值。所以如果需要把右值引用当做右值传递,需要使用 std::forward

#include <cstdlib>
#include <iostream>

using namespace std;

struct A {
    A() = default;

    A(const A& a)
    {
        cout << "copy" << endl;
    }

    A(const A&& a)
    {
        cout << "move" << endl;
    }

    A operator+(const A& a)
    {
        cout << "+" << endl;
        return A();
    }
};

int main(int argc, char *argv[])
{
    A a1;
    A b1 = a1;
    A b2 = move(a1); // 调用移动构造函数
    A&& a3 = a1 + a1; // a3 是右值引用
    A b4 = a3; // 仍然调用复制构造函数,因为 a3 是一个左值
    A b5 = forward<A>(a3); // 调用移动构造函数
    return EXIT_SUCCESS;
}
copy
move
+
copy
move

需要澄清的一点是,std::move 是把左值转为右值引用,所以如果本身就是一个右值,是不需要转的。比如返回一个局部变量 return v;,因为已经是将亡值,所以理论上编译器可以自动优化这部分,不需要显式地写 return std::move(v)

虽然 std::move 在语言层面上并没有移动的功能,但是在语义上,移动构造函数指的复用右值对象的堆内存,把被赋值对象中的指针直接指向这个右值对象的堆内存,从而省去拷贝这块数据的开销。标准库容器均有移动构造函数的实现。因此,在一个变量被 std::move 了以后,你要万分注意不应该再去访问它,虽然它的作用域还没有结束,但是语义上它已经消亡,它的内存空间已经被其他对象侵入,对它进行读写可能会产生难以预测的问题。

const 成员函数

const 加在成员函数前面,表示修饰的是返回值。然而,因为返回值是值传递的,所以如果返回的就是一个值的话,没有任何意思。例如:

const int fun(); // 不应该这样写,有没有 const 没区别

如果返回值是一个指针的话,则表示指针指向的对象不能修改(同底层 const 指针的含义),如

const int* fun();

const 写在函数后面,则表示这个函数不修改类的非静态成员变量,

#include <cstdlib>
#include <iostream>

using namespace std;

struct A {
    static int s;
    int a = 2020;

    void increase() // 对非静态成员变量进行了修改,不能声明为 const
    {
        a++;
    }

    void print() const // 语义上对内部成员没有修改的函数,建议声明为 const
    {
        cout << a << ' ' << ++A::s << endl;
    }
};

int A::s = 0;

int main(int argc, char *argv[])
{
    A a;
    a.increase();
    a.print();
    return EXIT_SUCCESS;
}

constexpr 修饰符

constexpr 常量

constexpr 修饰的是常量,也就是编译期就可以求值的量,这我们都知道。举个例子,

#include <cstdlib>

int main(int argc, char *argv[])
{
    const int i = 3600 * 24; // i 不能再赋值
    constexpr int i1 = 3600 * 24 * 365; // i1 是编译期可以求值的常量
    constexpr int i2 = i * 365; // i2 是编译期可以求值的常量
    return EXIT_SUCCESS;
}

下面这个程序看起来结构一模一样,但是编译时就会发现它不能在编译时求出声明为 constexpr 的常量值,会抛出编译错误:

#include <cstdlib>

int main(int argc, char *argv[])
{
    const int i = argc;
    constexpr int i1 = argc;
    constexpr int i2 = i;
    return EXIT_SUCCESS;
}
2.cc:6:19: error: constexpr variable 'i1' must be initialized by a constant expression
    constexpr int i1 = argc;
                  ^    ~~~~
2.cc:6:24: note: read of non-const variable 'argc' is not allowed in a constant expression
    constexpr int i1 = argc;
                       ^
2.cc:3:14: note: declared here
int main(int argc, char *argv[])
             ^
2.cc:7:19: error: constexpr variable 'i2' must be initialized by a constant expression
    constexpr int i2 = i;
                  ^    ~
2.cc:7:24: note: initializer of 'i' is not a constant expression
    constexpr int i2 = i;
                       ^
2.cc:5:15: note: declared here
    const int i = argc;
              ^
2 errors generated.

我们可以看到,无论是把一个可变变量直接赋值给常量,还是把一个用可变变量初始化的 const 变量赋值给常量,它都是不接受的。但是如果把用常量表达式初始化的 const 变量(的表达式)赋值给常量,编译器却能够接受。这说明编译器是有自动推断 const 变量是否是常量的能力的。

这么说有点绕。实际上,constexpr 的作用恰恰是在声明为 const 的基础上,要求编译器断言初始化值是常量表达式。如果不是,则会抛出编译错误。

所以,既然编译器具有断言 const 变量是否为常量的能力,那他自然就有直接优化 const 变量的能力,constexpr 这个修饰符还有什么用吗。先别急,我们可以先这样理解,这只是用来帮助我们确认,“噢这个表达式编译器确实能通过编译期求值来优化”而已。

如果 constexpr 仅仅可以修饰变量,似乎我们可以觉得,编译期完全可以很“聪明”地自己推断出哪些是常量来。但是配合上下面的 constexpr 函数,事情就显得复杂了起来。

constexpr 函数

#include <cstdlib>
#include <iostream>

using namespace std;

constexpr int add(int a, int b)
{
    return a + b;
}

int main(int argc, char *argv[])
{
    constexpr int i1 = add(3, 4); // 此处 i1 的值编译期求得
    int i2 = add(argc, 1); // 此处不可声明为 constexpr,否则会编译错误
    cout << i1 << ' ' << i2 << endl;
    return EXIT_SUCCESS;
}
7 2

我们可以看到,constexpr 放在函数声明前表示函数具有纯性。没有被 constexpr 修饰的函数是不会进行编译期求值的:编译器可以尝试对 const 变量的声明进行查找,但如果把每个函数都尝试求值,看起来就不是一个很明智的选择了。而被 constexpr 修饰的函数并不一定返回值是一个常量,例如上例的第 14 行,函数的返回值是无法编译期求值的。只有当 constexpr 函数的返回值赋值给一个 constexpr 常量时,编译器才会尝试编译期求值并断言表达式是常量表达式。

明白了这些道理,我们可以就写出一个任意复杂的纯函数,只要输入是一个常量,编译器就可以在编译期完成计算。单从计算复杂性角度说,C++ 语法允许编译期判定递归语言,识别递归可枚举语言,C++ 语言的编译过程具有和图灵机等价的计算能力。在以前呢,要做这么复杂的事情,也是可以的,但是要通过模板元编程来实现,而且需要利用编译错误,从错误的输出中读取结果,现在就很简单了:

#include <cstdlib>
#include <iostream>

using namespace std;

constexpr int fibonacci(const int n)
{
    if (n == 1 || n == 2)
        return 1;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

int main(int argc, char *argv[])
{
    constexpr int i = fibonacci(25);
    cout << i << ' ' << fibonacci(30) << endl;
    return EXIT_SUCCESS;
}
75025 832040

在上面这个例子中,fibonacci(25) 是在编译时计算的,而 fibonacci(30) 则是在运行时计算的。如果把 25 也改成 30,编译就会不通过,因为为了防止程序不停机,执行超过一定步数编译器就会停止继续执行并抛出异常。

if constexpr

if constexpr 表示要求编译器断言 if 判断的条件表达式是常量,基于此可以对选择结构进行剪枝优化,例如

#include <cstdlib>
#include <iostream>

using namespace std;

template<typename T>
constexpr auto nextstep(const T& t)
{
    if constexpr (is_integral_v<T>) {
        return t + 1;
    } else {
        return t + 0.001;
    }
}

int main(int argc, char *argv[])
{
    cout << nextstep(1) << endl;
    cout << nextstep(3.14) << endl;
    return EXIT_SUCCESS;
}
2
3.141

以上代码会被展开为

#include <cstdlib>
#include <iostream>

using namespace std;

constexpr int nextstep(const int& t)
{
    return t + 1;
}

constexpr double nextstep(const double& t)
{
    return t + 0.001;
}

int main(int argc, char *argv[])
{
    cout << nextstep(1) << endl;
    cout << nextstep(3.14) << endl;
    return EXIT_SUCCESS;
}

就这个简单例子而言,还有另一种写法,直接利用 SFINAE 的特点来重载 nextstep 函数。但是因为类型声明嵌套函数使其丧失了平凡性,所以使用的时候必须显式地声明类型。读者在实践中可以根据自身程序的特点来选择使用哪种写法。

#include <cstdlib>
#include <iostream>

using namespace std;

template<typename T>
constexpr auto nextstep(const enable_if_t<is_integral_v<T>, T>& t)
{
    return t + 1;
}

template<typename T>
constexpr auto nextstep(const enable_if_t<!is_integral_v<T>, T>& t)
{
    return t + 0.001;
}

int main(int argc, char *argv[])
{
    cout << nextstep<int>(1) << endl;
    cout << nextstep<double>(3.14) << endl;
    return EXIT_SUCCESS;
}
2
3.141

虚函数与重写

由于 C++ 这个语言把接口和类混为一谈,所以有一些复杂而又虚幻的特性。

回顾 virtual

我们先来复习一下什么是 virtual,

#include <cstdlib>
#include <iostream>

using namespace std;

struct A {
    virtual void foo()
    {
        cout << "A::foo() is called" << endl;
    }

    void bar()
    {
        cout << "A::bar() is called" << endl;
    }
};

struct B : public A {
    void foo()
    {
        cout << "B::foo() is called" << endl;
    }

    void bar()
    {
        cout << "B::bar() is called" << endl;
    }
};

int main()
{
    B b;
    A* a = &b;
    a->foo();
    a->bar();
    b.foo();
    b.bar();
    return EXIT_SUCCESS;
}
B::foo() is called
A::bar() is called
B::foo() is called
B::bar() is called

我们看到,是否使用 virtual 关键字主要体现在派生类对象被用作基类对象的时候,使用 virtual 关键字的函数此处会使用接口语义,调用派生类而非基类的实现。对于没有使用 virtual 修饰的基类函数,即使派生类中声明了这个名字,也不会对基类对象的行为产生影响。

override 操作符

那么当派生类函数签名不同的时候呢?

#include <cstdlib>
#include <iostream>

using namespace std;

struct A {
    virtual void foo()
    {
        cout << "A::foo() is called" << endl;
    }

    void bar()
    {
        cout << "A::bar() is called" << endl;
    }
};

struct B : public A {
    void foo(int a)
    {
        cout << "B::foo() is called" << endl;
    }

    void bar(int a)
    {
        cout << "B::bar() is called" << endl;
    }
};

int main()
{
    B b;
    A* a = &b;
    a->foo();
    a->bar();
    b.foo(0);
    b.bar(0);
    return EXIT_SUCCESS;
}
A::foo() is called
A::bar() is called
B::foo() is called
B::bar() is called

我们看到,如果函数名相同但签名不同,即使基类声明了 virtual,也不会认为派生类重写了基类函数。

这里我们就可以解释,C++ 有个 override 关键字是做什么用的了。

  • 如果基类方法改名,找不到对应的 virtual 方法,抛出编译错误
  • 如果基类方法签名改变,找不对对应签名的 virtual 方法,抛出编译错误

我觉得作为派生类,加上 override 这个关键字主要是为了防止基类方法被修改。如果基类改了,不加这个关键字,编译不会有任何错误的迹象,但是在使用的时候相应的派生类实现就不会被调用,就会和预期的不符。

显式析构

因为一些弱智原因吧,C++ 的析构函数虽然是从基类到派生类依次调用,但是析构函数同样服从上面讲的 virtual 的原则。也就是说,如果你有一个类型是基类类型的指针,而基类没有显式地声明析构函数,那么这个指针 delete 时不会调用派生类的析构函数。

#include <cstdlib>
#include <iostream>
#include <memory>

using namespace std;

struct A
{
    virtual void invoke() = 0;
};

struct B : public A
{
    void invoke() override {}
    ~B()
    {
        cout << "B destructs" << endl;
    }
};

void func()
{

    unique_ptr<A> p = make_unique<B>();
}

int main()
{
    func();
    return EXIT_SUCCESS;
}
<no output>

如果基类是一个抽象类,像上面这样的话,这个操作的行为是未定义的,严重的时候,你会收获一个像这样的奇怪异常,然后你并不知道是为什么。

EXC_BREAKPOINT (code=1, subcode=0x1029bb380)

所以,在声明基类和抽象类的时候总是显式地设置 virtual 析构函数是一个好习惯:

#include <cstdlib>
#include <iostream>
#include <memory>

using namespace std;

struct A
{
    virtual void invoke() = 0;
    virtual ~A() = default;
};

struct B : public A
{
    void invoke() override {}
    ~B()
    {
        cout << "B destructs" << endl;
    }
};

void func()
{

    unique_ptr<A> p = make_unique<B>();
}

int main()
{
    func();
    return EXIT_SUCCESS;
}
B destructs
除特殊说明以外,本网站文章采用 知识共享署名-相同方式共享 4.0 国际许可协议 进行许可。