c++C++面试知识5——第五章 类相关

Posted 超级无敌陈大佬的跟班

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了c++C++面试知识5——第五章 类相关相关的知识,希望对你有一定的参考价值。

目录

第五章 类相关

5.1 ★虚函数相关知识

5.2 什么是类的默认构造函数?

5.3 ★构造函数、析构函数是否需要定义成虚函数?为什么?

5.4 如何避免拷贝?

5.5 如何减少构造函数开销?

5.6 ★多重继承时会出现什么状况?如何解决?

5.7 空类占多少字节?C++ 编译器会给一个空类自动生成哪些函数?

5.8 ★C++ 类对象的初始化顺序

5.9 如何禁止一个类被实例化?

5.10 为什么用成员初始化列表会快一些?

5.11 实例化一个对象需要哪几个阶段

5.12 友元函数的作用及使用场景

5.13 静态绑定和动态绑定是怎么实现的?

5.14 ★深拷贝和浅拷贝的区别

5.15 编译时多态和运行时多态的区别

5.16 实现一个类成员函数,要求不允许修改类的成员变量?

5.17 如何让类不能被继承?


第五章 类相关

5.1 ★虚函数相关知识

面试高频指数:★★★★★

  • 内容较多,移步到下面链接

https://blog.csdn.net/chen1234520nnn/article/details/117389532

5.2 什么是类的默认构造函数?

面试高频指数:★★★☆☆

默认构造函数:未提供任何实参,来控制默认初始化过程的构造函数称为默认构造函数。

class A{
public:
    A(){ // 类的默认构造函数
        var = 10;
        c = 'q';
    }
    A(int tmp1, int tmp2) = delete;    // 禁止该构造函数使用
    int var;
    char c;
};

int main(){
    A ex;
    cout << ex.c << endl << ex.var << endl;
    return 0;
}
/*
运行结果:
q
10
*/

5.3 ★构造函数、析构函数是否需要定义成虚函数?为什么?

面试高频指数:★★★★☆

构造函数一般不定义为虚函数,原因:

  • 从存储空间的角度考虑:构造函数是在实例化对象的时候进行调用,如果此时将构造函数定义成虚函数,需要通过访问该对象所在的内存空间才能进行虚函数的调用(因为需要通过指向虚函数表的指针调用虚函数表,虽然虚函数表在编译时就有了,但是没有虚函数的指针,虚函数的指针只有在创建了对象才有),但是此时该对象还未创建,便无法进行虚函数的调用。所以构造函数不能定义成虚函数。
  • 从使用的角度考虑:虚函数是基类的指针指向派生类的对象时,通过该指针实现对派生类的虚函数的调用,构造函数是在创建对象时自动调用的。
  • 从实现上考虑:虚函数表是在创建对象之后才有的,因此不能定义成虚函数
  • 从类型上考虑:在创建对象时需要明确其类型。

析构函数一般定义成虚函数,原因:

  • 析构函数定义成虚函数是为了防止内存泄漏,因为当基类的指针或者引用指向或绑定到派生类的对象时,如果未将基类的析构函数定义成虚函数,会调用基类的析构函数,那么只能将基类的成员所占的空间释放掉,派生类中特有的就会无法释放内存空间导致内存泄漏。

5.4 如何避免拷贝?

最直观的想法是:将类的拷贝构造函数和赋值构造函数声明为私有 private,但对于类的成员函数和友元函数依然可以调用,达不到完全禁止类的对象被拷贝的目的,而且程序会出现错误,因为未对函数进行定义。

解决方法:声明一个基类,具体做法如下。

  1. 定义一个基类,将其中的拷贝构造函数和赋值构造函数声明为私有 private
  2. 派生类以私有 private 的方式继承基类

5.5 如何减少构造函数开销?

面试高频指数:★★☆☆☆

在构造函数中使用类初始化列表会减少调用默认的构造函数产生的开销,具体原因可以参考本章“为什么用成员初始化列表会快些?”这个问题。

5.6 ★多重继承时会出现什么状况?如何解决?

面试高频指数:★★★★☆

多重继承(多继承):是指从多个直接基类中产生派生类。

多重继承容易出现的问题:命名冲突和数据冗余问题。

举例:

#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:
    //===var1产生了命名冲突
    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 声明出现冲突的成员变量来源于哪个类.

    void set_var1(int tmp) { Base2::var1 = tmp; } // 这里声明成员变量来源于类 Base2,当然也可以声明来源于类 Base3

解决方法 2 虚继承

使用虚继承的目的:保证存在命名冲突的成员变量在派生类中只保留一份,即使间接基类中的成员在派生类中只保留一份。在菱形继承关系中,间接基类称为虚基类,直接基类和间接基类之间的继承关系称为虚继承。

实现方式:在继承方式前面加上 virtual 关键字。

#include <iostream>
using namespace std;
// 间接基类,即虚基类
class Base1{
public:
    int var1;
};
// 直接基类 
class Base2 : virtual public Base1 // 虚继承{
public:
    int var2;
};
// 直接基类 
class Base3 : virtual public Base1 // 虚继承{
public:
    int var3;
};
// 派生类
class Derive : public Base2, public Base3{
public:
    void set_var1(int tmp) { var1 = tmp; } 
    void set_var2(int tmp) { var2 = tmp; }
    void set_var3(int tmp) { var3 = tmp; }
};

int main(){
    Derive d;
    return 0;
}

类之间的继承关系:

 

5.7 空类占多少字节?C++ 编译器会给一个空类自动生成哪些函数?

面试高频指数:★★★☆☆

空类声明时编译器不会生成任何成员函数:

  • 对于空类,声明编译器不会生成任何的成员函数,只会生成 1 个字节的占位符

空类定义时编译器会生成 6 个成员函数:

  • 当空类 A 定义对象时,sizeof(A) 仍是为 1,但编译器会生成 6 个成员函数:缺省的构造函数、拷贝构造函数、析构函数、赋值运算符、两个取址运算符。

5.8 ★C++ 类对象的初始化顺序

面试高频指数:★★★☆☆

构造函数调用顺序:

  • 按照派生类继承基类的顺序,即派生列表中声明的顺序,依次调用基类的构造函数;
  • 按照派生类中成员变量的声名顺序,依次调用派生类中成员变量所属类的构造函数;
  • 执行派生类自身的构造函数。

综上可以得出,类对象的初始化顺序:基类构造函数–>派生类成员变量的构造函数–>自身构造函数
注:

  • 基类构造函数的调用顺序与派生类的派生列表中的顺序有关;
  • 成员变量的初始化顺序与声明顺序有关;
  • 析构顺序和构造顺序相反。

示例:

#include <iostream>
using namespace std;

class A{
public:
    A() { cout << "A()" << endl; }
    ~A() { cout << "~A()" << endl; }
};

class B{
public:
    B() { cout << "B()" << endl; }
    ~B() { cout << "~B()" << endl; }
};

class Test : public A, public B // 派生列表
{
public:
    Test() { cout << "Test()" << endl; }
    ~Test() { cout << "~Test()" << endl; }

private:
    B ex1;
    A ex2;
};

int main(){
    Test ex;
    return 0;
}
/*
运行结果:
A()
B()
B()
A()
Test()
~Test()
~A()
~B()
~B()
~A()
*/

程序运行结果分析:

  • 首先调用基类 A 和 B 的构造函数,按照派生列表 public A, public B 的顺序构造;
  • 然后调用派生类 Test 的成员变量 ex1 和 ex2 的构造函数,按照派生类中成员变量声明的顺序构造;
  • 最后调用派生类的构造函数;
  • 接下来调用析构函数,和构造函数调用的顺序相反。

5.9 如何禁止一个类被实例化?

面试高频指数:★★☆☆☆

方法一:

  • 在类中定义一个纯虚函数,使该类成为抽象基类,因为不能创建抽象基类的实例化对象;
class A {
public:
    int var1, var2;
    A(){
        var1 = 10;
        var2 = 20;
    }
    virtual void fun() = 0; // 纯虚函数
};

方法二:

  • 将类的构造函数声明为私有 private

5.10 为什么用成员初始化列表会快一些?

面试高频指数:★★★☆☆
说明:

  • 数据类型可分为内置类型和用户自定义类型(类类型)
  • 对于用户自定义类型,利用成员初始化列表效率高

原因:

  • 使用初始化列表:能够直接调用该成员变量的构造函数完成初始化;
  • 如果在构造函数中初始化:在进入构造函数之前,程序会调用该成员的默认构造函数为成员变量附初始值,然后在构造函数中又会调用一次该成员变量的构造函数进行初始化,一共调用了两遍该成员变量的构造函数。
  • 用户自定义类型如果使用类初始化列表,直接调用该成员变量对应的构造函数即完成初始化;
  • 如果在构造函数中初始化,因为 C++ 规定,对象的成员变量的初始化动作发生在进入构造函数本体之前,那么在执行构造函数的函数体之前首先调用默认的构造函数为成员变量设初值,在进入函数体之后,调用该成员变量对应的构造函数。因此,使用列表初始化会减少调用默认的构造函数的过程,效率高。
#include <iostream>
using namespace std;
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) // 成员列表初始化方式
    {
    }
};

class Test2{
private:
    A ex;

public:
    Test2() // 函数体中赋值的方式
    {
        ex = A(2);
    }
};
int main(){
    Test1 ex1;
    cout << endl;
    Test2 ex2;
    return 0;
}
/*
运行结果:
A(int 1)

A()
A(int 2)
*/

说明:
从程序运行结果可以看出,使用成员列表初始化的方式会省去调用默认的构造函数的过程

5.11 实例化一个对象需要哪几个阶段

面试高频指数:★★★☆☆

1、分配空间
创建类对象首先要为该对象分配内存空间。不同的对象,为其分配空间的时机未必相同。全局对象、静态对象、分配在栈区域内的对象,在编译阶段进行内存分配;存储在堆空间的对象,是在运行阶段进行内存分配。
2、初始化
首先明确一点:初始化不同于赋值。初始化发生在赋值之前,初始化随对象的创建而进行,而赋值是在对象创建好后,为其赋上相应的值。这一点可以联想下上一个问题中提到:初始化列表先于构造函数体内的代码执行,初始化列表执行的是数据成员的初始化过程,这个可以从成员对象的构造函数被调用看的出来。
3、赋值
对象初始化完成后,可以对其进行赋值。对于一个类的对象,其成员变量的赋值过程发生在类的构造函数的函数体中。当执行完该函数体,也就意味着类对象的实例化过程完成了。(总结:构造函数实现了对象的初始化和赋值两个过程,对象的初始化是通过初始化列表来完成,而对象的赋值则才是通过构造函数的函数体来实现。)
注:对于拥有虚函数的类的对象,还需要给虚表指针赋值。

  • 没有继承关系的类,分配完内存后,首先给虚表指针赋值,然后再列表初始化以及执行构造函数的函数体,即上述中的初始化和赋值操作。
  • 有继承关系的类,分配内存之后,首先进行基类的构造过程,然后给该派生类的虚表指针赋值,最后再列表初始化以及执行构造函数的函数体,即上述中的初始化和赋值操作。

5.12 友元函数的作用及使用场景

面试高频指数:★★☆☆☆

作用:友元提供了不同类的成员函数之间、类的成员函数与一般函数之间进行数据共享的机制。通过友元,一个不同函数或另一个类中的成员函数可以访问类中的私有成员和保护成员。

使用场景:

  1. 普通函数定义为友元函数,使普通函数能够访问类的私有成员。
  2. 友元类:类之间共享数据。

5.13 静态绑定和动态绑定是怎么实现的?

面试高频指数:★★★☆☆

静态类型和动态类型:

  • 静态类型:变量在声明时的类型,是在编译阶段确定的。静态类型不能更改。
  • 动态类型:目前所指对象的类型,是在运行阶段确定的。动态类型可以更改。

静态绑定和动态绑定:

  • 静态绑定是指程序在 编译阶段 确定对象的类型(静态类型)。
  • 动态绑定是指程序在 运行阶段 确定对象的类型(动态类型)。

静态绑定和动态绑定的区别:

  • 发生的时期不同:如上。
  • 对象的静态类型不能更改,动态类型可以更改。

注:对于类的成员函数,只有虚函数是动态绑定,其他都是静态绑定。

5.14 ★深拷贝和浅拷贝的区别

面试高频指数:★★★★★
如果一个类拥有资源,该类的对象进行复制时,如果资源重新分配,就是深拷贝,否则就是浅拷贝。

  • 深拷贝:该对象和原对象占用不同的内存空间,既拷贝存储在栈空间中的内容,又拷贝存储在堆空间中的内容。
  • 浅拷贝:该对象和原对象占用同一块内存空间,仅拷贝类中位于栈空间中的内容。

当类的成员变量中有指针变量时,最好使用深拷贝。因为当两个对象指向同一块内存空间,如果使用浅拷贝,当其中一个对象的删除后,该块内存空间就会被释放,另外一个对象指向的就是垃圾内存。

浅拷贝实例

#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()
*/

5.15 编译时多态和运行时多态的区别

面试高频指数:★★★☆☆

编译时多态:在程序编译过程中出现,发生在模板和函数重载中(泛型编程)。
运行时多态:在程序运行过程中出现,发生在继承体系中,是指通过基类的指针或引用访问派生类中的虚函数。

编译时多态和运行时多态的区别:

  • 时期不同:编译时多态发生在程序编译过程中,运行时多态发生在程序的运行过程中;
  • 实现方式不同:编译时多态运用泛型编程来实现,运行时多态借助虚函数来实现。

5.16 实现一个类成员函数,要求不允许修改类的成员变量?

面试高频指数:★★☆☆☆

如果想达到一个类的成员函数不能修改类的成员变量,只需用 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;
}

5.17 如何让类不能被继承?

面试高频指数:★★★☆☆

解决方法一:借助 final 关键字,用该关键字修饰的类不能被继承。

class Base final{
};

解决方法二:借助友元、虚继承和私有构造函数来实现

 

____________________________________________________________

参考:

作者:力扣 (LeetCode)
链接:https://leetcode-cn.com/leetbook/read/cpp-interview-highlights/o5v4w6/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

以上是关于c++C++面试知识5——第五章 类相关的主要内容,如果未能解决你的问题,请参考以下文章

第五章 循环结构课后反思

第一本书 第五章(课后题)

求c语言程序设计第二版(苏小红)课后第五章的本章实验题答案

第五章循环结构课后反思

[c++]第五章概念题 | 继承

第五章学习小结