常量和字符串
1. 常量变量(命名常量)
常量是在程序执行期间不能更改的值。C++ 支持两种不同类型的常量:
-
命名常量(named constants) 是与标识符关联的常量值。这些有时也称为符号常量。
-
字面常量(literal constants) 是与标识符无关的常量值。
在 C++ 中定义命名常量有三种方法:
-
常量变量
-
带有替换文本的类似对象的宏
-
枚举常量
1.1 常量变量
常量变量是最常见的命名常量类型。声明变量为常量的方法:
1 | const double gravity { 9.8 }; // preferred use of const before type |
一般来说
const
放在前面
常量变量必须在定义时初始化,然后该值不能通过赋值来更改,一个错误的示例:
1 | int main() |
常量变量可以从其他变量(包括非常量变量)初始化:
1 |
|
C风格的常量名称通常是大写加下划线(例如EARTH_GRAVITY
),在C++中通常与变量的命名约定相同,不作区分。
在现代 C++ 中,我们不会将值参数设置const
因为我们通常不关心函数是否更改参数的值(因为它只是一个副本,无论如何都会在函数结束时销毁)。 const
关键字还给函数原型添加了少量不必要的混乱。以下这种写法不推荐:
1 | void printInt(const int x) |
按值返回也不要使用常量。对于基本类型,返回类型上的const
限定符将被忽略(编译器可能会生成警告),以下写法不推荐:
1 | const int getValue() |
1.2 带有替换文本的宏
前面已经介绍过,一个示例:
1 |
|
优先选择常量变量而不是预处理器宏,最大的问题是宏不遵循正常的 C++ 范围规则。#defined
宏后,当前文件中出现的所有后续出现的宏名称都将被替换。如果该名称在其他地方使用,您将在您不需要的地方获得宏替换。这很可能会导致奇怪的编译错误。
1.3 类型限定符
类型限定符(type qualifier)(有时简称为限定符)是应用于类型的关键字,用于修改该类型的行为方式。用于声明常量变量的const
称为const类型限定符(或简称const限定符)。从 C++23 开始,C++ 只有两种类型限定符: const
和volatile
。volatile
限定符用于告诉编译器一个对象的值可以随时更改。这个很少使用的限定符会禁用某些类型的优化。
在技术文档中, const
和volatile
限定符通常称为cv 限定符
一个笑话:
2. 字面量
字面量(Literals) 是直接插入代码中的值。例如:
1 | return 5; // 5 is an integer 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
除外)
大多数后缀不区分大小写。例外:
-
s
和sv
必须为小写。 -
两个连续的
l
或L
字符必须具有相同的大小写。
由于小写
L
在某些字体中可能看起来像数字1
,因此一些开发人员更喜欢使用大写文本。其他选项使用小写后缀(L
除外)
默认情况下,浮点文本的类型为 double
。要使它们成为浮点
文本,应使用 f
(或 F
) 后缀:
1 |
|
以下情况会出现警告:
1 | float f { 4.1 }; // warning: 4.1 is a double literal, not a float literal |
因为浮点数的默认类型是double
。将值从double
转换为float
可能会导致精度损失,因此编译器会发出警告。
解决方法:
1 | float f { 4.1f }; // use 'f' suffix so the literal is a float and matches variable type of float |
有两种不同的方法可以编写浮点文本:
-
在标准表示法中,我们写一个带小数点的数字:
1
2
3double 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
2double 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::string
和std::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 |
|
要使用十六进制文字,请在文字前面加上0x
前缀:
1 |
|
在 C++14 之前,不支持二进制文字,可以使用16进制来表示:
1 |
|
在 C++14 及以上版本中,我们可以通过使用 0b 前缀来使用二进制文字:
1 |
|
由于长文字可能难以阅读,C++14 还添加了使用引号 (') 作为数字分隔符的功能。
1 |
|
另请注意,分隔符不能出现在值的第一个数字之前:
1 | int bin { 0b'1011'0010 }; // error: ' used before first digit of value |
数字分隔符纯粹是视觉上的,不会以任何方式影响字面值。
默认情况下,C++ 以十进制输出值。但是,您可以通过使用std::dec
、 std::oct
和std::hex
I/O 操纵器来更改输出格式:
1 |
|
一旦应用,I/O 操纵器将保持为未来输出设置,直到再次更改。
以二进制输出值有点困难,因为std::cout
没有内置此功能。但是可以使用std::bitset
,示例:
1 |
|
在 C++20 和 C++23 中,我们有更好的选项通过新的格式库 (C++20) 和打印库 (C++23) 打印二进制:
1 |
|
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 |
|
进行常量折叠后:
1 |
|
这个程序还可以继续优化,初始化 x
时,值 7
将存储在分配给 x
的内存中。然后在下一行,程序将再次进入内存以获取值 7
以便打印它。这需要两个内存访问作(一个用于存储值,另一个用于获取值)。常量传播是一种优化技术,其中编译器将已知具有常量值的变量替换为其值。使用常量传播,编译器将意识到 x
始终具有常量值 7
,并将变量 x
的任何使用替换为值 7
。结果将等效于以下内容:
1 |
|
死代码消除是一种优化技术,其中编译器删除可以执行但对程序行为没有影响的代码。继续上面的例子,在此程序中,定义并初始化了变量 x
,但它从未在任何地方使用,因此它对程序的行为没有影响。死代码消除将删除 x
的定义。结果将等效于以下内容:
1 |
|
当一个变量因为不再需要而从程序中删除时,我们说该变量已被优化掉(或被优化掉)。
4.4 编译时常量与运行时常量
C++ 中的常量有时分为两个非正式类别:
-
编译时常量(compile-time constant) 是指在编译时已知其值的常量。例子包括:
- 字面量
- 初始化为编译时常量的常量对象
-
运行时常量(runtime constant) 是指在运行时上下文中确定其值的常量。例子包括:
- 常量函数参数
- 初始化为非常量或运行时常量的常量对象。
例如:
1 |
|
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)。
-
操作数不是常量表达式的运算符(例如,当
x
或y
不是常量表达式时的x + y
,或std::cout << "hello\n"
,因为std::cout
不是常量表达式)。 -
new
、delete
、throw
、typeid
和逗号运算符,
。
常量表达式和非常量表达式的示例:
1 |
|
1 | // 具有常量表达式初始化器的 Const 整型变量可用于常量表达式: |
由于常量表达式始终能够在编译时进行计算,因此您可能已经假设常量表达式将始终在编译时进行计算。但事实并非如此,编译器只需要在需要常量表达式的上下文中在编译时计算常量表达式。必须在编译时计算的表达式的技术名称是明显常量计算的表达式。
1 | const int x { 3 + 4 }; // constant expression 3 + 4 must be evaluated at compile-time |
6. Constexpr 变量
具有整型类型的const
变量和常量表达式初始值设定项可以在常量表达式中使用。所有其他const
变量不能在常量表达式中使用。使用const
并不能立即清楚该变量是否可以在常量表达式中使用。
1 | int a { 5 }; // not const at all |
1 | const int d { someVar }; // not obvious whether d is usable in a constant expression or not |
constexpr变量始终是编译时常量。因此,constexpr 变量必须使用常量表达式进行初始化,否则将导致编译错误。示例:
1 |
|
constexpr
适用于非整数类型的变量:
1 | constexpr double d { 1.2 }; // d can be used in constant expressions! |
const
与 constexpr
对于变量的含义:
-
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 | const int arrLen = 5; |
普通函数调用在运行时进行评估,提供的参数用于初始化函数的参数。由于函数参数的初始化发生在运行时,这会导致两个后果:
-
const
函数参数被视为运行时常量(即使提供的参数是编译时常量)。 -
函数参数不能声明为
constexpr
,因为它们的初始化值直到运行时才确定。
constexpr 函数是可以在常量表达式中调用的函数。当 constexpr 函数所属的常量表达式必须在编译时求值时(例如,在 constexpr 变量的初始值设定项中),它必须在编译时求值。否则,可以在编译时(如果符合条件)或运行时评估 constexpr 函数。为了符合编译时执行的条件,所有参数都必须是常量表达式。示例:
1 |
|
7. std::string
简介
在现代 C++ 中,最好避免使用 C 风格的字符串变量。C++ 在语言中引入了两种额外的字符串类型,使用起来更加容易和安全: std::string
和std::string_view
(C++17)。与我们之前介绍的类型不同, std::string
和std::string_view
不是基本类型,而是类。就像普通变量一样,你可以按照预期初始化 std::string
对象或为其赋值:
1 |
|
std::string
可以做的最巧妙的事情之一就是存储不同长度的字符串:
1 |
|
如果
std::string
没有足够的内存来存储字符串,它将使用称为动态内存分配的内存分配形式请求额外的内存(在运行时)。这种获取额外内存的能力是std::string
如此灵活的部分原因,但也相对较慢。
要将整行输入读取到字符串中,最好使用std::getline()
函数。 std::getline()
需要两个参数:第一个是std::cin
,第二个是字符串变量。示例:
1 |
|
std::ws
输入操纵器告诉std::cin
在提取之前忽略任何前导空格。前导空白是出现在字符串开头的任何空白字符(空格、制表符、换行符)。
如果我们想知道std::string
中有多少个字符,我们可以询问std::string
对象的长度
1 |
|
尽管std::string
要求以 null 终止(从 C++11 开始), std::string
的返回长度不包括隐式 null 终止符。
在 C++20 中,还可以使用std::ssize()
函数来获取std::string
作为大型有符号数类型的长度(通常为std::ptrdiff_t
):
1 |
|
由于ptrdiff_t
可能大于int
,如果您想将std::ssize()
的结果存储在int
变量中,您应该将结果static_cast
为int
:
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 |
|
“s”后缀存在于命名空间中 std::literals::string_literals
。访问文字后缀的最简洁方法是通过 using 指令using namespace std::literals
。然而,这会将_所有_标准库文字导入到 using 指令的范围中,这会带来一堆您可能不会使用的东西。推荐 using namespace std::string_literals
,它仅导入std::string
的文字。
8. std::string_view
简介
1 |
|
当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 |
|
std::string_view
的优点之一就是它的灵活性。可以使用 C 样式字符串、 std::string
或另一个std::string_view
对象:
1 |
|
C 风格字符串和std::string
都会隐式转换为std::string_view
。因此, std::string_view
参数将接受 C 风格字符串、 std::string
或std::string_view
类型的参数:
1 |
|
因为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 |
|
默认情况下,双引号字符串文字是 C 样式字符串文字。我们可以通过在双引号字符串文字后面使用sv
后缀来创建类型为std::string_view
字符串文字。 sv
必须小写:
1 |
|
与std::string
不同, std::string_view
完全支持 constexpr:
1 |
|
这使得constexpr std::string_view
成为需要字符串符号常量时的首选。
std::string
是一个所有者——它负责从初始化器获取字符串数据,管理对字符串数据的访问,并在std::string
对象被调用时正确处理字符串数据。
在编程中,当我们称一个对象为所有者时,通常意味着它是唯一的所有者(除非另有说明)。唯一所有权(也称为单一所有权)可确保明确谁对该数据负责。
std::string_view
采用不同的初始化方法。 std::string_view
不是创建初始化字符串的昂贵副本,而是创建初始化字符串_的_廉价视图。然后,只要需要访问字符串,就可以使用std::string_view
。std::string_view
是一个查看器。它查看其他地方已经存在的对象,并且无法修改该对象。当视图被破坏时,正在查看的对象不受影响。让多个观看者同时观看一个物体是可以的
视图取决于被查看的对象。如果在视图仍在使用时正在查看的对象被修改或销毁,则会导致意外或未定义的行为。
查看已被销毁的字符串的std::string_view
有时称为悬空视图。
std::string_view
的最佳用途是作为只读函数参数。这允许我们传入 C 风格的字符串、 std::string
或std::string_view
参数而无需复制,因为std::string_view
将为参数创建一个视图。这是一个错误使用示例:
1 |
|
外面的看不见里面的。超出了作用域
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 |
|
std::string_view
可以用作函数的返回值。然而,这往往是危险的。因为局部变量在函数结束时被销毁,所以返回正在查看局部变量的std::string_view
将导致返回的std::string_view
无效,并且进一步使用该std::string_view
将导致未定义的行为。
有两种主要情况可以安全地返回std::string_view
。首先,因为整个程序都存在 C 风格的字符串文字,所以从返回类型为std::string_view
的函数返回 C 风格的字符串文字是很好的(而且很有用)。示例:
1 |
|
其次,通常可以返回std::string_view
类型的函数参数:
1 |
|
因为std::string_view
是一个视图,所以它包含让我们通过“关闭窗帘”来修改视图的函数。这不会以任何方式修改正在查看的字符串,只是修改视图本身。
-
remove_prefix()
成员函数从视图左侧删除字符。 -
remove_suffix()
成员函数从视图右侧删除字符。示例:
1 |
|
一旦调用了remove_prefix()
和remove_suffix()
,重置视图的唯一方法就是再次将源字符串重新分配给它。
C 风格的字符串文字和
std::string
始终以 null 终止。std::string_view
可能会也可能不会以 null 结尾。