【我的《冒号课堂》学习笔记】值与引用(2)语义类型
值与引用
值语义的对象是独立的,语义的对象却是允许共享的。由于Java不支持值类型对象,Java程序员才更需要加强这方面的意识。语法和语义并不总是一致的——语法上的值类型可能在语义上是引用类型,语法上的引用类型可能在语义上是值类型。永远不要忘记一个基本原则:语法只是手段,语义才是目的。
为了判断一个类型的语义,那么简明的‘石蕊测试法’便是一个很好的选择.在不影响程序正确性的前提下,一个对象的复件能否代替原件?如果可以则该对象的类型是值语义的,否则是引用语义的。(这种判断方法与语法无关,完全取决于对象设计者的用意。)
从命令式编程的角度看,一个值语义变量的内存地址是无关紧要的,原件和复件的唯一差别在被清除后变得完全的等价,因而值语义又称复制语义(copy semantics)。相对地,引用语义变量的内存地址至关重要,通常用指针来实现,因而引用语义又称指针语义(pointer semantics)
从函数式编程的角度看,值应当是引用透明的,即一个表达式随时可被其值所替代。比如2+3总可以被5代替,“ab”.concat(“cd”)总可用“abcd”来代替。显然,值的可替代性实质上抹杀了引用的作用。(在计算机术语中,透明transparency一词很容易引起误解,它不是指因透明而看得见,而是指透明得看不见或意识不到、不受影响)
从对象式编程的角度看,值语义与引用语义的区别在于对象标识(object identity)的重要程度。对象标识是一个对象区别于其他对象的唯一的标识。它的每个对象都具备的一个特质,反映了一个对象作为实体的独立性、可识别性和本体性,是对象的三大特性之一。OOP中对象的三大特性是状态(state)、行为(behavior)和标识(identity)。倘若一个对象的标识在程序中没有实际意义,意味着它的对象特性模糊、主体意识淡薄,更多地代表的是一种抽象的属性(attribute)而非一个具体的实体(entity),则它具有值语义。反而,则具有引用语义。值通过具体的数据来描述抽象的属性,引用通过抽象的方式来指代具体的实体。
比如字符串,Java和C#中的String类虽然是引用类型,但它们的值语义是很明显的。人们关心的是字符串的内容,而非它的内存地址。Java初学者最容易犯的一个错误就是用相等运算符(equality operator)‘==’来判断字符串的异同。用==比较的是字符串的引用,用equals方法比较的才是字符串的内容。因此,C#干脆明智地重载(overload)了String的相等运算符,避免了这类错误。虽然C++同样也重载了该运算符,但是C++中包括String在内的所有自定义类型本就是基于值语法的,它们的相等运算符自然不可能用于引用比较。C++中基于指针的char*字符串类型的比较也不能用==运算符,而应该用strcmp函数。
值与引用还有一个区别。值是不依赖内存地址的,即具有空间无关性。其实值还有时间无关性,即一个值语义的对象在其生命周期中的状态是固定的。也就是说,值语义类型一般是不可变的(immutable)。以Java中的String为例:
String s1 = “ab”; String s2 = s2;//这行的赋值是基于引用的,因此s1与s2指向同一个字符串对象。 assert(s1=s2); s2 += "cd"; assert(s1 != s2);
如果String是可变的,那么当s2的内容从“ab”变成“abcd”后,s1的内容也会发生相应的变化。这就产生别名问题(aliasing problem),通常并非我们想要的结果,因此在s2完成自增运算后,系统便让它换成另一个字符串对象,而原先的字符串对象任然保持不变。这样使我们省去了显示深克隆的过程。由此可见,不变性为引用类型贯彻值语义提供了变通的语法支持。
Java和C#中的StringBuffer是可变的字符串,角色的定位不是字符串的持有者,而是字符串的创造者,故而是可变的,并不具备值语义。C++中的string类由于是值类型的,对不可变的需求没有那么强烈。但除非特别需求,程序员还是应尽可能地保持字符串对象的不可变性。除了在赋值、按值传递、作为返回值等是凭借值类型的特点来保证字符串的复制以外,还可利用关键字const来保证常量正常性(const-corectness)。
Java和C#中的基本数据类型是值类型的,但有时需要以引用类型身份出现,这就需要一个转化过程,术语为封装(boxing),逆过程称为拆箱(unboxing)。装箱后的对象虽然是引用类型的,但仍保持值语义,因此应该是不可变的。
一个值语义的引用类型也有可能是可变的,比如Java的日期类型Date类。为了实现其值语义,需要手动进行必要的防御性复制(defensive copying)。比如在getter方法返回对象的日期属性之前、在setter方法传入的日期参数赋值之前,都应拷贝一份对象。虽然这就影响到程序性能,但程序的正确性永远是第一位的。略讽刺意味的是,当初Date类被设计成可变类型是为了减少对象的创建,但结果却事与愿违,反复的防御性复制也许会创建更多的对象。避免重复的防御性复制的一个方法就是在规范文档中进行相关说明。但这很不自然,同时不能保证客户遵守规范。
值语义对象不是真正意义的对象,接下来就是关于值的时间无关性在语义上的意义。值是静态而单纯的,而引用是动态而复杂的。从这个角度上说,不可变性加强了值语义。比如整型是最常见的值类型之一,一个整型就是一个常量,理所应当是不可变的。同理,一个值语义的对象也应该是一个常量。举个具体的例子,颜色类型Color具备着典型的值语义,在Java和C#中都是不可变的。你看得出x=Color.Red与x=1有什么本质的区别吗?无非是取值空间不一样。当x被重新赋值为Color.Green时,原来的对象Color.Red并未发生变化,只是不再被x所引用。退一步讲,即使一个值语义对象是可变的,它与引用语义对象在概念上仍是有区别的:值语义对象的改变是一种新旧更替,即新对象更替旧对象,只是凑巧重用了后者所用的内存空间;引用语义对象的改变是一种自我更新。即一个对象在保持同一性的前提下发生的状态迁移或属性改变。
如果说不可变性让语法上的引用类型倾向于值语义,那么不可复制性则让语法上的值类型具备明显的引用语义。这非常自然,值语义的特点是复件具有等效性,引用语义的特点是复件不具等效性。当然,不可复制性是一种比较极端的情形,因为一边引用语义的类型也是允许复制的,只不过不能替代原件而已。由于语法的缘故(Java没有自定义的值类型,而C#中的值类型通常都是遵循值语义的。),具有不可复制性的值类型主要出现在C++上,著名的Boost C++库提供了noncopyable类。
对于前面一直在提的“值类型对象”和“引用类型对象”之所以没有简称“值对象”和“引用对象”,是因为害怕产生歧义。因为后者常用于代表“值语义对象”和“引用语义对象”。尤其是值对象(Value Object,简称VO),更是常在系统设计中作为通用的术语被使用。
值对象与数据传输对象(Data Transfer Object 简称DTO)是有区别的。数据传输对象多用于多层架构(multi-tier architecture)的分布式系统(distributed system)中。由于不同层(tier)之间的通讯通常会跨越进程(process)或网络(network)的边界,相对昂贵而缓慢的远程调用(remote call)往往成为性能瓶颈。为了减少通讯次数,每次调用应返回尽可能多的数据。欲达此目的,单靠增加调用方法的参数是不够的。那样不仅代码丑陋、难以维护,而且只适用于C++和C#。Java由于不支持按引用传递,无法通过参数来储存返回值。但是Java仍可通过对象引用的复件来设置对象的内容。Java的方法参数要能起到返回值的作用,必须满足:参数类型不能是基本类型;参数类型不能是String那样的不可变类型;返回值永不取空值,因为按值传递既不能把传入的空值变成非空对象,也不能把传入的非空对象变成空值。虽然可以考虑把各种数据结果放入一个数组或集合中。这种方法有两大缺点。其一是,返回的数据未必是同类型的,而数组和集合的元素一般却是同类型的。如果统一采用Object类型,既不方便又有类型安全隐患。其二,采用这种方法需要按索引(index)、迭代器(iterator)或键(key)来获取返回结果,与按方法或属性来获取结果相比,不仅不直观,还缺乏必要的编译器检查(方法或属性错误会被编译器察觉,但索引或键名错误却不会)。最佳方法是把所有的数据打包到一个复合对象中。打包的目的是构造一个粗粒度的数据集合,打包的结果正是传输对象。这种方法看起来乏善可陈,却非常实用。传输对象只是简单的数据容器,除基本的参数验证和内部一致性校验外不应有其他行为,更不能包含业务逻辑,更多附加一些必要的序列化(serialozation)读写方法(Java和C#都提供了自动序列化机制)。另外由于传输对象在不同的层之间同时出现,它的域应当是由通用数据类型或其他传输对象组成,这样既便于序列化,又能提高中性读、降低耦合度。从结构上看,对于传输对象人们只关心它所包裹的数据内容,不关系它的同一性,因而具备典型的值语义,是值对象的一种特例。反过来,值对象未必都是传输对象。
数据传输对象与值对象之间的区别:
从实际用途的角度考察,传输对象的主要目的是携带多项数据以减少网络开销,值对象的主要目的是描述其他对象。
从内容传递的角度考察,传输对象因为重视跨层传输而更关心序列化问题,值对象因为重视值语义而更关心赋值问题。
从抽象程度的角度考察,传输对象不过是简单的数据载体,数据的透明度高、内聚度低,即使内部结构直接裸露在外也未尝不可。信息隐藏可有可无,甚至连getter、seter方法都可省去,丝毫不具备抽象性。
从设计模式的角度考察,传输对象有时是可变的。因为一个传输对象的内容可能需要多步填充或多次更新。值对象则不同,不可变性是一个应尽量遵守的原则,避免产生别名问题而破坏值语义。
从对立概念的角度考察,传输对象在实践中常与业务层的业务对象(Bussiness Object,简称BO)相提并论。业务对象又称领域对象(domin object),是代表业务逻辑或关系的实体。严格说来,领域对象比业务对象更广泛,因为有些问题领域没有所谓的“业务”,如与政府、军队、卫生保健、科研等部门相关的项目。尽管传输对象与业务对象在结构和数据上经常有重合的部分,但一个是具有值语义、具体而短暂的辅助对象,一个是具有引用语义、抽象而持久的核心对象(业务对象是应用系统中最核心的一类对象,常常与数据库中的表有直接的对应关系),具有强力的对立性。在看值对象,与之对立的概念是引用对象,在领域驱动设计(domin-driven design,简称DDD)中又称实体(entity)。
从表现特征的角度考察,引用对象是数据、运算、标识的三位一体,值对象则被抽去了标识,而传输对象连运算都被抽去了,只剩下数据。
从关注焦点的角度考察,传输对象的焦点在于“有什么”,值对象的焦点在于“是什么”,而引用对象的焦点在于"是哪个"。
不同的对象是通过引用来区分的,是否可以说对象的标识也就是它的内存地址?首先,标识不等于引用。标识是一种抽象的OOP概念,多用对象建模中;引用是一种具体的语法机制,多用于编码实现中。另外,虽然标识大都通过引用来实现,但也有其他的实现方式,比如通过Java中的数组索引(array index)。其次,引用不等于内存地址。C++中的引用通过指针来实现,指针即内存地址,但Java对C#中的引用则一般不是原始地址(raw address),而是不透明指针(opaque pointer),以保证内存的安全性。如果牵扯到对象的持久化或序列化问题,情况就更复杂了。比如在通过ORM来获取对象时,相同的主键对应的对象未必有相同的物理地址。如果是从同一个持久化上下文(persistence context)下获取的对象,因为缓存(cache)的缘故,它们具有相同的引用。再例如一个对象需要跨系统传输,不同机器之间内存地址的比较更是无从谈起。如何协调这类逻辑标识与物理标识之间的矛盾,正是ORM和分布式引用的重要课题之一。一直避谈标识的具体实现,强调引用的抽象指代,也是基于这方面的考虑。