C++17的聚合初始化扩展是否使花括号初始化变得危险?

48

似乎有一个普遍的共识,即使用花括号初始化比其他形式的初始化更好,然而自从引入了C++17聚合初始化扩展之后,就存在意外转换的风险。请考虑以下代码:

struct B { int i; };
struct D : B { char j; };
struct E : B { float k; };

void f( const D& d )
{
  E e1 = d;   // error C2440: 'initializing': cannot convert from 'D' to 'E'
  E e2( d );  // error C2440: 'initializing': cannot convert from 'D' to 'E'
  E e3{ d };  // OK in C++17 ???
}

struct F
{
  F( D d ) : e{ d } {}  // OK in C++17 ???
  E e;
};
在上面的代码中,struct Dstruct E代表两种完全不相关的类型。因此,令人惊讶的是,在C++17中,如果使用大括号(聚合)初始化,您可以“转换”为另一种类型而没有任何警告。
您会建议采取什么措施来避免这些意外的转换?或者我可能漏掉了什么?
附言:上面的代码已在Clang、GCC和最新的VC++中进行了测试-它们都是一样的。
更新:针对Nicol的答复,请考虑一个更实际的例子:
struct point { int x; int y; };
struct circle : point { int r; };
struct rectangle : point { int sx; int sy; };

void move( point& p );

void f( circle c )
{
  move( c ); // OK, makes sense
  rectangle r1( c );  // Error, as it should be
  rectangle r2{ c };  // OK ???
}

我可以理解,您可以将一个视为一个,因为具有基类,但是从圆形静默地转换为矩形的想法,对我来说是个问题。

更新2: 因为我的类名选择不好似乎让一些人感到困惑了。

struct shape { int x; int y; };
struct circle : shape { int r; };
struct rectangle : shape { int sx; int sy; };

void move( shape& p );

void f( circle c )
{
  move( c ); // OK, makes sense
  rectangle r1( c );  // Error, as it should be
  rectangle r2{ c };  // OK ???
}

5
看起来正在初始化基础子对象(毕竟D从B派生)。Clang还发出警告“缺少字段'k'的初始化程序”。如果不是这样,这将是相当亵渎的 - Marco A.
6
你的第二个例子中不直观的行为在很大程度上源自于圆和矩形“本质上”是点。坦白地说,这是对继承的一种滥用,因为矩形不是点。如果基类被定义为ShapeWithCenter,那么情况就会在很大程度上(但不是完全)变得明显。现在,我同意花括号初始化和表面上预期的构造函数之间的差异仍然很危险,但这并不是新鲜事。 - Max Langhof
9
@Barnett: “但是我不希望这种情况发生,所以我正在使用类型系统来创建不同的类型。” 然后使用类型系统来创建不同的类型。公共继承不是创建不同类型的方式。它从来都不是。你认为某些东西是“错误”的,并不意味着它在语言上是一个“错误”。 - Nicol Bolas
5
@Barnett:我没有看到你所说的例子,但根据名称,ManagerAssistantEmployee之间具有“是一个”关系比circlerectanglepoint更有意义。圆具有点,但它们不是点;您不能将其与点互换使用。然而,如果没有成为员工,经理就不能成为经理。 我还敢打赌,Stroustrup的所有类型都不是聚合体,因此这并不重要。 - Nicol Bolas
8
请阅读有关Liskov替换原则的内容。管理者和员工具备此原则,而圆形和点则不具备。问题并非公共继承的存在,而是你在不合适的情况下使用了它。 - Nicol Bolas
显示剩余9条评论
2个回答

37

struct D和struct E代表两个完全不相关的类型。

但它们并不是“完全不相关”的类型。它们都有相同的基类类型。这意味着每个D都可以隐式转换为B。因此,每个D都是一个B。因此,在所调用的操作方面,执行E e{d};与执行E e{b};没有任何区别。

您无法关闭对基类的隐式转换。

如果这真的让您烦恼,唯一的解决方法是通过提供适当的构造函数来防止聚合初始化,并将值转发给成员函数。

至于聚合初始化是否会变得更加危险,我认为不会。您可以使用以下结构重现上述情况:

struct B { int i; };
struct D { B b; char j; operator B() {return b;} };
struct E { B b; float k; };

因此,这种情况一直是可能的。我认为使用隐式基类转换并没有使它变得更加“糟糕”。

更深层次的问题是,为什么用户一开始就尝试用 D 初始化一个 E

对我来说,从圆形到矩形的静默转换是个问题。

如果你这样做,你会遇到同样的问题:

struct rectangle
{
  rectangle(point p);

  int sx; int sy;
  point p;
};

你不仅可以执行rectangle r{c};,还可以执行rectangle r(c)

你的问题在于你错误地使用了继承。你对circlerectanglepoint之间的关系说了一些你并不想表达的话。因此,编译器让你做了一些你不想做的事情。

如果你使用了包含而不是继承,这个问题就不会存在:

struct point { int x; int y; };
struct circle { point center; int r; };
struct rectangle { point top_left; int sx; int sy; };

void move( point& p );

void f( circle c )
{
  move( c ); // Error, as it should, since a circle is not a point.
  rectangle r1( c );  // Error, as it should be
  rectangle r2{ c };  // Error, as it should be.
}

无论是 circle 一直是一个 point,还是永远不是一个 point。你正在尝试有时将其变为 point,有时不是。这在逻辑上是不一致的。如果您创建逻辑上不一致的类型,则可以编写逻辑上不一致的代码。


对我来说,从圆形到矩形的默默转换就是一个问题。

这提出了一个重要的观点。严格来说,转换看起来应该像这样:

circle cr = ...
rectangle rect = cr;

这是不合法的。当你执行 rectangle rect = {cr};时,你并没有进行转换。你正在显式地调用列表初始化,对于聚合体通常会引发聚合初始化。

现在,列表初始化确实可以执行转换。但仅给出 D d = {e};,就不应该认为这意味着你正在从 e 转换为 D。你正在使用 e 对类型为 D 的对象进行列表初始化。如果 E 可以转换为 D,那么这样可以执行转换,但是如果非转换列表初始化形式也可以起作用,则此初始化仍然可以有效。

因此,说这个特性使得circle可以转换为rectangle是不正确的。


20
@Barnett:你的新示例并没有比旧示例更好。圆形有点;矩形也有点,但它们在任何实际意义上都不是点。那么为什么你要声明一个“是一个”关系(即继承),而该关系在类型中并没有反映出来呢?问题不在于语言,而在于你对语言的使用。 - Nicol Bolas
8
@Barnett:你问为什么语言会以这种方式工作。我解释了继承背后的理论思想。你不必同意它,但这是语言的看法,所以语言的行为就是这样的。你试图做的并不是语言想要支持的东西,这就是为什么你在让它工作时遇到问题的原因。 - Nicol Bolas
5
如果你使用C++对象模型,你将受其语义约束。 struct A:B 不意味着“重用 B 的实现”,而意味着“一个 A 是一个 B,可以在任何需要 B 的地方使用”。如果你试图在没有这个语义意义的情况下使用它,可能会出问题。你已经说明了圆是一种点,矩形是一种点。尽管C++17现在添加了一种新的聚合构造方法,但这里导致问题的仍是你的语义错误。 - Yakk - Adam Nevraumont
4
如果“B可以用来初始化C”,并且“A可以在任何可使用B的地方使用”,那么根据传递性质,“A可以用来初始化C”。问题在于,你试图将从单个对象进行初始化与从多个对象进行初始化的操作区分开来。 - Nicol Bolas
3
为了解决这个问题,你需要一个“构造函数”。如果C>B,那么编写一个不仅仅接受B的构造函数。在这个更改之前,你打算让人们如何构造一个“circle”?创建一个未初始化的对象,然后逐个设置它的值吗? - Yakk - Adam Nevraumont
显示剩余5条评论

29

C++17并没有引入这个特性。聚合初始化一直都允许不指定某些成员(这些成员将被用空的初始化列表进行初始化,C++11):

struct X {
    int i, j;
};

X x{42}; // ok in C++11

现在可以省略更多种类的内容,因为可以包含更多的内容。


至少gcc和clang通过-Wmissing-field-initializers(它是-Wextra的一部分)提供了警告,表示某些内容丢失。如果这是一个很大的问题,只需启用该警告进行编译(可能升级为错误):

<source>: In function 'void f(const D&)':
<source>:9:11: warning: missing initializer for member 'E::k' [-Wmissing-field-initializers]
   E e3{ d };  // OK in C++17 ???
           ^
<source>: In constructor 'F::F(D)':
<source>:14:19: warning: missing initializer for member 'E::k' [-Wmissing-field-initializers]
   F( D d ) : e{ d } {}  // OK in C++17 ???
                   ^

更直接的方法是为这些类型添加构造函数,这样它们就不再是聚合体了。毕竟,您并不一定要使用聚合初始化。


1
如果类D类E没有添加任何新成员,而是用作不同的类型(例如为了重载),会怎么样? - Barnett
1
@Barnett,我不理解你的担忧。在给定 void f(D); void f(E); 的情况下,f({d}) 调用前者,f({b}) 是不明确的。 - Barry
1
在我的代码中,我使用类作为强类型的形式。这使我能够根据类型重载函数等等... 但更重要的是,它可以保护我不会意外地从一个与之无关的类型初始化另一个类型。现在,使用C++17编译器,如果我继续使用花括号初始化,那么这种保护就不存在了。 - Barnett
8
那种保护已经消失了......除非你添加一个构造函数。或者编译时启用警告信息。或者不使用花括号初始化。或者有任何一个成员没有默认初始化。或者...... - Barry
不幸的是,Visual C++ 没有这样的警告,即使使用 /Wall 也没有。 - Barnett

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