Skip to content

Latest commit

 

History

History
747 lines (631 loc) · 36.5 KB

1.base.md

File metadata and controls

747 lines (631 loc) · 36.5 KB

C++基础

编译链接

编译过程

  • 编译(编译预处理、编译、优化),汇编,链接。

编译过程

  • 编译预处理,处理以 # 开头的指令,目的是文字替换。
  • 编译、优化,将源码 .cpp 文件翻译成 .s 汇编代码。
  • 汇编,将汇编代码 .s 翻译成机器指令 .o 文件。
  • 链接,汇编程序生成的目标文件,即 .o 文件,并不会立即执行,因为可能会出现,.cpp 文件中的函数引用了另一个 .cpp 文件中定义的符号或者调用了某个库文件中的函数。那链接的目的就是将这些文件对应的目标文件连接成一个整体,从而生成可执行的程序 .exe 文件。

链接

  • 静态链接,代码从其所在的静态链接库中拷贝到最终的可执行程序中,在该程序被执行时,这些代码会被装入到该进程的 虚拟地址空间 中。
  • 动态链接,代码被放到动态链接库或共享对象的某个目标文件中,链接程序只是在最终的可执行程序中记录了共享对象的名字等一些信息。在程序执行时,动态链接库的全部内容会被映射到运行时相应进行的 虚拟地址空间
  • 二者的优缺点
    • 静态链接,浪费空间,每个可执行程序都会有目标文件的一个副本,这样如果目标文件进行了更新操作,就需要重新进行编译链接生成可执行程序(更新困难);优点就是执行的时候运行速度快,因为可执行程序具备了程序运行的所有内容。
    • 动态链接,节省内存、更新方便,但是动态链接是在程序运行时,每次执行都需要链接,相比静态链接会有一定的性能损失。

编译优化

优化等级

  • -fno-omit-frame-pointer 选项指示编译器在生成的汇编代码中不要优化掉帧指针。帧指针是一个寄存器,它指向当前函数的堆栈帧,用于访问局部变量和参数。一些编译器会优化掉帧指针以减少代码大小并提高性能。保留帧指针可以用于调试和性能分析,因为它允许更轻松地生成堆栈跟踪信息。使用 -fno-omit-frame-pointer 可以确保帧指针始终存在于生成的汇编代码中,即使启用了优化。

函数级别链接

  • 让所有函数单独保存到一个段里,当链接器需要用到某个函数时就将它合并到输出文件,对于没有用的函数则抛弃。
  • /Gy-ffunction-sections-fdata-sections

调用约定

  • C 标准中并未规定,语言的各类语法结构应该以怎样的方式来实现。但实际上,从编译器的角度来看,每一个函数在被调用时,应该以怎样的方式通过机器指令来实现其调用过程,却存在着相应的事实标准。而通常,把编译器实现函数调用时所遵循的一系列规则称为函数的"调用约定(Calling Convention)"。
  • 调用约定规定了函数调用时需要关注的一系列问题, 如何将实参传递给被调用函数、如何将返回值从被调用函数中返回、如何管理寄存器,以及如何管理栈内存 ,等等。
  • 实际上每个编译器都可以使用自己独有的调用约定,来实现 C 函数的调用过程。相应地,这也会导致当具有外部链接的函数在多个不同编译单元内被使用,且这些不同编译单元对应的源文件通过不同的编译器进行编译时,各自生成的对象文件可能无法再被整合在一起,并生成最终的可执行文件。
  • 对于 C 语言来说,运行在 x86-64 平台上的编译器基本都会根据所在操作系统的不同,选择使用几种常见的调用约定事实标准。对于 Windows 来说,编译器会采用专有的 Microsoft x64 或 Vector 调用约定。而在 Unix 和类 Unix 系统上,则会使用名为 System V AMD64 ABI(SysV)的调用约定。对于 i386(IA32)、8086 等其他平台,也都有着对应的调用约定事实标准。而较为统一的调用约定,也在一定程度上保证了 C 程序在同一平台不同编译器下的最大可移植性。

尾递归优化

  • 尾递归调用优化是指在一定条件下,编译器可以直接利用跳转指令取代函数调用指令,来模拟函数的调用过程。便可以省去函数调用栈帧的不断创建和销毁过程。而且,递归函数在整个调用期间都仅在栈内存中维护着一个栈帧,因此只使用了有限的栈内存。
  • 对于函数体较为小巧,并且可能会进行较多次递归调用的函数,尾递归调用优化可以带来可观的执行效率提升。
  • 尾递归调用的一个重要条件是:递归调用语句必须作为函数返回前的最后一条语句

基础语法

volatile

  • 阻止编译器为了提高速度将一个变量缓冲到寄存器内而不写回。在多线程并发下由于寄存器属于线程所有,会导致问题。
  • 阻止编译器调整操作 volatile 变量的指令顺序。但解决不了由于 CPU 的动态调度换序。

i++与++i

内建数据类型的自增汇编代码

  • 内建数据类型的情况,效率没有区别。
  • 自定义数据类型的情况,++i 效率更高。++i 可以返回对象的引用,i++ 必须返回对象的值。

交换变量之间的值

void swap(int& a, int& b)
{
    //1 可能产生数据溢出
    a = a + b;
    b = a - b;
    a = a - b;
    //2
    a ^= b;
    b ^= a;
    a ^= b;
}

main 函数结束之后执行其他函数

  • atexit() 函数注册程序正常终止时要被调用的函数,在 main 函数结束时,按注册时的顺序 反序 调用这些函数。
#include <stdlib.h>
int atexit(void (*)void);

  • 因为宏的展开、替换发生在预处理阶段,不涉及函数调用、参数传递、指针寻址,没有任何运行期的效率损失,所以对于一些调用频繁的小代码片段来说,用宏来封装的效果比 inline 关键字要更好,因为它真的是源码级别的无条件内联。
  • 宏是没有作用域概念的,永远是全局生效。对于一些用来简化代码、起临时作用的宏,最好是用完后尽快用 #undef 取消定义,避免冲突的风险。
  • 必须知道的一个宏是 __cplusplus ,它标记了 C++ 语言的版本号,使用它能够判断当前是 C 还是 C++,是 C++98 还是 C++11。
// #把宏参数变成一个字符串
#define FUNC(a) #a
const char* str = FUNC(把宏参数变成一个字符串);//const char* str = "把宏参数变成一个字符串";

// ##把两个宏参数粘合在一起
#define FUNC(a, b) a##b
const char* str = FUNC("把两个宏参数", "粘合在一起");//const char* str = "把两个宏参数""粘合在一起";

属性

  • 属性没有新增关键字,而是用两对方括号的形式 [[…]] ,方括号的中间就是属性标签。

四大函数

  • 构造函数
  • 析构函数
  • 拷贝构造函数
  • 拷贝赋值函数

六大函数

  • 构造函数
  • 析构函数
  • 拷贝构造函数
  • 拷贝赋值函数
  • 移动构造函数
  • 移动赋值函数

static

  • static 定义静态变量,静态函数。
  • 保持变量内容持久,static 作用于局部变量,改变了局部变量的生存周期,使得该变量存在于定义后直到程序运行结束的这段时间。
#include <iostream>
using namespace std;
int fun(){
    static int var = 1; // var 只在第一次进入这个函数的时初始化
    var += 1;
    return var;
}
int main()
{
    for(int i = 0; i < 10; ++i)
    	cout << fun() << " "; // 2 3 4 5 6 7 8 9 10 11
    return 0;
}
  • 隐藏,static 作用于全局变量和函数,改变了全局变量和函数的作用域,使得全局变量和函数只能在定义它的文件中使用,在源文件中不具有全局可见性。(注,普通全局变量和函数具有全局可见性,即其他的源文件也可以使用。)
  • static 作用于类的成员变量和类的成员函数,使得类变量或者类成员函数和类有关,也就是说可以不定义类的对象就可以通过类访问这些静态成员。注意,类的静态成员函数中只能访问静态成员变量或者静态成员函数,不能将静态成员函数定义成虚函数。
#include<iostream>
using namespace std;
class A
{
private:
    int var;
    static int s_var; // 静态成员变量
public:
    void show()
    {
        cout << s_var++ << endl;
    }
    static void s_show()
    {
        cout << s_var << endl;
        // cout << var << endl; // error: invalid use of member 'A::var' in static member function. 静态成员函数不能调用非静态成员变量。无法使用 this.var
        // show();  // error: cannot call member function 'void A::show()' without object. 静态成员函数不能调用非静态成员函数。无法使用 this.show()
    }
};
int A::s_var = 1;  // 静态成员变量在类外进行初始化赋值,默认初始化为 0
int main()
{
    // cout << A::s_var << endl; // error: 'int A::s_var' is private within this context
    A ex;
    ex.show();
    A::s_show();
}

const

  • const 修饰成员变量,定义成 const 常量,相较于宏常量,可进行类型检查,节省内存空间,提高了效率。
  • const 修饰函数参数,使得传递过来的函数参数的值不能改变。
  • const 修饰成员函数,使得成员函数不能修改任何类型的成员变量(mutable修饰的变量除外),也不能调用非 const 成员函数,因为非 const 成员函数可能会修改成员变量。
  • const 成员变量只能在类内声明、定义,在构造函数初始化列表中初始化。
  • const成员变量只在某个对象的生存周期内是常量,对于整个类而言却是可变的,因为类可以创建多个对象,不同类的 const 成员变量的值是不同的。不能在类的声明中初始化 const 成员变量。
  • 对于没有 volatile 修饰的 const 常量,即使用指针改了常量的值,但这个值在运行阶段根本没有用到,可能在编译阶段就被优化掉(代码替换)。
#include <iostream>
using namespace std;
class A
{
public:
	int var;
	A(int tmp) : var(tmp) {}
	void c_fun(int tmp) const // const 成员函数
	{
		var = tmp; // error: assignment of member 'A::var' in read-only object. 在 const 成员函数中,不能修改任何类成员变量。
		fun(tmp); // error: passing 'const A' as 'this' argument discards qualifiers. const 成员函数不能调用非 const 成员函数,因为非 const 成员函数可能会修改成员变量。
	}
	void fun(int tmp)
	{
		var = tmp;
	}
};
int main()
{
    return 0;
}

define 和 const 的区别

  • 编译阶段,define 是在编译预处理阶段进行替换,const 是在编译阶段确定其值。
  • 安全性,define 定义的宏常量没有数据类型,只是进行简单的替换,不会进行类型安全的检查;const 定义的常量是有类型的,是要进行判断的,可以避免一些低级的错误。
  • 内存占用,define 定义的宏常量,在程序中使用多少次就会进行多少次替换,内存中有多个备份,占用的是 代码段 的空间;const 定义的常量占用静态存储区的空间(.rodata 节),程序运行过程中只有一份。
  • 调试,define 定义的宏常量不能调试,因为在预编译阶段就已经进行替换了;const 定义的常量可以进行调试。

mutable

  • mutable 只能修饰类里面的成员变量,表示变量即使是在 const 对象里,也是可以修改的。
  • 对于成员变量,加上 mutable 修饰,解除 const 的限制,让任何成员函数都可以操作它。

inline

  • inline 是一个关键字,可以用于定义内联函数。内联函数,像普通函数一样被调用,但是在调用时并不通过函数调用的机制而是直接在调用点处展开,这样可以大大减少由函数调用带来的开销,从而提高程序的运行效率。
  • 类内定义成员函数默认是内联函数。在类内定义成员函数,可以不用在函数头部加 inline 关键字,因为编译器会自动将类内定义的函数(构造函数、析构函数、普通成员函数等)声明为内联函数。
  • 类外定义成员函数,若想定义为内联函数,需用 inline 关键字声明。
  • 内联函数不是在调用时发生控制转移关系,而是在编译阶段将函数体嵌入到每一个调用该函数的语句块中,编译器会将程序中出现内联函数的调用表达式用内联函数的函数体来替换。普通函数是将程序执行转移到被调用函数所存放的内存地址,当函数执行完后,返回到执行此函数前的地方。转移操作需要保护现场,被调函数执行完后,再恢复现场,该过程需要较大的资源开销。

sizeof

  • sizeof 运算符是一个编译期运算符,编译器仅通过静态分析就能够将给定参数的大小计算出来。
  • 在最终生成的汇编代码中,不会看到 sizeof 运算符对应于任何汇编指令。
  • 运算符在编译过程中得到的计算结果值,将会以字面量值的形式直接嵌入到汇编代码中使用。

register

  • 在定义时为变量添加额外的 register 关键字,以建议编译器将该值存放在寄存器中,而非栈内存中。

非本地跳转

  • 非本地跳转的实现依赖于标准库头文件 setjmp.h 内的两个函数 setjmplongjmp

  • 非本地跳转提供了一种可以暂存函数调用状态,并在未来某个时刻再恢复的能力。

  • setjmp 函数在执行时,会将程序此刻的函数调用环境信息,存储在由其第一个参数指定的 jmp_buf 类型的对象中,并同时将数值 0 作为函数调用结果返回。

  • 当程序执行到 longjmp 函数时,该函数便会从同一个 jmp_buf 对象中再次恢复之前保存的函数调用上下文。通过这种方式,程序的执行流程得到了重置。

    #include <setjmp.h>
    #include <stdio.h>
    
    static jmp_buf j;
    
    void __exception(void)
    {
      printf("Show exception.\n");
      longjmp(j, 1); /* jump to exception handler case 1 */
      printf("This line will never be appeared.\n");
    }
    
    int main(void)
    {
      switch (setjmp(j))
      {
      case 0:
        printf("''setjmp'' initialization.\n");
        __exception();
        printf("This line will be never appeared.\n");
    
        break;
      case 1:
        printf("Deal with exception branch.\n");
        break;
      default:
        break;
      }
      return 0;
    }
    
    //''setjmp'' initialization.
    //Show exception.
    //Deal with exception branch.

为什么成员初始化列表会快一些?

  • 数据类型可分为内置类型和用户自定义类型(类类型),对于用户自定义类型,利用成员初始化列表效率高。
  • 用户自定义类型如果使用类初始化列表,直接调用该成员变量对应的构造函数即完成初始化;如果在构造函数中初始化,对象的成员变量的初始化动作发生在进入构造函数本体之前,那么在执行构造函数的函数体之前首先调用默认的构造函数为成员变量设初值,在进入函数体之后,调用该成员变量对应的构造函数。因此,使用列表初始化会减少调用默认的构造函数的过程,效率高。

能否用 memcmp 函数判断结构体相等?

  • 不能用函数 memcmp 来判断两个结构体是否相等,因为 memcmp 函数是逐个字节进行比较的,而结构体存在内存空间中保存时存在字节对齐,字节对齐时补的字节内容是随机的,会产生垃圾值,所以无法比较。

C++中 4 种指针转化运算符

  • const_cast,特定情况下,将const限制解除。
  • dynamic_cast,可以在运行时将一个指向派生类的基类指针还原成原来的派生类指针。
  • reinterpret_cast,指针间强行转化。
  • static_cast,安全类型转化,转换定义了相关构造函数、类型转换函数或者有继承关系的类,或者将数域宽度大的类型转换到较小的类型。

异常

  • 异常的抛出和处理需要特别的栈展开(stack unwind)操作,如果异常出现的位置很深,但又没有被及时处理,或者频繁地抛出异常,就会对运行性能产生很大的影响。

内存

内存管理

  • C++内存分区,栈、堆、全局/静态存储区、常量存储区、代码区。

    • 栈,存放函数的局部变量、函数参数、返回地址等,由编译器自动分配和释放。
    • 堆,动态申请的内存空间,就是由 malloc 分配的内存块,由程序员控制它的分配和释放,如果程序执行结束还没有释放,操作系统会自动回收。
    • 全局区/静态存储区(.bss段和.data段),存放全局变量和静态变量,程序运行结束操作系统自动释放,在 C 语言中,未初始化的放在.bss段中,初始化的放在.data段中,C++中不再区分了。
    • 常量存储区(.rodata段),存放的是常量,不允许修改,程序运行结束自动释放。
    • 代码区(.text段),存放代码,不允许修改,但可以执行。编译后的二进制文件存放在这里。
  • 从操作系统的本身来讲,以上存储区在内存中的分布是如下形式

    # env
    # stack
    #
    # unused
    #
    # heap
    # .bss
    # .data
    # .text

堆和栈的区别

  • 申请方式,栈是系统自动分配,堆是程序员主动申请。
  • 申请后系统响应,分配栈空间,如果剩余空间大于申请空间则分配成功,否则分配失败栈溢出;申请堆空间,堆在内存中呈现的方式类似于链表(记录空闲地址空间的链表),在链表上寻找第一个大于申请空间的节点分配给程序,将该节点从链表中删除,大多数系统中该块空间的首地址存放的是本次分配空间的大小,便于释放,将该块空间上的剩余空间再次连接在空闲链表上。
  • 栈在内存中是连续的一块空间(向低地址扩展),最大容量是系统预定好的,堆在内存中的空间(向高地址扩展),是不连续的。
  • 申请效率,栈是有系统自动分配,申请效率高,但程序员无法控制;堆是由程序员主动申请,效率低,使用起来方便但是容易产生碎片。
  • 存放的内容,栈中存放的是局部变量,函数的参数;堆中存放的内容由程序员控制。

栈展开

  • 在发生异常时对析构函数的调用,叫栈展开(stack unwinding)。

RAII

  • RAII,Resource Acquisition Is Initialization,是 C++ 所特有的资源管理方式。
  • RAII 依托栈和析构函数,来对所有的资源包括堆内存在内进行管理。
  • 在析构函数里做必要的清理工作,是 RAII 的基本用法。

全局变量、局部变量、静态全局变量、静态局部变量的区别

  • C++变量根据定义的位置的不同的生命周期,具有不同的作用域,作用域可分为6种,全局作用域,局部作用域,语句作用域,类作用域,命名空间作用域和文件作用域。
    • 全局变量,具有全局作用域。全局变量只需在一个源文件中定义,就可以作用于所有的源文件。其他不包含全局变量定义的源文件需要用extern关键字再次声明这个全局变量。
    • 静态全局变量,具有文件作用域。与全局变量的区别在于如果程序包含多个文件的话,作用于定义它的文件里,不能作用到其它文件里,即被static关键字修饰过的变量具有文件作用域。这样即使两个不同的源文件都定义了相同名字的静态全局变量,它们也是不同的变量。
    • 局部变量,具有局部作用域。是自动对象(auto),在程序运行期间不是一直存在,而是只在函数执行期间存在,函数的一次调用执行结束后,变量被撤销,其所占用的内存也被收回。
    • 静态局部变量,具有局部作用域。只被初始化一次,自从第一次被初始化直到程序运行结束都一直存在,和全局变量的区别在于全局变量对所有的函数都是可见的,而静态局部变量只对定义自己的函数体始终可见。
  • 从分配内存空间看,变量可以分为
    • 静态存储区,全局变量,静态局部变量,静态全局变量
    • 栈,局部变量
  • 静态变量会被放在程序的静态数据存储区(.data段)中(静态变量会自动初始化),这样可以在下一次调用的时候还可以保持原来的赋值。
  • 栈变量或堆变量不能保证在下一次调用的时候依然保持原来的值。
  • 静态变量用static告知编译器,自己仅仅在变量的作用范围内可见。
  • 初始化的全局变量和静态变量,其值通常会被存放到进程虚拟地址空间内的.data中。
  • 未初始化的全局变量和静态变量存放到进程虚拟地址空间内的.bss中。
  • 由于常量本身的不可变特征,会按照数据的大小和类型被选择性存放到进程虚拟地址空间的.rodata以及.text中。.rodata用于存放只读数据,而.text通常用于存放程序本身的代码。 若内联的常量值较大,则会被单独存放到.rodata中保存,否则会直接内联到应用程序代码中,作为机器指令的字面量参数。
  • 局部变量将被存放在寄存器或应用程序虚拟地址空间的栈内存中,具体使用哪种方式则依赖于编译器的选择。

限制类对象只能在堆或栈上创建

  • C++中的类的对象的建立分为两种,静态建立、动态建立。

    • 静态建立,由编译器为对象在栈空间上分配内存,直接调用类的构造函数创建对象。例如,A a;
    • 动态建立,使用new关键字在堆空间上创建对象,底层首先调用operator new()函数,在堆空间上寻找合适的内存并分配;然后,调用类的构造函数创建对象。例如,A *p = new A();
  • 限制对象只能建立在堆上

    • 避免直接调用类的构造函数,因为对象静态建立时,会调用类的构造函数创建对象。但是直接将类的构造函数设为私有并不可行,因为当构造函数设置为私有后,不能在类的外部调用构造函数来构造对象,只能用new来建立对象。但是由于new创建对象时,底层也会调用类的构造函数,将构造函数设置为私有后,那就无法在类的外部使用new创建对象了。因此,这种方法不可行。
    • 将析构函数设置为私有。原因,静态对象建立在栈上,是由编译器分配和释放内存空间,编译器为对象分配内存空间时,会对类的非静态函数进行检查,即编译器会检查析构函数的访问性。 当析构函数设为私有时,编译器创建的对象就无法通过访问析构函数来释放对象的内存空间,因此,编译器不会在栈上为对象分配内存。
     class A
     {
     public:
         A() {}
         void destory()
         {
             delete this;
         }
     private:
         ~A()
         {
         }
     };
    • new创建的对象,通常会使用delete释放该对象的内存空间,但此时类的外部无法调用析构函数,因此类内必须定义一个destory()函数,用来释放new创建的对象。

    • 如果这个类作为基类,析构函数要设置成virtual,然后在派生类中重写该函数,来实现多态。但此时,析构函数是私有的,派生类中无法访问。

    • 构造函数设置为protected,并提供一个public的静态函数来完成构造,而不是在类的外部使用new构造;将析构函数设置为protected。原因,类似于单例模式,也保证了在派生类中能够访问析构函数。通过调用create()函数在堆上创建对象。

      class A
      {
      protected:
          A() {}
          ~A() {}
      public:
          static A *create()
          {
              return new A();
          }
          void destory()
          {
              delete this;
          }
      };
  • 限制对象只能建立在栈上

    • operator new()设置为私有。原因,当对象建立在堆上时,是采用new的方式进行建立,其底层会调用operator new()函数,因此只要对该函数加以限制,就能够防止对象建立在堆上。

      class A
      {
      private:
          void *operator new(size_t t) {}    // 注意函数的第一个参数和返回值都是固定的
          void operator delete(void *ptr) {} // 重载了 new 就需要重载 delete
      public:
          A() {}
          ~A() {}
      };

new 和 delete

  • new 的实现原理

    • 首先通过调用operator new的标准库函数来申请所占的内存空间
    • 进而执行该对象所属类的构造函数。
  • new 用来申请单个对象所占的空间,只会调用一次构造函数。

  • new []用来申请数组空间,会对数组中的每个成员都调用一次构造函数。

  • delete 的实现原理

    • 首先执行该对象所属类的析构函数。
    • 进而通过调用operator delete的标准库函数来释放所占的内存空间
  • delete 用来释放单个对象所占的空间,只会调用一次析构函数。

  • delete []用来释放数组空间,会对数组中的每个成员都调用一次析构函数。

malloc

  • malloc 的原理
    • 当开辟的空间小于128K时,调用brk()函数,通过移动_enddata来实现。
    • 当开辟空间大于128K时,调用mmap()函数,通过在虚拟地址空间中开辟一块内存空间来实现。
  • malloc 的底层实现
    • brk()函数实现原理,向高地址的方向移动指向数据段的高地址的指针_enddata
    • mmap 内存映射原理
      • 进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域。
      • 调用内核空间的系统调用函数 mmap(),实现文件物理地址和进程虚拟地址的一一映射关系。
      • 进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝。

内存对齐

  • 内存对齐,编译器将程序中的每个数据单元安排在字的整数倍的地址指向的内存之中。

  • 对于现代计算机而言,当内存中需要被读写的数据,其所在地址满足自然对齐时,CPU 通常能够以最高的效率进行数据操作。而所谓自然对齐,是指被操作数据的所在地址为该数据大小的整数倍。

  • 当结构对象被连续存放时(比如通过数组),前一个对象的结束位置正好可以满足后一个对象作为起始位置时的自然对齐要求。而这也就要求结构对象本身的大小必须是其内部最大成员大小的整数倍。因此,编译器会在结构最后一个成员的后面再填充适当字节,以满足这个条件。可以说,在这种情况下的结构对象,已经满足了在不同场景下的自然对齐条件,因此,此时的结构大小也会被作为 sizeof 运算符的最终计算结果。

    /*
    说明,程序是在 64 位编译器下测试的
    */
    #include <iostream>
    using namespace std;
    struct A
    {
      short var; // 2 字节
      int var1;  // 8 字节 (内存对齐原则,填充 2 个字节) 2 (short) + 2 (填充) + 4 (int)= 8
      long var2; // 16 字节 8 + 8 (long) = 16
      char var3; // 24 字节 (内存对齐原则,填充 7 个字节)16 + 1 (char) + 7 (填充) = 24
      string s;  // 56 字节 24 + 32 (string) = 48
    };
    int main()
    {
      short var;
      int var1;
      long var2;
      char var3;
      string s;
      A ex1;
      cout << sizeof(var) << endl;  // 2 short
      cout << sizeof(var1) << endl; // 4 int
      cout << sizeof(var2) << endl; // 8 long
      cout << sizeof(var3) << endl; // 1 char
      cout << sizeof(s) << endl;    // 32 string
      cout << sizeof(ex1) << endl;  // 56 struct
      return 0;
    }
  • 进行内存对齐的原因,(主要是硬件设备方面的问题)

    • 某些硬件设备只能存取对齐数据,存取非对齐的数据可能会引发异常
    • 某些硬件设备不能保证在存取非对齐数据的时候的操作是原子操作
    • 相比于存取对齐的数据,存取非对齐的数据需要花费更多的时间
    • 某些处理器虽然支持非对齐数据的访问,但会引发对齐陷阱(alignment trap)
    • 某些硬件设备只支持简单数据指令非对齐存取,不支持复杂数据指令的非对齐存取
  • 内存对齐的优点

    • 便于在不同的平台之间进行移植,因为有些硬件平台不能够支持任意地址的数据访问,只能在某些地址处取某些特定的数据,否则会抛出异常。
    • 提高内存的访问效率,因为 CPU 在读取内存时,是一块一块的读取。

类大小的计算

  • 类的大小是指类的实例化对象的大小,用sizeof对类型名操作时,结果是该类型的对象的大小。

  • 遵循结构体的对齐原则。

  • 与普通成员变量有关,与成员函数和静态成员无关。

  • 虚函数对类的大小有影响,是因为虚函数表指针的影响。

  • 虚继承对类的大小有影响,是因为虚基表指针带来的影响。

  • 空类的大小是一个特殊情况,空类的大小为1,当用 new 来创建一个空类的对象时,为了保证不同对象的地址不同,空类也占用存储空间。

    /*
    说明,程序是在 64 位编译器下测试的
    */
    #include <iostream>
    using namespace std;
    class A
    {
    private:
      static int s_var; // 不影响类的大小
      const int c_var;  // 4 字节
      int var;          // 8 字节 4 + 4 (int) = 8
      char var1;        // 12 字节 8 + 1 (char) + 3 (填充) = 12
    public:
      A(int temp) : c_var(temp) {} // 不影响类的大小
      ~A() {}                      // 不影响类的大小
    };
    class B
    {
    };
    int main()
    {
      A ex1(4);
      B ex2;
      cout << sizeof(ex1) << endl; // 12 字节
      cout << sizeof(ex2) << endl; // 1 字节
      return 0;
    }
  • 带有虚函数的情况,(虚函数的个数并不影响所占内存的大小,因为类对象的内存中只保存了指向虚函数表的指针。)

    /*
    说明,程序是在 64 位编译器下测试的
    */
    #include <iostream>
    using namespace std;
    class A
    {
    private:
      static int s_var; // 不影响类的大小
      const int c_var;  // 4 字节
      int var;          // 8 字节 4 + 4 (int) = 8
      char var1;        // 12 字节 8 + 1 (char) + 3 (填充) = 12
    public:
      A(int temp) : c_var(temp) {} // 不影响类的大小
      ~A() {}                      // 不影响类的大小
      virtual void f() { cout << "A::f" << endl; }
      virtual void g() { cout << "A::g" << endl; }
      virtual void h() { cout << "A::h" << endl; } // 24 字节 12 + 4 (填充) + 8 (指向虚函数的指针) = 24
    };
    int main()
    {
      A ex1(4);
      A *p;
      cout << sizeof(p) << endl;   // 8 字节 注意,指针所占的空间和指针指向的数据类型无关
      cout << sizeof(ex1) << endl; // 24 字节
      return 0;
    }

内存泄漏

  • 由于疏忽或错误导致的程序未能释放已经不再使用的内存。并非指内存从物理上消失,而是指程序在运行过程中,由于疏忽或错误而失去了对该内存的控制,从而造成了内存的浪费。

  • 常指堆内存泄漏,因为堆是动态分配的,而且是用户来控制的,如果使用不当,会产生内存泄漏。

  • 使用malloc、calloc、realloc、new等分配内存时,使用完后要调用相应的freedelete释放内存,否则这块内存就会造成内存泄漏。

  • 防止内存泄漏的方法

    • 内部封装,将内存的分配和释放封装到类中,在构造的时候申请内存,析构的时候释放内存。

      #include <iostream>
      #include <cstring>
      using namespace std;
      class A
      {
      private:
          char *p;
          unsigned int p_size;
      public:
          A(unsigned int n = 1) // 构造函数中分配内存空间
          {
              p = new char[n];
              p_size = n;
          };
          ~A() // 析构函数中释放内存空间
          {
              if (p != NULL)
              {
                  delete[] p; // 删除字符数组
                  p = NULL;   // 防止出现野指针
              }
          };
          char *GetPointer()
          {
              return p;
          };
      };
      void fun()
      {
          A ex(100);
          char *p = ex.GetPointer();
          strcpy(p, "Test");
          cout << p << endl;
      }
      int main()
      {
          fun();
          return 0;
      }
    • 但这样做并不是最佳的做法,在类的对象复制时,程序会出现同一块内存空间释放两次的情况。

      void fun1()
      {
        A ex(100);
        A ex1 = ex;
        char *p = ex.GetPointer();
        strcpy(p, "Test");
        cout << p << endl;
      }
    • 对于 fun1 这个函数中定义的两个类的对象而言,在离开该函数的作用域时,会两次调用析构函数来释放空间,但是这两个对象指向的是同一块内存空间,所以导致同一块内存空间被释放两次,可以通过增加计数机制来避免这种情况。

      #include <iostream>
      #include <cstring>
      using namespace std;
      class A
      {
      private:
        char *p;
        unsigned int p_size;
        int *p_count; // 计数变量
      public:
        A(unsigned int n = 1) // 在构造函数中申请内存
        {
          p = new char[n];
          p_size = n;
          p_count = new int;
          *p_count = 1;
          cout << "count is , " << *p_count << endl;
        };
        A(const A &temp)
        {
          p = temp.p;
          p_size = temp.p_size;
          p_count = temp.p_count;
          (*p_count)++; // 复制时,计数变量 +1
          cout << "count is , " << *p_count << endl;
        }
        ~A()
        {
          (*p_count)--; // 析构时,计数变量 -1
          cout << "count is , " << *p_count << endl;
          if (*p_count == 0) // 只有当计数变量为 0 的时候才会释放该块内存空间
          {
            cout << "buf is deleted" << endl;
            if (p != NULL)
            {
              delete[] p; // 删除字符数组
              p = NULL;   // 防止出现野指针
              if (p_count != NULL)
              {
                delete p_count;
                p_count = NULL;
              }
            }
          }
        };
        char *GetPointer()
        {
          return p;
        };
      };
      void fun()
      {
        A ex(100);
        char *p = ex.GetPointer();
        strcpy(p, "Test");
        cout << p << endl;
        A ex1 = ex; // 此时计数变量会 +1
        cout << "ex1.p = " << ex1.GetPointer() << endl;
      }
      int main()
      {
        fun();
        return 0;
      }
    • 智能指针是 C++中已经对内存泄漏封装好了一个工具,可以直接拿来使用。