C++基础知识点
@(技术笔记)
文章目录
- C++基础知识点
-
- 一、 C++基础
-
- 1 const关键字
-
- 1.1 概念
- 1.2 修饰位置
- 1.3 常量存储区
- 1.3 常量折叠
- 2 static关键字
-
- 3 extern关键字
-
- 4 inline内联函数
-
- 4.1 函数调用过程
- 4.2 内联函数定义
- 4.3 声明内联函数
- 4.4 内联函数与宏定义区别
- 5 指针
-
- 5.1 指针跟引用的区别
- 5.2 指针参数传递和引用参数传递
- 5.2 this指针
- 5.3 野指针指针与悬空指针
- 5.4 函数指针
- 5.5 类型转换
- 6 C++三大特性
-
- 6.1 多态
- 6.2 虚函数表
- 6.3 虚表指针
- 6.4 抽象类与接口类
-
- 6.5 动态绑定
- 6.6 虚析构函数
- 6.7 基类的非虚构造函数
- 6.8 类成员函数的重写、重载、隐藏
- 6.8 RTTI
- 7 类相关
-
- 7.1 构造函数
-
- 7.1.1 默认构造函数
- 7.1.2 普通构造函数
- 7.1.3 拷贝构造函数
- 7.1.4 转换构造函数
- 7.1.5 移动构造函数
- 二、C和C++内存分配问题
-
- 1 C++编程中的内存基本构造
-
- 1.1 为什么分成这么多个区域?
- 1.2 C++的自由存储区是什么
- 2 内存分配关键字malloc/free、new/delete区别
- 3 内存泄露
- 4 内存对齐
- 三、C++代码的编译过程
-
一、 C++基础
1 const关键字
1.1 概念
在 C++ 中是用来修饰内置类型变量、自定义对象、成员函数、返回值、函数参数。
C++ const 允许指定一个语义约束,编译器会强制实施这个约束,允许程序员告诉编译器某值是保持不变的。如果在编程中确实有某个值保持不变,就应该明确使用const,这样可以获得编译器的帮助。
1.2 修饰位置
-
修饰变量
const int a = 7;
-
修饰指针变量
const 修饰指针有以下三种情况。
- const 修饰指针指向的内容,则内容为不可变量。两种写法一样:一般称作常量指针(指向常量的指针)
const int *p = 8;
int const *p = 8;
- const 修饰指针,则指针为不可变量。称作指针常量(指针是个常量)
int a = 8;
int* const p = &a;
常量指针跟指针常量的区别就是在 “*” 的左边还是右边,*p可以看做地址p指向的内容物,而p是一个地址。
- const 修饰指针和指针指向的内容,则指针和指针指向的内容都为不可变量。
const int* const p = &a;
-
修饰成员函数
-
函数头部的结尾加上 const 表示常成员函数,这种函数只能读取成员变量的值,而不能修改成员变量的值,不能和static关键字同时修饰成员函数,但是可以修改mutable修饰的成员变量。(实际上相当于传了一个const * this指针,不能改变this指针指向的内容)
char * getname() const;
-
修饰函数返回值
- 函数开头的 const 用来修饰函数的返回值,表示返回值是 const 类型,也就是不能被修改
const char * getname();
- 修饰成员变量
- const修饰的成员变量相当于该变量是一个常量,所以只能在初始化列表上初始化。对于该对象是不可变的,但是不同对象的const值在初始化的时候可以初始化成不同值。
- 修饰函数参数
一般使用const引用传参,减少传参的副本拷贝次数,提高性能void g(const Obj& a)
{
}
- 修饰类对象
常量对象只能调用常量函数,就相当于this指针指向的内容直接是const
1.3 常量存储区
局部常量存在栈区
全局常量编译器不分配内存,放在符号表中提高访问效率
字面值常量例如字符串存在常量区
1.3 常量折叠
#include
using namespace std;
int main(void)
{
const int a = 7;
int *p = (int*)&a;
*p = 8;
std::cout << a << std::endl;
}
看到a的内存确实被改成了8
从调试窗口看到 a 的值被改变为 8,但是输出的结果仍然是 7。
从结果中我们可以看到,编译器然后认为 a 的值为一开始定义的 7,所以对 const a 的操作就会产生上面的情况。
具体的原因是编辑器的常量折叠
- 常量折叠表面上的效果和宏替换是一样的,只是,“效果上是一样的”,而两者真正的区别在于,宏是字符常量,在预编译阶段的宏替换完成后,该宏名字会消失,所有对宏如PI的引用已经全部被替换为它所对应的值,编译器当然没有必要再维护这个符号。
- 常量折叠发生的情况是,对常量的引用全部替换为该常量的值(如果没有对该常量进行引用(取地址),就不会分配内存空间),但是,常量名并不会消失,编译器会把他放入到符号表中,同时,会为该变量分配空间,栈空间或者全局空间。既然放到了符号表中,就意味着可以找到这个变量的地址。
符号表不是一张表,是一系列表的统称,这里的const常量,会把这个常量的名字、类型、内存地址、值都放到常量表中。符号表还有一个变量表,这个表放变量的名字、类型、内存地址,但是没有放变量的值。
更多详细解释在《Thinking in C++》中
2 static关键字
2.1 定义与功能
static 是 C/C++ 中很常用的修饰符,它被用来控制变量的存储方式和可见性。
主要功能有:
- 当同时编译多个文件时,所有未加static前缀的全局变量和函数都具有全局可见性。全局变量或者全局函数加了static关键字后,只有当前编译文件可见,会对其它源文件隐藏。
- 延长局部变量变量的生命周期
- 默认初始化0
- 类成员变量加static,表示变量属于类不属于该类的对象,
- 类的静态成员函数一样是属于整个类,所以它没有this指针,这就导致 了它仅能访问类的静态数据和静态成员函数
2.2 修饰位置
- 局部变量
- 第一次被调用时进行初始化,之后不再进行初始化,如果没有显式初始化,那会自动初始化0。
- 生命周期延长至程序结束,但是作用域还是跟普通的局部变量一样。
- 全局变量
- static 修饰的变量存放在全局数据区的静态变量区,包括全局静态变量和局部静态变量,都在全局数据区分配内存。
- 初始化的时候自动初始化为 0。静态全局变量不能被其它文件所用;
- 其它文件中可以定义相同名字的变量,不会发生冲突。
- 全局函数
- static 修饰一个函数,则这个函数的只能在本文件中调用,不能被其他文件调用。用extern关键字也不可以调用。
- 类成员变量
- 静态数据成员可以实现多个对象之间的数据共享,它是类的所有对象的共享成员,它在内存中只占一份空间,如果改变它的值,则各对象中这个数据成员的值都被改变。
- 静态数据成员是在程序开始运行时被分配空间,到程序结束之后才释放,只要类中指定了静态数据成员,即使不定义对象,也会为静态数据成员分配空间。
- 静态数据成员可以被初始化,但是只能在类体外进行初始化,若未对静态数据成员赋初值,则编译器会自动为其初始化为 0。
- 静态数据成员既可以通过对象名引用,也可以通过类名引用。
静态成员变量使用前必须先初始化(如 int MyClass::m_nNumber = 0;),否则会在 linker 时出错。
- 类成员函数
- 静态成员函数和静态数据成员一样,他们都属于类的静态成员,而不是对象成员。
- 非静态成员函数有 this 指针,而静态成员函数没有 this 指针。类静态成员函数调用非静态成员函数或者非静态成员变量的时候可以通过传递this指针调用。
- 静态成员函数主要用来访问静态数据成员而不能直接访问非静态成员。(需要传递this指针才能调用)
3 extern关键字
extern可以置于变量或者函数前,以标示变量或者函数的定义在别的文件中,提示编译器遇到此变量和函数时在其他模块中寻找其定义。此外extern也可用来进行链接指定。
3.1 作用
extern有两个作用:
- 当它与"C"一起连用时,如: extern “C” void fun(int a, int b);则告诉编译器在编译fun这个函数名时按着C的规则去翻译而不是C++的,C++的规则在翻译这个函数名时会把fun这个名字进行变化而不是本名,原因是在于C++支持函数的重载。
在C++环境下使用C函数的时候,常常会出现编译器无法找到obj模块中的C函数定义,从而导致链接失败的情况,应该如何解决这种情况呢?
因为,C++语言在编译的时候为了解决函数的多态问题,会将函数名和参数联合起来生成一个中间的函数名称,而C语言则不会,因此会造成链接时找不到对应函数的情况,此时C函数就需要用extern “C”进行链接指定,这告诉编译器,请保持我的名称,不要给我生成用于链接的中间函数名。
这个extern C可以用于dll的导出函数中。
- 当extern不与"C"在一起修饰变量或函数时,如在头文件中: extern int g_Int; 它的作用就是声明函数或全局变量的作用范围的关键字,其声明的函数和变量可以在本模块和其他模块中使用,记住它是一个声明不是定义!
也就是说B模块(编译单元)引用模块(编译单元)A中定义的全局变量或函数时,它只要包含A模块的头文件即可,在编译阶段,模块B虽然找不到该函数或变量,但它不会报错,它会在连接时从模块A生成的目标代码中找到此函数。
3.2 使用场景
extern "C" int Add(int left, int right);
extern "C"
{
test1();
test2();
}
- 可以修饰头文件,相当于头文件的所有内容都以 C 编译规则进行编译。比如我们自己写的头文件希望用C语言风格来编译,可以将整个头文件用extern "C"来修饰。
extern "C"
{
#include "test.h"
}
4 inline内联函数
4.1 函数调用过程
1.程序先执行函数调用之前的语句
2.流程的控制转移到被调用函数的入口处,同时进行参数传递
3.执行被调用函数中函数体的语句
4流程返回到函数调用的下一条指令处,将函数返回值带回
5接着执行主函数中未执行的语句。
这样的调用过程,就要求在转去被调用函数之前,要记下当时执行的指令的地址,还要保护现场(记下当时相关的信息),以便在函数调用之后,流程返回到先前记下的地址处,并且根据记下的信息恢复现场,然后继续执行。这些都要花费时间。
4.2 内联函数定义
c++提供一种提高效率的方法,在编译时将所调用函数的代码直接嵌入到主调函数中,而不是将流程转出去,这种嵌入到主函数的代码称为内嵌函数,或者叫内置函数,或者叫内联函数。
4.3 声明内联函数
#include
using namespace std;
inline int max(int, int,int c);
int main()
{
int i = 10, j = 20, k = 30;
cout << max(i, j, k) << endl;
return 0;
}
inline int max(int a,int b,int c)
{
if (b > a)
a = b;
if (c > a)
a = c;
return a;
}
可以在声明和定义函数时同时写inline,也可以只在函数声明时加inline,而定义时不加inline。主要在调用该函数之前把inline的信息告诉编译系统,编译系统就会在处理函数调用时按内联函数处理
对函数做inline声明,只是程序设计者对编译系统提出的一个建议,也就是说它是建议性的,不是指令性的。并非一经指定为inline,编译系统就必须这样做。编译系统会根据具体的情况决定是否这样做。内联函数inline一般要是定义在头文件中,这与通常的函数定义是不一样的。
4.4 内联函数与宏定义区别
-
内联函数在编译时展开,宏在预编译时展开;
-
内联函数直接嵌入到目标代码中,宏是简单的做文本替换;
-
内联函数有类型检测、语法判断等功能,宏没有;
-
inline函数是函数,宏不是;
-
宏定义时要注意书写(参数要括起来)否则容易出现歧义,内联函数不会产生歧义;
-
inline相比宏定义有哪些优越性?
1 inline函数代码是被放到符号表中,使用时像宏一样展开,没有调用的开销,效率很高;
2 inline函数是真正的函数,所以要进行一系列的数据类型检查;
3 inline函数作为类的成员函数,可以使用类的保护成员及私有成员;
5 指针
指针用于共享内存,常见的数据结构,比如链表,树,正是因为指针的存在才得以实现。
5.1 指针跟引用的区别
指针跟引用都是地址的概念,其中指针指向一块内存,它的本身是所指内存的地址;而引用是某块内存的别名,
- 指针可以为空,而引用不能为空
- 指针可以不初始化、而引用必须初始化
- 指针可以改变指向,一个引用的初始化后的对象不能再对其他对象进行引用
- 指针是个数据类型大小为四字节(在32位程序上),而引用只是取别名大小为其依附数据对象的大小
- 可以定义指针的指针(有二级指针),但不能定义引用的引用(只有一级引用)
5.2 指针参数传递和引用参数传递
指针参数传递的本质是值传递,在被调函数中生成一个副本,会开辟新的空间存放实参值。改变参数指针的指向是改变的副本指针,如果离开作用域以后会被析构掉,所以不会改变实参的指针指向,但是是可以改变指针地址内的内容。
如果想通过指针参数传递改变主调函数中的相关变量(地址)那就得需要使用指向指针的指针或者指针的引用。
不能返回临时变量的引用。
5.2 this指针
this作用域是在类内部,当在类的非静态成员函数中访问类的非静态成员的时候,编译器会自动将对象本身的地址作为一个隐含参数传递给函数。
也就是说,即使你没有写上this指针,编译器在编译的时候也是加上this的,它作为非静态成员函数的隐含形参,对各成员的访问均通过this进行。例如,调用date.SetMonth(9) <===> SetMonth(&date, 9),this帮助完成了这一转换
在成员函数内部,我们可以直接使用调用该函数的对象的成员,而无需通过成员访问运算符来做到这一点,因为this所指的正是这个对象。
任何对类成员的直接访问都被看成this的隐式使用。
this的目的总是指向这个对象,所以this是一个常量指针,我们不允许改变this中保存的地址
- 为什么静态成员函数不能使用this指针?
静态成员函数是属于类本身的,而不是属于某一个类实例化出的对象
- 类成员函数加const之后为什么不能改变非const成员变量?
对象调⽤成员函数时,在形参列表的最前⾯隐式的加⼀个形参 this。this 指针是默认指向调⽤函数的当前对象的,this 是⼀个常量指针 test * const,因为不可以修改 this 指针代表的地址。但当成员函数的参数列表(即⼩括号)后加了 const 关键字(void print() const;),此成员函数为常量成员函数,此时它的隐式this形参为 const test * const,即不可以通过 this 指针来改变指向对象的值。(就是把形参的this指针变成常量指针+指针常量的类型。)
5.3 野指针指针与悬空指针
- 野指针:没有被初始化过的指针。⽤ gcc -Wall 编译, 会出现 used uninitialized 警告。
- 悬空指针:是指针最初指向的内存已经被释放了的⼀种指针。
⽆论是野指针还是悬空指针,都是指向⽆效(不安全不可控)内存区域的指针。 访问"不安全可控"(invalid)的内存区域将导致"Undefined Behavior"。
- 如何避免使⽤野指针?
在平时的编码中,养成在定义指针后且在使⽤之前完成初始化的习惯或者使⽤智能指针。
5.4 函数指针
- 定义:函数指针是指向函数的指针变量。函数指针本身⾸先是⼀个指针变量,该指针变量指向⼀个具体的函
数。这正如⽤指针变量可指向整型变量、字符型、数组⼀样,这⾥是指向函数。
在编译时,每⼀个函数都有⼀个⼊⼝地址,该⼊⼝地址就是函数指针所指向的地址。有了指向函数的指针变量后,
可⽤该指针变量调⽤函数,就如同⽤指针变量可引⽤其他类型变量⼀样。
- ⽤途:调⽤函数和做函数的参数,⽐如回调函数
char * fun(char * p) {…}
char * (*pf)(char * p);
pf = fun;
pf(p);
5.5 类型转换
-
static_cast:
- ⽤于⾮多态类型的转换
- 不执⾏运⾏时类型检查(转换安全性不如 dynamic_cast)
- 通常⽤于转换数值数据类型(如 float -> int)
- 可以在整个类层次结构中移动指针,⼦类转化为⽗类安全(向上转换),⽗类转化为⼦类不安全(因为⼦类可
能有不在⽗类的字段或⽅法)
- 如果转换成功了,也可能是一个莫名奇妙的指针
-
dynamic_cast:
- ⽤于多态类型的转换
- 执⾏⾏运⾏时类型检查
- 只适⽤于指针或引⽤
- 对不明确的指针的转换将失败(返回 nullptr),但不引发异常
- 可以在整个类层次结构中移动指针,包括向上转换、向下转换
-
const_cast:
- ⽤于删除 const、volatile 和 __unaligned 特性(如将 const int 类型转换为 int 类型 )
-
reinterpret_cast:
- 允许将任何指针转换为任何其他指针类型(如 char* 到 int* 或 One_class* 到 Unrelated_class* 之类的转
换,但其本身并不安全)
- 也允许将任何整数类型转换为任何指针类型以及反向转换。
- reinterpret_cast 运算符不能丢掉 const、volatile 或 __unaligned 特性。
- reinterpret_cast 的⼀个实际⽤途是在哈希函数中,即,通过让两个不同的值⼏乎不以相同的索引结尾的⽅式
将值映射到索引
- 滥⽤ reinterpret_cast 运算符可能很容易带来⻛险。除⾮所需转换本身是低级别的,否则应使⽤其他强制转
换运算符之⼀。
6 C++三大特性
- 继承:将客观事物封装成抽象的类,而类可以把自己的数据和方法暴露给可信的类或者对象,对不可信的类或对象则进行信息隐藏。
- 封装:可以使用现有类的所有功能,并且无需重新编写原来的类即可对功能进行拓展;
- 多态:一个类实例的相同方法在不同情形下有不同的表现形式,使不同内部结构的对象可以共享相同的外部接口。一个接口,多个实现。
6.1 多态
-
静态多态:也称为编译期间的多态,编译器在编译期间完成的,编译器根据函数实参的类型(可能会进行隐式类型转换),可推断出要调用那个函数,如果有对应的函数就调用该函数,否则出现编译错误。
-
静态多态有两种实现方式:
函数重载:包括普通函数的重载和成员函数的重载
函数模板:函数模板是通用的函数描述,也就是说,使用泛型来定义函数,其中泛型可用具体的类型(int 、double等)替换。通过将类型作为参数,传递给模板,可使编译器生成该类型的函数。
-
动态多态:运行时的多态,在程序执行期间(非编译期)判断所引用对象的实际类型,根据其实际类型调用相应的方法。
-
动态多态的作⽤:
- 隐藏实现细节,使代码模块化,提⾼代码的可复⽤性;
- 接⼝重⽤,使派⽣类的功能可以被基类的指针/引⽤所调⽤,即向后兼容,提⾼代码的可扩充性和可维护性。
- 动态多态的必要条件:
- 需要有继承;
- 需要有虚函数覆盖;
- 需要有基类指针/引⽤指向⼦类对象
6.2 虚函数表
任何有虚函数的类及其派生类的对象都包含这多出来的 4 个字节,这 4 个字节就是实现多态的关键——它位于对象存储空间的最前端,其中存放的是虚函数表的地址。
每一个有虚函数的类(或有虚函数的类的派生类)都有一个虚函数表(所以虚表是属于类的),该类的任何对象中都放着该虚函数表的指针(可以认为这是由编译器自动添加到构造函数中的指令完成的)。
虚函数表是编译器生成的,程序运行时被载入内存。一个类的虚函数表中列出了该类的全部虚函数地址。
6.3 虚表指针
为了指定对象的虚表,对象内部包含一个虚表的指针,来指向自己所使用的虚表。为了让每个包含虚表的类的对象都拥有一个虚表指针,编译器在类中添加了一个指针,__vptr,用来指向虚表。这样,当类的对象在创建时便拥有了这个指针,且这个指针的值会自动被设置为指向类的虚表*。
虚函数指针的赋值发生在编译器的编译阶段,也就是说在代码的编译阶段,虚表就可以构造出来了。
6.4 抽象类与接口类
6.4.1 抽象类
- 纯虚函数:
- 一个在基类中只有声明的虚函数,在基类中无定义。要求在任何派生类中都定义自己的版本;
- 纯虚函数为各派生类提供一个公共界面(接口的封装和设计,软件的模块功能划分);
- 纯虚函数声明形式:
virtual void func() = 0;
```
有纯虚函数的类叫做**抽象类**
- 抽象类对象**不能做函数参数**,**不能创建对象**,**不能作为函数返回类型**;
```cpp
A a;(×);
void func(A a);(×)
A func(); (×)
A * a; (√)
A & a; (√)
-
子类必须继承父类的纯虚函数才能创建对象
-
抽象类作用:
- 接口规范,因为它只代表了一个规范,并没有具体实现,所以它不能被实例化
- 传参是子类。
6.5 动态绑定
- C++中,通过基类的引用或指针调用虚函数时,发生动态绑定。引用(或指针)既可以指向基类对象也可以指向派生类对象,这一事实是动态绑定的关键。用引用(或指针)调用的虚函数在运行时确定,被调用的函数是引用(或指针)所指对象的实际类型所定义的。
- C++中动态绑定是通过虚函数实现的。而虚函数是通过一张虚函数表(virtualtable)实现的。这个表中记录了虚函数的地址,解决继承、覆盖的问题,保证动态绑定时能够根据对象的实际类型调用正确的函数。
- 在C++的标准规格说明书中说到,编译器必需要保证虚函数表的指针存在于对象实例中最前面的位置(这是为了保证正确取到虚函数的偏移量)。这意味着我们通过对象实例的地址得到这张虚函数表,然后就可以遍历其中函数指针,并调用相应的函数
6.6 虚析构函数
为了实现动态绑定,基类指针指向派⽣类对象,如果析构函数不是虚函数,那么在对象销毁时,就会调⽤基类的析构函数,只能销毁派⽣类对象中的部分数据,所以必须将析构函数定义为虚函数,从⽽在对象销毁时,调⽤派⽣类的析构函数,从⽽销毁派⽣类对象中的所有数据。
否则可能可出现内存泄露的情况。
6.7 基类的非虚构造函数
虚函数的调⽤依赖于虚函数表,⽽指向虚函数表的指针 vptr 需要在构造函数中进⾏初始化,所以⽆法调⽤定义为虚函数的构造函数。
6.8 类成员函数的重写、重载、隐藏
- 重写(覆盖):子类重写父类的方法,基类函数必须有 virtual 关键字函数名字相同和参数相同,override修饰
- 重载:同名函数重载,同一作用域内被声明的几个具有不同参数列表(参数的类型,个数,顺序不同)的同名函数,根据参数列表确定调用哪个函数,重载不关心函数返回类型。
- 隐藏(重定义):是指派生类的函数屏蔽了与其同名的基类函数,注意只要同名函数并且基类函数没有virtual修饰,不管参数列表是否相同,基类函数都会被隐藏。
6.8 RTTI
RTTI即运⾏时类型识别,其功能由两个运算符实现:
- typeid 运算符,⽤于返回表达式的类型,可以通过基类的指针获取派⽣类的数据类型;
- dynamic_cast 运算符,具有类型检查的功能,⽤于将基类的指针或引⽤安全地转换成派⽣类的指针或引
⽤。
7 类相关
7.1 构造函数
7.1.1 默认构造函数
C++ 默认构造函数是对类中的参数提供默认值的构造函数,一般情况下,是一个没有参数值的空函数,也可以提供一些的默认值的构造函数,如果用户没有定义构造函数,那么编译器会给类提供一个默认的构造函数,但是只要用户自定义了任意一个构造函数,那么编译器就不会提供默认的构造函数,这种情况下,容易编译报错,所以正确的写法就是用户在定义构造函数的时候,也需要添加一个默认的构造函数,这样就不会造成编译报错。
7.1.2 普通构造函数
也可以称作是重载构造函数,允许参数不同
7.1.3 拷贝构造函数
参数为对象本身的引用,对于一个已经存在的对象复制出一个新的对象。如果没有显式声明,编译器会自动生成一个。如果存在指针对象,需要自己重新定义一个进行深拷贝,否则可能会出现一个内存被析构多次的情况。
ClassA a;
ClassA b(a);
ClassA c = a;
7.1.4 转换构造函数
根据一个指定类型对象创建一个本类的对象,如果不允许默认转换的话需要声明为explict阻止隐式转换。
7.1.5 移动构造函数
右值传参直接给个名字。
当类中同时包含拷贝构造函数和移动构造函数时,如果使用临时对象初始化当前类的对象,编译器会优先调用移动构造函数来完成此操作。只有当类中没有合适的移动构造函数时,编译器才会退而求其次,调用拷贝构造函数。
二、C和C++内存分配问题
1 C++编程中的内存基本构造
在C++中内存通常分成5个部分:分别是堆、栈、全局/静态存储区、常量存储区和代码区;
1、栈:在需要时由编译器自动分配释放的存储区,通常存放局部变量,参数等。
2、堆:由程序员分配跟释放,C用malloc/free关键字,C++用new/delete关键字,如果程序员没有释放掉,会造成内存泄露。在程序结束后,操作系统会自动回收。
3、全局/静态存储区:全局变量和静态变量被分配到同一块内存中,在C语言中,全局变量又分为初始化的和未初始化的,在C++里面没有这个区分,他们共同占用同一块内存区。
4、常量存储区:存放的是常量,不允许修改(通过非正当手段也可以修改)。
5、代码区 :.text段,存放代码(如函数),不允许修改(类似常量存储区),但可以执行(不同于常量存储区)。
如果按照操作系统去划分内存,可以分为四个区:堆、栈、全局区(包含上述的全局静态存储区跟常量区)、代码区
从操作系统的本身来讲,以上存储区在内存中的分布是如下形式(从低地址到高地址):.text 段 --> .data 段 --> .bss 段 --> 堆 --> unused --> 栈 --> env
1.1 为什么分成这么多个区域?
- 代码是根据流程依次执行的,一般只需要访问一次,而数据一般都需要访问多次,因此单独开辟空间以方便访问和节约空间。
- 未初始化数据区在运行时放入栈区中,生命周期短。
- 全局数据和静态数据有可能在整个程序执行过程中都需要访问,因此单独存储管理。
- 堆区由用户自由分配,以便程序员自由管理。
1.2 C++的自由存储区是什么
因为C++的new跟delete是运算符,可以重载,自由存储区可以理解为C++的一个概念,new所申请的空间是从自由存储区分配的,自由存储区不等于堆(取决于operator new在哪里为对象分配内存:不仅可以是堆,还可以是静态存储区)。new出来的对象是放在自由存储区,但是new的底层实现也是靠malloc,所以也可以说new出来的是放在堆上。
2 内存分配关键字malloc/free、new/delete区别
- new/delete是C++的操作符,而malloc/free是C中的库函数。
- new会分配内存、调用类的构造函数;delete会调用类的析构函数、释放内存。
malloc和free只是分配和释放内存。
- new建立的是一个对象,而malloc分配的是一块内存;
new建立的对象可以用成员函数访问,不要直接访问它的地址空间;
malloc分配的是一块内存区域,用指针访问,可以在里面移动指针;
- new出来的指针是带有类型信息的,而malloc返回的是void指针。
- new/delete是关键字,不需要头文件支持;malloc/free需要头文件库函数支持。所以new/delete支持运算符重载,而malloc/free不支持。
3 内存泄露
内存泄漏:由于疏忽或错误导致的程序未能释放已经不再使用的内存。
并非指内存从物理上消失,而是指程序在运行过程中,由于疏忽或错误而失去了对该内存的控制,从而造成了内存的浪费。
常指堆内存泄漏,因为堆是动态分配的,而且是用户来控制的,如果使用不当,会产生内存泄漏。
- 使用 malloc、calloc、realloc、new 等分配内存时,使用完后要调用相应的 free 或 delete 释放内存,否则这块内存就会造成内存泄漏。
- 指针重新赋值
char *p = (char *)malloc(10);
char *p1 = (char *)malloc(10);
p = np;
开始时,指针 p 和 p1 分别指向一块内存空间,但指针 p 被重新赋值,导致 p 初始时指向的那块内存空间无法找到,从而发生了内存泄漏。
4 内存对齐
- 内存对齐原则
- 结构体第一个成员的偏移量(offset)为0,以后每个成员相对于结构体首地址的 offset 都是该成员大小与有效对齐值中较小那个的整数倍,如有需要编译器会在成员之间加上填充字节。
- 结构体的总大小为有效对齐值的整数倍,如有需要编译器会在最末一个成员之后加上填充字节。
struct S1
{
int i;
char j;
int a;
double b;
};
struct S2
{
int i;
char j;
double b;
int a;
};
struct S3
{
int i;
char j;
double b;
int a;
};
int main()
{
printf("%d\n",sizeof(S1));
printf("%d\n",sizeof(S1);
printf("%d\n",sizeof(Test3));
return 0;
}
三、C++代码的编译过程
预处理——编译——汇编——链接:
- 预处理器先处理各种宏定义,然后交给编译器;
- 编译器编译成.s为后缀的汇编代码;
- 汇编代码再通过汇编器形成**.o为后缀的机器码**(二进制);
- 最后通过链接器将一个个目标文件(库文件)链接成一个完整的可执行程序(或者静态库、动态库)。
1 编译名词
- 编译:把源文件中的源代码翻译成机器语言,保存到目标文件中。如果编译通过,就会把.cpp转换成.obj文件。
- 编译单元:每个cpp就是一个编译单元,每个编译单元相互之间是独立且相互不知的。一个编译单元(Translation Unit)是指一个.cpp文件以及.cpp文件**#include(包含)的所有.h文件**,.h文件里面的代码将会被扩展到包含它的.cpp文件里,然后编译器编译该.cpp文件为一个.obj文件(所以日常改一个头文件会发现包含此头文件的.cpp文件都会被编译),后者拥有PE(Portable Executable,即Windows可执行文件)文件格式,并且本身包含的就是二进制代码,但是不一定能执行,因为并不能保证其中一定有main函数。当编译器将一个工程里的所有.cpp文件以分离的方式编译完毕后,再由链接器进行链接成为一个.exe或.dll文件。
- 目标文件:编译后生成的文件,以机器码的形式包含了编译单元里所有的函数和数据、导出符号表、未解决符号表、地址重定向表等
- 目标文件的类型:
- 可重定位文件(.o、.obj文件):其中包含有适合于其它目标文件链接来创建一个可执行的或者共享的目标文件的代码和数据。每个cpp会被编译成一个.o文件
- 共享的目标文件(库文件)
这种文件存放了适合于在两种上下文里链接的代码和数据。
第一种是静态链接程序(静态库)可把它与其它可重定位文件及共享的目标文件一起处理来创建另一个目标文件
静态链接库实际上是一个目标文件的集合,其中的每个文件含有库中的一个或者一组相关函数的代码
第二种是动态链接程序(动态库)将它与另一个可执行文件及其它的共享目标文件结合到一起,创建一个进程映象
动态链接库在程序执行时才被调用
- 可执行文件 :一个可以被操作系统创建一个进程来执行之的文件,.o文件在编译后就能获得,但是库文件、可执行文件都需要在链接后才能获得
2 编译过程
1.编译:
-
预处理阶段:预处理器进行代码预处理
- 宏#define 替换
- 条件编译指令,如#ifdef,#ifndef,#else,#elif,#endif等
- 头文件包含,#include
- 特殊符号:
LINE标识将被解释为当前行号(十进制数)
FILE则被解释为当前被编译的C源程序的名称。预编译程序对于在源程序中出现的这些串将用合适的值进行替换
-
编译、优化阶段: 编译器编译源代码
2.汇编:
汇编器把汇编语言代码翻译成目标机器指令,生成目标文件(.o文件、.obj文件)。此过程会依赖机器的硬件和操作系统环境。
2.1 表结构
.o文件至少要提供3张表:
导出符号表:即该目标文件可以提供的符号及地址
未解决符号表:即找不到地址的符号的列表,告诉链接器这些符号没找到地址
地址重定向表:链接的时候,链接器会为目标文件的**“未解决符号表”里的符号在其他目标文件中寻找地址**,但是每个目标文件的地址都是从0x0000开始的,这样直接将对方文件中符号的地址拿过来用显然会是不正确的,为了区分不同的文件,链接器在链接时就会对每个目标文件的地址进行调整。
在这个例子中,假如B.obj的0x0000被定位到可执行文件的0x00001000上,而A.obj的0x0000被定位到可执行文件的0x00002000上,那么实现上对链接器来说,A.obj的导出符号地地址都会加上0x00002000,B.obj所有的符号地址也会加上0x00001000。这样就可以保证地址不会重复。
因为被加上了起始地址,所以符号在自身文件中的实际地址就不对了,需要再用一张地址重定向表记录符号相对自身文件的地址
2.2 链接过程
- 链接:链接程序的主要工作就是将有关的目标文件(库文件、.o文件)彼此相连接,也即将在一个文件中引用的符号同该符号在另外一个文件中的定义连接起来,使得所有的这些目标文件成为一个能够被操作系统装入执行的统一整体。
- 具体工作: 当链接器进行链接的时候,首先决定各个目标文件在最终可执行文件里的位置。然后访问所有目标文件的地址重定义表,对其中记录的地址进行重定向(加上一个偏移量,即该编译单元在可执行文件上的起始地址)。然后遍历所有目标文件的未解决符号表,并且在所有的导出符号表里查找匹配的符号,并在未解决符号表中所记录的位置上填写实现地址。最后把所有的目标文件的内容写在各自的位置上,再作一些另的工作,就生成一个可执行文件。
- 链接方式
- 静态链接(.lib):函数的代码将从其所在地静态链接库中被拷贝到最终的可执行程序中。这样该程序在被执行时这些代码将被装入到该进程的虚拟地址空间中。
- 动态链接(.dll):函数的代码被放到称作是动态链接库或共享对象的某个目标文件中。链接程序此时所作的只是在最终的可执行程序中
记录下共享对象的名字以及其它少量的登记信息。在此可执行文件被执行时,动态链接库的全部内容将被映射到运行时相应进程的虚地址空间。动态链接程序将根据可执行程序中记录的信息找到相应的函数代码。