纯虚函数,抽象基类和接口类
纯虚拟(抽象)函数和抽象基类
到目前为止,我们编写的所有虚函数都有一个正文(定义)。但是,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)。