组合对象初始化
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
具有指向其成员对象的指针这一事实使得在初始化后初始化 obj1
和 obj2
成为可能。然而,动态内存可能会使程序变得更加复杂和混乱,从而导致内存泄漏。此外,使用 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 不变量是什么?知道(并定义!)很重要,否则您会用 if
s 污染您的成员函数以检查操作是否有效——这并不比根本没有类型好多少。在 90 年代,我们有 class 这样的东西,但现在我们真的坚持不变的定义,并希望对象始终处于有效状态。 (函数式编程更进一步,试图从对象中提取变量状态,因此对象可以是常量。)
- 如果您的 class 是有效的且您拥有这些子对象,则将它们作为成员,句号。
- 如果您想在大对象中共享 小对象,那么您需要指点。
- 如果没有给定的 SmallObject 是有效的,但您不需要共享,您可以考虑
std::optional<SmallObject>
成员。 Optional 通常在本地分配(相对于堆),因此您可能会受益于缓存局部性。
- 如果您发现很难构造这样的对象,例如,构造函数参数太多,那么您有两个正交问题:构造和 class 成员。通过引入建造者class(建造者模式)解决构造问题。通常可行的解决方案是将所有构造函数的所有参数都作为可选成员。
请注意,我们中的许多喜欢函数式风格的人都认为构建器是一种反模式,并且仅将其用于反序列化(如果有的话)。背后的原因,很难推理一个构建器(结果是什么,它会成功,哪个构造函数被调用)。如果您有两个整数,那就是:两个整数。您最好的选择通常只是将它们保存在单独的变量中,然后由编译器进行各种优化。如果这些碎片奇迹般地变成碎片并且您的整数将被构建,我不会感到惊讶 'in place',因此以后不需要副本。
OTOH,如果您发现相同的参数 'get bounded'(获取它们的值)在许多地方之前出现在其他地方,那么您可能会为它们引入一个类型。在这种情况下,您的两个整数将是一个类型(最好是一个结构)。您可能会决定是否要使它成为 BigObject
的基础 class、一个成员,或者只是一个单独的 class(如果您有多个绑定订单,则必须选择第三个) - 无论哪种情况,您的构造函数现在都将采用新的 class 而不是两个整数。您甚至可以考虑将其他构造函数(采用两个整数的构造函数)弃用为 1. 可以轻松构造新对象,2. 它可能是共享的(例如,在循环中创建项目时)。如果你想保留旧的构造函数,让其中一个成为另一个的委托。
这是一个似乎没有真正答案的广泛问题。
很长一段时间以来,我一直对组合对象的初始化感到困惑。我被正式教导为所有成员数据提供 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
具有指向其成员对象的指针这一事实使得在初始化后初始化 obj1
和 obj2
成为可能。然而,动态内存可能会使程序变得更加复杂和混乱,从而导致内存泄漏。此外,使用 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 不变量是什么?知道(并定义!)很重要,否则您会用 if
s 污染您的成员函数以检查操作是否有效——这并不比根本没有类型好多少。在 90 年代,我们有 class 这样的东西,但现在我们真的坚持不变的定义,并希望对象始终处于有效状态。 (函数式编程更进一步,试图从对象中提取变量状态,因此对象可以是常量。)
- 如果您的 class 是有效的且您拥有这些子对象,则将它们作为成员,句号。
- 如果您想在大对象中共享 小对象,那么您需要指点。
- 如果没有给定的 SmallObject 是有效的,但您不需要共享,您可以考虑
std::optional<SmallObject>
成员。 Optional 通常在本地分配(相对于堆),因此您可能会受益于缓存局部性。 - 如果您发现很难构造这样的对象,例如,构造函数参数太多,那么您有两个正交问题:构造和 class 成员。通过引入建造者class(建造者模式)解决构造问题。通常可行的解决方案是将所有构造函数的所有参数都作为可选成员。
请注意,我们中的许多喜欢函数式风格的人都认为构建器是一种反模式,并且仅将其用于反序列化(如果有的话)。背后的原因,很难推理一个构建器(结果是什么,它会成功,哪个构造函数被调用)。如果您有两个整数,那就是:两个整数。您最好的选择通常只是将它们保存在单独的变量中,然后由编译器进行各种优化。如果这些碎片奇迹般地变成碎片并且您的整数将被构建,我不会感到惊讶 'in place',因此以后不需要副本。
OTOH,如果您发现相同的参数 'get bounded'(获取它们的值)在许多地方之前出现在其他地方,那么您可能会为它们引入一个类型。在这种情况下,您的两个整数将是一个类型(最好是一个结构)。您可能会决定是否要使它成为 BigObject
的基础 class、一个成员,或者只是一个单独的 class(如果您有多个绑定订单,则必须选择第三个) - 无论哪种情况,您的构造函数现在都将采用新的 class 而不是两个整数。您甚至可以考虑将其他构造函数(采用两个整数的构造函数)弃用为 1. 可以轻松构造新对象,2. 它可能是共享的(例如,在循环中创建项目时)。如果你想保留旧的构造函数,让其中一个成为另一个的委托。