使用 initializer_list & variant 优雅地构造 NestedInteger
潘忠显 / 2021-09-14
之前文章提到,我个人在刷 LeetCode 时,会利用自己搭建的框架,编写测试用例。在刷到题目#341时,需要构造一个 NestedInteger 的类。简单的讲,它有以下特点:
- NestedInteger 中的可以是一个整数元素,或者是一个向量
- 如果 NestedInteger 是向量,向量中的元素是 NestedInteger,既每个元素可以是整数也可以是向量
- 直观的表示
{1, {2, {3, 4}}, 5}
本文介绍如何使用 C++ 优雅的实现 NestedInteger 这个类,其中涉及到使用 std::initializer_list 和 std::variant,最后着重介绍了 initializer_list 的各种情形下的推导细节。
一、直观实现
根据上边的描述,直观地 NestedInteger 实现可以封装整数和向量两种类型,再额外使用一个枚举类型来标记对象表示的是何种类型。因为同一时间只可能是同一种类型,可以利用 union,但是 union 对其中的类型有严苛的限制,这里直接使用两个不同类型的成员变量存储:
class NestedInteger {
public:
// ... constructor/destructor and get/set APIs
private:
enum { INT, VECTOR } type_;
int val_int_;
std::vector<NestedInteger> val_vec_;
};
二、使用 std::initializer_list
我们知道 C++ 11 开始支持 列表初始化(List Initialization),比如因为 std::vector 有这样一个构造函数:
template<typename value_type, typename allocator_type >
vector(initializer_list<value_type> __l,
const allocator_type& __a = allocator_type());
因此,我们可以方便的在构造向量的时候,直接传入向量内容。
// c++11 initializer list syntax:
std::vector<std::string> words{"hello", "world"};
可不可以 {1, {2, {3, 4}}, 5} 去构造我们定义的 NestedInteger 呢? 答案是肯定的。
首先,我们要考虑输入初始化列表是 {2} 的情况,直接接收一个整数作为参数的构造函数可以支持[隐藏细节1]:
NestedInteger(int i) : type_(INT), val_int_(i) {}
其次,我们考虑如果输入初始化列表是 {1, 2} 则意味着需要产生一个 NestedInteger,而其中的 vector<NestedInteger> 存储的是储存着保存 Integer 值的类型。[隐藏细节2] 因此,这里对应的构造函数结构应该是:
NestedInteger(std::initializer_list<NestedInteger> ni) {
for (auto it = ni.begin(); it != ni.end(); it++) {
val_vec_.push_back(*it);
}
}
最后,上边的构造函数还能够接收 {1, {2, {3, 4}}, 5} 形式的非初始化列表。
这里的首先-其次-最后,隐藏了关于初始化列表的许多一些细节,会在最后一部分再做详细讨论。
三、使用 std::variant
之前的实现,一共是使用了三个成员变量:
- 两个变量来存储 int 或者 vector 嵌套类型
- 一个枚举变量来存储的对象对应的类型
直观上,两个存储变量的变量可以存储在一个 union 中,但是 union 对属性类型有非常严格的要求 。
从 C++ 17 开始,有一个 std::variant 类模板,表示一个类型安全的联合体。 std::variant 的一个实例在任意时刻要么保有其一个可选类型之一的值,要么在错误情况下无值。详见 cppreference 说明
通过一个 std::variant<int, std::vector<NestedInteger>> 类型的成员变量,就可以替代上述三个变量。具体的:
- 使用
std::holds_alternative<T>(value)来判断是否存储的指定类型T - 使用
std::get<T>(value)来获取指定类型所存储的值 - 直接
value = i进行赋值,或使用成员初始化器列表中直接使用value(i)来赋值 - 以上的类型
T可以是int或std::vector<NestedInteger>
四、最终代码
#include <cassert>
#include <variant>
#include <vector>
class NestedInteger final {
public:
NestedInteger(int i) : value(i) {}
NestedInteger(std::initializer_list<NestedInteger> ni) : value(ni) {}
inline bool isInteger() const { return std::holds_alternative<int>(value); }
inline int getInteger() const {
assert(std::holds_alternative<int>(value));
return std::get<int>(value);
}
inline const std::vector<NestedInteger>& getList() const {
assert(std::holds_alternative<std::vector<NestedInteger>>(value));
return std::get<std::vector<NestedInteger>>(value);
}
private:
std::variant<int, std::vector<NestedInteger>> value;
};
TL;DR 深入理解初始化列表
通过提出几个问题并对其进行解释,来深入的理解一下初始化列表的底层逻辑。
1. 为什么定义了 NestedInteger(int i) 就能使用 ni{1}
其实定义了 NestedInteger(int i) 之后,直接能使用的表达式是:
NestedInteger ni(1);
auto nip = new NestedInteger(2);
而 NestedInteger ni{1}; 是会先找 NestedInteger(initializer_list) 的定义,如果找不到,会将花括号中的内容展开,作为参数,寻找对应的构造函数。
class C {
public:
C(int i) { std::cout << "C(int)" << std::endl; }
C(std::initializer_list<C> i) {
std::cout << "C(initializer_list)" << std::endl;
}
};
int main() {
C c1(1);
std::cout << "---" << std::endl;
C c2{1};
}
输出:
C(int)
---
C(int)
C(initializer_list)
2. 为什么不能使用自动推导类型的模板
这个问题描述的更清楚一点就是,下边的构造函数声明/定义:
NestedInteger(std::initializer_list<NestedInteger> ni);
如果改写成:
template<typename T>
NestedInteger(std::initializer_list<T> ni);
针对以下若干种场景:
NestedInteger ni1{1};
NestedInteger ni2{1, 2, 3};
NestedInteger ni3{{1}, 2, 3};
NestedInteger ni4{{1, 2, 3}};
NestedInteger ni5{{1, 2, 3}, {4, 5, 6, 7}};
会发生:
- 前两种情况,就是普通的
initializer_list,能够自动推导出 T 为int ni3{{1}, 2, 3};中,{1}与没有大括号的1一样,事实上会发出在1上使用了多余的{}的警告,因此等价于第二种情况,自动推到出类型T为intni4{{1, 2, 3}};中的两层{},无法推导出内层的{1,2,3}是何种类型T,因此需要将外层中的括号去掉,并将其中的每个变量作为参数,继续寻找可以的构造函数,既等价于ni4({1, 2, 3}),而这一表达式明显符合NestedInteger(std::initializer_list<T> ni);- [编译错误]
ni5{{1, 2, 3}, {4, 5, 6, 7}};中也是两层{},与ni4情况相似,在无法推到第一层之内的元素类型后,将每个元素一起作为参数寻找构造函数,很明显接收两个参数的构造函数。会编译报错。
3. 为什么可以使用指定元素的构造函数
当使用 NestedInteger(std::initializer_list<NestedInteger> ni); 声明/定义时,同样考虑之前的 5 个变量定义:
- 前两种情况,依然是普通的 initializer_list,但是在构造
std::initializer_list<NestedInteger>的时候,会先通过整数构造NestedInteger。也就是会先调用三次NestedInteger(int),然后再调用NestedInteger(initializer_list) ni3{{1}, 2, 3};中,其中{1}会先调用NestedInteger(int),再NestedInteger(initializer_list)构造 NestedInteger,然后调用两次NestedInteger(int)用2, 3构造 NestedInteger。最后再调用一次NestedInteger(initializer_list)构造出ni3ni4{{1, 2, 3}};中的两层{},最外层要在最后调用一次NestedInteger(initializer_list)构造n4,中间的一层括号倒数第二被调用的NestedInteger(initializer_list),最先调用的还是 3 次NestedInteger(int)- 与
ni4类似,先 3 次NestedInteger(int)再一次NestedInteger(initializer_list)构造出第一个{},再调用 4 次NestedInteger(int)再一次NestedInteger(initializer_list)构造出第二个{}。最后在调用一次NestedInteger(initializer_list)构造出ni5
