C++面向对象编程进阶知识I

参考南软2024秋C++高级程序设计课件CPP-2-2

继承

派生类对应Java的子类,基类对应Java的父类

单继承

注意声明时不需要强调类之间的继承关系,定义时才需要,声明时带上继承关系会报编译错误

// class Student: public Person; // 编译错误
class Student;

// ...

class Student: public Person {
// ...
};

继承方式有:public, private, protected

三种继承方式的差异

继承方式 基类成员的访问权限 外部可访问性 使用场景
公有继承 公有成员 -> 公有,保护成员 -> 保护,私有成员不可访问 继承的公有成员对外可见,保护成员不可见 表示派生类是基类的一种特殊类型,符合“is-a”关系
保护继承 公有成员 -> 保护,保护成员 -> 保护,私有成员不可访问 外部无法访问任何基类成员,内部可访问 用于继承实现,内部使用基类成员,不暴露给外部
私有继承 公有成员 -> 私有,保护成员 -> 私有,私有成员不可访问 外部无法访问任何基类成员,内部可访问 用于实现“has-a”关系,派生类是基类的一部分,但对外不可见

示例

class Base {
public:
int publicVar;
protected:
int protectedVar;
private:
int privateVar;
};

class Derived : public Base {
public:
void print() {
std::cout << publicVar << std::endl; // 可以访问公有成员
std::cout << protectedVar << std::endl; // 可以访问保护成员
// std::cout << privateVar << std::endl; // 错误,不能访问私有成员
}
};
class Derived : protected Base {
public:
void print() {
std::cout << publicVar << std::endl; // 可以访问公有成员
std::cout << protectedVar << std::endl; // 可以访问保护成员
// std::cout << privateVar << std::endl; // 错误,不能访问私有成员
}
};

int main() {
Derived d;
// std::cout << d.publicVar << std::endl; // 错误,不能访问基类的保护成员(基类的public到派生类变为protected)
// std::cout << d.protectedVar << std::endl; // 错误,不能访问保护成员
}
class Derived : private Base {
public:
void print() {
std::cout << publicVar << std::endl; // 可以访问公有成员
std::cout << protectedVar << std::endl; // 可以访问保护成员
// std::cout << privateVar << std::endl; // 错误,不能访问私有成员
}
};

int main() {
Derived d;
// std::cout << d.publicVar << std::endl; // 错误,不能访问基类的私有成员(基类的public到派生类变为private)
// std::cout << d.protectedVar << std::endl; // 错误,不能访问保护成员
}

派生类友元对基类成员的可访问性

派生类的友元只能访问基类的public成员,无法访问protectedprivate的成员

示例

class A {
protected:
int prot = 1;
};

class B: public A {
friend void func(B& b); // 可以访问B::prot
friend void func(A& a); // 不可访问A::prot
// 事实上派生类的友元对基类的访问权限和一般的外部函数是一样的,取决于基类中成员的访问权限,即外部只能访问public成员
};

void func(A& a) {
// 不能访问A的私有和保护成员,只能访问公有成员
// cout << a.prot; 编译错误,外部不能访问受保护的成员prot
}

void func(B& b) {
// 该func可以访问B的所有成员变量,包括私有、保护、公有
cout << b.prot; // 可以访问
}

派生类对象的初始化

  • 基类和派生类共同完成

构造函数的执行次序

  • 基类构造函数
  • 派生类对象成员类(成员变量是对象)的构造函数
  • 派生类自己的构造函数

析构函数执行次序

  • 与构造函数相反

基类构造函数的调用

  • 在派生类的构造函数中,若缺省对基类构造函数的调用则执行基类默认(无参)构造函数,若基类并未提供无参构造函数则会编译错误
  • 如果指定执行非默认构造函数,则必须在派生类的成员初始化表中显式指出,此时不在调用基类默认构造函数

示例:

class A {
int x;
public:
A() {x = 0;}
A(int i): x(i) {}
};

class B: public A {
int y;
public:
B() {y = 0;} // 默认会先执行A()
B(int i): y(i) {} // 同理
B(int x_i, int y_i): A(x_i), y(y_i) {} // 显式调用基类A的带参构造函数
}

B b1; // A::A() => B::B()
B b2(1); // A::A() => B::B(int)
B b3(0, 1); // A::A(int) => B::B(int, int)

虚函数

在正式介绍虚函数前,先介绍一些C++中的重要概念,对后面正确理解静态与动态、判断多态很有帮助

相容

相容分为两种:类型相容和赋值相容

类型相容

类型相容是指两个类型之间是否能够在某些操作(如比较、函数调用、运算等)中互相使用。不意味着赋值操作能成功执行,仅指两个类型在语义上是兼容的,可以进行某些转换

  • 如果类型完全相同,自然是兼容的,比如intint
  • 两个类型不同,但能够通过显式或隐式的转换来兼容,比如intdouble;如果必须通过显式的类型转换才能兼容将会触发上面的特殊情况即赋值操作不能成功进行,示例:
// B继承A
A* a;
a = new B; // 合法
B* b;
// b = a; // 编译错误,不能隐式地从A* -> B*,因此即使两者类型兼容,赋值操作并未成功
b = static_cast<B*>(a); // 显式类型转换成功赋值

赋值相容

赋值相容则是指是否可以把一个类型的值赋给另一个类型的变量。赋值相容关系通常取决于类型是否可以通过赋值语句进行转换。

  • 如果类型完全相同自然是相容的
  • 类型不同,但可以通过显式或隐式的类型转换使得一个类型的变量可以赋给另一个类型,还是可以参考上例

思考:

A a;
B b;
a = b;

什么时候上面的a = b是合法的?—— B是A的派生类,反过来不合法

// b = a; // 编译错误

尽管当B是A的派生类时赋值是合法的,但需要注意:

  • 对象的身份已经发生变化,即b赋值给a后,a和b并不等价(思考为什么,下面会放一张图解释)
  • 对上一步的文字解释:属于派生类的属性不存在了(这是为什么?见图解)

这种对象赋值操作称作切片,指a只保留了b的一部分属性,而这部分属性是基类和派生类共有的。

图解:

考虑指针类型/引用类型的变量:

// B仍是A的派生类
A* pa;
B* pb = new B;
pa = pb; // 不会发生切片,对象身份没有改变,pb指向的空间的对象还是那个对象没有变化,只不过多了个pa也指向它

B b;
A &a = b; // 取引用也是同理,对象身份没有变化,b还是那个b没有改变,只不过多了个引用a指向对象b,a是基类型的引用,不影响

此外,总是可以把派生类直接赋给基类而不用做显式的类型转换,但是基类不能直接赋给派生类,必须做显示类型转换(可能出错,因此需要程序运行过程中进行适当的检查来保证);形象地说就是:狗都是动物,但动物不全是狗。

override何时发生?

构造函数中无法使用多态,原因是当一个对象的构造函数被调用时,类的继承关系尚未完全建立,因此无法通过调用虚函数实现多态

看一个例子

#include <bits/stdc++.h>

class A {
public:
A() {
call(); // 不会调用派生类的call()方法,而是调用A的call()方法
}
virtual void call() {
std::cout << "A's call\n";
}
};

class B : public A {
public:
void call() override {
std::cout << "B's call\n";
}
};

int main() {
B b; // 输出 "A's call",而不是 "B's call"
}

输出:

A's call

该例可以解释为派生类构造时会先调用基类的构造函数,因此此时派生类尚未构造完成,内存找还找不到B::call的定义,而目前处于A的命空间内。只能找到A::call,因此调用的是A::call

再看另一个例子

class A {
int x, y;
public:
void f();
};

class B: public A {
int z;
public:
void f();
void g();
}

void func1(A& a) {
a.f();
}

void func2(A* pa) {
pa->f();
}

int main() {
B b;
A &a = b;
A* pa = &b; // A是B的基类,合法的
func1(a); // 调用的是A::f
func2(pa); // 调用的是A::f
}

为什么上例调用的都是A::f,明明传入的实例是B? —— f并没有用virtual关键字修饰,编译器未把它解释成虚函数,因此执行静态绑定编译时刻即与A::f绑定,因此不论后面传入A的哪个派生类,总是调用A::f

下面开始正式介绍虚函数来解答上面的问题。

两种绑定

前期(静态)绑定q

  • 编译时刻确定
  • 依据对象的静态类型(即声明的类型)
  • 效率高但灵活性差

动态绑定

  • 运行时刻
  • 依据对象的动态类型(即实际的类型)
  • 灵活性高但效率低

为了注重效率,C++默认使用前期绑定,若使用动态绑定需显式指出(virtual)

定义虚函数

virtual

class A {
public:
virtual void f();
}

基类中的成员函数被定义为虚函数,则派生类中所有override的该函数都是虚函数,从而可以继续被派生类重写。

限制

  • 类的成员函数才可以是虚函数
  • 静态成员函数不能是虚函数
  • 内联成员函数不能是虚函数
  • 构造函数不能是虚函数
  • 析构函数可以是虚函数且往往是虚函数

如何实现后期(动态)绑定?

原理分析

对象的内存空间中存在一个指针,指向其对应的虚函数表,这个虚函数表指针位于对象首地址的前4字节中

具体到一个对象指针p,p指向空间的前4字节中存放着指向对应虚函数表的指针,示例:

class A {
int x, y;
public:
virtual void f();
virtual void g();
};

class B: public A {
int z;
public:
void f() override; // override关键字不是必须的,但加上可以让编译器帮助检查是否真的重写了虚函数,更安全
void g();
};

int main() {
A a;
B b;
// case 1
A* p = &a;
p->f();
// case 2
p = &b;
p->f();
}d

p - 4即取得虚函数表指针,这个虚函数表里记录两个运行时刻确定的虚函数的地址(动态绑定),对应实际指向的对象的虚函数,因为fg前面定义所以虚函数表中f在g前面,虚函数表的首地址即指向f

执行p->f()时,实际执行的是(**((char*)p - 4))(p), 当p指向a时,该虚函数表指针就指向A的虚函数(A::f, A::g),当p指向b时,该虚函数表指针就指向B的虚函数(B::f, B::g)。因此如果拿到对象的地址就可以根据地址向前偏移4B找到对应的虚函数表去调用具体的虚函数,找到的是哪一个虚函数完全由运行时刻赋予的具体的实际对象的地址情况决定,编译时刻不会绑定对象(也无法绑定因为编译时不知道地址),因此实现动态绑定

更多例子

再看另一个例子:

class A {
public:
A() {f();}
virtual void f();
void g();
void h() {
f();
g();
}
};

class B: public A {
public:
void f();
void g();
};

int main() {
B b; // A::A() => A::f() => B::B(), 因为执行A的构造函数中的f()时B的构造函数尚未执行完成(都没执行到),因此只能找到A::f(), 还找不到B::f()
A* p = &b;
p->f(); // B::f(), 动态绑定
p->g(); // A::g(), 没有virtual修饰,静态绑定
p->h(); // A::h() => B::f() => A::g(), 因为h执行的实际是this->f(); this->g(); this即p,是b的版本,其中f动态绑定,g静态绑定
}

另一个例子:

class A {
public:
virtual void f();
void g();
};

class B: public A {
public:
void f() { // f()看似无参实际上有一个默认参数(隐藏)B* const this, p->f()调用时实际上是p->f(p)
g(); // 实际上是this->g(); 隐去了this
}
void g();
}

int main() {
B b;
A* p = &b;
p->f(); // B::f() => B::g(), 因为B::f的定义中实际上是this->g(); 省略了this,而this是指向对象b的指针,因此调用的是B版本的g, 即B::g
}

final与override

final关键字告诉编译器这个函数已经是“最终版本”了,不允许被派生类重写;尝试重写final修饰的函数会触发编译错误

override关键字在任何时候都不是必须的,但它告诉编译器这个函数的定义意图重写基类的虚函数,那么编译器会帮助做一些检查来保证重写是正确的(即函数签名完全一致),如果函数签名并不满足重写的要求,会触发编译错误

示例:

class B {
public:
virtual void f1(int) const;
virtual void f2();
void f3();
virtual void f5(int) final;
};

class D: public B {
public:
void f1(int) const override; // 正确的重写,函数签名与基类虚函数完全一致
// void f2(int) override; // 错误的重写,基类的虚函数f2没有带一个int参数的重载版本
// void f3() override; // 错误的重写,基类f3不是虚函数
// void f4() override; // 错误的重写,基类没有虚函数f4
// void f5(int) override; // 错误的重写,尝试重写final函数
};

纯虚函数

  • 声明时在虚函数原型后面加上= 0, 意味着定义不在这里
  • 往往只给出函数声明,不给定义(实现)

抽象类

  • 至少包含一个纯虚函数
  • 不能用于创建对象(类似Java抽象类abstract class),因此没有栈上对象,只有指针
  • 为派生类提供框架,派生类必须为抽象基类的所有纯虚成员函数提供实现

示例:

class AbstractClass {
// ...
public:
virtual void f() = 0; // 纯虚函数
};

class DicretClass {
// ...
public:
void f() override {
// ... 为抽象基类的纯虚函数提供实现
}
};

提供框架的意义:抽象类总结出出派生类的特征,提供共有的行为,设为纯虚,派生类按照各自特性对同一行为表现出不同动作,即为纯虚函数提供不同的具体实现

利于一些设计模式的实现,比如抽象工厂模式、策略模式等

虚析构函数

析构函数往往是虚函数,只要在堆上申请了新资源。

因为如果基类析构函数不是虚函数,那么析构的时候就不会调用派生类的析构函数,如果派生类申请了超过基类持有的资源(指针),就会发生内存泄漏

好的继承习惯

  • 确定public继承是真正意义上的"is a"关系
  • 不要定义一个成员函数,它和继承而来的非虚成员函数同名。因为非虚函数是静态绑定的,同名没有意义,除非静态类型是派生类否则还是会调用基类的版本,并不会重写

示例:

class Rectangle {
public:
/*virtual*/ void setHeight(int); // 加上virtual也不能改变仍可从外部访问派生类set方法的事实(继承而来)
/*virtual*/ void setWidth(int);
int height() const;
int width() const;
};

class Square: public Rectangle {
public:
void setLength(int);
private: // 这样写是没有意义的,因为会从基类继承过来一个仍可被外部访问的set方法
void setHeight(int);
void setWidth(int);
};
// 除非声明类型是明确是Square类型,否则只要带上基类型Rectangle。就能在外部访问到set方法
class B {
// ...
public:
void mf();
};

class D: public B {
// ...
public:
void mf();
};

int main() {
B b;
D d;
B* pd = &d;
D* pb = &b;
pd->mf(); // B::mf()
pb->mf(); // D::mf()
// 没有virtual不是虚函数,都是静态绑定的,派生类与基类的非虚函数重名是没有意义的,并不会重写
// 调用非虚函数的什么版本完全取决于编译时刻的静态绑定
}
  • 明智地运用private继承,private继承后自己的派生类就继承不了自己基类中的方法了

函数种类汇总

  • 纯虚函数
    • 只有函数接口会被继承
    • 派生类必须继承函数接口
    • 派生类必须提供实现代码
  • 一般虚函数
    • 函数的接口及缺省的实现代码都会被继承
    • 子类必须继承函数接口
    • 可以缺省实现代码
  • 非虚函数
    • 函数的代码及其实现代码都会被继承
    • 必须同时继承接口和实现代码,无法重写以提供自己的实现

切记

绝对不要重新定义继承而来的缺省参数值!考虑到:

  • 缺省参数值是静态绑定的
  • 效率问题

示例:

class A {
public:
virtual void f(int x=0) = 0; // 缺省参数
};

class B: public A {
public:
virtual void f(int x = 1) {
std::cout << x;
}
};

class C: public A {
public:
virtual void f(int x) {
std::cout << x;
}
};

int main() {
A* p_a, *p_a1;
B b;
C c;
p_a = &b;
p_a1 = &c;
p_a->f(); // 0
p_a1->f(); // 0
// 均输出0说明缺省参数值是静态绑定的,因为编译时刻编译器并不知道派生类型,因此缺省参数都是静态绑定,编译器在编译时刻只知道x = 0
}

多继承

一个常见的问题:名冲突

语法

class <派生类名>: [<继承方式>]<基类名1>, [<继承方式>]<基类名2>, ... {
<成员表>
};

示例:

case1

class Furniture {
public:
int weight;
void setWeight();
};

class Bed: public Furniture {
public:
void sleep();
};

class Sofa: public Furniture {
public:
void watchTV();
};

class SleepSofa: public Bed, public Sofa {
public:
int Bed::weight;
void Bed::setWeight(int); // 不加Bed::会编译错误
void foldOut();
// 有一个问题:派生类SleepSofa有来自基类的两个版本的weight和setWeight, 如果不指明namespace的话会触发编译错误
// 两个版本的weight: Bed::weigh, Sofa::weight
};

那么一个更好的实践是什么呢?—— 虚继承

虚继承

在声明继承时使用virtual关键字,表示派生类继承的并不是一个“单独新”的基类,而是所有虚继承的派生类“共享”一个基类,使得继承树形成一个**“格”**的结构。

示例:

case2

class Furniture {
public:
int weight;
void setWeight();
};

class Bed: virtual public Furniture {
public:
void sleep();
};

class Sofa: public virtual Furniture {
public:
void watchTV();
};

class SleepSofa: public Bed, public Sofa {
public:
// 因为Bed和Sofa都是虚继承的Furniture,所以派生类SleepSofa只有一个版本的weight和setWeight
void foldOut();
};

本质

虚继承不会带来名冲突的本质:并没有真的继承基类的成员,而是生成了一个指向基类的指针,因此不会有名冲突问题(基类实际上只有一份,被所有虚继承的派生类共用)

多继承总结

基类的声明次序将会决定:

  1. 对基类构造函数/析构函数的调用次序,先声明的构造函数先调用,析构函数则相反
  2. 对基类数据成员的存储安排

名冲突问题

如果不使用虚继承可能会引入名冲突问题,即不同的命名域存在重名变量,编译器找不到该用哪个从而引起编译错误,解决方法是显式标明命名域,即<基类名>::<基类成员名>;或使用虚继承

非虚基类

如果多继承派生类的直接基类有公共的基类,则该公共基类中的成员变量在多继承的派生类中有多个副本(直接基类对公共基类不使用虚继承,见上例case1), 不标明namespace将引起上述的名冲突问题

虚继承

解决派生类中成员变量有多个基类副本的名冲突问题,且使得继承结构更优雅清晰

虚基类

virtual继承

  • 虚基类的构造函数由最新派生出的类(即继承树中最底层的类)的构造函数调用
  • 虚基类的构造函数优先于非虚基类的构造函数执行

(•‿•)