C++基础
C++三大特性:封装,继承,多态。
String的包括空白字符的录入,显然不可以用cin.get()和cin.getline() ,因为原型都是char *s。
可用getline(cin,s); 且此包含在string头文件中
超出设置的录入个数,Cin.get() 不设置失效位,不处理结尾换行或其他自定义结尾符号,其会留在缓冲区。Cin.getline()相反。只要超出,后续的都失效。且会处理结尾换行或其他自定义结尾符号。
而cin.read()必须录入完指定的个数才会结束输入。且不会自动补”\0”
Cin.get()常用来吞缓冲区的换行符,和getchar()类似。同时cin.ignore()可忽略缓冲区的第一个字符。所以三者常用来吞换行。
文件处理:
ifstream fin ofstream fout. Ifstream和ofstream是文件流对象对应的类。fin和fout是自己定义的文件流对象。前者用于从文件中读出数据到变量中。后者用于从变量中读取数据输出到文件中。用法就和cin和cout一样,不过此时的终端不再是键盘,而是文件。fin从文件中读取数据到变量中,fout从变量中读取数据存到文件中。直接>> <<。还可以fstream finout. 能输出和输入。如,ofstream outfile("salary.dat");直接打开当前目录下的此文件。Outfile.close()关闭文件。也可先创建对象。再用fin.open()打开文件。可以设置文件打开方式。俩参数,第一个为文件名,可以包含完整路径,无完整路径则是在当前路径下寻找。第二个参数则是打开方式。会查表即可。
左/右值
表达式显然不是左值就是右值。左值即是在其生存期内都占有具体的内存地址。显然是可以作右值,作右值时被利用值。字面值和即将销毁的临时变量显然都是右值。显然变量都是左值。
显然const对象必须初始化,因为后续无法修改赋值,不初始化则是未定义的。
Const对象显然有很多优点,其中比较突出的一点就是其一般是运行时常量(若const对象是用常量表达式初始化的,那么其在值是在编译时确定,否则不然),从而其做形参时可以接受即将销毁的临时变量。在运算符重载等一些场合会体现出其必要性。
将变量定义加上前缀,constexpr,其便成为编译时常量,#define也是编译时可确定的常量。在编译时确定值。则其对象只能被常量表达式或者类型为constexpr的函数初始化。而constexpr函数特点,返回类型不能是void,且内部有且仅有一条return语句,且返回的表达式必须是常量表达式;可以有空或者其他编译时确定而运行时不执行的语句。且形参接收的实参都必须是常量类型,否则无法编译时确定。并且constexpr函数常常被隐式转换为内联函数。
常量表达式,即值不会发生变化且在编译时就能计算出结果的表达式。字面值是常量表达式,用常量表达式初始化的const对象也是常量表达式。
Const int a=20;是
Const int b=a+10;是
Int c=20;不是。因为其值可以改变。
Const int d=getv();假设getv不是一个constexpr函数 ,则不是,因为其值要运行时才能确定。
Eg:
int b=2;
constexpr int a=3;//正确
//constexpr int c=b+1;错误,即使是b+1也不是编译时常量。无法在编译时给c赋值
constexpr int d=a;//正确
typedef long long ll 格式easy
Using ll=long long
decltype(f()) a;//即自动识别f()的类型然后给a定义。但不会算其值
类型转换
显然赋值时的类型转换,类型随左值,如 double a,int b,char c。
a=b+c。显然结果是double 类型
运算时则是随大。如char +int 结果自动转成int
指针和引用
指针类型显然和其指向对象类型强匹配。
- 首先,指向常量的指针。
T const *p /const T *p
其实非常简单,即p所指向的对象相当于常量,此时不能通过p来修改其的值。但是指针本身仍是变量,可以指向其他的对象。并且所指向的对象可以不是常量。因为指向常量的指针仅仅是要求不能通过该指针修改对象的值。但显然非指向常量的指针不能指向常量对象。
- 指针常量,顾名思义,常量二字是修饰指针的。
T * const p. 要知道指针变量本质也是对象。也有初始类型。* const,*说明其是指针,const说明其是常量。T则是它指向的对象的类型。显然,此时的指针是常量,常量则必须初始化,且后续不能修改。所以此时p只能在初始化时指向某个变量,后续不可再修改其指向。此处规定其指向的对象不能是常量。只能是变量。只是指向不能变,可以通过p修改其指向的对象的值。本质上是因为T 只是普通非常量类型。
- 指向常量的指针常量。
指针常量不能指向常量,本质是因为,指针常量的写法, T*const p。指定了其指向对象的类型T。是普通变量而非const变量。显然普通指针不能绑定常量。所以引入指向常量的指针常量。
写法 const T* const p; 所以soeasy。显然此时指向常量的指针常量是可以指向常量或者非常量的。此时必须初始化 p。且既不能通过p修改其指向对象的值,也不能改变p的指向。
new与delete
int *p=new int(b);//只是以b的值初始化了p所申请的新空间,p并不是指向b的。
const int *p2=new const int();//new后const可以省略。
auto *p3=new auto(a);//当()里只又一个初识值时,可以用auto
T * p=new T[n];//n必须是整型,但不一定是常量值
typedef int a[10]
Int *pa=new a;//相当于new int[10],但是是未初始化的,但若是string,不用()也会默认为空
Int *pa=new int [10]();//初始化为0
可以new一个空指针,但是不会指向任何对象。所以是不能解引用的。相当于数组中后最后一个元素的后一个元素的存储单元。
delete后只是销毁了p所指向的对象,调用其析构函数,释放其对应的内存,但是很多机器上都是不改变p的指向。其还是指向原来的内存。所以要及时置空。=nullptr。可以用NULL,但是要用<cstdlib>头文件
数组与指针
Eg:
int *b[10];//b为含有10个指针元素的数组,每个指针都为一级指针,指向一个整型数 即指针数组
int (*p3)[10];//p3为一个指向有10个整型数数组的指针 即数组指针
int (*p4)(int );//p4为一个指向函数的指针,该函数有一个整型参数并返回一个整型数
//p4即函数指针
int (*p5[10])(int );//p5为一个有10个指针的数组,该指针指向一个函数,该函数有一个整型参数并返回一个整型数
//p5即函数指针数组
函数指针数组其实很好理解,比如int (*p[10])(int ),[]优先级比*更高,所以p先和[]结合,所以p是数组,而对于一个数组而言,除去数组名和元素个数,剩下的就是元素的类型,所以除去p[10],剩下的int(*)(int),就是该数组的元素的类型,这显然是一个函数指针类型,所以整体上是一个函数指针数组。函数指针也好理解,如int(*p1)(int),因为有(),所以p先和*结合,所以p1是指针。而一个指针,除了本身名和*以外,就是指针指向的对象的类型,所以int (int) 显然就是p1指针指向对象的类型,这显然是个返回值为int型,且含一个int 型参数的函数,所以此时的p1便是一个函数指针。数组指针和指针数组也是此类记法。
引用
引用显然是不能为空的,必须绑定到具体的对象上。
定义一般变量时:1声明:声明变量类型和名字 -> 2定义:根据类型分配内存地址空间 -> 3初始化:将初始值拷贝到变量的内存地址空间中(三步)
因此变量的声明,定义和初始化可以分开,不需要一次完成
定义引用类型时:
1将引用绑定到初始化对象(一步)
因此定义引用类型时必须有初始值对象。
左值引用显然是必须初始化且后续不能修改的,普通引用的类型和其绑定对象的类型要是一致的。并且其不会有独立空间,只是起别名,指向引用的对象。且始终绑定,后续都不能修改其引用的对象。
Eg:int &p=a; 显然是不用取地址的,若取地址则变成指针类而非引用。
常引用和常指针部分类似,原则上引用的类型和引用的对象类型需要一致,常引用是例外。只是限定了不能通过此引用来修改对象的值,而所绑定的对象可以是常量和非常量。
显然常引用是必须初始化且后续不能绑定其他对象的。且初始化时 可以用任何可以转化为相应常类型的表达式,字面值等来初始化。
若尝试让一个常引用绑定到其他类型的对象上,则不会绑定成功,而只是绑定到了一个临时变量上。
Eg:
Double b;
Const int &ra=b;//不报错。
但是实际上
相当于
Const int temp =b;
Const int &ra=temp;
当常引用绑定非常量时,显然只是不能通过此引用来修改其值,其对象可以自己改变,也可以通过普通的引用和指针等来修改其值。
定义数组引用显然要指定数组类型和其长度。
Eg:int a[10];
Int (&ra)[10]=a;
关于右值引用,eg:int &&ra=a+3;
右值引用只能绑定在即将销毁的对象上,而不能是左值,左值表达式,const对象等。简单的解释便是,即将销毁的对象,若要拷贝赋值给另一变量。要为另一变量开辟新空间,然后再拷贝赋值。浪费了内存资源和时间。不如直接将该即将销毁的对象对其内存的控制权转移。
形象的比喻便是,A即将逝去,但他想把房子转交给别人,与其将房子推倒,土地销毁后再重买其他土地,重新建一模一样的。不如将房子和土地的使用权都交给B,A也安然逝去。此处的房子便类似于内存中的值,内存则类似于土地。
后面学到的move其实也是转换成右值引用。移动构造函数和移动赋值运算符其实也是右值引用。
总之:
普通的左值引用要求绑定的对象类型和其是一致的。而左值常引用可以绑定的范围很广,可以用任何表达式来完成其初始化:非常量的对象(甚至是不同类型),字面值,一般表达式等。
右值引用只能绑定到即将销毁的对象上,如一些字面值常量,返回右值的表达式,但不能是左值上。
无论是左值引用还是右值引用,都必须初始化,且后续不能更改。
函数
数组形参
任何情况下都不能以值的方式传递数组参数。
以下三种方式等价:
Void f(int *a);
Void f(int a[]);
Void f(int a[10]);
返回数组指针
由于函数无法返回数组,但是可以返回数组的指针和引用
最直接的方法是用类型名
typedef int arr[10]; // arr是一个类型明,表示含有10个int的数组
using arr = int[10]; // 与上等价声明
arr* fun(int i); // fun返回一个含有10个int的数组指针(本质是一个指针)
如不使用类型名,则数组的维度必须跟在函数名字的后面,形式如下:
Type (∗func(形参表)) [数组大小]
Type表示元素的类型,中间括号中的表示该函数返回的是一个指针。分析此式从括号中开始分析,有间接运算符号,则说明 该函数返回值为一个指针,括号中括号的优先级比较高,则在读后面的维度,说明该指针指向一个数组,数组的类型为Type类型。
即该函数返回一个指向含有指定大小个Type类型的数组的指针。
两端的括号必须有,不然则返回的是指针数组。
例子:
int (*func(int i))[10];
分析:
func(int i))表示调用func函数时需要传递一个int类型的的实参。
(*func(int i))对函数调用结果进行解引用,则说明其返回值是一个指针。
(*func(int i))[10]表示解引用得到一个大小是10数组。
int (*func(int i))[10]表示数组中的元素是int类型。
使用尾置返回类型
在c++11新标准中可以使用尾置返回类型。尾置返回类型在形参列表后面以一个->符号开头。为了表示函数真正的返回类型跟在形参列表之后,在本应该出现返回类型的地方放置一个auto。
auto func(int i) -> int (*)[10];
// func接受一个int型实参,返回一个数组指针,数组包含10个int类型的值
使用decltype
int odd[] = {1,3,5,7,9};
int even[] = {0,2,4,6,8};
decltype(odd) *arrPtr(int i)
{
return (i % 2) ? odd : even;
}
因为decltype关键字并不负责把数组类型转换成对应的指针,所以decltype的结果是个数组。
故本函数的返回值是一个指向含有5个int类型的数组的指针。
可变参数个数的函数,即形参列表中写上省略号 ... 三点。显然只能单独出现或者放最后。一般只出现在和C函数交互时的程序接口。
默认实参
若有函数原型,只能在函数原型的形参列表中指定默认实参,而不能在函数定义时才指定或者重复指定。
若无函数原型只有定义,那么便是在定义时的形参列表中指定。
因为默认参数编译运行时是从参数列表中从右到左开始指定,所以形参中一旦开始指定默认形参,那么其右边以后的形参也都必须指定默认值。
因为上述性质,又因为实参在替换形参时是从左往右,只要开始省略某个实参,那么其右边的实参都应该省略。即只要一用默认实参,右边的实参都必须省略从而使用默认形参的值。如f(,2,3)显然是不允许的。
函数重载,运算符重载,复用函数名等,实现简单的编译时多态。注意参数表的唯一性(参数类型,个数,顺序)和不同函数之间可能存在的二义性即可。看二义性就看传参时是否有多个函数都可以接收此实参。
Eg:形参 int 和const int char* 和char *const 显然都不算是重载的函数,因为此时参数本身是const,如第二个是指针常量。其能绑定或者说接收的对象,可以是常量和非常量。当实参为非常类型时,int和const int 都可以接收其,从而存在二义性。
而,int&和const int & char *和const char * 是重载的函数。因为此时对于引用和指针指向的对象的类型是指明的,一个是int 一个是const int 一个是char* 一个是const char *。传实参时,会自动推断其类型然后进行匹配。
内联函数
可以显示inline定义也可以定义在类内自动是内联函数。在编译运行时的调用处,是采用简单的拷贝其代码块到相应的调用位置。所以显然不能实现递归,且应该尽量简短,才能有效的以空间换时间。
Constexpr类函数
首先返回类型不能是void。因为其往往在编译时被隐式指定为内联函数,内部的语句必须是编译时就能确定的。所以内部必须有且仅有一条return语句,且返回的语句必须是常量表达式。还可以有如空语句,类型别名,using声明语句等编译时执行而运行时不执行的语句。使用前必须有完整定义。
const int f(){ return 1; }
constexpr int g(){return f();}//错误,调用了非constexpr函数,且const是运行时常量,从而不能编译时确定值
constexpr int f1() { return 1; }
constexpr int g1(){return f1();}//正确,f1()是constexpr函数
constexpr int get(){ return 12; }
constexpr int getvalue(int ival) { return get() * ival; }
cout << getvalue(2) << endl;//正确,getvalue(2)是常量表达式,会在编译时确定值
int i = 2; //i不是常量表达式
cout << getvalue(i)<<endl;//错误,getvalue(i)不是常量表达式,不能在编译时确定值
命名空间
建立独立的作用域。加上命名空间前缀来指定来自某命名空间的成员。显然可以分块不断的更新和追加内容。
namespace +name{
}
缺省左侧操作对象时,默认为全局命名空间 (全局作用域)。
int cnt; //全局变量
namespace nsp2{
int cnt;
int getCntValue(){ cnt = 1; return cnt; }//命名空间nsp2的cnt
int getGlobalCntValue(){ ::cnt = 8; return ::cnt; }//::cnt 全局变量cnt
}
使用嵌套命名空间内名字的一般规则有如下几条。
内层命名空间定义或声明的名字将隐藏外层命名空间同名的成员
以下各项会导致发生通过嵌套的名称隐藏:在命名空间内嵌套其他命名空间或类型;在类或结构中的嵌套类型;声明形参和局部变量。以类的隐藏举例:
Eg:
class Outer
{
static void F(int i) {}
static void F(string s) {}
class Inner
{
void G() {
F(1); // 只会尝试调用下方F
F("Hello"); // 错误,因为内部有F,所以会隐藏外部所有的F
}
static void F(long l) {}
}
}
其实所谓的隐藏,愚见是,在内部调用某个函数或者变量时,只是从内部开始找,一旦找到就不会在找。这就是所谓的内部隐藏外部。和派生类重载函数隐藏基类的类似。但派生类的会向上找,命名空间不会。因为都是独立的作用域。不加声明内部没有权限往外找。
在嵌套的命名空间内定义的名字只能在内层命名空间内有效
外层命名空间访问内层命名空间的名字时需要用命名空间的名字限定
namespace mynsp{
Int a;
namespace mysubnsp1{ int a;}//mysubnsp1成员的声明或定义
namespace mysubnsp2{ int a; … }//mysubnsp2的
}
虽然是嵌套,但是外层的对内层的是没有直接访问权限的,即虽然形式上外层包含内层,但是外层并不能随意访问内层,要+命名空间限定。
内联命名空间
在嵌套的内部命名空间的namespace前+inline,外部则可以不需要+内部的命名空间限定而直接访问内部。
NOTE:将指定的嵌套命名空间定义成内联命名空间时,关键字inline必须出现在命名空间第一次定义的地方
后续再打开命名空间的时候可以使用inline,也可以不使用
namespace A=B。从而为B起别名A
未命名的命名空间
Eg:
namespace{
//内部成员
}
若其是在全局定义的。则其内部成员可以直接在程序任意部分被访问。
若其是在局部定义的,那么其成员作用域也只是局部。
命名空间别名
外层的则直接:
namespace newname=oldname;
内层的:
namespace newname=其外层名::内层oldname;
Using声明/指示
每次using声明只引入某个成员。并且一旦引入,则会隐藏外层同名对象。其实也就是就近原则,在此作用域内找到了它。就不会去再找外层的。如局部变量会覆盖全局同名变量。
且每次声明后,从此作用域内此名字便是唯一的了。不能再在同一作用域定义同名的。
也可以直接using namespace +空间名。直接全部引入。如using namespace std;
类与对象
C++中 Struct +A A便是类名。
此结构类型是聚合类 :1,成员都是public;2,没有任何构造函数;3,不能类内初始化成员;4,无基类,无虚函数。
Class的引入
解除struct的不安全性(struct成员的默认访问权限是public)
类的名称就是一种类型
区别于struct(class可以类内赋初值等)
类的数据成员可以是任何数据类型
内置类型
数组、指针和引用
其他类的对象或指向对象的指针
指向自身类的指针或引用
const常量
可以使用decltype推断类型
类的数据成员不能使用
不能是自身类的对象 (因为要做数据成员的对象所属于的类必须是完整的)
不能是constexpr常量
不能使用auto推断类型
不能指定为extern的存储类别
若数据成员没有被指定为static,也不能被指定为thread_local
勿忘{};最后的分号。
单独声明class A;可以没有{}
若是继承,则即使是声明,{};也不能少。
如 class B:public A{};
成员函数类内声明类外定义时:
返回类型 +类名限定+函数名+形参列表+主体。
其实也就是普通函数定义 在函数名前+类名限定而已。
常量成员函数即在形参后+const即可。是禁止成函修改类数据成员的值,而非禁止成函修改形参的值。且常成函类内/外定义后面都要+const。
说明:
① 只有类的成员函数才能定义为常量函数,普通函数不能定义为常量函数。
② 常量参数与常量成员函数是有区别的,常量参数限制函数对参数的修改,但与数据成员是否被修改无关。
且常类型的对象是只能调用常量成员函数的。
成函的重载,和普通函数重载规则类似。
其默认实参的规则也是和普通函数类似。只能在类内声明或者类内定义处指明一次形参。
类的嵌套也和之前叙述的类似。不过特殊的是,其实类的嵌套就等价于,在某类的内部定义了一个类成员。这个类的成员的访问权限由外层类决定。是public还是private等。外层对内层没有特殊的访问权限,内对外也是。此时内层类相当与外层类的一个类成员。要访问内层类需要通过外层类。且其可以类内声明,类外定义。类外定义时需要外层类名限定。
嵌套类的定义
类内部分定义
嵌套类的成员函数可以在嵌套类外定义,但不能在外层类中实现,只能在外层类之外实现。即,嵌套类的成员函数可以在嵌套类内部完整定义。也可以在其内先声明然后在外层类外定义。但是不能在嵌套类内声明然后在外层类中定义。
class X{
…
public:
class Y{
public:
T func( … ); //嵌套类内声明
};
};
T X::Y::func( … ){ //嵌套类成员函数必须在外层类外定义
…
return Tval;
};
别名
Typedef T A;T为类名,A为别名
Using A=T;
显然只有定义对象时才分配空间,类的定义和声明不分配空间。且每个对象的数据成员拥有独立的空间,而成员函数和静态成员只会有一份备份,且属于类而不属于对象。
显然对象只能访问共有成员,这一点很关键。
显然对象之间的普通赋值只是值传递。
构造函数和析构函数
显然构造函数和析构函数只能是由系统隐式调用,不可显示调用。且显然二者必须是公有成员。
构造函数和析构函数显然和普成 并且构造函数是可以在初始化列表中向常数据成员写值,因为在进入构造函数体内部后,常数据成员才获得其常的属性。
前面提到class区别于struct的重要一点是可以为数据成员在类内提供初始值。在初始化时不能用()。可以用{}和=等。且类内初始值先于构造函数为数据成员指定初值
当显式定义过构造函数,就不会再合成默认的构造函数。但可以+上如 A()=default; 要求编译器合成默认构函。
当A类中的构造函数只有一个参数时,以下形式等价。
A a(100); A a=100;
构造函数的重载和默认形参和普通函数的重载以及默认形参性质一致。要注意的便是,当显示定义的构造函数的形参全部是有默认值的,那么其显然是和默认的构造函数存在二义性的。所以不能再用default来要求编译器生成默认的构造函数。
若不在构造函数的初始化列表中显式的初始化数据成员,那么在进入构造函数的函数体内之前,是先进行默认初始化,再进入构造函数的函数体内实现赋值初始化。一般情况下没有问题,但常量成员,引用成员,类对象成员,派生类构造函数对基类构造函数的调用必须采用初始化列表进行初始化。
因为显然,当进入构造函数体内以后,常量成员和引用成员相当于已经定义,不能在定义后出现在等式左边来被赋值。若不调用基类的构造函数,或者为对象成员调用其对应类的构造函数,显然没法完成初始化。且已知引用是必须在定义时初始化的,后续是不可更改的。所以用初始化列表相当于直接对这些成员在定义时进行初始化,而后再进入构造函数体内。
关于初始化的次序,显然在定义对象后,数据成员根据定义的顺序,连续的分配地址。所以在初始化时,显然也是按定义时的顺序来的。
所谓的委托构造函数其实也就是一个构造函数调用其他构造函数罢了,一般是在初始化列表中调用,也可以在函数体内部调用。既然是调用,那么被调用的构造函数显然会被完整执行,从初始化列表执行到函数体内部完成后再返回调用处。
Eg:
A(int a,int b,int c){}
A(int a,int b):A(a,b,6){}
int n,m;
A(int a):A(a,3){ }//顺序无妨
A(int a,int b):n(a),m(b){}
在定义类的指针时不会调用构造函数。
构造函数与隐式类型转换
标准内置类型——>自定义类型
如int ——>A类。
以下两种方法:
- A中有int类型的数据成员
在A中显式定义构造函数 eg:
A(int x):a(x){}
然后main函数中:
Int b=3;
A a(b); /A a=b; 这样就把int型的b转成了自定义类A。
这种方法有较大局限性,即只能是一次转换。因为构造函数只能调用一次,且是已经固定的转换方式。
- 重载=
Eg:A中有int类型的数据成员 a
A &operator=(const int &x){
a=x;
Return *this;
}
main中
Int b=3;
A a=b;
自定义类型转换——>标准内置类型
在类中重载强转成函
Eg:A中有int类型的数据成员 a
Operator int(){
return a;
}
main中
A b(3);
Int a=b;
C风格函数,strcmp等函数,头文件<string.h>勿忘
析构函数
若没有需要动态申请空间的成员,那么编译器提供的默认析构函数就够了。否则需要自定义。主动delete即可。
析构显然只能有一个,不能重载,没有参数表。可以~X()=default;来要求编译器生成默认析构函数。
对象的复制(拷贝),赋值和移动
拷贝构造函数和拷贝赋值运算符
拷贝(复制)构造函数: 在新对象定义时,以已有同类对象来构造新对象。
X(const X& a),
Eg:
Point A(1,2);
Point B(A);
Point C=A;
以及下列三种会调用
类的非引用对象做形参,实参传值给形参时;
返回类型为非引用类型的类对象;
利用花括号初始化数组中元素时。
拷贝赋值运算符,已经定义后,再赋值。
X& operator=(const X& a)
Eg:
A a(1);
A b;
b=a;
拷贝赋值运算符重载,赋值运算符重载一般都返回该类引用,并且形参类型为该类的常引用。编译器提供的是不检查自赋值等的浅拷贝赋值。
拷贝构造函数,形参往往总是该类的常引用。非引用则会无限循环调用拷贝构造函数。
因为若拷贝构造函数为简单的值传递,那么值传递时是需要调用拷贝构造函数的,进而无穷的循环调用下去直到栈溢出。
编译器总是会给一个类添加默认构造函数和赋值运算符=(浅拷贝,即只是简单的拷贝赋值)。
类通过特殊的成员函数来控制对象的拷贝、移动和赋值操作
拷贝构造函数、拷贝赋值运算符、移动构造函数和移动赋值运算符
拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时所执行的操作
拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时所应该执行的操作
这四种操作统称为拷贝控制操作
编译器合成
若没有定义这些拷贝控制成员,编译器会自动定义缺省的操作
若自定义了有参的任一种构造函数,那么则不会再提供默认构造函数,但仍然会提供默认的拷贝构造函数。
若自定义了拷贝构造函数,编译器不会再提供其他的默认构造函数。如默认拷贝,移动等。
默认的拷贝构造提供的是浅拷贝
位拷贝(浅拷贝)和值拷贝(深拷贝)的区别
位拷贝,及"bitwise copy"是指将一个对象的内存映像按位原封不动的复制给另一个对象,单纯地逐个bit拷贝;所谓值拷贝就是指,将原对象的值复制一份给新对象。
浅拷贝,即"bitwise assignment",会直接将对象的内存映像复制给另一个对象,这样两个对象会指向同一个内存区域,当一个对象被释放后,若有指针成员,另一个对象的指针会成为悬挂指针。
需要显式定义的情况
如果一个类需要显式地定义析构函数,就需要为它显式地定义拷贝构造函数和赋值运算符函数。因为既然需要显式定义析构函数,说明有需要在堆区动态申请空间的数据成员,对于此类指针成员,默认提供的浅拷贝函数并不能安全执行。所以要显式定义拷贝构造和重载赋值运算符。
显式定义拷贝构造函数,即深拷贝,重新申请空间同时初始化即可。
Eg:
A(const A& a) :p(new int(*a.p)){}//直接重新申请空间,再用形参实现初始化即可
显式定义拷贝赋值运算符三步,1.检查自赋值,2.delete当前内存,3.重新申请空间同时初始化即可。
Eg:
A& operator=(const A& a) {
if(&a!=this){//1.检查自赋值
delete p;//2.delete当前内存
p = new int(*a.p);//3.重新申请空间再初始化
}
return *this;
}
移动构造函数和移动赋值运算符
所谓的移动,本质上其实也就是右值引用。即,即将销毁的对象的内存和其内存中的资源的转移。也只有在此时才有意义,才达到了资源的节约。
移动构造函数,显然也是在定义时调用
A (A && a); //此处不能是const了,因为要实现资源的转移,显然是不能是const的。
移动赋值运算符重载
A& operator=(A && a)
若一个类显式定义了拷贝构造函数/拷贝赋值运算符/析构函数/ 编译器则不会再提供默认的移动构造函数和移动赋值运算符函数。
显式定义移动构造函数,直接用形参中的右值引用来初始化,最后置空该形参的指针成员即可。
Eg:
A(A&& a) : p(a.p)
{
a.p = nullptr;
}//定义时调用,新对象直接接管a.p的内存资源,同时置空a.p,让其被正常析构。
好像干了件很危险的事情:直接用参数对象(a)里面的指针(a.p)来初始化当前对象的指针(p),(按理说这不是浅层复制吗?!说了有指针成员,我们复制时不能做这种浅层复制的呀,怎么在这里我们恰恰做浅层复制了呢)
看函数体里面,我们发现在做完p(a.p)这种指针对指针的复制(也就是把参数指针所指向的对象转给了当前正在被构造的指针)后,接着就把参数a里面的指针置为空指针(a.p = nullptr;),对象里面的指针置为空指针后,将来析构函数析构该指针(delete p;)时,是delete一个空指针,不发生任何事情,这就是一个移动构造函数。
移动构造函数中的参数类型,&&符号表示是右值引用;即将消亡的值就是右值,函数返回的临时变量也是右值,这样的单个的这样的引用可以绑定到左值的,而这个引用它可以绑定到即将消亡的对象,绑定到右值。
显式定义移动赋值运算符函数
先定义完对象,再赋值调用;
四步,1.检查自赋值,2.delete当前内存,3.接管形参的内存资源(直接初始化),4.置空形参的指针成员。
A& operator=(A&& a) {
if(&a!=this){//1.检查自赋值
delete p;//2.delete当前内存
p=a.p;//3.接管形参的内存资源
a.p = nullptr;//4.置空形参的指针成员
}
return *this;
}
总eg代码:
class A {
public:
int* p;
A(){}
A(int value) :
p(new int(value))
{}
A(const A& a) :
p(new int(*a.p))
{
cout << "调用复制构造函数" << endl;
//调用A的复制构造函数
}
A(A&& a) :
p(a.p)
{
cout << "调用移动构造函数" << endl;
//调用A的移动构造函数
a.p = nullptr;
}
A& operator=(const A& a) {
cout << "调用复制赋值运算符" << endl;
//调用A的复制赋值运算符
if(&a!=this){
delete p;
p = new int(*a.p);
}
return *this;
}
A& operator=(A&& a) {
cout << "调用移动赋值运算符" <<endl;
//调用A的移动赋值运算符
if(&a!=this){
delete p;
p=a.p;
a.p = nullptr;
}
return *this;
}
~A() {
if (p != nullptr)
{
delete p;
p = nullptr;
}
}
};
A fun(int a)
{
return A(a);
}
int main() {
A a1 = move(fun(98));// fun函数中的 A(a),将a强转成A类。但其仍然为即将销毁和不再使用的临时变量。
//因此利用移动构造函数,从而使a1绑定a的右值,接管了其内存资源,从而避免了复制对象,节约系统资源,提高程序性能。
cout<<"*a1.p= "<<*a1.p<<endl;
A a2;
a2=move(fun(99));//调用移动赋值运算符
cout<<"*a2.p= "<<*a2.p<<endl;
//不要把一个左值轻易转换为右值使用,除非你确定它在下面不再被使用
//err: A a3=move(a1); a1并不是即将销毁的对象。如此轻易当右值使用可能会有错误。
//移动赋值运算,提高了运算效率,没有拷贝的过程
// eg: A a3=a1+a2;
A a3=a1;
cout<<"*a3.p= "<<*a3.p<<endl;
A a4;
a4=a2;
cout<<"*a4.p= "<<*a4.p<<endl;
return 0;
}
关于拷贝/移动构造函数和拷贝/移动赋值运算符函数的小结
- 拷贝构造函数和移动构造函数的共同点便是,函数名都是其类名(没有返回类型)。区别便是拷贝构造函数的形参是该类的左值引用,且往往是常引用。移动构造函数的形参类型是该类的右值引用,且不能是const。且二者显式定义时,前者是1重新申请内存2然后赋值即可。后者不用重新申请内存,1直接用右值引用的形参初始化指针成员,2然后置空该右值的指针成员即可。调用二者的时机,主要是在定义时的初始化。
- 拷贝/移动赋值运算符的返回值都是其类型的引用。从而可实现连续赋值。二者形参和其对应的构造函数相同。前者显式定义需要三步,1.检查自赋值(eg:this!=&ra),2.delete当前内存,3.重新申请空间同时初始化。后者需要四步,1.检查自赋值,2.delete当前内存,3.接管形参的内存资源(直接赋值初始化),4.置空形参的指针成员。调用二者的时机,主要是在定义完后。再用=进行赋值。
- 有时显式调用移动构造函数和移动赋值运算符函需要用到move(),若=右端是右值,会自动调用。
阻止拷贝
阻止拷贝的原因
对个别类来说,拷贝没有存在的意义
阻止拷贝的实现
不定义拷贝成员就能实现吗?
即使不在类中定义,编译器也会为它们生成合成的版本
方法一: 使用=delete将拷贝控制成员声明为删除的(C++11)
方法二:将拷贝控制成员声明为私有的(Before C++11)
建议使用方法一
使用=delete将拷贝控制成员声明为删除的
通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝。
删除的函数:虽然定义了它们,但是不能以任何方式使用它们
通过在函数的参数列表后面加上=delete来指出此函数是删除的
class NoCopy{
public:
NoCopy() = default; //使用合成的默认构造函数
NoCopy(const NoCopy&) = delete; //阻止拷贝
NoCopy& operator=(const NoCopy&) = delete; //阻止赋值
~Nocopy() = default; //使用合成的析构函数
... //其他成员
};
delete和default的区别
使用=delete将拷贝控制成员声明为删除的
=delete和=default
=delete必须出现在函数第一次声明的时候
可以对任何函数指定=delete,但只能对编译器能自动生成的成员函数使用=default
析构函数不应指定为=delete
如果析构函数被删除,则无法销毁该类型的对象。
class NoDestructor{
public:
NoDestructor() = default;
~NoDestructor() = delete;
};
NoDestructor* pd = new NoDestructor();
NoDestructor nd; //错误,NoDestructor的析构函数是删除的
delete pd; //错误,NoDestructor的析构函数是删除的
this指针
this指针是指向调用该函数的对象自身的隐含指针
代表对象自身的地址,并且不允许修改
当类的对象调用某个成员函数时,成员函数通过这个名为this的额外隐含参数来访问调用它的对象。
当调用一个成员函数时,用请求该函数的对象地址初始化this指针。
A a;
a.print(); à A::print(&a);
即:调用A类的print成员函数时,传入了对象a的地址
在编译类成员函数时,编译器自动将this指针添加到成员函数的参数列表中
在调用成员函数时,调用对象会把自己的地址通过this指针传递给成员函数
函数内任何对类成员的直接访问都被看作是this的隐式引用。
显式this指针
this是一个隐式指针,但可以在类的成员函数内部显式使用它
this是一个常量指针,相当于X* const类型,其值不允许改变
在类的非const成员函数里,this的类型就是X*。
this并不是一个常规变量,不能给它赋值,但可以通过它修改数据成员的值。
在类的const成员函数里,this被设置成const X*类型,不能通过它修改对象的数据成员值。
//所以说this指针的类型显然不是唯一的,即不一定是指针常量,还可以是指向常量的指针。
NOTE:
不修改类数据成员的成员函数尽可能声明为const成员函数
否则,const约束的引用做函数参数时可能会出现问题
成员指针
只能指向公有的数据成员和数据函数。且使用前要先声明,再赋值,然后才能访问。
- 指向数据成员的指针:
定义:数据成员对应的数据类型 类名::* 指针名;
Eg:int X::* p;//牢记*p显然是连在一起的。限定只是在*号前+上
定义了一个指向X类中int成员的指针,它可以指向X类中任何一个int成员。
赋值:数据成员指针名 = &类名::数据成员名;
P=&X::i; 从而p获取了i在X类中的偏移量。
访问:对象名.*数据成员指针名 或 对象指针名->*数据成员指针名
总结:其实就和普通的指针类似,只不过分了两步,先定义再赋值。
1,定义时只不过是在*号前加上了类名限定而已。
2,赋值时候也是和平常一样取地址,只不过是在变量前,&后加上了类名限定符而已。
3,并且p相当于获得了i在X中的相对位置。从而对每一个该类的对象都能成功指向i。
访问.
- 使用时也是和普通指针类似,*p就代表了i的值。也可以对象对应的指针名->*p 表示对象名.i
- 指向成员函数的指针:
也是先定义再赋值。
定义指向成员函数的指针时也要指明成员函数的类类型。
定义:返回类型 (类名::*指向成员函数的指针名)(形参列表);
Eg: int(A::*p)(int)
若指向成员函数的指针指向常量成员函数,则在定义时需要在形参列表后添加const关键字。
赋值:指向成员函数的指针名 = &类名::成员函数名;
Eg:
A a;
int(A::*p)(int);//定义时要写清楚形参
p=&A::geti;//赋值的时候显然是不用的
A *pa=&a;
(a.*p)()==a.geti()==(pa->*p)();
访问:
(对象名.*指向成员函数的指针名)(实参表)
OR
(对象指针名->*指向成员函数的指针名)(实参表)
总结:无论是定义还是调用,其实都是和普通的函数指针是类似的。只不过
- 定义时函数指针名要加上类名限定并且括起来。int(A::*p)(int);
- 赋值时取地址也是一样的,只是需要类名限定。
- 使用时候也是类似的,pa=&a; *p=对应的成函名。(a.*p)()==a.geti()==(pa->*p)()。
友元
友元特性:
- 没有传递性和可逆性,不可被继承。
- 友元可以访问类的所有成员。
- 友元必须在被访问的类内声明。友元函数则声明原型即可。友元类声明类即可。
- +前缀关键字friend。
声明友元时要遵循的规则
友元必须在被访问的类内进行声明
一个类的友元可以是
全局函数--友元函数
另一个类的成员函数--友元成员函数
另一个类--友元类
- 友元函数——全局函数
因为只是个普通函数,所以在类内声明时,放在public或者private等位置都一样。不受限制。
并且类外定义时候别多加类名限定,它并不属于类。
- 友元类——另一个类
关系单向不可逆。如A中声明friend class B。则B是A的友元类。B的所有成员函数都是A的友元函数,可以访问A的所有成员。
- 友元成员函数——另一个类的成员函数
可以指定类的某个成员函数是另一个类的友元,也就是友元成员函数。友元成员函数可以直接访问另一个类的私有成员或保护成员。
在指定友元成员函数时,往往采用交错的形式来声明和定义。如要声明A类中的int sum(B)函数是B类的友元。一般是先声明B类,再定义A类,且定义A类时,先只是声明sum(B)函数。然后定义B类,B类中声明友元函数sum。定义完B后。最后定义int sum(B)函数。
Eg:
#include<bits/stdc++.h>
using namespace std;
class Point;//1声明Point类
class Line{
int a,b,c;
public:
Line(int i,int j,int k):a(i),b(j),c(k){}
double dist(Point);//2 声明形参有Point类的Line的成员函数
};
class Point {//3 定义Point 类
int x,y;
public:
Point(int i,int j):x(i),y(j){}
friend double Line::dist(Point);//4 声明友元成员函数 注意声明时的语法 friend+函数原型。其中函数名前和返回类型后记得+上类名限定。
};
double Line::dist(Point T){//5 完成友元成员函数的定义
return fabs(T.x*a+T.y*b+c)/sqrt(a*a+b*b);
}
int main(){
Line L1(4,5,6);
Point x1(9,3);
cout<<L1.dist(x1)<<endl;
return 0;
}
总结:
此类交错现象出现在,A类的某个函数a形参中有B类。又渴望A类中的成员函数a能够随意访问B类的成员,便要将a函数声明为B类的友元成员函数。从而出现上述的BABA的形式。
友元的声明不受public,private,protected的影响,只要是在内部声明即可。
静态成员
其实静态成员就是相当于把类中的全局成员,属于类而不属于对象,不会随着对象的销毁和销毁。既可以通过对象访问,也可以通过类名访问,且可以在没有定义任何对象时访问。
一定要清楚,静态成员只能是在类内声明类外定义,且声明和定义皆不可少。因为类内的声明并不会为其分配内存。类外定义便是要为其分配内存。定义时可以初始化赋值也可以不,不赋值则默认为0。但是不能类内定义和赋值。因为若如此,则每个对象都会有这样已经被初始化过的成员,变成属于对象而不是属于类了。就失去了静态成员的意义,当然这也是语法不允许的。
- 静态数据成员
声明形式:
Static +类型+名
定义形式:
类型+类名限定::+名;(可以用=赋值也可以不赋值,不赋值则默认为0)
注意定义时显然不用再+static
通过对象来访问时,若静态数据成员是public,则可以直接名+.访问。若不是,则对象只能靠公有的成员函数访问。
- 静态成员函数
- 只能访问静态成员(包括静态数据成员和静态成员函数),而不能访问其他非静态成员。
- 返回类型前要+static,且形参后不能+const;
- 可以直接用类名::+名+参数表访问,也可以对象.+名+参数表访问;
- 同普通成员函数一样,静态成员函数也可以在类内或类外定义,还可以定义成内联函数;
- 类外定义时不能+static,就和普通成函定义一样。
组合和继承
组合与继承是复用已有类的两种重要途径,通过组合与继承可以在已有类的基础上建立新类,使得软件复用更简单、易行,复用已有的程序资源,缩短软件开发的周期。
组合
组合很简单,其实也就是一个类中有其他类的对象成员,注意在初始化列表中初始化即可。因为地址分配连续,所以初始化次序也就是按定义次序来。
继承
继承语法形式
class B {……};
class D : [private | protected | public] B
{
……
};
D后的{};不能漏
阻止继承
使某个类不能作为基类
阻止继承的实现:通过在类名后使用关键字final
class NoDerived final { … }; //NoDerived不能作为基类
class Base { … };
class Last final : Base { … }; //Last不能作为基类
继承的三种方式
简单的说,其实不论是哪种继承方式,子类都可以在内部访问基类的公有和保护成员。而对象始终是只能访问公有成员。子类内部(成员函数)或者子类对象任何时候都不能直接访问基类的私有成员。
公有继承
基类中成员访问权限不变。子类对象可直接访问其公有成员。
私有继承
基类的中的public和protected的成员在派生类中会变成private
虽然基类的public和protected成员在派生类中都变成了private成员,但它们与基类本身的private成员是有区别的,它们可被派生类的成员函数直接访问,而基类中的private成员不能被派生类直接访问。
保护继承
派生方式为protected的继承称为保护继承,在这种继承方式下,基类的public成员在派生类中会变成protected成员,基类的protected和private成员在派生类中保持原来的访问权限。
总之:不论哪种方式,其实只是限定了子类对象对基类成员的访问,牢记对象只能访问公有成员。子类在类内任何时候都可以直接访问基类的公有和保护成员。任何时候都不能直接访问私有成员,且私有成员始终是私有成员,变的只是其他两者。而所谓的三种方式的继承,只是改变了基类的public和protected成员对于子类对象来说的可见性。
析构函数、友元函数和友元类、静态成员显然不能被继承
友元与继承
关键便是,派生类的友元,只是能访问派生类的所有成员。派生类能直接在内部访问基类的公有和保护成员。此时基类的保护成员和公有成员在对于子类都是有访问权限的,子类的友元拥有其所拥有的所有访问权限,能通过子类对象访问任意成员。所以其友元能通过派生类对象直接访问基类的保护成员和公有成员。
而其友元并不能通过基类对象直接访问其保护成员。因为对于基类来,子类的友元和他没有任何关系。基类的保护成员对外来说是私有的。所以子类友元不能通过基类对象来访问基类的私有和保护成员。
不过注意的时,若基类A有友元类B,同时有派生类C。那么B能通过C类对象来访问A的所有成员。
改变基类成员在派生类中的访问权限
其实也就是影响子类对象对基类成员的访问权限。
派生类只能为那些它可访问的名字提供using声明,即非private
Eg:
class Base {
protected:
int x;
public:
int value() const {
return x;
}
};
class Derived : private Base {
public:
using Base::value;
protected:
using Base::x;
};
虽然是私有继承,但是经过上述操作后,基类的value函数对于子类的对象而言变成公有,可以直接访问。基类的x对于子类来说变成保护成员。
继承中的类作用域
其实也就是若子类中找不到某成员,就逐层向上找。
名字冲突与继承
其实也就是基于类的作用域,派生类中显然是可以有和基类中重名的成员。所谓的隐藏也只是说,在编译运行时,既然在子类找到了,显然就不会往上找了。若要使用基类的此函数,+上基类名限定即可。
类型转换与继承
其实本质也就是,当子类继承基类时, 相当于拥有了基类的所有成员。同时子类也可以有自己的成员。所以就看起来像是子类对象中有一个完整的基类对象,同时还有自己独特的部分。所以,基类的指针和引用能够指向/绑定子类对象。实际上绑定的是子类对象中的基类子对象。而显然这种关系是不可逆的,因为基类中并没有完整的子类备份。对象之间也是不存在类型转换的。
当表达式既不是引用也不是指针时,显然其动态类型永远和静态类型一致。
派生类的构造函数和析构函数
派生类的构造函数
其实说白了,也就是每个类只是控制自己成员的初始化。子类只负责其直接基类的初始化。当子类没有成员要初始化,基类没定义任何构造函数或者定义的是无参/全部默认参数的构造函数,此时显然子类就不用定义构造函数。
继承的构造函数
子类是不能够继承默认的构造函数的,要继承也只能是继承直接基类的显式定义的构造函数。语法为,在子类public处 using base::base; 这样基类的所有显式定义的构造函数都会被继承。其中默认实参的默认值不会被继承。继承拥有默认实参的构造函数的方式,在子类中生成所有的可能匹配此构造函数的形参列表,有多少种可能,就会有多少个此构造函数。
//默认生成的当然是不能继承的
派生类构造函数的调用次序
大顺序,基类构造函数——>对象成员的构造函数——>派生类构造函数
子类调用基类构造函数的顺序是按照在继承时候的声明顺序来的。若基类又是某个类的子类,则会直接从此基类最远的基类开始调用,然后逐层向下。
调用对象成员的构造函数顺序,其实也就是按内部的声明顺序而来。
总之所谓大顺序其实也就是遵循着小原理。
派生类析构函数
次序与构造顺序相反。
派生类的复制控制成员(拷贝/移动构造函数,拷贝/移动赋值运算符函数)
其实也就是,子类的复制控制成员,显式定义时,要在初始化列表中调用基类的相应函数完成对基类子对象的成功操作。然后在函数体内部完成对子类独特部分的操作。
定义派生类的拷贝/移动构造函数
其实很简单,也就是正常的写法。也就把子类的对象看成是一个基类子对象+子类的独有部分。要在初始化列表处调用基类的相应函数,从而完成对基类部分的正常拷贝/移动构造。
Eg:
B是A子类
B的拷贝构造函数:
B(const B& x):A(x){//调用基类拷贝构造函数,负责拷贝构造基类子对象。
//内部负责B类独特部分的拷贝构造
return *this;//勿忘
}
B的移动构造函数:
B( B&& x):A(move(x)){//调用基类移动构造函数,负责移动构造基类子对象。
//内部负责B类独特部分的移动构造
return *this;//勿忘
}
定义派生类的拷贝/移动构造函数赋值运算符
也是和上述定义构造函数类似。
Eg:
B的拷贝赋值运算符函数
B&operator=(const B &x){
A::operator=(x);//显式调用基类的拷贝=
//然后进行B类的独有部分的拷贝赋值
return *this;//勿忘
}
B的移动赋值运算符函数
B&operator=(B &&x){
A::operator=(move(x));//显式调用基类的移动=
//然后进行B类的独有部分的移动赋值
return *this;//勿忘
}
多继承
就是一个类继承于多个类。
二义性
调用时+类名限定即可。
多继承下派生类的构造函数和析构函数
也就是构造函数那几个规则,次序和单继承也是一样的规则。然后注意只用负责其直接基类的初始化即可。
继承的构造函数与多继承
利用using。但有时不同的基类拥有形参相同的构造函数。此时需要子类显式定义构造函数。并且要用=default生成无参的默认构造函数。
析构函数
显然派生类只是负责自身分配资源的销毁。会自动调用基类的析构函数。顺序和构造相反。
多继承下派生类的复制控制成员函数
其实也就是和单继承类似,把基类相关的函数都显式调用即可。
显然多继承下,每个基类的指针和引用都能绑定其子类的对象。所以要注意二义性。
多继承下由于类的作用域而出现的二义性
如某子类的多个直接基类都有同名的函数,在子类中访问时则不知道哪个符合。
所以要么+类名限定,要么子类中自定义新版本。
虚继承
引入的原因--重复基类
派生类间接继承同一基类使得间接基类(如Person)在派生类中有多份拷贝,引发二义性。
已知继承时,子类会有基类的完整拷贝。
Eg:如下列菱形继承关系
A派生出B,C。此时B,C中都有A的一份拷贝。
B和C共同派生出D。此时D中有B,C的拷贝。
所以此时D中有两份A的拷贝。
然而D只需要A的一份拷贝就够了。两份和多份会浪费空间,而且很奇怪也容易发生错误。如设置在D中调用B,C的构造函数,B和C又都会调用A的构造函数,然后各自都给自己的A类备份初始化。这显然是不行的。
所以引入虚继承来解决此问题。
虚继承与虚基类
通过虚继承机制来解决继承过来的同名冲突问题
无论虚基类在继承体系中出现了多少次,在派生类中只包含唯一一个共享的虚基类子对象
虚基类只会自下而影响,而不会自上影响。
如:B和C类虚继承自A,那么其实这对B,C和A都没有影响。
有影响的是,B,C接下来派生的类。无需再虚继承。只要是B,C各自派生或者一起派生出来的类,A都是他们的虚基类。他们都共享A的一份备份。
语法便是在继承方式前或者后+virtual
Eg:class B:vitrual public A{}; or class B:public vitrual A{};
虚基类成员的可见性
也是和之前的作用域类似,可能出现二义性。
即当通过子类寻找某函数时,有多条路径同时可行,那么显然会二义性。最好是在子类中自定义新版本。
构造函数与虚继承
即,虚基类需要当前最底层的派生来来初始化。
愚以为,此时底层上,虚基类相当于最底层派生类的直接基类,所以需要最底层的派生类负责虚基类的初始化。若未在最底层派生类初始化列表中调用虚基类构造函数,那么会尝试调用虚基类的默认构造函数,若无,则错误。
构造与析构次序:
其实都是遵守的同样的原则。
即,首先是调用虚基类构造函数,再调用非虚基类构造函数。
若有多个虚基类和非虚基类构造函数,则先按派生列表中的次序依次调用。且同一个虚基类构造函数显然只会调用一次,而非虚基类可能调用多次。
若虚基类由非虚基类派生而来,那么尝试调用此虚基类构造函数时会先调用其基类的构造函数。
虽然看起来很复杂,但是其实很简单,首先大原则就是虚基类的调用总是优先于非虚基类。当有多个同级的基类,那么就按派生列表来。
对于子类调用基类的构造函数,因为子类只是负责直接基类的初始化,所以每次只会调用其直接基类的构造函数,然而,其基类可能还有基类,那么也是一样的原则,会不断尝试向上调用。所以每次调用基类的构造函数表现出的就是,先从最远的基类的构造函数开始调用。然后逐层向下。
析构顺序显然和构造顺序相反。
其实不管是基类还是派生类还是虚基类。只要理解到,它们都是类,都遵循相同的语法规则。像很多继承中的语法规则,只不过是基础的语法规则的延申罢了。再复杂的语法规则也只是在基础的语法规则上增加了限制或和其他规则的组合和变化罢了。了解共性,掌握特性,记忆起来便方便了。
多态
单接口,多实现。基类体现接口,派生类体现实现。
大致分为
编译时(静态)多态:函数重载,运算符重载,复用函数名等
运行时(动态)多态:派生类与虚函数。
也可以说是主要包括:
1重载多态:函数重载和运算符重载
2模板多态:函数模板和类模板
3继承多态:通过基类指针或引用绑定派生类对象,使用基类的指针或引用调用不同派生类对象重定义的与基类同名的成员函数,从而表现出不同的行为。
C++中的多态一般强调的是通过继承和虚函数实现的运行时多态。
多态的体现要求:
- 要有继承
- 基类中要有虚函数,子类中要重写(!=重载)基类的虚函数。原型要完全相同。
- 要有动态绑定,即基类的指针或者引用绑定到子类对象上。
三者缺一不可。且虚函数和虚继承没有关系。俩回事。
所谓的重写,即重定义,覆盖。原型需要完全一致,除了返回是本身指针或引用时,虚函数可以在基类中返回基类的引用和指针,子类重写时,可返回自身的引用和指针。且只要一个函数被定义为虚函数,其派生出的类都会继承此虚函数,若不重写,则还是其基类的。
形参不一样则会被视为重载,不会覆盖基类的虚函数。不会体现多态。
可以在子类准备重写的虚函数形参列表后+override。若未重写成功则会报错。显然只能放在虚函数后。
可以在某虚函数形参列表后+final,表示此后派生出的类只能继承此虚函数而不能重写。
俩符号显然是只能用于虚函数的。
虚函数特征总结:
- 若子类不重写基类中的虚函数,则自动继承,且仍然为虚函数,只不过仍然只是基类中的版本。
- 虚函数必须要有定义,不能只是声明。
- 虚函数通过动态联编实现多态。
- 虚函数的定义只会自下影响不会自上。即若A中有f函数定义为虚函数,此后A类派生出的子类中不用声明virtual,f永远都会是虚函数。而若A中f不是虚函数,A的某个派生类将f声明为虚函数,对A不会有任何影响。只会对其自下的派生类有影响。
- 只有当基类的指针或者引用绑定其子类的对象上时,才会执行动态绑定,才可能体现多态。
- 只有通过基类的指针或引用访问派生类对象的虚函数时,才能体现虚函数特征,才能体现多态。
- 若重写时原型不完全一致,那么只是重载,不是重写,不会覆盖,与虚函数无关。
- 子类对象通过从基类继承的成员函数调用虚函数时,会自动访问相应的子类中的虚函数版本。
- 覆盖不是抹杀和去除,仍然可以在子类中或者通过子类对象通过类名限定访问基类的虚函数。
- 只有类的非静态成员函数和析构函数能指定为虚函数。静态成员函数和构造函数都不能是虚函数。
- 内联函数由于是编译时确定,所以不能定义为虚函数。
虚函数与默认实参
特点:
当虚基类中有默认实参时,若调用时使用默认实参,那么使用的只会是基类的版本。所以建议子类中重写虚函数时,默认实参也要保持和基类一致。
回避虚函数机制
显然可以通过调用时用类名限定来指定调用哪个版本的虚函数。(当然要在有权限调用的情况下)
Eg:
Pa->eat();
Pa->Animal::eat();
虚函数的实现技术
可以实现虚函数的技术之一——虚函数表
不同的编译器实现起来可能原理不同。
虚函数表显然是属于类的,里面存着虚函数的指针。每个对象中会有一个虚函数指针来指向这个虚函数表。
子类在继承时,继承该虚函数表,重写后,子类的虚函数指针替代原本基类的对应的虚函数指针。若不重写或重写不成功,则虚函数表中还是基类的虚函数指针。
通过绑定了子类对象的基类的指针/引用调用过程主要是三步:
通过绑定了子类对象的基类的指针/引用在动态联编时找到子类对象中的虚函数指针——>找到子类的虚函数表——>在其中找到需要调用的虚函数并调用。
虚析构函数
前面说到,可以通过基类的指针和引用来绑定子类的对象。当通过此指针或者引用来销毁子类对象时,若只是普通的析构函数,显然基类的指针和引用只能是调用基类的析构函数。从而导致只是删除了基类子对象的部分,而子类的对象并未删干净,根本原因是基类的指针和引用无法直接调用子类的普通析构函数。所以引入了虚析构函数,即是将基类的虚构函数定义为虚函数,那么通过基类的指针和引用在尝试调用析构函数时,会自动调用子类的虚构函数和基类的析构函数(次序与构造时相反),从而删干净了。
只要定义了虚析构函数,就不会在合成移动相关函数。涉及到继承和虚函数时,最好是将析构函数定义为虚析构。
纯虚函数和抽象类
纯虚函数
纯虚函数即只有接口,类内只有函数原型=0,没有函数体。
Eg:
Virtual int f(int)=0;
注意类内声明时不能有花括号函数体。类外定义时可以有函数体。
=0只能对虚函数如此,且只能出现在类内声明时,类外定义时既不能有vitrual前缀也不能有=0。
抽象类
只要有一个或以上纯虚函数的类,就是抽象类。
抽象类显然是不能用来定义对象的,一般只做基类,但是可以有抽象类的指针和引用。
抽象类派生出的子类,若不给出全部纯虚函数的定义,则会继承抽象类中的纯虚函数,那么此子类也会变成抽象类。
运行时类型识别
主要是利用一些操作实现基类指针/引用和子类指针/引用之间的转换。
指针类型的dynamic_cast,使用条件:
基类至少有一个虚函数
派生类是公有派生类
有一个基类的指针/引用
基类和子类指针/引用之间的转换:
要想利用dynamic_cast进行转换,基类必须是多态的,即至少有一个虚函数。
向上转化,子类指针/引用——>基类指针/引用
总是成功的,并且若不使用dynamic_cast则是在编译时就能成功转换。
Eg:
Base *pb;
D d;
Pb=&d;//隐式转换,编译时完成。
Pb=dynamic_cast<B*>(&d);//显式向上转换,运行时完成
向下转化,基类指针/引用——>子类指针/引用
要想成功,则基类的指针/引用确实绑定了一个子类的对象时才能成功。否则不行。
Eg:
B *Pb;
D d;
D*Pd;
Pb=&d;
B &Rb=d;
D &Rd=dynamic_cast<D&>(Rb);//正确
Pd=dynamic_cast<D*>(Pb);//正确
通过dynamic_cast,能将基类的指针/引用转换成子类指针/引用。而原本基类的指针/引用只能是正确访问子类的虚函数,但不能访问子类的非虚函数。转换后则使得其能够访问。
typeid
其实也就是用来确定某个对象在运行时的真实类型。会查表即可。
使用typeid运算符求数据类型
typeid(expr)
使用typeid比较两条表达式的类型是否相同
比较一条表达式的类型是否与指定的类型相同
Derived* pD = new Derived;
Base* pB = pD; //两个指针都指向Derived对象
if(typeid(*pB) == typeid(*pD)) {//在运行时比较两个对象的类型
//pB和pD指向同一类型的对象
}
if(typeid(*pB) == typeid(Derived)) {//检查运行时类型是否为指定类型
//pB实际指向Derived对象
}
运算符重载
可以重新定义大多数运算符,
+ - / * % ^ & | ~ ! = < > +=
-= *= /= %= ^= &= |= >>
>>= <<= == != <= >= [ ]
() new new[] delete delete[]
不能重载某些特殊运算符,包括:. . * :: ?: # sizeof typeid
只能被重载为类成员函数的运算符: = [] () ->
不能改变运算符的目、优先级、结合性
无隐含重载,即:定义了+,并不隐含定义+=
操作数中至少一个是自定义类型
程序定义的含义与运算符固有含义吻合
只能重载系统已有的运算符,不能创造新运算符
C++为类提供了默认的重载运算符
① 赋值运算符(=);
② 取类对象地址的运算符(&);
③ 成员访问运算符(->)。
这些运算符不需要重载就可以使用,但要在类中使用其他运算符,就必须明确地重载它们。
二元运算符的调用形式与解析//记住原理便于理解
aa@bb 可解释成 aa.operator@(bb)
或解释成 operator@(aa,bb)
第1种形式是@被重载为类的非静态成员函数的解释方式,这种方式要求运算符@左边的参数必须是一个对象,operator@是该对象的成员函数。
第2种形式是@作为类的友元或普通重载函数时的解释方式。
重载输出运算符
<<只能重载为普通函数。若重载为类的成员函数,则无法实现cout在左侧。
要明白原理,若是成员函数,调用的原理是,a.operator<<(cout)
缩写起来就是a<<cout。这显然是不符合cout特质的。
而普通函数调用原理:原型举例: ostream &operator<<(ostream &os,const A& a)
调用时 operator<<(cout,a) 简化写法:cout<<a;
最好是常引用,也可以是值传递。且返回类型必须是输出类引用。
声明为类的友元函数即可。
重载输入运算符
与<<类似。>>显然也只是重载为普通函数。
原型举例:istream & operator>>(istream &is,A &a);//要录入,显然必须是引用,其不能是const。
内部要检查是否录入成功,不成功则为对象默认初始化
If(!is){
a=A();//或者写成A ta;a=ta;即默认初始化a
}
算数运算符的重载
包括+,-,/,x等
以+为例,重载为友元函数;
A operator+(const A&a1,const A&a2){
A t;
t.n=a1.n+a2.n;
t.m=a1.m+a2.m;
teturn t;
}
注意:此时只能是返回A而不是A&。因为t是一个局部变量,当离开作用域后会被销毁。已经被销毁了,其引用显然是无效且非法的。所以显然是只能返回值的。其他的返回值的情况其实也是类似,函数体内部往往是返回了一个临时变量。返回引用,则返回的不会是临时变量。
关系运算符的重载
关系运算符包括,==,!=,<,<=,>,>=等。返回类型一般为bool。其中,具有对立关系的,往往利用其对立的运算符来重载。以!=和==为例:
bool operator==(const A &a1,const A &a2){//一般都为常引用
if(a1.n==a2.n&&a1.m==a2m)return ture;
else return false;
//或者直接写return a1.n==a2.n&&a1.m==a2m;
}
bool operator!=(const A &a1,const A &a2){
return !(a1==a2);
}
重载前要记得这些关系运算符对此类是否有意义。
赋值运算符的重载
前面学类和对象时学习过,具有不会被继承等特点。不赘述。注意只能是成员函数。且注意拷贝和移动的几个步骤,以及各自的返回类型和形参类型即可。
拷贝和移动的返回类型都是该类引用,拷贝的形参最好时候常引用。移动的形参不能是const。是右值引用。
拷贝:1检查自赋值,2合理赋值(有指针则先干掉当前内存再重新申请空间同时初始化),
3 return *this;
移动:1检查自赋值,2合理移动操作,3 return *this;
复合赋值运算符重载
如+=,-=。可以不是类成员。赋值的一般都返回引用。一般定义为成员函数,最后return*this;
自增/减运算符重载
为单目运算符,应该尽量重载为成员函数;
以自增为例:
分为前自增和后自增;
前自增:
- 返回引用;
- 无参;
Eg:
A &operator++(){
n++;
m++;
return *this;
}
后自增:
- 返回值;
- 有占位int形参
Eg:
A operator++(int){
A t=*this;//保存值
n++;
m++;
return t;
}
此处t是临时变量,只能返回值
下标运算符的重载
只能是成员函数
1、[ ]是一个二元运算符,其重载形式如下:
class X{
……
X& operator[](T n);//T可以是任意类型
};
2、重载[]需要注意的问题
① [ ]是一个二元运算符,其第1个参数是通过对象的this指针传递的,第2个参数代表数组的下标
② 由于[ ]既可以出现在赋值符“=”的左边,也可以出现在赋值符“=”的右边,所以重载运算符[ ]时常返回引用。
③ [ ]只能被重载为类的非静态成员函数,不能被重载为友元和普通函数。
PPT代码解析:
【例6-10】 设计一个工资管理类,它能根据职工的姓名录入和查询职工的工资,每个职工的基本数据有职工姓名和工资。
//CH6-10.cpp
#include <iostream>
#include <string>
using namespace std;
struct Person{ //职工基本信息的结构
double salary;
char *name;//指针类型
};
class SalaryManage{
Person *employ; //存放职工信息的数组
int max; //数组下标上界
int n; //数组中的实际职工人数
public:
SalaryManage(int Max=0){
max=Max;
n=0;
employ=new Person[max];//申请空间
}
double &operator[](char *Name) { //重载[],返回引用,此时的下标时char*类型的
Person *p;//相当于temp
for(p=employ;p<employ+n;p++) //遍历数组
if(strcmp(p->name,Name)==0)
return p->salary;
p=employ + n++; //说明是新职工,则添加
p->name=new char[strlen(Name)+1];//name也是指针成员,申请空间
strcpy(p->name,Name);
p->salary=0;
return p->salary;
}
void display(){ for(int i=0;i<n;i++)
cout<<employ[i].name<<" "<<employ[i].salary<<endl;
}
};
int main(){
SalaryManaege s(3);
s["杜一为"]=2188.88;
s["李海山"]=1230.07;
s["张军民"]=3200.97;
cout<<"杜一为\t"<<s["杜一为"]<<endl; cout<<"李海山\t"<<s["李海山"]<<endl;
cout<<"张军民\t"<<s["张军民"]<<endl;
cout<<"-------下为display的输出--------\n\n";
s.display();
}
//成员访问运算符的重载,老师课上没讲,应该不考
函数调用运算符重载
因为重载后类似于函数的调用,所以被称为仿函数。
没有固定写法,很灵活。根据需要任意定义。
只能是成员函数。
Eg:
A类中
double operator()(int n,int m){
return n+m;
}
main中
A a;
double b=a(1,2);//像是在调用函数。
还可以构造匿名函数对象
double b1=A()(1,2); //匿名调用
小结:
说到重载函数,首先的问题便是到底是重载为成员函数还是友元函数。
一般具有对称性的,双目运算符,最好重载为友元函数,如+,>等。但=是例外,只能是成成员函数。因为只要重载为成员函数,那么第一个操作数是会通过this指针传递。所以有失双目运算符的对称性。<<和>>只能重载为友元函数。
单目运算符一般重载为成员函数。
关于返回类型:
非赋值的普通运算符往往返回值即可。除了<<,>>,前自增/减。赋值运算符最好是返回引用(成员函数返回*this)。
总之是否返回引用,看是否需要实现连续调用此运算符。
关于形参的类型:
自增自减显然无参。那关于其他的,到底是const还是不能是const,则看是否需要改变实参。需要改变则不能是const,如重载>>。若不改变,尽量常引用。
到底是左值引用还是右值引用还是非引用:
一般都是定义成引用。且有一些必须定义为常引用。若非如此,则无法实现部分功能。
Eg:
A类中的拷贝构造函数和拷贝赋值运算符重载和+号重载中的形参为非常量引用时,一些操作可能是失败的。
如 A a(1,3);
A c;
c=a+1//错误!
c=a+1失败的原因是,a+1是临时变量,即右值,不可以直接转引用。需要将c所属类A拷贝赋值运算符中的参数类型改为const A &,便可以接收右值引用,且重载的+部分也要改成const 引用,其他的涉及到右值引用的也是类似,要将形参类型设为常引用。因为const限定的变量时运行时的常量,常引用可以绑定常量,字面值等右值。
模板
函数模板
- 模板参数列表不能为空;
- inline,constexpr等+在返回类型前,<>后。
如template<class T>
Inline T fun(T a);
而不是放在template前
- 编译时实例化;//所以要确定T的确切类型
- 模板参数如果不在<>中指定类型,会自动根据()中的实参数推断类型,且是强匹配,不会自动类型转换。
- <>中有已经指定的类型和参数,如<int n>,那么要想在编译时能实现实例化,则<>中实际传时必须是常量,如<2>//对。<a>//错! 如果是数组,eg:
template<class T,int n>
Void fun(T (&a)[n]){
}
传参时,fun(a);//传一个数组名,会自动推断n大小。
- 对特殊的不适用一般模板的特化eg:
一般模板:
template<class T>
T max(T a,T b){
return a>b?a:b;
}
特化字符串之间的比较
template<>//特化时不用在<>里加东西。
const char*Max<const char*>(const char *a,const char *b){
//但是需要在函数名后 形参列表前中加上<特化的类型>
}
- 实例化时才会生成代码,且同一个实例化函数只会生成一次。
- 若函数模板和普通函数都可以实现,优先调用普通函数。
- 函数模板也可以重载成普通函数。
- 如果函数模板可以产生更好的匹配,如当某次调用,函数模板和普通函数都可以被调用,但是调用普通函数需要隐式类型转换,而函数模板不需要,那么此时会优先调用函数模板。
- 非类型参数是指某种具体的数据类型,在调用模板时只能为其提供用相应类型的常数值。非类型参数是受限制的,通常可以是整型、枚举型、对象或函数的引用,以及对象、函数或类成员的指针,但不允许用浮点型(或双精度型)、类对象或void作为非类型参数。
在下面的模板参数表中,T1、T2是类型参数,T3是非类型参数。
template<class T1,class T2,int T3>
在实例化时,必须为T1、T2提供一种数据类型,为T3指定一个整常数(如10),该模板才能被正确地实例化。
类模板
- 类模板不能自动推导类型,必须要在<>中指定类型。
- 类模板在模板参数列表可以有默认参数。如template<class T=int>//若未<>指定,那么就默认int。
- <>中的非类型参数依然是只能传常量。如<int size>。因为要编译时能实例化。
- 类模板中的成员函数,只有在被调用时才会实例化。当其类内定义则自动内联。类外定义则是类似于模板函数。
如模板类Stack类中的成员函数 的类外定义 :
template<class T>
返回值 Stack<T>::成员函数名(形参){
//及类名限定也要加上<T>
}
5,
类模板可以声明static成员
template<typename T>
class X{
static int ctr;
public:
Static int count() { return ctr; }
};
静态数据成员也定义为模板
template<typename T>
int X<T>::ctr = 0; //定义并初始化ctr,其实也就是类名限定时候要+上<T>。
通过类来直接访问静态成员,必须引用一个特定的实例
X<int> xi; //实例化X<int>类和静态数据成员ctr
auto ct = X<int>::count(); //实例化X<int>::count
ct = xi.count(); //使用X<int>::count
Ct=X::count();//错误。不知道要访问哪个实例化后的函数
容器
/
总之双向的会有比较慢的部分。Deque插入删除块,但遍历慢。List插入删除都很慢
总之要会选择合适容器解决问题。
异常
try{
throw.....;
}catch(){
...
}catch(){
...
}
- 只会catch完全匹配的类型。不过基类的指针和引用能匹配子类对象。
- Catch()中只能是有一个类型的形参或者没有。
- try-throw-catch异常处理的执行逻辑如下
1、当程序执行过程中遇到try块时,将进入try块并按正常的程序逻辑顺序执行其中的语句
2、如果try块的所有语句都被正常执行,没有发生任何异常,那么try块中就不会有异常被throw。在这种情况下,程序将忽略所有的catch块,顺序执行那些不属于任何catch块的程序语句,并按正常逻辑结束程序的执行,就像catch块不存在一样。
3,如果在执行try块的过程中,某条语句产生错误并用throw抛出了异常,则程序控制流程将自此throw子句转移到catch块,try块中该throw语句之后的所有语句都不会再被执行了。
4、C++将按catch块出现的次序,用异常的数据类型与每个catch参数表中指定的数据类型相比较,如果两者类型相同,就执行该catch块,同时还将把异常的值传递给catch块中的形参arg(如果该块有arg形参)。只要有一个catch块捕获了异常,其余catch块都将被忽略。
5、如果没有任何catch能够匹配该异常,C++将调用系统默认的异常处理程序处理该异常,其通常做法是直接终止该程序的运行。
6,catch根据异常的数据类型捕获异常,如果catch参数表中异常声明的数据类型与throw抛出的异常的数据类型相同,该catch块将捕获异常。
注意:catch在进行异常数据类型的匹配时,不会进行数据类型的默认转换,只有与异常的数据类型精确匹配的catch块才会被执行。
- 抛出的若是类对象,那么该类要有析构函数和拷贝构造函数(或移动函数)。
Eg:throw A(); A是一个类,如此抛出时,会生成一个临时的异常对象,然后被catch捕获。
- 异常类,如full类。在stack类中的成函中,若已满,则throw full类。Main中来try和catch。Try中则尝试给stack对象push。
- 处理派生类对象的异常时,catch的排列应该是从派生类到基类。最外层还可以用(...)。
8.3.1 在函数中处理异常
异常处理可以局部化为一个函数,当每次进行该函数的调用时,异常将被重置。
【例8-3】 temperature是一个检测温度异常的函数,当温度达到冰点或沸点时产生异常。
#include<iostream>
using namespace std;
void temperature(int t)
{
try{
if(t==100) throw "沸点!";
else if(t==0) throw "冰点!";
else cout<<"the temperature is OK..."<<endl;
}
catch(int x){cout<<"temperatore="<<x<<endl;}
catch(char *s){cout<<s<<endl;}
}
void main(){
temperature(0); //L1
temperature(10); //L2
temperature(100); //L3
}
8.3.2 在函数调用中完成异常处理
将产生异常的程序代码放在一个函数中,将检测处理异常的函数代码放在另一个函数中,能让异常处理更具灵活性和实用性。
【例8-4】 异常处理从函数中独立出来,由调用函数完成。
#include<iostream>
using namespace std;
void temperature(int t) {
if(t==100) throw "沸点!";
else if(t==0) throw "冰点!";
else cout<<"the temperature is ..."<<t<<endl;
}
void main(){
try{
temperature(10);
temperature(50);
temperature(100);
}
catch(char *s){cout<<s<<endl;}
}
8.4 异常处理的几种特殊情况
8.4.1 捕获所有异常
在多数情况下,catch都只用于捕获某种特定类型的异常,但它也具有捕获全部异常的能力。其形式如下:
catch(…) {
…… //异常处理代码
}
8.4.2再次抛出异常——catch中的空throw语句。
如是catch块无法处理捕获的异常,它可以将该异常再次抛出,使异常能够在恰当的地方被处理。再次抛出的异常不会再被同一个catch块所捕获,它将被传递给外部的catch块处理。要在catch块中再次抛出同一异常,只需在该catch块中添加不带任何参数的throw语句即可。
会由下一个try块处理。
【例8-11】 在异常处理块中再次抛出同一异常。
//eg8.cpp
#include<iostream>
using namespace std;
void Errhandler(int n)throw()
{
try{
if(n==1) throw n;
cout<<"all is ok..."<<endl;
}
catch(int n){
cout<<"catch an int exception inside..."<<n<<endl;
throw; //再次抛出本catch捕获的异常
}
}
void main(){
try{Errhandler(1); }
catch(int x){ cout<<"catch an int exception in main..."<<x<<endl; }
cout<<"....End..."<<endl;
}
8.4.3 异常的嵌套调用
try块可以嵌套,即一个try块中可以包括另一个try块,这种嵌套可能形成一个异常处理的调用链。
即,try中可以调用其他函数。其他函数中可能也有try块。
当此try后的catch不能匹配throw出的异常。则整个异常会沿着调用链不断上抛,直到被捕获处理或者到调用链尽头未被处理则会导致系统强制结束。
Catch中有return则会返回到调用到调用处。若无,则会执行最后一个catch后的语句。
总之,对于异常处理,学会处理异常即可。
其中异常类,往往是在某类中内部throw另一类的异常。Main中try和catch
如try给stack类push,stack类中的push,判断若满了则throw Full类。如throw Full();//调用Full类的构造函数生成临时对象被catch。所以Full类要有构造和析构函数。
处理派生类的异常。Catch子句排列从特殊到一,即从派生类到基类。
这里所说的“基类的子对象”即基类的数据成员
在C++中,构造函数不能被继承,因此,派生类的构造函数必须通过调用基类的构造函数来初始化基类子对象。
在派生类初始化列表直接初始化基类的成员,被称为“越级初始化”,是会报错的。
举个栗子:
我们先创建一个基类,里面有一个int型子对象
class A{
protected:
int n;//基类的子对象
public:
A();
A(int temp):n(temp){}
};
然后创建一个派生类,并用派生类构造函数的初始化列表来进行“越级初始化”
class B:public A{
public:
B(int temp):n(temp){}//对基类子对象n进行初始化
};
在主函数中调用
int main()
{
B(1);
return 0;
}
结果报错:[Error] class ‘B’ does not have any field named ‘n’
所以越级初始化是不可以的,但是可以“越级赋值”(自造名词哈哈)
class B:public A{
public:
///B(int temp):n(temp){}
///改为
B(int temp){n=temp};
};
这样就可以完美运行了。
因此,派生类的构造函数必须通过调用基类的构造函数初始化基类成员,不能够在派生类初始化列表直接初始化基类的成员