2.3.5 this 与 super
对象实例化时,至少有一条从本类出发抵达Object 的通路,而打通这条路的两个主要工兵就是this 和super,逢山开路,遇水搭桥。但是this 和super 往往是默默无闻的,在很多情况下可以省略,比如:
- 本类方法调用本类属性。
- 本类方法调用另一个本类方法。
- 子类构造方法隐含调用 super()。
任何类在创建之初,都有一个默认的空构造方法,它是super() 的一条默认通路。构造方法的参数列表决定了调用通路的选择;如果子类指定调用父类的某个构造方法,super 就会不断往上溯源;如果没有指定,则默认调用super()。如果父类没有提供默认的构造方法,子类在继承时就会编译错误,如图2-4 所示。
图2-4 父类默认构造方法缺失
如果父类坚持不提供默认的无参构造方法,必须在本类的无参构造方法中使用super 方式调用父类的有参构造方法,如public Son(){ super(123); }。
一个实例变量可以通过this. 赋值另一个实例变量;一个实例方法可以通过this.调用另一个实例方法;甚至一个构造方法都可以通过this.调用另一个构造方法。如果this 和super 指代构造方法,则必须位于方法体的第一行。换句话说,在一个构造方法中,this和super 只能出现一个,且只能出现一次,否则在实例化对象时,会因子类调用到多个父类构造方法而造成混乱。
由于this 和super 都在实例化阶段调用,所以不能在静态方法和静态代码块中使用this 和super 关键字。this 还可以指代当前对象, 比如在同步代码块synchronized(this){...} 中,super 并不具备此能力。但super 也有自己的特异功能,在子类覆写父类方法时,可以使用super 调用父类同名的实例方法。最后总结一下this和super 的异同点,如图2-5 所示。
图2-5 this和super的异同点
2.3.6 类关系
关系是指事物之间存在单向或相互的作用力或者影响力的状态。类与类之间的关系可分成两种:有关系与没关系,这似乎是一句非常正确的废话,难点在于确定类与类之间是否存在相互作用。证明类之间没关系是一个涉及业务、架构、模块边界的问题,往往由于业务模型的抽象角度不同而不同,是一件非常棘手的事情。如果找到了没有关系的点,就可以如庖丁解牛一样,进行架构隔离、模块解耦等工作。有关系的情况下,包括如下6 种类型:
- 【继承】extends (is-a)。
- 【实现】implements (can-do)。
- 【组合】类是成员变量 (contains-a)。
- 【聚合】类是成员变量 (has-a)。
- 【依赖】是除组合与聚合外的单向弱关系。比如使用另一个类的属性、方法,或以其作为方法的参数输入,或以其作为方法的返回值输出(depends-a)。
- 【关联】是互相平等的依赖关系 (links-a)。
继承和实现是比较容易理解的两种类关系。在架构设计中,要注意组合、聚合、依赖和关联这四者的区别。
组合在汉语中的含义是把若干个独立部分组成整体,各个部分都有其独立的使用价值和生命周期。而类关系中的组合是一种完全绑定的关系,所有成员共同完成一件使命,它们的生命周期是一样的,极度容易混淆。组合体现的是非常强的整体与部分的关系,同生共死,部分不能在整体之间共享。
聚合是一种可以拆分的整体与部分的关系,是非常松散的暂时组合,部分可以被拆出来给另一个整体。比如,汽车与*之间的关系就是聚合关系,*模块包括钢圈、轮胎、气嘴。*拆卸下来用到另一个汽车上是完全没有问题的。
依赖是除组合和聚合外的类与类之间的单向弱关系,就是一个类A 用到类B,那么就说A依赖B,这种关系是偶然的、松散的、临时的。依赖使用另一个类的属性、方法,或以其作为方法的参数输入,或以其作为方法的返回值输出。本书认为,广义上的有向关联也等同于依赖。依赖往往是模块解耦的最佳点。
关联即是互相平等的依赖关系,可以在关联点上进行解耦,但是解耦难度略大于依赖关系。
在类图中,用空心的三角形表示继承,用实心的菱形表示组合,用空心的菱形表示聚合,用一条直线表示关联,这四者都是用实线连接的。用三角形来表示实现,用一个箭头表示依赖,与前面的区别是这两者都是用虚线连接的。在画类图时,菱形、箭头、三角形放在哪一侧呢?在很多类图中,这个处理是非常随意的。如果方向画反了,那么类结构的认知也就反了。有一个规律,有形状的图形符号一律放在权力强的这一侧,如表2-3 所示。
随着业务和架构的发展,类与类的关系是会发生变化的,必须用发展的眼光看待类图。比如表2-3 中的Body 和Head,如果有一天,动物的脑袋可以随意地移植,那么就从组合关系变成聚合关系了。狗与狗绳之间的约束,虽然很弱,但是如果防疫局在狗绳上标记疫苗记录,那么它们之间的关系就会变强,就变成组合关系了。在业务重构过程中,往往会把原来强组合的关系拆开来,供其他模块调用,这就是类图的一种演变。
表2-3 类关系示例图
2.3.7 序列化
内存中的数据对象只有转换为二进制流才可以进行数据持久化和网络传输。将数据对象转换为二进制流的过程称为对象的序列化(Serialization)。反之,将二进制流恢复为数据对象的过程称为反序列化(Deserialization)。序列化需要保留充分的信息以恢复数据对象,但是为了节约存储空间和网络带宽,序列化后的二进制流又要尽可能小。序列化常见的使用场景是RPC 框架的数据传输。常见的序列化方式有三种:
(1) Java原生序列化。Java类通过实现Serializable接口来实现该类对象的序列化,这个接口非常特殊,没有任何方法,只起标识作用。Java 序列化保留了对象类的元数据(如类、成员变量、继承类信息等),以及对象数据等,兼容性最好,但不支持跨语言,而且性能一般。
实现Serializable 接口的类建议设置serialVersionUID 字段值,如果不设置,那么每次运行时,编译器会根据类的内部实现,包括类名、接口名、方法和属性等来自动生成serialVersionUID。如果类的源代码有修改,那么重新编译后serialVersionUID的取值可能会发生变化。因此实现Serializable 接口的类一定要显式地定义serialVersionUID 属性值。修改类时需要根据兼容性决定是否修改serialVersionUID 值:
- 如果是兼容升级,请不要修改 serialVersionUID 字段,避免反序列化失败。
- 如果是不兼容升级,需要修改 serialVersionUID 值,避免反序列化混乱。
使用Java 原生序列化需注意,Java 反序列化时不会调用类的无参构造方法,而是调用native 方法将成员变量赋值为对应类型的初始值。基于性能及兼容性考虑,不推荐使用Java 原生序列化。
(2)Hessian 序列化。Hessian 序列化是一种支持动态类型、跨语言、基于对象传输的网络协议。Java 对象序列化的二进制流可以被其他语言(如C++、Python)反序列化。Hessian 协议具有如下特性:
- 自描述序列化类型。不依赖外部描述文件或接口定义,用一个字节表示常用基础类型,极大缩短二进制流。
- 语言无关,支持脚本语言。
- 协议简单,比 Java原生序列化高效。
相比Hessian 1.0,Hessian 2.0 中增加了压缩编码,其序列化二进制流大小是Java序列化的50%,序列化耗时是Java 序列化的30%,反序列化耗时是Java 反序列化的20%。
Hessian 会把复杂对象所有属性存储在一个Map 中进行序列化。所以在父类、子类存在同名成员变量的情况下,Hessian 序列化时,先序列化子类,然后序列化父类,因此反序列化结果会导致子类同名成员变量被父类的值覆盖。
(3)JSON 序列化。JSON(JavaScript Object Notation)是一种轻量级的数据交换格式。JSON 序列化就是将数据对象转换为JSON 字符串。在序列化过程中抛弃了类型信息,所以反序列化时只有提供类型信息才能准确地反序列化。相比前两种方式,JSON 可读性比较好,方便调试。
序列化通常会通过网络传输对象,而对象中往往有敏感数据,所以序列化常常成为黑客的攻击点,攻击者巧妙地利用反序列化过程构造恶意代码,使得程序在反序列化的过程中执行任意代码。Java 工程中广泛使用的Apache Commons Collections、Jackson、fastjson 等都出现过反序列化漏洞。如何防范这种黑客攻击呢?有些对象的敏感属性不需要进行序列化传输,可以加transient 关键字,避免把此属性信息转化为序列化的二进制流。如果一定要传递对象的敏感属性,可以使用对称与非对称加密方式独立传输,再使用某个方法把属性还原到对象中。应用开发者对序列化要有一定的安全防范意识,对传入数据的内容进行校验或权限控制,及时更新安全漏洞,避免受到攻击。