C++ 继承
一、继承概念与语法
1.1 继承是什么
继承(Inheritance)是面向对象编程程序设计(OOP)的一个重要特征。它允许一个类(派生类)继承另一个类(基类)的属性和方法,并可以在此基础上修改已有的功能或增加新的功能。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程,继承是类设计层次的代码复用。
1.2 继承语法
class <派生类名>: [继承方式] <基类名> // 单继承
{
// member list
};
class <派生类名>: [继承方式] <基类名1>, [继承方式] <基类名2>,... // 多继承
{
// member list
};
单继承:只有一个基类
多继承:有两个或两个以上基类
继承方式:public
、protected
、private
三种,不指定时使用默认继承方式(不推荐)。
a. 对于class,默认继承方式是private
b. 对于struct,默认继承方式是public
1.3 访问控制
继承方式与访问限定符共同决定了基类成员在派生类中的可见性。
类成员\继承方式 | public继承 | protected继承 | private继承 |
---|---|---|---|
基类的public成员 | 派生类的public成员 | 派生类的protected成员 | 派生类的private成员 |
基类的protected成员 | 派生类的protected成员 | 派生类的protected成员 | 派生类的private成员 |
基类的private成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
class Person
{
public :
void print() const
{
cout << "name:" << _name << " age: " << _age << endl;
}
void set_name(const string &name) { _name = name; };
void set_age(int age) { _age = age; };
protected:
void eat() const { cout << "eating" << endl; }
private :
string _name;
int _age;
};
class Student : public Person
{
public:
void students_eat() { eat(); };
private:
int _id;
};
int main() {
Student s;
s._name = "张三"; // 错误,public继承,但_name在基类为private成员,所以在派生类不可见
s._age = 10; // 错误,同上
s.set_name("张三"); // 正确,public继承,set_name在基类为public成员,所以在派生类为public
s.set_age(18); // 正确,同上
s.print(); // 正确,同上
s.eat(); // 错误,public继承,但eat在基类为protected成员,所以在派生类为protected,只能在类里面调用该函数
s.students_eat(); // 正确
return 0;
}
问题:不管使用什么继承方式,基类中private的成员在派生类中是不可见的,为什么派生类还要含有这些不可见的成员?
- 虽然派生类不能直接访问基类的private成员,但基类可以提供public或protected的方法(方法里面访问private成员)。当派生类调用基类的这些方法时,它会间接访问或修改基类的private成员,所以派生类要含有这些不可见成员。
- 为保证对象模型的一致性。如果派生类不包含基类的private成员,那么派生类的对象在内存布局上就会与基类的对象不同,这会导致继承体系中的类型兼容性问题。在派生类中保留基类的私有成员,可以确保派生类对象在内存中包含基类对象的所有部分,这使得基类指针或引用可以安全地指向派生类对象,以及通过派生类对象构造出基类对象。
1.4 继承中的作用域
在继承体系中,基类和派生类都有独立的作用域,如果派生类定义了与基类同名的成员(变量和函数),则基类的同名成员会被隐藏(shadowed),派生类默认访问的是自身的成员,如要访问基类的成员,需要显式指定基类作用域。
语法: 基类名::成员名
注意:
- 对于变量来说,同名即构成隐藏。
- 对于函数来说,如果基类函数不是虚函数,派生类只要函数同名即构成隐藏。如果基类为虚函数,派生类同名函数的参数列表不相同构成隐藏,而不是覆盖。
class A
{
public:
void f1() { cout << "A::f1()" << endl; }
void f2() { cout << "A::f2()" << endl; }
void f3(int i) { cout << "A::f3()" << endl; }
virtual void f4(int i) { cout << "A::f4()" << endl; }
void f5(int i) { cout << "A::f5(int i)" << endl; }
void f5() { cout << "A::f5()" << endl; }
};
class B : public A
{
public:
void f1() { cout << "B::f1()" << endl; }
int f2()
{
cout << "B::f2()" << endl;
return 1;
}
void f3() { cout << "B::f3()" << endl; }
virtual void f4() { cout << "B::f4()" << endl; }
};
int main()
{
B b;
b.f1(); // 隐藏
b.f2(); // 隐藏
b.f3(); // 隐藏
b.f4(); // 隐藏,虽然基类f4为虚函数,但派生类f4与基类f4参数列表不相同,所以不构成覆盖(override)
b.f5(); // 基类有2个f5函数,处于同一作用域,构成函数重载
b.f5(1); // 基类有2个f5函数,处于同一作用域,构成函数重载
b.A::f1(); // 使用作用域解析运算符访问基类f1函数
return 0;
}
上面代码运行后输出内容如下:
B::f1()
B::f2()
B::f3()
B::f4()
A::f5(int i)
A::f5()
A::f1()
1.5 继承下的类型转换
1.5.1 向下类型转换
将基类的指针、引用、对象转换为派生类指针、引用、对象称为向下类型转换。
说明:
- 将基类对象转换为派生类对象时,实际上参照基类对象创建了一个新的派生类对象,所以派生类要提供一个
Derived(const Base&)
拷贝构造函数。 - 将基类指针、引用转换为派生类指针、引用时没有创建新的对象,编译器只是对这个起始内存空间从按基类类型解释变为按派生类类型解释,所以有内存越界风险。
- 对于使用
protected/private
继承方式,向下类型转换没有什么实际意义,因为基类很多成员对派生类是不可见的。
class Person
{
public :
void print() const
{
cout << "name:" << _name << " age: " << _age << endl;
}
void set_name(const string &name) { _name = name; };
void set_age(int age) { _age = age; };
private :
string _name;
int _age;
};
class Student : public Person
{
public:
Student(const Person &obj) {} // 对象的向下类型转换必须提供该拷贝构造函数
void set_id(int id) { _id = id; };
private:
int _id;
};
int main()
{
Person p;
Student s(p); // 调用Student(const Person &obj)拷贝构造函数
Student *point = (Student *) &p; // 将p对象的内存空间按Student类型解释
Student &ref = (Student &) p; // 将p对象的内存空间按Student类型解释
// 非法访问内存,point指向的区域实际是Person类型,现在将它按Student类型解释,调用set_id函数会修改p对象后面的内存区域
point->set_id(10);
return 0;
}
1.5.2 向上类型转换
将派生类的指针、引用、对象转换为基类指针、引用、对象称为向上类型转换。
说明:
- 将派生类对象转换为基类对象时,实际上根据派生类对象裁剪出创建了一个新的基类对象。
- 将派生类指针、引用转换为基类指针、引用时没有创建新的对象,编译器只是对这个起始内存空间从按派生类类型解释变为按基类类型解释。
- 对于使用
protected/private
继承方式,向上类型转换没有什么实际意义,且需要通过强制类型转换实现。
class Bike
{
public:
void move();
void stop();
void repair();
protected:
int change_color(int color);
private:
int _color;
};
class Player : private Bike
{
public:
void start_race();
void end_race();
protected:
int cur_strength();
private:
int _max_strength;
int _age;
};
int main() {
Player player;
Bike *p = (Bike *) &player; // 使用protected/private继承时,需要强制类型转换
Bike &ref = (Bike &) player; // 使用protected/private继承时,需要强制类型转换
return 0;
}
class Person
{
public :
void print() const
{
cout << "name:" << _name << " age: " << _age << endl;
}
void set_name(const string &name) { _name = name; };
void set_age(int age) { _age = age; };
private :
string _name;
int _age;
};
class Student : public Person
{
public:
void set_id(int id) { _id = id; };
private:
int _id;
};
int main() {
Student s;
Person *point = &s; // 将s对象的内存空间按Person类型解释
Person &ref = s; // 将s对象的内存空间按Person类型解释
Person p(s);
return 0;
}
1.6 继承下的友元
友元关系不能继承
class Base; // 前向声明
class A
{
friend class Base; // 声明 Base 为 A 的友元类
private:
int secret = 42;
};
class Base
{
public:
void accessSecret(A& a)
{
std::cout << "Base can access secret: " << a.secret << std::endl;
}
};
class Derived : public Base
{
public:
void accessSecret(A& a)
{
// 尝试访问 A 的私有成员,编译错误
std::cout << "Derived can access secret: " << a.secret << std::endl;
}
};
int main()
{
A a;
Base base;
Derived derived;
base.accessSecret(a); // 正常工作,Base 可以访问 A 的私有成员
derived.accessSecret(a); // 编译错误,Derived 无法访问 A 的私有成员
return 0;
}
1.7 继承下的static成员变量
static成员变量在类中是共享的,这意味着类的所有实例共享同一个变量,无论派生出多少个类,都只有一个static成员变量 。
class A
{
public:
static int _a;
};
class B : public A
{
};
class C : public A
{
};
int A::_a = 1;
int main()
{
A a;
B b;
C c;
// 打印出的地址相同
cout << &a._a << endl;
cout << &b._a << endl;
cout << &c._a << endl;
return 0;
}
二、派生类的成员函数
2.1 派生类构造与析构函数
2.1.1 派生类构造函数
在派生类中不显式定义构造函数,编译器自动生成构造函数。这个构造函数会先调用基类的默认构造函数,然后调用派生类中自定义类型成员变量的默认构造函数。
派生类显式定义构造函数说明:
- 构造顺序:先基类,再派生类
- 若没在派生类的初始化列表中显式指定基类构造函数,编译器会隐式调用基类的无参构造函数
- 多重继承时,基类按先后顺序构造
class A
{
public:
A(int a) : _a(a) { cout << "A()" << endl; };
private:
int _a;
};
class B
{
public:
B(int b) : _b(b) { cout << "B()" << endl; };
private:
int _b;
};
class C : public A, public B
{
public:
C(int a, int b, int c) : A(a), B(b), _c(c)
{
cout << "C()" << endl;
}
private:
int _c;
};
int main()
{
C(1, 2, 3); // 依次输出 A() 、B() 、C()
return 0;
}
2.1.2 派生类析构函数
在派生类中不显式定义析构函数,编译器自动生成析构函数。这个析构函数会先调用派生类中自定义类型成员变量的析构函数,然后调用基类的析构函数。
派生类显式定义析构函数说明:
- 析构顺序:先派生类,再基类
- 不要显式调用基类析构函数:为了保证析构顺序,编译器会在派生类的析构函数结束前自动调用基类析构函数。如果用户显式调用了,则会对基类析构两次。
- 多重继承时,基类析构顺序与基类构造顺序相反
class A
{
public:
virtual ~A() // 基类的析构函数建议为虚函数,通过基类指针或引用删除一个派生类对象,virtual确保能调用派生类的析构函数,后续多态章节会详细讲解
{
cout << "~A()" << endl;
}
};
class B : public A
{
public:
~B()
{
cout << "~B()" << endl;
A::~A(); // 不应该显式调用基类的析构函数,这样基类会析构两次
}
};
int main()
{
B b; // 依次输出 ~B() 、~A() 、~A(), 基类调用了两次析构函数
return 0;
}
2.2 派生类拷贝构造与赋值函数
2.2.1 派生类拷贝构造函数
在派生类中不显式定义拷贝构造函数,编译器自动生成拷贝构造函数。这个拷贝构造函数会先调用基类的拷贝构造函数,然后对派生类对象中内置类型成员变量按字节拷贝(浅拷贝),如果成员变量为自定义类型则调用它的拷贝构造函数。
派生类显式定义析构函数说明:
- 拷贝构造顺序:先基类,再派生类
- 若没在派生类的初始化列表中显式指定基类拷贝构造函数,编译器会隐式调用基类的拷贝构造函数
- 多重继承时,基类按先后顺序拷贝构造
class A
{
public:
A(int a = 0) : _a(a) {};
A(const A &obj):_a(obj._A)
{
cout << "A(const &A)" << endl;
}
int _a;
};
class B : public A
{
public:
B() = default;
B(const B &obj) : A(obj)
{
cout << "B(const &B)" << endl;
}
};
int main() {
B b1;
B b2(b1); // 依次输出 A(const &A)、B(const &B)
return 0;
}
2.2.2 派生类赋值函数
在派生类中不显式定义赋值函数,编译器自动生成赋值函数。这个赋值函数会先调用基类的赋值函数,然后对派生类对象中内置类型成员变量按字节拷贝(浅拷贝),如果成员变量为自定义类型则调用它的赋值函数。
派生类显式定义赋值函数说明:
- 赋值顺序:先基类,再派生类
- 多重继承时,基类按先后顺序赋值
class A {
public:
A(int a = 0) : _a(a) {};
A &operator=(const A &obj)
{
if (this != &obj)
{
_a = obj._a;
cout << "A=()" << endl;
}
return *this;
}
private:
int _a;
};
class B : public A {
public:
B() = default;
B &operator=(const B &obj)
{
if (this != &obj)
{
A::operator=(obj); // 调用基类的赋值函数
cout << "B=()" << endl;
}
return *this;
}
};
int main() {
B b1;
B b2;
b2 = b1; // 依次输出 A=()、B=()
return 0;
}
三、菱形继承
3.1 什么是菱形继承
多个子类继承同一个父类而又有子类同时继承这几个子类或其子孙类。
class Person
{
public:
std::string _name;
};
class Student : public Person {
protected:
int _num; // 学号
};
class Teacher : public Person{
protected:
int _id; // 职工编号
};
class Assistant : public Student, public Teacher
{
protected:
std::string _majorCourse; // 主修课程
};
3.2 菱形继承带来的问题
菱形继承带来了二义性和数据冗余的问题。
二义性:Student
和Teacher
类都继承Person
类,所以它们继承了_name属性,Assistant
类继承Student
和Teacher
类,所以在Assistant
类中访问_name时会有歧义,编译器不知道是访问Student
类的_name还是Teacher
类的_name。
数据冗余:对于Assistant
对象来说存放两份_name。
int main()
{
Assistant a;
a._name = "张三"; // 错误,二义性
// 指定基类作用域解决二义性问题,但是无法解决数据冗余问题。
a.Student::_name = "张三";
a.Teacher::_name = "李四";
}
指定基类作用域解决二义性问题,但是用起来很奇怪,对Assistant
类来说_name属性应该只有一个才对,不能我学生身份时有一个名字,教师身份时又有一个名字,而且没有解决数据冗余问题。
3.3 菱形继承内存布局
为了便于观察菱形继承的内存布局,下面使用简化的类结构。
测试环境: MSVC编译器 32位
class A
{
public:
int _a;
};
class B : public A
{
public:
int _b;
};
class C : public A
{
public:
int _c;
};
class D : public B, public C
{
public:
int _d;
};
int main() {
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
return 0;
}
3.4 虚继承解决方案
3.4.1 虚继承原理
虚继承(虚拟继承) 是面向对象编程中的一种技术,是指一个指定的基类,在继承体系结构中,将其成员数据实例共享给也从这个基类型直接或间接派生的其它类。通过虚继承可以解决菱形继承带来的二义性与数据冗余问题。
语法:
class Derive: virtual public Base{};
注意:只有直接继承基类的派生类需要进行虚继承,间接继承的派生类不需要。
测试环境: MSVC编译器 32位
class A
{
public:
int _a;
};
class B : virtual public A
{
public:
int _b;
};
class C : virtual public A
{
public:
int _c;
};
class D : public B, public C
{
public:
int _d;
};
int main() {
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
return 0;
}
观察d对象内存视图可以发现,使用虚继承后,将同属于B和C的A存放到对象最下面, 将 B空间位置原来存放基类A的地方现在存放了一个指针,C空间位置原来存放基类A的地方现在也变为存放了一个指针,这两个指针为虚基表指针,虚基表中存放了A的偏移量。查看B的虚基表(0x00bfbb40)可以看到有一个值0x14000000(补码),转换为源码后对应的值为20,B空间首地址加上偏移量20刚好是A中成员_a的地址。查看C的虚基表(0x00bfbb48)可以看到有一个值0x0c000000(补码),转换为源码后对应的值为12,C空间首地址加上偏移量12刚好是A中成员_a的地址。这就是虚继承解决二义性与数据冗余的原理。
说明:
- 虚基表中起始全0的4个字节空间是为了实现多态的虚表指针预留的。
- 虚继承的基类只会调用一次构造.
- 菱形继承没有虚基表,菱形虚拟继承才有虚基表
同一派生类的所有对象共享一份虚基表
D d1, d2 ,d3;
3.4.2 虚继承缺点
- 参照前文部分,虚继承对于B类与C类的开发者提出更高要求,能预言出未来会被多继承而引发的菱形继承问题。
- 在
public
继承下向上类型转换是安全的,但是虚继承改变了对象内存布局,类型转换时编译器要做很多额外操作,效率低下。
四、继承与组合的选择
组合:
- 具有"has-a"或"contain-a"的关系
- 子对象所属类的源代码可有可无
- 类间是水平关系,相比继承可减少类的层次
- 黑盒复用,功能复用
继承:继承方式不同,目的不同
- 基类源代码必须有
- 白盒复用,代码复用
- public继承表示“is-a”关系
- private继承表示“has-a”,“contain-a”,"implement of"关系,完全可以换成组合
- protected继承同private,同时便于在多层继承中保持这种关系
总结:除了"is-a"关系使用public继承以外其它都使用组合。