CppGuide社区 CppGuide社区
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥交易系统开发岗位求职与面试指南统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go系统接口编程
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥交易系统开发岗位求职与面试指南统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go系统接口编程
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
  • C++语言面试问题集锦 目录与说明
  • 第一章 auto与类型推导
  • 第二章 关键字static及其不同用法
  • 第三章 多态、继承和虚函数
    • 问题13:函数重载和函数重写有什么区别?
    • 问题14:什么是虚函数?
    • 问题15:override关键字是什么,它有什么优点?
    • 问题16:解释协变返回类型的概念,并举例说明其适用场景。
    • 问题17:C++中的虚继承(virtual inheritance)是什么,应该在何时使用它?
    • 问题18:我们应该一直使用虚继承吗?如果是,为什么?如果不是,又是为什么?
    • 问题19:下面这个示例程序的输出是什么?这是你预期的结果吗?为什么是或为什么不是?
    • 问题20:在私有继承(private inheritance)的情况下,能否访问基类的公有(public)和受保护(protected)成员及函数?
    • 问题21:私有继承用于什么场景?
    • 问题22:能否从构造函数或析构函数中调用虚函数?
    • 问题23:虚析构函数(virtual destructor)有什么作用?
    • 问题24:我们可以从标准容器(如std::vector)继承吗?如果可以,会有什么影响?
    • 问题25:强类型(strong type)是什么意思,它有什么优点?
    • 问题26:解释短路求值(short - circuit evaluation)
    • 问题27:什么是析构函数(destructor),如何对其进行重载?
    • 问题28:下面这段代码的输出是什么,为什么?
    • 问题29:如何在C++中使用= delete说明符?
  • 第四章 Lambda函数
  • 第五章 C++中如何使用 const 限定符
  • 第六章 Modern C++的一些最佳实践
  • 第七章 智能指针
  • 第八章 引用、万能引用等
  • 第九章 C++20相关问题
  • 第十章 特殊函数及数量规则
  • 第十一章 C++面向对象设计
  • 第十二章 程序质量
  • 第十三章 标准模板库
  • 第十四章 杂项
  • cppinterviewmostaskedquestions
zhangxf
2025-03-27
目录

第三章 多态、继承和虚函数

# 第三章 多态、继承和虚函数

接下来的十六个问题,我们将聚焦于多态、继承、虚函数以及相关主题。

# 问题13:函数重载和函数重写有什么区别?

函数重写(Function overriding)是一个与多态相关的术语。如果你在基类中声明了一个具有特定实现的虚函数,在派生类中可以使用完全相同的函数签名来重写其行为。如果基类中没有virtual关键字,派生类中仍可以创建一个具有相同签名的函数,但这不算重写。自C++11起,还有override说明符,它能帮助你确保没有错误地未能重写基类函数。你可以在此处找到更多详细信息。

函数重写使你能够在派生类中为基类已定义的函数提供特定的实现。

另一方面,函数重载(overloading)与多态无关。当有两个函数名称相同、返回类型相同,但参数数量或类型,或者限定符不同时,就构成了函数重载。

以下是基于参数的函数重载示例:

void  myFunction(int  a);
void  myFunction(double  a);
void  myFunction(int  a, int  b);
1
2
3

以下是基于限定符的函数重载示例:

class  MyClass  {
public:
    void  doSomething() const;
    void  doSomething();
};
1
2
3
4
5

甚至还可以基于实际实例是左值(lvalue)还是右值(rvalue)来重载函数:

class  MyClass  {
public:
    //  ...
    void  doSomething() &;  // 当*this是左值时使用
    void  doSomething() &&; // 当*this是右值时使用
};
1
2
3
4
5
6

# 问题14:什么是虚函数?

虚函数用于替换,换句话说,重写基类提供的实现。

只要对象实际上是派生类的对象,即使是通过基类指针而不是派生类指针来访问该对象,也总是会调用派生类中的代码。

#include  <iostream>

class  Car  {
public:
    virtual  void  printName() {
        std::cout << "Car\n";
    }
};

class  SUV : public  Car {
public:
    virtual  void  printName() override  {
        std::cout << "SUV\n";
    }
};

int  main() {
    Car* car = new  SUV();
    car->printName();
}
// 输出:SUV
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

虚函数是基类中存在的成员函数,可能会被派生类重新定义。当我们在基类和派生类中使用相同的函数名时,基类中的函数必须用virtual关键字声明,否则它不会被重写,只是被隐藏了。

当函数被声明为虚函数时,C++会在运行时根据基类指针所指向的对象类型来确定调用哪个函数。因此,通过让基类指针指向不同的对象,我们可以执行虚函数的不同版本。

虚函数的一些规则:

  • 它们总是成员函数。
  • 它们不能是静态函数。
  • 它们可以是另一个类的友元。
  • C++中不存在虚构造函数,但可以有虚析构函数。

实际上,如果你希望其他类从某个给定类继承,应该始终将析构函数声明为虚函数,否则很容易出现未定义行为。

# 问题15:override关键字是什么,它有什么优点?

override说明符会告知编译器和代码阅读者,使用该说明符的函数实际上是在重写其基类中的一个方法。

它向代码阅读者表明:“这是一个虚方法,重写了基类的虚方法。”

正确使用它时,可能看不出什么效果:

class  Base  {
    virtual  void  foo();
};

class  Derived  : Base {
    void  foo() override; //  没问题:Derived::foo重写了Base::foo
};
1
2
3
4
5
6
7

但它能帮助你发现有关常量性的问题:

class  Base  {
    virtual  void  foo();
    void  bar();
};

class  Derived  : Base {
    void  foo() const  override; // 错误:Derived::foo没有重写Base::foo
                                 // 它试图重写Base::foo const,但该函数不存在
};
1
2
3
4
5
6
7
8
9

别忘了,在C++中,方法默认不是虚函数。如果使用override,可能会发现没有可重写的内容。如果没有override说明符,我们只是简单地创建了一个全新的方法。如果你始终使用override说明符,就不会忘记将基类方法声明为虚函数。

class  Base  {
    void  foo();
};

class  Derived  : Base {
    void  foo() override; // 错误:Base::foo不是虚函数
};
1
2
3
4
5
6
7

我们还应该记住,无论是否使用override说明符,重写方法时都不允许进行类型转换:

class  Base  {
public:
    virtual  long  foo(long  x) = 0;
};

class  Derived : public  Base {
public:
    // 错误:'long int Derived::foo(int)'标记为override,但没有重写
    long  foo(int  x) override  {
        //  ...
    }
};
1
2
3
4
5
6
7
8
9
10
11
12

在我看来,使用C++11中的override说明符是代码整洁原则的一部分。它揭示了作者的意图,使代码更易读,并有助于在构建时发现错误。请毫不犹豫地使用它!

# 问题16:解释协变返回类型的概念,并举例说明其适用场景。

对于虚函数及其所有重写版本使用协变返回类型(covariant return types),意味着你可以用更窄的类型,换句话说,用更具体的类型来替换原始返回类型。

假设你有一个汽车工厂生产线(CarFactoryLine)生产汽车(Car)。这些工厂生产线的特定类型可能生产SUV、跑车(SportsCars)等。如何在代码中表示呢?一种常见的方式是仍然将返回类型设为汽车指针。

class  CarFactoryLine  {
public:
    virtual  Car* produce() {
        return  new  Car{};
    }
};

class  SUVFactoryLine   : public  CarFactoryLine {
public:
    virtual  Car* produce() override  {
        return  new  SUV{};
    }
};
1
2
3
4
5
6
7
8
9
10
11
12
13

这样的话,获取一个SUV*需要进行动态类型转换:

SUVFactoryLine sf;
Car* car = sf.produce();
SUV* suv = dynamic_cast<SUV*>(car);
1
2
3

相反,我们可以直接返回SUV*:

class  Car  {
public:
    virtual  ~Car() = default;
};

class  SUV  : public  Car {};

class  CarFactoryLine  {
public:
    virtual  Car* produce() {
        return  new  Car{};
    }
};

class  SUVFactoryLine   : public  CarFactoryLine {
public:
    virtual  SUV* produce() override  {
        return  new  SUV{};
    }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

这样你就可以简单地这样做:

SUVFactoryLine sf;
SUV* car = sf.produce();
1
2

在C++中,在派生类的重写函数中,你不必返回与基类相同的类型,但可以返回协变返回类型。换句话说,你可以用一个 “更窄” 的类型,也就是更具体的数据类型来替换原始类型。

# 问题17:C++中的虚继承(virtual inheritance)是什么,应该在何时使用它?

虚继承是一种C++技术,它确保孙子派生类仅继承基类成员变量的一份副本。如果不使用虚继承,若类B和类C都继承自类A,而类D又继承自B和C,那么D将包含两份A的成员变量:一份通过B继承,另一份通过C继承。通过作用域解析,这两份成员变量可以被独立访问。

相反,如果类B和类C虚拟继承自类A,那么类D的对象将仅包含一组来自类A的成员变量。

你可能已经猜到,当需要处理多重继承并解决臭名昭著的菱形继承问题时,这项技术就派上用场了。

在实际应用中,当从虚基类派生的类,尤其是虚基类本身是纯抽象类时,虚基类最为适用。这意味着 “连接类”(位于继承层次底部的类)之上的类即使有数据也很少。

考虑下面这个用来表示菱形继承问题的类层次结构,不过这里没有使用纯抽象类。

struct  Person {
    virtual  ~Person() = default;
    virtual  void  speak() {}
};

struct  Student : Person {
    virtual  void  learn() {}
};

struct  Worker : Person {
    virtual  void  work() {}
}; 
// 助教既是学生也是工作人员
struct  TeachingAssistant : Student, Worker {};

int  main() {
    TeachingAssistant aTeachingAssistant;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

按照上述定义,调用aTeachingAssistant.speak()会出现歧义,因为TeachingAssistant中有两个间接的Person基类,所以任何TeachingAssistant对象都有两个不同的Person基类子对象。因此,试图直接将引用绑定到TeachingAssistant对象的Person子对象会失败,因为这种绑定本质上是不明确的:

TeachingAssistant aTeachingAssistant;
Person& aPerson = aTeachingAssistant; // 错误:应该将TeachingAssistant转换为哪个Person子对象,是Student::Person还是Worker::Person?
1
2

为了消除歧义,必须将aTeachingAssistant显式转换为其中一个基类子对象:

TeachingAssistant aTeachingAssistant;
Person& student = static_cast<Student&>(aTeachingAssistant);
Person& worker = static_cast<Worker&>(aTeachingAssistant);
1
2
3

为了调用speak(),同样需要进行这样的歧义消除或显式限定:

static_cast<Student&>(aTeachingAssistant).speak()
1

或者

static_cast<Worker&>(aTeachingAssistant).speak()
1

也可以用

aTeachingAssistant.Student::speak()
1

或者

aTeachingAssistant.Worker::speak()
1

显式限定不仅对指针和对象都使用了更简单、统一的语法,还允许静态分派,因此可以说这是更可取的方法。

在这种情况下,Person的双重继承可能并非我们想要的,因为我们希望建模的关系是 “助教是一个人” 只存在一次;助教是学生且是工作人员,并不意味着他是两次 “人”(除非助教有精神分裂症):Person基类对应一份契约,TeachingAssistant实现了这份契约(上述 “是一个” 关系实际上意味着 “实现了…… 的要求”),而TeachingAssistant只实现一次Person契约。

“只实现一次” 在现实世界中的意义是,TeachingAssistant应该只有一种实现speak的方式,而不是根据TeachingAssistant的Student视角或Worker视角有两种不同的实现方式。(在第一个代码示例中,我们看到Student和Worker都没有重写speak(),所以两个Person子对象实际上行为相同,但这只是一种特殊情况,从C++的角度来看并没有区别。)

如果像这样在继承中引入virtual,问题就迎刃而解了。

struct  Person {
    virtual  ~Person() = default;
    virtual  void  speak() {}
};

// 两个类虚拟继承自Person
struct  Student : virtual  Person {
    virtual  void  learn() {}
};

struct  Worker : virtual  Person {
    virtual  void  work() {}
};

// 助教仍然既是学生也是工作人员
struct  TeachingAssistant : Student, Worker {};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

现在我们可以轻松调用speak()了。

TeachingAssistant::Worker中的Person部分现在与TeachingAssistant::Student中使用的Person实例相同,也就是说,TeachingAssistant在其表示中只有一个共享的Person实例,因此调用TeachingAssistant::speak不再有歧义。此外,由于现在TeachingAssistant只能转换为一个Person实例,所以从TeachingAssistant到Person的直接转换也不再有歧义。

这可以通过虚函数表指针(vtable pointers)来实现。这里不深入细节,使用虚继承后对象大小会增加两个指针,但背后只有一个Person对象,并且不会有歧义。

必须在菱形继承结构的中间层使用virtual关键字。在底部使用它没有帮助。

# 问题18:我们应该一直使用虚继承吗?如果是,为什么?如果不是,又是为什么?

答案显然是否定的,我们不应该总是使用虚继承。按照一种惯常的说法,C++的关键特性之一是只应为使用的功能付出代价。如果你不需要解决虚继承所针对的问题,那就不应该使用它。

虚继承几乎很少有必要。它解决的是我们刚刚提到的菱形继承问题。只有在存在多重继承的情况下才会出现这个问题,而如果你能避免多重继承,那就无需解决这个问题。实际上,许多语言甚至都没有这个特性。

那么,让我们来看看虚继承的主要缺点。

虚继承会给对象初始化和复制带来麻烦。由于 “最派生” 的类负责这些操作,它必须熟知基类结构的所有细节。因此,类之间会出现更复杂的依赖关系,这使得项目结构更加复杂,并且在重构时,你不得不在所有这些类中进行一些额外的修改。所有这些都会导致新的错误,并且使代码可读性变差。

类型转换问题也可能导致错误。你可以通过在原本使用static_cast的地方使用开销较大的dynamic_cast来部分解决这些问题。不幸的是,dynamic_cast的速度要慢得多,如果你在代码中频繁使用它,这意味着你的项目架构还有很大的改进空间。

# 问题19:下面这个示例程序的输出是什么?这是你预期的结果吗?为什么是或为什么不是?

#include  <iostream> 
#include  <memory>

class Animal {
public:
    ~Animal() = default;
    virtual  void  eat(int  quantity) {
        std::cout << "Animal eats " << quantity << std::endl;
    }

    void  speak() {
        std::cout << "Animal speaks" << std::endl;
    }
};

class  Dog  : public Animal {
public:
    void  eat(unsigned  int  quantity) {
        std::cout << "Dog eats " << quantity << std::endl;
    }

    void  speak() {
        std::cout << "Dog speaks" << std::endl;
    }
};

int  main() {
    Dog d;
    d.speak();
    d.eat(42u);

    std::unique_ptr<Animal> a = std::make_unique<Dog>();
    a->speak();
    a->eat(42u);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

如果你把上面这段程序输入到编译器中,很容易得到答案。我希望你首先尝试自己思考一下。

Dog speaks
Dog eats 42
Animal speaks
Animal eats 42
1
2
3
4

如果这是你预期的结果,恭喜!很可能你也知道原因。

如果你没有想出来,并且对上面的结果感到惊讶,也不用担心。再检查一下代码,接着往下读。

原始代码中有两个很容易忽略的小错误,在C++11之前,你只能依靠自己或代码审查人员来发现这些错误。

但从C++11开始,有了override说明符。它可以帮助你显式标记派生类中的某个函数是重写自基类的函数。

std::unique_ptr<Animal> a = std::make_unique<Dog>();
a->speak();
1
2

当我们编写这样的代码时,期望a调用派生类Dog中定义的行为。然而实际执行的却是Animal类中的行为。

由于Dog类的方法签名中缺少override,让我们加上它,然后重新编译。

#include  <iostream> 
#include  <memory>

class Animal {
public:
    ~Animal() = default;
    virtual  void  eat(int  quantity) {
        std::cout << "Animal eats " << quantity << std::endl;
    }

    void  speak() {
        std::cout << "Animal speaks" << std::endl;
    }
};

class  Dog  : public Animal {
public:
    void  eat(unsigned  int  quantity) override {
        std::cout << "Dog eats " << quantity << std::endl;
    }

    void  speak() override {
        std::cout << "Dog speaks" << std::endl;
    }
};

int  main() {
    Dog d;
    d.speak();
    d.eat(42u);

    std::unique_ptr<Animal> a = std::make_unique<Dog>();
    a->speak();
    a->eat(42u);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

根据错误信息,Dog::eat和Dog::speak都没有重写任何函数。

在第一种情况下,这是因为基类中的参数是简单的有符号整数int,而派生类中有一个同名、返回类型相同但参数不同的函数,参数为无符号整数unsigned int。如果我们想要实现多态行为,就必须让它们匹配。

在另一种情况下,我们只是忘记在基类的函数签名中添加virtual关键字,因此该函数无法被重写。

修正这两个错误后,就能得到我们通常期望的行为。

#include  <iostream> 
#include  <memory>

class Animal {
public:
    ~Animal() = default;
    virtual  void  eat(int  quantity) {
        std::cout << "Animal eats " << quantity << std::endl;
    }

    virtual  void  speak() {
        std::cout << "Animal speaks" << std::endl;
    }
};

class  Dog  : public Animal {
public:
    void  eat(int  quantity) override {
        std::cout << "Dog eats " << quantity << std::endl;
    }

    void  speak() override {
        std::cout << "Dog speaks" << std::endl;
    }
};

int  main() {
    Dog d;
    d.speak();
    d.eat(42u);

    std::unique_ptr<Animal> a = std::make_unique<Dog>();
    a->speak();
    a->eat(42u);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

这里得到的经验是,对于想要重写基类函数的函数,应该始终使用override说明符,这样就能在编译时捕获这些不易察觉的错误。

# 问题20:在私有继承(private inheritance)的情况下,能否访问基类的公有(public)和受保护(protected)成员及函数?

继承的访问说明符不会影响实现的继承。实现总是基于函数的访问级别进行继承。继承的访问说明符仅影响类接口的可访问性。

这意味着即使使用私有继承,派生类仍可使用基类的所有公有和受保护变量及函数。

另一方面,基类的这些公有和受保护元素无法通过派生类从外部访问。

如果基类的子类(即孙类)的父类是从该基类私有继承的,那么这个孙类将无法访问基类的成员和函数,即使这些成员和函数原本是受保护的甚至是公有的。

#include  <iostream>

class  Base {
public:
    Base() = default;
    virtual  ~Base() = default;
    virtual  int  x() {
        std::cout << "Base::x()\n";
        return  41;
    }

protected:
    virtual  int  y() {
        std::cout << "Base::y()\n";
        return  42;
    }
};

class  Derived  : private  Base {
public:
    int  x() override {
        std::cout << "Derived::x()\n";
        return  Base::y();
    }
};

class  SoDerived  : public  Derived {
public:
    int  x() override {
        std::cout << "SoDerived::x()\n";
        return  Base::y();
    }
};

int  main() {
    SoDerived* p = new  SoDerived();
    std::cout << p->x() << std::endl;
}
/*
main.cpp: 在成员函数‘virtual int SoDerived::x()’中:
main.cpp:31:12: 错误: ‘class Base Base::Base’在此上下文中是私有的
     31  |        return Base::y();
        |                         ^~~~
main.cpp:19:7: 注意: 在此声明为私有
     19  |  class Derived  : private Base  {
        |              ^~~~~~~
main.cpp:31:19: 错误: ‘Base’不是‘SoDerived’的可访问基类
     31  |        return Base::y();
        |                        ~~~~~~~^~
*/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

# 问题21:私有继承用于什么场景?

首先,让我们简单回顾一下什么是私有继承。

继承的访问说明符不会影响实现的继承。实现总是基于函数的访问级别进行继承。继承的访问说明符仅影响类接口的可访问性。

这意味着即使使用私有继承,派生类仍可使用基类的所有公有和受保护变量及函数。

另一方面,基类的这些公有和受保护元素无法通过派生类从外部访问。

那么这种情况在什么时候有用呢?

我们可能都学过,继承用于表达 “是一个(is - a)” 的关系,对吧?

如果有Car类继承自Vehicle类,我们可以说Car是一种Vehicle。然后Roadster类继承自Car类,它仍然是一种Vehicle,可以访问Vehicle的所有成员(函数)。

但是,如果Vehicle和Car之间的继承是私有的会怎样呢?那么那辆闪亮的小红跑车Roadster将无法访问Vehicle的接口,即使它在中间是从Car类公开继承的。

我们不能再简单地称这种关系为 “是一个” 关系了。

这是一种 “有一个(has - a)” 关系。在这个具体例子中,派生类Car可以访问基类Vehicle,并根据访问级别(受保护或私有)来暴露它。而后者意味着它不会被暴露,它就像一个私有成员。

在受保护的情况下,你可能会说Roadster仍然可以访问Vehicle,这是对的。

但是当使用非公有继承时,你不能把Roadster当作Vehicle来创建。下面这行代码将无法编译:

Vehicle* p = new  Roadster();
1

需要再次强调的是,C++中的非公有继承表达的是 “有一个” 关系,就像组合(composition)一样。

所以,如果我们继续以汽车为例,可以说Car可以私有继承自假设的Engine类,同时它仍然公开继承自Vehicle类。通过这个小小的多重继承示例,你可能已经明白了为什么组合比私有继承更容易维护。

但即使你不打算引入继承树,我认为私有继承也不直观,它与大多数其他语言差异很大,使用起来会让人感到困惑。它本身并没有错,只是维护成本更高。

这正是你可以在C++核心准则中找到的内容:尽可能使用组合,必要时才使用私有继承。

但是什么时候是必要的呢?

根据核心准则,当满足以下条件时,就有合理的使用场景:

  • 派生类必须调用基类的(非虚)函数。
  • 基类必须调用派生类的(通常是纯虚)函数。

# 问题22:能否从构造函数或析构函数中调用虚函数?

从技术上讲,你可以这样做,代码也能编译通过。但这样的代码可能会产生误导,甚至可能导致未定义行为。

在构造基类的过程中尝试调用派生类的函数是很危险的。

#include  <iostream>

class  Base {
public:
    Base() {
        foo();
    }
protected:
    virtual  void  foo() {
        std::cout << "Base::foo\n";
    }
};

class  Derived  : public  Base {
public:
    Derived() {}

    void  foo() override {
        std::cout << "Derived::foo\n";
    }
};

int  main() {
    Derived d;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

输出结果只是Base::foo。

  • 根据约定,派生类构造函数首先会调用基类构造函数。
  • 基类构造函数调用的是基类的成员函数,而不是子类中重写的函数,这会让子类的开发者感到困惑。

如果虚函数也是纯虚函数,就会出现未定义行为。运气好的话,在链接时你会得到一些错误提示。

有什么解决办法呢?

最简单的方法可能就是完全指定你要调用的函数:

Base() {
    Base::foo();
}
1
2
3

从基类中,你只能调用基类的函数。当你在派生类中时,你可以决定调用哪个类的版本。

一种更优雅的解决方案是将虚函数封装在非虚函数中,并且在构造函数 / 析构函数中只使用非虚包装函数。

# 问题23:虚析构函数(virtual destructor)有什么作用?

当对象超出作用域时,会执行其析构函数。析构函数不接受参数,不能被重载,但可以是虚函数。

使用虚析构函数,你可以在不知道对象类型的情况下销毁对象,对象会通过虚函数机制调用正确的析构函数。对于抽象类,析构函数也可以声明为纯虚函数。

派生类的析构函数(无论你是否显式定义)会自动调用基类子对象的析构函数。基类在成员对象之后被析构。在多重继承的情况下,直接基类会按照它们在继承列表中出现顺序的相反顺序被析构。

但我们仍然不知道为什么需要将析构函数声明为虚函数。

这是因为如果你通过指向基类的指针删除派生类的对象,而该基类的析构函数不是虚函数,那么行为是未定义的。相反,基类的析构函数应该声明为虚函数。

将基类析构函数声明为虚函数可确保派生类对象被正确析构,即基类和派生类的析构函数都会被调用。作为一个指导原则,只要类中有虚函数,就应该立即添加一个虚析构函数(即使它什么也不做)。这样可以避免日后出现意外情况。

# 问题24:我们可以从标准容器(如std::vector)继承吗?如果可以,会有什么影响?

标准容器将它们的构造函数声明为公有且非最终的,所以从它们继承是可行的。事实上,这是一种广为人知且常用的技术,用于从强类型容器中获益。

class  Squad  : public  std::vector {
    using  std::vector::vector;
    // ...
};
1
2
3
4

这种做法很简单,也具有可读性,但你会在不同的论坛上发现很多人说这是第八大 “致命罪过”,如果你是一名严谨的开发者,就应该不惜一切代价避免这样做。

他们为什么这么说呢?

主要有两个理由。一是在STL中,算法和容器是被明确区分开来的关注点。二是关于缺少虚析构函数的问题。

但这些真的是合理的担忧吗?也许是,这取决于具体情况。

让我们从缺少虚析构函数这个问题开始分析。这似乎更实际一些。

确实,缺少虚析构函数可能会导致未定义行为和内存泄漏。这两个问题都很严重,但未定义行为更糟糕,因为它不仅可能导致程序崩溃,甚至可能导致难以检测的内存损坏,最终导致应用程序出现奇怪的行为。

但缺少虚析构函数并不会默认导致未定义行为和内存泄漏,只有在你特定的使用派生类方式下才会出现。

如果你通过指向基类的指针删除一个对象,而该基类的析构函数不是虚函数,你就必须面对未定义行为的后果。另外,如果派生对象引入了新的成员变量,还会出现内存泄漏问题。不过,这相对来说是个较小的问题。

另一方面,这也意味着那些因为未定义行为和内存泄漏而坚决反对从std::vector(或任何没有虚析构函数的类)继承的人并不完全正确。

如果你清楚自己在做什么,并且仅使用这种继承来引入强类型向量,而不是为容器引入多态行为和额外状态,那么使用这种技术完全没问题。不过你必须要清楚其中的局限性,尽管在公共库的情况下,这可能不是最佳策略。稍后会详细讨论这一点。

所以另一个主要担忧是,你可能会在新对象中混合使用容器和算法。因为STL的创造者是这么说的,所以这被认为是不好的做法。那又怎样呢?最初设计STL的Alexander Stepanov以及后来为其做出贡献的其他人都是很聪明的人,很有可能他们比我们大多数人都更擅长编程。他们设计的函数和对象在C++社区中被广泛使用,可以说几乎每个人都在使用。

但很可能我们的工作并没有这样的限制,我们不是在为整个C++社区开发东西。我们是在为有特定严格限制的特定应用程序工作。我们的代码不会像这样被复用,永远不会。我们大多数人不是在开发通用库,而是在开发一次性的商业应用程序。

只要我们保持代码的整洁(不管这个 “整洁” 意味着什么),提供一个非通用的解决方案完全没问题。

总之,可以说在应用场景中,为了提供强类型而从容器继承是可行的,只要你不涉及多态。

# 问题25:强类型(strong type)是什么意思,它有什么优点?

强类型通过其名称携带额外信息和特定含义⁵⁴。虽然你可以在任何地方使用布尔值(booleans)或字符串(strings),但它们传递含义的唯一方式是通过变量名。 强类型的主要优点是可读性和安全性。 看看这个函数签名,你可能觉得没问题:

Car::Car(unit32_t horsepower, unit32_t numberOfDoors,
         bool  isAutomatic, bool  isElectric);
1
2

它的参数名比较合理,那问题出在哪呢?看看可能的实例化代码:

auto  myCar{Car(96, 4, false, true)};
1

这是什么意思?天晓得…… 你可能也不清楚!除非你花时间去查看构造函数并仔细思考。有些集成开发环境(IDEs)可以帮你直观显示参数名,就好像它们是Python风格的命名参数一样,但你不应该依赖这个功能。 当然,你可以这样命名变量:

constexpr  unit32_t horsepower = 96;
constexpr  unit32_t numberOfDoors = 4;
constexpr  bool  isAutomatic = false;
constexpr  bool  isElectric = false;

auto  myCar = Car{horsepower, numberOfDoors,
                  isAutomatic, isElectric};
1
2
3
4
5
6
7

现在你能立刻明白每个变量代表什么。你需要往上看几行才能知道具体的值,但一切都一目了然。另一方面,这样做需要意志力和自律。你无法强制要求别人这么做。当然,你可以成为一个严格的代码审查员,但你不可能发现每一个问题,而且你也不可能一直都在审查代码。 强类型就是来帮你的!想象一下这样的函数签名:

Car::Car(Horsepower hp, DoorsNumber numberOfDoors,
         Transmission transmission, Fuel fuel);
1
2

那么之前的实例化代码可以写成这样:

auto  myCar{Car{Horsepower{98u}}, DoorsNumber{4u},
           Transmission::Automatic, Fuel::Gasoline};
1
2

这个版本比原来那个可读性很差的版本更长、更啰嗦,但比为每个参数都引入命名恰当的临时变量的版本要短得多。 所以强类型的一个优点是可读性,另一个优点是安全性。使用强类型后,混淆值的情况会大大减少。在前面的例子中,你很容易把车门数量和性能参数弄混,但使用强类型后,这种混淆实际上会导致编译错误。

# 问题26:解释短路求值(short - circuit evaluation)

下面这段代码的输出是什么,为什么?

#include  <iostream> 

bool  a() {
    std::cout << "a|";
    return  false;
}

bool  b() {
    std::cout << "b|";
    return  true;
}

int  main() {
    if  (a() && b()) {
        std::cout << "main";
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

输出将只是a|。 在main()函数中,在if语句里,首先调用子表达式a(),它会输出a|。由于a()的返回值为false,所以整个表达式不可能为true,于是第二个子表达式b()就不会被执行。 在C++和许多其他编程语言中都存在短路求值,也就是说,一旦确定整个条件不可能满足,求值就会停止。 由于这两个子表达式是通过&&连接的,两边都必须为true,整个表达式才为true:

a() b() a() && b()
false false false
false true false
true false false
true true true

如果a()不为true,为了节省宝贵的CPU周期,表达式的其余部分将不再求值。 除了明显的可读性之外,短路求值也是条件中的调用不应有副作用(改变对象/变量的状态)的一个原因。当计算一个布尔表达式时,它应该只返回一个布尔值。调用者无法确定这个表达式是否会被求值。 我知道这听起来很简单,可能我们在刚开始学习C++时就都学过了。但还有一点需要记住。当你为自己的类定义逻辑运算符时,比如重载operator&&,你就会失去短路求值的特性。 如果有类似myObjectA && myObjectB这样的表达式,即使第一个操作数myObjectA求值为false,两边也都会被求值。 原因是在调用重载的operator&&之前,重载函数的左操作数和右操作数都会被求值。函数调用是一个序列点,因此在进行函数调用之前,所有的计算和副作用都已经完成。这是一种积极求值策略。

# 问题27:什么是析构函数(destructor),如何对其进行重载?

析构函数是类的一个特殊成员函数。它与类同名,并且前面还带有一个波浪号(~),例如~MyClass。只要对象超出作用域,若存在析构函数,它就会自动执行。 析构函数没有参数,不能是const、volatile或static的,并且和构造函数一样,它没有返回类型。 默认情况下,析构函数由编译器生成,但你需要注意“五法则”(rule of 5)的应用。如果手动实现了其他4个特殊函数中的任何一个,编译器就不会生成析构函数。快速回顾一下,除析构函数外,特殊函数还有:

  • 拷贝构造函数(copy constructor)
  • 拷贝赋值运算符(copy assignment operator)
  • 移动构造函数(move constructor)
  • 移动赋值运算符(move assignment operator)

如果类获取了必须释放的资源,就需要析构函数。记住,你应该编写遵循资源获取即初始化(RAII,Resource Acquisition Is Initialization)原则的类,这意味着在构造时获取资源,在析构时释放资源。这些资源可以是释放连接、关闭文件句柄、保存事务等。 如前所述,析构函数没有参数,不能是const、volatile或static的,并且只能有一个析构函数。因此,它不能被重载。 另一方面,析构函数可以是虚函数(virtual),但这是下一个问题要讨论的内容。

# 问题28:下面这段代码的输出是什么,为什么?

#include  <iostream> 

class A  {
public:
    int  value() { return  1; }
    int  value() const  { return  2; }
};

int  main() {
    A a;
    const  auto  b = a.value();
    std::cout << b << '\n';
}
1
2
3
4
5
6
7
8
9
10
11
12
13

输出将是1。如果a被声明为const,而b不是,结果将是2。实际上,结果与b是否为const完全无关。 为什么会这样呢? 如果一个函数有两个重载版本,一个是const版本,另一个不是,编译器会根据对象是否为const来选择调用哪个版本。 另一方面,这与返回变量的const属性无关。 这只是对后续内容的一个简单介绍,接下来我们将讨论const属性、const正确性以及如何使用const关键字。

# 问题29:如何在C++中使用= delete说明符?

这个问题也可以表述为:如何禁止函数调用时的隐式类型转换? 假设有一个处理整数的函数。这里说的是整数,比如它的参数表示一辆汽车能坐多少人。可能是2人,有一些特殊的三座车,豪华车可能坐4人,大多数车能坐5人。不会是4.9人,也不是5.1人,甚至不会是5个半人,就是5人。我们不涉及人体部分(这种不合理的数值)。 那么如何确保函数只接收整数作为参数呢? 显然,你会使用一个整型参数。它可以是int,甚至unsigned,或者仅仅是short,有很多选择。你可能还会在文档中注明numberOfSeats参数应该是一个整数。 很好! 那么,如果客户端调用时仍然传入一个浮点数会怎样呢?

#include  <iostream> 

void  foo(int  numberOfSeats) {
    std::cout << "Number of seats: " << numberOfSeats << '\n';
    //  . . .
}

int  main() {
    foo(5.6f);
}
/*
Number of seats:  5 
*/
1
2
3
4
5
6
7
8
9
10
11
12
13

浮点型参数被接受并转换为整数。甚至不能说它是四舍五入,它是被隐式转换为整数的。 你可能会说,在某些情况下这没问题。但在其他情况下,这种行为是完全不可接受的。 在这种情况下,如何避免这个问题呢?显然,你可以在调用方进行处理,但:

  • 如果foo函数经常被使用,每次都进行检查会很繁琐;
  • 如果foo是外部使用的应用程序编程接口(API)的一部分,你无法控制调用方。

从C++11开始,我们可以使用delete说明符来限制某些类型的拷贝、移动,实际上甚至可以限制其多态使用。 但是= delete的用途不止于此。它可以应用于任何函数,无论是成员函数还是自由函数。 如果你不想允许从浮点数进行隐式转换,可以简单地删除接受float类型参数的foo重载版本:

#include  <iostream> 

void  foo(int  numberOfSeats) {
    std::cout << "Number of seats: " << numberOfSeats << '\n';
    //  . . .
}

void  foo(double) = delete ;

int  main() {
    foo(5.6f);
}
/*
main.cpp:  In  function  'int main()':
main.cpp:10:6:  error: use of  deleted  function  'void  foo(d\
ouble)'
     10  |      foo(5.6f);
        |      ~~~^~~~~~
main.cpp:8:6: note:  declared here
      8  |  void  foo(double)  =  delete;
        |            ^~~
 */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

就是这样。通过删除函数的某些重载版本,你可以禁止从某些类型隐式转换为你期望的类型。 现在,你完全可以控制用户可以向你的API传递什么样的参数了。

上次更新: 2025/03/27, 20:29:48
第二章 关键字static及其不同用法
第四章 Lambda函数

← 第二章 关键字static及其不同用法 第四章 Lambda函数→

最近更新
01
第一章 auto与类型推导
03-27
02
第二章 关键字static及其不同用法
03-27
03
C++语言面试问题集锦 目录与说明
03-27
更多文章>
Copyright © 2024-2025 沪ICP备2023015129号 张小方 版权所有
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式