派生类
在面向对象编程中,派生类(子类)与基类(超类)之间存在一种继承关系。派生类通过继承机制获得基类的属性和方法。这种关系可以用一个箭头图表示,箭头从派生类指向基类,表示派生类引用基类。
成员函数
在面向对象编程中,继承允许派生类继承并扩展基类的功能。派生类可以定义自己的成员函数,这些函数可以调用基类的公有和保护成员,但不能访问基类的私有成员。
class Employee {
public:
void print() const;
string full_name() const {
return first_name + " " + family_name;
}
private:
string first_name, family_name;
char middle_initial;
};
class Manager : public Employee {
public:
void print() const;
int level;
};
派生类的成员函数可以像使用自己声明的成员一样使用基类的公有和保护成员。
void Manager::print() const {
cout << "name is " << full_name() << '\n';
}
在派生类中重写基类的成员函数时,需要使用作用域解析运算符 ::
来明确调用基类的成员函数。否则,如果只是简单地调用 print()
,可能会导致无限递归调用,因为派生类中的 print()
会不断调用自己。
正确例子:
void Manager::print() const {
Employee::print(); // 调用基类的print函数
cout << level; // 打印Manager特有的信息
}
错误例子:
void Manager::print() const {
print(); // 错误:这会导致无限递归调用
}
构造函数和析构函数
在面向对象编程中,构造函数和析构函数是类的重要组成部分,它们控制对象的创建和销毁过程。以下是一些关键规则和概念:
- 对象构造顺序:对象是自底向上构造的,即基类先于成员,成员先于派生类。这意味着在创建对象时,首先构造基类部分,然后是成员变量,最后是派生类部分。
- 对象销毁顺序:对象是自顶向下销毁的,即派生类先于成员,成员先于基类。这意味着在销毁对象时,首先销毁派生类部分,然后是成员变量,最后是基类部分。
- 成员和基类的初始化:每个类都可以初始化其成员和基类,但不能直接初始化其基类的成员或基类的基类。
- 析构函数的虚性:类层次中的析构函数通常应该是虚函数(virtual),以确保通过基类指针删除派生类对象时,能够正确调用派生类的析构函数。
- 拷贝构造函数和切片现象:类层次中类的拷贝构造函数须小心使用,以避免切片现象,即派生类对象被赋值给基类对象时,丢失派生类特有的信息。
- 虚函数调用和类型识别:虚函数调用的解析、dynamic_cast,以及构造函数或析构函数中的 typeid()反映了构造和析构的阶段,而不是尚未构造完成的对象的类型
类层次
在面向对象编程中,类层次结构允许一个派生类作为另一个类的基类,形成树状或更一般的图状结构。
类型域
为了确定一个基类指针指向的对象的实际派生类型,C++ 提供了四种基本解决方法:
- 保证指针只能指向单一类型的对象
- 在基类中放置一个类型域,供函数查看(一个成员变量,记录类型)
- 使用
dynamic_cast
- 使用虚函数
虚函数
虚函数机制是面向对象编程中实现多态性的关键技术。它允许在基类中声明函数,并在每个派生类中重新定义这些函数,确保使用引用或指针时可以调用正确的函数。
如果派生类中一个函数的名字和参数类型与基类中的一个虚函数完全相同,则称它覆盖(override)了虚函数的基类版本。
有虚函数的类型称为多态类型(polymorphic type
)或(更精确的)运行时多态类型(run-time polymorphic type
)。在 C++
中,为了获得运行时多态行为,必须调用 virtual
成员函数,对象必须通过指针或引用进行访问。当直接操作一个对象时(而不是通过指针或引用),编译器了解其确切类型,从而就不需要运行时多态了。
class Base {
public:
virtual void foo() {
// 虚函数实现
}
};
class Derived : public Base {
public:
void foo() override {
// 覆盖虚函数
}
};
默认情况下,覆盖虚函数的函数自身也变为 virtual 的。我们可以在派生类中重复关键字 virtual,但是这不是必要的,建议使用 override。
为了实现多态性,编译器在每个对象中保存类型信息,并使用它来选择正确的虚函数版本。这通常通过虚函数表实现,将虚函数名转换为函数指针表中的一个索引,每个具有虚函数的类都有自己的 vtbl。
从构造函数或析构函数中调用虚函数通常不是一个好主意,因为它们可能反映出对象的部分构造或部分销毁状态。
覆盖控制
在复杂的类层次中,确保正确覆盖虚函数可能会变得困难。以下是一些常见的问题:
- 基类中的非虚函数:如果基类中的函数不是虚函数,派生类中的同名函数不会覆盖它,而是隐藏它。
- 参数类型不匹配:如果派生类中的函数参数类型与基类中的虚函数参数类型不一致,它不会覆盖基类中的虚函数,而是隐藏它。
- 基类中不存在的函数:如果派生类中声明了基类中不存在的函数,它不会覆盖任何基类中的函数,而是引入了一个全新的虚函数。
C++ 提供了几个关键字来帮助控制和明确覆盖行为:
virtual
:声明函数可能被覆盖。= 0
:声明函数必须是 virtual 的,且必须被覆盖(纯虚函数)。override
:明确指出函数要覆盖基类中的一个虚函数。final
:声明函数不能被覆盖
override
override
关键字用于明确指出派生类中的函数覆盖了基类中的虚函数。这有助于提高代码的可读性和可维护性,并允许编译器检查覆盖关系的正确性。
override
的使用规则:
- 位置:
override
关键字应该放在函数声明的末尾。 - 语法:
override
不是函数类型的一部分,不能在类外定义中重复使用。
override
是一个上下文关键字,这意味着它在特定的上下文中有特殊的含义,但在其他地方可以作为普通的标识符使用。
尽管 override
可以作为普通标识符使用,但这种做法不推荐,因为它会使代码难以维护和理解。
final
当我们不希望派生类覆盖某个虚函数时,可以在函数声明后加上 final
关键字。
使用 final
关键字后,该函数不能再被覆盖,尝试覆盖将导致编译错误。
struct Node {
virtual Type type() = 0;
};
class If_statement : public Node {
public:
Type type() override final; // 阻止进一步覆盖
};
通过在类名后加上 final
,可以将一个类的所有虚成员函数都声明为 final
。(该类不可派生!)
class For_statement final : public Node {
public:
Type type() override;
};
class Modified_for_statement : public For_statement { // 错误:For_statement为final
public:
Type type() override;
};
与 override
一样,final
说明符不是函数类型的一部分,不能在类外定义中重复使用。
using 基类成员
在 C++ 中,函数重载不会跨越类的作用域。这意味着即使一个派生类有一个同名的函数,它也不会重载基类中的同名函数,除非明确地使用 using
声明将基类的函数引入到派生类的作用域中。
struct Base {
void f(int);
};
struct Derived : Base {
void f(double);
};
void use(Derived d) {
d.f(1); // 调用Derived::f(double),而不是Base::f(int)
}
使用 using
声明引入基类函数:
struct D2 : Base {
using Base::f; // 将Base中的f函数引入D2的作用域
void f(double);
};
void use2(D2 d) {
d.f(1); // 调用D2::f(int),即Base::f(int)
}
构造函数也可以通过 using
声明引入到派生类的作用域中。
由 using
声明引入到派生类作用域的名字的访问权限,由 using
声明所在的位置决定。
继承构造函数
在 C++ 中,当一个派生类继承自一个基类时,它并不自动继承基类的构造函数。如果派生类没有提供自己的构造函数,并且添加了新的成员变量或者要求更严格的类不变式,那么继承构造函数可能会引起问题。
但是,如果派生类没有添加新的成员变量或者没有更严格的不变式要求,那么继承构造函数是合理的。
#include <vector>
template<class T>
struct Vector : std::vector<T> {
using std::vector<T>::vector; // 继承vector的构造函数
T& operator[](size_type i) { check(i); return this->elem(i); }
const T& operator[](size_type i) const { check(i); return this->elem(i); }
void check(size_type i) { if (this->size() < i) throw std::bad_index("Vector::check() failed"); }
};
// 正确:使用来自std::vector的初始化器列表构造函数
Vector<int> v{ 1, 2, 3, 5, 8 }; // 正确
返回类型放松
在 C++ 中,覆盖函数的类型必须与它所覆盖的虚函数的类型完全一致。然而,C++ 提供了一种称为协变返回(covariant return) 规则的放松规则。根据这一规则,如果原始返回类型为 B*
,则覆盖函数的返回类型可以为 D*
,只要 B
是 D
的一个公有基类即可。类似地,返回类型 B&
可以放松为 D&
。这一规则仅适用于返回类型为指针或引用的情况,但不适用于像 unique_ptr
这样的智能指针。重要的是,参数类型没有类似的放松规则。
抽象类
通过在虚函数声明后添加 = 0
来定义纯虚函数,使得包含纯虚函数的类成为抽象类。
由于抽象类包含未实现的纯虚函数,因此不能直接创建其对象。
如果纯虚函数在派生类中未被定义,那么它仍然保持是纯虚函数,因此派生类也是一个抽象类。
抽象类通常用于定义一个接口,通过指针或引用来访问对象,以保持多态性。
由于抽象类对象通常通过指针或引用访问,定义虚析构函数对于资源管理很重要。
抽象类支持的设计风格称为接口继承,与实现继承相对。接口继承关注于定义接口,而实现继承关注于代码的复用。两者可以结合使用,但需要小心处理,以避免混淆。
访问控制
访问说明符的默认行为
-
对于
class
,基类默认为private
。class Base { public: int publicVar; }; class Derived : Base { // 默认是 private 继承 public: void accessBase() { // publicVar = 1; // 编译错误:publicVar 是 private 继承,无法访问 } };
-
对于
struct
,基类默认为public
。struct Base { public: int publicVar; }; struct Derived : Base { // 默认是 public 继承 public: void accessBase() { publicVar = 1; // 可以访问,因为是 public 继承 } };
基类访问说明符的影响
public
继承
-
语法:
class Derived : public Base { ... };
-
特性:
- 基类的
public
成员在派生类中保持public
。 - 基类的
protected
成员在派生类中保持protected
。 - 基类的
private
成员在派生类中不可访问,仍然是private
,只能通过基类的公有或保护方法间接访问。
- 基类的
示例:
class Base {
public:
int publicVar;
protected:
int protectedVar;
private:
int privateVar;
};
class Derived : public Base {
public:
void accessBaseMembers() {
publicVar = 1; // 可以访问
protectedVar = 2; // 可以访问
// privateVar = 3; // 不可访问,编译错误
}
};
protected
继承
-
语法:
class Derived : protected Base { ... };
-
特性:
- 基类的
public
成员在派生类中变为protected
。 - 基类的
protected
成员在派生类中保持protected
。 - 基类的
private
成员在派生类中不可访问。
- 基类的
示例:
class Derived : protected Base {
public:
void accessBaseMembers() {
publicVar = 1; // 可以访问,但在派生类中作为`protected`
protectedVar = 2; // 可以访问
// privateVar = 3; // 不可访问,编译错误
}
};
在这种继承方式下,派生类的对象不能直接访问基类的
public
成员(已经是protect
了,外界访问不了了),但派生类及其子类可以访问这些成员。
private
继承
-
语法:
class Derived : private Base { ... };
-
特性:
- 基类的
public
成员在派生类中变为private
。 - 基类的
protected
成员在派生类中变为private
。 - 基类的
private
成员在派生类中不可访问。
- 基类的
示例:
class Derived : private Base {
public:
void accessBaseMembers() {
publicVar = 1; // 可以访问,但在派生类中作为`private`
protectedVar = 2; // 可以访问,但在派生类中作为`private`
// privateVar = 3; // 不可访问,编译错误
}
};
这种继承方式通常用于“实现继承”或组合,派生类的外部用户无法访问基类的
public
或protected
成员。
比较三种继承方式的访问权限
继承方式 | 基类 public 成员 |
基类 protected 成员 |
基类 private 成员 |
---|---|---|---|
public |
public |
protected |
不可访问 |
protected |
protected |
protected |
不可访问 |
private |
private |
private |
不可访问 |
总结
- public 继承:基类的接口保持公开,适用于
is-a
关系。 - protected 继承:限制派生类的访问,通常用于库设计中需要隐藏实现细节。
- private 继承:使基类的接口在派生类中不可见,适用于
is-implemented-in-terms-of
关系。
多重继承与访问控制
如果一个基类可以通过多条路径被访问,只要其中任何一条路径中的基类是可访问的,那么在派生类中这个基类就是可访问的。
struct B {
int m; // 非静态成员变量
static int sm; // 静态成员变量
};
class D1 : public virtual B {
// D1继承自B
};
class D2 : public virtual B {
// D2继承自B
};
class D12 : public D1, private D2 {
// D12同时继承自D1和D2,D1是public继承,D2是private继承
};
D12* pd = new D12;
B* pb = pd; // 正确:可以通过D1访问B
int i1 = pd->m; // 正确:可以通过D1访问B的成员m
using 声明与访问控制
using
声明不能用来获得额外的访问权限,它只能用于那些已经可以访问的成员。
class B {
private:
int a; // 私有成员
protected:
int b; // 受保护成员
public:
int c; // 公有成员
};
class D : public B {
public:
// 错误:B::a是私有的,不能在D中通过using声明访问
// using B::a;
// 正确:B::b在D中是受保护的,通过using声明后在D中变为公有
using B::b;
};
如果成员已经是可访问的,using
声明可以使得这些成员在当前类中更易于访问。
成员指针
成员指针是 C++ 中一种特殊的指针类型,它允许间接引用类的成员。这种指针类似于偏移量,用于访问类的成员变量或成员函数。成员指针的使用涉及到特殊的运算符 ->*
和 .*
。
- 成员指针类型:使用
X::*
声明,其中X
是类名,*
表示指针。 - 成员指针的赋值:不能将
void*
或其他普通指针赋值给成员指针,但可以将nullptr
赋值给成员指针,表示“无成员”。 - 成员指针的获取:通过使用
&
运算符对完全限定的类成员名取地址获得。 - 成员指针的使用:通过
->*
和.*
运算符将成员指针与对象指针组合使用,以访问或调用成员。
函数成员指针
函数成员指针用于引用类的成员函数。它们在需要动态决定调用哪个成员函数时非常有用,例如在解释器程序或事件驱动的用户界面中。
class Std_interface {
public:
virtual void start() = 0;
virtual void suspend() = 0;
virtual void resume() = 0;
virtual void quit() = 0;
virtual void full_size() = 0;
virtual void small() = 0;
virtual ~Std_interface() {}
};
using Pstd_mem = void(Std_interface::*)(); // 成员指针类型
void f(Std_interface* p) {
Pstd_mem s = &Std_interface::suspend; // 指向suspend()的指针
(p->*s)(); // 通过成员指针调用suspend()
}
一个成员指针并不指向一片内存区域,这一点与变量指针或函数指针是不同的。它更像一个结构内部的偏移量或数组内的下标,但具体 C++
实现当然会考虑数据成员、虚函数、非虚函数等之间的区别。当一个成员指针与一个恰当类型的对象指针组合使用时,所产生的结果标识着一个特定对象的一个特定成员。
调用 p->*s()
可图示如下:
静态函数
静态成员不关联特定对象,因此静态成员的指针是普通指针。
class Task {
public:
static void schedule();
};
void(*p)() = &Task::schedule; // 正确:普通指针赋值给静态成员函数指针
数据成员指针
成员指针不仅可以用于成员函数,还可以用于数据成员。此外,成员指针的类型检查和赋值需要遵循特定的规则,特别是涉及到基类和派生类时。
数据成员指针用于指向类的非静态数据成员。它们在使用时需要匹配正确的类型。
struct C {
const char* val;
int i;
void print(int x) { cout << val << x << '\n'; }
int f1(int);
void f2();
C(const char* v) { val = v; }
};
using Pmfi = void(C::*)(int); // C的成员函数指针,接收int
using Pm = const char* C::*; // C的char*数据成员的指针
void f(C& z1, C& z2) {
C* p = &z2;
Pmfi pf = &C::print;
Pm pm = &C::val;
z1.print(1);
(z1.*pf)(2); // 通过成员函数指针调用print
z1.*pm = "nv1"; // 通过数据成员指针修改val
p->*pm = "nv2"; // 通过数据成员指针修改z2的val
z2.print(3);
(p->*pf)(4); // 通过成员函数指针调用print
}
成员指针的类型检查与其他类型一样严格。不匹配类型的成员指针之间不能相互赋值。
pf = &C::f1; // 错误:返回类型不匹配
pf = &C::f2; // 错误:参数类型不匹配
pm = &C::i; // 错误:类型不匹配
pm = pf; // 错误:类型不匹配
基类和派生类成员
派生类至少包含从基类那里继承来的成员,这意味着我们可以安全地将基类成员指针赋予派生类成员指针,但反方向赋值则不行。这一特性被称为逆变性。
class Std_interface {
public:
virtual void start();
virtual void suspend();
virtual void print();
};
class Text : public Std_interface {
public:
void start();
void suspend();
virtual void print();
private:
std::vector s;
};
void(Std_interface::* pmi)() = &Text::print; // 错误
void(Text::* pmt)() = &Std_interface::start; // 正确