C++静态工厂方法与构造函数:如何避免复制?

14

这个问题询问在C++中实现静态工厂方法的清晰方式,而这个答案描述了一种明确的方法。返回值优化将使我们避免复制Object,因此创建Object的这种方式就像直接调用构造函数一样有效率。在私有构造函数中将i复制到id的开销可以忽略不计,因为它是一个小的int

然而,这个问题和答案并没有涵盖更复杂的情况,即Object包含一个类Foo的实例变量(需要复杂的初始化逻辑)而不是一个小的原始类型。假设我想使用传递给Object的参数构造Foo。使用构造函数的解决方案会看起来像:

class Object {
    Foo foo;

public:
    Object(const FooArg& fooArg) {
        // Create foo using fooArg here
        foo = ...
    }
}

我认为一个类似于引用答案的静态工厂方法的替代方案是:

class Object {
    Foo foo;

    explicit Object(const Foo& foo_):
        foo(foo_)
    {

    }

public:
    static Object FromFooArg(const FooArg& fooArg) {
        // Create foo using fooArg here
        Foo foo = ...
        return Object(foo);
    }
}

在这种情况下,复制 foo_foo 的开销不再无关紧要,因为 Foo 可能是一个任意复杂的类。此外,据我所知(作为 C++ 新手,可能我错了),这个代码隐式地要求定义 Foo 的复制构造函数。

有没有一种同样简洁但也有效的方法来实现这种模式?

为了预先考虑可能出现的关于为什么这很重要的问题,我认为具有比只复制参数更复杂逻辑的构造函数是一种反模式。 我期望构造函数:

  • 保证工作并且不抛出异常,
  • 并且在内部不进行重计算。

因此,我更喜欢将复杂的初始化逻辑放入静态方法中。此外,这种方法提供了额外的好处,例如通过静态工厂方法名称进行重载,即使输入参数类型相同,以及可以清楚地说明方法名称中正在执行的操作。


2
自C++11起,您确实可以使用移动语义。 但这是一个复杂的主题; 最好通过像Stroustrup这样的好书来回答。 不过问题写得很好,+1。 - Bathsheba
严格来说,你的第一个版本使用公共构造函数也是昂贵的。你默认初始化了 foo(我假设所有的初始化都是昂贵的),然后你创建了另一个 Foo 对象,并将其分配给默认构造的对象。 - StoryTeller - Unslander Monica
@StoryTeller 嗯,我不知道这一定是这样的。我认为在第一个版本中,如果我执行 foo = Foo(fooArg),那么只有该构造函数被调用,不会发生任何复制。我从Java跳入C++的知识非常浅薄。无论如何,我的主要关注点是_复制_而不是_初始化_(即使对于可能很大的对象,例如空数组初始化,初始化也可能很便宜)。 - Vossler
1
@Vossler - 我建议你小心行事。C++对象模型与Java非常不同。成员的初始化和复制并不像在Java中那样完全分离。 - StoryTeller - Unslander Monica
一个直接从 fooArg 初始化其 foo 的构造函数有什么问题吗?这样的构造函数当然也可以被工厂使用。 - Walter
2个回答

8
感谢移动构造函数,您可以这样做:
class Object {
    Foo foo;

    explicit Object(Foo&& foo_) : foo(std::move(foo_)) {}

public:
    static Object FromFooArg(const FooArg& fooArg) {
        // Create foo using fooArg here
        Foo foo = ...
        return Object(std::move(foo));
    }
};

如果 Foo 不能移动,将其包装在智能指针中是一种可能性:
class Object {
    std::unique_ptr<Foo> foo;

    explicit Object(std::unique_ptr<Foo>&& foo_) : foo(std::move(foo_)) {}

public:
    static Object FromFooArg(const FooArg& fooArg) {
        // Create foo using fooArg here
        std::unique_ptr<Foo> foo = ...
        return Object(std::move(foo));
    }
};

谢谢!我可能对这些解决方案有偏见,因为这更或多或少地模仿了Java的引用传递风格,这对我来说更加熟悉。关于这个问题有两个问题:1)在解决方案2中,使用std::shared_ptr而不是std::unique_ptr会更接近Java克隆,对吗?然后我们可以模拟Java的引用计数垃圾回收?2)虽然这感觉很好,但如此简单的操作的冗长和复杂性感觉不太对。如果我通常遵循这个惯用语,那么我就必须将所有内容都包装成智能指针,对吗?_我在C++中做错了吗_? - Vossler
嗯,C++不是Java。在所有地方使用shared_ptr会产生一定的成本,并且使得确定性销毁变得困难。 - dandan78
@Vossler:垃圾回收并不等同于引用计数,但是使用shared_ptr更像是Java。 - Jarod42
如果你有复制省略来完成工作,为什么还需要堆分配呢?如果构造Foo很昂贵,那么你只需要支付一次成本。 - Jorge Bellon

6

为什么直接在构造函数中使用所需的参数初始化实例会有问题?

class Object
{
    Foo foo;                         // or const Foo foo, disallowing assignment

public:

    explicit Object(FooCtorArgs const&fooArg,
                    const AdditionalData*data = nullptr)
      : foo(fooArg)                  // construct instance foo directly from args
    {
        foo.post_construction(data); // optional; doesn't work with const foo
    }

    static Object FromFooArg(FooCtorArgs const&fooArg,
                             const AdditionalData*data = nullptr)
    { 
        return Object{fooArg,data};  // copy avoided by return value optimization
    }
};

据我所知,在构建后即使需要调整 foo,也无需复制/移动任何内容。

虽然这对我很有用,但只适用于Foo直接从FooArg构造函数的特定情况(对吧?..)。如果在FromFooArg中我使用默认初始化foo并手动使用来自fooArg的某些计算(或一般情况下的其他参数)填充它,那么您将不得不回到我的原始片段。此外,这种解决方案有点软性违反了我的动机,即不要让构造函数执行复杂的初始化逻辑(这也取决于FooArg遵循此约定)。 - Vossler
那个问题是一个误导。在这种情况下,你可以在工厂或Object的构造函数中填充Object::foo实例。然而,一个设计良好的class Foo不应该需要这样的后期构建初始化。 - Walter
@ÖöTiib 请阅读我的原始问题 - 无论是文本还是代码都没有表明我假设Foo直接从FooArg构造。如果您认为原始问题表述不清楚,那么您有权对其进行投票,但是您暗示我对此答案的评论对Foo施加了某些特定限制是错误的。 - Vossler
@Vossler 但为什么你要强调“直接”呢?如果 Foo 构造函数需要 fooArg 参数或者 fooArg.gimmeSomething() 参数,有什么区别呢?它们仍然会在该地点被构造。另一方面,当从 FooArgObject 的路径非常复杂时,通常会在构建 Object 之前使用“工厂”或“构建器”进行操作,因为显而易见的原因,构造函数不适合用于复杂错误处理。 - Öö Tiib
@ÖöTiib 这个(fooArgfooArg.gimmeSomething() 的等价性)是一个我之前由于某些原因没有看到的有效观点,也是对我最初评论的很好回答。这并不意味着我给 Foo 添加了临时限制,只是我的误解。 - Vossler
显示剩余4条评论

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