std::initializer_list返回值的生命周期

38
GCC的实现会在返回完整表达式的末尾销毁从函数返回的std::initializer_list数组。这正确吗?
该程序中的两个测试用例都显示析构函数在值被使用之前执行:
#include <initializer_list>
#include <iostream>

struct noisydt {
    ~noisydt() { std::cout << "destroyed\n"; }
};

void receive( std::initializer_list< noisydt > il ) {
    std::cout << "received\n";
}

std::initializer_list< noisydt > send() {
    return { {}, {}, {} };
}

int main() {
    receive( send() );
    std::initializer_list< noisydt > && il = send();
    receive( il );
}

我认为程序应该能够正常工作。但相关的标准术语有些复杂。
return语句会初始化一个返回值对象,就好像它被声明了一样。
std::initializer_list< noisydt > ret = { {},{},{} };

这个函数使用给定的初始化程序序列初始化一个临时的initializer_list和其底层数组存储,然后从第一个initializer_list初始化另一个initializer_list。那么数组的生命周期是什么?“数组的生命周期与initializer_list对象相同。”但有两个这样的对象;哪一个是模糊的。如果8.5.4/6中的示例按照广告运作,则应解决数组具有复制到对象的生命周期的歧义。然后返回值的数组也应该在调用函数中保持存在,并且可以通过将其绑定到命名引用来保留它。
LWS上,GCC错误地在返回之前杀死了数组,但根据示例保留了一个命名的initializer_list。Clang也正确处理示例,但列表中的对象永远不会被销毁;这会导致内存泄漏。ICC根本不支持initializer_list。
我的分析正确吗?
C++11 §6.6.3/2:

使用带括号的初始化列表的返回语句,通过从指定的初始化列表进行复制列表初始化(8.5.4),来初始化从函数返回的对象或引用。

8.5.4/1:

在复制初始化的上下文中进行列表初始化称为复制列表初始化

8.5/14:

在形式上为 T x = a; 的初始化过程称为复制初始化。
回到8.5.4 / 3:
用类型T的对象或引用进行列表初始化的定义如下:...
- 否则,如果T是std :: initializer_list<E> 的特化,则构造一个initializer_list对象,并根据从同类型类初始化对象的规则(8.5)来使用该对象来初始化对象。

8.5.4/5:

一个类型为std::initializer_list<E>的对象可以从一个初始化列表中构造,就像实现分配了一个元素类型为E、大小为N的数组一样,其中N是初始化列表中元素的数量。该数组的每个元素都与初始化列表的相应元素进行复制初始化,并且std::initializer_list<E>对象被构造为引用该数组。如果需要缩小转换来初始化任何元素,则程序是非法的。

8.5.4/6:

The lifetime of the array is the same as that of the initializer_list object. [Example:

typedef std::complex<double> cmplx;
 std::vector<cmplx> v1 = { 1, 2, 3 };
 void f() {
   std::vector<cmplx> v2{ 1, 2, 3 };
   std::initializer_list<int> i3 = { 1, 2, 3 };
 }

For v1 and v2, the initializer_list object and array createdfor { 1, 2, 3 } have full-expression lifetime. For i3, the initializer_list object and array have automatic lifetime. — end example]


关于返回花括号初始化列表的一点澄清

当你返回一个被括在花括号中的裸列表时,

带有花括号初始化列表的返回语句通过从指定的初始化列表进行复制列表初始化(8.5.4)来初始化将要从函数返回的对象或引用。

这并不意味着返回到调用作用域的对象是从某个地方复制的。例如,以下代码是有效的:

struct nocopy {
    nocopy( int );
    nocopy( nocopy const & ) = delete;
    nocopy( nocopy && ) = delete;
};

nocopy f() {
    return { 3 };
}

这不是:

nocopy f() {
    return nocopy{ 3 };
}

复制列表初始化指的是使用等效于语法nocopy X = {3}来初始化表示返回值的对象。这不会调用复制,与数组寿命被扩展的8.5.4/6示例完全相同。
而Clang和GCC在这一点上是agree的。

其他注意事项

检查N2640的评论未提及这种情况。虽然已经对这些单独的功能进行了广泛讨论,但我没有看到它们之间的交互内容。

实现起来会比较棘手,因为它涉及到通过值返回一个可选的、可变长度的数组。由于std::initializer_list不拥有其内容,所以函数还必须返回其他东西来拥有它。当传递给函数时,这只是一个本地的、固定大小的数组。但在另一个方向上,VLA需要与std::initializer_list的指针一起在堆栈上返回。然后调用者需要被告知是否处理序列(无论它们是否在堆栈上)。

通过从lambda函数中返回大括号初始化列表,作为返回几个临时对象的“自然”方式而不关心它们如何包含,很容易遇到这个问题。

auto && il = []() -> std::initializer_list< noisydt >
               { return { noisydt{}, noisydt{} }; }();

事实上,这与我到达此处的方式类似。但是,省略 -> 尾随返回类型是错误的,因为 lambda 返回类型推断仅在返回表达式时发生,而大括号初始化列表不是表达式。


GCC生成的“destroyed”消息不是在“receive”调用发生之前产生的吗?这只是“send”函数内部对象被销毁的表现形式,毕竟你是按值传递的。在这种情况下,这不会出错。Clang可能会优化掉这个问题。 - jogojapan
@jogojapan 我在复制构造函数中添加了输出,但是两个实现都没有调用它。我认为这里没有任何空间来进行noisydt的复制构造。请注意,复制初始化列表不会复制底层数组。 - Potatoswatter
@Nawaz 因为它破坏了整个数组,没有剩余的内容需要销毁。没有复制。在实际应用中,“receive”产生了一个段错误,因为被破坏的对象是一个std::string - Potatoswatter
@Potatoswatter,你说得对。复制初始化列表并不会复制其中包含的元素。但是这意味着通过复制将其从函数返回是非法的,是吗?因为你引用的那段话清楚地说明了,生命周期在函数结束时结束,数组(以及其中包含的任何东西)也是一样。 - jogojapan
@jogojapan和Nawaz,除了8.5.4/3和/5指定的元素(而不是列表),语言没有指定任何副本。尽管这是复制列表初始化,但它并不是“通过复制返回”。尽管生命周期被延长,但示例中仍发生相同的复制。由复制列表初始化初始化的对象是返回的对象,该对象位于调用范围内。return std::initializer_list<noisydt>{ … };则是另一回事,因为然后列表初始化将应用于一个附加的临时对象,其生命周期将决定数组的生命周期。 - Potatoswatter
显示剩余11条评论
2个回答

25

std::initializer_list 不是一个容器,不要使用它来传递值并期望它们持久存在。

DR 1290 更改了措辞,您还应该注意 15651599,它们还没有准备好。

那么返回值的数组也应该在调用函数中保留,并且通过将其绑定到命名引用来保存它应该是可能的。

不,这是不正确的。数组的生存期不会随着 initializer_list 的延长而一直延长。请考虑以下情况:

struct A {
    const int& ref;
    A(const int& i = 0) : ref(i) { }
};

参考i绑定到临时的int,然后引用ref也将其绑定,但这并不延长i的生命周期,它仍然在构造函数结束时超出范围,留下一个悬空引用。通过将另一个引用与其绑定,无法延长底层临时对象的生命周期。

如果1565被批准并且您将il作为副本而不是引用,则您的代码可能更安全,但该问题仍未解决,甚至没有提出措辞,更别说实现经验了。

即使您的示例可以工作,有关底层数组生命周期的措辞显然仍在改进中,编译器要实现最终确定的语义需要一段时间。


数组的生命周期与initializer_list(8.5.4/6)相同。initializer_list是返回值对象(6.6.3/2),该对象绑定到命名引用并扩展其生命周期,与ScopeGuard惯用法(12.2/5)相同。因此,数组的生命周期也会延长。您的反例是12.2/5中列出的异常情况之一。(当然您知道这一点。)1290似乎保留了行为,因为数组仍在初始化/绑定到调用作用域中可见的原始临时对象。1599直接针对这个问题,但没有提出变更建议。 - Potatoswatter
1
12.2/5的第三个要点难道不适用于你的情况吗?数组temporary绑定到返回的“initializer_list”,但在完整表达式结束之前即在调用“receive(il)”之前被销毁。构造函数是“重新绑定”不会“重新扩展”生命周期的一个例子,而我认为你的情况也是如此。如果我错了,我很高兴被证明是错误的,但我尽量避免使用“initializer_list”对象来做这样的事情,因为我不相信它们能够按照你想要的方式工作 :) - Jonathan Wakely
不,那是指返回一个引用。obj const &f() { return obj(); } 根据该规则返回一个悬空引用,但在其他情况下是有效的。我的情况与 ScopeGuard 约定完全相同。函数返回对象 就是 临时对象;没有临时对象绑定到它上面。完善语言的唯一方法是挑战极限……我不信任这个,因为有 1599,或者我对标准中的“歧义”有另一种解读。也许我会尝试为 GCC 添加支持,这将是一个很好的挑战,但它几乎肯定不适合 ABI(或者也许可以通过堆栈欺骗实现)。 - Potatoswatter
1
initializer_list是函数返回对象,底层数组是临时的。 - Jonathan Wakely
这是怎么回事?数组的状态处于一种悬而未决的状态。我们只知道它的生命周期与“initializer_list”相同。 - Potatoswatter

21
你所指的8.5.4/6中的措辞是有缺陷的,并且已经通过DR1290进行了修正。现在修订后的标准说:

数组的生命周期与任何其他临时对象相同(12.2 [class.temporary]),但用数组初始化initializer_list对象会像绑定临时对象的引用一样延长数组的生命周期。

因此,临时数组的生命周期的控制措辞为12.2/5,即:

在函数返回语句中绑定到返回值的临时对象的生命周期不会被延长; 临时对象在return语句中的完整表达式结束时销毁。

因此,在函数返回之前,noisydt对象将被销毁。

最近,Clang存在一个bug,在某些情况下导致它无法销毁initializer_list对象的底层数组。我已经在Clang 3.4中修复了这个问题;从Clang trunk中运行您的测试用例的输出如下:

destroyed
destroyed
destroyed
received
destroyed
destroyed
destroyed
received

...这是正确的,根据DR1290。


很好。我认为如果将其措辞为“将数组的生命周期扩展为引用数组类型,就像initializer_list一样”,那么它会更清晰。将列表对象与引用等同是一种跳跃思维的方式。无论如何,整个东西都是有问题的。initializer_list的名称及其*begin()*end()应永久为右值,如果用户希望其持久存在,则应将其声明为const,以便不会选择典型的移动构造函数。 - Potatoswatter
1
那么,返回initializer_list的函数唯一的用例是返回一个static的吗? - M.M
1
@MattMcNabb:你也可以合理地返回一个作为参数给定的列表。但是返回一个initializer_list应该被视为可疑的。 - Richard Smith
@RichardSmith 没错。我在想,也许应该完全禁止它,因为有效的用例是神秘的。常见的编译器不会警告这个问题,但也许他们应该这样做。 - M.M
2
更新:截至2018年7月下旬,Clang trunk现在会发出警告,显示“返回本地临时对象的地址”错误。 - Brooks Moses

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