C++17之std::optional全方位详解

1. 引言

编程中,我们经常会需要表示或处理一个“可能为空”的变量,可能是一个为包含任何元素的容器,可能是一个类型的指针没有指向任何有效的对象实例,再或者是一个对象没有被赋予有效的值。通常处理这类问题意味着写更多的代码来处理这些“特殊”情况,很容易导致代码变得冗余,可读性变差或者容易出错。比如,我们很容易想到的如下三种方法:

  1. 使用特殊值标记,如-1, infinity或者nullptr。这种方法几乎是最常用的方法,在调用一个对象之前,需要先将其与特殊值进行比较保证其有效性。但是这种方法可能比较脆弱,因为在有些corner case下,这些“特殊值”可能也有意义。
  2. 如果函数可能出错导致返回结果是无效值,我们会引入boolean或者error code作为函数返回值来表示结果是否有意义。但是这种方法会使函数接口变得隐晦,因为接口的使用者可能并不会检查函数返回值而直接使用结果。
    bool function(tResult & result);
    
  3. 抛出异常。这样我们就必须引入try-catch代码块来处理这些异常,使得代码变得冗余,可读性变差。

C++17中的std::optional<T>为解决这类问题提供了简单的解决方案。optional<T>可以看作是T类型变脸与一个布尔值的打包(wrapper class)。 其中的布尔值用来表示T是否为“空”。

template <typename T>
class optional
{
	bool _initialized;
	std::aligned_storage_t<sizeof(T), alignof(T)> _storage;
public: 
// operations 
};

2. 快速上手

使用std::optional我们可以写出如下的代码:

std::optional<std::string> tItem::findShortName()
{
	if (hasShortName)
	{
		return mShortName;
	}
	return std::nullopt;
}
// 使用
std::optional<std::string> shortName = item->findShortName();
if (shortName)
{
	PRITNT(*shortName);
}

在上面的例子中,我们定义了函数findShortName返回一个包含字符串类型的optional对象。如果商品(tItem)有缩写名称,则返回缩写,否则将返回nullopt表示缩写名称为空。optional类型可以隐式转换为boolean类型来表示当前是否有有效值,同时optional支持操作符*来进行取值。从这个例子中我们可以看出来,用optional作为函数返回值可以更好地解决引言中的问题,函数更简洁同时接口含义也更明确。

3. 创建std::optional的方式

  • 初始化为空
  • 直接用有效值初始化
  • 使用 std::make_optional构造
  • 使用std::in_place构造
  • 使用其它optional对象构造(拷贝,移动)

如以下代码所示:

//初始化为空
std::optional<int> emptyInt;
std::optional<double> emptyDouble = std::nullopt;
//直接用有效值初始化
std::optional<int> intOpt{10};
std::optional intOptDeduced{10.0}; // auto deduced
//使用make_optional
auto doubleOpt = std::make_optional(10.0);
auto complexOpt = std::make_optional<std::complex<double>>(3.0, 4.0);
//使用in_place
std::optional<std::complex<double>> complexOpt{std::in_place, 3.0, 4.0};
std::optional<std::vector<itn>> vectorOpt{std::in_place, {1, 2, 3}};
//使用其它optional对象构造
auto optCopied = vectorOpt;

3.1 使用in_place / make_optional进行构造

std::optional的其中一个构造函数接受U&&(U为可转换为optional底层类型的类型)作为参数进行构造。

template <typename U>
constexpr optional(U&& value);

因此对于可以转换为optional底层类型的类型,我们可以将其直接传入optional构造函数,从而节省了一次临时对象的构造和拷贝。

std::optional<std::string> strOpt {"hello world"};

那为何我们还需要in_place / make_optional来对optional对象进行“原地”构造呢?主要是考虑到如下3种情形:

  1. optional存储的对象需要使用默认构造函数进行构造
  2. optional内部存储对象不支持拷贝和移动(non-copyable,non-movable)
  3. 提高构造函数有多个参数的类型对象的构造效率

3.1.1 使用默认构造函数

如果我们有这样一个类,它提供一个默认构造函数如下:

class tSampleClass
{
public:
	tSampleClass() : mInt(100)
	{
	}
};

如果我们想用默认函数构造的tSampleClass对象构造optional,代码应该怎么写呢?
你可能会想到如下写法:

std::optional<tSampleClass> sample;
std::optional<tSampleClass> sample{};

但是这两种方法得到的结果都只是空的optional对象,而不是包含默认构造值的对象。
你还可以这么写:

std::optional<tSampleClass> sample{tSampleClass()};

这种方法是可以工作的,我们将得到包含默认tSampleClass对象的optional对象。但是在上面的代码中,将先构造出一个tSampleClass的临时对象,然后调用move函数将这个临时对象“移动”到optional存储的对象中,带来了额外的开销。在这种情况下,我们就可以使用std::in_place_t / std::make_optional来“原地”构造optional底层存储的对象。

std::optional<tSampleClass> opt{std::in_place};
auto opt = std::make_optional<tSampleClass>();

此时opt存储的tSampleClass是被“原地”构造出来的,不会引入额外的copy或者move。

3.1.2 non-copyable/movable类型

假如我们的tSampleClass类型不支持移动和拷贝:

class tSampleClass
{
public:
	tSampleClass() : mInt(100)
	{
	}
	tSampleClass(const tSampleClass &) = delete;
	tSampleClass & operator= (const tSampleClass &) = delete;
	tSampleClass(tSampleClass &&) = delete;
	tSampleClass & operator= (tSampleClass &&) = delete;
};

在上面的例子中我们看到,如果使用一个临时的对象来初始化optional,那么会调用移动或者拷贝构造函数。显而易见,对于上述移动和拷贝构造函数被禁用的类型(如:std::mutex),我们就只能使用std::in_palce来初始化optional了。

3.1.3 多个构造函数参数

当构造函数有个多个参数时,也推荐使用原地构造的方法来提高效率。optional提供如下构造函数来处理多参数原地构造的情况:

template <typename... tArgs>
constexpr explicit optional(std::in_place_t, tArgs&&... args);

template <typename U, typename.. tArgs>
constexpr explicit optional(std::in_place_t, std::initializer_list<U> list, tArgs&&... args);

若我们需要够造一个多参数optional,代码可以写成如下形式:

std::optional<std::complex<double>> complexOpt(std::in_place, 3.0, 4.0); //第一个构造函数

std::optional<std::vector<int>> vectOpt(std::in_place, {1, 2, 3}); //第二个构造函数

auto complexOpt = std::make_optional<std::complex<double>>(3.0, 4.0);
auto vectOpt = std::make_optional<std::vector<int>>({1, 2, 3});

4. optional对象作为函数返回值

如果用optional对象作为函数返回值,那么我们将很容易地解决引言中所述的问题。如果函数失败,则返回std::nullopt表示没有有效返回值,否则就直接返回计算值。这样代码将更将简介,可靠。

std::optional<int> findStudent(const std::map<std::string, int> & students, const std::string & name)
{
	if (students.find(name) == students.end())
	{
		return std::nullopt;
	}
	return students[name];
}

//使用
auto studentId = findStudent(students, "Bob");

C++17引入了guaranteed copy illision,上例中的optional对象在调用处构造。
说到函数返回值,有一个有趣的问题值得讨论,先来看如下代码:

std::optional<std::string> createString()
{
	std::string result{"Hello world!"};
	return {result}; //产生拷贝
	// return result; //只产生move
}

根据C++标准,在函数体内部的临时变量作为函数返回值时,临时变量将被move到目标变量中,而不是被copy过去。但是当我们用{}将变量名括起来时,临时变量将强制被copy而不是被move。对于non-copyable的类型,如std::unique_ptr,见如下例子:

std::unique_ptr<double> nonCopyableReturn()
{
	std::unique_ptr<double> p = nullptr;
	return {p}; //强制产生拷贝,将产生编译错误,因为unique_ptr为non-copyable类型
	// return p; //move语义,unique_ptr可以move,编译通过。
}

5. optional的其他操作

5.1 访问存储值

// operator* 和 operator->
// operator* 返回内部存储对象的引用,operator->返回指向内部存储对象的指针
// 如果没有有效值,则行为未定义
std::optional<std::string> opt{"abc"};
std::cout << "content is " << *opt << ", size is " << opt->size() << std::endl;

// value()
// 返回内部存储对象的值,当optional为空时抛出std::bad_optional_access异常
try
{
	std::cout << "content is " << opt.value() << std::endl;
}
catch(const std::bad_optional_access & e)
{
	std::cout << e.what() << std::endl;
}

// value_or(defaultValue)
// optional有有效值时返回有效值,否则返回默认值
std::optional<int> optInt(100);
std::cout << "value is " << optInt.value_or(10) << std::endl;

5.2 修改存储值以及存储对象的生命周期

对于一个已经存在的optional对象,通过调用emplace, reset, swap, operator=,可以将其中存储的值修改掉。如果调用operator=或者reset将optional对象赋值为nullopt,若之前的optional存储有有效值,则存储类型的析构函数将被调用。除此之外,每次optional内部存储对象被重置,之前对象的析构函数都会被调用。

class tStudent
{
public:
	explicit tStudent(std::string str)
	: m_name(str)
	{}
	~tStudent() = default;
};
// 构造空的optional
std::optional<tStudent> optStudent;
// 构造名字为“Bob”的tStudent对象存储在optional对象中
optStudent.emplace("Bob");
// 相当于
// optStudent = tStudent{"Bob"};
// "Bob"对象析构,构造"Steve"
optStudent.emplace("Steve")
// "Steve"对象析构
optStudent.reset();

5.3 比较大小

对于定义了<,>,==操作符的类型,保存他们的optional对象也可以比较大小。如optional<int>之间比较大小和直接比较int数值的大小是一样的。比较特殊的是std::nullopt,在比较大小时,它总小于存储有效值的optional对象。

std::optional<int> int1(1);
std::optional<int> int2(10);
std::optional<int> int3;

std::cout << std::boolalpha;
std::cout << (int1 < int2) << std::endl; // true
std::cout << (int2 > int1) << std::endl; // true
std::cout << (int3 == std::nullopt) << std::endl; // true
std::cout << (int3 < int1) << std::endl; // true

6. 内存

使用optional包装原始类型意味着需要存储原始类型的空间和额外的boolean flag,因此optional对象将占有更多的内存空间。此外,optional对象的内存排列须遵循与内部对象一致的内存对齐准则。

template <typename T>
class optional
{
	bool _initialized;
	std::aligned_storage_t<sizeof(T), alignof(T)> _storage;
public: 
// operations 
};

假如sizeof(double) = 8,sizeof(int) = 4,则:
std::optional<double> optDouble; // sizeof(optDouble) = 16
std::optional<int> optInt; // sizeof(optInt) = 8

总结

  • std::optional用来包装可以为空的类型
  • std::optional或者为空,或者包含一个有效值
    • 使用operator *,operator->,value()或者value_or()来访问有效值
  • std::optional可以音质转换为布尔类型,因此我们可以很方便地检查有效值是否存在
  • 46
    点赞
  • 136
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值