定义多个变量
通过逗号分隔名称,能在单个语句中定义相同类型的多个变量。以下两段效果相同:
int a;
int b;等同于:
int a,b;常见错误:
int a, double b;//错误
int a; double b;//正确
int a;
int b; //正确初始化
int a; // 默认初始化
int b = 5; // 拷贝初始化
int c( 6 ); // 直接初始化
// 列表初始化 (C++11引进)
int d { 7 }; // 直接列表初始化
int e = { 8 }; // 拷贝列表初始化
int f {}; // 值列表初始化
int c( 6 ); // 直接初始化,直接将6设置到变量c当中,直接初始化很难区分变量和函数定义
int c(); //声明一个函数c列表初始化
int width { 5 }; // 直接列表初始化,将变量width设置为 5
int height = { 6 }; // 拷贝列表初始化,将变量height设置为6
int depth {}; // 值初始化列表初始化还有一个好处:不允许“缩小转换范围”。意味着,如果尝试使用变量不能安全保存的值,编译器将产生错误。
int width { 4.5 }; // error: int类型无法装下分数拷贝和直接初始化只会删除小数部分,而将值4初始化到变量width(编译器会发出警告,因为很少需要丢失数据)。
当用空大括号对变量列表初始化时,进行值初始化。大多数情况,值初始化将变量初始化为零(或空,如果这更适合给定类型)。发生归零的这种情况,称为零初始化。
int width {}; // 值初始化 / 零初始化,将变量width设为 0
/*如果实际使用初始化的值,则显式设置初始化值。*/
int x { 0 }; // 显示将变量x设为 0
std::cout << x; // 使用设置的值
/*如果值在使用前被替换,请使用值初始化。*/
int x {}; // 值初始化 / 零初始化
std::cin >> x; // x直接被其它语句赋值函数与二维数组
数组指针与指针数组
int *arr[4];
int (*arr)[4];int *arr[4]是一个数组,由四个指向int类型的指针组成
int (*arr)[4]是一个指针,指向一个长度为4的int类型的数组
递归
#include<iostream>
void countdown(int n);
int main()
{
countdown(4);
return 0;
}
void countdown(int n)
{
std::cout<<"counting down..."<<n<<std::endl;
if(n>0)
{
countdown(n-1);
}
std::cout<<n<<"caboom!"<<std::endl;
}递归流程如下:
函数指针
1.函数的地址
若think()为一个函数,则think为该函数的地址。函数作为参数传递时,必须传递函数名。
process(think());//传递的是函数的返回值
thought(think);//传递的是函数的地址2.声明函数指针
声明函数的指针必须指定指针所指向的函数类型,声明应该指定函数的返回值类型和特征值(参数列表)
double pam(int)//函数原型
double (*pf)(int)//函数指针常量
编译时常量
是指其值为常量表达式的常量。字面量(如 1、2.3 和 “Hello, world!")是编译时常量的一种。
常变量可能是也可能不是编译时常量,这取决于它们的初始化方式
运行时常量
如果常变量的初始值不是常量表达式,那么它就是运行时常量。运行时常量是指其初始化值要在运行时才能确定的常量。
下面的示例展示了一个作为运行时常量的用例:
#include <iostream>
int getNumber()
{
std::cout << "Enter a number: ";
int y{};
std::cin >> y;
return y;
}
int main()
{
const int x { 3 }; // x 是编译时常量
const int y { getNumber() }; // y 是运行时常量
const int z { x + y }; // x + y 是运行时表达式, z 是运行时常量
return 0;
}constexpr 关键字
当使用 const 关键字声明 const 变量时,编译器会隐式地跟踪它是运行时常量还是编译时常量。但在某些场景下 C++ 需要常量表达式,而常量表达式中只能使用编译时常量。由于编译时常量还允许更好的优化(且几乎没有缺点),我们通常希望尽可能多地使用编译时常量。
使用 const 时,变量到底是编译时常量还是运行时常量,取决于其初始值是否为编译时常量表达式。在某些情况下,这并不容易一眼看出。
int x { 5 }; // 非 const
const int y { x }; // 运行时常量 (因为使用非 const 变量初始化)
const int z { 5 }; // 编译时常量
const int w { getValue() }; // 不容易判断w 可能是运行时常量,也可能是编译时常量,这取决于 getValue() 是如何定义的。
编译时常量,结果确定,不需要进行计算
constexpr int getValue() {
return 100;
}运行时常量,结果需要计算
int getValue() {
return std::rand() % 100; // 运行时产生的随机数
}常量折叠(Constant folding)
#include <iostream>
int main()
{
constexpr int x { 3 + 4 }; // 3 + 4 是常量表达式
std::cout << x << '\n'; // 这是一个运行时表达式
return 0;
}3+4 是一个常量表达式,编译器会在编译时计算出 3+4 并将其替换为值 7。由于 x 是编译时常量,编译器可能会在上述程序中把 x 完全优化掉,将 std::cout << x << '\n' 替换为 std::cout << 7 << '\n'。最终的输出表达式会在运行时执行。
不过,既然 x 只使用了一次,我们更可能一开始就把程序写成这样:
#include <iostream>
int main()
{
std::cout << 3 + 4 << '\n'; // 这是一个运行时表达式
return 0;
}由于表达式 std::cout << 3 + 4 << '\n' 并不是常量表达式,但其中的常量子表达式 3+4 是否仍然会被编译时优化。std::cout << 3 + 4 << '\n'会被优化为std::cout << 7<< '\n';
Constexpr 函数可以在编译时计算
#include <iostream>
constexpr int greater(int x, int y) // 这是一个 constexpr 函数
{
return (x > y ? x : y);
}
int main()
{
constexpr int x{ 5 };
constexpr int y{ 6 };
// 稍后会解释这里为什么要使用一个变量
constexpr int g { greater(x, y) }; // 在编译时求值
std::cout << g << " is greater!\n";
return 0;
}函数调用 greater(x, y) 会在编译时计算,而不是在运行时计算!
对 greater(x, y) 的调用将被其返回值——整数 6——所替换。
此程序等价于:
#include <iostream>
int main()
{
constexpr int x{ 5 };
constexpr int y{ 6 };
constexpr int g { 6 }; // greater(x, y) 被计算后替换为返回值 6
std::cout << g << " is greater!\n";
return 0;
}Constexpr 函数也可以在运行时求值
具有 constexpr 返回值的函数同样可以在运行时求值,此时它返回的是非 constexpr 的结果。
#include <iostream>
constexpr int greater(int x, int y)
{
return (x > y ? x : y);
}
int main()
{
int x{ 5 }; // 不是 constexpr
int y{ 6 }; // 不是 constexpr
std::cout << greater(x, y) << " is greater!\n"; // 直到运行时才会被计算
return 0;
},由于参数 x 和 y 不是 constexpr,因此无法在编译时调用该函数。函数将在运行时被调用,并以非 constexpr 的 int 作为返回值。
constexpr 函数何时会在编译时求值?
根据 C++ 标准:当返回值被用在需要常量表达式的地方时,有资格进行编译时计算的 constexpr 函数必须在编译时计算。否则,编译器可以自行决定是在编译时还是运行时计算该函数。
#include <iostream>
constexpr int greater(int x, int y)
{
return (x > y ? x : y);
}
int main()
{
constexpr int g { greater(5, 6) }; // case 1: 编译时求值
std::cout << g << " is greater!\n";
int x{ 5 }; // 不是 constexpr
std::cout << greater(x, 6) << " is greater!\n"; // case 2: 运行时求值
std::cout << greater(5, 6) << " is greater!\n"; // case 3: 可能在编译时求值,也可能在运行时求值
return 0;
}判断 constexpr 函数的调用是在编译时还是运行时求值
C++20 引入了 std::is_constant_evaluated()(定义在 <type_traits> 头文件中),它会返回一个布尔值,指示当前函数调用是否在常量上下文中执行。结合条件语句使用,就可以让函数在编译时和运行时展现不同的行为。
#include <type_traits> // 为了使用 std::is_constant_evaluated
constexpr int someFunction()
{
if (std::is_constant_evaluated()) // 如果在编译时求值
// 做某件事
else // 如果在运行时求值
// 做另一件事
}Consteval(C++20)
C++20 引入了关键字 consteval,用于指定函数必须在编译时求值,否则会导致编译错误。此类函数被称为即时函数(immediate functions)。
#include <iostream>
consteval int greater(int x, int y) // 该函数现在是 consteval
{
return (x > y ? x : y);
}
int main()
{
constexpr int g { greater(5, 6) }; // ok: 在编译时求值
std::cout << g << '\n';
std::cout << greater(5, 6) << " is greater!\n"; // ok: 在编译时求值
int x{ 5 }; // 不是 constexpr
std::cout << greater(x, 6) << " is greater!\n"; // error: consteval 函数必须在编译时求值
return 0;
}前两次对 greater() 的调用都可以在编译时计算。而对 greater(x, 6) 的调用无法在编译时计算,因此会导致编译错误。
C++20 中利用 consteval 使 constexpr 在编译时执行
consteval 函数的缺点是它无法在运行时求值,因此不如 constexpr 函数灵活。consteval能强制 constexpr 函数在编译时求值(即使其返回值被用在不需要常量表达式的场合),这样我们就可以在条件允许时进行编译时计算,而在无法编译时计算时也能正常地进行运行时求值。
consteval 函数提供了一种实现这一点的方法,即使用一个辅助函数:
#include <iostream>
// 使用函数模板 (C++20) 和 `auto` 返回类型,使该函数可以作用于任意类型
// 你不需要关心这个函数为什么能正常工作
consteval auto compileTime(auto value)//强制在编译时求值
{
return value;
}
constexpr int greater(int x, int y) // 该函数是 constexpr
{
return (x > y ? x : y);
}
int main()
{
std::cout << greater(5, 6) << '\n'; // 可能在编译时求值
std::cout << compileTime(greater(5, 6)) << '\n'; // 保证在编译时求值
int x { 5 };
std::cout << greater(x, 6) << '\n'; // greater 函数仍可在运行时求值
return 0;
}这满足了我们的需求,因为 consteval 函数要求其参数必须是常量表达式——因此,如果我们将 constexpr 函数的返回值作为 consteval 函数的参数,那么这个 constexpr 函数就必须在编译时求值!consteval 函数只不过是把这个参数原样返回,所以调用方依旧能拿到它。
注意,consteval 函数是按值返回的。在运行时这样做可能效率不高(特别是当值是复制代价较高的类型,例如 std::string),但在编译时这无关紧要,因为对 consteval 函数的整个调用会被直接替换为其计算后的返回值。
Constexpr/consteval 函数是隐式内联的
因为 constexpr 函数可以在编译时求值,所以编译器必须在每一个调用该函数的地方都能看到它的完整定义。前向声明是不够的,即便该函数的实际定义稍后在同一个编译单元中出现也不行。
这意味着,在多个文件中被调用的 constexpr 函数,需要在每个调用它的文件中都包含其定义——这通常会违反单一定义规则。为避免这一问题,constexpr 函数是隐式内联的,这样它们就不再受单一定义规则的约束。
如果 constexpr/consteval 函数只在单个源文件(.cpp)中使用,则需要在使用之前就在该文件中定义它。
如果 constexpr/consteval 函数会在多个源文件中使用,则应将其定义在头文件中,以便能够被各个源文件包含。
constexpr 函数可以调用非常量表达式函数吗?
答案是肯定的,但前提是 constexpr 函数是在非常量上下文中被求值。当 constexpr 函数在常量上下文中被求值时,就不能调用非常量表达式的函数了(否则,constexpr 函数就无法生成编译时常量值)。
允许调用非常量表达式函数,是为了让 constexpr 函数能写出如下形式的代码:
#include <type_traits> // 为了使用 std::is_constant_evaluated
constexpr int someFunction()
{
if (std::is_constant_evaluated()) // 如果在编译时求值
return someConstexprFcn(); // 做编译时的计算
else // 如果在运行时求值
return someNonConstexprFcn(); // 做运行时的计算
}再考虑一下这个变体:
constexpr int someFunction(bool b)
{
if (b)
return someConstexprFcn();// 做编译时的计算
else
return someNonConstexprFcn();// 做运行时的计算
}只要永远不在常量表达式中调用 someFunction(false),这段代码就是合法的。
#include <iostream>
#include <type_traits>
constexpr int constexprFunc() { return 1; }//在编译时的计算
int nonConstexprFunc() { return 2; }在运行时的计算
// 版本1:自动判断
constexpr int test_auto() {
if (std::is_constant_evaluated())
return constexprFunc();
else
return nonConstexprFunc(); // 运行时调用,OK
}
// 版本2:bool参数
constexpr int test_bool(bool b) {
if (b)
return constexprFunc();
else
return nonConstexprFunc(); // 危险:如果编译时 b==false
}
int main() {
// 测试版本1
constexpr int a = test_auto(); // 编译时求值,走 constexpr 分支 → 1
int b = test_auto(); // 运行时求值,走 nonConstexpr分支 → 2
std::cout << a << ", " << b << std::endl; // 输出 1, 2
// 测试版本2
constexpr int c = test_bool(true); // 合法,编译时走 true 分支 → 1
// constexpr int d = test_bool(false); // 编译错误!常量表达式,试图在编译时调用 nonConstexprFunc
int e = test_bool(false); // 合法,运行时调用
std::cout << c << ", " << e << std::endl; // 输出 1, 2
return 0;
}C++ 标准规定,constexpr 函数必须至少为某组参数返回 constexpr 值,否则它在技术上就属于格式错误的程序。因此,在 constexpr 函数中无条件地调用非常量表达式函数会导致该 constexpr 函数本身格式错误。不过,编译器并不要求必须为这种情况生成错误或警告——因此,除非你尝试在常量上下文中调用这样的 constexpr 函数,编译器可能并不会报错。
基于上述原因,建议:
尽量避免在 constexpr 函数中调用非 constexpr 函数。
如果 constexpr 函数需要根据运行时与编译时计算区分不同的行为,请使用 std::is_constant_evaluated()。
测试 constexpr 函数时,请在常量上下文中测试。因为 constexpr 函数在非常量上下文中可能可以编译通过,但在常量上下文中未必能够编译通过。
现代的 inline 关键字
不应在头文件中实现(具有外部链接的)函数,因为当该头文件被多个 .cpp 文件包含时,函数定义会被复制到多个 .cpp 文件中。接着在编译链接这些文件时,链接器会发现同一个函数被定义了多次,从而抛出错误,这违反了单一定义规则。
common.h
int add(int a, int b) { // 普通函数定义
return a + b;
}main.cpp
#include "common.h"
int main() {
return add(1, 2);
}other.cpp
#include "common.h"
int foo() {
return add(3, 4);
}编译链接:
预处理后,
main.cpp中会展开add的完整函数体。other.cpp中也会展开完全相同的add定义。
编译器分别生成
main.obj和other.obj,每个目标文件都包含add的机器代码。链接器看到两个同名的强符号
add,报错:multiple definition of 'add'。
add.h
inline int add(int a, int b) { // 内联函数定义
return a + b;
} common.h (inline int add(...) { ... })
/ \
/ \
main.cpp other.cpp
#include "common.h" #include "common.h"
| |
[预处理] [预处理]
(插入add定义) (插入add定义)
| |
[编译] [编译]
| |
main.obj other.obj
(包含add机器码, 标记为"内联弱符号") (包含add机器码, 标记为"内联弱符号")
\ /
\ /
\ [链接器] /
\ 看到两个同名弱符号 /
\ 只需保留一份, 不报错 /
\ | /
\ v /
→→→ 最终可执行文件 ←←←
(只有一份add)每个包含此头文件的 .cpp 仍然会各自生成一份 add 的代码(即每个目标文件内都有一份机器码)。但由于 add 被标记为 inline,编译器会在生成的目标文件中对 add 符号附加一个特殊属性(比如 弱符号 或 可链接 的标志)。
链接器看到多个同名的内联定义时,不会报错,而是选择其中一份(认定它们完全一致),丢弃其他份,最终只有一份 add 进入可执行文件。
std::string
std::string 可以处理不同长度的字符串
#include <iostream>
#include <string>
int main()
{
std::string name { "Fly" }; // 用 "Fly" 初始化变量 name
std::cout << name << '\n';
name = "Jason"; // 将 name 改成一个更长的字符串
std::cout << name << '\n';
name = "Jay"; // 再把 name 改成一个更短的字符串
std::cout << name << '\n';
return 0;
}输出:
Fly
Jason
Jay使用 std::cin 读取字符串输入
要将整行输入读入到字符串中,最好改用 std::getline() 函数。getline() 需要两个参数:第一个是 std::cin,第二个是字符串变量。
#include <iostream>
#include <string>
int main()
{
std::cout << "Enter your full name: ";
std::string name{};
std::cin >> name; // 可能不会按预期工作,因为 std::cin 遇到空白字符就会停止读取
std::cout << "Enter your favorite color: ";
std::string color{};
std::cin >> color;
std::cout << "Your name is " << name << " and your favorite color is " << color << '\n';
return 0;
}结果:
Enter your full name: John Doe
Enter your favorite color: Your name is John and your favorite color is Doe当使用
>>运算符从 std::cin 中提取字符串时,>>只会返回遇到的第一个空白字符之前的那部分内容。剩下的字符都留在 std::cin 中,等待下一次提取。因此,当我们用>>将输入提取到变量 name 时,只取到了 “John”,而 “Doe” 则被留在了 std::cin 中。接着,当我们用>>向 color 提取输入时,它直接提取了 “Doe”,而不再等待我们输入新的内容。随后程序就结束了。
使用 std::getline() 读取整行文本
要将整行输入读入到字符串中,最好改用 std::getline() 函数。getline() 需要两个参数:第一个是 std::cin,第二个是字符串变量。下面是使用 std::getline() 改写的上面的程序:
#include <iostream>
#include <string> // 引入 std::string 和 std::getline
int main()
{
std::cout << "Enter your full name: ";
std::string name{};
std::getline(std::cin >> std::ws, name); // 将一整行输入读入到 name
std::cout << "Enter your favorite color: ";
std::string color{};
std::getline(std::cin >> std::ws, color); // 将一整行输入读入到 color
std::cout << "Your name is " << name << " and your favorite color is " << color << '\n';
return 0;
}结果:
Enter your full name: John Doe
Enter your favorite color: blue
Your name is John Doe and your favorite color is blue什么是 std::ws?
#include <iostream>
#include <string>
int main()
{
std::cout << "Pick 1 or 2: ";
int choice{};
std::cin >> choice;
std::cout << "Now enter your name: ";
std::string name{};
std::getline(std::cin, name); // 注意: 没有使用 std::ws
std::cout << "Hello, " << name << ", you picked " << choice << '\n';
return 0;
}输出:
Pick 1 or 2: 2
Now enter your name: Hello, , you picked 2当使用 >> 运算符读取输入值时,std::cin 不仅会捕获该值,还会捕获按下回车时产生的换行符(’\n’)。因此,当我们输入 2 并按下回车时,std::cin 实际上捕获了字符串 “2\n”。然后它从中把整数 2 提取给 choice,把换行符留在缓冲区里。接下来,当 std::getline() 想把输入读到 name 中时,它发现 std::cin 中已经有一个 “\n” 在等待,于是认为我们输入了一个空字符串!
使用 std::ws 输入操纵器来告诉 std::getline() 忽略任何前导空白字符:
#include <iostream>
#include <string>
int main()
{
std::cout << "Pick 1 or 2: ";
int choice{};
std::cin >> choice;
std::cout << "Now enter your name: ";
std::string name{};
std::getline(std::cin >> std::ws, name); // 注意: 增加了 std::ws
std::cout << "Hello, " << name << ", you picked " << choice << '\n';
return 0;
}结果:
Pick 1 or 2: 2
Now enter your name: Alex
Hello, Alex, you picked 2std::string_view(C++17)
为了解决 std::string 初始化(或复制)开销大的问题,C++17 引入了 std::string_view(位于 <string_view> 头文件中)。std::string_view 提供了对已有字符串(C 风格字符串、std::string 或另一个 std::string_view)的只读访问能力,而不需要复制字符串。“只读”的意思是我们可以访问和使用它所查看的值,但不能修改它。
#include <iostream>
#include <string>
// 方式1:传值(会复制字符串)
void printStringByValue(std::string str) {
std::cout << str << '\n';
}
// 方式2:传 const 引用(不会复制,但仍有引用开销,且只能绑定到 std::string)
void printStringByConstRef(const std::string& str) {
std::cout << str << '\n';
}
int main() {
std::string s = "Hello, world!"; // 构造 std::string,分配内存
printStringByValue(s); // 发生一次完整的字符串复制(分配新内存+拷贝字符)
printStringByConstRef(s); // 不复制,但只能接受 std::string 对象
const char* cstr = "Hello, world!";
printStringByConstRef(cstr); // 隐式构造临时 std::string(仍需一次内存分配和复制)
// printStringByValue(cstr); // 同样会隐式构造临时 std::string
return 0;
}传值(std::string str)每次调用都会深拷贝字符串内容(O(n) 时间 + 内存分配)。传 const 引用虽然避免了拷贝,但如果你传入一个 C 风格字符串或 std::string_view,编译器会隐式创建一个临时 std::string,依然带来分配和复制开销。
void printStringByConstRef(const std::string& str)函数参数类型被固定为 std::string,无法接受其他字符串类型而不产生临时对象。
使用std::string_view:
#include <iostream>
#include <string_view>
void printSV(std::string_view str) {
std::cout << str << '\n';
}
int main() {
std::string_view s{ "Hello, world!" };
printSV(s);
std::string str = "Hello, world!";
printSV(str); // 自动转换,不复制字符串
const char* cstr = "Hello, world!";
printSV(cstr); // 直接查看,不复制
return 0;
}std::string_view 只存储一个指针和长度(通常 16 字节),复制它是极快的(O(1))。
它可以由 std::string、C 风格字符串、另一个 string_view 直接构造,不会复制底层字符。
函数可以接受任何连续的字符序列,无需临时对象。
std::string与std::string_views性能对比:
#include <iostream>
#include <string>
#include <string_view>
#include <chrono>
void takeString(std::string s) {
volatile auto len = s.size(); // 防止优化
}
void takeStringView(std::string_view sv) {
volatile auto len = sv.size();
}
int main() {
std::string huge = "some very long string..."; // 假设很长
auto start = std::chrono::steady_clock::now();
for (int i = 0; i < 10000000; ++i)
takeString(huge); // 每次复制整个字符串
auto end = std::chrono::steady_clock::now();
std::cout << "std::string: "
<< std::chrono::duration<double>(end - start).count() << "s\n";
start = std::chrono::steady_clock::now();
for (int i = 0; i < 10000000; ++i)
takeStringView(huge); // 只复制指针+长度
end = std::chrono::steady_clock::now();
std::cout << "std::string_view: "
<< std::chrono::duration<double>(end - start).count() << "s\n";
}结果:
std::string: 9.98824s
std::string_view: 0.352901sstd::string_view 不会隐式转换为 std::string
由于 std::string 在创建时会复制其初始化值(这会涉及不少操作),因此 C++ 不允许将 std::string_view 隐式转换为 std::string。这是为了防止意外地把 std::string_view 参数传递给 std::string 参数,从而避免产生一份可能并不需要的昂贵副本。
如果确实需要进行转换,我们有两种选择:
显式创建一个 std::string,并用 std::string_view 来初始化它。
使用 static_cast 进行转换。
#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); // 编译失败: 不能将 std::string_view 隐式转换为 std::string
std::string s{ sv }; // okay: 可以用 std::string_view 来初始化 std::string
printString(s); // 然后用这个 std::string 来调用函数
printString(static_cast<std::string>(sv)); // okay: 可以进行显式转换
return 0;
}修改 std::string_view
给 std::string_view 赋一个新的字符串,会让 std::string_view 转而指向新的字符串,但不会对原来的字符串做任何修改。sv = “John” 让 sv 转而查看字符串 “John”。它不会修改 name 变量所持有的值(仍然是 “Alex”)
#include <iostream>
#include <string>
#include <string_view>
int main()
{
std::string name { "Alex" };
std::string_view sv { name }; // sv 现在查看的是 name
std::cout << sv << '\n'; // 打印 Alex
sv = "John"; // sv 现在查看 "John" (不会修改 name)
std::cout << sv << '\n'; // 打印 John
std::cout << name << '\n'; // 打印 Alex
return 0;
}std::string_view 的字面值
默认情况下,双引号括起的字符串是 C 风格字符串。我们可以在双引号字符串之后加上 sv 后缀,来创建类型为 std::string_view 的字符串字面值。
#include <iostream>
#include <string> // for std::string
#include <string_view> // for std::string_view
int main()
{
using namespace std::string_literals; // 允许使用 s 后缀
using namespace std::string_view_literals; // 允许使用 sv 后缀
std::cout << "foo\n"; // 无后缀,C 风格字符串字面值
std::cout << "goo\n"s; // s 后缀,std::string 字面值
std::cout << "moo\n"sv; // sv 后缀,std::string_view 字面值
return 0;
};constexpr std::string_view
与 std::string 不同,std::string_view 完全支持 constexpr,这使得 constexpr std::string_view 成为需要字符串符号常量时的首选。
#include <iostream>
#include <string_view>
int main()
{
constexpr std::string_view s{ "Hello, world!" }; // s 是一个字符串常量
std::cout << s << '\n'; // s 在编译时会被替换为 "Hello, world!"
return 0;
}std::string 与std::string_view
std::string → 所有者(Owner)
它有自己的内存,初始化时会把数据复制进来,之后独立存在,不依赖来源。std::string_view → 查看者(Viewer)
它不拥有数据,只是保存一个指向别人数据的指针和长度,创建几乎无开销,但必须保证查看期间原数据不被修改或销毁。
std::string_view 最适合作为只读函数参数
#include <iostream>
#include <string>
#include <string_view>
void printSV(std::string_view str) // std::string_view, 只是传入实参的一个视图
{
std::cout << str << '\n';
}
int main()
{
printSV("Hello, world!"); // 使用 C 风格字符串调用
std::string s2{ "Hello, world!" };
printSV(s2); // 使用 std::string 调用
std::string_view s3 { s2 };
printSV(s3); // 使用 std::string_view 调用
return 0;
}std::string_view错误用法
#include <iostream>
#include <string>
#include <string_view>
std::string getName()
{
std::string s { "Alex" };
return s;
}
int main()
{
std::string_view name { getName() }; // 用函数返回值初始化 name
std::cout << name << '\n'; // 未定义行为
getName() 函数返回包含字符串 “Alex” 的 std::string。返回值是一个临时对象,它会在包含该函数调用的完整表达式末尾被销毁。我们必须立即使用这个返回值,或者将它复制保存下来以供后续使用。但 std::string_view 并不会复制,而是为这个临时返回值创建了一个视图。这就使 std::string_view 处于悬空状态(指向无效对象),之后打印它会导致未定义行为。
#include <iostream>
#include <string>
#include <string_view>
int main()
{
using namespace std::string_literals;
std::string_view name { "Alex"s }; // "Alex"s 创建了一个临时的 std::string
std::cout << name << '\n'; // 未定义行为
return 0;
}std::string 字面值(通过 s 后缀创建)会生成一个临时的 std::string 对象。因此在本例中,“Alex"s 创建了一个临时的 std::string,并用它作为 name 的初始值。随后该临时 std::string 被销毁,留下一个悬空的 name。当再使用 name 变量时,就会产生未定义行为。
如果修改正在被查看的字符串,也会导致未定义行为:
#include <iostream>
#include <string>
#include <string_view>
int main()
{
std::string s { "Hello, world!" };
std::string_view sv { s }; // sv 正在查看 s
s = "Hello, universe!"; // 修改 s, 会使 sv 失效 (s 本身仍然有效)
std::cout << sv << '\n'; // 未定义行为
return 0;
}让失效的 std::string_view 重新生效
无效的对象通常可以通过将其重置到已知的有效状态来重新生效。对于失效的 std::string_view,我们可以给它赋一个有效的字符串,从而让它重新生效。在通过修改 s 让 sv 失效之后,我们通过语句 sv = s 让 sv 再次成为 s 的有效视图。当我们再次打印 sv 时,它就会输出 “Hello, universe!"。
#include <iostream>
#include <string>
#include <string_view>
int main()
{
std::string s { "Hello, world!" };
std::string_view sv { s }; // sv 现在查看 s
s = "Hello, universe!"; // 修改 s, 使 sv 失效 (s 本身仍然有效)
std::cout << sv << '\n'; // 未定义行为
sv = s; // 让 sv 重新生效: sv 再次查看 s
std::cout << sv << '\n'; // 打印 "Hello, universe!"
return 0;
}谨慎返回 std::string_view
std::string_view 也可以用作函数的返回值。然而,这通常是有风险的。由于局部变量会在函数结束时被销毁,因此如果 std::string_view 是一个局部变量的视图,那么返回std::string_view 就是失效的,再使用它会导致未定义行为
#include <iostream>
#include <string>
#include <string_view>
std::string_view getBoolName(bool b)
{
std::string t { "true" }; // 局部变量
std::string f { "false" }; // 局部变量
if (b)
return t; // 返回查看 t 的视图
return f; // 返回查看 f 的视图
} // t 和 f 在函数结束时销毁
int main()
{
std::cout << getBoolName(true) << ' ' << getBoolName(false) << '\n'; // 未定义行为
return 0;
}安全情况:
由于 C 风格字符串字面值在整个程序执行期间一直存在,因此可以从返回类型为 std::string_view 的函数中返回 C 风格字符串字面值。
#include <iostream>
#include <string_view>
std::string_view getBoolName(bool b)
{
if (b)
return "true"; // 返回 "true" 的视图
return "false"; // 返回 "false" 的视图
} // "true" 和 "false" 在函数结束时不会失效
int main()
{
std::cout << getBoolName(true) << ' ' << getBoolName(false) << '\n'; // ok
return 0;
}对于类型为 std::string_view 的函数参数,通常也可以将其作为返回值返回。
#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'; // 打印 "Hello"
return 0;
}首先,要注意实参 a 和 b 存在于调用者作用域中。当函数被调用时,函数参数 s1 是 a 的视图,函数参数 s2 是 b 的视图。当函数返回 s1 或 s2 时,实际上就是把查看 a 或 b 的视图返回给了调用方。由于此时 a 和 b 仍然存在,因此返回的 std::string_view 可以合法地继续查看 a 或 b。
修改视图的功能
由于 std::string_view 是一个视图,它也提供了一些类似“拉窗帘”的函数,让我们可以修改视图本身。这些操作完全不会修改被查看的字符串,只会修改视图。
remove_prefix() 成员函数会从视图的前面移除若干字符。
remove_suffix() 成员函数会从视图的后面移除若干字符。
#include <iostream>
#include <string_view>
int main()
{
std::string_view str{ "Peach" };
std::cout << str << '\n';
// 从视图左侧移除一个字符
str.remove_prefix(1);
std::cout << str << '\n';
// 从视图右侧移除两个字符
str.remove_suffix(2);
std::cout << str << '\n';
str = "Peach"; // 重置视图
std::cout << str << '\n';
return 0;
}输出:
Peach
each
ea
Peachstd::string_view 可以查看子字符串
这就带来了 std::string_view 的一个重要用途。虽然 std::string_view 可以在不复制字符串的情况下查看整个字符串,但它在不复制字符串的情况下查看子字符串时同样非常有用。子字符串是已有字符串中一段连续的字符序列。例如,对于字符串 “snowball”,“snow”、“all” 和 “now” 都是它的子字符串。而 “owl” 则不是 “snowball” 的子字符串,因为这些字符在 “snowball” 中并不连续出现。
#include <iostream>
#include <string>
#include <string_view>
int main() {
std::string s = "snowball";
std::string_view sv{s}; // 整个字符串的视图
// 提取子字符串视图,不需要复制
std::string_view sub1 = sv.substr(0, 4); // "snow",索引0开始,向后数四个
std::string_view sub2 = sv.substr(4, 3); // "bal" 注意索引从0开始,索引4开始,向后3
std::string_view sub3 = sv.substr(4); // "ball" (从索引4到末尾)
std::cout << "Original: " << sv << '\n';
std::cout << "sub1: " << sub1 << '\n'; // snow
std::cout << "sub2: " << sub2 << '\n'; // bal
std::cout << "sub3: " << sub3 << '\n'; // ball
// 也可以直接从字符串字面量创建子字符串视图
std::string_view word {"hello world"};
std::string_view hello = word.substr(0, 5);
std::string_view world = word.substr(6); // "world"
std::cout << hello << ' ' << world << '\n';
}std::string_view 可能以 null 结尾,也可能不以 null 结尾
查看子字符串的能力带来了一个值得注意的后果:std::string_view 可能以 null 结尾,也可能不以 null 结尾。考虑字符串 “snowball”,它是以 null 结尾的。如果 std::string_view 查看的是整个字符串,那么它看到的就是一个以 null 结尾的字符串。但如果 std::string_view 只查看其中的 “now” 子串,那么这个子串就不是以 null 结尾的(下一个字符是 “b”)。
在几乎所有场景下,这都不会造成影响——std::string_view 会记录它所查看的字符串或子字符串的长度,因此并不依赖 null 结尾符。无论 std::string_view 是否以 null 结尾,都可以将其转换为 std::string。
C 风格字符串(
const char*)
依赖一个特殊字符'\0'(null 终止符)来标记字符串的结束。它就像一个没有目录的书,你只能一页页翻,直到看到“完”这个字才知道结束了。std::string_view
它不看'\0',只看自己内部记录的长度。就像一个自带页码目录的书,它知道从哪开始,一共有多少页,根本不需要去找那个“完”字。
图解:
整个字符串: s n o w b a l l \0
索引: 0 1 2 3 4 5 6 7 8情况 1:查看整个字符串
std::string_view full{"snowball"};它指向索引 0 的位置(字符 s),内部长度记录为 8。虽然它指向的底层数据末尾确实有个 \0,但 full 并不依赖它。它凭借长度 8 就能准确知道数据在哪结束,绝不会多读、少读。
情况 2:查看子字符串 “now”
std::string_view sub = full.substr(1, 3);它指向索引 1 的位置(字符 n)。内部长度记录为 3。
现在,sub 看到的这片内存区域是这样的:
n o w b a ...它后面的第一个字符是 b,而不是 \0。所以如果哪个函数想用查找 \0 的方式来解析 sub,它就会把 b、a、l、l 全读进去,造成错误。但 std::string_view 不会犯这个错,因为它有长度 3,读到 w 之后就干净地停止,后面是 b 还是 \0 都跟它无关。
在代码中的实际影响安全操作:打印、转换为 std::string
#include <iostream>
#include <string>
#include <string_view>
int main() {
std::string_view sub{"snowball"};
sub = sub.substr(1, 3); // "now",不以 null 结尾
// 1. 安全:cout 会严格使用 string_view 的长度
std::cout << sub << '\n'; // 输出 "now"
// 2. 安全:转换为 string
// std::string 的构造函数会使用 string_view 的长度来复制
std::string s{sub}; // s 得到 "now",不依赖 null 终止符
std::cout << s << '\n'; // 输出 "now"
}这两种操作都利用了 string_view 提供的长度信息,所以不管底层有没有 \0,结果都正确。
危险操作:偷偷当 C 风格字符串用
如果你试图把 string_view 的数据直接传给一个只接受 const char* 且内部依赖 \0 的函数,而你的视图正好不看 \0,就可能出问题
#include <cstring> // for strlen
#include <string_view>
int main() {
std::string_view full{"snowball"}; // 底层有 \0
std::string_view sub = full.substr(1, 3); // "now",底层无 \0
// 危险:strlen 会扫描直到遇到 \0
// 对于 sub,它从 'n' 开始扫,会越过 'w',直到撞见后面的某个 \0
// 这属于未定义行为,因为读到了不属于它的内存
// size_t len = std::strlen(sub.data()); // 千万别这么做!
}s.data() 返回指针,strlen 通过指针找 \0,而视图可能不含 \0,这就越界了。永远不要对 string_view 的数据指针使用 C 风格字符串函数。
为什么可以安全转换为 std::string?
std::string 有一个专门接受 std::string_view 的构造函数,它的实现大致像这样:
std::string::string(std::string_view sv) {
// 使用 sv.data() 和 sv.length() 来精确复制字符
// 不检查 null 终止符
}它老老实实地使用 string_view 给出的长度来复制,所以即使中间没有 \0,也能完整、正确地复制出子字符串。
关于何时使用 std::string 与 std::string_view 的快速指南
本指南并不涵盖所有情况,但旨在突出最常见的用法:
在以下情况下,使用 std::string 变量:
你需要一个可以被修改的字符串。
你需要存储用户输入的文本。
你需要存储返回 std::string 的函数的返回值。
在以下情况下,使用 std::string_view 变量:
你需要对已经存在于别处的字符串(部分或全部)进行只读访问,并且在该 std::string_view 使用结束前,它不会被修改或销毁。
需要定义 C 风格字符串的符号常量。
你需要继续查看返回 C 风格字符串或非悬空 std::string_view 的函数的返回值。
在以下情况下,使用 std::string 作为函数参数:
函数需要在不影响调用方的情况下修改传入的字符串。这是较少见的情况。
使用的语言标准早于 C++17。
需要传递左值引用(后续课程会介绍引用)。
在以下情况下,使用 std::string_view 作为函数参数:
函数只需要一个只读字符串。
在以下情况下,使用 std::string 作为返回类型:
返回值是一个 std::string 局部变量。
返回值来自一个按值返回 std::string 的函数调用或运算符。
返回值需要按引用传递(后续课程会介绍引用)。
在以下情况下,使用 std::string_view 作为返回类型:
返回 C 风格字符串字面值。
返回类型为 std::string_view 的函数参数。
关于 std::string 的注意事项:
初始化和复制 std::string 的开销很大,应尽可能避免。
避免按值传递 std::string,因为这样会产生副本。
如果可能,避免创建短生命周期的 std::string 对象。
修改一个 std::string 会使指向该字符串的所有视图失效。
关于 std::string_view 的注意事项:
由于 C 风格字符串字面值在整个程序执行期间都有效,因此可以把 std::string_view 设置为 C 风格字符串字面值。
当字符串被销毁时,指向该字符串的所有视图都会失效。
使用失效的视图会导致未定义行为。
std::string_view 可能不是以 null 结尾的。
运算符
操作数和函数参数的求值顺序大多是未指定的
#include <iostream>
int getValue()
{
std::cout << "Enter an integer: ";
int x{};
std::cin >> x;
return x;
}
int main()
{
std::cout << getValue() + (getValue() * getValue()) << '\n'; // a + (b * c)
return 0;
}如果运行该程序并输入1、2和3,则可以假设该程序将计算1+(2*3)并打印7。但这是在假设对getValue() 的调用将按从左到右的顺序计算。编译器可以选择不同的顺序。例如,如果编译器选择从右到左的顺序,则程序将计算3+(2*1),这将为相同的输入打印5。
#include <iostream>
int getValue()
{
std::cout << "Enter an integer: ";
int x{};
std::cin >> x;
return x;
}
int main()
{
int a{ getValue() }; // 先执行
int b{ getValue() }; // 第二个执行
int c{ getValue() }; // 第三个执行
std::cout << a + (b * c) << '\n'; // 求值顺序不再重要
return 0;
}使用static_cast<>对整数进行浮点除法
可以使用static_cast<>将整数转换为浮点数,以便可以进行浮点除法,而不是整数除法。
#include <iostream>
int main()
{
int x{ 7 };
int y{ 4 };
std::cout << "int / int = " << x / y << '\n';
std::cout << "double / int = " << static_cast<double>(x) / y << '\n';
std::cout << "int / double = " << x / static_cast<double>(y) << '\n';
std::cout << "double / double = " << static_cast<double>(x) / static_cast<double>(y) << '\n';
return 0;
}结果:
int / int = 1
double / int = 1.75
int / double = 1.75
double / double = 1.75只要任一操作数是浮点数,结果就是浮点除法,而不是整数除法。
指数运算符
要在C++中执行指数,请 #include 标头,并使用 pow() 函数:
#include <cmath>
double x{ std::pow(3.0, 4.0) }; // 3的4次方函数pow() 的参数(和返回值)是double类型。由于浮点数的舍入误差,pow() 的结果可能不精确(即使传递的是整数)。
如果要进行整数求幂,最好使用自己的函数来完成。以下函数实现整数求幂(使用非直观的“平方求幂”算法以提高效率):
#include <cassert> // for assert
#include <cstdint> // for std::int64_t
#include <iostream>
#include <limits> // for std::numeric_limits
// 一个更安全(但是稍慢) 的检查溢出的版本
// note: exp 必须是非负整数
// 如果发生越界,返回 std::numeric_limits<std::int64_t>::max()
constexpr std::int64_t powint_safe(std::int64_t base, int exp)
{
assert(exp >= 0 && "powint_safe: exp parameter has negative value");
// 处理 base 为 0 的情况
if (base == 0)
return (exp == 0) ? 1 : 0;
std::int64_t result { 1 };
// 为了确保越界更简单,将base调转为正数
// 在返回结果时,再补上符号
bool negativeResult{ false };
if (base < 0)
{
base = -base;
negativeResult = (exp & 1);
}
while (exp > 0)
{
if (exp & 1) // 如果exp 为奇数
{
// 检查结果是否会溢出
if (result > std::numeric_limits<std::int64_t>::max() / base)
{
std::cerr << "powint_safe(): result overflowed\n";
return std::numeric_limits<std::int64_t>::max();
}
result *= base;
}
exp /= 2;
// 处理完成
if (exp <= 0)
break;
// 需要继续迭代,则继续执行检查
// 检查结果是否会溢出
if (base > std::numeric_limits<std::int64_t>::max() / base)
{
std::cerr << "powint_safe(): base overflowed\n";
return std::numeric_limits<std::int64_t>::max();
}
base *= base;
}
if (negativeResult)
return -result;
return result;
}
}逗号运算符
逗号在所有运算符中的优先级最低,甚至低于赋值。
z = (a, b); // 首先计算 (a, b) 得到b的值, 将结果赋值给 z.
z = a, b; // 计算过程是 "(z = a), b", 所以 z 拿到的是 a 的值, 然后计算b,丢弃b的求值结果。比较浮点值可能会有问题
#include <iostream>
int main()
{
double d1{ 100.0 - 99.99 }; // 数学上应该等于 0.01
double d2{ 10.0 - 9.99 }; // 数据上应该等于 0.01
if (d1 == d2)
std::cout << "d1 == d2" << '\n';
else if (d1 > d2)
std::cout << "d1 > d2" << '\n';
else if (d1 < d2)
std::cout << "d1 < d2" << '\n';
return 0;
}结果:
d1 > d2如果在调试器中检查d1和d2的值,您可能会看到d1=0.010000000000005116和d2=0.99999999997868。这两个数字都接近0.01,但d1大于0.01,d2小于0.01。使用任何关系运算符比较浮点值都可能是危险的。这是因为浮点值不精确,浮点运算中的舍入错误可能会导致它们比预期的稍小或稍大。
比较浮点数
自定义命名空间和作用域解析操作符
定义自己的命名空间
命名空间的语法如下:
namespace 命名空间标识符
{
// 命名空间中的内容
}
域解析操作符非常有用,因为它允许我们显式地选择要查看的命名空间,因此没有潜在的歧义。
#include <iostream>
namespace Foo // 定义了命名空间 Foo
{
// doSomething() 在命名空间 Foo 中
int doSomething(int x, int y)
{
return x + y;
}
}
namespace Goo // 定义了命名空间 Goo
{
// doSomething() 在命名空间 Goo 中
int doSomething(int x, int y)
{
return x - y;
}
}
int main()
{
std::cout << Foo::doSomething(4, 3) << '\n'; // 使用的是命名空间 Foo 中的 doSomething
std::cout << Goo::doSomething(4, 3) << '\n'; // 使用的是命名空间 Goo 中的 doSomething
return 0;
}使用无名称前缀的域解析操作符
域解析操作符也可以在标识符之前使用,而不提供命名空间名称(例如 ::doSomething)。在这种情况下,在全局命名空间中查找标识符。
#include <iostream>
void print() // 这个 print() 在全局命名空间
{
std::cout << " there\n";
}
namespace Foo
{
void print() // 这个 print() 在 Foo 命名空间
{
std::cout << "Hello";
}
}
int main()
{
Foo::print(); // 调用 Foo 命名空间中的 print()
::print(); // 调用 全局命名空间中的 print() (这里与只输入print()效果一样)
return 0;
}::print() 的执行方式与直接调用 print() 行为一致。因此,在这种情况下,使用域解析操作符是多余的。
命名空间内的标识符解析
如果使用命名空间内的标识符,并且没有提供域解析限定,编译器将首先尝试在同一命名空间中查找匹配的声明。如果没有找到匹配的标识符,编译器将依次检查外围每个层级的命名空间,以查看是否找到匹配,直到检查全局命名空间。
#include <iostream>
void print() // 这个 print() 在全局命名空间
{
std::cout << " there\n";
}
namespace Foo
{
void print() // 这个 print() 在 Foo 命名空间
{
std::cout << "Hello";
}
void printHelloThere()
{
print(); // 调用 Foo 命名空间中的 print()
::print(); // 调用 全局命名空间中的 print()
}
}
int main()
{
Foo::printHelloThere();
return 0;
}结果:
Hello there在上面的示例中,第一次调用 print() 时未提供域解析。由于 print() 的使用是在Foo命名空间内,编译器将首先查看是否可以找到Foo::print() 的声明。存在一个,因此调用Foo::print() 。
命名空间中内容的前向声明
对于命名空间内的标识符,前向声明也需要在同一命名空间内:
add.h
#ifndef ADD_H
#define ADD_H
namespace BasicMath
{
// 函数 add() 在命名空间 BasicMath 中
int add(int x, int y);
}
#endifadd.cpp
#include "add.h"
namespace BasicMath
{
// 函数 add() 定义在命名空间 BasicMath 中
int add(int x, int y)
{
return x + y;
}
}main.cpp
#include "add.h" // for BasicMath::add()
#include <iostream>
int main()
{
std::cout << BasicMath::add(4, 3) << '\n';
return 0;
}如果如果add.cpp没有写namespace BasicMath而是直接定义函数add(int x,int y),那么在运行时,编译器能找到BasicMath::add的向前声明,没在里面找到add的定义,编译器就会在全局范围内找BasicMath::add(int x,int y),如果找不到就会报错。只有声明和定义都有才会成功连接。
单个命名空间可以存在多个文件中
在多个位置(跨多个文件或同一文件中的多个位置)声明命名空间块是合法的。命名空间中的所有声明都被视为命名空间的一部分。
circle.h:
#ifndef CIRCLE_H
#define CIRCLE_H
namespace BasicMath
{
constexpr double pi{ 3.14 };
}
#endifgrowth.h:
#ifndef GROWTH_H
#define GROWTH_H
namespace BasicMath
{
// 常量 e 也是命名空间 BasicMath 的一部分
constexpr double e{ 2.7 };
}
#endifmain.cpp:
#include "circle.h" // for BasicMath::pi
#include "growth.h" // for BasicMath::e
#include <iostream>
int main()
{
std::cout << BasicMath::pi << '\n';
std::cout << BasicMath::e << '\n';
return 0;
}结果:
3.14
2.7嵌套命名空间
命名空间可以嵌套在其他命名空间中。例如:
#include <iostream>
namespace Foo
{
namespace Goo // Goo 命名空间 在 Foo 命名空间 中
{
int add(int x, int y)
{
return x + y;
}
}
}
int main()
{
std::cout << Foo::Goo::add(1, 2) << '\n';
return 0;
}在C++17,嵌套命名空间也可以这样声明:
#include <iostream>
namespace Foo::Goo // Goo 命名空间 在 Foo 命名空间 中 (C++17 样式)
{
int add(int x, int y)
{
return x + y;
}
}
int main()
{
std::cout << Foo::Goo::add(1, 2) << '\n';
return 0;
}命名空间别名的一个很好的优点:如果您想要将 Foo::Goo 中的功能移动到不同的位置,您可以只更新 Active 这个别名以指代新的目标,而不必查找/替换Foo:∶Goo的每个实例。
#include <iostream>
namespace Foo::Goo
{
}
namespace V2
{
int add(int x, int y)
{
return x + y;
}
}
int main()
{
namespace Active = V2; // active 现在指代 V2
std::cout << Active::add(1, 2) << '\n'; // 这一行不用修改
return 0;
}变量名称遮挡(Variable shadowing)
局部变量名称遮挡
#include <iostream>
int main()
{ // 外围代码块
int apples { 5 }; // 外围代码块中的 apples 变量
{ // 内层代码块
// 这里的 apples 是外围的
std::cout << apples << '\n'; // 打印外围的 apples 的值
int apples{ 0 }; // 嵌套的代码块中的 apples 变量
// apples 现在指代的是内层代码块里的变量
// 外围的被遮挡住了
apples = 10; // 内层的 apples 被赋值
std::cout << apples << '\n'; // 打印内层的 apples的值
} // 内层代码块中的apples被销毁
std::cout << apples << '\n'; // 打印外围代码块中的 apples 变量
return 0;
} // 外围代码块中的apples被销毁如果运行此程序,它将打印:
5
10
5请注意,如果未在嵌套块内新定义apples,则嵌套块中的apples仍然指外部块apples,因此将值10分配给apples将应用于外部块apples:
#include <iostream>
int main()
{ // 外围代码块
int apples{5}; // 外围代码块中的 apples 变量
{ // 内层代码块
// apples refers to outer block apples here
std::cout << apples << '\n'; // 打印外围的 apples 的值
// 这个例子不定义内层的apples变量
apples = 10; // 赋值给外部块的apples
std::cout << apples << '\n'; // 打印外围的 apples 的值
} // 外围代码块中的 apples在内层代码块结束时仍然存在
std::cout << apples << '\n'; // 打印外围代码块中的 apples 变量
return 0;
} // 外围代码块中的apples被销毁结果:
5
10
10遮挡全局变量
类似于嵌套块中的变量会隐藏外部块中的同名变量,与全局变量同名的局部变量将遮挡全局变量,无论局部变量在作用域中的何处:
#include <iostream>
int value { 5 }; // 全局变量
void foo()
{
std::cout << "global variable value: " << value << '\n'; // 这里使用的是全局变量
}
int main()
{
int value { 7 }; // 会遮挡全局变量 value,直到本代码块结束
++value; // 局部变量value加一
std::cout << "local variable value: " << value << '\n';
foo();
return 0;
} // 局部变量 value 销毁结果:
local variable value: 8
global variable value: 5
由于全局变量是全局命名空间的一部分,我们可以使用没有前缀的域操作符( :: )来告诉编译器我们是指全局变量,而不是局部变量。
#include <iostream>
int value { 5 }; // 全局变量
void printvalue()//打印当前全局变量的值
{
std::cout << value << "\n";
}
int main()
{
int value{ 7 }; // 会遮挡全局变量 value,直到本代码块结束
++value; // 局部变量value加一
printvalue();
--(::value); // 全局变量 value 减一, 这里的括号是为了可读性
printvalue();
std::cout << "local variable value: " << value << '\n';
std::cout << "global variable value: " << ::value << '\n';
return 0;
} // 局部变量 value 销毁注意:main结束后全局变量value的值会变成4。
此代码打印:
5
4
local variable value: 8
global variable value: 4程序启动 → 全局 value=5
│
main 开始 → 局部 value=7(全局被遮挡但还在)
│
++value → 局部变成 8
--(::value) → 全局变成 4
│
main 结束 → 局部销毁,全局 value 依然为 4
│
程序结束 → 全局 value 被销毁通常应避免局部变量的命名遮挡,因为这很容易导致意外使用或修改了错误的变量,从而引发难以排查的错误。当变量被遮挡时,某些编译器将发出警告。出于相同原因,我们建议也避免遮挡全局变量。如果所有全局变量名称都使用”g_”前缀,则可以方便地避免这一问题。
内部链接
全局变量和函数标识符可以具有内部链接或外部链接属性。
具有内部链接的标识符可以在单个翻译单元内看到并使用,但它不能从其他翻译单元访问(即,它不向链接器公开)。这意味着,如果两个源文件拥有同名的内部链接标识符,则这些标识符将被视为独立的(并且不会因具有重复定义而导致ODR冲突)。
理解:
内部链接(internal linkage)的意思是:一个函数或全局变量只在自己所在的 .cpp 文件(严格说是翻译单元)里可见,别的文件完全不知道它的存在,链接器也看不见它。所以不同文件里即使出现了完全同名的内部链接实体,它们也是独立的个体,不会违反 ODR(单一定义规则),不会引起链接错误。
内部链接 = 文件私有的全局量 / 函数。
两个文件各写一个 static int x;,就有两个独立的 x,互不干扰。
怎么产生内部链接?
static关键字(用于全局变量或函数)
static int value = 42; // 内部链接变量
static void helper() { ... } // 内部链接函数未命名命名空间(C++ 推荐方式)
namespace {
int value = 42; // 内部链接变量
void helper() { ... } // 内部链接函数
}示例:两个 .cpp 各自拥有同名内部链接变量 count,main.cpp 能独立操作它们,互不影响。
a.cpp
// a.cpp —— 文件私有的 count,只能在a.cpp中访问
#include <iostream>
// 使用 static 产生内部链接
static int count = 10;
void incrementA() {
++count;
std::cout << "A: count = " << count << '\n';
}b.cpp:
// b.cpp —— 另一个文件私有的 count(与 a.cpp 的同名变量完全独立)
#include <iostream>
// 使用未命名命名空间产生内部链接
namespace {
int count = 100;
}
void incrementB() {
++count;
std::cout << "B: count = " << count << '\n';
}main.cpp:
// main.cpp —— 调用 a.cpp 和 b.cpp 提供的接口
void incrementA(); // 声明(来自 a.cpp)
void incrementB(); // 声明(来自 b.cpp)
int main() {
incrementA(); // A: count = 11
incrementA(); // A: count = 12
incrementB(); // B: count = 101
incrementB(); // B: count = 102
// 注意:无法直接访问 a.cpp 或 b.cpp 中的 count,
// 因为它们都是内部链接,对外不可见。
return 0;
}为什么不会产生 ODR 冲突?
外部链接(普通全局变量 / 函数)要求整个程序中有且只有一个定义,否则链接器报错:
multiple definition。内部链接的实体只在当前翻译单元有效,链接器根本不知道其他文件里有同名实体。所以每个文件都可以安静地拥有自己的一份,彼此独立。
用
static或namespace {}把变量 / 函数“关在文件里”,就能避免与其他文件的同名实体打架。
具有内部链接的全局变量
具有内部链接的全局变量有时称为内部变量。
为了使非常量的全局变量成为内部变量,使用static关键字。
#include <iostream>
static int g_x{}; // 非常量的全局变量默认是外部链接, 但可以通过 static 关键字使它成为内部链接
const int g_y{ 1 }; // const 全局变量默认是内部链接
constexpr int g_z{ 2 }; // constexpr 全局变量默认是内部链接
int main()
{
std::cout << g_x << ' ' << g_y << ' ' << g_z << '\n';
return 0;
}默认情况下,Const和constexpr全局变量具有内部链接属性(因此不需要static关键字——如果使用static关键字也无额外作用)。
下面是使用内部变量的多个文件的示例:
a.cpp:
constexpr int g_x { 2 }; // 这里的内部链接 g_x 只在 a.cpp 中能被访问main.cpp:
#include <iostream>
static int g_x { 3 }; // 这里的内部链接 g_x 只在main.cpp 中能被访问
int main()
{
std::cout << g_x << '\n'; // 使用 main.cpp 中的 g_x, 打印 3
return 0;
}具有内部链接的函数
由于链接是标识符的属性(不是变量的属性),函数标识符也具有链接属性。函数默认为外部链接,但可以通过static关键字设置为内部链接:
add.cpp:
// 这个函数被声明为 static, 因此只能在本文件中被使用
// 即使其它文件中有add函数的前向声明,也仍然不能访问到add函数
static int add(int x, int y)
{
return x + y;
}main.cpp:
#include <iostream>
static int add(int x, int y); // 前向声明 add
int main()
{
std::cout << add(3, 4) << '\n';
return 0;
}此程序无法链接成功,因为在add.cpp之外无法访问函数add!
单定义规则和内部链接
根据单定义规则,我们注意到,对象或函数不能在文件或程序中具有多个定义。然而,值得注意的是,在不同文件中定义的内部对象(和函数)被认为是独立的实体(即使它们的名称和类型相同),因此不会违反单定义规则。每个文件内部对象只有一个定义。
static 对比 未命名的命名空间
在现代C++中,使用static关键字赋予标识符内部链接的做法已逐渐不再被推荐。未命名的名称空间可以为更广泛的标识符(例如,类型标识符)提供内部链接,并且它们更适合为多个标识符提供内部链接。
为什么要费心为标识符提供内部链接?
让标识符具有内部链接通常有两个原因:
有一个标识符,我们要确保其他文件无法访问。这可能是一个我们不想弄乱的全局变量,或者是一个不想调用的辅助函数。
避免命名冲突。由于具有内部链接的标识符不会向链接器公开,因此它们只能与同一翻译单元中的名称发生冲突,而不会在整个程序中发生冲突。
当您有明确的理由不允许从其他文件访问时,将标识符设置为内部链接。最好将您不希望其他文件访问的所有标识符设置为内部链接(使用未命名的命名空间)。
外部链接和变量声明
具有外部链接的标识符既可以从定义它的文件中看到,也可以从其他代码文件中使用(通过前向声明)。在这种意义上,具有外部链接的标识符是真正的“全局”标识符,因为它们可以在程序中的任何位置使用!
默认情况下,函数具有外部链接
为了调用在另一个文件中定义的函数,必须在使用该函数的任何其他文件中放置该函数的前向声明。前向声明告知编译器该函数的存在,链接器将函数调用连接到实际的函数定义。
示例:
a.cpp:
#include <iostream>
void sayHi() // 这个函数有外部链接,其它文件中可以使用
{
std::cout << "Hi!\n";
}main.cpp:
void sayHi(); // 函数 sayHi 的前向声明, 让 sayHi 可以在这个文件中使用
int main()
{
sayHi(); // 调用其它文件中定义的函数, 链接器会连接到实际的函数定义
return 0;
}main.cpp中函数 sayHi() 的前向声明,允许main.cpp访问在a.cpp中定义的 sayHi() 函数。前向声明满足了编译器的要求,并且链接器能够将函数调用链接到函数定义。
如果函数 sayHi() 具有内部链接,则链接器将无法将函数调用连接到函数定义,并将导致链接错误。
具有外部链接的全局变量
具有外部链接的全局变量有时称为外部变量。要将全局变量设置为外部变量(因此可由其他文件访问),可以使用extern关键字:
int g_x { 2 }; // 非常量全局变量默认是 外部链接
extern const int g_y { 3 }; // const 全局变量可以使用extern, 设置为外部链接
extern constexpr int g_z { 3 }; // constexpr 全局变量可以使用extern, 设置为外部链接 (但这是无用的定义, 详情见下述内容)
int main()
{
return 0;
}通过extern关键字进行变量前向声明
要实际使用在另一个文件中定义的外部全局变量,还必须在希望使用该变量的任何其他文件中放置全局变量的前向声明。对于变量,也可以通过extern关键字(没有初始化值)创建前向声明。
a.cpp
/main.cpp
#include <iostream>
extern int g_x; // 这里的extern,表示 g_x 是在其它地方定义的全局变量,这里做一个前向声明
extern const int g_y; // 这里的extern,表示 g_x 是在其它地方定义的 const 全局变量,这里做一个前向声明
int main()
{
std::cout << g_x << ' ' << g_y << '\n'; // 打印 2 3
return 0;
}函数前向声明不需要extern关键字——编译器能够根据是否提供函数体来判断您是在定义新函数还是在进行前向声明。变量前向声明确实需要extern关键字来帮助区分未初始化的变量定义和变量前向声明(它们在其他方面看起来相同):
// 非常量
int g_x; // 变量定义 (可以不用显式初始化)
extern int g_x; // 前向声明 (无初始化)
// 常量
extern const int g_y { 1 }; // 变量定义 (const 外部变量定义需要初始化值)
extern const int g_y; // 前向声明 (无初始化)为什么(非常量)全局变量是邪恶的
常量全局变量是危险的最大原因是,它们的值可以被调用的任何函数更改,并且程序员没有简单的方法得知这发生在哪里。
#include <iostream>
int g_mode; // 声明全局变量 (默认初始化为0)
void doSomething()
{
g_mode = 2; // 全局变量 g_mode设置为 2
}
int main()
{
g_mode = 1; // 全局变量 g_mode设置为 1
doSomething();
// 有很大可能阅读代码的人,认为 g_mode 是 1
// 但 doSomething 将它修改成 2!
if (g_mode == 1)
{
std::cout << "No threat detected.\n";
}
else
{
std::cout << "Launching nuclear missiles...\n";
}
return 0;
}main函数将变量g_mode设置为1,然后调用doSomething() 。除非明确知道 doSomething() 的实现细节,否则阅读代码的读者不知道它修改了变量g_mode的值!因此,main() 的其余部分不能像预期的那样工作。
全局变量使程序的状态不可预测。每个函数调用都有潜在的危险,没有简单的方法知道哪些函数调用是危险的,哪些不是!局部变量更安全,因为其他函数不能直接影响它们。
对于全局变量,通常会发现如下所示的代码:
void someFunction()
{
// 一些其它代码
if (g_mode == 4)
{
// 根据mode不同执行的代码
}
}调试后,您确定程序工作不正常,因为g_mode的值为3,而不是4。怎么修理它?现在您需要找到所有可能将g_mode设置为3的地方,并跟踪它是如何设置的。这可能是在一段完全不相关的代码中!
声明局部变量的一个原则,是尽可能靠近它们的使用位置,因为这样做可以最大限度地减少,您为了查看变量的用途所需要查看的代码量。全局变量处于另一个极端——因为它们可以在任何地方访问,所以您可能必须查看整个程序才能理解它们的用法。在小型程序中,这可能不是问题。但在大型程序中,这样极大提高排查问题难度。
您可能会发现在程序中引用了442次g_mode。除非g_mode有很好的文档记录,否则您可能必须仔细检查g_mode的每个相关代码,以了解它在不同的情况下是如何使用的,它的有效值是什么,以及它的整体功能是什么。
全局变量也会降低程序的模块化程度和灵活性。只使用其参数并且没有副作用的函数是完全模块化的。模块化有助于理解程序的功能以及可重用性。全局变量显著降低了模块化程度。
全局变量的初始化顺序问题
在执行main函数之前,静态变量(包括全局变量)的初始化作为程序启动的一部分发生。这分两个阶段进行。
第一阶段称为静态初始化(static initialization)。在静态初始化阶段,具有constexpr初始值设定项(包括字面值)的全局变量被初始化为对应的值。此外,没有初始值设定项的全局变量被零初始化。
第二个阶段称为动态初始化(dynamic initialization)。这个阶段更加复杂和微妙,但其要点是初始化具有非constexpr初始值设定项的全局变量。
非constexpr初始值设定项的示例:
int init()
{
return 5;
}
int g_something{ init() }; // 非 constexpr 初始化
在单个文件中,对于每个阶段,全局变量通常按定义顺序初始化(对于动态初始化阶段,此规则有一些例外)。考虑到这一点,您需要小心,不要让变量依赖于其他变量的初始化值,这些变量直到最后才会初始化。
int initX();
int initY();
int g_x{ initX() }; // 1. 先初始化 g_x(动态初始化)
int g_y{ initY() }; // 2. 后初始化 g_y(动态初始化)
int initX() { return g_y; } // 此时 g_y 还未初始化!
int initY() { return 5; }
int main() { std::cout << g_x << ' ' << g_y; }执行流程:
进入 动态初始化阶段。
遇到 g_x 的定义,调用 initX()。
initX() 返回 g_y 的值 —— 但 g_y 还没有初始化到 initY() 的结果,它只完成了 静态初始化(因为没有初始值,所以被零初始化)。所以 g_y 当前的值是 0。
g_x 被初始化为 0。
接着初始化 g_y,调用 initY() 得到 5,g_y 变为 5。
main 中输出 g_x 和 g_y → 0 5。
关键点:g_x 的初始化用到了 g_y,但 g_y 在此刻还没被动态初始化,只有零初始化的值。这就是 按顺序初始化的结果:先定义的先初始化,依赖后面的变量就会出问题。
更重要的是,C++没有定义不同文件之间的初始化顺序。给定两个文件a.cpp和b.cpp,任何一个都可以首先初始化其全局变量。这意味着,如果a.cpp中的变量依赖于b.cpp中的值,则这些变量尚未初始化的可能性为50%。
a.cpp:
extern int g_y; // 来自 b.cpp
int g_x = g_y + 1;b.cpp:
int g_y = 5;如果编译器先初始化 a.cpp 里的 g_x,这时 g_y 还是 0(静态零初始化),所以 g_x = 1。
如果先初始化 b.cpp 里的 g_y,那么 g_x = 5+1 = 6。
结果不确定 —— 这就是“未定义行为”(严格说是“未指定行为”)。
那么,使用非常量全局变量的充分理由是什么呢?
没有太多充分的理由。在大多数情况下,有其他方法可以解决问题,从而避免使用非常量全局变量。但在某些情况下,明智地使用非常量全局变量实际上可以降低程序的复杂性,并且在这些罕见的情况下,它们的使用可能比替代方案更好。
一个很好的例子是日志文件,您可以在其中打印错误或调试信息。将其定义为全局变量是有意义的,因为您可能在程序中只有一个日志,并且它可能会在程序中的任何地方使用。
值得一提的是,std::cout和std::cin对象被实现为全局变量(在std命名空间内)。
全局变量的任何使用都应该至少满足以下两个标准:变量在程序中表示的东西应该只有一个,并且它的使用应该在整个程序中无处不在。
保护自己免受全局变量破坏
如果您确实很好地使用了非常量全局变量,那么一些有用的建议将最大限度地减少您可能遇到的麻烦。这个建议不仅适用于非常量全局变量,而且可以帮助处理所有全局变量。
首先,用“g”或“g_”作为所有非命名空间全局变量的前缀,或者将它们放在命名空间中,以减少命名冲突的可能性。
constants.h:
namespace constants {
constexpr double gravity { 9.8 }; // 内部链接
}
double getGravity();
double getTimeScale();
void setTimeScale(double ts);在 .cpp 文件中定义内部链接的全局变量
constants.cpp:
// constants.cpp
#include "constants.h"
namespace constants {
// 内部链接全局变量(只在 constants.cpp 中可见)
// 使用 static 或未命名命名空间,这里用未命名命名空间更好
namespace {
double g_timeScale { 1.0 }; // 默认值为 1.0
}
}
double getTimeScale() {
// 可以在这里添加逻辑,例如检查标志位、计算等
return constants::g_timeScale;
}
void setTimeScale(double ts) {
// 验证输入范围
if (ts > 0.0 && ts < 100.0) {
constants::g_timeScale = ts;
}
// 否则忽略或报错
}调用示意图:
constants.cpp 内部
┌──────────────────────────────┐
│ namespace { │ ← 内部链接,外界不可见
│ double g_timeScale = 1.0; │
│ } │
└──────────────────────────────┘
↑ set / get ↓
┌──────────────────────────────┐
│ setTimeScale(double) │ ← 公共接口(有验证)
│ getTimeScale() : double │
└──────────────────────────────┘
↑ 调用 ↓
其他文件(如 main.cpp)