纯虚函数、抽象基类和接口类
本节阅读量:
到目前为止,我们编写的所有虚函数都有函数体(定义)。然而,C++ 允许您创建一种特殊的虚函数,称为纯虚函数(或抽象函数),它可以完全没有函数体!纯虚函数只充当占位符,用于表明这个函数需要由派生类重新定义。
要创建纯虚函数,只需将值 0 赋给函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
#include <string_view>
class Base
{
public:
std::string_view sayHi() const { return "Hi"; } // 普通非虚函数
virtual std::string_view getName() const { return "Base"; } // 普通虚函数
virtual int getValue() const = 0; // 纯虚函数
int doSomething() = 0; // 编译失败: 无法将非虚函数设置为0
};
|
当我们向类中添加纯虚函数时,我们实际上是在表示,“需要由派生类来实现该函数”。
使用纯虚函数有两个主要目的:首先,任何包含一个或多个纯虚函数的类都会成为抽象基类,这意味着它不能被实例化!假设我们想创建 Base:
1
2
3
4
5
6
7
|
int main()
{
Base base {}; // 不允许实例化抽象基类
base.getValue(); // 这一行会发生什么呢?
return 0;
}
|
因为没有 getValue() 的定义,所以 base.getValue() 无法解析。
其次,任何派生类都必须为此函数提供函数体,否则该派生类也会被视为抽象基类。
一个纯虚函数的例子
来看一个纯虚函数的例子。在上一课中,我们编写了一个简单的Animal基类,并从中派生了一个Cat和一个Dog类。下面是当时的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
#include <string>
#include <string_view>
class Animal
{
protected:
std::string m_name {};
// 将构造函数设置为 protected
// 因为不想任何人可以直接创建Animal对象
// 但是派生类可以使用这个构造函数
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"; }
};
|
我们通过 protected 构造函数来防止直接创建 Animal 类型的对象。然而,仍然可以创建一个没有重新定义 speak() 的派生类。
例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
#include <iostream>
#include <string>
#include <string_view>
class Animal
{
protected:
std::string m_name {};
// 将构造函数设置为 protected
// 因为不想任何人可以直接创建Animal对象
// 但是派生类可以使用这个构造函数
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 }
{
}
// 这里忘记重新定义 speak
};
int main()
{
Cow cow{"Betsy"};
std::cout << cow.getName() << " says " << cow.speak() << '\n';
return 0;
}
|
这将打印:
发生了什么?我们忘了重新定义 speak()。cow.speak() 解析为 Animal::speak(),这不是我们想要的。
这个问题的更好的解决方案是使用纯虚函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
#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; // 注意speak现在是纯虚函数
virtual ~Animal() = default;
};
|
这里有几点需要注意。首先,speak() 现在是一个纯虚函数。这意味着 Animal 现在是一个抽象基类,不能被实例化。因此,我们不再需要把构造函数设为 protected(尽管保留它也没有坏处)。其次,因为 Cow 类从 Animal 派生,但我们没有定义 Cow::speak(),所以 Cow 也是一个抽象基类。现在,当我们试图编译这段代码时:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
#include <iostream>
#include <string>
#include <string_view>
class Animal // Animal 现在是一个抽象基类
{
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; // 注意 speak 现在是一个纯虚函数
virtual ~Animal() = default;
};
class Cow: public Animal
{
public:
Cow(std::string_view name)
: Animal{ name }
{
}
// 这里忘记重新定义 speak
};
int main()
{
Cow cow{ "Betsy" };
std::cout << cow.getName() << " says " << cow.speak() << '\n';
return 0;
}
|
编译器将给出错误信息,因为 Cow 是抽象基类,我们无法创建它的实例:
1
2
3
4
5
6
|
prog.cc:35:9: error: variable type 'Cow' is an abstract class
35 | Cow cow{ "Betsy" };
| ^
prog.cc:17:30: note: unimplemented pure virtual method 'speak' in 'Cow'
17 | virtual std::string_view speak() const = 0; // 注意 speak 现在是一个纯虚函数
| ^
|
这说明,只有当 Cow 为 speak() 提供函数体时,我们才能实例化 Cow。
继续修改:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
#include <iostream>
#include <string>
#include <string_view>
class Animal // Animal 现在是一个抽象基类
{
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; // 注意 speak 现在是一个纯虚函数
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;
}
|
现在,这个程序可以编译,并打印:
当某个函数应当放在基类中,但只有派生类知道具体行为时,纯虚函数就很有用。纯虚函数使我们无法实例化基类,并且要求派生类在实例化之前必须定义这些函数。这有助于确保派生类不会忘记定义这些函数。
和普通虚函数一样,也可以通过基类引用(或指针)来调用纯虚函数:
1
2
3
4
5
6
7
8
9
|
int main()
{
Cow cow{ "Betsy" };
Animal& a{ cow };
std::cout << a.speak(); // 指向 Cow::speak(), 打印 "Moo"
return 0;
}
|
在上面的示例中,a.speak() 通过虚函数解析为 Cow::speak()。
注意,任何包含纯虚函数的类也应该具有虚析构函数。
带定义的纯虚函数
我们可以为纯虚函数提供定义:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
#include <string>
#include <string_view>
class Animal // Animal 是抽象基类
{
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; // = 0 意味着这个函数是纯虚函数
virtual ~Animal() = default;
};
std::string_view Animal::speak() const // 但是它仍然有一个函数定义
{
return "buzz";
}
|
在这种情况下,由于“=0”(即使它已经有了定义),speak()仍然会被认为是纯虚函数,Animal仍然被视为抽象基类(因此不能被实例化)。从Animal继承的任何类都需要为speak()提供自己的实现,否则它也将被视为抽象基类。
为纯虚函数提供定义时,必须在类外单独提供定义(而不能放在类内部)。
当您希望基类为函数提供默认实现,但仍然强制任何派生类提供自己的实现时,这种写法可能很有用。如果派生类认可基类提供的默认实现,可以直接调用基类实现。例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
#include <iostream>
#include <string>
#include <string_view>
class Animal // Animal 是抽象基类
{
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; // 注意speak是纯虚函数
virtual ~Animal() = default;
};
std::string_view Animal::speak() const
{
return "buzz"; // 默认的一些实现
}
class Dragonfly: public Animal
{
public:
Dragonfly(std::string_view name)
: Animal{name}
{
}
std::string_view speak() const override // 因为有定义,所以不再是纯虚函数
{
return Animal::speak(); // 使用 Animal 的默认实现
}
};
int main()
{
Dragonfly dfly{"Sally"};
std::cout << dfly.getName() << " says " << dfly.speak() << '\n';
return 0;
}
|
上面的代码打印:
这个功能并不常用。
析构函数可以是纯虚的,但必须提供定义,以便派生对象析构时能够调用它。
接口类
接口类是不包含成员变量的类,其中所有成员函数都是纯虚函数!当您想要定义派生类必须实现的功能时,接口非常有用,并把具体实现细节完全留给派生类。
接口类通常以 I 开头命名。下面是一个接口类示例:
1
2
3
4
5
6
7
8
9
10
11
12
|
#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() {} // 有virtual函数的类,析构函数也必需是virtual
};
|
从 IErrorLog 继承的任何类都必须为所有三个函数提供实现,才能被实例化。您可以派生一个名为 FileErrorLog 的类,其中 openLog() 在磁盘上打开文件,closeLog() 关闭该文件,writeError() 将消息写入该文件。您也可以派生另一个名为 ScreenErrorLog 的类,其中 openLog() 和 closeLog() 什么也不做,writeError() 将消息打印到屏幕上。
现在,假设您需要编写一些使用错误日志的代码。如果代码直接依赖 FileErrorLog 或 ScreenErrorLog,那么实际上就很难替换为另一个 Log 类(至少在不重新编写程序的情况下很难)。例如,下面的函数强制 mySqrt() 的调用方使用 FileErrorLog,这可能符合调用方需求,也可能不符合。
1
2
3
4
5
6
7
8
9
10
11
12
|
#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:
1
2
3
4
5
6
7
8
9
10
11
|
#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)。