之前写类的时候,经常会遇到一种情况:几个类拥有很多相同的成员,但是又有各自不同的功能。
比如学生和老师,都有姓名、年龄、地址和电话,也都有身份认证的功能。学生有自己的学号和学习功能,老师则有职称和授课功能。如果每个类都把这些公共成员重新写一遍,不仅代码重复,后面修改起来也麻烦。
C++ 中的继承就是用来解决这类问题的。它可以在保留原有类特征的基础上,让新类继续拥有原来的成员,再增加自己的属性和功能。
一、为什么需要继承?
先看一个简单的例子:
cpp
#include <iostream>
#include <string>
using namespace std;
class Student
{
public:
void identity()
{
cout << "身份认证" << endl;
}
void study()
{
cout << "学生学习" << endl;
}
protected:
string _name;
string _address;
string _tel;
int _age;
int _stuid;
};
class Teacher
{
public:
void identity()
{
cout << "身份认证" << endl;
}
void teaching()
{
cout << "老师授课" << endl;
}
protected:
string _name;
string _address;
string _tel;
int _age;
string _title;
};
可以看到,Student 和 Teacher 都重复定义了姓名、地址、电话、年龄以及 identity()。
如果以后身份认证的逻辑需要修改,就需要在两个类中分别修改。随着类越来越多,重复代码也会越来越多。
这时候可以把公共的部分抽出来,放进一个 Person 类中,让学生类和老师类继承它:
cpp
class Person
{
public:
void identity()
{
cout << "身份认证" << endl;
}
protected:
string _name;
string _address;
string _tel;
int _age;
};
class Student : public Person
{
public:
void study()
{
cout << "学生学习" << endl;
}
protected:
int _stuid;
};
class Teacher : public Person
{
public:
void teaching()
{
cout << "老师授课" << endl;
}
protected:
string _title;
};
现在,学生和老师都可以使用从 Person 继承来的 identity(),而不必分别定义一遍。
这里的 Person 是基类,也叫父类;Student 和 Teacher 是派生类,也叫子类。
继承的主要作用是复用已有类的实现,同时允许派生类增加自己的成员。
二、继承的基本语法
C++ 的继承语法并不复杂:
cpp
class 派生类名 : 继承方式 基类名
{
// 派生类自己的成员
};
比如:
cpp
class Student : public Person
{
};
这里的 public 表示公有继承。
C++ 中常见的继承方式有三种:
public:公有继承。protected:保护继承。private:私有继承。
需要注意,如果使用 class 定义类,省略继承方式时默认为 private;如果使用 struct,默认继承方式是 public。不过,实际写代码时最好把继承方式明确写出来,避免因为默认规则而看错代码。
在日常使用中,公有继承最常见,也更适合表达"派生类是一种基类"的关系。
三、继承之后,成员的访问权限有什么变化?
这是继承中很容易混淆的地方。
先记住一个结论:基类的 private 成员虽然属于派生类对象的一部分,但派生类不能直接访问这些成员。
例如:
cpp
class Person
{
public:
void Print()
{
cout << _name << endl;
}
protected:
string _name;
private:
int _age;
};
class Student : public Person
{
public:
void Test()
{
cout << _name << endl; // 可以访问
// cout << _age << endl; // 编译错误
}
};
_age 并不是没有被继承,而是受到访问权限限制,派生类不能直接访问它。
如果某个成员不希望被类外代码直接访问,但又需要让派生类使用,就可以考虑将它定义为 protected。
三种继承方式的访问规则
理解时,可以把基类成员原本的访问权限与继承方式结合起来看。对于基类的 public 和 protected 成员,派生类中的访问权限取两者中更严格的一种。
| 基类成员权限 | 公有继承 public |
保护继承 protected |
私有继承 private |
|---|---|---|---|
public |
public |
protected |
private |
protected |
protected |
protected |
private |
private |
派生类不可直接访问 | 派生类不可直接访问 | 派生类不可直接访问 |
这里有两个容易搞错的点。
第一,基类中的 private 成员并不会因为公有继承就变成派生类的 public 成员。它仍然不能被派生类直接访问。
第二,继承方式会影响派生类外部的访问权限。比如基类的公有成员经过私有继承后,在派生类外部就不能再通过普通的公有接口方式访问。
一般情况下,优先考虑公有继承。protected 和 private 继承有各自的使用场景,但不应该只是为了让代码编译通过就随意选择。
四、派生类对象可以转换成基类吗?
假设有下面两个类:
cpp
class Person
{
public:
string _name;
};
class Student : public Person
{
public:
int _stuid;
};
Student 对象本身包含了继承自 Person 的部分,因此,公有继承下可以让基类指针或基类引用指向派生类对象:
cpp
Student s;
Person* pp = &s;
Person& rp = s;
这类转换通常被形象地称为"切片"中的基类部分引用或切片关系。更准确地说,指针和引用只是访问派生类对象中的基类部分,并没有复制出一个独立的基类对象。
如果直接用派生类对象初始化一个基类对象:
cpp
Student s;
Person p = s;
这就会创建一个独立的 Person 对象,只保留 Person 部分的数据。派生类独有的 _stuid 不会进入这个基类对象,这通常称为对象切片。
反过来则不行:
cpp
Person p;
// Student s = p; // 编译错误
因为一个普通的 Person 对象不一定拥有学生所需要的成员。
如果基类指针实际上指向的是派生类对象,在某些场景下需要向下转换,可以使用运行时类型识别机制,例如 dynamic_cast。不过,安全转换需要满足相应条件,不能把任意基类指针强制转换成派生类指针后就直接使用。
五、继承中的作用域与成员隐藏
继承体系中的基类和派生类有各自独立的作用域。
如果派生类定义了一个与基类同名的成员,那么派生类成员会隐藏基类中的同名成员。
例如:
cpp
#include <iostream>
using namespace std;
class Person
{
public:
int _num = 111;
};
class Student : public Person
{
public:
int _num = 999;
void Print()
{
cout << _num << endl;
cout << Person::_num << endl;
}
};
int main()
{
Student s;
s.Print();
return 0;
}
输出结果是:
text
999
111
在 Student 的作用域中,直接使用 _num,访问的是 Student::_num。如果想明确访问基类成员,就要写成 Person::_num。
成员函数也有同样需要注意的地方:
cpp
class A
{
public:
void fun()
{
cout << "A::fun()" << endl;
}
};
class B : public A
{
public:
void fun(int x)
{
cout << "B::fun(int)" << endl;
}
};
这里即使两个函数的参数列表不同,B::fun(int) 仍然会隐藏基类中的 A::fun()。
因此:
cpp
B b;
b.fun(10); // 可以
// b.fun(); // 编译错误
如果确实需要在派生类对象上使用基类的同名函数,可以通过作用域明确指定:
cpp
b.A::fun();
也可以在派生类中使用 using A::fun; 引入基类的同名函数。不过,更好的习惯还是避免在继承体系中随意定义含义相同但接口容易混淆的成员。
六、派生类的默认成员函数
继承不仅影响普通成员,还会影响构造函数、拷贝构造函数、赋值运算符和析构函数等成员函数。
1. 构造函数
创建派生类对象时,要先初始化基类部分,再初始化派生类自己的成员。
cpp
class Person
{
public:
Person(const string& name)
: _name(name)
{
cout << "Person()" << endl;
}
protected:
string _name;
};
class Student : public Person
{
public:
Student(const string& name, int num)
: Person(name), _num(num)
{
cout << "Student()" << endl;
}
private:
int _num;
};
这里的 Person(name) 用于调用基类构造函数。
如果基类没有默认构造函数,那么派生类构造函数就需要在初始化列表中明确调用合适的基类构造函数,否则就无法正确初始化基类部分。
2. 拷贝构造函数
派生类拷贝构造时,也需要复制基类部分:
cpp
Student(const Student& s)
: Person(s), _num(s._num)
{
}
Person(s) 会调用基类的拷贝构造函数,负责复制 Person 部分的数据。
如果不显式处理基类部分,不能假设派生类自己的拷贝代码就自动完成了所有想要的复制工作。
3. 赋值运算符
派生类的赋值运算符需要处理基类部分以及派生类自己的成员:
cpp
Student& operator=(const Student& s)
{
if (this != &s)
{
Person::operator=(s);
_num = s._num;
}
return *this;
}
这里通过 Person::operator=(s) 显式调用基类的赋值运算符。
派生类中的赋值运算符可能隐藏基类的同名运算符,所以指定 Person:: 可以明确告诉编译器要调用哪个函数。
4. 析构函数
析构时的顺序与构造时相反。
创建派生类对象时,先构造基类部分,再构造派生类部分;销毁对象时,则先执行派生类析构函数,再执行基类析构函数。
这样可以保证派生类的成员先被清理,然后基类部分再进行清理。
七、final:禁止继续继承
C++11 引入了 final 关键字,可以用来限制类被继续继承:
cpp
class Base final
{
};
// 编译错误:Base 不允许被继承
// class Derived : public Base {};
如果一个类不希望被其他类扩展,可以使用 final 明确表达这个限制。
八、总结
继承最直观的作用就是复用代码,但实际使用时,不能只记住 class Student : public Person 这一种写法。
成员访问权限会受到继承方式影响;派生类中的同名成员会隐藏基类成员;派生类对象可以通过公有继承转换成基类指针或引用,但不能反过来把任意基类对象当作派生类对象使用。
另外,构造、拷贝、赋值和析构都需要考虑基类部分的处理顺序。
把这些规则弄清楚,写继承代码时才能知道哪些行为是语言本身规定的,哪些地方需要自己明确处理。