在 C++ 中使用接口






3.29/5 (30投票s)
2005 年 6 月 1 日
11分钟阅读

241993

1011
如何定义和使用 C++ 中的接口。
摘要
类与接口的区别是 Java 和 C# (以及许多其他语言) 中存在的一个强大的语言特性,但在 C++ 中不存在。多年来,我的团队一直在 C++ 中使用一种“方法学”的接口概念实现,我在这里呈现给大家。从 VS7 开始,还有一个 MS 扩展指向了相同的方向,允许编译器强制执行接口的大部分定义特征,当然,C++ 的托管扩展也支持 .NET 接口的定义和实现。然而,这些机制之间存在一些细微和不那么细微的差异,您应该加以考虑。
背景
接口描述了一个类的行为或能力,而不承诺特定的实现。它代表了提供者和使用者之间的契约,定义了对每个实现者的要求,但仅限于他们必须提供的服务,而不管他们如何做到这一点。
如果您不熟悉接口的概念,以及何时何地使用它来改进您的设计,下面的 MSDN 文章可能会有很大帮助。它面向从 VB 转向 VB.NET 的用户,但它很好地解释了基本思想,这些思想无论如何都适用于任何面向对象的语言。
引言
多年前,大约在 1999 年 11 月,我定义了一种方法来声明 C++ 的接口,并通过使用一些宏并确保遵循一些基本规则,让类实现它们。我应该明确一点,我并不声称是这种技术的“发明者”之类的。尽管它是独立开发的,并且得到了 Roberto Lublinerman 的宝贵建议,但我后来在互联网上发现了许多文章,描述了或多或少相同的想法,其中至少有一些可以追溯到我们开始考虑这个问题之前。
如今,可以通过利用 C++ 编译器后期版本中引入的 Microsoft 扩展来改进解决方案,但我们一步一步来。
第一个版本
首先,一些宏定义在一个头文件中,您可能会想将它包含在预编译头文件中
// // CppInterfaces.h // #define Interface class #define DeclareInterface(name) Interface name { \ public: \ virtual ~name() {} #define DeclareBasedInterface(name, base) class name : public base { \ public: \ virtual ~name() {} #define EndInterface }; #define implements public
使用这些宏,您可以按如下方式声明一个接口
// // IBar.h // DeclareInterface(IBar) virtual int GetBarData() const = 0; virtual void SetBarData(int nData) = 0; EndInterface
然后,您可以使用类似这样的方式声明一个实现该接口的类
// // Foo.h // #include "BasicFoo.h" #include "IBar.h" class Foo : public BasicFoo, implements IBar { // Construction & Destruction public: Foo(int x) : BasicFoo(x) { } ~Foo(); // IBar implementation public: virtual int GetBarData() const { // stuff... } virtual void SetBarData(int nData) { // stuff... } };
很简单,不是吗?您现在无需费力就可以在 C++ 中使用接口了。但是,由于语言不支持直接支持,您需要遵循一些规则,这些规则无法在编译时自动强制执行。毕竟,编译器能看到的是普通的多重继承和抽象基类。以下是您需要遵循的规则,以及一些建议
- 声明类时,如果存在“ is a”关系,请将第一个基类用于“结构”继承,就像您通常做的那样。(例如:
CFrameWnd
派生自CWnd
,CBitmapButton
派生自CButton
,YourDialog
派生自CDialog
,等等。) 如果您从 MFC 类派生,这一点尤其重要;将它们声明为第一个基类可以避免破坏 MFC 的 RuntimeClass 机制。 - 使用额外的基类来实现接口,数量不限。 (例如:
class Foo : public BasicFoo
,实现IBar
,实现IOther
,实现IWhatever
,...) - 不要在接口中声明任何成员变量。接口旨在表达行为,而不是数据。此外,这有助于避免一些问题,如果您使用多重“结构”继承并碰巧多次从同一个接口派生。
- 将接口中的所有成员函数声明为
virtual
纯函数 (即:带 "= 0")。这确保了声明实现接口的每个可实例化类都为其所有函数实现。在abstract
类中部分实现接口是可以的 (事实上,如果您这样做,它将是抽象的),只要您在实际要实例化的派生类中实现其余函数。由于接口不提供“基本”实现,您需要确保任何接收接口指针的人都能够调用其任何成员;将所有接口成员声明为virtual
纯函数将在编译时强制执行这一点。 - 不要让您的接口派生自任何非接口的类。您可以使用
DeclareBasedInterface()
宏来实现这一点。普通类可以选择实现基本接口或派生 (扩展) 接口,后者自然意味着实现两者。 - 将实现某个接口的类的指针赋给该接口的指针不需要进行类型转换,因为您实际上是转换为基类。反过来 (从接口指针到实现它的类的指针),则需要显式类型转换,因为您将转换为派生类。由于您实际上将使用多重继承 (即使在几乎所有其他实际需求中,您都可以将其视为单一继承加上接口实现),因此这些类型转换不能“按老方法”进行,因为它们可能需要不同的实际内存值。然而,启用运行时类型信息 (/GR 编译器选项) 并使用动态类型转换可以正常工作,而且当然更安全。
- 此外,使用
dynamic_cast
可以让您询问任何对象或接口是否实现了给定的接口。 - 您需要小心避免不同接口中的函数之间的名称冲突,因为如果您有一个类同时实现了它们,很难检测和解决这些冲突。
评估版
当我最近在我的 博客 上发布关于上述技术的内容时,一位读者提出了如下担忧:
- 何必费力使用宏呢?它们不强制任何东西,而且在提高可读性方面并不比这些老旧的常用方法更好。
#define begin { #define end }
- 也许这对 Java/C# 开发者有用,但如果一个开发者需要这样的拐杖,他们就有其他问题了。
如果您仔细观察 DeclareInterface
和 DeclareBasedInterface
宏,您会注意到至少有一点被强制执行了:每个实现接口的类都将有一个 virtual
析构函数。您可能认为这很重要,也可能不认为,但有些情况下,缺少 virtual
析构函数会引起问题。例如,考虑以下代码:
DeclareInterface(IBar) virtual LPCTSTR GetName() const = 0; virtual void SetName(LPCTSTR name) = 0; EndInterface class Foo : implements IBar { // Internal data private: char* m_pName; // Construction & Destruction public: Foo() { m_pName = NULL; } ~Foo() { ReleaseName(); } // Helpers protected: void ReleaseName() { if (m_pName != NULL) free(m_pName); } // IBar implementation public: virtual const char* GetName() const { return m_pName } virtual void SetName(const char* name) { ReleaseName(); m_pName = _strdup(name); } }; class BarFactory { public: enum BarType {Faa, Fee, Fii, Foo, Fuu}; static IBar CreateNewBar(BarType barType) { switch (barType) { default: case Faa: return new Faa; case Fee: return new Fee; case Fii: return new Fii; case Foo: return new Foo; case Fuu: return new Fuu; } } };
如您所见,有一个工厂,您可以根据 BarType
参数从中请求创建一个 IBar
实现。使用后,您应该删除该对象;到目前为止一切正常。现在考虑一下如何在某个应用程序的 main 函数中使用它:
int main() { IBar* pBar = BarFactory::CreateBar(Foo); pBar->SetName("MyFooBar"); // Use pBar as much as you want, // ... // and then just delete it when it's no longer needed delete pBar; // Oops! }
在 delete pBar
行上发生什么取决于该对象的实际类是否具有 virtual
析构函数。如果 Foo
没有 virtual
析构函数,编译器只会生成对 IBar
的隐式空析构函数的调用,Foo
的析构函数不会被调用,因此您将遇到内存泄漏。接口声明宏中的 virtual
析构函数是为了避免这种情况;它们确保每个实现接口的类也拥有一个 virtual
析构函数。
现在,如果我们使用 DeclareInterface
,那么使用 EndInterface
来匹配它,而不是一个没有明显匹配的开放括号的闭合括号,似乎是有道理的。Interface
和 implements
宏,分别解析为 class
和 public
,可能会被认为多余,但我发现它们更好地表达了代码的实际意图。如果我写 Foo : public IBar
,您只能看到某种继承关系,而没有别的。但如果我写 Foo implements IBar
,您就能看到它的实际价值和意图:接口概念的实现,而不仅仅是任何类型的类继承。我确实认为这很有价值。
公平地说,我确定读者实际上担心的是这些宏不能也不强制执行的其他方面:接口预计只包含纯 virtual
函数,并且不包含实例数据。但至少,如果您使用 DeclareInterface
和 EndInterface
编写接口,则很容易发现任何实例数据或非 virtual
纯函数的包含。用 Joel Spolsky 的话说,这些宏也有助于 “使错误的代码看起来错误”。
VS7 中的 C++ 接口支持
Microsoft 的开发人员可能也有同样的需要来强制执行这些对用作接口的类的限制,正如 VS7 中引入的 __interface
关键字一样,它是 Microsoft 对 C++ 编译器的一项新扩展。在 文档 中,他们将 Visual C++ 接口定义为:
- 可以继承自零个或多个基接口。
- 不能继承自基类。
- 只能包含
public
、纯virtual
方法。 - 不能包含构造函数、析构函数或运算符。
- 不能包含
static
方法。 - 不能包含数据成员;允许使用属性。
他们指出:“C++ 的 class
或 struct
可以用这些规则实现,但 __interface
强制执行了这些规则。” 所以,如果您不担心可移植性,可以使用这个扩展并让编译器强制执行需要强制执行的规则,对吧?错了。
还记得关于 virtual
析构函数的需求吗? __interface
不会为实现类添加 virtual
析构函数,而且我可以理解为什么 MS 不认为 virtual
析构函数对接口有必要。如果您查看文档中的示例,可以清楚地看出它们来自 COM。您永远不会对它们使用 delete
,因为它们是引用计数的对象,当计数达到零时,它们会调用 delete
来销毁自己。
我们不能在 DeclareInterface
宏定义中使用 __interface
,以便兼顾两全其美吗?嗯……,再次阅读定义:“不能包含构造函数、析构函数或运算符”。这让我最初认为这是不可能的,甚至认为 __interface
对于 COM 接口可能有用,但对于我们讨论的通用接口来说,它既不适合也不合适。幸运的是,故事并没有就此结束。
兼顾两全其美
过了一段时间,我找到了一个相当简单的解决方案:所需接口的名称实际上用于声明一个包含 virtual
析构函数并继承自包含所需方法的 __interface
的类。宏现在定义如下:
// // CppInterfaces2.h // #define Interface class #define implements public #define DeclareInterface(name) __interface actual_##name { #define DeclareBasedInterface(name, base) __interface actual_##name \ : public actual_##base { #define EndInterface(name) }; \ Interface name : public actual_##name { \ public: \ virtual ~name() {} \ };
以下是使用上述定义的宏声明接口的方法:
// // IBar2.h // DeclareInterface(IBar) int GetBarData() const; void SetBarData(int nData); EndInterface(IBar)
您可能会注意到,这些新的宏定义要求两次使用所需接口的名称 (即:IBar
),一次用于 DeclareInterface()
,另一次用于 EndInterface()
。这引入了一个总是令人不悦的冗余,我曾为此苦苦思索,但未能消除。如果有人找到定义宏的方法,可以避免重复使用同一个名称两次,请告诉我。
另一方面,只要您不介意牺牲可移植性到 VS7 及更高版本的 MS 编译器之外的任何东西,新的宏就比以前的宏有许多优点,因为接口的所有要求 (只有纯 virtual
方法,没有数据成员,实现类有 virtual
析构函数) 现在都自动强制执行了。您甚至不需要显式地将接口方法声明为 virtual
或纯 virtual
("= 0"),尽管如果您这样做,编译器也不会报错。
附加阅读
在结束本文之前,我想包含一些与我在此主题上研究时找到的相关资源的链接。
这里描述的技术允许通过使用 abstract
基类来定义和实现 C++ 中的接口。有些人不喜欢这种解决方案,因为它迫使每个实现类都拥有 (并使用) 一个虚函数表,他们认为这因空间 (每个类的虚函数表) 和性能 (每次方法调用的间接性) 的损失而不可接受。作为替代方案,Georgia Institute of Technology 的 Brian McNamara 和 Yannis Smaragdakis 撰写了一篇题为 Static interfaces in C++ 的论文,该论文发表在 2000 年 10 月 10 日于德国埃尔福特举行的第一届 C++ 模板编程研讨会 上。
Christopher Diggins 撰写了一份 提案,用于修改 C++ 语言,使其在没有 virtual
函数的情况下支持接口。我不知道这个提案是否被实际提交或考虑过标准委员会。
Dr. Dobb's Journal 1998 年 8 月 刊登了 Fred Wild 的一篇题为 Keeping interfaces and implementations separate 的文章,讨论了在 C++ 代码中这样做的一些方法。
在我最初提交的评论中,用户 Nemanja Trifunovic 向我指出了另一种静态接口的替代方案,即 Boost Interface Library[^]。
Bill Venners 在 2002 年 12 月对 Scott Meyers 的一次非常有趣的 采访 中,讨论了 C++ 中的接口主题。
最后但同样重要的是,C++ 之父 Bjarne Stroustrup 本人在 Bill Venners 于 2003 年 11 月进行的 采访 中,对接口和 C++ 发表了有趣的评论。
历史
- 2005 年 6 月 6 日:示例代码进行少量修改。
- 2005 年 6 月 1 日:添加了 Boost Interface 库和 Scott Meyers 采访的链接。