25. Groovy 孵化功能-记录类record和密封sealed的学习

2023-02-23 17:39:47 浏览数 (2)

1. 介绍

本篇内容为Groovy学习笔记第二十五篇。主要内容为Groovy孵化功能(incubating)的学习。

孵化功能是还没有确定的可以理解为正在开发中的一些功能。在之后的版本迭代过程中可能孵化失败,可能会有重大修改。

2. 记录类-关键字 record

记录类,或者简称为记录,是一种特殊的类,用于对普通数据聚合建模。它们提供了比普通类更紧凑的语法和更少的代码。Groovy已经有了@Immutable@Canonical这样的AST转换,它们已经显著地减少了代码,但是记录已经在Java中引入,并且Groovy中的记录类被设计成与Java记录类保持一致。使用关键字为:record

假设我们想要创建一个表示电子邮件消息的Message记录。出于本例的目的,让我们简化这样的消息,只包含一个发电子邮件地址、一个到电子邮件地址和一个消息体。我们可以如下定义这样的记录:

代码语言:javascript复制
record Message(String from, String to, String body) { } //使用record 关键字,创建了一个记录类

我们将像使用普通类一样使用record类,如下所示:

代码语言:javascript复制
def msg = new Message('zin@zinyan.com', 'yan@zinyan.com', 'Hello!')

println(msg) //输出:Message[from=zin@zinyan.com, to=yan@zinyan.com, body=Hello!]

简化的代码使我们不用定义显式字段如:gettertoStringequalshashCode方法。事实上,它是以下大致等价物的简写:

代码语言:javascript复制
final class Message extends Record {
    private final String from
    private final String to
    private final String body
    private static final long serialVersionUID = 0

    /* constructor(s) */

    final String toString() { /*...*/ }

    final boolean equals(Object other) { /*...*/ }

    final int hashCode() { /*...*/ }

    String from() { from }
    // other getters ...
}

请注意记录获取器的特殊命名约定。它们与字段的名称相同(而不是常用的JavaBean惯例,即用“get”前缀大写)。术语组件通常用于记录,而不是指记录的字段或属性。因此,我们的Message记录包含fromtobody组件。

像在Java中一样,我们可以通过编写自己的代码来重写通常隐式提供的方法:

代码语言:javascript复制
record Point3D(int x, int y, int z) {
    String toString() {
        "Point3D[coords=$x,$y,$z]"
    }
}
println(new Point3D(10,20,30)) //输出:Point3D[coords=10,20,30]

还可以以正常方式将泛型与记录一起使用。例如,考虑以下Coord记录定义:

代码语言:javascript复制
//创建了一个Coord的记录类,并且使用了继承Number的抽象类进行入参。
record Coord<T extends Number>(T v1, T v2){
    double distFromOrigin() { 
        //Math.sqrt 是个开平方公式  下面的代码内容为:v1的平方 v2的平方的和进行开方运算
        Math.sqrt(v1()**2   v2()**2 as double) 
    }
}

def r1 = new Coord<Integer>(3, 4)
println(r1.distFromOrigin()) //输出:5.0


def r2 = new Coord<Double>(6d, 2.5d)
println(r2.distFromOrigin()) //输出:6.5

2.1 特性

记录具有两个明显的特性:

  • 隐式构造函数。上面的示例中我们就可以直接省略构造函数的创建。
  • 可序列化

记录具有隐式构造函数。这可以通过提供自己的构造函数以正常方式重写-如果这样做,需要确保设置了所有字段。然而,为了简洁起见,在省略了普通构造函数的参数声明部分的情况下,可以使用紧凑的构造函数语法。对于这种特殊情况,仍然提供了正常的隐式构造函数,但通过紧凑构造函数定义中提供的语句进行了扩充:

代码语言:javascript复制
public record Warning(String message) {
    public Warning {  //创建一个紧凑的构造函数
        Objects.requireNonNull(message) //判断message是否为空
        message = message.toUpperCase()  //将message 全部转换为大小字母
    }
}

def w = new Warning('Hi zinyan.com')
println w.message()  //输出:HI ZINYAN.COM

类似上面的示例,根本不用在构造方法中填写入参。

还有一个特性就是:可序列化。Groovy本地化代码遵循适用于Java记录的特殊可序列化约定。Groovy类记录类遵循正常的Java类可序列化约定。

2.2 @RecordType

在上面我们创建了一个Message记录,并大致使用了它的方法。也介绍了它和Java中的记录类保持一致。

ps:java是在14的时候添加了record的预览版本经过14,15,16三个大版本的迭代,在17的时候正式上线。

事实上,Groovy经历了一个中间阶段,其中记录关键字被class关键字和附带的@RecordType注解替换:

原先的Groovy的记录类是通过以下方法创建的:

代码语言:javascript复制
import groovy.transform.RecordType
@RecordType
class Message {
    String from
    String to
    String body
}

之后@RecordType被改为元注解,并扩展为其组成子注解,如@TupleConstructor@POJO@RecordBase等。在某种意义上,这是一个经常被忽略的实现细节。但是,如果您希望自定义或配置记录实现,我们可能希望返回@RecordType样式或使用一个组成子注释来扩充记录类。

2.3 Groovy的增强

上面介绍的信息,和java中的记录类可以说差不多,共性也是一样的。而有别于Java的记录类,Groovy提供了部分的功能增强。

2.3.1 参数默认值

Groovy支持构造函数参数的默认值。此功能也适用于以下记录定义中所示的记录。例如:

代码语言:javascript复制
record ColoredPoint(int x, int y = 0, String color = 'white') {}

在定义ColoredPoint记录类的时候,给y值和color值添加了默认值。

不使用时的参数(从右侧删除一个或多个参数)将替换为默认值,如下例所示:

代码语言:javascript复制
def x = new ColoredPoint(5, 5, 'black')
 //因为记录类自动重写了toString方法,所以我们可以直接打印
println x   //输出 :ColoredPoint[x=5, y=5, color=black]

def i = new ColoredPoint(5, 5)
println i  //输出 :ColoredPoint[x=5, y=5, color=white]

def n=new ColoredPoint(5,'green')  //非法传值
println n   //groovy.lang.GroovyRuntimeException: Could not find matching constructor for:

def y=new ColoredPoint(5)
println y   //ColoredPoint[x=5, y=0, color=white]

该处理遵循常规Groovy约定的构造函数默认参数,本质上自动为构造函数提供以下方法:

代码语言:javascript复制
ColoredPoint(int, int, String)
ColoredPoint(int, int)
ColoredPoint(int)

PS:所以new ColoredPoint(5,'green')的写法就会触发异常

也可以使用命名参数(默认值也适用于此处):

代码语言:javascript复制
def n=new ColoredPoint(x:5,color:'green')  //使用命名参数就不会出现异常了
println n   //输出:ColoredPoint[x=5, y=0, color=green]

也可以禁用默认参数处理,如下所示:

代码语言:javascript复制
import groovy.transform.TupleConstructor
import groovy.transform.DefaultsMode


@TupleConstructor(defaultsMode=DefaultsMode.OFF)
record ColoredPoint2(int x, int y, String color) {}

def demo = new ColoredPoint2(4, 5, 'red')
println demo //输出  ColoredPoint2[x=4, y=5, color=red]

这将根据Java的默认值生成一个构造函数。我们如果创建对象的时候传值缺少参数就会出现错误。示例:

代码语言:javascript复制
def demo = new ColoredPoint2( 5, 'red')
println demo

将会输出:

代码语言:javascript复制
Caught: groovy.lang.GroovyRuntimeException: Could not find matching constructor for: ColoredPoint2(Integer, String)
groovy.lang.GroovyRuntimeException: Could not find matching constructor for: ColoredPoint2(Integer, String)
	at zinyan.run(DSJ.groovy:6)

可以强制所有属性具有默认值,如下所示:

代码语言:javascript复制
import groovy.transform.TupleConstructor
import groovy.transform.DefaultsMode

@TupleConstructor(defaultsMode=DefaultsMode.ON)
record ColoredPoint3(int x, int y = 0, String color = 'white') {}


def demo = new ColoredPoint3(y: 5)
println demo //输出  ColoredPoint3[x=0, y=5, color=white]

def demo1 = new ColoredPoint3(5)
println demo1 //输出 ColoredPoint3[x=5, y=0, color=white]

任何没有显式初始值的属性/字段都将被赋予参数类型的默认值(null,或为零/假)。

2.3.2 声明式toString方法自定义

根据Java,我们可以通过编写自己的方法自定义记录的toString方法。如果喜欢更具声明性的样式,也可以使用Groovy的@ToString转换来覆盖默认记录类的toString。例如:

代码语言:javascript复制
import groovy.transform.ToString
// leftDelimiter左边分隔符,rightDelimiter 右边分隔符,过滤掉null的值,includeNames是否显示名称,nameValueSeparator 名称和参数的分隔符
@ToString(ignoreNulls=true, cache=false, includeNames=true,
          leftDelimiter='{', rightDelimiter='}', nameValueSeparator=':')
record Point(Integer x, Integer y, Integer z=null) { }

println new Point(1024,2048)  // 输出 :Point{x:1024, y:2048}

@ToString 还有更多的一些参数。这里不展开详细介绍了。想了解更多的方法可以通过:https://docs.groovy-lang.org/docs/latest/html/gapi/ 查找相关API。

2.3.3 获取记录组件值数据以及相关方法的禁用

我们可以从记录中获取组件值,列表如下:

代码语言:javascript复制
//创建一个Point 记录对象
record Point(int x, int y, String color) { }

def p = new Point(100, 200, 'green')
def (x, y, c) = p.toList()
assert x == 100
assert y == 200
assert c == 'green'

我们也可以使用@RecordOptions(toList=false)禁用此功能。示例如下:

代码语言:javascript复制
import groovy.transform.RecordOptions

@RecordOptions(toList=false)
record Point(int x, int y, String color) { }

默认情况下,我们可以通过toMap获取map数据列表。示例如下:

代码语言:javascript复制
record Point(int x, int y, String color) { }

def p = new Point(100, 200, 'green')
assert p.toMap() == [x: 100, y: 200, color: 'green']

可以通过:@RecordOptions(toMap=false) 禁止掉toMap方法。

除此外,还有sizemodegetAtcopyWithcomponents等参数值,输入值都是boolean。示例:

代码语言:javascript复制
@RecordOptions(toList=false,toMap=false,size=false,mode=false,getAt=false,copyWith=false,components=false)

@RecordOptions除了在记录类中使用,在Groovy的其他类中也是可以使用的。

唯一需要注意的就是,@RecordOptionsGroovy SDK 4.0.0版本才提供的注解对象。在低版本中没有。

例如getAt方法的作用:

代码语言:javascript复制
record Point(int x, int y, String color) { }

def p = new Point(100, 200, 'green')
assert p[1] == 200

如果禁用getAt方法。那么就不能直接使用p[1]进行获取数据了。

其他的几种禁用方式都大同小异。

2.4 Groovy的可选功能

默认情况下,可选功能都是关闭状态。

2.4.1 复制-Copying

在某些组件已更改的情况下制作记录的副本可能很有用。这可以使用可选的copyWith方法完成,该方法接受命名参数。根据提供的参数设置记录组件。对于未提及的组件,使用原始记录组件的(浅)副本。

PS:copyWith方法可以通过@RecordOptions(copyWith=true)开启

代码语言:javascript复制
import groovy.transform.RecordOptions

@RecordOptions(copyWith=true) //开启copy功能
record Fruit(String name, double price) {}


def zinyan = new Fruit('Zinyan', 1024)
println zinyan  //输出 :Fruit[name=Zinyan, price=1024.0]


def Zstudent = zinyan.copyWith(name: 'Z同学') //进行复制操作
println Zstudent  //输出 :Fruit[name=Z同学, price=1024.0]
println zinyan    //输出:Fruit[name=Zinyan, price=1024.0]
2.4.2 深层不变性

与Java一样,记录在默认情况下提供了浅的不可变性。Groovy的@Immutable转换为一系列可变数据类型执行防御性复制。记录可以利用这种防御性复制获得如下的深度不变性。

简单理解就是,原先java中将参数传进方法或者类中,只是一个浅拷贝。而添加@Immutable之后,可以将入参进行深拷贝。

两者之间已经不是一个对象了。示例如下:

代码语言:javascript复制
import groovy.transform.ImmutableProperties

@ImmutableProperties
record Shopping(List items) {}

def items = ['zin', 'yan']
def shop = new Shopping(items)
items << 'com'  //集合对象,添加数据

println shop.items() //输出:[zin, yan]
println items  // 输出:[zin, yan, com]

这些示例说明了Groovy的记录功能背后的原理,它提供了三个级别的便利性:

  • 使用record关键字以获得最大的代码简洁性。
  • 支持使用声明性注解的低代码定制。
  • 在需要完全控制时允许正常方法实现。

也就是说,在使用各种低代码的同时,也支持我们自定义方法实现完全控制。

2.4.3 以类型化元组的形式获取记录的组件

可以以类型化元组的形式获取记录的组件:

通过注解@RecordOptions(components=true)开启。默认是false状态。

代码语言:javascript复制
import groovy.transform.*

@RecordOptions(components=true)
record Point(int x, int y, String color) { }

@CompileStatic
def method() {
    def p1 = new Point(100, 200, 'green')
    def (int x1, int y1, String c1) = p1.components()
    //如果不了解Groovy中字符串拼接逻辑可以通过:https://zinyan.com/?p=388 了解
    println "x1=$x1,y1=$y1,c1=$c1"   //输出:x1=100,y1=200,c1=green
    

    def p2 = new Point(10, 20, 'blue')
    def (x2, y2, c2) = p2.components()
    //** 代表了幂运算 下面示例中就是20的2次方
    println "参数1=${x2*10}, 参数2=${y2**2} ,参数3=${c2.toUpperCase()}"  //输出:参数1=100, 参数2=400 ,参数3=BLUE
    
    def p3 = new Point(1, 2, 'red')
    println "${p3.components() instanceof Tuple3}"  //输出:ture
   
}

method()

Groovy的TupleN类数量有限。如果记录中有大量组件,则可能无法使用此功能。

Tuple从0-16 一共17个对象。所以几乎大部分情况下都能够满足。通常情况下应该不可能有那么多的值

2.5 与Java的区别

上面介绍过,java也有Redord 记录类。

Groovy支持创建类似记录的类以及本地记录。类似记录的类不会扩展Java的Record类,Java不会将此类类视为记录,但在其他方面会具有类似的属性。

@RecordOptions注解(@RecordType的一部分)支持mode注解属性,该属性可以采用三个值之一(默认值为AUTO):

  • NATIVE:生成类似于Java的类。在早于JDK16的JDK上编译时产生错误。
  • EMULATE:为所有JDK版本生成类似记录的类。
  • AUTO:为JDK16 生成一个本机记录,并以其他方式模拟该记录。

使用record关键字还是@RecordType注解与模式无关。

模式使用示例为:

代码语言:javascript复制
import groovy.transform.RecordTypeMode
import groovy.transform.RecordOptions

@RecordOptions(mode = RecordTypeMode.NATIVE)
record Point(int x, int y, String color) { }

3. 密封-sealed关键字

密封类、接口和特性(traits)限制了哪些子类可以扩展/实现它们。在密封类出现之前,类层次结构设计者有两个主要选择:

  • 设置一个类关键字为final不允许扩展。
  • 将类设为public和非final,以允许任何人扩展。

与这些要么全有要么全无的选择相比,密封类提供了一个中间地带。

密封类也比以前用于实现中间地带的其他技巧更灵活。例如,对于类层次结构,访问修饰符(如protectedpackage private)提供了一些限制继承层次结构的能力,但通常以灵活使用这些层次结构为代价。

密封类的层次结构在已知的类、接口和特性(traits)的层次结构中提供完整的继承,但在层次结构之外禁用或只提供受控的继承。

例如,假设我们要创建一个仅包含圆和正方形的形状层次。我们还希望形状界面能够引用层次结构中的实例。我们可以创建如下层次结构:

代码语言:javascript复制
sealed interface ShapeI permits Circle,Square { }
final class Circle implements ShapeI { }
final class Square implements ShapeI { }

密封关键字:sealed.. permits 上面就是创建了一个密封接口,这个必须Groovy SDK 4.0.0版本才能支持。

Groovy还支持另一种注释语法。

代码语言:javascript复制
import groovy.transform.Sealed
//创建了一个密封接口,该接口必须是Circle 或者Square的才能使用
@Sealed(permittedSubclasses=[Circle,Square]) interface ShapeI { }
final class Circle implements ShapeI { }
final class Square implements ShapeI { }

有关于permittedSubclasses关键字的作用我在前面的traits中有进行过介绍,可以参考https://zinyan.com/?p=455

我们可以有一个ShapeI类型的引用,由于permits,它可以指向CircleSquare,因为我们的类是final类,所以我们知道将来不会在我们的层次结构中添加其他类。至少在不更改permits和重新编译的情况下不会。

通常,我们可能希望像这里这样立即锁定类层次结构的某些部分,在这里我们将子类标记为final,但其他时候我们可能希望允许进一步的受控继承。

示例:

代码语言:javascript复制
//创建一个密封类,仅仅许可Circle,Polygon,Rectangle这三个子类
sealed class Shape permits Circle,Polygon,Rectangle { }
//创建final 的Circle 类,继承 Shape
final class Circle extends Shape { }

//创建一个非密封的类,继承Polygon
class Polygon extends Shape { }

//创建一个非密封的类RegularPolygon
non-sealed class RegularPolygon extends Polygon { }

//创建一个不可变的类Hexagon 继承Polygon类
final class Hexagon extends Polygon { }

//创建一个密封类,继承Shape 仅许可Square类
sealed class Rectangle extends Shape permits Square{ }
//创建一个Square静态类,继承Rectangle
final class Square extends Rectangle { }

PS:如果是想在低版本中使用密封,但是没有sealed和non-sealed关键字,可以使用@NonSealed@Sealed 代替

代码语言:javascript复制
import groovy.transform.Sealed
import groovy.transform.NonSealed

@Sealed(permittedSubclasses=[Circle,Polygon,Rectangle]) class Shape { }
final class Circle extends Shape { }

class Polygon extends Shape { }
@NonSealed class RegularPolygon extends Polygon { }
final class Hexagon extends Polygon { }

@Sealed(permittedSubclasses=Square) class Rectangle extends Shape { }
final class Square extends Rectangle { }

下面介绍一下上面的继承逻辑和密封效果。

在本例中,我们允许的Shape子类是CirclePolygonRectangle

而Circle类是final标识的。所以它不能进行扩展。

Polygon隐式声明是非密封的(没有声明默认就是非密封),RegularPolygon显式标记为非密封(使用关键字non-sealed 或者@NonSealed)这意味着我们的层次结构可以通过子类进一步扩展。

例如:PolygonRegularPolygonRegularPolygonHexagon所示。

然后创建的Rectangle是密封的,只允许一种控制的方法进行扩展就是Square

密封类用于创建类似枚举的相关类,这些类需要包含特定于实例的数据。示例如下,创建关于天气的三个枚举对象:

代码语言:javascript复制
//创建了一个枚举对象Weather
enum Weather { Rainy, Cloudy, Sunny }
//初始化集合对象,插入了三个枚举值
def forecast = [Weather.Rainy, Weather.Sunny, Weather.Cloudy]

println forecast  //输出:[Rainy, Sunny, Cloudy]

但我们现在希望在天气预报中添加特定于天气的实例数据。我们可以这样改变我们的抽象:

代码语言:javascript复制
import groovy.transform.Immutable
//创建一个抽象的密封类 Weather
sealed abstract class Weather { }  

//创建三个实体类 继承该密封抽象类
@Immutable(includeNames=true) 
class Rainy extends Weather { 
    Integer expectedRainfall  //定义自己的属性
}
@Immutable(includeNames=true)
 class Sunny extends Weather {
     Integer expectedTemp //定义自己的属性
}

@Immutable(includeNames=true)
class Cloudy extends Weather {
     Integer expectedUV //定义自己的属性
}

def forecast = [new Rainy(12), new Sunny(35), new Cloudy(6)]
println forecast //输出:[Rainy(expectedRainfall:12), Sunny(expectedTemp:35), Cloudy(expectedUV:6)]

密封层次结构在指定整数或抽象数据类型(ADT)时也很有用,如下例所示:

代码语言:javascript复制
import groovy.transform.Immutable
import groovy.transform.Canonical
//创建一个密封接口 Tree ,同时支持泛型T传参
sealed interface Tree<T> {}


@Singleton //声明单例
final class Empty implements Tree {
    String toString() { 'Empty' }
}
//创建一个Canonical声明的final类Node
@Canonical
final class Node<T> implements Tree<T> {
    T value
    Tree<T> left, right
}

//创建一个类
Tree<Integer> tree = new Node<>(42, new Node<>(0, Empty.instance, Empty.instance), Empty.instance)

println tree //输出 :Node(42, Node(0, Empty, Empty), Empty)

密封的层次结构可以很好地处理记录,如以下示例所示:

代码语言:javascript复制
//创建一个密封接口Expr
sealed interface Expr {}

//创建一个记录类ConstExpr,扩展了Expr接口
record ConstExpr(int i) implements Expr {}

//创建了一个记录类PlusExpr,扩展了Expr接口
record PlusExpr(Expr e1, Expr e2) implements Expr {}

//创建了一个记录类MinusExpr,扩展了Expr接口
record MinusExpr(Expr e1, Expr e2) implements Expr {}

//创建了一个记录类NegExpr,扩展了Expr接口
record NegExpr(Expr e) implements Expr {}

def threePlusNegOne = new PlusExpr(new ConstExpr(3), new NegExpr(new ConstExpr(1)))
println threePlusNegOne  //输出:PlusExpr[e1=ConstExpr[i=3], e2=NegExpr[e=ConstExpr[i=1]]]

3.1 和java的区别

  • Java没有为密封类的子类提供默认修饰符,并要求指定final、密封或非密封中的一个。Groovy默认为非密封的,但是如果您愿意,仍然可以使用non-sealed@NonSealed。我们预计样式检查工具CodeNarc最终会有一个规则来查找非密封的存在,所以想要更严格风格的开发人员可以使用CodeNarc和该规则。
  • 目前,Groovy不会检查permittedSubclasses中提到的所有类是否在编译时可用,并与基密封类一起编译。这可能会在Groovy的未来版本中改变。

Groovy支持注解方式创建密封类和接口,也支持使用sealed关键字创建密封类和接口。

@SealedOptions注解支持一个mode注解属性,它可以接受以下参数:(AUTO是默认值)

  • NATIVE:产生一个类似于Java的类。在JDK17之前的jdk上编译时产生错误。
  • EMULATE:指示使用@Sealed注解对类进行密封。这种机制适用于JDK8 的Groovy编译器,但不能被Java编译器识别。
  • AUTO:为JDK17 生成一个本机记录,并以其他方式模拟该记录。

使用sealed关键字还是@Sealed注解与mode无关。示例如下:

代码语言:javascript复制
import groovy.transform.SealedMode
import groovy.transform.RecordOptions

@SealedOptions(mode = SealedMode.NATIVE)
sealed interface Expr {}

PS: @SealedOptions 注解最低版本Groovy SDK 4.0.0。更低版本中没有该注解

4. 小结

本篇学习了记录类record 和密封 sealed的使用。该功能从4.0.0版本开始孵化。现在最新Groovy版本为4.0.6版本。

所以,之后版本可能还是会有一些变化的。但是整体的功能框架已经确定了。改动应该不会过多。

下一篇开始介绍Groovy的闭包吧。如果觉得本篇内容介绍的还算清晰,希望能够给我点个赞。谢谢。

本篇内容参考与Groovy官方文档:http://docs.groovy-lang.org/docs/groovy-4.0.6/html/documentation/#_record_classes_incubating 。

0 人点赞