接上文
三、类的相关内容
1.什么是虚函数?什么是纯虚函数?
虚函数:被 virtual 关键字修饰的成员函数,就是虚函数。
纯虚函数:纯虚函数在类中声明时,加上 =0;
含有纯虚函数的类称为抽象类(只要含有纯虚函数这个类就是抽象类),类中只有接口,没有具体的实现方法;
继承纯虚函数的派生类,不能实例化对象。
说明:抽象类对象不能作为函数的参数,不能创建对象,不能作为函数返回类型;
可以声明抽象类指针,可以声明抽象类的引用;子类必须继承父类的纯虚函数,并全部实现后,才能创建子类的对象。
2.虚函数和纯虚函数的区别?
虚函数和纯虚函数可以出现在同一个类中,该类称为抽象基类。(含有纯虚函数的类称为抽象基类)
使用方式不同:虚函数可以直接使用,纯虚函数必须在派生类中实现后才能使用;
定义形式不同:虚函数在定义时在普通函数的基础上加上 virtual 关键字,纯虚函数定义时除了加上virtual 关键字还需要加上 =0;
虚函数必须实现,否则编译器会报错;
对于实现纯虚函数的派生类,该纯虚函数在派生类中被称为虚函数,虚函数和纯虚函数都可以在派生类中重写;
析构函数最好定义为虚函数,特别是对于含有继承关系的类;析构函数可以定义为纯虚函数,此时,其所在的类为抽象基类,不能创建实例化对象。
3.虚函数的实现机制
实现机制:虚函数通过虚函数表来实现。
虚函数的地址保存在虚函数表中,在类的对象所在的内存空间中,保存了指向虚函数表的指针(称为“虚表指针”),通过虚表指针可以找到类对应的虚函数表。虚函数表解决了基类和派生类的继承问题和类中成员函数的覆盖问题,当用基类的指针来操作一个派生类的时候,这张虚函数表就指明了实际应该调用的函数。
虚函数表相关知识点:
虚函数表存放的内容:类的虚函数的地址。
虚函数表建立的时间:编译阶段,即程序的编译过程中会将虚函数的地址放在虚函数表中。
虚表指针保存的位置:虚表指针存放在对象的内存空间中最前面的位置,这是为了保证正确取到虚函数的偏移量。
4 如何禁止构造函数的使用?
为类的构造函数增加 = delete 修饰符,可以达到虽然声明了构造函数但禁止使用的目的。
#include <iostream>
using namespace std;
class A {
public:
int var1, var2;
A(){
var1 = 10;
var2 = 20;
}
A(int tmp1, int tmp2) = delete;
};
int main()
{
A ex1;
A ex2(12,13); // error: use of deleted function 'A::A(int, int)'
return 0;
}
5.什么是类的默认构造函数?
默认构造函数:未提供任何实参,来控制默认初始化过程的构造函数称为默认构造函数。
6 构造函数、析构函数是否需要定义成虚函数?为什么?
构造函数一般不定义为虚函数,原因:
- 从存储空间的角度考虑:构造函数是在实例化对象的时候进行调用,如果此时将构造函数定义成虚函数,需要通过访问该对象所在的内存空间才能进行虚函数的调用(因为需要通过指向虚函数表的指针调用虚函数表,虽然虚函数表在编译时就有了,但是没有虚函数的指针,虚函数的指针只有在创建了对象才有),但是此时该对象还未创建,便无法进行虚函数的调用。所以构造函数不能定义成虚函数。
- 从使用的角度考虑:虚函数是基类的指针指向派生类的对象时,通过该指针实现对派生类的虚函数的调用,构造函数是在创建对象时自动调用的。
- 从实现上考虑:虚表指针是在创建对象之后才有的,因此不能定义成虚函数。
- 从类型上考虑:在创建对象时需要明确其类型。
析构函数一般定义成虚函数,原因:
析构函数定义成虚函数是为了防止内存泄漏,因为当基类的指针或者引用指向或绑定到派生类的对象时,如果未将基类的析构函数定义成虚函数,会调用基类的析构函数,那么只能将基类的成员所占的空间释放掉,派生类中特有的就会无法释放内存空间导致内存泄漏。
7 如何避免拷贝?
最直观的想法是:将类的拷贝构造函数和赋值构造函数声明为私有 private,但对于类的成员函数和友元函数依然可以调用,达不到完全禁止类的对象被拷贝的目的,而且程序会出现错误,因为未对函数进行定义。
解决方法:声明一个基类,具体做法如下。
定义一个基类,将其中的拷贝构造函数和赋值构造函数声明为私有 private,派生类以私有 private 的方式继承基类
class Uncopyable
{
public:
Uncopyable() {}
~Uncopyable() {}
private:
Uncopyable(const Uncopyable &); // 拷贝构造函数
Uncopyable &operator=(const Uncopyable &); // 赋值构造函数
};
class A : private Uncopyable // 注意继承方式
{
};
简单解释:能够保证,在派生类 A 的成员函数和友元函数中无法进行拷贝操作,因为无法调用基类 Uncopyable 的拷贝构造函数或赋值构造函数。同样,在类的外部也无法进行拷贝操作。
方法二:C++ 11 可以使用弃置函数delete关键字
class noncopyable {
protected:
noncopyable() = default;
~noncopyable() = default;
public:
noncopyable(const noncopyable&) = delete;
noncopyable& operator=(const noncopyable&) = delete;
};
class foo : private noncopyable {
};
8 如何减少构造函数开销?
在构造函数中使用类初始化列表,会减少调用默认的构造函数产生的开销,具体原因可以参考本章“为什么用成员初始化列表会快些?”这个问题。
class A
{
private:
int val;
public:
A()
{
cout << "A()" << endl;
}
A(int tmp)
{
val = tmp;
cout << "A(int " << val << ")" << endl;
}
};
class Test1
{
private:
A ex;
public:
Test1() : ex(1) // 成员列表初始化方式
{
}
};
9 多重继承时会出现什么状况?如何解决?
多重继承(多继承):是指从多个直接基类中产生派生类。
多重继承容易出现的问题:命名冲突和数据冗余问题。
#include <iostream>
using namespace std;
// 间接基类
class Base1
{
public:
int var1;
};
// 直接基类
class Base2 : public Base1
{
public:
int var2;
};
// 直接基类
class Base3 : public Base1
{
public:
int var3;
};
// 派生类
class Derive : public Base2, public Base3
{
public:
void set_var1(int tmp) { var1 = tmp; } // error: reference to 'var1' is ambiguous. 命名冲突
void set_var2(int tmp) { var2 = tmp; }
void set_var3(int tmp) { var3 = tmp; }
void set_var4(int tmp) { var4 = tmp; }
private:
int var4;
};
int main()
{
Derive d;
return 0;
}
上述程序的继承关系如下:(菱形继承)
上述代码中存的问题:
对于派生类 Derive 上述代码中存在直接继承关系和间接继承关系
直接继承:Base2 、Base3
间接继承:Base1
对于派生类中继承的的成员变量 var1 ,从继承关系来看,实际上保存了两份,一份是来自基类 Base2,一份来自基类 Base3。因此,出现了命名冲突。
解决方法 1: 声明出现冲突的成员变量来源于哪个类
解决方法 2: 虚继承
使用虚继承的目的:保证存在命名冲突的成员变量在派生类中只保留一份,即使间接基类中的成员在派生类中只保留一份。在菱形继承关系中,间接基类称为虚基类,直接基类和间接基类之间的继承关系称为虚继承。
实现方式:在继承方式前面加上 virtual 关键字。
10 空类占多少字节?C++ 编译器会给一个空类自动生成哪些函数?
空类声明时编译器不会生成任何成员函数:对于空类,声明编译器不会生成任何的成员函数,只会生成 1 个字节的占位符 。
#include <iostream>
using namespace std;
class A
{
};
int main()
{
cout << "sizeof(A):" << sizeof(A) << endl; // sizeof(A):1
return 0;
}
空类定义时编译器会生成 6 个成员函数:当空类 A 定义对象时,sizeof(A) 仍是为 1,但编译器会生成 6 个成员函数:缺省的构造函数、拷贝构造函数、析构函数、赋值运算符、两个取址运算符。
#include <iostream>
using namespace std;
/*
class A
{}; 该空类的等价写法如下:
*/
class A
{
public:
A(){}; // 缺省构造函数
A(const A &tmp){}; // 拷贝构造函数
~A(){}; // 析构函数
A &operator=(const A &tmp){}; // 赋值运算符
A *operator&() { return this; }; // 取址运算符
const A *operator&() const { return this; }; // 取址运算符(const 版本)
};
int main()
{
A *p = new A();
cout << "sizeof(A):" << sizeof(A) << endl; // sizeof(A):1
delete p;
return 0;
}
11 为什么拷贝构造函数必须为引用?
避免拷贝构造函数无限制的递归,最终导致栈溢出。
#include <iostream>
using namespace std;
class A
{
private:
int val;
public:
A(int tmp) : val(tmp) // 带参数构造函数
{
cout << "A(int tmp)" << endl;
}
A(const A &tmp) // 拷贝构造函数
{
cout << "A(const A &tmp)" << endl;
val = tmp.val;
}
A &operator=(const A &tmp) // 赋值函数(赋值运算符重载)
{
cout << "A &operator=(const A &tmp)" << endl;
val = tmp.val;
return *this;
}
void fun(A tmp)
{
}
};
int main()
{
A ex1(1);
A ex2(2);
A ex3 = ex1;
ex2 = ex1;
ex2.fun(ex1);
return 0;
}
/*
运行结果:
A(int tmp)
A(int tmp)
A(const A &tmp)
A &operator=(const A &tmp)
A(const A &tmp)
*/
- 说明 1:ex2 = ex1; 和 A ex3 = ex1; 为什么调用的函数不一样?
对象 ex2 已经实例化了,不需要构造,此时只是将 ex1 赋值给 ex2,只会调用赋值函数;但是 ex3 还没有实例化,因此调用的是拷贝构造函数,构造出 ex3,而不是赋值函数,这里涉及到构造函数的隐式调用。
说明 2:如果拷贝构造函数中形参不是引用类型,A ex3 = ex1;会出现什么问题?
构造 ex3,实质上是 ex3.A(ex1);,假如拷贝构造函数参数不是引用类型,那么将使得 ex3.A(ex1); 相当于 ex1 作为函数 A(const A &tmp)的形参,在参数传递时相当于 A tmp = ex1,因为 tmp 没有被初始化,所以在 A tmp = ex1 中继续调用拷贝构造函数,接下来的是构造 tmp,也就是 tmp.A(ex1) ,必然又会有 ex1 作为函数 A(const A tmp); 的形参,在参数传递时相当于即 A tmp = ex1,那么又会触发拷贝构造函数,就这下永远的递归下去。
说明 3:为什么 ex2.fun(ex1); 会调用拷贝构造函数?
- ex1 作为参数传递给 fun 函数, 即 A tmp = ex1;,这个过程会调用拷贝构造函数进行初始化。
12 C++ 类对象的初始化顺序
构造函数调用顺序:
按照派生类继承基类的顺序,即派生列表中声明的顺序,依次调用基类的构造函数;
按照派生类中成员变量的声名顺序,依次调用派生类中成员变量所属类的构造函数;
执行派生类自身的构造函数。
综上可以得出,类对象的初始化顺序:基类构造函数–>派生类成员变量的构造函数–>自身构造函数
注:
基类构造函数的调用顺序与派生类的派生列表中的顺序有关;
成员变量的初始化顺序与声明顺序有关;
析构顺序和构造顺序相反。
13 如何禁止一个类被实例化?
方法一:在类中定义一个纯虚函数,使该类成为抽象基类,因为不能创建抽象基类的实例化对象;
方法二:将类的构造函数声明为私有 private
14 为什么用成员初始化列表会快一些?
说明:数据类型可分为内置类型和用户自定义类型(类类型),对于用户自定义类型,利用成员初始化列表效率高。
原因:用户自定义类型如果使用类初始化列表,直接调用该成员变量对应的构造函数即完成初始化;如果在构造函数中初始化,因为 C++ 规定,对象的成员变量的初始化动作发生在进入构造函数本体之前,那么在执行构造函数的函数体之前首先调用默认的构造函数为成员变量设初值,在进入函数体之后,调用该成员变量对应的构造函数。因此,使用列表初始化会减少调用默认的构造函数的过程,效率高。
15 实例化一个对象需要哪几个阶段
1.分配空间
创建类对象首先要为该对象分配内存空间。不同的对象,为其分配空间的时机未必相同。全局对象、静态对象、分配在栈区域内的对象,在编译阶段进行内存分配;
存储在堆空间的对象,是在运行阶段进行内存分配。
2.初始化
首先明确一点:初始化不同于赋值。初始化发生在赋值之前,初始化随对象的创建而进行,而赋值是在对象创建好后,为其赋上相应的值。这一点可以联想下上一个问题中提到:初始化列表先于构造函数体内的代码执行,初始化列表执行的是数据成员的初始化过程,这个可以从成员对象的构造函数被调用看的出来。
3.赋值
对象初始化完成后,可以对其进行赋值。对于一个类的对象,其成员变量的赋值过程发生在类的构造函数的函数体中。当执行完该函数体,也就意味着类对象的实例化过程完成了。(总结:构造函数实现了对象的初始化和赋值两个过程,对象的初始化是通过初始化列表来完成,而对象的赋值则才是通过构造函数的函数体来实现。)
注:对于拥有虚函数的类的对象,还需要给虚表指针赋值。
没有继承关系的类,分配完内存后,首先给虚表指针赋值,然后再列表初始化以及执行构造函数的函数体,即上述中的初始化和赋值操作。
有继承关系的类,分配内存之后,首先进行基类的构造过程,然后给该派生类的虚表指针赋值,最后再列表初始化以及执行构造函数的函数体,即上述中的初始化和赋值操作。
16 友元函数的作用及使用场景
作用:友元提供了不同类的成员函数之间、类的成员函数与一般函数之间进行数据共享的机制。通过友元,一个不同函数或另一个类中的成员函数可以访问类中的私有成员和保护成员。
使用场景:普通函数定义为友元函数,使普通函数能够访问类的私有成员。
#include <iostream>
using namespace std;
class A
{
friend ostream &operator<<(ostream &_cout, const A &tmp); // 声明为类的友元函数
public:
A(int tmp) : var(tmp)
{
}
private:
int var;
};
ostream &operator<<(ostream &_cout, const A &tmp)
{
_cout << tmp.var;
return _cout;
}
int main()
{
A ex(4);
cout << ex << endl; // 4
return 0;
}
友元类:类之间共享数据。
#include <iostream>
using namespace std;
class A
{
friend class B;
public:
A() : var(10){}
A(int tmp) : var(tmp) {}
void fun()
{
cout << "fun():" << var << endl;
}
private:
int var;
};
class B
{
public:
B() {}
void fun()
{
cout << "fun():" << ex.var << endl; // 访问类 A 中的私有成员
}
private:
A ex;
};
int main()
{
B ex;
ex.fun(); // fun():10
return 0;
}
17 静态绑定和动态绑定是怎么实现的?**
静态类型和动态类型:
静态类型:变量在声明时的类型,是在编译阶段确定的。静态类型不能更改。
动态类型:目前所指对象的类型,是在运行阶段确定的。动态类型可以更改。
静态绑定和动态绑定:
静态绑定是指程序在 编译阶段 确定对象的类型(静态类型)。
动态绑定是指程序在 运行阶段 确定对象的类型(动态类型)。
静态绑定和动态绑定的区别:
发生的时期不同:
对象的静态类型不能更改,动态类型可以更改。
注:对于类的成员函数,只有虚函数是动态绑定,其他都是静态绑定。
#include <iostream>
using namespace std;
class Base
{
public:
virtual void fun() { cout << "Base::fun()" << endl;
}
};
class Derive : public Base
{
public:
void fun() { cout << "Derive::fun()";
}
};
int main()
{
Base *p = new Derive(); // p 的静态类型是 Base*,动态类型是 Derive*
p->fun(); // fun 是虚函数,运行阶段进行动态绑定
return 0;
}
/*
运行结果:
Derive::fun()
*/
18 深拷贝和浅拷贝的区别 ***
如果一个类拥有资源,该类的对象进行复制时,如果资源重新分配,就是深拷贝,否则就是浅拷贝。
深拷贝:该对象和原对象占用不同的内存空间,既拷贝存储在栈空间中的内容,又拷贝存储在堆空间中的内容。
浅拷贝:该对象和原对象占用同一块内存空间,仅拷贝类中位于栈空间中的内容。
当类的成员变量中有指针变量时,最好使用深拷贝。因为当两个对象指向同一块内存空间,如果使用浅拷贝,当其中一个对象的删除后,该块内存空间就会被释放,另外一个对象指向的就是垃圾内存。
#include <iostream>
using namespace std;
class Test
{
private:
int *p;
public:
Test(int tmp)
{
this->p = new int(tmp);
cout << "Test(int tmp)" << endl;
}
~Test()
{
if (p != NULL)
{
delete p;
}
cout << "~Test()" << endl;
}
};
int main()
{
Test ex1(10);
Test ex2 = ex1;
return 0;
}
/*
运行结果:
Test(int tmp)
~Test()
*/
说明:上述代码中,类对象 ex1、ex2 实际上是指向同一块内存空间,对象析构时,ex2 先将内存释放了一次,之后 析构对象 ex1 时又将这块已经被释放过的内存再释放一次。对同一块内存空间释放了两次,会导致程序崩溃。
深拷贝实例:
#include <iostream>
using namespace std;
class Test
{
private:
int *p;
public:
Test(int tmp)
{
p = new int(tmp);
cout << "Test(int tmp)" << endl;
}
~Test()
{
if (p != NULL)
{
delete p;
}
cout << "~Test()" << endl;
}
Test(const Test &tmp) // 定义拷贝构造函数
{
p = new int(*tmp.p);
cout << "Test(const Test &tmp)" << endl;
}
};
int main()
{
Test ex1(10);
Test ex2 = ex1;
return 0;
}
/*
Test(int tmp)
Test(const Test &tmp)
~Test()
~Test()
*/
深拷贝的问题其实可以改进,不用在堆区重新开辟空间,放置数据啥的。完全可以使用右值引用,将深拷贝问题改进成浅拷贝问题。
#include <iostream>
using namespace std;
class A
{
public:
A() :m_ptr(new int(0)) { cout << "构造函数的实现" << endl; }
A(const A& a) :m_ptr(new int(*a.m_ptr)) { cout << "拷贝构造函数的实现" << endl; }
A(A&& a):m_ptr(a.m_ptr)
{
a.m_ptr = nullptr;
cout << "右值引用的拷贝构造函数" << endl;
}
~A()
{
delete m_ptr;
cout << "析构函数的调用" << endl;
}
private:
int* m_ptr;
};
A geta()
{
A b;
return b;
}
int main()
{
A b;
A a = std::move(b); //因为b是一个左值,可以使用move函数将左值b转换成临时右值,然后将b里面的内容转交给a
A c = geta();//返回的本身就是一个临时左值,然后就可以直接将临时对象里面的内容转交给c;
return 0;
}
19 编译时多态和运行时多态的区别*
编译时多态:在程序编译过程中出现,发生在模板和函数重载中(泛型编程)。
运行时多态:在程序运行过程中出现,发生在继承体系中,是指通过基类的指针或引用访问派生类中的虚函数。
编译时多态和运行时多态的区别:
时期不同:编译时多态发生在程序编译过程中,运行时多态发生在程序的运行过程中;
实现方式不同:编译时多态运用泛型编程来实现,运行时多态借助虚函数来实现。
20 实现一个类成员函数,要求不允许修改类的成员变量
如果想达到一个类的成员函数不能修改类的成员变量,只需用 const 关键字来修饰该函数即可。
该问题本质是考察 const 关键字修饰成员函数的作用,只不过以实例的方式来考察,面试者应熟练掌握 const 关键字的作用。
#include <iostream>
using namespace std;
class A
{
public:
int var1, var2;
A()
{
var1 = 10;
var2 = 20;
}
void fun() const // 不能在 const 修饰的成员函数中修改成员变量的值,除非该成员变量用 mutable 修饰
{
var1 = 100; // error: assignment of member 'A::var1' in read-only object
}
};
int main()
{
A ex1;
return 0;
}
21 如何让类不能被继承?
解决方法一:借助 final 关键字,用该关键字修饰的类不能被继承。
解决方法二:借助友元、虚继承和私有构造函数来实现
22public protected private
第一: private,public,protected的访问范围:
private: 只能由该类中的函数、其友元函数访问,不能被任何其他访问,该类的对象也不能访问.
protected: 可以被该类中的函数、子类的函数、以及其友元函数访问,但不能被该类的对象访问
public: 可以被该类中的函数、子类的函数、其友元函数访问,也可以由该类的对象访问
注:友元函数包括两种:设为友元的全局函数,设为友元类中的成员函数
第二:类的继承后方法属性变化:
使用private继承,父类的所有方法在子类中变为private;
使用protected继承,父类的protected和public方法在子类中变为protected,private方法不变;
使用public继承,父类中的方法属性不发生改变;
23 类都有哪几种构造方式?
默认构造函数 Student();//没有参数
有参构造函数 Student(int num,int age);//有参数
拷贝构造函数 Student(Student&);//形参是本类对象的引用
转换构造函数 Student(int r) ;//形参是其他类型变量,且只有一个形参
24 拷贝构造函数参数中为什么有时候要加const
这是因为当参数为一个临时对象的时候,临时对象是一个右值。而拷贝构造函数的参数中,如果不加const,那么就是一个非常量左值引用(非常量左值是不能引用右值的),加了const之后就是一个常量左值引用,可以引用右值。
25 常量左值引用
常量左值引用是一个“万能”的引用类型可以接受左值,右值,常量左值、常量右值。需要注意的是普通的左值引用是不能接受右值的。
26 关于类模板是否可以定义虚函数
类模板可以定义成虚函数,但是需要注意一些细节。
首先,需要明确的是,类模板本身是一个模板,不能直接定义为虚函数。类模板的实例化才能被定义为虚函数。
其次,当类模板的实例化被定义为虚函数时,只有在实例化后的类的对象才能调用虚函数。因为在编译时,虚函数表是根据类的实例化类型来生成的,而不是根据类模板本身。