一、extern关键字作用
1、extern用在变量或者函数的声明前,用来说明“此变量/函数是在别处定义的,要在此处引用”。extern声明不是定义,即不分配存储空间。也就是说,在一个文件中定义了变量和函数, 在其他文件中要使用它们, 可以有两种方式:使用头文件,然后声明它们,然后其他文件去包含头文件;在其他文件中直接extern。
2、extern C作用
链接指示符extern C
如果程序员希望调用其他程序设计语言尤其是C 写的函数,那么调用函数时必须告诉编译器使用不同的要求,例如当这样的函数被调用时函数名或参数排列的顺序可能不同,无论是C++函数调用它还是用其他语言写的函数调用它,程序员用链接指示符告诉编译器该函数是用其他的程序设计语言编写的,链接指示符有两种形式既可以是单一语句形式也可以是复合语句形式。
// 单一语句形式的链接指示符
extern "C" void exit(int);
// 复合语句形式的链接指示符
extern "C" {
int printf( const char* ... );
int scanf( const char* ... );
}
// 复合语句形式的链接指示符
extern "C" {
#include
}
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++函数能够为C 程序所用又该怎么办呢我们也可以使用extern "C"链接指示符来使C++函数为C 程序可用例如。
// 函数calc() 可以被C 程序调用
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() 可以从C 程序中被调用
double calc( double dparm ) { // ...
在本节中我们只看到为C 语言提供的链接指示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)不能将静态成员函数定义为虚函数:
(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用在如下的几个地方: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 int *p1或int const *p1;p1指向的数据不能被修改,但p1本身的值可以被修改(指向其他数据)const修饰指针指向的对象, int * const p2;p2本身的值不能被修改,但它指向的数据可以被修改。(const int *const p3;指针和所指向数据都是常量)const修饰引用做形参;
const 通常用在函数形参中,如果形参是一个指针,为了防止在函数内部修改指针指向的数据,就可以用 const 来限制。在C语言标准库中,有很多函数的形参都被 const 限制了,下面是部分函数的原型:
const
对象只能调用const成员
函数对于类的成员函数,有时候必须指定其返回值为const类型,以使得其返回值不为“左值”,只能作为右值使用。const int getNum();getNum() = 10; // 提示语法错误!
五、new与malloc的区别
#include //必须使用new头文件
Manager * pManager = new (nothrow) Manager();
if(NULL == pManager)
{
//记录日志
return false;
}
void *__CRTDECL operator new(size_t size) _THROW1(_STD bad_alloc)
{ // try to allocate size bytes
void *p;
while ((p = malloc(size)) == 0)
if (_callnewh(size) == 0)
{ // report no memory
_THROW_NCEE(_XSTD bad_alloc, );
}
return (p);
}
调用malloc失败后会调用_callnewh。如果_callnewh返回0则抛出bac_alloc异常,返回非零则继续分配内存。
_callnewh是
一个new handler,通俗来讲就是new失败的时候调用的回调函数。可以通过_set_new_handler来设置。
虚函数的作用?
动态绑定是如何实现的?
第一个问题中基本回答了,主要都是结合虚函数表来答就行。
静态多态和动态多态。静态多态是指通过模板技术或者函数重载技术实现的多态,其在编译器确定行为。动态多态是指通过虚函数技术实现在运行期动态绑定的技术。
虚函数表
虚函数表是针对类的还是针对对象的?同一个类的两个对象的虚函数表是怎么维护的?
编译器为每一个类维护一个虚函数表,每个对象的首地址保存着该虚函数表的指针,同一个类的不同对象实际上指向同一张虚函数表。为了实现多态进行动态绑定,将派生类对象指针绑定到基类指针上,对象销毁时,如果析构函数没有定义为虚析构函数,则会调用基类的析构函数,显然只能销毁部分数据。如果要调用对象的析构函数,就需要将该对象的析构函数定义为虚函数,销毁时通过虚函数表找到对应的析构函数。
纯虚函数定义:
virtual ~myClass()=0;
八、析构函数能抛出异常吗
答案肯定是不能。
C++标准指明析构函数不能、也不应该抛出异常。C++异常处理模型最大的特点和优势就是对C++中的面向对象提供了最强大的无缝支持。那么如果对象在运行期间出现了异常,C++异常处理模型有责任清除那些由于出现异常所导致的已经失效了的对象(也即对象超出了它原来的作用域),并释放对象原来所分配的资源, 这就是调用这些对象的析构函数来完成释放资源的任务,所以从这个意义上说,析构函数已经变成了异常处理的一部分。
(1) 如果析构函数抛出异常,则异常点之后的程序不会执行,如果析构函数在异常点之后执行了某些必要的动作比如释放某些资源,则这些动作不会执行,会造成诸如资源泄漏的问题。
(2) 通常异常发生时,c++的机制会调用已经构造对象的析构函数来释放资源,此时若析构函数本身也抛出异常,则前一个异常尚未处理,又有新的异常,会造成程序崩溃的问题。
九、构造函数和析构函数中调用虚函数吗?
十、指针和引用的区别点击打开链接
指针保存的是所指对象的地址,引用是所指对象的别名,
指针需要通过解引用间接访问,而引用是直接访问;
指针可以改变地址,从而改变所指的对象,而引用必须从一而终,总是指向最初获得的对象;
引用在定义的时候必须初始化,而指针则不需要;
指针有指向常量的指针和指针常量,而引用没有常量引用???
指针更灵活,用的好威力无比,用的不好处处是坑,而引用用起来则安全多了,但是比较死板。
没有所谓的空引用,但可以有空指针。
引用可能比使用指针更高效,因为使用引用前不用测试其有效性
double dval = 3.14;
const int &ri = dval;
dval = 5;//dval=5,ri=3
当一个常量引用被绑定到另一种类型上时发生什么,上面那段代码中,ri引用了int型的数,对ri的操作应该是整数运算,但dval却是一个双精度浮点数而非整数。因此为了确保ri绑定的是一个整数,编译器会把上面那段代码变成:
double dval = 3.14;
const int temp = dval;
const int &ri = temp;
ri绑定的对象并不是dval,而是一个临时量temp,所以此时无论你怎么改变dval的值都不会影响到ri的值,这就是为什么修改dval的值为5后ri的值认为3的原因。
double dval = 3.14;
int temp = dval;
int & ri = temp;
如果ri不是常量,就允许对ri赋值,这样就会改变ri所绑定对象的值,值得注意的是,上面ri绑定的是临时量temp,我们既然让ri绑定dval,就肯定是想通过ri来改变dval的值,否则干嘛要给ri赋值呢,如此,我们也不会想着把引用绑定到临时量上面吧,所以C++也把这种行为归为非法,即不允许将一个非常量引用绑定到另外一种类型的对象上.
如果是对一个常量进行引用,则编译器首先建立一个临时变量,然后将该常量的值置入临时变量中,对该引用的操作就是对该临时变量的操作。对常量的引用(常量引用)可以用其它任何引用来初始化,但不能改变:(初始化常量引用时允许用任意表达式作为初始值)
int i = 42;
const int &r1 = i; //正确:允许将const int & 绑定到一个普通int对象上
const int &r2 = 42; //正确
const int &r3 = r1 * 2; //正确
int &r4 = r1 * 2; //错误 ,相当于int &r4=84;不对,初始化值是一个左值,只能对const T&(常量引用)赋值
double dval = 3.14;
const int &ri = dval; //正确
//等价于
const int temp = dval;
const int &ri = temp;
int i = 42;
int &r1 = i;
const int &r2 = i;
r1 = 0; //正确
r2 = 0; //错误 ,常量引用不能改变
关于引用的初始化有两点值得注意:
(1)当初始化值是一个左值(可以取得地址)时,没有任何问题;
(2)当初始化值不是一个左值时,则只能对一个const T&(常量引用)赋值。而且这个赋值是有一个过程的:
首先将值隐式转换到类型T,然后将这个转换结果存放在一个临时对象里,最后用这个临时对象来初始化这个引用变量。
例子:
double& dr = 1; // 错误:需要左值
const double& cdr = 1; // ok
第二句实际的过程如下:
double temp = double(1);
const double& cdr = temp;
十一、指针与数组千丝万缕的联系
int num =0 ; //在32机器中告诉C编译器分配4个字节的内存
int a [] = {1,3,5,12,6,7,54,32}; //告诉C编译器分配32个字节的内存
printf("a:%d ,a+1:%d,&a:%d,&a+1:%d\n",a,a+1,&a,&a+1) ;
//a+1 和 &a+1 结果不一样
//虽然输出结果上面,a和&a一样 。 但是a 和 &a所代表的数据类型不一样
/*重要*/
//a 代表的数据首元素的地址 (首元素),同时与整个数组地址重合,但其不能代表整个数组,只能代表起始个体的地址
//&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
)。在初始化智能指针后,它将拥有原始指针,这意味着智能指针负责删除原始指针指定的内存, 智能指针析构函数包括要删除的调用,当智能指针超出范围时将调用其析构函数,析构函数会自动释放资源。
shared_ptr多个指针指向相同的对象。shared_ptr使用引用计数,每一个shared_ptr的拷贝都指向相同的内存。每使用他一次,内部的引用计数加1,每析构一次,内部的引用计数减1,减为0时,自动删除所指向的堆内存。shared_ptr内部的引用计数是线程安全的,但是对象的读取需要加锁。
unique_ptr“唯一”拥有其所指对象,同一时刻只能有一个unique_ptr指向给定对象(通过禁止拷贝语义、只有移动语义来实现)。相比与原始指针unique_ptr用于其RAII的特性,使得在出现异常的情况下,动态资源能得到释放。unique_ptr指针本身的生命周期:从unique_ptr指针创建时开始,直到离开作用域。离开作用域时,若其指向对象,则将其所指对象销毁(默认使用delete操作符,用户可指定其他操作)。unique_ptr指针与其所指对象的关系:在智能指针生命周期内,可以改变智能指针所指对象,如创建智能指针时通过构造函数指定、通过reset方法重新指定、通过release方法释放所有权、通过移动语义转移所有权。
#include
#include
int main() {
{
std::unique_ptr uptr(new int(10)); //绑定动态对象
//std::unique_ptr uptr2 = uptr; //不能賦值
//std::unique_ptr uptr2(uptr); //不能拷貝
std::unique_ptr uptr2(uptr.release());//uptr放弃对指针的控制权,将所有权从uptr转移给uptr2
std::unique_ptr uptr3(new int(1));
uptr2.reset(uptr3.release());//将所有权从uptr3转移给uptr2,reset释放了uptr2原来指向的内存 } //超過uptr的作用域,內存釋放}
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的区别?
//share_ptr可能出现循环引用,从而导致内存泄露
class A
{
public:
share_ptr p;
};
class B
{
public:
share_ptr p;
}
int main()
{
while(true)
{
share_prt pa(new A()); //pa的引用计数初始化为1
share_prt pb(new B()); //pb的引用计数初始化为1
pa->p = pb; //pb的引用计数变为2
pb->p = pa; //pa的引用计数变为2
}
//假设pa先离开,引用计数减一变为1,不为0因此不会调用class A的析构函数,因此其成员p也不会被析构,pb的引用计数仍然为2;
//同理pb离开的时候,引用计数也不能减到0
return 0;
}
/*
** weak_ptr是一种弱引用指针,其存在不会影响引用计数,从而解决循环引用的问题
*/
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行为需要一个虚表,而想要虚表,就必须得是一个含有虚函数的多态类型!
十四、内存对齐的原则
结构体:
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)。
具体例子:
typedef struct bb
{
int id; //[0]....[3] 表示4字节
double weight; //[8].....[15] 原则1
float height; //[16]..[19],总长要为8的整数倍,仅对齐之后总长为[0]~[19]为20,补齐[20]...[23] 原则3
}BB;
typedef struct aa
{
int id; //[0]...[3] 原则1
double score; //[8]....[15]
short grade; //[16],[17]
BB b; //[24]......[47] 原则2(因为BB内部最大成员为double,即8的整数倍开始存储)
char name[2]; //[48][49]
}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中,所有的共用体成员共用一个空间,并且同一时间只能储存其中一个成员变量的值。当一个共用体被声明时, 编译程序自动地产生一个变量, 其长度为联合中元类型(如数组,取其类型的数据长度)最大的变量长度的整数倍,且要大于等于其最大成员所占的存储空间。
union foo
{
char s[10];
int i;
}
在这个union中,foo的内存空间的长度为12,是int型的3倍,而并不是数组的长度10。若把int改为double,则foo的内存空间为16,是double型的两倍。
union mm{
char a;//元长度1 1
int b[5];//元长度4 20
double c;//元长度8 8
int d[3]; 12
};
struct inner
{
char c1;
double d;
char c2;
};
union data4
{
struct inner t1;
int i;
char c;
};
由于data4共用体中有一个inner结构体,所以最大的基本数据类型为double,因此以8字节对齐。共用体的存储长度取决于t1,而t1长度为24,因此sizeof(uniondata4)的值为24.
当在结构体中包含共用体时,共用体在结构体里的对齐地址为共用体本身内部所对齐位数,如下:
typedef union{
long i;
int k[5];
char c;
}DATE;
struct data{
int cat;
char cc;
DATE cow;
char a[6];
};
sizeof(DATE)=20, 而在结构体中中是4+1+3(补齐4对齐)+20+6+2(补齐4对齐)=36;
十五、内联函数有什么优点?内联函数与宏定义的区别?
十六、C++内存管理
1、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\0在常量区,p3在栈上。
static int c = 0; //全局(静态)初始化区
p1 = (char *)malloc(10);
p2 = (char *)malloc(20);
//分配得来得10和20字节的区域就在堆区。
strcpy(p1, "123456");
//123456\0放在常量区,编译器可能会将它与p3所指向的"123456"优化成一块。
}
3、学会迁移,可以说到malloc,从malloc说到操作系统的内存管理,说到内核态和用户态,然后就什么高端内存,slab层,伙伴算法,VMA可以巴拉巴拉了,接着可以迁移到fork()。
十七、STL里的内存池实现(请看<
二级分配器设计的非常巧妙,分别给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++的二级配置器:
第一级配置器以malloc(),free(),realloc()等C函数执行实际的内存配置、释放、重新配置等操作,并且能在内存需求不被满足的时候,调用一个指定的函数。
free_list的节点结构:union obj联合体
union obj {
union obj *free_list_link;
char client_data[1];
};
在内存池中所有的空闲块都以这样的方式连接起来,我们知道这个联合体的大小为4Byte,书中描述为这样:由于union之故,从其第一字段观之,obj可被视为一个指针,指向相同形式的另一个obj。从其第二字段观之,obj可被视为一个指针,指向实际区块。也就是说如果我们用下面的方式取出内存池中的可用内存:
// 使用时将其取出,指向下一个区块的free_list_link此时就被我们当做空闲区域来使用了,因而不会额外占用空间
obj *myBlock = free_list;
free_list = free_list->free_list_link;
// 之后直接使用myBlock->client_data来访问该内存区域
也就是说:
printf("%x\n", myblock);
printf("%x\n", myblock->client_data);
它们的地址是一样的,但是为什么要这样呢?在一般情况下,假设我们利用malloc申请了一块内存,它返回的是void*的指针,如果我们要使用这块内存,假设我们要让它存放char型的数据,要进行(char *)myblock的转换。但是这里我们不需要转换,直接使用myblock->client_data进行,就可以以char*类型获得这块内存的首地址!!这这使得操作更加方便!!!
//申请大小为n的数据块,返回该数据块的起始地址
static void * allocate(size_t n)
{
obj * __VOLATILE * my_free_list;
obj * __RESTRICT result;
if (n > (size_t) __MAX_BYTES)//大于128字节调用第一级配置器
{
return(malloc_alloc::allocate(n));
}
my_free_list = free_list + FREELIST_INDEX(n);//根据申请空间的大小寻找相应的空闲链表(16个空闲链表中的一个)
result = *my_free_list;
if (result == 0)//如果该空闲链表没有空闲的数据块
{
void *r = refill(ROUND_UP(n));//为该空闲链表填充新的空间
return r;
}
*my_free_list = result -> free_list_link;//如果空闲链表中有空闲数据块,则取出一个,并把空闲链表的指针指向下一个数据块
return (result);
};
//释放地址为p,释放大小为n
static void deallocate(void *p, size_t n)
{
obj *q = (obj *)p;
obj * __VOLATILE * my_free_list;
if (n > (size_t) __MAX_BYTES)//如果空间大于128字节,采用普通的方法析构
{
malloc_alloc::deallocate(p, n);
return;
}
my_free_list = free_list + FREELIST_INDEX(n);//否则将空间回收到相应空闲链表(由释放块的大小决定)中
q -> free_list_link = *my_free_list;
*my_free_list = q;
}
回收内存块:
(4)重新填充空闲链表refill()
template
void* refill(size_t n)
{
int nobjs = 20;
char * chunk = chunk_alloc(n, nobjs);//从内存池里取出nobjs个大小为n的数据块,返回值nobjs为真实申请到的数据块个数,注意这里nobjs个大小为n的数据块所在的空间是连续的
obj * __VOLATILE * my_free_list;
obj * result;
obj * current_obj, * next_obj;
int i;
if (1 == nobjs) return(chunk);//如果只获得一个数据块,那么这个数据块就直接分给调用者,空闲链表中不会增加新节点
my_free_list = free_list + FREELIST_INDEX(n);//否则根据申请数据块的大小找到相应空闲链表
result = (obj *)chunk;
*my_free_list = next_obj = (obj *)(chunk + n);//第0个数据块给调用者,地址访问即chunk~chunk + n - 1
for (i = 1; ; i++)//1~nobjs-1的数据块插入到空闲链表
{
current_obj = next_obj;
next_obj = (obj *)((char *)next_obj + n);//由于之前内存池里申请到的空间连续,所以这里需要人工划分成小块一次插入到空闲链表
if (nobjs - 1 == i)
{
current_obj -> free_list_link = 0;
break;
}
else
{
current_obj -> free_list_link = next_obj;
}
}
return(result);
}
假设程序一开始调用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是基于什么实现的。红黑树的特点?
红黑树的定义:(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),而且不容易进行调整),插入的情况包括下面几种:
父亲结点和叔父结点均为红色,显然无法满足性质(2),则将父亲结点和叔父结点绘制成黑色,祖父结点设置成红色,但是仍然无法满足情况,比如考虑到祖父结点可能是根结点,则无法满足性质(4),或者祖父结点的父结点是红色的,则无法满足性质(2),这时需要将祖父结点作为新的结点来看待进行各种情况的判断,涉及到对祖父结点的递归;
新插入结点为父亲结点的左子结点,那么就构成了一个左左的情况,在之前平衡树中提到过,如果要将其进行平衡,则需要对父结点进行一次单右旋转,形成一个父亲结点为相对根结点,子结点和祖父结点为子结点的树,同时将父亲结点的红色改为黑色,祖父结点更改为红色,这下之前无法满足的性质4和性质5就满足了;
新插入结点为父亲结点的右子结点,那么就会构成一个左右的情况,在之前的平衡树也提到过要进行一次双旋转,先对新结点进行一次单左旋转,变成了左左的结构,再进行一次单右旋转(上图),从而达到满足所有性质;
父亲结点是祖父结点的右结点,参考平衡树进行相应的操作,原理是一致的
红黑树删除
删除操作伪代码:
RB-DELETE(T, z)
if left[z] = nil[T] or right[z] = nil[T]
then y ← z // 若“z的左孩子” 或 “z的右孩子”为空,则将“z”赋值给 “y”;
else y ← TREE-SUCCESSOR(z) // 否则,将“z的后继节点”赋值给 “y”。
if left[y] ≠ nil[T]
then x ← left[y] // 若“y的左孩子” 不为空,则将“y的左孩子” 赋值给 “x”;
else x ← right[y] // 否则,“y的右孩子” 赋值给 “x”。
p[x] ← p[y] // 将“y的父节点” 设置为 “x的父节点”
if p[y] = nil[T]
then root[T] ← x // 情况1:若“y的父节点” 为空,则设置“x” 为 “根节点”。
else if y = left[p[y]]
then left[p[y]] ← x // 情况2:若“y是它父节点的左孩子”,则设置“x” 为 “y的父节点的左孩子”
else right[p[y]] ← x // 情况3:若“y是它父节点的右孩子”,则设置“x” 为 “y的父节点的右孩子”
if y ≠ z
then key[z] ← key[y] // 若“y的值” 赋值给 “z”。注意:这里只拷贝y的值给z,而没有拷贝y的颜色!!!
copy y's satellite data into z
if color[y] = BLACK
then RB-DELETE-FIXUP(T, x) // 若“y为黑节点”,则调用
return y
RB-DELETE-FIXUP(T, x)
while x ≠ root[T] and color[x] = BLACK
do if x = left[p[x]]
then w ← right[p[x]] // 若 “x”是“它父节点的左孩子”,则设置 “w”为“x的叔叔”(即x为它父节点的右孩子)
if color[w] = RED // Case 1: x是“黑+黑”节点,x的兄弟节点是红色。(此时x的父节点和x的兄弟节点的子节点都是黑节点)。
then color[w] ← BLACK ▹ Case 1 // (01) 将x的兄弟节点设为“黑色”。
color[p[x]] ← RED ▹ Case 1 // (02) 将x的父节点设为“红色”。
LEFT-ROTATE(T, p[x]) ▹ Case 1 // (03) 对x的父节点进行左旋。
w ← right[p[x]] ▹ Case 1 // (04) 左旋后,重新设置x的兄弟节点。
if color[left[w]] = BLACK and color[right[w]] = BLACK // Case 2: x是“黑+黑”节点,x的兄弟节点是黑色,x的兄弟节点的两个孩子都是黑色。
then color[w] ← RED ▹ Case 2 // (01) 将x的兄弟节点设为“红色”。
x ← p[x] ▹ Case 2 // (02) 设置“x的父节点”为“新的x节点”。
else if color[right[w]] = BLACK // Case 3: x是“黑+黑”节点,x的兄弟节点是黑色;x的兄弟节点的左孩子是红色,右孩子是黑色的。
then color[left[w]] ← BLACK ▹ Case 3 // (01) 将x兄弟节点的左孩子设为“黑色”。
color[w] ← RED ▹ Case 3 // (02) 将x兄弟节点设为“红色”。
RIGHT-ROTATE(T, w) ▹ Case 3 // (03) 对x的兄弟节点进行右旋。
w ← right[p[x]] ▹ Case 3 // (04) 右旋后,重新设置x的兄弟节点。
color[w] ← color[p[x]] ▹ Case 4 // Case 4: x是“黑+黑”节点,x的兄弟节点是黑色;x的兄弟节点的右孩子是红色的。(01) 将x父节点颜色 赋值给 x的兄弟节点。
color[p[x]] ← BLACK ▹ Case 4 // (02) 将x父节点设为“黑色”。
color[right[w]] ← BLACK ▹ Case 4 // (03) 将x兄弟节点的右子节设为“黑色”。
LEFT-ROTATE(T, p[x]) ▹ Case 4 // (04) 对x的父节点进行左旋。
x ← root[T] ▹ Case 4 // (05) 设置“x”为“根节点”。
else (same as then clause with "right" and "left" exchanged) // 若 “x”是“它父节点的右孩子”,将上面的操作中“right”和“left”交换位置,然后依次执行。
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中允许修改键值的话,那么首先需要删除该键,然后调节平衡,再插入修改后的键值,再调节平衡,如此一来,严重破坏了set的结构,导致iterator失效,不知道应该指向之前的位置,还是指向改变后的位置。所以STL中将set的迭代器设置成const_iterator,不允许修改迭代器的值。
map:
map和set一样是关联式容器,它们的底层容器都是红黑树,区别就在于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 *strDest, const char *strSrc)
{
assert((strDest!=NULL) && (strSrc !=NULL)); // 2分
char *address = strDest; // 2分
while( (*strDest++ = * strSrc++) != '\0' ); // 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]'\0'
(A)循环写成while (*dst++=*src++);明显是错误的。
(B)循环写成while (*src!='\0') *dst++=*src++;
循环体结束后,dst字符串的末尾没有正确地加上'\0'。
har s[10]="hello";
strcpy(s, s+1); //应返回ello,
//strcpy(s+1, s); //应返回hhello,但实际会报错,因为dst与src重叠了,把'\0'覆盖了
所谓重叠,就是src未处理的部分已经被dst给覆盖了,只有一种情况:src<=dst<=src+strlen(src)
char *my_strcpy(char *dst,const char *src)
{
assert(dst != NULL);
assert(src != NULL);
char *ret = dst;
memcpy(dst,src,strlen(src)+1);
return ret;
}
memcpy函数实现时考虑到了内存重叠的情况,可以完成指定大小的内存拷贝。
void * my_memcpy(void *dst,const void *src,unsigned int count)
{
assert(dst);
assert(src);
void * ret = dst;
if (dst <= src || (char *)dst >= ((char *)src + count))//源地址和目的地址不重叠,低字节向高字节拷贝
{
while(count--)
{
*(char *)dst = *(char *)src;
dst = (char *)dst + 1;
src = (char *)src + 1;
}
}
else //源地址和目的地址重叠,高字节向低字节拷贝
{
dst = (char *)dst + count - 1;
src = (char *)src + count - 1;
while(count--)
{
*(char *)dst = *(char *)src;
dst = (char *)dst - 1;
src = (char *)src - 1;
}
}
return ret;
}
char* Strcat(char *dst, const char *src)
{
assert(dst != NULL && src != NULL);
char *temp = dst;
while (*temp != '\0')
temp++;
while ((*temp++ = *src++) != '\0');
return dst;
}
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'为止
int strcmp(const char* str1, const char* str2)
{
int ret = 0;
while(!(ret=*(unsigned char*)str1-*(unsigned char*)str2) && *str1)
{
str1++;
str2++
}
if (ret < 0)
return -1;
else if (ret > 0)
return 1;
return 0;
}
int strcmp(const char *str1, const char *str2)
{assert(str1!=NULL&&str2!=NULL);
while(*str1 == *str2 && *str1 != '\0' && *str2 != '\0')
{
++str1;
++str2;
}
return *str1 - *str2;
}