1.C++三个特性
- 封装:使代码模块化。
- 继承:在不改变原有模块化代码的基础上对功能进行扩展,以达到代码复用的目的。
- 多态:为了接口重用。
2.#include<>与#include""区别
命令 | 说明 |
---|---|
#include<> | 直接到系统标准路径中查找文件 |
#include"" | 现在程序目录搜索,如果没有找到,则去系统标准路径中查找 |
那么上面说到的系统标准路径到底指的的是什么?
系统标准路径主要分为两个部分:1.系统默认路径2.用户自定义默认路径
如果你只是想查看c++的系统默认路径那么可以如下面所示
$ `g++ -print-prog-name=cc1plus` -v
忽略不存在的目录“/usr/lib/gcc/x86_64-redhat-linux/4.8.5/include-fixed”
忽略不存在的目录“/usr/lib/gcc/x86_64-redhat-linux/4.8.5/../../../../x86_64-redhat-linux/include”
#include "..." 搜索从这里开始:
#include <...> 搜索从这里开始:
/usr/lib/gcc/x86_64-redhat-linux/4.8.5/../../../../include/c++/4.8.5
/usr/lib/gcc/x86_64-redhat-linux/4.8.5/../../../../include/c++/4.8.5/x86_64-redhat-linux
/usr/lib/gcc/x86_64-redhat-linux/4.8.5/../../../../include/c++/4.8.5/backward
/usr/lib/gcc/x86_64-redhat-linux/4.8.5/include
/usr/local/include
/usr/include
搜索列表结束。
通过g++命令可以查询系统默认路径,这里可以清晰的看到
如果你只是想查看c语言的系统默认路径那么可以如下面所示
$ `gcc -print-prog-name=cc1` -v
忽略不存在的目录“/usr/lib/gcc/x86_64-redhat-linux/4.8.5/include-fixed”
忽略不存在的目录“/usr/lib/gcc/x86_64-redhat-linux/4.8.5/../../../../x86_64-redhat-linux/include”
#include "..." 搜索从这里开始:
#include <...> 搜索从这里开始:
/usr/lib/gcc/x86_64-redhat-linux/4.8.5/include
/usr/local/include
/usr/include
搜索列表结束。
大家可以看到,C++的默认搜索路径是包含C语言的默认搜索路径,只是C++特有的路径是先行搜索的。
#include<...>的搜索列表,用户可以通过自定义的环境变量提交自己的系统默认路径
语言 | 环境变量参数 |
---|---|
c | export C_INCLUDE_PATH=XXX:$C_INCLUDE_PATH |
c++ | export CPLUS_INCLUDE_PATH=XXX:$CPLUS_INCLUDE_PATH |
3.#include的参数是类名还是文件名?
是文件名,如果答错的话,证明答题者连#include最基本的概念都不清晰,include实际上是预处理命令,在include位置把需要引入的文件
中的内容全部包含进来。
C++标准库头文件命令规则由于历史原因,有两种形式:一种是filename.h形式的,一种是filename形式的。大多数的filename都是filename.h的升级版。
因为C++是C语言发展而来,介于C语言基本没有代码重用的机制,不支持面向对象等的缺陷,从1980年开始,贝尔实验室为C语言陆续加入面向对象的特性,并在1983年正式命名为C++,但是最初的标准库功能都是定义在全局空间中,声明全部放在带.h的头文件中。后来为了规范C++的使用,规定不再使用.h头文件,所以大多数filename文件都是filename.h文件的升级版,主要就是做了一些优化改动,并且引入std命令空间。但是一切都有意外
这个意外就是string
因为在C++兼容C的标准库,而C的标准库中原来也有一个名字叫string.h的头文件,包含一些常用的C字符串的处理函数strcpy等
C++标准库中存在,其中cstring是string.h的std命名空间的版本,C++标准库类string只包含在 文件中。
4.static作用及应用场景
首先,我们从static发展来看,此关键字的含义经历了以下三个阶段:
- 静态局部变量。表达退出一个块依然存在的变量,一般用于创造自带内部存储器功能的函数。
实际的代码块
- 不被其他文件访问的全局变量和函数。此种含义中的全局变量和函数依然可以抽象为退出一个"块"依然存在的"东西",只不过此处的"块"是一个文件,所以没有必要重新引入关键字,所以依然使用static关键字表示则第二种含义。
物理层面的块
- 静态成员变量和静态成员函数。如果将一个类看作一个块,那么此含义依然可以抽象称为独立于"块"存在的变量和函数。所以自此就有了static在面向对象过程中的含义,也是static的第三种含义。
逻辑层面的块
综上所述:实际上static标记的就是 一个独立于所在"块"的变量或者函数。
作用主要就是:
- 将自己隐藏在所在代码块中,即隔离作用域。
- 独立于所在代码块的生存周期,即持久化
- 对于变量而言,默认初始化,此特性是由于static变量存储位置特殊引起的,这并不是其独有的,全局变量也具有这一特性,因为全局变量和静态变量都存储于静态数据区,在静态数据区,会默认初始化为0x00
应用场景:
- 保存函数上次调用的信息:由于静态变量无论什么时候只初始化一次,并且独立于所在块的生存周期而单独存在,所以需要将函数此次调用产生的结果传递给下次调用时,就可以在函数中定义一个静态局部变量
- 类的实际对象间的通信机制:因为类的静态成员变量和静态成员函数是独立于对象存在的,即是此类所有对象共享的。
- 创建只属于自己的全局变量:一个大工程中,如果需要创建全局变量,但是还要避免冲突,那么静态全局变量就可以派上用场了,因为其只可以是本文件可见。
5.const基本用法
5.1 初始化与const
默认状态下,const对象仅在文件内有效
当以编译时初始化的方式定义一个const对象,编译器会在编译时找到所有的用到该变量的地方替换成对应的值。当多个文件中出现同样名字的const变量,那也等同于不同文件中分别定义独立的变量。但是如果我们需要不同的文件中共享同一个const变量
,对于这个变量,我们不仅是声明还是定义
都要添加extern关键字。
那么为什么加上extern关键字就可以了呢?
因为const默认的是内部链接,也就是仅包含文件可见,所以别的文件无法共享,但是加上extern就不一样了,首先是因为extern比const优先级高,所以定义或声明加上extern,变量便具有了外部属性,这样在编译的时候此变量才会出现在导出符号表中,才会在链接的时候被其他文件发现
共享常量的初始化
在一些C++初学者的代码中,常看见这种情况,他们将工程中的公共常量直接写在了头文件中,即实现了常量的‘共享’,不会引起编译错误,但是这种做法是极其不可取的。因为const是默认内部链接,写在头文件中,就相当于每个包含此头文件的编译单元都会有一份此变量的副本。这并不是真正意义上的共享,而且还会造成了代码的膨胀。那么怎样才能正确的定义和使用共享常量呢?
方法有两个:
- 在cpp文件中使用导出定义常量,extern const string xxx = "test";
然后在头文件中添加导出声明,告知其他编译单元,此变量已有定义,extern const string xxx;
这样就能保证此共享常量内存地址唯一。 - 利用static的性质在类中将其声明为静态常量。
test.h中声明
class ctest{
public:
static const string xxx;
};
test.cpp中定义
const string ctest::xxx = "test";
在外部使用的时候只需要调用ctest::xxx,即可。
个人比较推荐使用第二种方法。
5.2 引用与const
引用类型必须与其所引用对象的类型一致,但是在常量引用的过程中有两个例外:
- 初始化常量引用允许用任意表达式作为初始值,只要该表达式的结果能够转化成引用类型即可。比如double a = 19.9; const int& ra = a;ra的结果是19,并且ra和a的地址不同。
在上个例子中const引用是可以绑定临时变量的,那么为什么普通引用就不可以了呢?
假设普通引用可以绑定临时变量,那么很有可能对其进行修改操作,但是由于是绑定的临时变量,本来希望修改原始变量的值,结果变成了修改临时变量的值。这种结果与期望不符,所以C++将普通引用(左值引用)绑定临时变量的情况定义为非法。而const引用就不会出现这样与期望不符的情况。所以C++是允许的。也就是说当一个const引用绑定了一个临时变量,那么这个临时变量的生命周期就被延长,直至const引用被销毁。
- 一个常量引用的被引用对象可以是非const对象,因为常量引用只是限定了引用自身的行为,即不可以通过常量引用修改实际对象的值,但是实际对象的其他行为不会因为常量引用的限定而受到任何影响。
5.3. 指针与const
在了解指针与const之间的关系时,我们需要知道两个概念:顶层const;底层const。
顶层const:表示的是定义的对象是常量; 底层const:表示的是指针和引用等复合类型的基本类型部分是常量,而非定义的对象本身
下边是比较典型指针与const的组合
- const int * p
- int * const p
- const int * const p
凡是遇到指针,则从右向左确定对象类型
凡是遇到const,也是从右向左,谁离他最近谁倒霉。
- 先用()将const和const右边括起来,原代码就变成了(const int ) * p,由此可知const并没有直接修饰现在定义的变量,所以const 是底层const,代表的意思是不可表的是p指向的东西而非p本身,从右完左读,p是一个指针,指向的是一个(const int)类型的对象。
- int* (const p),由此可见const 找的是当前定义变量的麻烦,所以p是一个不可修改的变量,然后从右往左,p是一个不可修改的变量,这个变量是一个指针,指向的是一个int类型的对象。
- (const int) * (const p) ,(const p )表示的是当前定义的变量是一个不可修改的变量。而且存在(const int)底层const ,所以从右往左阅读,p是一个不可修改的变量,这个变量是个指针,指向了一个底层const,这个底层const是一个不可变的int类型。
5.4. 函数与const
const在函数可以出现在三个不同的位置:参数,函数体,返回值
参数与const
在定义const变量时,是可以将非const变量赋值给const变量,变量如此,所以将普通变量赋值给const参数,理论上是可以做到的,事实证明是可以转化的
void test(const int * p)
{
cout << "argument :const int * p "<
但是当const变量作为形参实现函数重载时,你会发现前面有一个大坑等待这我们。
当顶层const作为形参时,是被忽略的,也就是说(int* const p )和(int * p)作为参数时编译器会认为这两个参数是一摸一样的,这是为什么呢?
为什么只忽略顶层const呢?
因为顶层const只是作用于函数形参本身,形式参数可不可以改变对于函数调用者来说没有区别,也就是说函数编写者通过两种形参拿到的操作实际传递进来的那个对象权限是一样的,所以没有必要区分,忽略还可以提高性能。但是如果存在底层const的话,函数编写者通过普通形参是可以改变实际传递过来的对象的,而底层const限定,就不可以通过指针或者引用修改实际传递过来的对象,两个形参给函数编写者提供的权限是有区别的,如果函数调用者就是不希望函数体实际变量,那么就可以用const_cast将实际变量转化成const类型然后传递给底层const版本的函数。
void test(const int * i)
{
cout << "const "<(&i));
getchar();
return 0;
}
函数体与const
const修饰的函数体中是不可以修改任何成员变量mutable修饰的变量除外
const成员函数不能调用非const 成员函数,因为非成员函数内部可能会涉及成员变量的修改,也就是说,const修饰的块内默认不可以对成员变量做任何改动,除非变量在声明的时候授予可修改权限(mutable)
当const函数与非const函数在同一个作用域一起出现可以形成---重载
当遇到以const作为重载判断条件的类时,类的常量对象调用常量版本的函数,非常量对象则调用非常量版本的函数
class CTest{
void abc(){cout<<"no const"<
返回值与const
主要是是底层const的引用,避免调用者通过返回值修改底层数据。
6.const 与#define在定义常量时的区别
- 从本质属性来看:#define就是一个预处理标记,const是变量,拥有自己的类型。本身就是两种不同的东西,只有在定义常量时才会呈现雷同的表象。
- 从作用阶段:#define是作用来预处理阶段,const处理阶段可能在编译阶段,也可能在运行阶段。
- 从原理上看:#define简单粗暴的进行字符串替换,没有任何安全检测,引用不当会导致边缘效应,比如#define x 1+2 int a = x*3,这样的错误很难查证。并且#define替换的都是立即数,所以会在内存中形成N个拷贝,浪费资源。
const是有对应的数据类型和数据检查,并且C++编译器通常不会为普通的const常量分配存储空间,而是将它们保存在符号表中,并在适当的时候折合在代码中,但是如果加上关键字extern或者取const变量的地址,编译器就会为const分配存储空间。 - 从开发角度:#define没有办法通过调试发现问题,但是const常量是可以进行调试的。
7.类型转换
7.1 隐式转换
隐式转换是程序依照默认的规则进行的,无须程序员介入,遵守的最基本的规则就是尽可能不损失精度。
7.1.1 整形提升
对于bool,char,unsigned char,short,unsigned short等类型来说,只要它们所有可能值都能存在int里,都会提升至 int类型。
7.1.2 无符号类型的运算对象
如果一个运算数是signed,一个是unsigned
- 如果unsigned >= signed,那么signed运算数将会被转换成unsigned进行计算。如果不注意这一点会有很严重的后果。比如下面这段代码
vector a={1,2,3,4,5,6}
int i = -1;
while(i
上段代码中,你期望的是打印vector中的全部信息,但是结果却一行都没有打印,原因就是,a.size() 返回的是unsigned类型,所以当比较时,-1转换成unsigned 类型后变大,所以无法进入循环内。
- 如果unsigned < signed , 当unsigned类型所有值能够全部容纳到signed时,转换成signed类型,如果不能则全部转换成unsigned类型。
综上所述:只有当signed值大于unsigned值并且unsigned全部值能够容纳在signed里面,才会将unsigned类型提升至signed类型进行求解,否则遇到全部转换成unsigned类型进行求解。所以当signed为负数时,一定要避免直接和unsigned类型的值发生比较或者运算。
7.1.3 数组与指针的隐式转换
一般在大多数的数组表达式中,或者将数组传递给函数时,都会发生隐式转换,数组首地址将会退化成指针的形式。
但是也有例外,当数组被用作decltype关键字的参数,或者作为&,sizeof及typeid等运算符的运算对象时,将不会发生退化,
7.2 显式转换
C++中主要存在以下四种显示转换
static_cast但包含底层const时
,下面我们举例说明这种特殊情况
const char * m = "test";
string t = static_cast(m);✅
const int a = 10;
const int * b = &a;
int * c = static_cast(a); ❌
int z = 10;
int *x = &z;
const int * y = static_cast(x);✅
为什么那个string就对了呢,难道是const被强制转换了?
其实不是,凡事不要死记硬背,我们用通俗的话翻译一下static_cast以expression为参数构造一个type类型的返回值
,那么这个就很好理解了,m只是作为string构造函数的参数转化了,转化的string可以是type中定义的类型。
为什么int* -> const int* 就可以,但是const int* ->int,就不行呢?因为我们强制转化后确保的是值一致,也就是说指针对象中保存的地址值,在转化前后都是一样的,地址值指向的块的属性和值也不会受到影响。如果int->const int,除了后来的指针增加了限定,对程序没有任何伤害,但是如果允许const int->int*,那么当你利用转化后的指针对常量区进行值修改,就会产生未定义行为,后果很严重!!!
底层const 还有一种类似与下面的特殊的情况
int a = 10;
int * b = &a;
const int* c = static_cast(b);
c指向的实际底层并不在常量区,c的const底层只是让程序觉得自己指向的是常量区,那么如果当我们只能拿到c,并且也知道它的底层并不是const,但是我们利用c直接修改底层的值,程序是无法编译通过的,我们应该怎样去掉这个底层const?这里我们只能请出const_cast了
const_cast
只能
用于改变运算对象的底层const属性。它实际上就是对static_cast的一种补充,目的就是将底层const属性的修改特殊化。那么我们接着上面的例子说,c现在是const int* 的类型,指向的又不是常量区,我们怎样通过c修改它底层的值呢?
int a = 10;
int * b = &a;
const int* c = static_cast(b);
int* x = const_cast(c);
*x = 100;
一定要记住const_cast只是static_cast的补充,除了与底层const有关的操作,其他的时候一定要用static_cast进行操作。
const char* m = "test";
string t = static_cast(m);✅
string t = const_cast(m);❌
如果不确定底层结构是否为const,就胡乱应用const_cast 去掉底层const属性限制,然后修改指针说指向的值,会导致未定义行为,所以const_cast的使用一定要慎重。
dynamic_cast
此方法支持运行时类型判定,用于将基类的指针或引用安全的转化成为派生类的指针或者引用。
class father{};
class child:public father{};
...
father* f = new father();
father* c = new child();
child* temp = dynamic_cast(f);//失败,并抛出了bad_cast异常
child* temp = dynamic_cast(c); //成功
这个强制类型转换是经常遇到的,因为在继承机制下,通过基类和派生类指针交替操作派生类对象是经常遇到的。你也可以将其运用于派生类指针转基类指针,但是派生类指针转基类指针可以实现隐式转化,没有必要进行安全判断,不必画蛇添足。
下面是dynamic_cast最常用的用法
father* f = new child();
if( child* temp = dynamic_cast(f)){//如果成功转化,执行此代码}
else{//如果没有转化成功证明f指向的是一个基类对象,那么执行此处代码,并且抛出bad_cast异常信息}
由此可见此操作符,可以说是最安全的强制类型转换操作符了。
reinterpret_cast
这个操作符可谓是最暴力的了,它是为运算对象的位模式提供较低层次上的重新解释,应用最少。
其与强制的隐士转换的区别在于:使用reinterpret_cast强制转换是需要保证不损失精度的前提下进行的,而强制的隐式转换可能会损失精度。
double d = 1.2;
int a = d;✅可以进行
int a = reinterpret_cast(d);❌转换无效,编译时报错,原因是损失了精度
我们再列举一个实际应用的例子,当你设计一个hash表,hash表中存储的是某一种类的对象指针。我们可以应用hash算法中位偏向的设计思路,计算hash值。
什么是位偏向?
在纯粹随机的情况下,产生高位或者低位的位偏向应该是50%,所以我们可以将一个32位的机器数a,这样操作,usigned short( a ^ (a >> 16))
我们将指针地址值强制转化位unsigned long,然后计算hash值,代码如下
//64位系统,所以
unsigned short hashCalculate(void* p ){
unsigned long val = reinterpret_cast(p);
return (unsigned short)(val ^ (val>>16));
}
8.构造函数详解
8.1 构造函数与初始化列表
特点:
- 初始化列表中的顺序不影响成员变量的初始化顺序,成员变量的初始化顺序与类中定义的顺序一致。
尽量使用初始化列表:
- 某些变量只能用初始化列表进行初始化,比如说const,&。
- 使用初始化列表效率高,原因是使用初始化列表,成员变量在构造时就会直接使用初始化列表中的初始值进行构造,而函数体内赋值是利用默认值构造,然后再赋值。
8.2 委托构造函数
定义:使用它所属类的其他构造函数执行自己的初始化过程。实际上就是用其他的构造函数代替初始化列表的做法。
注意:委托构造函数初始化列表的位置除了其他构造函数以外,不可以有其他项
那么委托者和被委托者函数体内都有代码,那是先执行哪个呢?
先执行被委托者函数体,然后控制权才移交给委托者,对于委托者而言,被委托者的所有行为都相当于初始化。
class test{
public:
test(int i,string str):a(i),b(str){}
test(int x):test(10,"test"){ c = x;}✅
test(int x):test(10,"test"),c(x){}❌
private:
int a;
string b;
int c;
};
8.3 转换构造函数
定义:如果一个类拥有只有一个实参
的构造函数,那么实际上就定义了从参数类型转换成此类类型的隐私转换机制。
比如:string str = "abc","abc"是const char类型,string中恰恰有string(const char)构造函数,所以“abc”可以隐式的转换成string类型,调用的就是string的转换构造函数
注意:只能进行一步隐式转换
class test1{
public:
test1(const char *){}
}
class test2{
test2(test1){}
}
test1 t = "test";✅
test2 t = "test";❌
test2 t = test1("test");✅
注意:定义中说只能有一个实参,并不是说只能有一个参数,而是说可接受一个实参的意思。
class test{
public:
test(const char * ptr,int x = 0){}
或
test(const char * ptr="",int x=0){}
}
test t = "test";✅
那么如果我们想抑制转换构造函数,不让它隐式转换,我们应该怎么办?
使用 关键字explicit
将此关键字放在只有一个参数的构造函数之前即可
class test{
public:
explicit test(const char*){}
};
8.4 拷贝构造函数
特点:
- 第一个参数是自身类类型的引用
- 如果有额外的参数,一定都有默认值。
形式为:
class test{
public :
test& test(const test& );
或
test& test(const test& t, int a =0);
};
- 那么为什么第一个参数必须是引用呢?
我们知道一个函数的返回值被用于初始化被调用者的结果,这个初始化过程用到的就是拷贝构造函数。为了初始化结果,我们就必须调用拷贝构造函数,如果拷贝构造函数的参数不是引用,为了拷贝实参,我们还会调用拷贝构造函数,那么这个过程就永远无法停止了。
如果一个类没有自定义拷贝构造函数,那么编译器会自动合成一个拷贝构造函数,无论你定没定义其他构造函数。除非你将拷贝构造函数声明成private的或者删除函数。
在一些面试题中会问你,什么情况调用的是拷贝赋值运算符,什么时候调用的是拷贝构造函数?
那么我们首先看一下什么是拷贝赋值运算符。
拷贝赋值运算符基本上是和拷贝构造函数成对出现。形式为
test& operator=(const test&);
拷贝赋值运算符是将其左侧的运算对象绑定到隐式的this参数,并且返回左侧运算对象的引用。所以下面的两种写法的作用是一样的。
test t,x;
t = x;
t.operator=(x);
那么为什么要返回左侧运算对象的引用呢?
- 需要和内置类型的赋值保持一致。
- 标准库通常要求保存在容器中的类型是具有赋值运算符,并且其返回值是左侧运算对象的引用。
现在我们知道了拷贝赋值运算符,那么我们怎么区分什么时候用拷贝构造函数什么时候用拷贝赋值运算符呢?我们以下面的代码说明。
test a,b;
test c = a;
b = a;
c应用的是拷贝构造函数。b应用的是拷贝赋值运算符。仔细看二者的区别,我们可以总结出一条经验,当等号左边的运算对象已经存在时用的就是拷贝赋值运算符。如果在赋值语句之前,等号坐标的运算对象不存在,则需要构造和初始化,此时调用的就是拷贝构造函数。
- 合成拷贝构造函数
如果把我们没有定义任何类型
(包括声明成delete)的拷贝构造函数,那么系统就会为我们合成。拷贝构造运算符和拷贝构造函数的合成规则一摸一样。
注意!!!
即便是系统默认合成,但并不代表就能使用,因为合成的拷贝控制成员有可能会被隐式定义为删除的。
主要是因为,如果一个类有数据成员不能默认构造拷贝删除销毁,那么对应的类的相应功能的函数也会被定义为删除。
8.5 移动构造函数
这个是C++11新加入的特性,要理解移动构造函数,我们首先要了解一下右值引用。
一般我们说的引用通常指的是左值引用。右值引用顾名思义,就是只能绑定右值的引用。
那么什么是左值什么是右值呢?左值实际上就是一个对象的身份,而右值表示的是对象的值。
我们知道使用左值引用是不能绑定系统创建的临时变量上的,而右值引用和const左值引用是可以的,因为右值引用和const左值引用可以延长绑定变量的寿命,直到引用被注销。
右值引用是通过窃取临时变量的资源来达到延长寿命周期的目的,当一个右值引用绑定了临时变量,那么这个临时变量将失去对原有资源的控制,但是由于这个临时变量是一个将亡值,不会再利用它进行任何操作,所以也就无所谓了。
但是一个右值引用是不能直接绑定在左值上的,这又是为什么呢?
假设允许这种操作,你在编程中不小心将一个左值引用写成了右值引用,并绑定在了一个左值上,那么这个左值的所有资源就会被这个右值引用窃取,但是你并不知道这个左值已经是一个空壳了,还利用它进行一些操作,就会出现问题,所以C++把右值引用绑定左值的这种行为定义为非法。
那么我就是想用右值引用绑定左值呢?通过move语义,将左值强制转化成右值,然后赋给右值引用。那左值在move之后的状态是什么呢?C++11标准库给的说法是依然有效,但是状态不明。所以执行完move语义的左值,除了销毁以外应该避免其他操作。
我们知道了什么是右值引用,现在我们可以回归正题---移动构造函数。
和拷贝构造类似,移动构造函数和移动赋值运算符也是成对出现的。
移动构造函数的特点和拷贝构造函数的特点特别类似,这是因为移动构造函数的出现就是为了解决拷贝构造函数在处理大内存对象时拷贝影响效率的问题。
特点:
- 第一个参数是一个右值引用(拷贝构造函数是左值引用)
- 如果有额外的参数,一定都有默认值(这点和拷贝构造函数类似)
- 接管内存后,一定要将接管的内存从源对象中卸载掉,否则源对象析构时会将接管的内存干掉。
形式为
class test{
public :
test& test(const test&& ) noexcept;
或
test& test(const test&& t, int a =0) noexcept ;
};
此外,移动构造函数通常只是窃取资源,基本上不会分配任何资源,所以移动构造函数通常被声明成noexcept,当你确定自定义类中移动构造函数不会抛出异常的话一定要使用noexcept进行声明,那么这样做有什么好处呢?
标准库一般会有一些保护机制,比如说vector中,当重新分配资源失败时,它会确保原来的内容不会被改变,如果利用拷贝构造函数拷贝原有数据时,出现异常只需要保留原有资源,释放新分配的资源即可,但是如果使用移动构造函数移动原有数据时,中间发生异常,那么原数据和新数据都是被破坏的,所以标准库在没有确定移动构造函数是否会抛出异常时,就会强制使用拷贝构造函数进行扩容。拷贝构造函数要比移动构造函数的效率低得多,所以如果你确定自己的移动构造函数不会抛出异常,就将其声明为noexcept,这样标准库就会知道你的移动构造函数是安全可用的。
下面我们来说一下和移动构造函数成对出现的移动赋值运算符
形式为
test& operator = (test&& ) noexcept;
和移动构造函数一样,如果确定无异常抛出,那么就将其声明成noexcept形式
判断某种赋值语句是应用移动构造函数还是应用移动赋值运算符的主要依据依然是,当赋值语句的左值在此语句之前没有完成构造和初始化,那么此时用的就是移动构造函数,如果这个左值在之前已存在,那么用的就是移动赋值函数。
- 合成移动构造函数和移动赋值函数
只有当没有定义任何类型的拷贝构造函数,并且其所有成员都可以移动构造或移动赋值,系统才会合成默认的移动构造函数和移动赋值函数。
与拷贝操作不同的是,移动操作永远都不会隐式定义为删除的函数,只有当我们强制的将其定义为default形式,但是编译器不能够默认移动所有成员,那么编译器才会将其定义为删除函数。
8.6 移动和拷贝的相互制约
上面我们了解了移动和拷贝的构造函数和赋值运算符,两种操作直接有着千丝万缕的联系,所以我们把这部分单独提出,重点说明。
- 一个类定义了移动构造函数
和/或
移动赋值运算符,则该类的合成拷贝构造函数和
拷贝赋值运算符将被定义为删除。 - 一个类定义了拷贝构造函数
和/或
拷贝赋值运算符,那么该类将不会自动合成移动构造函数和
移动赋值运算符。
- 为什么会这样⁉️
主要是为了兼容,因为老版本的c++就已经定义了隐式生成拷贝的行为,移动成员是后来才加进去的。并且当你什么构造函数均不声明的情况下,编译器会自动给你生成默认构造函数,拷贝构造函数和拷贝赋值运算符。如果在存在拷贝构造函数和拷贝赋值运算符的前提下,依然合成默认的移动成员,那么就会导致之前的老代码发生不确定性的错误。所以在定义了拷贝行为或者什么都没有定义的情况下,且没有显示定义移动成员,为了兼容,是不自动合成移动成员的。
那么如果显示定义了移动成员,并没有显示定义拷贝成员时,由于老版本中在没有拷贝成员的情况下是一定要自动合成拷贝成员的,为了兼容这一点,所以即便是显式定义了移动成员,依然阻止不了自动合成拷贝成员,但是存在移动成员的类,状态很复杂,自动合成的拷贝成员会增加程序的不确定性,所以C++委员会就将这种情况下自动生成的拷贝成员定义为删除,合成可以,毕竟要兼容老版本的逻辑,但是不确定性的隐式合成不能用。如果你想用显示定义成=default,由于移动语义时候来加入的,这种修改既不影响老版本的代码,即便在老版本的代码在后续更新中既想要移动又想要拷贝,那就在加入移动语义的时候,顺手定义一个default的拷贝成员,代价可以忽略不计~
8.7 析构函数
析构函数与构造函数执行的是相反操作,不仅是是逻辑相反,连销毁的顺序都是相反的,构造函数是先执行成员变量的初始化工作,初始化顺序与声明顺序一致,然后再执行函数体内部的代码,但是析构函数,首先执行的是函数体内的代码,然后在根据成员变量声明顺序的相反顺序进行销毁操作。还有一点与构造函数不同,由于构造函数可以接受参数,所以可以重载多个构造函数,但是析构函数是不接受参数的,所以它也是不能被重载的。
- 析构函数是否能够抛出异常?
不能,原因是如果析构函数抛出异常那么此函数后续的相关操作就不会被执行,如果后续操作恰好和资源释放有关,那么在析构函数中抛出异常,就会导致内存泄漏的严重后果。另外,还有一个更主要的原因,对象在执行过程中出现异常,C++异常处理模型有责任清除那些由于异常失效的对象,这时就会调用这些对象的析构函数,如果这时再抛出异常。那么谁来保证之前那个对象资源的释放?
但是析构函数不应该抛出异常,并不是不能处理异常。如果在析构函数处理过程中可能存在异常,我们直接就地解决,而不是抛出。就像下面这样:
~test(){
try{
....
}catch(){}
}
9.函数-重载,重写,重定义
名称 | 作用域 | 有无virtual | 函数名 | 形参列表 | 返回值类型 | 特殊备注 |
---|---|---|---|---|---|---|
重载overload | 相同 |
无限制 | 相同 |
不同 |
无限制 | 1.main函数是不可以重载的 |
重写override | 不同 |
有 |
相同 |
相同 |
相同(协变) |
1.如果子类的重写函数是一个重载版本的函数,那么基类中没有被重写的函数将在子类中隐藏 |
重定义redefine | 不同 |
无限制 | 相同 |
相同 |
无限制 | 1.必须发生在继承环境下2.在继承环境下只要不构成重写的属于重定义 |
⚠️基类的重载函数未全部被派生类重写(基类函数声明了virtual)或者重定义(基类函数未被声明virtual),则未被处理的基类重载函数将会被派生类隐藏!!!!这是为什么呢?
首先,我们需要知道一个概念:作用域可见性规则
如果存在包含关系的作用域,外层声明了一个标识符,而内层没有再次声明同名标示符,那么外层标示符在内层依然可见,但是如果内层声明了同名标示符,那么外层标示符在内层不可见,这种现象叫做隐藏规则。
知道了这个隐藏规则后,就可以解释未被重写或者重定义的函数被隐藏的问题了,在继承机制下,基类和派生类具有类作用域,基类属于外层作用域,派生类属于内层作用域,所以派生类才可以重定义基类的成员函数。如果派生类中声明了一个域基类同名的新函数(即便参数表不同),从基类继承的同名函数的所有重载形式都会被隐藏。如果想访问被隐藏的成员,就需要用基类名+作用域分辨符取得基类版函数。
10. 字节对齐
首先,我们要知道各个内置类型在程序中所占的字节数
目前,主流的操作系统主要是32位和64位版本
类型 | 64位 | 32位 |
---|---|---|
char | 1 | 1 |
short | 2 | 2 |
int | 4 | 4 |
float | 4 | 4 |
long | 8 | 4 |
double | 8 | 8 |
void* | 8 | 4 |
为什么要字节对齐呢?
主要是尽可能保证CPU处理数据的效率,假设每次访存只能读取8个字节,地址空间从0x0000开始存放,恰巧一个int类型的数据存放在0x0007,0x0008,0x0009,0x000A这四个字节的位置,那么CPU读取这个int就需要两次访存,这样直接降低了指令的执行效率。所以操作系统为了保证执行效率,就按照对齐方式分配管理内存,编译器也会按照字节对齐的方式去设置结构体,类等对象申请内存的规则。
还有一个概念叫对齐基数:
我们可以通过设置对齐基数指定编译器对齐方式,如果对齐基数是缺省状态,那么不同的编译器不同的平台下,这个状态表现可能会不一样的,比如Linux gcc就没有默认的对齐基数,只能显式的定义。当缺省状态下,则不引入对齐基数这个概念。
所以一切抛开平台和编译器去讨论对齐基数的行为都是在耍流氓
结构体和类的对齐规则主要有以下几点:
- 结构体变量的首地址能够给其min(最宽基本类型成员的大小,对齐基数)所整除。
- 结构体中每个数据成员相对于首地址的偏移量主要分为
两种
情况:一种是基础类型成员,则偏移量是min(自身大小,对齐基数)的整数倍;一种是结构体或类成员,其偏移量是min(自身成员中对齐值最大的那个值,对齐基数) - 结构体最终大小就是min(结构体最宽基本类型成员大小,对齐基数)的整数倍。
注意此处所说的
下面我们讲一下Linux平台GCC编译器的两种字节对齐方式:
10.1 自然边界对齐
Linux平台下GCC的对齐基数处于缺省状态时,首地址是最宽成员整数倍,每个成员相对与首地址的偏移量也是自身大小的整数倍,结构体最终大小是最宽成员大小的整数倍。
所以在写代码的过程中避免由于字节对齐导致内存过度浪费,结构体内的成员应该按照字节数升序排列。
10.2 编译器指定对齐
(Linux + GCC)
其实如果遵守自然边界对齐规则,并且按照直接数升序排列,基本上可以达到结构体内存相对最小的目的,如果你的程序只是在一个确定的平台下运行,你可能永远都用不上这个编译器指定。一个比较典型的应用,就是网络游戏中,如果你在客户端(windows)和服务端(Linux)之间传递二进制流(比如说结构体),你就必须定义相同的对齐方式,绝对不能用默认的,因为平台不一样,编译器也不一样,为了避免一些莫名其妙的错误,就需要手动指定对齐基准。
GCC编译器规定了两种内存直接对齐方法:
10.2.1 伪指令方式
伪指令的方式修改的是对齐基准,所以对齐方式满足上面所说的对齐规则。
#pragma pack(n)//n取值为1,2,4,8,16,最大值与平台中基本类型中最大宽度有关。
#pragma pack()//取消指定对齐,按照编译器默认对齐方式对齐
或
#pragma pack(push,n)
#pragma pack(pop)
10.2.2 属性设置方式
__attribute__((packed));//取消结构在编译过程中的优化对齐,相当于#pragma pack(1)
__attribute__((aligned(n)));//此方法主要影响的是结构体首地址和结构体整体大小,并不会单独影响结构体内部成员
。当结构体按照自然对齐的方式确定了大小m,如果n>m则结构体整体大小将被扩充为m,如果n<=m,则结构体大小维持原来大小。
10.2.3 两种指定对齐的区别和联系
为什么要单独提出来呢,因为它们不仅仅是形式不同,连功能,作用点都不尽相同,单独提出来,有利于对比,巩固。
设置方式 | 首地址 | 结构体成员 | 结构体总大小 |
---|---|---|---|
pragma pack(n) | 影响 | 影响,n相当于对齐基准 | 影响 |
__attribute__ pack(packed) | 影响 | 影响,相当于pragma pack(1) | 影响 |
__attribute__ pack(aligned(n)) | 当n大于结构体自然对齐时的总大小时会影响 | 不影响 | 当n大于结构体自然对齐时的总大小时会影响 |