C ++:类应该拥有还是观察其依赖关系?


16

说我有一个Foobar使用(取决于)class的类Widget。好的时候,将Widgetwolud声明为中的字段Foobar,或者如果需要多态行为,则可以声明为智能指针,并将其在构造函​​数中初始化:

class Foobar {
    Widget widget;
    public:
    Foobar() : widget(blah blah blah) {}
    // or
    std::unique_ptr<Widget> widget;
    public:
    Foobar() : widget(std::make_unique<Widget>(blah blah blah)) {}
    (…)
};

而且我们已经准备就绪。不幸的是,今天,Java的孩子们会嘲笑我们一旦看到它,这是理所当然的,因为它的夫妇FoobarWidget在一起。解决方案看似很简单:应用“依赖注入”以使依赖构造脱离Foobar类。但是,C ++迫使我们考虑依赖关系的所有权。想到三个解决方案:

唯一指针

class Foobar {
    std::unique_ptr<Widget> widget;
    public:
    Foobar(std::unique_ptr<Widget> &&w) : widget(w) {}
    (…)
}

Foobar要求将所有权Widget转移给它。这具有以下优点:

  1. 对性能的影响可以忽略不计。
  2. 它很安全,因为它可以Foobar控制生命周期Widget,因此可以确保Widget它不会突然消失。
  3. 它是安全的,Widget不会泄漏,并且在不再需要时会被适当破坏。

但是,这是有代价的:

  1. 它对如何使用Widget实例设置了限制,例如,不能使用堆栈分配WidgetsWidget不能共享。

共享指针

class Foobar {
    std::shared_ptr<Widget> widget;
    public:
    Foobar(const std::shared_ptr<Widget> &w) : widget(w) {}
    (…)
}

这可能是与Java和其他垃圾收集语言最接近的等效语言。优点:

  1. 更通用,因为它允许共享依赖项。
  2. 保持unique_ptr解决方案的安全性(第2点和第3点)。

缺点:

  1. 不涉及共享时浪费资源。
  2. 仍然需要堆分配,并且不允许堆栈分配的对象。

普通的观察指针

class Foobar {
    Widget *widget;
    public:
    Foobar(Widget *w) : widget(w) {}
    (…)
}

将原始指针放在类中,并将所有权负担转移给其他人。优点:

  1. 尽可能简单。
  2. 通用,接受任何Widget

缺点:

  1. 不再安全了。
  2. 引入了另一个实体,负责双方的所有权FoobarWidget

一些疯狂的模板元编程

我能想到的唯一好处是,我可以在软件运行时阅读所有我没有找到时间的书;)

我倾向于第三个解决方案,因为它是最通用的,Foobars无论如何都必须进行管理,因此管理Widgets是简单的更改。但是,使用原始指针困扰我,另一方面,智能指针解决方案令我感到不舒服,因为它们使依赖项的使用者限制了如何创建依赖项。

我想念什么吗?还是仅仅是C ++中的依赖注入是不平凡的?类应该拥有它的依赖关系还是仅仅观察它们?


1
从一开始,如果您可以在C ++ 11下进行编译,并且/或者永远也不能进行编译,则不再建议使用普通指针作为处理类中资源的方法。如果Foobar类是资源的唯一所有者,并且应该释放资源,那么当Foobar超出范围时,这std::unique_ptr是可行的方法。您可以std::move()用来将资源的所有权从高级范围转移到类。
安迪

1
我怎么知道Foobar是唯一的所有者?在旧情况下很简单。但是,正如我所看到的,DI的问题在于,它使类与依赖关系的构造脱钩,也使类与这些依赖关系的所有权脱钩(因为所有权与构造相关)。在Java等垃圾收集环境中,这不是问题。在C ++中,是这样。
el.pescado

1
@ el.pescado Java也有同样的问题,内存不是唯一的资源。例如,还有文件和连接。用Java解决此问题的常用方法是拥有一个容器,该容器管理其所有包含的对象的生命周期。因此,您不必在DI容器中包含的对象中担心它。如果DI容器有办法知道何时切换到哪个生命周期阶段,那么这对于c ++应用程序也可能适用。
SpaceTrucker

3
当然,您可以以老式的方式进行操作,而没有所有的复杂性,并且可以正常工作并且易于维护的代码。(请注意,Java的男孩们可能会笑,但是他们一直在进行重构,所以解耦并不能帮助他们很多)
gbjbaanb 2015年

3
另外,让其他人嘲笑您并不是像他们一样的理由。倾听他们的论点(而不是“因为被告知”),并实施DI(如果它为您的项目带来了实实在在的利益)。在这种情况下,请将模式视为一个非常通用的规则,您必须以一种特定于项目的方式应用该模式-所有三种方法(以及您尚未想到的所有其他潜在方法)都有效它们带来了整体利益(即,优点多于缺点)。

Answers:


2

我本打算将其写为评论,但结果太长了。

我怎么知道Foobar是唯一的所有者?在旧情况下很简单。但是,正如我所看到的,DI的问题在于,它使类与依赖关系的构造脱钩,也使类与这些依赖关系的所有权脱钩(因为所有权与构造相关)。在Java等垃圾收集环境中,这不是问题。在C ++中,是这样。

是否应使用std::unique_ptr<Widget>std::shared_ptr<Widget>,由您决定并取决于功能。

假设您有一个Utilities::Factory,负责创建块,例如Foobar。遵循DI原则,您将需要该Widget实例,以使用Foobar的构造函数将其注入,这意味着在Utilities::Factory的方法之一内部,例如createWidget(const std::vector<std::string>& params),创建Widget并将其注入到Foobar对象中。

现在您有了一个Utilities::Factory创建Widget对象的方法。这是否意味着我应该负责删除该方法?当然不是。它只是在那里使您成为实例。


想象一下,您正在开发一个具有多个窗口的应用程序。每个窗口都是使用Foobar类表示的,因此实际上Foobar就像控制器一样。

控制器可能会使用您Widget的某些,并且您必须问自己:

如果我在应用程序中的此特定窗口上运行,则将需要这些小部件。这些小部件是否在其他应用程序窗口之间共享?如果是这样,我不应该重新创建它们,因为它们将始终保持相同,因为它们是共享的。

std::shared_ptr<Widget> 是要走的路。

您还拥有一个应用程序窗口,其中仅Widget专门与该窗口相关联,这意味着它不会在其他任何地方显示。因此,如果关闭窗口,则不再需要Widget应用程序中的任何位置,也不需要它的实例。

那就是std::unique_ptr<Widget>宣称自己宝座的地方。


更新:

对于生命周期问题,我真的不同意@DominicMcDonnell。调用std::movestd::unique_ptr完全转移所有权的,所以即使你创建object A一个方法,并将其传递到另一个object B作为依赖,对object B现在负责的资源object A和将正确删除它,当object B范围熄灭。


所有权和智能指针的一般概念对我来说很清楚。在我看来,DI的想法似乎不太好。对我而言,依赖注入是关于将类与其依赖项分离的,但是在这种情况下,类仍然会影响其依赖项的创建方式。
el.pescado

例如,我遇到的问题unique_ptr是单元测试(DI被宣传为易于测试的广告):我想进行测试Foobar,所以我创建了WidgetFoobarFoobar然后将其传递给,进行练习,然后再进行检查Widget,但是除非Foobar以某种方式公开它,否则我可以自从它被宣称为Foobar
el.pescado

@ el.pescado您正在将两件事混合在一起。您正在混合可访问性和所有权。仅仅因为Foobar拥有资源并不意味着其他任何人都可以使用它。您可以实现Foobar诸如之类的方法Widget* getWidget() const { return this->_widget.get(); },该方法将为您返回可以使用的原始指针。然后,当您要测试Widget类时,可以将此方法用作单元测试的输入。
安迪

好点,这是有道理的。
el.pescado

1
如果将shared_ptr转换为unique_ptr,您将如何保证unique_ness?
阿空加瓜

2

我将使用参考形式的观察指针。当您使用它时,它提供了更好的语法,并且具有语义上的优势,即它不暗示所有权,而普通指针可以。

这种方法的最大问题是生命周期。您必须确保依赖项在依赖类之前构造,而在依赖类之后构造。这不是一个简单的问题。使用共享指针(作为依赖项存储以及所有依赖它的类,上面的选项2)可以消除此问题,但同时也引入循环依赖项的问题,这也是不平凡的,我认为不那么明显,因此更难在导致问题之前进行检测。这就是为什么我宁愿不自动执行此操作,而是手动管理生命周期和施工顺序。我也看到过使用轻模板方法的系统,该方法按照创建对象的顺序构造对象列表,并以相反的顺序销毁它们,虽然这不是万无一失的,但是它使事情变得简单得多。

更新资料

大卫·帕克(David Packer)的回答使我对这个问题有所思考。根据我的经验,原始答案适用于共享依赖关系,这是依赖关系注入的优势之一,您可以使用一个依赖关系的一个实例拥有多个实例。但是,如果您的类需要具有自己的特定依赖项实例,那么这std::unique_ptr是正确的答案。


1

首先-这是C ++,不是 Java-在这里,许多事情有所不同。Java人员没有所有权方面的问题,因为有自动垃圾收集可以解决这些问题。

第二:这个问题没有普遍的答案-这取决于要求!

耦合FooBar和Widget有什么问题?FooBar想要使用一个Widget,并且如果每个FooBar实例始终总是拥有自己的和相同的Widget,则使其保持耦合状态。

在C ++中,您甚至可以执行Java中根本不存在的“怪异”事情,例如具有可变参数的模板构造函数(嗯,Java中有...表示法,也可以在构造函数中使用...,但这就是只是语法糖来隐藏对象数组,实际上与真正的可变参数模板无关!)-类别“一些疯狂的模板元编程”:

namespace WidgetFactory
{
    Widget* create(int, int)
    {
        return 0;
    }
    Widget* create(int, bool, long)
    {
        return 0;
    }
}
class FooBar
{
public:
    template < typename ...Arguments >
    FooBar(Arguments... arguments)
        : mWidget(WidgetFactory::create(arguments...))
    {
    }
    ~FooBar()
    {
        delete mWidget;
    }
private:
    Widget* mWidget;
};

FooBar foobar1(10, 12);
FooBar foobar2(51, true, 54L);

喜欢它?

当然,出于某些原因,您需要或需要解耦两个类-例如,如果您需要在任何FooBar实例存在之前的某个时间创建窗口小部件,或者您想要或需要重用窗口小部件,...,或者简单地因为对于当前问题,它更合适(例如,如果Widget是GUI元素,而FooBar应该/可以/一定不能是一个)。

然后,我们回到第二点:没有一般性答案。您需要确定,对于实际问题,哪种才是更合适的解决方案。我喜欢DominicMcDonnell的参考方法,但是只有在FooBar不会拥有所有权的情况下才可以应用它(嗯,实际上,您可以,但这意味着非常非常肮脏的代码...)。除此之外,我还加入了大卫·帕克(David Packer)的答案(该内容应写为评论-但无论如何都是一个很好的答案)。


1
在C ++中,class即使在示例中它只是一个工厂,也不要将es与静态方法一起使用。这违反了OO原则。请改用名称空间,并使用它们对功能进行分组。
安迪

@DavidPacker Hmm,当然,您是对的-我默默地假设课堂上有一些内部私人物品,但这在其他地方都看不到也没有提及……
Aconcagua 2015年

1

您缺少至少两个在C ++中可用的选项:

一种是使用“静态”依赖项注入,其中依赖项是模板参数。这使您可以选择按值保留依赖项,同时仍然允许进行编译时依赖项注入。STL容器将这种方法用于分配器以及比较和哈希函数。

另一个方法是使用深拷贝按值获取多态对象。传统的方法是使用虚拟克隆方法,另一种流行的选择是使用类型擦除来使值类型表现出多态性。

哪个选项最合适实际上取决于您的用例,很难给出一个普遍的答案。如果您只需要静态多态性,尽管我会说模板是最C ++的方式。


0

您忽略了第四个可能的答案,该答案将您发布的第一个代码(按值存储成员的“好日子”)与依赖项注入结合在一起:

class Foobar {
    Widget widget;
public:
    Foobar(Widget w) // pass w by value
     : widget{ std::move(w) } {}
};

客户端代码然后可以编写:

Widget w;
Foobar f1{ w }; // default: copy w into f1
Foobar f2{ std::move(w) }; // move w into f2

不应(纯粹地)根据列出的标准来表示对象之间的耦合(即,不完全基于“对生命周期的安全管理更好”)。

您也可以使用概念上的标准(“一辆汽车有四个轮子”与“一辆汽车有四个驾驶员必须携带的轮子”)。

您可以让其他API施加条件(例如,如果从API获得的是自定义包装程序或std :: unique_ptr,则客户端代码中的选项也受到限制)。


1
这排除了多态行为,该行为严重限制了附加值。
el.pescado

真正。我只是想提一下它,因为它是我最常使用的形式(我只在多态行为的编码中使用继承-而不是代码重用,所以我没有很多需要多态行为的情况。)
utnapistim
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.