C++学习笔记
一、面向对象基本特性
面向对象的三个基本特征:继承、封装、多态
1. 继承:代码重用;向上转型(复合:不同于继承,是用已有的类对象去生成新的类,区别什么时候用继承什么时候用复合)。
使用继承的情况:如果新类 is a 旧类;需要向上转型(蚂蚁—>昆虫)
使用复合的情况:如果新类 has a 旧类(汽车—>引擎)
public公有继承:基类中公有成员和受保护的成员在派生类中保持原有状态,私有成员不可访问。
protected受保护的继承:基类中的公有成员和受保护的成员在派生类中变为受保护的状态,私有成员不可访问。
private私有继承:基类中的所有成员在派生类中变为私有成员。
public:派生类可访问,类作用域之外可访问。
protected:派生类可访问,类作用域之外不可访问。
private:均不可访问。
通过派生类指针或引用可以调用基类的方法,如果已经重写或者重定义了,需要使用类限定符,如果没有的话可以直接调用。但是基类指针要想调用派生类的方法必须先将该方法声明为虚函数。
2. 封装:将对象的数据包围起来,只能通过特定的接口(公开的函数)访问对象的数据。隐藏具体实现,公开对外接口。封装的优点:保证公共的对外接口不变的情况下,可以自由的修改内部实现。
3. 多态:基于虚函数实现,不同的派生类对基类的虚函数进行重写。借助虚函数,基类的指针或引用既可以调用基类的成员函数,也可以调用派生类成员函数。可以使用一种通用的编码方式处理不同的派生类对象,实现了泛型。
构成多态的条件:
1. 必须存在继承关系;
2. 继承关系中必须存在同名的虚函数,必须是重写的关系;
3. 用基类的指针调用派生类族中任意类对象的虚函数。
二、函数的重载、重写和重定义
1. 重载:同一个类中(同一作用域中),函数名相同,参数类型或者个数或者次序不同,对函数返回值类型没要求(不能用返回值类型不同进行区分)。函数重载的好处是通过一组相同的函数名来表达一组相似的功能,避免了过多的函数名污染了命名空间,增加了可读性。
注意:并不是说参数类型不相同就一定没问题了,虽然符合重载的要求,但是编译器还是有可能会报错,比如func(char)和func(long)现在传进来一个int型的实参,编译器不知道该用哪个了,会报ambiguous。貌似先进行精确匹配,比如这里如果有个func(int)那么编译器会毫不犹豫的使用这个函数。如果精确匹配找不到,那就进行模糊匹配。
那么编译器是如何区分重载函数的呢?通过对代码进行汇编,可以发现函数在被编译后由函数名加参数类型进行区分,比如fun(int a, char b)通过Xcode进行汇编,发现汇编语言下对应的函数名是Z3funic也就是Z + 函数名长度 + 函数名 + 参数类型1 + 参数类型2,这里并没有返回值类型,这也是为什么不能通过返回值类型的不同进行区分。注意C语言中所有的全局变量和函数编译成汇编语言后会在符号名前面加上下划线,这是为了避免自定义函数名和其他库中的函数名冲突。
2. 重写:继承层次中(不同作用域),派生类与基类的函数名相同,参数列表相同,返回值也必须相同(如果前两者都相同,但是返回值类型不同会报错),实现功能不同。
注意:如果基类定义了虚函数而派生类没有进行重写,并且这时派生类声明了一个同名函数,但是参数列表不同,那么发生了重定义,此时通过派生类的指针只能访问派生类重定义的这个函数,而不能访问到基类的虚函数,如果要访问需要加基类的类限定符。当然如果派生类没声明同名函数的话是可以通过派生类的指针去访问基类的虚函数的。
3. 重定义:继承层次中(不同作用域),派生类的函数名和基类相同,参数列表可以相同也可以不同,不过参数列表相同时基类不能是虚函数。
注意:访问权限修饰符对于这三种方式都没有任何影响。即便基类把某个函数定义为私有,还是不耽误重载,派生类的重写和重定义。
三、虚函数
1. 虚函数
在继承层次中,如果派生类重写了基类的某个函数,那么将基类指针指向派生类对象时,基类指针只能调用基类中的该函数(发生了向上转型),而不能调用派生类的。虚函数的作用就是使得基类指针可以调用派生类中的该函数,也就是说只需要一个指针,使其指向同一类族中不同的类对象,就可以调用整个类族中任意类对象的虚函数。
注意:static修饰的函数不能定义为虚函数。因为static修饰的函数属于类全局的,不属于某个对象,因此不含this指针,而virtual修饰的函数需要知道是哪个类对象调用他(每个类对象都有一个vptr),必须得有隐含的this指针,通过this指针找到当前对象,然后在类对象的虚函数表里找到这个虚函数。
虚函数的应用案例:
一个函数形参是基类指针或引用,传入基类或派生类指针或引用(注意这里形参不能是类对象,也就是不能进行值传参,因为会发生向上转型)。
静态绑定:编译期间将函数调用和函数实现绑定起来,比如在一个函数参数列表里传入一个类指针Base *base,函数体中通过这个类指针调用了这个类的某个函数base->fun(),如果这个函数不是虚函数,在编译期间,编译器会把调用fun()的地方和fun()函数实现绑定起来。
动态绑定:相对于静态绑定,在函数参数列表中传入Base *base,其中base->fun()调用了虚函数fun(),那么由于可能传给base的是基类对象也可能是派生类对象,所以编译器不知道base->fun()调用的是哪个类对象的虚函数,也就无法在编译期进行绑定,只有运行时,传入具体参数才能进行绑定。
下面是几种使用动态绑定或者静态绑定的情况:
1. 类名::函数名-----------不管是不是虚函数都是静态绑定
2. 对象名.函数名----------不管是不是虚函数都是静态绑定
3. 引用变量名.函数名-----虚函数动态绑定;非虚函数静态绑定
4. 指针->函数名----------虚函数动态绑定;非虚函数静态绑定
下面的案例中,由于print()是虚函数,vir_print()无法知道函数体内部的print函数应该执行基类还是派生类的。因此无法静态绑定,只有在程序执行的时候进行绑定。
#include "Shape.h" #include "Circle.h" void vir_print(Shape* it) { it->print(); } int main() { Shape* shape = new Shape(); Circle* circle = new Circle(); vir_print(shape); vir_print(circle); return 0; }
2. 纯虚函数
和虚函数的区别是虚函数在基类中可以选择是否实现,而纯虚函数不能实现,相当于一个接口,只有通过子类来实现。
抽象类:含有纯虚函数的类,只要有一个纯虚函数就是抽象类,抽象类不能实例化对象。如果派生类没有实现抽象基类中的所有纯虚函数,该派生类仍然是一个抽象类。抽象类是其类族的一个公共基类,相当于为其类族提供一个公共接口。
3. 为什么构造函数和析构函数中不能使用虚函数?为什么构造函数不能声明为虚函数?
原因一:在执行基类的构造函数时派生类的构造函数还没进行初始化,因此这个时候调用派生类的虚函数,如果使用了派生类中未初始化的成员变量会出错。
原因二:当派生类在执行基类的构造函数时,派生类对象的类型其实是基类的类型,所以虚函数肯定也是基类的虚函数。
析构函数不能调用虚函数主要是因为,析构函数是从派生类到基类的调用顺序,因此如果基类析构函数中调用了派生类的虚函数,但是派生类虚函数用到了派生类中已释放的成员变量时,会出错。
不过在构造函数中调用虚函数是可以编译通过的,但是将构造函数直接声明为虚函数是无法通过编译的,因为调用构造函数时,会初始化vptr指向当前对象的vtbl,但是现在声明构造函数为虚函数,在执行构造函数之前,vptr和vtbl都没有初始化,所以也就找不到虚构造函数。(先有鸡还是先有蛋的问题)
4. 析构函数在什么时候使用虚函数?
当使用基类指针指向派生类对象时,使用delete释放内存空间时会调用析构函数,如果不声明为虚函数会导致只调用了基类的析构函数,没有调用派生类的析构函数,造成内存泄露。
另外也不要总是把析构函数声明为虚函数,因为对于存在虚函数的类对象里面有一个虚函数表指针,它会指向这个类的虚函数表,然后编译器从中寻找需要执行的虚函数,但是vptr会将类对象的占用空间增大将近一倍。
还有一种情况,想把当前的类变成一个抽象类,但是该类又没有可用的纯虚函数可以声明时,可以把析构函数声明为纯虚析函数。
5. 虚函数表
先声明一下:vtbl是在类构建或者派生时初始化的,只属于类,而不属于某个类对象,而vptr是在类实例化时初始化的,每个类对象中都存有一份vptr,通过vptr在vtbl中找需要调用的虚函数,vtbl就是一个虚函数指针数组。
占用类的内存的有其非静态成员变量、vptr(个数跟继承父类的个数有关),剩下的静态成员变量、成员函数都是不占类的存储空间的。
只要一个类中声明了虚函数,那么这个类的每个实例化对象中都存有一个虚函数表指针vptr,并且派生类中会继承基类中的虚函数表,虚函数在虚函数表中是按照其在类中的声明顺序排列的,对于单继承、多重继承、派生类是否重写基类虚函数分为四种情况进行讨论。
单继承不重写:基类有三个虚函数f,g,h,派生类将它们存储到自己的虚函数表中,如果不进行重写,那么就是基类的虚函数。底下紧跟的是其他成员变量(从上往下先是基类的,然后才是派生类的),如下图
下面这张图在单继承不重写的前提下,派生类还定义了自己的虚函数,它们是按照声明的顺序放到基类虚函数的后面的,如下图
单继承重写:派生类重写了基类的f虚函数,那么会将派生类的f写到原基类f的位置覆盖掉,如下图
多重继承不重写:每个基类的虚函数表都会继承到派生类中,派生类自己定义的虚函数放到第一个虚函数表中,每个虚函数表最后一位存放的信息是后面还有没有虚函数表,下面这张图貌似有点问题,每个基类的其他成员变量应该插在虚函数表中间。
多重继承重写:派生类重写的虚函数会覆盖所有基类中的同名虚函数。
单继承并且有成员变量:成员变量紧接着虚函数表,先是基类成员变量,然后才是派生类的。
多重继承有成员变量:基类的成员变量紧跟着基类的虚函数表,最后才是派生类的成员变量。
菱形继承:B1和B2中都存在基类B的虚函数以及成员变量,派生出D后,这些都存在两份。
四. 构造函数、析构函数和赋值运算符函数
构造函数执行顺序:从基类到派生类
析构函数执行顺序:从派生类到基类
析构函数不应该抛出异常
注意是不应该抛出异常throw,也就是不应该将异常抛到函数之外,因为在析构函数中需要进行清理和内存释放工作,当在析构函数抛出异常时,如果该类有成员对象,那么这个成员对象的析构函数会被调用,但是当前这个类对象本身的内存却得不到释放,从而造成内存泄漏。
最跟本的原因是当一个对象发生异常时,c++会调用它的析构函数释放它的内存(因为该对象已经失效),可以把析构函数视为异常处理的一部分,如果此时析构函数也抛出异常,也就是异常嵌套异常,因为异常处理机制涉及到一个异常链,具体原因现在还解释不清楚。。。先理解为本身出现异常是要调用析构函数的,当在析构函数中抛出异常,那就没有析构函数可寻了。
如果析构函数内部真的可能会发生异常的话,也要想办法把异常给吞掉(try-catch语句),而不是抛出到析构函数之外。
构造函数也不应该抛出异常
因为当构造函数抛出异常的时候,这个类的成员对象已经被分配了内存(注意是成员对象,而不是指向某一对象的指针),那么会自动调用它们的析构函数销毁掉它们,但是并不会调用该对象自己的析构函数,所以需要用try-catch语句将异常捕获,在catch里面显式调用当前对象的析构函数,或者用智能指针来对它进行管理。
关于析构函数,对于一个类中的成员对象,在调用外部类对象的析构函数时,会自动调用成员对象的析构函数。但是如果成员是一个指向某个对象内存地址的指针,那么是不会调用这个对象的析构函数的。所以在该类的析构函数里面要显式的delete掉其指针所指向的对象内存,并且不要忘了把指针置为null。
class Help { public: ~Help(){ cout<<"Help Des"<<endl; } }; class Base { public: Base(){ cout<<"C"<<endl; } ~Base(){ cout<<"D"<<endl; delete help_ptr;
help_ptr=null; } public: //会调用help的析构函数 Help help; //不会调用Help的析构函数 Help *help_ptr=new Help(); }; int main() { //会调用Base的析构函数 Base base; //不会调用Base的析构函数 Base *base_ptr = new Base(); delete base_ptr;
base_ptr = null; return 0; }
注意事项:类中成员变量的初始化顺序是按照声明顺序来的,跟构造函数的初始化列表顺序无关,另外,C++如果没有对类成员变量进行初始化,将默认初始化一个随机值。
拷贝构造函数
貌似使用自定义拷贝构造函数可以避免编译器自动生成拷贝构造函数,提高程序效率。。。不清楚是不是真的。。。
深拷贝和浅拷贝
浅拷贝在执行自定义拷贝构造函数时,将一个类对象的所有成员变量赋值给另一个,这样一来,如果某个成员指针指向一个内存空间,当进行浅拷贝时,新的类对象也会将指针指向这个内存空间,当通过某个指针释放了内存后,另外一个指针会成为野指针。
为了解决上述问题,可以使用深拷贝,深拷贝会进行资源的重新分配,也就是重新申请一块内存空间,然后将指针指向该内存,然后再将原对象指针所指内存里的内容赋值给新的内存。
一般来说,如果类成员变量不涉及指针指向对象内存时,可以用浅拷贝,否则应该使用深拷贝以防止野指针的出现。
注意默认构造函数执行的是浅拷贝操作。
注意事项:
首先,编译器会默认实现赋值函数(operator=)和复制构造函数,如果自定义的话需要注意赋值或者复制要把派生类中所有成员变量以及其基类的所有成员变量进行复制。
class Base { public: Base(int a):_a(a) {} Base(Base& base):_a(base._a) {} Base():_a(10) {} ~Base() {} public: int get_A() { return _a; } private: int _a; }; class Derive: public Base { public: Derive(int a,int b):Base(a), _b(b){} Derive(Derive& derive):Base(derive), _b(derive._b){} Derive(int b):Base(10), _b(b){} Derive():Base(10), _b(10) {} ~Derive() {} public: int get_B() { return _b; } private: int _b; }; int main() { Base* base = new Base(20); Base* base2 = new Base(*base); //输出20 cout<<base2->get_A()<<endl; Derive* derive = new Derive(15, 20); Derive* derive2 = new Derive(*derive); //输出15 20 cout<<derive2->get_A()<<" "<<derive2->get_B()<<endl; Derive* derive3 = new Derive(15); //输出10 15 cout<<derive3->get_A()<<" "<<derive3->get_B()<<endl; return 0; }
赋值运算符函数
1. 把返回的类型声明为该类型的引用,这样可以使用连续赋值a=b=c。
2. 把传入参数的类型声明为常量引用,这样可以省去将实参传给形参的拷贝工作,还有利于实现多态(值传递会发生向上转型),不过引用传递当在函数体里进行修改,函数体外也会发生改变,如果不希望改变可以在形参前面加const修饰符。
3. 在分配新内存前要释放原有内存空间,防止出现内存泄露。
4. 判断传入参数和当前实例是不是同一个实例,如果是就不进行赋值操作,因为如果是同一个实例,在上一步释放自身内存的同时参数的内存也会被释放掉。
class MyString { public: MyString() { mstr = ""; } ~MyString() {} //传入参数采用pass by reference to const的形式 MyString& operator=(const MyString &str) { //先判断是否为同一实例 if(this == &str) { return *this; } //先释放内存 delete []mstr; mstr = NULL; mstr = new char[strlen(str.mstr) + 1]; strcpy(mstr, str.mstr); //返回当前对象的引用 return *this; } void setStr(const char *str) { this->mstr = const_cast<char*>(str); } public: char *mstr; }; int main() { MyString *ms = new MyString(); char *str = "Mike"; ms->setStr(str); MyString *mss = new MyString(); str = "Tom"; mss->setStr(str); MyString *msss = new MyString(); str = "Lucy"; msss->setStr(str); mss = ms; cout<<mss->mstr<<endl; mss = ms = msss; cout<<mss->mstr<<endl; return 0; }
上面这种写法,如果在新分配内存的时候由于内存不足抛出异常,字符串指针可能会成为一个空指针,并且之前的内存空间还被释放了,没法恢复。可以这样,在释放内存之前先分配新内存,在分配成功后再释放内存。还有一种写法是直接声明一个局部临时对象,通过其所在作用域结束后调用析构函数的方法来释放原对象内存。mstr通过tmp指向新内存区域,其原来指向的内存通过临时对象tmpStr来释放。
MyString& operator =(const MyString &str) { if(this != &str) { MyString tmpStr(str); char *tmp = tmpStr.mstr; tmpStr.mstr = mstr; mstr = tmp; } return *this; }
目前我对于拷贝构造函数和赋值构造函数的写法是这样的
class MyString { public: MyString(const MyString &rhs) { //由于成员指针指向某个对象内存,使用深拷贝,先开辟新的内存空间,然后进行赋值 //这里如果使用浅拷贝这样写 s=rhs.s s = new char[strlen(rhs.s) + 1]; if (s != NULL) { strcpy(s, rhs.s); } } ~MyString() { //注意这里必须显式的调用delete否则不会释放s所指向的内存空间 delete []s;
s = NULL; } //pass by reference to const //return reference MyString& operator=(const MyString &rhs) { //判断当前对象和rhs是否是同一对象 if(this != &rhs) { //通过局部类对象实现先重新分配内存,再释放旧的内存 MyString tmp_str(rhs); char *tmp_ptr = tmp_str.s; tmp_str.s = s; s = tmp_ptr; /* 还有一种并不安全的写法 delete []s; s = new char[strlen(rhs.s) + 1]; if(s != nullptr) { strcpy(s, rhs.s); } */ } //为了能够进行连续赋值 return *this; } public: char *s; };
四、指针和引用
指针和引用的区别:
1. 指针是指向内存,内容是内存地址,而引用是变量别名
2. 引用不需要解引用,而指针需要
3. 引用只能在定义的时候被初始化一次之后不能改变,而指针可以改变,引用相当于一个常指针
4. sizeof指针得到的是指针指向变量或对象地址的大小,sizeof引用指的是引用变量或对象的大小。
联系:引用相当于只允许取内容的指针
int a = 10; int b = a; //b是a的一个拷贝,b需要占据存储空间 int *p = &a; //p是指向a的指针,p需要占据存储空间 int &c = a; //c是a的别名,不占据存储空间
指向指针的指针:其中p是指向a的指针,pp是指向指针p的指针。*pp得到的是指针p的地址,*pp得到的是指针p的内容。
int a = 10; int *p = &a; int **pp = &p; cout<<**pp<<endl; return 0;
指向函数的指针:编译期间编译器会给函数分配一个入口地址(函数名表示这个入口地址,就像数组名表示其首元素地址),可以使用指针指向这个入口地址,然后可以通过指针调用函数。一般指向函数的指针定义形式如下:
函数返回值类型 (*指针变量名)(参数类型列表) = 函数名
比如下面的int (*p)(int,int) = max;通过指针调用函数(*p)(10,90)相当于max(10,90)
int max(int a,int b) { return a>b?a:b; } int main() { int (*p)(int,int)=max; cout<<(*p)(10, 90)<<endl; return 0; }
另外,函数指针还有个非常重要的作用,就是作为一个函数的参数进行实参到形参到传递,可以方便的将返回值类型和参数列表类型都相同的函数作为参数传递。
注意:下面第三种写法输出了100,不知道为啥。。。
int getA(int a) { return a; } int getB(int b) { return b; } int max(int (*p1)(int), int (*p2)(int)) { int a=10,b=20; int ret1=(*p1)(a); int ret2=(*p2)(b); return ret1>ret2?ret1:ret2; } int main() { int (*p1)(int) = getA; int (*p2)(int) = getB; //输出20 cout<<max(getA,getB)<<endl; //输出20 cout<<max(*p1,*p2)<<endl; //输出100 cout<<max((*p1)(10),(*p2)(100))<<endl; return 0; }
函数的入口地址
每个函数名都表示函数的入口地址,相当于数组名表示数组的首元素地址,可以通过函数入口地址来调用函数。
int mmax(int a,int b) { return a>b?a:b; } int main() { int addr = (int)mmax; //保存函数的入口地址 //先用函数指针将入口地址进行强制类型转换,然后输入参数 cout<<((int(*)(int,int))addr)(1000,100)<<endl; return 0; }
指针和数组的关系
首先得讲一下二维数组的内存布局方式,虽然二位数组是一个矩阵,但是在内存中存储方式仍然是一维,比如:int a[3][3] 中某个元素a[i][j]的实际寻址方式是这样的:*(*(a+i)+j),首先a表示数组首元素地址也就是a[0][0]的地址,假设a的地址是0x477020,那么a+1的地址是0x47702c,注意这里偏移了12个字节,因为对于每个a[i]需要3个整型变量的存储空间,也就是12字节的空间。然后*(a+1)+1的地址是0x477030,跨越了4个字节的地址空间,然后*(*(a+1)+1)就是a[1][1]的元素内容了。
指针数组
由指针组成的数组,int *p[10] 是一个数组,里面每个元素都是一个指针,可以指向一个int类型。
数组指针/行指针
指向数组的指针,int (*p)[10] 是一个指针,可以指向10个int类型的元素,指针指向的是一个数组类型。
四种表达方式:
二维数组: int a[3][6] 在函数传参数时被编译器转换为 int (*p)[6] 数组指针
指针数组:int *a[3] 在函数传参数时被编译器转换为 int **p 指针的指针
数组指针:int (*a)[6] 不进行转换
指针的指针:int **a 不进行转换
一维数组: int a[3] 在函数传参数时被编译器转换为int *p
对于一个数组,函数体之外对其进行sizeof得到的是数组大小,作为参数传给函数后,在函数体内对其进行sizeof得到的是指针大小,32位下是4字节,64位下是8字节。
二维数组和数组指针是等价的,在首元素偏移的时候都是偏移数组长度所占的字节数。
指针数组和指针的指针是等价的,在首元素偏移的时候都是偏移指针所占的字节数。
在编译器上定义int a[3][6],然后让int **p=a会报错,表示不能将int (*)[6]转换成int **,也就是说编译器会把一个二维数组翻译成数组指针。当然直接让int *p[3]=a也会报错,应该用一个for循环让数组中每个指针指向a中的每个数组,当然如果是动态数组的话,这几个指针会指向同一块内存区域,也可以用拷贝的方法,for循环中每次都malloc一次,malloc(sizeof(int)*6),当然也可以一次malloc,malloc(sizeof(int)*6*3),然后for循环用指针指向不同的内存区域。
可以这样写
字符串数组和字符串指针的区别
如果使用数组存储字符串,就是将字符串拷贝到不同的数组中去,可以对数组进行修改,但是数组的起始地址是不同的。使用指针指向字符串,那么不同指针指向的是同一个字符串存储区,所以指针指向的地址都是相同的,但是不能通过指针来修改这个字符串常量。
#include <cstdio> int main() { char str1[] = "hello"; char str2[] = "hello"; char* str3 = "hello"; char* str4 = "hello"; //两个数组的初始地址不同 if(str1 == str2) printf("str1 and str2 are same.\n"); else printf("str1 and str2 are not same.\n"); //两个指针指向相同的字符串地址 if(str3 == str4) printf("str3 and str4 are same.\n"); else printf("str3 and str4 are not same.\n"); str1[0] = 'H'; *(str3+0) = 'H'; //这样修改会报错,指针指向的是字符串常量 return 0; }
五、new和malloc的区别
1. malloc / free
首先malloc是一个库函数
void* malloc(int size)表示分配size个字节的内存,void*表示未知类型的指针,也就是说分配内存空间,但是不知道用来存放什么,可以强制转换为任何类型的指针。
void free(void* FirstByte)
注意:
申请了内存空间后必须检查分配内存空间是否成功,检查指针是否为null
释放内存后,要将指向该内存空间的指针指向null,防止以后再次访问这个指针
如果释放空指针多次不会报错,但是释放非空指针多次可能会报错,要看编译器了
calloc
void *calloc(size_t nobj, size_t size) 动态分配n块长为size大小的内存区域。为空间中的每一位都初始化为0
realloc
void *realloc(void *ptr, size_t newsize) 对于一块已经分配的内存空间进行重新分配。不过在增大内存区域的时候,可能需要重新找一块更大的内存区域,然后将内容赋值过来。
2. new / delete
new是一个操作符
new返回的是指向新创建对象所在内存首地址的指针,new做了三件事:1. 调用::operator new分配内存空间。2. 调用构造函数进行初始化。3. 返回指向该内存首地址的指针。
delete做两件事:1. 调用析构函数清理资源。2. 调用::operator delete释放内存空间。
new和delete是不能被重载的,要实现不同的内存分配行为,可以对operator new、operator new[]和operator delete、operator delete[]进行重载,不过operator new不能进行全局的重载,一般只能在类中进行重载,如果在类中不进行重载,那么就调用默认的operator new来进行内存分配的工作。
void* operator new(std::size_t size) throw(std::bad_alloc);
void* operator new(std::size_t size, const std::nothrow_t& nothrow_constant) throw();
第一种写法分配size个存储空间,并对对象进行内存对齐,如果分配内存异常,会抛出bad_alloc;第二种不抛异常,这两种operator new的重载都不会调用构造函数。
ClassType *p = new ClassType();对于这样一条语句,new首先调用operator new分配内存,然后调用构造函数进行初始化,然后返回指向对象内存首地址的指针。
placement new
使用前首先要#include<new>
如果想在已经分配好的内存中创建新的对象,使用new是不行的(开辟新内存空间,然后创建对象),只能使用placement new,placement new也是一个标准全局的版本,是不能被重载的,其原型如下:
void *operator new(size_t size, void *p) throw() {return p;}
placement new允许在已经分配好的内存(堆或者栈)中构建新对象,new操作符在分配内存的时候需要在堆中找到一块足够大的空间,这个操作时非常耗时的,并且在程序执行过程中有可能出现内存分配异常(内存不足导致的bad_alloc),使用placement new首先分配一块内存缓冲区,构建对象直接在缓冲区中进行,不需要查找,效率高,适合用于对效率要求较高的应用程序。
使用步骤:
1. 分配内存缓冲区
在堆上进行分配:char *buf = new char[N*sizeof(ClassType) + sizeof(int)]; 注意这里需要多申请sizeof(int)个字节用来存放数组长度或者对象个数。
在栈上进行分配:char buf[N*sizeof(ClassType) + sizeof(int)];
2. 构建对象
ClassType *p = new(buf) ClassType;
3. 销毁对象
由于内存缓冲区还要留着构建其他对象,因此不急着销毁内存,只需要调用对象的析构函数进行清理即可,当然如果是存在栈里面的就不必了。
p->~ClassType();
4.释放内存
delete[] buf;
3.new和malloc区别
3.1. new返回指定类型的指针(知道要存储的对象类型),比如:
int *p = new int; 分配sizeof(int)字节的空间
int *p = new int[100]; 分配sizeof(int)*100字节的空间
然而malloc需要我们去计算所需空间的字节数,并在返回时强制类型转换成所需指针,比如:
int *p = (int*) malloc(sizeof(int)*100);
3.2 malloc和free只管分配,不管初始化,而new和delete在使用时会调用到构造函数和析构函数进行初始化
new:动态分配内存 + 初始化
delete:清理 + 释放内存
mallco:动态分配内存
free:释放内存
之所以c++不淘汰掉malloc是因为c++有时候要调用c,而c只能用malloc,因此c++仍需要支持malloc。
如果在分配内存时使用了new那么在释放内存时应该使用delete
如果在分配内存时使用了new[]那么在释放内存时应该使用delete[]
如果创建一个单一对象,却使用delete[]释放那么编译器可能会释放多余无辜的内存。
如果创建一个动态数组,却使用delete释放,一般动态数组地址前都有个记录数组长度的标志,delete是不不去获得这个标志的,可能会导致并没有将数组所有内存全部释放。
二维指针的初始化
初始化二维指针使其存储二维数组的过程是:首先初始化指针数组用来存储第一维,然后初始化第二维数组,使得指针数组里的每个指针元素都指向第二维数组的首地址(指针指向元素所在地址)。释放内存的时候需要先释放第二维的数组然后再释放第一维的指针数组。
初始化三位指针使其存储三维数组的过程是:首先初始化二维指针数组用来存储第一维,然后初始化第二维指针数组,使得二维指针数组的每个指针元素都指向第二维数组的首地址(二维指针指向指针所在地址),然后初始化第三维数组,使得指针数组的每个指针元素都指向第三维数组的首地址(指针指向元素所在地址)。
注意:多维指针是不能直接指向多维数组的,必须使用上面的过程进行内存的分配和释放。
#include <iostream> using namespace std; int main() { //二维指针的内存分配与释放 a[10][15] int** a = new int*[10]; //初始化指针数组 for(int i=0;i<10;++i) a[i] = new int[15]; //初始化数组 for(int i=0;i<10;++i) { delete[] a[i]; //释放第二维 a[i] = NULL; } delete[] a; //释放第一维 a = NULL; //三维指针的内存分配与释放 b[10][15][20] int ***b = new int**[10]; //初始化二维指针数组 for(int i=0;i<10;++i) { b[i] = new int*[15]; //初始化指针数组 for(int j=0;j<15;++j) b[i][j] = new int[20]; //初始化数组 } for(int i=0;i<10;++i) { for(int j=0;j<15;++j) { delete[] b[i][j]; //释放第三维 b[i][j] = NULL; } delete[] b[i]; //释放第二维 b[i] = NULL; } delete[] b; //释放第一维 b = NULL; return 0; }
六、++i和i++的效率问题
++i 相当于 i+=1; return i; 就是先将对象加1,然后返回它的引用。
i++ 相当于 j=i; i+=1; return j; 就是先将对象进行拷贝,然后再将原对象加1,返回的是对象的拷贝。
由此可见,++i的效率要高一些。
九、类型转换
1. 隐式类型转换:
1. 进行算数逻辑运算的时候,比如两个数相加,其中一个的数据类型被强制转换为另一个(int转long,float转double)
2. 赋值操作的时候等号右边的数据类型被转化成等号左边的;
3. 函数传参的时候,实参和形参类型不一致,实参数据类型被强制转换为形参的数据类型;
4. 函数返回表达式的类型和返回类型不一致,表达式被转换为函数的返回类型。
2. 显示类型转换:
static_cast<type>(exp):表示将exp转换为type类型,这个函数可以对指针、引用、基本数据类型、对象进行转换,不过除了基本数据类型之外,它只能对存在关系的两者之间进行转换(比如继承关系,注意这里即便两个派生类的基类相同,它们之间也不能进行转换,并且不同类型的指针之间也不能进行转换),如果两者无关,进行转换会报错。另外,将指向父类对象的指针转换为指向子类对象的指针是不安全的,不过不会生成空指针,转换之后即便有虚函数,还是只能调用父类的函数。指向子类对象的父类指针转换成子类指针时,有虚函数就调用子类的函数,没有就调用父类的。
注意:static_cast将父对象转换成子对象会报错,子对象转换成父对象不会,发生向上转型之后,子对象的成员变量的值保持不变。
dynamic_cast<type>(exp):只接受指向类对象的指针和引用的转换(注意必须是指向类对象的指针和引用,指向基本数据类型是不可以的),子类转成父类是没问题的,但是父类专成子类首先父类要有虚函数,也就是父类要具有多态属性,如果是将指向父类对象的父类指针转成子类指针,会直接生成一个空指针(如果是引用的话直接抛异常bad_cast),无法调用其成员,也就是转换失败。如果是将指向子类对象的父类指针转换成子类指针,是可以转换成功的,这里有一点,本来父类指针指向子类对象时,调用非虚函数时会发生向上转型的,也就是调用的是父类的函数,但是如果将这个父类指针转换为子类指针后,再调用非虚函数时就是调用子类的。如果把指向子类对象的子类指针或引用转换为父类指针或引用,除了虚函数之外都会发生向上转型。
reinterpret_cast<type>(exp):可以对两个无关的数据类型进行转换,不过很容易出问题。。。
const_cast<type>(exp):去掉常量指针和引用的const,不过对于常对象和基本数据类型不适用。
十、C++中struct和class的区别
如果不声明成员变量类型,struct默认为public而class默认为private;
很多人喜欢用struct而不是class,难道就是因为struct默认public,不用自己添加?
十一、C++设计模式
单例模式:
饿汉单例:直接在定义静态变量的时候就进行实例化,在类第一次加载的时候就初始化了,但是有可能还不需要实例,白白占用了内存。
懒汉单例:将初始化定义在单例类函数的内部,调用的时候再进行初始化。
不存在线程保护的单例模式+存在线程保护但效率低+高效线程保护。c++现在还不会像java那样利用内部私有静态工厂类对instance进行实例化。。。
class ThreadLock { private: static pthread_mutex_t threadLock; public: static int initLock() { return pthread_mutex_init(&threadLock, NULL); } static void lock() { pthread_mutex_lock(&threadLock); } static void unlock() { pthread_mutex_unlock(&threadLock); } static void destroyLock() { pthread_mutex_destroy(&threadLock); } }; pthread_mutex_t ThreadLock::threadLock;//懒汉单例,线程不安全 class Singleton { private: Singleton() {} static Singleton *instance; public: static Singleton *getInstance() { if(instance == NULL) { instance = new Singleton(); } return instance; } }; Singleton *Singleton::instance = NULL; //懒汉单例,线程安全 class Singleton { private: Singleton() {} static Singleton *instance; public: static Singleton *getInstance() { ThreadLock::lock(); if(instance == NULL) { instance = new Singleton(); } ThreadLock::unlock(); return instance; } }; Singleton* Singleton::instance = NULL; //懒汉单例,线程安全,更高效 class Singleton { private: Singleton(){} static Singleton *instance; public: static Singleton *getInstance() { if(instance == NULL) { ThreadLock::lock(); /* 进行两次if判断的主要目的是如果有多个线程同时访问线程锁,其中一个最先获得锁后进行实例化对象,
等到它释放锁后,其他线程获得锁会先进行判断,以保证不会重复实例化 */ if(instance == NULL) { instance = new Singleton(); } ThreadLock::unlock(); } return instance; } }; Singleton* Singleton::instance = NULL;
十二、多重继承和虚继承
1. 多重继承
多重继承下派生类中构造函数的执行顺序是类派生表中的基类声明顺序,先声明的基类构造函数先被调用,最后才是派生类的构造函数。
同名成员的二义性,如果在两个基类中存在同名的成员(函数或者变量),并且在派生类中没有对其进行变量重定义或者函数重定义(注意这里即便基类中同名成员函数的参数列表不同也不行),那么在派生类指针访问该成员变量或者调用函数时会发生二义性错误,编译器报ambiguous,可以通过类限定符来限定使用哪个基类的成员, base->Base::a。注意这里强调是用派生类指针,因为如果用基类指针指向派生类对象时是可以正确访问基类的成员变量和函数的,相当于发生了向上转型(除了虚函数)。
单继承具有的多态特性,多继承都存在。比如通过基类指针调用派生类的虚函数,dynamic_cast等等。
2. 虚继承
虚继承只有在菱形继承时才可以防止发生二义性,而且这个二义性必须是最初的父类A在子类B中的多个副本导致的,虚继承只能保证这个公共父类A的副本只存一份,一旦子类B重定义或者重写了这些副本,即便使用了虚继承在子类C中还是会发生二义性问题,也就是说如果C的父类B中有同名变量或者函数,调用的时候必须使用类限定符,虚继承无济于事:
class A
class B1: public virtual A
class B2: public virtual A
class C: public B1, public B2
这样可以避免在派生类对象中保存多份虚基类子对象的拷贝,相对于多重继承节省空间。不过和普通继承不同的是,基类子对象的布局不同,需要一个指向基类子对象的指针。
注意上面的代码不使用虚继承的话,基类A中的成员变量或者成员函数分别继承到B1和B2中,再由B1和B2派生C会出现二义性问题(当然在C中对成员变量或者成员函数重定义可以解决。。。),如果使用了虚继承,那么即便在C中不进行重定义也不会报错,C在派生自B1和B2时只会保留虚基类A的一份拷贝。
十三、RTTI运行时类型检查
在运行时通过基类指针或引用检索其所指对象的实际类型。
typeid(exp):返回指针或引用所指对象的实际类型。头文件添加typeinfo.h然后typeid(类型).name()可以获得类型名称,静态指针或引用或者基本数据类型,就返回静态结果,如果是基于多态的指针或引用则返回根据括号里的内容返回不同信息,比如用具有多态属性的基类指针去指向派生类对象,如果查看基类指针b的类型,则返回类型是基类型的指针,如果查看基类指针所指向的内容*b,则返回派生类类型。如果基类没有虚函数,通过基类指针指向派生类对象时,使用typeid检索基类指针所指向的派生类对象的类型时得到的是基类类型。
dynamic_cast<Derived*>(Base*):将基类的指针或引用安全的转换为派生类指针或引用。
对于带虚函数的类在运行RTTI时返回动态信息,其他类返回静态信息。
十七、C++11新特性
1. auto关键字
可以进行自动类型推导,它是在编译时进行类型推导,不会影响运行速度,其实也不会影响编译速度,因为编译时本身也要进行等号右侧类型推导,然后和左侧进行类型匹配。它是通过初始化表达式来进行类型推导的。
2. decltype关键字
可以从一个变量或者表达式中获取类型,比如:
int x = 10;
decltype(x) y = x;
获取x的类型来定义y
3. nullptr表示空指针
用来解决null二义性问题,null表示0,而nullptr专门用来表示空指针,它不能用来给其它类型赋值。
4. 序列for循环
for(auto x : array) array表示数组,x表示数组里的一个元素,对于stl容器可以是iterator。
5. lambda表达式
一个匿名函数,主要形式为[](int &x){...}和[](int &x)->int{...return ...},其中方括号里面的参数表示外部的哪些变量是函数作用域内的全局变量,圆括号里面传入参数,->后面跟返回值类型,{}里面是函数体。
[]不截取外部变量
[=]截取外部作用域中的所有变量,并拷贝一份在函数体中使用
[&]截取外部作用域中的所有变量,并作为引用在函数体中使用
[a, &b]截取外部作用域中的a变量并拷贝一份在函数体中使用,同时截取外部作用域中的b变量并作为引用在函数体中使用
[this]截取当前所在类的this指针
6. 智能指针
用对象来进行资源管理。首先,对于下面的函数
void fun() {
int *p = new int;
...
delete p;
}
如果在...中发生了些使得函数直接退出的操作,那么内存无法得到释放,因此采用对象来管理内存资源的思想,因为在函数作用域结束之后,局部对象会被销毁,自动调用其析构函数来将内存资源释放掉。
智能指针就是利用了这种思想,首先将需要管理的对象指针赋值给智能指针,在函数作用域结束后,智能指针会销毁该对象。
智能指针的几种用法:
class Base {
public:
Base(): a(10) {}
int a;
};
int main()
{
auto_ptr<Base> ap(new Base());
//输出10
cout<<ap.get()->a<<endl;
//输出10
cout<<ap->a<<endl;
int a=10;
int *p=&a;
auto_ptr<int> pp(p);
*pp=20;
//输出20
cout<<*pp<<endl;
//输出20
cout<<*(pp.get())<<endl;
*(pp.get())=66;
//输出66
cout<<*pp<<endl;
//输出66
cout<<*(pp.get())<<endl;
return 0;
}
auto_ptr<ClassType> p(ClassType*)
缺点:该智能指针的复制和赋值操作是不正常的,当一个auto_ptr给另一个进行复制或者赋值时,获取值的指针获得了资源对象的唯一管理权,另一指针被置为null,这种设计原因是当多个指针指向同一内存区域时,释放资源时,可能会对该内存区域多次释放,编译器会报错(要看是哪种编译环境)。当需要进行正常复制时,不能使用该指针。
shared_ptr<ClassType> p(ClassType*)
通过引用计数来管理内存资源,当一个shared_ptr指向资源对象时,该对象引用计数加1,当通过一个智能指针被销毁时,对象引用计数减1,当引用计数为0时销毁对象。这样一来就解决了auto_ptr不正常复制的缺陷。不过该智能指针的缺陷是不能解决环状引用的问题。
环状引用就是两个对象相互引用,导致引用计数永远不能为1,也就无法将它们删除。
这两种智能指针的析构函数中调用的都是delete而不是delete [],这样一来就不能用他们管理动态数组,因为动态分配内存的数组必须用new []分配内存和delete []销毁,不过编译器不会报错,可以用vector来代替动态分配内存的数组。
#include <iostream>
using namespace std;
class Base {
public:
Base() {
cout<<"构造"<<endl;
}
~Base() {
cout<<"销毁"<<endl;
}
};
void fun() {
//输出构造+销毁
Base base1;
//输出构造
Base* base2 = new Base();
//输出构造+销毁
auto_ptr<Base> base3(base2);
//复制或者赋值都会使base3=null
auto_ptr<Base> base4 = base3;
auto_ptr<Base> base5(base3);
//采用引用计数的智能指针
shared_ptr<Base> base6(base2);
//这里复制或者赋值不会使base6=null
shared_ptr<Base> base7 = base6;
shared_ptr<Base> base8(base6);
}
int main() {
fun();
return 0;
}
unique_ptr<ClassTye> p(ClassType*)
和auto_ptr是类似的,只能有一个保持对对象的控制权,与auto_ptr不同的是,unique_ptr在进行赋值或者拷贝时会报错,但是它和上面两个指针相比,又多了一个功能,就是可以进行移动构造或者移动赋值,比如作为函数的返回参数,将unique_ptr通过函数返回值的形式赋值给另一个unique_ptr,或者在拷贝或者赋值时显示调用move()函数将所有权移交给新的指针,旧的报废掉。另外还可以作为容器元素使用,不过在push_back进入vector时需要move()移交控制权。
//auto_ptr和share_ptr都不支持这样写
unique_ptr<int> getSP() {
int *p=new int;
unique_ptr<int> sp(p);
return sp;
}
int main() {
//通过参数传递
unique_ptr<int> uup = getSP();
//将控制权转移
unique_ptr<int> up(new int);
unique_ptr<int> upp(move(up));
unique_ptr<int> uppp = move(upp);
//这两种写法都会报错
unique_ptr<int> uup(up);
unique_ptr<int> uuup = up;
return 0;
}
weak_ptr<ClassType> p(shared_ptr<ClassType> sp)
弱引用指针是专门针对shared_ptr无法销毁环状引用内存而出现的,下面的代码Base和Derived相互引用,导致无法销毁,通过将Derived指向Base的引用改为weak_ptr,编译器会将Base先销毁,因为弱引用指向的对象其引用计数是不会增加的。不过要注意的是,weak_ptr是无法直接管理类对象的,没法这样构造weak_ptr<ClassType> p(ClassType*),它的构造函数只能以shared_ptr作为参数,只能先构造一个shared_ptr,然后再通过这个shared_ptr来构造weak_ptr,比如weak_ptr<ClassType> p(shared_ptr<ClassType> sp) 或者weak_ptr<ClassType> p = shared_ptr<ClassType> sp 注意哦,其他的构造函数都不支持隐式类型转换(就是不能等于一个指针来进行构造),必须显示的调用构造函数(就是括号包着一个指针),但是weak_ptr却可以。。。
class Base;
class Derived;
class Base {
public:
Base() {
cout<<"Base Constructed"<<endl;
}
~Base() {
cout<<"Base Destructed"<<endl;
}
public:
shared_ptr<Derived> sp_derived;
};
class Derived {
public:
Derived() {
cout<<"Derived Constructed"<<endl;
}
~Derived() {
cout<<"Derived Destructed"<<endl;
}
public:
weak_ptr<Base> sp_base;
};
void point_each_other() {
Base *base = new Base();
Derived *derived = new Derived();
shared_ptr<Base> b(base);
shared_ptr<Derived> d(derived);
//环状引用,无法销毁
base->sp_derived = d;
derived->sp_base = b;
//指向base的是一个弱引用,所以先销毁base
base->sp_derived = d;
derived->sp_base = b;
}
int main() {
point_each_other();
return 0;
}
资源管理还涉及到一个什么时候应该进行分离语句的问题
比如这个
func(shared_ptr<ClassType> sp(new Class()), doSomething());
上面函数参数一共进行三种操作
1. new Class()
2. 初始化智能指针sp
3. doSomething
c++和java或者c#不同的是它的函数参数执行不一定是按照某种次序执行的,因此如果出现这种情况:
1. new Class()
2. doSomething
3. 初始化智能指针sp
假如在执行doSomething时程序发生了异常,那么new Class的内存无法得到管理可能会发生泄漏,这个时候应该采用分离语句的做法
shared_ptr<ClassType> sp(new Class());
func(sp, doSomething);
因为编译器对于跨越语句的操作并没有重新排列的自由。
十六、enum枚举类型
1. 枚举类型的成员都是常整型,初始化之后不能被修改。
2. 如果不在enum定义时对其成员进行初始化,那么默认初始化从0开始,每个元素的值都是上一个的值加1。
3. 如果对enum类型不能直接赋一个整型数,而是需要在这个整型数前面加一个强制类型转换。
enum Fruit { //值为0 apple, //值为5 orange=5, //值为6 peach }; int main() { //对enum变量进行赋值 Fruit ft = (Fruit)7; cout<<ft<<endl; cout<<apple<<endl; cout<<orange<<endl; cout<<peach<<endl; }
函数参数传递 pass by reference to const / pass by value
1. 能用引用传参的最好不要用值传参,因为值传参的过程是先将实参进行拷贝,然后在函数体内使用该拷贝,函数作用域结束之后会将该拷贝销毁,比如用传值的方式传一个对像,首先调用这个对象的拷贝构造函数,如果这个对象内还有其它对象,那么也会相应的调用其拷贝构造函数,并且在函数作用域结束之后,会调用相应的析构函数进行销毁。而采用引用传参,则不会涉及这些问题,不存在对象拷贝,也不会存在对象销毁。效率更高。不过使用引用的话,如果在函数内部修改了参数,那么原始实参也会被修改,所以如果不想将其修改,需要加一个const常量修饰符。
2. 另外两种方式还涉及到向上转型和多态的问题
假设基类有虚函数,如果将一个派生类对象通过值传递的形式传给函数的基类形参,那么在函数中通过基类调用虚函数时,调用的是基类的虚函数,也就是派生类发生了向上转型,其特有的派生类性质都被截去了。如果使用引用(或者指针)传递派生类就不会出现这个问题。
3. Effective C++上说在函数返回值的时候绝对不要使用指针或引用指向一个局部变量或对象(存在栈中),因为在函数作用域结束后局部变量或对象会被销毁,因此指针或引用指向的就是一个已经被销毁的变量或对象了(不过在Xcode上面试了下。。。都没啥问题。。。不清楚是咋回事。。。)。另外也不要让引用指向一个在堆中创建的对象,因为在函数外,仅仅通过这个引用是没法获得指向那个堆空间的指针的,也就没法释放这块内存空间。
c++关键字
1. const
c++中尽量用const和enum去替换#define,因为#include和#define都是由预处理器加载并插入到程序中的,因此编译器并不知道#define的常量名具体叫什么,只知道它的值,比如#define PI 3.1415926 编译器只认得3.1415926,并不认得PI,所以在出错时不好定位错在哪里。
const int *p = &a:表示不能通过指针修改数据,注意并不是不能修改a,而是不能修改*p。比如a=10是可以的,而*p=10是会报错的。上面也可以写成int const *p = &a。
int * const p = &a:表示指针的指向不能更改,比如int b=10; p=&b是错误的。
可以这样理解,const在*的左边时,*p=...是错的(*p不能更改),const在*右边时,p=...是错误的(p不能更改)。
const修饰成员变量:不可更改的成员变量。
const修饰函数:常函数不能修改类中成员变量的值。外部函数不能声明为常函数。
const修饰对象:常对象,只能访问常成员函数(不能修改成员变量)。
2. mutable
由于常函数是不能修改类成员变量的,但是有些变量需要在常函数里面进行修改,因此需要将这些变量定义为mutable,表示在常函数中也可以进行修改。另外,常对象也可以对mutable修饰的成员变量进行修改。
3. explicit
用来修饰类构造函数,表示构造函数不能进行隐式调用比如下面这种写法对Base b = 15进行了构造函数的隐式调用(相当于把int隐式类型转换为Base),如果将构造函数声明为explicit,那么就不能这么写。原则上对所有构造函数都应该声明为explicit,避免隐式类型转换的发生,因为这可能带来隐患。
class Base { public: //如果不写explicit会将15赋值给a Base(int a):a(a) {} public: int a; }; int main() { Base b = 15; return 0; }
4. static
修饰局部变量:首先,函数内的局部变量是存储在栈中的,生命周期局限在函数作用域内。如果用static关键字修饰,那么它的存储区会改为静态存储区,并且生命周期也会持续到程序结束。之所以这样做,我认为是由于静态变量只在函数第一次调用的时候初始化一次,下次再调用不进行初始化,省去了每次压栈、出栈的操作,可以减小开销。
修饰全局变量:首先一个全局变量是整个工程可见的,也就是说在同一工程下,file1.c定义了int i; 那么在file2.c中可以通过extern int i; 来获取这个变量。不过要注意不能这样写extern int i=10; extern只能声明不能初始化。但是如果用static修饰了一个全局变量,那么其可见度会被限定在本文件内,在其他文件中通过extern来声明,编译器会找不到。
注意:静态成员函数只能访问静态成员变量,因为静态成员函数和成员变量是属于类全局的,在类第一次被加载的时候进行初始化,直接通过类名进行调用,但是非静态成员变量是属于类对象的,只有在类实例化的时候才会调用构造函数进行初始化,如果静态成员函数里访问了非静态成员变量,那么当这个函数被调用的时候,里面的非静态成员变量还没有被初始化。编译器是会直接报错的,属于语法错误。
还有一点要注意的是静态成员变量和函数虽然不属于类对象,但是可以通过类对象进行访问,也可以通过类限定符进行访问。
修饰函数:和修饰变量一样。
注意: 类内部的静态成员变量必须在函数体外定义,否则在编译的时候会报链接错误。一般直接写到类定义体的下方。
5. extern
注意extern不能声明其他文件中的局部变量。
修饰全局变量:表示此函数或者变量是在其他文件中定义的,要在此文件中使用。不过extern修饰的变量的作用域要看在哪个位置声明,要是全局声明,那作用域就是全局,要是在函数内部声明的话只能在函数内部使用。其实想要调用其他文件的变量或者函数只需要在头文件中声明,然后include头文件就可以了,不过要注意头文件最好只声明不要定义(虽然不会报错),因为一个头文件可能会被很多其它文件include,如果在头文件中定义会出现多次定义导致链接错误。貌似是使用extern效率更高,因为毕竟include头文件会在预处理阶段将头文件的代码直接插入到当前文件中,而extern用哪个就声明哪个就好了。
修饰函数:和修饰全局变量一样。
extern "C" void func(); 当C++调用C的库函数时,告诉编译器用C语言的方式进行编译,因为C++支持函数重载,为了区分相同的函数名,编译器会将函数名和参数类型一起作为函数的唯一标识,而C不支持函数重载,在编译的时候只需要函数名来作为唯一标识,所以在编译C库函数的时候需要让编译器知道,不要用C++的方式进行编译。
当然也可以用个大括号把需要使用C语言风格进行编译的函数或变量括起来。
extern "C" {
void func();
int foo;
}
另外要注意的是不要在头文件中定义变量,当一个变量在头文件中被定义,如果存在多个文件include了这个头文件,那么其中的变量就被定义了多次,在链接阶段不知道该链接哪一个了,具体分析见下面关于链接的总结。可以这样做,就是把变量和函数的定义都放到cpp文件中,然后在头文件中使用extern声明这些变量和函数,因为定义只能进行一次,而声明可以多次(不过貌似int a;这样也是个定义。。。),所以其它文件再include头文件就不会有问题了,不过可以在其它文件直接用extern声明过来。。。
7. violate
易变:将当前语句处理的变量值放到寄存器中,下一条语句再次使用该变量时,直接从寄存器中取出,而不是内存中,如果加上violate修饰符,会将第一条指令中修改的内容存入内存中,下一条指令再次使用时不是直接使用寄存器中的,而是从内存中重新读取。
不可优化:有的时候编译器会把变量优化掉,因为用不上,所以在汇编语言上直接没有变量存在,如果加上了violate后,是不允许编译器去优化的,变量必须存在。
举个例子:int a=0; if(a==0) return a; 编译器会认为反正a=0没必要if了,直接return,编译成汇编语言之后就没了if语句对应的汇编代码,但是这里在多线程编程时可能会出问题,虽然在当前线程里面a没被修改,但是可能其他线程对a进行了修改,所以优化掉if语句会出错,如果用violate来修饰a,就是告诉编译器a是易变的,所以每次用到a的时候要去内存中读取,而不是在某个缓冲区或者寄存器中取用,每次改完都写入到内存中,每次用都从内存中读取。
预处理阶段要做的三件事
1. 文件包含
#include<>通过预处理器将头文件插入到代码中。为什么头文件是不需要编译的(只编译源文件),因为在预处理阶段,预处理器会将头文件代码插入到包含它的源文件中去,然后编译源文件就会将头文件中的代码进行编译了。
include <> 和 include "" 的区别
include <> 从标准库路径寻找头文件
include "" 从当前工作路径中寻找头文件(当前项目所在目录)
include cpp文件是错的
2. 宏定义
#define X 1 定义常量
#define X(a,b) ((a)*(b)) 定义函数
注意宏定义替换是预处理阶段的事,不涉及内存分配,只有在运行期间才会根据宏定义具体使用的环境来决定内存分配。
#undef X 取消宏定义
#define宏定义和inline关键字
define宏定义:如果用宏定义一个函数,那么这个函数的执行代码会在预处理阶段通过预处理器插入到代码中的调用处,这样就省去了函数中局部变量压栈的操作(相当于用传给宏定义的参数构建了一个表达式),提高了效率。但是宏定义只是将其定义的函数做了预处理器符号表中的简单替换,因此不能进行参数有效性的检查(C++编译器有一个强制类型检查),并且它的返回值还不能进行强制类型转换。
inline的出现就是用来继承宏定义的优点,并且解决了宏定义的缺点。用来声明函数,在编译期间,编译器会拿函数的执行代码去替换函数被调用的地方,并且会对其进行参数检查,另外,内联函数一般可以用来访问类的受保护成员和私有成员。编译器会将类中的get/set函数自动优化为内敛函数。
3. 条件编译
#if + 宏条件 注意这里的条件必须是宏条件,一般的变量是不行的,因为宏是预处理阶段执行的,那个时候变量还都不存在呢。。 表示如果条件为真就执行下面的代码段
#elif +宏条件或者宏 相当于else if
#else
#ifdef + 宏 表示如果定义了宏就执行下面的代码段
#ifndef + 宏 表示如果没定义宏就执行下面的代码段,一般用来防止头文件重复引用
#endif 结束条件编译
举个例子
#include <iostream> using namespace std; #define ONE 1 #define TWO 2 #define THREE 3 int main() { #if ONE>TWO cout<<"+++++"<<endl; #elif ONE==TWO cout<<">>>>>"<<endl; #else cout<<"-----"<<endl; #endif #ifdef ONE cout<<"ONE"<<endl; #elif TWO cout<<"TWO"<<endl; #else THREE cout<<"THREE"<<endl; #endif #ifndef X #define X 10 cout<<X<<endl; #endif #undef X #ifndef X cout<<"Undefined X"<<endl; #endif return 0; }
编译过程:
预处理
对应的指令:gcc -E hello.c -o hello.i
1. 添加头文件,递归进行,因为头文件中还包含着头文件
2. 展开宏定义
3. 处理条件预编译指令
4. 去掉注释
5. 保留#pragma编译器指令,因为编译器需要它
6. 添加行号和文件名标识
编译
对应的指令:gcc -S hello.i -o hello.s
汇编
对应的指令:gcc -c hello.s -o hello.o
链接
对应的指令:ld
IDE做的事:先将各个cpp文件编译成目标文件,再将这些文件链接起来。
链接过程主要涉及到三个表:导出符号表 export symbol table、未解决符号表 unresolved symbol table 、地址重定向表 address redirect table
举个栗子:
在1.cpp文件中有如下代码:
int n=1;
void foo() {++n;}
目标文件中的代码如下:
0x000 n
0x004 f inc DWORD PTR[0x000] 将0x000位置上的数加1
未解决符号表为空
在2.cpp文件中有如下代码:
extern int n;
void g() {++n;}
目标文件中的代码是:
0x000 g inc DWORD PTR[0x001]
未解决符号表
0x001 n
1.cpp的导出符号表中找到符号n后进行替换。
由于上面两个目标文件中的地址都是相对的,链接到可执行文件中后地址会发生重叠,因此需要进行调整。
就像这样
1.cpp导出符号表
0x000 + 0x2000 n
0x004 + 0x2000 f
未导出符号表为空
2.cpp导出符号表
0x000 + 0x1000 g
未导出符号表
0x001 + 0x1000 n
地址重定向表
因为目标文件到了可执行文件里面地址会发生改变,因此需要维护一个地址重定向表,告诉目标文件哪些地址需要加上偏移量。
对于一个工程中的所有cpp源文件,通过预处理器-编译器-汇编器后生成.o后缀的目标文件,这些目标文件是相互独立的编译单元,也就是说如果在某个文件中实现一个全局变量,另一个文件中通过extern进行声明,它们需要通过链接器进行链接,每个编译单元都有一个导出符号表和一个未解决符号表,对于本文件中的全局变量或者函数,会将变量名或者函数名作为符号名放入导出符号表中,对于其他文件中的extern声明,会将变量或者函数名作为符号加入未解决符号表中,之后在通过链接器进行链接的时候,链接器会通过未解决符号表中的符号,在其他文件中的导出符号表中进行查找,对于未解决符号表中的符号,会预先分配一个内存,然后将对应的导出符号表中的内容存入该内存中。
这样一来就可以解释的通,为什么在头文件中应该只声明不定义变量,因为头文件中的定义会在预处理阶段被插入到所有包含头文件的文件中,也就是说在这些文件中都会存在一个该全局变量的定义,那么它们的导出符号表中就都会存在相同的符号名,链接器在链接的时候不知道应该链接哪个了。如果只在头文件中进行声明,那么通过预处理器插入到包含该头文件的文件中后,声明会存在于该文件的未解决符号表中,变量只在某个源文件中进行了定义,因此,只有它的导出符号表中才存在这个定义,是唯一的,因此也就不会出现链接错误。
常见的链接错误有两个:
1. 导出符号表中的符号名存在冲突,链接器不知道应该链接哪一个(duplicate symbol)。
2. 导出符号表中没找到某未解决符号表中的符号名。(undefined symbol)
static关键字之前说过可以限定可见区域在本文件内,导致其他文件中无法通过extern来调用,它的作用其实就是限制了全局变量或者函数的符号名不能写入导出符号表中,属于内部链接。
默认链接属性:对于变量和函数,默认是外部链接(当然必须写在全局区域),对于const修饰的变量,默认是内部链接,之所以把常量搞成内部链接,是为了可以在头文件中定义常量,对于一个内部链接,即便在多个文件中都有定义也没关系,因为它不会被写入导出符号表。可以通过添加extern和static来改变链接属性。
inline函数要在头文件定义而不是源文件,因为inline写在声明处是没有作用的,必须写在定义处,如果在头文件中只写个内联函数声明,那么其他文件包含了头文件之后也只能得到一个声明,是没法嵌入到代码中的调用处的。 内联函数相当于宏定义,如果定义在一个文件中,在另一个文件中通过extern调用是不起作用的。可以理解为内联函数是不会写入到导出符号表的,所以链接器会报错,找不到符号。
对于在头文件中定义的宏、const常量、static静态变量、inline内联函数,如果将其修改,那么所有包含了这个头文件的源文件都需要重新编译(头文件是不会被编译的)。如果是在一个源文件中定义的,那么只需要重新编译这个源文件,然后再将那些调用文件和这个源文件重新连接就行了。或者头文件中的函数使用非内联函数,并且只声明,在其他文件中定义。因为用extern声明,只是将声明的变量名或者函数名写入未解决符号表中,然后由链接器在导出符号表中进行匹配,匹配上就连接起来,所以不需要编译器去重新编译,只需要链接器重新连接即可。
柔性数组
可变长结构体中需要使用柔性数组,定义数组长度为0,或者不指定数组长度。常用于网络数据包,一般发送的数据长度不等,如果静态分配数组空间会造成严重的空间浪费。因此采用动态分配内存的方式,根据需要发送数据的长度来分配内存空间,示例代码如下:
#include <cstdio> #include <cstring> #include <cstdlib> using namespace std; struct SoftArray { int len; char buffer[0]; }softArray; int main() { SoftArray *sa = (SoftArray*)malloc(sizeof(SoftArray)+15); sa->len = 15; memcpy(sa->buffer, "Hello World!", 12); printf("%s\n",sa->buffer); return 0; }
使用malloc分配内存的时候,分配字节数是sizeof(SoftArray)+15,这里柔性数组是不占用内存空间的,相当于一个符号,而数据包长度len会占用4字节的内存空间,sizeof(SoftArray)=4,然后缓冲区长度最大是15个字符。
类型定义
typedef
typedef一般用来定义类型别名,比如typedef int x
和宏定义的区别:
1. 宏定义的类型可以和其他内容组合,而typedef不行,比如:
#define X int
unsigned X a = 10是正确的
typedef int X
unsigned X a = 10是错误的
2. 连续变量声明时,typedef可以声明多个变量,而宏定义不行,比如:
typedef int * X
X a,b,c 这里a,b,c都是指针,而
#define X int *
X a,b,c 这里a是指针,但是b和c是int。
C语言中函数参数入栈顺序从右到左
对于可变参函数,其参数个数不一定,因此将参数从右向左入栈,这样可以保证第一个参数位于栈顶,容易确定其地址。
可变参函数
C语言库函数printf就是个可变参函数,需要先添加头文件stdarg.h,然后在可变参函数的参数列表中添加…,之后要定义一个va_list用来存储参数列表,之后使用宏va_start()来初始化va_list,使用宏va_arg()来从va_list中取出一个参数,用宏va_end()来清理va_list。
#include <cstdio> #include <cstring> #include <iostream> #include <cstdlib> #include <stdarg.h> using namespace std;
void demo(char *msg, ...) { va_list argp; char *para = msg; va_start(argp, msg); while(1) { printf("%s ", para); para = va_arg(argp, char*); if(strcmp(para,"\0")==0) break; } va_end(argp); } int main() { demo("hello","world","\0"); return 0; }
再来一个参数为整数的:
#include <iostream> #include <cstring> #include <cstdlib> #include <utility> #include <stdarg.h> using namespace std; void demo(int arg_array, ...) { va_list arg_list; int len = arg_array; va_start(arg_list, arg_array); for(int i=0;i<len;++i) cout<<va_arg(arg_list, int)<<endl; va_end(arg_list); } int main() { demo(5,1,2,3,7,8); return 0; }
段错误和总线错误
段错误
- 对有非法值的指针解除引用
- 对空指针解除引用
- 超出访问权限,比如对只读文件进行写操作
- 用尽堆栈或堆空间
总线错误
函数调用
ebp寄存器和esp寄存器分别为栈底指针和栈顶指针。
首先将函数参数(实参)从右到左压入栈中,然后将函数返回后要执行的第一条指令的地址压入栈中,再将旧的ebp地址压入栈中,然后将局部变量压入栈中。
c++内存结构
首先占用类内存空间的只有非静态成员变量和虚函数表指针,静态成员变量、函数和虚函数表是不会占用类内存空间的。
非静态成员变量占用的内存空间由其类型决定;
虚函数表指针占用的内存空间由操作系统位数决定,32位系统占用4字节,64位系统占用8字节(所有类型的指针占用的内存空间是一致的);
静态成员变量占用的是全局存储区的空间;
成员函数的地址和全局函数一样都是存储在全局存储区中的,只不过对于类的非静态成员函数有一个隐含参数this,指向调用它的对象实例。
虚函数表就是一个指针数组,虚函数地址以及虚函数表都是存储在全局存储区中的。
如果类中没有非静态成员变量或者虚函数,那么它所占据的内存空间是1字节,也就是说空类型占据1字节的内存空间。
数组的初始化
以下数组初始化方式中只有超过指定元素数量才编译不通过。
int a[] = {}; int b[] = {1,2,3}; int c[3] = {1,2}; int d[1] = {1,2}; //编译不通过
C++前向引用和头文件相互包含问题
1. 前向引用
在一个文件中定义多个类,存在两个类相互引用,举个例子:
1. 通过前向声明一个类,可以将其作为指针或引用参数在下面的类中使用;
2. 所有类的声明和定义要分开,否则new会报错。
#include <iostream> class B; //这里前向声明了B之后可以作为一个指针或者引用参数在下面的类中使用 //这里要先声明,定义写在后面 class A { public: A(); private: B *b; }; class B { public: B(); private: A *a; }; A::A() { b = new B(); } B::B() { a = new A(); } int main() { return 0; }
2. 头文件相互包含
两个头文件中定义的类相互调用,如果相互include头文件会报错。
1. 两个头文件中都不include对方,而是使用class声明;
2. 在源文件中include对方头文件。
A.h
#ifndef A_H #define A_H class B; class A { public: A(); private: B *b; }; #endif // A_H
B.h
#ifndef B_H #define B_H class A; class B { public: B(); private: A *a; }; #endif // B_H
A.cpp
#include "A.h" #include "B.h" A::A() { b = new B(); }
B.cpp
#include "B.h" #include "A.h" B::B() { a = new A(); }