为什么std :: initializer_list可以不指定大小并且同时在堆栈上分配?

18

我从这里了解到,std::initializer_list不需要分配堆内存。这让我感到非常奇怪,因为您可以接收一个std::initializer_list对象而无需指定其大小,而对于数组,您总是需要指定大小。虽然这篇文章提到 initializer list 内部实现几乎与数组相同。

我很难理解的是,C++作为一种静态类型语言,每个对象的内存布局(和大小)必须在编译时确定。因此,每个std::array都是另一种类型,我们仅从共同的模板生成这些类型。但是对于std::initializer_list,这个规则显然不适用,因为接收函数或构造函数不需要考虑内存布局(虽然它可以从传递给其构造函数的参数中推导出来)。只有当类型分配堆内存并且仅保留存储器以管理该内存时,这才对我有意义。然后差异将类似于std::arraystd::vector,对于后者,您也不需要指定大小。

然而,如我的测试所示,std::initializer_list不使用堆分配:

#include <string>
#include <iostream>

void* operator new(size_t size)
{
    std::cout << "new overload called" << std::endl;    
    return malloc(size);
}


template <typename T>
void foo(std::initializer_list<T> args)
{
    for (auto&& a : args)
    std::cout << a << std::endl;
}

int main()
{
    foo({2, 3, 2, 6, 7});

    // std::string test_alloc = "some string longer than std::string SSO";
}

这怎么可能?我能为我的类型编写类似的实现吗?那样可以在进行编译时不会炸掉我的二进制文件,真是太好了。

编辑:我应该指出我想问的问题不是编译器如何知道应该用什么大小来实例化初始化列表(可以通过模板参数推导来实现),而是它与所有其他初始化列表实例化不同的方式(因此您可以将大小不同的初始化列表传递给同一个函数)。


7
因为编译器的作用,这是可能的。在标准C++中无法实现std::initializer_list - NathanOliver
尽管您只有在元素数量已知的情况下才能构建和使用它,但其大小在编译时是固定的。 - Quimby
你存储在std::initializer_list中的值就像底层的简单数组一样。如果你检查你的示例生成的汇编代码(https://godbolt.org/z/vEoc46Pn9),你会发现你的数组在二进制文件中。你不能实现它,因为`std::initializer_list`是一个特殊的类,与编译器"绑定"。就像`constexpr construt_at`一样,你也无法实现它... - simre
这类似于在 int a[] = {1,2,3}; 中您不需要指定大小 - 编译器会自动识别。 - molbdnilo
想一想 const char s[] = "Hello World"; 如何与 const char *s = "Hello World"; 一样工作,且 s 会衰变为一个简单的指针。 - Goswin von Brederlow
4个回答

21

问题在于,std::initializer_list并不包含其内部的对象。当你实例化它时,编译器会注入一些额外的代码来在堆栈上创建一个临时数组,并将指向该数组的指针存储在initializer_list中。就其价值而言,initializer_list只是一个具有两个指针(或一个指针和一个大小)的结构体:

template <class T>
class initializer_list {
private:
  T* begin_;
  T* end_;
public:
  size_t size() const { return end_ - begin_; }
  T const* begin() const { return begin_; }
  T const* end() const { return end_; }

  // ...
};

当你执行以下操作时:

foo({2, 3, 4, 5, 6});

概念上,这里发生了什么:

int __tmp_arr[5] {2, 3, 4, 5, 6};
foo(std::initializer_list{arr, arr + 5});

其中一个细微的区别是,数组的生命周期不超过初始化列表的生命周期。


17

... 对于数组,您总是需要指定大小 ...

您的意思是像这样

int a[] = {2, 3, 2, 6, 7};

我很难理解的是,C++作为一种静态类型语言,每个变量的内存布局(以及大小)都必须在编译时固定。

与上面的数组一样,初始化列表的大小也是在编译时确定的——这是因为你在编译之前明确地写出了大括号表达式{2, 3, 2, 6, 7}

那么,这是如何实现的呢?我能不能为自己的类型编写类似的实现?

不可以拦截大括号初始化列表的解析。正如你可以看到的,处理初始化列表的规则非常具体。

然而,std::initializer_list旨在轻量化,因此可以直接使用它。就像其他答案所说的那样,你可以将其视为一个带有隐式大小的普通数组,并进行隐式转换为类似范围的视图。


8

我想随意附加一些自己的思考。 std::initializer_list 实际上是一个有趣的动物 - 它是一种嵌合体,部分STL,部分编译器构造。

如果你在适当的STL头文件中查找,你会发现它有一个定义来定义API。但实际的实现实际上是内置在编译器中的,所以当你编写:

std::initializer_list <int> l = { 1, 2, 3, 4, 5 };

编译器会说:“哦!一个初始化列表(而且里面是一组int),我知道该如何构造它。”于是,编译器就这样做了。在STL本身中没有相应的代码来执行此操作。
换句话说,对于编译器而言,std::initializer_list是一个本地类型的一部分。但它并不完全是本地类型,因此它是非常特殊的一员(请参见注释)。

1
在这方面,它确实是特殊的,但并不是独一无二的。std::type_info也是通过特殊的编译器魔法构建的。新的std::source_location有点不同,因为使用静态成员函数请求魔法,但它仍然是编译器魔法。 - Ben Voigt
@BenVoigt 很有趣,谢谢。编译器显然能够识别这些标识符,并在看到它们时像变魔术一样做出反应,就像当你写 int x = 42; 时一样。根据您的输入修改了我的答案。 - Paul Sanders

2
你似乎有一个误解,认为需要在编译时知道大小才能在堆栈上分配空间。实际上,没有任何技术原因不允许在堆栈上分配动态确定大小的对象。事实上,C语言通过可变长度数组明确允许这样做。关于这个问题还有另一个问题,虽然它是关于C语言的。
虽然我不知道有什么理智的C++方法来手动进行堆栈分配(alloca()是一个糟糕的想法,也不是真正的C++东西),但没有什么阻止编译器为您完成这项工作。
堆栈分配非常简单 - 其他人可能会纠正我,但据我所知,它归结为简单地增加堆栈指针寄存器中的值。

我也想到了这一点,基本上std :: initializer list在幕后使用VLA,对于初始化列表的stackoverflow影响问题怎么样? - glades
@glades并不是真正的可变长度数组,只是在堆栈上分配一些东西。没必要这么具体。另外,“堆栈溢出影响”是什么意思?有很多方法可以溢出堆栈,没有一种语言是安全的。至少只要它们允许递归就不安全。 - jaskij
我的意思是,如果您使用大量字符串的大型初始化程序列表来初始化对象,则可能会遇到堆栈溢出的风险。 - glades
1
就像我说的,这总是有风险的。在初始化列表和大数组之间没有任何区别。更不用说堆栈大小取决于操作系统了。在Windows上,它是1 MiB。在Linux上,通常为8或10 MiB,但可以通过适当的系统调用进行更改。此外,我认为编译器实际上并不会在编译期间检查任何估计的堆栈大小(在GCC中有一个选项来生成数据,但很少使用)。 - jaskij
1
禁止使用可变长度数组(VLAs)有一个技术原因:为了跳过一堆栈指针的边界检查,栈后面会跟着一页受保护的内存。当程序试图访问受保护区域时,就会发生栈溢出。而VLAs允许你将栈指针移动到受保护区域之外。 - Schmid

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