C++中的容器协变性

23

我知道C++不像Java或C#一样支持协变容器元素。因此,以下代码可能会导致未定义行为:

#include <vector>
struct A {};
struct B : A {};
std::vector<B*> test;
std::vector<A*>* foo = reinterpret_cast<std::vector<A*>*>(&test);

毫不意外的是,当我建议这个解决方案给另一个问题时,我收到了负评。

但是C++标准中的哪一部分确切地告诉我这会导致未定义行为?保证std::vector<A*>std::vector<B*>将它们的指针存储在连续的内存块中。保证sizeof(A*) == sizeof(B*)。最后,A* a = new B是完全合法的。

那么除了风格之外,我召唤了标准中的哪些恶灵呢?


1
使用reinterpret_cast<>()之后,没有任何定义。它可能有效,但您的条件列表非常短。我会添加另外几个前提条件。sizeof(A) == sizeof(B); A或B都不能包含任何类型的虚函数。A、B或数组中放置的任何后代都不能使用多重继承。 - Martin York
7
非 C++ 特定的答案是它不具备类型安全性。如果您将一个 A 添加到 foo 中,它就会处于无效状态,因为 foo 保证所有元素都是类型 B。C# 也不支持这一点。C# 只支持在接口和委托中用于安全方式使用泛型参数(仅限输入或输出)。Java 支持它,因为它添加了运行时检查,并在对象基类上内部工作。 - CodesInChaos
这个问题看起来与https://dev59.com/VEfRa4cB1Zd3GeqP9oS3相似。 - Nekuromento
1
@CodeInChaos:你为什么这样想?整个想法都是有缺陷的,会破坏类型系统。一个简单的测试在这里。泛型允许协变和逆变的函数参数,根据你想做的事情,比如像 void append( Vector<? super Derived> v ) { v.add( new Derived() ); }void extract( Vector<? extends Base> v ) { Base b = v.get(0); },但它不会允许引用的转换。 - David Rodríguez - dribeas
1
函数中允许使用的原因是泛型只是编译时类型检查,而泛型类型从二进制文件中被 擦除 。当在函数中使用协变/逆变参数时,编译器可以检查函数内部的操作是否破坏了接口的要求。在调用方面,它可以检查同样的要求,然后传递引用,其永远是一个非泛型的 Vector(包含 Object)。另一方面,如果允许转换,则可以将 Base 对象添加到 Derived 的容器中。 - David Rodríguez - dribeas
显示剩余6条评论
4个回答

19
这里违反的规则在C++03 3.10/15 [basic.lval]中有记录,也被非正式地称为“严格别名规则”。
如果程序试图通过不是以下类型之一的lvalue来访问对象的存储值,则行为未定义:
- 对象的动态类型 - 对象的动态类型的cv限定版本 - 与对象的动态类型对应的有符号或无符号类型 - 与对象的动态类型的cv限定版本对应的有符号或无符号类型 - 包含上述类型之一在其成员中(包括子聚合或包含的联合体的成员,递归地)的聚合或联合类型 - 对象的动态类型的(可能带有cv限定符的)基类类型 - char或unsigned char类型
简而言之,给定一个对象,只允许通过具有列表中一种类型的表达式访问该对象。对于没有基类的类类型对象,如std :: vector ,您基本上受到第一、第二和最后一个项目中命名的类型的限制。
std :: vector 和std :: vector 是完全不相关的类型,您不能使用类型std :: vector 作为std :: vector 。如果违反此规则,编译器可能会执行各种操作,包括:
- 在其中一个上执行不同的优化,或 - 以不同的方式布局一个的内部成员,或 - 执行假设std :: vector *永远不能引用与std :: vector *相同的对象的优化 - 使用运行时检查以确保您未违反严格别名规则
即使只有一个Base *[N],您也不能将该数组用作Derived *[N](尽管在这种情况下,使用可能会更安全,其中“更安全”意味着“仍然未定义但不太可能让您陷入麻烦)。

@James:感谢你的到来;-)哇,你最后的评论真让我惊讶。在使用C++ 15年后,我并不认为自己是一个新手,但我从未想过这种(无效行为)甚至适用于数组的情况!谢谢。 - Daniel Gehriger
如果大小不同,例如派生类具有更多成员,则Base[N]Derived[N]会让您陷入麻烦。 - etarion
@etarion: 我们正在讨论指针:例如,f(Base a[]) { ... } 并通过 reinterpret_cast 传递一个 Derived 数组。 - Daniel Gehriger
@etarion:糟糕,我是指Base*[N]Derived*[N],以匹配OP对std::vector<Base*>std::vector<Derived*>的使用。 - James McNellis
f(Base a[]) 有同样的问题。 f(Base *a[]) 是一个不同的情况,请参见James'的编辑。 - etarion

4

在容器中使用协变存在以下一般问题:

假设您的转换可以正常工作并且是合法的(但实际上不是这样),请看下面的示例:

#include <vector>
struct A {};
struct B : A { public: int Method(int x, int z); };
struct C : A { public: bool Method(char y); };
std::vector<B*> test;
std::vector<A*>* foo = reinterpret_cast<std::vector<A*>*>(&test);
foo->push_back(new C);
test[0]->Method(7, 99); // What should happen here???

所以你也将一个C*重新解释为B*了...

实际上我不知道.NET和Java是如何处理这个问题的(我想当试图插入一个C时,它们会抛出异常)。


好观点。虽然我知道foo不能被修改,但我应该将其声明为“const”。 - Daniel Gehriger
Java和C#都有这种机制。这就是List<? extends Base>的作用:当x具有这种类型时,您无法调用x.add(someBaseObject) - Norswap

4

你正在调用reinterpret_cast<>的邪恶精神。

除非你真正知道自己在做什么(我指的不是骄傲和学究式的),reinterpret_cast是邪门的入口之一。

我所知道的唯一安全的用法是在C++和C函数调用之间管理类和结构体。也许还有其他用途。


1
另一个合理的用途是使用“快速数学”近似,利用浮点数的表示。近似要求基本上消除了风险,例如快速inv sqrt,它仅在IEEE 754浮点格式的32位浮点数上工作。简而言之,除非你是专业的“鼻子恶魔”驯兽师,否则要避开这个东西。 - John P

1

我认为展示比讲解更容易理解:

struct A { int a; };

struct Stranger { int a; };

struct B: Stranger, A {};

int main(int argc, char* argv[])
{
  B someObject;
  B* b = &someObject;

  A* correct = b;
  A* incorrect = reinterpret_cast<A*>(b);

  assert(correct != incorrect); // troubling, isn't it ?

  return 0;
}

这里描述的问题是,在进行“适当”的转换时,编译器会根据对象的内存布局添加一些指针调整。在 reinterpret_cast 中,不执行任何调整。
我想你应该明白为什么代码中通常应禁止使用 reinterpet_cast...

是的,在多重继承的情况下,这会增加麻烦。但在单一继承的情况下,这并不是问题。 - Daniel Gehriger
1
@Daniel:除非您使用virtual继承,否则无法解决... 除非您的基类没有虚方法且派生类有(在大多数实现上); 标准不保证,因此这是等待中的错误。 - Matthieu M.

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