在C++11中声明接口的最佳方式是什么?

50

众所周知,有些编程语言具有接口的概念。这是Java:

public interface Testable {
  void test();
}

如何以最简洁的方式和少量的代码噪音在C++(或C++11)中实现这一点?我希望得到一个不需要额外定义的解决方案(让头文件就足够了)。这是一种非常简单的方法,即使我也觉得有些问题;-)

class Testable {
public:
  virtual void test() = 0;
protected:
  Testable();
  Testable(const Testable& that);
  Testable& operator= (const Testable& that);
  virtual ~Testable();
}

这只是个开始.. 而且已经比我想要的更长了。如何改善它?也许在 std 命名空间中有一个专门为此而制作的基类?


2
接口通常不可复制,也不能直接构造,你真的要声明一个接口吗? - MerickOWA
2
为什么需要使用protected定义? - James
2
相关:如何在C++中模拟接口? - AShelly
1
@MerickOWA,所以我想现在很明显为什么我把它们设置成protected——是为了使子类能够被复制。那么=delete呢?它会被继承吗?我可以在它被删除后实现一个方法吗? - emesx
@elmes:它们是公共的,但它们与派生类定义的不同。派生类可以将它们定义为私有或受保护的。 - Cornstalks
显示剩余11条评论
5个回答

47

对于动态(运行时)多态性,我建议使用非虚拟接口(NVI)惯用语。该模式使接口保持非虚拟和公共的,析构函数是虚拟和公共的,实现是纯虚拟和私有的。

class DynamicInterface
{
public:
    // non-virtual interface
    void fun() { do_fun(); } // equivalent to "this->do_fun()"

    // enable deletion of a Derived* through a Base*
    virtual ~DynamicInterface() = default;    
private:
    // pure virtual implementation
    virtual void do_fun() = 0; 
};

class DynamicImplementation
:
    public DynamicInterface
{
private:
    virtual void do_fun() { /* implementation here */ }
};
动态多态的好处在于,在运行时,您可以将任何派生类传递到期望接口基类的指针或引用中。运行时系统会自动将“this”指针从其静态基类型向下转换为其动态派生类型,并调用相应的实现(通常通过具有指向虚函数的表来实现)。
对于静态(编译时)多态性,我建议使用“奇异递归模板模式”(Curiously Recurring Template Pattern,CRTP)。这要复杂得多,因为动态多态性的自动向下转换必须使用“static_cast”进行。这种静态转换可以在每个静态接口都继承的辅助类中定义。
template<typename Derived>
class enable_down_cast
{
private:  
        typedef enable_down_cast Base;    
public:
        Derived const* self() const
        {
                // casting "down" the inheritance hierarchy
                return static_cast<Derived const*>(this);
        }

        Derived* self()
        {
                return static_cast<Derived*>(this);
        }    
protected:
        // disable deletion of Derived* through Base*
        // enable deletion of Base* through Derived*
        ~enable_down_cast() = default; // C++11 only, use ~enable_down_cast() {} in C++98
};

那么你可以这样定义一个静态接口:

template<typename Impl>
class StaticInterface
:
    // enable static polymorphism
    public enable_down_cast< Impl >
{
private:
    // dependent name now in scope
    using enable_down_cast< Impl >::self;    
public:
    // interface
    void fun() { self()->do_fun(); }    
protected:
    // disable deletion of Derived* through Base*
    // enable deletion of Base* through Derived*
    ~StaticInterface() = default; // C++11 only, use ~IFooInterface() {} in C++98/03
};

最后,您可以创建一个从该接口继承的实现,并将其本身作为参数

class StaticImplementation
:
    public StaticInterface< StaticImplementation > 
{
private:
    // implementation
    friend class StaticInterface< StaticImplementation > ;
    void do_fun() { /* your implementation here */ }
};

这仍然允许您拥有同一接口的多个实现,但您需要在编译时知道您正在调用哪个实现。

那么何时使用哪种形式? 两种形式都可以让您重用常见接口并在接口类中注入前/后条件测试。动态多态性的优势在于您具有运行时灵活性,但您需要通过虚函数调用来付出代价(通常是通过函数指针调用,很少有机会进行内联)。静态多态性是其镜像:没有虚函数调用开销,但缺点是您需要更多的样板代码,并且您需要在编译时知道要调用哪个实现。基本上是效率/灵活性的权衡。

注意:对于编译时多态性,您还可以使用模板参数。通过CRTP惯用语法的静态接口和普通模板参数之间的区别在于,CRTP类型接口是显式的(基于成员函数),而模板接口是隐式的(基于有效表达式)。


2
我已经阅读过,当你有一些常见的代码,比如前置或后置条件时,NVI很好用。在接口声明中,NVI会带来什么改进? - emesx
这是Herb Sutter首选的方法。我不确定是否同意,因为它似乎会不必要地使事情复杂化,但他提出了一些好观点:http://www.gotw.ca/publications/mill18.htm - Mark Ransom
1
它将允许您在不需要派生类调整其代码的情况下随后向类添加先决条件或后置条件。这种灵活性是NVI的一个优点。 - TemplateRex
1
用const版本的代码来编写非const版本。嗯,这是一种重用复杂代码的工具,但在这种情况下,你只是让它变得更加复杂了。 - GManNickG
1
@vargonian 是的,使用公共虚拟接口仍然保持了多态性。但是,使用具有受保护的虚拟实现的公共非虚拟接口允许在基类中实现各种断言。例如,请参见Herb Sutter的这篇专栏文章:http://www.gotw.ca/publications/mill18.htm - TemplateRex
显示剩余3条评论

41

怎么样:

class Testable
{
public:
    virtual ~Testable() { }
    virtual void test() = 0;
}

在C++中,这并不意味着子类可复制。这只是说子类必须实现test(这正是您希望实现的接口)。您无法实例化此类,因此无需担心任何隐式构造函数,因为它们永远不能直接调用作为父接口类型。

如果您希望强制要求子类实现析构函数,您也可以使其成为纯虚函数(但仍必须在接口中实现)。

另请注意,如果您不需要多态销毁,您可以选择将析构函数设置为受保护的非虚函数。


2
@elmes:不行,需要一个定义(但是你可以像Mark B展示的那样将其留空)。 - Cornstalks
13
析构函数可以是纯虚函数,但必须提供定义(两者并不冲突)。接口可复制并不意味着对象也可复制,因此该接口并不包含该功能。实际上,在接口层次进行复制会导致切割问题,这将不是一个好主意。 - David Rodríguez - dribeas
1
@MarkB,这是一个没有上下文的可怕陈述,我甚至不确定在什么情况下这样的陈述有用。=delete适用于任何非(普通、非复制和非移动)构造函数/析构函数的成员函数。 - rubenvb
4
@Steve-o:不,它不应该是=delete=delete 表示调用它是非法的(尝试调用它会导致错误)。=0表示它是合法的,但必须由子类定义。 - Dave S
8
在C++11中,最好使用virtual ~Testable() = default;来定义虚析构函数,而不是自己定义析构函数的实现。这样做可以使代码更加规范,并且符合现代C++的最佳实践。 - Steve Folly
显示剩余5条评论

24

根据 Scott Meyers(《Effective Modern C ++》)的说法:在声明接口(或多态基类)时,您需要虚拟析构函数,以便在通过基类指针或引用访问派生类对象时,对 deletetypeid 等操作可以得到正确的结果。

virtual ~Testable() = default;
然而,用户声明的析构函数会抑制移动操作的生成,因此为了支持移动操作,您需要添加:

但是,用户声明的析构函数会抑制移动操作的生成,因此要支持移动操作,您需要添加:

Testable(Testable&&) = default; 
Testable& operator=(Testable&&) = default;

声明移动操作会禁用复制操作,您还需要:

Testable(const Testable&) = default;
Testable& operator=(const Testable&) = default;

最终结果是:

class Testable 
{
public:
    virtual ~Testable() = default; // make dtor virtual
    Testable(Testable&&) = default;  // support moving
    Testable& operator=(Testable&&) = default;
    Testable(const Testable&) = default; // support copying
    Testable& operator=(const Testable&) = default;

    virtual void test() = 0;

};

这里有另一篇有趣的文章:C++中的零规则


12
通过将单词class替换为struct,所有方法默认为公共方法,您可以节省一行代码。
没有必要将构造函数设置为受保护,因为您无法实例化具有纯虚拟方法的类。这同样适用于复制构造函数。编译器生成的默认构造函数将为空,因为您没有任何数据成员,并且完全足够为您的派生类提供服务。
你关心=运算符是正确的,因为编译器生成的运算符肯定会出错。实际上,没有人会担心它,因为将一个接口对象复制到另一个接口对象从来就没有意义;这不是普遍发生的错误。
可继承类的析构函数应始终为public和virtual,或protected和非virtual。在这种情况下,我更喜欢使用public和virtual。
最终结果仅比Java等效代码多一行:
struct Testable {
    virtual void test() = 0;
    virtual ~Testable();
};

析构函数应该始终是公共和虚拟的,或者是受保护和非虚拟的。为什么要互斥? - emesx
1
@elmes,如果析构函数是公共的,你会想使用它,并且它需要是虚拟的才能正常工作。如果它是受保护的,就没有必要使其虚拟化,因为只有派生类可以调用它,并且它们将自动调用基类析构函数。当然,将受保护的析构函数设为虚拟的也不会造成任何问题,只是没有好处。我不是制定这个规则的人,我只是在重复它。 - Mark Ransom
“使用 dtor” 是什么意思?是指通过基类指针简单地删除派生类(delete b),还是在“placement new”之后显式调用 dtor(b->~b())?有人会被这种方法所吸引吗? :) - emesx
1
@elmes,是的,我的意思是通过基类指针删除派生类。你是否需要这样做取决于你如何处理对象的生命周期。即使你从未使用过,始终假定你会需要它,这样更安全。 - Mark Ransom

7

请记住,如果您不管理指针、句柄或者类的所有数据成员都有自己的析构函数来管理任何清理工作,则“三法则”是不必要的。此外,在虚基类的情况下,因为无法直接实例化基类,如果您只想定义一个没有数据成员的接口,那么声明构造函数是不必要的...编译器默认就可以了。唯一需要保留的是虚析构函数,如果您计划在接口类型的指针上调用delete,那么这个函数就很重要。所以实际上您的接口可以非常简单,如下所示:

class Testable 
{
    public:
        virtual void test() = 0;  
        virtual ~Testable();
}

10
如果通过接口指针删除对象的可能性存在,仍然需要虚析构函数。 - Mark Ransom
3
是的,如果析构函数是公有的,那么它应该是虚拟的;如果不是虚拟的,则应将其保护。 - ChrisW
为什么将析构函数设为受保护的而不是公共的? - emesx
一个虚拟的受保护析构函数,就像你现在回答的那样,是没有用处的。它只能从派生类中调用,因为它是受保护的,所以不需要是虚拟的。 - user743382
谢谢。这就是我开始这个话题的原因——在一个地方总结所有那些细节。 - emesx

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接