C++ std::initializer_list 设计缺陷与[不正规]补救措施

Posted by Ciel's Paperplane on September 6, 2024

图片

本文前置知识见 C++ SFINAE 简单介绍与两个常用用法

1. 重点介绍

std::initializer_list<T> 是 C++11 起对 T 类型只读数组的轻量代理对象,它的实现依赖于编译器。

它的用途主要是为容器提供初始化器列表的构造函数,例如 std::vector 的构造函数:

vector(std::initializer_list<T> init, const Allocator& alloc = Allocator());

(1) 花括号劫持

但它也有非常臭名昭著的点,比如会劫持花括号,即

// 指 vector(size_type count, const T& value, const Allocator& alloc = Allocator());
// 构造 2 个值为 3 的 int 值
std::vector<int> v1(2, 3);
// 指 vector(std::initializer_list<T> init, const Allocator& alloc = Allocator());
// 构造两个值分别为 2 和 3 的 int 值
std::vector<int> v2{2, 3};

(2) 构造条件

参见 std::initializer_list - cppreference.comstd::initializer_list 在这些时候自动构造:

用花括号包围的初始化式列表来列表初始化一个对象,其中对应的构造函数接受一个 std::initializer_list 形参。
以花括号包围的初始化式列表为赋值的右操作数,或函数调用实参,且对应的赋值运算符/函数接受 std::initializer_list 形参。
将花括号包围的初始化式列表绑定到 auto,包括在范围 for 循环中。

{1, 2} 本身并不是一个表达式,更不是 std::initializer_list 类型,实际上它压根不存在类型,它只是能从 std::initializer_list 的构造规则中转化成它。

这导致了如果定义一个模板函数,例如

template<class T>
void f(T) {}

// f({1, 2}); // error

它是无法推导出 T 的,因为 {1, 2} 并没有类型。

所以这里其实就已经产生了第一个小缺陷,请看下面两个例子的对比:

std::vector<int> v1{1, 2};

std::vector<std::vector<int>> v2;
// v2.emplace_back({1, 2}); // error
v2.push_back({1, 2});

因为上面介绍过 std::vector 有对应的构造函数,所以 v1 没有问题,而 v2.push_back 因为接受的参数是 std::vector<int>,所以也如 v1 一样构造了一个临时变量再移动构造进去,所以也没问题。

v2.emplace_back 则因为形参为变长模板,无法对 {1, 2} 推导类型,编译失败。

C++ 标准可能也意识到了这个问题,因为据我所知从 C++17 开始有很多类,比如 std::variant std::expected 等,构造函数都会在变长模板的基础上加上这样一个形式:

// std::in_place_type_t 是一个占位符,提示要转到原地构造的构造函数上

template<class T, class... Args>
constexpr explicit variant(std::in_place_type_t<T>, Args&&... args);

template<class T, class U, class... Args>
constexpr explicit variant(std::in_place_type_t<T>, std::initializer_list<U> il, Args&&... args); // 专门为了 {...} 的正确转换

所以说 std::vector::emplace_back 这类函数其实也可以加上这样的重载版本。

(3) const 元素

std::initializer_list<T> 的元素全为 const T,这导致了我们只能从里面复制构造每个元素,这是公认的设计缺陷,以下则是对这一缺陷的补救。

2. [不正规]补救措施

拿我自己实现的 vector 举例,首先放上最终成果,我们一步步解释:

template<class InitializerList,
         typename std::enable_if<std::is_same<InitializerList, std::initializer_list<T>>::value, int>::type
         = 0>
vector(InitializerList init, const allocator_type& alloc = allocator_type())
    : vector(init.begin(), init.end(), alloc) {}

template<class U = T,
         typename std::enable_if<worth_move<U>::value, int>::type
         = 0>
vector(std::initializer_list<move_proxy<T>> init, const allocator_type& alloc = allocator_type())
    : vector(init.begin(), init.end(), alloc) {}

template<class U = T,
         typename std::enable_if<!worth_move<U>::value, int>::type
         = 0>
vector(std::initializer_list<T> init, const allocator_type& alloc = allocator_type())
    : vector(init.begin(), init.end(), alloc) {}

(1) 移动代理类

注意到这里的 std::initializer_list<move_proxy<T>>move_proxy 是一个简单的代理类,它的实现如下:

template<class T>
class move_proxy {
public:
    template<class... Args,
             typename std::enable_if<std::is_constructible<T, Args&&...>::value, int>::type
             = 0>
    move_proxy(Args&&... args) noexcept(std::is_nothrow_constructible<T, Args&&...>::value)
        : data_(std::forward<Args>(args)...) {}

    operator T&&() const noexcept {
        return std::move(data_);
    }

private:
    mutable T data_;

}; // class move_proxy

它会用万能引用原地构造一个 mutable T data_ 成员变量,这使得它后续作为 const move_proxy<T> 也能被作为移动构造的参数。

operator T&&() const noexcept 则是可以隐式转换为 T&&,所以当我们

move_proxy<T> mp;
T t{mp};
t = mp;

就分别调用了 T 的移动构造和移动赋值。

(2) 只移动值得移动的 T

但是 move_proxy 也有自己的性能损失,所以我们可能希望只有需要移动的 T 才用 move_proxy 包一层。像基本类型例如 int float 等,以及它们的聚合体,并没有有意义的移动构造函数,直接复制就可。所以我最后总结的分界线就是,当 T 是类,且不为可平凡复制类,且可移动构造,且有用户(可能隐式)定义的移动构造函数。

“且有用户(可能隐式)定义的移动构造函数”与“可移动构造”并不是相同的定义,因为只要一个类定义了复制构造,右值引用也可以传给复制构造,std::is_move_constructible 也是同样的道理,即使 T 只定义了复制构造 std::is_move_constructible<T>::value 也为 true

这里有一个不太完善的黑科技来检测 T 是否同时存在复制构造和移动构造函数,而不是只存在复制构造函数而导致 std::is_move_constructible<T>::valuetrue

// FIXME: Current implementation returns true for const&& constructor and assignment.
template<class T>
struct worth_move {
    static_assert(!std::is_const<T>::value);

private:
    using U = typename std::decay<T>::type;

    struct helper {
        operator const U&() noexcept;
        operator U&&() noexcept;
    }; // struct helper

public:
    static constexpr bool value = std::is_class<T>::value 
                               && !std::is_trivially_copyable<T>::value
                               && std::is_move_constructible<T>::value
                               && !std::is_constructible<T, helper>::value;

}; // struct worth_move

重点在于这个 helper,它可以同时转换为 const U&U&& 两种类型,也就意味着它可以被复制构造和移动构造函数所接受,但是!不能同时被它们接受!因为这导致了函数调用选择二义性问题。所以如果 T 同时存在两个构造函数,T 就无法从 helper 的实例构造,所以 std::is_constructible<T, helper>::value 就为 false

但是会不会一个类两种构造函数都不存在,而导致 std::is_constructible<T, helper>::valuefalse 呢,其实是不会的,因为就算显式 delete 了构造函数,构造函数也是一个定义过的函数,不存在一个类不定义构造函数的可能。

所以最后我们用 SFINAE 来将 worth_move 的两个结果分别写了模板重载。

而这个 worth_move 实现不太完善指的是类还可以有一个非常特殊的构造函数 T(const T&&),虽然我不知道它的存在意义是什么,但是 worth_move 的逻辑无法区分它的存在,也没有任何解决办法。

(3) 向 std::vector 等容器行为兼容

最后解释一下最终代码里的第一个构造函数模板重载,它的存在只是因为我们需要允许这样的用法:

std::initializer_list<WorthMoveObject> il{};
std::vector<WorthMoveObject> v(il);

这是完全合法的,但是由于上面一通操作,WorthMoveObject 符合 worth_move,构造函数只存在一个接受 std::initializer_list<move_proxy<T>> 的版本,il 无法转换为这个类型。

所以我们定义了 template<class InitializerList>,并且用 SFINAE 确定了 InitializerList 推导出的结果只能为 std::initializer_list<T>,作为一个 backup 函数。因为 il 的类型已经是 std::initializer_list<WorthMoveObject> 类型了,不存在模板推导不出的问题。而由于它与另两个模板的特化程度不同,所以就算同时存在也不会有函数调用二义性问题。



Ciel's Paperplane

作诗中...