作者
,译者作为Oracle的Java语言架构师,Brian Goetz一直致力于Java编程语言在生产力和性能上的日臻完美。最近,Goetz撰文绍了数据类(data classes)这一可能整合到Java语言中的实验性理念。他的研究工作很好地证明了,数据类完全可以与一些即将推出的Java特性自然结合,例如值类型(value types)、模式匹配(pattern matching)等。但是要使数据类概念为成为Java语言的组成部分,还有大量的工作要做。Goetz基于时常提及的“数据就是数据”这一前提,探讨了数据类上存在的问题及一些权衡考虑。
动机
在编写一个Java类时,无论它是多么简单或多么复杂,我们通常都需要在其中加入大量的“八股代码”(boilerplate code)。这使得Java落下了一个“过于繁琐”的名声。对此,Goetz在文中解释道:
即便是对于一个十分简单的数据载体(data carrier)类,如果我们要负责任地编写它的代码,也必须在类中加入大量低价值的、重复性的代码,其中包括构造函数、访问器、
equals()
、hashCode()
、toString()
等。有时候开发人员会试图走点捷径,例如忽略一些重要的方法,但这将会导致一些出人意料的行为,或是降低了代码的可调试性。也可能会因为开发人员并不想再定义一个类,就在服务中硬塞入了一个并不是完全合适的替代类,仅是考虑到该替代类具有“正确的形状”。IDE会帮助开发人员填充大部分的代码,但编写代码只是问题的一小部分。要帮助代码阅读者从几十行样板代码中提炼出“我是x、y和z的一个普通数据载体”这样的设计意图,IDE爱莫能助。重复代码是错误隐匿的好地方。如果有可能的话,最好应该彻底消除这些错误隐匿点。
类似于Scala(case
),Kotlin(data
)和C#(record
)中定义的类声明在设计上就是紧凑的,这可能同样适用于Java,Java类会成为开销最小的普通数据载体。尽管“普通数据载体”(plain data carrier)一词并没有一个正式的定义,但大多数Java开发人员应该对此了然于胸。Java社区的确对语言中提供数据类机制持欢迎态度,但是具体到每个人,不同人对普通数据载体的理解可能会是千差万别的。在文中,Goetz使用了“盲人摸象”的典故,给出了如下解释:
Algebraic Annie会说,“数据类只是一种代数产品类型”。和Scala的
case
类一样,它与模式匹配一并出现,并且最好以不可变形式提供。Annie喜欢选择密封口的甜点(译者注:一些Java经典编程书籍中常以“Dessert”类作为例子代码,诸如《Effective Java》)。Boilerplate Billy会说,“数据类只是一种具有更好语法的普通类”。他会因为其在可变性、扩展性或封装性上的限制而大发雷霆。Billy的兄弟JavaBean Jerry会说,“数据类一定是用于JavaBeans类的,所以我当然可以使用
get()
和set()
之类的方法”。注意,他的妹妹POJO Patty正沉溺于企业POJO中。她提醒我们,她希望数据类可以通过Hibernate等框架代理(Proxyable)。Tuple Tommy会说,“数据类只是一种标称元组(nominal tuple)”。他甚至可能不希望数据类具有超出核心
Object
方法以外的方法。他希望数据类只是一种最简单的聚合,他甚至可能期望数据类不具有名称,这样,两个具有相同“形状”的数据类可以自由地相互转换。Values Victor说,“数据类实际上只是一种更为透明的值类型。”
所有这些人因对“数据类”的共同喜好而联手,但是每个人对数据类具有自己的看法。可能并不存在一个能让所有人都满意的解决方案。
理解问题
在Goetz看来,数据类的概念并非仅限于减少样板代码,而是“表征了更深层次的问题”,将封装的成本均摊在所有的Java类中。作为面向对象的基本原则,抽象和封装使得Java开发人员可以跨越下列各种边界编写健壮和安全的代码:
- 维护边界;
- 安全和可信的边界;
- 完整性边界;
- 版本化边界。
考虑到一些类中固有的复杂性,例如SocketInputStream,因此上述边界是必不可少的。但是,如果有一个如下声明的Point
类,它定义了两个整数组件的一个普通数据载体:
record Point(int x,int y) { ... }
类似于Point
的类也需要关注上述边界吗?Goetz对此解释道:
在各个类间,虽然边界(例如,构造函数的参数是如何映射到状态的?如何从状态中导出相等合约?)的建立和维护成本是固定的,但其所带来的好处并非一成不变的,成本有时会偏离收益。这就是Java开发人员所说的“过多仪式”的问题所在。问题并非在于这些“仪式”是否有存在的价值,而是在于即便这些仪式并没有提供显著的好处,开发人员也必须要调用它们。
在Java给出的封装模型中,类的表示与构造、状态访问和相等方法是完全分离的。很多类并需要给出构造、状态访问和相等判断等方法。类与边界间的关系越是简单,将越可能从简单的模型中受益。在一个简单模型中,我们可以将类定义为类中状态的简单包裹,并从类中获取状态、构造、相等和状态访问间的关系。
此外应指出,表示与API解耦的成本,超出了声明样板成员的开销。封装在本质上就是破坏信息。
数据类的要求
下面我们使用上面声明的Point
类,考虑将Point
类的“去语法糖”(desugared)声明作为普通数据载体。
final class Point extends java.lang.DataClass { public final int x; public final int y; public Point(int x,int y) { this.x = x; this.y = y; } // Point(int x,int y)的解构模式。 // 基于状态实现的equals()、hashCode()和toString()。 // 公开的读取访问器x()和y()。 }
在对普通数据载体设计的进一步研究后,Goetz定义了一组要求(或约束),“用于安全并机械地生成构造函数、模式提取器(Extractor)、访问器(Accessor)、equals()
、hashCode()
、和toString()
等的样板” 。他写道:
我们称一个类
C
是状态向量S
的透明载体(transparent carrier),如果C
满足:
- 存在将状态向量实例映射为
C
实例的函数ctor : S -> C
,(构造函数可能会拒绝一些无效的状态向量,例如分母为零的有理数)。- 存在将
C
实例在ctor
域中映射为状态向量S
的全函数(total function)dtor : C -> S
。- 对于
C
的任意一个实例s
,ctor(dtor(c))
根据C
的equals()
合约等于c
。- 对于两个状态向量
s1
和s2
,如果一个向量的各个组件与另一个向量的相应组件相等(根据组件的equals()
合约),那么或者cstor(s1)
和cstor(s2)
都是未定义的,或者两者根据C
的equals()
合约相等。- 对于等价的实例
c
和d
,调用同一操作将生成相等的结果,即c.m()
与d.m()
相等。并且在操作后,c
和d
应该依然是等价的。这些不变条件试图达成这样的需求,即载体是透明的,并且在类的表示、类的构造和解构之间存在着一种简单并可预测的关系。API就是类的表示。
数据类和模式匹配
Goetz指出,普通数据载体的优势在于,“可将数据类实例在聚合形式和迸发状态之间来回自由转换”。这非常适用于和模式匹配一起工作。正如模式匹配一文所展示的,Goetz在文中介绍了利用switch
结构的解构及其可改进之处。鉴于此,我们可以编写如下代码。
interface Shape { ... } record Point (int x,int y) { ... } record Rect(Point p1,Point p2) implements Shape { ... } record Circle(Point center,int radius) implements Shape { ... } ... switch(shape) { case Rect(Point(var x1,var y1),Point(var x2,var y2)) : ... case Circle(Point(var x,var y),int radius): ... }
Shape
的任一具体实例,都可很容易地在switch
语句中解构。这对于序列化、JSON和XML的编排和解排(marshal/unmarshal)以及数据库映射等外部化(externalization)同样十分有用。
改善设计空间
Goetz指出,在普通数据载体的要求中存在一些折衷。他解释说:
最简单的(也是最严格的)数据类模型就是将数据类作为一个
final
类,其中每个状态组件具有public final
字段、public
构造函数、签名匹配状态描述的解构模式,以及对核心Object
方法的基于状态的实现,甚至不允许具有其它的成员(或隐式成员的显式实现)。这实质上是对标称元组的一种最严格的解释。这一出发点是简单且稳定的,几乎每个人都会从中发现一些值得反对之处。那么,我们是否可以在放宽这些约束条件的同时,继续秉持那些我们在语义上想要得到的优点?下面给出一些严格出发点的可能扩展方向,以及各个方向间的相互作用。
这些方向涵盖了大范围的设计元素及相关问题:
- 接口及一些额外方法。
- 存在违反“只有状态”规则的风险。
- 重写隐式成员。
- 存在违反简单数据载体要求的风险。
- 额外的构造器。
- 确保对象状态和状态描述是等价的。
- 额外的字段。
- 存在违反“状态、整体状态以及只有状态”规则的风险。
- 扩展。
- 与数据类和正常类间扩展相关的问题。
- 可变性。
- 对允许数据类可变的合理性存在质疑。
- 字段的封装和访问器。
- 确保封装的字段必须是可读取的。
- 数组和保护性拷贝(defensive copy)。
- 保护性拷贝违反了解构的不变性要求,应重建数组以确保一个同等的实例。
- 线程安全。
- 对数据类的可变性是如何实现线程安全的质疑。
总结
Java在2017年发展势头强劲,今年有望推出多个备受关注的新特性。然而正如Goetz向InfoQ介绍的,数据类仍然被认为是一种“半成品”的理念。还需要更多的工作,才能完全理解应如何将这一理念转变为现实。
文末,Gozte总结道:
对于在Java中设计一个用于“简单数据聚合”的特性,其中的关键问题在于确定我们愿意在何种程度上放弃自由度。如果试图对类的所有自由度建模,那么我们只是将复杂性转移了。为了获得一些收益,我们必须要接受一些限制。我们认为一个可接受的明智约束是,不允许使用封装将表示从API中解耦出来,也不允许使用封装去调解对状态的读取访问。反过来说,如果一个类可接受这些约束,将在句法和语义上提供显著的好处。
Oracle技术团队的主要成员Vicente Romero最近发布了数据类开发的“首次公开推送”(initial public push)。它给出在Amber项目的代码库的基准分支上。
Goetz向InfoQ介绍了他在数据类上的研究工作:
InfoQ:您在发表该文后,社区都有哪些反馈?
Brian Goetz:反馈在预期中,即对此理念有一些非常积极的评论,还有各种关于如何“改进”的建议,其中大多数相互并不一致。也就是说,人们喜欢这个理念,但正如预期那样,许多人希望我们能将设计中心向一个方向或是另一个方向做一些偏斜,以适应他们的个人偏好。数据类作为一个非常主观的特性,这正是我们所预期的。
InfoQ:您是否想到有朝一日数据类机制会整合到Java编程语言中?如果是这样,要解决您在文中谈及所有问题,还需要做哪些努力?
Goetz:这将需要一些“烘焙时间”。在一个语言的设计中,无论你的第一个想法考虑得多么仔细,都难免会出错。第二个想法同样如此。许多语言功能需要数次乃至更多次的迭代,才能最终找到一个正确的着陆点。所以我们需要去做试验、设计原型、收集反馈、迭代并再迭代,直到我们认为自己已经抵达了正确之处。
InfoQ:在数据类上,推广非Java语言实现紧凑类(例如,Scala的case类)的Rebase(版本衍合)是否会成为一个目标?
Goetz:每种语言都有自己的表层语法。但是,数据类关联了其它一些语言的特性(例如,模式匹配),并且我们希望(与Lambda一样)其它一些语言会针对这些特性提供运行时支持,并从中获得可互操作的好处。
InfoQ:据您所知,在实现更紧凑的类声明上,Scala,Kotlin和C#的架构师是否面临着类似的挑战?
Goetz:的确如此。但是Kotlin和Scala在项目一开始就比C#走得更近,因此需要处理的限制也更少。每种语言在设计空间上会略有差异。
InfoQ:对于数据类,您希望我们的读者能了解的最重要信息是什么?
Goetz:数据类关注的是数据,而非语法上的简洁性。数据类为在对象模型中建模纯数据提供了一种自然的方式。并非所有的类都是纯数据载体,即便这些类想要使用数据类提供的简洁性优点。
InfoQ:您的数据类研究近期将会有何进展?
Goetz:我正在将数据类所需的特性分解为一些更细粒度的特性,以适用于所有的类。例如,即便对于一个明显并非仅是数据载体的类,其中的构造函数也会充斥着一些易于出错的重复代码。我们可以通过在构造函数的参数和表示间建立更高层次上的对应关系,来取代构造函数。这将使数据类更简单,成为一种只是用于其它语言特性的语法糖。这样,无需将该特性硬塞入到数据类中,更多的类就可以从中受益。
相关资源
- InfoQ报道“与Brian Goetz聊Java的模式匹配”(2017年9月27日)。
- Brian Goetz在Devoxx比利时大会上的演讲“Java Language Features – All Aboard Project Amber”(2017年11月10日)。
- InfoQ报道“Java值类型设计进展”(2017年11月30日)。
- InfoQ报道“2018年Java展望”(2017年11月30日)。
查看英文原文: Brian Goetz Speaks to InfoQ on Data Classes for Java
转自 http://www.infoq.com/cn/news/2018/02/data-classes-for-java