为什么不鼓励在C ++中使用所有对象基础


76

Stroustrup说:“不要立即为所有类(对象类)发明一个唯一的基础。通常,如果没有很多/大多数类,您可以做得更好。” (C ++编程语言第四版,第1.3.4节)

为什么对所有事物都采用基类通常是个坏主意,何时创建一个基类有意义?


16
因为C ++不是Java ...而且您不应该尝试将其强制为Java。
AK_

10
在Stack Overflow上被问到:为什么C ++中没有基类?

26
另外,我不同意“主要基于意见”的直接投票。有非常具体的原因可以解释,原因是答案都证明了这个问题和相关的SO问题。

2
这是敏捷的“您不需要它”原则。除非您已经确定有特殊需要,否则不要这样做(直到您这样做)。
Jool 2015年

3
@AK_:您的评论中缺少“愚蠢到”。
DeadMG

Answers:


75

因为该对象将具有什么功能?在Java中,所有基类都有一个toString,一个hashCode和相等性以及一个monitor + condition变量。

  • ToString仅对调试有用。

  • hashCode仅在要将其存储在基于哈希的集合中时才有用(C ++的首选是将哈希函数作为模板参数传递给容器,或者std::unordered_*完全避免使用而是使用std::vector无格式的普通列表)。

  • 没有基础对象的相等可以在编译时得到帮助,如果它们没有相同的类型,那么它们就不能相等。在C ++中,这是一个编译时错误。

  • 最好在逐个案例的基础上显式包括Monitor和condition变量。

但是,当需要执行更多操作时,就会有一个用例。

例如,在QT中,存在一个根QObject类,它构成线程亲和力,父子所有权层次结构和信号槽机制的基础。它也强制将指针用于QObject,但是Qt中的许多类都不继承QObject,因为它们不需要信号槽(特别是某些描述的值类型)。


7
您可能忘记了Java拥有基类的主要原因:在泛型之前,集合类需要基类才能起作用。键入了所有内容(内部存储,参数,返回值)Object
Aleksandr Dubinsky

1
@AleksandrDubinsky:泛型只添加了语法糖,并没有真正改变任何东西,只是修饰。
Deduplicator 2015年

4
我会说,哈希代码,相等性和监视器支持也是Java中的设计错误。谁认为将所有对象都锁成一个好主意?
usr

1
是的,但是没人想要。您上一次需要锁定对象的时间是什么时候,无法实例化单独的锁定对象来执行此操作。这是非常罕见的,并且会给所有事情带来负担。当时,Java专家对线程安全性的理解很差,无法作为所有对象的锁和现在不赞成使用的线程安全集合的证据。线程安全是全局属性,而不是针对每个对象的属性。
usr 2015年

2
hashCode仅在要将其存储在基于散列的集合中时才有用(C ++中的首选项是std :: vector和纯无序列表)。 ”真正的反驳_hashCode不是“使用其他容器”而是指向C ++ std::unordered_map使用模板参数进行哈希处理,而不是要求元素类本身提供实现。就是说,就像C ++中所有其他好的容器和资源管理器一样,它是非侵入性的。它不会污染具有函数或数据的所有对象,以防万一以后有人在某些情况下需要它们。
underscore_d

100

因为没有所有对象共享的功能。在该接口中没有任何东西适合所有类。


10
为简单起见,+ 1是唯一的原因。
BWG

7
在我有经验的大型框架中,通用基类提供了<whatever>上下文中所需的序列化和反射基础结构。嗯 这只是导致人们将一堆废料与数据和元数据一起序列化,并使数据格式太大,太复杂以至于效率不高。
dmckee 2015年

19
@dmckee:我也认为序列化和反射并不是普遍有用的需求。
DeadMG

16
@DeadMG:“但是如果您需要保存一切,该怎么办?”
deworde

8
我不知道,您将其用引号括起来,用大写字母,别人看不见这个笑话。@MSalters:嗯,这应该很容易,它只有很少的状态,您只需指定它就可以了。我可以将我的名字写在列表上而无需进入递归循环。
deworde

25

每当您建立对象的高继承层次结构时,您都会遇到脆弱基类(Wikipedia)的问题

具有许多小的单独的(不同的,隔离的)继承层次结构可以减少遇到此问题的机会。

使所有对象成为单个庞大继承体系的一部分实际上可以确保您会遇到此问题。


6
当基类(在Java“ java.lang.Object”中)不包含任何调用其他方法的方法时,就不会发生脆弱基类问题。
Martin Rosenau

3
一个强大的有用的基类!
Mike Nakis 2015年

9
@MartinRosenau ...就像您可以在C ++中完成的一样,而无需掌握主基础类!
gbjbaanb 2015年

5
@DavorŽdralo因此,C ++对于基本功能(“ operator <<”而不是诸如“ DebugPrint”之类的明智名称)有一个愚蠢的名称,而Java对于您所编写的每个类都有一个基类的怪胎,没有例外。我想我更喜欢C ++的疣。
塞巴斯蒂安·雷德尔

4
@DavorŽdralo:函数的名称无关紧要。用语法表示图像cout.print(x).print(0.5).print("Bye\n")-它不依赖语法operator<<
MSalters

24

因为:

  1. 您不应该为不使用的东西付费。
  2. 与基于引用的类型系统相比,这些功能在基于值的类型系统中意义不大。

实施任何形式的virtual功能都会引入一个虚拟表,该表需要每个对象的空间开销,而这在很多(大多数?)情况下都是不必要的。

实施toStringnonvirtually将是毫无用处的,因为它可以返回的唯一事情是对象的地址,这是非常用户不友好,以及调用者已经访问了,不像在Java中。
同样,非虚拟的equalshashCode只能使用地址来比较对象,这再次是毫无用处的,甚至常常是完全错误的-与Java不同,对象经常在C ++中复制,因此区分对象的“身份”甚至不总是有意义或有用的。(例如int真应该不会有其他的身份比它的价值......相同值的两个整数应该是平等的。)


关于此问题以及Mike Nakis指出的脆弱的基类问题,请注意有趣的研究/建议,基本上是通过使内部的所有方法(例如,从同一类调用)为非虚拟方法,而在使用Java修复方法时保留其虚拟行为,从而在Java中进行修复外部调用 为了获得旧的/标准的行为(即到处都是虚拟的),该提案引入了一个新open关键字。我认为除了几篇论文外,它没有其他地方。
Fizz

有关该论文的更多讨论可以在lambda-the-ultimate.org/classic/message12271.html
Fizz

拥有一个共同的基类将可以测试任何 一个基类,shared_ptr<Foo>以查看它是否也是一个基类shared_ptr<Bar>(或者同样适用于其他指针类型),即使FooBar是相互不了解的无关类。给定这样的东西与“原始指针”一起使用,考虑到如何使用这些东西的历史,将是昂贵的,但是对于无论如何将要堆存储的东西,增加的成本将是最小的。
超级猫

虽然为所有内容提供通用基类可能没有帮助,但我确实认为,存在一些相当大的对象类别对通用基类会有所帮助。例如,可以以两种方式使用Java中的许多(大量,如果不是多数的话)类:作为可变数据的非共享持有者,或作为任何人都不允许修改的可共享数据的持有者。在这两种使用模式下,托管指针(引用)都用作基础数据的代理。能够为所有此类数据使用通用的托管指针类型是有帮助的。
超级猫

16

具有一个根对象限制了您可以做的事情和编译器可以做的事情,而没有太多收益。

通用根类使得创建任何容器并使用提取它们成为可能dynamic_cast,但是如果您需要任何容器,那么无需通用根类就boost::any可以做到这一点。和boost::any还支持原语-甚至可以支持小缓冲区优化,并以Java的说法将它们几乎“拆箱”。

C ++支持并繁荣发展值类型。文字和程序员编写的值类型。C ++容器有效地存储,排序,散列,使用和产生值类型。

继承,特别是那种整体继承Java样式基类暗示的继承,需要基于自由存储的“指针”或“引用”类型。您对数据的句柄/指针/引用持有一个指向类接口的指针,并且多态可以表示其他内容。

尽管这在某些情况下很有用,但是一旦您将自己与“通用基类”结合使用,就可以将整个代码库锁定在该模式的成本和负担范围内,即使它没有用。

在调用站点或使用它的代码中,几乎总是比“类型是对象”更了解类型。

如果函数很简单,则将函数编写为模板会为您提供基于鸭子类型的编译时多态性,而不会丢弃调用站点上的信息。如果函数更复杂,则可以执行类型擦除,从而可以构建并存储(在编译时)要对类型执行的统一操作(例如,序列化和反序列化),以供运行时使用(在运行时)使用。代码在其他翻译单元中。

假设您有一些想要所有内容都可序列化的库。一种方法是拥有一个基类:

struct serialization_friendly {
  virtual void write_to( my_buffer* ) const = 0;
  virtual void read_from( my_buffer const* ) = 0;
  virtual ~serialization_friendly() {}
};

现在,您编写的所有代码都可以了serialization_friendly

void serialize( my_buffer* b, serialization_friendly const* x ) {
  if (x) x->write_to(b);
}

除了不是std::vector,所以现在您需要编写每个容器。而不是从bignum库中获得的那些整数。并不是您写的那种您认为不需要序列化的类型。而不是tupleintdouble或或std::ptrdiff_t

我们采取另一种方法:

void write_to( my_buffer* b, int x ) {
  b->write_integer(x);
}    
template<class T,
  class=std::enable_if_t< void_t<
    std::declval<T const*>()->write_to( std::declval<my_buffer*>()
  > >
>
void write_to( my_buffer* b, T const* x ) {
  if (x) x->write_to(b);
}
template<class T>
void serialize( my_buffer* b, T const& t ) {
  write_to( b, t );
}

看来,它什么也不做。除了现在,我们可以write_to通过重写write_to为类型的名称空间中的自由函数或类型中的方法来扩展。

我们甚至可以编写一些类型擦除代码:

namespace details {
  struct can_serialize_pimpl {
    virtual void write_to( my_buffer* ) const = 0;
    virtual void read_from( my_buffer const* ) = 0;
    virtual ~can_serialize_pimpl() {}
  };
}
struct can_serialize {
  void write_to( my_buffer* b ) const { pImpl->write_to(b); }
  void read_from( my_buffer const* b ) { pImpl->read_from(b); }
  std::unique_ptr<details::can_serialize_pimpl> pImpl;
  template<class T> can_serialize(T&&);
};
namespace details { 
  template<class T>
  struct can_serialize : can_serialize_pimpl {
    std::decay_t<T>* t;
    void write_to( my_buffer*b ) const final override {
      serialize( b, std::forward<T>(*t) );
    }
    void read_from( my_buffer const* ) final override {
      deserialize( b, std::forward<T>(*t) );
    }
    can_serialize(T&& in):t(&in) {}
  };
}
template<class T> can_serialize::can_serialize<T>(T&&t):pImpl(
  std::make_unique<details::can_serialize<T>>( std::forward<T>(t) );
) {}

现在我们可以采用任意类型并将其自动装箱到can_serialize接口中,以便您serialize稍后通过虚拟接口进行调用。

所以:

void writer_thingy( can_serialize s );

是一个需要任何可以序列化的函数,而不是

void writer_thingy( serialization_friendly const* s );

和第一,与第二,它可以处理intstd::vector<std::vector<Bob>>自动。

编写它并不需要花费太多,特别是因为这种事情是您很少想做的事情,但是我们获得了将任何东西视为可序列化的能力,而无需基类型。

而且,我们现在可以std::vector<T>通过简单地覆盖使一流的公民变成可序列化的对象write_to( my_buffer*, std::vector<T> const& )-通过该重载,可以将其传递给can_serialize,而get的可序列std::vector化性存储在vtable中,并由进行访问.write_to

简而言之,C ++足够强大,您可以在需要时即时实现单个基类的优点,而不必在不需要时付出强制继承层次结构的代价。而且,只需要一个基础(伪造与否)的时间就很少见了。

当类型实际上是它们的标识,而您知道它们是什么时,优化机会比比皆是。数据存储在本地和连续的位置(这对于现代处理器上的缓存友好性而言非常重要),编译器可以轻松地了解给定操作的功能(而不是必须跳过不透明的虚拟方法指针,否则会导致未知代码)另一面),可以使指令进行最佳排序,并且将较少的圆钉锤入圆孔中。


8

上面有很多好的答案,很明显的事实是,您可以对所有基类进行任何操作都可以通过@ratchetfreak的答案所示的其他方式来做得更好,并且对此的评论非常重要,但是还有另一个原因,那就是避免创建继承钻石使用多重继承时。如果您在通用基类中具有任何功能,则一旦开始使用多重继承,就必须开始指定要访问的哪个变体,因为在继承链的不同路径中它可能会以不同的方式进行重载。而且基础不能是虚拟的,因为这会非常低效(要求所有对象都拥有一个虚拟表,而这在内存使用和位置方面可能会付出巨大的代价)。这将很快成为后勤噩梦。


1
解决钻石问题的一种方法是让所有通过多个路径非虚拟地派生基本类型的类型覆盖该基本类型的所有虚拟成员。如果从一开始就在语言中内置了通用基本类型,则编译器可以自动生成合法的(尽管不一定令人印象深刻)默认实现。
超级猫

5

实际上,Microsoft的早期C ++编译器和库(我知道Visual C ++是16位的)就具有这样的类CObject

但是,您必须知道,那时这个简单的C ++编译器不支持“模板”,因此不可能提供类似的类std::vector<class T>。取而代之的是,“向量”实现只能处理一种类型的类,因此存在一个与std::vector<CObject>今天相当的类。因为CObject几乎是所有类的基类(不幸的是,不是现代编译器中CString的等效string类),所以可以使用此类存储几乎所有类型的对象。

因为现代的编译器支持模板,所以不再给出“通用基类”的用例。

您必须考虑以下事实:使用这样的通用基类会花费(一点)内存和运行时-例如在调用构造函数时。因此,使用此类时存在弊端,但至少在使用现代C ++编译器时,此类类几乎没有用例。


3
那是MFC吗?[评论填充]
immibis

3
确实是MFC。面向对象设计的闪亮信标向世界展示了应该如何做。哦,等等...
gbjbaanb

4
@gbjbaanb Turbo Pascal和Turbo C ++ TObject甚至在MFC 诞生之前就已经有了。不要责怪微软的那部分设计,那段时间对几乎所有人来说都是一个好主意。
hvd

甚至在没有模板之前,尝试用C ++编写Smalltalk都会产生可怕的结果。
JDugugz

@hvd尽管如此,MFC是一个面向对象设计的比什么Borland的产生恶化的例子。
朱尔斯

5

我将提出另一个来自Java的原因。

因为至少没有一堆样板,无法为所有内容创建基类。

您可能可以在自己的类中使用它-但是您可能会发现最终要复制大量代码。例如:“我不能std::vector在这里使用它,因为它没有实现IObject-我最好创建一个新的派生类IVectorObject来做正确的事情……”。

每当您处理内置或标准库类或其他库中的类时,都是如此。

现在,如果将它内置到语言中,您最终会遇到诸如Java中的Integerint混乱之类的问题,或者对语言语法进行了很大的更改。(请记住,我认为其他一些语言在将其构建为每种类型方面也做得很好-红宝石似乎是一个更好的示例。)

还要注意,如果您的基类不是运行时多态的(即使用虚函数),则可以从使用诸如框架之类的特性中获得相同的收益。

例如,代替.toString()您可能会有以下操作:(注意:我知道您可以使用现有的库等进行整洁,这只是一个说明性示例。)

template<typename T>
struct ToStringTrait;

template<typename T> 
std::string toString(const T & t) {
  return ToStringTrait<T>::toString(t);
}

template<>
struct ToStringTrait<int> {
  std::string toString(int v) {
    return itoa(v);
  }
}

template<typename T>
struct ToStringTrait<std::vector<T>> {
  std::string toString(const std::vector<T> &v) {
    std::stringstream ss;
    ss<<"{";
    for(int i=0; i<v.size(); ++i) {
      ss<<toString(v[i]);
    }
    ss<<"}";
    return ss.str();
  }
}

3

可以说“无效”扮演了通用基类的许多角色。您可以将任何指针投射到void*。然后,您可以比较那些指针。您可以static_cast返回原始班级。

然而,你有什么不能void,你可以做的Object是使用RTTI找出你真的有什么类型的对象。最终,这取决于C ++中并非所有对象都具有RTTI,并且实际上可能有零宽度的对象。


1
仅零宽度的基类子对象,而不是普通的子类。
Deduplicator

@Deduplicator通过更新,C ++ 17增加了[[no_unique_address]],编译器可以使用,将成员子对象的宽度设置为零。
underscore_d

1
@underscore_d您的意思是计划用于C ++ 20,[[no_unique_address]]它将允许编译器转换为EBO成员变量。
重复数据删除器

@Deduplicator糟糕,是的。我已经开始使用C ++ 17,但是我想我仍然认为它比实际更先进!
underscore_d

2

Java采用了不存在未定义行为的设计哲学。代码如:

Cat felix = GetCat();
Woofer Rover = (Woofer)felix;
Rover.woof();

将测试是否felix拥有Cat实现接口的子类型Woofer; 如果这样做,它将执行强制转换和调用woof(),如果不执行,将抛出异常。 代码的行为是完全定义是否felix工具Woofer与否

C ++秉承这样的理念:如果程序不应该尝试某些操作,则尝试执行该操作也不会影响生成的代码,并且计算机不应浪费时间尝试在“应该”情况下限制行为。永远不会出现。在C ++中,添加适当的间接运算符以将a强制转换*Cat为a *Woofer,当强制转换合法时,代码将产生已定义的行为,但如果不是,则代码将产生未定义的行为

拥有通用的事物基本类型可以在该基本类型的派生对象中验证强制类型转换,并进行尝试转换操作,但是验证强制类型转换比简单地假设它们是合法的并且希望不会发生任何不良情况要昂贵得多。C ++的哲学是这样的验证需要“支付您(通常)不需要的东西”。

与C ++有关的另一个问题,但对于新语言而言并不是问题,如果多个程序员各自创建一个公共基数,从中派生自己的类,然后编写代码以使用该公共基类的东西,这样的代码将无法与使用其他基类的程序员开发的对象一起使用。如果一种新语言要求所有堆对象都具有通用的标头格式,并且从未允许不允许使用的堆对象,那么一种需要使用此类标头引用堆对象的方法将接受对任何堆对象的引用。可以创造。

我个人认为,在语言/框架中,具有一种常见的询问对象“您是否可以转换为X型”的功能是一项非常重要的功能,但是如果从一开始就没有将这种功能内置到语言中,则很难以后添加。我个人认为,应该首先将这样的基类添加到标准库中,强烈建议所有将被多态使用的对象都应从该基类继承。让程序员各自实现自己的“基本类型”将使在不同人员的代码之间传递对象更加困难,但是拥有许多程序员可以继承的通用基本类型将使其更加容易。

附录

使用模板,可以定义一个“任意对象持有人”,并询问其中包含的对象的类型。Boost程序包包含这样的东西any。因此,即使C ++没有标准的“对任何内容进行类型检查的引用”类型,也可以创建一个类型。这并不能解决语言标准中没有的问题,即不同程序员的实现之间不兼容,但是它确实解释了C ++是如何在没有基础类型的情况下获得的:通过使创建成为可能像一个人的东西。


C ++JavaC#中,该强制转换在编译时失败。
milleniumbug

1
@milleniumbug:如果Woofer是接口并且Cat是可继承的,则强制转换将是合法的,因为可能存在(如果不是现在,则可能是将来)WoofingCatCat和实现的a Woofer。请注意,在Java编译/链接模型下,创建WoofingCat不需要访问Catnor 的源代码Woofer
2015年

3
C ++具有dynamic_cast,可以正确处理从a Cat到a的Woofer转换,并会回答“您是否可以转换为X类型”的问题。C ++将允许您强制转换,原因,嘿,也许您实际上知道自己在做什么,但是如果这不是您的真正意图,它也可以帮助您。
Rob K

2
@RobK:您当然对语法是正确的;米卡帕。我已经阅读了更多有关dynamic_cast的内容,从某种意义上讲,现代C ++似乎使所有多态对象都从基“多态对象”基类派生而来,该基类具有识别对象类型(通常是vtable)所需的任何字段指针,尽管这是实现细节)。C ++不会以这种方式描述多态类,但是如果传递指向的指针,dynamic_cast它将指向已定义的行为(如果它指向一个多态对象),如果未指向未定义的行为,则从语义角度来看……
supercat

2
...所有多态对象以相同的布局存储一些信息,并且所有对象都支持非多态对象不支持的行为;在我看来,这意味着无论它们的语言定义是否使用此类术语,它们的行为都好像是源自一个共同的基础。
超级猫

1

实际上,对于所有以特定方式(主要是分配了堆的对象)运行的对象,Symbian C ++确实具有通用基类CBase。它提供了一个虚拟析构函数,将构造时的类内存清零,并隐藏了副本构造函数。

其背后的理由是,它是一种用于嵌入式系统和C ++编译器的语言,而规范在10年前确实非常糟糕。

不是所有的类都继承自此,只有一些。

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.