值语义,引用语义对比理解

写在前面

值语义,引用语义

主要内容

值语义和引用语义的复合代表就是c++。

值语义(value sematics)指的是对象的拷贝与原对象无关,就像拷贝 int 一样。C++ 的内置类型(bool/int/double/char)都是值语义,标准库里的 complex<> 、pair<>、vector<>、map<>、string 等等类型也都是值语意,拷贝之后就与原对象脱离关系。

简单来说就是这样的效果:
int a = 10;
int b = a;
b的值和a无关。

专业点来说:

对于用户定义的类呢?我们说,如果一个type X 具有值语义, 则:

1)X 的size在编译时可以确定。这一点看似自然,其实在C++里有许多变量的size编译时无法确定。比如我在reference 三位一体里提到的polymorphic 变量,因为是“多身份”的,其(内容)的size是动态的。

2)将X的变量x,赋值与另一个变量y,无须专门的 = operator,简单的bit-wise-copy 即可。

3)当上述赋值发生后,x和y脱离关系:x 和 y 可以独立销毁, 其内存也可以独立释放。

了解第三点很重要,比如下面的class A就不具备值语义:

class A {
char * p;
public:
     A() { p = new char[10]; }
     ~A() { delete [] p; }

};

A 满足1和2,但不满足3。因为下面的程序会出错误:

Foo()
{
    A a;
    A b = a;
} // crash here

改进的方法是定义一个A::operator=(constA &),并且用reference counting 的技术来保护指针,实现起来并不简单。所以我们说一旦一个class 失去了value semantics, 它也就失去了简单明了的 = 语义。
从上面的分析可以得出结论,value semantics 有个简单的 = , 也正是数学意义上的 = 。

对象语义 或者 称为引用语义

与值语义对应的是“对象语义/object sematics”,或者叫做引用语义(reference sematics),由于“引用”一词在 C++ 里有特殊含义,所以我在本文中使用“对象语义”这个术语。
对象语义指的是面向对象意义下的对象,对象拷贝是禁止的。例如 muduo 里的 Thread 是对象语义,拷贝 Thread 是无意义的,也是被禁止的:因为 Thread 代表线程,拷贝一个 Thread 对象并不能让系统增加一个一模一样的线程。
同样的道理,拷贝一个 Employee 对象是没有意义的,一个雇员不会变成两个雇员,他也不会领两份薪水。拷贝 TcpConnection 对象也没有意义,系统里边只有一个 TCP 连接,拷贝 TcpConnection 对象不会让我们拥有两个连接。Printer 也是不能拷贝的,系统只连接了一个打印机,拷贝 Printer 并不能凭空增加打印机。凡此总总,面向对象意义下的“对象”是 non-copyable。

学过Java, C#, 和JavaScript的程序员都知道,这些语言里的object都不具有值语义,因为它们都是指针,= 并不copy内容。也不满足条件3。

ArrayList<Integer> a = new ArrayList<Integer>(); 
ArrayList<Integer> b = a;

那么 a 和 b 指向的是同一个ArrayList 对象,修改 a 同时也会影响 b。

值语义的好处

  • 值语义的一个巨大好处是生命期管理很简单。
    就跟 int 一样——你不需要操心 int 的生命期。值语义的对象要么是 stack object,或者直接作为其他 object 的成员,因此我们不用担心它的生命期(一个函数使用自己stack上的对象,一个成员函数使用自己的数据成员对象)相反,对象语义的 object 由于不能拷贝,我们只能通过指针或引用来使用它。

  • 一旦使用指针和引用来操作对象,那么就要担心所指的对象是否已被释放,这一度是 C++ 程序 bug 的一大来源。
    使用指针和引用之后所有的赋值代表将有多个变量指向同一个对象,一旦其中一个变量释放了对象的资源,其他的变量的使用将是一个问题。

  • 由于 C++ 只能通过指针或引用来获得多态性,那么在C++里从事基于继承和多态的面向对象编程有其本质的困难——资源管理。

值语义是C语言的三大约束之一,C 的设计初衷是让用户定义的类型(class)能像内置类型(int)一样工作,具有同等的地位。为此C++做了以下设计(妥协):

  • class 的 layout 与 C struct 一样,没有额外的开销。定义一个“只包含一个 int 成员的 class ”的对象开销和定义一个 int 一样
  • 甚至 class data member 都默认是 uninitialized,因为函数局部的 int 是 uninitialized。
  • class 可以在 stack 上创建,也可以在 heap 上创建。因为 int 可以是 stack variable。
  • class 的数组就是一个个 class 对象挨着,没有额外的 indirection。因为 int 数组就是这样。
  • 编译器会为 class 默认生成 copy constructor 和 assignment operator。其他语言没有 copy constructor 一说,也不允许重载 assignment operator。C++ 的对象默认是可以拷贝的,这是一个尴尬的特性。
  • 当 class type 传入函数时,默认是 make a copy (除非参数声明为 reference)。因为把 int 传入函数时是 make a copy。
    当函数返回一个 class type 时,只能通过 make a copy(C++ 不得不定义 RVO 来解决性能问题)。因为函数返回 int 时是 make a copy。
  • 以 class type 为成员时,数据成员是嵌入的。例如
    pair<complex<double>, size_t> 的 layout 就是 complex<double> 挨着 size_t

以上的这些做法带来的是性能上的极大好处

一般定义一个类,我们如果不自定义拷贝构造函数,编译器是会默认提供一个拷贝构造函数的,这是为什么呢?为什么会提供一个默认的拷贝构造函数?这个默认的拷贝构造函数是提供的怎样的操作?

首先编译器提供的默认构造函数是最简单的浅拷贝,将传入的同类型变量的所有的成员变量的值赋值给当前创建的对象的各个成员。遇到类成员变量是指针类型时,由于会直接将指针本身拷贝过去,导致有几个指针指向同一片内存,以致于最后在调用析构函数时会对同一片内存区域释放多次。
这就是当我们设计的类管理着某些资源的时候(成员是指针类型),就需要自定义拷贝构造函数,将该指针变量的拷贝手动修改成深拷贝。这样一旦拷贝完成新创建的对象将和被拷贝的对象完全脱离没有半毛钱关系,对新对象的任何修改都不会影响被拷贝的对象。

所以c++本身在于对象这个概念上是遵循类类型和基本的内置类型一样的地位处理。C++ 的强大之处在于“抽象”不以性能损失为代价

那么,什么样的类没有值语义呢?我们不妨称这种型为 none-value-semantics type (NVST).:

1)有virtual function 的类

2)包含NVST成员的类

3)NVST 的衍生类(derived classed)

4)定义了自己的 = operator 的类

5)继承 virtual 基类的衍生类

对比

此时再看Java。
Java基本就是一个引用语义。
虽然Java也有基本数据类型但是也都有这些类型的包装类。基本所有的都是以对象的形式存在的。
所有栈空间的变量都是引用类型,带来就是一旦拷贝就是浅拷贝。
所以java直接就是使用GC来进行内存管理。
可以看这篇文章当中对于Java对象头的具体分析,可见Java对象本身就带有额外的信息。
【Java对象解析】不得不了解的对象头

HotSpot虚拟机的对象头(Object Header)包括两部分信息,第一部分用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,这部分数据的长度在32位和64位的虚拟机(暂 不考虑开启压缩指针的场景)中分别为32个和64个Bits,官方称它为“Mark Word”。
对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额 外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机 中对象未被锁定的状态下,Mark Word的32个Bits空间中的25Bits用于存储对象哈希码(HashCode),4Bits用于存储对象分代年龄,2Bits用于存储锁标志 位,1Bit固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)对象头的另外一部分是类型指针,即是对象指向它的类的元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说查找对象的元数据信息并不一定要经过对象本身。另外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。

猜你喜欢

转载自blog.csdn.net/zhc_24/article/details/82156970