【问题标题】:How come std::initializer_list is allowed to not specify size AND be stack allocated at the same time?为什么允许 std::initializer_list 不指定大小并同时分配堆栈?
【发布时间】:2022-06-19 03:50:55
【问题描述】:

我从here 了解到std::initializer_list 不需要分配堆内存。这对我来说很奇怪,因为您可以在不指定大小的情况下接收 std::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";
}

这怎么可能?我可以为自己的类型编写类似的实现吗?每当我演奏编译时管弦乐队时,这真的可以让我免于烧毁我的二进制文件。

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

【问题讨论】:

  • 这是可能的,因为编译器会这样做。 std::initializer_list 无法在标准 C++ 中实现。
  • 大小在编译时是固定的,因为只有在元素数量已知的情况下才能构造和使用它。
  • 您存储在std::initializer_list 中的值就像是底层的简单数组。如果您检查为您的示例 (godbolt.org/z/vEoc46Pn9) 生成的 asm,您可以看到您的数组在二进制文件中。您无法实现它,因为std::initializer_list 是编译器“绑定”的特殊类。就像constexpr construt_at 一样,你也不能实现...
  • 这类似于您不需要在int a[] = {1,2,3}; 中指定大小 - 编译器知道。
  • 想想const char s[] = "Hello World"; 是如何以同样的方式工作的,而s 则衰减为一个简单的指针。或const char *s = "Hello World";.

标签: c++ types heap-memory stack-memory stdinitializerlist


【解决方案1】:

问题是,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});

一个小的区别是,数组的生命周期不超过 initializer_list 的生命周期。

【讨论】:

    【解决方案2】:

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

    你的意思是喜欢

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

    ?

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

    初始值设定项列表的大小与上面数组的大小一样在编译时固定 - 它是固定的,因为您在编译之前明确写出了括号表达式{2, 3, 2, 6, 7}

    这怎么可能?我可以为自己的类型编写类似的实现吗?

    你不能拦截一个braced-init-list的解析,不。你可以see,处理列表初始化的规则非常具体。

    但是,std::initializer_list 旨在实现轻量级,因此您可以直接使用它。正如另一个答案所说,您可以将其视为普通的隐式大小数组,并隐式转换为类似范围的视图。

    【讨论】:

      【解决方案3】:

      随便补充几句我自己的想法,std::initializer_list 实际上是一种有趣的动物——它是一种嵌合体,部分是 STL,部分是编译器构造。

      如果您查看适当的 STL 头文件,您会找到一个定义 API 的定义。但是实际的实现实际上是内置在编译器中的,所以当你写的时候,说:

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

      编译器“啊哈!一个初始化列表(和一个 int 的列表,要启动),我知道那是什么,我会构造一个”。它确实如此。在 STL 本身中没有执行此操作的代码。

      换句话说,对于编译器来说,std::initializer_list 部分是原生类型。只是它不是,不是完全的,因此它是独特一个非常精选的俱乐部的成员(请参阅 cmets)。

      【讨论】:

      • 在这方面肯定是特殊,但不是唯一的。 std::type_info 也是由特殊的编译器魔法构造的。新的std::source_location 有点不同,因为使用了静态成员函数来请求魔法,但它仍然是编译器魔法。
      • @BenVoigt 很有趣,谢谢。编译器显然可以识别这些标识符,并在看到它们时将兔子从帽子里拉出来,就像它在编写 int x = 42; 时所做的那样。调整了我的答案以反映您的意见。
      【解决方案4】:

      您似乎有一个误解,即您需要在编译时已知的大小才能在堆栈上分配。事实上,没有技术上的理由禁止在运行时从堆栈中确定大小的对象。事实上,C 明确地允许它使用Variable Length Arrays。有another question on this topic,虽然它询问的是C。

      虽然我不知道实际执行手动堆栈分配的合理 C++ 方法(alloca() 是一个 想法,而不是真正的 C++ 事情),但没有什么能阻止编译器做给你。

      堆栈分配非常简单 - 其他人可能会纠正我,但据我所知,它归结为简单地增加堆栈指针寄存器中的值。

      【讨论】:

      • 我也是这么想的,基本上std::initializer list是在后台使用VLA的吧?那么关于初始化列表的 stackoverflow 隐含问题呢?
      • @glades 不是真正的 VLA,只是在堆栈上分配东西。没必要这么具体。另外,“堆栈溢出含义”是什么意思?溢出堆栈的方法有很多,没有一种语言可以避免它们。至少不是只要它们允许递归。
      • 我的意思是,如果您要使用包含大量字符串的巨大初始化列表来初始化对象,那么您可能会遇到堆栈溢出的风险。
      • 然后呢?就像我说的,这总是有风险的。初始化器列表和大型数组之间没有什么不同。更不用说堆栈大小取决于操作系统。在 Windows 上为 1 MiB。在 Linux 上,它通常为 8 或 10 MiB,但可以通过适当的系统调用进行更改。另外,我认为编译器实际上不会在编译期间检查任何类型的估计堆栈大小(有一个选项可以在 GCC 中生成数据,但很少有人使用它)。
      • 禁止 VLA 有一个技术原因:要跳过堆栈指针的一堆边界检查,堆栈后跟一页受保护的内存。当程序尝试访问受保护区域时,您会遇到堆栈溢出。 VLA 允许您将堆栈指针移动到受保护区域之外。
      猜你喜欢
      • 2015-06-15
      • 2020-09-01
      • 2020-01-28
      • 2018-06-29
      • 2017-02-19
      • 2014-06-01
      • 2016-12-28
      • 2012-07-09
      • 1970-01-01
      相关资源
      最近更新 更多