1. 常量变量(命名常量)

常量是在程序执行期间不能更改的值。C++ 支持两种不同类型的常量:

  • 命名常量(named constants) 是与标识符关联的常量值。这些有时也称为符号常量

  • 字面常量(literal constants) 是与标识符无关的常量值。

在 C++ 中定义命名常量有三种方法:

  • 常量变量

  • 带有替换文本的类似对象的宏

  • 枚举常量

1.1 常量变量

常量变量是最常见的命名常量类型。声明变量为常量的方法:

1
2
const double gravity { 9.8 };  // preferred use of const before type
int const sidesInSquare { 4 }; // "east const" style, okay but not preferred

一般来说const放在前面

常量变量必须在定义时初始化,然后该值不能通过赋值来更改,一个错误的示例:

1
2
3
4
5
6
7
int main()
{
const double gravity; // error: const variables must be initialized
gravity = 9.9; // error: const variables can not be changed

return 0;
}

常量变量可以从其他变量(包括非常量变量)初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>

int main()
{
std::cout << "Enter your age: ";
int age{};
std::cin >> age;

const int constAge { age }; // initialize const variable using non-const value

age = 5; // ok: age is non-const, so we can change its value
constAge = 6; // error: constAge is const, so we cannot change its value

return 0;
}

C风格的常量名称通常是大写加下划线(例如EARTH_GRAVITY),在C++中通常与变量的命名约定相同,不作区分。

在现代 C++ 中,我们不会将值参数设置const因为我们通常不关心函数是否更改参数的值(因为它只是一个副本,无论如何都会在函数结束时销毁)。 const关键字还给函数原型添加了少量不必要的混乱。以下这种写法不推荐:

1
2
3
4
void printInt(const int x)
{
std::cout << x << '\n';
}

按值返回也不要使用常量。对于基本类型,返回类型上的const限定符将被忽略(编译器可能会生成警告),以下写法不推荐:

1
2
3
4
const int getValue()
{
return 5;
}

1.2 带有替换文本的宏

前面已经介绍过,一个示例:

1
2
3
4
5
6
7
8
9
10
#include <iostream>

#define MY_NAME "Alex"

int main()
{
std::cout << "My name is: " << MY_NAME << '\n';

return 0;
}

优先选择常量变量而不是预处理器宏,最大的问题是宏不遵循正常的 C++ 范围规则。#defined 宏后,当前文件中出现的所有后续出现的宏名称都将被替换。如果该名称在其他地方使用,您将在您不需要的地方获得宏替换。这很可能会导致奇怪的编译错误。

1.3 类型限定符

类型限定符(type qualifier)(有时简称为限定符)是应用于类型的关键字,用于修改该类型的行为方式。用于声明常量变量的const称为const类型限定符(或简称const限定符)。从 C++23 开始,C++ 只有两种类型限定符: constvolatile 。volatile限定符用于告诉编译器一个对象的值可以随时更改。这个很少使用的限定符会禁用某些类型的优化。

在技​​术文档中, constvolatile限定符通常称为cv 限定符

一个笑话:

image.png

2. 字面量

字面量(Literals) 是直接插入代码中的值。例如:

1
2
3
4
return 5;                     // 5 is an integer literal
bool myNameIsAlex { true }; // true is a boolean literal
double d { 3.4 }; // 3.4 is a double literal
std::cout << "Hello, world!"; // "Hello, world!" is a C-style string literal

字面量有时被称为字面常量,因为它们的含义无法重新定义( 5始终表示整数值 5)。就像对象有类型一样,所有的字面量都有类型。字面量的类型是根据字面量的值推导出来的。默认情况下:

字面量值 示例 默认字面量类型
整数值 5, 0, -3 int
布尔值 true, false bool
浮点数值 1.2, 0.0, 3.4 double (不是float!)
字符 ‘a’, ‘\n’ char
C风格字符串 “Hello, world!” const char[14]

如果文本的默认类型与预期不符,则可以通过添加后缀来更改文本的类型。以下是一些更常见的后缀:

数据类型 后缀 含义
整数类型 u 或 U 无符号整型
整数类型 l 或 L 长整型
整数类型 ul, uL, Ul, UL, lu, lU, Lu, LU 无符号长整型
整数类型 ll 或 LL 长长整型
整数类型 ull, uLL, Ull, ULL, llu, llU, LLu, LLU 无符号长长整型
整数类型 z 或 Z std::size_t 的有符号版本 (C++23)
整数类型 uz, uZ, Uz, UZ, zu, zU, Zu, ZU std::size_t (C++23)
浮点数类型 f 或 F 浮点型
浮点数类型 l 或 L 长双精度浮点型
字符串类型 s std::string
字符串类型 sv std::string_view

在大多数情况下,不需要后缀(f 除外)

大多数后缀不区分大小写。例外:

  • ssv 必须为小写。

  • 两个连续的 lL 字符必须具有相同的大小写。

由于小写 L 在某些字体中可能看起来像数字 1,因此一些开发人员更喜欢使用大写文本。其他选项使用小写后缀(L 除外)

默认情况下,浮点文本的类型为 double。要使它们成为浮点文本,应使用 f (或 F) 后缀:

1
2
3
4
5
6
7
8
9
#include <iostream>

int main()
{
std::cout << 5.0 << '\n'; // 5.0 (no suffix) is type double (by default)
std::cout << 5.0f << '\n'; // 5.0f is type float

return 0;
}

以下情况会出现警告:

1
float f { 4.1 }; // warning: 4.1 is a double literal, not a float literal

因为浮点数的默认类型是double。将值从double转换为float可能会导致精度损失,因此编译器会发出警告。

解决方法:

1
2
float f { 4.1f }; // use 'f' suffix so the literal is a float and matches variable type of float
double d { 4.1 }; // change variable to type double so it matches the literal type double

有两种不同的方法可以编写浮点文本:

  • 在标准表示法中,我们写一个带小数点的数字:

    1
    2
    3
    double pi { 3.14159 }; // 3.14159 is a double literal in standard notation
    double d { -1.23 }; // the literal can be negative
    double why { 0. }; // syntactically acceptable, but avoid this because it's hard to see the decimal point (prefer 0.0)
  • 在科学记数法中,我们添加一个 e 来表示指数:

    1
    2
    double avogadro { 6.02e23 }; // 6.02 x 10^23 is a double literal in scientific notation
    double protonCharge { 1.6e-19 }; // charge on a proton is 1.6 x 10^-19

由于字符串在程序中常用,因此大多数现代编程语言都包含基本的字符串数据类型。由于历史原因,字符串并不是 C++ 中的基本类型。相反,它们有一种奇怪、复杂的类型,很难使用。此类字符串通常称为C 字符串C 样式字符串,因为它们是从 C 语言继承的。

  • 所有 C 风格的字符串文字都有一个隐式的 null 终止符。考虑一个诸如"hello"之类的字符串。虽然这个 C 风格字符串看起来只有 5 个字符,但实际上有 6 个: 'h' 、 'e' 、 'l '、 'l' 、 'o''\0' ( ASCII 代码为 0 的字符)。这个结尾的“\0”字符是一个称为空终止符的特殊字符,它用于指示字符串的结尾。以空终止符结尾的字符串称为空终止字符串

  • 与大多数其他文字(是值,而不是对象)不同,C 样式字符串文字是在程序开始时创建的常量对象,并保证在整个程序中存在

与 C 风格的字符串文字不同, std::stringstd::string_view文字创建临时对象。这些临时对象必须立即使用,因为它们在创建它们的完整表达式结束时被销毁。

3. 数字系统

八进制是以 8 为基数的——也就是说,唯一可用的数字是:0、1、2、3、4、5、6 和 7

Decimal  十进制 0 1 2 3 4 5 6 7 8 9 10 11
Octal  八进制 0 1 2 3 4 5 6 7 10 11 12 13

要使用八进制文字,请在文字前面加上 0(零):

1
2
3
4
5
6
7
8
#include <iostream>

int main()
{
int x{ 012 }; // 0 before the number means this is octal
std::cout << x << '\n';
return 0;
}

要使用十六进制文字,请在文字前面加上0x前缀:

1
2
3
4
5
6
7
8
#include <iostream>

int main()
{
int x{ 0xF }; // 0x before the number means this is hexadecimal
std::cout << x << '\n';
return 0;
}

在 C++14 之前,不支持二进制文字,可以使用16进制来表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

int main()
{
int bin{}; // assume 16-bit ints
bin = 0x0001; // assign binary 0000 0000 0000 0001 to the variable
bin = 0x0002; // assign binary 0000 0000 0000 0010 to the variable
bin = 0x0004; // assign binary 0000 0000 0000 0100 to the variable
bin = 0x0008; // assign binary 0000 0000 0000 1000 to the variable
bin = 0x0010; // assign binary 0000 0000 0001 0000 to the variable
bin = 0x0020; // assign binary 0000 0000 0010 0000 to the variable
bin = 0x0040; // assign binary 0000 0000 0100 0000 to the variable
bin = 0x0080; // assign binary 0000 0000 1000 0000 to the variable
bin = 0x00FF; // assign binary 0000 0000 1111 1111 to the variable
bin = 0x00B3; // assign binary 0000 0000 1011 0011 to the variable
bin = 0xF770; // assign binary 1111 0111 0111 0000 to the variable

return 0;
}

在 C++14 及以上版本中,我们可以通过使用 0b 前缀来使用二进制文字:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>

int main()
{
int bin{}; // assume 16-bit ints
bin = 0b1; // assign binary 0000 0000 0000 0001 to the variable
bin = 0b11; // assign binary 0000 0000 0000 0011 to the variable
bin = 0b1010; // assign binary 0000 0000 0000 1010 to the variable
bin = 0b11110000; // assign binary 0000 0000 1111 0000 to the variable

return 0;
}

由于长文字可能难以阅读,C++14 还添加了使用引号 (') 作为数字分隔符的功能。

1
2
3
4
5
6
7
8
9
#include <iostream>

int main()
{
int bin { 0b1011'0010 }; // assign binary 1011 0010 to the variable
long value { 2'132'673'462 }; // much easier to read than 2132673462

return 0;
}

另请注意,分隔符不能出现在值的第一个数字之前:

1
int bin { 0b'1011'0010 };  // error: ' used before first digit of value

数字分隔符纯粹是视觉上的,不会以任何方式影响字面值。

默认情况下,C++ 以十进制输出值。但是,您可以通过使用std::dec 、 std::octstd::hex I/O 操纵器来更改输出格式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>

int main()
{
int x { 12 };
std::cout << x << '\n'; // decimal (by default)
std::cout << std::hex << x << '\n'; // hexadecimal
std::cout << x << '\n'; // now hexadecimal
std::cout << std::oct << x << '\n'; // octal
std::cout << std::dec << x << '\n'; // return to decimal
std::cout << x << '\n'; // decimal

return 0;
}

一旦应用,I/O 操纵器将保持为未来输出设置,直到再次更改。

以二进制输出值有点困难,因为std::cout没有内置此功能。但是可以使用std::bitset,示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <bitset> // for std::bitset
#include <iostream>

int main()
{
// std::bitset<8> means we want to store 8 bits
std::bitset<8> bin1{ 0b1100'0101 }; // binary literal for binary 1100 0101
std::bitset<8> bin2{ 0xC5 }; // hexadecimal literal for binary 1100 0101

std::cout << bin1 << '\n' << bin2 << '\n';
std::cout << std::bitset<4>{ 0b1010 } << '\n'; // create a temporary std::bitset and print it

return 0;
}

在 C++20 和 C++23 中,我们有更好的选项通过新的格式库 (C++20) 和打印库 (C++23) 打印二进制:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <format> // C++20
#include <iostream>
#include <print> // C++23

int main()
{
std::cout << std::format("{:b}\n", 0b1010); // C++20, {:b} formats the argument as binary digits
std::cout << std::format("{:#b}\n", 0b1010); // C++20, {:#b} formats the argument as 0b-prefixed binary digits

std::println("{:b} {:#b}", 0b1010, 0b1010); // C++23, format/print two arguments (same as above) and a newline

return 0;
}

4. as-if 规则和编译时优化

4.1 优化简介

在编程中,优化是修改软件以使其更有效地工作(例如运行更快或使用更少的资源)的过程。优化会对应用程序的整体性能水平产生巨大影响。优化另一个程序的程序称为优化器。优化器通常在低级别工作,通过重写、重新排序或消除来寻找改进语句或表达式的方法。例如,当你写i = i * 2; ,优化器可能会将其重写为i *= 2; , i += i; ,或i <<= 1;。现代 C++ 编译器正在优化编译器,这意味着它们能够在编译过程中自动优化你的程序

4.2 as-if 规则

as-if 规则表示,编译器可以随意修改程序,以生成更优化的代码,只要这些修改不影响程序的“可观察行为”。

4.3 编译时评估

现代 C++ 编译器能够在编译时(而不是在运行时)完全或部分计算某些表达式。当编译器在编译时对表达式进行完全或部分计算时,这称为编译时评估

编译时评估的最初形式之一称为“常量折叠”。常量折叠是一种优化技术,编译器将具有字面量操作数的表达式替换为表达式的结果。使用常量折叠,编译器会识别出表达式 3+4 具有常量操作数,然后将表达式替换为结果 7。考虑以下程序:

1
2
3
4
5
6
7
8
9
#include <iostream>

int main()
{
int x { 3 + 4 };
std::cout << x << '\n';

return 0;
}

进行常量折叠后:

1
2
3
4
5
6
7
8
9
#include <iostream>

int main()
{
int x { 7 };
std::cout << x << '\n';

return 0;
}

这个程序还可以继续优化,初始化 x 时,值 7 将存储在分配给 x 的内存中。然后在下一行,程序将再次进入内存以获取值 7 以便打印它。这需要两个内存访问作(一个用于存储值,另一个用于获取值)。常量传播是一种优化技术,其中编译器将已知具有常量值的变量替换为其值。使用常量传播,编译器将意识到 x 始终具有常量值 7,并将变量 x 的任何使用替换为值 7。结果将等效于以下内容:

1
2
3
4
5
6
7
8
9
#include <iostream>

int main()
{
int x { 7 };
std::cout << 7 << '\n';

return 0;
}

死代码消除是一种优化技术,其中编译器删除可以执行但对程序行为没有影响的代码。继续上面的例子,在此程序中,定义并初始化了变量 x,但它从未在任何地方使用,因此它对程序的行为没有影响。死代码消除将删除 x 的定义。结果将等效于以下内容:

1
2
3
4
5
6
7
8
#include <iostream>

int main()
{
std::cout << 7 << '\n';

return 0;
}

当一个变量因为不再需要而从程序中删除时,我们说该变量已被优化掉(或被优化掉)。

4.4 编译时常量与运行时常量

C++ 中的常量有时分为两个非正式类别:

  • 编译时常量(compile-time constant) 是指在编译时已知其值的常量。例子包括:

    • 字面量
    • 初始化为编译时常量的常量对象
  • 运行时常量(runtime constant) 是指在运行时上下文中确定其值的常量。例子包括:

    • 常量函数参数
    • 初始化为非常量或运行时常量的常量对象。

例如:

1
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
27
28
29
30
#include <iostream>

int five()
{
return 5;
}

int pass(const int x) // x is a runtime constant
{
return x;
}

int main()
{
// The following are non-constants:
[[maybe_unused]] int a { 5 };

// The following are compile-time constants:
[[maybe_unused]] const int b { 5 };
[[maybe_unused]] const double c { 1.2 };
[[maybe_unused]] const int d { b }; // b is a compile-time constant

// The following are runtime constants:
[[maybe_unused]] const int e { a }; // a is non-const
[[maybe_unused]] const int f { e }; // e is a runtime constant
[[maybe_unused]] const int g { five() }; // return value isn't known until runtime
[[maybe_unused]] const int h { pass(5) }; // return value isn't known until runtime

return 0;
}

5. 常量表达式

5.1 编译时编程

虽然as-if规则对于提高性能非常有用,但它使我们依赖于编译器的复杂性来确定哪些内容可以在编译时进行评估。这意味着如果我们真的希望某段代码在编译时执行,它可能会也可能不会。相同的代码在不同的平台上编译,或使用不同的编译器,或使用不同的编译选项,或稍作修改,可能会产生不同的结果。由于as-if规则对我们来说是隐式应用的,我们无法从编译器获得关于它决定在编译时评估哪些代码部分或为什么这样做的反馈。我们希望编译时评估的代码可能甚至不符合条件(由于拼写错误或误解),而我们可能永远不知道。为了改善这种情况,C++语言提供了明确指定我们希望哪些代码部分在编译时执行的方法。使用导致编译时评估的语言特性被称为编译时编程。以下C++特性是编译时编程最基础的部分:

  • Constexpr变量

  • Constexpr函数

  • 模板

  • static_assert

所有这些特性都有一个共同点:它们都使用了常量表达式。

5.2 常量表达式

常量表达式是文本、常量变量、运算符和函数调用的非空序列,所有这些都必须在编译时可计算。主要区别在于,在常量表达式中,表达式的每个部分都必须在编译时可计算。不是常量表达式的表达式通常称为非常量表达式,并且可以非正式地称为运行时表达式(因为此类表达式通常在运行时计算)。

常量表达式可以包含的常见内容:

  • 字面量(例如‘5’,‘1.2’)

  • 大多数运算符具有常量表达式操作数(例如 3 + 4, 2 * sizeof(int))。

  • 具有常量表达式初始化器的常量整型变量(例如 const int x { 5 };

  • Constexpr 变量

  • 具有常量表达式参数的 Constexpr 函数调用

  • 非类型模板参数

  • 枚举器

  • 类型特征

  • Constexpr lambda 表达式

以下不能在常量表达式中使用:

  • 非常量变量。

  • 非常量整型的常量变量,即使它们具有常量表达式初始化器(例如 const double d { 1.2 };)。要在常量表达式中使用此类变量,请将它们定义为 constexpr 变量。

  • constexpr 函数的返回值(即使返回表达式是常量表达式)。

  • 函数参数(即使函数是 constexpr)。

  • 操作数不是常量表达式的运算符(例如,当 xy 不是常量表达式时的 x + y,或 std::cout << "hello\n",因为 std::cout 不是常量表达式)。

  • newdeletethrowtypeid 和逗号运算符 ,

常量表达式和非常量表达式的示例:

1
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
27
28
29
30
31
32
33
34
35
36
37
38
#include <iostream>

int getNumber()
{
std::cout << "Enter a number: ";
int y{};
std::cin >> y; // can only execute at runtime

return y; // return value only known at runtime
}

int five()
{
return 5; // return value known at compile-time
}

int main()
{
// Literals can be used in constant expressions
5; // constant expression
1.2; // constant expression
"Hello world!"; // constant expression

// Most operators that have constant expression operands can be used in constant expressions
5 + 6; // constant expression
1.2 * 3.4; // constant expression
8 - 5.6; // constant expression (even though operands have different types)
sizeof(int) + 1; // constant expression (sizeof can be determined at compile-time)

// Calls to non-constexpr functions can only be used in runtime expressions
getNumber(); // runtime expression
five(); // runtime expression (even though return value is constant expression)

// Operators without constant expression operands can only be used in runtime expressions
std::cout << 5; // runtime expression (std::cout isn't a constant expression operand)

return 0;
}
1
2
3
4
5
6
7
8
9
// 具有常量表达式初始化器的 Const 整型变量可用于常量表达式:
const int a { 5 }; // a is usable in constant expressions
const int b { a }; // b is usable in constant expressions (a is a constant expression per the prior statement)
const long c { a + 2 }; // c is usable in constant expressions (operator+ has constant expression operands)

// 其他变量不能在常量表达式中使用(即使它们具有常量表达式初始值设定项):
int d { 5 }; // d is not usable in constant expressions (d is non-const)
const int e { d }; // e is not usable in constant expressions (initializer is not a constant expression)
const double f { 1.2 }; // f is not usable in constant expressions (not a const integral variable)

由于常量表达式始终能够在编译时进行计算,因此您可能已经假设常量表达式将始终在编译时进行计算。但事实并非如此,编译器只需要在需要常量表达式的上下文中在编译时计算常量表达式。必须在编译时计算的表达式的技术名称是明显常量计算的表达式

1
2
const int x { 3 + 4 }; // constant expression 3 + 4 must be evaluated at compile-time
int y { 3 + 4 }; // constant expression 3 + 4 may be evaluated at compile-time or runtime

6. Constexpr 变量

具有整型类型的const变量和常量表达式初始值设定项可以在常量表达式中使用。所有其他const变量不能在常量表达式中使用。使用const并不能立即清楚该变量是否可以在常量表达式中使用。

1
2
3
int a { 5 };       // not const at all
const int b { a }; // clearly not a constant expression (since initializer is non-const)
const int c { 5 }; // clearly a constant expression (since initializer is a constant expression)
1
2
const int d { someVar };    // not obvious whether d is usable in a constant expression or not
const int e { getValue() }; // not obvious whether e is usable in a constant expression or not

constexpr变量始终是编译时常量。因此,constexpr 变量必须使用常量表达式进行初始化,否则将导致编译错误。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 #include <iostream>

// The return value of a non-constexpr function is not a constant expression
int five()
{
return 5;
}

int main()
{
constexpr double gravity { 9.8 }; // ok: 9.8 is a constant expression
constexpr int sum { 4 + 5 }; // ok: 4 + 5 is a constant expression
constexpr int something { sum }; // ok: sum is a constant expression

std::cout << "Enter your age: ";
int age{};
std::cin >> age;

constexpr int myAge { age }; // compile error: age is not a constant expression
constexpr int f { five() }; // compile error: return value of five() is not a constant expression

return 0;
}

constexpr适用于非整数类型的变量:

1
constexpr double d { 1.2 }; // d can be used in constant expressions!

constconstexpr 对于变量的含义:

  • const表示对象的值在初始化后不能更改。初始化器的值可以在编译时或运行时已知。 const 对象可以在运行时评估。

  • constexpr表示该对象可以在常量表达式中使用。初始化程序的值必须在编译时已知。 constexpr 对象可以在运行时或编译时求值。
    Constexpr 变量是隐式 const。 const 变量不是隐式 constexpr(带有常量表达式初始值设定项的 const 整型变量除外。与const不同, constexpr不是对象类型的一部分。因此,定义为constexpr int变量实际上具有const int类型。

任何其初始值设定项是常量表达式的常量变量都应声明为constexpr 。任何初始值设定项不是常量表达式(使其成为运行时常量)的常量变量都应声明为const 。

在C和C++中,数组对象(一个对象可以保存多个值)的声明需要在编译时知道数组的长度(它可以保存的值的数量)(这样编译器就可以确保正确的数量)的内存分配给数组对象)。在许多情况下,最好使用符号常量作为数组长度。在 C 中,这可以通过预处理器宏或通过枚举器来完成,但不能通过 const 变量(不包括 VLA,它有其他缺点)。 C++ 希望改善这种情况,希望允许使用 const 变量而不是宏。但变量的值通常被认为只有在运行时才知道,这使得它们不适合用作数组长度。为了解决这个问题,C++ 语言标准添加了一个豁免,以便带有常量表达式初始值设定项的 const 整型将被视为编译时已知的值,因此可用作数组长度:

1
2
const int arrLen = 5;
int arr[arrLen]; // ok: array of 5 ints

普通函数调用在运行时进行评估,提供的参数用于初始化函数的参数。由于函数参数的初始化发生在运行时,这会导致两个后果:

  • const函数参数被视为运行时常量(即使提供的参数是编译时常量)。

  • 函数参数不能声明为constexpr ,因为它们的初始化值直到运行时才确定。

constexpr 函数是可以在常量表达式中调用的函数。当 constexpr 函数所属的常量表达式必须在编译时求值时(例如,在 constexpr 变量的初始值设定项中),它必须在编译时求值。否则,可以在编译时(如果符合条件)或运行时评估 constexpr 函数。为了符合编译时执行的条件,所有参数都必须是常量表达式。示例:

1
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
27
28
29
30
#include <iostream>

int max(int x, int y) // this is a non-constexpr function
{
if (x > y)
return x;
else
return y;
}

constexpr int cmax(int x, int y) // this is a constexpr function
{
if (x > y)
return x;
else
return y;
}

int main()
{
int m1 { max(5, 6) }; // ok
const m2 { max(5, 6) }; // ok
constexpr m3 { max(5, 6) }; // compile error: max(5, 6) not a constant expression

int m1 { cmax(5, 6) }; // ok: may evaluate at compile-time or runtime
const m2 { cmax(5, 6) }; // ok: may evaluate at compile-time or runtime
constexpr m3 { cmax(5, 6) }; // okay: must evaluate at compile-time

return 0;
}

7. std::string 简介

在现代 C++ 中,最好避免使用 C 风格的字符串变量。C++ 在语言中引入了两种额外的字符串类型,使用起来更加容易和安全: std::stringstd::string_view (C++17)。与我们之前介绍的类型不同, std::stringstd::string_view不是基本类型,而是类。就像普通变量一样,你可以按照预期初始化 std::string 对象或为其赋值:

1
2
3
4
5
6
7
8
9
#include <string>

int main()
{
std::string name { "Alex" }; // initialize name with string literal "Alex"
name = "John"; // change name to "John"

return 0;
}

std::string可以做的最巧妙的事情之一就是存储不同长度的字符串:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#include <string>

int main()
{
std::string name { "Alex" }; // initialize name with string literal "Alex"
std::cout << name << '\n';

name = "Jason"; // change name to a longer string
std::cout << name << '\n';

name = "Jay"; // change name to a shorter string
std::cout << name << '\n';

return 0;
}

如果std::string没有足够的内存来存储字符串,它将使用称为动态内存分配的内存分配形式请求额外的内存(在运行时)。这种获取额外内存的能力是std::string如此灵活的部分原因,但也相对较慢。

要将整行输入读取到字符串中,最好使用std::getline()函数。 std::getline()需要两个参数:第一个是std::cin ,第二个是字符串变量。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <string> // For std::string and std::getline

int main()
{
std::cout << "Enter your full name: ";
std::string name{};
std::getline(std::cin >> std::ws, name); // read a full line of text into name

std::cout << "Enter your favorite color: ";
std::string color{};
std::getline(std::cin >> std::ws, color); // read a full line of text into color

std::cout << "Your name is " << name << " and your favorite color is " << color << '\n';

return 0;
}

std::ws输入操纵器告诉std::cin在提取之前忽略任何前导空格。前导空白是出现在字符串开头的任何空白字符(空格、制表符、换行符)。

如果我们想知道std::string中有多少个字符,我们可以询问std::string对象的长度

1
2
3
4
5
6
7
8
9
10
#include <iostream>
#include <string>

int main()
{
std::string name{ "Alex" };
std::cout << name << " has " << name.length() << " characters\n";

return 0;
}

尽管std::string要求以 null 终止(从 C++11 开始), std::string的返回长度不包括隐式 null 终止符。

在 C++20 中,还可以使用std::ssize()函数来获取std::string作为大型有符号数类型的长度(通常为std::ptrdiff_t ):

1
2
3
4
5
6
7
8
9
10
#include <iostream>
#include <string>

int main()
{
std::string name{ "Alex" };
std::cout << name << " has " << std::ssize(name) << " characters\n";

return 0;
}

由于ptrdiff_t可能大于int ,如果您想将std::ssize()的结果存储在int变量中,您应该将结果static_castint :

1
int len { static_cast<int>(std::ssize(name)) };

每当初始化 std::string 时,都会创建用于初始化它的字符串的副本。复制字符串的成本很高,因此应注意尽量减少复制的数量。

不要按值传递std::string ,因为它会生成昂贵的副本。在大多数情况下,请使用std::string_view参数

当函数按值返回给调用者时,返回值通常会从函数复制回调用者。大多数情况避免返回std::string ,因为这样做会返回std::string的昂贵副本。

双引号字符串文字(例如“Hello, world!”)默认是 C 样式字符串(因此具有奇怪的类型)。我们可以通过在双引号字符串文字后面使用s后缀来创建类型为std::string字符串文字。 s必须小写。示例:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
#include <string> // for std::string

int main()
{
using namespace std::string_literals; // easy access to the s suffix

std::cout << "foo\n"; // no suffix is a C-style string literal
std::cout << "goo\n"s; // s suffix is a std::string literal

return 0;
}

“s”后缀存在于命名空间中 std::literals::string_literals。访问文字后缀的最简洁方法是通过 using 指令using namespace std::literals 。然而,这会将_所有_标准库文字导入到 using 指令的范围中,这会带来一堆您可能不会使用的东西。推荐 using namespace std::string_literals ,它仅导入std::string的文字。

8. std::string_view 简介

1
2
3
4
5
6
7
8
9
10
#include <iostream>
#include <string>

int main()
{
std::string s{ "Hello, world!" }; // s makes a copy of its initializer
std::cout << s << '\n';

return 0;
}

s初始化时,C 风格的字符串文字"Hello, world!"被复制到为std::string s分配的内存中。与基本类型不同,初始化和复制std::string很慢。

为了解决std::string初始化(或复制)成本高昂的问题,C++17 引入了std::string_view 。 std::string_view提供对_现有_字符串(C 样式字符串、 std::string或另一个std::string_view )的只读访问,而无需进行复制。只读意味着我们可以访问和使用正在查看的值,但不能修改它。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#include <string_view> // C++17

// str provides read-only access to whatever argument is passed in
void printSV(std::string_view str) // now a std::string_view
{
std::cout << str << '\n';
}

int main()
{
std::string_view s{ "Hello, world!" }; // now a std::string_view
printSV(s);

return 0;
}

std::string_view的优点之一就是它的灵活性。可以使用 C 样式字符串、 std::string或另一个std::string_view对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
#include <string>
#include <string_view>

int main()
{
std::string_view s1 { "Hello, world!" }; // initialize with C-style string literal
std::cout << s1 << '\n';

std::string s{ "Hello, world!" };
std::string_view s2 { s }; // initialize with std::string
std::cout << s2 << '\n';

std::string_view s3 { s2 }; // initialize with std::string_view
std::cout << s3 << '\n';

return 0;
}

C 风格字符串和std::string都会隐式转换为std::string_view 。因此, std::string_view参数将接受 C 风格字符串、 std::stringstd::string_view类型的参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <string>
#include <string_view>

void printSV(std::string_view str)
{
std::cout << str << '\n';
}

int main()
{
printSV("Hello, world!"); // call with C-style string literal

std::string s2{ "Hello, world!" };
printSV(s2); // call with std::string

std::string_view s3 { s2 };
printSV(s3); // call with std::string_view

return 0;
}

因为std::string会复制其初始值设定项(这是昂贵的),所以 C++ 不允许将std::string_view隐式转换为std::string 。这是为了防止意外地将std::string_view参数传递给std::string参数,以及无意中在可能不需要此类副本的情况下创建昂贵的副本。但是有两个选择:

  • 使用std::string_view初始值设定项显式创建std::string

  • 使用static_cast将现有的std::string_view转换为std::string示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <string>
#include <string_view>

void printString(std::string str)
{
std::cout << str << '\n';
}

int main()
{
std::string_view sv{ "Hello, world!" };

// printString(sv); // compile error: won't implicitly convert std::string_view to a std::string

std::string s{ sv }; // okay: we can create std::string using std::string_view initializer
printString(s); // and call the function with the std::string

printString(static_cast<std::string>(sv)); // okay: we can explicitly cast a std::string_view to a std::string

return 0;
}

默认情况下,双引号字符串文字是 C 样式字符串文字。我们可以通过在双引号字符串文字后面使用sv后缀来创建类型为std::string_view字符串文字。 sv必须小写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
#include <string> // for std::string
#include <string_view> // for std::string_view

int main()
{
using namespace std::string_literals; // access the s suffix
using namespace std::string_view_literals; // access the sv suffix

std::cout << "foo\n"; // no suffix is a C-style string literal
std::cout << "goo\n"s; // s suffix is a std::string literal
std::cout << "moo\n"sv; // sv suffix is a std::string_view literal

return 0;
}

std::string不同, std::string_view完全支持 constexpr:

1
2
3
4
5
6
7
8
9
10
#include <iostream>
#include <string_view>

int main()
{
constexpr std::string_view s{ "Hello, world!" }; // s is a string symbolic constant
std::cout << s << '\n'; // s will be replaced with "Hello, world!" at compile-time

return 0;
}

这使得constexpr std::string_view成为需要字符串符号常量时的首选。

std::string是一个所有者——它负责从初始化器获取字符串数据,管理对字符串数据的访问,并在std::string对象被调用时正确处理字符串数据。

在编程中,当我们称一个对象为所有者时,通常意味着它是唯一的所有者(除非另有说明)。唯一所有权(也称为单一所有权)可确保明确谁对该数据负责。

std::string_view采用不同的初始化方法。 std::string_view不是创建初始化字符串的昂贵副本,而是创建初始化字符串_的_廉价视图。然后,只要需要访问字符串,就可以使用std::string_viewstd::string_view是一个查看器。它查看其他地方已经存在的对象,并且无法修改该对象。当视图被破坏时,正在查看的对象不受影响。让多个观看者同时观看一个物体是可以的

视图取决于被查看的对象。如果在视图仍在使用时正在查看的对象被修改或销毁,则会导致意外或未定义的行为。

查看已被销毁的字符串的std::string_view有时称为悬空视图。

std::string_view的最佳用途是作为只读函数参数。这允许我们传入 C 风格的字符串、 std::stringstd::string_view参数而无需复制,因为std::string_view将为参数创建一个视图。这是一个错误使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <string>
#include <string_view>

std::string getName()
{
std::string s { "Alex" };
return s;
}

int main()
{
std::string_view name { getName() }; // name initialized with return value of function
std::cout << name << '\n'; // undefined behavior

return 0;
}

外面的看不见里面的。超出了作用域

std::string_view指向std::string时,如果std::string重新分配内存以容纳新的字符串数据,它会将用于旧字符串数据的内存返回给操作系统。由于std::string_view仍在查看旧的字符串数据,因此它现在悬空(指向现在无效的对象)。如果std::string不重新分配内存,它将复制新的字符串数据覆盖旧的字符串数据(从相同的内存地址开始)。 std::string_view现在将查看新的字符串数据(因为它被放置在与查看时相同的内存地址处),但它不会意识到std::string的长度可能已更改。如果新字符串比旧字符串长,则std::string_view现在将查看新字符串的子字符串(与旧字符串长度相同)。如果新字符串比旧字符串短,则std::string_view现在将查看新字符串的超字符串(由整个新字符串组成,加上内存中超出字符串末尾的任何垃圾字符) 。

我们可以通过重新验证无效的std::string_view来解决:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <string>
#include <string_view>

int main()
{
std::string s { "Hello, world!" };
std::string_view sv { s }; // sv is now viewing s

s = "Hello, universe!"; // modifies s, which invalidates sv (s is still valid)
std::cout << sv << '\n'; // undefined behavior

sv = s; // revalidate sv: sv is now viewing s again
std::cout << sv << '\n'; // prints "Hello, universe!"

return 0;
}

std::string_view可以用作函数的返回值。然而,这往往是危险的。因为局部变量在函数结束时被销毁,所以返回正在查看局部变量的std::string_view将导致返回的std::string_view无效,并且进一步使用该std::string_view将导致未定义的行为。

有两种主要情况可以安全地返回std::string_view 。首先,因为整个程序都存在 C 风格的字符串文字,所以从返回类型为std::string_view的函数返回 C 风格的字符串文字是很好的(而且很有用)。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <string_view>

std::string_view getBoolName(bool b)
{
if (b)
return "true"; // return a std::string_view viewing "true"

return "false"; // return a std::string_view viewing "false"
} // "true" and "false" are not destroyed at the end of the function

int main()
{
std::cout << getBoolName(true) << ' ' << getBoolName(false) << '\n'; // ok

return 0;
}

其次,通常可以返回std::string_view类型的函数参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
#include <string>
#include <string_view>

std::string_view firstAlphabetical(std::string_view s1, std::string_view s2)
{
if (s1 < s2)
return s1;
return s2;
}

int main()
{
std::string a { "World" };
std::string b { "Hello" };

std::cout << firstAlphabetical(a, b) << '\n'; // prints "Hello"

return 0;
}

因为std::string_view是一个视图,所以它包含让我们通过“关闭窗帘”来修改视图的函数。这不会以任何方式修改正在查看的字符串,只是修改视图本身。

  • remove_prefix()成员函数从视图左侧删除字符。

  • remove_suffix()成员函数从视图右侧删除字符。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <string_view>

int main()
{
std::string_view str{ "Peach" };
std::cout << str << '\n';

// Remove 1 character from the left side of the view
str.remove_prefix(1);
std::cout << str << '\n';

// Remove 2 characters from the right side of the view
str.remove_suffix(2);
std::cout << str << '\n';

str = "Peach"; // reset the view
std::cout << str << '\n';

return 0;
}

一旦调用了remove_prefix()remove_suffix() ,重置视图的唯一方法就是再次将源字符串重新分配给它。

C 风格的字符串文字和std::string始终以 null 终止。std::string_view可能会也可能不会以 null 结尾。

参考资料

Learn C++ – Skill up with our free tutorials