「BUAA-C++」Lec6:多态


C++中的多态是继数据抽象(封装)和继承之后第三个重要的面向对象的特性,所谓多态是指用一个名字定义不同的函数,这些函数执行不同但又类似的操作,这样就可以用同一个函数名调用不同内容的函数。C++中多态主要依靠向上转型、动态绑定、虚函数等机制实现。

向上转型upcasting

我们定义的类是一种数据类型,既然是数据类型,就可以发生数据类型的转换,但是这种转换仅限于在父类和子类之间。父类和子类之间的转换并不是双向的,而是只能从子类向父类进行转换,这种转换叫做 “向上转型”。 向上转型有下面两种方式——

  • 用父类的引用类型来引用子类对象
  • 用父类的指针类型来指向子类对象
    class Father{
        //...
    };
    class Child : public Father{
        //...
    };
    
    int main() {
        //第一种转换
        Child a;
        Father& b = a;
        //第二种转换
        Child *a = new Child();
        Father *b = a;
    }

向上转型由编译器自动完成,是十分安全的。向下转型也不是不可以,但是这样做会有风险,并且需要程序员根据需要手动强制转换。

向上转型后可以直接由父类的指针或引用来调用子类对象中的数据成员(只能是子类从父类中继承来的数据成员), 考虑下面的例子——

class Father {
public:
	int father_id;
    Father(int a) : father_id(a) {}
}

class Child : public Father{
public:
    int child_id;
    Child(int a, int b) : Father(a), child_id(b) {}//Father(a)是调用父类有参构造,child_id(b)是初始化列表
};

int main() {
    Child c(1, 2);
	Father &f = c;
	cout << f.father_id << endl; //输出1
	cout << f.child_id << endl; //error!child_id不是子类从父类中继承来的数据成员!
}

当某一个场景(如函数)需要传入父类的指针或者引用时,我们仍然可以传入子类对象的指针或引用,这样编译器会自动帮我们实现向上转型。如我们在上述程序中再定义一个get_father_id(Father &f)函数, 调用该函数时我们可以传入一个子类对象。
int get_father_id(Father &f) {
    return f.father_id;
}

int main() {
    Child c(1, 2);
    cout << get_father_id(c) << endl; //输出仍然是1
    /*上述代码相当于\
    Child c(1, 2);
    Father& f = c;
    cout << get_father_id(f) << endl;
}

动态绑定

父类的指针或引用类型能不能调用调用子类对象中的的函数呢(只能是子类从父类中继承来的函数)?答案当然是可以的,但是并不像调用数据成员那样简单。可以看下面的例子——

class Pet {
public:
    void speak() {
		cout << "pet::speak" << endl;
	}
}

class Dog : public Pet {
public:
    void speak() {
        cout << "dog::speak" << endl;
    }
}

void listen(Pet& pet) {
    pet.speak();
}

int main() {
    Dog dog;
    listen(dog);
}

我们希望的是调用Dog类中重写后的speak()函数,即希望输出 "dog::speak", 但最终输出的却是 "pet::speak"。这是为什么呢?解答这个问题就需要涉及绑定的概念了——

绑定(binding)是指将函数的一次调用与函数对应入口相对应的过程,绑定分为两种——

  • 前绑定(early binding,是指函数运行前就决定好了函数的运行的状态。我们遇到的大多数函数都是前绑定,例如上边的例子中listen函数中调用了pet.speak(),后者在运行前就决定好了要运行父类Pet中的speak()函数,因此即使传进来的是子类,仍然调用父类的函数。
  • 后绑定(later binfing,也称为运行时绑定、动态绑定,是指函数调用是根据调用者(对象)的状态来决定函数的运行状况。

所以我们如何将speak()函数设置为后绑定呢?只需要在父类的函数声明前加入virtual关键字即可。

class Pet {
public:
    virtual void speak() {
		cout << "pet::speak" << endl;
	}
}
//....
int main() {
    Dog dog;
    listen(dog);
}

再次运行程序,输出结果就变成了 "dog::speak"!!!

虚函数

上面virtual声明的函数被称为虚函数。当某个虚函数通过指针或者引用被调用时,编译器产生的代码直到运行时才能确定该调用哪个版本的函数,这就是上面所说的运行时调用。虚函数的使用要注意以下几点——

  • 若父类中某函数被定义为虚函数,子类在继承该函数的同时,也继承了virtual关键字
  • 当子类的某函数重写了父类的一个虚函数,则它的形参类型必须与被它覆盖的父类函数完全一致
  • 构造函数一般不能被定义为虚函数,即不存在多态性。析构函数一般是多态的,多用virtual来修饰。此外,静态函数也不能是多态的
  • later binding 称运行时绑定,函数调用时跟据当前对象的状态决定函数的运行状况
    使用virtual来限定父类中的函数,该关键词自动继承,
  • 虚指针V-Ptr
  • 虚列表V-table
    每个类都有一个V-table,并在每个对象的初始部分多出四个字节储存V-Ptr指向这个V-table,这样保证了upcasting行为的正确性。虚指针的初始化是在构造函数中默认完成的

补充:override和final的使用
当子类对父类中的某虚函数进行重写时,我们可以在子类的对应函数后面写上override关键字,表示该函数是重写父类的。如果被override修饰的函数在父类中没有同名同参的函数,编译器会报错。使用该关键字可以让编译器帮助我们检查子类在重写父类函数时是否发生了错误,类似于JAVA中的@Override注解

class Base {
public:
   virtual void speak(int);
};

class Derived : public Base{
public:
   void speak(int) override;
};

父类中有某个函数,他只想让子类继承而不想让子类重写,这样我们可以在该函数后面加上final关键字。一旦子类重写了父类中被final修饰的函数,那么编译器就会报错。
class Base {
public:
  virtual void speak(int) final;//无法被子类修改
};
//Base::speak(int a) {...};

class Derived : public Base{
public:
  void speak(int) override; //error!!!
};
//Derived::speak(int a) {...};

纯虚函数

在父类中有一种虚函数,在父类中没有必要将他定义,只有子类才能根据具体情况实现它,这种函数称为纯虚函数。纯虚函数的定义方式为——

virtual void func() = 0;

纯虚函数只能声明,不能定义。拥有纯虚函数的类被称为抽象类,而因为纯虚函数是不能被定义的,所以抽象类不能用来创建对象。如果父类是抽象类,则子类应该重写父类中的纯虚函数,不重写纯虚函数的子类仍然是抽象类

抽象类有以下两个作用:

  • 规定一个类家族所有的共性行为。 这种抽象类既可以有纯虚函数,也可以有定义好的函数(包括虚函数和一般函数),相当于JAVA中的抽象类(abstract class
  • 链接本不相关的多个类家族。 这种抽象类只能由纯虚函数组成,相当于JAVA中的接口(interface)

文章作者: Hyggge
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Hyggge !
  目录