提问人:Adomas Baliuka 提问时间:12/23/2020 最后编辑:Adomas Baliuka 更新时间:1/6/2021 访问量:1612
在 C++ 中定义接口(没有成员的抽象类)
Defining interfaces (abstract classes without members) in C++
问:
通过接口(C#术语),我的意思是没有数据成员的抽象类。因此,这样的类只指定子类必须实现的协定(一组方法)。我的问题是:如何在现代C++中正确实现这样的类?
C++ 核心准则 [1] 鼓励使用没有数据成员的抽象类作为接口 [I.25 和 C.121]。接口通常应完全由公共纯虚函数和默认/空虚析构函数组成[来自 C.121]。因此,我想它应该用关键字声明,因为它无论如何只包含公共成员。struct
为了通过指向抽象类的指针来使用和删除子类对象,抽象类需要一个公共默认虚拟析构函数 [C.127]。“多态类应该抑制复制”[C.67],通过删除复制操作(复制赋值运算符、复制构造函数)来防止切片。我认为这也扩展到移动构造函数和移动赋值运算符,因为它们也可用于切片。对于实际克隆,抽象类可以定义一个虚拟方法。(目前尚不完全清楚应该怎么做。通过智能指针或指南支持库。使用 owner<T>
的方法对我来说毫无意义,因为示例不应该编译:派生函数仍然没有任何作用!?clone
owner<T*>
override
在 C.129 中,该示例仅使用具有虚拟继承的接口。如果我理解正确的话,如果接口是使用 or 派生的(也许更好:“实现”?),则没有区别,因为它们没有可以复制的数据。接口不存在菱形问题(以及相关问题)(我认为,这就是 C# 等语言不允许/需要类多重继承的原因)。这里的虚拟继承只是为了清楚起见吗?这是好的做法吗?class Impl : public Interface {...};
class Impl : public virtual Interface {...};
总而言之,似乎:接口应该只由公共方法组成。它应该声明一个公共默认的虚拟析构函数。它应显式删除副本分配、副本构造、移动分配和移动构造。它可以定义一种多态性克隆方法。我应该使用公共虚拟
派生。
还有一件事让我感到困惑: 一个明显的矛盾:“抽象类通常不需要构造函数”[C.126]。但是,如果通过删除所有复制操作(遵循 [C.67])来实现五规则,则该类不再具有默认构造函数。因此,子类永远无法实例化(因为子类构造函数调用基类构造函数),因此抽象基类总是需要声明默认构造函数?!我是不是误解了什么?
下面是一个示例。您是否同意用这种方式定义和使用没有成员(接口)的抽象类?
// C++17
/// An interface describing a source of random bits.
// The type `BitVector` could be something like std::vector<bool>.
#include <memory>
struct RandomSource { // `struct` is used for interfaces throughout core guidelines (e.g. C.122)
virtual BitVector get_random_bits(std::size_t num_bits) = 0; // interface is just one method
// rule of 5 (or 6?):
RandomSource() = default; // needed to instantiate sub-classes !?
virtual ~RandomSource() = default; // Needed to delete polymorphic objects (C.127)
// Copy operations deleted to avoid slicing. (C.67)
RandomSource(const RandomSource &) = delete;
RandomSource &operator=(const RandomSource &) = delete;
RandomSource(RandomSource &&) = delete;
RandomSource &operator=(RandomSource &&) = delete;
// To implement copying, would need to implement a virtual clone method:
// Either return a smart pointer to base class in all cases:
virtual std::unique_ptr<RandomSource> clone() = 0;
// or use `owner`, an alias for raw pointer from the Guidelines Support Library (GSL):
// virtual owner<RandomSource*> clone() = 0;
// Since GSL is not in the standard library, I wouldn't use it right now.
};
// Example use (class implementing the interface)
class PRNG : public virtual RandomSource { // virtual inheritance just for clarity?
// ...
BitVector get_random_bits(std::size_t num_bits) override;
// may the subclass ever define copy operations? I guess no.
// implemented clone method:
// owner<PRNG*> clone() override; // for the alternative owner method...
// Problem: multiple identical methods if several interfaces are inherited,
// each of which requires a `clone` method?
//Maybe the std. library should provide an interface
// (e.g. `Clonable`) to unify this requirement?
std::unique_ptr<RandomSource> clone() override;
//
// ... private data members, more methods, etc...
};
[1]: https://github.com/isocpp/CppCoreGuidelines, commit 2c95a33fefae87c2222f7ce49923e7841faca482
答:
你问了很多问题,但我会试一试。
通过接口(C#术语),我的意思是没有数据成员的抽象类。
没有像 C# 接口那样的特别的东西存在。C++ 抽象基类是最接近的,但存在差异(例如,您需要为虚拟析构函数定义主体)。
因此,这样的类只指定子类必须实现的协定(一组方法)。我的问题是:如何在现代C++中正确实现这样的类?
作为虚拟基类。
例:
class OutputSink
{
public:
~OutputSink() = 0;
// contract:
virtual void put(std::vector<std::byte> const& bytes) = 0;
};
OutputSink::~OutputSink() = default;
因此,我想它应该使用 struct 关键字声明,因为它无论如何只包含公共成员。
对于何时使用结构与类,有多种约定。我推荐的准则(嘿,你:D征求意见)是当你的数据没有不变性时使用结构。对于基类,请使用关键字。class
“多态类应该禁止复制”
大多是真的。我编写的代码中,客户端代码没有执行继承类的副本,并且代码运行良好(没有禁止它们)。基类没有明确禁止它,但这是我在自己的爱好项目中编写的代码。在团队中工作时,最好专门限制复制。
通常,不要为克隆而烦恼,直到您在代码中找到它的实际用例。然后,使用以下签名实现克隆(上面的类示例):
virtual std::unique_ptr<OutputSink> OutputSink::clone() = 0;
如果由于某种原因这不起作用,请使用另一个签名(例如返回shared_ptr)。 是一个有用的抽象,但它应该只在极端情况下使用(当你有一个代码库,强制你使用原始指针时)。owner<T>
接口应仅由公共方法组成。它应该声明 [...]。它应该 [...]。它应该使用公共虚拟派生。
不要试图在 C++ 中表示完美的 C# 界面。C++比这更灵活,你很少需要在C++中添加C#概念的一对一实现。
例如,在 C++ 的基类中,我有时会添加公共非虚拟函数实现,以及虚拟实现:
class OutputSink
{
public:
void put(const ObjWithHeaderAndData& o) // non-virtual
{
put(o.header());
put(o.data());
}
protected:
virtual void put(ObjectHeader const& h) = 0; // specialize in implementations
virtual void put(ObjectData const& d) = 0; // specialize in implementations
};
因此,抽象基类总是需要声明一个默认的构造函数?!我是不是误解了什么?
根据需要定义 5 规则。如果代码由于缺少默认构造函数而无法编译,请添加默认构造函数(仅在有意义时才使用指南)。
编辑:(解决评论)
一旦你声明了一个虚拟析构函数,你就必须声明一些构造函数,以便该类以任何方式可用
不一定。最好(但实际上“更好”取决于你与团队的协议)了解编译器为你添加的默认值,并且只在构造代码与此不同时添加构造代码。例如,在现代 C++ 中,您可以内联初始化成员,通常完全不需要默认构造函数。
评论
public virtual
虽然大部分问题已经得到解答,但我想我会分享一些关于默认构造函数和虚拟继承的想法。
该类必须始终具有一个公共(或至少受保护)构造函数,以确保子类仍然可以调用超级构造函数。尽管基类中没有什么可构造的,但这是 C++ 语法的必要条件,在概念上没有真正的区别。
我喜欢将 Java 作为接口和超类的例子。人们经常想知道为什么 Java 将抽象类和接口分成不同的语法类型。您可能已经知道,这是由于菱形继承问题,其中两个超类都具有相同的基类,因此从基类复制数据。Java 使这是不可能的,强制携带数据的类是类,而不是接口,并强制子类只继承一个类(而不是不携带数据的接口)。
我们有以下情况:
struct A {
int someData;
A(): someData(0) {}
};
struct B : public A {
virtual void modifyData() = 0;
};
struct C : public A {
virtual void alsoModifyData() = 0;
};
struct D : public B, public C {
virtual void modifyData() { someData += 10; }
virtual void alsoModifyData() { someData -= 10; }
};
当在 D 的实例上调用 modifyData 和 alsoModifyData 时,它们不会像预期的那样修改相同的变量,因为编译器将为类 B 和 C 创建两个 someData 副本。
为了解决这个问题,引入了虚拟继承的概念。这意味着编译器不仅会以暴力递归方式从超类成员中构建派生类,还会查看虚拟超类是否来自共同的祖先。非常相似,Java 有一个接口的概念,它不允许拥有数据,只允许拥有函数。
但是接口可以严格继承其他接口,从一开始就排除了菱形问题。这当然是Java与C++的不同之处。这些C++“接口”仍然允许从拥有数据的类继承,而这在java中是不可能的。
具有“虚拟继承”的想法,它表明类应该被子类化,并且在菱形继承的情况下,来自祖先的数据将被合并,这使得在“接口”上使用虚拟继承的必要性(或至少是习语)变得清晰。
我希望这个答案(虽然更概念化)对您有所帮助!
评论
Do you agree
是基于意见的。也许你应该在 codereview.stackexchange.com 上发帖?如果您发现某些指南中存在一些矛盾,请写信给作者并帮助他澄清。在设计和建筑方面没有简单的答案,答案主要来自经验。选择最适合您正在解决的特定问题的设计。 实施所有可能的方法,看看哪种方法更适合您。指导方针并不严格,它们只向您展示了一种可能的方法。这个问题太宽泛了——这么多问题。Via smart pointers or owner<T*>