💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
# [31] 引用与值的语义 ## FAQs in section [31]: * [31.1] 什么是值和/或引用传递,在C++用哪个最好? * [31.2] 什么是“虚成员”,如何/为什么在C++中使用? * [31.3] 怎么区别虚拟数据和动态数据? * [31.4] 应该通常使用数据成员对象指针或者使用“组合”? * [31.5] 什么是使用成员对象指针的3个相对性能开销? * [31.6] “内联虚函数”的会被“内联”吗? * [31.7] 听起来像我不应该使用引用? * [31.8] 引用的性能问题是否意味着我要使用值传递? ## 31.1 什么是值和/或引用传递,在C++用哪个最好? 对于引用,被赋值的是一个指针拷贝。而值传递,被赋值的是值的拷贝而不是指针。C++中你可以选择使用赋值操作符或者拷贝值(值传递),或者使用指针拷贝来复制一个指针(引用传递)。C++也允许你重写复制操作符来实现你想要的操作,但是默认选择是拷贝值。 引用传递的好处:灵活和动态绑定(只有使用指针或者引用的时候,才能获得动态绑定)。 值传递的好处:速度。因为值传递需要拷贝一个对象(而不是一个指针),你可能很奇怪为什么会这样。事实是大家通常使用一个对象,而不是拷贝多个对象,因此偶尔的拷贝开销比间接指针访问对象带来的开销要小。 三种情况你会获得一个对象而不是对象指针:本地对象,全局或者静态对象,以及类的非指针成员对象。最重要的是后者(对象组合)。 下个FAQ会给出更多的值/引用传递的信息。请阅读所有内容以有个全面认识。前几个倾向于使用值传递,如果你只阅读前几个,可能你会得到一个片面的认识。 赋值还包括其他问题(比如浅拷贝和深拷贝),这里不讨论这些。 ## 31.2 什么是“虚成员”,如何/为什么在C++中使用? "虚成员(Virtual Data)"允许子类改变父类的成员对象。C++并不严格支持“虚成员”,但是可以模拟实现。虽然实现的不是很漂亮。 模拟实现要求基类要有一个成员对象指针,子类必须提供一个新对象,基类的成员对象指针指向这个新对象。基类可以有一个或者多个正常的构造函数提供成员指针对象的对象(通过`new`),基类的析构函数将会“`delete`”这个对象。 例如, `Stack`类可能有个`Array`成员对象(使用指针)而子类`StrechableStack`可以重写基类的`Array`成员为`StrechableArray`。要使这个实现,`StretchableArray`必须从`Array`继承,这样`Stack`类可以使用`Array*`。`Stack`类的正常构造函数可以初始化`Array*`为`new Array`, 但是`Stack`类也要有一个构造函数(很可能`protected`属性的构造函数)可以接受一个来自子类的`Array*`。 `StretchableStack`类的构造函数为基类的这个特殊构造函数提供`new StretchableArray`对象。 好处: * 易于实现`StretchableStack` (多数代码可以被继承) * 用户可以传递`StrechableStack`为`Stack`类型的参数或者变量 缺点: * 为访问`Array`增加了额外层 * 为堆内存分配需要增加额的`new`和`delete`操作 * 增加了额外的动态绑定开销 (理由见下节FAQ) 换句话讲,我们简化了`StrechableStack`的实现代码,但是所有的用户都要付出代价。不幸的是,不仅`StrechableStack`用户而且`Stack`用户都要付出这个代价。 _请阅读本节其他内容。(这样你会有一个全面认识)_ ## 31.3 怎么区别虚拟数据和动态数据? 最简单的办法是虚函数分析法。虚函数: 虚函数意味着“声明(签名)”在子类中必须一样,但是“定义(实现)”可以被重写。继承的成员函数的重写是子类的静态属性,不会随着任何特定对象的改变而动态改变,也不可能应为子类的不同实例而有不同的实现。 现在重新阅读上面段落,但是要做下面替换: * "成员函数" → "成员对象" * "签名" → "类型" * "实现" → "确切类" 这样你就可以定义“虚数据”。 另外一种方法是辨别"per-object"成员函数和"dynamic" 成员函数。 "per-object" 成员函数是指在不同的实例中实现有可能不同的成员函数,可以使用函数指针实现,这个指针可以是`const`,因为该指针在对象的生命周期中不会被改变。而"dynamic" 成员函数是指将会随时间而动态改变的成员函数,也可以由函数指针实现,但是函数指针不能为`const`。 概括一下上面的分析,数据成员有三种概念: * 虚数据: 类的成员对象定义可以在子类中被重写,假设成员对象的生命(类型)相同。这种重写是子类的静态属性。 * per-object-data: 任何类的既定对象可以在初始化(wrapper对象)的时候实例化一个不同conformal(相同类型)的成员对象,成员对象的确切类是Wrapper类的静态属性。 * dynamic-data: 成员对象的确切类可以被动态改变。 他们相似的原因是他们都不被C++支持,只有很少情况下可以这样使用。在这种情况下,模拟机制都是相同的:通过指向基类(很可能是抽象类)的指针。在支持“first class” abstraction mechanisms的语言中,可能这种区别很明显一些,因为他们将会有各自不同的语法表示。 ## 31.4 应该通常使用数据成员对象指针或者使用“组合”? 组合。 一般来说,你的成员对象应该被包含在组合对象中(并不总是这样,包装器(Wrapper)对象是一个你可以使用指针或者引用的好例子; 而N-to-1-uses-a关系也需要指针或者引用)。 完全包含成员对象性能优于指针的原因有三点: * 访问对象时候是否需要额外的间接访问 * 额外的堆内存分配(在构造函数中使用`new`,在析构函数中使用`delete`) * 额外的动态绑定(理由见下面FAQ) ## 31.5 什么是使用成员对象指针的3个相对性能开销? 前一节FAQ列举了3个相对性能开销:: * 就自身来说,一个额外的间接访问开销不值一提。 * 堆内存分配可能成为一个性能问题(`malloc`的传统实现的性能会下降,随着内存分配的增加; 面向对象软件很容易使得内存分配增加,除非你很细心)。 * 额外的动态绑定来自于对象指针,而不是对象。只要C++编译器能够知道确切的`class`, 虚函数调用就会被静态绑定,静态绑定允许内联。而内联将会带来成千上万的优化机会,比如 procedural integration, register lifetime issues等等。下面三种情况下C++编译器能够知道对象确切的`class`: 本地变量,全局/静态变量,完全包含的成员对象。 因此完全包含成员对象允许重要的优化,而这在使用对象指针的情况下是不可能的。这是具有引用语义的编程语言为什么面临继承性能挑战的主要原因。 _请阅读下面__3__个__FAQ__一遍获得全面理解!_ ## 31.6 “内联虚函数”的会被“内联”吗? 有时... 当对象是个指针或者引用的时候, 虚函数调用不能被内联,因为函数必须被动态调用。原因:编译器无法知道实际的代码来调用直到运行时(即动态),因为该代码可能是来自一个派生类,调用函数编译以后才创建的。 因此,只有当编译器知道虚函数调用的目标的“确切类”的时候,”内联虚函数”才有可能被内联。发生这种情况只有在编译器知道一个实际的对象,也就是说,本地对象,全局/静态对象,或在组合的完全包含对象,而不是一个指针或引用的时候。 注意,内联和非内联之间的差别远远超过普通函数调用和虚函数调用的差别。例如,普通函数调用和虚函数调用的差别常常只有两个额外的内存引用,但内联函数和非内联函数的差别可以多达一个数量级(数以亿计的调用无关紧要的成员函数, 内联虚函数的损失可能会导致25倍的差距!Doug Lea, "Customization in C++," proc Usenix C++ 1990。 这种顿悟的实际后果:不要陷在无休止的辩论中(或销售策略!),来比较编译器/语言的虚函数调用的成本。和具有扩展“内联”成员函数调用的语言/编译器做比较是没有任何意义的。也就是说,许多语言实现厂商带鼓吹他们的调度策略是如何好,但如果没有内联成员函数的话,系统的整体性能会很差,正是因为靠内联调度,他们才具有最好的性能。 _注意:请阅读下面的__2__个__FAQs__一遍了解另一方面!_ ## 31.7 听起来像我不应该使用引用? 不对。 引用是个好东西。我们不能生活在没有引用。我们只是不希望我们的软件使用太多的指针。在C++中,你可以挑选你想要引用语义(指针/引用)以及值语义(如对象包含其他对象等)。在一个大的系统中,应该有一个平衡。然而,如果你无论什么都使用指针的话,你将得到许多速度方面的问题。 求解问题的对象往往要比更高层次的对象占用更多的存储空间。这些”问题空间“抽象类的ID通常比他们的“值”更重要,因此以用语义应该被用于求解问题的对象。 请注意,这些求解问题的对象通常在较高的抽象层次,相比那些处于解决方案空间的对象来说。因此求解问题的对象通常有一个相对较低的使用频率。因此,C ++中为我们提供了一个理想的情况:对于那些需要独特的身份的对象,或过大而不能复制我们选择使用引用语义,对于其他对象我们可以选择值语义。因此,最高使用频率的对象将最终使用值语义,因为在灵活性方面我们没有损失,但是在性能方面,实现了我们最需要的! 这些只是真正的面向对象设计的诸多问题中的一部分。精通面向对象设计/C++需要需要时间和高质量的训练。如果你想有一个强有力的工具,你要投入时间和精力。 _不要停下来!__无比阅读下一个问题!!_ ## 31.8 引用的性能问题是否意味着我要使用值传递? 不是。 前面FAQ谈论的是成员对象,而不是参数。一般而言,对象是继承层次结构的一部分,应该通过引用或指针来传递,而不是值传递,因为只有这样你才能得到(期望的)动态绑定(按值传递和继承不相符,因为派生类对象将会被切片 ,当按值传递到一个基类对象的时候)。 除非另有其他的理由,成员对象应当按值传递,参数应按引用传递。以前的FAQ里面讨论了应该按引用传递的成员对象的“其他的理由”。