这里写目录标题
- 形参与实参的区别
- 函数调用过程
- 指针和引用当函数参数
- 引用作为函数参数有哪些好处
- 回调函数
- 友元
- 函数重载匹配
- 运算符重载
- 直接初始化与拷贝初始化
- 函数指针
- C++中struct(结构)和class(类)的区别
- C++有哪几种构造函数
- 构造函数的执行顺序
- 析构函数的执行顺序
- 什么时候会调用析构
- 设计计算单个子类的对象个数
- 如何禁止程序自动生成拷贝构造函数?
- 阻止一个类被实例化
- 临时对象
- 1.值传递的方式给函数传参
- 2.类型转换产生临时对象
- 3. 函数返回对象产生临时变量
- 类成员初始化方式与成员初始化列表
- 为什么静态成员函数不能访问非静态成员
- 面向对象
- 虚函数与纯虚函数
- 虚函数表
- 为什么析构函数是虚函数
- 为什么默认的析构函数不是虚函数
- 为什么构造函数不能是虚函数
- 构造或者析构函数能否设置为内联函数
- 什么情况会自动生成默认构造函数?
- 什么情况下会合成构造函数?
- 抽象基类为什么不能创建对象?
- 构造函数和析构函数调用虚函数
- 虚析构函数
- 构造函数与析构函数抛异常
- 构造函数关键字
- 什么是继承
- 虚拟继承
- final和override
- 实现C++的多态
- C++中的重载、重写(覆盖)和隐藏的区别
- public、protect、private 与继承
- 如果想将某个类用作基类,为什么该类必须定义而非声明?
- 继承机制中对象之间如何转换?指针和引用之间如何转换?
- 继承与组合
- this指针
- delete this
- 异常处理
- 什么是模板
- 函数模板
- 类模板
- 模板特例化
- 函数模板特例化
- 类模板特例化
- 模板声明定义为什么要放在.h文件中
- “hello world”从开始到打印到屏幕上的全过程?
- 静态函数能定义为虚函数吗?常函数呢?
形参与实参的区别
形参变量只有在被调用时才分配内存单元,在调用结束时, 会立刻释放所分配的内存单元。因此,形参只有在函数内部有效。
函数调用过程
在讲函数调用入栈过程之前,需要先讲两个比较重要的指针。
ebp(extended base pointer )“基址指针”。这个指针在改变之前始终指向栈帧的开始,也就是栈底,它一般用来在堆栈中寻址。
esp(extended stack pointer)可称为“ 栈指针”。 esp是会随着数据的入栈和出栈移动的,也就是说,esp始终指向栈顶。
接下来开始讲函数入栈出栈过程,假设现在在main函数里调用函数B,这个时候main函数是"调用者",B函数是"被调用者"
(1)开始调用时,main函数的堆栈基址,也就是ebp会先入栈,以此保存之前任务的信息,这样当函数调用完之后可以继续执行之前的逻辑。
(2)然后将main函数的栈顶指针esp的值赋给ebp,作为新的基址,这个新的基址就是被调用者B的栈底。
(3)然后在这个基址上开辟相应的空间用作被调用者B的栈空间,进行函数入参的压栈操作,函数参数压栈的顺序是从右向左。
(4)函数B执行结束后从栈里依次弹出变量,然后把当前栈帧的ebp恢复为调用者A的栈顶esp,使栈顶能够恢复到函数B被调用前的位置;然后main函数再从恢复后的栈顶弹出之前的ebp值。这样,ebp和esp就都恢复了调用函数B前的位置,也就是栈恢复函数B调用前的状态。这就是一个大体的函数调用入栈和出栈过程。
int func(int param1 ,int param2,int param3)
{
int var1 = param1;
int var2 = param2;
int var3 = param3;
return var1;
}
int main(int argc, char* argv[])
{
int result = func(1,2,3);
return 0;
}
1.函数main执行,main各个参数从右向左逐步压入栈中,最后压入返回地址
2.执行int result = func(1,2,3);3个参数以从右向左的顺序压入堆栈,即从param3到param1
3.执行返回地址入栈
4.执行完毕后将栈中的变量依次弹出,最后主函数返回
指针和引用当函数参数
要讲清楚这个问题,我觉得需要先讲一下指针和引用作为函数参数传递时的主要区别。
首先指针作为函数参数传递时,它其实本质上是值传递。传入函数的这个指针只是原指针的一个拷贝,所以此时是存在两个指针的,因为这两个指针指向的是同一片内存空间,所以它们都能够修改指向的值。
除非你把这个参数指针的指向改了,那就和原对象没什么关系了。
如果数据对象是数组,则使用指针(唯一的选择),并且指针声明为指向const的指针;
而引用作为参数进行传递时,传递进来的并不是实参的一个拷贝,而是实参的地址。那么这时候对形参的任何操作都会影响到原来的实参。所以在用引用进行参数传递时,不仅节约时间,而且可以节约空间。
如果数据对象是较大的结构,则使用const指针或者引用,已提高程序的效率。这样可以节省结构所需的时间和空间;
如果数据对象是类对象,则使用const引用(传递类对象参数的标准方式是按照引用传递);
引用作为函数参数有哪些好处
首先传引用和传指针本质上传的都是地址。
但是使用指针传参时,在被调函数里需要给形参分配内存,而且需要重复使用"*xxx"的形式进行运算,这相较于引用传递的代码阅读性更差、更容易产生错误;
回调函数
我理解回调函数就相当于一个中断处理函数,让系统在符合我们设定的条件时候自动去调用它。它是一个通过函数地址调用的函数。首先通过传参的形式将A函数的地址传递给B函数,然后在B函数中通过函数指针(地址)调用A函数,那作为被调用的A函数就称为回调函数;
回调函数不是在函数的定义上区别于普通函数,而是在调用的方式有区别,重点在"回调"这两个字。
设计回调函数的初衷:
假设A程序和B程序分别有两个人独立进行开发。函数FA由程序A开发定义,而且只有在B程序里的FB函数调用的时候才能执行,有点像中断函数的概念。B程序只负责调用FA,但是不管FA函数的具体实现。这就是回调。
那可能会有人问,在A程序里不断去查询FB函数的状态,一旦FB被执行了,就让A程序自己去执行FA不行吗?
这样是可以的,但是这样实现的方案不好。因为在整个过程中A程序一直都在查询状态,非常耗资源,查询频率高了耗费CPU,查询频率低了实时性保证不了,FB都执行好久了你才反应过来,FA的执行就会明显晚于FB了。
那这就是回调函数的由来。
友元
友元的关键字是 friend
创造友元的初衷
在程序里,有些私有属性会需要让类外的一些函数或者类进行访问,就需要用到友元的技术,简而言之,友元的目的就是让一个函数或者类访问另一个类中的私有成员
友元有三种实现:
- 成员函数做友元
- 全局函数做友元
- 类做友元
评价:友元的正确使用能提高程序的运行效率,但同时也破坏了类的封装性和数据的隐藏性,导致程序可维护性变差
注意:
(1) 友元关系不能被继承。
(2) 友元关系是单向的,不具有交换性。若类B是类A的友元,类A不一定是类B的友元,要看在类中是否有相应的声明。
(3) 友元关系不具有传递性。若类B是类A的友元,类C是B的友元,类C不一定是类A的友元,同样要看类中是否有相应的申明
函数重载匹配
确定候选函数,确定可行函数,确定最佳匹配函数。
参数数量相同的情况下,实参类型越接近,它们就越匹配
运算符重载
operator + 要重载的运算符。
所谓运算符重载,就是对已有的运算符重新进行定义,赋予其另一种功能,以适应不同的数据类型,我们一般用来处理我们自定义的数据类型。运算符重载的实质就是函数重载。
有两种重载方式:成员运算符和非成员运算符,成员运算符比非成员运算符少一个参数;下标运算符、箭头运算符必须是成员运算符;当重载的运算符是成员函数时,this绑定到左侧运算符对象。重载后的运算符必须至少有 一 个操作数是用户定义的类型,防止用户为标准类型重载运算符。
- 不允许定义新的运算符
- 不能改变该运算符操作数(对象)的个数
- 不能改变该运算符的优先级别和结合性
直接初始化与拷贝初始化
首先讲一下什么是初始化
我们都知道对象是类的实例化,它在内存里会占据一个一定大小的空间。而创建一个对象一般分为两步:1分配内存空间和2初始化,初始化指的是创建变量的时候就赋予它一个值(而赋值是指用一个值覆盖原来的值),类的构造函数控制其对象的初始化过程,无论何时只要类的对象被创建就会执行构造函数。
接下来就是直接初始化与拷贝初始化了,在讲直接初始化和拷贝初始化的区别之前我们要说,我们一般都是针对于类类型的对象。因为我们认为内值类型不存在构造函数一说,所以直接初始化也好,用等号初始化也好,复杂度可以认为没有区别。
直接初始化
直接初始化指的是使用普通的函数匹配来调用与我们提供的参数最匹配的构造函数。也就是说只是直接调用类的构造函数或拷贝构造函数就能完成初始化的就是直接初始化。
拷贝初始化
拷贝初始化首先使用指定的构造函数创建一个临时对象,然后用拷贝构造函数把临时对象拷贝到正在创建的对象。有时候为了提高效率,会允许编译器跳过创建临时对象这一步。
所以拷贝初始化就相当于先创建一个对象(执行构造函数)再进行拷贝(执行拷贝构造函数),这样就相当于多执行了一步,那它的效率比起直接初始化的效率就相对低一些。
有的人觉得在初始化语句里如果有等号"="出现,那就是拷贝初始化,其实这种观点是不太正确的,因为有三种没有使用’='号的情况也会发生拷贝初始化
1.将一个对象作为实参传递给一个非引用类型的实参
2.从一个返回类型为非引用的函数返回一个对象
3.花括号初始化一个数组的元素或一个聚合类(struct)的成员
函数指针
如果在程序中定义了一个函数,那么在编译时编译器也会为这个函数分配一段内存空间,这段内存空间的首地址就是这个函数的地址。函数名也表示这个地址。既然函数也有地址,我们就可以像其他变量一样用一个指针来存放它的地址,这个指针就是函数指针。
为什么要有函数指针:
主要是实现调用的灵活性,函数指针使用一个指针的方式指向一个函数,并且还可以轮换指向别的函数,比如有多个函数的声明,它们有不同的具体实现,如果需要调用它们,就可以用一个指针轮流指向它们。
double cal(int);
double (*pf)(int); // 指针pf指向的函数, 输入参数为int,返回值为double
pf = cal; // 指针赋值
double y = cal(5); // 通过函数调用
double y = (*pf)(5); // 通过指针调用 推荐的写法
C++中struct(结构)和class(类)的区别
首先C++中的结构体是在C语言结构体的基础上进行了扩充而来的,在C语言里面结构体是一般只能用来封装不同类型数据的数据结构,而C++中结构体可以包含成员函数,可以继承也可以实现多态的,那这些功能就和C++中的类比较相似了。
我理解C++里面结构体和类最本质的区别就是默认的访问控制不同:结构体里的成员在默认情况下访问级别是public,而类的成员在默认情况下的访问级别是private。
我觉得struct更适合看成是一个数据结构的实现体,而class更适合看成是一个对象的实现体!
什么时候用结构体,什么时候用类?
一般在存储轻量级的数据对象时,可以选用struct,而在表示数据量比较大、逻辑比较抽象的对象时,一般用class。
C++有哪几种构造函数
构造函数的作用:最主要的作用就是初始化对象的数据成员
一共有五种构造函数:默认构造函数、普通构造函数、拷贝构造函数、转换构造函数、移动构造函数。
- 默认构造:无参构造和缺省构造都被称为默认构造函数,它只能有一个。C++ 里面把类型分为内置类型和 自定义类型两类。如果我们没有手动创建构造函数,那编译器就会生成一个空的默认构造。这个默认构造对内置类型变量不做初始化处理,但对自定义类型会去调用它的默认构造来初始化。
- 普通构造:普通构造是指程序员手动定义的构造函数,通常用来做一些初始化工作。此时编译器不再生成默认构造函数。
- 拷贝构造:拷贝构造比较特殊,它只有一个参数,这个参数通常是对这个对象类型的引用。
在三种情况下会发生拷贝构造:
- 当用一个对象去初始化同类型的另一个对象时,会显式调用拷贝构造。
- 当一个对象以值传递的方式传入函数体时
- 当一个对象以值传递的方式从函数返回时
和默认构造一样,如果我们没有手动创建拷贝构造,那么编译器就会自动创建一个默认拷贝构造。
这个默认拷贝构造对于内置类型会使用浅拷贝,对于自定义类型成员会继续去调用该成员的拷贝构造。因为要调用拷贝构造需要先传参,当使用值传参时编译器需要创建该对象的副本,这就又是一个拷贝构造开始了往复循环直到系统崩溃,所以我们必须用引用传参。
4. 转换构造:转换构造函数指的是它能够接收一个与本类型不同的形参,并将该形参转换成本类的一个对象,比如string 类可以接收一个C 字符串形参并且把这个C字符串转换为 string。string(char *);
5. 移动构造:移动构造是C++11里新提出来的构造方法。
移动构造函数设计的初衷:在移动构造出来之前我们一般用的比较多的是拷贝构造,但是有些时候的拷贝其实是不需要的。比如我们用对象a初始化对象b,然后对象a我们就不再使用了,但是对象a的空间却还在,既然拷贝构造函数只是把a对象的内容复制一份到b中,那么为什么我们不能直接使用a的空间呢?这样就避免了新的空间的分配,大大降低了构造的成本。所谓移动构造,就是把属于原来对象的东西移动到新的对象上,这个东西的所有权进行了变更,那这就减少了拷贝构造所需要的拷贝开销。
构造函数的执行顺序
一个子类构造函数的执行顺序如下:
第①步:调用虚拟父类的构造函数(多个虚拟父类则按照继承的顺序执行构造函数)。
第②步:父类的构造函数(多个普通父类也按照继承的顺序执行构造函数)。
第③步:类类型的成员对象的构造函数(按照成员对象在类中的定义顺序)
第④步:子类自己的构造函数。
析构函数的执行顺序
析构函数顺序
① 调用派生类的析构函数;
② 调用成员类对象的析构函数;
③ 调用基类的析构函数
什么时候会调用析构
-
对象的生命周期结束被销毁时;
-
delete对象的指针时;
-
对象i是对象o的成员,o的析构函数被调用时,对象i的析构函数也被调用。
设计计算单个子类的对象个数
1、为类设计一个static变量count作为计数器;
2、类定义结束后初始化count;
3、在构造函数中对count进行+1;
4、设计拷贝构造函数,在进行拷贝构造函数中进行count +1;
5、设计赋值构造函数,在进行赋值函数中对count+1;
6、在析构函数中对count进行-1;
如何禁止程序自动生成拷贝构造函数?
为了阻止编译器默认生成拷贝构造函数和拷贝赋值函数,我们一般需要手动去重写这两个函数。如果要避免调用拷贝构造函数和拷贝赋值函数,我们可以把它们设置成private。
但是即使如此,类的成员函数和友元函数还是可以调用private函数。针对这种情况,我们可以定一个base类,在base类里把拷贝构造函数和拷贝赋值函数设置成private,那么子类的编译器就不会自动生成这两个函数,而且由于base类中这个函数是私有的,所以子类将阻止编译器执行相关的操作。
阻止一个类被实例化
将类定义为抽象基类或者将构造函数声明为private;
不允许类外部创建类对象,只能在类内部创建对象
临时对象
一般有三种情况会产生临时对象
1.值传递的方式给函数传参
以值传递的方式给函数传参这种方式会直接调用对象的拷贝构造函数,生成一个临时对象传参给函数。
class Person{
public:
Preson(){cout << "无参构造函数!" << endl;}
Person(int a){ cout << "有参构造函数!" << endl;}
Person(const Person &p){ cout << "拷贝构造函数!" << endl;}
~Person(){cout << "析构函数!" << endl;}
int fun(Person p) //普通的成员函数,注意参数是以值的方式调用的
{
p.m_age = 20; //这里修改对外界没有影响
return p.m_age;
}
int m_age;
};
int main()
{
Person p(10);
p.fun(p);
return 0;
/** 输出:
有参构造函数!
拷贝构造函数! 多余的
析构函数! 多余的
析构函数!
原因:因为fun函数的形参是Person p,所以在调用fun的时候,传递过去的是实参的复制品,即临时对象,并不是外面main函数的实参。
所以此时在我们看来类似 Person p = p;实际上却是Person p = temp;
而这句 Person p = temp;就会发生拷贝构造函数啦。
当fun函数调用结束后,p的声明周期也就结束,所以还会多调用析构函数。
*/
}
解决方法:只要把值传递的方式修改为引用传递的方式即可
2.类型转换产生临时对象
int main()
{
Person p;
p = 1000;
return 0;
}
/**
无参构造函数!
有参构造函数! 多余的
析构函数! 多余的
析构函数!
原因:其实是由于 p = 1000;这句引起的,p的类型为 Person,而1000为int,明显类型不一致。编译器会进行隐式类型转换。
其实就是创建一个临时对象,这个临时对象调用了有参构造函数,并把1000作为形参,传入有参构造函数。
当这个函数调用结束后,对象也就销毁了,所以临时对象会调用析构函数。
*/
解决方法:
Person p; p = 1000; 修改为:Person p = 1000; //把单参数构造函数的复制语句,改为初始化语句。
3. 函数返回对象产生临时变量
Person test(Person & p)
{
Person p1; //这里会调用无参构造函数和结束的一次析构函数
p1.m_age = p.m_age;
return p1; //这里会多调用一次临时拷贝和析构函数
}
int main()
{
Person p;
test(p);
return 0;
}
/**
无参构造函数!
无参构造函数!
拷贝构造函数! 多余的
析构函数 * 3
*/
原因:以值的方式返回时候,就会多调用一次拷贝构造和析构函数;
解决方法:直接返回临时对象
Person test(Person &p)
{
return Person(p.m_age);//直接返回临时对象
}
类成员初始化方式与成员初始化列表
//构造函数里的成员初始化列表
Date(int year = 2022, int month = 4, int day = 19)
:_year(year) ,_month(month) ,_day(day){
xxx
}
首先初始化类的成员有两种方式,一是在构造函数里面进行赋值初始化,二就是使用初始化列表,也就是不在函数体内对成员变量赋值,而是在构造函数的花括号前面使用冒号和初始化列表赋值。
我们一般提倡使用初始化列表的方式,原因主要是性能问题,对于内置类型,使用初始化类表和在构造函数体内初始化差别不是很大。但是对于类类型来说,最好使用初始化列表,使用成员初始化列表会少一次调用默认构造函数的过程,这对于包含数据比较多的类来说,是非常高效的。
class Data
{
public:
// 无参构造函数
Data() {cout<<"This is Data constructor1"<<endl;}
// 拷贝构造函数
Data(const Data&) {cout<<"This is Data constructor2"<<endl;}
// 拷贝赋值构造函数
Data& operator=(const Data&) {cout<<"This is Data constructor3"<<endl;}
};
// 低效写法
class Test1
{
public:
Test1(Data data) {m_data = data;}
private:
Data m_data;
};
// 高效写法
class Test2
{
public:
Test2(Data data) : m_data(data){}
private:
Data m_data;
};
// 更高效写法
class Test3
{
public:
Test3(Data& data) : m_data(data){}
private:
Data m_data;
};
int main()
{
Data a;
cout<<"---------------THIS IS TEST1---------------"<<endl;
Test1 t1(a);
cout<<"---------------THIS IS TEST2---------------"<<endl;
Test2 t2(a);
cout<<"---------------THIS IS TEST3---------------"<<endl;
Test3 t3(a);
return 0;
}
/*
输出结果:
This is Data constructor1
---------------THIS IS TEST1---------------
This is Data constructor2
This is Data constructor1
This is Data constructor3
---------------THIS IS TEST2---------------
This is Data constructor2
This is Data constructor2
---------------THIS IS TEST3---------------
This is Data constructor2
(1)对于TEST1,没有使用列表初始化,所以其私有变量m_data是通过调用Data()定义的,所以会出现“This is Data constructor1”,而“This is Data constructor2”是在发生在参数传递调用的拷贝构造,最后“m_data = data”会发生拷贝赋值,从而调用“This is Data constructor3”
(2)对于TEST2,“This is Data constructor2”也是在发生在参数传递调用的拷贝构造,而另外一次调用是发生在列表初始化“Test2(Data data) : m_data(data){}”
(3)对于TEST3,则是TEST3的构造函数中参数使用了引用,就避免了参数传递而调用的拷贝构造,所以只有一次调用拷贝构造是发生在列表初始化的时候
*/
注意点:
- 构造函数体内可以多次赋值,但初始化列表能只能初始化一次,多次初始化会报错。
- 成员变量初始化的顺序就是成员变量在类中的声明次序,与初始化列表中的先后次序无关。
必须要使用初始化列表的情况:
主要有三种情况:1.const成员变量、2.引用成员变量、3.没有默认构造函数的自定义类型成员,这几种情况只能在初始化列表进行初始化
原因:初始化列表是对象的成员变量定义的地方,对于const成员变量、引用成员变量和没有默认构造的自定义类型成员变量来说,它们和内置类型成员变量是不同的,它们不能先定义再初始化,必须在定义时就初始化,所以就必须在初始化列表内初始化。
为什么静态成员函数不能访问非静态成员
- 静态成员函数只属于类本身,随着类的加载而存在,不属于任何对象,是独立存在的。而非静态成员在实例化对象之后才存在,所以静态成员函数产生在前,非静态成员函数产生在后,故不能访问。
- 静态成员函数与非静态成员函数的根本区别是:非静态成员函数有this指针,静态成员函数并不属于某一对象,它与任何对象都无关,所以静态成员函数没有this指针。由此决定了静态成员函数不能访问本类中的非静态成员。
面向对象
我理解面向对象是一种编程风格,它的基本思想就是万物皆对象,把所有事物抽象为一个个的对象,这些对象具有自己的属性和操作这些属性的方法,对同类对象抽取出他们的共性就可以形成类。是一种自下向上的设计风格,先设计组件再完成拼装,强调的是事件的主体和角色。而与之对应的是面向过程设计,面向过程是自顶向下的设计风格,先定好框架,再实现里面的具体内容,强调事件的流程和顺序。面向对象设计适用于大型的复杂系统,可以设计出搞内聚低耦合和易于维护的系统。
面向对象设计风格最重要的是有三大特性,分别是封装,继承和多态。首先封装和继承的最主要目的就是代码重用,而多态则是接口重用。其中封装的意思是把实现细节隐藏起来,向外提供访问的方式,比如现在java开发中常用的把类里的属性private私有化,然后向外提供可以调用get和set方法,这样不仅能使代码模块化还提高了数据的安全性。而继承是指事物之间的所属关系,一个类可以通过继承来获得被继承类的属性和方法,让某种类型对象获得另一个类型对象的属性和方法。
它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。最后是多态,多态是指一个类实例的相同方法在不同情形有不同表现形式,实现多态有两种主要方式,重写和重载。重写是子类对父类中允许访问的方法进行重新编写, 它的返回值和形参都不能改变。也就是它的外壳不能变,但内容核心可以重写!而重载是指在一个类里面,它的参数要不同,但返回类型可以相同也可以不同的实现过程。简单一句话:允许将子类类型的指针赋值给父类类型的指针.
虚函数与纯虚函数
C++中的虚函数的作用主要是实现了多态的机制。关于多态,简而言之就是用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数
首先需要讲一下为什么需要虚函数和纯虚函数
我们都知道C++与C语言最大的不同就是C++有了面向对象编程的思想,而多态是面向对象思想的一个重要特性。通过虚函数和纯虚函数的特点,当我们父类指针指向子类对象的时候,实际调用的函数会动态绑定指针所引用对象的实际类型,这就实现了多态,就会让代码更易于扩展和维护。
具体来说
在父类中声明为virtual并且在一个或多个派生类中重新定义的成员函数叫做虚函数。为什么叫虚函数呢,因为在编写代码的时候并不能确定被调用的是父类的函数还是某一个子类的函数,所以称它为’虚函数’。而纯虚函数是在父类中声明的虚函数,我理解他就是一个类似于接口的抽象概念,它在父类中没有定义,但要求所有子类都要定义自己的实现方法。在父类中实现纯虚函数的方法是在函数原型后加"=0"。
虚函数表
C++ 通过虚函数表,实现了虚函数与对象的动态绑定,虚函数表是一个指针数组,它里面每个元素对应一个虚函数的函数指针。虚表是属于整个类的,所以一个类只需要一个虚表,同类的所有对象都会使用这一个虚表。
虚函数指针的赋值发生在编译器的编译阶段,也就是说在代码的编译阶段,虚表就可以构造出来了。
需要指出的是,普通的函数即非虚函数,其调用并不需要经过虚表,所以虚表的元素并不包括普通函数的函数指针。
虚表可以继承,如果子类没有重写虚函数,那么子类虚表中还会有父类虚函数函数的地址,只不过指向父类。如果重写了虚函数,那虚函数地址就会改变并且指向自己。子类虚表里的的虚函数地址排列顺序和父类中一致,如果自己还有虚函数就加到后面。
这样指向派生类的基类指针在运行时,就可以根据派生类对虚函数重写情况动态的进行调用,从而实现多态性。
虚函数表类似于类中静态成员变量.静态成员变量也是全局共享,大小确定,因此最有可能存在全局数据区。
为什么析构函数是虚函数
首先,析构函数的主要工作是释放构造函数开辟的内存空间和资源。而我们使用虚函数一般是用来父类指针指向子类对象,也就是为了实现多态性。
如果删除父类的指针,编译器就会调用这个指针指向的子类析构函数,而子类的析构函数又自动调用父类的析构,这样整个子类的对象完全被释放。
而如果析构函数不被声明成虚函数,那么在删除基类指针时,编译器只会调用基类的析构函数而不调用派生类析构函数,这样就会造成派生类对象析构不完全,造成内存泄漏。所以一般情况下基类析构函数要定义为虚函数。
为什么默认的析构函数不是虚函数
因为,当类中有虚函数时,类会生成虚函数表和虚表指针。这样就会占用额外的内存,当们定义的类并不需要被其他类继承时,这种内存开销无疑是浪费的。
为什么构造函数不能是虚函数
虚函数的调用需要虚函数表指针,而该指针存放在对象的内存空间中;如果把构造函数声明为虚函数,那么由于对象还未创建,还没有内存空间,更没有虚函数表地址用来调用虚函数——构造函数了。
构造或者析构函数能否设置为内联函数
将构造函数和析构函数声明为inline没有什么意义,编译器并不会真的对声明为内联的构造和析构进行内联操作,因为编译器会在构造和析构函数中添加申请和释放内存的额外操作,这就让构造函数和析构函数并不像看上去的那么精简。
而内联是可以修饰虚函数的,但是当虚函数表现多态性的时候不能内联。
内联是在编译阶段把用到内联函数的地方进行函数体的直接插入,所以如果虚函数表现为多态性时不能内联,因为在运行期,编译器不知道运行期调用哪个代码。
什么情况会自动生成默认构造函数?
当没有显示定义默认构造函数,编译器会自动生成默认构造函数
如果派生类没有显式定义构造函数,并且基类有默认构造函数,那编译器会自动生成派生类的默认构造函数,并自动调用基类的默认构造函数来初始化基类部分。
什么情况下会合成构造函数?
默认构造函数:如果没有显式定义任何构造函数(包括默认构造函数),编译器会自动生成默认构造函数。默认构造函数用于创建对象时的初始化。
拷贝构造函数:如果没有显式定义拷贝构造函数,并且需要进行对象的拷贝操作(例如,将对象作为参数传递给函数、返回对象等),编译器会自动生成拷贝构造函数。拷贝构造函数用于在创建对象副本时进行成员变量的复制。
移动构造函数:如果没有显式定义移动构造函数,并且需要进行对象的移动操作(例如,将对象转移到另一个对象、从函数返回右值引用等),编译器会自动生成移动构造函数。移动构造函数用于在创建对象副本时执行高效的资源转移操作。
拷贝赋值运算符:如果没有显式定义拷贝赋值运算符(即重载赋值操作符=),并且需要进行对象的赋值操作,编译器会自动生成拷贝赋值运算符。拷贝赋值运算符用于将一个对象的值复制给另一个对象。
移动赋值运算符:如果没有显式定义移动赋值运算符(即重载赋值操作符=),并且需要进行对象的移动赋值操作,编译器会自动生成移动赋值运算符。移动赋值运算符用于将一个对象的值移动给另一个对象。
抽象基类为什么不能创建对象?
抽象基类指的是带有纯虚函数的类为抽象类,抽象类是不能实例化对象的。它只能作为基类来使用,它的纯虚函数需要由子类实现。如果子类中没有重新定义纯虚函数,而只是继承,那这个子类仍然还是一个抽象类。如果派生类中给出了基类纯虚函数的实现,则该派生类就不再是抽象类了,它是一个可以建立对象的具体的类。在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔 雀等子类,但动物本身生成对象明显不合常理。
a.编译时多态性:通过重载函数实现 b.运行时多态性:通过虚函数实现
构造函数和析构函数调用虚函数
在C++里面不提倡在构造函数和析构函数中调用虚函数;
构造函数调虚函数
因为父类对象会在子类之前进行构造,这个时候子类部分的数据成员还没有初始化,所以调用子类的虚函数时是不安全的,导致C++不会进行动态联编;
析构函数调虚函数
析构函数是用来销毁一个对象的,在销毁一个对象时,会先调用子类的析构,然后再调父类的析构。如果在调用基类的析构函数时,子类对象的数据成员已经销毁,这个时候再调用子类的虚函数没有任何意义。
虚析构函数
总的来说虚析构函数是为了避免内存泄露,当子类中会有指针成员变量时才会用得到。使用虚析构函数在删除指向子类对象的父类指针时,可以调用子类的析构函数达到释放子类中堆内存的目的,从而防止内存泄露。
构造函数与析构函数抛异常
最好都不要。
如果构造函数里抛异常,对象的控制权会转出构造函数之外,对象的析构函数就不会执行,这就可能导致资源泄露的问题
析构函数一般是用来释放资源的,如果析构里抛了异常,在异常点之后的释放资源动作就不会被执行,然后造成资源泄露的问题。
构造函数关键字
default
default关键字可以显式要求编译器生成合成构造函数,防止在调用时相关构造函数类型没有定义而报错
delete
delete关键字可以删除构造函数、赋值运算符函数等,这样在使用的时候会得到友善的提示
=0
在基类中实现纯虚函数的方法是在函数原型后加“=0”
什么是继承
继承是面向对象最显著的一个特性。 继承是从已有的类中派生出新的类, 新的类能吸收已有类的数据属性和行为,并能扩展新的能力。
继承缺点:
耦合度高,因为继承了父类的东西,所以想分开就很难了,藕断必定丝连。增减的代码维护难度,尤其是多重继承和兄弟关系继承,毕竟家族历史长
虚拟继承
虚拟继承一般用来解决菱形继承的二义性和数据冗余问题。
菱形继承:
class A
{
public:
int a;
};
class B : public A
{
public:
int b;
};
class C : public A
{
public :
int C;
};
class D : public B, public C
{
public:
void func()
{
}
};
如果类D继承自类B1、B2,而类B1、B2又都继承自类A,那么在类D中就会出现两次类A中的变量和函数,有了二义性和数据冗余。二义性通过在前面加限定符可以解决,而数据冗余问题,可以把B1、B2对A的继承定义为虚拟继承,而A就成了虚拟基类。
解决方法
class A
{
public:
int a;
};
class B : virtual public A
{
public:
int b;
};
class C : virtual public A
{
public :
int C;
};
class D : public B, public C
{
public:
void func()
{
}
};
对于普通继承,父类的虚函数和子类的虚函数是共用同一个虚表。而在虚拟继承时,不管是父类还是子类都需要有一个虚表指针来维护自己的虚表,而且还要有一个虚基类指针指向虚基表,里面放着虚基类和本类的偏移量,编译器也就是根据这个偏移量来找到的子类对象中父类成员的存储位置。
这样在出现菱形继承时,可以防止一个派生类中同时出现了两个基类的子对象的情况。也就是说普通多继承会维持着公共父类两份同样的拷贝。而虚拟继承,无论基类被继承多少次,都只会存在一个实体。
尽量避免菱形继承的使用,因为大量使用菱形虚拟继承时,底层多了通过虚基表指针访问虚基表中记录的偏移量,再通过偏移量寻找公共数据的步骤,这会导致程序效率降低 。
final和override
C++中的final关键字主要有两个作用:
1、在类后面使用final,这意味着禁止其他类来继承本类
2、在函数后加上final,这意味着禁止子类来重载本方法
override是C++11中的一个继承控制关键字,使用 override 主要是防止代码中出现意外的继承行为。override明确地表示这个函数是对父类中一个虚函数的重载。比较重要的是,它会检查父类中虚函数和派生类中重载函数的签名不匹配问题。如果签名不匹配,编译器会发出错误信息。就比如可以你本来打算在子类里重载父类一个虚函数,但是你把函数名写错了,如果不加override 那编译器就以为你这是故意新写了一个函数,而加了override 之后编译器编译了之后发现不对就会告诉你:你这不是重载,和父类函数对不上,快检查检查。
实现C++的多态
在基类的函数前加上virtual关键字,在派生类中重写该函数,运行时将会根据所指对象的实际类型来调用相应的函数,如果对象类型是派生类,就调用派生类的函数,如果对象类型是基类,就调用基类的函数。
#include <iostream>
using namespace std;
class Base{
public:
virtual void fun(){
cout << " Base::func()" <<endl;
}
};
class Son1 : public Base{
public:
virtual void fun() override{
cout << " Son1::func()" <<endl;
}
};
int main()
{
Base* base = new Son1;
base->fun(); // Son1::func()
delete base;
base = NULL;
return 0;
}
C++中的重载、重写(覆盖)和隐藏的区别
重载(overload)
重载指的是在同一范围中,两个函数的函数名相同,但参数列表不同就发生了重载。
注意点:
1.重载和返回值类型无关。
2.重载功能与函数成员是否是虚函数无关。
重写(override)
重写发生于父类与派生类之中,在派生类中覆盖父类里的同名函数,所谓重写就是重写函数体。
注意点:
1.父类函数必须为虚函数。
2.必须要与父类的虚函数有相同参数列表与返回值类型
重写与重载区别:
重载是不同函数之间的水平关系(同一范围);而重写是父类和子类之间的垂直关系(不同类)
重载要求参数列表不同,与返回值类型无关;而重写要求参数列表相同,返回值类型也相同;
重载根据参数列表的对应关系来选择函数体;而重写根据对象类型来选择函数体;
隐藏(hide)
函数隐藏一般有两种表现形式
第一种
子类与父类是同名函数,但参数列表不同,子类函数会隐藏父类函数。此时和返回值类型、父类函数是否为虚函数都无关。
这个隐藏形式和重载的区别在于两个函数不在同一个类中。
第二种
子类和父类是同名函数,且参数列表相同,但父类函数不是虚函数,子类函数会隐藏父类函数。
这个隐藏形式和重写的区别在于父类函数是否是虚函数。
public、protect、private 与继承
public:可以被任意对象访问;
protected:可以被类的成员函数访问,可以被子类访问;
private:只能被类的成员函数访问;
访问权限 | 外部 | 子类 | 内部 |
---|---|---|---|
public | ✔ | ✔ | ✔ |
protected | ❌ | ✔ | ✔ |
private | ❌ | ❌ | ✔ |
继承
继承 | 父类public | 父类protected | 父类private |
---|---|---|---|
public继承 | public | protected | 不可见 |
protected继承【1】 | protected | protected | 不可见 |
private继承【2】 | private | private | 不可见 |
其中:【1】情况时:继承后父类成员只能被它的子类成员函数/友元函数访问
【2】情况时:继承后父类成员并不被它的子类的子类所访问
如果想将某个类用作基类,为什么该类必须定义而非声明?
派生类中包含并且可以使用它从基类继承而来的成员,为了使用这些成员,派生类必须知道他们是什么。
所以必须定义而非声明。
继承机制中对象之间如何转换?指针和引用之间如何转换?
向上类型转换
将派生类指针或引用转换为基类的指针或引用被称为向上类型转换,向上类型转换会自动进行,而且向上类型转换是安全的。
向下类型转换
将基类指针或引用转换为派生类指针或引用被称为向下类型转换,向下类型转换不会自动进行,因为一个基类对应几个派生类,所以向下类型转换时不知道对应哪个派生类,所以在向下类型转换时必须加动态类型识别技术。RTTI技术,用dynamic_cast进行向下类型转换。
指针转引用:把指针用*就可以转换成对象,可以用在引用参数当中。
引用转指针:把引用类型的对象用&取地址就获得指针了。
继承与组合
继承与组合都是面向对象中代码复用的方式。
继承是面向对象三大基本特征之一,继承的意思就是子类继承父类的特征和行为,使得子类也能够具有父类相同的行为,继承强调的是is-a特殊和一般的关系。比如计算机专业的基础课程有计网、数据结构、操作系统等等,然后更细分的专业比如计算机科学与技术、软件工程等等都要继承计算机专业的基础课。
组合是通过对现有对象进行拼装即组合产生新的具有更复杂的功能。组合体现的是整体和部分,强调的是has-a整体和部分的关系。比如车灯类、方向盘类、轮胎类等待一起组合起来一辆完整汽车,这就用到了组合的思想。
继承里父类的内部细节对子类可见,其代码属于白盒式的复用,而组合中,对象之间的内部细节不可见,其代码属于黑盒式复用。
继承在编码过程中就要指定具体的父类,其关系在编译期就确定,而组合的关系一般在运行时确定。继承强调的是is-a的关系,而组合强调的是has-a的关系。
继承支持扩展,易于修改被复用的代码,但它是白盒复用,父类的实现细节暴露给了子类,破坏了封装性。不支持动态拓展,在编译期就决定了父类。子类缺乏独立性,依赖于父类,耦合度较高。
组合黑盒复用,被包括的对象内部实现细节对外不可见,封装性好。支持动态扩展,可在运行时根据具体对象选择不同类型的组合对象(扩展性比继承好)。整体类与局部类之间松耦合,相互独立,但是容易产生过多的对象。
this指针
this 是 C++ 中的一个关键字,也是一个 const 指针,指向当前对象的首地址,通过它可以访问当前对象的所有成员。所谓当前对象,是指正在使用的对象。比如对于book.num();,book 就是当前对象,this 就指向 book。
就像是我们走进了一间屋子,能看见屋里的桌子椅子,但是这间屋子本身的全貌我们看不到了,那这个时候的this就时刻指向的是这间屋子本身。
this 实际上是成员函数的一个形参,在调用成员函数时将对象的地址作为实参传递给 this。不过 this 这个形参是隐式的,它并不出现在代码中,而是在编译阶段由编译器默默地将它添加到参数列表中。
为什么需要this指针
首先成员函数经过编译后会被编译成和对象没有关系的普通函数,除了成员变量,会丢失所有的信息,所以编译时要在成员函数中添加一个额外的参数,把当前对象的首地址传入,以此来关联成员函数和成员变量。这个额外的参数,实际上就是 this,它是成员函数和成员变量关联的桥梁。
注意:
this 只能用在成员函数的内部,通过 this 可以访问类的所有成员。
只有当对象被创建后 this 才有意义,因此不能在 static 成员函数中使用。
this在成员函数的开始前构造,在成员函数的结束后清除。
this指针不存储在对象中,不影响对象大小,且始终指向当前对象。
this指针是“成员函数”的第一个隐藏参数,由编译器自动给出,即T * const this。
delete this
首先我们知道当一个类对象声明时,系统会为它分配内存空间。这个内存空间里面,只有数据成员和虚函数表指针,因为类的成员函数会放在代码段中。在调用成员函数的时候,会隐含传递一个this指针,让成员函数知道当前是哪个对象在调用它。
1.delete this 用在成员函数里
如果我们使用了delete this,那肯定这个类对象的内存空间就被释放了,也就是说数据成员和虚函数表指针都找不到了。这时我们的调用一旦涉及到this指针,比如操作数据成员,调用虚函数等,就会出现不可预期的错误。这个不可预期指的是你要获取数据成员,但可能得到的是一串未初始化的随机数,访问虚函数表,可能指针无效造成系统崩溃。
2.delete this 用在析构函数里
delete this会去调用当前对象的析构函数,而析构函数中又调用delete this,形成无限递归,造成堆栈溢出,最后可能导致系统崩溃。
异常处理
异常处理也就是针对于程序里的错误处理而言的,程序的错误大致可以分为三种,分别是语法错误、逻辑错误还有运行时错误:
- 语法错误在编译和链接阶段就能发现,只有完全符合语法规则的代码才能生成可执行程序,所以编译器可以帮助我们发现这种错误我们来解决。
- 逻辑错误是说我们编写的代码思路有问题,不能够完成我们预期的功能性,这种错误可以通过调试来解决。
- 运行时错误是指程序在运行期间发生的错误,例如除数为 0、数组越界、文件不存在等。
C++ 异常机制就是为解决运行时错误而引入的。
发生了运行时错误之后,如果我们放任不管,那么系统就会执行默认的操作,终止程序的运行,也就是我们常说的程序崩溃。而C++ 提供了异常机制,让我们能够捕获运行时错误,给程序一次“起死回生”的机会,或者至少告诉用户发生了什么再终止程序。
异常处理的基本思想是:让一个函数在发现了自己无法处理的错误时抛出一个异常,然后它的调用者能够处理这个问题。也就是《C++ primer》里面说的:把问题检测和问题处理相分离。
C++ 异常处理机制会涉及 try、catch、throw 三个关键字。
程序的执行流程是先执行try包裹的语句块,如果执行过程中没有异常发生,则不会进入任何catch包裹的语句块,如果发生异常,则使用throw进行异常抛出,再由catch进行捕获
什么是模板
模板是c++的一种特性,它可以让类或者函数在对应不同的类型的时候正常工作,而不用为每一种类型分别写一份代码。也就是用模板可以编写出和类型无关的通用代码,是代码复用的一种手段。
模板分为函数模板和类模板两类。
模板的声明只是提供了一个函数或者类的语法框架,实例化是指从模板构建出一个真正的函数或者类的过程。用具体类型代替模板参数的过程叫做实例化;从而产生一个模板实例。
所以模板类在没有调用之前是不会生成代码的。
由于编译器并不会直接编译模板本身,所以模板的定义通常放在头文件中
函数模板
// template是声明模板的关键字,告诉编译器开始泛型编程
template<typename T> // typename 是用来定义模板参数的关键字,也可以使用 class替代
void Swap( T& left, T& right)
{
T temp = left;
left = right;
right = temp;
}
函数模板不允许自动类型转化
C++编译器优先考虑普通函数
函数模板有两种实例化方法,分别是显示实例化和隐式实例化
显示实例化:在代码中明确指定要针对哪种类型进行实例化
隐式实例化:根据具体的传入参数,推导出模板参数类型
#include <iostream>
using namespace std;
template<typename T>
T add(T t1, T t2)
{
return t1 + t2;
}
template int add<int>(int t1, int t2);//显示实例化为int类型
int main()
{
cout << add<int>(12, 'A') << endl;//函数模板调用:A-65
cout << add(1.4, 5.7) << endl;//隐式实例化:自动实参推演
cout << add<int>(23.4, 44.2) << endl;//显示声明可省,结果为67
return 0;
}
类模板
template<class T1, class T2, ..., class Tn>
class 类模板名
{
// 类内成员定义
};
c++ 有两种类型的模板
1:函数模板(function tempalte):使用泛型参数的函数(function with generic parameters)
2:类模板(class template):使用泛型参数的类(class with generic parameters)
模板特例化
特例化也就是特殊实例化,把函数模板或类模板实例化成特殊的定制类型。
函数模板特例化
使用关键字template后跟一个空尖括号对<>
template<typename T>
bool compare(T param1,T param2)
{
return param1 < param2;
}
如果需要比较两个指针类型的就不符合了
template<>
bool compare(const char * str, const char * str2)
{
cout << (str < str2) << endl;
return strcmp(str, str2) < 0;
}
这里的特例化模板函数并不是模板重载,它一样是匹配原模板,只不过在T为const char *时使用特例compare。
类模板特例化
unique_ptr源码中的一部分
template<class T,bool b> //原模板
class My
{
public:
void fun()
{
cout << "original" << endl;
}
};
template<class T> //特例化第二个参数为true时相应的模板
class My < T, true >
{
public:
void fun()
{
cout << "special" << endl;
}
};
My<double,false> m;
m.fun(); //使用原模板 打印“original”
My<int,true>n;
n.fun(); //使用特例化模板 打印“speacial”
模板声明定义为什么要放在.h文件中
在分离式编译的环境下,编译器编译某一个.cpp文件时并不知道另一个.cpp文件的存在,当遇到未决符号的时候它就等着连接器找到对应的然后填充过来。那这种模式在没有模板的情况下运行良好,但遇到模板时就不行了,因为模板仅在需要的时候才会实例化出来。所以,当编译器只看到模板的声明时,那它就不能实例化这个模板,只能创建一个具有外部连接的符号,然后等着连接器能够把符号对应的地址决议出来。
而C++标准明确表示,当一个模板不被用到的时侯它就不该被实例化出来,所以,实现该模板的.cpp文件不会创造出对应的实例,这样连接器在整个工程的.obj里就找不到对应模板实例的二进制代码,就会报连接错误。所以为了容易使用,几乎总是在头文件中放置全部的模板声明和定义。
“hello world”从开始到打印到屏幕上的全过程?
1.用户通过键盘输入告诉操作系统执行HelloWorld程序
2.操作系统找到代码在可执行文件中的位置和对应的磁盘位置。
3.操作系统创建一个新进程,把HelloWorld可执行文件映射到这个进程结构,给helloworld程序设置cpu的上下文环境。
4.操作系统分配物理内存,把代码从磁盘读入内存,然后执行helloworld程序。
5.操作系统找到输出设备的进程,把这个字符串发给进程。设备进程把字符串转换为像素,然后把像素转换为显示器可以接收的一组控制信号,显示器解释信号,显示HelloWorld。
静态函数能定义为虚函数吗?常函数呢?
static成员不属于任何类对象或类实例,所以即使给此函数加上virutal也是没有任何意义的。