C++ 面向对象面试题

1.面向对象的三大特征:

  • 封装:将客观事物封装成抽象的类,而类可以把自己的数据和方法暴露给可信的类或者对象,对不可信的类或对象则进行信息隐藏。
  • 继承:可以使用现有类的所有功能,并且无需重新编写原来的类即可对功能进行拓展;
  • 多态:一个类实例的相同方法在不同情形下有不同的表现形式,使不同内部结构的对象可以共享相同的外部接口。

2.C++中类成员的访问权限

C++通过 public、protected、private 三个关键字来控制成员变量和成员函数的访问权限,它们分别表示公有的、受保护的、私有的,被称为成员访问限定符。在类的内部(定义类的代码内部),无论成员被声明为 public、protected 还是 private,都是可以互相访问的,没有访问权限的限制。在类的外部(定义类的代码之外),只能通过对象访问成员,并且通过对象只能访问 public 属性的成员,不能访问 private、protected 属性的成员。

3.多态的实现有哪几种?

多态分为静态多态和动态多态。其中,静态多态是通过重载和模板技术实现的,在编译期间确定;动态多态是通过虚函数和继承关系实现的,执行动态绑定,在运行期间确定。

4.动态绑定是如何实现的?

当编译器发现类中有虚函数时,会创建一张虚函数表,把虚函数的函数入口地址放到虚函数表中,并且在对象中增加一个指针 vptr,用于指向类的虚函数表。当派生类覆盖基类的虚函数时,会将虚函数表中对应的指针进行替换,从而调用派生类中覆盖后的虚函数,从而实现动态绑定。

5.动态多态有什么作用?有哪些必要条件?

动态多态的作用:

  • 隐藏实现细节,使代码模块化,提高代码的可复用性;
  • 接口重用,使派生类的功能可以被基类的指针/引用所调用,即向后兼容,提高代码的可扩充性和可维护性。

动态多态的必要条件:

  • 需要有继承;
  • 需要有虚函数覆盖;
  • 需要有基类指针/引用指向子类对象

6.纯虚函数有什么作用?如何实现?

定义纯虚函数是为了实现一个接口,起到规范的作用,想要继承这个类就必须覆盖该函数。

实现方式是在虚函数声明的结尾加上 = 0即可。

7.虚函数表是针对类的还是针对对象的?同一个类的两个对象的虚函数表是怎么维护的?

虚函数表是针对类的,类的所有对象共享这个类的虚函数表,因为每个对象内部都保存一个指向该类虚函数表的指针 vptr,每个对象的 vptr的存放地址都不同,但都指向同一虚函数表。

8.为什么基类的构造函数不能定义为虚函数?

虚函数的调用依赖于虚函数表,而指向虚函数表的指针 vptr需要在构造函数中进行初始化,所以无法调用定义为虚函数的构造函数。

9.为什么基类的析构函数需要定义为虚函数?

为了实现动态绑定,基类指针指向派生类对象,如果析构函数不是虚函数,那么在对象销毁时,就会调用基类的析构函数,只能销毁派生类对象中的部分数据,所以必须将析构函数定义为虚函数,从而在对象销毁时,调用派生类的析构函数,从而销毁派生类对象中的所有数据。

10.构造函数和析构函数能抛出异常吗?

  • 从语法的角度来说,构造函数可以抛出异常,但从逻辑和风险控制的角度来说,尽量不要抛出异常,否则可能导致内存泄漏。
  • 析构函数不可以抛出异常,如果析构函数抛出异常,则异常点之后的程序,比如释放内存等操作,就不会被执行,从而造成内存泄露的问题;而且当异常发生时,C++通常会调用对象的析构函数来释放资源,如果此时析构函数也抛出异常,即前一个异常未处理又出现了新的异常,从而造成程序崩溃的问题。

11.如何让一个类不能实例化?

将类定义为抽象类(也就是存在纯虚函数)或者将构造函数声明为 private

12.多继承存在什么问题?如何消除多继承中的二义性?

  1. 增加程序的复杂度,使得程序的编写和维护比较困难,容易出错;

  2. 在继承时,基类之间或基类与派生类之间发生成员同名时,将出现对成员访问的不确定性,即同名二义性;

    消除同名二义性的方法:

  • 利用作用域运算符 ::,用于限定派生类使用的是哪个基类的成员;
  • 在派生类中定义同名成员,覆盖基类中的相关成员;
  1. 当派生类从多个基类派生,而这些基类又从同一个基类派生,则在访问此共同基类的成员时,将产生另一种不确定性,即路径二义性;

    消除路径二义性的方法:

  • 消除同名二义性的两种方法都可以;
  • 使用虚继承,使得不同路径继承来的同名成员在内存中只有一份拷贝。

13.请你来说-下C++中struct和class的区别

在C++中,class和struct做类型定义如下区别:

  • 默认继承权限不同,class继承默认是private继承,而struct默认是public继承
  • class还可用于定义模板参数,像typename,但是关键字struct不能用于定义模板参数

C++保留struct关键字,主要有如下原因

  • 保证与C语言的向下兼容性,C++必须提供一个struct
  • C++中的struct定义必须百分百地保证与C语言中的struct的向下兼容性,把C++中的最基本的对象单元规定为class而不是struct,就是为了避免各种兼容性要求的限制
  • 对struct定义的扩展使C语言的代码能够更容易的被移植到C++中

14.拷贝构造函数和赋值运算符重载之间有什么区别?

  • 拷贝构造函数用于构造新的对象;

    Student s;
    Student s1 = s; // 隐式调用拷贝构造函数
    Student s2(s);  // 显式调用拷贝构造函数
    
    
  • 赋值运算符重载用于将源对象的内容拷贝到目标对象中,而且若源对象中包含未释放的内存需要先将其释放;

    Student s;
    Student s1;
    s1 = s; // 使用赋值运算符
    
    

    一般情况下,类中包含指针变量时需要重载拷贝构造函数、赋值运算符和析构函数。

15.对虚函数和多态的理解

多态的实现主要分为静态多态和动态多态,静态多态主要是重载,在编译的时候就已经确定;动态多态是用虚函数机制实现的,在运行期间动态绑定。举个例子:一个父类类型的指针指向一个子类对象时候,使用父类的指针去调用子类中重写了的父类中的虚函数的时候,会调用子类重写过后的函数,在父类中声明为加了virtual关键字的函数,在子类中重写时候不需要加virtual也是虚函数。

虚函数的实现:在有虚函数的类中,类的最开始部分是一个虚函数表的指针,这个指针指向一个虚函数表,表中放了虚函数的地址,实际的虚函数在代码段(.text)中。当子类继承了父类的时候也会继承其虚函数表,当子类重写父类中虚函数时候,会将其继承到的虚函数表中的地址替换为重新写的函数地址。使用了虚函数,会增加访问内存开销,降低效率。

16.如果类A是一个空类,那么sizeof(A)的值为多少?

sizeof(A)的值为1,因为编译器需要区分这个空类的不同实例,分配一个字节,可以使这个空类的不同实例拥有独一无二的地址。

17.说说强制类型转换运算符

1、reinterpret_cast

reinterpret_cast< type-id > (expression)

type-id 必须是一个指针、引用、算术类型、函数指针或者成员指针。它可以用于类型之间进行强制转换。

2、const_cast

const_cast (expression)

该运算符用来修改类型的const或volatile属性。除了const 或volatile修饰之外, type_id和expression的类型是一样的。用法如下:

  • 常量指针被转化成非常量的指针,并且仍然指向原来的对象
  • 常量引用被转换成非常量的引用,并且仍然指向原来的对象
  • const_cast一般用于修改底指针。如const char *p形式

3、static_cast

static_cast < type-id > (expression)

该运算符把expression转换为type-id类型,但没有运行时类型检查来保证转换的安全性。它主要有如下几种用法:

  • 用于类层次结构中基类(父类)和派生类(子类)之间指针或引用引用的转换
    • 进行上行转换(把派生类的指针或引用转换成基类表示)是安全的
    • 进行下行转换(把基类指针或引用转换成派生类表示)时,由于没有动态类型检查,所以是不安全的
  • 用于基本数据类型之间的转换,如把int转换成char,把int转换成enum。这种转换的安全性也要开发人员来保证。
  • 把空指针转换成目标类型的空指针
  • 把任何类型的表达式转换成void类型

注意:static_cast不能转换掉expression的const、volatile、或者__unaligned属性。

4、dynamic_cast

有类型检查,基类向派生类转换比较安全,但是派生类向基类转换则不太安全

dynamic_cast (expression)

该运算符把expression转换成type-id类型的对象。type-id 必须是类的指针、类的引用或者void*

如果 type-id 是类指针类型,那么expression也必须是一个指针,如果 type-id 是一个引用,那么 expression 也必须是一个引用

dynamic_cast运算符可以在执行期决定真正的类型,也就是说expression必须是多态类型。如果下行转换是安全的(也就说,如果基类指针或者引用确实指向一个派生类对象)这个运算符会传回适当转型过的指针。如果 如果下行转换不安全,这个运算符会传回空指针(也就是说,基类指针或者引用没有指向一个派生类对象)

dynamic_cast主要用于类层次间的上行转换和下行转换,还可以用于类之间的交叉转换

在类层次间进行上行转换时,dynamic_cast和static_cast的效果是一样的

在进行下行转换时,dynamic_cast具有类型检查的功能,比static_cast更安全

举个例子:

#include <bits/stdc++.h>
using namespace std;

class Base
{
public:
    Base() :b(1) {}
    virtual void fun() {};
    int b;
};

class Son : public Base
{
public:
    Son() :d(2) {}
    int d;
};

int main()
{
    int n = 97;

    //reinterpret_cast
    int *p = &n;
    //以下两者效果相同
    char *c = reinterpret_cast<char*> (p); 
    char *c2 =  (char*)(p);
    cout << "reinterpret_cast输出:"<< *c2 << endl;
    //const_cast
    const int *p2 = &n;
    int *p3 = const_cast<int*>(p2);
    *p3 = 100;
    cout << "const_cast输出:" << *p3 << endl;

    Base* b1 = new Son;
    Base* b2 = new Base;

    //static_cast
    Son* s1 = static_cast<Son*>(b1); //同类型转换
    Son* s2 = static_cast<Son*>(b2); //下行转换,不安全
    cout << "static_cast输出:"<< endl;
    cout << s1->d << endl;
    cout << s2->d << endl; //下行转换,原先父对象没有d成员,输出垃圾值

    //dynamic_cast
    Son* s3 = dynamic_cast<Son*>(b1); //同类型转换
    Son* s4 = dynamic_cast<Son*>(b2); //下行转换,安全
    cout << "dynamic_cast输出:" << endl;
    cout << s3->d << endl;
    if(s4 == nullptr)
        cout << "s4指针为nullptr" << endl;
    else
        cout << s4->d << endl;


    return 0;
}
//输出结果
//reinterpret_cast输出:a
//const_cast输出:100
//static_cast输出:
//2
//-33686019
//dynamic_cast输出:
//2
//s4指针为nullptr

从输出结果可以看出,在进行下行转换时,dynamic_cast安全的,如果下行转换不安全的话其会返回空指针,这样在进行操作的时候可以预先判断。而使用static_cast下行转换存在不安全的情况也可以转换成功,但是直接使用转换后的对象进行操作容易造成错误。

18.覆盖和重载之间有什么区别?

  • 覆盖是指派生类中重新定义的函数,其函数名、参数列表、返回类型与父类完全相同,只是函数体存在区别;覆盖只发生在类的成员函数中;
  • 重载是指两个函数具有相同的函数名,不同的参数列表,不关心返回值;当调用函数时,根据传递的参数列表来判断调用哪个函数;重载可以是类的成员函数,也可以是普通函数。

19.RTTI是什么?其原理是什么?

RTTI即运行时类型识别,其功能由两个运算符实现:

  • typeid运算符,用于返回表达式的类型,可以通过基类的指针获取派生类的数据类型;
  • dynamic_cast运算符,具有类型检查的功能,用于将基类的指针或引用安全地转换成派生类的指针或引用。

20.说-说c++中四种cast转换

C++中四种类型转换是:static_cast, dynamic_cast, const_cast, reinterpret_cast

1、const_cast

  • 用于将const变量转为非const

2、static_cast

  • 用于各种隐式转换,比如非const转const,void*转指针等, static_cast能用于多态向上转化,如果向下转能成功但是不安全,结果未知;

3、dynamic_cast

用于动态类型转换。只能用于含有虚函数的类,用于类层次间的向上和向下转化。只能转指针或引用。向下转化时,如果是非法的对于指针返回NULL,对于引用抛异常。要深入了解内部转换的原理。

  • 向上转换:指的是子类向基类的转换
  • 向下转换:指的是基类向子类的转换

它通过判断在执行到该语句的时候变量的运行时类型和要转换的类型是否相同来判断是否能够进行向下转换。

4、reinterpret_cast

  • 几乎什么都可以转,比如将int转指针,可能会出问题,尽量少用;

5、为什么不使用C的强制转换?

  • C的强制转换表面上看起来功能强大什么都能转,但是转化不够明确,不能进行错误检查,容易出错。

21.C++的空类有哪些成员函数

  • 缺省构造函数。
  • 缺省拷贝构造函数。
  • 省析构函数。
  • 赋值运算符。
  • 取址运算符。
  • 取址运算符 const 。

「注意」:有些书上只是简单的介绍了前四个函数。没有提及后面这两个函数。但后面这两个函数也是 空类的默认函数。另外需要注意的是,只有当实际使用这些函数的时候,编译器才会去定义它们。

22.模板函数和模板类的特例化

「引入原因」

编写单一的模板,它能适应多种类型的需求,使每种类型都具有相同的功能,但对于某种特定类型,如果要实现其特有的功能,单一模板就无法做到,这时就需要模板特例化

**「定义」**对单一模板提供的一个特殊实例,它将一个或多个模板参数绑定到特定的类型或值上

(1)模板函数特例化

必须为原函数模板的每个模板参数都提供实参,且使用关键字template后跟一个空尖括号对<>,表明将原模板的所有模板参数提供实参,举例如下:

template<typename T> //模板函数
int compare(const T &v1,const T &v2)
{
    if(v1 > v2) return -1;
    if(v2 > v1) return 1;
    return 0;
}
//模板特例化,满足针对字符串特定的比较,要提供所有实参,这里只有一个T
template<> 
int compare(const char* const &v1,const char* const &v2)
{
    return strcmp(p1,p2);
}

**「本质」**特例化的本质是实例化一个模板,而非重载它。特例化不影响参数匹配。参数匹配都以最佳匹配为原则。例如,此处如果是compare(3,5),则调用普通的模板,若为compare(“hi”,”haha”)则调用特例化版本(因为这个cosnt char*相对于T,更匹配实参类型),注意二者函数体的语句不一样了,实现不同功能。

**「注意」**模板及其特例化版本应该声明在同一个头文件中,且所有同名模板的声明应该放在前面,后面放特例化版本。

(2)类模板特例化

原理类似函数模板,不过在类中,我们可以对模板进行特例化,也可以对类进行部分特例化。对类进行特例化时,仍然用template<>表示是一个特例化版本,例如:

template<>
class hash<sales_data>
{
    size_t operator()(sales_data& s);
    //里面所有T都换成特例化类型版本sales_data
    //按照最佳匹配原则,若T != sales_data,就用普通类模板,否则,就使用含有特定功能的特例化版本。
};

「类模板的部分特例化」

不必为所有模板参数提供实参,可以指定一部分而非所有模板参数,一个类模板的部分特例化本身仍是一个模板,使用它时还必须为其特例化版本中未指定的模板参数提供实参(特例化时类名一定要和原来的模板相同,只是参数类型不同,按最佳匹配原则,哪个最匹配,就用相应的模板)

「特例化类中的部分成员」

可以特例化类中的部分成员函数而不是整个类,举个例子:

template<typename T>
class Foo
{
    void Bar();
    void Barst(T a)();
};

template<>
void Foo<int>::Bar()
{
    //进行int类型的特例化处理
    cout << "我是int型特例化" << endl;
}

Foo<string> fs;
Foo<int> fi;//使用特例化
fs.Bar();//使用的是普通模板,即Foo<string>::Bar()
fi.Bar();//特例化版本,执行Foo<int>::Bar()
//Foo<string>::Bar()和Foo<int>::Bar()功能不同

23.为什么析构函数一般写成虚函数

由于类的多态性,基类指针可以指向派生类的对象,如果删除该基类的指针,就会调用该指针指向的派生类析构函数,而派生类的析构函数又自动调用基类的析构函数,这样整个派生类的对象完全被释放。如果析构函数不被声明成虚函数,则编译器实施静态绑定,在删除基类指针时,只会调用基类的析构函数而不调用派生类析构函数,这样就会造成派生类对象析构不完全,造成内存泄漏。所以将析构函数声明为虚函数是十分必要的。在实现多态时,当用基类操作派生类,在析构时防止只析构基类而不析构派生类的状况发生,要将基类的析构函数声明为虚函数。举个例子:

#include <iostream>
using namespace std;

class Parent{
public:
    Parent(){
        cout << "Parent construct function"  << endl;
    };
    ~Parent(){
        cout << "Parent destructor function" <<endl;
    }
};

class Son : public Parent{
public:
    Son(){
        cout << "Son construct function"  << endl;
    };
    ~Son(){
        cout << "Son destructor function" <<endl;
    }
};

int main()
{
    Parent* p = new Son();
    delete p;
    p = NULL;
    return 0;
}
//运行结果:
//Parent construct function
//Son construct function
//Parent destructor function

将基类的析构函数声明为虚函数:

#include <iostream>
using namespace std;

class Parent{
public:
    Parent(){
        cout << "Parent construct function"  << endl;
    };
    virtual ~Parent(){
        cout << "Parent destructor function" <<endl;
    }
};

class Son : public Parent{
public:
    Son(){
        cout << "Son construct function"  << endl;
    };
    ~Son(){
        cout << "Son destructor function" <<endl;
    }
};

int main()
{
    Parent* p = new Son();
    delete p;
    p = NULL;
    return 0;
}
//运行结果:
//Parent construct function
//Son construct function
//Son destructor function
//Parent destructor function