`std::optional` 比 `std::shared_ptr` 和 `std::unique_ptr` 有啥优势?

Posted

技术标签:

【中文标题】`std::optional` 比 `std::shared_ptr` 和 `std::unique_ptr` 有啥优势?【英文标题】:What's the advantage of `std::optional` over `std::shared_ptr` and `std::unique_ptr`?`std::optional` 比 `std::shared_ptr` 和 `std::unique_ptr` 有什么优势? 【发布时间】:2017-08-13 06:40:29 【问题描述】:

std::optional 的推理是made by saying 它可能包含也可能不包含值。因此,如果我们不需要它,它就节省了我们构建一个可能是大对象的工作。

For example,这里的工厂,如果不满足某些条件,将不会构造对象:

#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::optional 而不是通常使用std::shared_ptr 赢得了什么?

【问题讨论】:

一方面,它更冗长 当您只能使用整数时使用枚举和布尔值,或者当您可以转到时使用结构化循环,您可以获得什么? @molbdnilo 我觉得std::optional 太过分了。那时,当我和我的博士导师进行这些激烈的辩论时,他总是说 C 比 C++ 更好,因为你可以从 300 页的书中学习 C。 @TheQuantumPhysicist,请问您的博士在哪个领域?) @TheQuantumPhysicist std::optional 不是一种新的语言结构,它只是一种标准库类型,如std::stringstd::size_t。 (顺便说一句,我会推荐 Null References: The Billion Dollar Mistake,由发明它们的人 Tony Hoare。) 【参考方案1】:

一个指针可能是也可能不是NULL。这对你是否意味着什么完全取决于你。在某些情况下,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 允许我们检查。我在 cmets 中写了一个 small example,以较小的规模展示了这个概念

【讨论】:

“现在我们可以跟踪我们是否已经尝试加载名称以及该名称是否为空” - 我不明白这一点。您是否建议将示例中的unique_ptr 替换为指向optional&lt;std::string&gt; 或实际optional&lt;std::string *&gt; 或...的指针?使用普通的 optional&lt;std::string&gt; 成员对象不会给您所讨论的三态可能性。 @davmac:我说的是optional&lt;unique_ptr&lt;std::string&gt;&gt;。 Here is a code example【参考方案2】:

添加 std::optional 胜过通常只使用 std::shared_ptr 的好处是什么?

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

如果一个可选项包含一个值,则该值保证为 作为可选对象足迹的一部分分配,即没有动态 内存分配永远发生。因此,一个可选对象模拟一个 对象,而不是指针,即使 operator*() 和 operator->() 已定义。

因此不涉及动态内存分配,即使与原始指针相比,差异也可能很大。

【讨论】:

std::optional::value_or 也可以说是值得的【参考方案3】:

重要的是,如果您尝试从不存在的可选项访问value(),您会得到一个已知的、可捕获的异常,而不是未定义的行为。因此,如果optional 出现问题,您可能会比使用shared_ptr 或类似的东西有更好的调试时间。 (请注意,在这种情况下,optional 上的 * 取消引用运算符仍然给出 UB;使用 value() 是更安全的选择)。

此外,value_or 等方法通常很方便,它允许您非常轻松地指定“默认”值。比较:

(t == nullptr) ? "default" : *t

t.value_or("default")

后者更具可读性且略短。

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

【讨论】:

【参考方案4】:

我们通过添加 std::optional 而不是通常只使用 std::shared_ptr 赢得了什么?

@Slava 提到了不执行内存分配的优势,但这是附带的好处(好吧,在某些情况下它可能是一个显着的好处,但我的意思是,它不是主要的)。

主要好处是(恕我直言)更清晰的语义

返回指针通常意味着(在现代 C++ 中)“分配内存”,或“处理内存”,或“知道这个和那个在内存中的地址”。

返回一个可选值意味着“没有这个计算的结果,不是错误”:返回类型的名称告诉你 API 是如何构思的(API 的意图,而不是实现)。

理想情况下,如果您的 API 不分配内存,则不应返回指针。

在标准中提供可选类型,确保您可以编写更具表现力的 API。

【讨论】:

【参考方案5】:

可选项是可以为空的值类型。

shared_ptr 是一个可以为空的引用计数引用类型。

unique_ptr 是可以为空的仅移动引用类型。

它们的共同点是它们可以为空——它们可以“不存在”。

它们是不同的,两个是引用类型,一个是值类型。

值类型有几个优点。首先,它不需要在堆上分配——它可以与其他数据一起存储。这消除了可能的异常来源(内存分配失败),速度更快(堆比堆栈慢),并且缓存更友好(因为堆往往相对随机排列)。

引用类型还有其他优点。移动引用类型不需要移动源数据。

对于非仅移动引用类型,您可以使用不同名称对同一数据进行多个引用。具有不同名称的两种不同值类型总是指代不同的数据。无论哪种方式,这都可能是优势或劣势;但它确实让推理一个值类型更容易。

推理shared_ptr 非常困难。除非对数据的使用方式进行一套非常严格的控制,否则几乎不可能知道数据的生命周期。关于unique_ptr 的推理要容易得多,因为您只需要跟踪它的移动位置。关于optional 的生命周期的推理是微不足道的(嗯,就像你嵌入它的那样微不足道)。

可选接口增加了一些类似单子的方法(如.value_or),但这些方法通常可以很容易地添加到任何可为空的类型。不过,目前,它们是为optional 而不是shared_ptrunique_ptr

可选的另一大好处是,您非常清楚您有时希望它可以为空。 C++ 中有一个坏习惯是假定指针和智能指针不为空,因为它们的使用原因其他而不是可空的。

所以代码假定某些共享或唯一的 ptr 永远不会为空。它通常有效。

相比之下,如果你有一个可选的,你拥有它的唯一原因是因为它有可能实际上是空的。

在实践中,我对将unique_ptr&lt;enum_flags&gt; = nullptr 作为参数持怀疑态度,我想说“这些标志是可选的”,因为在调用者上强制进行堆分配似乎很粗鲁。但是optional&lt;enum_flags&gt; 不会强制调用者这样做。 optional 非常便宜,让我愿意在许多情况下使用它,如果我唯一的可空类型是智能指针,我会找到其他解决方法。

这消除了“标志值”的大部分诱惑,例如int rows=-1;optional&lt;int&gt; rows; 具有更清晰的含义,并且在调试时会告诉我何时使用行而不检查“空”状态。

可以合理地失败或不返回任何感兴趣的函数可以避免标志值或堆分配,并返回optional&lt;R&gt;。例如,假设我有一个可放弃的线程池(例如,当用户关闭应用程序时停止处理的线程池)。

我可以从“队列任务”函数返回std::future&lt;R&gt;,并使用异常来指示线程池已被放弃。但这意味着线程池的所有使用都必须针对“来自”异常代码流进行审核。

相反,我可以返回 std::future&lt;optional&lt;R&gt;&gt;,并提示用户他们必须在他们的逻辑中处理“如果该过程从未发生过会发生什么”。

“来自”异常仍然可能发生,但它们现在是异常的,不是标准关闭程序的一部分。

在其中一些情况下,expected&lt;T,E&gt; 加入标准后会是更好的解决方案。

【讨论】:

以上是关于`std::optional` 比 `std::shared_ptr` 和 `std::unique_ptr` 有啥优势?的主要内容,如果未能解决你的问题,请参考以下文章

std::optional

将一个 std::optional 转换为另一个 std::optional

std::optional - 用 或 std::nullopt 构造空?

什么时候适合使用 std::optional

如何在 C++ 中使用 std::optional ?

将 std::iter_swap 用于 std::optional