Cpp复习 Chapter 5 类的继承、虚函数、抽象基类


Cpp系列笔记目录

【Cpp筑基】一、内联函数、引用变量、函数重载、函数模板
【Cpp筑基】二、声明 vs 定义、头文件、存储持续性作用域和链接性、名称空间
【Cpp筑基】三、对象和类
【Cpp筑基】四、重载运算符、友元、类的转换函数
【Cpp筑基】五、类的继承、虚函数、抽象基类


1. 类的继承

在C++中,类的继承(inheritance)是一种创建新类(派生类)的方式,该新类可以从现有的类(基类)中继承属性和方法。继承是面向对象编程的一个核心特性,允许代码复用并提供了实现多态性的基础。从一个类派生出另一个类时,原始类被称为基类,继承类被称为派生类。

1.1 基本语法

C++中,继承使用的是冒号:来指明基类。派生类从基类继承成员(成员变量和成员函数)。语法如下:

1
2
3
4
5
6
7
class BaseClass {
// 基类成员
};

class DerivedClass : access-specifier BaseClass {
// 派生类成员
};

其中access-specifier用于指定访问控制权限,可以指定三种访问控制权限:

  • public继承:基类的public成员在派生类中保持为publicprotected成员保持为protectedprivate成员不可访问。
  • protected继承:基类的public成员和protected成员在派生类中都变为protectedprivate成员不可访问。
  • private继承:基类的public成员和protected成员在派生类中都变为privateprivate成员不可访问。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Base {
public:
int publicVar;
protected:
int protectedVar;
private:
int privateVar;
};

class BasePlus : public Base {
// publicVar -> public
// protectedVar -> protected
// privateVar -> 不可访问
};

在完成继承之后,派生类储存了基类的数据成员(派生类继承了基类的实现);派生类对象可以使用基类的方法(派生类继承了基类的接口)。

1.2 派生类的构造和析构函数

在C++中的继承关系中,构造函数和析构函数的调用顺序和方式有其特定的规则。这些规则确保了基类和派生类的对象在构造和销毁时都能正确初始化和清理。下面将详细介绍这些规则以及构造和析构函数在继承中的使用方式

  • 构造函数:==派生类的构造函数调用时,会首先调用基类的构造函数。基类构造函数可以在初始化列表中显式调用==。
  • 析构函数:==析构函数的调用顺序与构造函数相反,首先调用派生类的析构函数,然后自动调用基类的析构函数==。

派生类不能直接访问基类中的私有private成员,而必须通过基类方法进行访问(即必须使用基类的方法来访问私有的基类成员变量),这就意味着==派生类的构造函数必须使用基类的构造函数==。当你创建派生类的对象时,基类的构造函数总是会被调用。这是因为派生类继承了基类的成员,基类部分需要在派生类的构造函数执行之前被初始化。

显式调用:在派生类的构造函数中,你可以通过初始化列表显式调用基类的某个构造函数。这在你需要传递参数给基类的构造函数时是必要的。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Base {
public:
Base(int x) {
// 基类构造函数
}
};

class Derived : public Base {
public:
Derived(int x, int y) : Base(x) { // 显式调用基类构造函数
// 派生类构造函数
}
};

隐式调用:如果你在派生类的构造函数中没有显式调用基类的构造函数,编译器会自动调用基类的默认构造函数(如果存在)。但如果基类没有默认构造函数,而你又没有在派生类中显式调用基类的其他构造函数,那么代码将无法编译。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Base {
public:
Base() {
// 基类默认构造函数
}
};

class Derived : public Base {
public:
Derived() {
// 派生类构造函数,隐式调用Base()
}
};

派生类构造函数的要点如下:

  • 首先创建基类对象;
  • 派生类构造函数应通过成员初始化列表将基类信息传递给基类构造函数;
  • 派生类构造函数应初始化派生类新增的数据成员。

举个例子:

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
#include <iostream>
#include <string>

// 基类:Animal
class Animal {
protected:
std::string name;
int age;

public:
Animal(const std::string& name, int age) : name(name), age(age) {
std::cout << "Animal Constructor: " << name << std::endl;
}
};

class Mammal : public Animal {
protected:
std::string furColor;

public:
// 使用基类的构造函数来初始化基类的成员,然后初始化派生类的成员
Mammal(const std::string& name, int age, const std::string& furColor)
: Animal(name, age), furColor(furColor) {
std::cout << "Mammal Constructor: " << name << std::endl;
}
};

1.3 基类和派生类的关系

派生类和基类之间存在一些关系:

  • 派生类可以使用基类的非私有private方法;
  • 基类指针可以在不进行显示类型转换的情况下指向派生类;
  • 基类引用可以在不进行显示类型转换的情况下引用派生类;
  • 基类指针或引用只能调用基类的方法,而不能调用派生类的方法。(这一点非常好理解,因为派生类会存在一些基类没有的成员变量,当派生类方法可能会操作这些变量,所以基类方法不能调用派生类的方法)

举个例子:

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
// Animal是基类, Dog是派生类
#include <iostream>
#include <string>
// 基类:Animal
class Animal {
protected:
std::string name;
public:
Animal(const std::string& name) : name(name) {}
void makeSound() const {
std::cout << name << " makes a sound!" << std::endl;
}
void displayInfo() const {
std::cout << "Animal Name: " << name << std::endl;
}
};
// 派生类:Dog
class Dog : public Animal {
public:
Dog(const std::string& name) : Animal(name) {}
// 重写基类方法
void makeSound() const {
std::cout << name << " barks!" << std::endl;
}
void wagTail() const {
std::cout << name << " is wagging its tail!" << std::endl;
}
};
int main() {
Dog myDog("Buddy");
// 1. 基类指针可以指向派生类对象
Animal* animalPtr = &myDog;
// 2. 基类引用可以引用派生类对象
Animal& animalRef = myDog;
// 调用基类方法
animalPtr->makeSound(); // 输出: Buddy makes a sound!
animalRef.displayInfo(); // 输出: Animal Name: Buddy
// 注意:通过基类指针或引用,不能直接调用派生类的方法
// animalPtr->wagTail(); // 错误:Animal类没有wagTail方法
return 0;
}

1.4 多态公有继承

派生类可能会遇到这样的情况,希望同一个方法在派生类和基类中的行为是不同的,换句话来说,方法的行为应该取决于调用该方法的对象,这种较为复杂的行为称为多态——具有多种形态,即同一个方法随上下文而异。有两种方式可以实现多态公有继承:

  • 在派生类中==重写override,也称为覆盖==基类方法
  • 使用虚函数(在下一节中介绍)

在派生类中重写基类方法,如上面的例子中,我们在派生来Dog中覆盖了基类Animal中的makeSound()方法。

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
class Animal {
public:
...
void makeSound() const {
std::cout << name << " makes a sound!" << std::endl;
}
};
// 派生类:Dog
class Dog : public Animal {
public:
...
// 覆盖基类方法
void makeSound() const {
std::cout << name << " barks!" << std::endl;
}
};

int main(){
Dog myDog("Buddy");
Animal* animalPtr = &myDog;
Dog* dogPtr = &myDog;
// 基类指针调用基类方法,不能调用派生类方法
animalPtr->makeSound(); // 输出: Buddy makes a sound!
// 派生类指针调用派生类方法
dogPtr->makeSound(); // 输出: Buddy barks
}

这里简单区别一下重载与覆盖

  • ==重载(Overloading)==:在同一个类中,具有相同名称但参数列表不同的方法被称为重载。
  • ==覆盖(Overriding)==:派生类中的方法与基类的方法具有相同的名称和参数列表,但派生类的方法会“覆盖”基类的方法。这种覆盖通常通过virtual关键字来实现动态多态性。
    如果你不使用virtual,那么派生类的方法虽然可以覆盖基类的方法,但当你通过基类指针或引用调用这个方法时,不会调用派生类的方法,而是调用基类的方法。这是因为在没有virtual的情况下,方法调用是==静态绑定==的。

2. 虚函数

虚函数(Virtual Function)是一个在基类中使用 virtual 关键字声明的成员函数,它允许派生类重写这个函数。虚函数的主要目的是支持==运行时多态==性,使得通过基类指针或引用可以调用派生类的重写版本,而不是基类的版本。

在上面的例子中,我们希望当我们使用基类指针指向派生类对象的时候,能够调用派生类的方法,这样我们就可以使用基类指针来管理派生类的对象了。这就需要用到virtual关键字所代表的虚函数,我们将上述例子中的makeSound()函数改写成虚函数的形式,如下:

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
// Animal是基类, Dog,Cat是派生类
#include <iostream>
#include <string>
// 基类:Animal
class Animal {
protected:
std::string name;
public:
Animal(const std::string& name) : name(name) {}
virtual void makeSound() const {
std::cout << name << " makes a sound!" << std::endl;
}
void displayInfo() const {
std::cout << "Animal Name: " << name << std::endl;
}
};
// 派生类:Dog
class Dog : public Animal {
public:
Dog(const std::string& name) : Animal(name) {}
// 重写基类方法
void makeSound() const override {
std::cout << name << " barks!" << std::endl;
}
void wagTail() const {
std::cout << name << " is wagging its tail!" << std::endl;
}
};
// 派生类:Cat
class Cat : public Animal {
public:
Cat(const std::string& name) : Animal(name) {}
// 重写基类方法
void makeSound() const override {
std::cout << name << " miao!" << std::endl;
}
};
int main() {
Dog myDog("Buddy");
Cat myCat("Kitty");
// 1. 基类指针可以指向派生类对象
Animal* animalPtr = &myDog;
// 2. 基类引用可以引用派生类对象
Animal& animalRef = myCat;
// 调用基类方法
animalPtr->makeSound(); // 输出: Buddy barks!
animalRef.makeSound(); // 输出: Kitty miao!
return 0;
}

需要注意上面例子中virtualoverride关键字的用法,virtual关键字用于指定函数在运行时支持动态绑定,这意味着通过基类指针或引用调用该函数时,会调用派生类中对应的重写函数,而不是基类版本。override关键字用于派生类的成员函数定义中,指示该函数是对基类中虚函数的重写,但注意在某些Cpp标准中,override不是必须的,但是尽量需要加上override用于指定该成员函数是重写基类的虚函数。

2.1 静态联编与动态联编

程序调用函数时,将使用哪个可执行代码呢?编译器负责回答这个问题,将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编(binding),在C语言中,这非常简单,因为每个函数名都对应一个不同的函数,在Cpp中,由于函数重载的缘故,这项任务更复杂,编译器必须查看函数参数以及函数名才能确定使用哪个函数,然而,C/C++编译器可以在编译过程中完成这种联编,在编译过程中进行联编被称为==静态联编(static binding)==。然而虚函数的出现让这项工作变得困难,所以编译器必须能够在程序运行时选择正确的虚函数方法的代码,这被称为==动态联编(dynamic binding)==。

这里存在一个类型提升的问题,==将派生类引用或指针转为基类引用或指针被称为向上强制转换(upcasting)==,这是因为派生类是继承自基类,拥有基类对象的所有数据成员和成员函数,这种转换是可以允许的,举个例子,现在有基类Animal和派生类Cat,我们可以这样做

1
2
3
void useBasePtr(Animal an);
Cat mycat;
useBasePtr(mycat); //相当于,(Animal)mycat,将mycat对象从Cat类型转化为Animal类型了

相反的过程——==将基类指针或引用转换为派生类指针或引用被称为向下强制转换(downcasting)==,如果不使用显示类型转换,则向下类型转换是不被允许的。举一个完整的例子:

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
// Animal是基类, Dog是派生类
#include <iostream>
#include <string>
// 基类:Animal
class Animal {
protected:
std::string name;
public:
Animal(const std::string& name) : name(name) {}
virtual void makeSound() const {
std::cout << name << " makes a sound!" << std::endl;
}
};
// 派生类:Cat
class Cat : public Animal {
public:
Cat(const std::string& name) : Animal(name) {}
// 重写基类方法
void makeSound() const override {
std::cout << name << " miao!" << std::endl;
}
};
void fr(Animal* an) {
an->makeSound();
};
void fp(Animal& an) {
an.makeSound();
}
void fv(Animal an) {
an.makeSound();
}
int main() {
Cat myCat("Kitty");
fr(&myCat); // use Cat::makeSound()
fp(myCat); // use Cat::makeSound()
fv(myCat); // use Animal::makeSound(),向上强制转换
return 0;
}

2.2 虚函数表与虚函数指针

在C++中,虚函数的实现依赖于两个重要的概念:虚函数表(Virtual Table,vtable)和虚函数指针(Virtual Table Pointer,vptr)。它们是实现动态联编和多态性的核心机制。

虚函数表是编译器为每一个包含虚函数的类生成的一个隐式结构。这个结构包含了指向该类的虚函数实现的指针列表。每个包含虚函数的类(或派生类)都会有一个虚函数表。

特点

  • 虚函数表是一个表格,包含了该类中所有虚函数的地址。
  • 如果一个类没有虚函数,那么它不会有虚函数表。
  • 派生类继承了基类的虚函数表,并且可以覆盖或扩展虚函数表。

工作方式

  • 当程序运行时,如果通过基类指针或引用调用虚函数,编译器会查找对象的虚函数表,并使用其中的函数指针调用实际的函数实现。

虚函数指针是每个对象中隐含的指针,指向该对象所属类的虚函数表。每个包含虚函数的对象都有一个虚函数指针(vptr),这个指针在对象的构造函数中被自动初始化,指向正确的虚函数表。

特点

  • vptr是对象的一部分,通常由编译器隐式管理。
  • 当对象创建时,构造函数会设置vptr指向正确的虚函数表。
  • 在派生类对象中,vptr指向派生类的虚函数表。

工作方式

  • 当对象调用虚函数时,编译器通过vptr查找到对应的虚函数表,并根据表中的指针调用适当的函数实现。
  • 如果派生类重写了基类的虚函数,派生类的虚函数表会指向新的函数实现,而不是基类的实现。

2.2 构造函数与析构函数,谁应该是虚函数?

==构造函数不能是虚函数==,创建派生类对象时,将调用派生类的构造函数,而不是基类的构造函数,然后,派生类的构造函数将使用基类的构造函数,这种顺序不同于继承机制。因此,派生类不继承基类的构造函数,所以将构造函数声明为虚virtual的没有意义。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Base {
public:
Base() {
std::cout << "Base Constructor" << std::endl;
}
virtual void show() {
std::cout << "Base show()" << std::endl;
}
};

class Derived : public Base {
public:
Derived() {
std::cout << "Derived Constructor" << std::endl;
}
void show() override {
std::cout << "Derived show()" << std::endl;
}
};

int main() {
Derived obj; // 构造顺序:Base Constructor -> Derived Constructor
return 0;
}

==析构函数应该是虚函数==,原因是在面向对象编程中,如果通过基类指针删除一个派生类对象,而基类的析构函数不是虚函数,程序将只调用基类的析构函数,派生类的析构函数不会被调用。这会导致派生类中动态分配的资源没有被正确释放,造成内存泄漏等问题。

举个例子:

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
#include <iostream>

class Base {
public:
Base() {
std::cout << "Base Constructor" << std::endl;
}
virtual ~Base() {
std::cout << "Base Destructor" << std::endl;
}
};

class Derived : public Base {
public:
Derived() {
std::cout << "Derived Constructor" << std::endl;
}
~Derived() {
std::cout << "Derived Destructor" << std::endl;
}
};

int main() {
Base* basePtr = new Derived();
delete basePtr; // 正确调用Base和Derived的析构函数
return 0;
}

3. 抽象基类

在C++中,纯虚函数和抽象基类是实现抽象和接口的一种机制,用于创建不能直接实例化的类,并强制派生类提供具体的实现。这是面向对象编程中的关键概念,尤其在设计框架、库和接口时非常有用。

3.1 纯虚函数

纯虚函数是一种没有实现的虚函数。它只在基类中声明,必须在派生类中被重写。==纯虚函数的存在使得一个类成为抽象类,不能被直接实例化==。

特点:

  • 纯虚函数在声明时没有函数体(即可以不提供抽象基类的函数体),用=0来表示。
  • 任何包含一个或多个纯虚函数的类都是抽象类,不能直接创建该类的对象。

举个例子:

1
2
3
4
5
// 拥有纯虚函数就称为了抽象基类
class Base {
public:
virtual void display() = 0; // 纯虚函数,没有实现
};

3.2 抽象基类

抽象基类是指包含至少一个纯虚函数的类。抽象基类不能直接实例化,它的目的是为派生类提供接口或基本行为。派生类必须实现抽象基类中的所有纯虚函数,才能实例化派生类对象。

特点:

  • 抽象基类通常用作接口定义,强制派生类实现特定的功能。
  • 抽象基类可以包含普通的成员函数和数据成员,这些成员可以在派生类中继承。
  • 抽象基类无法实例化,但可以用于定义指向派生类对象的指针或引用,从而实现多态性。

举个例子:

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
#include <iostream>
// 抽象基类
class Shape {
public:
virtual void draw() = 0; // 纯虚函数,派生类必须实现
virtual ~Shape() = default; // 虚析构函数
};
// 派生类,实现了抽象基类中的纯虚函数
class Circle : public Shape {
public:
void draw() override {
std::cout << "Drawing a circle" << std::endl;
}
};
// 派生类,实现了抽象基类中的纯虚函数
class Square : public Shape {
public:
void draw() override {
std::cout << "Drawing a square" << std::endl;
}
};

int main() {
Shape* shape1 = new Circle();
Shape* shape2 = new Square();
shape1->draw(); // 输出:Drawing a circle
shape2->draw(); // 输出:Drawing a square
delete shape1;
delete shape2;
return 0;
}

其中= default是 C++11 引入的一种语法,用于指示编译器生成默认的构造函数、析构函数或拷贝/移动操作符。这是一种显式要求编译器为我们生成函数体的方式。

Reference

《C++ Primier Plus》