C++ 基础难点梳理
本文整理了个人认为 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