第十四章 杂项
# 第十四章 杂项
在接下来的内容中,我们将涵盖各种主题,比如一些棘手的代码问题、优化方法以及C++核心准则。
# 问题117:能否从构造函数或析构函数中调用虚函数(virtual function)?
从技术上讲,你可以这么做,代码也能编译通过。但这样的代码可能会产生误导,甚至导致未定义行为(undefined behaviour)。
在基类构造过程中尝试调用派生类的函数是很危险的。
#include <iostream>
class Base {
public:
Base() {
foo();
}
protected:
virtual void foo() {
std::cout << "Base::foo\n";
}
};
class Derived : public Base {
public:
Derived() { }
void foo() override {
std::cout << "Derived::foo\n";
}
};
int main() {
Derived d;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
输出结果仅仅是Base::foo
:
- 根据规则,派生类构造函数会先调用基类构造函数。
- 基类构造函数调用的是基类的成员函数,而不是子类中被重写的函数,这会让子类的开发者感到困惑。
如果虚方法还是纯虚方法(pure virtual method),就会出现未定义行为。运气好的话,在链接时会得到一些错误提示。
有什么解决办法呢? 最简单的方法可能就是完全指定要调用的函数:
Base() {
Base::foo();
}
2
3
对于基类来说,只能这样做;而在派生类中,你可以自行决定。更优雅的解决方案是将虚函数封装在非虚函数中,然后在构造函数/析构函数中只使用这些非虚函数。
# 问题118:什么是默认参数(default arguments)?在C++函数中它们是如何求值的?
默认参数是在声明函数时赋给参数的值。
默认参数允许在调用函数时不提供一个或多个尾部参数。
在函数声明时的参数列表中指定参数的默认值,我们只需在函数声明中给参数赋值即可。
以下是一个示例:
int calculateArea(int a, int b);
int calculateArea(int a, int b=2);
int calculateArea(int a=3, int b=2);
2
3
在调用函数时,如果一个或多个参数留空,就会根据留空参数的数量使用这些默认值。
来看一个完整的示例。如果在函数调用时没有为任何参数传递值,编译器就会使用提供的默认值;如果指定了值,那么默认值将被覆盖,使用传递的值。
#include <iostream>
int calculateArea(int a=3, int b=2) {
return a*b;
}
int main() {
std::cout << calculateArea(6, 4) << '\n';
std::cout << calculateArea(6) << '\n';
std::cout << calculateArea() << '\n';
}
2
3
4
5
6
7
8
9
10
如上述代码所示,对calculateArea
函数进行了三次调用。在第一次调用中,我们传递了两个参数,所以默认值被调用者覆盖;在第二次调用中,只传递了一个参数,所以最后一个参数使用默认值;在最后一次调用中,我们没有传递任何参数,所以两个参数都使用默认值。
在函数声明中,在有默认参数的参数之后,所有后续参数必须在本次声明或同一作用域的先前声明中提供默认值……除非该参数是从参数包(parameter pack)展开的,并且要记住省略号(...
)不是参数。
这意味着int calculateArea(int a=5, int b);
无法编译通过。另一方面,int g(int n = 0, ...)
可以编译,因为省略号不算作参数。
默认参数仅允许在函数声明的参数列表和(自C++14起的)lambda表达式中使用,不允许在函数指针声明、函数引用声明或类型定义(typedef)声明中使用。
最后要注意的是,应该始终在头文件中声明默认值,而不是在.cpp
文件中。
# 问题119:虚函数(virtual functions)可以有默认参数吗?
可以,但你不应该依赖这个特性,因为你可能得不到预期的结果。
虽然在虚函数中使用默认参数初始化器完全合法,但在维护期间,很有可能因为代码修改导致多态代码出错,以及类层次结构变得不必要地复杂。
来看一个例子:
#include <iostream>
class Base {
public:
virtual void fun(int p = 42) {
std::cout << p << std::endl;
}
};
class Derived : public Base {
public:
void fun(int p = 13) override {
std::cout << p << std::endl;
}
};
class Derived2 : public Base {
public:
void fun(int p) override {
std::cout << p << std::endl;
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
你觉得下面这个main
函数的输出会是什么样的呢?
int main() {
Derived *d = new Derived;
Base *b = d;
b->fun();
d->fun();
}
2
3
4
5
6
如果你预期输出是:
42
13
2
那恭喜你!
如果不是,也别担心,这并不显而易见。b
指向一个派生类对象,但却使用了B
类的默认值。
那么,下面这个可能的main
函数呢?
int main() {
Base *b2 = new Base;
Derived2 *d2 = new Derived2;
b2->fun();
d2->fun();
}
2
3
4
5
6
你可能以为会连续输出两个42
,但这是错的。这段代码无法编译。因为重写函数不会 “继承” 默认值,所以对Derived2
类的空参数fun
调用会失败。
main.cpp : In function 'int main()':
main.cpp :28:10: error : no matching function for call to 'Derived2::fun()'
28 | d2->fun();
| ~~~~~~~^~
main.cpp :19:8: note : candidate : 'virtual void Derived2::fun(int)'
19 | void fun(int p ) override {
| ^~~
main.cpp:19:8: note: candidate expects 1 argument, 0 provided
2
3
4
5
6
7
8
现在,让我们稍微修改一下最初的例子,忽略Derived2
类。
#include <iostream>
class Base {
public:
virtual void fun(int p = 42) {
std::cout << "Base::fun " << p << std::endl;
}
};
class Derived : public Base {
public:
void fun(int p = 13) override {
std::cout << "Derived::fun" << p << std::endl;
}
};
int main() {
Derived *d = new Derived;
Base *b = d;
b->fun();
d->fun();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
现在你预期的输出是什么呢?结果会是:
Derived::fun 42
Derived::fun 13
2
原因在于,虚函数是根据对象的动态类型来调用的,而默认参数值是基于静态类型的。在这两种情况下,动态类型都是Derived
,但静态类型不同,所以使用了不同的默认值。
鉴于与正常多态行为的这些差异,最好避免在虚函数中使用任何默认参数。
# 问题120:基类的析构函数应该是虚函数吗?
在过去的几天里,我们一直在讨论多态性(polymorphism)、继承以及相关的内容。所以,我们再次讨论析构函数也就不足为奇了。
通常的回答可能是 “当然应该”,但这并不完全正确。想想标准库本身,很多类都没有虚析构函数,我在关于强类型容器的文章里就提到过这点。所以,如果像std::vector
都没有虚析构函数,那么 “当然应该” 这个答案就不对。
根据赫伯·萨特的观点,“基类的析构函数要么是公有的虚函数,要么是受保护的非虚函数”。
任何通过基类接口执行,并且应该表现出多态行为的操作都应该是虚函数。因此,如果删除操作可以通过基类接口以多态方式执行,那么它必须表现出多态行为,即析构函数必须是虚函数。这是语言的要求 —— 如果你在没有虚析构函数的情况下进行多态删除,就会遇到所谓的未定义行为(undefined behaviour),这是我们一直都要避免的。
class Base { /* . . .*/ };
class Derived : public Base { /* . . .*/ };
int main() {
Base* b = new Derived;
delete b;
}
2
3
4
5
6
但是,基类并不总是需要支持多态删除。在这种情况下,将基类析构函数设为受保护的非虚函数,这样通过基类指针进行删除操作就会失败:
class Base {
/* . . .*/
protected:
~Base() {}
};
class Derived : public Base { /* . . .*/ };
int main() {
Base* b = new Derived;
delete b;
}
2
3
4
5
6
7
8
9
10
11
程序编译报错:
main.cpp: In function 'int main()':
main.cpp:11:10: error: 'Base::~Base()' is protected within this context
11 | delete b;
| ^
main.cpp:4:4: note: declared protected here
4 | ~Base() {}
| ^
2
3
4
5
6
7
# 问题121:关键字mutable
的作用是什么?
自C++11起,mutable
有两个功能、两种作用。
mutable
说明符:先从它较传统的含义说起。mutable
允许修改类成员,即使这些成员被声明为const
。它可以出现在非引用、非const
类型的非静态类成员声明中:
class X {
mutable const int* p;
mutable int* const q;
};
2
3
4
mutable
用于指定该成员不会影响类的外部可见状态(常用于互斥锁、内存缓存、延迟求值和访问检测等场景)。
所以,如果你有一个const
对象,其mutable
成员是可以被修改的。
mutable
的另一种用法体现在延迟初始化(lazy initialization)场景中。通常,访问器(getter)函数应该是const
方法,因为它们只用于返回类成员,而不应该改变成员。
但在进行延迟初始化时,第一次调用访问器函数实际上会改变成员,可能是从数据库、通过网络等方式进行初始化。所以,即使该成员初始化后其值不会改变,也不能将其声明为const
。
使用mutable
关键字就可以解决这个问题。不过要注意,除了初始化,不要对该成员进行其他操作。
class SomethingExpensive{
// . . .
};
class A{
public:
SomethingExpensive* getSomethingExpensive() const {
if (_lazyMember == nullptr) {
_lazyMember = new SomethingExpensive();
}
return _lazyMember;
}
private:
mutable SomethingExpensive* _lazyMember;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mutable
lambda表达式:如果你声明一个lambda
为mutable
,那么它就可以修改通过值捕获的对象,并调用这些对象的非const
成员函数,否则是不可以的。
#include <iostream>
struct A{
void a() const {
std::cout << "a\n";
}
void b() {
std::cout <<"b\n";
}
};
int main(){
A a;
// error: passing 'const A' as 'this'
// auto l = [a]() {a.b();};
auto lm = [a]() mutable {a.b();};
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
在C++ Insights上尝试运行上述代码,对比声明lambda
为mutable
和非mutable
的区别,会很有收获。你会发现,当lambda
是非mutable
时,其operator()
是const
的;当lambda
是mutable
时,operator()
就变成非const
的了。你还会观察到,生成的对象将捕获的变量作为成员。这样一来,就很容易理解为什么普通的lambda
表达式不能对捕获的变量调用非const
函数了。
# 问题122:关键字volatile
的作用是什么?
volatile
是一个关键字,用于声明相应的变量是易变的(volatile),这会告诉编译器该变量的值可能会在外部发生改变,因此编译器应该避免对该变量引用进行优化。
用通俗的话来说,这是什么意思呢?
这意味着,如果你声明一个变量为volatile
,就表明你知道存储在该变量内存地址中的值可能会独立地发生变化。
这种情况是怎么发生的呢?
可能是由于硬件的改变,也可能是另一个线程的操作导致的。
说到后者,虽然这种情况有可能发生,但根据C++11 ISO标准,volatile
关键字仅用于硬件访问场景,不要将其用于线程间通信。
对于线程间通信,标准库提供了std::atomic
模板。
虽然最初对volatile
的保证是对volatile
修饰的变量的赋值顺序会被保留,但这并不意味着在volatile
赋值操作周围不会发生重排序。
所以,不要用volatile
进行线程间通信,它是一种类型限定符,用于声明程序中的对象可能会被硬件修改。
C++11标准中添加了内存模型来支持多线程。但在C++中,保留volatile
关键字是为了解决最初的内存映射I/O问题。
# 问题123:什么是内联函数(inline function)?
在函数定义前加上关键字inline
的函数被称为内联函数。如果一个函数是内联函数,编译器会在编译时将该函数的代码副本放置在每次调用它的位置。
内联函数的任何修改都可能要求所有使用该函数的代码重新编译,因为编译器需要再次替换所有相关代码,否则程序将继续使用旧的功能。
要将一个函数内联,只需在函数名前加上inline
关键字,并在任何调用该函数的代码之前定义它。需要记住的是,这通常只是给编译器的一个提示。如果定义的函数不止一行代码,编译器可以忽略inline
限定符。
在类定义中定义的函数,即使没有使用inline
说明符,也是内联函数定义。
为了理解内联函数的作用,我们需要了解普通函数调用的开销。
当程序执行函数调用指令时,CPU会存储函数调用后面那条指令的内存地址,将函数参数复制到栈中,最后将控制权转移到指定的函数。然后CPU执行函数代码,将函数返回值存储在预定义的内存位置或寄存器中,再将控制权返回给调用函数。
如果一个函数的执行时间比从调用函数切换到被调用函数的时间还要短,这种切换就会成为开销。对于较大且 / 或执行复杂任务的函数,函数调用的开销与函数运行所需的时间相比通常微不足道。然而,对于小的、常用的函数来说,进行函数调用所需的时间往往比实际执行函数代码的时间长得多。小函数会产生这种开销,是因为小函数的执行时间比切换时间短。
需要记住的是,使用inline
关键字只是向编译器提出的一个请求,而不是命令。编译器可能会忽略它,同时,即使没有请求,编译器也可能将函数内联作为一种优化方式。如果函数较小且经常被使用,内联函数可以带来一些性能优势,但同时,它可能会影响缓存的时效性,并增加二进制文件的大小。
# 问题124:我们捕获到了什么?
考虑以下代码。如果我们取消两个catch
块中注释掉的打印语句的注释,输出会是什么?为什么会这样?
#include <iostream>
#include <exception>
class SpecialException : public std::exception {
public:
virtual const char* what() const throw() {
return "SpecialException";
}
};
void a() {
try {
throw SpecialException();
} catch (std::exception e) {
// std::cout << "exception caught in a(): " << e .what() << '\n';
throw ;
}
}
int main () {
try {
a();
} catch (SpecialException& e) {
// std::cout << "exception caught in main(): " << e .what() << '\n';
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
除了一条建议你不要按值捕获异常的编译器警告外,输出结果是:
exception caught in a(): std::exception
exception caught in main(): SpecialException
2
让我们再看一下代码。这里声明了一个新的异常类型(1)。在函数a()
中,我们抛出这个异常(2),然后在那里按值捕获了一个很常见的std::exception
(3)。记录这个异常后,我们重新抛出该异常(4)。在main()
函数中,我们通过常量引用捕获自定义的异常类型(5):
#include <iostream>
#include <exception>
class SpecialException : public std::exception {
public:
virtual const char* what() const throw() {
return "SpecialException";
}
};
void a() {
try {
throw SpecialException();
} catch (std::exception e) {
std::cout << "exception caught in a(): " << e.what() << '\n';
throw ;
}
}
int main () {
try {
a();
} catch (SpecialException& e) {
std::cout << "exception caught in main(): " << e.what() << '\n';
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
当我们按值捕获并记录一个标准异常(3)时,丢失了一些信息。尽管最初抛出的是SpecialException
(2),但为了将它赋值给std::exception
变量,编译器不得不舍弃该异常的某些部分。换句话说,发生了切片(slicing)。如果我们通过引用捕获它,就能保留其原始类型。
然而,当你仅通过调用throw;
重新抛出异常时,它会重新抛出原始异常,在我们的例子中就是SpecialException
。如果你按值捕获异常,那么作为复制源的异常将被重新抛出。因此,任何修改(包括切片)都会丢失。
所以在上述例子中,重新抛出的是原始异常(4),不是我们在catch
块中使用的那个,而是离开try
块的那个。我们保留了更具体的SpecialException
。
一般的经验法则是,始终通过引用捕获异常,以避免不必要的复制,并能够对捕获的异常进行持久化修改。
# 问题125:引用(references)和指针(pointers)有什么区别?
引用和指针都可以用于将局部变量从一个函数传递到另一个函数。使用它们时,被引用/指向的对象不会被复制,在被调用函数中对其值/状态所做的更改在调用函数中也能看到。
指针和引用还可以通过节省复制大对象所需的资源来提高性能,比如在将对象传入函数或返回对象时。
尽管有这些相似之处,引用和指针之间还是存在一些区别:
- 引用一旦创建,就不能再改为引用另一个对象,即不能重新赋值。而指针经常会被重新赋值。
- 引用不能为
NULL
/nullptr
。指针则经常指向nullptr
,以表示它们没有指向任何有效的对象。不过值得注意的是,自C++17起,标准库中引入了std::optional
,如果需要表示某个对象“不存在”,可以使用它。 - 引用在声明时必须初始化。指针则没有这样的限制。
由于上述限制,C++中的引用不能用于实现链表、树等数据结构。
尽管存在这些限制,但引用更安全且更易用:
- 更安全:由于引用必须初始化,像野指针那样的野引用不太可能存在。不过,确实也可能存在引用没有指向有效位置的情况,这种引用被称为悬空引用(dangling references)。
- 更易用:引用访问值时不需要使用解引用操作符(
*
),可以像普通变量一样使用。只有在声明时才需要使用取地址操作符(&
)。此外,访问对象引用的成员时使用点操作符(.
),而指针则需要使用箭头操作符(->
)来访问成员。
# 问题126:以下变量声明哪些可以编译,a
的值会是多少?
unsigned int a = 42;
unsigned int a{42};
unsigned int a = -42;
unsigned int a{-42};
1 - 2)这相当简单。这两种情况都是声明一个无符号整数(unsigned int
),并用一个正数对其进行初始化。它们都能编译,如果你打印a
的值,两种情况下都是42,没什么特别的,继续看下一个。
- 这个就更有意思了。我们用一个负数初始化一个无符号整数。但无符号整数应该表示正数,有符号的
-42
会被转换为无符号数。虽然具体的值可能因平台而异,但一般来说,(无符号)int
的大小是32位,所以无符号数的最大值是4294967295(2**32 - 1
,因为是从0开始计数)。
想象一下没有负数的数轴,0的左边就是最大的数,所以-42
的值就等于无符号整数的最大值减去41。为什么是41而不是42呢?因为-1
实际上等于我们能表示的最大数,从这个最大数开始向左移动41步就到了-42
,也就是std::numeric_limits<unsigned int>::max() - 41
。
顺便说一下,无论你使用什么平台,std::numeric_limits<unsigned int>::max()
都会给出无符号int
的实际最大值。你应该能猜到,也可以传入其他类型作为模板参数。
- 最后这个是不能编译的。C++11引入的统一初始化(uniform initialization),即使用
{}
的初始化方式,不允许进行窄化转换(narrowing)。根据你使用的编译器及其版本,可能会得到一个错误或警告,但如果遵循行业建议,你应该把警告当作错误来处理,所以这其实没什么区别。
再次强调一下,使用=
进行初始化允许窄化转换,所以-42
变成了4294967254;而使用{}
初始化时,编译器会发出警告或错误。
# 问题127:下面这行代码会输出什么,为什么?
#include <iostream>
int main(int argc, char **argv) {
std::cout << 25u - 50;
return 0;
}
2
3
4
5
6
答案不是-25
。可能会让你惊讶的是,假设使用32位整数,答案是4294967271。
但为什么呢?
在C++中,如果两个操作数的类型不同,那么“较低”或“较小”类型的操作数会隐式提升为“较高”或“较大”类型操作数的类型。
这种提升是一种特殊的隐式转换,遵循以下类型层次结构(从最高类型到最低类型):
long double
double
float
unsigned long int
long int
unsigned int
int
所以在我们的例子中,当两个操作数分别是25u
(无符号int
)和50
(int
)时,50
会被提升为无符号int
(即50u
)。
运算结果的类型将和操作数的类型一致。因此,25u - 50u
的结果本身也是无符号int
。所以,-25
在提升为无符号int
时会转换为4294967271。
实际上,结果是无符号整数的最大值 - (50 - 25 + 1
)。加1是因为在1到无符号int
的最大值之间,还有0,我们需要把0也算进去。
#include <iostream>
#include <numeric>
int main() {
auto a = std::numeric_limits<unsigned int>::max()+1 - 25;
auto b = 25u - 50;
std::cout << std::boolalpha << (a == b) << '\n';
}
2
3
4
5
6
7
8
顺便说一下,这是一个整数下溢(integer underflow)的典型例子,整数下溢被视为一种安全漏洞。
# 问题128:解释前置和后置递增/递减运算符的区别
C++有一种很方便的语法糖,可以对变量进行加1或减1操作,它非常有名,甚至出现在了语言的名字里。
这就是++
(递增)和--
(递减)运算符,它们既可以用作前缀运算符,也可以用作后缀运算符。
int main() {
int a=5;
a++;
++a;
a--;
--a;
}
2
3
4
5
6
7
那么前缀和后缀运算符有什么区别呢?
前置和后置运算符的区别在于表达式的求值方式和结果的存储方式。
对于前置递增/递减运算符,先执行递增/递减操作,然后将结果赋给左值(lvalue)。
而对于后置递增/递减操作,先对左值进行求值,然后再执行递增/递减操作。
从另一个角度看,前置运算符返回变量的引用(T& T::operator++();
),而后置运算符返回变量的副本(T T::operator++(int);
)。
这意味着,虽然在语言名字里用的是后置递增(C++
),但在我们常用的原始for
循环中,习惯用i++
来递增索引,实际上,除非有特殊需求要用后置运算符并得到副本,否则默认使用前置运算符会更好。
在大多数情况下,使用前置运算符只能获得微不足道的性能提升,但在某些情况下,当你对大对象重载这些运算符时,可能会节省更多时间,特别是在不使用返回值的情况下。
# 问题129:a
、b
和c
的最终值是多少?
考虑下面这个小程序,a
、b
和c
的最终值是多少?
int main(){
int a, b, c;
a = 9;
c = a + 1 + 1 * 0;
b = c++;
return 0;
}
2
3
4
5
6
7
这个简短的测试既考察了简单的运算符优先级,又涉及了我们昨天刚讨论过的递增运算符的基础知识。这道题的难点在于b = c++;
这一行,你必须理解前置递增(++c
)和后置递增(c++
)的区别。
正确答案:
a = 9 // 未被修改
b = 10
c = 11
2
3
如果你猜b
的值是11,那就得回顾一下后置递增的知识。实际情况是,首先将c
的值复制到b
中(此时b
和c
的值都是10),然后c
才进行递增操作,变成11,但这不会影响b
的值。如果是b = ++c
这一行,那么b
和c
的值都会变成11。
另一个可能的错误答案是认为c
的值是0或1。
如果在计算优先级时出错,就会出现这种情况。因为乘法运算符(*
)的优先级高于加法运算符(+
),运算顺序是从左到右,所以这个等式可转换为:
c = ((a + 1) + (1 * 0))
c = ((9 + 1) + 0)
c = 10 // 正确(之后会进行递增操作)
2
3
如果不遵循正确的优先级顺序,可能会出现这样的情况:
c = a + 1 + 1 * 0
c = 9 + 1 + 1 * 0
c = 10 + 1 * 0
c = 11 * 0
c = 0 // 这是错误的
2
3
4
5
# 问题130:这段字符串声明能编译吗?
下面这段代码能编译吗?如果不能,原因是什么?如果能,又是为什么?
这没有什么陷阱,这就是完整的程序,foo
没有在任何地方被声明为全局变量。
#include <iostream>
int main() {
std::string(foo);
}
2
3
4
5
这段代码能否编译取决于你是否将警告当作错误处理。假设你不把警告当作错误。
这段代码只做了一件事,即创建一个空字符串,并将其赋值给一个名为foo
的变量。
下面两行代码含义相同:
std::string(foo);
std::string foo;
2
为什么呢?因为根据C++标准,任何能被解释为声明的内容,都会被解释为声明。这也被称为“最令人头疼的解析”(the most vexing parse)。
为什么这很重要呢?在实际生活中谁会这样写代码呢?
我们只需稍微扩展一下这个例子。想想互斥锁(mutex)的情况。
#include <mutex>
static std::mutex m_mutex;
static int shared_resource;
void increment_by_42() {
std::unique_lock<std::mutex>(m_mutex);
shared_resource += 42;
}
2
3
4
5
6
7
8
这里发生了什么呢?
一开始,你可能会认为,我们创建了一个临时的unique_lock
,锁定了互斥锁m_mutex
。然而,并非如此。我想你自己就能看出这里的问题。它创建了一个针对mutex
类型的锁,并将这个锁命名为m_mutex
,但实际上并没有锁定任何东西。
但是,如果你通过给锁命名来明确你的意图,或者使用大括号初始化,它就会按预期工作。
#include <mutex>
static std::mutex m_mutex;
static int shared_resource;
void increment_by_42() {
std::unique_lock aLock(m_mutex);
// std::unique_lock<std::mutex> {m};
shared_resource += 42;
}
2
3
4
5
6
7
8
9
顺便说一下,使用-Wshadow
编译器选项会发出警告,从而发现这个问题。把所有警告都当作错误处理,这样更安全!
# 问题131:C++中的默认成员初始化器(Default Member Initializers)是什么?
默认成员初始化自C++11起可用。
它允许你在声明类成员的地方对其进行初始化,而不是在构造函数中。
class T {
public:
T()=default ;
T(int iNum, std::string iText) : num(iNum), text(iText){};
private:
int num{0};
std::string text{};
};
2
3
4
5
6
7
8
在上面的例子中,成员num
和text
在声明的地方分别被初始化为0
和空字符串。这样一来,我们可以让默认构造函数更简洁。
它至少有两个优点:
- 如果你始终遵循这种做法,就不用担心忘记初始化某些成员。
- 你无需在其他地方查找变量的默认值。
我们仍然可以在任何构造函数中覆盖这些值。如果我们既在成员声明处又在构造函数中对成员进行初始化,构造函数中的初始化会生效。
这不会带来性能开销,构造函数不会对成员进行二次初始化。
你可能会问,这是否意味着成员会先被赋为默认值,然后再用构造函数中的值重新赋值。编译器足够智能,知道使用哪个值,并且会避免额外的赋值操作。C++核心准则也鼓励我们使用默认成员初始化来初始化数据成员,而不是使用默认构造函数。
# 问题132:什么是“最令人头疼的解析”?
“最令人头疼的解析”是C++编程语言中一种特定形式的语法歧义解析。这个术语由Scott Meyers 在《Effective STL》中提出,在C++语言标准的8.2节中有正式定义。
它指的是,任何能被解释为函数声明的内容,都会被解释为函数声明。
看下面这个例子:
std::string foo();
这可能是“最令人头疼的解析”最简单的形式。毫无防备的读者可能会认为我们只是声明了一个名为foo
的字符串,并调用了它的默认构造函数,也就是将其初始化为一个空字符串。
然后,例如当我们试图对它调用empty()
函数时,会得到以下错误信息(使用gcc
编译器):
main.cpp:18:5: error: request for member 'empty' in 'foo', which is of non-class type 'std::string()' {aka 'std::__cxx11::basic_string<char>()'
实际情况是,上面这行代码被解释为一个函数声明。我们只是声明了一个名为foo
的函数,它不接受任何参数并返回一个字符串。而我们原本只是想调用默认构造函数。
即使你知道“最令人头疼的解析”,调试这种问题也可能会很头疼。主要是因为编译器错误出现在不同的行,不是在声明变量(这里实际是函数)的时候,而是在尝试使用它的时候。
这个问题很容易解决。声明变量并调用其默认构造函数时,你根本不需要使用括号。不过自C++11起,如果你愿意,也可以使用大括号初始化。下面两个例子都能正常工作:
std::string foo;
std::string bar{};
2
现在让我们看一个更有意思的例子:
#include <iostream>
#include <string>
struct MyInt {
int m_i;
};
class Doubler {
public:
Doubler(MyInt i) : my_int(i) {}
int doubleIt() {
return my_int.m_i*2;
}
private:
MyInt my_int;
};
int main() {
int i=5;
Doubler d(MyInt(i));
std::cout << d.doubleIt() << std::endl;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
有三种方法可以修复这个问题:
- 在调用之前的那一行声明
MyInt
对象,这样它就不再是临时对象了。 - 用大括号初始化替换部分或全部括号。
Doubler d{MyInt(i)};
、Doubler d(MyInt{i})
以及Doubler d{MyInt{i}}
都能正常工作。不过第三种在构造函数调用方式上至少看起来不太一致。潜在的缺点是,这种方法从C++11起才有效。 - 如果你使用的C++版本早于C++11,可以在传给构造函数的参数周围额外加一对括号:
Doubler d((MyInt(i)))
。这样也能避免将其解析为声明。
# 问题133:这段代码能编译吗?如果能,它会做什么?如果不能,原因是什么?
这段代码能编译吗?如果能,它会做什么?如果不能,原因是什么?
class MyObject {
public:
void doSomething() {}
private:
// . . .
};
int main() {
MyObject o();
o.doSomething();
}
2
3
4
5
6
7
8
9
10
11
这段代码不能编译,错误信息如下:
main.cpp : In function 'int main()':
main.cpp :11:4: error : request for member 'doSomething' in 'o', which is of non-class type 'MyObject()'
11 | o .doSomething();
| ^~~~~~~~~~~
2
3
4
所以o
的类型不是MyObject
类,而是MyObject()
,换句话说,o
是一个不接受任何参数并返回MyObject
的函数。
这就是我们一个月前讨论过的声名狼藉的“最令人头疼的解析”。它是C++编程语言中一种特定形式的语法歧义解析。这个术语由斯科特·迈耶斯在《Effective STL》中提出,在C++语言标准的8.2节中有正式定义。它意味着任何能被解释为函数声明的内容,都会被解释为函数声明。
因此,MyObject o();
这行代码被解释为函数声明,而不是变量声明。
如果我们调用o
会发生什么呢?
class MyObject{
public:
void doSomething() {}
private:
// . . .
};
int main() {
MyObject o();
o();
}
2
3
4
5
6
7
8
9
10
11
main.cpp:(.text.startup+0x5): undefined reference to `o()'
collect2: error: ld returned 1 exit status
2
之前我们看到的是编译时错误,现在看到的是链接时错误。如前所述,我们声明了一个函数,但它没有在任何地方定义,因此出现了未定义引用的链接错误。
修复这个错误最简单的方法就是省略括号:MyObject o;
。自C++11起,我们也可以使用聚合初始化或大括号初始化。潜在的缺点是,这种方法从C++11起才有效,但这个问题现在越来越不是问题了。
# 问题134:什么是std::string_view
,为什么要使用它?
std::string_view
是C++17引入的,典型的实现需要两个信息:指向字符序列的指针和字符序列的长度。字符序列可以是C++字符串,也可以是C风格字符串。毕竟,std::string_view
是对字符串的非拥有式引用。
之所以需要这个类型,是因为复制它的成本很低,只需要复制上述信息和长度即可。我们可以有效地将其用于读取操作,此外,它还提供了remove_prefix
和remove_suffix
修改器。
你可能已经猜到,string_view
有这两个修改器,是因为在不修改底层字符序列的情况下很容易实现它们,只需修改字符序列的起始位置或长度即可。
如果你的函数不需要获取字符串参数的所有权,并且只需要读取操作(加上上述两个修改器),那就使用string_view
。实际上,string_view
应该取代所有const std::string&
类型的参数。
不过,它有一个缺点。由于在底层,你可能使用std::string
或std::string_view
,这样就失去了隐式的空字符终止。如果你需要这个特性,就必须继续使用std::string (const&)
。
前面提到的复制成本低,以及它比std::string&
少一级指针间接寻址,这些特点可以显著提升性能。如果你对细节感兴趣,可以阅读这篇文章。
# 问题135:如何检查一个字符串是否以某个子字符串开头或结尾?
与Python或其他语言不同,C++的字符串类型原本没有starts_with
/ends_with
函数,无法轻松判断一个字符串是否以特定前缀开头或特定后缀结尾。
到了C++20,情况发生了变化,starts_with
和ends_with
函数被添加到std::string
和std::string_view
中,进行这类检查变得极其简单且易读!
#include <iostream>
#include <string>
int main() {
std::string s{"what a string"};
std::cout << std::boolalpha;
std::cout << s.starts_with("what") << '\n';
std::cout << s.ends_with("not") << '\n';
}
2
3
4
5
6
7
8
9
在C++20之前,进行类似的检查也不难,只是可读性稍差,代码更冗长。
要检查一个字符串是否以某个前缀开头,可以使用std::string::find
函数,然后检查返回的位置是否是第一个字符的位置,即0。
检查后缀则更复杂一些,可读性也较差。我们可以使用std::string::compare
函数。在确保要检查的字符串至少和我们想验证的后缀一样长之后,我们必须传入潜在后缀的起始位置、后缀的长度以及后缀本身。
将其封装成一个函数,代码如下:
#include <iostream>
#include <string>
bool ends_with(const std::string& str, const std::string& suffix) {
if (str.size() >= suffix.size()) {
return str.compare(str.size() - suffix.size(), suffix.size(), suffix) == 0;
}
return false;
}
int main() {
std::string s{"what a string"};
std::cout << std::boolalpha;
std::cout << (s.find("what") == 0) << '\n';
std::cout << (s.find("a") == 0) << '\n';
std::cout << ends_with(s, "string") << '\n';
std::cout << ends_with(s, "what") << '\n';
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
如果你使用boost
库,也可以使用boost::algorithm::starts_with
/boost::algorithm::ends_with
:
#include <iostream>
#include <string>
#include <boost/algorithm/string/predicate.hpp>
int main() {
std::string s{"what a string"};
std::cout << std::boolalpha;
std::cout << boost::algorithm::starts_with(s, "what") << '\n';
std::cout << boost::algorithm::starts_with(s, "a") << '\n';
std::cout << boost::algorithm::ends_with(s, "string") << '\n';
std::cout << boost::algorithm::ends_with(s, "what") << '\n';
}
2
3
4
5
6
7
8
9
10
11
12
# 问题136:什么是返回值优化(RVO,Return Value Optimization)?
RVO代表返回值优化。
它是一种编译器优化技术,能够消除为存储函数返回值而创建的临时对象。RVO特别值得注意的是,C++标准允许它改变最终程序的可观察行为。
一般来说,C++标准允许编译器进行任何优化,只要生成的可执行文件表现出的可观察行为与满足标准所有要求时的行为一致(即假装满足标准要求)。这通常被称为“as-if规则”。返回值优化这个术语指的是C++标准中的一个特殊条款,它比“as-if规则”更进一步:即使复制构造函数有副作用,实现也可以省略由返回语句导致的复制操作。
#include <iostream>
struct C {
C() = default ;
C(const C&) {
std::cout << "A copy was made." << std::endl;
}
};
C f() {
return C();
}
int main() {
std::cout << "Hello World!" << std::endl;
C obj = f();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
当编译器看到调用函数中有一个变量(将由返回值构造),以及被调用函数中有一个变量(将被返回)时,它会意识到不需要同时使用这两个变量。实际上,编译器会将调用函数中变量的地址传递给被调用函数。
引用C++98标准的内容:“每当使用复制构造函数复制一个临时类对象时……实现可以将原始对象和复制对象视为引用同一对象的两种不同方式,甚至可以完全不执行复制操作,即使类的复制构造函数或析构函数有副作用。对于返回类型为类的函数,如果返回语句中的表达式是一个局部对象的名称……实现可以省略创建用于存储函数返回值的临时对象……”(C++98标准12.8节[class.copy],第15段。C++11标准在12.8节第31段有类似表述,但更复杂。)
# 问题137:如何确保编译器执行返回值优化(RVO)?
让我们回顾一下昨天提到的RVO是什么!
它是编译器执行的一种优化,用于消除为存储函数返回值而创建的临时对象。RVO特别之处在于,C++标准允许它改变最终程序的可观察行为。
在实践中,这意味着应用RVO时,对象的复制次数会减少,复制构造函数的调用次数也会减少。对于一些大对象来说,性能提升可能相当可观。
为了让编译器应用RVO,返回的对象必须在返回语句中构造。一个函数可以有多个出口,只要对象在返回语句中构造,就没有问题。
SomeBigObject f() {
if (...) {
return SomeBigObject{...};
} else {
return SomeBigObject{...};
}
}
2
3
4
5
6
7
什么是具名返回值优化(NRVO,Named Return Value Optimization),它在什么时候会发生呢?
NRVO中的“N”代表“具名(Named)”,所以我们说的是具名返回值优化。即使返回的对象有名称,因此不是在返回语句中构造的,也可以避免复制临时对象。NRVO的要求是,即使有多个出口,也只能返回一个对象。
SomeBigObject f() {
SomeBigObject result{...};
if (...) {
return result;
}
// . . .
return result;
}
2
3
4
5
6
7
8
所以在下面这种情况下,NRVO不会发生:
SomeBigObject f(...) {
SomeBigObject object1{...};
SomeBigObject object2{...};
if (...) {
return object1;
} else {
return object2;
}
}
2
3
4
5
6
7
8
9
# 问题138:C++中的主要值类别(primary value categories)和混合值类别是什么?
C++表达式(例如带操作数的运算符、字面量、变量名等)总是由两个独立的属性来描述:类型和值类别。每个表达式都属于三个主要值类别之一:
- 左值(lvalue)
- 纯右值(prvalue)
- 将亡值(xvalue)
# 左值(lvalue)
左值是一种表达式,其求值结果确定了一个对象、位域或函数的身份,且其资源不能被复用。
左值只能出现在赋值运算符的左侧。
可以使用内置的取地址运算符获取左值的地址。可修改的左值可以用作内置赋值运算符和复合赋值运算符的左操作数。左值可用于初始化左值引用,这会将一个新名称与表达式所标识的对象关联起来。
下面举例帮助你理解:
- 变量名、函数名、成员名。即使变量的类型是右值引用,由其名称组成的表达式也是左值表达式;
- 返回类型为左值引用的函数调用或重载运算符表达式;
- 所有内置的赋值和复合赋值表达式(如
a = b;
、a*=b;
等); - 转换为左值引用类型的强制转换表达式;
- ……
# 纯右值(prvalue)
纯右值(“纯粹的”右值)是一种表达式,其求值结果要么:
- 计算运算符操作数的值,要么是一个空表达式(这种纯右值没有结果对象);
- 初始化一个对象或位域(这种纯右值被认为有一个结果对象)。除了
decltype
,所有类和数组的纯右值都有一个结果对象,即使该对象被丢弃。结果对象可以是变量、由new
表达式创建的对象、通过临时对象物化创建的临时对象或它们的成员。
纯右值不能是多态的,它所表示的对象的动态类型始终是表达式的类型。非类、非数组的纯右值不能有cv
限定符。纯右值不能有不完全类型。纯右值不能有抽象类类型或抽象类数组类型。
一些例子:
- 字面量,字符串字面量除外,字符串字面量是左值;
- 返回类型为非引用的函数调用或重载运算符表达式;
- 所有内置的算术和逻辑表达式;
- lambda表达式;
- ……
# 将亡值(xvalue)
将亡值是一种表达式,其求值结果确定了一个对象、位域或函数的身份,且其资源可以被复用(与资源不能被复用的左值不同)。
一些例子:
- 返回类型为对象的右值引用的函数调用或重载运算符表达式,例如
std::move(x);
; - 转换为对象类型的右值引用的强制转换表达式,例如
static_cast<char&&>(x)
; - ……
# 问题139:可以安全地比较有符号整数和无符号整数吗?
不可以。为了避免出现错误结果,应该避免比较有符号整数和无符号整数。
int x = -3;
unsigned int y = 7;
// 无符号结果,可能是4294967286
std::cout << x - y << '\n';
// 无符号结果:4
std::cout << x + y << '\n';
// 无符号结果,可能是4294967275
std::cout << x * y << '\n';
std::cout << std::boolalpha;
std::cout << "-3 < 7: " << (x < y) << '\n';
std::cout << "-3 <= 7: " << (x <= y) << '\n';
std::cout << "-3 > 7: " << (x > y) << '\n';
std::cout << "-3 => 7: " << (x >= y) << '\n';
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
这背后的原因是,有符号整数x
会被转换为无符号整数。根据不同的平台,转换后的x
会变成4294967293(2^32 - 3
)。
应该避免比较有符号整数和无符号整数,除非你使用的是C++20,并且可以使用std::cmp_equal
及其相关函数。
# 问题140:main 函数的返回值是什么,有哪些可用的签名?
虽然在某些情况下你可能会看到void
,但这是不正确的。C++程序中main
函数的返回类型是int
。
如果程序成功完成,main
函数返回0,否则返回一个非零值。操作系统根据这个返回类型来判断程序是否执行成功。不过,对于不同的整数值代表什么含义,并没有统一的标准。
尽管main
函数的返回类型是int
,但在这种情况下,返回值可以省略,并且会自动被视为0,即被认为是成功返回。
main
函数有两种有效的签名:
int main()
,这种情况下没有参数传入。int main(int argc, char **argv)
或与之等效的形式,如int main(int argc, char *argv[])
。
在后一种情况下,argc
表示传递给C++程序的命令行参数的数量,在argv
中可以找到程序名和参数。当argc
大于0时,argv[0]
将是程序名,其余的是参数。
例如,如果你执行./myProg foo bar 'b a z'
,argc
将是3,argv
将是["myProg", "foo", "bar", "b a z"]
。
# 问题141:应该优先使用默认参数还是重载?
在大多数情况下,应该优先使用默认参数。这并非基于技术或性能方面的原因,而是从实用性考虑。虽然有可能出现一个重载函数只是用 “默认值” 调用另一个重载函数的情况,就像下面的例子,但并不能保证一直如此,我们可能最终会得到重复的代码,更糟糕的是,原本期望行为一致,结果却出现了差异。
// 使用重载实现 “默认” 值
int foo(int a, int b) {
// . . .
}
int foo(int a) {
return foo(a, 0);
}
2
3
4
5
6
7
8
// 真正的默认参数
int foo(int a, int b=0) {
// . . .
}
2
3
4
不过,如果你是库的维护者,可能需要考虑二进制兼容性。给函数添加一个新参数,即使它有默认值,也会破坏二进制兼容性。另一方面,添加一个新的重载函数则不会破坏二进制兼容性。
因此,如果你是库的维护者,并且很在意二进制兼容性,那么优先选择重载,并在计划发布新的主版本时注意合并这些重载函数。
# 问题142:一行代码应该声明多少个变量?
大多数情况下,一行只声明一个变量。从语法上讲,你想声明多少个变量都可以,但只声明一个变量可以提高代码的可读性并避免错误。
考虑这样一行代码:
char *p, c, a[7], *pp[7], **aa[10];
很难弄清楚这行代码的意图,也很容易出错。当你开始添加初始化时,情况会变得更混乱。
int a, b = 3;
a
是如何初始化的呢?
它并没有被初始化,但经验不足的同事可能会认为它应该被初始化为3。
如果你在单独的行上初始化每个变量,就不会产生这样的误解。另外,如果你想解释变量的用途,在代码中添加有意义的注释也会更容易。
我经常看到在同一行声明多个变量时,初始化往往是逐行、逐个变量地在下面几行进行,这完全是浪费赋值操作。
为了减少赋值操作并提高可读性,每行声明一个变量,并尽可能对其进行初始化。
# 问题143:应该优先使用 switch 语句还是链式if
语句?
优先使用switch
语句,原因有很多。
switch
语句更具可读性,即使你把所有必要的break
语句都考虑在内也是如此。
此外,通常switch
语句可以进行更好的优化。
但最重要的原因是,你可能会基于int
类型进行switch
判断,但更可能的是,它可以转换为枚举(enum)类型。如果你这样做了,并且避免使用default
分支,那么当你没有覆盖枚举中的所有不同情况时,编译器会发出警告。
这也是不使用default
分支的一个很好的理由,这样可以确保代码的完整性。想象一下,有一天你在枚举中添加了一个新的情况,但忘记更新代码库中的所有switch
语句。如果你不使用default
分支,并把警告当作错误来处理,那么你的代码将无法编译。
enum class Color { Red, Green, Blue, Yellow };
Color c = getColor();
switch (c) {
case Color::Red: break ;
case Color::Green: break ;
};
2
3
4
5
6
7
main.cpp:9:12: warning: enumeration value 'Blue' not handled in switch [-Wswitch]
main.cpp:9:12: warning: enumeration value 'Yellow' not handled in switch [-Wswitch]
2
# 问题144:什么是包含保护(include guards)?
包含保护(也叫头文件保护)用于防止同一个头文件被多次包含。如果一个头文件在多个文件中被包含,那么相同的实体就会被多次定义,这明显违反了单一定义规则,这样代码将无法编译。
为了更好地理解这一点,我们必须记住,在编译之前,预处理器会用被包含文件的文本内容替换所有的#include
语句。
因此,我们必须在所有头文件中使用包含保护:
#ifndef SOME_UNIQUE_NAME_HERE
#define SOME_UNIQUE_NAME_HERE
// 这里写你的声明(以及某些类型的定义)
#endif
2
3
4
5
6
在第一次包含时,由于SOME_UNIQUE_NAME_HERE
还未定义,它将被定义,并且头文件的内容会被复制。在第二次包含时,#ifndef SOME_UNIQUE_NAME_HERE
的求值结果为false
,因此预处理器会直接跳到#endif
之后,即文件末尾。
对于大多数现代编译器,你可以在头文件开头直接使用#pragma once
,这样可以避免上述语法,获得相同的效果。
# 问题145:应该使用尖括号(<filename>)还是双引号("filename")来包含文件?
和生活中的很多事情一样,答案是视情况而定!
对于同一项目中的文件,即与包含文件存在相对路径关系的文件,应该使用双引号。
对于标准库中的文件,或者实际上你所依赖的任何库中的文件,应该优先使用尖括号形式。
// foo.cpp:
// 包含标准库文件,需要使用<>形式
#include <string>
// 包含来自其他库的非本地相对文件,使用<>形式
#include <some_library/common.h>
// 包含与foo.cpp在同一项目中的本地相对文件,使用""形式
#include "foo.h"
// 包含与foo.cpp在同一项目中的本地相对文件,使用""形式
#include "foo_utils/utils.h"
2
3
4
5
6
7
8
9
10
11
12
如果你使用双引号,编译器会首先在本地相对路径中查找文件,如果找不到匹配的文件,才会在其他可能的地方查找。
这也意味着,如果你使用双引号包含来自其他库的文件,而在本地项目的相同相对路径下创建了一个同名文件,那么在编译时你可能会遇到意外的问题。
这些是常见的最佳实践,想要获取确切信息,最好查看你所使用编译器的具体实现。
# 问题146:一个函数中应该有多少个return
语句?
虽然有些人坚持认为为了提高可读性,一个函数中应该只有一个return
语句,但这可能适得其反。
确实,使用多个return
语句会导致函数有多个出口点,当你试图理解函数的功能时,需要考虑这些出口点。但只有在函数规模过大时,这才会成为一个问题。如果你编写的是简短的函数,比如在一屏内就能显示完整(使用较大的字体),那就不是问题。
此外,使用多个return
语句可以很容易地在函数开头添加一些保护条件:
void foo(MyClass* iMyClass) {
if (iMyClass == nullptr) {
return ;
}
// 执行操作
}
2
3
4
5
6
使用多个return
语句可以避免编写过于复杂的代码,也无需引入额外的状态变量。