叠甲:仅为记录个人学习过程;内容很多是从别处看来的,在参考中列出;缺乏实践经验,有些地方纸上谈兵;难免有错误,敬请指出。

系列目录:【我看UE源码】(0)系列开篇 & 目录

Const的内存分配

(接上文)

对于上述通过指针、引用(也就是地址)来修改const变量的值,只对局部变量有效。

const int ga = 1;

int main()
{
    int* pga = const_cast<int*>(&ga);
	*pga = 10;		// 运行时报错
	std::cout << "a:" << const_cast<int*>(&a) << ' ' << a << std::endl;
    
	int& rga = const_cast<int&>(ga);
	rga = 20;		// 运行时报错
	std::cout << "a:" << const_cast<int*>(&a) << ' ' << a << std::endl;
}

如果进行上面代码中的操作,试图修改全局const变量的值,编译器不报错,但运行时在指针、引用赋值的地方报错:“引发了异常:写入访问权限冲突”。

这是由C++ const的内存分配方式导致的。

全局const变量的内存

const全局变量存放在.rodata段的Read Only Data也就是常量存储区(只读数据区),无法通过取地址方式修改,所以报错

关键字修饰变量的内存存储方式如下:

  • static:
    • static表示是静态变量,内存中只存在一份。无论是全局变量还是局部变量都存储在全局/静态区域,在编译期就为其分配内存,在程序结束时释放
    • 类的static成员变量必须在类声明的外部进行初始化。初始化时不赋值就默认为0
      • 全局数据区的变量都有默认初始值0,而动态数据区(堆区、栈区)变量的默认值不确定,一般是垃圾值
    • static成员变量属于类而不属于对象,在全局区分配内存,不占用对象的内存
    • static成员变量不随着对象创建而分配内存,不随着对象销毁而释放内存
  • extern:在当前源文件声明全局变量,告知编译器去别的地方(其他文件,或本文件其他行)找定义
  • const
    • const全局变量存储在只读数据段,编译期最初将其保存在符号表中,第一次使用时为其分配内存,在程序结束时释放
    • const局部变量存储在栈中,代码块结束时释放(见下一节)
    • const修饰的全局变量默认是内部链接,即只在当前源文件有效,不能直接用于其他源文件。如果必须在其他源文件中使用只读的全局变量,需要加extern将变量转换成外部链接
局部const变量的内存

局部const变量的内存分配有一些争议,可以看看这篇文章。据我尝试,跟这篇文章一样,VS2019对于基础类型赋值的const变量,release模式下不分配内存,debug模式下分配内存。

一般来说,总结为:

  • 用基础类型赋值的const变量,放入符号表中,不会分配内存
  • 对const变量取地址的时候(&a),给这个变量开辟空间
  • 当用变量给const初始化赋值时,直接给它开辟空间,不会放入符号表中
  • 自定义数据类型(结构体、类)的const对象,const数组等会分配内存空间

举例:

#include<iostream>

struct Student
{
	int age;
	char name[10];
};

int main()
{
	const int a = 10;	// 用基础类型值初始化,a不分配内存,放到符号表里
	int* pa = const_cast<int*>(&a);	// 对a取地址,分配内存
	*pa = 20;
	std::cout << *pa << ' ' << a << std::endl;	// 由于编译器优化(见上篇),*pa=20,a=10

	int b = 30;
	const int c = b;	// 用变量初始化,直接给c分配内存空间,不放入符号表
	int* pc = const_cast<int*>(&c);
	*pc = 40;
	std::cout << *pc << ' ' << c << std::endl;	// 40 40

	const Student stu = { 20, "tom" };	// 自定义类型的const对象,分配内存
	Student* pstu = const_cast<Student*>(&stu);
	pstu->age = 21;
	std::cout << pstu->age << ' ' << stu.age << std::endl;	// 21 21

	return 0;
}
总结
const int a = 3;	// 全局const变量,符号表,使用时分配在常量区(只读数据段)
int b = 2;			// 全局变量,分配在静态/全局区
static int c = 1;	// 静态全局变量,分配在静态/全局区

static int d;		// 未初始化的静态全局变量,有初始值0
int e;				// 未初始化的全局变量,有初始值0

int main(){
    static int f = 4;	// 初始化的静态局部变量,分配在静态/全局区
    static int g;		// 未初始化的静态局部变量,分配在静态/全局区,有初始值0
    int h = 5;			// 初始化的局部变量,分配在栈区
    int i;				// 未初始化局部变量,分配在栈区,初始值是无效值
    
    const int j = 6;	// 局部const变量,放入符号表,取地址时分配在栈区
    const int k = h;	// 局部const变量,直接分配在栈区
}
const和define

const和define都可以用来定义常量,它们的区别是:

  • const常量是编译期的概念
    • const常量有数据类型,宏没有。编译器可以对const进行类型安全检查
  • define宏定义作用在预处理期
    • 程序在预处理阶段,把define定义的内容进行字符串替换。需要注意运算符优先级之类的边缘效应问题
    • 编译期不会进行数据类型检查
    • 程序运行时,系统不为define定义的常量分配内存

在C++里,如果想定义一个常量,最好用const而不是define。关于尽量使用const,可以看《Effective C++》的条款三。

const,引用传参,常量引用

在前文说过,可以把const看作是一种注释,对于局部const变量,不一定分配了内存,即使分配了或许也不在常量区。它只是让编译器注意不要直接修改其值,或者让程序员不要写直接修改值的代码。

const还有一大作用,就是在函数传参时,传递常量的引用类型。这是C++机制上的一个问题,可以看看这个答案下的回答

引用传参

在函数传参时,如果传值,会新建值的对象并且进行拷贝,在函数中对新对象进行操作,不会影响原来的对象;而引用相当于给一块内存起一个别名,如果传递引用,就可以直接对实参进行操作。

指针和引用基本上是一样的,区别在于:

  • 指针会分配内存,用来存储指向的内存地址。可以找到自身开辟的内存空间,可以通过*运算符解引用找到指向的内存单元。
  • 对于引用,虽然在C++底层处理时和指针处理方式相同,不过在用到引用变量的地方,会自动对其进行解引用,这一步骤系统默认进行,所以我们找不到引用自身开辟的内存单元,从这里看,引用好像没有开辟自身内存,只是给引用对象起了一个别名。

关于指针、引用的区别和用它们传参,可以看这篇文章。其中提到了使用引用的几个条件:

  • 引用必须在声明时就进行初始化
  • 引用初始化的变量一定要能取地址
  • 引用关系是不可变的

以及引用和指针的区别,不是本篇重点,但八股会问。稍微记录一下

  • 从内存分配上看,指针是对象实体,程序为其分配内存空间。而引用从表面上看,只是变量的别名,程序不需要为其分配空间(实际上引用跟指针是一样存的,但在使用时会自动解引用,用起来像别名)
  • 引用使用时无需解引用,系统会为其自动进行解引用,而指针使用时需要手动解引用(*运算符)
  • 指向关系上,引用只能在定义时被初始化一次,之后则不可变。指针则可以改变
  • 引用不能为空(必须初始化),指针可以为空。
  • “sizeof 引用”得到的是所指向的变量(对象)的大小,而“sizeof 指针”得到的是指针本身(所指向的变量或对象的地址)的大小(为了完整寻址,32位4字节,64位8字节)
  • 指针和引用的自增(++)运算意义不一样。指针自增是让其指向下一段内存单元,而引用自增是使其对应的变量自增
常量引用

对于第二条,类似int& a = 10;是不行的。也就是引用只能作用于一个变量,不能赋值到字面常量,比如1, 2, 3等字面量,"Hello World"等常量字符串。它们是不可以被修改的,不能使用非const类型的引用传递。

C++引入了常量引用的概念来处理这种情况。常量引用是“对const的引用”的简称,把它指向的对象看作是常量(不一定真是),不可以通过该引用修改它指向的对象的值。(严格来说不存在常量引用,因为引用不是一个对象,无法让引用本身恒定不变;由第三条,C++不允许随意改变引用绑定的对象,从这种意义上理解,所有引用本身都是常量。)

在使用上:

  • 指向常量对象时,必须用常量引用

    const int a = 10;
    const int& ra = a;	// 正确,指向常量对象
    ra = 20;	// 错误
    int& rb = a;	// 错误
  • 常量引用可以指向非常量对象,但不能修改它的值

    int a = 10;
    int& ra = a;
    const int& rb = a;	// 正确
    ra = 20;	// 正确
    rb = 20;	// 错误
  • 常量引用可以指向字面值、常量字符串、一般表达式等非左值、不能赋值的对象

    int i = 10;
    const int& ra = i;	// 正确
    const int& rb = 10;	// 正确,普通引用不行
    const int& rc = ra * 2;	// 正确,普通引用不行
  • 实际上,只要被引用的类型能够转换为常量引用的类型即可

    double dval = 3.14;
    const int& pi = dval;	// 可用

在上面一句引用语句中,编译器实际上相当于执行了以下两条语句:

const int temp = dval;	// 生成一个临时整形常量
const int& pi = temp;	// 给常量引用初始化

在这种情况下,常量引用实际上是绑定了一个临时量(temporary)对象。也就是说,允许常量引用指向一个临时量对象。

假设pi不是常量引用,就允许对它赋值,改变pi所引用的对象的值。但此时绑定的对象是一个临时量而非 dval,不能改变dval的值。在const int& pi = dval中,我们既然想让pi引用dval,就肯定想通过pi改变 dval的值。如此看来,基本上在使用时不会想着把引用绑定到临时量上,C++ 语言也就把这种行为归为非法。

也就是说,不允许一个普通引用与字面值或者某个表达式的计算结果,或类型不匹配的对象绑定在一起,其实就是不允许一个普通引用指向一个临时变量,只允许将常量引用指向临时对象。

常量引用传参

在函数参数中,使用常量引用很重要。因为函数有可能接收临时对象,并且同时要禁止对引用的对象进行修改。

int test(){ return 1; }

void func1(int& x){ std::cout << x << std::endl; }

void func2(const int& x){ std::cout << x << std::endl; }

int main()
{
	int m = 1;
    
    func1(m);	// 正确
    func1(1);	// 错误
    func1(test());	// 错误
    
    func2(m);	// 正确
    func2(1);	// 正确
    func2(test());	// 正确
}

日常使用是在写模板的时候,不知道会传入什么类型的模板参数,就必须要让模板适配所有的情况。一个 const T & 就是一种能够承载所有输入类型的办法。但一个 T& 就做不到接收字面量作为参数,字面量无法直接转化为非常量引用。当我们需要constT&或者T&作为返回值的时候,问题会更多。

本质上也就是const T& 能够承载右值(字面量),而单纯的T&只能承载一个变量的引用,不能存放一个字面量。接下来应该还有一篇:C++ 11的右值引用。

参考