对C++类的整理(3)——继承

派生与继承是一对好兄弟

考点:类与类之间有三种关系:has-auses-ais-ahas-a表示一个类是另一个类的数据成员;uses-a表示一个类使用另一个类的函数或对象;is-a表示继承。用有向无环图(DAG)表示类之间的继承关系,称为类格,前驱结点称为基类,后继节点称为派生类。

基类和派生类的关系

  之前的类都是独立的类,而通过继承我们可以将类联系起来,构成一种层次关系。位于底层的叫基类,继承得到的类叫派生类。我们可以“一继承一”,“一继承多”,“一派生多”,总之这种派生关系是没限制的。如果你不想某个类用作基类,可以在类名后面加上final

定义派生类

  派生类通过基类名表(类派生列表)指出它是从哪些基类派生而来的,其基本格式为:[访问控制] 基类名1, [访问控制] 基类名2, ...,访问控制和访问说明符一样,有三种:privateprotectedpublic,省略的话,stuct默认为public,class默认为private,这是它们唯二的区别。

1
2
3
4
5
class Base;//基类

class Derived: Base{
    //成员
}

访问控制和继承

  派生类的成员有两部分组成:自己的和继承的。自己的成员受访问说明符控制,而继承的那部分受 基类的访问说明符 和 基类名表的访问控制符 控制。具体如下:

  1. 先看基类的访问说明符:
    • private:只能在基类中访问,派生类和(任何)外部无法访问
    • protected:能在基类、派生类中访问,(任何)外部无法访问
    • public:能在基类、派生类中访问,(基类)外部可以访问,(派生类)外部要看下面一部分
  2. 再看继承时的访问描述符
    • private:继承的来的成员,除基类中的private部分外,全部属于派生类的private。(外部无法访问)
    • protected:继承的来的成员,除基类中的private部分外,全部属于派生类的protected。(外部无法访问)
    • public:继承的来的成员,除基类中的private部分外,基类中的protected部分属于派生类的protected,基类中的public部分属于派生类的public

(注意,外部指的是类的用户)

  派生类会继承基类中的以下部分:

  • 基类中privateprotectedpublic一般的数据成员和成员函数
  • 虚函数

  派生类不会继承基类的以下部分:

  • 友元声明:友元声明永远只对做出声明的类有效
  • 静态成员:静态成员在整个类体系(基类和派生类)中被共享

改变个别成员的访问性

  特别的,我们可以单独地改变某些成员的访问控制,称为访问声明。格式为基类名::成员,比如:

1
2
3
4
5
6
7
8
9
10
class Base{
public:
    int a;
};

class Derived: private Base{
public:
    Base::a;//外部可以访问
    //也可以这样写 using Base::a;//见Primer 546
};

  注意

  1. 访问声明不能带任何类型说明(数据成员),或参数和返回类型声明(成员函数)
  2. 访问声明只能用于基类中的非private部分,基类的私有成员不能用访问声明
  3. 访问声明不能降低基类成员的可访问性(这条有问题,但考试还是当它对)
  4. 访问声明作用于所有同名函数(重载函数),因此,同名函数位于基类的不同访问域时,无法用访问声明
  5. 若派生类中存在与基类名字相同的成员,则不能用访问声明

类作用域

  派生类的作用域嵌套在基类里面(想象几个同心圆)。当要查找某个数据成员或成员函数时,编译器先从内部查找,若不存在,再到外部查找。也就是说,如果派生类中存在与基类同名的成员,则派生类的成员将“隐藏”基类的成员,但我们依然可以通过类名::成员的方式显式地使用基类的成员。

  实际上,类名::成员的方式是让编译器从指定类开始找,而非内部,这点在多层继承时要注意。

   注意,只要名字相同,就会隐藏基类成员;就算同名而参数不同,也会隐藏!!! 如果想要隐藏某个,而不隐藏某个,可以使用using。参照上面的“改变个别成员的访问性”那一节。

多继承和虚继承

  派生类可以继承多个基类,这种继承是没有任何限制的,你甚至可以C继承B和A,同时B又继承A,这种情况下,C实际上有两个A部分,使用时要分清类作用域。

  如果你希望上面的例子中,C只有一个A部分,那么可以在B继承A的“基类名表”前加virtual

1
2
3
class A{};
class B: virtual public A{};
class C: public A, public B{};

  这样,在C中,无论你是通过B使用A成员,还是直接使用A成员,都是同一个A部分。

派生类的初始化

  派生类需要初始化两部分:从基类继承的部分 与 派生类自己的部分。我们依然可以用初始化列表,用基类名(变元表)初始化基类部分,用数据成员(参数)初始化自己部分。系统会先执行基类的初始化,之后的顺序和之前一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Base{
public:
    Base(int i): data(i){}
private:
    int data1;
};

class Derived: private Base{
public:
    Derived(int i, int j):Base(i), data2(j)//
private:
    int data2;
};

  对于有多个基类的派生类,其基类的初始化顺序取决于声明基类时的“基类名表”。总的来说,构造函数的执行顺序为:基类构造函数——对象成员构造函数——派生类构造函数;析构函数的执行情况与之相反。

多态性

  一般情况下,引用或指针的类型 与 所绑定的对象的类型 应一致。但在继承关系中,允许将基类的指针或引用绑定在派生类上。这种情况下,基类指针只能引用基类的成员,如果要引用派生类的成员,则必须使用强制类型转换,将基类指针转换为派生类指针。

  我们之所以能将基类的指针或引用绑定在派生类上,是因为派生类中包含一个基类部分。如果我们将派生类的指针或引用绑定在基类上,则指针或引用可能会使用基类中不存在的成员,因此不允许将派生类的指针或引用绑定在基类上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class A {
public:
	int data;
	A(int i=1):data(i){}
};

class B :public A{
public:
	int data;
	B(int i=-1):data(i){}
};

int main() {
	B *p;
	A a;
	p = (B*)&a;//强制类型转换
    std::cout<<p->data<<endl;//错误!
    cout<<((A*)p)->data<<endl;//1
    
    A *p;
    B b;
    p=&b;
    std::cout<<p->data<<endl;//1
    std::cout<<((B*)p)->data<<endl;//-1
}

  在此引入静态类型动态类型的关系:静态类型是声明时所指的类型,而动态类型是内存中所指的对象的类型。对于指针而言,指针的类型为静态类型,指针所指对象的类型为动态类型。

利用派生类初始化基类

  如果基类有复制构造函数,那么我们可以传递一个派生类,但只能用派生类中包含的基类部分。如果我们将派生类转化为基类,则派生类特有的那部分将会被“切掉”。

虚函数

  如果我们希望基类的某些函数在派生类中覆盖,我们可以在基类中将该函数声明为虚函数,即在函数声明前加virtual

1
2
3
4
class Base{
public:
    virtual void function();
};

  之后,我们在派生类中要定义一个函数名、返回类型、参数个数、参数类型、参数顺序完全相同的函数,才能覆盖派生类的版本。否则派生类依然会继承其在基类中的版本。

1
2
3
4
5
class Derived: public Base{
public:
    void function() const [override];
    //C++11允许在const后面显式注明“覆盖”override,但这非必须
};

  注意,这里的覆盖与之前的覆盖不同。按照之前的覆盖,如果我们用基类指针去使用被覆盖的函数,则使用的是覆盖前的版本;而声明为虚函数后,使用的是覆盖后的版本。也就是说,指针会执行实际指向的对象的函数。

  虚函数有以下特点:

  • 一旦基类声明了虚函数,则无论经过多少次派生,派生类的派生类依然保持这个函数的虚特性。(但之后的虚函数无需再加virtual,可以但没必要~)
  • 虚函数必须是成员函数,且必须位于类内的函数声明,不能用于类外函数定义
  • 不能将友元声明为虚函数
  • 析构函数可以是虚函数,但构造函数不能

  关于最后一点,因为如果我们用基类指针指向派生类,当我们delete指针时,则只会析构基类成员,而不会析构派生类成员,为了达到后一点,必须用虚析构函数。至于构造函数,编译器必须从基类开始,沿继承路径逐个调用构造函数,不能“选择性”地调用虚构函数。

纯虚函数和抽象类

  纯虚函数的声明如下:

1
virtual 类型 函数名( 参数表 ) = 0;

  这个实际上和虚函数的特点是一样的,但有它后面的=0表示它自己是没定义的。也就是说,我们不能在基类用它。而是需要等到派生类覆盖掉它后,才能在派生类中用。

  基类只要有纯虚函数,就是抽象类。抽象类有如下特点:

  • 只能用作其他函数的基类
  • 不能建立具体的对象(但可以声明指针和引用)
  • 不能用作参数类型、函数返回值或显式类类型转换

  抽象类的唯二用处是:

  • 声明指针或引用
  • 作为其他类的基类

  那么这有什么用呢?可以想象,如果我们由抽象类派生了多个派生类,则我们可以用抽象类的指针,指向这些派生类,从而实现多态,使程序更加灵活。甚至,我们可以建立抽象类的数组,储存不同的派生类。