C++ 11 推出的列表初始化本应成为统一 C++ 对象初始化方式的利器,提升语言的一致性与表达力,但它与 std::initializer_list<> 的深度绑定,却让这一特性沦为 C++ 发展史上颇具争议的设计,其负面影响一直延续至今。

引入:从 STL 的行为差异说起

在探讨 std::initializer_list<> 之前,我们先看看 C++ 20 引入的函数模板 std::to_array<>()。它的函数签名如下:

template< class T, std::size_t N >
constexpr std::array< std::remove_cv_t< T >, N > to_array( T ( &a )[ N ] );
template< class T, std::size_t N >
constexpr std::array< std::remove_cv_t< T >, N > to_array( T ( &&a )[ N ] );

日常使用中,它的表现符合预期。例如:

// C++ 23
#include <array>
#include <print>
#include <vector>
struct X final
{
    X() { std::println( "X()" ); }
    X( const X& ) { std::println( "X(const X&)" ); }
    X( X&& ) { std::println( "X(X&&)" ); }
    ~X() { std::println( "~X()" ); }
};
auto main() -> int
{
    [[maybe_unused]] auto a = std::to_array( { X{} } );
    return 0;
}

这段代码的输出是:

X()
X(X&&)
~X()
~X()

显然,这里调用了右值引用的重载,符合我们对临时对象传递的预期。

但当我们用 std::vector<> 存储同样的临时对象时,情况却变了:

// 替换为:
std::vector b{ X{} };

输出变成了:

X()
X(const X&)
~X()
~X()

拷贝构造函数被调用!更严重的是,如果 X 的拷贝构造函数被显式删除(= delete),这段代码将直接编译失败。

问题出在哪里?答案在于:标准容器(如 std::vector<>)的列表初始化会优先匹配接受 std::initializer_list<> 的构造函数,而 std::initializer_list<> 本身的设计限制了移动语义的使用。

揭开 std::initializer_list<> 的面纱

cppreference 对 std::initializer_list<> 的描述 中有这样一句关键信息:

std::initializer_list<T> 类型的对象是一个轻量级代理对象,它提供对 const T 类型对象数组的访问(这些对象可能分配在只读内存中)。

关键词很明确:代理对象const T 数组只读内存

实际上,std::initializer_list<> 的典型实现就是「一个指向 const T 数组的指针 + 数组长度(std::size_t)」。更重要的是:它不持有元素的所有权

那列表初始化中对象的生命周期如何?我们修改代码进一步观察:

// C++ 23
#include <array>
#include <print>
#include <vector>
struct X final
{
    X() { std::println( "X()" ); }
    X( const X& ) { std::println( "X(const X&)" ); }
    X( X&& ) { std::println( "X(X&&)" ); }
    ~X() { std::println( "~X()" ); }
};
auto main() -> int
{
    std::vector b{ X{} };
    std::println( "----------" );
    return 0;
}

输出为:

X()
X(const X&)
~X()
----------
~X()

过程很清晰:

  1. X{} 在列表初始化中构造,std::initializer_list<> 存储了它的地址(数组长度为 1);
  2. 容器通过拷贝构造在堆上创建新对象;
  3. 列表初始化中的 X{} 先析构,打印分割线后,堆上的对象再析构。

由此可得出结论:列表初始化中对象的生命周期,与 std::initializer_list<> 对象本身的生命周期一致。这是 C++ 中少数规则明确的生命周期场景,但这份「清晰」是以性能为代价的。

基于此,我们可以推测编译器对列表初始化的处理逻辑:

// 程序员编写:
std::vector v{ X{}, X{} };
// 编译器实际处理:
const X __temp_array[ 2 ] = { X{}, X{} };         // 临时数组
std::vector v( __temp_array, __temp_array + 2 );  // 从 const 数组拷贝
// 最后释放 __temp_array 的内存

正因如此,不当使用 std::initializer_list<> 还可能导致悬垂引用问题:

auto create_list()
{
    using namespace std::string_literals;
    return std::initializer_list< std::string >{ "a"s, "b"s };
}
// 函数返回后,临时 string 已销毁,initializer_list 持有悬垂指针!

为历史遗憾打补丁

std::initializer_list<> 的性能缺陷(如强制拷贝)并非无解,实践中可参考这些方案:

  1. 避免对非平凡拷贝类型使用列表初始化:对于移动成本低但拷贝成本高的类型(如 std::stringstd::vector<>),尽量用其他构造方式替代。
  2. 使用工厂函数绕过限制:我们可以自己实现构造函数,直接转发参数而非依赖 std::initializer_list<>。例如:
// C++ 23
#include <concepts>
#include <vector>
// 通过参数直接构造 std::vector<T>
template < typename T, std::constructible_from< T >... Args >
constexpr auto make_vector( Args&&... args )
{
    std::vector< T > result;
    result.reserve( sizeof...( Args ) );                           // 预分配空间
    ( result.emplace_back( std::forward< Args >( args ) ), ... );  // 完美转发,直接构造
    return result;
}
// 使用示例
#include <print>
auto main() -> int
{
    std::print( "{}", make_vector< int >( 1, 2, 3 ) );  // 输出:1 2 3
}

如今,标准库容器已普遍支持范围构造等更高效的初始化方式,例如:

std::vector< int > v( std::from_range, some_range );

这类接口绕过了 std::initializer_list<> 的限制,是更现代、更安全的初始化方式。

std::initializer_list<> 的设计初衷良好,但在实际使用中暴露了诸多问题:强制拷贝、无法移动、生命周期陷阱、悬垂指针风险。这些缺陷促使标准库不断引入更高效的替代方案。

对开发者而言,关键在于:不要被简洁的花括号语法迷惑。优雅的语法糖背后,可能隐藏着不易察觉的性能陷阱。理解底层机制,才能写出既安全又高效的 C++ 代码。

如无另外说明,本文采用 CC BY-NC-SA 4.0 许可