C++基础教程面向对象(学习笔记(59))

纯虚函数,抽象基类和接口类

纯虚拟(抽象)函数和抽象基类

到目前为止,我们编写的所有虚函数都有一个正文(定义)。但是,C ++允许您创建一种特殊的虚拟函数,称为纯虚函数(或抽象函数),它根本没有任何实体!纯虚函数只是作为占位符,由派生类重新定义。

要创建纯虚函数,而不是为函数定义主体,我们只需为函数赋值为0。

class Base
{
public:
    const char* sayHi() { return "Hi"; } // 一个正常的非虚函数
 
    virtual const char* getName() { return "Base"; } // 一个普通的虚函数
 
    virtual int getValue() = 0; // 纯虚函数
 
    int doSomething() = 0; //编译错误:无法将非虚函数设置为0
};

当我们向我们的类添加一个纯虚函数时,我们实际上是在说,“由派生类来实现这个函数”。

使用纯虚函数有两个主要结果:首先,任何具有一个或多个纯虚函数的类都会成为抽象基类,这意味着它无法实例化!考虑如果我们可以创建Base实例会发生什么:

int main()
{
    Base base; // 我们不能实例化一个抽象基类,但为了举例,假装这是允许的
    base.getValue(); // 这么做会怎样呢?
}

因为getValue()没有定义,base.getValue()会解析什么?

其次,任何派生类都必须为此函数定义一个主体,或者派生类也将被视为抽象基类。

纯虚函数示例

让我们看一下纯虚函数的实例。在上一课中,我们编写了一个简单的Animal基类,并从中派生出Cat和Dog类。这是我们上次的代码:

#include <string>
class Animal
{
protected:
    std::string m_name;
 
    //我们正在使这个构造函数受到保护,因为
    // 我们不希望人们直接创建Animal对象,
    // 但是我们仍然希望派生类能够使用它。
    Animal(std::string name)
        : m_name(name)
    {
    }
 
public:
    std::string getName() { return m_name; }
    virtual const char* speak() { return "???"; }
};
 
class Cat: public Animal
{
public:
    Cat(std::string name)
        : Animal(name)
    {
    }
 
    virtual const char* speak() { return "Meow"; }
};
 
class Dog: public Animal
{
public:
    Dog(std::string name)
        : Animal(name)
    {
    }
 
    virtual const char* speak() { return "Woof"; }
};

我们通过使构造函数受到保护来阻止人们分配Animal类型的对象。但是,这有两个问题:
1)仍然可以从派生类中访问构造函数,从而可以实例化Animal对象。
2)仍然可以创建不重新定义函数speak()的派生类。

例如:

#include <iostream>
class Cow: public Animal
{
public:
    Cow(std::string name)
        : Animal(name)
    {
    }
 
    // 我们忘了重新定义speak()
};
 
int main()
{
    Cow cow("Betsy");
    std::cout << cow.getName() << " says " << cow.speak() << '\n';
}

这将打印:

Betsy says ???
发生了什么?我们忘了重新定义函数speak(),所以cow.Speak()解析为Animal.speak(),这不是我们想要的。

解决此问题的更好方法是使用纯虚函数:

#include <string>
class Animal // 这个Animal是一个抽象的基类
{
protected:
    std::string m_name;
 
public:
    Animal(std::string name)
        : m_name(name)
    {
    }
 
    std::string getName() { return m_name; }
    virtual const char* speak() = 0; // 请注意,speak()现在是一个纯虚函数
};

这里有几点需要注意。首先,speak()现在是一个纯虚函数。这意味着Animal现在是一个抽象基类,无法实例化。因此,我们不需要再对构造函数进行保护(尽管它没有受到伤害)。其次,因为我们的Cow类是从Animal派生的,但是我们没有定义Cow :: speak(),而Cow也是一个抽象基类。现在,当我们尝试编译此代码时:

#include <iostream>
class Cow: public Animal
{
public:
    Cow(std::string name)
        : Animal(name)
    {
    }
 
    // 我们忘了重新定义speak()
};
 
int main()
{
    Cow cow("Betsy");
    std::cout << cow.getName() << " says " << cow.speak() << '\n';
}

编译器会给我们一个警告,因为Cow是一个抽象基类,我们不能创建抽象基类的实例:

C:\ Test.cpp(141):错误C2259:‘Cow’:由于以下成员,无法实例化抽象类:
C:Test.cpp(128):看’牛’的声明
C:\ Test.cpp(141):警告C4259:‘const char * __ thiscall Animal :: speak(void)’:未定义纯虚函数
这告诉我们,如果Cow为speak()提供了一个主体,我们将只能实例化Cow。

让我们继续这样做:

#include <iostream>
class Cow: public Animal
{
public:
    Cow(std::string name)
        : Animal(name)
    {
    }
 
    virtual const char* speak() { return "Moo"; }
};
 
int main()
{
    Cow cow("Betsy");
    std::cout << cow.getName() << " says " << cow.speak() << '\n';
}

现在这个程序将编译和打印:

Betsy says Moo
当我们有一个我们想要放在基类中的函数时,纯虚函数很有用,但只有派生类知道它应该返回什么。纯虚函数使得基类无法实例化,并且派生类在实例化之前必须定义这些函数。这有助于确保派生类不会忘记重新定义基类期望它们的函数。

纯虚拟函数与定义

事实证明,我们可以定义具有主体的纯虚函数:

#include <string>
class Animal // 这个Animal是一个抽象的基类
{
protected:
    std::string m_name;
 
public:
    Animal(std::string name)
        : m_name(name)
    {
    }
 
    std::string getName() { return m_name; }
    virtual const char* speak() = 0; //= 0表示此函数是纯虚函数
};
 
const char* Animal::speak()  // 这是它的主体
{
    return "buzz";
}

在这种情况下,speak()仍然被认为是一个纯虚函数(它被赋予了一个体,因为“= 0”)并且Animal仍然被认为是一个抽象基类(因此无法实例化) 。从Animal继承的任何类都需要为speak()提供自己的定义,或者它也将被视为抽象基类。

为纯虚函数提供主体时,必须单独提供主体(不是内联)。

当您希望基类为函数提供默认实现时,此范例可能很有用,但仍强制任何派生类提供自己的实现。但是,如果派生类对基类提供的默认实现感到满意,则可以直接调用基类实现。例如:

#include <string>
#include <iostream>
 
class Animal // 这个Animal是一个抽象的基类
{
protected:
    std::string m_name;
 
public:
    Animal(std::string name)
        : m_name(name)
    {
    }
 
    std::string getName() { return m_name; }
    virtual const char* speak() = 0; // 请注意,speak是一个纯虚函数
};
 
const char* Animal::speak()
{
    return "buzz"; // 一些默认实现
}
 
class Dragonfly: public Animal
{
 
public:
    Dragonfly(std::string name)
        : Animal(name)
    {
    }
 
    virtual const char* speak() //这个类不再是抽象的,因为我们定义了这个函数
    {
        return Animal::speak(); // 使用Animal的默认实现
    }
};
 
int main()
{
    Dragonfly dfly("Sally");
    std::cout << dfly.getName() << " says " << dfly.speak() << '\n';
}

上面的代码打印:

Sally says buzz
此功能不常用。

接口类

一个接口类是一个没有成员变量的一类,并在所有的函数都是纯虚!换句话说,该类纯粹是一个定义,并没有实际的实现。当您想要定义派生类必须实现的函数时,接口很有用,但是保留派生类如何完全实现该函数的详细信息直到派生类。

接口类通常以I开头命名。这是一个示例接口类:

class IErrorLog
{
public:
    virtual bool openLog(const char *filename) = 0;
    virtual bool closeLog() = 0;
 
    virtual bool writeError(const char *errorMessage) = 0;
 
    virtual ~IErrorLog() {}; // 如果我们删除IErrorLog指针,则创建一个虚拟析构函数,以便调用正确的派生析构函数
};

从IErrorLog继承的任何类都必须提供所有三个函数的实现才能实例化。您可以派生一个名为FileErrorLog的类,其中openLog()在磁盘上打开文件,closeLog()关闭文件,writeError()将消息写入文件。您可以派生另一个名为ScreenErrorLog的类,其中openLog()和closeLog()不执行任何操作,writeError()在屏幕上的弹出消息框中打印消息。

现在,假设您需要编写一些使用错误日志的代码。如果您编写代码使其直接包含FileErrorLog或ScreenErrorLog,那么您实际上是在使用这种错误日志(至少不重新编码程序)。例如,以下函数有效地强制mySqrt()的调用者使用FileErrorLog,这可能是也可能不是他们想要的。

#include <cmath> // sqrt()
double mySqrt(double value, FileErrorLog &log)
{
    if (value < 0.0)
    {
        log.writeError("Tried to take square root of value less than 0");
        return 0.0;
    }
    else
        return sqrt(value);
}

实现此功能的更好方法是使用IErrorLog:

#include <cmath> // for sqrt()
double mySqrt(double value, IErrorLog &log)
{
    if (value < 0.0)
    {
        log.writeError("Tried to take square root of value less than 0");
        return 0.0;
    }
    else
        return sqrt(value);
}

现在调用者可以传入任何符合IErrorLog接口的类。如果他们希望错误转到文件,他们可以传入FileErrorLog的实例。如果他们希望它进入屏幕,他们可以传入ScreenErrorLog的实例。或者,如果他们想要做一些你甚至没想过的事情,例如在出现错误时向某人发送电子邮件,他们可以从IErrorLog派生一个新类(例如EmailErrorLog)并使用它的实例!通过使用IErrorLog,您的函数变得更加独立和灵活。

不要忘记为接口类包含一个虚拟析构函数,以便在删除指向接口的指针时调用正确的派生析构函数。

接口类已经变得非常流行,因为它们易于使用,易于扩展且易于维护。事实上,一些现代语言,如Java和C#,添加了一个“接口”关键字,允许程序员直接定义接口类,而不必将所有成员函数显式标记为抽象。此外,虽然Java(版本8之前)和C#不允许您在普通类上使用多重继承,但它们会让您多次继承任意数量的接口。因为接口没有数据也没有函数体,所以它们避免了许多传统的多重继承问题,同时仍然提供了很大的灵活性。

纯虚函数和虚拟表

抽象类仍然具有虚拟表,因为如果您具有指向抽象类的指针或引用,则仍可以使用这些表。纯虚函数的虚表条目通常要么包含空指针,要么指向打印错误的泛型函数(如果没有提供覆盖,则有时将此函数命名为__purecall)。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值