组合对象初始化

Composed object initialization

这是一个似乎没有真正答案的广泛问题。

很长一段时间以来,我一直对组合对象的初始化感到困惑。我被正式教导为所有成员数据提供 getter 和 setter 并支持指向对象的原始指针而不是自动对象 - 这似乎与 Stack Overflow 上的许多人(例如 this 流行 post) 建议。

那么,我应该如何初始化对象组合对象?

这是我尝试使用我在学校学到的知识进行初始化的方式:

class SmallObject1 {
public:
    SmallObject1() {};
};

class SmallObject2 {
    public:
        SmallObject2() {};
};

class BigObject {
    private:
        SmallObject1 *obj1;
        SmallObject2 *obj2;
        int field1;
        int field2;
    public:
        BigObject() {}
        BigObject(SmallObject1* obj1, SmallObject2* obj2, int field1, int field2) {
        // Assign values as you would expect
        }
        ~BigObject() {
            delete obj1;
            delete obj2;
        }
    // Apply getters and setters for ALL members here
};

int main() {
    // Create data for BigObject object
    SmallObject1 *obj1 = new SmallObject1();
    SmallObject2 *obj2 = new SmallObject2();
    int field1 = 1;
    int field2 = 2;

    // Using setters
    BigObject *bobj1 = new BigObject();
    // Set obj1, obj2, field1, field2 using setters

    // Using overloaded contructor
    BigObject *bobj2 = new BigObject(obj1, obj2, field1, field2);

    return 0;
}

这种设计很吸引人,因为它(对我而言)可读性强。 BigObject 具有指向其成员对象的指针这一事实使得在初始化后初始化 obj1obj2 成为可能。然而,动态内存可能会使程序变得更加复杂和混乱,从而导致内存泄漏。此外,使用 getter 和 setter 会使 class 变得混乱,并且还可能使成员数据太容易访问和改变。

这实际上是一种不好的做法吗?我经常发现我需要独立于它的所有者初始化一个成员对象,这使得自动对象没有吸引力。此外,我考虑过让更大的对象构造它们自己的成员对象。从安全的角度来看,这似乎更有意义,但从对象责任的角度来看,意义不大。

I've been formally taught to supply getters and setters for all member data and to favor raw pointers to objects instead of automatic objects

很遗憾你被教错了。

绝对没有理由 支持原始指针 而不是 std::vector<>std::array<> 等任何标准库结构,或者如果您需要 std::unique_ptr<>, std::shared_ptr<>.

有问题的软件最常见的罪魁祸首是(自己动手)内存管理会暴露缺陷,更糟糕的是,这些缺陷通常很难调试。

I've been formally taught to supply getters and setters for all member data and to favor raw pointers to objects instead of automatic objects

就我个人而言,我对为所有数据成员设置 setter 和 getter 没有问题。这是一种很好的做法,可以避免很多麻烦,尤其是当您冒险进入线程时。事实上,许多 UML 工具会自动为您生成它们。你只需要知道要做什么 return。在此特定示例中,不要 return 指向 SmallObject1 * 的原始指针。 Return SmallObject1 * const 代替。

第二部分关于

raw pointers

用于教育目的。


对于您的主要问题:构建对象存储的方式取决于更大的设计。 BigObject 是唯一会使用 SmallObject 的 class 吗?然后我会将它们作为私有成员完全放在 BigObject 内部,并在那里进行所有内存管理。如果 SmallObject 在不同对象之间共享,而不一定是 BigObject class,那么我会做你所做的。但是,我会将指向 const 的引用或指针存储到它们,而不是在 BigObject class 的析构函数中删除它们 - BigObject 没有分配它们,因此不应该删除。

考虑以下代码:

class SmallObj {
public:
  int i_;
  double j_;
  SmallObj(int i, double j) : i_(i), j_(j) {}
};

class A {
  SmallObj so_;
  int x_;
public:
  A(SmallObj so, int x) : so_(so), x_(x) {}
  int something();
  int sox() const { return so_.i_; }
};

class B {
  SmallObj* so_;
  int x_;
public:
  B(SmallObj* so, int x) : so_(so), x_(x) {}
  ~B() { delete so_; }
  int something();
  int sox() const { return so_->i_; }
};

int a1() {
  A mya(SmallObj(1, 42.), -1.);
  mya.something();
  return mya.sox();
}

int a2() {
  SmallObj so(1, 42.);
  A mya(so, -1.);
  mya.something();
  return mya.sox();
}

int b() {
  SmallObj* so = new SmallObj(1, 42.);
  B myb(so, -1.);
  myb.something();
  return myb.sox();
}

方法'A'的缺点:

  • 我们对 SmallObject 的具体使用使我们依赖于它的定义:我们不能仅仅向前声明它,
  • 我们的实例 SmallObject 是我们实例独有的(非共享),

接近 'B' 的缺点有几个:

  • 我们需要建立所有权合同并让用户知道,
  • 在创建每个 B 之前必须执行动态内存分配,
  • 需要间接访问此重要对象的成员,
  • 如果我们要支持您的默认构造函数情况,我们必须测试空指针,
  • 销毁需要进一步的动态内存调用,

反对使用自动对象的一个​​论点是 成本 按值传递它们。

这是可疑的:在许多琐碎的自动对象的情况下,编译器可以针对这种情况进行优化并内联初始化子对象。如果构造函数是微不足道的,它甚至可以在一个堆栈初始化中完成所有事情。

这是 GCC 的 -O3 实现 a1()

_Z2a1v:
.LFB11:
  .cfi_startproc
  .cfi_personality 0x3,__gxx_personality_v0
  subq  , %rsp      ; <<
  .cfi_def_cfa_offset 48
  movabsq 31107791820423168, %rsi  ; <<
  movq  %rsp, %rdi     ; <<
  movq  %rsi, 8(%rsp)  ; <<
  movl  , (%rsp)     ; <<
  movl  $-1, 16(%rsp)  ; <<
  call  _ZN1A9somethingEv
  movl  (%rsp), %eax
  addq  , %rsp
  .cfi_def_cfa_offset 8
  ret
  .cfi_endproc

突出显示的 (; <<) 行是编译器执行 A 的就地构造,它是一次性的 SmallObj 子对象。

和 a2() 的优化非常相似:

_Z2a2v:
.LFB12:
  .cfi_startproc
  .cfi_personality 0x3,__gxx_personality_v0
  subq  , %rsp
  .cfi_def_cfa_offset 48
  movabsq 31107791820423168, %rcx
  movq  %rsp, %rdi
  movq  %rcx, 8(%rsp)
  movl  , (%rsp)
  movl  $-1, 16(%rsp)
  call  _ZN1A9somethingEv
  movl  (%rsp), %eax
  addq  , %rsp
  .cfi_def_cfa_offset 8
  ret
  .cfi_endproc

还有 b():

_Z1bv:
.LFB16:
        .cfi_startproc
        .cfi_personality 0x3,__gxx_personality_v0
        .cfi_lsda 0x3,.LLSDA16
        pushq   %rbx
        .cfi_def_cfa_offset 16
        .cfi_offset 3, -16
        movl    , %edi
        subq    , %rsp
        .cfi_def_cfa_offset 32
.LEHB0:
        call    _Znwm
.LEHE0:
        movabsq 31107791820423168, %rdx
        movl    , (%rax)
        movq    %rsp, %rdi
        movq    %rdx, 8(%rax)
        movq    %rax, (%rsp)
        movl    $-1, 8(%rsp)
.LEHB1:
        call    _ZN1B9somethingEv
.LEHE1:
        movq    (%rsp), %rdi
        movl    (%rdi), %ebx
        call    _ZdlPv
        addq    , %rsp
        .cfi_remember_state
        .cfi_def_cfa_offset 16
        movl    %ebx, %eax
        popq    %rbx
        .cfi_def_cfa_offset 8
        ret
.L6:
        .cfi_restore_state
.L3:
        movq    (%rsp), %rdi
        movq    %rax, %rbx
        call    _ZdlPv
        movq    %rbx, %rdi
.LEHB2:
        call    _Unwind_Resume
.LEHE2:
        .cfi_endproc

显然,在这种情况下,我们付出了沉重的代价来通过指针而不是值来传递。

现在让我们考虑以下代码:

class A {
    SmallObj* so_;
public:
    A(SmallObj* so);
    ~A();
};

class B {
    Database* db_;
public:
    B(Database* db);
    ~B();
};

根据以上代码,您对 A 的构造函数中 "SmallObj" 的所有权的期望是什么?您对 B 中 "Database" 的所有权期望是什么?您是否打算为您创建的每个 B 构建一个唯一的数据库连接?

为了进一步回答您支持原始指针的问题,我们只需看看 2011 C++ 标准,该标准引入了 std::unique_ptr and std::shared_ptr 的概念,以帮助解决自 Cs strdup() 以来就存在的所有权歧义(returns 指向字符串副本的指针,记得释放)。

标准委员会收到了在 C++17 中引入 observer_ptr 的提议,它是原始指针的非拥有包装器。

将这些与您的首选方法结合使用会引入大量样板:

auto so = std::make_unique<SmallObject>(1, 42.);
A a(std::move(so), -1);

我们在这里知道 a 拥有我们分配的 so 实例的所有权,因为我们通过 std::move 明确授予它所有权。但所有这些都是明确的成本字符。对比:

A a(SmallObject(1, 42.), -1);

SmallObject so(1, 4.2);
A a(so, -1);

所以我认为总体而言,支持小对象的原始指针进行组合的情况很少。您应该查看您的 material 得出的结论,因为您似乎忽略或误解了有关何时使用原始指针的建议中的因素。

其他人已经描述了优化原因,我现在从类型/功能的角度来看。根据 Stroustrup 的说法,'It is the job of every constructor to establish the class invariant'。这里的 class 不变量是什么?知道(并定义!)很重要,否则您会用 ifs 污染您的成员函数以检查操作是否有效——这并不比根本没有类型好多少。在 90 年代,我们有 class 这样的东西,但现在我们真的坚持不变的定义,并希望对象始终处于有效状态。 (函数式编程更进一步,试图从对象中提取变量状态,因此对象可以是常量。)

  • 如果您的 class 是有效的且您拥有这些子对象,则将它们作为成员,句号。
  • 如果您想在大对象中共享 小对象,那么您需要指点。
  • 如果没有给定的 SmallObject 是有效的,但您不需要共享,您可以考虑 std::optional<SmallObject> 成员。 Optional 通常在本地分配(相对于堆),因此您可能会受益于缓存局部性。
  • 如果您发现很难构造这样的对象,例如,构造函数参数太多,那么您有两个正交问题:构造和 class 成员。通过引入建造者class(建造者模式)解决构造问题。通常可行的解决方案是将所有构造函数的所有参数都作为可选成员。

请注意,我们中的许多喜欢函数式风格的人都认为构建器是一种反模式,并且仅将其用于反序列化(如果有的话)。背后的原因,很难推理一个构建器(结果是什么,它会成功,哪个构造函数被调用)。如果您有两个整数,那就是:两个整数。您最好的选择通常只是将它们保存在单独的变量中,然后由编译器进行各种优化。如果这些碎片奇迹般地变成碎片并且您的整数将被构建,我不会感到惊讶 'in place',因此以后不需要副本。

OTOH,如果您发现相同的参数 'get bounded'(获取它们的值)在许多地方之前出现在其他地方,那么您可能会为它们引入一个类型。在这种情况下,您的两个整数将是一个类型(最好是一个结构)。您可能会决定是否要使它成为 BigObject 的基础 class、一个成员,或者只是一个单独的 class(如果您有多个绑定订单,则必须选择第三个) - 无论哪种情况,您的构造函数现在都将采用新的 class 而不是两个整数。您甚至可以考虑将其他构造函数(采用两个整数的构造函数)弃用为 1. 可以轻松构造新对象,2. 它可能是共享的(例如,在循环中创建项目时)。如果你想保留旧的构造函数,让其中一个成为另一个的委托。