继承的基本语法不难,比较容易让人绕进去的,反而是一些边界情况:为什么基类的友元不能访问派生类的私有成员?静态成员被继承之后到底有几份?多个基类同时拥有同名成员时怎么办?菱形继承又为什么需要虚继承?
这些问题都和继承体系的结构有关,理解以后,在设计类时就能少踩不少坑。
一、友元关系不能继承
先看一个例子:
cpp
class Student;
class Person
{
public:
friend void Display(const Person& p,
const Student& s);
protected:
string _name;
};
class Student : public Person
{
protected:
int _stuNum;
};
void Display(const Person& p, const Student& s)
{
cout << p._name << endl;
cout << s._stuNum << endl; // 编译错误
}
Display() 是 Person 的友元,因此它可以访问 Person 的私有和保护成员。但是,它并不是 Student 的友元,所以不能因为 Student 继承了 Person,就顺便访问 Student 自己的保护成员。
如果确实需要让 Display() 访问 Student 中的受保护或私有成员,就需要在 Student 中也声明它为友元。
cpp
class Student : public Person
{
friend void Display(const Person& p,
const Student& s);
protected:
int _stuNum;
};
这里要记住:友元关系不具备继承性。 基类的友元不会自动成为派生类的友元,派生类也不会自动拥有基类的友元关系。
友元本身是为了给特定函数或类开放访问权限,不应该把它理解成普通成员的继承规则。
二、继承中的静态成员
再来看 static 成员。
cpp
class Person
{
public:
static int _count;
};
int Person::_count = 0;
class Student : public Person
{
};
静态成员与普通成员不同。普通成员变量通常属于每个对象,而静态成员属于类相关的共享状态。
如果 Person 定义了一个静态成员 _count,那么 Student 继承它之后,并不会为继承体系中的每个派生类对象再额外创建一份 _count。
例如:
cpp
Person p;
Student s;
p._count = 10;
cout << s._count << endl;
cout << Person::_count << endl;
cout << Student::_count << endl;
在没有其他修改的情况下,这几个访问表达式指向的是同一个继承来的静态成员,因此后面两个结果都是 10。
静态成员也可以通过类名访问:
cpp
Person::_count;
Student::_count;
不过,后者并不意味着 Student 有一份独立的 _count。从设计上看,使用 Person::_count 通常更能体现这个静态成员的定义归属。
三、多继承是什么?
之前的继承例子都是一个派生类只继承一个基类,这叫单继承。
C++ 还支持多继承,也就是一个派生类可以拥有多个直接基类。
例如,一个助教既具有学生的身份,也具有老师的一部分身份:
cpp
class Student
{
public:
int _stuNum;
};
class Teacher
{
public:
int _teacherId;
};
class Assistant : public Student, public Teacher
{
public:
string _majorCourse;
};
Assistant 同时继承了 Student 和 Teacher,因此可以使用这两个基类中的公有成员:
cpp
Assistant a;
a._stuNum = 1001;
a._teacherId = 2001;
a._majorCourse = "C++";
多继承在表达多个基类关系时比较灵活,但它也会让对象布局和成员访问更加复杂。
在通常的多继承布局中,派生类对象包含各个基类子对象,以及派生类自己的成员。不同基类子对象在对象内存中的位置可能不同,因此从同一个派生类对象转换成不同基类指针时,指针值也可能不同。
多继承最容易暴露的问题,是不同基类中存在同名成员。
四、菱形继承有什么问题?
下面是一个典型的菱形继承结构:
cpp
class Person
{
public:
string _name;
};
class Student : public Person
{
protected:
int _stuNum;
};
class Teacher : public Person
{
protected:
int _teacherId;
};
class Assistant : public Student, public Teacher
{
protected:
string _majorCourse;
};
结构关系如下:
text
Person
/ \
Student Teacher
\ /
Assistant
Assistant 同时继承了 Student 和 Teacher,而这两个类又分别继承了 Person。
问题就在这里:Assistant 会包含两份 Person 子对象。
一份来自 Student,另一份来自 Teacher。因此,当访问 _name 时,编译器无法知道应该选择哪一份:
cpp
Assistant a;
// a._name = "Peter"; // 二义性,编译错误
可以通过明确指定基类来解决访问时的二义性:
cpp
a.Student::_name = "Peter";
a.Teacher::_name = "Mary";
但这种方法只解决了"访问哪一份"的问题,并没有解决 Person 数据重复的问题。一个 Assistant 对象仍然存在两份独立的 _name。
如果设计上希望学生身份和老师身份共享同一份 Person 数据,就需要考虑虚继承。
五、虚继承如何解决菱形继承?
虚继承的写法是在继承时增加 virtual:
cpp
class Person
{
public:
string _name;
};
class Student : virtual public Person
{
protected:
int _stuNum;
};
class Teacher : virtual public Person
{
protected:
int _teacherId;
};
class Assistant : public Student, public Teacher
{
protected:
string _majorCourse;
};
这里的 Student 和 Teacher 都使用虚继承继承 Person。
当 Assistant 同时继承它们时,整个对象就共享同一份虚基类 Person 子对象。这样既消除了重复的 Person 数据,也解决了访问 _name 时的二义性:
cpp
Assistant a;
a._name = "Peter";
这段代码可以正常使用同一份 _name。
不过,虚继承也会让对象布局和构造过程更复杂一些,不能只把它当成一个普通的语法修饰符。
虚继承下的构造函数
虚继承还有一个需要注意的地方:在含有虚基类的继承体系中,负责构造虚基类的是最派生类。
例如:
cpp
class Person
{
public:
Person(const string& name)
: _name(name)
{
}
string _name;
};
class Student : virtual public Person
{
public:
Student(const string& name, int num)
: Person(name), _stuNum(num)
{
}
protected:
int _stuNum;
};
class Teacher : virtual public Person
{
public:
Teacher(const string& name, int id)
: Person(name), _teacherId(id)
{
}
protected:
int _teacherId;
};
class Assistant : public Student, public Teacher
{
public:
Assistant(const string& name1,
const string& name2,
const string& name3)
: Person(name3),
Student(name1, 1),
Teacher(name2, 2)
{
}
protected:
string _majorCourse;
};
创建 Assistant 对象时,Person 是虚基类,所以由最派生类 Assistant 负责初始化这份 Person 子对象。这里最终使用的是 Person(name3)。
虽然 Student 和 Teacher 的构造函数初始化列表中也写了 Person(name),但当它们作为 Assistant 的基类子对象构造时,这些虚基类初始化会被忽略。
这点比较容易让人疑惑:既然 Student 和 Teacher 都调用了 Person 构造函数,为什么最终只有一个名字?原因就是虚继承确保共享一份虚基类子对象,而它的初始化由最派生类负责。
虚继承能解决菱形继承中的重复基类问题,但实现和理解成本都更高,因此更应该从类之间的关系出发,谨慎决定是否需要多继承。
六、继承和组合有什么区别?
写类的时候,并不是所有代码复用都应该依靠继承。C++ 中还有组合这一种常见的设计方式。
两者最核心的区别是:
- 公有继承体现 is-a 关系:派生类是一种基类。
- 组合体现 has-a 关系:一个类拥有另一个类对象作为自己的成员。
比如汽车和轮胎之间就更适合使用组合关系。汽车不是一种轮胎,而是拥有轮胎。
cpp
class Tire
{
public:
string _brand;
size_t _size;
};
class Car
{
private:
Tire _t1;
Tire _t2;
Tire _t3;
Tire _t4;
};
这里 Car 通过成员对象拥有四个 Tire,这就是组合。
而汽车和某个具体车型之间就可以体现继承关系:
cpp
class Car
{
public:
void Drive()
{
cout << "Car driving" << endl;
}
};
class BMW : public Car
{
public:
void Drive()
{
cout << "BMW driving" << endl;
}
};
从关系上讲,BMW 是一种 Car,因此用继承表达比较自然。
为什么经常建议优先考虑组合?
继承虽然能复用基类的实现,但也会让派生类依赖基类的内部设计。基类的变化有可能影响所有派生类,导致类之间耦合较强。
组合则更多通过稳定的接口使用另一个对象。只要接口没有变化,内部实现可以调整,组合类一般不需要跟着改动。
所以,在设计类的时候,可以先问自己:这两个类真的是"是一种"的关系,还是一个类只是"拥有"另一个对象?
如果关系符合 is-a,就可以考虑继承;如果符合 has-a,通常更适合组合。需要扩展多态行为时,也可能需要使用继承,但不应该为了复用几行代码而强行建立继承关系。
七、总结
继承不只是把一个类的成员拿到另一个类里使用。进入多继承之后,还要考虑成员来自哪个基类、同名成员该如何访问,以及基类子对象究竟有几份。
友元关系不会自动继承,静态成员在继承体系中共享其原来的静态对象;菱形继承可能导致重复基类数据和二义性,虚继承则可以让多个路径共享一份虚基类。
最后,继承和组合要根据类之间的真实关系来选择。能够复用代码并不代表就适合继承,合理的类设计应该尽量让接口清晰、依赖关系可控。