Java记录是否有望最终成为值类型?

72
JDK 14引入的记录预览功能(JEP 384)是一项伟大的创新。它们使得创建简单的不可变类更加容易,这些类是值的纯集合,没有各种库中泛型元组类固有的上下文丢失。
由Brian Goetz编写的JEP描述(https://openjdk.java.net/jeps/384)很好地解释了其意图。然而,我希望它与最终引入的值类型有更密切的关联。值类型的最初目标相当广泛:通过删除这些类型对象不需要的所有开销(例如引用间接性、同步),从而允许潜在的显着性能提升,用于仅与其值相关的对象。此外,它还可以提供语法上的便利,例如myPosition != yourPosition而不是!myPosition.equals(yourPosition)
看起来,记录的限制非常接近于潜在值类型所需的类型限制。然而,JEP在动机方面没有提到这些目标。我曾试图找到任何关于这些考虑的公共记录,但未成功。
因此,我的问题是:记录是否打算成为可能转向值类型的一部分,还是这些概念完全不相关,并且未来的值类型可能看起来完全不同?

我提出问题的动机是:如果记录成为语言的永久部分,那么如果将来的版本中可能存在显著的性能优势,采用它们编写代码将会是一个额外的激励。

3个回答

105
记录和值类型有很多共同点——它们都是隐式的最终(final)和浅不可变(shallowly immutable)。因此,可以理解为两者是同一种东西。实际上,它们是不同的,它们可以共存,也可以相互配合。
这两种新类型的类都涉及某种限制,以换取一定的好处。(就像枚举(enum)一样,你放弃了对实例化的控制,作为回报,你获得了更简洁的声明、在switch语句中的支持等等。)
记录(record)要求你放弃扩展性、可变性以及将表示与API解耦的能力。作为回报,你将获得构造函数、访问器、equals、hashCode等的实现。
值类型(value class)要求你放弃身份,这包括放弃扩展性和可变性,以及其他一些东西(例如同步)。作为回报,你将获得一组不同的好处——扁平化表示、优化的调用序列以及基于状态的equals和hashCode。
如果你愿意做出妥协,你可以获得两种好处 -- 这将成为一个“价值记录”。价值记录有很多用例,所以今天是记录的类可能明天就可以迁移到价值记录,并且会变得更快。
但是,我们不希望强制所有记录都成为值,或者所有值都成为记录。有些值类希望使用封装,而有些记录希望有身份(这样它们可以组织成树或图),这也是可以的。

4
谢谢,这很有道理。我能看出将这两个概念分开保持了很大的优势。期待在语言中看到 inline - sprinter
2
@BrianGoetz 模式匹配只能用于记录吗?还是也支持值类型?我目前还没有看到任何相关信息,只是好奇一下;-) - mmirwaldt
6
对于记录类型,将内置解构函数;其他类(原始或标识)将能够声明显式的解构模式。就像构造函数是为记录类型内置的一样,但其他类需要声明它们一样。 - Brian Goetz
@BrianGoetz 这是个好消息。我可以想象很多开发者会像使用 lambda/stream API 一样喜欢模式匹配并经常使用它。如果只能在记录中使用模式匹配,那真的会很遗憾。 - mmirwaldt

7
声明:本答案仅扩展其他答案的总结一些含义并举例说明。您不应该基于这些信息做出任何决定,因为模式匹配和值类型仍然是变化的主题。
关于数据类别(records)与值类型(value types)有两个有趣的文档:
旧版文档发布于2018年2月 http://cr.openjdk.java.net/~briangoetz/amber/datum_2.html#are-data-classes-the-same-as-value-types 新版文档发布于2019年2月 https://cr.openjdk.java.net/~briangoetz/amber/datum.html#are-records-the-same-as-value-types 每个文档都包括一个关于记录和值类型之间差异的段落。
旧版文档中写道:
缺少布局多态性意味着我们必须牺牲其他东西:自我引用。值类型V不能直接或间接地引用另一个V。
此外,
与值类型不同,数据类很适合表示树和图节点。
但是,
但是值类不需要放弃任何封装,实际上,封装对某些值类型的应用是必不可少的。 让我们澄清一下: 您无法使用值类型实现基于节点的组合数据结构,例如链接列表或分层树。但是,您可以使用值类型作为这些数据结构的元素。 此外,相反地,值类型支持某些形式的封装,而记录则根本不支持。这意味着您可以在值类型中拥有未在类头中定义且对值类型用户隐藏的附加字段。记录无法做到这一点,因为它们的表示受限于其API,即所有字段都在类头中声明(且仅在那里!)。 以下是一些示例,以说明以上内容。 例如,你可以使用记录创建组合的逻辑表达式,但是不能使用值类型:
sealed interface LogExpr { boolean eval(); } 

record Val(boolean value) implements LogExpr {}
record Not(LogExpr logExpr) implements LogExpr {}
record And(LogExpr left, LogExpr right) implements LogExpr {}
record Or(LogExpr left, LogExpr right) implements LogExpr {}

这将无法使用值类型,因为这需要同一值类型的自我引用能力。你希望能够创建类似于"Not(Not(Val(true)))"的表达式。
例如,您还可以使用记录来定义类分数
record Fraction(int numerator, int denominator) { 
    Fraction(int numerator, int denominator) {
        if (denominator == 0) {
            throw new IllegalArgumentException("Denominator cannot be 0!");
        }
    }
    public double asFloatingPoint() { return ((double) numerator) / denominator; }
    // operations like add, sub, mult or div
}

计算该分数的浮点数值怎么样呢? 您可以在记录Fraction中添加一个方法asFloatingPoint()。 每次调用该方法时,它将始终计算(并重新计算)相同的浮点数值。 (默认情况下,记录和值类型是不可变的)。 但是,您无法以对用户隐藏的方式预先计算并存储该记录中的浮点数值。 而且,您也不想在类头中显式声明浮点数值作为第三个参数。 幸运的是,值类型可以做到这一点:

inline class Fraction(int numerator, int denominator) { 
    private final double floatingPoint;
    Fraction(int numerator, int denominator) {
        if (denominator == 0) {
            throw new IllegalArgumentException("Denominator cannot be 0!");
        }
        floatingPoint = ((double) numerator) / denominator;
    }
    public double asFloatingPoint() { return floatingPoint; }
    // operations like add, sub, mult or div
}

当然,隐藏字段可能是您想使用值类型的原因之一。但它只是其中的一个方面,可能是比较次要的。 如果您创建了许多 Fraction 实例并将它们存储在集合中,那么从平坦内存布局中受益会很大。 这肯定是更重要的原因,有利于将值类型优先于记录。

有一些情况您可能希望同时从记录和值类型中受益。
例如,您可能想开发一个游戏,在游戏中通过地图移动棋子。 您之前保存了一份包含每个移动的步数的历史记录列表,并且现在想根据该移动列表计算下一个位置。
如果您的类 Move 是一个值类型,那么该列表可以使用平坦的内存布局。
如果您的类 Move 同时也是一条记录,则无需定义显式解构模式即可使用模式匹配。
您的代码可以像这样:

enum Direction { LEFT, RIGHT, UP, DOWN }´
record Position(int x, int y) {  } 
inline record Move(int steps, Direction dir) {  }

public Position move(Position position, List<Move> moves) {
    int x = position.x();
    int y = position.y();

    for(Move move : moves) {
        x = x + switch(move) {
            case Move(var s, LEFT) -> -s;
            case Move(var s, RIGHT) -> +s;
            case Move(var s, UP) -> 0;
            case Move(var s, DOWN) -> 0;
        }
        y = y + switch(move) {
            case Move(var s, LEFT) -> 0;
            case Move(var s, RIGHT) -> 0;
            case Move(var s, UP) -> -s;
            case Move(var s, DOWN) -> +s;
        }
    }

    return new Position(x, y);
}

当然,实现相同行为的方法还有很多种。但是,记录和值类型为您提供了更多可以非常有用的实现选项。

3
注意:由于涉及到Java中未来动机或社区关于值类型的意图,因此我的回答可能不完全正确。答案基于我个人的知识和公开可用的信息。
我们都知道Java社区是如此庞大和成熟,以至于他们不会(也不能)添加任何随意的功能进行实验,除非另有说明。记住这一点,我记得在OpenJDK网站上看到了this article,其中简要描述了Java中值类型的想法。需要注意的一件事是,它是在2014年4月写作/更新的,而record则在2020年3月的Java 14中首次出现。
但在上述文章中,他们在解释值类型时确实举了record的例子。它的大部分描述也与当前的record相匹配。

JVM类型系统几乎完全是名义上的,而不是结构上的。同样,值类型的组件应该通过名称而不仅仅是元素编号来标识。(这使值类型更像记录而不是元组。)

毫不意外,Brian Goetz也是这篇文章的共同作者。

但在宇宙中还有其他地方,record 也被表示为 data classes。请参见 this article,它也是由 Brian 编写/更新的。有趣的部分在 这里

Values Victor 将会说 "a data class is really just a more transparent value type."

现在,考虑所有这些步骤,看起来 record 是受元组、数据类、值类型等等启发或用于的功能...但它们并不能互相替代。

正如 Brain 在评论中提到的:

更好的解释这里引用的文档是,元组类型只是值类型的一种可能用途,但绝不是唯一的用途。也可以有需要身份验证的记录类型。因此,两者通常一起工作,但没有一个支配另一个-每个都为表格带来了独特的东西。
关于性能提高的问题,这里有一篇文章(an article),比较了Java 14记录(预览)与传统类的性能。你可能会发现它很有趣。从上面链接的结果中,我没有看到性能方面的显着改进。
据我所知,堆栈比堆栈快得多。因此,由于record实际上只是一个特殊的类,然后进入堆栈而不是堆栈(值类型/基本类型应该像int一样存在于堆栈中,记住Brian“Codes like a class, works like an int!”)。顺便说一下,这是我的个人观点,在这里对堆栈和堆栈进行声明可能是错误的。如果有人纠正我或支持我,我将非常乐意。

2
感谢您的贡献和提供的链接 - 它们非常有帮助。目前我不会期望性能会有所改善,因为实现值类型需要在JVM层面进行重大更改,而这还没有发生。 - sprinter
2
@sprinter 项目 Valhalla(这是值类型(现在称为基本类)的起源)确实对 VM 进行了深度削减;基本类实例被扁平化为对象和数组。 - Brian Goetz
4
你引用的文件可以这样理解:类似元组的类型只是值类型的一种可能用法,但远远不是唯一的用法。同时也可能存在需要标识的记录类型。因此两者通常会一起使用,但并没有一个包含另一个,每个都为此贡献了独特的东西。 - Brian Goetz
感谢 @BrianGoetz 指出这一点。我进行了更正以避免混淆。 - miiiii

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接