【C++基础】第三十八课:特殊用途语言特性

默认实参,内联函数,constexpr函数,assert,NDEBUG,__func__,__FILE__,__LINE__,__TIME__,__DATE__

Posted by x-jeff on February 26, 2022

【C++基础】系列博客为参考《C++ Primer中文版(第5版)》C++11标准)一书,自己所做的读书笔记。
本文为原创文章,未经本人允许,禁止转载。转载请注明出处。

1.前言

本文我们介绍三种函数相关的语言特性,这些特性对大多数程序都有用,它们分别是:默认实参、内联函数和constexpr函数,以及在程序调试过程中常用的一些功能。

2.默认实参

调用含有默认实参的函数时,可以包含该实参,也可以省略该实参:

1
2
typedef string::size_type sz;
string screen(sz ht = 24, sz wid = 80, char backgrnd = ' ');

⚠️一旦某个形参被赋予了默认值,它后面的所有形参都必须有默认值:

1
2
3
4
5
6
7
8
9
10
11
12
//错误例子
int func1(int a = 2, int b, int c) {
    return a + b + c;
}
//正确例子
int func2(int a = 2, int b = 3, int c = 4) {
    return a + b + c;
}
//正确例子
int func3(int b, int c, int a = 2) {
    return a + b + c;
}

2.1.使用默认实参调用函数

1
2
3
4
5
string window;
window = screen();//等价于screen(24,80,' ')
window = screen(66);//等价于screen(66,80,' ')
window = screen(66,256);//等价于screen(66,256,' ')
window = screen(66,256,'#');//等价于screen(66,256,'#')

函数调用时实参按其位置解析,默认实参负责填补函数调用缺少的尾部实参(靠右侧位置)。例如,要想覆盖backgrnd的默认值,必须为ht和wid提供实参:

1
2
window = screen(,,'?');//错误:只能省略尾部的实参
window = screen('?');//调用screen('?',80,' ')

上述例子中,char类型的实参隐式地转换成string::size_type,然后作为height的值传递给函数。

2.2.默认实参声明

对于函数的声明来说,通常的习惯是将其放在头文件中,并且一个函数只声明一次,但是多次声明同一个函数也是合法的。不过有一点需要注意,在给定的作用域中一个形参只能被赋予一次默认实参。换句话说,函数的后续声明只能为之前那些没有默认值的形参添加默认实参,而且该形参右侧的所有形参必须都有默认值。假如给定:

1
2
//表示高度和宽度的形参没有默认值
string screen(sz, sz, char = ' ');

我们不能修改一个已经存在的默认值:

1
string screen(sz, sz, char = '*');//错误:重复声明

但是可以按照如下形式添加默认实参:

1
string screen(sz = 24, sz = 80, char);//正确:添加默认实参

函数声明:函数声明

2.3.默认实参初始值

⚠️局部变量不能作为默认实参。除此之外,只要表达式的类型能转换成形参所需的类型,该表达式就能作为默认实参:

1
2
3
4
5
6
//wd,def和ht的声明必须出现在函数之外
sz wd = 80;
char def = ' ';
sz ht();
string screen(sz = ht(), sz = wd, char = def);
string window = screen();//调用screen(ht(), 80, ' ')

用作默认实参的名字在函数声明所在的作用域内解析,而这些名字的求值过程发生在函数调用时:

1
2
3
4
5
6
void f2()
{
	def = '*';
	sz wd = 100;
	window = screen();//调用screen(ht(), 80, '*')
}

3.内联函数和constexpr函数

我们在之前的博客中定义过一个shorterString函数,这样的函数有一个潜在的缺点:调用函数一般比求等价表达式的值要慢一些。在大多数机器上,一次函数调用其实包含着一系列工作:调用前要先保存寄存器,并在返回时恢复;可能需要拷贝实参;程序转向一个新的位置继续执行。

3.1.内联函数可避免函数调用的开销

将函数指定为内联函数(inline),通常就是将它在每个调用点上“内联地”展开。假设我们把shorterString函数定义成内联函数,则如下调用:

1
cout << shorterString(s1,s2) << endl;

将在编译过程中展开成类似于下面的形式:

1
cout << (s1.size() < s2.size() ? s1 : s2) << endl;

从而消除了shorterString函数的运行时开销。

在shorterString函数的返回类型前面加上关键字inline,这样就可以将它声明成内联函数了:

1
2
3
4
5
//内联版本:寻找两个string对象中较短的那个
inline const string& shorterString(const string &s1, const string &s2)
{
	return s1.size() <= s2.size() ? s1 : s2;
}

运行程序时,操作系统将这些指令载入计算机内存中,因此每条指令都有特定的内存地址。计算机随后将逐步执行这些指令。有时(如有循环和分支语句时),将跳过一些指令,向前或向后跳到特定地址。常规函数调用也使程序跳到另一个地址(函数的地址),并在函数结束时返回。

下面更详细地介绍这一过程的典型实现。执行到函数调用指令时,程序将在函数调用后立即存储该指令的内存地址,并将函数参数复制到堆栈(为此保留的内存块),跳到标记函数起点的内存单元,执行函数代码(也许还需将返回值放入寄存器中),然后跳回到地址被保存的指令处(这与阅读文章时停下来看脚注,并在阅读完脚注后返回到以前阅读的地方类似)。来回跳跃并记录跳跃位置意味着以前使用函数时,需要一定的开销。

内联函数提供了另一种选择。编译器将使用相应的函数代码替换函数调用。因此,内联函数的运行速度比常规函数稍快,但代价是需要占用更多内存。

3.2.constexpr函数

constexpr函数是指能用于常量表达式的函数。定义constexpr函数的方法与其他函数类似,不过要遵循几项约定:函数的返回类型及所有形参的类型都得是字面值类型,而且函数体中必须有且只有一条return语句:

1
2
constexpr int new_sz() { return 42; }
constexpr int foo = new_sz();//正确:foo是一个常量表达式

执行该初始化任务时,编译器把对constexpr函数的调用替换成其结果值。为了能在编译过程中随时展开,constexpr函数被隐式地指定为内联函数

constexpr函数体内也可以包含其他语句,只要这些语句在运行时不执行任何操作就行。例如,constexpr函数中可以有空语句、类型别名以及using声明。

我们允许constexpr函数的返回值并非一个常量:

1
2
3
4
constexpr size_t scale(size_t cnt)
{
	return new_sz() * cnt;
}

当scale的实参是常量表达式时,它的返回值也是常量表达式;反之则不然:

1
2
3
int arr[scale(2)];//正确:scale(2)是常量表达式
int i = 2;//i不是常量表达式
int a2[scale(i)];//错误:scale(i)的返回值不是常量表达式

在自己电脑上尝试发现不会报错:

1
2
3
4
int arr[scale(i)];//被初始化成int [25227],没有报错,可能是编译器做了优化
arr[0] = 1;//可以正常执行
int arr1[scale(2)];//被初始化成int [84],正确,符合预期
arr1[0] = 1;//正常执行

4.调试帮助

C++程序员有时会用到一种类似于头文件保护的技术,以便有选择地执行调试代码。基本思想是,程序可以包含一些用于调试的代码,但是这些代码只在开发程序时使用。当应用程序编写完成准备发布时,要先屏蔽掉调试代码。这种方法用到两项预处理功能:assertNDEBUG

4.1.assert预处理宏

assert是一种预处理宏(preprocessor marco)。所谓预处理宏其实是一个预处理变量,它的行为有点类似于内联函数。assert宏使用一个表达式作为它的条件:

1
assert(expr);

首先对expr求值,如果表达式为假(即0),assert输出信息并终止程序的执行。如果表达式为真(即非0),assert什么也不做。

assert宏定义在cassert头文件中。如我们所知,预处理名字由预处理器而非编译器管理,因此我们可以直接使用预处理名字而无须提供using声明。也就是说,我们应该使用assert而不是std::assert,也不需要为assert提供using声明。

4.2.NDEBUG预处理变量

assert的行为依赖于一个名为NDEBUG的预处理变量的状态。如果定义了NDEBUG,则assert什么也不做。默认状态下没有定义NDEBUG,此时assert将执行运行时检查。例如下面这段程序会报错:

1
2
3
4
5
6
7
8
#include <cassert>
int main() {
    int a, b;
    a = 1;
    b = 2;
    assert(a > b);//此处报错
    return 0;
}

但如果我们在引入cassert头文件之前定义NDEBUG使assert失效就不会报错了:

1
2
3
4
5
6
7
8
9
#define NDEBUG
#include <cassert>
int main() {
    int a, b;
    a = 1;
    b = 2;
    assert(a > b);//此处assert失效
    return 0;
}

定义NDEBUG能避免检查各种条件所需的运行时开销,当然此时根本就不会执行运行时检查。除了用于assert外,也可以使用NDEBUG编写自己的条件调试代码。如果NDEBUG未定义,将执行#ifndef和#endif之间的代码;如果定义了NDEBUG,这些代码将被忽略掉:

1
2
3
4
5
6
7
void print(const int ia[], size_t size)
{
#ifndef NDEBUG
	//__func__是编译器定义的一个局部静态变量,用于存放函数的名字
	cerr << __func__ << " : array size is " << size << endl;
#endif
}

除了C++编译器定义的__func__之外,预处理器还定义了另外4个对于程序调试很有用的名字:

  1. __FILE__:存放文件名的字符串字面值。
  2. __LINE__:存放当前行号的整型字面值。
  3. __TIME__:存放文件编译时间的字符串字面值。
  4. __DATE__:存放文件编译日期的字符串字面值。

举个使用的例子:

1
2
if(word.size() < threshold)
	cerr << "Error : " << __FILE__ << " : in function " << __func__ << " at line " << __LINE__ << endl << " Compiled on " << __DATE__ << " at " << __TIME__ << endl << " Word read was \"" << word << "\" : Length too short" <<endl;