optional_and_variant

optional

面向对象(引用语义)的语言有时会使用空值 null 表示没有找到需要的对象。

也有人推荐使用一个特殊的空对象,来避免空值带来的一些问题。

空值和空对象,对于一个返回普通对象(值语义)的 C++ 函数都是不适用的,空值和空对象只能用在返回引用 、指针的场合,一般情况下需要堆内存分配,在 C++ 里会导致额外的开销。

C++17 引入的 optional 模板可以(部分)解决这个问题,optional 代表一个也许有效、可选的对象,一个 optional 对象有点像一个指针,但它所管理的对象是直接放在 optional 里的,没有额外的内存分配。

构造一个 optional<T> 对象:

  1. 不传递任何参数,或使用 std::nullopt(类比nullptr ),构造一个空的 optional 对象,里面不含有效值。
  2. 第一个参数 std::in_place,后面跟构造 T 所需的参数,可以在 optional 对象上直接构造出 T 的有效值。
  3. T 类型支持拷贝或者移动构造,构造 optional 时传递 T 的左值或右值来将 T 对象拷贝或移动到 optional 中。

第 1 种情况,optional 对象里是没有值的,在布尔值上下文里,会得到 false(类似于空指针的行为)。

第 2、3 两种情况,optional 对象里是有值的,在布尔值上下文里,会得到 true(类似于有效指针的行为)。

在 optional 对象有值的情况下,你可以用 * 和 -> 运算符去解引用(没值的情况下,结果是未定义行为)。

下面是一个简单实现。

template <typename T>
class m_optional
{
public:
	m_optional() noexcept :_empty(true){ }

	m_optional(T val) : _val(std::move(val)), _empty(false){}

	constexpr explicit operator bool() const noexcept{ return !_empty; }

	constexpr T& operator*() { return _val; }

	const T* operator->() const { return &_val; }

	constexpr T& val() { return _val; }

	constexpr bool has_val() const noexcept{ return !_empty; }

	template <class V>
	T value_or(V&& v) const
	{
		if (!_empty) return _val;	
		return static_cast<T>(std::forward<V>(v));
	}

private:
	T _val;
	bool _empty;
};

简单判断是否有值的递归实现。

template<typename T>
constexpr bool m_optional_has_val(const m_optional<T>& m_o) noexcept
{
	return m_o.has_val();
}

template<typename T, typename... Args>
constexpr bool m_optional_has_val(const m_optional<T>& m_o, const m_optional<Args>&... args) noexcept
{
	return m_o.has_val() && m_optional_has_val(args...);
}

lift_m_optional 接受一个函数,返回一个函数。在返回的函数里,参数是一个或多个 m_optional类型,res_type是用参数的值(value())去调用原先函数时的返回值类型,返回的则是 res_type 的 optional 封装。

函数会检查所有的参数是否有值,有值时会去拿参数的值去调用原先的函数,否则返回一个空的 optional 对象。

该函数能把一个原本要求参数全部有效的函数抬升(lift)成一个接受和返回 optional 参数的函数,并且只在参数全部有效时去调用原来的函数。

template <typename F>
auto lift_m_optional(F&& f) 
{ 
	return [f = std::forward<F>(f)](auto&&... args) 
	{ 
		typedef std::decay_t<decltype(f(std::forward<decltype(args)>(args).val()...))> res_type;
		if (m_optional_has_val(args...))
		{
			return m_optional<res_type>(f(std::forward<decltype(args)>(args).val()...));
		}
			
		return m_optional<res_type>();
	}; 
}

重载一个全局输出运算符。

template <typename T>
std::ostream& operator<<(std::ostream& os, m_optional<T> m_o)
{
	if (m_o) os << '(' << *m_o << ')';
	else os << "(empty)";
	return os;
}

测试。

auto f = lift_m_optional([](int a, int b) {return a + b; });
cout << f(m_optional<int>(), m_optional<int>()) << endl;	// (empty)
cout << f(m_optional<int>(1), m_optional<int>()) << endl;	// (empty)
cout << f(m_optional<int>(1), m_optional<int>(1)) << endl;	// (2)

variant

optional 可以把它看作是允许有两种数值的对象:放进去的对象或 nullopt(类比 nullptr)。

如果希望有三种或更多不同的类型,variant 是一个合适的解决方案。

如果不用 variant,要会使用一种叫做带标签的联合(tagged union)的数据结构。

struct FloatIntChar 
{
    enum 
    {
        Float,
        Int,
        Char
    } type;
    union {
        float float_value;
        int int_value;
        char char_value;
    };
};

这个数据结构的最大问题,就是它实际上有很多复杂情况需要特殊处理。对于上面例子里的 POD 类型,这么写就可以了(但我们仍需小心保证我们设置的 type 和实际使用的类型一致)。如果我们把其中一个类型换成非 POD 类型,就会有复杂问题出现。

编译器会合理地看到在 union 里使用 string 类型会带来构造和析构上的问题,所以会拒绝工作。要让代码工作,得手工加上析构函数,并且,在析构函数里得小心地判断存储的是什么数值,来决定是否应该析构。否则,默认不调用任何 union 里的析构函数,从而可能导致资源泄漏:

struct StringIntChar {
    ~StringIntChar() 
    { 
        if (type == String) 
        { 
            string_value.~string(); 
        } 
    }
    enum 
    {
        String,
        Int,
        Char
    } type;
    union 
    {
        string string_value;
        int int_value;
        char char_value;
    };
};

使用时也比较麻烦。

StringIntChar obj{
  .type = StringIntChar::String,
  .string_value = "Hello world"};
cout << obj.string_value << endl;

这里用到了按成员初始化的语法,把类型设置成了字符串,同时设置了字符串的值。这个语法虽然在 C99 里有,但在 C++ 里要在 C++20 才会被标准化,实际是有兼容性问题的。老版本的 MSVC,或最新版本的 MSVC 在没有开启 C++20 支持时,就不支持这个语法。

替换方式,就是 variant;从基本概念来讲,variant 就是一个安全的 union。

variant<string, int, char> obj{ "Hello world" };
cout << get<string>(obj) << endl; // Hello world

构造时使用的是 const char*,但构造函数仍然能够正确地选择 string 类型,这是因为标准要求实现在没有一个完全匹配的类型的情况下,会选择成员类型中能够以传入的类型来构造的那个类型进行初始化(有且只有一个时)。string 类存在形式为 string(const char*) 的构造函数,所以上面的构造能够正确进行。

variant 上可以使用 get 函数模板,其模板参数可以是代表序号的数字,也可以是类型;如果编译时可以确定序号或类型不合法,我们在编译时就会出错;如果序号或类型合法,但运行时发现 variant 里存储的并不是该类对象,则会得到一个异常 bad_variant_access。

variant 成员函数 index() 能获得当前的数值的序号。正常情况下,variant 里总有一个有效的数值(缺省为第一个类型的默认构造结果);如果 emplace 等修改操作中发生了异常,variant 里也可能没有任何有效数值, index() 将会得到 variant_npos。