自学编程,从此开始

上第2学堂,听有趣的编程课

课文: 《你好,对象!多态版》 (点击查看完整内容:视频+评测+讨论+……)

作者:null

打破出生决定一生的不公平!

课文题图

 

你好,对象! 多态版

出身决定一生?

公元前209年7月,秦王朝著名的两位民工陈胜和吴广说了一句话:“将相王候,宁有种乎?”。

他们在表达一种不满:难道那些有钱人或当官人(富二代和官二代),天生和我们不是同一人种吗?这个问题体现了他们是爱思考、敢置疑、并且有能力抓出事物的本质的人,可惜,喊出这个问题之后,这世上多了两个不成功的起义者,少了两个非常优秀的C++程序员。

思考一下,上一节的例子,也有“宁有种乎”的不公平现象:

Person xiaoA;
Beauty jiaLing;

xiaoA从出生那一刻起就是注定是普通人,而jiaLing却天生是美人胚子,因为它们的她们的类型的差异就摆在代码里:前者Person,后者Beauty。

C++允许一个指针类型的变量,定义为基类,但实际却初始成派生类。比如:

Person *someone;
someone = new Beauty; //“new”出来的是“Beauty”

someone在堆内存中,申请得到的实际类型是Beauty,尽管它自身被声明为Person*类型。一个被声明为基类指针的对象,实际创建的却是派生类对象,这对我们是一项新鲜技术,马上动手试试。

新建一个控制台应用。main.cpp主要代码内容如下:

……

struct Person
{
     void Hello()
    {
        cout << "你好,我是" << name << endl;
    }

    string name;
};

struct Beauty : Person
{
    void Hello()
    {
        cout << "我是你们的女神" << name << ",我爱你们!" << endl;
    }
};

int main()
{
036    Person *xiaoA = new Beauty;
037    xiaoA->name = "Xiao A";

039    xiaoA->Hello();
040    delete xiaoA;
      //------------------------------------------------
042   Beauty* zhiLing = new Beauty;
      zhiLing->name = "志玲";

      zhiLing->Helo();
      delete zhiLing;

      return 0;
}

以前 xiaoA 一直是一个栈对象,但现在在第036行它被声明为一个Person类型的堆对象,但随后就在同一行,它又被实际创建成Beauty类型的对象,接着是修改它的名字,然后调用问候的的方法。

作为对比,zhiLing声明的类型和实际创建的类型都是Beauty。后面操作与xiaoA一致,除了对name的赋值不一样。

最重要的是,小A双眼噙泪激动地喊到:“我也是美女啦!”。

编译、并运行程序,输出如下:

你好,我是Xiao A

我是你们的女神志玲,我爱你们!

志玲还是美女,可是xiaoA对象的屏幕输出,还是普通人的方式啊!这是为什么呢? xiaoA 问苍天。苍天回答她:我才不管你真身是不是美女,你的声明类型是Person,那你就得是普通人,出身决定一切!

虚函数,改变出身的力量

xiaoA 和 zhiLing 的本质区别在于: zhiLing 是名与实一致,而 xiaoA 却名与实不一,对比二者的创建代码:

036    Person *xiaoA = new Beauty;
……
042    Beauty* zhiLing = new Beauty;

这里的“名”不是指“名字”,名字和一个人是不是美人无关,这一点我们很早前就清楚了。这里的“名”指的是一个对象声明或定义时的类型。

虽然xiaoA实质上创建的是一个Beauty对象,但它在定义时类型是Person。所以在这一行的程序:

039    xiaoA->Hello();

xiaoA仍然被当成普通的Person。C++的世界居然也这么讲究出身。故事大概可以这样编写:小A的父母都是普通人,所以小A在出生那一瞬的定义,也是普通人(Person),但真正出生时,创建的确实又是Beauty。那么小A到底算美女还是普通人?C++在当前这情况下,无情地选择了后者

“出身决定一切”也许有某种道理,但我们不想接受的这样的结局。

怎么办?在C++世界中,让行为和真实的身份保持一致很简单:只是需要在基类Person的需要被重新定义的函数,比如例中的Hello函数前面加上修饰词:virtual。

当一个成员函数加上virtual修饰,我们称这个函数为虚函数。虚函数能够帮我们解决“名实不一”的问题。看实例:

struct Person
{
    //此处略去构造函数与析构函数
018 virtual void Hello()
    {
        cout << "你好,我是" << name << endl;            
    }

    //……
};

注意,为了扭转出身决定一切的错误,我们要从源头上做起,也就是要在基类的Hello函数上virtual。而不是派生类。请重新编译、运行程序,这次就能看到xiaoA终于也用上了美女专用的Hello函数了。

为了在派生类的定义中,明确表明自己改变了来自基类的行为,可以在派生版本中的函数,加上override,注意加的位置:

struct Beauty : Person
{
    void Hello() override
    {
        cout << "我是你们的女神" << name << ",我爱你们!" << endl;
    }
};

override 英文单词是“重写”的意思。请注意,要在基类和派生类对某个函数构成重写关系,要求两个函数名字一样、参数列表一样,返回值一样(或者派生类返回类型,是基类返回类型的派生类……有点绕,可先不管它)。比如,下面的代码会编译失败:

struct B
{
    virtual void foo() {}
};

struct D : B
{
    void foo(int a) override {}
};

原因很简单: 派生类的foo带有override修饰,但它还有入参,而基类的foo没有入参。说是要 ”重写“,可是找不到源头版本。

正确的死: 虚“析构函数”

析构函数也是成员函数,因此虚函数的规则对它也起作用。不过析构函数和普通成员函数相比,有以下特殊之处:

  1. 派生类的析构函数名字肯定和基类叫法不一样,但这不影响虚函数的规则对它起作用。
  2. 析构函数的特别之处是它是对象在死亡之前必定要做的一件事。通常复杂的对象会在析构函数内释放额外占用的内存等资源。
  3. 派生类调用完自己的析构函数之后,会自动调用基类的析构函数,其目的是为了确保基类的资源也能自动释放。

在我们的例子中,美人的死和普通的人死没有什么区别,干脆,我们给美人类专门设计一个死亡告别方式——我们准备为美人类提供自定义的析构函数。

struct Beauty : public Person
{
    ~Beauty()
    {
        cout << "wu~wu~人生似蚍蜉、似朝露;似秋天的草,似夏日的花……" << endl;
    }

    //此处略去美人类的 Hello ()函数
};

现在,基类对象,也就是普通人的死,仍然是安静的。但美女会在死前感伤一下。IDE(QtCreator)可能已经在编辑窗口上显示警告了,如果编译,会得到来自GCC的警告如下:

warning: deleting object of polymorphic class type 'Person' which has non-virtual destructor might cause undefined behaviour [-Wdelete-non-virtual-dtor]|

意思是:字面意思是,有“多态/polymorphic”特性的Person类的析构函数不是虚的,(non-virtual),对它的对象执行delete操作可能导致未定义的影响云云……

暂不理会这吓人的警告,我们编译并运行程序,观察xiaoA在被释放时,屏幕将输出什么内容?

delete xiaoA对象时,会调用它的析构函数,是调用派生类版(然后再调用基类版)的析构函数呢,还是只调用基类版的?分析过程如下:

首先,xiaoA对象的声明类型是“Person”,其次Person的析构函数不是虚函数,所以调用的是基类版的。所以屏幕上一片安静。你看不到人生似蚍蜉,也看不到人生是朝露……

解决方法来是从源头改起:在Person类添加析构函数,并且声明为虚函数:

……
struct Person
{
    //加上虚的析构:
    virtual ~Person()
    {
        //让基类对象临走前,也吱一声吧:
        cout << "吱~" << endl;
    }

     void Hello()
    {
        cout << "你好,我是" << name << endl;
    }

    string name;
};

struct Beauty : Person
{
    ~Beauty() override
    {
        cout << "wu~wu~人生似蚍蜉、似朝露;似秋天的草,似夏日的花……" << endl;
    }
};
……

再次编译,警告消息没了,运行,先出现派生类文艺范的遗言,然后出现基类的”吱~“。它们都来自这一行:

delete xiaoA;

梳理一下: xiaoA的声明类型是 Person的指针,实际创建类型是 Beauty对象。如果基类(Person)的析构函数不是虚的,那么当 delete xiaoA时,只会释放基类部分占用的内存——在本例子,这似乎也无所谓:因为派生类没没有添加任何成员数据,没有占用额外的内存——但这毕竟是一个严重的问题,将来Beautl添加新的成员数据(比如三围),那么新数据所占用的内存就不会在delete时释放。解决方法是将基类的析构函数声明为virtual。

不是说构成重写关系,函数的名字相同是要求之一吗? 这里当然是个合理的例外,因为析构函数(以及构造函数)有自己的特定的命名规则。

说到构造函数,构造函数有虚不虚这一说吗?在C++中是没有的。因为在C++当你要构造出一个对象,尽管可以声明它们为基类类型,但实际在new的时候,仍然要精确指定所要构造的是哪个类的对象。

多态实例

新建一个C++控制台项目,main.cpp代码:

#include <iostream>
#include <string>

using namespace std;

struct Person
{
    virtual ~Person();

    string name;
    virtual void Hello()
    {
        cout << "我是" << name << "。" << endl;
    }
};

Person::~Person()
{
      cout << "呜呜呜" << endl;
}

struct Beauty : Person
{
    ~Beauty() override;

    void Hello() override
    {
        cout << "大家好,我们是你们的女神" << name << ",我爱你们。" << endl;
    }
};

Beauty::~Beauty()
{
    cout << "这一生好美啊~" << endl;
}

int main()
{
    while(true)
    {
            Person *someone; //还不知道美不美

            cout << "请选择(1/2/3):" << endl
                  << "1----普通人" << endl
                  << "2----美人" << endl
                  << "3----退出" << endl;

            int sel = 0;
            cin >> sel; //流输入

            if (cin.fail ()) //读入失败吗
            {
                cin.clear(); //清除失败标志
            }

            cin.sync();  //清除所有未读入的内容

            if (3 == sel)
            {
                break;
            }

            if (1 == sel)
            {
                someone = new Person;
            }
            else if (2 == sel)
            {
                someone = new Beauty;
            }
            else //用户输入的,即不是1,也不是2,也不是3...
            {
                cout << "输入有误吧?请重新选择。" << endl;
                continue;
            }

            cout << "请输入姓名:";

            string name;
            getline(cin, name);

            someone->name = name;

            cout << name << "对大家问好:" << endl;
            someone->Hello();

            delete someone;
        }
}

以下是运行时的输出:

请选择(1/2/3): 1----普通人 2----美人 3----退出 1 请输入姓名:张三 张三对大家问好: 我是张三。 呜呜呜 请选择(1/2/3): 1----普通人 2----美人 3----退出 2 请输入姓名:志玲 志玲 对大家问好: 大家好,我们是你们的女神志玲 ,我爱你们。 这一生好美啊~ 呜呜呜 请选择(1/2/3): 1----普通人 2----美人 3----退出