EffectiveC++笔记

  1. 符号表
  2. 定义式和声明式

宏函数

p16问题

#define CALL_WITH_MAX(a,b) f((a)>(b)?(a):(b))
int a=5,b=0;
CALL_WITH_MAX(++a,b);
CALL_WITH_MAX(++a,b+10);
为什么前者a被累加两次,后者a被累加一次???

对上述进行展开,可以得到:

1
2
3
4
5
// 第一个调用
f((++a)>(b)?(++a):(b))

// 第二个调用
f((++a)>(b+10)?(++a):(b+10))
  1. 当条件为真的时候,会执行f(++a)
    1. 在第一个调用中++a已经被调用一次当条件为真的时候,在后面又会调用一次
    2. 在第二个调用中条件为假,所以在后面不会再次执行++a
    3. 所以最终执行的是f(7)
  2. 当条件为假的时候,会执行f(b+10)
    1. 这里最终执行的是f(10)
    2. 为什么是f(10)而不是f(20)?因为从始至终b的值都是0,所以最终执行的f(b+10)即f(10)

代码测试

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

// 定义宏
#define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b))

// 定义一个简单的函数f,用于打印参数值
void f(int value) {
printf("f(%d)\n", value);
}

int main() {
int a = 5, b = 0;

printf("Before first CALL_WITH_MAX: a = %d, b = %d\n", a, b);
CALL_WITH_MAX(++a, b);
printf("After first CALL_WITH_MAX: a = %d, b = %d\n", a, b);

printf("Before second CALL_WITH_MAX: a = %d, b = %d\n", a, b);
CALL_WITH_MAX(++a, b + 10);
printf("After second CALL_WITH_MAX: a = %d, b = %d\n", a, b);

return 0;
}

运行结果:

1
2
3
4
5
6
7
$ ./app
Before first CALL_WITH_MAX: a = 5, b = 0
f(7)
After first CALL_WITH_MAX: a = 7, b = 0
Before second CALL_WITH_MAX: a = 7, b = 0
f(10)
After second CALL_WITH_MAX: a = 8, b = 0

符号表

符号表是什么?

符号表是编程语言编译过程中使用的一种数据结构,用于存储源代码中各种标识符(如变量、函数、类型等)的信息。它在编译器的多个阶段中发挥重要作用,例如帮助编译器识别和解析标识符、进行类型检查、分配内存以及生成代码。

符号表是如何生成的?

符号表的生成通常在编译器的词法分析和语法分析阶段完成。词法分析器会识别源代码中的标识符,并将其传递给语法分析器。语法分析器在构建抽象语法树(AST)的同时,会将标识符及其相关信息插入到符号表中。例如,当遇到一个变量声明时,语法分析器会创建一个符号表条目,记录该变量的名称、类型、作用域等信息。

符号表长什么样子?

符号表通常由多个条目组成,每个条目代表一个标识符。每个条目包含以下信息:

  • 名称 :标识符的名称。
  • 类型 :标识符的数据类型。
  • 作用域 :标识符的作用域,例如全局作用域或局部作用域。
  • 存储位置 :标识符在内存中的位置。
  • 其他属性 :根据标识符的类型,可能还会包含其他属性,如函数的参数列表、变量的初始值等。

例如,对于以下C代码:

1
2
3
4
5
6
7
int main() {
int a = 5;
float b = 3.14;
int c;
c = a + b;
return 0;
}

其符号表可能如下所示:

名称 类型 作用域 存储位置
a int main() 0x1000
b float main() 0x1004
c int main() 0x1008

符号表的底层原理是什么?

符号表的底层原理主要涉及以下几个方面:

  • 数据结构 :符号表通常使用哈希表、平衡二叉搜索树等数据结构来存储和管理符号。哈希表可以提供快速的查找和插入操作,而平衡二叉搜索树则可以保证操作的时间复杂度。
  • 作用域管理 :符号表需要支持嵌套作用域。当进入一个新的作用域(如函数或代码块)时,符号表会创建一个新的作用域层,并在该层中存储局部符号。当退出作用域时,局部符号会被移除。
  • 符号解析 :在代码生成阶段,编译器需要根据符号表中的信息来分配内存和生成指令。例如,变量的地址可以通过其在符号表中的存储位置属性来确定。

定义式和声明式

定义式(Definition)

定义式是指在编程语言中,为一个变量、函数、类型等分配存储空间,并且可以初始化的过程。定义式不仅声明了标识符的存在,还分配了内存,并且可以为变量赋初值。

声明式(Declaration)

声明式是指在编程语言中,声明一个变量、函数、类型等的存在,但不分配存储空间。声明式的主要目的是让编译器知道标识符的类型、作用域等信息,以便在后续的代码中正确地使用它。

区别

1. 存储空间

  • 定义式 :会分配存储空间。
  • 声明式 :不会分配存储空间。

2. 初始化

  • 定义式 :可以初始化变量。
  • 声明式 :不能初始化变量。

3. 作用域和链接

  • 定义式 :定义的变量或函数在当前作用域中可用,并且可以被链接到其他作用域。
  • 声明式 :声明的变量或函数在当前作用域中可用,但不会分配存储空间,通常用于声明外部变量或函数。

4. 编译器处理

  • 定义式 :编译器会为定义的变量或函数分配内存,并且可以进行初始化。
  • 声明式 :编译器只会记录声明的变量或函数的类型和作用域信息,不会分配内存。

示例

1
2
3
4
5
6
7
8
9
// 定义式
int a = 10; // 定义了一个整型变量a,并初始化为10
void func() { // 定义了一个函数func
// 函数体
}

// 声明式
extern int b; // 声明了一个外部变量b,但不分配存储空间
void func(); // 声明了一个函数func,但不定义函数体

总结

  • 定义式 :分配存储空间,可以初始化,是声明式的一种特殊情况。
  • 声明式 :不分配存储空间,只声明标识符的存在,通常用于跨文件或跨作用域的引用。

常量指针和指针常量

  1. const修饰*:表示这个指针的指向是不能改变的;指向的内容可以改变;
  2. const修饰类型:表示这个指针指向的内容是不能改变的;指针的指向可以改变;

初始化

为免除“跨编译单元之初始化次序”问题,请以local static对象替代non-local static对象。

1.问题背景

在C++中,non-local static对象(非局部静态对象)是指定义在函数外部的全局静态对象,它们在程序启动时初始化,但在不同编译单元(.cpp文件)中定义的全局静态对象的初始化顺序是未定义的。这意味着如果一个全局静态对象依赖于另一个全局静态对象,可能会出现未初始化的依赖对象被访问的情况,从而导致未定义行为。

静态对象初始化顺序问题

在C++中,全局静态对象(non-local static objects)的初始化顺序是未定义的,特别是在多个编译单元(.cpp文件)中定义的全局静态对象。这意味着如果一个全局静态对象依赖于另一个全局静态对象,可能会出现未初始化的依赖对象被访问的情况,从而导致未定义行为。

1
2
3
4
5
6
7
// File1.cpp
#include "File2.h"
A a;

// File2.cpp
#include "File1.h"
B b;

如果 A的构造函数依赖于 B,而 B尚未初始化,就会导致问题。

2. 解决方案:使用Local Static对象

局部静态对象的优势

局部静态对象(local static objects)是指在函数内部定义的静态对象。它们在第一次调用该函数时初始化,并且只初始化一次。由于局部静态对象的初始化时机是明确的(即在第一次调用时),因此可以避免跨编译单元的初始化顺序问题。

3. 具体示例

问题代码

假设有一个全局静态对象 FileSystem,它被另一个全局静态对象 Directory使用。为了避免初始化顺序问题,可以将 FileSystem改为局部静态对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// FileSystem.h
class FileSystem {
public:
std::size_t numDisks() const;
};
extern FileSystem tfs; // 全局静态对象

// Directory.h
class Directory {
public:
Directory();
};

// Directory.cpp
#include "FileSystem.h"
Directory::Directory() {
std::size_t disks = tfs.numDisks(); // 使用全局静态对象
}

在上述代码中,tfsDirectory的初始化顺序是不确定的。

改进代码

FileSystem改为局部静态对象:

cppCopy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// FileSystem.h
class FileSystem {
public:
std::size_t numDisks() const;
};

FileSystem& tfs() { // 返回局部静态对象的引用
static FileSystem fs;
return fs;
}

// Directory.cpp
#include "FileSystem.h"
Directory::Directory() {
std::size_t disks = tfs().numDisks(); // 调用函数获取局部静态对象
}

通过这种方式,FileSystem对象 fs会在第一次调用 tfs()时初始化,从而避免了初始化顺序问题。

4. 多线程环境下的安全性

线程安全性

在多线程环境中,局部静态对象的初始化是线程安全的(C++11及之后的标准保证了这一点)。这意味着即使多个线程同时调用 tfs()FileSystem对象 fs也只会被初始化一次。

但如果需要在多线程启动阶段初始化多个对象,建议在程序的单线程启动阶段手工调用这些对象的初始化函数,以避免潜在的线程安全问题。

5. 总结

关键点

  • Non-local static对象 :初始化顺序是不确定的,容易导致跨编译单元的初始化问题。
  • Local static对象 :在第一次调用时初始化,初始化顺序明确,可以避免上述问题。
  • 多线程安全性 :C++11及之后的标准保证了局部静态对象的线程安全性,但在多线程启动阶段初始化多个对象时,建议在单线程启动阶段手工调用初始化函数。

6. 其他注意事项

避免使用全局静态对象

如果可能,尽量避免使用全局静态对象,因为它们不仅存在初始化顺序问题,还可能导致其他问题,如线程安全问题和资源管理问题。

使用单例模式

如果确实需要全局对象,可以考虑使用单例模式,结合局部静态对象来实现线程安全的全局对象。

1
2
3
4
5
6
7
8
9
10
11
12
class Singleton {
public:
static Singleton& getInstance() {
static Singleton instance;
return instance;
}

private:
Singleton() {} // 私有构造函数
Singleton(const Singleton&) = delete; // 禁止拷贝构造
Singleton& operator=(const Singleton&) = delete; // 禁止赋值
};

局部静态对象

局部静态对象(Local Static Object)的初始化顺序是明确的,原因在于其初始化时机是由语言标准明确定义的:局部静态对象在第一次进入其作用域时被初始化。这种机制确保了初始化的顺序与代码的执行流程一致,从而避免了全局静态对象(Non-local Static Object)初始化顺序不确定的问题。

1.局部静态对象的初始化机制

局部静态对象是定义在函数内部的静态变量。它们的生命周期从第一次进入作用域开始,直到程序结束,但其初始化时机是延迟到第一次使用时。这种机制被称为“延迟初始化”(Lazy Initialization)。

根据C++标准(C++11及之后版本),局部静态对象的初始化是线程安全的,并且只会在第一次进入其作用域时发生。具体来说:

  • 如果多个线程同时首次进入该局部静态对象的作用域,C++标准库会确保只有一个线程执行初始化操作,其他线程会等待初始化完成
  • 初始化完成后,后续的所有访问都会直接使用已经初始化的对象

2.为什么初始化顺序明确

局部静态对象的初始化顺序明确,是因为它们的初始化时机与代码的执行流程直接相关。具体来说:

  • 延迟初始化:局部静态对象只有在第一次进入其作用域时才会被初始化。这意味着它们的初始化顺序完全取决于代码的执行路径
  • 作用域限制:局部静态对象的作用域是函数内部,因此它们的初始化顺序不会受到其他编译单元中全局静态对象的影响。
  • 线程安全:在多线程环境中,C++标准确保局部静态对象的初始化是线程安全的。即使多个线程同时首次进入该作用域,初始化也只会发生一次

3.示例

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

void func() {
static int counter = 0; // 局部静态变量
counter++;
std::cout << "Counter: " << counter << std::endl;
}

int main() {
std::thread t1(func);
std::thread t2(func);
t1.join();
t2.join();
return 0;
}

输出:

1
2
Counter: 1
Counter: 2

解释:

  • counter 是一个局部静态变量,它在第一次调用 func() 时被初始化为 0
  • 在多线程环境中,即使 t1t2 同时调用 func()counter 的初始化也只会发生一次(线程安全)。
  • 第一次调用 func() 时,counter 被初始化为 0,然后递增为 1
  • 第二次调用 func() 时,counter 已经初始化,直接递增为 2

4. 为什么全局静态对象的初始化顺序不确定?

与局部静态对象不同,全局静态对象(Non-local Static Object)的初始化顺序是不确定的,原因在于:

  • 跨编译单元初始化:全局静态对象的初始化顺序取决于它们在不同编译单元中的定义顺序。
  • 编译器限制:编译器无法保证不同编译单元中全局静态对象的初始化顺序。
  • 依赖关系:如果一个全局静态对象依赖于另一个全局静态对象,而后者尚未初始化,就会导致未定义行为。

5. 总结

  • 局部静态对象的初始化顺序是明确的,因为它们的初始化时机与代码的执行流程直接相关,且初始化只发生在第一次进入作用域时。
  • 全局静态对象的初始化顺序是不确定的,因为它们的初始化顺序取决于编译单元的定义顺序,且编译器无法保证跨编译单元的初始化顺序。
  • 使用局部静态对象可以有效避免全局静态对象初始化顺序不确定的问题。

参考资料


EffectiveC++笔记
http://example.com/2025/02/22/Cpp/EffectiveCpp/
作者
ZhangHangming
发布于
2025年2月22日
许可协议