February 15, 2020

11014 words 22 mins read

C++之查漏补缺

C++之查漏补缺

关于C++的一些知识点,方便查阅。

基础

  1. 程序运行包括?预处理器包括?

  2. 动态链接和静态链接

  3. #include <> 和#include ““的区别

    这两个都可以将指定的文件中的内容引入到当前文件,但在搜索时采用了不同的搜索策略。

    • #include <>在搜索时直接从编译器指定的路径开始搜索,如D:\Program Files\Microsoft Studio 10.0\VC\include
    • #include ““首先从运行程序所在的目录处进行搜索,如果搜索失败再从编译器指定的路径处搜索,如果仍然搜索失败,程序直接报错。

关键字

  1. const

    作用:

    • 修饰指针,分为常量指针指针常量

    • 修饰变量,作用:修饰变量,说明该变量不可以被修改;

    • 常量引用,经常用于形参类型,即避免了拷贝,又避免了函数对值的修改;

    • 修饰类成员函数, 不能更新任何数据成员,也不能调用该类中没有用const修饰的成员函数,只能调用常成员函数常数据成员, 但是可以被其他成员函数调用;

    使用分析:

    // 定义一个类
    class A
    {
    private:
        const int a;                    // 常对象成员,只能在初始化列表(构造函数)里赋值
    public:
        // 构造函数
        A() : a(0) {};
        A(int x) : a(x) {};             // 初始化列表
           
        int getValue();                 // 普通成员函数
        int getValue() const;           // 常成员函数,不能更新任何数据成员(也就是不能在函数里对私有变量赋值),只能调用常成员函数和常数据成员。
    }
       
    void function()
    {
        // 对象
        A b;                            // 普通对象
        const A a;                      // 常对象,只能调用常成员函数
        const A *p = &a;				// 常指针
        const A &q = a;					// 常变量
           
        // 指针
        char greeting[] = "Hello";		
        char* p1 = greeting;			// 指针变量
        const char* p2 = greeting;		// 常量指针
        char* const p3 = greeting;		// 指针常量
        const char* const p4 = greeting;// 
    }
       
    // 函数
    void func1(const int var);			// 传递过来的参数在函数内不可变
    void func2(const char* var);		// 常量指针,参数指针所指的内容为常量
    void func3(char* const var);		// 指针常量
    void func4(const int& var);			// 引用参数在函数内为常量
       
    // 函数返回值
    const int func5();					// 返回一个整型常量
    const int* func6();					// 返回一个指向常量的指针变量,const int* p = func6();
    int* const func7();					// 返回一个指向指针类型的常量,int* const p = func7();
    
  2. static

    作用

    1. 修饰静态成员变量, 在类里的成员变量的声明前加上关键字static,那么该数据成员就是类内的静态数据成员。 该类的所有对象只保存这一个变量,在初次使用时就被初始化,而且不需要生成对象就可以访问该成员;

    2. 修饰静态成员函数,静态成员函数为类服务,而不是某一个类的具体对象。

      • 普通的成员函数一般都隐含了一个this指针,this指针指向类的对象本身,因为普通成员函数总是属于类的某个具体的对象;
      • 类的静态成员函数属于类本身,不作用于对象,因此不具有this指针,正因为它没有指向某一个对象,所以它无法访问属于类对象的非静态成员变量和非静态成员函数,它只能调用其余的静态成员函数和静态成员变量;
      • 由于没有this指针的额外开销,静态成员函数与类的全局函数相比速度上会稍快;
    3. 修饰静态全局变量,在全局变量前,加上关键字static,该变量即为静态全局变量。 在一个文件中,静态全局变量和全局变量功能相同;而在两个文件中,要使用同一个变量,则只能使用全局变量而不能使用静态全局变量。

    4. 修饰静态局部变量,在局部变量前,加上关键字static,该变量就被定义为一个静态局部变量。

      • 静态局部变量在程序执行到该对象的声明处时被首次初始化,即以后的函数调用不再进行初始化; 静态局部变量一般在声明处初始化,如果没有显式初始化,会被程序自动初始化为0;
      • 静态局部变量始终驻留在全局数据区,直到程序运行结束。但其作用域为局部作用域,当定义它的函数或语句块结束时,其作用域随之结束;
      • 静态局部变量始终驻留在全局数据区,直到程序运行结束。但其作用域为局部作用域,当定义它的函数或语句块结束时,其作用域随之结束;
    5. 修饰静态函数, 在函数的返回类型前加上static关键字,函数即被定义为静态函数。 静态函数与普通函数不同,它只能在声明它的文件当中可见,不能被其它文件使用。

      • 静态函数不能被其它文件所用;
      • 其它文件中可以定义相同名字的函数,不会发生冲突;

    静态函数可以是虚函数吗?

    • static不属于任何类对象或类实例,所以即使给此函数加上virutal也是没有任何意义的;
    • 静态与非静态成员函数之间有一个主要的区别。那就是静态成员函数没有this指针;虚函数依靠指针vptr和虚函数表vtable来处理。vptr是一个指针,在类的构造函数中创建生成,并且只能用this指针来访问它,因为它是类的一个成员,并且vptr指向保存虚函数地址的vtable。对于静态成员函数,它没有this指针,所以无法访问vptr. 这就是为何static函数不能为virtual. 虚函数的调用关系:this -> vptr -> vtable ->virtual function
  3. define宏定义

    作用:

    • 宏定义:提高程序的可读性
    • 仅仅是字符串的替换

    宏定义与内联函数的区别

    1. 宏定义是字符串的替换,内联函数是一个函数;
    2. 代码展开在程序的不同阶段:宏定义的展开是在预处理阶段,内联函数是编译阶段;
    3. 内联函数在运行时可调试,而宏定义不可以。
  4. inline内联函数

    优点

    • 不执行进入函数的步骤,直接执行函数体;内联函数同宏函数一样将在被调用处进行代码展开,省去了参数压栈、栈帧开辟与回收,结果返回等,从而提高程序运行速度。
    • 在类中声明同时定义的成员函数,自动转化为内联函数,因此内联函数可以访问类的成员变量,宏定义则不能。

    缺点

    • 代码膨胀。内联是以代码膨胀(复制)为代价,消除函数调用带来的开销。如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收获会很少。另一方面,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间。
    • inline 函数无法随着函数库升级而升级。inline函数的改变需要重新编译,不像 non-inline 可以直接链接;
    • 是否内联,程序员不可控。内联函数只是对编译器的建议,是否对函数内联,决定权在于编译器;
    • 内联函数(inline)可以是虚函数(virtual)吗?
      • 内联函数需要在编译阶段展开,而虚函数是运行时动态绑定的,inline关键字作为提示符建议编译器此函数作为内联函数希望在编译阶段展开,但是,编译器并不一定要展开
      • 虚函数可以是内联函数,内联是可以修饰虚函数的,但是当虚函数表现为多态性的时候不能内联(内联是在编译器建议编译器内联,而虚函数的多态性在运行期,编译器无法知道运行期调用哪个代码,因此虚函数表现为多态性时(运行期)不可以内联。);
      • inline virtual 唯一可以内联的时候是:编译器知道所调用的对象是哪个类(如 Base::who() ),这只有在编译器具有实际对象而不是对象的指针或引用时才会发生;
    • 静态函数可以是内联函数吗?

  5. volatile

    作用

    • volatile 关键字声明的变量,提醒编译器它后面所定义的变量随时有可能改变,volatile 告诉编译器不应对这样的对象进行优化,因此每次访问时都必须从内存中取出值(没有被 volatile 修饰的变量,可能由于编译器的优化,从 CPU 寄存器中取值);
    • 一个变量既可以是const还可以是volatile( 只读的状态寄存器 );
    • 指针可以是 volatile;

    例子:

    vilatile int a = 10;
    int b = a;
    int c = a;
    

    若忽略volatile,那变量a就只需要从内存中读取一次就够了。因为从内存中读取一次之后,CPU 的寄存器中就已经有了这个值;把这个值直接复用就可以了。这样一来,编译器就会做优化,把两次访存的操作优化成一次。

    但是, 例如说,假设 a 指向的内存是一个硬件设备。这样一来,从 a 指向的内存读取数据可能伴随着可观测的副作用:硬件状态的修改。此时,代码的原意可能是将硬件设备返回的连续两个 int 分别保存在 bc 当中。这种情况下,编译器的优化就会导致程序行为不符合预期了。

    总结来说,被 volatile 修饰的变量,在对其进行读写操作时,会引发一些可观测的副作用。而这些可观测的副作用,是由程序之外的因素决定的

    Volatile关键字参考链接

  6. new/delete和malloc/free

    • malloc/free是C/C++库函数,通过函数调用访问,需要传递参数并接受返回值;

      new/delete是C++运算符,有自己的一套语法规则和运算方式;

    • malloc函数返回的是void *类型,程序需要显示的转换程所需要的基本类型;

      new操作直接指明类型,不涉及类型转换问题;

    • 内存分配:malloc需要显式的指定内存块大小,new不需要;

    • new可以调用malloc,但是malloc不可用调用new;

    • new/delete允许重载,malloc/free不允许重载;

  7. delete与delete []区别

    • 当new[]中的数组元素是基本类型时,通过delete和delete[]都可以释放数组空间;
    • 当new[]中的数组元素是自定义类型时,只能通过delete[]释放空间,因为delete[]会逐个调用数组中每个对象的析构函数,只有这样才能讲数组元素内部申请的资源释放,从而将整个对象数组完全释放,不会造成内存泄漏;

指针和引用

  1. 指针和引用的区别

    知识点梳理

    引用:变量的别名,就是给一个变量重新起了一个名字。

    int val = 2;
    int &rval = val;			// 定义一个引用,且初始化
    

    上述代码中,定义了一个整型变量val,紧接着定义了一个引用rval并初始化。

    Notice

    1. 引用在定义时必须初始化,所以其与一个变量绑定在一起,初始化后,引用不能再绑定其他变量,对引用修改就是对所绑定变量的修改
    2. 引用并不是值的拷贝,而是将两者绑定在一起,变量valrval是等效的;

    适用场合函数参数的传递。在被调函数内修改形参就相当于修改了主调函数的实参,通过这种方式改变实参并没有数量的限制;

    好处:将函数声明为引用,就是可以避免对象的拷贝。如果函数占用了很大的空间,值传递可以就拷贝整个对象的空间,另外,程序可读性更强。

    Class Object
    {// 实现省略,只需要知道我们在这里声明了一个类,在下面我们要将这个类的对象作为
     // 函数参数类型来使用};
    void fun1(Object obj)    // 值传递
    {
         // 此函数声明中,obj是值传递,会产生一个临时对象
    }
    void fun2(Object &obj)   // 引用传递
    {
        // 我们不用检查obj是否为空,同时,使用引用传递,可以避免临时对象
    }
    

    特殊使用——常量引用初始化

    int &a = 10;			// 错误。引用是变量的别名,而10是常量10;
    const int &a = 10;		// 正确。
    

    上述代码第二行是可以执行代码的,编译器对这种操作进行特殊处理,具体如下:

    int tmp = 10;				// 第一步:将常量存放在一个临时变量中
    const int &a = tmp;			// 第二步:使用临时变量进行初始化
    

    参考回答

    • **指针是变量的地址,引用是变量的别名。**指针的值是另一个变量的地址,而引用是别名,所以在某些运算方面,有着一定的差异

      1. sizeof运算符(x64环境下测试)

        int a = 10;						// 原始变量
        int *p = &a;					// 指针p指向变量a的地址
        int &c = *p;					// *p结果为a,引用c指向的原变量a,c是a的别名
        cout << sizeof(p) << endl;		// 指针本身所占的空间,8个字节
        cout << sizeof(c) << endl;		// 取决于原变量的大小,a为int,4个字节
        
      2. 自增运算符。指针自增,指向下一个地址空间,引用自增,是对原变量的自增。

    • 指针可以不初始化,引用必须初始化。空指针可以有,空引用不能有) 对于指针来说,它是一个地址,这个地址是一个数值,那么就意味这个数值可以为0(空指针),也可以为其他,即指针可以不指向任何东西;而对于引用来说,他是一个外号,外号一定是“某个存在物体”的外号,所以引用不能为空,即不能存在空引用。

    • 指针需要解引用,引用直接使用。

  2. 指针常量和常量指针

    知识点梳理:

    • 指针常量 — 指针类型的常量,本质上是一个常量。(指针符号在前,const在后。)
    int a = 10;
    int* const p = &a;
    *p = 30;
    
    1. const修饰指针p,因此指针p是常量,故指针p指向的地址不可用改变
    2. *p是可以改变的,也就是**p指向的地址的内容是可以更改的**;
    • 常量指针 — 指向“常量”的指针。(const在前,指针符号在后)
    int a = 10;
    const int* p = &a;
    
    1. const修饰*p的,因此*p的内容不可以更改;
    2. p本身是一个地址,所以p是可以改变的;
  3. this指针

内存管理

  1. 在程序中,数据存储在不同的区段,通常分为5个部分

    • 栈存储区:主要存储函数参数局部变量,这部分数据的空间由编译器负责分配和回收,效率高,存储数据时采取“后进先出”的方式;分配容量有限;
    • 堆存储区:主要存储动态分配的内存块,程序员负责分配和回收, 如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收;
    • 全局/静态存储区:主要存储全局变量静态变量,这类变量比较特殊,其生命周期在程序运行期间始终存在,程序结束时操作系统才会回收这部分空间;
    • 常量存储区: 这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改。
    • 自由存储区: 就是那些由malloc等分配的内存块,他和堆是十分相似的,不过它是用free来结束自己的生命的;
  2. 栈和堆的区别

    int *p = new int[5];
    

    上述语句就包含了堆和栈,new,表示分配了一块堆内存,p是局部指针变量,分配的一块栈内存,所以这句话的意思就是:在栈内存中存放了一个指向一块堆内存的指针p

    区别:

    • 管理方式
      • 栈:由编译器自动管理,无需手动控制;
      • 堆:释放工作由程序员控制,容易产生memory leak(内存泄漏);
    • 空间大小
      • 栈:有一定大小空间限制,是一块连续的内存的区域,比较小,几M;
      • 堆:不连续的区域,大小受限于计算机的虚拟内存,堆获得的空间比较灵活,也比较大;
    • 碎片问题
      • 栈:栈是先进后出的队列,是一块连续的内存区域;
      • 堆:频繁的new/delete势必会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低;
    • 生长方向
    • 栈:生长方向是向下的,是向着内存地址减小的方向增长;
    • 堆:生长方向是向上的,是向着内存地址增加的方向增长;
    • 分配方式
    • 栈 - 静态分配:是编译器完成的,比如局部变量的分配 - 动态分配:由alloca函数进行分配,由编译器自动释放
    • 堆:只有动态分配
  3. 内存泄漏

    • 使用智能指针代替普通指针,因为智能指针自带引用计数功能,能够动态分配空间的引用数量,在引用技术为零时,自动调用析构函数释放空间;
    • 借助一些内存泄漏检测工具;
    • 程序员良好的编程习惯:malloc/free、new/delete成对出现;
  4. 智能指针

    为什么需要智能指针

    C++内存管理是一个头疼的问题,因为在确保在正确的时间释放内存是及其困难的。为了更容易和更安全的使用动态内存,建议使用智能指针

    头文件:#include <memory>

    • C++ 98 —> auto_ptr

      auto_ptr<string> ps (new string(str));
      
    • C++ 11

      • shared_ptr
      • unique_ptr
      • weak_ptr
      • auto_ptr(C++ 11弃用)
    • shared_ptr多个智能指针可以共享同一个对象的所有权,通过引用计数指向同一对象的智能指针个数,每增加一个智能指针指向对象时,引用计数加1,功能对象的最末一个拥有着有责任销毁对象,并清理与该对象相关的所有资源;

    • weak_ptr:weak_ptr是一种不控制所有对象生存期的智能指针,它指向由一个shared_ptr管理的对象,将一个weak_ptr绑定到一个shared_ptr不会改变shared_ptr的引用计数。一旦最末一个指向对象的shared_ptr被销毁,对象就被释放,即使有weak_ptr指向对象;

    • unique_ptr:与shared_ptr不同,某个时刻只能有一个unique_ptr指向一个给定的对象。当unique_ptr被销毁时,它所指向的对象也被销毁;unique_ptr唯一拥有所指向对象的所有权,不支持拷贝和赋值操作,只能通过move函数将其所有权转移给其它智能指针

    1. 设计思想: 将基本类型指针封装为类对象指针,并在析构函数里编写delete语句删除指针指向的内存;

    2. 为什么摒弃auto_ptr避免潜在的内存崩溃问题。

      auto_ptr<string> ps(new string("I am very happy!"));
      auto_ptr<string> pt;
      pt = ps;
      

      一个对象只能由一个auto_ptr所拥有,在给其他auto_ptr赋值的时候,会转移这种拥有关系,原指针在失去对象所有权时成为空指针。因此pt = ps pt接管ps所有权,ps所有权被剥夺,这两个指针将指向同一个string对象。这是不能接受的,因为程序将试图删除同一个对象两次——一次是ps过期时,另一次是pt过期时。

    3. unique_ptr为什么优于auto_ptr?

      auto_ptr<string> p1(new string("auto"));
      auto_ptr<string> p2;
      p2 = p1;      								// #3
      

      在语句#3中,p2接管string对象的所有权后,p1的所有权将被剥夺。前面说过,这是好事,可防止p1和p2的析构函数试图刪同—个对象;但如果程序随后试图使用p1,这将是件坏事,因为p1不再指向有效的数据。

      unique_ptr<string> p3 (new string ("auto");   //#4
      unique_ptr<string> p4;                       //#5
      p4 = p3;                                      //#6
      

      编译器认为语句#6非法,避免了p3不再指向有效数据的问题。因此,unique_ptr比auto_ptr更安全。

      unique_ptr禁止赋值操作。unique_ptr在内存安全性、充当容器元素和支持动态数组方面均优于auto_ptr。

      智能指针参考链接

    4. 环状引用问题及其解决方案

      **两个shared_ptr指针所指向对象的数据成员中,如果含有指向对象的shared_ptr指针,则会产生环状引用。**环状引用导致释放资源时发生死锁,引用计数不会降为0,造成对象空间无法释放,这时就需要weak_ptr,因为不会改变shared_ptr的引用计数。

    5. shared_ptr线程安全问题

      boost官方文档描述:

      • 一个 shared_ptr 实例可以同时被多个线程“读”(仅使用不变操作进行访问);

      • 不同的 shared_ptr 实例可以同时被多个线程“写入”(使用类似operator=reset 这样的可变操作进行访问)(即使这些实例是拷贝,而且共享下层的引用计数)。

        所谓的原子操作,取的就是“原子是最小的、不可分割的最小个体”的意义,它表示在多个线程访问同一个全局资源的时候,能够确保所有其他的线程都不在同一时间内访问相同的资源。也就是他确保了在同一时刻只有唯一的线程对这个资源进行访问。这有点类似互斥对象对共享资源的访问的保护,但是原子操作更加接近底层,因而效率更高。

      • shared_ptr的引用计数本身是安全且无锁的,但对象的读写则不是,因为 shared_ptr 有两个数据成员,读写操作不能原子化;

      • shared_ptr引用计数是原子的,它的析构函数原子地将引用计数减去1,当多个线程对同一对象析构时,也只会出现执行顺序的交错,不会有内存泄露。 那么同理shared_ptr的构造函数赋值析构都是线程安全的;

      • 不同的shared_ptr对象可以被多线程同时修改(即使这些shared_ptr对象管理着同一个对象的指针);

      • 如果要从多个线程读写同一个 shared_ptr 对象,无法确保编译器是先操作引用计数还是先操作指针,在这个时候就需要加锁, 那么需要加锁

      https://www.jianshu.com/p/c75a0d33655c

面向对象

  1. 三大特征:封装继承多态

    • 封装: **细节隐藏,使代码模块化。**C++支持数据封装,类是支持数据封装的工具,对象是数据封装的实现。封装体与外界进行信息交换是通过操作接口进行的,这种访问控制机制体现在类的成员可以有公有成员(public),私有成员(private),保护成员(protected)。

      • public 成员:可以被任意实体访问;
      • private 成员:只允许被本类的成员函数、友元类或友元函数访问;
      • protected 成员:只允许被子类及本类的成员函数访问;
    • 继承:基类(父类)——> 派生类(子类)

      **扩展已存在的代码模块,充分利用代码资源。**C++语言允许单继承和多继承。继承是面向对象语言的重要特性。一个类可以根据需要生成它的派生类,派生类还可以再生成派生类。派生类继承基类的成员,另外,还可以定义自己的成员。继承是实现抽象和共享的一种机制。

      具体来讲:继承是指这样一种能力:它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。其继承的过程,就是从一般到特殊的过程。通过继承创建的新类称为“子类”或“派生类”。被继承的类称为“基类”、“父类”或“超类”。要实现继承,可以通过“继承”(Inheritance)和“组合”(Composition)来实现。但是一般情况下,一个子类只能有一个基类,要实现多重继承,可以通过多级继承来实现。继承的实现方式有三类:实现继承、接口继承和可视继承

      • 实现继承是指使用基类的属性和方法而无需额外编码的能力;
      • 接口继承是指仅使用属性和方法的名称、但是子类必须提供实现的能力;
      • 可视继承是指子窗体(类)使用基窗体(类)的外观和实现代码的能力;
    • 多态: 多态性是指对不同类的对象发出相同的消息将会有不同的实现,也就是接口重用。通过重载函数重载运算符重载)和函数覆盖/重写两种方法实现。

      • 重载 —— 必须在同一类中,重载分为函数重载运算符重载

        • 函数重载是指不同的函数使用相同的函数名,但是函数的参数个数或类型不同。调用的时候根据函数的参数来区别不同的函数;

        • 运算符重载是带有特殊名称的函数,函数名是由关键字 operator 和其后要重载的运算符符号构成的,与其他函数一样,重载运算符有一个返回类型和一个参数列表。运算符重载是对已有的运算符赋予多重含义,使同一个运算符作用域不同类型的数据导致不同行为的发生。

          为了理解运算符重载,举一个例子,在C++中,“+”左右两边的对象可以是浮点型,也可以是整型,为什么同一个运算符”+“可以用于完成不同类型的数据的加法运算?这是因为C++针对预定义基本数据类型已经对”+“运算符做了适当的重载。在编译程序编译不同类型数据的加法表达式时,会自动调用相应类型的加法运算符重载函数。但是C++中所提供的预定义的基本数据类型毕竟是有限的,在解决一些实际的问题时,往往需要用户自定义数据类型。比如高中数学里所提到的复数,复数相加时,需要对应的实部和虚部分别相加再组合成新的复数,在C++里的加号“+”是不能完成这样的任务的,故C++提供了一种方法,即运算符重载函数,其函数名字规定为 operator后紧跟重载运算符。比如operator+或者operator*,请看例子:

          #include <iostream>
          using namespace std;
          // 定义复数类
          class Complex
          {
          public:
          	double real;
          	double imag;
          	Complex(double real = 0, double imag = 0)
          	{
                 this->real = real;
                 this->imag = imag;
          	}
                   
          };
          // 运算符重载
          Complex operator+(Complex num1, Complex num2)
          {
          	return Complex(num1.real + num2.real, num1.imag + num2.imag);
          }
                   
          int main()
          {
          	Complex num1(19, 20), num2(21, 20), sum;
          	sum = operator+(num1, num2);
          	cout << "复数相加:" << sum.real << " + i" << sum.imag << endl;
                   
          	return 0;
          }
          
      • 覆盖/重写 —— 必须发生于父类与子类之间,并且父类与子类中的函数必须有完全相同的原型,使用virtual声明之后能够产生多态(如果不使用virtual,那叫重定义,这句话非常重要!!!) ,**即在派生类中重新对基类中的虚函数(注意是虚函数)重新实现,也就是说函数名和参数都一样,只是函数的实现体不一样。**多态是在运行期间根据具体对象的类型决定函数调用,虚函数来支持动态联编,动态联编是多态性的一个重要的特征动态联编是指在程序执行的时候才将函数实现和函数调用关联,因此也叫运行时绑定或者晚绑定,动态联编对函数的选择不是基于指针或者引用,而是基于对象类型,不同的对象类型将做出不同的编译结果。 C++中一般情况下联编也是静态联编,但是一旦涉及到多态和虚拟函数就必须要使用动态联编了。

  2. 重载函数的二义性

  3. 构造函数和析构函数的执行顺序

    类的对象也是一个局部变量,局部变量存放在当前的作用域的中,因此构造函数在创建时,执行入栈操作,调用构造函数;销毁时,调用析构函数,执行出栈操作;

  4. 类的默认成员函数

    class Base
    {
    public:
        Base() {};				// 构造函数
        Base(const Base &a);    // 拷贝构造函数
        Base &operator = (const Base &a);    // 运算符重载函数
        ~Base() {};				// 析构函数
    }
    
    • 构造函数
      • 构造函数是用于构造新对象,并将初始值赋给对象的数据成员。;
      • 类型转化,适用于单参的构造函数;
    • 拷贝构造函数
      • 用此类已有的对象创建一个新的对象,一般在函数中会将已存在对象的数据成员的值复制一份到新创建的对象中。用类的一个已知的对象去初始化该类的另一个对象时,会自动调用对象的拷贝构造函数;
      • 拷贝构造函数的参数必须使用引用传参,使用传值方式会引发无穷递归调用;
      • 拷贝构造函数其实是一个构造函数的重载;
      • 浅拷贝和深拷贝
        • 编译器创建的默认拷贝构造函数只会执行"浅拷贝”,也就是通过赋值完成;
        • 如果指针成员是new出来就是“深拷贝”, 对象重新分配空间之后,然后将值拷贝的拷贝方式。 ;
    • 运算符重载函数
      • 赋值运算符重载就是为了代码的可读性,比如我们两个类进行赋值的时候如果不写赋值运算符重载的话就要用一个函数来实现我们的赋值,函数在调用的时候可读性没有我们的“=”高,所以c++实现了运算符的重载;
    • 析构函数
      • 析构函数作用是做一些清理工作,delete一个对象或对象生命周期结束时,会自动调用对象的析构函数;
      • 函数名在类名前加上字符~,没有参数(可以有void类型的参数),也没有返回值,可以为虚函数(通过基类的指针去析构子类对象时候),不能重载,故析构函数只有一个
      • 如果没有显式定义,编译器会自动生成一个默认的析构函数,默认的析构函什么都不会做;
      • 析构顺序:和构造函数顺序相反。析构的过程也是递归的;
  5. 虚析构函数

    • 当基类产生多个派生类时,析构函数可能只进行局部销毁,此时加上virtual关键字,将析构函数声明为虚析构函数,就可以解决对象的释放问题。

    • 构造函数不能为虚函数,因为在构造对象时,必须确切知道类型,才能正确的生成对象;

  6. 纯虚函数

    • 纯虚函数是一种特殊的虚函数,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做;
    • 含有纯虚函数的类为抽象类,不能生成对象;
  7. 虚继承

    • 多重继承:一个子类同时继承多个父类;比如:水上汽车兼具了汽车和轮船的特性

    • 菱形继承:多个父类,又同时继承一个类,这时会产生二义性的问题,这个时候需要虚继承

      class A
      {
      public: int a;
      };
      class B: virtual public A{};
      class C: virtual public A{};
      class D: public B, public: C{};    // 此时类D只有类A的一份拷贝
      

      通过virtual来修饰继承关系,虚继承中的父类称为虚基类

  8. 虚函数表

    • 如果一个类中有虚函数,则这个类就对应一个虚函数表;
    • 虚函数表中的元素是一组指向函数的指针,每个指针指向虚函数的入口地址;
    • 在含有虚函数的类对象模型中,除了对象的数据成员外,还有一个指向虚函数表的指针,称为虚指针vptr,位于对象模型的顶部;
    • 虚函数依靠vptr和vtable来处理。vptr是一个指针,在类的构造函数中创建生成,并且只能用this指针来访问vptr,因为它是类的一个成员,并且vptr指向保存虚函数地址的vtable。 虚函数的调用关系:this -> vptr -> vtable ->virtual function
  9. 为什么在多态时,析构函数是虚函数?

    答案: 为了防止内存泄漏。

    • 析构函数不一定必须是虚函数,是否为虚函数取决于该类的使用,一般该类为基类产生继承和多态时,才会是虚函数,单独使用可以不是虚函数。之所以在继承和多态时设计为虚函数是因为当new派生类并且用基类指针指向这个派生类, 当删除基类指针指向的派生类对象时就不会触发动态绑定在销毁基类指针时只会调用基类的析构函数,不会调用派生类的析构函数,因为基类无法操作派生类中非继承的成员, 那么在这种情况下,派生类中申请的空间就得不到释放从而产生内存泄漏
    • 默认不是虚析构函数是因为如果析构函数为虚函数就需要编译器在类中增加虚函数表来实现虚函数机制,这样所需内存空间就更大了,因此没有必要默认为虚析构函数;
  10. TODO

STL

  1. 容器底部的构造

    • 顺序容器:支持顺序访问元素的功能,根据元素在容器中的位置,按序依次访问容器中的每一个元素,包括:向量vector双向链表list双端数组 dequestring

    • 适配器:举个例子:比如视频输出接口是DVI,显示器接口是HDMI,为了解决接口不匹配的问题,需要一根转换线,可以两种不同的接口连接起来,这就类似于STL里的适配器,即构造了一个容器接口到需求接口之间的转换器。

      适配器对原容器进行了一层封装,低层基于普通容器,上层对外提供封装后的新接口,满足不同使用者的需求。

      常用的适配器包括:栈stack、队列queue、优先级队列priority_queue

    • 关联容器:set、map、multiset、multimap、

  2. vector和数组的不同

  3. vector扩容

  4. TODO