zoukankan      html  css  js  c++  java
  • C++面试常见问题汇总

    引自:https://blog.csdn.net/qq_22238021/article/details/79779574

    一、extern关键字作用

    1、extern用在变量或者函数的声明前,用来说明此变量/函数是在别处定义的,要在此处引用extern声明不是定义,即不分配存储空间。也就是说,在一个文件中定义了变量和函数, 在其他文件中要使用它们, 可以有两种方式:使用头文件,然后声明它们,然后其他文件去包含头文件;在其他文件中直接extern。

    2、extern C作用

    链接指示符extern C
        
    如果程序员希望调用其他程序设计语言尤其是写的函数,那么调用函数时必须告诉编译器使用不同的要求,例如当这样的函数被调用时函数名或参数排列的顺序可能不同,无论是C++函数调用它还是用其他语言写的函数调用它,程序员用链接指示符告诉编译器该函数是用其他的程序设计语言编写的,链接指示符有两种形式既可以是单一语句形式也可以是复合语句形式。
    // 
    单一语句形式的链接指示符
    extern "C" void exit(int);
    // 
    复合语句形式的链接指示符
    extern "C" {
    int printf( const char* ... );
    int scanf( const char* ... );
    }
    // 
    复合语句形式的链接指示符
    extern "C" {
    #include <cmath>
    }
        
    链接指示符的第一种形式由关键字extern 后跟一个字符串常量以及一个普通的函数,声明构成虽然函数是用另外一种语言编写的但调用它仍然需要类型检查例如编译器会检查传递给函数exit()的实参的类型是否是int 或者能够隐式地转换成int 型,多个函数声明可以用花括号包含在链接指示符复合语句中,这是链接指示符的第二种形式花扩号被用作分割符表示链接指示符应用在哪些声明上在其他意义上该花括号被忽略,所以在花括号中声明的函数名对外是可见的就好像函数是在复合语句外声明的一样,例如在前面的例子中复合语句extern "C"表示函数printf()scanf()是在语言中写的,函数因此这个声明的意义就如同printf()scanf()是在extern "C"复合语句外面声明的一样,当复合语句链接指示符的括号中含有#include 时,在头文件中的函数声明都被假定是用链接指示符的程序设计语言所写的,在前面的例子中在头文件<cmath>中声明的函数都是C函数链接指示符不能出现在函数体中下列代码段将会导致编译错误。
    int main()
    {
    // 
    错误链接指示符不能出现在函数内
    extern "C" double sqrt( double );
    305 
    第七章函数
    double getValue(); //ok
    double result = sqrt ( getValue() );
    //...
    return 0;
    }
    如果把链接指示符移到函数体外程序编译将无错误
    extern "C" double sqrt( double );
    int main()
    {
    double getValue(); //ok
    double result = sqrt ( getValue() );
    //...
    return 0;
    }
        
    但是把链接指示符放在头文件中更合适,在那里函数声明描述了函数的接口所属,如果我们希望C++函数能够为程序所用又该怎么办呢我们也可以使用extern "C"链接指示符来使C++函数为程序可用例如。
    // 
    函数calc() 可以被程序调用
    extern "C" double calc( double dparm ) { /* ... */ }
        
    如果一个函数在同一文件中不只被声明一次则链接指示符可以出现在每个声明中它,也可以只出现在函数的第一次声明中,在这种情况下第二个及以后的声明都接受第一个声明中链接指示符指定的链接规则例如
    // ---- myMath.h ----
    extern "C" double calc( double );
    // ---- myMath.C ----
    // 
    Math.h 中的calc() 的声明
    #include "myMath.h"
    // 
    定义了extern "C" calc() 函数
    // calc() 
    可以从程序中被调用
    double calc( double dparm ) { // ...
        
    在本节中我们只看到为语言提供的链接指示extern "C"extern "C"是惟一被保证由所有C++实现都支持的,每个编译器实现都可以为其环境下常用的语言提供其他链接指示例如extern "Ada"可以用来声明是用Ada 语言写的函数,extern "FORTRAN"用来声明是用FORTRAN 语言写的函数,等等因为其他的链接指示随着具体实现的不同而不同所以建议读者查看编译器的用户指南以获得其他链接指示符的进一步信息。

    总结 extern “C”

           extern “C” 不但具有传统的声明外部变量的功能,还具有告知C++链接器使用C函数规范来链接的功能。 还具有告知C++编译器使用C规范来命名的功能。

    二、static关键字作用

    1、隐藏变量

    当两个文件中存在全局变量时,通过extern关键字可以引用不同文件中的变量。如果加入static关键字,全局变量的作用域在文件内,其他文件无法访问。利用这一特性可以在不同的文件中定义同名函数和同名变量,而不必担心命名冲突。static可以用作函数和变量的前缀,对于函数来讲,static的作用仅限于隐藏.

    2、静态全局变量

    静态全局变量具有全局作用域,如果程序包含多个文件的话,它作用于定义它的文件里,不能作用到其它文件里,即被static关键字修饰过的变量具有文件作用域。这样即使两个不同的源文件都定义了相同名字的静态全局变量,它们也是不同的变量。

    静态全局变量在静态存储区分配空间,在程序刚开始运行时就完成初始化,也是唯一的一次初始化

    全局变量具有全局作用域。全局变量只需在一个源文件中定义,就可以作用于所有的源文件。当然,其他不包含全局变量定义的源文件需要用extern 关键字再次声明这个全局变量。

    局部变量也只有局部作用域,它是自动对象(auto),它在程序运行期间不是一直存在,而是只在函数执行期间存在,函数的一次调用执行结束后,变量被撤销,其所占用的内存也被收回。

    静态局部变量具有局部作用域,它只被初始化一次,自从第一次被初始化直到程序运行结束都一直存在,它和全局变量的区别在于全局变量对所有的函数都是可见的,而静态局部变量只对定义自己的函数体始终可见。

    静态全局变量也具有全局作用域,它与全局变量的区别在于如果程序包含多个文件的话,它作用于定义它的文件里,不能作用到其它文件里,即被static关键字修饰过的变量具有文件作用域。这样即使两个不同的源文件都定义了相同名字的静态全局变量,它们也是不同的变量。

    从分配内存空间看:
    全局变量,静态局部变量,静态全局变量都在静态存储区分配空间,而局部变量在栈里分配空间。

    从以上分析可以看出, 把局部变量改变为静态变量后是改变了它的存储方式即改变了它的生存期。把全局变量改变为静态变量后是改变了它的作用域,限制了它的使用范围。因此static 这个说明符在不同的地方所起的作用是不同的。

    3、默认初始化为0

    全局变量也具备这一属性,因为全局变量也存储在静态数据区。在静态数据区,内存中所有的字节默认值都是0x00,

    最后对static的三条作用做一句话总结。首先static的最主要功能是隐藏,其次因为static变量存放在静态存储区,所以它具备持久性和默认值0. 

    4、修饰普通函数

    static修饰一个函数,则这个函数只能在本文件中调用,不能被同一程序其他文件调用。则其他文件可以定义相同名字的函数,不会发生冲突。

    5、修饰成员变量

    静态数据成员是类的成员,而不是对象的成员,在类中声明static变量或者函数时,初始化时使用作用域运算符来标明它所属类,

    (1)静态数据成员可以实现多个对象之间的数据共享,它是类的所有对象的共享成员,它在内存中只占一份空间,如果改变它的值,则各对象中这个数据成员的值都被改变。 
    (2)静态数据成员是静态存储的,是在程序开始运行时被分配空间,到程序结束之后才释放,只要类中指定了静态数据成员,即使不定义对象,也会为静态数据成员分配空间。非静态成员(变量和方法)属于类的对象,所以只有在类的对象产生(创建类的实例)时才会分配内存,然后通过类的对象(实例)去访问。 

    (3)静态数据成员可以被初始化,但是只能在类体外进行初始化,若未对静态数据成员赋初值,则编译器会自动为其初始化为0 

         静态成员初始化与一般数据成员初始化不同:
           初始化在类体外进行,而前面不加static,以免与一般静态变量或对象相混淆;
           初始化时不加该成员的访问权限控制符private,public等;
            初始化时使用作用域运算符来标明它所属类;
            所以我们得出静态数据成员初始化的格式:
             <数据类型><类名>::<静态数据成员名>=<值>

    (4)静态数据成员既可以通过对象名引用,也可以通过类名引用。

    6、修饰成员函数

    (1)类的静态成员函数是属于整个类而非类的对象,所以它没有this指针,这就导致了它仅能访问类的静态数据成员和静态成员函数。

    (2)不能将静态成员函数定义为虚函数:

     虚函数依靠vptr和vtable来处理。vptr是一个指针,在类的构造函数中创建生成,并且只能用this指针来访问它,因为它是类的一个成员,并且vptr指向保存虚函数地址的vtable.
       对于静态成员函数,它没有this指针,所以无法访问vptr. 这就是为何static函数不能为virtual.
       虚函数的调用关系:this -> vptr -> vtable ->virtual function

    (3)由于静态成员声明于类中,操作于其外,所以对其取地址操作,就多少有些特殊,变量地址是指向其数据类型的指针,函数地址类型是一个“nonmember函数指针”。

    (4)由于静态成员函数没有this指针,所以就差不多等同于nonmember函数,结果就产生了一个意想不到的好处:成为一个callback函数,使得我们得以将C++和C-based X Window系统结合,同时也成功的应用于线程函数身上。
    (5)static并没有增加程序的时空开销,相反它还缩短了子类对父类静态成员的访问时间,节省了子类的内存空间。
    (9)为了防止父类的影响,可以在子类定义一个与父类相同的静态变量,以屏蔽父类的影响。这里有一点需要注意:我们说静态成员为父类和子类共享,但我们又重复定义了静态成员,这会不会引起错误呢?不会,我们的编译器采用了一种绝妙的手法:name-mangling 用以生成唯一的标志

    三、volatile关键字

    (1)访问寄存器要比访问内存要块,因此CPU会优先访问该数据在寄存器中的存储结果,但是内存中的数据可能已经发生了改变,而寄存器中还保留着原来的结果。为了避免这种情况的发生将该变量声明为volatile,告诉CPU每次都从内存去读取数据。

    (2)一个参数可以即是const又是volatile的吗?可以,一个例子是只读状态寄存器,是volatile是因为它可能被意想不到的被改变,是const告诉程序不应该试图去修改他。

    volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。声明时语法:int volatile vInt; 当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。而且读取的数据立刻被保存。

    volatile int iNum = 10;

    volatile 指出 iNum 是随时可能发生变化的,每次使用它的时候必须从原始内存地址中去读取,因而编译器生成的汇编代码会重新从iNum的原始内存地址中去读取数据。而不是只要编译器发现iNum的值没有发生变化,就只读取一次数据,并放入寄存器中,下次直接从寄存器中去取值(优化做法),而是重新从内存中去读取(不再优化).

    多线程并发访问共享变量时,一个线程改变了变量的值,怎样让改变后的值对其它线程 visible。一般说来,volatile用在如下的几个地方: 
    1) 中断服务程序中修改的供其它程序检测的变量需要加volatile; 
    2) 多任务环境下各任务间共享的标志应该加volatile; 

    3) 存储器映射的硬件寄存器通常也要加volatile说明,因为每次对它的读写都可能由不同意义;

    volatile 指针

        和 const 修饰词类似,const 有常量指针和指针常量的说法,volatile 也有相应的概念:

    • 修饰由指针指向的对象、数据是 const 或 volatile 的:

      const char* cpch;
      volatile char* vpch;

      注意:对于 VC,这个特性实现在 VC 8 之后才是安全的。

    • 指针自身的值——一个代表地址的整数变量,是 const 或 volatile 的:

            char* const pchc;   

            char* volatile pchv; 

    注意:(1) 可以把一个非volatile int赋给volatile int,但是不能把非volatile对象赋给一个volatile对象

         (2) 除了基本类型外,对用户定义类型也可以用volatile类型进行修饰。
            (3) C++中一个有volatile标识符的类只能访问它接口的子集,一个由类的实现者控制的子集。用户只能用const_cast来获得对类型接口的完全访问。此外,volatile像const一样会从类传递到它的成员。

    多线程下的volatile   

        有些变量是用volatile关键字声明的。当两个线程都要用到某一个变量且该变量的值会被改变时,应该用volatile声明,该关键字的作用是防止优化编译器把变量从内存装入CPU寄存器中。如果变量被装入寄存器,那么两个线程有可能一个使用内存中的变量,一个使用寄存器中的变量,这会造成程序的错误执行。volatile的意思是让编译器每次操作该变量时一定要从内存中真正取出,而不是使用已经存在寄存器中的值,如下: 

      volatile  BOOL  bStop  =  FALSE;  
       (1) 在一个线程中:  
      while(  !bStop  )  {  ...  }  
      bStop  =  FALSE;  
      return;    
       (2) 在另外一个线程中,要终止上面的线程循环:  
      bStop  =  TRUE;  
      while(  bStop  );  //等待上面的线程终止,如果bStop不使用volatile申明,那么这个循环将是一个死循环,因为bStop已经读取到了寄存器中,寄存器中bStop的值永远不会变成FALSE,加上volatile,程序在执行时,每次均从内存中读出bStop的值,就不会死循环了。
        这个关键字是用来设定某个对象的存储位置在内存中,而不是寄存器中。因为一般的对象编译器可能会将其的拷贝放在寄存器中用以加快指令的执行速度,例如下段代码中:  
      ...  
      int  nMyCounter  =  0;  
      for(;  nMyCounter<100;nMyCounter++)  
      {  
      ...  
      }  
      ...  
       在此段代码中,nMyCounter的拷贝可能存放到某个寄存器中(循环中,对nMyCounter的测试及操作总是对此寄存器中的值进行),但是另外又有段代码执行了这样的操作:nMyCounter  -=  1;这个操作中,对nMyCounter的改变是对内存中的nMyCounter进行操作,于是出现了这样一个现象:nMyCounter的改变不同步。

    四、const的作用

    只要一个变量前面用const来修饰,就意味着该变量里的数据可以被访问,不能被修改。也就是说const意味着“只读”
    规则:const离谁近,谁就不能被修改;
    const修饰一个变量,一定要给这个变量初始化值,若不初始化,后面就无法初始化。

    const修饰全局变量;

    const修饰局部变量; 

    const修饰指针,const int *p1或int const *p1;p1指向的数据不能被修改,但p1本身的值可以被修改(指向其他数据)const修饰指针指向的对象, int * const p2;p2本身的值不能被修改,但它指向的数据可以被修改。(const int *const p3;指针和所指向数据都是常量)const修饰引用做形参;

    const 通常用在函数形参中,如果形参是一个指针,为了防止在函数内部修改指针指向的数据,就可以用 const 来限制。

    在C语言标准库中,有很多函数的形参都被 const 限制了,下面是部分函数的原型:

    1. size_t strlen const char * str );
    2. int strcmp const char * str1const char * str2 );
    3. char strcat char * destinationconst char * source );
    4. char strcpy char * destinationconst char * source );
    5. int system (const char* command);
    6. int puts const char * str );
    7. int printf const char * format... );

    const修饰成员变量,必须在构造函数列表中初始化; const修饰成员函数,常量成员函数,说明该函数不应该修改非静态成员,但是这并不是十分可靠的,指针所指的非成员对象值可能会被改变。
    const成员函数可以被const或非const对象调用,但const对象只能调用const成员函数对于类的成员函数,有时候必须指定其返回值为const类型,以使得其返回值不为“左值”,只能作为右值使用。

    constint getNum();getNum() = 10; // 提示语法错误!

    五、new与malloc的区别

    new分配内存按照数据类型进行分配,malloc分配内存按照大小分配; new不仅分配一段内存,而且会调用构造函数,但是malloc则不会。new的实现原理?但是还需要注意的是,之前看到过一个题说int p = new int与int p = new int()的区别,因为int属于C++内置对象,不会默认初始化,必须显示调用默认构造函数,但是对于自定义对象都会默认调用构造函数初始化。翻阅资料后,在C++11中两者没有区别了,自己测试的结构也都是为0; new返回的是指定对象的指针,而malloc返回的是void*,因此malloc的返回值一般都需要进行类型转化; new是一个操作符可以重载,malloc是一个库函数; new分配的内存要用delete销毁,malloc要用free来销毁;delete销毁的时候会调用对象的析构函数,而free则不会; malloc分配的内存不够的时候,可以用realloc扩容。扩容的原理?new没用这样操作;
    realloc是从堆上分配内存的.当扩大一块内存空间时,realloc()试图直接从堆上现存的数据后面的那些字节中获得附加的字节,如果能够满足,自然天下太平;如果数据后面的字节不够,那么就使用堆上第一个有足够大小的自由块,现存的数据然后就被拷贝至新的位置,而老块则放回到堆上.这句话传递的一个重要的信息就是数据可能被移动.

    1. int len =7;  
    2. int * a = (int *) malloc (sizeof(int) * len);  
    3. len++;  
    4. int * aold = a;            //重新分配前保存a的地址  这个是多余的  
    5. a = (int *)realloc(sizeof(int)* len);   //重新分配28+4 = 32字节内存给数组a  
      前面两句定义了1个长度为7的int 类型数组, 每个元素的字节长度是4, 所以共占28byte 内存.
      第3句长度变量+1
      第4句 分两种情况:
         1) 假如数组a 内存里接着的4个字节还没被其他对象或程序占用, 那么就直接把后面4个字节加给数组a, 数组前面7个旧的元素的值不变,  数组a的头部地址也不变.

         2) 假如数组 a内存里接着的4个字节已经被占用了, 那么realloc 函数会在内存其他地方找1个连续的32byte 内存空间, 并且把数组a的7个旧元素的值搬过去,  所以数组a的7个旧元素的值也不变, 但是数组a的头部地址变化了.  但是这时我們无需手动把旧的内存空间释放. 因为realloc 函数改变地址后会自动释放旧的内存, 再手动释放程序就会出错了

    new如果分配失败了会抛出bad_alloc的异常,而malloc失败了会返回NULL。因此对于new,正确的姿势是采用try...catch语法,而malloc则应该判断指针的返回值。为了兼容很多c程序员的习惯,C++也可以采用new(nothrow) 的方法禁止抛出异常而返回NULL:

    1.  
      #include <new>//必须使用new头文件
    2.  
      Manager * pManager = new (nothrow) Manager();
    3.  
      if(NULL == pManager)
    4.  
      {
    5.  
      //记录日志
    6.  
      return false;
    7.  
      }
    1.  
      void *__CRTDECL operator new(size_t size) _THROW1(_STD bad_alloc)
    2.  
      { // try to allocate size bytes
    3.  
      void *p;
    4.  
      while ((p = malloc(size)) == 0)
    5.  
      if (_callnewh(size) == 0)
    6.  
      { // report no memory
    7.  
      _THROW_NCEE(_XSTD bad_alloc, );
    8.  
      }
    9.  
       
    10.  
      return (p);
    11.  
      }

    调用malloc失败后会调用_callnewh。如果_callnewh返回0则抛出bac_alloc异常,返回非零则继续分配内存。 _callnewh是 一个new handler,通俗来讲就是new失败的时候调用的回调函数。可以通过_set_new_handler来设置。

    new和new[]的区别,new[]一次分配所有内存,多次调用构造函数,分别搭配使用delete和delete[],同理,delete[]多次调用析构函数,销毁数组中的每个对象。而malloc则只能sizeof(int) * n;如果不够可以继续谈new和malloc的实现,空闲链表,分配方法(首次适配原则,最佳适配原则,最差适配原则,快速适配原则)。delete和free的实现原理,free为什么知道销毁多大的空间?

    六、多态与虚函数表vbtl
    C++多态的实现?
    多态分为静态多态和动态多态。静态多态是通过重载和模板技术实现,在编译的时候确定。动态多态通过虚函数和继承关系来实现,执行动态绑定,在运行的时候确定。
    动态多态实现有几个条件:
    (1) 虚函数;
    (2) 一个基类的指针或引用指向派生类的对象;
    基类指针在调用成员函数(虚函数)时,就会去查找该对象的虚函数表。虚函数表的地址vptr在每个对象的首地址。查找该虚函数表中该函数的指针进行调用。
    每个对象中保存的只是一个虚函数表的指针,C++内部为每一个类维持一个虚函数表,该类的对象都指向这同一个虚函数表。
    虚函数表中为什么就能准确查找相应的函数指针呢?因为在类设计的时候,虚函数表直接从基类也继承过来,如果覆盖了其中的某个虚函数,那么虚函数表的指针就会被替换,因此可以根据指针准确找到该调用哪个函数。点击打开链接

    虚函数的作用?

    1. 虚函数用于实现多态,这点大家都能答上来
    2. 但是虚函数在设计上还具有封装和抽象的作用。比如抽象工厂模式。

    动态绑定是如何实现的?
    第一个问题中基本回答了,主要都是结合虚函数表来答就行。

    静态多态和动态多态。静态多态是指通过模板技术或者函数重载技术实现的多态,其在编译器确定行为。动态多态是指通过虚函数技术实现在运行期动态绑定的技术。

    虚函数表

    虚函数表是针对类的还是针对对象的?同一个类的两个对象的虚函数表是怎么维护的?

    编译器为每一个类维护一个虚函数表,每个对象的首地址保存着该虚函数表的指针,同一个类的不同对象实际上指向同一张虚函数表。
    七、纯虚函数如何定义,为什么对于存在虚函数的类中析构函数要定义成虚函数

    为了实现多态进行动态绑定,将派生类对象指针绑定到基类指针上,对象销毁时,如果析构函数没有定义为虚析构函数,则会调用基类的析构函数,显然只能销毁部分数据。如果要调用对象的析构函数,就需要将该对象的析构函数定义为虚函数,销毁时通过虚函数表找到对应的析构函数。

    纯虚函数定义:

    virtual ~myClass()=0;

    八、析构函数能抛出异常吗

    答案肯定是不能。

    • C++标准指明析构函数不能、也不应该抛出异常。C++异常处理模型最大的特点和优势就是对C++中的面向对象提供了最强大的无缝支持。那么如果对象在运行期间出现了异常,C++异常处理模型有责任清除那些由于出现异常所导致的已经失效了的对象(也即对象超出了它原来的作用域),并释放对象原来所分配的资源, 这就是调用这些对象的析构函数来完成释放资源的任务,所以从这个意义上说,析构函数已经变成了异常处理的一部分。

    (1) 如果析构函数抛出异常,则异常点之后的程序不会执行,如果析构函数在异常点之后执行了某些必要的动作比如释放某些资源,则这些动作不会执行,会造成诸如资源泄漏的问题。

    (2) 通常异常发生时,c++的机制会调用已经构造对象的析构函数来释放资源,此时若析构函数本身也抛出异常,则前一个异常尚未处理,又有新的异常,会造成程序崩溃的问题。

    九、构造函数和析构函数中调用虚函数吗?

    从语法上讲,调用完全没有问题。但是从效果上看,往往不能达到需要的目的。
    Effective C++的解释是:派生类对象的基类成分会在派生类自身成分被构造之前先构造妥当,
    派生类对象构造期间会首先进入基类的构造函数,在基类构造函数执行时继承类的成员变量尚未初始化,对象类型是基类类型,而不是派生类类型,虚函数会被编译器解析为基类,若使用运行时类型信息,也会把对象视为基类类型,构造期间调用虚函数,会调用自己的虚函数,此时虚函数和普通函数没有区别了,达不到多态的效果。

    同样,进入基类析构函数时,对象也是基类类型。C++中派生类在构造时会先调用基类的构造函数再调用派生类的构造函数,析构时则相反,先调用派生类的析构函数再调用基类的构造函数。一旦派生类析构函数运行,这个对象的派生类数据成员就被视为未定义的值,所以 C++ 就将它们视为不再存在。假设一个派生类的对象进行析构,首先调用了派生类的析构,然后再调用基类的析构时,遇到了一个虚函数,这个时候有两种选择:Plan A是编译器调用这个虚函数的基类版本,那么虚函数则失去了运行时调用正确版本的意义;Plan B是编译器调用这个虚函数的派生类版本,但是此时对象的派生类部分已经完成析构,“数据成员就被视为未定义的值”,这个函数调用会导致未知行为。

            所以,虚函数始终仅仅调用基类的虚函数(如果是基类调用虚函数),不能达到多态的效果,所以放在构造函数中是没有意义的,而且往往不能达到本来想要的效果。

    十、指针和引用的区别点击打开链接

    指针保存的是所指对象的地址,引用是所指对象的别名,

    指针需要通过解引用间接访问,而引用是直接访问;

    指针可以改变地址,从而改变所指的对象,而引用必须从一而终,总是指向最初获得的对象; 

    引用在定义的时候必须初始化,而指针则不需要; 

    指针有指向常量的指针和指针常量,而引用没有常量引用???

    指针更灵活,用的好威力无比,用的不好处处是坑,而引用用起来则安全多了,但是比较死板。

    没有所谓的空引用,但可以有空指针。

    引用可能比使用指针更高效,因为使用引用不用测试其有效性

    1.  
      double dval = 3.14;
    2.  
      const int &ri = dval;
    3.  
      dval = 5;//dval=5,ri=3

    当一个常量引用被绑定到另一种类型上时发生什么,上面那段代码中,ri引用了int型的数,对ri的操作应该是整数运算,但dval却是一个双精度浮点数而非整数。因此为了确保ri绑定的是一个整数,编译器会把上面那段代码变成:

    1.  
      double dval = 3.14;
    2.  
      const int temp = dval;
    3.  
      const int &ri = temp;

    ri绑定的对象并不是dval,而是一个临时量temp,所以此时无论你怎么改变dval的值都不会影响到ri的值,这就是为什么修改dval的值为5后ri的值认为3的原因。

    1.  
      double dval = 3.14;
    2.  
      int temp = dval;
    3.  
      int & ri = temp;

    如果ri不是常量,就允许对ri赋值,这样就会改变ri所绑定对象的值,值得注意的是,上面ri绑定的是临时量temp,我们既然让ri绑定dval,就肯定是想通过ri来改变dval的值,否则干嘛要给ri赋值呢,如此,我们也不会想着把引用绑定到临时量上面吧,所以C++也把这种行为归为非法,即不允许将一个非常量引用绑定到另外一种类型的对象上.

    如果是对一个常量进行引用,则编译器首先建立一个临时变量,然后将该常量的值置入临时变量中,对该引用的操作就是对该临时变量的操作。对常量的引用(常量引用)可以用其它任何引用来初始化,但不能改变:(初始化常量引用时允许用任意表达式作为初始值
    1.  
      int i = 42;
    2.  
      const int &r1 = i; //正确:允许将const int & 绑定到一个普通int对象上
    3.  
      const int &r2 = 42; //正确
    4.  
      const int &r3 = r1 * 2; //正确
    5.  
      int &r4 = r1 * 2; //错误 ,相当于int &r4=84;不对,初始化值是一个左值,只能对const T&(常量引用)赋值
    6.  
       
    7.  
      double dval = 3.14;
    8.  
      const int &ri = dval; //正确
    9.  
      //等价于
    10.  
      const int temp = dval;
    11.  
      const int &ri = temp;
    12.  
       
    13.  
      int i = 42;
    14.  
      int &r1 = i;
    15.  
      const int &r2 = i;
    16.  
      r1 = 0; //正确
    17.  
      r2 = 0; //错误 ,常量引用不能改变
    关于引用的初始化有两点值得注意:
      (1)当初始化值是一个左值(可以取得地址)时,没有任何问题;
      (2)当初始化值不是一个左值时,则只能对一个const T&(常量引用)赋值。而且这个赋值是有一个过程的:
      首先将值隐式转换到类型T,然后将这个转换结果存放在一个临时对象里,最后用这个临时对象来初始化这个引用变量。
      例子:
      double& dr = 1; // 错误:需要左值
      const double& cdr = 1; // ok
      第二句实际的过程如下:
      double temp = double(1);
      const double& cdr = temp; 

    十一、指针与数组千丝万缕的联系

    1. 一个一维int数组的数组名实际上是一个int* const p类型;p指向第一个元素
    2. 一个二维int数组的数组名实际上是一个int (*const p)[n];p指向第一行
    3. 数组名做参数会退化为指针,除了sizeof。
    1.  
      int num =0 ; //在32机器中告诉C编译器分配4个字节的内存
    2.  
      int a [] = {1,3,5,12,6,7,54,32}; //告诉C编译器分配32个字节的内存
    3.  
       
    4.  
      printf("a:%d ,a+1:%d,&a:%d,&a+1:%d ",a,a+1,&a,&a+1) ;
    5.  
       
    6.  
      //a+1 和 &a+1 结果不一样
    7.  
      //虽然输出结果上面,a和&a一样 。 但是a 和 &a所代表的数据类型不一样
    8.  
       
    9.  
      /*重要*/
    10.  
      //a 代表的数据首元素的地址 (首元素),同时与整个数组地址重合,但其不能代表整个数组,只能代表起始个体的地址
    11.  
      //&a代表的是整个数组的地址 (特别特别的注意) 它的加1是以整块数组所占字节数总数为单位1

    输出结果:a:1638176 ,    a+1:1638180,    &a:1638176,    &a+1:1638208

    假如有一维数组如下:

      char a[3];

          该数组一共有3个元素,元素的类型为char,如果想定义一个指针指向该数组,也就是如果想把数组名a赋值给一个指针变量,那么该指针变量的类型应该是什么呢?前文说过,一个数组的数组名代表其首元素的首地址,也就是相当于&a[0],而a[0]的类型为char,因此&a[0]类型为char *,因此,可以定义如下的指针变量:  

      char * p = a;//相当于char * p = &a[0]

          以上文字可用如下内存模型图表示。

          大家都应该知道,a和&a[0]代表的都是数组首元素的首地址,而如果你将&a的值打印出来,会发现该值也等于数组首元素的首地址。请注意我这里的措辞,也就是说,&a虽然在数值上也等于数组首元素首地址的值,但是其类型并不是数组首元素首地址类型,也就是char *p = &a是错误的。

          对数组名进行取地址操作,其类型为整个数组,因此,&a的类型是char (*)[3],所以正确的赋值方式如下: 

      char (*p)[3] = &a;

          注:很多人对类似于a+1,&a+1,&a[0]+1,sizeof(a),sizeof(&a)等感到迷惑,其实只要搞清楚指针的类型就可以迎刃而解。比如在面对a+1和&a+1的区别时,由于a表示数组首元素首地址,其类型为char *,因此a+1相当于数组首地址值+sizeof(char)即第二个元素的地址;而&a的类型为char (*)[3],代表整个数组,因此&a+1相当于数组首地址值+sizeof(a)。(sizeof(a)代表整个数组大小,但是无论数组大小如何,sizeof(&a)永远等于一个指针变量占用空间的大小,具体与系统平台有关

      假如有如下二维数组:

      char a[3][2];

          由于实际上并不存在多维数组,因此,可以将a[3][2]看成是一个具有3个元素的一维数组,只是这三个元素分别又是一个一维数组。实际上,在内存中,该数组的确是按照一维数组的形式存储的,存储顺序为(低地址在前):a[0][0]、a[0][1]、a[1][0]、a[1][1]、a[2][0]、a[2][1]。(此种方式也不是绝对,也有按列优先存储的模式)

          为了方便理解,我画了一张逻辑上的内存图,之所以说是逻辑上的,是因为该图只是便于理解,并不是数组在内存中实际的存储模型(实际模型为前文所述)。

         

          如上图所示,我们可以将数组分成两个维度来看,首先是第一维,将a[3][2]看成一个具有三个元素的一维数组,元素分别为:a[0]、a[1]、a[2],其中,a[0]、a[1]、a[2]又分别是一个具有两个元素的一维数组(元素类型为char)。从第二个维度看,此处可以将a[0]、a[1]、a[2]看成自己代表”第二维”数组的数组名,以a[0]为例,a[0](数组名)代表的一维数组是一个具有两个char类型元素的数组,而a[0]是这个数组的数组名(代表数组首元素首地址),因此a[0]类型为char *,同理a[1]和a[2]类型都是char *。而a是第一维数组的数组名,代表首元素首地址,而首元素是一个具有两个char类型元素的一维数组,因此a就是一个指向具有两个char类型元素数组的数组指针,也就是char(*)[2]。

         也就是说,如下的赋值是正确的:

      char (*p)[2]  = a;//a为第一维数组的数组名,类型为char (*)[2]
    
      char * p = a[0];//a[0]为第二维数组的数组名,类型为char *

          同样,对a取地址操作代表整个数组的首地址,类型为数组类型(请允许我暂且这么称呼),也就是char (*)[3][2],所以如下赋值是正确的:  

      char (*p)[3][2] = &a;

    十二、智能指针是怎么实现的?什么时候改变引用计数?

    C++程序设计中使用堆内存是非常频繁的操作,堆内存的申请和释放都由程序员自己管理,malloc / free 到new / delete,再到allocator的出现。程序员自己管理堆内存可以提高程序的效率,但是整体来说堆内存的管理是麻烦的,C++11中引入了智能指针的概念,方便管理堆内存。使用普通指针,容易造成堆内存泄露(忘记释放),二次释放,程序发生异常时内存泄露等问题等,使用智能指针能更好的管理堆内存。

    智能指针是利用了一种叫做RAII(资源获取即初始化)的技术对普通的指针进行封装,这使得智能指针实质是一个对象,行为表现的却像一个指针。智能指针就是一个作用是资源管理的类,它是你在堆栈上声明的类模板,并可通过使用指向某个堆分配的对象的原始指针进行初始化(RAII)。在初始化智能指针后,它将拥有原始指针,这意味着智能指针负责删除原始指针指定的内存, 智能指针析构函数包括要删除的调用,当智能指针超出范围时将调用其析构函数,析构函数会自动释放资源。

    • unique_ptr 
      只允许基础指针的一个所有者。 除非你确信需要 shared_ptr,否则请将该指针用作 POCO 的默认选项。 可以移到新所有者,但不会复制或共享。 替换已弃用的 auto_ptr。 与 boost::scoped_ptr 比较。 unique_ptr 小巧高效,大小等同于一个指针且支持 rvalue 引用,从而可实现快速插入和对 STL 集合的检索。
    • shared_ptr 
      采用引用计数的智能指针。 如果你想要将一个原始指针分配给多个所有者(例如,从容器返回了指针副本又想保留原始指针时),请使用该指针。 直至所有 shared_ptr 所有者超出了范围或放弃所有权,才会删除原始指针。 大小为两个指针:一个用于对象,另一个用于包含引用计数的共享控制块。
    • weak_ptr 
      结合 shared_ptr 使用的特例智能指针。 weak_ptr 提供对一个或多个 shared_ptr 实例拥有的对象的访问,但不参与引用计数。 如果你想要观察某个对象但不需要其保持活动状态,请使用该实例。 在某些情况下,用于断开 shared_ptr 实例间的循环引用。

    shared_ptr的使用

    shared_ptr多个指针指向相同的对象。shared_ptr使用引用计数,每一个shared_ptr的拷贝都指向相同的内存。每使用他一次,内部的引用计数加1,每析构一次,内部的引用计数减1,减为0时,自动删除所指向的堆内存。shared_ptr内部的引用计数是线程安全的,但是对象的读取需要加锁。

    • 初始化。智能指针是个模板类,可以指定类型,传入指针通过构造函数初始化。也可以使用make_shared函数初始化。不能将指针直接赋值给一个智能指针,一个是类,一个是指针。例如std::shared_ptr<int> p4 = new int(1);的写法是错误的
    • 拷贝和赋值。拷贝使得对象的引用计数增加1,赋值使得原对象引用计数减1,当计数为0时,自动释放内存。后来指向的对象引用计数加1,指向后来的对象。
    • get函数获取原始指针
    • 注意不要用一个原始指针初始化多个shared_ptr,否则会造成二次释放同一内存
    • 注意避免循环引用,shared_ptr的一个最大的陷阱是循环引用,循环,循环引用会导致堆内存无法正确释放,导致内存泄漏。循环引用在weak_ptr中介绍

    unique_ptr的使用

      unique_ptr“唯一”拥有其所指对象,同一时刻只能有一个unique_ptr指向给定对象(通过禁止拷贝语义、只有移动语义来实现)。相比与原始指针unique_ptr用于其RAII的特性,使得在出现异常的情况下,动态资源能得到释放。unique_ptr指针本身的生命周期:从unique_ptr指针创建时开始,直到离开作用域。离开作用域时,若其指向对象,则将其所指对象销毁(默认使用delete操作符,用户可指定其他操作)。unique_ptr指针与其所指对象的关系:在智能指针生命周期内,可以改变智能指针所指对象,如创建智能指针时通过构造函数指定、通过reset方法重新指定、通过release方法释放所有权、通过移动语义转移所有权

    1.  
      #include <iostream>
    2.  
      #include <memory>
    3.  
       
    4.  
      int main() {
    5.  
      {
    6.  
      std::unique_ptr<int> uptr(new int(10)); //绑定动态对象
    7.  
      //std::unique_ptr<int> uptr2 = uptr; //不能賦值
    8.  
      //std::unique_ptr<int> uptr2(uptr); //不能拷貝
    9.  
      std::unique_ptr<int> uptr2(uptr.release());//uptr放弃对指针的控制权,将所有权从uptr转移给uptr2
          std::unique_ptr<int> uptr3(new int(1));

    uptr2.reset(uptr3.release());//将所有权从uptr3转移给uptr2,reset释放了uptr2原来指向的内存 } //超過uptr的作用域,內存釋放}

    weak_ptr的使用

      weak_ptr是为了配合shared_ptr而引入的一种智能指针,因为它不具有普通指针的行为,没有重载operator*和->,它的最大作用在于协助shared_ptr工作,像旁观者那样观测资源的使用情况。weak_ptr可以从一个shared_ptr或者另一个weak_ptr对象构造,获得资源的观测权。但weak_ptr没有共享资源,它的构造不会引起指针引用计数的增加。使用weak_ptr的成员函数use_count()可以观测资源的引用计数,另一个成员函数expired()的功能等价于use_count()==0,但更快,表示被观测的资源(也就是shared_ptr的管理的资源)已经不复存在。weak_ptr可以使用一个非常重要的成员函数lock()从被观测的shared_ptr获得一个可用的shared_ptr对象, 从而操作资源。但当expired()==true的时候,lock()函数将返回一个存储空指针的shared_ptr。

    1、构造函数中计数初始化为1

    2、拷贝构造函数中计数值加1;

    3、赋值运算符中,左边的对象引用计数减/1,右边的对象引用计数加1;

    4、析构函数中引用计数减1;

    5、在赋值运算符和析构函数中,如果减1后为0,则调用delete销毁对象并释放它占用的内存

    6、share_prt与weak_ptr的区别?

    1.  
      //share_ptr可能出现循环引用,从而导致内存泄露
    2.  
      class A
    3.  
      {
    4.  
      public:
    5.  
      share_ptr p;
    6.  
      };
    7.  
      class B
    8.  
      {
    9.  
      public:
    10.  
      share_ptr p;
    11.  
      }
    12.  
      int main()
    13.  
      {
    14.  
      while(true)
    15.  
      {
    16.  
      share_prt pa(new A()); //pa的引用计数初始化为1
    17.  
      share_prt pb(new B()); //pb的引用计数初始化为1
    18.  
      pa->p = pb; //pb的引用计数变为2
    19.  
      pb->p = pa; //pa的引用计数变为2
    20.  
      }
    21.  
      //假设pa先离开,引用计数减一变为1,不为0因此不会调用class A的析构函数,因此其成员p也不会被析构,pb的引用计数仍然为2;
    22.  
      //同理pb离开的时候,引用计数也不能减到0
    23.  
      return 0;
    24.  
      }
    25.  
      /*
    26.  
      ** weak_ptr是一种弱引用指针,其存在不会影响引用计数,从而解决循环引用的问题
    27.  
      */
    volatile char* vpch;

    十三、C++四种类型转换static_cast, dynamic_cast, const_cast, reinterpret_cast点击打开链接

    const_cast用于将const变量转为非const static_cast用的最多,对于各种隐式转换,非const转const,void*转指针等, static_cast能用于多态想上转化,如果向下转能成功但是不安全,结果未知; dynamic_cast用于动态类型转换。只能用于含有虚函数的类,用于类层次间的向上和向下转化。只能转指针或引用。向下转化时,如果是非法的对于指针返回NULL,对于引用抛异常。要深入了解内部转换的原理。 reinterpret_cast几乎什么都可以转,比如将int转指针,可能会出问题,尽量少用; 为什么不使用C的强制转换?C的强制转换表面上看起来功能强大什么都能转,但是转化不够明确,不能进行错误检查,容易出错。

    dynamic_cast和typeid一样,是属于c++中的RTTI机制:编译器把一个类的相关信息放在虚表中,然后利用class得到它的虚表指针,再从虚表指针转到虚表,最后才能做检查类型等动作。也就是说,dynamic_cast行为需要一个虚表,而想要虚表,就必须得是一个含有虚函数的多态类型!

    十四、内存对齐的原则

    从0位置开始存储; 变量存储的起始位置是该变量大小的整数倍; 结构体总的大小是其最大元素的整数倍,不足的后面要补齐; 结构体中包含结构体,从结构体中最大元素的整数倍开始存 如果加入pragma pack(n) ,取n和变量自身大小较小的一个。

    结构体:

    1、数据成员对齐规则:结构体(struct)的数据成员,第一个数据成员放在offset为0的地方,之后的每个数据成员存储的起始位置要从该成员大小的整数倍开始(比如int在32位机子上为4字节,所以要从4的整数倍地址开始存储)。

    2、结构体作为成员:如果一个结构体里同时包含结构体成员,则结构体成员要从其内部最大元素大小的整数倍地址开始存储(如struct a里有struct b,b里有char,int ,double等元素,那么b应该从8(即double类型的大小)的整数倍开始存储)。

    3、结构体的总大小:即sizeof的结果。在按之前的对齐原则计算出来的大小的基础上,必须还得是其内部最大成员的整数倍,不足的要补齐(如struct里最大为double,现在计算得到的已经是11,则总大小为16)。

    具体例子:

    1.  
      typedef struct bb
    2.  
      {
    3.  
      int id; //[0]....[3] 表示4字节
    4.  
      double weight; //[8].....[15]      原则1
    5.  
      float height; //[16]..[19],总长要为8的整数倍,仅对齐之后总长为[0]~[19]为20,补齐[20]...[23]     原则3
    6.  
      }BB;
    1.  
      typedef struct aa
    2.  
      {
    3.  
      int id; //[0]...[3]          原则1
    4.  
      double score; //[8]....[15]    
    5.  
      short grade; //[16],[17]        
    6.  
      BB b; //[24]......[47]       原则2(因为BB内部最大成员为double,即8的整数倍开始存储)
    7.  
      char name[2]; //[48][49]
    8.  
      }AA;

    sizeof(bb)=24,sizeof(aa)=56

    编译器中提供了#pragma pack(n)来设定变量以n字节对齐方式。//n为1、2、4、8、16...

    n字节对齐就是说变量存放的起始地址的偏移量有两种情况:第一、如果n大于等于该变量所占用的字节数,那么偏移量必须满足默认的对齐方式,即该变量所占用字节数的整数倍;第二、如果n小于该变量的类型所占用的字节数,那么偏移量为n的倍数,不用满足默认的对齐方式。

    结构的总大小也有个约束条件,分下面两种情况:如果n大于所有成员变量类型所占用的字节数,那么结构的总大小必须为占用空间最大的变量占用的空间数的倍数;否则必须为n的倍数。

    所以在上面的代码前加一句#pragma pack(1),

    则代码输出为bb:(0~3)+(4~11)+(12~15)=16;aa:(0~1)+(2~5)+(6~13)+(14~15)+(16~31)=32,也就是说,#pragma pack(1)就是没有对齐规则。

    再考虑#pragma pack(4),bb:(0~3)+(4~11)+(12~15)=16;aa:(0~1)+(4~7)+(8~15)+(16~17)+(20~35)=36

    联合体:

    共用体表示几个变量共用一个内存位置,在不同的时间保存不同的数据类型和不同长度的变量。在union中,所有的共用体成员共用一个空间,并且同一时间只能储存其中一个成员变量的值。当一个共用体被声明时, 编译程序自动地产生一个变量, 其长度为联合中元类型(如数组,取其类型的数据长度)最大的变量长度的整数倍,且要大于等于其最大成员所占的存储空间。

    1.  
      union foo
    2.  
      {
    3.  
      char s[10];
    4.  
      int i;
    5.  
      }

    在这个union中,foo的内存空间的长度为12,是int型的3倍,而并不是数组的长度10。若把int改为double,则foo的内存空间为16,是double型的两倍。

    1.  
      union mm{
    2.  
      char a;//元长度1 1
    3.  
      int b[5];//元长度4 20
    4.  
      double c;//元长度8 8
    5.  
      int d[3]; 12
    6.  
      };
    所以sizeof(mm)=8*3=24;
    当在共用体中包含结构体时,如下:
    1.  
      struct inner
    2.  
      {
    3.  
      char c1;
    4.  
      double d;
    5.  
      char c2;
    6.  
      };
    7.  
       
    8.  
      union data4
    9.  
      {
    10.  
      struct inner t1;
    11.  
      int i;
    12.  
      char c;
    13.  
      };

    由于data4共用体中有一个inner结构体,所以最大的基本数据类型为double,因此以8字节对齐。共用体的存储长度取决于t1,而t1长度为24,因此sizeof(uniondata4)的值为24.

    当在结构体中包含共用体时,共用体在结构体里的对齐地址为共用体本身内部所对齐位数,如下:

    1.  
      typedef union{
    2.  
      long i;
    3.  
      int k[5];
    4.  
      char c;
    5.  
      }DATE;
    6.  
      struct data{
    7.  
      int cat;
    8.  
      char cc;
    9.  
      DATE cow;
    10.  
      char a[6];
    11.  
      };

    sizeof(DATE)=20, 而在结构体中中是4+1+3(补齐4对齐)+20+6+2(补齐4对齐)=36;

     

    (1). 共用体和结构体都是由多个不同的数据类型成员组成, 但在任何同一时刻, 共用体只存放了一个被选中的成员, 而结构体的所有成员都存在。
    (2). 对于共用体的不同成员赋值, 将会对其它成员重写, 原来成员的值就不存在了, 而对于结构体的不同成员赋值是互不影响的。

    十五、内联函数有什么优点?内联函数与宏定义的区别?

    宏定义在预编译的时候就会进行宏替换; 内联函数在编译阶段,在调用内联函数的地方进行替换,减少了函数的调用过程,但是使得编译文件变大。因此,内联函数适合简单函数,对于复杂函数,即使定义了内联编译器可能也不会按照内联的方式进行编译。 内联函数相比宏定义更安全,内联函数可以检查参数,而宏定义只是简单的文本替换。因此推荐使用内联函数,而不是宏定义。 使用宏定义函数要特别注意给所有单元都加上括号,#define MUL(a, b) a b,这很危险,正确写法:#define MUL(a, b) ((a) (b))

    十六、C++内存管理

    (堆区,栈区,常量存储区,自由存储区,全局/静态存储区)

    2、每块存储哪些变量?

    (1),就是那些由编译器在需要的时候分配,在不需要的时候自动清除的变量的存储区。里面的变量通常是局部变量、函数参数等。  

    (2),就是那些由new分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个delete。如果程序员没有释放掉, 那么在程序结束后,操作系统会自动回收;

    (3)自由存储区,就是那些由malloc等分配的内存块,它和堆是十分相似的, 不过它是用free来结束自己的生命的。

    (4)全局/静态存储区全局变量静态变量被分配到同一块内存中,在以前的 C语言中,全局变量又分为初始化的和未初始化的(初始化的全局变量和静态变量在一块区域,未初始化的全局变量与静态变量在相邻的另一块区域,同时未被初始化的对象存储区可以通过void*来访问和操纵,程序结束后由系统自行释放),在C++里面没有这个区分了,他们共同占用同一块内存区。  

    (5)常量存储区,这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改(当然,你要通过非正当手段也可以修改,而且方法很多)

    比如:
    代码: 
    int a = 0; //全局初始化区 
    char *p1; //全局未初始化区 
    main() 

    int b; //栈 
    char s[] = "abc"; //栈 
    char *p2; //栈 
    char *p3 = "123456"; //123456在常量区,p3在栈上。 
    static int c = 0; //全局(静态)初始化区 
    p1 = (char *)malloc(10); 
    p2 = (char *)malloc(20); 
    //分配得来得10和20字节的区域就在堆区。 
    strcpy(p1, "123456"); 
    //123456放在常量区,编译器可能会将它与p3所指向的"123456"优化成一块。 

    3、学会迁移,可以说到malloc,从malloc说到操作系统的内存管理,说到内核态和用户态,然后就什么高端内存,slab层,伙伴算法,VMA可以巴拉巴拉了,接着可以迁移到fork()。

    十七、STL里的内存池实现(请看<<STL源码剖析>>)

    • STL内存分配分为一级分配器和二级分配器,一级分配器就是采用malloc分配内存,二级分配器采用内存池。

    二级分配器设计的非常巧妙,分别给8k,16k,..., 128k比较小的内存片都维持一个空闲链表,每个链表的头节点由一个数组来维护。需要分配内存时从合适大小的链表中取一块下来。假设需要分配一块10K的内存,那么就找到最小的大于等于10k的块,也就是16K,从16K的空闲链表里取出一个用于分配。释放该块内存时,将内存节点归还给链表。
    如果要分配的内存大于128K则直接调用一级分配器。

    为了节省维持链表的开销,采用了一个union结构体,分配器使用union里的next指针来指向下一个节点,而用户则使用union的空指针来表示该节点的地址。点击打开链接

     

    默认内存管理函数的不足:

    利用默认的内存管理函数new/delete或malloc/free在堆上分配和释放内存会有一些额外的开销。

    系统在接收到分配一定大小内存的请求时,首先查找内部维护的内存空闲块表,并且需要根据一定的算法(例如“最先分配”:分配最先找到的不小于申请大小的内存块给请求者,或者“最优分配”:分配最适于申请大小的内存块,或者分配最大空闲的内存块等)找到合适大小的空闲内存块。如果该空闲内存块过大,还需要切割成已分配的部分和较小的空闲块。然后系统更新内存空闲块表,完成一次内存分配。类似地,在释放内存时,系统把释放的内存块重新加入到空闲内存块表中。如果有可能的话,可以把相邻的空闲块合并成较大的空闲块。这都会产生额外开销。

    默认的内存管理函数还考虑到多线程的应用,需要在每次分配和释放内存时加锁,同样增加了开销。 
    可见,如果应用程序频繁地在堆上分配和释放内存,则会导致性能的损失。并且会使系统中出现大量的内存碎片,降低内存的利用率。

    默认的分配和释放内存算法自然也考虑了性能,然而这些内存管理算法的通用版本为了应付更复杂、更广泛的情况,需要做更多的额外工作。而对于某一个具体的应用程序来说,适合自身特定的内存分配释放模式的自定义内存池则可以获得更好的性能。

    内存池(Memory Pool)是一种内存分配方式,是一种代替直接调用malloc/free、new/delete进行内存管理的常用方法。通常我们习惯直接使用new、malloc等API申请分配内存,这样做的缺点在于:由于所申请内存块的大小不定,当频繁使用时会造成大量的内存碎片并进而降低性能。内存池则是在真正使用内存之前,先申请分配一定数量的、大小相等(一般情况下)的内存块留作备用。当有新的内存需求时,就从内存池中分出一部分内存块,若内存块不够再继续申请新的内存。这样做的一个显著优点是,使得内存分配效率得到提升。当申请内存空间时,首先到内存池中查找合适的内存块,而不是直接向操作系统申请;同理,当程序释放内存的时候,并不真正将内存返回给操作系统,而是返回内存池。当程序退出(或者特定时间)时,内存池才将之前申请的真正内存释放。

    内存池的一个简单应用,c++的二级配置器:

     

    一、STL中的内存管理
    当我们new一个对象时,实际做了两件事情:(1)使用malloc申请了一块内存。(2)执行构造函数。在SGI中,这两步独立出了两个函数:allocate申请内存,construct调用构造函数。这两个函数分别在<stl_alloc.h>和<stl_construct.h>中。

     

    二、第一级配置器

    第一级配置器以malloc(),free(),realloc()等C函数执行实际的内存配置、释放、重新配置等操作,并且能在内存需求不被满足的时候,调用一个指定的函数。

     

    三、第二级配置器
    在STL的第二级配置器中多了一些机制,避免太多小区块造成的内存碎片,小额区块带来的不仅是内存碎片,配置时还有额外的负担。区块越小,额外负担所占比例就越大。
        如果要分配的区块大于128bytes,则移交给第一级配置器处理。
        如果要分配的区块小于128bytes,则以内存池管理(memory pool),又称之次层配置(sub-allocation):每次配置一大块内存,并维护对应的16个空闲链表(free-list)。下次若有相同大小的内存需求,则直接从free-list中取。如果有小额区块被释放,则由配置器回收到free-list中。
    (1)空闲链表的设计
    这里的16个空闲链表分别管理大小为8、16、24......120、128bytes的数据块。这里空闲链表节点的设计十分巧妙,这里用了一个联合体既可以表示下一个空闲数据块(存在于空闲链表中)的地址,也可以表示已经被用户使用的数据块(不存在空闲链表中)的地址。

    free_list的节点结构:union obj联合体

    1.  
      union obj {
    2.  
        union obj *free_list_link;
    3.  
        char client_data[1];
    4.  
      };

    在内存池中所有的空闲块都以这样的方式连接起来,我们知道这个联合体的大小为4Byte,书中描述为这样:由于union之故,从其第一字段观之,obj可被视为一个指针,指向相同形式的另一个obj。从其第二字段观之,obj可被视为一个指针,指向实际区块。也就是说如果我们用下面的方式取出内存池中的可用内存:

    1.  
      // 使用时将其取出,指向下一个区块的free_list_link此时就被我们当做空闲区域来使用了,因而不会额外占用空间
    2.  
      obj *myBlock = free_list;
    3.  
      free_list = free_list->free_list_link;
    4.  
       
    5.  
      // 之后直接使用myBlock->client_data来访问该内存区域

    也就是说:

    1.  
      printf("%x ", myblock);
    2.  
      printf("%x ", myblock->client_data);

    它们的地址是一样的,但是为什么要这样呢?在一般情况下,假设我们利用malloc申请了一块内存,它返回的是void*的指针,如果我们要使用这块内存,假设我们要让它存放char型的数据,要进行(char *)myblock的转换。但是这里我们不需要转换,直接使用myblock->client_data进行,就可以以char*类型获得这块内存的首地址!!这这使得操作更加方便!!!

    (2)空间配置函数allocate()
    首先先要检查申请空间的大小,如果大于128字节就调用第一级配置器,小于128字节就检查对应的空闲链表,如果该空闲链表中有可用数据块,则直接拿来用(拿取空闲链表中的第一个可用数据块,然后把该空闲链表的地址设置为该数据块指向的下一个地址),如果没有可用数据块,则调用refill()重新填充空间。
    1.  
      //申请大小为n的数据块,返回该数据块的起始地址
    2.  
      static void * allocate(size_t n)
    3.  
      {
    4.  
      obj * __VOLATILE * my_free_list;
    5.  
      obj * __RESTRICT result;
    6.  
       
    7.  
      if (n > (size_t) __MAX_BYTES)//大于128字节调用第一级配置器
    8.  
      {
    9.  
      return(malloc_alloc::allocate(n));
    10.  
      }
    11.  
      my_free_list = free_list + FREELIST_INDEX(n);//根据申请空间的大小寻找相应的空闲链表(16个空闲链表中的一个)
    12.  
       
    13.  
      result = *my_free_list;
    14.  
      if (result == 0)//如果该空闲链表没有空闲的数据块
    15.  
      {
    16.  
      void *r = refill(ROUND_UP(n));//为该空闲链表填充新的空间
    17.  
      return r;
    18.  
      }
    19.  
      *my_free_list = result -> free_list_link;//如果空闲链表中有空闲数据块,则取出一个,并把空闲链表的指针指向下一个数据块
    20.  
      return (result);
    21.  
      };

     

    (3)空间释放函数deallocate()
    首先先要检查释放数据块的大小,如果大于128字节就调用第一级配置器,小于128字节则根据数据块的大小来判断回收后的空间会被插入到哪个空闲链表。
    例如回收下面指定位置大小为16字节的数据块,首先数据块的大小判断回收后的数据块应该插入到第二个空闲链表,把该节点指向的下一个地址修改为原链表指向的地址(这里是NULL),然后将原链表指向该节点。
    1.  
      //释放地址为p,释放大小为n
    2.  
      static void deallocate(void *p, size_t n)
    3.  
      {
    4.  
      obj *q = (obj *)p;
    5.  
      obj * __VOLATILE * my_free_list;
    6.  
       
    7.  
      if (n > (size_t) __MAX_BYTES)//如果空间大于128字节,采用普通的方法析构
    8.  
      {
    9.  
      malloc_alloc::deallocate(p, n);
    10.  
      return;
    11.  
      }
    12.  
       
    13.  
      my_free_list = free_list + FREELIST_INDEX(n);//否则将空间回收到相应空闲链表(由释放块的大小决定)中
    14.  
      q -> free_list_link = *my_free_list;
    15.  
      *my_free_list = q;
    16.  
      }

    回收内存块:

    (4)重新填充空闲链表refill()

    在用allocate()配置空间时,如果空闲链表中没有可用数据块,就会调用refill()来重新填充空间,新的空间取自内存池。缺省取20个数据块,如果内存池空间不足,那么能取多少个节点就取多少个。
    1.  
      template <bool threads, int inst>
    2.  
      void* refill(size_t n)
    3.  
      {
    4.  
      int nobjs = 20;
    5.  
      char * chunk = chunk_alloc(n, nobjs);//从内存池里取出nobjs个大小为n的数据块,返回值nobjs为真实申请到的数据块个数,注意这里nobjs个大小为n的数据块所在的空间是连续的
    6.  
      obj * __VOLATILE * my_free_list;
    7.  
      obj * result;
    8.  
      obj * current_obj, * next_obj;
    9.  
      int i;
    10.  
       
    11.  
      if (1 == nobjs) return(chunk);//如果只获得一个数据块,那么这个数据块就直接分给调用者,空闲链表中不会增加新节点
    12.  
      my_free_list = free_list + FREELIST_INDEX(n);//否则根据申请数据块的大小找到相应空闲链表
    13.  
       
    14.  
      result = (obj *)chunk;
    15.  
      *my_free_list = next_obj = (obj *)(chunk + n);//第0个数据块给调用者,地址访问即chunk~chunk + n - 1
    16.  
      for (i = 1; ; i++)//1~nobjs-1的数据块插入到空闲链表
    17.  
      {
    18.  
      current_obj = next_obj;
    19.  
      next_obj = (obj *)((char *)next_obj + n);//由于之前内存池里申请到的空间连续,所以这里需要人工划分成小块一次插入到空闲链表
    20.  
       
    21.  
      if (nobjs - 1 == i)
    22.  
      {
    23.  
      current_obj -> free_list_link = 0;
    24.  
      break;
    25.  
      }
    26.  
      else
    27.  
      {
    28.  
      current_obj -> free_list_link = next_obj;
    29.  
      }
    30.  
      }
    31.  
       
    32.  
      return(result);
    33.  
      }
    (5)从内存池取空间
    从内存池取空间给空闲链表用是chunk_alloc的工作:
    首先根据end_free-start_free来判断内存池中的剩余空间是否足以调出nobjs个大小为size的数据块出去,如果内存连一个数据块的空间都无法供应,需要用malloc从堆中申请内存。

     

    申请内存后,如果要拨出去20个大小为8字节的数据块:
    假如山穷水尽,整个系统的堆空间都不够用了,malloc失败,那么chunk_alloc会从空闲链表中找是否有大的数据块,然后将该数据块的空间分给内存池(这个数据块会从链表中去除):
    举例:

    假设程序一开始调用chunk_alloc(32,20),于是,malloc()配置40个32bytes区块,其中第一个交出,另19个交给free_list[3],余20个留给内存池。接下来客端调用chunk_alloc(64,20),此时free_list[7]空空如也,必须向内存池要求支持,内存池只够供应(32*20)/64=10个64bytes区块,就把这10个区块返回,第一个交给客端,其余9个由free_list[7]维护。此时内存池全空。接下来再调用chunk_alloc(96,20),此时free_list[11]空空如也,必须向内存池要求支持,而内存池此时也空,于是以malloc()配置40+n(附加量)个96bytes区块,第一个交给客端,其余19个由free_list[11]维护,余20+n(附加量)个区块交给内存池......万一整个system heap空间都不够用了,malloc()行动失败,chunk_alloc就四处寻找有无“尚有未用区块,且区块足够大”之free_list,找到就挖一块交出,找不到就调用第一级配置器。第一级配置器也是使用malloc()配置内存,但它有out-of-memory处理机制,或许有机会释放其他的内存拿来此处使用,若可以就成功,否则抛出bad_alloc异常


    整个内存池在内存分配中的逻辑:

    十八、STL里set和map是基于什么实现的。红黑树的特点?

    红黑树的定义:
    (1) 节点是红色或者黑色;
    (2) 父节点是红色的话,子节点就不能为红色;
    (3) 从根节点到每个页子节点路径上黑色节点的数量相同;

    (4) 根是黑色的,NULL节点被认为是黑色的。

    set和map都是基于红黑树实现的。 

    红黑树是一种平衡二叉查找树,与AVL树的区别是什么? AVL树是完全平衡的,红黑树基本上是平衡的。 

    为什么选用红黑树呢? 因为红黑树是平衡二叉树,其插入和删除的效率都是(logN),与AVL相比红黑树插入和删除最多只需要3次旋转,而AVL树为了维持其完全平衡性,在坏的情况下要旋转的次数太多。

    红黑树并不是高度的平衡树。所谓平衡树指的是一棵空树或它的左右两个子树的高度差的绝对值不超过1,红黑树放弃了高度平衡的特性而只追求部分平衡,这种特性降低了插入、删除时对树旋转的要求,从而提升了树的整体性能。而其他平衡树比如AVL树虽然查找性能是O(logn),但是为了维护其平衡特性,可能要在插入、删除操作时进行多次的旋转,产生比较大的消耗。

    1. 如果插入一个node引起了树的不平衡,AVL和RB-Tree都是最多只需要2次旋转操作,即两者都是O(1);但是在删除node引起树的不平衡时,最坏情况下,AVL需要维护从被删node到root这条路径上所有node的平衡性,因此需要旋转的量级O(logN),而RB-Tree最多只需3次旋转,只需要O(1)的复杂度。

    2. 其次,AVL的结构相较RB-Tree来说更为平衡,在插入和删除node更容易引起Tree的unbalance,因此在大量数据需要插入或者删除时,AVL需要rebalance的频率会更高。因此,RB-Tree在需要大量插入和删除node的场景下,效率更高。自然,由于AVL高度平衡,因此AVL的search效率更高。

    红黑树的查询性能略微逊色于AVL树,因为他比avl树会稍微不平衡最多一层,也就是说红黑树的查询性能只比相同内容的avl树最多多一次比较,但是,红黑树在插入和删除上完爆AVL树,AVL树每次插入删除会进行大量的平衡度计算,而红黑树为了维持红黑性质所做的红黑变换和旋转的开销,相较于AVL树为了维持平衡的开销要小得多

    一棵含有n个节点的红黑树的高度至多为2log(n+1).高度为h的红黑树,它的包含的内节点个数至少为 2h/2-1个

    从某个节点x出发(不包括该节点)到达一个叶节点的任意一条路径上,黑色节点的个数称为该节点的黑高度,记为bh(x)。关于bh(x)有两点需要说明: 
        第1点:根据红黑树的"特性,即从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点"可知,从节点x出发到达的所有的叶节点具有相同数目的黑节点。这也就意味着,bh(x)的值是唯一的
        第2点:根据红黑色的"特性,即如果一个节点是红色的,则它的子节点必须是黑色的"可知,从节点x出发达到叶节点"所经历的黑节点数目">= "所经历的红节点的数目"。假设x是根节点,则可以得出结论"bh(x) >= h/2"。
    进而, "高度为h的红黑树,它包含的黑节点个数至少为 2bh(x)-1个"。

     

    红黑树插入

    用BST(二叉搜索树)的方法将结点插入,将该结点标记为红色的(因为如果标记为黑色,则会导致根结点到叶子结点的路径会多出一个黑结点,无法满足性质(3),而且不容易进行调整),插入的情况包括下面几种:

    1. 插入到一个空的树,插入结点则为根结点,只需要将红色结点重新转染成黑色结点来满足性质2;
    2. 新结点的父结点为黑色,满足所有条件;
    3. 新结点的父结点为红色,因为性质(2)和性质(4),所以树必然有祖父结点,则又包括以下的情况:
      1. 父亲结点和叔父结点均为红色,显然无法满足性质(2),则将父亲结点和叔父结点绘制成黑色,祖父结点设置成红色,但是仍然无法满足情况,比如考虑到祖父结点可能是根结点,则无法满足性质(4),或者祖父结点的父结点是红色的,则无法满足性质(2),这时需要将祖父结点作为新的结点来看待进行各种情况的判断,涉及到对祖父结点的递归;

      2. 父亲结点为红色同时叔父结点为黑色或者从缺,这里又分为两种情况,新插入结点为父亲结点的左子结点和右子结点(假设其中父亲结点为祖父结点的左子结点),区别在于旋转的方向,显然,这棵树父亲结点既然为红色,那么其祖父结点则为黑色(性质2),不然无法满足前提。
        1. 新插入结点为父亲结点的左子结点,那么就构成了一个左左的情况,在之前平衡树中提到过,如果要将其进行平衡,则需要对父结点进行一次单右旋转,形成一个父亲结点为相对根结点,子结点和祖父结点为子结点的树,同时将父亲结点的红色改为黑色,祖父结点更改为红色,这下之前无法满足的性质4和性质5就满足了;

        2. 新插入结点为父亲结点的右子结点,那么就会构成一个左右的情况,在之前的平衡树也提到过要进行一次双旋转,先对新结点进行一次单左旋转,变成了左左的结构,再进行一次单右旋转(上图),从而达到满足所有性质;

      3. 父亲结点是祖父结点的右结点,参考平衡树进行相应的操作,原理是一致的

    红黑树删除

    删除操作伪代码:

    1.  
      RB-DELETE(T, z)
    2.  
      if left[z] = nil[T] or right[z] = nil[T]
    3.  
      then y ← z // 若“z的左孩子” 或 “z的右孩子”为空,则将“z”赋值给 “y”;
    4.  
      else y ← TREE-SUCCESSOR(z) // 否则,将“z的后继节点”赋值给 “y”。
    5.  
      if left[y] ≠ nil[T]
    6.  
      then x ← left[y] // 若“y的左孩子” 不为空,则将“y的左孩子” 赋值给 “x”;
    7.  
      else x ← right[y] // 否则,“y的右孩子” 赋值给 “x”。
    8.  
      p[x] ← p[y] // 将“y的父节点” 设置为 “x的父节点”
    9.  
      if p[y] = nil[T]
    10.  
      then root[T] ← x // 情况1:若“y的父节点” 为空,则设置“x” 为 “根节点”。
    11.  
      else if y = left[p[y]]
    12.  
      then left[p[y]] ← x // 情况2:若“y是它父节点的左孩子”,则设置“x” 为 “y的父节点的左孩子”
    13.  
      else right[p[y]] ← x // 情况3:若“y是它父节点的右孩子”,则设置“x” 为 “y的父节点的右孩子”
    14.  
      if y ≠ z
    15.  
      then key[z] ← key[y] // 若“y的值” 赋值给 “z”。注意:这里只拷贝y的值给z,而没有拷贝y的颜色!!!
    16.  
      copy y's satellite data into z
    17.  
      if color[y] = BLACK
    18.  
      then RB-DELETE-FIXUP(T, x) // 若“y为黑节点”,则调用
    19.  
      return y
    1.  
      RB-DELETE-FIXUP(T, x)
    2.  
      while x ≠ root[T] and color[x] = BLACK
    3.  
      do if x = left[p[x]]
    4.  
      then w ← right[p[x]] // 若 “x”是“它父节点的左孩子”,则设置 “w”为“x的叔叔”(即x为它父节点的右孩子)
    5.  
      if color[w] = RED // Case 1: x是“黑+黑”节点,x的兄弟节点是红色。(此时x的父节点和x的兄弟节点的子节点都是黑节点)。
    6.  
      then color[w] ← BLACK ▹ Case 1 // (01) 将x的兄弟节点设为“黑色”。
    7.  
      color[p[x]] ← RED ▹ Case 1 // (02) 将x的父节点设为“红色”。
    8.  
      LEFT-ROTATE(T, p[x]) ▹ Case 1 // (03) 对x的父节点进行左旋。
    9.  
      w ← right[p[x]] ▹ Case 1 // (04) 左旋后,重新设置x的兄弟节点。
    10.  
      if color[left[w]] = BLACK and color[right[w]] = BLACK // Case 2: x是“黑+黑”节点,x的兄弟节点是黑色,x的兄弟节点的两个孩子都是黑色。
    11.  
      then color[w] ← RED ▹ Case 2 // (01) 将x的兄弟节点设为“红色”。
    12.  
      x ← p[x] ▹ Case 2 // (02) 设置“x的父节点”为“新的x节点”。
    13.  
      else if color[right[w]] = BLACK // Case 3: x是“黑+黑”节点,x的兄弟节点是黑色;x的兄弟节点的左孩子是红色,右孩子是黑色的。
    14.  
      then color[left[w]] ← BLACK ▹ Case 3 // (01) 将x兄弟节点的左孩子设为“黑色”。
    15.  
      color[w] ← RED ▹ Case 3 // (02) 将x兄弟节点设为“红色”。
    16.  
      RIGHT-ROTATE(T, w) ▹ Case 3 // (03) 对x的兄弟节点进行右旋。
    17.  
      w ← right[p[x]] ▹ Case 3 // (04) 右旋后,重新设置x的兄弟节点。
    18.  
      color[w] ← color[p[x]] ▹ Case 4 // Case 4: x是“黑+黑”节点,x的兄弟节点是黑色;x的兄弟节点的右孩子是红色的。(01) 将x父节点颜色 赋值给 x的兄弟节点。
    19.  
      color[p[x]] ← BLACK ▹ Case 4 // (02) 将x父节点设为“黑色”。
    20.  
      color[right[w]] ← BLACK ▹ Case 4 // (03) 将x兄弟节点的右子节设为“黑色”。
    21.  
      LEFT-ROTATE(T, p[x]) ▹ Case 4 // (04) 对x的父节点进行左旋。
    22.  
      x ← root[T] ▹ Case 4 // (05) 设置“x”为“根节点”。
    23.  
      else (same as then clause with "right" and "left" exchanged) // 若 “x”是“它父节点的右孩子”,将上面的操作中“right”和“left”交换位置,然后依次执行。
    24.  
      color[x] ← BLACK

     

    第一步:将红黑树当作一颗二叉查找树,将节点删除。
           这和"删除常规二叉查找树中删除节点的方法是一样的"。分3种情况:
           ① 被删除节点没有儿子,即为叶节点。那么,直接将该节点删除就OK了。
           ② 被删除节点只有一个儿子。那么,直接删除该节点,并用该节点的唯一子节点顶替它的位置。
           ③ 被删除节点有两个儿子。那么,先找出它的后继节点;然后把“它的后继节点的内容”复制给“该节点的内容”;之后,删除“它的后继节点”。(被删节点的右孩子节点的左子树非空,后继为最左下节点;被删节点的右孩子的左子树为空,后继节点为被删节点的右孩子)

    第二步:通过"旋转和重新着色"等一系列来修正该树,使之重新成为一棵红黑树。
           因为"第一步"中删除节点之后,可能会违背红黑树的特性。所以需要通过"旋转和重新着色"来修正该树,使之重新成为一棵红黑树。

    “在删除节点后,原红黑树的性质可能被改变,如果删除的是红色节点,那么原红黑树的性质依旧保持,此时不用做修正操作,如果删除的节点是黑色节点,原红黑树的性质可能会被改变,我们要对其做修正操作。那么哪些树的性质会发生变化呢,如果删除节点不是树唯一节点,那么删除节点的那一个支的到各叶节点的黑色节点数会发生变化,此时性质3被破坏。如果被删节点的唯一非空子节点是红色,而被删节点的父节点也是红色,那么性质2被破坏。如果被删节点是根节点,而它的唯一非空子节点是红色,则删除后新根节点将变成红色,违背性质4。”

    为了便于分析,我们假设"x包含一个额外的黑色"(x原本的颜色还存在),这样就不会违反"特性3"。为什么呢?
          删除节点y之后,x占据了原来节点y的位置。 既然删除y(y是黑色),意味着减少一个黑色节点;那么,再在该位置上增加一个黑色即可。这样,当我们假设"x包含一个额外的黑色",就正好弥补了"删除y所丢失的黑色节点",也就不会违反"特性3"。 因此,假设"x包含一个额外的黑色"(x原本的颜色还存在),这样就不会违反"特性3"。

          现在,x不仅包含它原本的颜色属性,x还包含一个额外的黑色。即x的颜色属性是"红+黑"或"黑+黑",它违反了"特性1"。 现在,我们面临的问题,由解决"违反了特性(2)、(3)、(4)三个特性"转换成了"解决违反特性(1)、(2)、(4)三个特性"

    恢复红黑树特性:

    将x所包含的额外的黑色不断沿树上移(向根方向移动),直到出现下面的姿态:
    a) x指向一个"红+黑"节点。此时,将x设为一个"黑"节点即可。
    b) x指向根。此时,将x设为一个"黑"节点即可。
    c) 非前面两种姿态。

    将上面的姿态,可以概括为3种情况。
    ① 情况说明:x是“红+黑”节点。
        处理方法:直接把x设为黑色,结束。此时红黑树性质全部恢复。
    ② 情况说明:x是“黑+黑”节点,且x是根。
        处理方法:什么都不做,结束。此时红黑树性质全部恢复。
    ③ 情况说明:x是“黑+黑”节点,且x不是根。

    1. (Case 1)x是"黑+黑"节点,x的兄弟节点是红色

    (此时x的父节点和x的兄弟节点的子节点都是黑节点)。

    处理策略
    (01) 将x的兄弟节点设为“黑色”。
    (02) 将x的父节点设为“红色”。
    (03) 对x的父节点进行左旋。
    (04) 左旋后,重新设置x的兄弟节点。

          下面谈谈为什么要这样处理。(建议理解的时候,通过下面的图进行对比)
          这样做的目的是将“Case 1”转换为“Case 2”、“Case 3”或“Case 4”,从而进行进一步的处理。对x的父节点进行左旋;左旋后,为了保持红黑树特性,就需要在左旋前“将x的兄弟节点设为黑色”,同时“将x的父节点设为红色”;左旋后,由于x的兄弟节点发生了变化,需要更新x的兄弟节点,从而进行后续处理。

    示意图(图中A为替换的节点x)                                                                                       该图有错,B红D黑

    2. (Case 2) x是"黑+黑"节点,x的兄弟节点是黑色,x的兄弟节点的两个孩子都是黑色

    处理策略
    (01) 将x的兄弟节点设为“红色”。
    (02) 设置“x的父节点”为“新的x节点”。

          下面谈谈为什么要这样处理。(建议理解的时候,通过下面的图进行对比)
          这个情况的处理思想:是将“x中多余的一个黑色属性上移(往根方向移动)”。 x是“黑+黑”节点,我们将x由“黑+黑”节点 变成 “黑”节点,多余的一个“黑”属性移到x的父节点中,即x的父节点多出了一个黑属性(若x的父节点原先是“黑”,则此时变成了“黑+黑”;若x的父节点原先时“红”,则此时变成了“红+黑”)。 此时,需要注意的是:所有经过x的分支中黑节点个数没变化;但是,所有经过x的兄弟节点的分支中黑色节点的个数增加了1(因为x的父节点多了一个黑色属性)!为了解决这个问题,我们需要将“所有经过x的兄弟节点的分支中黑色节点的个数减1”即可,那么就可以通过“将x的兄弟节点由黑色变成红色”来实现。
          经过上面的步骤(将x的兄弟节点设为红色),多余的一个颜色属性(黑色)已经跑到x的父节点中。我们需要将x的父节点设为“新的x节点”进行处理。若“新的x节点”是“红+黑”,直接将“新的x节点”设为黑色,即可完全解决该问题;若“新的x节点”是“黑+黑”,则需要对“新的x节点”进行进一步处理。

    示意图

    3. (Case 3)x是“黑+黑”节点,x的兄弟节点是黑色;x的兄弟节点的左孩子是红色,右孩子是黑色的

     处理策略
    (01) 将x兄弟节点的左孩子设为“黑色”。
    (02) 将x兄弟节点设为“红色”。
    (03) 对x的兄弟节点进行右旋。
    (04) 右旋后,重新设置x的兄弟节点。

           下面谈谈为什么要这样处理。(建议理解的时候,通过下面的图进行对比)
           我们处理“Case 3”的目的是为了将“Case 3”进行转换,转换成“Case 4”,从而进行进一步的处理。转换的方式是对x的兄弟节点进行右旋;为了保证右旋后,它仍然是红黑树,就需要在右旋前“将x的兄弟节点的左孩子设为黑色”,同时“将x的兄弟节点设为红色”;右旋后,由于x的兄弟节点发生了变化,需要更新x的兄弟节点,从而进行后续处理。

    示意图

    4. (Case 4)x是“黑+黑”节点,x的兄弟节点是黑色;x的兄弟节点的右孩子是红色的,x的兄弟节点的左孩子任意颜色

    处理策略
    (01) 将x父节点颜色赋值给 x的兄弟节点。
    (02) 将x父节点设为“黑色”。
    (03) 将x兄弟节点的右子节设为“黑色”。
    (04) 对x的父节点进行左旋。
    (05) 设置“x”为“根节点”。

          下面谈谈为什么要这样处理。(建议理解的时候,通过下面的图进行对比)
          我们处理“Case 4”的目的是:去掉x中额外的黑色,将x变成单独的黑色。处理的方式是“:进行颜色修改,然后对x的父节点进行左旋。下面,我们来分析是如何实现的。
          为了便于说明,我们设置“当前节点”为S(Original Son),“兄弟节点”为B(Brother),“兄弟节点的左孩子”为BLS(Brother's Left Son),“兄弟节点的右孩子”为BRS(Brother's Right Son),“父节点”为F(Father)。
          我们要对F进行左旋。但在左旋前,我们需要调换F和B的颜色,并设置BRS为黑色。为什么需要这里处理呢?因为左旋后,F和BLS是父子关系,而我们已知BLS是红色,如果F是红色,则违背了“特性(2)”;为了解决这一问题,我们将“F设置为黑色”。 但是,F设置为黑色之后,为了保证满足“特性(3)”,即为了保证左旋之后:
          第一,“同时经过根节点和S的分支的黑色节点个数不变”。
                 若满足“第一”,只需要S丢弃它多余的颜色即可。因为S的颜色是“黑+黑”,而左旋后“同时经过根节点和S的分支的黑色节点个数”增加了1;现在,只需将S由“黑+黑”变成单独的“黑”节点,即可满足“第一”。
          第二,“同时经过根节点和BLS的分支的黑色节点数不变”。
                 若满足“第二”,只需要将“F的原始颜色”赋值给B即可。之前,我们已经将“F设置为黑色”(即,将B的颜色"黑色",赋值给了F)。至此,我们算是调换了F和B的颜色。
          第三,“同时经过根节点和BRS的分支的黑色节点数不变”。
                 在“第二”已经满足的情况下,若要满足“第三”,只需要将BRS设置为“黑色”即可。
    经过,上面的处理之后。红黑树的特性全部得到的满足!接着,我们将x设为根节点,就可以跳出while循环(参考伪代码);即完成了全部处理。

    至此,我们就完成了Case 4的处理。理解Case 4的核心,是了解如何“去掉当前节点额外的黑色”。

    示意图

    十九、STL里的其他数据结构和算法实现也要清楚

    这个问题,把STL源码剖析好好看看,不仅面试不慌,自己对STL的使用也会上升一个层次。

    set:点击打开链接

     

    set是一种关联式容器,其特性如下:

    • set以RBTree作为底层容器
    • 所得元素的只有key没有value,value就是key
    • 不允许出现键值重复
    • 所有的元素都会被自动排序
    • 不能通过迭代器来改变set的值,因为set的值就是键

    如果set中允许修改键值的话,那么首先需要删除该键,然后调节平衡,再插入修改后的键值,再调节平衡,如此一来,严重破坏了set的结构,导致iterator失效,不知道应该指向之前的位置,还是指向改变后的位置。所以STL中将set的迭代器设置成const_iterator,不允许修改迭代器的值。

    map:

     

    map和set一样是关联式容器,它们的底层容器都是红黑树,区别就在于map的值不作为键,键和值是分开的。它的特性如下:

    • map以RBTree作为底层容器
    • 所有元素都是键+值存在
    • 不允许键重复
    • 所有元素是通过键进行自动排序的
    • map的键是不能修改的,但是其键对应的值是可以修改的

    在map中,一个键对应一个值,其中键不允许重复,不允许修改,但是键对应的值是可以修改的,原因可以看上面set中的解释。

    点击打开链接

    二十、必须在构造函数初始化列表里进行初始化的数据成员有哪些

    (1) 常量成员,因为常量只能初始化不能赋值,所以必须放在初始化列表里面
    (2) 引用类型,引用必须在定义的时候初始化,并且不能重新赋值,所以也要写在初始化列表里面
    (3) 没有默认构造函数的类类型若没有提供显示初始化式,则编译器隐式使用成员类型的默认构造函数,若类没有默认构造函数,则编译器尝试使用默认构造函数将会失败。使用初始化列表可以不必调用默认构造函数来初始化,而是直接调用拷贝构造函数初始化。

    对象成员:A类的成员是B类的对象,在构造A类时需对B类的对象进行构造,当B类没有默认构造函数时需要在A类的构造函数初始化列表中对B类对象初始化

    类的继承:派生类在构造函数中要对自身成员初始化,也要对继承过来的基类成员进行初始化,当基类没有默认构造函数的时候,通过在派生类的构造函数初始化列表中调用基类的构造函数实现

    类对象的构造顺序显示,进入构造函数体后,进行的是计算,是对已经构造好的成员变量的赋值操作,显然,赋值和初始化是不同的,这样就体现出了效率差异,如果不用成员初始化列表,那么类对自己的类成员分别进行的是一次隐式的默认构造函数的调用(在进入函数体之前),和一次拷贝赋值运算符的调用(进入函数体之后),如果是类对象,这样做效率就得不到保障。(类类型的数据成员对象在进入函数体前己经构造完成,也就是说在成员初始化列表处进行对象的构造工作,调用构造函数,在进入函数体之后,进行的是对己构造好的类对象赋值,又调用个拷贝赋值操作符才能完成(如果并未提供,则使用编译器默认的按成员赋值行为))

    初始化是从无到有的过程,先分配空间,然后再填充数据;赋值是对己有的对象进行操作。

            使用初始化列表的构造函数显式的初始化类的成员;而没使用初始化列表的构造函数是对类的成员赋值,并没有进行显式的初始化。
    • 初始化和赋值对内置类型的成员没有什么大的区别。对非内置类型成员变量,为了避免两次构造推荐使用类构造函数初始化列表。

    二十一、模版特化

    模板特化分为全特化和偏特化,模板特化的目的就是对于某一种变量类型具有不同的实现,因此需要特化版本。例如,在STL里迭代器为了适应原生指针就将原生指针进行特化。

    二十二、定位内存泄露

    (1)在windows平台下通过CRT中的库函数进行检测;
    (2)在可能泄漏的调用前后生成块的快照,比较前后的状态,定位泄漏的位置

    (3)Linux下通过工具valgrind检测

    二十三、手写函数strcpy、memcpy、strcat、strcmp

    已知strcpy函数的原型是

           char *strcpy(char *strDest, const char *strSrc);

           其中strDest是目的字符串,strSrc是源字符串。

    (1)不调用C++/C的字符串库函数,请编写函数 strcpy

    char *strcpy(char *strDestconst char *strSrc)

    {

        assert((strDest!=NULL) && (strSrc !=NULL));    // 2

        char *address = strDest;                                          // 2

        while( (*strDest++ = * strSrc++) != '' );         // 2

        return address ;                                                  // 2

    }

    (2)strcpy能把strSrc的内容复制到strDest,为什么还要char * 类型的返回值?

    答:为了实现链式表达式。                                              // 2分

    例如       int length = strlen( strcpy( strDest, “hello world”) );

    [1]const修饰

    源字符串参数用const修饰,防止修改源字符串。

    [2]空指针检查

    (A)不检查指针的有效性,说明答题者不注重代码的健壮性。

    (B)检查指针的有效性时使用assert(!dst && !src);

    char *转换为bool即是类型隐式转换,这种功能虽然灵活,但更多的是导致出错概率增大和维护成本升高。

    (C)检查指针的有效性时使用assert(dst != 0 && src != 0);

    直接使用常量(如本例中的0)会减少程序的可维护性。而使用NULL代替0,如果出现拼写错误,编译器就会检查出来。

    [3]返回目标地址

    (A)忘记保存原始的strdst值。

    [4]''

    (A)循环写成while (*dst++=*src++);明显是错误的。

    (B)循环写成while (*src!='') *dst++=*src++;

    循环体结束后,dst字符串的末尾没有正确地加上''。

    假如考虑strDest和strSrc内存重叠的情况,strcpy该怎么实现?

    har s[10]="hello";

    strcpy(s, s+1); //应返回ello,

    //strcpy(s+1, s); //应返回hhello,但实际会报错,因为dst与src重叠了,把''覆盖了

    所谓重叠,就是src未处理的部分已经被dst给覆盖了,只有一种情况:src<=dst<=src+strlen(src)

    1.  
      char *my_strcpy(char *dst,const char *src)
    2.  
      {
    3.  
      assert(dst != NULL);
    4.  
      assert(src != NULL);
    5.  
      char *ret = dst;
    6.  
      memcpy(dst,src,strlen(src)+1);
    7.  
      return ret;
    8.  
      }

    memcpy函数实现时考虑到了内存重叠的情况,可以完成指定大小的内存拷贝。

    1.  
      void * my_memcpy(void *dst,const void *src,unsigned int count)
    2.  
      {
    3.  
      assert(dst);
    4.  
      assert(src);
    5.  
      void * ret = dst;
    6.  
      if (dst <= src || (char *)dst >= ((char *)src + count))//源地址和目的地址不重叠,低字节向高字节拷贝
    7.  
      {
    8.  
      while(count--)
    9.  
      {
    10.  
      *(char *)dst = *(char *)src;
    11.  
      dst = (char *)dst + 1;
    12.  
      src = (char *)src + 1;
    13.  
      }
    14.  
      }
    15.  
      else //源地址和目的地址重叠,高字节向低字节拷贝
    16.  
      {
    17.  
      dst = (char *)dst + count - 1;
    18.  
      src = (char *)src + count - 1;
    19.  
      while(count--)
    20.  
      {
    21.  
      *(char *)dst = *(char *)src;
    22.  
      dst = (char *)dst - 1;
    23.  
      src = (char *)src - 1;
    24.  
      }
    25.  
      }
    26.  
      return ret;
    27.  
      }


    strcat() 函数用来连接字符串,其原型为:
        char *strcat(char *dest, const char *src);

    【参数】dest 为目的字符串指针,src 为源字符串指针。

    strcat() 会将参数 src 字符串复制到参数 dest 所指的字符串尾部;dest 最后的结束字符 NULL 会被覆盖掉,并在连接后的字符串的尾部再增加一个 NULL。

    注意:dest 与 src 所指的内存空间不能重叠,且 dest 要有足够的空间来容纳要复制的字符串。

    【返回值】返回dest 字符串起始地址。

    1.  
      char* Strcat(char *dst, const char *src)
    2.  
      {
    3.  
      assert(dst != NULL && src != NULL);
    4.  
      char *temp = dst;
    5.  
      while (*temp != '')
    6.  
      temp++;
    7.  
      while ((*temp++ = *src++) != '');
    8.  
      return dst;
    9.  
      }

    int strcmp(const char* str1, const char* str2);
    其中str1和str2可以是字符串常量或者字符串变量,返回值为整形。返回结果如下规定:
    ① str1小于str2,返回负值或者-1(VC返回-1);                    by wgenek 转载请注明出处
    ② str1等于str2,返回0;
    ③ str1大于str2,返回正值或者1(VC返回1);

    strcmp函数实际上是对字符的ASCII码进行比较,实现原理如下:首先比较两个串的第一个字符,若不相等,则停止比较并得出两个ASCII码大小比较的结果;如果相等就接着 比较第二个字符然后第三个字符等等。无论两个字符串是什么样,strcmp函数最多比较到其中一个字符串遇到结束符'/0'为止

    1.  
      int strcmp(const char* str1, const char* str2)
    2.  
      {
    3.  
      int ret = 0;
    4.  
      while(!(ret=*(unsigned char*)str1-*(unsigned char*)str2) && *str1)
    5.  
      {
    6.  
      str1++;
    7.  
      str2++
    8.  
      }
    9.  
      if (ret < 0)
    10.  
      return -1;
    11.  
      else if (ret > 0)
    12.  
      return 1;
    13.  
      return 0;
    14.  
      }
    1.  
      int strcmp(const char *str1, const char *str2)
    2.  
      {assert(str1!=NULL&&str2!=NULL);
    3.  
      while(*str1 == *str2 && *str1 != '' && *str2 != '')
    4.  
      {
    5.  
          ++str1;
    6.  
          ++str2;
    7.  
      }
    8.  
      return *str1 - *str2;
    9.  
  • 相关阅读:
    使用微软消息队列实现C#进程间通信(转)
    JavaScript获得页面区域大小的代码
    我的第一份外包经历及所得 (转)
    用Aptana调试JavaScript教程(转)
    NET中的消息队列
    c#线程基础之线程控制
    c#线程基础之原子操作
    sql2005分区表示例
    系统资源调用和shell32.dll简介
    Windows API入门简介
  • 原文地址:https://www.cnblogs.com/lyp1010/p/13834079.html
Copyright © 2011-2022 走看看