ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

第十二章 多态

第十二章 多态

第十二章 多态

1. 函数重载

1.1 第七章的老朋友

函数重载第七章 6.2 节讲过,这里只复习结论。同名函数,参数表对不上就能同时存在,调用时候编译器按实参的类型和个数挑版本。

#include <iostream> using namespace std; int maxOf(int a, int b) { return a > b ? a : b; } double maxOf(double a, double b) { return a > b ? a : b; } int main() { cout << maxOf(3, 5) << endl; cout << maxOf(2.5, 1.5) << endl; return 0; }

输出两行,5 和 2.5。同一个名字盖了两副身板,实参是 int 挑第一版,实参带了小数挑第二版,挑版本的活儿编译时候就干完了。

┌─────────────────────────────────────────────────┐ │ 重载也是多态 │ ├─────────────────────────────────────────────────┤ │ 一个名字,多副身板 │ │ │ │ maxOf(3, 5) 挑 int 版 │ │ maxOf(2.5, 1.5) 挑 double 版 │ │ │ │ +--------------------+----------------------+ │ │ |时候 |谁挑的 | │ │ +--------------------+----------------------+ │ │ |编译时候 |编译器看实参类型 | │ │ |跑起来 |直接调定好的那版 | │ │ +--------------------+----------------------+ │ │ │ │ 第七章认过的脸,本章换个名头 │ │ 这叫静态多态,编译期就定了 │ └─────────────────────────────────────────────────┘

一个名字多副身板,这就是多态三个字的字面意思。靠重载实现的这份,编译期就把版本定死,叫静态多态,也叫编译期多态。第七章那条规矩接着算,光换返回类型不算重载,参数表得分得出来,两个版本得让编译器挑得动。

重载是省脑子的手艺,一个名字盖住一串活。也别滥用,两个同名函数干的事八竿子打不着,不如拆成两个名字,读代码的人省心。

1.2 遮罩不算重载

第十一章讲过,派生类声明一个同名函数,基类那版直接被遮住。这句话放到重载的语境里再说一遍,因为它是静态联编和动态联编的分水岭。

#include <iostream> #include <string> using namespace std; class Base { public: void show(int x) { cout << "Base 整数 " << x << endl; } }; class Derived : public Base { public: void show(string s) { cout << "Derived 字符串 " << s << endl; } }; int main() { Derived d; d.Base::show(3); d.show("abc"); return 0; }

输出两行,Base 整数 3 和 Derived 字符串 abc。

Base 整数 3 Derived 字符串 abc

把 main 里第一句改成 d.show(3),编译直接报错,Derived 里找不到对得上的 show。实参 3 是 int,Derived 那版参数是 string,int 变不成 string。Base 那版明明有个 show(int),可名字被遮住了,重载的候选名单里根本没有它,编译器在 Derived 这一亩三分地里翻了个底朝天,只能两手一摊。

想让 Base 那版露面,得写 d.Base::show(3) 点名,第十一章那双冒号在这儿接着用。所以遮罩和重载是两码事,重载在同一个类里玩,跨了类就是遮罩。本章 4.x 讲的覆盖是另一回事,那才是多态要的东西。

2. 运算符重载

2.1 运算符是糖

加减乘除这些符号天生认内置类型,int 加 int,double 减 double,都没问题。自己写的类往加号两边一摆,编译器翻遍语法书找不到说法,直接报错。运算符重载就是给类配上这门手艺,让加号在类身上也知道该干什么。

┌─────────────────────────────────┐ │ 运算符是糖 │ ├─────────────────────────────────┤ │ 加号背后是个函数 │ │ │ │ Complex a(3, 4), b(1, 2); │ │ Complex c = a + b; │ │ │ │ 编译器看成 │ │ │ │ Complex c = a.operator+(b); │ │ │ │ 运算符是皮,函数是馅 │ │ 重载就是给这函数写实现 │ └─────────────────────────────────┘

拆开看,符号只是门面,函数才是里子。a + b 在编译器眼里就是 a.operator+(b),一次普通的成员函数调用,函数名换成 operator 加符号。所以重载运算符,说到底就是写一个名字带 operator 的函数,第十章成员函数那套规矩原样管用,参数表、返回值、const 修饰一个不落。

能重载的运算符有份名单,加减乘除、关系运算、赋值、下标、括号、自增自减、流式插入提取,都在里头。名单外的不能碰,域冒号、点号、点星、sizeof、条件问号、typeid 这几个天生带着编译器的私事,重载不了。还有两条死规矩,重载不能发明新符号,优先级和结合性也改不动,加号永远是那个加号。

2.2 成员函数还是友元函数

┌───────────────────────────────────────────────────┐ │ 两种形式怎么挑 │ ├───────────────────────────────────────────────────┤ │ 成员还是友元,看左边 │ │ │ │ +----------------------+----------------------+ │ │ |场合 |写哪儿 | │ │ +----------------------+----------------------+ │ │ |左操作数是自己类 |成员函数 | │ │ |左操作数是别的类 |友元函数 | │ │ |流式 cout << a |友元,左是 ostream | │ │ |下标 a[0] |成员函数 | │ │ +----------------------+----------------------+ │ │ │ │ 成员函数隐含一个 this │ │ 友元两边都得摆明 │ └───────────────────────────────────────────────────┘

重载写成成员函数还是友元函数,看左边站的是谁。左边是自己类的对象,写成员函数,编译器把它翻译成 a.operator+(b),左边的对象就是 this,参数表里只剩右操作数。左边是别人家的类,成员函数插不上嘴,这时候请友元,两个操作数都在参数表里摆明。

cout << a 是个典型,左边是 ostream,右边是 Complex,两头都不归自己管,只能写友元。下标 a[0] 反过来,左边必须是自己的对象,operator[] 只能是成员函数,写成友元,编译器直接摇头。

2.3 复数类六件套

光说不练假把式,一个 Complex 类把常用的六种重载认全。

┌──────────────────────────────┐ │ 复数类六件套 │ ├──────────────────────────────┤ │ 一副复数家当 │ │ │ │ Complex a(3, 4); │ │ Complex b(1, 2); │ │ │ │ a + b 得 4+6i │ │ a == b 不成立 │ │ ++a 得 4+5i │ │ a++ 旧值给 old │ │ a[0] a[1] 实部虚部 │ │ cout << a 打 5+6i │ │ │ │ 六个重载函数,一个类认全 │ └──────────────────────────────┘
#include <iostream> using namespace std; class Complex { public: Complex(double r = 0, double i = 0) : real(r), imag(i) {} Complex operator+(const Complex &other) const { return Complex(real + other.real, imag + other.imag); } bool operator==(const Complex &other) const { return real == other.real && imag == other.imag; } Complex &operator++() { real += 1; imag += 1; return *this; } Complex operator++(int) { Complex old = *this; real += 1; imag += 1; return old; } double &operator[](int idx) { return idx == 0 ? real : imag; } friend ostream &operator<<(ostream &out, const Complex &c); private: double real; double imag; }; ostream &operator<<(ostream &out, const Complex &c) { out << c.real << "+" << c.imag << "i"; return out; } int main() { Complex a(3, 4), b(1, 2); Complex c = a + b; cout << "a + b 得 " << c << endl; cout << "a == b ? " << (a == b ? "成立" : "不成立") << endl; ++a; cout << "++a 得 " << a << endl; Complex old = a++; cout << "a++ 之后 old 是 " << old << " a 是 " << a << endl; cout << "a[0] 是 " << a[0] << " a[1] 是 " << a[1] << endl; cout << "cout << a 打得开 " << a << endl; return 0; }

输出六行,和图上的数一致。

a + b 得 4+6i a == b ? 不成立 ++a 得 4+5i a++ 之后 old 是 4+5i a 是 5+6i a[0] 是 5 a[1] 是 6 cout << a 打得开 5+6i

六个函数挨个看。operator+ 是成员函数,右边那位进参数表,左边那位藏在 this 里,返回值是新复数,两个操作数都不改动它。函数尾巴上的 const 是第十章那套规矩,常对象也能参加加法。operator== 同理,实部虚部都比一遍,全等才 true。

前置自增 operator++ 不带参数,直接改自己,返回引用,这样 ++(++a) 才连得起来。后置自增多一个 int 参数,这个 int 没人传值,只是给编译器区分前后置的一个记号,所以先存旧值再改自己,返回旧值。a++ 那行,old 拿到 4+5i,a 变成 5+6i,和图对上。复数本没有大小,自增自减谈不上天然含义,这里图演示方便,让 ++ 把实部虚部各加一,换成只加实部也一样,规矩不变。

operator[] 只能是成员函数,返回引用是个关键点,要是返回的值的话 a[0] = 9 这种写法就废了,改到临时量上,白写。流式插入是友元,out 和 c 两个参数摆明,返回 out 是为了 cout << a << endl 这样串着写,ostream 引用一路往下传。

构造函数的参数默认值接第七章,Complex(double r = 0, double i = 0) 让空参和带参一个构造全包,Complex() 和 Complex(3, 4) 都造得出来。

3. 静态联编

3.1 编译时候拍板

值班程序翻车那版摊开。

#include <iostream> #include <string> using namespace std; class Animal { public: void speak() { cout << "动物叫" << endl; } ~Animal() {} }; class Dog : public Animal { public: Dog(string t) : tag(new string(t)) {} ~Dog() { cout << "Dog 析构, 摘下项圈 " << *tag << endl; delete tag; } void speak() { cout << "汪汪" << endl; } private: string *tag; }; class Cat : public Animal { public: void speak() { cout << "喵喵" << endl; } }; int main() { Animal *zoo[5]; zoo[0] = new Dog("阿黄"); zoo[1] = new Dog("小黑"); zoo[2] = new Dog("胖虎"); zoo[3] = new Cat; zoo[4] = new Cat; for (int i = 0; i < 5; i++) { zoo[i]->speak(); } for (int i = 0; i < 5; i++) { delete zoo[i]; } return 0; }

输出五行,一个样。

动物叫 动物叫 动物叫 动物叫 动物叫
┌────────────────────────────────────────┐ │ 静态联编 │ ├────────────────────────────────────────┤ │ 编译时候就拍板 │ │ │ │ ┌──────────────────────────────────┐ │ │ │源码 │ │ │ │ | │ │ │ │ v │ │ │ │编译期 函数地址定死 │ │ │ │ | │ │ │ │ v │ │ │ │可执行 调用处写死地址 │ │ │ └──────────────────────────────────┘ │ │ │ │ 重载、模板、普通调用 │ │ 都属于这一拨 │ │ │ │ 好处是快,坏处是不灵活 │ └────────────────────────────────────────┘

狗三条猫两条,一句 zoo[i]->speak() 打出来的东西分毫不差。病根不在继承,继承好好的,Dog 对象肚子里 Animal 那截加 Dog 那截一样不少。病根在编译时候的那一次拍板。

编译器处理 zoo[i]->speak() 这句,看的是 zoo[i] 的纸面类型,Animal 指针。Animal 类里有个 speak,地址当场定死,调用处直接写这个地址,跑时候照单执行。至于是谁站在地址后头,是 Dog 是 Cat 还是别的,编译时候一概不问。这种编译期就把函数地址定死的联编方式,叫静态联编。

重载也是静态联编,第七章挑 maxOf 那版,就是按实参类型定的。模板下一章讲,生成代码时候定,也算这一拨。静态联编的好处是快,调用就是一次跳转,不查表不绕路。坏处是不灵活,编译器只认纸面类型,纸面写 Animal,跑起来是 Dog 它也不管。

删那五段更安静。delete 走的也是 Animal 的析构,地址同样编译时候定死,Dog 析构一次没叫,三个 new 出来的 string 没人收。静态联编在 delete 这件事上照样说得起话,值班程序的第二笔账就在这。

4. 动态联编和虚函数

4.1 一个词改命

翻车那版的 Animal,speak 前面加上 virtual,别的字一动不动。

class Animal { public: virtual void speak() { cout << "动物叫" << endl; } };
┌───────────────────────────────────────┐ │ 虚函数登场 │ ├───────────────────────────────────────┤ │ 一个 virtual 改命 │ │ │ │ class Animal { │ │ public: │ │ virtual void speak() { │ │ cout << "动物叫" << endl; │ │ } │ │ }; │ │ │ │ 基类函数前加 virtual │ │ 派生类照写,编译器自动认账 │ │ │ │ C++11 起派生类可加 override │ │ 签名写错它替你拦 │ └───────────────────────────────────────┘

基类函数前头加上这个词,意思就变了,这个函数的地址不许编译时候定死,等跑起来看对象真身再定。派生类的 speak 照写,不用加 virtual,编译器自动认账。C++11 起推荐在派生类那版尾巴上加个 override,签名和基类对不上就编译报错。第十一章讲同名遮罩时候说过,遮住了也不报警,静悄悄换个不相干的函数上去,override 就是治这个静悄悄的。

virtual 这东西一旦在基类写上,整条继承链都带着,派生类不写也算虚函数。每层都写清楚不是为了它生效,是为了让 override 这个安检门每层都亮着,签名写歪一层拦一层。

4.2 背后的花名册

┌────────────────────────────────────┐ │ 虚函数表 │ ├────────────────────────────────────┤ │ 背后有本花名册 │ │ │ │ ┌──────────────────────────────┐ │ │ │Animal 类 │ │ │ │ vtable: speak -> 动物叫 │ │ │ └──────────────────────────────┘ │ │ | │ │ v │ │ ┌──────────────────────────────┐ │ │ │Dog 类 │ │ │ │ vtable: speak -> 汪汪 │ │ │ └──────────────────────────────┘ │ │ │ │ ┌──────────────────────────────┐ │ │ │Cat 类 │ │ │ │ vtable: speak -> 喵喵 │ │ │ └──────────────────────────────┘ │ │ │ │ 每个对象藏个指针,指向自家表 │ │ 调时候查表,不写死地址 │ └────────────────────────────────────┘

虚函数的靠山是虚函数表。每个带虚函数的类,编译器给它攒一张表,表里一格一个虚函数的地址。Animal 的表里 speak 那格写着 Animal 版的地址,Dog 的表同一格换成 Dog 版,Cat 的表再换成 Cat 版。每个对象肚子里多藏一个指针,指向自家类的那张表,这个指针叫 vptr。

跑时候调虚函数,走的不是定死的地址,是先问对象要 vptr,顺着指针翻到自家那张表,查到那一格再跳。Dog 对象翻到的是汪汪,Cat 对象翻到的是喵喵。这一翻就是动态联编的全部秘密,地址不写在代码里,写在表里,表跟着对象走。

开销也说在明处。每个对象大一个指针,每次调用多一跳。所以虚函数不是白加的,用不着多态的场合老老实实写普通函数。判断标准也简单,这个函数会不会被基类指针或者基类引用指着调,会就加,不会别加。

4.3 基类指针的魔法

值班程序换成虚函数版,main 里那两句调用原地复活。

┌───────────────────────────────┐ │ 基类指针的魔法 │ ├───────────────────────────────┤ │ 同一句话,各自应答 │ │ │ │ Animal *zoo[2]; │ │ zoo[0] = new Dog; │ │ zoo[1] = new Cat; │ │ │ │ zoo[0]->speak(); 汪汪 │ │ zoo[1]->speak(); 喵喵 │ │ │ │ 指针类型都是 Animal │ │ 调出来的却是各自那版 │ │ │ │ 引用也一样,多态的两条路 │ └───────────────────────────────┘
#include <iostream> using namespace std; class Animal { public: virtual void speak() { cout << "动物叫" << endl; } virtual ~Animal() {} }; class Dog : public Animal { public: void speak() override { cout << "汪汪" << endl; } }; class Cat : public Animal { public: void speak() override { cout << "喵喵" << endl; } }; int main() { Animal *zoo[2]; zoo[0] = new Dog; zoo[1] = new Cat; zoo[0]->speak(); zoo[1]->speak(); delete zoo[0]; delete zoo[1]; return 0; }

输出两行,汪汪和喵喵。zoo 两个元素都是 Animal 指针,编译器这回拍不死板了,问对象要表,查完再跳,谁站着就应谁的话。这就是多态两个字落地的地方。

多态有两条路,基类指针和基类引用。指针这条刚走过,引用那条一个样,void feed(Animal &a) 这个函数,传 Dog 进去汪汪,传 Cat 进去喵喵,函数体一个字不用改。十条动物一百条动物,往数组里往容器里塞,循环里一句调用,各答各的话。

Animal 里那行 virtual 析构先带着,4.4 算这笔账。

4.4 虚析构

┌────────────────────────────────────────┐ │ 虚析构 │ ├────────────────────────────────────────┤ │ delete 也要看真身 │ │ │ │ 基类析构不加 virtual │ │ │ │ ┌──────────────────────────────────┐ │ │ │delete 基类指针 │ │ │ │ 只叫基类析构 │ │ │ │ 派生类那份没人收拾 │ │ │ └──────────────────────────────────┘ │ │ │ │ 加上 virtual 之后 │ │ │ │ ┌──────────────────────────────────┐ │ │ │delete 基类指针 │ │ │ │ 先叫派生类析构 │ │ │ │ 再叫基类析构 │ │ │ └──────────────────────────────────┘ │ └────────────────────────────────────────┘

Dog 的项圈还挂在原处。析构前面少了 virtual,拿基类指针去 delete,只叫得到基类析构,项圈那行永远不出现,new 出来的 string 一块不剩全漏光。

#include <iostream> #include <string> using namespace std; class Animal { public: virtual ~Animal() { cout << "Animal 析构" << endl; } }; class Dog : public Animal { public: Dog(string t) : tag(new string(t)) { cout << "Dog 构造, 项圈 " << *tag << endl; } ~Dog() override { cout << "Dog 析构, 摘下项圈 " << *tag << endl; delete tag; } private: string *tag; }; int main() { Animal *p = new Dog("阿黄"); delete p; return 0; }

输出三行,顺序是派生类先走基类后走。

Dog 构造, 项圈 阿黄 Dog 析构, 摘下项圈 阿黄 Animal 析构

和第十一章的构造析构顺序一条规矩,只不过这次靠虚函数表翻出了真身。把 virtual 从基类析构上摘掉,override 那行当场报错,编译器先把这关拦了。连 override 一起摘,才轮到运行时候出事,删完只剩 Dog 构造和 Animal 析构两行,项圈那行连个响都听不见,new 出来的 string 漏光。

死规矩记死,基类里有虚函数,析构函数就必须是虚的。也常反过来说,基类析构是虚的,这个类才配当基类使。标准库那些容器类,析构都不是虚的,拿它们当基类就是给自己挖坑,这条栽过的人太多。

4.5 纯虚函数和抽象类

虚函数再往前一步,基类那版连实现都不给,只留一张图纸。

┌──────────────────────────────────────┐ │ 纯虚函数 │ ├──────────────────────────────────────┤ │ 只画图纸不盖房 │ │ │ │ class Shape { │ │ public: │ │ virtual double area() = 0; │ │ }; │ │ │ │ 等于零,不写实现 │ │ 派生类不实现,它也抽象 │ │ │ │ Shape s; 报错,不许实例化 │ │ Shape *p; 可以,当指针用 │ └──────────────────────────────────────┘
#include <iostream> using namespace std; class Shape { public: virtual double area() const = 0; virtual ~Shape() {} }; class Circle : public Shape { public: Circle(double r) : r(r) {} double area() const override { return 3.14 * r * r; } private: double r; }; class Rect : public Shape { public: Rect(double w, double h) : w(w), h(h) {} double area() const override { return w * h; } private: double w, h; }; int main() { Shape *shapes[2]; shapes[0] = new Circle(2); shapes[1] = new Rect(3, 4); for (int i = 0; i < 2; i++) { cout << "面积 " << shapes[i]->area() << endl; } delete shapes[0]; delete shapes[1]; return 0; }

输出两行,12.56 和 12。

面积 12.56 面积 12

等于零放在函数尾巴上,这个函数就是纯虚函数,只声明不实现。Shape 带着一个纯虚函数,自己就成了抽象类,抽象类不许实例化,Shape s 这种写法编译直接拦下。Shape *p 却好使,当指针当引用都行,指针不落地,不占对象那截肚子。

Circle 和 Rect 把 area 补上,各自算各自的,一个 3.14 乘半径平方,一个长乘宽。基类指针数组一摆,循环里一句 area,圆算圆的长算方的,谁也不用问是谁。这就是抽象类的用法,基类定规矩,派生类交作业,指针数组当花名册。

派生类要是不交作业呢,Circle 也把 area 留着不写,那 Circle 也是抽象类,照样不许实例化。抽象会传染,一路传染到有人把作业补全为止。所以纯虚函数还有个名目叫接口,基类只画图纸,规矩写在图纸上,盖房是派生类的事。

5. 小结

┌────────────────────────────────┐ │ 本章收口 │ ├────────────────────────────────┤ │ 多态这张拼图 │ │ │ │ 重载 编译期挑版本 │ │ 运算符 给类配上加减号 │ │ 静态 地址编译时候定死 │ │ 动态 虚函数跑时候查表 │ │ 纯虚 抽象类只画图纸 │ │ │ │ 下一章模板,静态多态的舞台 │ └────────────────────────────────┘

多态这张拼图到这儿拼齐了。函数重载是一个名字多副身板,编译时候按实参挑版本,静态多态。运算符重载是给类配上加减号那些内置手艺,成员函数还是友元,看左边站的是谁。静态联编把函数地址在编译期写死,快但是不灵活,值班程序五条动物叫就是它干的好事。动态联编靠虚函数,地址跑时候查表,基类指针和基类引用两条路,谁站着就应谁的话。

虚函数的账也记在明处,对象多藏一个指针,调用多跳一格,所以不是所有函数都该加 virtual。基类有虚函数,析构就得是虚的,这条是死规矩。纯虚函数画图纸,抽象类定接口,派生类交作业,不交就一起抽象下去。

速查卡和排错表在附录。下一章讲模板,重载让编译器从几个版本里挑,模板让编译器照着一张图纸现场生成,静态多态的下一幕在那儿。

附录 A 速查卡

知识点写法说明
函数重载参数表不同返回类型不算数
重载的范围同一个类里头派生类同名算遮罩
运算符重载operator+只许重载名单内的
成员形式Complex operator+(const Complex &)左操作数是自己的
友元形式friend ostream &operator<<(ostream &, const Complex &)左操作数是别人的
前置自增Complex &operator++()改自己,返回引用
后置自增Complex operator++(int)int 只是个记号
下标重载double &operator只能是成员函数
静态联编编译期定地址重载和普通调用都算
虚函数virtual void speak()基类声明那版
overridevoid speak() override签名不对它拦
虚析构virtual ~Animal()有虚函数就带上
纯虚函数virtual double area() const = 0只画图纸不实现
抽象类不许实例化指针和引用可以
基类指针Animal *p = new Dog多态第一条路
基类引用void feed(Animal &a)多态第二条路

附录 B 排错表

常见现象病根药方
调出来还是基类那版基类函数没加 virtual补上 virtual
override 报错没重写签名和基类对不上照基类抄参数表
cout << a == b 报错流式优先级比 == 高加括号写成 (a == b)
报错抽象类不许实例化类里带着纯虚函数改用指针或写派生类
delete 只叫基类析构析构没加 virtual基类析构补 virtual
operator[] 报错必须是成员写成了友元挪回类里
d.show(3) 找不到函数派生类同名遮住基版写 d.Base::show(3)
对象莫名大一个指针类里有虚函数正常,vptr 占的
想重载点号或 sizeof名单外的运算符换成员函数实现
加号结果不对劲返回值写成引用了返回新对象,别返回自己
返回列表