关于std::optional传递开销的讨论与优化

在讨论std::optional之前,我们应该先适当谈论一下“可空类型”。

我们知道,在传统的C++中,是不存在现代编程语言中常见的“可空类型”(如C#中的Nullable)的,这就导致很多情况下我们无法给一个指针以外的变量或返回值设置一个安全的空值(实际上C++11之前用于指针空值的NULL也并不安全),而需要设置一个人为规定的标记值(Sentinel Value)来将其标识为空。

1
2
3
4
5
6
7
//C#
//size理应为非负
uint? size = null; //OK, 通过C#语法糖[int?]标记size可为空,并将size设置为空
//C++
//因为需要考虑Sentinel Value,故size无法为unsigned int
//当size == -1时,size为空
int size = -1; //OK,通过人为设定标记值来将size设置为空

显而易见,传统C++使用标记值来对某一变量设置为空的行为相比现代语言是存在问题、丑陋且不安全的:

  • 某些情况下为了考虑标记值不得不放弃最为合适的数据类型(比如unsigned int之于size)
  • 其他使用者容易忘记甚至不了解标记值的含义
  • ……

std::optional

为了解决这一问题,C++17中引入了std::optional,实现了安全的可空值。

1
std::optional<unsigned int> size = std::nullopt;  //通过std::nullopt将size设置为空

这很好,极大提高了代码的安全性与严谨性。
但同时又引入了一个新问题,当我们将一个对象(比如std::string)做参数传递时,我们总想避免传递过程中无意义的拷贝、移动等操作带来的额外开销,比如:

1
void Func(const std::string& str);  //引用传递,不会发生拷贝,当Func不改变str时将其修饰为const

那么在使用optional做参数、返回值传递时,optional包裹着的对象是否会进行拷贝、移动等增大程序开销的操作呢?如果会,该如何避免?

开销测试与优化

为了解决这个问题,我编写了如下测试代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//测试类
class Test
{
public:
std::string s = "Fuxk Cpp.";

Test()
{ std::cout << "\tctor\n"; }
~Test()
{ std::cout << "\tdtor\n"; }
Test(const Test&)
{ std::cout << "\tcopy ctor\n"; }
Test(Test&&) noexcept
{ std::cout << "\tmove ctor\n"; }
Test& operator=(const Test&)
{ std::cout << "\tcopy op= \n"; return *this; }
Test& operator=(Test&&) noexcept
{ std::cout << "\tmove op= \n"; return *this; }
};

首先是一个很简单的测试类,包含一个std::string类型的字段s,以及基于五法则编写的用于debug的析构函数、拷贝/移动构造函数和重载op=运算符。
然后是用于测试的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//测试函数
void OptFunc(std::optional<Test> x)
{
std::cout << std::format("\t{}\n", x->s);
}
void DefaultFunc(const Test& x)
{
std::cout << std::format("\t{}\n", x.s);
}
//测试代码
int main()
{
Test t0;
std::cout << "-----------------------------------------\n";
std::cout << "Use Optional:\n";
std::cout << "copy:\n";
OptFunc(t0);
std::cout << "-------------\n";
std::cout << "move:\n";
OptFunc(Test());
std::cout << "-----------------------------------------\n";
std::cout << "Not use Optional:\n";
std::cout << "copy:\n";
DefaultFunc(t0);
std::cout << "-----------------\n";
std::cout << "move:\n";
DefaultFunc(Test());
std::cout << "-----------------------------------------\n";

return 0
}

这段代码定义了两个函数OptFunc()和DefaultFunc(),以分别表示形参使用和不使用optional的情况。
在Vsiual Studio 2022 17.0.2,/std:c++latest下的运行结果为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
        ctor
-----------------------------------------
Use Optional:
copy:
copy ctor
Fuxk Cpp.
dtor
-------------
move:
ctor
move ctor
Fuxk Cpp.
dtor
dtor
-----------------------------------------
Not use Optional:
copy:
Fuxk Cpp.
-----------------
move:
ctor
Fuxk Cpp.
dtor
-----------------------------------------
dtor

可以很明显的看出,在使用std::optional作为形参的情况下,调用两个测试函数分别多了一次拷贝/移动构造函数和析构函数,这很显然不是我们想看到的。
那么有什么方案解决这个问题吗?

1
void OptFunc(const std::optional<Test>& x);  //这样?

不行,虽然在形式上这与const Test&非常相似,但实际上当Test类型的参数传入时,将会被拷贝到std::optional类型的对象x中,之后x将以引用的形式继续传递到函数中。所以实际上在这种写法中我们并没有避免x的拷贝开销,当然,如果你的传入参数本身就是optional时,这或许会有用——不过一般这种情况非常罕见。
那么这样呢?

1
void OptFunc(std::optional<const Test&> x);

这看起来好像是可行的,既然修饰optional不行,那么我们就对原对象下手。然而很遗憾的是,C++并不支持这样做,即std::optional<Test&>。

若以引用类型实例化optional则程序非良构。

这样一来好像走到了死胡同。但C++还是给我们留了一个缺口,即std::reference_wrapper。

std::reference_wrapper是包装引用于可复制、可赋值对象的类模板。它常用作将引用存储入无法正常保有引用的标准容器(类似std::vector)的机制。
可用T类型的std::reference_wrapper的optional保有引用。

通过reference_wrapper,我们可以做到等同于std::optional<const Test&>的效果,我们添加一个函数并编写新的测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//新添加测试函数
void OptWithRefWrapperFunc(std::optional<std::reference_wrapper<const Test>> x)
{
std::cout << std::format("\t{}\n", x->get().s);
}

//新添加测试代码
int main()
{
//...
std::cout << "Use Optional with std::reference_wrapper:\n";
std::cout << "copy:\n";
OptWithRefWrapperFunc(t0);
std::cout << "-----------------\n";
std::cout << "move:\n";
//FuncOptWithRefWrapper(Test());
std::cout << "-----------------------------------------\n";
}

再次运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
        ctor
-----------------------------------------
Use Optional:
copy:
copy ctor
Fuxk Cpp.
dotr
-------------
move:
cotr
move cotr
Fuxk Cpp.
dtor
dtor
-----------------------------------------
Not use Optional:
copy:
Fuxk Cpp.
-----------------
move:
ctor
Fuxk Cpp.
dtor
-----------------------------------------
Use Optional with std::reference_wrapper:
copy:
Fuxk Cpp.
-----------------
move:
-----------------------------------------
dtor

可以看到,这样一来,我们终于在optional上实现了传统的const T&的效果。但同时需要注意的是,正如无法使用一个Test&类型的对象接收一个右值(如Test()),std::reference_wrapper同样无法接收一个右值,所以我们看到,在新的测试代码中,尝试将右值传入函数的FuncOptWithRefWrapper(Test())被注释掉了。

最后一件事

虽然我们最终通过std::optional<std::reference_wrapper>的形式实现了安全可空同时兼顾开销的参数传递,但不可否认的是这样写出的代码实在太过复杂繁琐。(虽然很多情况下复杂和繁琐本身就是C++体验的一环)
所以说——或许看到这里的很多朋友也已经发现,上述这一串复杂冗余的代码其实就等价于const T,指针也早在C++11就拥有了安全的空值nullptr(实际上reference_wrapper的本质就是把引用保存为指针),甚至T占用的内存还要比std::optional<std::reference_wrapper>小。

1
2
3
4
5
std::cout << std::format("{}, {}\n", sizeof(const Test*), 
sizeof(std::optional<std::reference_wrapper<const Test>>));
//output:
//x86: 4, 8
//x64: 8, 16

如果真要说有什么使用上的优势,那么应该就是相比T表义更为清晰、以及在传参时不需要加取地址符&了吧,或许这对部分人来说很重要,但为了方便一般还是推荐使用T,只在传递足够小的对象比如int时,使用std::optional。

Reference

https://abseil.io/tips/171
https://zh.cppreference.com/w/cpp/utility/optional
https://stackoverflow.com/a/47842325/12822957
https://abseil.io/tips/163
https://zh.cppreference.com/w/cpp/utility/functional/reference_wrapper

原文链接: https://zhuanlan.zhihu.com/p/438821425