类常量

​ 在这个栏目中,我们将不会对于一个知识点进行系统的学习,将只会对于各个我在过程中遇到的一些问题进行一些分析。

零散知识点

const属性

​ 首先来看到一句话

当我们创建类的一个const对象时,直到构造函数完成初始化过程,对象才能真正取得其“常量”属性。因此,构造函数在const对象的构造过程中可以向其写值。

​ 这句话其实涉及到了C++中对于const属性的一些分析,我们来看一下。

​ 在C++中,const声明的值是一种常量,我们通常会使用这种来做为程序中不变的量,但是我们是否考虑过这种常量是怎么来的呢。其实在CSAPP中,我们学到了一点,就是基本来说这些个常量,或者说局部静态变量在编译链接后生成的二进制文件中都存在着一块各自的内存区域。

​ 回顾一下吧,GPT真好用,我都省的去敲了。


1. .bss

  • 全称:Block Started by Symbol。
  • 存储内容:
    • 未初始化的全局变量
    • 未初始化的静态变量(包括 static 修饰的局部变量)。
    • 初始化为零的全局或静态变量

特点:

  • 节省空间:未初始化变量在 .bss 段中只记录变量大小和布局,实际运行时会分配内存,并自动初始化为零(通常由操作系统负责)。(这也是未初始化的全局变量默认为0的原因)
  • 不直接存储在程序的可执行文件中,而是在加载时动态分配。

分析:

​ 我又来开始发散思维了,来分析下这里所谓的节省空间的含义。

​ 首先,对于这些个.bss段中的数据,可执行文件中值存在着这些数据的部分属性。就比如,标识这个数据存在,这个数据的变量名,这个数据的类型等。但是,在这个文件之中,并不存在对于这个数据实际的值的表示。这也是符合出现在这个文本段中的数据的性质的。

​ 这里面的这系列数据只会在可执行文件被操作系统加载到内存中去再进行一次初始化,而且这系列的初始化都会为0,这里就不要问为什么了,毕竟初始化不为0的就不再这个文本段中而在我们的.data段中了。

示例:

1
2
3
int x;              // 全局未初始化变量 -> .bss
static int y; // 静态未初始化变量 -> .bss
static int z = 0; // 静态初始化为零 -> .bss

2. .data

  • 全称:Data Segment。
  • 存储内容:
    • 已初始化的全局变量
    • 已初始化的静态变量(包括局部静态变量)。

特点:

  • .data 段中的变量在程序运行前已经完成初始化,初始化的值直接存储在可执行文件中。
  • 这些变量的内容在运行时可以修改。

示例:

1
2
int a = 10;             // 全局已初始化变量 -> .data
static int b = 20; // 静态已初始化变量 -> .data

3. .rodata

  • 全称:Read-Only Data Segment。
  • 存储内容:
    • 只读的常量(如字符串字面量)。
    • const 修饰的全局或静态变量
    • 某些编译器优化后的结果(如 constexpr)。

特点:

  • .rodata 是只读的,运行时无法修改。
  • 主要用于存储不会改变的值,编译器可能利用这段内容进行内存保护。
  • 常量通常会存储在 .rodata 中,而不是 .data

示例:

1
2
3
const int c = 30;           // 全局 const 变量 -> .rodata
static const int d = 40; // 静态 const 变量 -> .rodata
char* str = "Hello"; // 字符串字面量 -> .rodata

4. 总结对比

段名 存储内容 是否初始化 运行时可修改
.bss 未初始化的全局变量,静态变量,初始化为零的变量 否(加载时清零)
.data 已初始化的全局变量,已初始化的静态变量
.rodata 只读常量、const 全局或静态变量,字符串字面量

5. 运行时的内存布局

程序在运行时的内存结构主要分为以下区域:

  • 代码段(.text):存储程序的可执行代码。
  • 数据段:
    • .bss:未初始化的全局或静态变量。
    • .data:已初始化的全局或静态变量。
    • .rodata:只读常量和字面量。
  • 堆(Heap):动态分配的内存(如 mallocnew)。
  • 栈(Stack):局部变量和函数调用栈。

6. 可视化示例

假设以下代码:

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

int global_var; // -> .bss
int global_init_var = 10; // -> .data
const int const_var = 20; // -> .rodata
static int static_var; // -> .bss
static int static_init_var = 30; // -> .data

int main() {
int local_var = 40; // -> 栈
static int static_local_var = 50; // -> .data
const char* str = "Hello, World!"; // -> .rodata
return 0;
}

​ 现在看完了上面几个,其实,并没有对于我们理解上面那句话有什么用处,举出上面那个例子主要是要把我接下来的对于const对象性质和.rodata段的性质做一些区分。

const 对象的构造

  • 当我们声明一个 const 对象 时,编译器要求这个对象的值在构造之后不能被修改。这是对象的常量性

  • 然而,const 对象的构造过程

    并不等同于 .rodata段的只读数据,它实际上是对象的生命周期的一部分。

    在对象的构造过程中,const 对象的成员变量是可以被初始化的,即使它们之后不能被修改。

    • 在构造函数中,你仍然可以对 const 对象的成员进行赋值,但一旦构造函数完成,成员就会被标记为“常量”,不能再修改。

当我们创建类的一个const对象时,直到构造函数完成初始化过程,对象才能真正取得其“常量”属性。因此,构造函数在const对象的构造过程中可以向其写值。

​ 我们需要先来分析下我们现在需要分析的是谁。首先,我们分析的必然不是那些在.rodata段中的数据。毕竟这些数据已经被严格保护起来了,没有进行研究的价值。

​ 我们需要重点看的其实是对于那些存在于栈帧上的const常量,这些常量的生命周期与栈帧绑定,这些就不赘述了。主要是看到,我们在栈帧中对于这些变量到底是怎么构造的。

栈帧常量

​ 对于栈帧的常量,由于它是从0开始构造的,所以它必须经过一个初始化的过程,而这个就是起初困惑我的地方。毕竟你已经是一个常量了,但是你为什么还需要去进行初始化呢。但是,这是我对常量先入为主造成的一种后果。我忽略了语言设计的细节。如果不去进行初始化,我还怎么去取得一个常量呢。所以,这里就存在一个可能的异或。

​ 所以,我们需要知道const常量构建的流程,在一个const常量构建中,这个数据一开始并不具有const这个属性,这个属性可以看做是在这块数据处理的最后阶段才进行处理的。毕竟学了CSAPP后我们应该简单的知道这些所谓的权限管理到底是一个什么个情况,实际上其实就是一些标志位的修改,就比如这里,通过将可修改的标志位置0等来进行变量的锁定。

​ 但是,在这个锁定之前,我们这块数据还是可以修改的,这就意味着我们完全可以在这段时期对于数据进行处理。而这段时间就是我们const常量的实际初始化时间,在这个时间中,所谓的const常量其实可以视为只是一个简单的变量,所以我们可以对于这块数据进行任意的修改知道我们的权限标志位被锁。

通过对于const常量的较为深入的理解,我们理解了为什么在这种初始化过程中可以向其中去写值。

接下来想一些比较零碎的知识点,主要是关于类的构造函数那一块的

我们主要来看那些默认构造函数的一些性质。

合成默认构造函数的限制


1. 默认构造函数的生成规则

  • 编译器只有在类未显式声明任何构造函数时,才会为类生成一个合成的默认构造函数
  • 如果类中声明了任何构造函数(比如带参数的构造函数),编译器不会再生成默认构造函数。
  • 规则的依据:如果一个类需要通过特定构造函数控制初始化,则可能在所有情况下都需要这种控制。由用户显式定义默认构造函数可以确保这种一致性。

例子:

1
2
3
4
5
6
class A {
int x;
A(int value) : x(value) {} // 自定义构造函数
// 编译器不会合成默认构造函数
};
A obj; // 错误,缺少默认构造函数

2. 合成默认构造函数可能执行错误操作

对于含有内置类型或复合类型的类,如果这些成员未显式初始化,编译器生成的合成默认构造函数不会为它们赋值,可能导致未定义行为。

为什么可能出错?

  • 内置类型(如 intfloat)和复合类型(如数组、指针)在默认初始化时,其值未定义。

  • 如果用户没有手动初始化这些成员变量,程序可能使用未定义的值,导致潜在的逻辑错误或运行时异常。

    这里来对于这种为定义的行为存在的分析

    ​ 我们一直听所谓的未初始化导致随机值,但是为什么呢?这个的原因其实如果学过CSAPP应该很好理解。毕竟这些所谓的声明,其实就是一段开辟内存的代码,这段代码所开辟的内存将交给我们的操作系统进行维护。但是,这里只是开辟了一段内存,进行了一段指针的偏移,这块内存上的值呢?我们并没有对其进行初始化,由于我们所谓的销毁变量其实就是操作系统取消对其的维护,一些指针进行额外的移动等。在这其中并不存在对于内存区域的重新初始化。所以,这块内存上的东西可能是任何时候遗留下来的,但是系统并不会去检查它到底是什么,它只是去读取并且解析,所以这就导致了所谓的未定义的情况。

解决方法:

  • 提供类内初始化(C++11 引入)或自定义默认构造函数,确保所有成员变量有明确的初始值。

例子:

1
2
3
4
5
6
7
class B {
int num; // 内置类型,未初始化
int *ptr; // 指针类型,未初始化
// 合成的默认构造函数不会初始化 num 和 ptr
};

B obj; // 使用 num 和 ptr 时会导致未定义行为

改进:

1
2
3
4
5
class B {
int num = 0; // 类内初始化
int *ptr = nullptr;
// 合成的默认构造函数现在是安全的
};

或者:

1
2
3
4
5
6
class B {
int num;
int *ptr;
public:
B() : num(0), ptr(nullptr) {} // 自定义默认构造函数
};

3. 无法为某些类合成默认构造函数

如果类包含的成员类型本身没有默认构造函数,编译器将无法为该类生成合成的默认构造函数。

为什么无法生成?

  • 成员对象的初始化必须调用其构造函数。
  • 如果成员类型没有默认构造函数,编译器无法完成其初始化。

例子:

1
2
3
4
5
6
7
8
9
10
class C {
int x;
C(int val) : x(val) {} // 没有默认构造函数
};

class D {
C c; // 成员 c 的类型是 C
// 编译器无法合成默认构造函数,因为 C 类型没有默认构造函数
};
D obj; // 错误:D 的默认构造函数无法生成

解决方法:

  • 为成员类型提供默认构造函数,或者在包含该成员的类中显式定义默认构造函数并指定初始化方式。
1
2
3
4
5
6
7
8
9
10
class C {
int x;
public:
C(int val = 0) : x(val) {} // 添加默认构造函数
};

class D {
C c; // 成员 c 的类型现在有默认构造函数
};
D obj; // 正常工作

4. 总结

为什么某些类不能依赖合成的默认构造函数?

  1. 用户显式声明构造函数后,编译器不会生成默认构造函数
  2. 合成默认构造函数对内置或复合类型成员不安全,可能导致未定义行为。
  3. 当成员类型缺少默认构造函数时,编译器无法生成默认构造函数

最佳实践:

  • 如果类中含有非简单类型成员需要自定义初始化逻辑,显式定义默认构造函数。
  • 利用 类内初始化 提高代码安全性和可读性。
-------------本文结束 感谢阅读-------------