1. 复合语句(块)

复合语句(也称为块语句)是一组零个或多个语句,编译器将其视为单个语句。块以{符号开始,以}符号结束,要执行的语句放置在它们之间。块可以用在允许单个语句的任何地方。块末尾不需要分号。虽然函数不能嵌套在其他函数中,但块可以嵌套在其他块中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int add(int x, int y)
{ // block
return x + y;
} // end block

int main()
{ // outer block

// multiple statements
int value {};

{ // inner/nested block
add(3, 4);
} // end inner/nested block

return 0;

} // end outer block

C++ 标准规定 C++ 编译器应支持 256 个嵌套级别,但并非所有编译器都支持 256 个嵌套级别。最好将嵌套级别保持在 3 或更低。

2. 用户定义的命名空间和范围解析运算符

C++ 允许我们通过namespace关键字定义自己的命名空间。你在自己的程序中创建的命名空间被随意称为用户定义的命名空间(尽管称为程序定义的命名空间更准确)。命名空间的语法如下:

1
2
3
4
namespace NamespaceIdentifier
{
// content of namespace here
}

建议命名空间名称以大写字母开头。

告诉编译器在特定命名空间中查找标识符的最佳方法是使用范围解析运算符 (::)。范围解析运算符告诉编译器,应在左侧操作数的范围内查找右侧操作数指定的标识符。示例:

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
#include <iostream>

namespace Foo // define a namespace named Foo
{
// This doSomething() belongs to namespace Foo
int doSomething(int x, int y)
{
return x + y;
}
}

namespace Goo // define a namespace named Goo
{
// This doSomething() belongs to namespace Goo
int doSomething(int x, int y)
{
return x - y;
}
}

int main()
{
std::cout << Foo::doSomething(4, 3) << '\n'; // use the doSomething() that exists in namespace Foo
return 0;
}

范围解析运算符也可以用在标识符前面,而不提供命名空间名称(例如::doSomething )。在这种情况下,将在全局命名空间中查找标识符(例如doSomething )。

如果使用命名空间内的标识符并且未提供范围解析,则编译器将首先尝试在同一命名空间中查找匹配的声明。如果没有找到匹配的标识符,编译器将按顺序检查每个包含名称空间以查看是否找到匹配项,最后检查全局名称空间。示例:

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
#include <iostream>

void print() // this print() lives in the global namespace
{
std::cout << " there\n";
}

namespace Foo
{
void print() // this print() lives in the Foo namespace
{
std::cout << "Hello";
}

void printHelloThere()
{
print(); // calls print() in Foo namespace
::print(); // calls print() in global namespace
}
}

int main()
{
Foo::printHelloThere();

return 0;
}

对于命名空间内的标识符,这些前向声明也需要位于同一命名空间内:
add.h

1
2
3
4
5
6
7
8
9
10
#ifndef ADD_H
#define ADD_H

namespace BasicMath
{
// function add() is part of namespace BasicMath
int add(int x, int y);
}

#endif

add.cpp

1
2
3
4
5
6
7
8
9
10
#include "add.h"

namespace BasicMath
{
// define the function add() inside namespace BasicMath
int add(int x, int y)
{
return x + y;
}
}

在多个位置(跨多个文件或同一文件中的多个位置)声明名称空间块是合法的。命名空间内的所有声明都被视为命名空间的一部分。

不要将自定义功能添加到 std 命名空间。

命名空间可以嵌套在其他命名空间内。例如:

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

namespace Foo
{
namespace Goo // Goo is a namespace inside the Foo namespace
{
int add(int x, int y)
{
return x + y;
}
}
}

int main()
{
std::cout << Foo::Goo::add(1, 2) << '\n';
return 0;
}

因为名称空间Goo位于名称空间Foo内部,所以我们将add访问为Foo::Goo::add。从 C++17 开始,嵌套命名空间也可以这样声明:

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

namespace Foo::Goo // Goo is a namespace inside the Foo namespace (C++17 style)
{
int add(int x, int y)
{
return x + y;
}
}

int main()
{
std::cout << Foo::Goo::add(1, 2) << '\n';
return 0;
}

因为在嵌套命名空间中输入变量或函数的限定名称可能会很痛苦,所以 C++ 允许你创建命名空间别名,这允许我们暂时将一长串命名空间缩短为更短的名称:

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

namespace Foo::Goo
{
int add(int x, int y)
{
return x + y;
}
}

int main()
{
namespace Active = Foo::Goo; // active now refers to Foo::Goo

std::cout << Active::add(1, 2) << '\n'; // This is really Foo::Goo::add()

return 0;
} // The Active alias ends here

3. 局部变量

C++ 实际上没有一个属性将变量定义为局部变量。相反,局部变量具有几个不同的属性,这些属性可以区分这些变量与其他类型(非局部)变量的行为方式。局部变量具有块作用域,这意味着它们的作用域是从定义点到定义块的末尾。尽管函数参数没有在函数体内部定义,但对于典型的函数来说,它们可以被认为是函数体块范围的一部分。

1
2
3
4
5
6
7
int max(int x, int y) // x and y enter scope here
{
// assign the greater of x or y to max
int max{ (x > y) ? x : y }; // max enters scope here

return max;
} // max, y, and x leave scope here

作用域内的所有变量名称必须是唯一的局部变量具有自动存储持续时间,这意味着它们在定义时创建并在定义它们的块末尾销毁。例如:

1
2
3
4
5
6
7
int main()
{
int i { 5 }; // i created and initialized here
double d { 4.0 }; // d created and initialized here

return 0;
} // d and i are destroyed here

因此,局部变量有时也称为自动变量

标识符具有另一个名为链接的属性。标识符的链接确定在不同范围中的同一标识符声明是否是指同一对象(或函数)。局部变量没有链接。没有链接的标识符的每个声明是指唯一的对象或函数。

1
2
3
4
5
6
7
8
9
10
int main()
{
int x { 2 }; // local variable, no linkage

{
int x { 3 }; // this declaration of x refers to a different object than the previous x
}

return 0;
}

变量应在最有限的范围中定义。如果仅在嵌套块中使用变量,则应在该嵌套块中定义它:

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

int main()
{
// do not define y here

{
// y is only used inside this block, so define it here
int y { 5 };
std::cout << y << '\n';
}

// otherwise y could still be used here, where it's not needed

return 0;
}

4. 全局变量

在 C++ 中,变量也可以在函数_外部_声明。此类变量称为全局变量。按照惯例,全局变量在全局命名空间中的文件顶部、包含项下方声明。以下是定义全局变量的示例:

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

// Variables declared outside of a function are global variables
int g_x {}; // global variable g_x

void doSomething()
{
// global variables can be seen and used everywhere in the file
g_x = 3;
std::cout << g_x << '\n';
}

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

// global variables can be seen and used everywhere in the file
g_x = 5;
std::cout << g_x << '\n';

return 0;
}
// g_x goes out of scope here

在全局命名空间中声明的标识符具有全局命名空间作用域(通常称为全局作用域,有时非正式地称为文件作用域),这意味着它们从声明点到声明它们的文件末尾都是可见的。全局变量也可以在用户定义的命名空间内定义。这是与上面相同的示例,但g_x已从全局范围移至用户定义的命名空间Foo中:

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
#include <iostream>

namespace Foo // Foo is defined in the global scope
{
int g_x {}; // g_x is now inside the Foo namespace, but is still a global variable
}

void doSomething()
{
// global variables can be seen and used everywhere in the file
Foo::g_x = 3;
std::cout << Foo::g_x << '\n';
}

int main()
{
doSomething();
std::cout << Foo::g_x << '\n';

// global variables can be seen and used everywhere in the file
Foo::g_x = 5;
std::cout << Foo::g_x << '\n';

return 0;
}

尽管标识符g_x现在仅限于namespace Foo的范围,但该名称仍然可以全局访问(通过Foo::g_x ),并且g_x仍然是一个全局变量。

在命名空间内声明的变量也是全局变量。

全局变量在程序启动时创建(在main()开始执行之前),并在程序结束时销毁。这称为静态持续时间。具有静态持续时间的变量有时称为静态变量。按照惯例,一些开发人员在全局变量标识符前加上“g”或“g_”前缀,以表明它们是全局的。

与默认情况下未初始化的局部变量不同,具有静态持续时间的变量默认为零初始化。非常量全局变量可以选择初始化:

1
2
3
int g_x;       // no explicit initializer (zero-initialized by default)
int g_y {}; // value initialized (resulting in zero-initialization)
int g_z { 1 }; // list initialized with specific value

就像局部变量一样,全局变量可以是常量。与所有常量一样,常量全局变量必须进行初始化。

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
#include <iostream>

const int g_x; // error: constant variables must be initialized
constexpr int g_w; // error: constexpr variables must be initialized

const int g_y { 1 }; // const global variable g_y, initialized with a value
constexpr int g_z { 2 }; // constexpr global variable g_z, initialized with a value

void doSomething()
{
// global variables can be seen and used everywhere in the file
std::cout << g_y << '\n';
std::cout << g_z << '\n';
}

int main()
{
doSomething();

// global variables can be seen and used everywhere in the file
std::cout << g_y << '\n';
std::cout << g_z << '\n';

return 0;
}
// g_y and g_z goes out of scope here

5. 变量遮蔽(名称隐藏)

每个代码块定义了自己的作用域区域。那么,当我们有一个嵌套代码块中的变量与外层代码块中的变量同名时,会发生什么呢?当这种情况发生时,嵌套变量会在它们都处于作用域的区域内“隐藏”外层变量。这被称为名称隐藏变量遮蔽

局部变量的遮蔽:

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
#include <iostream>

int main()
{ // outer block
int apples { 5 }; // here's the outer block apples

{ // nested block
// apples refers to outer block apples here
std::cout << apples << '\n'; // print value of outer block apples

int apples{ 0 }; // define apples in the scope of the nested block

// apples now refers to the nested block apples
// the outer block apples is temporarily hidden

apples = 10; // this assigns value 10 to nested block apples, not outer block apples

std::cout << apples << '\n'; // print value of nested block apples
} // nested block apples destroyed


std::cout << apples << '\n'; // prints value of outer block apples

return 0;
} // outer block apples destroyed

与嵌套块中的变量可以隐藏外部块中的变量类似,与全局变量同名的局部变量将在局部变量所在的范围内隐藏全局变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
int value { 5 }; // global variable

void foo()
{
std::cout << "global variable value: " << value << '\n'; // value is not shadowed here, so this refers to the global value
}

int main()
{
int value { 7 }; // hides the global variable value (wherever local variable value is in scope)

++value; // increments local value, not global value

std::cout << "local variable value: " << value << '\n';

foo();

return 0;
} // local value is destroyed

但是,由于全局变量是全局命名空间的一部分,因此我们可以使用不带前缀的作用域运算符 (::) 来告诉编译器我们指的是全局变量而不是局部变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
int value { 5 }; // global variable

int main()
{
int value { 7 }; // hides the global variable value
++value; // increments local value, not global value

--(::value); // decrements global value, not local value (parenthesis added for readability)

std::cout << "local variable value: " << value << '\n';
std::cout << "global variable value: " << ::value << '\n';

return 0;
} // local value is destroyed

建议避免隐藏局部变量,如果所有全局名称都使用“g_”前缀,这是完全可以避免的。

6. 内部链接

全局变量和函数标识符可以具有内部链接(internal linkage)外部链接(external linkage)

具有内部链接的标识符可以在单个翻译单元中看到和使用,但不能从其他翻译单元访问。这意味着,如果两个源文件具有具有内部链接的相同名称的标识符,则这些标识符将被视为独立的(并且不会因具有重复定义而导致 ODR 违规)。

具有内部链接的全局变量有时称为内部变量。为了使非常量全局变量成为内部变量,我们使用static关键字。

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

static int g_x{}; // non-constant globals have external linkage by default, but can be given internal linkage via the static keyword

const int g_y{ 1 }; // const globals have internal linkage by default
constexpr int g_z{ 2 }; // constexpr globals have internal linkage by default

int main()
{
std::cout << g_x << ' ' << g_y << ' ' << g_z << '\n';
return 0;
}

上述对 static 关键字的使用是一个 存储类别说明符(storage class specifier) 的例子,它既设置了名称的链接性(linkage),也设置了它的存储期(storage duration)。最常用的存储类别说明符是 staticexternmutable。术语“存储类别说明符”主要在技术文档中使用。

函数标识符也具有链接。功能默认为外部链接,但可以通过static关键字设置为内部链接:

add.cpp

1
2
3
4
5
6
// This function is declared as static, and can now be used only within this file
// Attempts to access it from another file via a function forward declaration will fail
[[maybe_unused]] static int add(int x, int y)
{
return x + y;
}

main.cpp

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

int add(int x, int y); // forward declaration for function add

int main()
{
std::cout << add(3, 4) << '\n';

return 0;
}

此程序不会链接,因为add.cpp之外无法访问add 。

通常有两个原因可以给标识符内部链接:

  • 我们要确保其他文件无法访问一个标识符。这可能是我们不想弄乱的全局变量,或者是我们不想被调用的辅助函数。

  • 要避免命名碰撞。由于具有内部链接的标识符不会接触到链接器,因此它们只能与同一翻译单元中的名称相撞,而不是在整个程序中。

7. 外部链接和变量前向声明

可以从定义的文件以及其他代码文件(通过前向声明)中看到和使用具有外部链接的标识符。函数默认情况下具有外部链接。

为了调用另一个文件中定义的函数,你必须在希望使用该函数的任何其他文件中为函数放置一个前置声明 。前置声明告诉编译器函数的存在,并且链接器将函数调用连接到实际的函数定义。示例:
a.cpp

1
2
3
4
5
6
#include <iostream>

void sayHi() // this function has external linkage, and can be seen by other files
{
std::cout << "Hi!\n";
}

mian.cpp

1
2
3
4
5
6
7
8
void sayHi(); // forward declaration for function sayHi, makes sayHi accessible in this file

int main()
{
sayHi(); // call to function defined in another file, linker will connect this call to the function definition

return 0;
}

如果函数 sayHi() 具有内部链接,链接器将无法将函数调用连接到函数定义,并会产生链接器错误。

全局变量与外部链接有时被称为外部变量。为了使全局变量外部(从而其他文件可以访问),我们可以使用 extern 关键字来实现:

1
2
3
4
5
6
7
8
9
int g_x { 2 }; // non-constant globals are external by default (no need to use extern)

extern const int g_y { 3 }; // const globals can be defined as extern, making them external
extern constexpr int g_z { 3 }; // constexpr globals can be defined as extern, making them external (but this is pretty useless, see the warning in the next section)

int main()
{
return 0;
}

非常量全局变量默认是外部的,所以不需要标记为 extern。要实际上使用在另一个文件中定义的外部全局变量,你还需要在任何其他希望使用该变量的文件中为全局变量放置一个前置声明。对于变量,通过 extern 关键字(没有初始化值)也可以进行前向声明。
a.cpp

1
2
3
// global variable definitions
int g_x { 2 }; // non-constant globals have external linkage by default
extern const int g_y { 3 }; // this extern gives g_y external linkage

main.cpp

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

extern int g_x; // this extern is a forward declaration of a variable named g_x that is defined somewhere else
extern const int g_y; // this extern is a forward declaration of a const variable named g_y that is defined somewhere else

int main()
{
std::cout << g_x << ' ' << g_y << '\n'; // prints 2 3

return 0;
}

extern 关键字在不同的上下文中有不同的含义。在某些上下文中, extern 意味着“给这个变量外部链接”。在其他上下文中, extern 意味着“这是一个外部变量的前向声明,该变量在其他地方定义”。这确实很令人困惑。

如果你想要定义一个未初始化的非常量全局变量,不要使用 extern 关键字,否则 C++会认为你在为该变量做前置声明。

注意函数前置声明不需要 extern 关键字——编译器可以根据是否提供函数体来判断你是定义新函数还是进行前置声明。变量前置声明需要 extern 关键字来帮助区分未初始化变量的定义和变量的前置声明(它们在外表上看起来是相同的):

1
2
3
4
5
6
7
8
// non-constant
int g_x; // variable definition (no initializer)
int g_x { 1 }; // variable definition (w/ initializer)
extern int g_x; // forward declaration (no initializer)

// constant
extern const int g_y { 1 }; // variable definition (const requires initializers)
extern const int g_y; // forward declaration (no initializer)

只使用 extern 进行全局变量的前向声明或常量全局变量定义。不要使用 extern 来定义非常量全局变量(它们会被隐式地视为 extern )。

1
2
int g_x { 1 };        // extern by default
extern int g_x { 1 }; // explicitly extern (may cause compiler warning)

你的编译器可能会对你后一个语句发出警告,尽管它是技术上有效的。

8. 为什么(非常量)全局变量是邪恶的

非常量全局变量最危险的原因在于它们的值可以被任何被调用的函数改变,而程序员很难知道这一点。考虑以下程序:

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
#include <iostream>

int g_mode; // declare global variable (will be zero-initialized by default)

void doSomething()
{
g_mode = 2; // set the global g_mode variable to 2
}

int main()
{
g_mode = 1; // note: this sets the global g_mode variable to 1. It does not declare a local g_mode variable!

doSomething();

// Programmer still expects g_mode to be 1
// But doSomething changed it to 2!

if (g_mode == 1)
{
std::cout << "No threat detected.\n";
}
else
{
std::cout << "Launching nuclear missiles...\n";
}

return 0;
}

声明局部变量尽可能靠近它们被使用的地方的一个关键原因是这样做可以减少理解变量功能所需查看的代码量。全局变量则处于另一端——因为它们可以在任何地方被访问,你可能需要查看整个程序来理解它们的使用方式。在小程序中这可能不是问题。在大程序中,这将会是一个问题。

全局变量也会使你的程序更不模块化和更不灵活。一个仅使用参数且没有副作用的功能是完全模块化的。模块化有助于理解程序做什么,以及重用性。全局变量会显著降低模块化程度。

全局变量的初始化顺序问题:静态变量(包括全局变量)的初始化是在程序启动时进行的,发生在 main 函数执行之前。这个过程分为两个阶段。

  • 第一阶段称为静态初始化。静态初始化分为两个阶段:

    • 全局变量用 constexpr 初始化(包括字面量)会被初始化为那些值。这称为常量初始化。
    • 全局变量如果没有初始化,则会被零初始化。零初始化被视为一种静态初始化形式,因为 0 是一个 constexpr 值。
  • 第二阶段称为动态初始化。这一阶段更为复杂和微妙,但大体上是全局变量使用非 constexpr 初始化器时进行初始化。

在单个文件中,对于每个阶段,通常按照定义顺序初始化全局变量(动态初始化阶段有一些例外情况)。因此,你需要小心,不要有依赖于稍后才会初始化的其他变量的初始值的变量。例如:

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

int initX(); // forward declaration
int initY(); // forward declaration

int g_x{ initX() }; // g_x is initialized first
int g_y{ initY() };

int initX()
{
return g_y; // g_y isn't initialized when this is called
}

int initY()
{
return 5;
}

int main()
{
std::cout << g_x << ' ' << g_y << '\n';
}

跨不同翻译单元的静态对象初始化顺序是模糊的。给定两个文件 a.cppb.cpp,要么 a.cpp 中的全局变量先初始化,要么 b.cpp 中的全局变量先初始化。如果 a.cpp 中的静态持续变量用 b.cpp 中定义的静态持续变量初始化,那么 b.cpp 中的变量有 50%的概率还没有被初始化。

避免使用来自不同翻译单元的其他静态持续对象初始化具有静态持续时间的对象。动态初始化全局变量也可能存在初始化顺序问题,应尽可能避免。

使用非常量全局变量有哪些很好的理由?

  • 在大多数情况下,使用局部变量并将它们作为参数传递给其他函数是更优的选择。但在某些情况下,恰当地使用非常量全局变量实际上可以降低程序复杂性,在这些罕见的情况下,它们的使用可能比其他选择更好。

  • 一个好的例子是日志文件,你可以在其中倾倒错误或调试信息。你可能希望将其定义为全局变量,因为一个程序中通常只有一个这样的日志,并且它很可能在程序中的各个地方被使用。另一个好的例子是随机数生成器。

如果你确实找到了一个非常量全局变量的好用法,一些有用的建议可以最大限度地减少你可能遇到的麻烦:

  • 用“g”或“g_”前缀所有无命名空间的全局变量,或者更好些,将它们放在一个命名空间中

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

    namespace constants
    {
    constexpr double gravity { 9.8 }; // will not collide with other global variables named gravity
    }

    int main()
    {
    std::cout << constants::gravity << '\n'; // clear this is a global variable (since namespaces are global)

    return 0;
    }
  • 与其直接访问全局变量,更好的做法是“封装”该变量。确保该变量只能在声明它的文件内部访问,例如通过将变量声明为静态或常量,然后提供外部全局“访问函数”来操作该变量。这些函数可以确保维持正确的使用方式(例如进行输入验证、范围检查等)。此外,如果你将来决定更改底层实现(例如从一个数据库切换到另一个数据库),你只需要更新访问函数,而不需要修改直接使用全局变量的每一处代码。

    contants.cpp

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    namespace constants
    {
    constexpr double gravity { 9.8 }; // has internal linkage, is accessible only within this file
    }

    double getGravity() // has external linkage, can be accessed by other files
    {
    // We could add logic here if needed later
    // or change the implementation transparently to the callers
    return constants::gravity;
    }

    main.cpp

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

    double getGravity(); // forward declaration

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

    return 0;
    }
  • 当编写一个独立函数且该函数使用全局变量时,不要在函数体内直接使用该变量。而是将变量作为参数传入。这样,如果将来函数需要在某些情况下使用不同的值,只需改变参数即可。这有助于保持模块化。

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

    namespace constants
    {
    constexpr double gravity { 9.8 };
    }

    // This function can calculate the instant velocity for any gravity value (more useful)
    double instantVelocity(int time, double gravity)
    {
    return gravity * time;
    }

    int main()
    {
    std::cout << instantVelocity(5, constants::gravity) << '\n'; // pass our constant to the function as a parameter

    return 0;
    }

9. 内联函数和变量

C++ 编译器可以使用一个技巧来避免函数调用的开销:**内联展开(inline expansion)**是一种将函数调用替换为被调用函数定义代码的过程。内联展开有其自身的潜在成本:如果被展开的函数体所需的指令比被替换的函数调用多,那么每次内联展开都会导致可执行文件变大。较大的可执行文件往往运行较慢(因为它们不太适合内存缓存)。内联展开最适合简单、短小的函数(例如不超过几个语句),尤其是在单个函数调用可以执行多次的情况下(例如循环内的函数调用)。

每个函数都属于两类之一,其中对函数的调用:

  • 可以展开(大部分功能都属于这一类)。

  • 无法展开。

大多数函数都属于“可能”类别:如果有利的话,它们的函数调用可以展开。对于此类中的函数,现代编译器将评估每个函数和每个函数调用,以确定该特定函数调用是否会受益于内联展开。编译器可能决定不展开对给定函数的函数调用、部分函数调用或全部函数调用。

从历史上看,编译器要么没有能力确定内联扩展是有益的,要么是不太擅长的。因此,C ++提供了关键字inline ,最初旨在用作编译器的提示,即功能(可能)可能会受益于内联。使用inline关键字声明的函数称为内联函数。示例:

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

inline int min(int x, int y) // inline keyword means this function is an inline function
{
return (x < y) ? x : y;
}

int main()
{
std::cout << min(5, 6) << '\n';
std::cout << min(3, 2) << '\n';
return 0;
}

但是,在现代C ++中, inline关键字不再用于要求向内联函数展开。有很多原因:

  • 使用inline请求内联展开是一种过早优化的形式,滥用实际上可能会损害性能。

  • inline关键字只是一个提示,可以帮助编译器确定在何处执行内联展开。编译器完全可以忽略该请求,并且很可能这样做。编译器还可以免费执行不使用inline关键字作为其正常优化集的一部分的功能的内联展开。

  • inline关键字在错误的粒度水平上定义。我们在功能定义上使用inline关键字,但是实际上每个函数调用实际上确定了内联展开。扩展某些功能调用并不利于扩展其他功能可能是有益的,并且没有语法来影响这一点。

在现代的C ++中, inline术语已演变为“允许多个定义”。因此,内联函数是允许在多个翻译单元(而不违反ODR)中定义的功能。示例:main.cpp

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

double circumference(double radius); // forward declaration

inline double pi() { return 3.14159; }

int main()
{
std::cout << pi() << '\n';
std::cout << circumference(2.0) << '\n';

return 0;
}

good.cpp

1
2
3
4
5
6
inline double pi() { return 3.14159; }

double circumference(double radius)
{
return 2.0 * pi() * radius;
}

两个文件都有一个函数pi()的定义,但是由于此函数已被标记为inline ,因此可以接受,并且链接器将删除它们。如果您从pi()的两个定义中删除了inline关键字,则会违反ODR(因为不允许非内部功能的重复定义)

内联函数通常在头文件中定义

1
2
3
4
5
6
#ifndef PI_H
#define PI_H

inline double pi() { return 3.14159; }

#endif

为什么不将所有函数内联并在头文件中定义呢?

主要是因为这样做会显著增加编译时间。当包含内联函数的标头 #included 到源文件中时,该函数定义将被编译为该翻译单元的一部分。#included 为 6 个翻译单元的内联函数的定义将被编译 6 次(在链接器删除重复定义之前)。相反,在源文件中定义的函数将只编译一次其定义,无论其前向声明包含多少个翻译单元。其次,如果源文件中定义的函数发生更改,则只需重新编译该单个源文件。当头文件中的内联函数发生更改时,包含该头文件的每个代码文件(直接或通过另一个头文件)都需要重新编译。在大型项目中,这可能会导致一连串的重新编译,并产生巨大的影响。

在上面的例子中,pi() 被写成一个返回常量值的函数。如果 pi 被实现为 (const) 变量,那将更加坚定。但是,在 C++17 之前,这样做存在一些障碍和低效率。C++17 引入了内联变量,这些变量是允许在多个文件中定义的变量。内联变量的工作方式与内联函数类似,并且具有相同的要求(编译器必须能够在使用变量的任何地方看到相同的完整定义)。

10. 在多个文件之间共享全局常量(使用内联变量)

在某些应用程序中,可能需要在整个代码中使用某些符号常量(而不仅仅是在一个位置)。这些可以包括不变的物理或数学常数(例如 pi 或 Avogadro 数),或特定于应用程序的“调整”值(例如摩擦或重力系数)。与其在每个需要它们的文件中重新定义这些常量(违反 “Don’t Repeat Yourself” 规则),不如在一个中心位置声明一次它们,并在需要的地方使用它们。这样,如果你需要更改它们,你只需要在一个地方更改它们,这些更改就可以传播出去。

作为内部变量的全局常量:
在 C++17 之前,以下是最简单和最常见的解决方案:

  • 创建头文件以保存这些常量

  • 在此头文件中,定义命名空间

  • 在命名空间中添加所有常量(确保它们是 constexpr

在您需要的任何位置#include头文件

示例:

constants.h

1
2
3
4
5
6
7
8
9
10
11
12
13
#ifndef CONSTANTS_H
#define CONSTANTS_H

// Define your own namespace to hold constants
namespace constants
{
// Global constants have internal linkage by default
constexpr double pi { 3.14159 };
constexpr double avogadro { 6.0221413e23 };
constexpr double myGravity { 9.2 }; // m/s^2 -- gravity is light on this planet
// ... other related constants
}
#endif

main.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "constants.h" // include a copy of each constant in this file

#include <iostream>

int main()
{
std::cout << "Enter a radius: ";
double radius{};
std::cin >> radius;

std::cout << "The circumference is: " << 2 * radius * constants::pi << '\n';

return 0;
}

这种方法的缺点:

  • 更改头文件中的任何内容都需要重新编译文件,包括头文件。

  • 每个翻译单元(包括 header)都会获得自己的变量副本。

作为外部变量的全局常量:

避免这些问题的一种方法是将这些常量转换为外部变量,因为这样我们就可以拥有一个在所有文件之间共享的单个变量(初始化一次)。在这个方法中,我们将在 .cpp 文件中定义常量(以确保定义只存在于一个地方),并在头文件中提出声明(将被其他文件包含)。
constants.cpp

1
2
3
4
5
6
7
8
9
#include "constants.h"

namespace constants
{
// We use extern to ensure these have external linkage
extern constexpr double pi { 3.14159 };
extern constexpr double avogadro { 6.0221413e23 };
extern constexpr double myGravity { 9.2 }; // m/s^2 -- gravity is light on this planet
}

constants.h

1
2
3
4
5
6
7
8
9
10
11
12
13
#ifndef CONSTANTS_H
#define CONSTANTS_H

namespace constants
{
// Since the actual variables are inside a namespace, the forward declarations need to be inside a namespace as well
// We can't forward declare variables as constexpr, but we can forward declare them as (runtime) const
extern const double pi;
extern const double avogadro;
extern const double myGravity;
}

#endif

现在,符号常量将只实例化一次(在 constants.cpp 中),而不是在 constants.h #included 的每个代码文件中实例化,并且这些常量的所有使用都将链接到 constants.cpp 实例化的版本。对 constants.cpp 所做的任何更改都只需要重新编译 constants.cpp

但是,这种方法有几个缺点:

  • 因为只有变量定义是 constexpr(前向声明不是,也不可能是),所以这些常量仅在它们实际定义的文件中是常量表达式 (constants.cpp)。在其他文件中,编译器将只看到 forward 声明,它没有定义 constexpr 值(必须由链接器解析)。这意味着在定义它们的文件之外,这些变量不能在常量表达式中使用。

  • 由于常量表达式通常比运行时表达式更优化,因此编译器可能无法对这些表达式进行尽可能多的优化。

由于编译器单独编译每个源文件,因此它只能看到正在编译的源文件(包括任何包含的标头)中出现的变量定义。例如,当编译器编译 main.cpp 时,constants.cpp 中的变量定义不可见。因此,constexpr 变量不能分为头文件和源文件,它们必须在头文件中定义。

在C++17之后,我们可以使用作为内联变量的全局常量:

constants.h

1
2
3
4
5
6
7
8
9
10
11
12
#ifndef CONSTANTS_H
#define CONSTANTS_H

// define your own namespace to hold constants
namespace constants
{
inline constexpr double pi { 3.14159 }; // note: now inline constexpr
inline constexpr double avogadro { 6.0221413e23 };
inline constexpr double myGravity { 9.2 }; // m/s^2 -- gravity is light on this planet
// ... other related constants
}
#endif

main.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "constants.h"

#include <iostream>

int main()
{
std::cout << "Enter a radius: ";
double radius{};
std::cin >> radius;

std::cout << "The circumference is: " << 2 * radius * constants::pi << '\n';

return 0;
}

此方法确实保留了缺点,即如果更改了任何常量值,则需要重新编译包含 constants 标头的每个文件。优势:

  • 可以在包含它们的任何翻译单元的常量表达式中使用。

  • 每个变量只需要一个副本。

11. 静态局部变量

在局部变量上使用 static 关键字会将其存储期从自动存储期改为静态存储期。这意味着该变量现在会在程序启动时被创建,并在程序结束时被销毁(就像全局变量一样)。因此,即使该静态变量超出作用域,它也会保留其值!示例:

自动存储期(默认):

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

void incrementAndPrint()
{
int value{ 1 }; // automatic duration by default
++value;
std::cout << value << '\n';
} // value is destroyed here

int main()
{
incrementAndPrint();
incrementAndPrint();
incrementAndPrint();

return 0;
}

静态存储期(使用static关键字):

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

void incrementAndPrint()
{
static int s_value{ 1 }; // static duration via static keyword. This initializer is only executed once.
++s_value;
std::cout << s_value << '\n';
} // s_value is not destroyed here, but becomes inaccessible because it goes out of scope

int main()
{
incrementAndPrint();
incrementAndPrint();
incrementAndPrint();

return 0;
}

静态存储期局部变量最常见的用途之一是唯一 ID 生成器。使用静态持续时间局部变量生成唯一 ID 号非常容易:

1
2
3
4
5
int generateID()
{
static int s_itemID{ 0 };
return s_itemID++; // makes copy of s_itemID, increments the real s_itemID, then returns the value in the copy
}

静态变量提供了全局变量的一些好处(它们在程序结束之前不会被销毁),同时将它们的可见性限制在块范围内。这使它们更易于理解且使用更安全。

静态局部变量可以设为 const (或 constexpr)。const 静态局部变量的一个很好的用途是,当你有一个函数需要使用 const 值,但创建或初始化对象很昂贵时(例如,你需要从数据库中读取值)。如果使用普通局部变量,则每次执行函数时都会创建并初始化该变量。使用 const/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 getInteger()
{
static bool s_isFirstCall{ true };

if (s_isFirstCall)
{
std::cout << "Enter an integer: ";
s_isFirstCall = false;
}
else
{
std::cout << "Enter another integer: ";
}

int i{};
std::cin >> i;
return i;
}

int main()
{
int a{ getInteger() };
int b{ getInteger() };

std::cout << a << " + " << b << " = " << (a + b) << '\n';

return 0;
}

示例输出:

1
2
3
Enter an integer: 5
Enter another integer: 9
5 + 9 = 14

此代码执行它应该执行的操作,但由于我们使用了静态局部变量,因此使代码更难理解。如果有人阅读了 main() 中的代码,而没有阅读 getInteger() 的实现,他们就没有理由假设对 getInteger() 的两次调用做了不同的事情。但是这两个调用执行不同的操作,如果差异不仅仅是更改的提示符,则可能会非常令人困惑。

getInteger() 是不可重用的,因为它有一个内部状态(静态局部变量 s_isFirstCall),不能从外部重置。s_isFirstCall 不是在整个程序中应唯一的变量。尽管我们的程序在第一次编写时运行良好,但 static 局部变量阻止了我们以后重用该函数。

12. using 声明和 using 指令

以下这种写法已经过时:

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

using namespace std;

int main()
{
cout << "Hello world!\n";

return 0;
}

存在的原因:

在 C++ 支持命名空间之前,现在位于 std 命名空间中的所有名称都位于全局命名空间中。这会导致程序标识符和标准库标识符之间的命名冲突。在一个 C++ 版本下运行的程序可能与较新版本的 C++ 存在命名冲突。在 1995 年,命名空间被标准化,标准库中的所有功能都从全局命名空间移动到命名空间 std 中。此更改破坏了仍在使用不带 std::的名称的旧代码。任何参与过大型代码库工作的人都知道,对代码库的任何更改(无论多么微不足道)都有破坏程序的风险。更新现在移动到 std 命名空间中的每个名称以使用 std::前缀是一个巨大的风险。要求解决方案。C++ 以 using 语句的形式为这两个问题提供了一些解决方案。

名称可以是限定的,也可以是非限定的。限定名称是包含关联范围的名称。大多数情况下,名称是使用范围解析运算符 (:😃。例如:

1
2
std::cout // identifier cout is qualified by namespace std
::foo // identifier foo is qualified by the global namespace

非限定名称是不包含范围限定符的名称。例如,cout 和 x 是非限定名称,因为它们不包含关联的范围。减少一遍又一遍地重复键入 std::的一种方法是使用 using 声明语句。using 声明允许我们使用非限定名称(没有范围)作为限定名称的别名。

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

int main()
{
using std::cout; // this using declaration tells the compiler that cout should resolve to std::cout
cout << "Hello world!\n"; // so no std:: prefix is needed here!

return 0;
} // the using declaration expires at the end of the current scope

另一种简化的方法是使用 using 指令。using 指令允许引用给定命名空间中的所有标识符,而无需对 using 指令的范围进行限定。

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

int main()
{
using namespace std; // all names from std namespace now accessible without qualification
cout << "Hello world!\n"; // so no std:: prefix is needed here

return 0;
} // the using-directive ends at the end of the current scope

using namespace std的 using 指令告诉编译器,std 命名空间中的所有名称都应该可以在当前范围内(在本例中为函数 main())中无需限定即可访问。当我们使用非限定标识符 cout 时,它将解析为 std::cout

在现代 C++ 中,与风险相比,using 指令通常几乎没有什么好处(节省一些键入)。这是由于两个因素:

  • using 指令允许对命名空间中的所有名称进行无条件访问(可能包括许多您永远不会使用的名称)。

  • using 指令不会优先使用 using 指令标识的命名空间中的名称,而不是其他名称。

最终结果是,发生命名冲突的可能性显著增加(尤其是在导入 std 命名空间时)。示例:

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

namespace A
{
int x { 10 };
}

namespace B
{
int x{ 20 };
}

int main()
{
using namespace A;
using namespace B;

std::cout << x << '\n';

return 0;
}

在上面的示例中,编译器无法确定 main 中的 x 是引用 A::x 还是 B::x。在这种情况下,它将无法编译,并显示 “ambiguous symbol” 错误。我们可以通过删除其中一个 using 指令,改用 using 声明,或者限定 x(如 A::x 或 B::x)来解决这个问题。

另一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream> // imports the declaration of std::cout

int cout() // declares our own "cout" function
{
return 5;
}

int main()
{
using namespace std; // makes std::cout accessible as "cout"
cout << "Hello, world!\n"; // uh oh! Which cout do we want here? The one in the std namespace or the one we defined above?

return 0;
}

在此示例中,编译器无法确定我们对 cout 的不合格使用是指 std::cout 还是我们定义的 cout 函数,并且将再次编译失败,并显示“歧义符号”错误。虽然这个例子很简单,但如果我们像这样显式地为 std::cout 添加前缀:

1
std::cout << "Hello, world!\n"; // tell the compiler we mean std::cout

或者使用 using 声明而不是 using 指令:

1
2
using std::cout; // tell the compiler that cout means std::cout
cout << "Hello, world!\n"; // so this means std::cout

则不会有问题。

using 语句的范围:

  • 如果在块中使用 using 声明或 using 指令,则名称仅适用于该块(它遵循正常的块范围规则)。这是一件好事,因为它减少了在该块内发生命名冲突的几率。

  • 如果在命名空间(包括全局命名空间)中使用 using 声明或 using 指令,则这些名称适用于文件的整个其余部分(它们具有文件范围)。

一个好的经验法则是,using 语句不应该放在任何可能影响不同文件中的代码的地方。也不应将它们放置在其他文件的代码可能影响它们的任何位置。更具体地说,这意味着 using 语句不应该在头文件中使用,也不应在 #include 指令之前使用。

一旦声明了 using 语句,就无法在声明它的范围内取消或将其替换为不同的 using 语句。

1
2
3
4
5
6
7
8
9
int main()
{
using namespace Foo;

// there's no way to cancel the "using namespace Foo" here!
// there's also no way to replace "using namespace Foo" with a different using statement

return 0;
} // using namespace Foo ends here

你能做的最好的事情就是从一开始就使用块范围规则有意识地限制 using 语句的范围。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main()
{
{
using namespace Foo;
// calls to Foo:: stuff here
} // using namespace Foo expires

{
using namespace Goo;
// calls to Goo:: stuff here
} // using namespace Goo expires

return 0;
}

当然,所有这些麻烦都可以通过首先显式使用范围解析运算符 (::) 来避免。

13. 未命名命名空间和内联命名空间

未命名命名空间(也称为匿名命名空间)是定义时没有名称的命名空间,如下所示:

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

namespace // unnamed namespace
{
void doSomething() // can only be accessed in this file
{
std::cout << "v1\n";
}
}

int main()
{
doSomething(); // we can call doSomething() without a namespace prefix

return 0;
}

在未命名命名空间中声明的所有内容都被视为父命名空间的一部分。因此,即使函数 doSomething() 是在未命名的命名空间中定义的,函数本身也可以从父命名空间(在本例中为全局命名空间)访问,这就是为什么我们可以在没有任何限定符的情况下从 main() 调用 doSomething()

这可能会使未命名的命名空间看起来毫无用处。但是未命名空间的另一个影响是未命名空间内的所有标识符都被视为具有内部链接,这意味着未命名空间的内容在定义未命名空间的文件之外看不到。

对于函数,这实际上与将未命名空间中的所有函数定义为静态函数相同。以下程序实际上与上面的程序相同:

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

static void doSomething() // can only be accessed in this file
{
std::cout << "v1\n";
}

int main()
{
doSomething(); // we can call doSomething() without a namespace prefix

return 0;
}

当您有大量内容要确保这些内容保持在给定翻译单元的本地时,通常会使用未命名命名空间,因为将这些内容聚集在单个未命名命名空间中比将所有声明单独标记为static更容易。

当你有要保留到翻译单元本地的内容时,首选未命名的命名空间。避免在头文件中使用未命名的命名空间。

内联命名空间:

现在考虑以下程序:

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

void doSomething()
{
std::cout << "v1\n";
}

int main()
{
doSomething();

return 0;
}

假设你对 doSomething() 不满意,并且你想以某种方式改进它,从而改变它的行为方式。但是,如果您这样做,则可能会破坏使用旧版本的现有程序。一种方法是使用其他名称创建函数的新版本。但是在许多更改的过程中,您最终可能会得到一整套名称几乎相同的函数(doSomethingdoSomething_v2doSomething_v3 等)。另一种方法是使用内联命名空间。内联命名空间是通常用于对内容进行版本控制的命名空间。与未命名空间非常相似,在内联命名空间中声明的任何内容都被视为父命名空间的一部分。但是,与未命名的命名空间不同,内联命名空间不会影响链接。

要定义内联命名空间,我们使用 inline 关键字:

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
#include <iostream>

inline namespace V1 // declare an inline namespace named V1
{
void doSomething()
{
std::cout << "V1\n";
}
}

namespace V2 // declare a normal namespace named V2
{
void doSomething()
{
std::cout << "V2\n";
}
}

int main()
{
V1::doSomething(); // calls the V1 version of doSomething()
V2::doSomething(); // calls the V2 version of doSomething()

doSomething(); // calls the inline version of doSomething() (which is V1)

return 0;
}

输出:

1
2
3
V1
V2
V1

在上面的示例中,doSomething() 的调用者将获得 doSomething() 的 V1(内联版本)。想要使用较新版本的调用方可以显式调用 V2::d oSomething()。这保留了现有程序的功能,同时允许较新的程序利用更新/更好的变体。

如果你想推送较新的版本:

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
#include <iostream>

namespace V1 // declare a normal namespace named V1
{
void doSomething()
{
std::cout << "V1\n";
}
}

inline namespace V2 // declare an inline namespace named V2
{
void doSomething()
{
std::cout << "V2\n";
}
}

int main()
{
V1::doSomething(); // calls the V1 version of doSomething()
V2::doSomething(); // calls the V2 version of doSomething()

doSomething(); // calls the inline version of doSomething() (which is V2)

return 0;
}

输出:

1
2
3
V1
V2
V2

doSomething() 的所有调用方都将默认获得 v2 版本(更新更好的版本)。仍然需要旧版本 doSomething() 的用户可以显式调用 V1::d oSomething() 来访问旧行为。这意味着需要 V1 版本的现有程序需要将 doSomething 全局替换为 V1::d oSomething,但如果函数命名正确,这通常不会有问题。

参考资料

Learn C++ – Skill up with our free tutorials