C ++中安全接口的模式是什么


22

注意:以下是C ++ 03代码,但是我们希望在未来两年内升级到C ++ 11,因此我们必须牢记这一点。

我正在编写有关如何用C ++编写抽象接口的指南(针对新手)。我已经阅读了有关Sutter的两篇文章,在互联网上搜索了示例和答案,并进行了一些测试。

此代码不得编译!

void foo(SomeInterface & a, SomeInterface & b)
{
   SomeInterface c ;               // must not be default-constructible
   SomeInterface d(a);             // must not be copy-constructible
   a = b ;                         // must not be assignable
}

上面的所有行为都在切片时发现了问题的根源:抽象接口(或层次结构中的非叶子类)不应构造,不可复制/可分配,即使派生类可以。

0th解决方案:基本界面

class VirtuallyDestructible
{
   public :
      virtual ~VirtuallyDestructible() {}
} ;

这个解决方案是简单的,并且有些天真:它克服了我们的所有限制:它可以是默认构造的,复制构造的和复制分配的(我甚至不确定移动构造函数和赋值,但是我还有2年的时间来弄清楚出来)。

  1. 我们不能将析构函数声明为纯虚函数,因为我们需要保持其内联,并且某些编译器不会使用内联空主体来消化纯虚函数。
  2. 是的,此类的唯一要点是使实现者实际上可破坏,这是极少数情况。
  3. 即使我们有一个额外的虚拟纯方法(大多数情况下),该类仍然是可复制分配的。

所以不行...

第一个解决方案:boost :: noncopyable

class VirtuallyDestructible : boost::noncopyable
{
   public :
      virtual ~VirtuallyDestructible() {}
} ;

该解决方案是最好的,因为它是纯净,清晰和C ++的(无宏)

问题在于它仍然不适用于该特定接口,因为VirtuallyConstructible仍可以默认构造

  1. 我们不能将析构函数声明为纯虚函数,因为我们需要保持其内联,并且某些编译器不会消化它。
  2. 是的,此类的唯一要点是使实现者实际上可破坏,这是极少数情况。

另一个问题是,实现不可复制接口的类如果必须具有这些方法,则必须显式声明/定义复制构造函数和赋值运算符(在我们的代码中,我们仍然可以通过客户端访问这些值类)接口)。

这违反了零规则,这就是我们要去的地方:如果默认实现可以,那么我们应该可以使用它。

第二个解决方案:保护它们!

class MyInterface
{
   public :
      virtual ~MyInterface() {}

   protected :
      // With C++11, these methods would be "= default"
      MyInterface() {}
      MyInterface(const MyInterface & ) {}
      MyInterface & operator = (const MyInterface & ) { return *this ; }
} ;

这种模式遵循我们的技术限制(至少在用户代码中):MyInterface不能默认构造,不能复制构造,也不能复制分配。

而且,它对实现类没有任何人为的约束,然后可以自由遵循零规则,甚至可以在C ++ 11/14中将一些构造函数/运算符声明为“ = default”而没有问题。

现在,这非常冗长,一种替代方法是使用宏,例如:

class MyInterface
{
   public :
      virtual ~MyInterface() {}

   protected :
      DECLARE_AS_NON_SLICEABLE(MyInterface) ;
} ;

受保护者必须保留在宏之外(因为它没有作用域)。

正确地“命名空间”(即,以公司或产品的名称为前缀),宏应该是无害的。

这样做的好处是,代码只在一个源中分解,而不是复制粘贴到所有接口中。如果将来以相同的方式显式禁用move-constructor和move-assignment,那么代码中的更改将非常小。

结论

  • 我是否希望对代码进行保护以防止在接口中切片?(我相信我不是,但是一个人永远都不知道...)
  • 在上述解决方案中最好的解决方案是什么?
  • 还有其他更好的解决方案吗?

请记住,这是一种模式,将作为新手(除其他外)的指南,因此,诸如:“每种情况都应实现”的解决方案不是可行的解决方案。

赏金和结果

我将赏金授予coredump,是因为花在回答问题上的时间以及答案的相关性。

我对这个问题的解决方法可能是这样的:

class MyInterface
{
   DECLARE_CLASS_AS_INTERFACE(MyInterface) ;

   public :
      // the virtual methods
} ;

...具有以下宏:

#define DECLARE_CLASS_AS_INTERFACE(ClassName)                                \
   public :                                                                  \
      virtual ~ClassName() {}                                                \
   protected :                                                               \
      ClassName() {}                                                         \
      ClassName(const ClassName & ) {}                                       \
      ClassName & operator = (const ClassName & ) { return *this ; }         \
   private :

对于我的问题,这是一个可行的解决方案,其原因如下:

  • 此类无法实例化(构造函数受到保护)
  • 该类几乎可以被摧毁
  • 可以在不对继承类施加过多约束的情况下继承此类(例如,继承类默认情况下可以复制)
  • 宏的使用意味着接口“声明”易于识别(和搜索),并且其代码集中在一个位置,使其易于修改(适当的前缀名称将消除不希望的名称冲突)

请注意,其他答案也提供了宝贵的见解。谢谢所有尝试的人。

请注意,我想我仍然可以在这个问题上另外悬赏,并且我重视启发性的答案,以至于我应该看到一个答案,我将打开一个赏金以将其分配给该答案。


5
您不能在界面中简单地使用纯虚函数吗?virtual void bar() = 0;例如?这样可以防止您的界面被实例化。
Morwenn 2014年

@Morwenn:如问题中所述,这将解决99%的案件(如果可能,我的目标是100%)。即使我们选择忽略丢失的1%,也不会解决分配切片问题。因此,不,这不是一个好的解决方案。
paercebal 2014年

@Morwenn:认真吗?... :-D ...我首先在StackOverflow上写下了这个问题,然后在提交之前改变了主意。您认为我应该在这里删除它,然后提交给SO吗?
paercebal 2014年

如果我是对的,您所需要的只是virtual ~VirtuallyDestructible() = 0接口类的虚拟继承(仅限抽象成员)。您可能会忽略VirtuallyDestructible。
DieterLücking2014年

5
@paercebal:如果编译器在纯虚拟类上阻塞,则它属于垃圾桶。根据定义,真正的接口是纯虚拟的。
没人

Answers:


13

在C ++中创建接口的规范方法是为其提供纯虚拟析构函数。这样可以确保

  • 不能创建接口类本身的实例,因为C ++不允许您创建抽象类的实例。这可以解决不可构造的要求(默认值和副本)。
  • 调用delete指向该接口的指针是正确的事情:它为该实例调用最派生类的析构函数。

仅仅拥有一个纯虚拟析构函数并不能阻止对接口的引用赋值。如果还需要使该操作失败,则必须在接口中添加一个受保护的赋值运算符。

任何C ++编译器都应该能够处理这样的类/接口(全部在一个头文件中):

class MyInterface {
public:
  virtual ~MyInterface() = 0;
protected:
  MyInterface& operator=(const MyInterface&) { return *this; } // or = default for C++14
};

inline MyInterface::~MyInterface() {}

如果您确实有对此感到cho恼的编译器(这意味着它必须在C ++ 98之前),那么您的选项2(具有受保护的构造函数)是个不错的选择。

使用boost::noncopyable不建议这个任务,因为它发出的信息,即所有层次结构中的类应该是不可复制的,因此它可以创造更多的有经验的开发谁不熟悉你的意图使用像这样的困惑。


If you need [prevent assignment] to fail as well, then you must add a protected assignment operator to your interface.:这是我问题的根源。需要接口支持分配的情况确实很少见。另一方面,我通过引用传递接口的情况(不可接受NULL的情况)因此要避免进行无操作或切片的情况要多得多。
paercebal 2014年

由于永远不应该调用赋值运算符,因此为什么要给它定义?顺便说一句,为什么不这样做呢private?另外,您可能要处理default-和copy-ctor。
Deduplicator

5

我是偏执狂...

  • 我是否希望对代码进行保护以防止在接口中切片?(我相信我不是,但是一个人永远都不知道...)

这不是风险管理问题吗?

  • 您是否担心可能会引入与切片相关的错误?
  • 您认为它可能会被忽略并引发无法恢复的错误吗?
  • 您愿意在多大程度上避免切片?

最佳解决方案

  • 在上述解决方案中最好的解决方案是什么?

您的第二个解决方案(“使它们受到保护”)看起来不错,但是请记住,我不是C ++专家。
至少,我的编译器(g ++)似乎将无效用法正确地报告为错误。

现在,您需要宏吗?我会说“是”,因为即使您没有说出编写本指南的目的是什么,但我想这是为了在您的产品代码中实施一组特定的最佳实践。

为此,宏可以帮助检测人们何时有效地应用了模式:提交的基本过滤器可以告诉您是否使用了宏:

  • 如果使用了该样式,则很可能会应用该样式,更重要的是,可以正确应用该样式(只需检查是否存在 protected关键字),
  • 如果不使用,则可以尝试调查为什么不使用它。

如果没有宏,则必须检查该模式在所有情况下是否都是必需的并已实现良好。

更好的解决方案

  • 还有其他更好的解决方案吗?

用C ++进行切片仅是该语言的独特之处。由于您正在编写指南(特别是针对新手),因此您应该专注于教学,而不仅仅是枚举“编码规则”。您必须确保您真正说明了切片的方式和原因,以及示例和练习(不要重新发明轮子,不要从书本和教程中获得启发)。

例如,练习的标题可以是“ C ++中安全接口的模式什么?”

因此,最好的办法是确保C ++开发人员了解切片发生时的情况。我坚信,如果这样做的话,即使没有正式执行该特定模式,它们也不会在代码中犯太多的错误(但是您仍然可以执行它,编译器警告是不错的选择)。

关于编译器

你说 :

我无法选择该产品的编译器,

人们常常会说“我无权做[X]”“我不应该做[Y] ...”,……是因为他们认为这是不可能的,而不是因为他们尝试或询问。

就技术问题发表意见可能是您工作描述的一部分;如果您真的认为编译器是问题域的理想选择(或唯一选择),请使用它。但是您还说过:“具有内联实现的纯虚拟析构函数并不是我所见过的最糟糕的瓶颈”;据我了解,编译器是如此特殊,以至于知识渊博的C ++开发人员都难以使用它:您的旧版/内部编译器现在承担了技术责任,您有权(有义务)与其他开发人员和管理人员讨论该问题。 。

尝试评估保留编译器的成本与使用另一个编译器的成本:

  1. 当前的编译器为您带来了其他人无法比拟的什么?
  2. 您的产品代码是否可以使用其他编译器轻松编译?为什么不呢?

我不知道您的情况,实际上您可能有充分的理由要绑定到特定的编译器。
但是在这种情况下,这仅仅是一种惯性,如果您或您的同事没有报告生产率或技术债务问题,情况将永远不会改变。


Am I paranoid...:“使您的界面易于正确使用,而难以错误使用”。当有人报告错误地使用了我的一种静态方法时,我已经尝到了这一特殊原理。产生的错误似乎无关紧要,工程师花了多个小时才能找到源。此“接口错误”与将接口引用分配给另一个接口引用相同。所以,是的,我想避免这种错误。同样,在C ++中,哲学是在编译时捕获尽可能多的东西,而语言赋予了我们这种力量,因此我们顺其自然。
paercebal 2014年

Best solution: 我同意。。。Better solution:这是一个很棒的答案。我将继续努力...现在,关于Pure virtual classes:这是什么?一个C ++抽象接口?(没有状态的类,只有纯虚方法?)。这个“纯虚拟类”如何保护我免于切片?(纯虚拟方法将不会编译实例化,但是IIRC也会执行复制分配和移动分配)。
paercebal 2014年

About the compiler:我们同意,但是我们的编译器不在我的责任范围内(不是因为它使我免受过时的评论……:-p ...)。我不愿透露细节(我希望可以),但这与内部原因(例如测试套件)和外部原因(例如,客户端与我们的库链接)有关。最后,更改编译器版本(甚至修补它)不是一件容易的事。更不用说用最新的gcc替换一个损坏的编译器了。
paercebal 2014年

@paercebal感谢您的评论;关于纯虚拟类,您是对的,它不能解决您的所有约束(我将删除此部分)。我了解“接口错误”部分,以及在编译时捕获错误有何用处:但是您询问自己是否偏执,我认为合理的方法是在对静态检查的需要与发生错误的可能性之间取得平衡。祝编译器好运:)
coredump

1
我不喜欢这些宏,特别是因为该指南还针对初级人员。我经常看到有人得到这样的“方便”工具,使他们盲目地应用它们,却从不了解实际情况。他们开始相信宏所做的事情一定是最复杂的事情,因为老板认为这样做对他们来说太困难了。而且由于该宏仅存在于您的公司中,因此他们甚至无法在网络上对其进行搜索,而对于有记录的准则,可以声明哪些成员函数以及为什么声明它们。
5gon12eder 2015年

2

当向用户公开运行时多态接口时,切片的问题是一个,但肯定不是唯一的。想想空指针,内存管理,共享数据。在所有情况下都不容易解决所有这些问题(智能指针很棒,但即使它们不是灵丹妙药)。实际上,从您的帖子看来,您似乎并没有尝试解决切片问题,而是通过不允许用户进行复制来回避它。要提供一种解决切片问题的方法,您要做的就是添加一个虚拟克隆成员函数。我认为公开运行时多态接口的更深层问题是,您迫使用户处理引用语义,而引用语义比值语义更难推理。

我所知道的避免C ++中这些问题的最好方法是使用类型擦除。这是一种在常规类接口后面隐藏运行时多态接口的技术。然后,这个普通的类接口具有值语义,并照顾屏幕后面的所有多态“混乱”。std::function是类型擦除的典型示例。

有关为什么向用户公开继承的原因不好以及类型擦除如何解决该问题的重要解释,请参见Sean Parent的以下演示文稿:

继承是邪恶的基础(简短版本)

重视基于语义和概念的多态性(长版;易于理解,但声音不佳)


0

你不是偏执狂 我作为C ++程序员的第一个专业任务导致了切片和崩溃。我认识别人 对此没有很多好的解决方案。

考虑到编译器的限制,选项2是最好的。我建议您使用一个脚本或工具来自动生成代码,而不是创建一个宏,您的新程序员将这些宏视为怪异和神秘的。如果您的新员工将使用IDE,则应该能够创建“新的MYCOMPANY接口”工具,该工具将询问接口名称,并创建所需的结构。

如果您的程序员使用的是命令行,则使用可用的任何脚本语言来创建NewMyCompanyInterface脚本以生成代码。

过去,我曾将这种方法用于常见的代码模式(接口,状态机等)。令人高兴的是,新程序员可以阅读并轻松理解输出,并在需要无法生成的内容时重新生成所需的代码。

宏和其他元编程方法倾向于混淆正在发生的事情,而新程序员则不会在“幕后”中了解正在发生的事情。当他们不得不打破模式时,他们就像以前一样迷路。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.