spotlight
Java SE 15(2020年9月)引入了密封类作为预览功能 。 密封允许类和接口对其允许的子类型有更多的控制; 这对于一般领域建模和构建更安全的平台库都是有用的。
一个类或接口可以声明为sealed
,这意味着只有一组特定的类或接口可以直接扩展它:
sealedinterface Shape
permits Circle, Rectangle { ... }
这声明了一个称为Shape
的密封接口。 permits
列表意味着只有Circle
和Rectangle
可以实现Shape
。 (在某些情况下,编译器可能能够为我们推断allows子句。)任何其他尝试扩展Shape
类或接口都将收到编译错误(或运行时错误,如果您尝试作弊并生成off-标签Shapefile声明Shape
为超类型。)
我们已经熟悉了限制通过final
课程进行扩展的概念。 密封可以被认为是最终性的概括。 限制允许的子类型的集合可能会带来两个好处:超类型的作者可以更好地说明可能的实现,因为它们可以控制所有实现,而编译器可以更好地说明穷举性(例如在switch
语句或强制转换中)。密封类也与记录很好地配对。
上面的接口声明声明Shape
可以是Circle
或Rectangle
而不能是其他任何东西。 换句话说,所有Shape
的集合等于所有Circle
的集合加上所有Rectangles
的集合。 因此,密封类通常称为求和类型 ,因为它们的值集是其他类型的固定列表的值集的总和。 总和类型和密封类并不是什么新鲜事物。 例如, Scala也具有密封类, Haskell和ML具有用于定义和类型的原语(有时称为标记联合或区分联合) 。
总和类型经常在产品类型旁边找到。 最近引入到Java中的记录是产品类型的一种形式,之所以称为记录,是因为它们的状态空间是其组件的状态空间的笛卡尔乘积(的子集)。 (如果这听起来很复杂,请将产品类型视为元组,并将记录视为名义元组 。)让我们使用记录来声明子类型来完成Shape
声明:
sealed interface Shape
permitsCircle , Rectangle {
record Circle(Point center, int radius) implements Shape { }
record Rectangle ( Point lowerLeft, Point upperRight) implements Shape { }
}
在这里,我们看到总和与乘积类型如何结合在一起; 我们可以说“圆形由中心和半径定义”,“矩形由两个点定义”,最后“形状是圆形或矩形”。 因为我们希望以这种方式共同声明基本类型及其实现是常见的,所以当所有子类型都在同一编译单元中声明时,我们允许permits
子句被省略,并将其推断为声明的子类型集在该编译单元中:
sealed interface Shape {
record Circle ( Point center, int radius ) implements Shape { }
record Rectangle ( Point lowerLeft, Point upperRight ) implements Shape { }
}
从历史上看,面向对象的建模鼓励我们隐藏抽象类型的实现集。 我们一直不鼓励询问“ Shape
的可能的子类型是什么”,并且类似地告诉我们,向下转换到特定的实现类是一种“代码味道”。 那么,为什么我们突然添加似乎违反了这些长期原则的语言功能呢? (我们也可以对记录提出相同的问题:是不是违反封装来强制在类表示与其API之间建立特定关系?)
答案当然是“取决于情况”。 在对抽象服务进行建模时,客户端仅通过抽象类型与服务进行交互是一个积极的好处,因为这可以减少耦合并最大限度地提高系统演化的灵活性。 但是,在对特定领域进行建模时,该领域的特性已经众所周知,封装可能不足以提供给我们。 正如我们在记录中看到的那样,当对诸如XY点或RGB颜色这样的平淡的物体进行建模时,使用对象的全部通用性来对数据进行建模既需要大量低价值的工作,而且更糟的是,通常会混淆实际发生的事情。 在这种情况下,封装的成本无法通过其优势来证明。 将数据建模为数据更简单,更直接。
相同的论点适用于密封类。 在为一个易于理解且稳定的域建模时,“我不会告诉您存在哪些形状”的封装并不一定会带来我们希望从不透明抽象中获得的收益,甚至可能使客户很难使用实际上是简单域的内容。
这并不意味着封装是错误的。 它仅表示有时成本和收益之间的平衡不平衡,我们可以使用判断来确定何时提供帮助以及何时阻碍它。 在选择是公开还是隐藏实现时,我们必须清楚封装的好处和成本。 它是在为我们提供改进实施的灵活性,还是仅仅是在另一端已经很明显的方式上破坏了信息的障碍? 通常,封装的好处是可观的,但是在简单的层次结构为易于理解的领域建模的情况下,声明防弹抽象的开销有时可能会超出好处。
当诸如Shape
的类型不仅将其接口提交给实现它的接口,还将其提交给实现它的类时,我们会感觉更好“询问您是一个圈子”并强制转换为Circle
,因为Shape
专门将Circle
命名为它的已知子类型之一。 正如记录是一种更透明的类,总和是一种更透明的多态性。 这就是为什么总和和乘积如此频繁地一起出现的原因; 它们都代表了透明性和抽象性之间的类似折衷,因此,在一个有意义的地方,另一个也有可能。 (产品和通常被称为代数数据类型 。)
诸如Shape
类的密封类会提交可能的子类型的详尽列表,这有助于程序员和编译器以我们没有这些信息就无法避免的方式来推理形状。 (其他工具也可以利用此信息; Javadoc工具在生成的文档页面中为密封类列出了允许的子类型。)
Java SE 14引入了一种有限形式的模式匹配 ,它将在将来扩展。 第一个版本允许我们在instanceof
使用类型模式 :
if (shape instanceof Circle c) {
// compiler has already cast shape to Circle for us, and bound it to c
System.out.printf( "Circle of radius %d%n" , c.radius()) ;
}
从那里到在switch
使用类型模式只是一小段路程。 (这在Java SE 15中不支持,但是很快就会到来。)到达那里时,我们可以使用case
标签为类型模式的switch表达式来计算形状的面积,如下所示1 :
float area = switch ( shape ) {
case Circle c -> Math. PI * c.radius() * c.radius();
case Rectangle r -> Math. abs ((r.upperRight().y() - r.lowerLeft().y())
* (r.upperRight().x() - r.lowerLeft().x()));
// no default needed!
}
这里的密封作用是我们不需要default
子句,因为编译器从Shape
的声明中知道Circle
和Rectangle
覆盖了所有形状,因此default
子句在上述switch
是不可到达的。 (为了防止Shape
的允许子类型在编译和运行时之间发生更改,编译器仍会在切换表达式中默默地插入引发默认子句,但无需坚持要求程序员编写此默认子句以防万一。 )这类似于我们处理另一个穷举性来源的方式-覆盖所有已知常量的enum
上的switch表达式也不需要default子句(在这种情况下通常将其省略是一个好主意)更有可能使我们警觉错过案件。)
诸如Shape
类的层次结构为其客户提供了选择:他们可以完全通过抽象界面处理形状,但也可以“展开”抽象并在有意义时通过更清晰的类型进行交互。 语言功能(例如模式匹配)使这种展开方式更易于阅读和编写。
“产品总和”模式可能是一个有力的模式。 为了使其适当,子类型列表必须极不可能发生变化,并且我们希望客户直接区别子类型将更加容易和有用。
致力于固定的子类型集,并鼓励客户直接使用这些子类型,是紧密耦合的一种形式。 在所有条件都相同的情况下,我们鼓励在设计中使用松散耦合,以最大程度地提高将来进行更改时的灵活性,但是这种松散耦合也会带来成本。 在我们的语言中同时具有“不透明”和“透明”两种抽象,使我们能够为情况选择正确的工具。
我们可能曾经使用过一定数量产品的地方(当时是一种选择)在java.util.concurrent.Future
的API java.util.concurrent.Future
。 Future
表示可以与其启动程序同时运行的计算。 由Future
表示的计算可能尚未开始,尚未开始但尚未完成,已经成功或异常完成,已超时或已被中断取消。 Future
的get()
方法反映了所有这些可能性:
interface Future < V > {
...
V get ( long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException ;
}
如果计算尚未完成,则get()
阻塞,直到出现一种完成模式为止;如果成功,则返回计算结果。 如果通过抛出异常完成了计算,则将此异常包装在ExecutionException
; 如果计算超时或被中断,则会引发另一种异常。 这个API相当精确,但是使用起来有些痛苦,因为有多个控制路径,正常路径( get()
返回一个值)和许多失败路径,每个路径必须在catch
块中处理:
try {
V v = future. get ();
// handle normal completion
}
catch (TimeoutException e) {
// handle timeout
}
catch (InterruptedException e) {
// handle cancelation
}
catch (ExecutionException e) {
Throwable cause = e.getCause();
// handle task failure
}
如果在Java 5中引入Future
时,我们具有密封的类,记录和模式匹配,则可能会如下定义返回类型:
sealed interface AsyncReturn< V > {
record Success < V > (V result) implements AsyncReturn < V > { }
record Failure < V > (Throwable cause) implements AsyncReturn < V > { }
record Timeout < V > () implements AsyncReturn < V > { }
record Interrupted < V > () implements AsyncReturn < V > { }
}
...
interface Future < V > {
AsyncReturn < V > get();
}
在这里,我们说的异步结果是成功(带有返回值),失败(带有异常),超时或取消。 这是对可能结果的更统一的描述,而不是用返回值描述其中一些,并用例外来描述。 客户仍然必须处理所有情况-无法解决任务可能失败的事实-但我们可以统一(更紧凑)地处理案件1 :
AsyncResult r = future.get();switch (r) {
case Success(var result): ...
case Failure(Throwable cause): ...
case Timeout(), Interrupted(): ...
}
考虑乘积和的一个好方法是它们是枚举的泛化。 枚举声明用一个详尽的常量实例集声明一个类型:
enum Planet { MERCURY , VENUS , EARTH , ... }
可以将数据与每个常数相关联,例如行星的质量和半径:
enum Planet {
MERCURY ( 3.303e+23 , 2.4397e6 ),
VENUS ( 4.869e+24 , 6.0518e6 ),
EARTH ( 5.976e+24 , 6.37814e6 ),
...
}
概括地说,密封类不是枚举密封类实例的固定列表,而是枚举种类的固定列表。 例如,此密封界面列出了各种天体,以及与每种天体有关的数据:
sealedinterface Celestial {
record Planet( String name, double mass, double radius)
implements Celestial {}
record Star( String name, double mass, double temperature)
implements Celestial {}
record Comet( String name, double period, LocalDateTime lastSeen)
implements Celestial {}
}
正如您可以在枚举常量上进行详尽的切换一样,您也可以在各种天体上进行详尽的切换1 :
switch (celestial) {
case Planet( String name, double mass, double radius): ...
case Star( String name, double mass, double temp): ...
case Comet( String name, double period, LocalDateTime lastSeen): ...
}
这种模式的示例无处不在:UI系统中的事件,面向服务的系统中的返回码,协议中的消息等。
到目前为止,我们已经讨论了密封类何时可用于将替代项合并到域模型中。 密封类还具有另一个非常不同的应用程序:安全层次结构。
Java始终允许我们通过将类标记为final
来说“此类不能扩展”。 语言中final
的存在承认有关类的一个基本事实:有时将它们设计为可扩展的,有时不进行扩展,并且我们希望支持这两种模式。 实际上, Effective Java建议我们“设计和记录扩展文档,否则禁止它”。 这是极好的建议,如果该语言在我们的帮助下可以提供更多帮助,则可能会更常使用该建议。
不幸的是,这种语言无法通过两种方式帮助我们:类的默认值是可扩展的而不是最终的,而final
机制实际上很弱,因为它迫使作者在限制扩展和使用多态作为实现技术之间进行选择。 。 我们为这种压力付出的一个很好的例子是String
; 字符串不可变对于平台的安全性至关重要,因此String
不能公开扩展-但是对于实现具有多个子类型来说将非常方便。 (解决此问题的成本是巨大的; 紧凑的字符串通过对仅由Latin-1
字符组成的字符串进行特殊处理,显着减少了占用空间并提高了性能,但是如果String
是sealed
类,则这样做会容易得多,而且便宜得多而不是final
一个。)
这是一个众所周知的技巧,它通过使用程序包专用的构造函数来模拟密封类(而不是接口)的效果,并将所有实现都放在同一个程序包中。 这有所帮助,但是公开一个不打算扩展的公共抽象类仍然有些不舒服。 图书馆作者更喜欢使用接口公开不透明的抽象。 抽象类应作为一种实现辅助,而不是建模工具。 (请参阅有效的Java ,“更喜欢抽象类的接口”。)
使用密封的接口,库作者不再需要在使用多态作为一种实现技术,允许不受控制的扩展或将抽象公开为接口之间进行选择,他们可以拥有这三种。 在这种情况下,作者可以选择使实现类可访问,但是实现类将更可能被封装。
密封类允许库作者将可访问性与可扩展性分离。 拥有这种灵活性很好,但是什么时候应该使用它呢? 当然,我们不希望像List
这样密封接口-用户创建新种类的List
是完全合理和合乎需要的。 密封可能会带来成本(用户无法创建新的实现)和收益(实现可以全局推断所有实现); 当收益超过成本时,我们应该省钱。
sealed
修饰符可以应用于类或接口。 尝试密封已经完成的类(无论是使用final
修饰符显式声明还是隐式final(例如,枚举和记录类))都是错误的。
密封类有一个permits
列表,它们是唯一允许的直接子类型。 这些必须在密封类编译时可用,实际上必须是密封类的子类型,并且必须与密封类在同一模块中(如果在未命名的模块中,则在同一包中。)这一要求实际上意味着它们必须与密封等级共同维护 ,这对于这种紧密耦合是合理的要求。
如果所有允许的子类型都与密封类在同一编译单元中声明,则permits
子句可以省略,并被推断为同一编译单元中的所有子类型。 密封类不能用作lambda表达式的功能接口,也不能用作匿名类的基类型。
密封类的子类型必须更明确地说明其可扩展性。 密封类的子类型必须是sealed
, final
或明确标记non-sealed
。 (记录和枚举是隐式final
,因此不需要这样显式标记。)如果类或接口没有sealed
直接超类型,则将其标记non-sealed
是错误的。
这是二进制和源兼容的更改,以sealed
现有的final
类。 密封尚未完成所有实现的非最终类既不是二进制也不是源兼容的。 将新的允许的子类型添加到密封的类中是二进制兼容的,但不是源兼容的(这可能会破坏switch
表达式的穷举性。)
密封类具有多种用途; 当在领域模型中捕获详尽的替代方案时,它们可用作领域建模技术; 当希望将可访问性与可扩展性脱钩时,它们还可用作实现技术。 密封类型是记录的自然补充,因为它们共同形成了一种称为代数数据类型的通用模式; 它们也很自然地适合模式匹配 ,这也将很快出现在Java中。
1该示例使用一种形式的switch表达式-一种使用模式作为大小写标签的形式-Java语言尚不支持。 六个月的发布节奏使我们可以共同设计功能,但可以独立交付。 我们完全希望交换机在不久的将来能够将模式用作案例标签。
翻译自: https://www.infoq.com/articles/java-sealed-classes/?topicPageSponsorship=c1246725-b0a7-43a6-9ef9-68102c8d48e1
spotlight