引用和值语义
什么是值语义和/或引用语义,在 C++ 中哪种最好?
使用引用语义时,赋值是指针复制(即,一个引用)。值(或“复制”)语义意味着赋值复制的是值,而不仅仅是指针。C++ 给了你选择:使用赋值operator
复制值(复制/值语义),或者使用指针复制来复制指针(引用语义)。C++ 允许你重载赋值operator
来做任何你希望的事情,但是默认(也是最常见)的选择是复制值。
引用语义的优点:灵活性和动态绑定(在 C++ 中,只有当你通过指针传递或通过引用传递时才能获得动态绑定,而通过值传递则不能)。
值语义的优点:速度。“速度”对于一个需要复制对象(而不是指针)的特性来说似乎是一个奇怪的优点,但事实是,通常访问对象的次数多于复制对象的次数,因此偶尔复制的成本(通常)会被拥有实际对象而不是对象指针的优点所抵消。
有三种情况你拥有实际对象而不是对象指针:局部对象、全局/static
对象,以及类中完全包含的成员对象。其中最重要的是最后一种(“组合”)。
有关复制语义与引用语义的更多信息将在接下来的常见问题中给出。请阅读所有这些问题以获得平衡的视角。前几个问题有意偏向于值语义,因此如果你只阅读下面的前几个常见问题,你将获得一个扭曲的视角。
赋值还有其他问题(例如,浅拷贝与深拷贝),这里不作讨论。
什么是“virtual
数据”,以及我如何在 C++ 中使用它/为什么要使用它?
virtual
数据允许派生类改变基类成员对象的精确类。C++ 并不严格“支持”virtual
数据,但可以在 C++ 中模拟它。它并不优雅,但它有效。
为了在 C++ 中模拟virtual
数据,基类必须有一个指向成员对象的指针,派生类必须提供一个新对象,由基类的指针指向。基类还将有一个或多个提供它们自己的指向(同样通过new
)的普通构造函数,并且基类的析构函数将delete
指向的对象。
例如,class
Stack
可能有一个 Array 成员对象(使用指针),而派生class
StretchableStack
可能会将基类成员数据从Array
重写为StretchableArray
。为了使这工作,StretchableArray
必须继承自Array
,所以Stack
将有一个Array*
。Stack
的普通构造函数将用new Array
初始化这个Array*
,但Stack
也将有一个(可能是protected
的)构造函数,它将接受来自派生类的Array*
。StretchableStack
的构造函数将为这个特殊构造函数提供一个new StretchableArray
。
优点
- 更容易实现
StretchableStack
(大部分代码是继承的) - 用户可以将
StretchableStack
作为一种Stack
传递
缺点
- 访问
Array
时增加了一层间接 - 增加了额外的自由存储分配开销(
new
和delete
) - 增加了额外的动态绑定开销(原因在下一个 FAQ 中给出)
换句话说,我们成功地使我们作为StretchableStack
实现者的工作变得更容易,但我们所有的用户都为此付出了代价。不幸的是,额外的开销施加在StretchableStack
的用户和Stack
的用户身上。
请阅读本节的其余部分。(没有其他部分,你将无法获得平衡的视角。)
virtual
数据和动态数据有什么区别?
最容易理解这种区别的方法是类比虚函数:一个virtual
成员函数意味着其声明(签名)在派生类中必须保持不变,但其定义(函数体)可以被重写。继承成员函数的重写性是派生类的一个静态属性;它在任何特定对象的生命周期内都不会动态改变,派生类的不同对象也不可能拥有成员函数的不同定义。
现在回去重读上一段,但进行以下替换:
- “成员函数” → “成员对象”
- “签名” → “类型”
- “函数体” → “精确类”
完成这些替换后,你将得到一个关于virtual
数据的有效定义。
另一种看待这个问题的方式是区分“每个对象”的成员函数和“动态”成员函数。“每个对象”的成员函数是可能在对象的任何给定实例中有所不同的成员函数,可以通过在对象中嵌入一个函数指针来实现;这个指针可以是const
,因为指针在对象的生命周期内不会改变。“动态”成员函数是会随时间动态改变的成员函数;这也可以通过一个函数指针来实现,但这个函数指针将不是 const。
扩展这个类比,这给了我们数据成员的三个不同概念:
virtual
数据:成员对象的定义(class
)可以在派生类中重写,前提是其声明(“类型”)保持不变,并且这种重写性是派生类的静态属性- 每对象数据:类的任何给定对象都可以在初始化时实例化一个不同的兼容(相同类型)成员对象(通常是“包装”对象),并且成员对象的精确类是包装它的对象的静态属性
- 动态数据:成员对象的精确类可以随时间动态改变
它们看起来如此相似的原因是,C++ 都不“支持”这些。这都只是“允许”,在这种情况下,伪造每个机制的方法是相同的:指向一个(可能是抽象)基类的指针。在一个将这些作为“头等”抽象机制的语言中,差异会更显著,因为它们各自会有不同的语法变体。
我通常应该使用指向自由存储分配对象的指针作为我的数据成员,还是应该使用“组合”?
组合。
你的成员对象通常应该“包含”在复合对象中(但并非总是如此;“包装”对象是需要指针/引用的一个很好的例子;另外,N 对 1 的“uses-a”关系也需要类似指针/引用的东西)。
完全包含的成员对象(“组合”)比指向自由存储分配的成员对象的性能更好的原因有三个:
- 每次访问成员对象时都会增加一层间接
- 额外的自由存储分配(构造函数中的
new
,析构函数中的delete
) - 额外的动态绑定(原因见下文)
与从自由存储分配成员对象相关的 3 种性能损失的相对成本是多少?
这三种性能损失在之前的 FAQ 中已经列举:
- 就其本身而言,额外的一层间接影响很小。
- 自由存储分配可能是一个性能问题(当存在大量分配时,
malloc()
典型实现的性能会下降;如果不小心,OO 软件很容易变得“自由存储受限”)。 - 额外的动态绑定来自于拥有一个指针而不是一个对象。每当 C++ 编译器能够知道一个对象的精确类时,
virtual
函数调用可以被静态绑定,这允许内联。内联允许海量的(你相信有半打吗 :-) 优化机会,例如过程集成、寄存器生命周期问题等。C++ 编译器可以在三种情况下知道对象的精确类:局部变量、全局/static
变量和完全包含的成员对象。
因此,完全包含的成员对象允许进行“成员对象通过指针”方法无法实现的显著优化。这是强制引用语义的语言存在“固有”性能挑战的主要原因。
注意:请阅读接下来的三个常见问题以获得平衡的视角!
“inline
virtual
”成员函数真的会被“内联”吗?
偶尔会……
当对象通过指针或引用被引用时,对virtual
函数的调用通常不能内联,因为调用必须动态解析。原因:编译器在运行时(即动态地)无法知道要调用哪个实际代码,因为代码可能来自在调用者编译后创建的派生类。
因此,inline
virtual
调用能够内联的唯一时间是编译器知道作为virtual
函数调用目标的对象的“精确类”时。这只有在编译器拥有实际对象而不是指向对象的指针或引用时才能发生。也就是说,要么是局部对象,要么是全局/static
对象,要么是复合对象内部的完全包含对象。在某些情况下,即使使用指针或引用也可能发生这种情况,例如当函数被内联时,通过指针或引用访问可能会变成对对象的直接访问。
请注意,内联与非内联之间的差异通常远比常规函数调用与virtual
函数调用之间的差异更显著。例如,常规函数调用与virtual
函数调用之间的差异通常只有两个额外的内存引用,但inline
函数与非inline
函数之间的差异可能高达一个数量级(对于无数次对不重要成员函数的调用,失去内联virtual
函数可能导致 25 倍的速度下降![Doug Lea,“Customization in C++,” proc Usenix C++ 1990])。
这种见解的实际后果:不要陷入编译器/语言供应商无休止的争论(或销售策略!)中,他们将自己语言/编译器上的virtual
函数调用成本与另一种语言/编译器上的成本进行比较。与语言/编译器“inline
展开”成员函数调用的能力相比,这种比较在很大程度上是没有意义的。也就是说,许多语言实现供应商大肆宣扬他们的分派策略有多好,但如果这些实现不内联成员函数调用,整个系统性能就会很差,因为是内联——而不是分派——对性能影响最大。
以下是一个即使通过引用,虚调用也能内联的例子。以下代码都在同一个翻译单元中,或者以其他方式组织,以便优化器可以一次性看到所有这些代码。
class Calculable
{
public:
virtual unsigned char calculate() = 0;
};
class X : public Calculable
{
public:
virtual unsigned char calculate() { return 1; }
};
class Y : public Calculable
{
public:
virtual unsigned char calculate() { return 2; }
};
static void print(Calculable& c)
{
printf("%d\n", c.calculate());
printf("+1: %d\n", c.calculate() + 1);
}
int main()
{
X x;
Y y;
print(x);
print(y);
}
编译器可以自由地将 main 转换为如下所示:
int main()
{
X x;
Y y;
printf("%d\n", x.calculate());
printf("+1: %d\n", x.calculate() + 1);
printf("%d\n", y.calculate());
printf("+1: %d\n", y.calculate() + 1);
}
现在它能够内联虚函数调用。
注意:请阅读接下来的两个常见问题,看看这枚硬币的另一面!
听起来我应该永远不要使用引用语义,对吗?
错了。
引用语义是件好事。我们离不开指针。我们只是不希望我们的软件成为一个巨大的指针烂摊子。在 C++ 中,你可以选择在哪里使用引用语义(指针/引用)以及在哪里使用值语义(对象物理地包含其他对象等)。在一个大型系统中,应该有一个平衡。但是,如果你将所有一切都实现为指针,你将获得巨大的性能损失。
靠近问题表面的对象比高层对象更大。“问题空间”抽象的身份通常比它们的“值”更重要。因此,引用语义应该用于问题空间对象。
请注意,这些问题空间对象通常比解决方案空间对象处于更高的抽象级别,因此问题空间对象通常具有相对较低的交互频率。因此,C++ 为我们提供了理想的情况:我们为需要唯一身份或因过大而无法复制的对象选择引用语义,而为其他对象选择值语义。因此,最高频率的对象最终将采用值语义,因为我们只在不伤害我们的地方安装灵活性,并在最需要的地方安装性能!
这些是真正的 OO 设计中涉及的众多问题中的一部分。OO/C++ 的精通需要时间和高质量的培训。如果你想要一个强大的工具,你必须投入。
不要停!也请阅读下一个常见问题!
引用语义的糟糕性能是否意味着我应该按值传递?
不。
之前的常见问题讨论的是成员对象,而不是参数。通常,属于继承层次结构的对象应该通过引用或指针传递,而不是按值传递,因为只有这样你才能获得(期望的)动态绑定(按值传递不与继承混用,因为较大的派生类对象在作为基类对象按值传递时会被切片)。
除非有令人信服的相反理由,否则成员对象应按值传递,参数应按引用传递。前面几个常见问题中的讨论指出了成员对象何时应按引用传递的一些“令人信服的理由”。