0

接口(C# 术语)是指没有数据成员的抽象类。因此,这样的类只指定了子类必须实现的契约(一组方法)。我的问题是:如何在现代 C++ 中正确实现这样的类?

C++ 核心指南 [1] 鼓励使用没有数据成员的抽象类作为接口 [I.25 和 C.121]。接口通常应该完全由公共纯虚函数和默认/空虚析构函数[来自 C.121] 组成。因此我想它应该用struct关键字声明,因为它只包含公共成员。

为了通过指向抽象类的指针来使用和删除子类对象,抽象类需要一个公共的默认虚拟析构函数[C.127]。“多态类应该禁止复制”[C.67] 通过删除复制操作(复制赋值运算符、复制构造函数)来防止切片。我假设这也扩展到移动构造函数和移动赋值运算符,因为它们也可以用于切片。对于实际的克隆,抽象类可以定义一个虚clone方法。(目前尚不完全清楚应该如何完成。通过智能指针或owner<T*>指南支持库。使用的方法owner<T>对我来说毫无意义,因为示例不应该编译:派生函数仍然没有override任何作用!?)。

在 C.129 中,该示例仅使用具有虚拟继承的接口。如果我理解正确,那么使用class Impl : public Interface {...};or派生接口(也许更好:“实现”?)没有区别class Impl : public virtual Interface {...};,因为它们没有可以复制的数据。接口不存在菱形问题(和相关问题)(我认为,这是诸如 C# 之类的语言不允许/需要类的多重继承的原因)。这里的虚拟继承是为了清楚起见吗?这是好习惯吗?

总之,似乎: 一个接口应该只包含公共方法。它应该声明一个公共的默认虚拟析构函数。它应该明确删除复制分配、复制构造、移动分配和移动构造。它可以定义一个多态克隆方法。我应该使用public virtual.

还有一件事让我感到困惑:一个明显的矛盾:“抽象类通常不需要构造函数”[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
4

2 回答 2

3

你问了很多问题,但我会试一试。

接口(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++ 中,您可以内联初始化成员,通常完全不需要默认构造函数。

于 2021-01-04T14:55:29.220 回答
1

虽然大部分问题已经得到解答,但我想我会分享一些关于默认构造函数和虚拟继承的想法。

该类必须始终具有公共(或至少受保护)构造函数,以确保子类仍然可以调用超构造函数。即使在基类中没有要构造的东西,这是 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 中是不可能的。

具有“虚拟继承”的想法表明该类应该是子类,并且在菱形继承的情况下将合并来自祖先的数据,这使得在“接口”清晰。

我希望这个答案(尽管更具概念性)对您有所帮助!

于 2021-01-05T23:22:11.497 回答