【C++八股-第九期】继承与多态 - 24年春招特供

感谢关注,你必Offer

提纲:

👉 八股:

  1. 构造函数可以是虚函数吗

  2. 请问析构函数必须为虚函数吗

  3. 构造顺序与析构顺序

  4. 基类中的成员变量和成员函数在派生类中的访问权限变化

  5. 多态在C++中的实现方式是什么

  6. 请解释虚函数的工作机制

  7. 虚函数表在什么时候创建?每个对象都有一份虚函数表吗?

  8. 什么情况下使用纯虚函数

9.重载和重写的区别

  1. 函数重载怎么实现

  2. 什么是操作符重载?如何在C++中进行操作符重载?

  3. 哪些操作符不能重载?

  4. 可以通过引用实现多态吗?

  5. 解释直接继承可能产生的二义性,以及提供相应的解决方法

👉 代码:

1. 构造函数可以是虚函数吗

  构造函数不能是虚函数。在 C++ 中,构造函数是用于初始化对象的特殊成员函数,其目的是在对象创建时进行初始化操作。虚函数的调用是通过对象的指针或引用来进行的,而在对象创建阶段,对象尚未创建完成,因此无法使用虚函数。

  此外,虚函数需要在对象的虚函数表中存储函数指针,以实现动态绑定和运行时多态性。但在对象创建阶段,虚函数表还未构建完成,因此无法在构造函数中使用虚函数。

  因此,构造函数不能声明为虚函数。如果需要在基类的构造函数中调用派生类的函数,可以通过其他方式来实现,如在构造函数中传递参数或使用工厂模式等。

拓展(了解即可):

虚函数表(Virtual Function Table,简称 vtable) 是 C++ 中用于实现动态绑定(运行时多态)的关键机制之一。它是一个存储了类中虚函数地址的表格,每个具有虚函数的类都有自己的虚函数表。

以下是虚函数表的主要特点和工作原理:

  1. 存储虚函数地址: 虚函数表存储了类中每个虚函数的地址。当一个类声明了虚函数时,编译器会为该类生成一个虚函数表,并将该类的虚函数地址填充到虚函数表中。

  2. 针对每个类: 每个具有虚函数的类都有自己的虚函数表。这意味着每个类的对象都包含了一个指向其对应虚函数表的指针。这个指针通常被称为虚函数指针(vptr)。

  3. 实现动态绑定: 当一个类的对象被用作基类指针或引用时,并调用了一个虚函数时,C++ 运行时系统会根据对象实际的类型(而不是指针或引用的类型)来查找并调用正确的虚函数。这是通过访问对象的虚函数指针,然后根据虚函数指针指向的虚函数表来找到对应的虚函数地址,从而实现动态绑定。

  4. 继承和覆盖: 当一个类继承自另一个类,并覆盖了基类的虚函数时,派生类会继承基类的虚函数表,但会将覆盖的虚函数地址替换为派生类中的虚函数地址。这样,通过基类指针或引用调用该虚函数时,会调用派生类中的版本。

  虚函数表是 C++ 实现运行时多态性的关键,它使得在面向对象编程中能够使用基类指针或引用来处理派生类对象,而且能够正确地调用派生类的虚函数。

2. 请问析构函数必须为虚函数吗

析构函数不一定是虚函数,但是要是有继承,则父类一定要为虚函数

  C++默认析构函数不是虚函数,因为申明虚函数会创建虚函数表,占用一定内存,当不存在继承的关系时,析构函数不需要申明为虚函数。

  若存在继承关系时,析构函数必须申明为虚函数,这样父类指针指向子类对象,释放基类指针时才会调用子类的析构函数释放资源,否则当通过基类指针删除对象时,只会调用基类的析构函数,而不会调用派生类的析构函数,这可能导致派生类中的资源得不到释放,造成内存泄漏。

case: 析构函数 isn't 虚函数

// 基类 Base
class Base {
public:
    // 非虚析构函数
    ~Base() {
        std::cout << "Base destructor" << std::endl;
    }
};

// 派生类 Derived
class Derived : public Base {
public:
    // 非虚析构函数
    ~Derived() {
        std::cout << "Derived destructor" << std::endl;
    }
};

int main() {
    Base* ptr = new Derived();  // 基类指针指向派生类对象
    delete ptr;  // 通过基类指针删除对象
    return 0;
}

output:

  Base destructor

case: 析构函数 is 虚函数

  当析构函数被声明为虚函数时,其声明方式与普通的析构函数相同,只需在函数声明前添加关键字 virtual 即可。

// 基类 Base
class Base {
public:
    // 非虚析构函数
    virtual ~Base() {
        std::cout << "Base destructor" << std::endl;
    }
};

// 派生类 Derived
class Derived : public Base {
public:
    // 非虚析构函数
    virtual ~Derived() {
        std::cout << "Derived destructor" << std::endl;
    }
};

int main() {
    Base* ptr = new Derived();  // 基类指针指向派生类对象
    delete ptr;  // 通过基类指针删除对象
    return 0;
}

output:

  Derived destructor
  Base destructor

3. 构造顺序与析构顺序

构造顺序:父类构造函数 —→ 对象成员构造函数 —→ 子类构造函数

析构顺序:子类析构函数 —→ 对象成员析构函数 —→ 基类析构函数

口诀:

  创造:鸟类 —→ 乌鸦    灭绝:乌鸦 ←— 鸟类

4. 基类中的成员变量和成员函数在派生类中的访问权限变化

Private 继承:

  基类的公有和保护成员在派生类中变为私有成员,基类的私有成员在派生类中仍然是私有成员,不能被访问。例如“has-a”关系。

Protected 继承:

  基类的公有和保护成员在派生类中变为保护成员,基类的私有成员在派生类中仍然是私有成员,不能被访问。

Public 继承:

  基类的公有和保护成员在派生类中保持为公有和保护成员,基类的私有成员在派生类中仍然是私有成员,不能被访问。例如“is-a”关系。

拓展(了解即可):

"has-a" 关系示例:

// 定义一个车轮类 Wheel
class Wheel {
public:
    void rotate() {
        std::cout << "Wheel rotates" << std::endl;
    }
};

// 定义一个汽车类 Car,拥有四个车轮
class Car {
private:
    Wheel wheels[4]; // Car 类拥有四个 Wheel 对象

public:
    void move() {
        for (int i = 0; i < 4; ++i) {
            wheels[i].rotate(); // Car 类可以操作 Wheel 对象,但不是继承自 Wheel
        }
        std::cout << "Car moves" << std::endl;
    }
};

int main() {
    Car myCar;
    myCar.move(); // Car 类具有车轮对象,可以移动
    return 0;
}

"is-a" 关系示例:

// 定义一个动物类 Animal
class Animal {
public:
    virtual void sound() const = 0; // 纯虚函数,所有动物都有声音
};

// 定义一个狗类 Dog,是动物的一种
class Dog : public Animal {
public:
    void sound() const override {
        std::cout << "Dog barks" << std::endl;
    }
};

int main() {
    Dog myDog;
    myDog.sound(); // Dog 类是 Animal 类的一种,具有声音
    return 0;
}

5. 多态在C++中的实现方式是什么

  在 C++ 中,多态(Polymorphism)通过虚函数(Virtual Function)实现。多态允许基类的指针或引用在运行时表现出不同派生类的行为。

利用虚函数实现多态的原理:

  1. 在基类中声明虚函数。虚函数是在基类中声明为虚拟的函数,它可以被派生类覆盖(重写)。

  2. 在派生类中覆盖虚函数。派生类可以重写基类中的虚函数,从而改变其行为。

  3. 会根据实际对象的类型(而不是指针或引用的类型)来决定调用哪个版本的虚函数,从而实现多态性。

拓展(了解即可):

#include <iostream>

// 图形类 Shape
class Shape {
public:
    virtual double area() const = 0; // 虚函数,计算面积
};

// 圆形类 Circle
class Circle : public Shape {
private:
    double radius;

public:
    Circle(double r) : radius(r) {}

    double area() const override {
        return 3.14 * radius * radius;
    }
};

// 矩形类 Rectangle
class Rectangle : public Shape {
private:
    double width;
    double height;

public:
    Rectangle(double w, double h) : width(w), height(h) {}

    double area() const override {
        return width * height;
    }
};

int main() {
    Shape* shapes[2];
    shapes[0] = new Circle(5);
    shapes[1] = new Rectangle(3, 4);

    for (int i = 0; i < 2; ++i) {
        std::cout << "Area of shape " << i + 1 << ": " << shapes[i]->area() << std::endl;
    }

    // 释放内存
    for (int i = 0; i < 2; ++i) {
        delete shapes[i];
    }

    return 0;
}

  在这个例子中,Shape 类是基类,定义了虚函数 area(),并且包含纯虚函数 area() = 0,使得 Shape 类成为一个抽象基类。Circle 类和 Rectangle 类分别是 Shape 类的派生类,它们都覆盖了基类中的虚函数 area()。

  在 main 函数中,通过基类指针数组存储不同派生类的对象,并调用了各自的 area() 函数。即使是通过基类指针调用虚函数,根据实际对象的类型,仍会调用相应的派生类版本的虚函数,从而实现了多态性。

6. 请解释虚函数的工作机制

在 C++ 中,虚函数的实现依赖于 虚函数表(Virtual Function Table)虚表指针(vptr)

虚函数表(Virtual Function Table):

  虚函数表是一个存储了类中虚函数地址的表格,每个具有虚函数的类都有自己的虚函数表。 (可以理解为-虚函数表是一个数组,数组的元素存放的是类中虚函数的地址)

  虚函数表中存储了类中每个虚函数的地址,按照声明的顺序排列。当一个类声明了虚函数时,编译器会为该类生成一个虚函数表,并将该类的虚函数地址填充到虚函数表中。派生类会继承基类的虚函数表,并覆盖其中的虚函数地址,以适应派生类的特定实现。

虚表指针(vptr):

  每个对象都包含一个指向其对应虚函数表的指针,这个指针通常被称为虚表指针(vptr)。虚表指针位于对象的内存布局中的开头位置或者结束位置,取决于具体的编译器实现。虚表指针的作用是指向对象所属类的虚函数表,从而使得在运行时能够根据实际对象的类型来调用正确的虚函数。

虚函数的工作机制:

  1. 当一个类声明了虚函数时,编译器会在该类的虚函数表中为每个虚函数分配一个地址,并将这些地址填充到虚函数表中。

  2. 当通过基类指针或引用调用虚函数时,实际调用的是通过虚表指针找到的虚函数地址所指向的函数。这个过程发生在运行时,根据对象的实际类型来决定调用哪个版本的虚函数。

  3. 如果派生类覆盖了基类的虚函数,那么在派生类对象上调用该虚函数时,会调用派生类的版本,因为派生类的虚表指针指向了派生类的虚函数表。

虚函数表在继承和多态中的作用:

  • 在继承中,派生类会继承基类的虚函数表,并在需要的情况下修改其中的虚函数地址,以实现覆盖基类的虚函数。

  • 在多态中,通过基类指针或引用调用虚函数时,会根据实际对象的类型来决定调用哪个版本的虚函数,从而实现动态绑定和运行时多态性。这使得程序能够更加灵活地处理不同类型的对象。

  虚函数表只有一份,而有多少个对象,就对应多少个虚函数表指针

7. 虚函数表在什么时候创建?每个对象都有一份虚函数表吗?

  • 当一个类里存在虚函数时,编译器会为类创建一个虚函数表,发生在编译期。

  • 虚函数表只有一份,而有多少个对象,就对应多少个虚函数表指针。

8. 什么情况下使用纯虚函数

纯虚函数(Pure Virtual Function) 是在 C++ 中的一种特殊函数,它在基类中声明但没有实现。通过将函数声明为纯虚函数,可以强制派生类提供其自己的实现,或者使得基类成为抽象类,不能直接实例化。

纯虚函数的概念和用法:

  1. 概念: 纯虚函数是在基类中声明但没有实现的虚函数。它通过在函数声明后面添加 "= 0" 来标记。纯虚函数没有默认实现,因此不能在基类中直接调用,只有通过派生类的实现才能被调用。

  2. 用法: 使用纯虚函数的主要目的是定义一个接口,要求派生类必须提供自己的实现。它经常用于以下情况:

    • 定义抽象基类:如果基类中有纯虚函数,那么基类就不能被实例化,只能被用作派生类的接口定义,这样的基类称为抽象基类。

    • 实现多态:通过将函数声明为纯虚函数,可以使得基类指针或引用在运行时根据实际对象的类型来调用正确的函数版本,从而实现多态性。

#include <iostream>

// 抽象基类 Shape,定义纯虚函数 area()
class Shape {
public:
    virtual double area() const = 0; // 纯虚函数,要求派生类提供实现
};

// 派生类 Circle
class Circle : public Shape {
private:
    double radius;

public:
    Circle(double r) : radius(r) {}

    double area() const override {
        return 3.14 * radius * radius;
    }
};

// 派生类 Rectangle
class Rectangle : public Shape {
private:
    double width;
    double height;

public:
    Rectangle(double w, double h) : width(w), height(h) {}

    double area() const override {
        return width * height;
    }
};

int main() {
    // Shape* shape = new Shape(); // 错误:不能实例化抽象基类
    Shape* circle = new Circle(5);
    Shape* rectangle = new Rectangle(3, 4);

    std::cout << "Area of circle: " << circle->area() << std::endl;
    std::cout << "Area of rectangle: " << rectangle->area() << std::endl;

    delete circle;
    delete rectangle;

    return 0;
}

9. 重载和重写的区别

函数—重载(overload)

  函数名相同,参数列表不同(参数类型、参数顺序),不能用返回值区分。

  • 特点:

    • (1)作用域相同;
    • (2)函数名相同;
    • (3)参数列表必须不同,但返回值无要求;
    • (4)重载函数可以属于同一个类,也可以跨越不同的类
  • 特殊情况:

    • 若某一重载版本的函数前面有virtual关键字修饰,则表示它是虚函数,但它也是重载的一个版本。
  • 作用效果:

    • 编译器根据函数不同的参数列表,将函数与函数调用进行早绑定,重载与多态无关,与面向对象无关,它只是一种语言特性。

派生类—重写(override)

  • 派生类重定义基类的虚函数,既会覆盖基类的虚函数(多态)。

  • 特点:

    • (1)作用域不同;

    • (2)函数名、参数列表、返回值相同;

    • (3)基类函数是virtual;

  • 特殊情况:

    • 若派生类重写函数是一个重载版本,那么基类的其他同名重载函数将在子类中隐藏。
  • 作用效果:

    • 父类指针和引用指向子类的实例时,通过父类指针或引用可以调用子类的函数,这就是C++的多态。

总结:

  1. 适用范围:

    • 重载适用于同一个作用域内的多个同名函数。

    • 重写适用于继承关系中的虚函数。

  2. 参数:

    • 重载的函数参数列表必须不同。

    • 重写的函数参数列表必须相同。

  3. 静态绑定 vs 动态绑定:

    • 重载是静态绑定的,编译器根据函数调用时提供的参数确定调用哪个版本的函数。

    • 重写是动态绑定的,通过基类指针或引用调用虚函数时,根据指针或引用的实际对象类型来确定调用哪个版本的函数。

  4. 关联性:

    • 重载是在同一个类中或同一个作用域内的多个函数间的关系。

    • 重写是基类和派生类中虚函数间的关系。

  5. 语法:

    • 重载的函数名相同,但参数列表不同。

    • 重写的函数在基类中声明为虚函数,在派生类中通过 override 关键字重新实现。

10. 函数重载怎么实现

代码层面:

  修改传入参数类型、顺序;不可以修改返回值,

架构层面:

  在编译后,函数签名已经都不一样了,自然也就不冲突了。这就是为什么C++可以实现重名函数,但实际上编译后的函数签名是不一样的。

拓展(了解即可):

  签名命名的方式是:_z+函数名字符个数+函数参数列表。

  例如:_Z7displayc

  前缀 _z 是GCC(由GNU开发的编程语言译器)的规定,7 是函数名display的字符个数,参数类型转换规则:int-->i,long-->l,char-->c,short-->s

  由此可以看出来,函数重载与返回值类型没有关系。

11. 什么是操作符重载?如何在C++中进行操作符重载?

  操作符重载(Operator Overloading)是指重新定义 C++ 中的运算符,使其能够用于用户自定义类型的操作。通过操作符重载,可以使得用户自定义类型的对象可以像内置类型一样使用运算符进行操作

  在 C++ 中进行操作符重载,需要定义一个成员函数或友元函数来实现运算符的重载。操作符重载函数的命名约定是使用关键字 operator 后跟要重载的运算符,如 operator+ 代表重载加法运算符。

  

下面是一些操作符重载的基本语法:

  1. 成员函数形式的操作符重载:

    class Complex {
    private:
        double real;
        double imag;
    
    public:
        Complex(double r, double i) : real(r), imag(i) {}
    
        // 重载加法运算符 +
        Complex operator+(const Complex& other) const {
            return Complex(real + other.real, imag + other.imag);
        }
    };
    
    int main() {
        Complex c1(1.0, 2.0);
        Complex c2(2.0, 3.0);
        Complex result = c1 + c2; // 调用重载的加法运算符
        return 0;
    }
    
    
  2. 友元函数形式的操作符重载:

    class Complex {
    private:
        double real;
        double imag;
    
    public:
        Complex(double r, double i) : real(r), imag(i) {}
    
        // 声明友元函数
        friend Complex operator+(const Complex& lhs, const Complex& rhs);
    
        double getReal() const { return real; }
        double getImag() const { return imag; }
    };
    
    // 定义友元函数
    Complex operator+(const Complex& lhs, const Complex& rhs) {
        return Complex(lhs.real + rhs.real, lhs.imag + rhs.imag);
    }
    
    int main() {
        Complex c1(1.0, 2.0);
        Complex c2(2.0, 3.0);
        Complex result = c1 + c2; // 调用重载的加法运算符
        return 0;
    }
    

拓展: 重载+号

有两种方法:

  1. 作为成员函数,接受一个参数。

  2. 作为友元函数,接受两个参数。

// 作为成员函数,接受一个参数。
class add
{
public:
    add():m_n(0){}
    add operator+(const add &aR)
    {
        add a;
        a.m_n = m_n * 10 + aR.m_n;
        return a;
    }
    int m_n;
};
// 作为友元函数,接受两个参数。
class complex 
{
public:
    complex():m_n(0){}
    friend complex operator+(const complex &cL,const complex &cR);
    int m_n;
};
complex operator+(const complex &cL,const complex &cR)
{
    complex c;
    c.m_n = cL.m_n + cR.m_n;
    return c;
}
int main()
{
    add a1,a2,a3;
    a1.m_n = 1;
    a2.m_n = 2;
    a3 = a1 + a2;

    complex c1,c2,c3;
    c1.m_n = 100;
    c2.m_n = 50;
    c3=c1+c2;

    return 0;
}

12. 哪些操作符不能重载?

. 成员选择操作符:用于访问类对象的成员。

.* 成员指针操作符:用于通过成员指针访问类对象的成员。

:: 作用域解析操作符:用于指定命名空间、类、结构体或枚举的作用域。

?: 三目条件运算符:用于条件表达式。

sizeof 运算符:用于获取类型或对象的大小。

typeid 运算符:用于获取对象的类型信息。

:: 作用域解析运算符:用于指定全局命名空间或类的作用域。

# 预处理运算符:用于字符串化操作。

## 预处理运算符:用于连接两个标识符。

  

  • 操作符被重载的基本前提:

    1、只能为自定义类型重载操作符;

    2、不能对操作符的语法(优先级、结合性、操作数个数、语法结构)、语义进行颠覆;

    3、不能引入新的自定义操作符。

13. 可以通过引用实现多态吗?

可以,引用本身并不是实现多态的机制,但是通过引用可以实现多态性。

具体来说,当基类指针或引用指向派生类对象时,通过引用调用虚函数时,实际会调用派生类的版本,这就是多态性的体现。下面是一个简单的示例:

#include <iostream>

// 基类 Shape
class Shape {
public:
    virtual void draw() const {
        std::cout << "Drawing a shape." << std::endl;
    }
};

// 派生类 Circle
class Circle : public Shape {
public:
    void draw() const override {
        std::cout << "Drawing a circle." << std::endl;
    }
};

// 派生类 Rectangle
class Rectangle : public Shape {
public:
    void draw() const override {
        std::cout << "Drawing a rectangle." << std::endl;
    }
};

int main() {
    Circle circle;
    Rectangle rectangle;

    Shape& shape1 = circle;
    Shape& shape2 = rectangle;

    shape1.draw(); // 调用 Circle 的 draw 函数
    shape2.draw(); // 调用 Rectangle 的 draw 函数

    return 0;
}

注意:

  • 由于引用类似于常量,只能在定义的同时初始化,并且以后也要从一而终,不能再引用其他数据,所以本例中必须要定义两个引用变量,一个用来引用基类对象,一个用来引用派生类对象。

  • 当基类的引用指代基类对象时,调用的是基类的成员;而指代派生类对象时,调用的是派生类的成员。

  • 不过引用不像指针灵活,指针可以随时改变指向,而引用只能指代固定的对象,在多态性方面缺乏表现力,所以以后我们再谈及多态时一般是说指针。

14. 解释直接继承可能产生的二义性,以及提供相应的解决方法

多重继承的二义性是指一个类(D)通过多条路径继承了相同的父类(A),从而导致在调用某些方法或访问某些属性时产生歧义或冲突的情况。这种情况通常被称为 “钻石继承” ,因为在类的继承图中,继承关系形成了类似钻石形状的结构。

例如,考虑如下继承关系:

   A
  / \
 B   C
  \ /
   D

在这个示例中,类 D 继承自类 B 和类 C,而类 B 和类 C 都继承自类 A。如果类 B 和类 C 都定义了同名的方法或属性,并且类 D 想要调用这个方法或访问这个属性,就会导致二义性,因为不清楚应该使用哪个版本。

直接继承可能会导致问题,因为在继承链中的某个节点定义了与其他节点相同的方法或属性,从而引发了二义性。这会使代码难以理解和维护,并且可能导致意外的行为。

解决这种二义性的一种方法是使用虚拟继承(virtual inheritance)(如果是在支持该特性的编程语言中)。虚拟继承允许在类的继承链中只有一个共享的父类实例,从而消除了二义性。在C++中,可以通过在继承关系中使用虚拟继承来解决钻石继承问题。例如:


class A { };
class B : virtual public A { };
class C : virtual public A { };
class D : public B, public C { };

使用虚拟继承,类 D 将只包含一个 A 的实例,而不是分别继承自类 B 和类 C 的 A 实例,从而避免了二义性。

全部评论

相关推荐

3 23 评论
分享
牛客网
牛客企业服务