为什么std::make_shared / std::make_unique不使用列表初始化?

7
具体来说:直接列表初始化 (cppreference.com (3))。
C++11中引入了std::make_shared统一初始化功能。因此,我们可以在堆上分配对象时使用聚合初始化new Foo{1, "2", 3.0f}。这是一种很好的直接初始化对象的方式,例如聚合体、pod等没有构造函数的对象。
在我的经验中,声明临时结构以有效地向lambda提供一组参数等实际场景变得非常普遍:
void foo()
{
    struct LambdaArgs
    {
        std::string arg1;
        std::string arg2;
        std::string arg3;
    };

    auto args = std::make_shared<LambdaArgs>(LambdaArgs{"1", "2", "3"});

    auto lambda = [args] {
        /// ...
    };

    /// Use lambda
    /// ...
}

这里 auto args = std::make_shared<LambdaArgs>("1", "2", "3"); 看起来很好,但是不会起作用,因为 std::make_shared 通常实现为:

template<typename T, typename... Args>
std::shared_ptr<T> make_shared(Args && ...args)
{
    return std::shared_ptr<T>(new T(std::forward<Args>(args)...));
}

所以我们只能使用auto args = std::make_shared<LambdaArgs>(LambdaArgs{"1", "2", "3"});
使用std::make_shared解决的问题对于没有构造函数的对象仍然存在。而且这种解决方法不仅不美观,而且效率较低。
这是另一个疏忽还是有一些理由来支持这个选择。具体来说,在列表初始化解决方案中可能会遇到哪些陷阱?std::make_unique后来在C++14中引入,为什么它也遵循同样的模式?

1
不是针对你的问题的答案,但是有一个解决方法:如果你使用元组而不是普通结构体,你将会得到适当的构造函数,而且它甚至更加简洁:using LambdaArgs = std::tuple<std::string,std::string,std::string>; - user4442671
你可能会觉得这很有趣:https://dev59.com/7WAf5IYBdhLWcg3w0Ve7 - marcinj
2
@W.F:你可能忽略了T{a,b}T(a,b)可能会产生不同的效果。例如,对于T=std::string,它们具有不同的效果。理想情况下,除了旧的方法之外,应该有一种基于花括号的make_shared,可以使用稍微不同的名称或带有一些消歧义的虚拟参数(标签)。 - Cheers and hth. - Alf
“在我的经验中,声明函数内的临时结构体以有效地向lambda表达式提供一组参数等实际场景变得非常普遍:”为什么对于您来说,为lambda表达式分配堆内存是一件常见的事情?直接存储这些参数会更有效率。好吧,也许如果有数十个副本的lambda表达式在运行,那么情况可能不同。但是在那种情况下...为什么会有数十个副本在运行呢?” - Nicol Bolas
@W.F. 这个问题涉及到拥有自己版本可能出现的陷阱,Alf 实际上指出了其中一个。 - GreenScape
显示剩余7条评论
2个回答

8
具体来说,列表初始化方案可能会有哪些陷阱?
所有使用列表初始化的典型陷阱。
例如,隐藏非初始化列表构造函数。make_shared<vector<int>>(5, 2)做什么?如果你的答案是“构造一个包含5个int的数组”,那是绝对正确的……只要make_shared没有使用列表初始化。因为一旦你这样做了,情况就会改变。
请注意,突然更改这一点将会破坏现有的代码,因为现在所有的间接初始化函数都使用构造函数语法。因此,你不能轻易地改变它并期望世界仍然正常运作。
还有一个与此案例独特相关的问题:缩小转换问题。
struct Agg
{
  char c;
  int i;
};

您可以使用Agg a{5, 1020};来初始化这个聚合体。但是,您无法使用make_shared<Agg>(5, 1020)。为什么?因为编译器可以保证字面值5可以转换为char而不会丢失数据。然而,当您使用间接初始化时,字面值5被模板推断为int。编译器无法保证任何int都可以转换为char而不会丢失数据。这被称为“缩窄转换”,在列表初始化中明确禁止。
您需要将该5显式转换为char
标准库对此有一个问题:LWG 2089。尽管技术上这个问题谈论的是allocator::construct,但它同样适用于所有间接初始化函数,如make_X和C++17的就地构造函数any/optional/variant
为什么它也要遵循相同的模式?
它遵循相同的模式是因为拥有两个外观几乎相同但行为截然不同且出乎意料的函数并不是一件好事。
请注意,C++20至少通过使构造函数样式语法在初始值对于常规直接初始化而言是非法的时触发聚合初始化来解决了这个问题。因此,如果T是某种聚合类型(没有用户声明的构造函数),并且T(args)不会调用复制/移动构造函数(只有构造函数需要一个类型没有用户声明的构造函数可以具有的参数时),则参数将被用于尝试聚合初始化结构。
由于allocator::construct和其他形式的转发初始化默认为直接初始化,这将允许您通过转发初始化来初始化聚合体。
您仍然无法使用其他列表初始化操作而不在调用站点显式使用initializer_list。但这可能是最好的选择。

顺便提一下,随意的结构并不能替代“聚合体”,这只是我为那些在狭窄的上下文中(如函数体内)声明仅供一次使用的结构所起的名称。 - GreenScape
可悲的是,这个问题似乎没有任何解决方案,甚至原则上也没有。不是吗? - alfC
@alfC:P0960 可以很好地解决这个问题。 - Nicol Bolas
@NicolBolas,好的,感谢您提供链接。但是 std::make_unique<std::vector<int>>(3,4) 的变体如何解释为 std::vector<int>{3, 4}std::vector<int>(3,4) - alfC
@alfC:我说过它可以充分解决这个问题:聚合体的初始化。使用列表初始化没有通用解决方案,因为整个前提都取决于一个错误的假设:值列表足以知道用户想要如何使用它们来初始化任何对象。 - Nicol Bolas

1

使用std::make_shared仍然无法解决没有构造函数的对象的问题。

不,这个问题已经不存在了。主要问题是make_shared正在解决一个问题,即在对象分配和智能指针拥有所有权之间存在内存泄漏的潜在问题。它还可以删除控制块的一个额外分配。

是的,不能使用直接初始化确实很不方便,但这从来不是make_shared声明的目标。


1
make_shared与内存泄漏无关。make_shared提供的是引用计数和对象之间的保证引用局部性,可能避免在取消引用时出现额外的缓存未命中。 - user4442671
1
@Frank,或许你可以通过这个链接自学一下:http://en.cppreference.com/w/cpp/memory/shared_ptr/make_shared - SergeyA
1
我改口了。谢谢提供参考!编辑:您的链接确实说避免额外的内存分配是主要原因,避免内存泄漏只是一个不错的附加好处,我并不感到那么愚蠢。 - user4442671
1
make_shared源自Boost,Boost文档中指出:“最初,Boost的函数模板make_shared和allocate_shared仅用于有效分配共享对象。”。跪下并投降!够了! - Cheers and hth. - Alf
1
“make_shared 的主要问题是解决潜在的内存泄漏” 至少在历史上是不正确的。值得注意的是,它并没有解决内存泄漏问题:它只是支持编写安全代码。为了解决这个问题,使得使用一个接受 shared 指针的函数的客户端代码被限制在安全的方式下(即参数中不包含多个 new 表达式),需要一个正式的参数类型,该类型不能用指针或临时 shared_ptr 初始化。 - Cheers and hth. - Alf
显示剩余3条评论

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