继承和模板类 C++
CS106L p16 Part 5.6 Inheritance and Template Classes
继承
C++中的继承概述
继承是面向对象编程(OOP)中的一种机制,它允许创建一个新的类(派生类),该类可以继承并扩展或修改已有类(基类)的属性和方法。在C++中,继承提供了一种组织代码的方式,使得可以在不重复代码的情况下重用和扩展已有的类。
继承的基本概念如下:
- 基类(Base Class):也称为父类,它包含一些通用的属性和方法。
- 派生类(Derived Class):也称为子类,它继承了基类的成员,并可以在此基础上进行扩展或修改。
C++支持单继承(一个派生类只能继承一个基类)和多继承(一个派生类可以继承多个基类)。C++的继承是通过使用:
符号来实现的。
继承的基本语法
在C++中,继承的基本语法结构如下:
1 |
|
DerivedClass
是派生类的名称。BaseClass
是基类的名称。accessSpecifier
是继承方式,可以是public
、protected
或private
,它决定了基类成员在派生类中的可访问性。
继承的访问控制
C++允许通过三种访问控制修饰符来指定基类成员在派生类中的可访问性:
public
继承:基类的public
成员在派生类中依然是public
,protected
成员在派生类中是protected
,private
成员不可访问。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17class Base {
public:
int publicVar;
protected:
int protectedVar;
private:
int privateVar;
};
class Derived : public Base {
public:
void show() {
std::cout << publicVar << std::endl; // 可以访问
std::cout << protectedVar << std::endl; // 可以访问
// std::cout << privateVar << std::endl; // 错误,不能访问private成员
}
};protected
继承:基类的public
和protected
成员会变为派生类中的protected
,private
成员不可访问。private
继承:基类的所有成员(无论是public
、protected
还是private
)都会变为派生类中的private
,这意味着它们不能被派生类外部访问。
继承的类型
单继承(Single Inheritance):一个派生类只继承自一个基类。
1
2
3
4
5
6
7
8
9class Base {
public:
void show() { std::cout << "Base class" << std::endl; }
};
class Derived : public Base {
public:
void display() { std::cout << "Derived class" << std::endl; }
};多继承(Multiple Inheritance):一个派生类可以继承自多个基类。
1
2
3
4
5
6
7
8
9
10
11
12
13
14class Base1 {
public:
void show1() { std::cout << "Base1" << std::endl; }
};
class Base2 {
public:
void show2() { std::cout << "Base2" << std::endl; }
};
class Derived : public Base1, public Base2 {
public:
void display() { std::cout << "Derived class" << std::endl; }
};虚继承(Virtual Inheritance):解决多继承中的“钻石问题”。虚继承确保派生类只继承基类的一份数据。通过
virtual
关键字来声明虚继承。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19class Base {
public:
int baseVar;
};
class A : virtual public Base {
public:
void showA() { std::cout << "Class A" << std::endl; }
};
class B : virtual public Base {
public:
void showB() { std::cout << "Class B" << std::endl; }
};
class Derived : public A, public B {
public:
void showDerived() { std::cout << "Derived Class" << std::endl; }
};
构造函数与析构函数的继承
基类构造函数:派生类通常不直接调用基类构造函数,而是通过派生类的构造函数隐式调用基类的构造函数。如果基类没有默认构造函数,派生类必须显式调用基类的构造函数。
1
2
3
4
5
6
7
8
9
10
11
12
13class Base {
public:
Base(int x) { std::cout << "Base class constructor, x = " << x << std::endl; }
};
class Derived : public Base {
public:
Derived(int x) : Base(x) { std::cout << "Derived class constructor" << std::endl; }
};
int main() {
Derived d(10); // 会调用Base(int)构造函数
}基类析构函数:如果派生类的对象被销毁,析构函数会从派生类开始,逐步调用基类的析构函数。如果基类的析构函数是虚拟的,C++将确保在删除通过基类指针指向派生类的对象时,能够正确地调用派生类和基类的析构函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14class Base {
public:
virtual ~Base() { std::cout << "Base class destructor" << std::endl; }
};
class Derived : public Base {
public:
~Derived() { std::cout << "Derived class destructor" << std::endl; }
};
int main() {
Base* b = new Derived();
delete b; // 会先调用Derived类的析构函数,再调用Base类的析构函数
}
方法重写与多态
继承使得派生类可以重写基类的方法。这种现象称为方法重写(Override)。如果基类中的方法被标记为virtual
,则C++会启用运行时多态(Runtime Polymorphism),即通过基类指针或引用调用派生类的重写方法。
1 |
|
总结
C++中的继承机制支持代码复用和扩展,可以帮助实现类的层次化结构。通过继承,派生类可以继承基类的属性和方法,进而对其进行修改或扩展,支持单继承和多继承(通过虚继承解决多继承中的问题)。此外,C++支持方法的重写与多态,使得面向对象编程更具灵活性和表达力。在实际编程中,理解并合理运用继承能大大提高代码的可复用性和维护性。
虚继承
虚继承(Virtual Inheritance)是C++中多重继承的一种特殊机制,用于解决多重继承中的“菱形继承问题”(Diamond Problem)。为了更好地理解虚继承,我们首先需要了解多重继承、菱形继承问题的概念以及虚继承的解决方案。
1. 多重继承与菱形继承问题
1.1 多重继承
在C++中,类可以继承多个基类,称为多重继承。例如:
1 |
|
在上面的例子中,类 B
和类 C
都继承自 A
,这是一个典型的多重继承。虽然多重继承在某些情况下是非常有用的,但它可能带来一些问题,尤其是在多个基类之间存在共同部分时。
1.2 菱形继承问题
菱形继承问题是指,当一个类通过多个路径继承同一个基类时,基类的成员会被重复继承,从而导致冲突或冗余。这种问题通常出现在多个派生类继承自同一个基类,然后再被另一个类继承的情况下。
考虑以下示例:
1 |
|
在上面的代码中,B
和 C
都继承了 A
,而 D
又继承了 B
和 C
。这就导致了 D
类中有两个 A
类的副本,这样会带来以下问题:
- 数据冗余:类
D
会有两个A
类的副本,两个x
。 - 歧义问题:如果
D
类要访问x
,会产生歧义,因为编译器无法确定是通过B
继承来的A
还是通过C
继承来的A
。
2. 虚继承的概念
虚继承就是通过关键字 virtual
来标明基类是以虚拟方式继承的,目的就是解决菱形继承中基类的重复继承问题。虚继承的基本思想是:多个派生类之间共享同一个基类的实例,从而避免重复继承,确保只有一个基类的实例存在。
2.1 语法
要实现虚继承,需要在派生类声明继承时使用 virtual
关键字。例如:
1 |
|
在上面的代码中,B
和 C
都是虚继承自 A
,而 D
则继承了 B
和 C
。这样,D
只有一个 A
类的实例。
2.2 如何解决菱形继承问题
虚继承通过在 B
和 C
中将 A
类声明为虚继承,确保 D
类只拥有一个 A
类的实例。即使 B
和 C
都从 A
继承,最终只有一个 A
的副本存在,并且只有一个 x
成员。
3. 虚继承的实现原理
虚继承背后的实现机制比常规继承复杂,因为编译器需要确保不同路径继承的基类共享同一实例。为了实现这一点,编译器需要引入一个虚拟基类表(Virtual Base Table,VBT)。这个表用于维护不同派生类对虚拟基类的引用关系。
具体来说,虚继承会影响构造函数的调用顺序和数据布局:
- 构造顺序:虚基类的构造函数会在最底层派生类的构造函数调用之前进行调用。这个顺序确保了虚拟基类的唯一实例在派生类构造之前被正确初始化。
- 内存布局:虚继承还可能影响对象的内存布局,因为虚基类的成员需要被唯一实例共享,所以虚基类可能会通过指针或其他机制来访问。
4. 示例与行为分析
以下是一个更详细的例子来说明虚继承的行为:
1 |
|
输出结果:
1 |
|
分析:
- 虚基类
A
的构造函数被最先调用,确保A
的实例在派生类之前构造。 B
和C
的构造函数分别被调用,而不是D
自己去调用A
的构造函数。- 最后,
D
的构造函数被调用。
5. 虚继承的优缺点
优点:
- 解决菱形继承问题:虚继承通过共享基类的实例,避免了冗余的数据成员和可能的歧义。
- 提高代码复用性:多个派生类可以共同使用同一个基类实例,避免了多次初始化和冗余数据。
缺点:
- 性能开销:虚继承需要额外的指针或表格来管理虚基类的共享实例,因此可能带来一定的性能开销。
- 复杂性:虚继承使得类的继承关系变得复杂,构造函数的调用顺序和内存布局需要更加小心。
总结
虚继承是C++提供的一个机制,主要用于解决多重继承中的菱形继承问题,它通过确保多个派生类共享同一个基类实例,避免了冗余和歧义。尽管虚继承增加了程序的复杂性,并可能带来一定的性能开销,但它在处理复杂的继承关系时是非常有用的,尤其是在大规模的面向对象设计中。
虚函数
虚函数(Virtual Function)详解
在C++中,虚函数是一种成员函数,其目的是通过基类指针或引用来动态调用派生类的重写版本,从而实现运行时多态性。通过虚函数,C++支持动态绑定,即在程序运行时确定调用哪个函数,而不是在编译时静态绑定。
虚函数是面向对象编程中的重要特性之一,使得同一类型的对象能够表现出不同的行为,增强了代码的扩展性和可维护性。
1. 虚函数的基本概念
1.1 什么是虚函数?
虚函数是基类中声明为 virtual
的成员函数。一个基类中的成员函数如果被声明为虚函数,那么在派生类中可以对其进行重写(覆盖),并且通过基类指针或引用来调用派生类的版本。
虚函数的语法如下:
1 |
|
1.2 虚函数的工作原理
虚函数的核心在于动态绑定(或称为晚绑定)。当通过基类的指针或引用调用虚函数时,C++不会立即调用编译时确定的函数,而是根据对象的实际类型(即指针或引用指向的派生类类型)来调用相应的函数。
1 |
|
即使 basePtr
是 Base*
类型,C++会根据它实际指向的对象类型(此处为 Derived
)来调用 Derived
中的 display()
方法,而不是 Base
中的版本。
2. 虚函数的实现细节
虚函数的实现依赖于一个称为虚函数表(Virtual Function Table,VTable)的机制。每个包含虚函数的类都会有一个虚函数表,该表保存指向类的虚函数的指针。对于一个类的每个对象,它都会包含一个指向虚函数表的指针,称为虚指针(VPointer)。
2.1 虚函数表(VTable)
虚函数表是一个数组,其中每个元素是指向虚函数的指针。当类中有虚函数时,编译器会为该类生成一个虚函数表,并将所有虚函数的地址存储在表中。
- 每个包含虚函数的类都有一个虚函数表。
- 每个对象会有一个隐式的指针,指向该类的虚函数表。
- 当调用虚函数时,程序会通过虚指针查找虚函数表,并跳转到相应的函数地址。
2.2 虚指针(VPointer)
虚指针是编译器自动为每个对象添加的一个指针,用于指向虚函数表。当通过指针或引用调用虚函数时,程序会利用虚指针查找虚函数表,进而确定要调用哪个派生类中的虚函数。
3. 虚函数的应用
虚函数在C++中的主要作用是实现运行时多态性,从而使得程序在运行时能够根据对象的实际类型执行不同的操作。常见的应用场景包括:
3.1 多态性
1 |
|
在上述示例中,Animal
类有一个虚函数 sound()
,而 Dog
和 Cat
类分别重写了这个函数。通过基类指针调用虚函数时,C++会根据对象的实际类型来决定调用哪个版本的 sound()
方法,从而实现多态性。
3.2 抽象基类和接口
虚函数通常与纯虚函数结合使用,用于定义抽象基类和接口。
- 纯虚函数:虚函数后面加
= 0
表示纯虚函数。纯虚函数没有函数体,必须在派生类中实现。包含纯虚函数的类是抽象基类,无法实例化。
1 |
|
在上面的例子中,Shape
是一个抽象基类,包含纯虚函数 draw()
。Circle
类继承自 Shape
并实现了 draw()
方法。由于 Shape
是抽象的,无法直接创建其对象,只能通过派生类来实例化。
4. 虚函数的特点
4.1 重写虚函数
派生类可以重新定义基类的虚函数,称为重写(Override)虚函数。使用 override
关键字不仅能够明确表示该函数是重写父类的虚函数,还能帮助编译器检查是否正确重写。
1 |
|
4.2 析构函数的虚拟化
如果一个类具有虚函数,并且这个类被用作基类,则析构函数通常也应该声明为虚函数。这样可以确保在删除基类指针指向的派生类对象时,派生类的析构函数也能够被正确调用,避免内存泄漏。
1 |
|
4.3 虚函数与性能
虚函数的机制引入了一定的开销,主要体现在以下几个方面:
- 虚函数表的查找:每次通过基类指针或引用调用虚函数时,都需要查找虚函数表,这会带来一定的运行时开销。
- 内存开销:每个包含虚函数的类都需要有虚函数表,且每个对象需要存储一个虚指针,这增加了内存占用。
不过,这些开销通常是可以接受的,尤其在多态性需求较强的场景中,虚函数的灵活性远远超越了性能上的影响。
5. 总结
虚函数是C++支持多态性的一项核心特性,通过它,程序能够在运行时根据对象的实际类型来决定调用哪个函数。虚函数使得C++能够支持动态绑定,避免了静态绑定带来的限制。虚函数广泛应用于实现抽象类、接口、运行时多态等场景,是面向对象编程中非常重要的一部分。
通过理解虚函数的工作原理、实现细节以及应用场景,程序员可以更有效地设计灵活、可扩展的代码结构。
final 关键字
在C++中,final
是一个关键字,用来指定类或成员函数的特性,它的主要作用是:
- 防止类被继承(用于类声明中)。
- 防止函数被重写(用于成员函数声明中)。
final
是 C++11 引入的功能,旨在增强程序设计的安全性和可维护性,避免不必要的继承或函数重写。下面是对 final
关键字的详细解释。
1. final
用于类
当 final
用于类声明时,意味着该类不能再被继承。也就是说,任何尝试继承此类的操作都会导致编译错误。
示例:
1 |
|
在这个例子中,Derived
类被标记为 final
,因此任何试图继承自 Derived
的类都会导致编译错误。这是为了防止某些类被不恰当地进一步继承。
2. final
用于成员函数
final
也可以用于成员函数声明中,表示该函数不能在派生类中被重写。换句话说,如果一个函数被标记为 final
,那么派生类不能重写(override)这个函数。
示例:
1 |
|
在这个例子中,Base
类中的 display()
函数被标记为 final
,这意味着 Derived
类无法重写这个函数。如果尝试在派生类中重写 display()
函数,编译器会报错。
3. final
与虚函数的结合使用
final
经常与虚函数结合使用,确保某些虚函数在派生类中不能被重写。它提供了一种防止继承体系被错误扩展的机制,确保了接口或行为的一致性。
示例:
1 |
|
4. final
的应用场景
4.1 防止不必要的继承
使用 final
来阻止某些类被继承,这在设计一些不希望被继承的类时非常有用。例如,某些类可能只需要提供固定的行为,并不希望其他类进一步扩展它们的功能。
1 |
|
在这个例子中,Singleton
类被标记为 final
,并且禁止拷贝和赋值操作,确保它不能被继承并且只能有一个实例。
4.2 增强代码安全性
通过使用 final
关键字,程序员可以确保某些函数或类的设计意图不会被无意中改变。例如,如果一个基类的方法已经为所有派生类提供了适当的实现,而不希望它们被重写或修改,可以将该方法标记为 final
。
1 |
|
通过使用 final
,你可以明确控制类的继承结构以及方法的重写行为,从而避免程序中不希望发生的修改。
5. final
与编译优化
在某些情况下,final
可以帮助编译器优化代码。比如,当一个类被标记为 final
时,编译器就知道该类不能再被继承,从而可能对该类的虚函数进行更高效的内联处理。编译器可以更好地进行静态分析,因为它知道这个类的继承结构是固定的。
6. 总结
final
是 C++11 中引入的关键字,主要用于:
- 防止类被继承:在类声明中使用
final
,阻止该类被进一步继承。 - 防止函数被重写:在虚函数声明中使用
final
,阻止派生类重写该函数。
使用 final
提高了代码的可维护性和安全性,帮助开发人员明确限制某些类或方法的继承或重写行为,从而避免不必要的扩展或修改。
Concepts
在 C++ 中,Concepts 和 requires 是 C++20 引入的新特性,它们是用于约束模板类型的工具,旨在提高模板编程的可读性和可维护性。这些特性帮助程序员指定模板参数的要求,从而确保模板类型满足一定的条件。通过 Concepts,程序员可以在编译时进行类型检查,确保模板的类型参数符合预期的行为。
1. Concepts:概念
Concept(概念)是对类型要求的命名约定,用来描述一种类型的“能力”或“特性”。概念可以定义模板参数应该具备哪些操作、成员或属性,而 C++ 编译器可以根据这些要求进行类型检查。
Concept的定义使用 concept
关键字。其作用类似于类型约束,目的是使得模板代码更加类型安全,并且可以提高代码的可读性和可维护性。Concept 允许编译器在模板实例化时检查类型是否符合特定的要求,从而避免在编译时出现不可预测的错误。
Concept的定义语法:
1 |
|
其中,ConceptName
是概念的名称,<condition>
是对类型 T
的约束条件,通常是一些类型特征或者操作。
示例:自定义 Concept
1 |
|
在这个例子中,我们定义了一个名为 Addable
的概念,它要求类型 T
必须支持加法运算并且返回值类型是 T
。在 add
函数模板中,只有满足 Addable
概念的类型 T
才能被使用。
2. requires
:要求表达式
requires
是 C++20 中用于在模板参数中指定约束条件的关键字。它可以用于声明和表达某些条件,限制模板的使用。requires
可以单独使用,也可以与 Concepts 一起使用。
requires
表达式的作用是检查模板参数是否满足某些特定的操作或条件,若条件不满足,编译器会报错。
requires
语法
1 |
|
在 C++20 中,requires
关键字通常与概念(Concepts)结合使用,用来指定模板的约束条件。例如:
1 |
|
requires
用于模板参数
requires
关键字可以直接用来约束模板参数,表示该模板只适用于满足某些要求的类型。以下是一个带有 requires
关键字的模板示例:
1 |
|
上面的代码中,requires
表达式检查 T
类型是否可以进行加法操作。如果类型不满足这个要求,编译器将无法实例化 add
函数。
requires
表达式与 concept
的结合
Concept 是一种可重用的约束条件,可以通过 requires
关键字在模板中更直观地使用。例如:
1 |
|
3. requires
子句与 SFINAE
requires
还可以用于指定 SFINAE(Substitution Failure Is Not An Error,替代失败不是错误)条件。通常在模板特化或函数重载时,使用 requires
可以根据模板参数类型的特性进行选择。
示例:SFINAE 与 requires
1 |
|
这里的 requires
用于函数模板的重载,使得 multiply
函数能够根据传入的类型选择正确的重载版本。
4. 总结
- Concepts 提供了一种声明类型要求的方式,允许对模板参数进行约束,使得模板编程更加安全和可读。
requires
是用于定义约束条件的关键字,可以与 Concept 一起使用,或单独在模板函数中进行条件检查。requires
关键字和 Concepts 的结合能在编译时有效地过滤掉不符合要求的类型,提高了代码的可维护性和类型安全性。
通过 Concept 和 requires
,C++ 模板编程可以更加灵活、清晰和精确地表达类型要求,减少了由于模板不合规类型导致的编译时错误。