什么是多态?

  • 从字面意思来讲,多态的意思很简单就是多种形态的意思。
  • 教材中对多态的描述是:不同对象调用相同函数时,执行不同的动作。
  • 从代码的角度讲,就是对于一个函数名有多个实现,并且可以用相同的方式调用这些实现不同的同名函数。在代码中对多态的实现又分为两种:
    • 函数重载 overload:同一作用域、函数名相同、参数特征标不同。仅返回值类型不同不构成重载。
    • 函数重写(覆盖) override:派生类、函数名相同、特征表相同。派生类返回值类型“小于等于”基类返回值类型。

函数重载我们应该已经很熟悉了,在没有接触面向对象编程之前就在学习函数时学过了,学习类的知识时也有使用,比如构造函数的重载、运算符重载。重载可以通过为同名函数传递不同的参数来执行不同的实现,这就是多态。
函数重写是我们本节学习的重点。

背景

对于简单的继承,派生类直接继承基类的方法,不做任何修改。然而,可能会遇到这样的情况:即希望同一个方法在派生类和基类中的行为是不同的。换句话来说,方法的行为应取决于调用该方法的对象,这种较复杂的行为被称为多态 —— 具有多种形态,即调用同一个方法(同名函数)会根据调用该方法的对象的不同而执行不同的任务。C++有两种重要的机制用于实现多态:

  • 使用虚方法;
  • 派生类中重新定义基类的方法。

接下来看一个实例来了解这两种机制。

实例:银行账号

现在来看另一个例子。为银行开发两个类,一个类是用于表示基本支票账户 —— Brass,另一个类是用来表示 Brass Plus 支票账户,它添加了透支特性。也就是说,如果用户支付超出其存款金额的支票,但是超出的金额不能很大,银行将同意支付这张支票,对超出的部分收取额外的费用。可以根据要保存的数据以及允许执行的操作来确定这两种账户的特征。
Brass 类:

  • 信息:
    • 客户姓名;
    • 账号;
    • 当前余额。
  • 操作:
    • 创建账户;
    • 存款;
    • 取款;
    • 显示账户信息。

BrassPlus 类:

  • 信息:
    • 透支上限;
    • 透支部分贷款利率;
    • 当前透支的总额。
  • 操作(有两种操作的实现不同):
    • 对于取款操作,必须考虑透支保护;
    • 显示操作必须显示 BrassPlus 账户的其他信息;
    • 修改贷款相关信息。

BrassPlus 应该从 Brass 公有派生吗?要回答这个问题需要先回答另一个问题 —— BrassPlus 类是否满足 is-a 条件?当然满足,BrassPlus 对象是一个 Brass 对象。它们都将保存客户姓名、账号、余额,使用这两个类都可以存款、取款和显示账户信息。

类声明

  1. #ifndef BRASS_H_
  2. #define BRASS_H_
  3. #include <string>
  4. class Brass {
  5. private:
  6. std::string name; // 用户名
  7. long account; // 账号
  8. double balance; // 余额
  9. public:
  10. Brass(const std::string & n = "Null", long ac = -1, double b = 0.0) : name(n), account(ac), balance(b) {}
  11. void deposit(double money); // 存款
  12. double getBalance() const { return balance; } // 获取余额
  13. virtual void withdrawal(double money); // 取款
  14. virtual void show() const;
  15. virtual ~Brass() {}
  16. };
  17. class BrassPlus : public Brass {
  18. private:
  19. double maxLoan; // 贷款上限
  20. double rate; // 利率
  21. double totalLoan; // 可贷款的总额
  22. public:
  23. BrassPlus(const std::string & n = "Null", long ac = -2, double b = 0.0, double ml = 500, double r = 0.1125) : Brass(n, ac, b), maxLoan(ml), rate(r), totalLoan(0) {}
  24. BrassPlus(const Brass & b, double ml = 500, double r = 0.1125) : Brass(b), maxLoan(ml), rate(r), totalLoan(0) {}
  25. virtual void withdrawal(double money);
  26. virtual void show() const;
  27. void setMaxLoan(double m) { maxLoan = m; }
  28. void setRate(double r) { rate = r; }
  29. void setTotalLoad() { totalLoan = 0; }
  30. };
  31. #endif // BRASS_H_
  • BrassPlus 类在 Brass 类的基础上添加了3个私有数据成员和3个公有成员函数;
  • Brass 类和 Brass Plus 类虽然都声明了 withdrawal() 和 show() 函数,但它们的具体实现是有所不同的;
  • 在声明 withdrawal() 和 show() 函数时使用了新关键字 virtual,表示这些方法被声明为虚方法;
  • Brass 类声明了一个虚析构函数,虽然该析构函数不执行任何操作。

关于第二点,Brass 和 BrassPlus 中的两个 show() 原型表明将有两个独立的方法定义。基类 Brass 版本的限定名为 Brass::show(),派生类 BrassPlus 版本的限定名为 BrassPlus::show()。程序将根据调用 show() 的对象类型来确定使用哪个版本。withdrawal() 同理。而两个行为相同的方法,如 deposit() 和 getBalance() 则只需要在基类中声明即可。
关于第三点使用 virtual 关键字,如果函数是通过引用或指针而不是对象调用的,它将确定使用哪一种函数(是基类的还是派生类的)。如果没有使用 virtual,程序将根据引用类型或指针类型调用对应的方法,此时基类引用或指针调用函数时调用的是基类的函数,哪怕基类引用或指针指向派生类对象;如果使用 virtual,程序将根据引用或指针指向的对象类型来选择函数,如果基类引用或指针指向派生类对象,此时用基类引用或指针调用函数将会调用派生类的函数,而不是基类的函数,这就是 virtual 的作用。
虚函数的这种行为非常方便,因此常在基类中将派生类会重新定义的方法声明为虚方法。方法在基类中被声明为虚的后,它在派生类中将自动成为虚方法。不过在派生类声明中显式使用关键字 virtual 来指出那些函数是虚函数是一个很好的习惯。
关于第三点,基类声明了一个虚析构函数,这样是为了确保释放派生对象时按照正确的顺序调用析构函数。可以在后面介绍完虚函数的知识之后回过来思考下这个问题。

注意:如果要在派生类中重新定义基类的方法,通常应将基类方法声明为 virtual,这样程序可以根据对象类型而不是引用或指针的类型来选择方法。为基类声明一个虚析构函数也是一种惯例。

类实现

#include "Brass.h"
#include <iostream>
// Brass Methods
void Brass::deposit(double money) { // 存款
    if(money < 0) {
        std::cout << "Failed. Negative deposit not allowed." << std::endl;
        return;
    }
    balance += money;
    std::cout << "Success. " << name << " deposits $" << money << ".\n";
}

void Brass::withdrawal(double money) { // 取款
    if (money < 0)
        std::cout << "Failed. Negative withdrawal not allowed." << std::endl;
    else if (money > balance)
        std::cout << "Failed. Withdrawal amount of $" << money << " exceeds your balance($"<< balance <<").\n";
    else {
        balance -= money;
        std::cout << "Success. " << name << " Withdrawal $" << money << ", Left $" << balance << ".\n";
    }
}

void Brass::show() const {
    std::cout << "Account: " << account << std::endl;
    std::cout << "UserName: " << name << std::endl;
    std::cout << "Balance: " << balance << std::endl;
}

// BrassPlus Methods
void BrassPlus::withdrawal(double money) { // 取款
    double balance = getBalance();
    if (money <= balance) {
        Brass::withdrawal(money);
    } else if (money > balance + maxLoan - totalLoan) { // 取款大于(存款 + 剩余可贷款额度)
        std::cout << "Failed. Withdrawal $" << money << " exceeds upper limit.\n";
    } else {
        // 先向银行借款
        deposit(money - balance);
        // 算上利息共需要还银行的钱
        totalLoan += balance * (1.0 + rate);
        // 然后再取款
        Brass::withdrawal(money);
    }
}

void BrassPlus::show() const {
    Brass::show();
    std::cout << "Max Loan: " << maxLoan << std::endl;
    std::cout << "Total Loan: " << totalLoan << std::endl;
    std::cout << "Rate: " << rate << std::endl;
}

派生类不能直接访问基类的私有成员,必须通过基类的公有方法才能访问这些数据。访问的方法取决于方法:派生类的构造函数通过成员初始化列表来设置基类的私有数据成员,参考 BrassPlus 的构造函数;派生类的成员函数可以通过基类公有成员函数的限定名来调用派生类会重写的基类公有成员函数,对于在派生类中没有被重写的函数可以直接使用非限定名来访问其私有成员,参考 BrassPlus 的 show() 和 withdrawal()。在 withdrawal() 的实现中,getBalance() 是基类中定义的公有接口,在派生类中没有被重写,因此可以直接使用非限定名,而基类的 withdrawal() 在派生类中被重写了,因此想调用基类的 withdrawal() 必须使用限定名,否则编译器会认为调用的是派生类的 withdrawal(),这样派生类的 withdrawal() 就变成了递归函数,并且没有终止条件。

使用类

int main() {
    using std::cout;
    using std::endl;

    Brass b1("Peppa", 220614, 4000.0);
    BrassPlus bp1("George", 220615, 2000.0);
    b1.show();
    cout << endl;
    bp1.show();
    cout << endl;

    // 指向派生类对象的基类指针
    Brass * p_b1 = &bp1;
    p_b1->show(); // 虽然p_b1是基类指针, 但这里会调用派生类的show(), 而不是基类的show()
    cout << endl;

    b1.withdrawal(4200);
    b1.withdrawal(2000);

    bp1.withdrawal(1000);
    bp1.withdrawal(1100);
    bp1.withdrawal(600);

    b1.show();
    cout << endl;
    bp1.show();
    cout << endl;
}
Account: 220614
UserName: Peppa
Balance: 4000

Account: 220615
UserName: George
Balance: 2000
Max Loan: 500
Total Loan: 0
Rate: 0.1125

Account: 220615
UserName: George
Balance: 2000
Max Loan: 500
Total Loan: 0
Rate: 0.1125

Failed. Withdrawal amount of $4200 exceeds your balance($4000).
Success. Peppa Withdrawal $2000, Left $2000.
Success. George Withdrawal $1000, Left $1000.
Success. George deposits $100.
Success. George Withdrawal $1100, Left $0.
Failed. Withdrawal $600 exceeds upper limit.
Account: 220614
UserName: Peppa
Balance: 2000

Account: 220615
UserName: George
Balance: 0
Max Loan: 500
Total Loan: 1112.5
Rate: 0.1125

虚函数

什么是虚函数?

在类声明中使用 virtual 关键字修饰的类成员函数被称为虚函数。virtual 关键字只能出现在类声明中,在实现类成员函数时的函数定义(函数实现)处不能出现 virtual,也就是说类外不能出现 virtual。

虚函数有什么用?

用一句话总结虚函数的作用就是:当基类指针或引用指向派生类对象,并且派生类中重写了基类中的虚函数,此时使用指针或引用去调用虚函数是调用派生类的函数实现,而不是调用基类的实现。例如:

// BrassPlus继承了Brass类, 并重写了Brass类的show()
class Brass {
public:
    // Brass类将show()函数声明为虚函数, 表示子类可以重写该函数
    virtual void show() const { std::cout << "Brass::show();" << std::endl; }
};

class BrassPlus : public Brass{
public:
    // BrassPlus类重写了父类的show()函数
    virtual void show() const { std::cout << "BrassPlus::show();" << std::endl; }
};

int main() {
    BrassPlus bp;
    Brass b = bp; // 基类对象
    Brass & r_b = bp; // 指向派生类的基类引用
    Brass * p_b = &bp;// 指向派生类的基类指针
    b.show(); // 输出 "Brass::show();"
    r_b.show(); // 输出 "BrassPlus::show();"
    p_b->show(); // 输出 "BrassPlus::show();"
    return 0;
}

静态联编和动态联编

Q:C++支持函数重载和重写,在调用某个函数时到底是执行那个函数?
这个问题是由编译器负责处理。将代码中的函数调用解释为执行特定的函数代码块的过程被称为函数名联编。在 C 语言中,这非常简单,因为 C 语言不支持函数重载和重写,所以每个函数名都对应一个不同的函数。在C++中,由于函数重载的原因,代码中可以有同名的函数,编译器必须查看函数参数以及函数名才能确定使用哪个函数。不过,C/C++编译器可以在编译过程中完成这种联编。这种在编译过程中进行的联编被称为静态联编,又称为早期联编
然而,虚函数使这项工作变得更困难了,因为基类指针或引用调用的是那个虚函数不是在编译过程能确定的,所以编译器必须能在程序运行时选择正确的虚函数的代码,这种在程序运行中进行联编被称为动态联编,又称为晚期联编

函数重载是静态联编,函数重写是动态联编。Java 中常称为编译时多态和运行时多态。

BrassPlus bp;
Brass * pb;
bp = &bp;
bp->show();

静态联编:如果在基类中没有将 show() 声明为虚的,则bp->show()将因为指针指向的类型是 Brass 而调用Brass::show()。而指针类型是在编译期已知的,因此编译器在编译时可以将 show() 关联到Brass::show(),即编译器对非虚函数使用静态联编。
动态联编:如果在基类中将 show() 声明为虚的,并且派生类中重写了 show(),则bp->show()将根据对象类型(BrassPlus)调用BrassPlus::show(),通常只有在运行程序时才能知道指针指向的对象的类型,所以编译器生成的代码将在程序执行时,根据对象类型将 show() 关联到Brass::show()BrassPlus::show(),即编译器对虚函数使用动态联编。
在大多数情况下,动态联编很好,因为它让程序能够选择为特定类型设计的方法。

Q:既然动态联编很好,为什么不只使用动态联编?为什么默认联编是静态联编?
如果静态联编让您能重写类方法,而静态联编在这方面表现很差,为什么不放弃使用静态联编呢?原因有两个,效率和概念模型。
首先是效率。为了使程序能够在运行阶段确定使用那个函数,必须采用一些方法来跟踪基类指针或引用指向的对象类型,这增加了额外的处理开销(稍后将介绍一种动态联编实现原理)。如果类不会用作基类,或者其派生类不会重写基类的任何方法,就不需要用动态联编,这种情况下,使用静态联编更合理,它的效率更高。正是由于静态联编效率更高,因此被设置为C++的默认方式。因此,仅当程序设计确实需要虚函数时,才使用动态联编。

C++的指导原则之一是,不要为不使用的特性付出代价(内存空间或处理时间)。

接下来看概念模型。在类设计时,可能还会包含一些不需要在派生类重写的成员函数。这些成员函数不能改被设置为虚函数,这有两方面好处:首先效率更高,其次指出该函数不会被派生类重写。因此,在设计类时,仅将那些预期中将被派生类重写的函数声明为 virtual 函数。

如果要在派生类中重新定义基类的函数,则将它声明为虚函数;否则,声明为非虚函数。

当然,在设计类时,方法属于那种情况有时并不那么明显,类设计不是一个线性过程。

向上转型和向下转型

在C++中,动态联编与通过指针和引用调用方法相关,从某种程度上说,这是由继承控制的。
向上类型转换:通常,C++不允许将一种类型的地址赋值给另一种类型的指针,也不允许一种类型的引用指向两一种类型。但继承除外,C++允许基类指针指向派生类对象,或基类引用指向派生类对象,而不必进行显式类型转换。例如,下面的初始化是被允许的:

// BrassPlus 是 Brass 派生类
BrassPlus bp1("YouKa", 220616);
Brass * p_b = &bp1;
Brass & r_b = &bp1;

将派生类的引用或指针转换为(赋值给)基类引用或指针被称为向上类型转换,简称向上转型。向上类型转换使公有继承不需要进行显式类型转换。BrassPlus 对象都是 Brass 对象,因为它继承了 Brass 对象所有的数据成员和成员函数。所以 Brass 对象执行的任何操作,BrassPlus 对象都可以执行。因此,参数为 Brass 引用或指针的函数一样可以处理 BrassPlus 对象,因为向上类型转换是隐式的,可以自动进行。向上转型是可以传递,也就是说,如果 BrassPlus 派生出一个 BrassPlusPlus 类的话,Brass 指针或引用可以引用 Brass 对象、BrassPlus 对象或 BrassPlusPlus 对象。

向下类型转换:和向上类型转换相反的过程是 —— 将基类指针或引用转换为(赋值给)派生类指针或引用,这被称为向下类型转换。和向上转型不同,向下转型必须显式使用,不允许隐式转换。原因在于 is-a 关系通常不可逆。BrassPlus 对象一定是一个 Brass 对象,而 Brass 对象不一定是 BrassPlus 对象。派生类可以新增数据成员,使用这些数据成员的类成员函数不能应用于基类,不然就会出现错误。
2. 多态与虚函数 - 图1

虚函数工作原理

C++规定了虚函数的行为,但将实现方法留给了编辑器作者。不需要知道实现方法就可以使用虚函数,但了解虚函数的工作原理有助于更好地理解虚函数。
通常,编辑器处理虚函数的方法是:给每个对象添加一个隐藏的数据成员,这个隐藏成员是一个指针,它指向虚函数表的地址。虚函数表(virtal function table, vtbl)中存储了为类对象声明的虚函数的地址。
例如,基类对象包含一个指向虚函数表的指针,派生类对象也将包含一个指向派生类虚函数表的指针。这样对于类结构来说,基类和派生类都只增加了一个存储指针的空间,只是基类和派生类的这个指针成员指向的虚函数表的大小不同。如果派生类没有重写基类的虚函数,派生类的虚函数表(vtbl)将保存该函数的原始版本(基类)的地址。如果派生类重写了虚函数,派生类的虚函数表将保存新版本(派生类)的函数地址。如果派生类定义了新的虚函数,则该地址会被添加到虚函数表中。

PS:无论类中包含的虚函数是1个还是10个,都只需要在对象中添加一个指针成员,只是该成员指向的虚函数表的大小不同而已。

2. 多态与虚函数 - 图2
调用虚函数时,程序将查看存储在对象中的指向虚函数的指针,然后找到相应的虚函数表。如果调用的是类声明中的第一个虚函数,则程序将使用虚函数表中的第一个函数地址,并去执行该地址的函数。如果调用的是类声明中的第三个虚函数,则程序将使用虚函数表中的第三个函数地址,并执行该地址的函数。
使用虚函数在内存和执行速度上有一定的成本,这个成本包括:

  • 每个对象的存储空间都将增大,增大量为存储虚函数表地址的空间,即一个指针的大小;
  • 对于每个类,编辑器都将创建一个虚函数表(数组);
  • 对于每个函数调用,都需要执行一项额外的操作,即到虚函数表中查找地址。

    为什么基类析构函数要声明为 virtual?

    学习了虚函数的相关知识之后,我们再来看看在银行账号这个实例中为什么基类 Brass 的析构函数需要声明为虚函数。首先,看下面的这一段代码:

    Brass * p_b2 = new BrassPlus("YouKa", 220616, 2000.0);
    delete p_b2;
    

    如果基类的析构函数不声明为 virtual,对于让 Brass 指针指向一个 new 生成的 BrassPlus 对象,在使用 delete 释放的时候将调用 Brass 的析构函数。
    而将基类的析构函数声明为 virtual,释放 Brass 指针指向的内存时将调用 Brass 指针指向的对象类型的析构函数。所以delete p_b;将调用 BrassPlus 的析构函数,然后在调用 Brass 的析构函数。
    因此,基类使用虚析构函数可以确保正确的析构函数被调用。在本例中,因为 BrassPlus 的析构函数什么也不做,这种行为不是很重要。然而,一旦派生类的析构函数需要完成某个操作,比如释放 new 申请的内存,那么基类的析构函数必须声明为 virtual,不然很容易出现内存泄漏的问题。

    虚函数的注意事项

    用 virtual 修饰类声明中的函数声明,可以将类的成员函数声明为虚函数。

  • 不能声明为 virtual 的函数:

    • 普通函数和友元函数一定不能被声明为虚函数。因为只有类的成员函数才能被声明为虚函数,而友元函数虽然是出现在类声明中,但是友元函数并不是类的成员函数,而是普通函数。
    • 构造函数一定不能是虚函数。创建派生类对象时,将调用派生类的构造函数,而不是基类的构造函数。派生类的构造函数将使用基类的构造函数,这种顺序不同于继承机制。因此,派生类不继承基类的构造函数,所以类构造函数声明为虚函数并没有什么意义。
    • 静态成员函数一定不能是虚函数。静态成员函数是属于类的,而不是某个对象。
    • 内联函数不能是虚函数。如果内联函数被virtual修饰,计算机会忽略inline使它变成存粹的虚函数。
  • 需要声明为 vritual 的函数:
    • 对于基类来说,应该将那些要在派生类中重写的函数声明为虚函数;
    • 基类的析构函数应该被声明为虚函数,即使基类并不需要析构函数。这样在涉及到动态内存分配时才能避免错误;
  • 重点!如果使用指向对象的引用或指针来调用虚函数,程序将根据对象类型调用函数,而不是引用或指针类型调用函数。这称为动态联编或晚期联编,是运行时的多态。这种行为非常重要,因为这样基类指针或引用可以指向派生类对象。

静态成员函数为什么不能是虚函数?
当调用一个对象的非静态成员函数时,系统会把该对象的起始地址赋给成员函数的this指针。

class A {
public:
    void f1(int i);
    // void f1(A * this, int i);
}

普通的成员函数其实隐式传递了一个指向本类对象的指针 —— this,因此类 A 中定义的void f1(int i);实际上的参数列表应该是void f1(A * this, int i);

静态成员函数并不属于某一对象,它与任何对象都无关,因此静态成员函数不会隐式传递 this 指针。可以说,静态成员函数与非静态成员函数的根本区别是:非静态成员函数有 this 指针,而静态成员函数没有 this 指针。由此决定了静态成员函数不能访问本类中的非静态成员。
虚函数是通过在类中隐式声明一个指向虚函数表的指针成员,该指针在创建对象时通过构造函数对其进行初始化,和 this 指针关系密切,因此静态函数不能是虚函数。

同理还有静态函数不能是 const 函数。因为 const 函数实际上是在隐式传递的 this 参数上用 const 修饰,而静态函数没有 this 指针,也就没办法给 this 指针加上 const 关键字:

class A {
public:
    void f1(int i) const;
    // void f1(const A * this, int i);
}

派生类没有重写基类虚函数会怎么样?
如果派生类没有重写基类的虚函数,派生类将使用该函数的基类版本。如果派生类位于派生链中,则使用最新的虚函数版本,例外情况是基类版本是隐藏的。

派生类隐藏基类同名函数
假设编写了以下代码:

class Base {
public:
    virtual void f1(int arg1) { std::cout << "Base::f1(int)" << std::endl; }
    virtual void f1(int arg1, int arg2) { std::cout << "Base::f1(int, int)" << std::endl; }
    void f1(int arg1, int arg2, int arg3) { std::cout << "Base::f1(int, int, int)" << std::endl; }
};

class BasePlus : public Base {
public:
    virtual void f1(int) { std::cout << "BasePlus::f1(int)" << std::endl; }
//    void f1() { std::cout << "BasePlus::f1()" << std::endl; }
};

在基类 Base 中定义了两个 f1 虚函数,以及一个 f1 非虚函数,在派生类 BasePlus 中重写了基类的 f1(int),此时编写以下代码:

BasePlus bp;
bp.f1(1); // valid, BasePlus::f1(int)
bp.f1(1, 2); // 报错, 因为基类的f1(int, int)被隐藏了
bp.f1(1, 2, 3); // 报错, 因为基类的f1(int, int, int)被隐藏了

不光是重写基类的函数,在派生类中重新定义了 f1(void) 同样会隐藏基类中的同名函数。这就引出两条经验规则:

  • 如果要重写基类的函数,需要确保重写的函数与原来基类的函数原型完全一致。但如果基类函数返回的是指向基类的指针或引用,则派生类重写时可以修改为指向派生类的指针或引用。这种特性被称为返回类型协变,因为允许返回类型随类型的变化而变化。
  • 如果在派生类中重新定义了一个和基类函数同名的函数,则应该在派生类中重新定义所有同名函数的基类版本。如果只定义一个版本,则其他版本会被隐藏,派生类对象将无法直接使用,但是可以通过基类名+作用域解析符来使用。

    PS:这里的重新定义包括重写和重载。

BasePlus bp;
bp.f1(1); // valid, BasePlus::f1(int)
bp.f1(1, 2); // 报错, 因为基类的f1(int, int)被隐藏了
bp.f1(1, 2, 3); // 报错, 因为基类的f1(int, int, int)被隐藏了
bp.Base::f1(1, 2); // valid
bp.Base::f1(1, 2, 3); // valid

难点:重载、重写、隐藏

重载

  • 在同一个范围(作用域)内,例如在同一个类中
  • 函数名相同,参数特征标不同(类型、数目、顺序)
  • 返回值类型可以相同也可以不同,仅仅返回值类型不同不构成函数重载;
  • 与 virtual 关键字无关。

函数重载的工作原理是,编译器会对重载函数在编译时有一个重命名的过程,其命名规则为:作用域+返回类型+函数名+参数列表。

重写 or 覆盖

  • 重写函数与被重写函数分别位于派生类与基类中(不在同一个范围内)
  • 函数名相同,参数特征标相同(类型、数目、顺序)
  • 派生类的返回值类型必须小于等于基类返回值类型,即派生类重写函数的返回值类型如果和基类被重写函数的返回值类型不同,那么必须是基类被重写函数返回值类型的派生类。
  • 基类函数必须带有 virtual 关键字

函数重写的工作原理是虚函数表,在类中会有一个指向虚函数表的隐藏成员(占一个指针的大小)。

隐藏

隐藏的定义是派生类的函数屏蔽了与其同名的基类函数。

  • 分别位于派生类与基类中(不在同一个范围内)
  • 不论基类函数是否为虚函数,只要派生类有与其同名但不同参的函数,该基类函数在派生类中将被隐藏。
  • 对于非虚函数的基类函数,如果有派生类函数与此基类函数同名同参,此时该基类函数在派生类中将被隐藏。

    PS:如果基类的虚函数在派生类中有同名同参的函数,那就是重写,而不是隐藏。

如果基类函数在派生类中被隐藏,可以通过全限定名来调用。

纯虚函数与抽象类

纯虚函数

什么样的函数是纯虚函数?
C++中的纯虚函数,一般在函数原型后使用=0作为此类函数的标志。Java、C# 等语言中,则直接使用 abstract 作为关键字修饰这个函数签名,表示这是抽象函数(纯虚函数)。

class Father {
public:
    virtual void f() = 0;
}

纯虚函数是干什么的?
纯虚函数是一种特殊的虚函数,在许多情况下,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。这就是纯虚函数的作用。
纯虚函数也可以叫抽象函数,一般来说它只有函数名、参数和返回值类型,不需要函数体。这意味着它没有函数的实现,需要让派生类去实现。

PS:即使给出了纯虚函数的实现,编译器也不会识别的。

抽象类

抽象类的知识其实只需要记住两点。首先,抽象类是类声明中至少有一个成员函数是纯虚函数的类。其次,抽象类不能被实例化,即抽象类不能直接创建对象。
关于第二点,因为抽象类中有一个纯虚函数,而纯虚函数是没有函数定义的,如果可以直接创建抽象类的对象,那么抽象类对象调用这个纯虚函数的时候,编译器找不到该函数的实现部分,因此抽象类并不能拥有对象。
抽象类实际上是声明了一个“接口”,其所有的派生类都需要实现这个接口。

抽象类的知识点很少,因此在学校的考试中考察的不是很多,更多情况下都是考察虚函数和多态的知识。但抽象类在实际开发中是很常见的,可能在刚接触的时候没办法理解使用抽象类的好处在哪里,这就需要多看多练。