C++面向对象——继承、派生与多态

继承、派生

面向对象程序设计有4个主要特点: 抽象、封装、继承和多态性。

面向对象技术强调软件的可重用性。

C++语言提供了类的继承机制,解决了软件重用问题。

继承(Inheritance)就是在一个已存在的类的基础上建立一个新类,实质就是利用已有的数据类型定义出新的数据类型。

在继承关系中:被继承的类称为基类(Base class)(或父类),定义出来的新类称为派生类(Derived class)(子类)

派生类不仅可以继承原来类的成员,还可以通过以下方式扩充新的成员:
(1)增加新的数据成员
(2)增加新的成员函数
(3)重新定义已有成员函数
(4)改变现有成员的属性

多层次继承

在派生过程中,派生出来的新类同样可以作为基类再继续派生出更新的类,依此类推形成一个层次结构。

直接参与派生出某类称为直接基类;
基类的基类,以及更深层的基类称为间接基类。

在类的层次结构中,处于高层的类表示最一般的特征,而处于底层的类则表示更具体的特征。

扫描二维码关注公众号,回复: 17544845 查看本文章

类族:同时一个基类可以直接派生出多个派生类。形成了一个相互关联的类族。

如MFC就是这样的类族,它由一个CObject类派生出200个MFC类中的绝大多数。

单继承:派生类只有一个直接基类。
多重继承:派生类同时有多个直接基类。
关于基类和派生类的关系,可以表述为:派生类是基类的具体化,而基类则是派生类的抽象。

声明派生类的一般形式为:

class 派生类名: [继承方式] 基类名 {
    
    
    派生类新增加的成员
};
#include<iostream>
using namespace std;
class A{
    
    
    private:
        char s[21];
};
class B: public A{
    
    
    private:
        int num;
};
int main(){
    
    
    A a;   B b;
    cout<<"sizeof(A) = "<<sizeof(A)<<endl;// 21
    cout<<"sizeof(B) = "<<sizeof(B)<<endl;// 28
    return 0;
}

继承方式

继承方式是可选的,如果不写,则默认为private(私有的)。

基类名必须是程序中已有的一个类名

继承方式包括:public(公用的)、private(私有的)、protected(受保护的)

public:可以被该类中的函数、子类的函数、友元函数访问,也可以由该类的对象访问;

protected:可以被该类中的函数、子类的函数、友元函数访问,但不可以由该类的对象访问;

private:可以被该类中的函数、友元函数访问,但不可以由子类的函数、该类的对象访问。

private 关键字的作用在于更好地隐藏类的内部实现。

如果声明不写 public、protected、private,则默认为 private;

声明public、protected、private的顺序可以任意;

在一个类中,public、protected、private 可以出现多次,每个限定符的有效范围到出现另一个限定符或类结束为止。但为了使程序清晰,应该使每种限定符只出现一次。

不同继承方式的影响主要体现在:

  1. 派生类成员对基类成员的访问控制;
  2. 派生类对象对基类成员的访问控制

派生类拥有基类的全部成员函数和成员变量,派生类中不能访问基类中的private成员。

#include<iostream>
#include<string>
#include<cstring>
using namespace std;
class A{
    
    
    private:
        char s[21];
    public:
        A(){
    
     strcpy(s, "hello"); }
        void show(){
    
     cout<<"s = "<<string(s)<<endl; }
};
class B: public A{
    
    
    private:
        int num;
    public:
        void show(){
    
      // 重名覆盖
            //show(); // 递归操作,不合法,猜一猜这样输出的结果会是什么
            A::show();// 指定基类 
            cout<<"num = "<<num<<endl;
        }
};
int main(){
    
    
    A a;  B b;
    a.show();  b.show();
    return 0;
}

派生新类经历了三个步骤:

  1. 吸收基类成员

    派生类继承和吸收了基类的全部数据成员和除了构造函数、析构函数之外的全部成员函数。

  2. 改造基类成员

    一是基类成员的访问方式问题;

    二是对基类数据成员或成员函数的覆盖。

  3. 添加新成员

    保证了派生类在功能上比基类有所发展。

继承方式对基类成员的访问属性控制

  1. 公有继承(public继承方式)
    基类中public和protected成员的访问属性在派生类中不变;
    基类中的不可访问成员和private成员在派生类中不可访问。
    注意:不可访问成员与私有成员的区别。

  2. 私有继承(private继承方式)
    基类中public和protected成员都以private成员出现在派生类中;
    基类中的不可访问成员和private成员在派生类中不可访问。
    相当于中止了基类功能的继续派生!

  3. 保护继承(protected继承方式)
    基类中public和protected成员都以protected成员出现在派生类中;
    基类中的不可访问成员和private成员在派生类中不可访问。

基类访问权限 基类访问权限 基类访问权限 基类访问权限
继承方式\派生类访问权限 public protected private 不可访问
public public protected 不可访问 不可访问
protected protected protected 不可访问 不可访问
private private private 不可访问 不可访问

类的继承方式对基类成员的访问属性控制

#include<iostream>
using namespace std;
 
class A {
    
    
    public:
        void f1();
    protected:
        void f2();
    private:
        int i;
};

class B: public A {
    
    
    public:
        void f3();
        int k;
    private:
        int m;
};

class C: protected B {
    
    
    public:
        void f4();
    protected:
        int n;
    private:
        int p;
};

class D: private C {
    
    
    public:
        void f5();
    protected:
        int q;
    private:
        int r;
};

int main() {
    
    
    A a1;
    B b1;
    C c1;
    D d1;
    return 0;
}
类的范围 f1 f2 i f3 k m f4 n p f5 q r
基类A 公用 保护 私有
公用派生类B 公用 保护 不可访问 公用 公用 私有
保护派生类C 保护 保护 不可访问 保护 保护 不可访问 公用 保护 私有
私有派生类D 私有 私有 不可访问 私有 私有 不可访问 私有 私有 不可访问 公用 保护 私有
#include<iostream>
using namespace std;
class A {
    
    
    public:
        int a;
    protected :
        int b;
    private:
        int c;
};
class B:public A {
    
    
    public:
        void show() {
    
    
            cout<<a<<endl; // public
            cout<<b<<endl; // proteced
//            cout<<c<<endl; // 不可访问 
        }
};
class C:protected A {
    
    
    public:
        void show() {
    
    
            cout<<a<<endl; // proteced
            cout<<b<<endl; // proteced
//            cout<<c<<endl; // 不可访问 
        }
};
class D:private A {
    
    
    public:
        void show() {
    
    
            cout<<a<<endl;// private
            cout<<b<<endl;// private
//            cout<<c<<endl; //不可访问 
        }
};
int main() {
    
    
    A p1; 
    B p2; p2.show();
    C p3; p3.show();
    D p4; p4.show(); 
//    cout<<p4.a; // private
    return 0;
}

【例】请先建立一个点(Point)类, 包含数据成员坐标点(x, y)。然后以它为基类,派生出一个圆类(Circle),增加数据成员半径(r),再以Circle类为直接基类,派生出一个圆柱体类(Cylinder),增加数据成员高(height)。

#include<iostream>
using namespace std;
class Point{
    
    
    public:
        double _x,_y;
};
class Circle: public Point{
    
    
    public:
        double _r;
};
class Cylinder: public Circle{
    
    
    public:
        double _height;
};
int main(){
    
    
    Cylinder c1;
    return 0;
}

派生类的构造函数

请思考:派生类的构造函数的定义应该怎么写?
(1)基类的构造函数不能继承,从基类继承来的数据怎么初始化?
(2)派生类新增的数据成员怎么初始化?

派生类的构造函数:
(1)一方面负责调用基类的构造函数对基类成员进行初始化;
(2)另一方面还要负责对基类的构造函数所需要的参数进行必要的设置。

派生类构造函数的定义格式:

派生类名::派生类构造函数名 (总参数列表):基类构造函数名 (参数列表) {
    
      
    派生类中新增数据成员初始化语句 
}

总参数列表:基类和派生类的所有参数
可省略对基类构造函数的调用,但是要求基类中必须有无参构造函数或者根本没有定义构造函数

在实例化派生类的对象时,系统首先执行基类的构造函数,然后执行派生类的构造函数。

调用基类构造函数的两种方式:

  1. 显式方式:在派生类的构造函数中,为基类构造函数提供参数
  2. 隐式方式:在派生类的构造函数中,省略基类构造函数时,派生类的构造函数自动调用基类的默认构造函数,但是要求基类的默认构造函数存在。

派生类成员的访问之------同名覆盖原则

请思考:当派生类和基类中有相同的成员时,怎么办??

若未强行指明,则通过派生类的对象使用的是派生类中的同名成员。

如果要通过派生类的对象访问基类中被覆盖的同名成员,应该使用基类名来限定。

派生类对基类成员重新定义:

通过派生类的对象调用一个被重新定义过的基类的成员函数,被调用的是派生类的成员函数;

此时,若想调用基类的同名成员函数,必须在成员函数名前加基类名和作用域运算符“::”。

【例】定义一个描述圆的类Circle和一个描述圆柱体的类Cylinder。

#include<iostream>
using namespace std;
const double PI = 3.14;
class Point{
    
    
    public:
        double _x,_y;
        Point(double x,double y) {
    
    
            _x = x, _y = y;
        }
};
class Circle: public Point{
    
    
    public:
        double _r;
        Circle(double x,double y,double r):Point(x,y){
    
    
            _r = r;
        }
        double area(){
    
    
            return PI*_r*_r;
        }
};
class Cylinder: public Circle{
    
    
    public:
        double _height;
        Cylinder(double x,double y,double r,double height):Circle(x,y,r){
    
    
            _height = height;
        }
        double area(){
    
    
            return Circle::area()*2 + 2*PI*_r*_height;
        }
};
int main(){
    
    
    Point p1(0,0);
    Circle c1(1,1,1);
    Cylinder c2(2,2,2,2);
    cout<<"圆的面积为:"<<c1.area()<<endl;
    cout<<"圆柱体表面积:"<<c2.area()<<endl;
    cout<<"圆柱体底面积:"<<c2.Circle::area()<<endl;
    return 0;
}

在C++中,处理同名函数时有以下3种基本方法:

  1. 根据函数参数的特征进行区分,即编译器根据参数的类型或个数进行区分。

    如:max(int,int)       max(float,float)
    
  2. 根据类对象进行区分。

    如:cylinder.area()         circle.area()
    
  3. 使用作用域运算符“::”进行区分

    如:Circle::area()
    

以上3种方法都是在程序编译过程中完成的,因此称为静态联编。

有子对象的派生类的构造函数

派生类名::派生类构造函数名(总参数列表):基类构造函数名(参数列表),子对象名(参数列),...{
    
      
    派生类中新增数据成员初始化语句 
}

此时,构造函数执行的一般次序为:

  1. 调用基类的构造函数;

  2. 调用子对象的构造函数,当派生类中含有多个子对象时,各子对象的构造函数的调用顺序按照它们在类中说明的先后顺序进行。

  3. 执行派生类构造函数的函数体。

派生类的析构函数

析构函数的作用是在对象撤销之前,进行必要的清理工作。

当对象被删除时,系统会自动调用析构函数。

析构函数的调用顺序与构造函数的调用顺序正好相反:

先执行派生类自己的析构函数,然后调用子对象的析构函数,最后调用基类的析构函数。

【例】分析以下程序的执行结果。

#include <iostream>
using namespace std;
class Base {
    
    
    private:
        int x;
    public:
        Base(int a) {
    
    
            x = a;
            cout<<"执行基类Base的构造函数"<<endl;
        }
        ~Base() {
    
    
            cout<<"执行基类Base的析构函数"<<endl;
        }
};
class MyClass {
    
    
    private:
        int n;
    public:
        MyClass(int num) {
    
    
            n = num;
            cout<<"执行MyClass类的构造函数"<<endl;
        }
        ~MyClass() {
    
    
            cout<<"执行MyClass类的析构函数"<<endl;
        }
};
class Derive:public Base {
    
    
    private:
        int y;
        MyClass bobj;
    public:
        Derive(int a,int b,int c):bobj(c),Base(a) {
    
    
            y = b;
            cout<<"执行派生类Derive的构造函数"<<endl;
        }
        ~Derive() {
    
    
            cout<<"执行派生类Derive的析构函数"<<endl;
        }
};
int main() {
    
    
    Derive dobj(1,2,3);
    return 0;
}

赋值兼容

#include<iostream>
using namespace std;
class A {
    
    
    public:
        void show() {
    
     cout<<"A::show(){}"<<endl; }
};
class B: public A {
    
    
    public:
        void show() {
    
     cout<<"B::show(){}"<<endl; }
};
//void show(B &b){ b.show(); }
void show(A &a) {
    
     a.show();}
int main() {
    
    
    A a;  B b;
// 下列赋值兼容的具体表现是在public继承下才可以
    a = b;     //(1)派生类对象可以向基类对象赋值
    A& pr = b; //(2)派生类对象可以初始化基类对象的引用
    A *pb = &b;//(3)派生类对象地址可以赋值给指向基类对象的指针
    show(b);   //(4)如果函数形参是基类对象或基类对象的引用,在调用函数时可以将派生类对象作为实参
    return 0;
}

多重继承的定义方式

class 派生类名:访问方式 基类名1,访问方式 基类名2,... {
    
    
    ...
}; 

多重继承下,派生类的构造函数的定义格式:

派生类构造函数名(参数表):基类名1(参数表1),基类名2(参数表2),...{
    
    
    ...
}

在多重继承下,系统首先执行各基类的构造函数,然后再执行派生类的构造函数;

处于同一层次的各基类构造函数的执行顺序与声明派生类时所继承的基类顺序一致,而与派生类的构造函数定义中所调用的基类构造函数的顺序无关。

【例】在多重继承关系下基类和派生类的构造函数的执行顺序。

#include <iostream>
using namespace std;
class B1 {
    
    
    protected:
        int b1;
    public:
        B1(int val1) {
    
    
            b1 = val1;
            cout<<"基类B1的构造函数被调用"<<endl;
        }
};
class B2 {
    
    
    protected:
        int b2;
    public:
        B2(int val2) {
    
    
            b2 = val2;
            cout<<"基类B2的构造函数被调用"<<endl;
        }
};
class D:public B1,public B2 {
    
    
    protected:
        int d;
    public:
        D(int val1,int val2,int val3):B1(val1),B2(val2) {
    
    
            d = val3;
            cout<<"派生类D的构造函数被调用"<<endl;
        }
};
int main() {
    
    
    D dobj(1,2,3);
    return 0;
}

多重继承的二义性

多重继承下,可能会产生一个类是通过多条路径从一个给定的类中派生出来的情况。

1.多重继承的二义性

情况一:被继承的多个基类中具有同名成员,在派生类中对该同名成员的访问会产生二义性。

情况二:被继承的多个基类有一个共同的基类,在派生类中访问这个共同基类的成员会产生二义性。

情况二也被称为菱形继承:一个派生类可从多个基类派生出来,又由于一个基类可派生出多个派生类。

2.多重继承的二义性问题的解决方法

一是使用作用域运算符::

直接基类名::数据成员名
直接基类名::成员函数名(参数表)

二是将直接基类的共同基类设置为虚基类。

  • 情况一:多个基类中具有同名成员
#include<iostream>
using namespace std;//情况一:多个基类中具有同名成员
class Base1{
    
    
    public:
        void fun(){
    
     cout<<"Base1::fun(){}"<<endl; }
};
class Base2{
    
    
    public:
        void fun(){
    
     cout<<"Base2::fun(){}"<<endl; }
};
class Derived:public Base1, public Base2{
    
    };

int main(){
    
    
    Derived d;
//    d.fun();// 产生二义性
    d.Base1::fun();//使用作用域运算符可以解决二义性问题 
    return 0;
    
}
  • 情况二:多个基类有一个共同的基类
#include<iostream>
using namespace std;//情况二:多个基类有一个共同的基类
class Base{
    
    
    public:
        void fun(){
    
     cout<<"Base::fun(){}"<<endl; }
};
class Derived11:public Base{
    
     };
class Derived12:public Base{
    
     };
class Derived2:public Derived11, public Derived12{
    
    };

int main(){
    
    
    Derived2 d;
//    d.fun();// 产生二义性
    d.Derived11::fun();//使用作用域运算符可以解决二义性问题 
    d.Derived12::fun();//使用作用域运算符可以解决二义性问题 
    return 0;
}

虚基类

虚基类及其派生类的构造函数

如果一个派生类有多个直接基类,而这些直接基类又有一个共同的基类,则在最终的派生类中会保留该间接共同基类数据成员的多份同名成员。

在一个类中保留间接共同基类的多份同名成员,这种现象是不希望出现的。

C++提供虚基类(virtual base class )的方法,使得在间接继承共同基类时只保留一份成员(在内存中只有基类成员的一份拷贝),通过把基类继承声明为虚拟的,就只能继承基类的一份拷贝,从而消除歧义。

我们已经知道,当某个派生类的部分或全部直接基类是从另一个共同基类派生而来时,在这些直接基类中从上一级共同基类继承来的成员就拥有相同的名称。在派生类的对象中,这些同名数据成员在内存中同时拥有多个拷贝,同一个成员函数名会有多个映射。

这时,如果将共同基类设置为虚基类,从不同的路径继承过来的同名数据成员在内存中就只有一个拷贝,同一个函数名也只有一个映射。这样就解决了同名成员的惟一标识问题。

虚基类的声明是在定义派生类时完成,即在定义派生类时,在基类的访问方式前加上关键字“virtual”,格式如下:

class 派生类名:virtual 访问方式 基类名 {
    
      
   //声明派生类成员
};
#include<iostream>
using namespace std;
class Base{
    
    
    public:
        void fun(){
    
     cout<<"Base::fun(){}"<<endl; }
};
class Derived11:virtual public Base{
    
     };//虚基类
class Derived12:virtual public Base{
    
     };
class Derived2:public Derived11, public Derived12{
    
    };
int main(){
    
    
    Derived2 d;
    d.fun();// 不产生二义性
    d.Derived11::fun();// 提问:这样可以吗? 
    d.Derived12::fun();
    return 0;
}

虚基类及其派生类的构造函数

虚基类的声明是在定义派生类时完成。

虚基类虽然被一个派生类间接地多次继承,但派生类却只继承一份该基类的成员。

对于虚基类的任何派生类,其构造函数不仅负责调用直接基类的构造函数,还需调用虚基类的构造函数。

考虑到虚基类的派生类的构造函数调用顺序,规定:

  1. 虚基类的构造函数在非虚基类之前调用。
  2. 若同一层次中包含多个虚基类,虚基类构造函数按它们说明的次序调用。
  3. 若虚基类由非虚基类派生,则遵守先调用基类构造函数,再调用派生类构造函数的规则。
- demo1 
class  X:public Y, public Z {
    
    
    ...
};
X obj;  // 将产生如下调用次序:  Y()  Z()   X()
----------------------------------
- demo2
class  X:public Y, virtual public Z {
    
    
    ...
};
X obj;  // 将产生如下调用次序: Z()   Y()   X() 
  • demo1:由于都是普通继承,所以构造顺序是基类、子对象、派生类,其中基类和子对象的构造顺序是按照继承和声明顺序。
  • demo2:这里Z是X的虚基类,故先调用Z的构造函数,再调用Y的构造函数,最后才调用派生类X自己的构造函数。

虚基类的几点总结和补充:

  1. 一个类可以在一个类族中既被用作虚基类,也被用作非虚基类。

  2. 在派生类的对象中,同名的虚基类只产生一个虚基类子对象,而非虚基类产生各自的子对象。

  3. 虚基类子对象是由最远派生类的构造函数通过调用虚基类的构造函数进行初始化的。

  4. 最远派生类是指在继承结构中建立对象时所指定的类。

  5. 派生类的构造函数的成员初始化列表中必须列出对虚基类构造函数的调用;

    如果未列出,则表示使用该虚基类的缺省构造函数。

  6. 从虚基类直接或间接派生的派生类中的构造函数的成员初始化列表中都要列出对虚基类构造函数的调用。

    但仅仅用建立对象的最远派生类的构造函数调用虚基类的构造函数,而该派生类的所有基类中列出的对虚基类的构造函数的调用在执行中被忽略,从而保证对虚基类子对象只初始化一次。

  7. 在一个成员初始化列表中同时出现对虚基类和非虚基类构造函数的调用时,虚基类的构造函数先于非虚基类的构造函数执行。

例:由A、B、C、D类派生E类,其中A、C类为虚基类,则定义E类对象时构造函数的执行顺序。

#include <iostream>
using namespace std;
class A {
    
    
    public:
        A()  {
    
     cout<<"A\n"; }
        ~A() {
    
     cout<<"~A\n"; }
};
class B {
    
    
    public:
        B()  {
    
     cout<<"B\n"; }
        ~B() {
    
     cout<<"~B\n"; }
};
class C {
    
    
    public:
        C()  {
    
     cout<<"C\n"; }
        ~C() {
    
     cout<<"~C\n"; }
};
class D {
    
    
    public:
        D()  {
    
     cout<<"D\n"; }
        ~D() {
    
     cout<<"~D\n"; }
};
class E:public A,virtual public B,public C,virtual public D {
    
    
    public:
        E()  {
    
     cout<<"E\n"; }
        ~E() {
    
     cout<<"~E\n"; }
};
int main() {
    
    
    E e;
    return 0;
}

运行结果:

B
D
A
C
E
~E
~C
~A
~D
~B

解决二义性的两种方法比较

  1. 作用域运算符::

    在派生类中拥有同名成员的多个拷贝,分别通过直接基类名来惟一标识同名成员,可以存放不同的数据,进行不同的操作。可以理解为此方法并未解决这个问题,而是避免向外展示这个问题。

  2. 虚基类

    只维护一个成员拷贝,使用更为简洁,内存空间更为节省。

对多重继承二义性的一点建议

使用多重继承时要十分小心,经常会出现二义性问题。

许多专业人员认为:不提倡在程序中使用多重继承,只有在比较简单和不易出现二义性的情况或实在必要时才使用多重继承,能用单一继承解决的问题就不要使用多重继承。也是由于这个原因,有些面向对象的程序设计语言(如Java,Smalltalk)并不支持多重继承。

【作业】分别定义一个日期类和一个时间类。然后派生出一个新的日期时间类。

#include <iostream>
using namespace std;
class Date {
    
    
    int year, month, day;
   public:
    Date() : year(2022), month(4), day(30) {
    
    }
    void show() {
    
     cout << year << "/" << month << "/" << day; }
};
class Time {
    
    
    int hour, minute, second;
   public:
    Time() : hour(7), minute(8), second(9) {
    
    }
    void show() {
    
     cout << hour << ":" << minute << ":" << second; }
};
class NewTime : public Date, public Time {
    
    
   public:
    void show() {
    
    
        Date::show(); cout << " ";
        Time::show(); cout << endl;
    }
};
int main() {
    
    
    NewTime time;
    time.show();
    return 0;
}

类与类之间的关系

常见关系:依赖、关联、聚合、组合

  1. 继承:B类通过继承A类,拥有A类全部属性,并且可以额外拥有一些A类没有的特征, B is A — 如:男人是人。
class B :public A{
    
     };
  1. 实现:对应的是面向对象中的"接口",C++中,接口通过纯虚函数来实现。
#include <iostream>
using namespace std;
class A {
    
    
   public:
    virtual void fun() = 0;  // 纯虚函数
};
class B : public A {
    
    
   public:
    void fun() {
    
     cout << "B::fun()" << endl; }
};
int main() {
    
    
    A* pa = new B();
    pa->fun();
    return 0;
}
  1. 依赖:对象之间最弱的一种关联方式,是临时性的关联。代码中一般指由局部变量、函数参数、返回值建立的对于其他对象的调用关系。
    需要使用到某个类,就是依赖某个类:B类使用到了A类一部分属性或方法,但A类中不包含B类,则依赖A类。

对于类A和类B,若出现下面情况,称为类B依赖类A:
(1)类B中某个方法的形参是类B类型。
(2)类B中某个方法的返回类型是类A类型。
(3)类B中某个方法中的局部变量是类A类型。

class A {
    
    
   public:
    int a;
};
class B {
    
    
   public:
    void fun() {
    
     A a; }
};
  1. 关联:是对象之间的拥有关系,如果B类中某个成员变量的类型是A类, 称B关联于A。
    两个类之间语义级别的一种强依赖关系,比如我和我的朋友,这种关系比依赖更强、不存在依赖关系的偶然性、关系也不是临时性的,一般是长期性的,而且双方的关系一般是平等的。关联可以是单向、双向的。表现在代码层面,为被关联类B以类的属性形式出现在关联类A中,也可能是关联类A引用了一个类型为被关联类B的全局变量。
class A {
    
     }
class B {
    
    
public:
    A a;
};
  1. 聚合:A类独立于B类存在,且可以被多个类共享,在B类中用一个指针指向A。

    聚合是关联关系的一种特例,它体现的是整体与部分的关系,即has-a的关系。

    此时整体与部分之间是可分离的,它们可以具有各自的生命周期,部分可以属于多个整体对象,也可以为多个整体对象共享。比如计算机与CPU、公司与员工的关系等,比如一个航母编队包括海空母舰、驱护舰艇、舰载飞机及核动力攻击潜艇等。表现在代码层面,和关联关系是一致的,只能从语义级别来区分。

class B{
    
    
    public:
        A* a;
};
  1. 组合:也称为包含关系,B类的一个成员是A类的对象。

    组合也是关联关系的一种特例,它体现的是一种contains-a的关系,这种关系比聚合更强,也称为强聚合。

    它同样体现整体与部分间的关系,但此时整体与部分是不可分的,整体的生命周期结束也就意味着部分的生命周期结束,比如人和人的大脑。表现在代码层面,和关联关系是一致的,只能从语义级别来区分。

class B{
    
    
    public:
        A a;
};

参考文章:https://blog.csdn.net/weixin_38234890/article/details/80055362

多态

当一个基类被继承为不同的派生类时,各派生类可以使用与基类成员相同的成员名,如果运行时使用同一成员名调用对象的成员,会调用哪个对象的成员?

多态是指类族中具有相似功能的不同函数使用同一个名称来实现,从而可以使用相同的调用方式来调用这些具有不同功能的同名函数。

多态性就是指同样的消息被类的不同对象接收时导致的完全不同的行为的一种现象。

消息即对类的成员函数的调用,不同的行为是指不同的实现,也就是调用了不同的函数。

多态性实质是指同一个函数的多种形态。

#include<iostream>
using namespace std;
class Point{
    
    
public:
    Point(double a=0,double b=0):x(a),y(b){
    
    }
    double area(){
    
     return 0; }
protected:
    double x,y;
};
class Circle: public Point{
    
    
public:
    Circle(double a=0,double b=0,double c=0):Point(a,b),r(c){
    
    }
    double area(){
    
     return 3.1415926*r*r; }
private:
    double r;
};
int main(){
    
    
    Point p(1,1);
    Circle c(1,1,10);
    cout<<p.area()<<endl; // area 在不通对象的调用下有不同的结果
    cout<<c.area()<<endl;
    return 0;
} 

虚函数

#include<iostream>
using namespace std;
class Point{
    
    
public:
    Point(double a=0,double b=0):x(a),y(b){
    
    }
    double area(){
    
     return 0; }
//    virtual double area(){ return 0; } // 虚函数 
protected:
    double x,y;
};
class Circle: public Point{
    
    
public:
    Circle(double a=0,double b=0,double c=0):Point(a,b),r(c){
    
    }
    double area(){
    
     return 3.1415926*r*r; }
private:
    double r;
};
void test(Point& temp){
    
     cout<<temp.area()<<endl; }
int main(){
    
    
    Point p(1,1);
    Circle c(1,1,10);
    test(p); test(c); // 输出结果相同,均为 0
    return 0;
} 

虚函数的作用是实现动态联编,也就是在程序的运行阶段动态地选择合适的成员函数。

联编:是指把一个消息和一个方法联系在一起,也就是把一个函数名与其实现代码联系在一起,实质是把一个标识符名和一个存储地址联系在一起的过程。

根据实现联编的阶段的不同,可将其分为静态联编和动态联编两种。

两种联编过程分别对应着多态两种实现方式:

  1. 在编译时的多态是通过静态联编实现的;
  2. 在运行时的多态则是通过动态联编实现的。

普通函数及类的成员函数的重载就实现了一种多态性。


在继承与派生的环境中

当通过对象名调用某个成员函数时,只可能是调用对象自身的成员,所以这种情况可采用静态联编实现。

当通过基类指针调用成员函数时,只有在运行时才能确定实际操作对象的类,并由此确定应该调用哪个类中的成员函数,这种运行时的多态性是由对象赋值的兼容规则所引起的。

赋值兼容规则

只有公用派生类才是基类真正的子类型,它完整地继承了基类的功能。

一个公有派生类的对象可以提供其基类对象的全部行为(基类的全部接口)。

赋值兼容规则是指在公有继承情况下,对于某些场合,一个派生类的对象可以作为基类对象来使用,也就是在需要基类对象的任何地方都可以使用公有派生类的对象来替代。

用基类指针指向公有派生类对象

基类指针、派生类指针、基类对象和派生类对象四者间有以下4种组合的情况:

(1)直接用基类指针指向基类对象。

(2)直接用派生类指针指向派生类对象。

(3)用基类指针引用其派生类对象。基类指针仅能访问派生类中的基类部分。自动隐式类型转换

(4)用派生类指针引用基类对象。 可强制类型转换,不会自动类型转换。

对于通过基类的对象指针(或引用)对成员函数的调用,编译时无法确定对象的类,而只有在运行时才能确定并由此确定该调用哪个类的成员函数。

同名覆盖很麻烦,设想能否用同一个调用形式,既能调用派生类,又能调用基类的同名函数?

解决方法:通过同一种形式,达到不同的目的。

在程序中,不是通过不同的对象名去调用不同派生层次中的同名函数,而是通过指针去调用它们!

形式一样,例如 pt->area() 只需在调用前给pt赋予不同的值即可。

虚函数的作用:允许在派生类中重新定义与基类同名的成员函数,并且通过基类指针或引用来访问基类或派生类中的同名函数。

 class 类名 {
    
          
    virtual 类型 函数名(参数表);
 }; 

说明:

(1)虚函数声明只能出现在类声明中的函数原型声明中,而不能在成员函数的函数体实现的时候。

(2)只有类的普通成员函数才能声明为虚函数,全局函数及静态成员函数不能声明为虚函数。

(3)虚函数可以在一个或多个派生类中被重新定义,它属于函数重载。
它要求在派生类中重新定义时必须与基类中的函数原型完全相同。
这时,无论在派生类的相应成员函数前是否加上关键字virtual,都将其视为虚函数;(派生类中的同名函数都自动成为虚函数)

系统会遵循以下规则来判断一个派生类的成员函数是不是虚函数:
① 该函数是否与基类的虚函数有相同的名称
② 该函数是否与基类的虚函数有相同的参数个数及相同的对应参数类型
③ 该函数是否与基类的虚函数有相同的返回值或者满足赋值兼容规则的指针、引用型的返回值。

(4)当一个类的成员函数声明为虚函数后,就可以在该类的派生类中定义与其基类虚函数原型相同的函数。
这时,当用基类指针指向这些派生类对象时,系统会自动用派生类中的同名函数来代替基类中的虚函数。
也就是说,当用基类指针指向不同派生类对象时,系统会在程序运行中根据所指向对象的不同,自动选择适当的成员函数,从而实现了运行时的多态性。

总结:用指向基类的指针+虚函数来实现了多态性。

运行过程中的多态需要满足三个条件:

  1. 类之间应满足赋值兼容规则;
  2. 要声明虚函数;
  3. 要由成员函数来调用或者通过指针、引用来访问虚函数。

个人总结,多态的实现需要满足条件:

  1. 要有继承关系;
  2. 基类中声明虚函数;
  3. 基类指针或引用指向派生类对象。

注意:如果使用对象名来访问虚函数,是静态联编

函数重载解决的是同一层次上的同名函数问题(横向重载)

虚函数解决的是不同派生层次上的同名函数问题(纵向重载)

  • 根据什么考虑是否把一个成员函数声明为虚函数呢?

首先看成员函数所在的类是否会作为基类。

然后看成员函数在类的继承后有无可能被更改功能,如果希望更改其功能的,一般应该将它声明为虚函数。

如果成员函数在类被继承后功能不需修改,或派生类用不到该函数,则不要把它声明为虚函数。

不要仅仅考虑到要作为基类而把类中的所有成员函数都声明为虚函数。

应考虑对成员函数的调用是通过对象名还是通过基类指针或引用去访问,如果是通过基类指针或引用去访问的,则应当声明为虚函数。

有时,在定义虚函数时,并不定义其函数体,即函数体是空的。
它的作用只是定义了一个虚函数名,具体功能留给派生类去添加。

  • 对多态性的一点补充说明

使用虚函数,系统要有一定的空间开销。
当一个类带有虚函数时,编译系统会为该类构造一个虚函数表(virtual function table,简称vtable),它是一个指针数组,存放每个虚函数的入口地址。系统在进行动态关联时的时间开销是很少的,因此,多态性是高效的。

开发中,通常会保留基类,减少新类的开发时间;
但是基类的某些函数不完全适应派生类的需要。
如果让基类和派生类的函数不同名,则派生层次太多就会很麻烦;
如果同名,又会发生覆盖。所以用虚函数很方便。

虚析构函数

当派生类的对象从内存中撤销时一般先调用派生类的析构函数,然后再调用基类的析构函数。
但是,如果用new运算符建立了临时对象,若基类中有析构函数,并且定义了一个指向该基类的指针变量。
在程序用带指针参数的delete运算符撤销对象时,会发生一个情况:
系统会只执行基类的析构函数,而不执行派生类的析构函数。

#include <iostream>
using namespace std;

class Point {
    
      // 定义基类Point类
   public:
    Point() {
    
    }
    ~Point() {
    
     cout << "executing Point destructor" << endl; }
    // virtual ~Point() { cout << "executing Point destructor" << endl; }
};
class Circle : public Point {
    
      // 定义派生类Circle类
   public:
    Circle() {
    
    }
    ~Circle() {
    
     cout << "executing Circle destructor" << endl; }
};
int main() {
    
    
    Point* p = new Circle;  // 用new开辟动态存储空间
    delete p;               // 用delete释放动态存储空间
    return 0;
}

运行结果为

executing Point destructor

表示只执行了基类Point的析构函数,而没有执行派生类Circle的析构函数
将基类的析构函数声明为虚析构函数,则运行结果为:

executing Circle destructor
executing Point destructor

当基类的析构函数为虚函数时,无论指针指的是同一类族中的哪一个类对象,系统会采用动态关联,调用相应的析构函数,对该对象进行清理工作。
如果将基类的析构函数声明为虚函数时,由该基类所派生的所有派生类的析构函数也都自动成为虚函数,即使派生类的析构函数与基类的析构函数名字不相同。

最好把基类的析构函数声明为虚函数。这将使所有派生类的析构函数自动成为虚函数。
这样,如果程序中显式地用了delete运算符准备删除一个对象,而delete运算符的操作对象用了指向派生类对象的基类指针,则系统会调用相应类的析构函数。
虚析构函数的概念和用法很简单,但它在面向对象程序设计中却是很重要的技巧。
专业人员一般都习惯声明虚析构函数,即使基类并不需要析构函数,也显式地定义一个函数体为空的虚析构函数,以保证在撤销动态分配空间时能得到正确的处理。
构造函数不能声明为虚函数。这是因为在执行构造函数时类对象还未完成建立过程,当然谈不上函数与类对象的绑定。

纯虚函数与抽象类

1.纯虚函数

在定义一个表达抽象概念的基类时,有时可能会无法给出某些成员函数的具体实现。
这时,就可以将这些函数声明为纯虚函数。

virtual 函数原型 = 0;

纯虚函数是只在基类中声明虚函数但未给出具体的函数定义体;
它的具体定义放在各派生类中。

#include <iostream>
using namespace std;

class A {
    
    
   public:
    virtual void show() = 0;  // 纯虚函数,起到接口声明的作用
};
class B : public A {
    
    
   public:
    void show() {
    
      // 对虚函数进行实现
        cout << "B::show()" << endl;
    }
};
int main() {
    
    
//    A a; // error
    B b;
    b.show();
    return 0;
}

2.抽象类

声明了纯虚函数的类,称为抽象类。
抽象类的主要作用:通过它为一个类族建立一个公共的接口,使它们能够更有效地发挥多态特性。
抽象类声明了一族派生类的共同接口,而接口的完整实现,即纯虚函数的函数体,要由派生类自己定义。
上述程序中,A类就是一个抽象类;
如果B类未对A类进行虚函数实现,则B类同于也是一个抽象类,不能使用B类进行对象的创建。

3.使用纯虚函数与抽象类的注意事项

(1)抽象类只能用作基类来派生新类,不能声明抽象类的对象,但可以声明指向抽象类的指针变量和引用变量。
(2)抽象类中可以有多个纯虚函数。
(3)抽象类中也可以定义其他非纯虚函数。
(4)抽象类派生出新类之后,如果在派生类中没有重新定义基类中的纯虚函数,则必须再将该虚函数声明为纯虚函数,这时,这个派生类仍然是一个抽象类;
(5)在一个复杂的类继承结构中,越上层的类抽象程度就越高,有时甚至无法给出某些成员函数的具体实现。
(6)引入抽象类的目的主要是为了能将相关类组织在一个类继承结构中,并通过抽象类来为这些相关类提供统一的操作接口。

#include <iostream>
using namespace std;

class A {
    
    
   public:
    virtual void show() = 0;  // 纯虚函数,起到接口声明的作用
    void display() {
    
     cout << "A::display()" << endl; }
};
class B : public A {
    
    
   public:
    void show() {
    
      // 对虚函数进行实现
        cout << "B::show()" << endl;
    }
};

class C : public A {
    
    };
int main() {
    
    
    // A a; // error
    A* point_a = nullptr;  // ok
    B b;
    b.show();
    A& pa = b;
    return 0;
}