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()
过程很清晰:
X{}
在列表初始化中构造,std::initializer_list<>
存储了它的地址(数组长度为 1);- 容器通过拷贝构造在堆上创建新对象;
- 列表初始化中的
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<>
的性能缺陷(如强制拷贝)并非无解,实践中可参考这些方案:
- 避免对非平凡拷贝类型使用列表初始化:对于移动成本低但拷贝成本高的类型(如
std::string
、std::vector<>
),尽量用其他构造方式替代。 - 使用工厂函数绕过限制:我们可以自己实现构造函数,直接转发参数而非依赖
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++ 代码。