C++接口是否必须遵守五个规则?

23

在定义接口类时,声明实例化方法的正确方式是什么?

显而易见,抽象基类需要具有虚析构函数。但是,接下来会出现以下编译警告:“‘InterfaceClass’定义了一个非默认析构函数,但没有定义复制构造函数、复制赋值运算符、移动构造函数或移动赋值运算符”,这就是‘五法则’。

我理解为什么通常应该遵守‘五法则’,但对于抽象基类或接口是否仍然适用呢?

我的实现如下:

class InterfaceClass
{
    //  == INSTANTIATION ==
  protected:
    //  -- Constructors --
    InterfaceClass()                      = default;
    InterfaceClass(const InterfaceClass&) = default;
    InterfaceClass(InterfaceClass&&)      = default;

  public:
    //  -- Destructors --
    virtual ~InterfaceClass() = 0;


    //  == OPERATORS ==
  protected:
    //  -- Assignment --
    InterfaceClass& operator=(const InterfaceClass&) = default;
    InterfaceClass& operator=(InterfaceClass&&)      = default;


    //  == METHODS ==
  public:
    // Some pure interface methods here...
};



//  == INSTANTIATION ==
//  -- Destructors --
InterfaceClass::~InterfaceClass()
{
}

这是否正确?这些方法应该是= delete吗?有没有办法声明析构函数为虚拟纯函数,同时仍然保持默认值?

即使我将析构函数声明为:virtual ~InterfaceClass() = default;,如果我不明确地将其他四个函数设置为默认值,则会收到相同的编译器警告。

Tl; dr:满足接口类的“五法则”正确方式是什么,因为用户必须定义虚拟析构函数。

感谢您的时间和帮助!


2
“接下来会给出以下编译警告” - 是由哪个编译器/版本发出的? - Tony Delroy
9
一个基类的析构函数应该是受保护的(防止多态删除)或公共和虚拟的(使多态删除安全)。你目前使用的保护和虚拟组合相当怪异。 - Ben Voigt
2
非拥有接口不应该拥有,这就是零规则。http://en.cppreference.com/w/cpp/language/rule_of_three - Mikhail
如果你的类除了纯虚函数之外没有其他的方法,但你仍然希望它是抽象的。或者只是为了保持一致性,因为纯虚函数意味着“必须被重写”。但是一个纯虚析构函数仍然需要一个离线定义:struct C { ~C()=0; }; C::~C() = default; 因为它将被派生类的析构函数调用。未定义的纯虚析构函数将导致链接错误。 - Oktalist
我的建议是遵循C++核心准则,特别是C.67C.130C.ctor (C.20, C.21, C.22)。 - undefined
显示剩余6条评论
4个回答

8

这是正确的吗?这些方法应该改为=delete吗?

您的代码看起来是正确的。在尝试以多态方式复制派生类时,需要将特殊的副本/移动成员函数定义为默认和受保护的。请考虑以下附加代码:

#include <iostream>

class ImplementationClass : public InterfaceClass
{
  private:
    int data;
  public:
    ImplementationClass()
    {
        data=0;    
    };
    ImplementationClass(int p_data)
    {
        data=p_data;
    };
    void print()
    {
        std::cout<<data<<std::endl;
    };
};


int main()
{
    ImplementationClass A{1};
    ImplementationClass B{2};
    InterfaceClass *A_p = &A;
    InterfaceClass *B_p = &B;
    // polymorphic copy
    *B_p=*A_p;
    B.print();
    // regular copy
    B=A;
    B.print();
    return 0;
}
   

考虑在InterfaceClass中定义特殊的复制/移动成员函数的4种选项。

  1. 复制/移动成员函数 = delete

如果在InterfaceClass中删除特殊的复制/移动成员函数,则会防止多态复制:

*B_p = *A_p; // would not compile, copy is deleted in InterfaceClass

这很好,因为多态复制将无法复制派生类中的数据成员。

另一方面,您还会防止普通复制,因为编译器将无法在没有基类复制赋值运算符的情况下隐式生成复制赋值运算符:

B = A; //  would not compile either, copy assignment is deleted in ImplementationClass 
  1. 将特殊成员函数的复制/移动权限设为public

通过将特殊成员函数的复制/移动权限默认设为public(或者不定义复制/移动成员函数),可以让普通复制正常工作:

B = A; //will compile and work correctly

但是多态复制将被启用并导致切片:

*B_p = *A_p; // will compile but will not copy the extra data members in the derived class. 
  1. 未定义复制/移动特殊成员函数

如果未定义移动和复制的特殊成员函数,那么在复制方面的行为类似于2:编译器将隐式生成已弃用的复制特殊成员函数(导致多态切片)。但是,在这种情况下,编译器不会隐式生成移动特殊成员函数,因此会在可以使用移动的地方使用复制。

  1. 保护的复制/移动成员函数(您的提案)

通过将特殊的复制/移动成员函数设置为默认和受保护的,就像您的示例一样,可以防止多态复制,否则可能会导致切片:

*B_p = *A_p; // will not compile, copy is protected in InterfaceClass

然而,编译器将会显式地为InterfaceClass生成一个默认的复制赋值运算符,而ImplementationClass将能够隐式地生成其复制赋值运算符:

B = A; //will compile and work correctly

所以你的方法似乎是最好、最安全的选择。

关于1和4:另一方面,你也会阻止正常的复制,因为编译器将无法在没有基类复制赋值运算符的情况下隐式生成复制赋值运算符(...) 那为什么这不好呢?为了避免切片的风险,请遵循C.67:多态类应该禁止公共复制/移动。如果你想要“克隆”一个多态类型,添加一个克隆函数(C.130)。克隆API可以由派生类支持。 - undefined

1

对于析构函数,如果您希望将其同时设置为纯虚函数和默认函数,则可以在实现中将其设置为默认值:

class InterfaceClass
{
    //  -- Destructors --
    virtual ~InterfaceClass() = 0;
};

InterfaceClass::~InterfaceClass() = default;

即使析构函数是默认的或为空,也没有太大区别。

现在回答你的其余问题。

通常情况下,应该将复制构造函数和赋值运算符默认设置。这样,它们不会阻止在派生类中创建默认赋值运算符和复制构造函数。默认实现是正确的,因为没有不变量需要复制。

因此,如果您想轻松实现Clone方法,则删除复制构造函数会有影响:

class InterfaceClass
{
    virtual  InterfaceClass* Clone() = 0;
    virtual ~InterfaceClass() = 0;
};

class ImplementationClass : public InterfaceClass
{
public:
    // This will not work if base copy constructor is deleted
    ImplementationClass(const ImplementationClass&) = default; 
    // Writing copy constructor manually may be cumbersome and hard to maintain,
    // if class has a lot of members

    virtual  ImplementationClass* Clone() override
    {
        return new ImplementationClass(*this); // Calls copy constructor
    }
};

请注意,默认的复制/移动构造函数的实现不会被意外使用 - 因为抽象基类的实例无法创建。因此,您将始终复制派生类,并且它们应该定义是否允许复制。

但是,对于某些类来说,完全复制可能没有意义,在这种情况下,在基类中禁止复制/分配可能是明智的。

Tl;dr:这取决于情况,但最好将它们保留为默认值。


0
通常来说,如果三个特殊函数中的任何一个没有平凡/默认定义,则应该定义其他两个。如果这两个特殊移动函数没有平凡/默认定义,则需要处理所有5个函数。 在接口有nop定义的析构函数的情况下,您无需定义其他函数-除非出于其他原因需要。 即使是非平凡的定义也不需要重新定义其他函数;只有在涉及某种资源管理(例如内存、文件、io、同步等)时,才需要定义大的3(5)。

仅定义大三是确实安全的,但移动语义特殊成员不会被隐式生成,并且您将强制对象在可以移动时进行复制。nop defined dtor 是什么意思?非公共的?这是否解决了多态复制中的切片问题?如果我确定它是安全的,我很乐意在接口中省略定义特殊成员函数。 - Gianni
@Gianni nop 意味着无操作。移动并不总是比复制更便宜或不同。移动只是一种优化或在不可复制对象上进行所有权转移的手段。 - Red.Wave

0
最近我遇到了一个问题,尝试定义一个抽象的观察者类。Clang-tidy(版本14.0.6)给出了一个非常类似的警告,标记为[cppcoreguidelines-special-member-functions]。起初,我尝试定义拷贝/移动构造函数和拷贝/移动赋值运算符,并标记为= delete(也尝试过= default)。最终,我遇到了一个编译错误,因为具体的观察者确实需要一个常规构造函数来获取一个指向主题的指针以注册通知。
然而,经过反思,我认为没有抽象类真正需要拷贝/移动构造函数(因此也不需要遵循三五法则),因为按照定义,抽象类无法实例化。因此,对于抽象类发出“特殊成员函数”警告的clang-tidy或任何其他编译器/分析器都是错误的。

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