【问题标题】:Defining interfaces (abstract classes without members) in C++在 C++ 中定义接口(没有成员的抽象类)
【发布时间】:2021-04-02 05:32:17
【问题描述】:

接口(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 {...};class Impl : public virtual Interface {...}; 派生接口(也许更好:“实现”?)没有区别,因为它们没有可以复制的数据。接口不存在菱形问题(和相关问题)(我认为,这是诸如 C# 之类的语言不允许/需要类的多重继承的原因)。这里的虚拟继承是为了清楚起见吗?这是个好习惯吗?

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

还有一件事让我感到困惑: 一个明显的矛盾:“抽象类通常不需要构造函数”[C.126]。但是,如果通过删除所有复制操作(遵循 [C.67])来实现五规则,则该类不再具有默认构造函数。因此子类永远不能被实例化(因为子类构造函数调用基类构造函数),因此抽象基类总是需要声明一个默认构造函数?!我误解了什么吗? p>

下面是一个例子。 您是否同意这种方式来定义和使用没有成员(接口)的抽象类?

// 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

【问题讨论】:

  • 最佳实践是意见。虽然我同意我在核心指南中读到的大部分内容,但我并不完全同意。不过我绝对可以推荐它。
  • 您可以尝试在这里获得评论:codereview.stackexchange.com。虽然我听说他们相当严格,所以一定要阅读他们的规则,这样你的问题就会成为主题
  • 感谢 cmets!如果你问我,C++ 允许 ~10 种正确和 ~100 种看起来正确但内心深处破碎的方法来解决任何问题(非常可悲)这一事实,并没有提出诸如“你如何定义抽象类?”之类的基本问题。基于意见。
  • 您可能会问:“这是否从根本上破坏了?”这不是关于意见,但你确实征求意见。有时这只是措辞的问题......
  • Do you agree 是基于意见的。也许您应该改为在 codereview.stackexchange.com 上发帖?如果您在某些指南中发现一些矛盾,请写信给作者并帮助他澄清。在设计和建筑工程方面没有简单的答案,答案大多来自经验。选择最适合您正在解决的特定问题的设计。 Via smart pointers or owner&lt;T*&gt; 实施所有可能的方式,看看哪种方式更适合您。指南并不严格,它们只向您展示一种可能的方式。这个问题太宽泛了——问题太多了。

标签: c++ abstract-class cpp-core-guidelines rule-of-five


【解决方案1】:

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

接口(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&lt;T&gt; 是一个有用的抽象,但它应该只在极端情况下使用(当你的代码库强制你使用原始指针时)。

接口应该只包含公共方法。它应该声明[...]。这应该 [...]。它应该使用 public virtual 派生出来。

不要试图在 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++ 的基类中,我有时会添加公共的非虚拟函数实现,以及 [受保护的] 虚拟实现。” 1) 这似乎暗示这不能在 C# 中完成,但可以通过抽象类轻松完成。 2)这只是一种设计模式;它被称为模板方法模式,它实际上与关于接口的问题无关。也许应该从答案中删除这个细节。
  • @Nerdizzle,我的观点是,在 C++ 中添加不模拟 c# 接口的抽象基类是有意义的(模板方法模式是添加到基类的非虚拟代码的一个很好的例子类)。
【解决方案2】:

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

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

具有“虚拟继承”的想法,这表明类应该被子类化,并且在菱形继承的情况下要合并来自祖先的数据,这使得使用虚拟的必要性(或至少成语)清除“接口”上的继承。

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

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2017-06-18
    • 1970-01-01
    • 2011-07-30
    • 2016-05-08
    • 2015-10-03
    • 1970-01-01
    • 2016-02-14
    相关资源
    最近更新 更多