`std::optional` 相较于 `std::shared_ptr` 和 `std::unique_ptr` 有什么优势?

13

std::optional的推理是通过表明它可能包含值,也可能不包含值。因此,如果我们不需要它,它可以节省我们构造一个可能很大的对象的工作量。

例如,在这里的一个工厂,如果某个条件不满足,就不会构建该对象:

#include <string>
#include <iostream>
#include <optional>

std::optional<std::string> create(bool b) 
{
    if(b)
        return "Godzilla"; //string is constructed
    else
        return {}; //no construction of the string required
}

但这又与这个有何不同呢:
std::shared_ptr<std::string> create(bool b) 
{
    if(b)
        return std::make_shared<std::string>("Godzilla"); //string is constructed
    else
        return nullptr; //no construction of the string required
}

在一般情况下,使用std::shared_ptr和添加std::optional相比,我们能获得什么优势?


首先,它更冗长。 - SingerOfTheFall
1
@molbdnilo 我觉得 std::optional 做得有些过头了。以前我在与我的博士导师进行激烈争论的时候,他总是说 C 比 C++ 更好,因为你可以通过一本 300 页的书学习 C。 - The Quantum Physicist
@TheQuantumPhysicist,我可以问一下你的博士学位是在哪个领域吗? - SingerOfTheFall
@SingerOfTheFall 我的博士研究领域是粒子和原子物理学。实际上,使用std::optional而不是std::shared_ptr的更好的推理是其中一个答案,并且对我来说很有意义,因为不需要动态分配。顺便说一下,我总是不同意我的导师,我不认为C语言“更好”,但我只是在说它对外行人看起来是什么样子(我目前是C和C++开发人员)。此外,我认为与typedef的比较并不公平。我认为像size_t这样的typedef非常有用,可以实现向前兼容,并且不像std::optional那样是语言中的新构造。 - The Quantum Physicist
3
std::optional不是一种新的语言结构,它只是一个标准库类型,类似于std::stringstd::size_t。(顺便说一句,我推荐您观看由Null引用发明者Tony Hoare所演讲的“空引用:价值数十亿美元的错误”视频。) - molbdnilo
显示剩余3条评论
5个回答

19

使用std::optional相对于仅使用std::shared_ptr,我们可以获得什么优势?

假设你需要从函数返回一个带有“非值”标志的符号。如果你使用std::shared_ptr,你会有很大的开销-char将被分配在动态内存中,加上std::shared_ptr将维护控制块。而另一方面std::optional

如果可选对象包含一个值,则该值保证作为可选对象占用空间的一部分进行分配,即永远不会发生动态内存分配。因此,可选对象模拟一个对象,而不是指针,尽管定义了operator*()和operator->()。

因此,不涉及动态内存分配,与原始指针相比的差异可能是显着的。


9
std::optional::value_or 单独来说也是值得的。 - Caleth

11
一个可选类型是一个可空的值类型。
shared_ptr是一种可计数的可空引用类型。 unique_ptr是一种只能移动但可空的引用类型。
它们共同之处在于它们是可空的-它们可以“不存在”。
它们之间的不同在于两个是引用类型,而另一个是值类型。
值类型有几个优点。首先,它不需要在堆上分配——它可以与其他数据一起存储。这消除了可能的异常源(内存分配失败),可以更快(堆比栈慢得多),并且更加缓存友好(因为堆倾向于相对随机地排列)。
引用类型具有其他优点。移动引用类型不需要移动源数据。
对于非移动引用类型,可以通过不同的名称具有对同一数据的多个引用。两个具有不同名称的不同值类型始终引用不同的数据。这可以是优势或劣势,但它确实使对值类型的推理容易得多。
推理shared_ptr极其困难。除非对使用它的方式施加非常严格的控制,否则几乎不可能知道数据的生命周期是什么时候。推理unique_ptr要容易得多,因为您只需要追踪它被移动到哪里。推理optional的生命周期很简单(嗯,就像您将其嵌入的那样简单)。
optional接口已增加了一些类似于单子的方法(如.value_or),但这些方法通常可以轻松地添加到任何可空类型中。尽管如此,目前它们适用于optional而不是shared_ptr或unique_ptr。
optional的另一个重大好处是它非常清楚您希望它有时为空。在C++中存在一种不良习惯,即假定指针和智能指针不为null,因为它们用于其他原因而不是可空。
因此,代码假设某个共享或唯一指针永远不会为null。通常情况下它也有效。
相比之下,如果您有一个optional,那么唯一的原因是因为它实际上可能为null。
在实践中,我对于以“unique_ptr<enum_flags> = nullptr”作为参数感到犹豫,在那里我想说“这些标志是可选的”,因为强制调用者进行堆分配似乎不礼貌。但是,optional<enum_flags>并不会强制调用者这样做。optional的便宜使我愿意在许多情况下使用它,如果我所拥有的唯一可空类型是一个智能指针,那么我将找到其他解决方法。
这样做可以减少“标志值”的诱惑,比如int rows=-1;。而使用optional<int> rows;则更加清晰明了,在调试时会告诉我是否在使用未检查的“空”状态下的行数。
可以避免使用标志值或堆分配并返回optional<R>的函数,可以合理地失败或不返回任何有趣的内容。例如,假设我有一个可放弃的线程池(例如,当用户关闭应用程序时停止处理的线程池)。
我可以从“队列任务”函数返回std::future<R>并使用异常来指示线程池已被放弃。但这意味着必须审核所有线程池的使用情况以检查“来自”异常代码流。
相反,我可以返回std::future<optional<R>>,并向用户提示他们必须在逻辑中处理“如果进程从未发生会怎样”的情况。
“来自”异常仍然可能发生,但它们现在是异常情况,而不是标准关闭过程的一部分。
在某些情况下,expected<T,E>将是更好的解决方案,一旦它进入标准。

4
一个指针可以是空值或非空值。这是否对您有意义完全取决于您的理解。在某些情况下,nullptr是您要处理的有效值,在其他情况下,它可以用作表示“没有值,请继续”的标志。
使用std::optional,可以明确地定义“包含值”和“不包含值”。甚至可以将指针类型与可选项一起使用!
以下是一个人为的例子:
我有一个名为Person的类,我想从磁盘上懒加载数据。我需要说明是否已加载某些数据。让我们使用一个指针来实现:
class Person
{
   mutable std::unique_ptr<std::string> name;
   size_t uuid;
public:
   Person(size_t _uuid) : uuid(_uuid){}
   std::string GetName() const
   {
      if (!name)
         name = PersonLoader::LoadName(uuid); // magic PersonLoader class knows how to read this person's name from disk
      if (!name)
         return "";
      return *name;
   }
};

太好了,我可以使用nullptr值来判断名称是否已经从磁盘加载。

但如果字段是可选的呢?也就是说,PersonLoader::LoadName()可能会为此人返回nullptr。我们真的每次有人请求这个名字时都去磁盘寻找吗?

这时就需要使用std::optional。现在我们可以跟踪是否已经尝试加载了该名称及其是否为空。没有std::optional,解决这个问题的方法是为名称和每个可选字段创建一个布尔值isLoaded。(如果我们“只将标志封装到结构体中”怎么办?那么你就实现了optional,但效果不如原生的好):

class Person
{
   mutable std::optional<std::unique_ptr<std::string>> name;
   size_t uuid;
public:
   Person(size_t _uuid) : uuid(_uuid){}
   std::string GetName() const
   {
      if (!name){ // need to load name from disk
         name = PersonLoader::LoadName(uuid);
      }
      // else name's already been loaded, retrieve cached value
      if (!name.value())
         return "";
      return *name.value();
   }
};

现在我们不需要每次都去磁盘上查找了;std::optional 允许我们检查这一点。我在评论中写了一个小例子,展示了这个概念的更小规模的应用。

现在我们可以追踪是否已经尝试加载名称以及该名称是否为空。我不理解这个。您是建议在您的示例中将unique_ptr替换为指向optional<std::string>的指针,还是实际的optional<std::string *>,或者其他什么?使用普通的optional<std::string>成员对象不能给您所讨论的三态可能性。 - davmac
@davmac:我正在讨论一个optional<unique_ptr<std::string>>这里是一个代码示例 - AndyG

3
重要的是,如果您尝试访问不存在的optional中的value(),您会得到一个已知的、可捕获的异常,而不是未定义的行为。因此,如果使用optional发生问题,调试可能比使用shared_ptr或类似物品要轻松得多。(请注意,在这种情况下,optional上的*解引用运算符仍然会导致UB;使用value()是更安全的替代方法)。
此外,还有一般方便的方法,例如value_or,可以轻松地指定一个“默认”值。比较如下:
(t == nullptr) ? "default" : *t

使用

t.value_or("default")

后者更易读且稍微更短。

最后,在 optional 中,项目的存储是在对象内部完成的。这意味着如果对象不存在,则 optional 需要比指针更多的存储空间;然而,这也意味着将对象放入空的 optional 中不需要进行动态分配。


0
通过在一般情况下添加std::optional而不仅仅使用std::shared_ptr,我们获得了什么好处?
@Slava提到了执行无内存分配的优点,但这是一个附加好处(在某些情况下可能是重要的好处,但我的观点是,它不是主要的好处)。
主要好处是(我的看法)更清晰的语义:
返回指针通常意味着(在现代C ++中)“分配内存”、“处理内存”或“知道内存中这个和那个的地址”。
返回可选值意味着“没有此计算的结果,不是错误”:返回类型的名称告诉您有关API如何构思的信息(API的意图,而不是实现)。
理想情况下,如果您的API不分配内存,则不应返回指针。
在标准中提供可选类型,确保您可以编写更具表达性的API。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接