25-7 纯虚函数、抽象基类和接口类

纯虚函数(抽象函数)与抽象基类

迄今为止,我们编写的虚函数都带有函数体(即定义)。然而C++允许创建一种特殊的虚函数——纯虚函数pure virtual function(或称抽象函数abstract function),其完全不包含函数体!纯虚函数仅作为占位符存在,旨在由派生类进行重定义。

创建纯虚函数时,无需定义函数体,只需将该函数赋值为0即可。

#include <string_view>

class Base
{
public:
    std::string_view sayHi() const { return "Hi"; } // a normal non-virtual function

    virtual std::string_view getName() const { return "Base"; } // a normal virtual function

    virtual int getValue() const = 0; // a pure virtual function

    int doSomething() = 0; // Compile error: can not set non-virtual functions to 0
};

image

当我们在类中添加纯虚函数时,实际上是在宣告:“该函数的实现权交由派生类决定”。

使用纯虚函数会产生两个主要后果:首先,包含一个或多个纯虚函数的类将自动成为抽象基类abstract base class,这意味着它无法被实例化!试想若能创建基类的实例会发生什么:

int main()
{
    Base base {}; // We can't instantiate an abstract base class, but for the sake of example, pretend this was allowed
    base.getValue(); // what would this do?

    return 0;
}

image

由于未定义 getValue() 方法,base.getValue() 将解析为何种形式?

其次,任何派生类都必须为该方法定义主体,否则该派生类也将被视为抽象基类。


纯虚函数示例

让我们通过一个示例来观察纯虚函数的实际应用。在之前的课程中,我们编写了一个简单的动物基类,并从中派生出猫和狗类。以下是当时留下的代码:

#include <string>
#include <string_view>

class Animal
{
protected:
    std::string m_name {};

    // We're making this constructor protected because
    // we don't want people creating Animal objects directly,
    // but we still want derived classes to be able to use it.
    Animal(std::string_view name)
        : m_name{ name }
    {
    }

public:
    const std::string& getName() const { return m_name; }
    virtual std::string_view speak() const { return "???"; }

    virtual ~Animal() = default;
};

class Cat: public Animal
{
public:
    Cat(std::string_view name)
        : Animal{ name }
    {
    }

    std::string_view speak() const override { return "Meow"; }
};

class Dog: public Animal
{
public:
    Dog(std::string_view name)
        : Animal{ name }
    {
    }

    std::string_view speak() const override { return "Woof"; }
};

我们通过将构造函数设为受保护的,阻止了人们分配Animal类型的对象。然而,仍然可以创建不重新定义speak()函数的派生类。

例如:

#include <iostream>
#include <string>
#include <string_view>

class Animal
{
protected:
    std::string m_name {};

    // We're making this constructor protected because
    // we don't want people creating Animal objects directly,
    // but we still want derived classes to be able to use it.
    Animal(std::string_view name)
        : m_name{ name }
    {
    }

public:
    const std::string& getName() const { return m_name; }
    virtual std::string_view speak() const { return "???"; }

    virtual ~Animal() = default;
};

class Cow : public Animal
{
public:
    Cow(std::string_view name)
        : Animal{ name }
    {
    }

    // We forgot to redefine speak
};

int main()
{
    Cow cow{"Betsy"};
    std::cout << cow.getName() << " says " << cow.speak() << '\n';

    return 0;
}

这将输出:

image

发生了什么?我们忘记重新定义 speak() 函数,导致 cow.Speak() 被解析为 Animal.speak(),这并非我们想要的结果。

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

#include <string>
#include <string_view>

class Animal // This Animal is an abstract base class
{
protected:
    std::string m_name {};

public:
    Animal(std::string_view name)
        : m_name{ name }
    {
    }

    const std::string& getName() const { return m_name; }
    virtual std::string_view speak() const = 0; // note that speak is now a pure virtual function

    virtual ~Animal() = default;
};

这里有几点需要注意。首先,speak() 现已成为纯虚函数。这意味着 Animal 类现在是抽象基类,无法被实例化。因此,我们不再需要将构造函数设为受保护(尽管这样做无妨)。其次,由于Cow类从Animal派生却未定义Cow::speak(),Cow同样成为抽象基类。现在当我们尝试编译这段代码时:

#include <iostream>
#include <string>
#include <string_view>

class Animal // This Animal is an abstract base class
{
protected:
    std::string m_name {};

public:
    Animal(std::string_view name)
        : m_name{ name }
    {
    }

    const std::string& getName() const { return m_name; }
    virtual std::string_view speak() const = 0; // note that speak is now a pure virtual function

    virtual ~Animal() = default;
};

class Cow: public Animal
{
public:
    Cow(std::string_view name)
        : Animal{ name }
    {
    }

    // We forgot to redefine speak
};

int main()
{
    Cow cow{ "Betsy" };
    std::cout << cow.getName() << " says " << cow.speak() << '\n';

    return 0;
}

编译器会报错,因为 Cow 是抽象基类,而我们无法创建抽象基类的实例:

image

这告诉我们,只有当 Cow 提供了 speak() 的主体时,我们才能实例化 Cow。

让我们继续这样做:

#include <iostream>
#include <string>
#include <string_view>

class Animal // This Animal is an abstract base class
{
protected:
    std::string m_name {};

public:
    Animal(std::string_view name)
        : m_name{ name }
    {
    }

    const std::string& getName() const { return m_name; }
    virtual std::string_view speak() const = 0; // note that speak is now a pure virtual function

    virtual ~Animal() = default;
};

class Cow: public Animal
{
public:
    Cow(std::string_view name)
        : Animal(name)
    {
    }

    std::string_view speak() const override { return "Moo"; }
};

int main()
{
    Cow cow{ "Betsy" };
    std::cout << cow.getName() << " says " << cow.speak() << '\n';

    return 0;
}

现在这个程序将编译并输出:

image

当我们希望在基类中定义某个函数,但只有派生类知道该函数应返回什么时,纯虚函数就派上用场了。纯虚函数使得基类无法被实例化,迫使派生类在实例化前必须先定义这些函数。这有助于确保派生类不会忘记重定义基类所期望的函数。

与普通虚函数类似,纯虚函数可通过基类的引用(或指针)进行调用:

int main()
{
    Cow cow{ "Betsy" };
    Animal& a{ cow };

    std::cout << a.speak(); // resolves to Cow::speak(), prints "Moo"

    return 0;
}

在上例中,a.speak() 通过虚函数解析机制解析为 Cow::speak()。

重要提示:
任何包含纯虚函数的类都应同时拥有虚析构函数。


带定义的纯虚函数

事实证明,我们可以创建带有定义的纯虚函数:

#include <string>
#include <string_view>

class Animal // This Animal is an abstract base class
{
protected:
    std::string m_name {};

public:
    Animal(std::string_view name)
        : m_name{ name }
    {
    }

    const std::string& getName() { return m_name; }
    virtual std::string_view speak() const = 0; // The = 0 means this function is pure virtual

    virtual ~Animal() = default;
};

std::string_view Animal::speak() const  // even though it has a definition
{
    return "buzz";
}

image

在此情况下,由于存在“= 0”条件(即使已给出定义),speak()仍被视为纯虚函数,而Animal仍被视为抽象基类(因此无法实例化)。任何继承自Animal的类都需要为speak()提供自己的定义,否则也将被视为抽象基类。
为纯虚函数提供定义时,必须单独定义(不可内联)。

对于 Visual Studio 用户:
Visual Studio 允许纯虚函数声明兼作定义,例如:

virtual std::string_view speak() const = 0
{
  return "buzz";
}

这不符合C++标准,且无法禁用。

当你希望基类为某个函数提供默认实现,同时强制派生类必须提供自己的实现时,这种范式就很有用。不过,如果派生类对基类提供的默认实现感到满意,它可以直接调用基类的实现。例如:

#include <iostream>
#include <string>
#include <string_view>

class Animal // This Animal is an abstract base class
{
protected:
    std::string m_name {};

public:
    Animal(std::string_view name)
        : m_name(name)
    {
    }

    const std::string& getName() const { return m_name; }
    virtual std::string_view speak() const = 0; // note that speak is a pure virtual function

    virtual ~Animal() = default;
};

std::string_view Animal::speak() const
{
    return "buzz"; // some default implementation
}

class Dragonfly: public Animal
{

public:
    Dragonfly(std::string_view name)
        : Animal{name}
    {
    }

    std::string_view speak() const override// this class is no longer abstract because we defined this function
    {
        return Animal::speak(); // use Animal's default implementation
    }
};

int main()
{
    Dragonfly dfly{"Sally"};
    std::cout << dfly.getName() << " says " << dfly.speak() << '\n';

    return 0;
}

上述代码输出:

![image](https://img2024.cnblogs.com/blog/1915850/202602/1915850-20260204011448408-367897444.png)

这种功能并不常用。

析构函数可以定义为纯虚函数,但必须提供定义,以便在派生对象被销毁时能够调用它。

---

# 接口类

**接口类**`interface class`是指不包含成员变量,且所有函数均为纯虚函数的类!当需要定义派生类必须实现的功能,同时完全将实现细节交由派生类自行决定时,接口便显得尤为有用。

接口类通常以字母I开头命名。以下是一个接口类的示例:

```cpp
#include <string_view>

class IErrorLog
{
public:
    virtual bool openLog(std::string_view filename) = 0;
    virtual bool closeLog() = 0;

    virtual bool writeError(std::string_view errorMessage) = 0;

    virtual ~IErrorLog() {} // make a virtual destructor in case we delete an IErrorLog pointer, so the proper derived destructor is called
};

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

现在假设你需要编写使用错误日志的代码。若直接在代码中包含FileErrorLog或ScreenErrorLog,则程序将被迫使用该类型的错误日志(至少在不重写程序的情况下如此)。例如,以下函数实质上强制mySqrt()的调用者使用FileErrorLog,而这可能并非调用者所需。

#include <cmath> // for 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;
    }

    return std::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;
    }

    return std::sqrt(value);
}

现在调用方可以传入任何符合 IErrorLog 接口的类。如果希望将错误记录到文件中,可以传入 FileErrorLog 的实例。若需将错误输出到屏幕,则可传递ScreenErrorLog的实例。若需实现您尚未考虑的功能(例如错误发生时向特定人员发送邮件),则可从IErrorLog派生新类(如EmailErrorLog),并使用该类的实例!通过采用IErrorLog接口,您的函数将获得更高的独立性和灵活性。

请务必为接口类添加虚拟析构函数,这样当删除指向接口的指针时,就能调用正确的派生析构函数。

接口类之所以广受欢迎,在于其易用、易扩展且易维护的特性。事实上,Java和C#等现代语言已引入“interface”关键字,允许程序员直接定义接口类而无需显式标记所有成员函数为抽象。更值得注意的是,尽管Java和C#禁止普通类的多重继承,却允许接口类进行任意次数的多重继承。由于接口既无数据也无函数体,它们在保留高度灵活性的同时,规避了多重继承的诸多传统问题。


纯虚函数与虚函数表

为保持一致性,抽象类仍保留虚函数表。抽象类的构造函数或析构函数可能调用虚函数,此时需解析至正确的函数(该函数必须位于同一类中,因为派生类要么尚未构造,要么已被销毁)。

包含纯虚函数的类在虚函数表中的条目通常包含空指针,或指向一个输出错误信息的通用函数(该函数有时命名为__purecall)。

posted @ 2026-02-04 01:20  游翔  阅读(0)  评论(0)    收藏  举报