Java 中的“密封接口”有何意义?

What is the point of a “sealed interface” in Java?

密封 classes密封接口preview feature in Java 15, with a second preview in Java 16, and now proposed delivery in Java 17.

他们提供了 classic 示例,例如 Shape -> CircleRectangle

我理解 sealed classes:提供的 switch 语句示例对我来说很有意义。但是,密封的 接口 对我来说是个谜。任何实现接口的 class 都必须为它们提供定义。接口不会损害实现的完整性,因为接口本身是无状态的。不管我是否想将实施限制在几个选定的 classes.

你能告诉我密封接口在 Java 15+ 中的正确用例吗?

虽然接口本身没有状态,但它们可以访问状态,例如通过 getter,并且可能有代码通过 default 方法对该状态执行某些操作。

因此支持sealed 类 的推理也可以应用于接口。

假设你写了一个认证库,包含一个密码编码的接口,即char[] encryptPassword(char[] pw)。您的库提供了几个可供用户选择的实现。

您不希望他能够传递他自己的可能不安全的实现。

自从 Java 在版本 14 中引入了 records,密封接口的一个用例肯定是创建密封记录。这对于密封的 classes 是不可能的,因为记录不能扩展 class(很像枚举)。

接口并不总是完全由 API 单独定义。举个例子,在最好的情况下,ProtocolFamily. This interface would be easy to implement, considering its methods, but the result would not be useful regarding the intended semantics, as all methods accepting ProtocolFamily as input 只会抛出 UnsupportedOperationException

这是一个典型的接口示例,如果该功能存在于早期版本中,该接口将被密封;该接口旨在抽象库导出的实现,而不是在该库之外有实现。

较新的类型 ConstantDesc 甚至明确提到了该意图:

Non-platform classes should not implement ConstantDesc directly. Instead, they should extend DynamicConstantDesc

API Note:

In the future, if the Java language permits, ConstantDesc may become a sealed interface, which would prohibit subclassing except by explicitly permitted types.

关于可能的用例,密封抽象 class 和密封接口之间没有区别,但是密封接口仍然允许实现者扩展不同的 classes(在由作者)。或者由 enum 类型实现。

简而言之,有时,接口用于在库与其客户端之间实现最小耦合,而不打算 client-side 实现它。

Could you tell me the proper use case of sealed interfaces in Java 15+?

我写了一些实验代码和支持 blog 来说明如何使用密封接口为 Java 实现 ImmutableCollection 接口层次结构,提供 合同结构可验证 不变性。我认为这可能是密封接口的实际用例。

示例包括四个sealed接口:ImmutableCollectionImmutableSetImmutableListImmutableBagImmutableCollectionImmutableList/Set/Bag 扩展。每个叶子接口 permits 两个最终的具体实现。此博客描述了限制接口的设计目标,因此开发人员无法实现“不可变”接口并提供可变的实现。

注意:我是 Eclipse Collections 的提交者。

基本上是为了在没有具体状态可供不同成员共享时提供一个密封的层次结构。这是实现接口和扩展 class 之间的主要区别 - 接口没有自己的字段或构造函数。

但在某种程度上,这不是重要的问题。真正的问题是为什么您希望以密封的层次结构开始。一旦建立起来,应该更清楚密封接口的位置。

(为示例的人为性和长篇大论提前道歉)

1。在没有“为 subclassing 设计”的情况下使用 subclassing。

假设您有一个这样的 class,它在您已经发布的库中。

public final class Airport {
    private List<String> peopleBooked;

    public Airport() {
        this.peopleBooked = new ArrayList<>();
    }

    public void bookPerson(String name) {
        this.peopleBooked.add(name);
    }

    public void bookPeople(String... names) {
        for (String name : names) {
            this.bookPerson(name);
        }
    }

    public int peopleBooked() {
        return this.peopleBooked.size();
    }
}

现在,您想向您的图书馆添加一个新版本,该版本将在预订时打印出预订人员的姓名。有几种可能的方法可以做到这一点。

如果您是从头开始设计,您可以合理地将 Airport class 替换为 Airport 界面,并将 PrintingAirport 设计为与 [=41] 组合=] 像这样。

public interface Airport {
    void bookPerson(String name);

    void bookPeople(String... names);

    int peopleBooked();
}
public final class BasicAirport implements Airport {
    private final List<String> peopleBooked;

    public Airport() {
        this.peopleBooked = new ArrayList<>();
    }

    @Override
    public void bookPerson(String name) {
        this.peopleBooked.add(name);
    }

    @Override
    public void bookPeople(String... names) {
        for (String name : names) {
            this.bookPerson(name);
        }
    }

    @Override
    public int peopleBooked() {
        return this.peopleBooked.size();
    }
}
public final class PrintingAirport implements Airport {
    private final Airport delegateTo;

    public PrintingAirport(Airport delegateTo) {
        this.delegateTo = delegateTo;
    }

    @Override
    public void bookPerson(String name) {
        System.out.println(name);
        this.delegateTo.bookPerson(name);
    }

    @Override
    public void bookPeople(String... names) {
        for (String name : names) {
            System.out.println(name);
        }

        this.delegateTo.bookPeople(names);
    }

    @Override
    public int peopleBooked() {
        return this.peopleBooked.size();
    }
}

这在我们的假设中是不可行的,因为 Airport class 已经存在。将会有对 new Airport() 的调用和期望类型 Airport 的方法,除非我们使用继承,否则不能以向后兼容的方式保留。

因此,要在 java 15 之前做到这一点,您需要从 class 中删除 final 并编写子 class.

public class Airport {
    private List<String> peopleBooked;

    public Airport() {
        this.peopleBooked = new ArrayList<>();
    }

    public void bookPerson(String name) {
        this.peopleBooked.add(name);
    }

    public void bookPeople(String... names) {
        for (String name : names) {
            this.bookPerson(name);
        }
    }

    public int peopleBooked() {
        return this.peopleBooked.size();
    }
}
public final class PrintingAirport extends Airport {
    @Override
    public void bookPerson(String name) {
        System.out.println(name);
        super.bookPerson(name);
    }
}

此时我们 运行 进入了最基本的继承问题之一 - 有很多方法可以“打破封装”。因为Airport中的bookPeople方法刚好在内部调用了this.bookPerson,所以我们的PrintingAirportclass按设计工作,因为它新的bookPerson方法会结束每人被召唤一次

但是如果把Airportclass改成这样,

public class Airport {
    private List<String> peopleBooked;

    public Airport() {
        this.peopleBooked = new ArrayList<>();
    }

    public void bookPerson(String name) {
        this.peopleBooked.add(name);
    }

    public void bookPeople(String... names) {
        for (String name : names) {
            this.peopleBooked.add(name);
        }
    }

    public int peopleBooked() {
        return this.peopleBooked.size();
    }
}

那么 PrintingAirport subclass 将无法正常运行,除非它也覆盖了 bookPeople。进行反向更改,除非它没有覆盖 bookPeople.

,否则它不会正确运行

这不是世界末日之类的,它只是一些需要考虑和记录的东西——“你如何扩展这个 class 以及你允许覆盖什么”,但是当你有一个 public class 开放扩展 任何人 都可以扩展它。

如果您跳过记录如何 subclass 或没有足够的记录,很容易导致您无法控制的代码使用您的库或模块可能依赖于一个小细节您现在遇到的超级class。

Sealed classes 允许您通过打开您的 superclass 最多扩展到您想要的 classes 来回避这个问题。

public sealed class Airport permits PrintingAirport {
    // ...
}

现在您无需向外部消费者提供任何文件,只需您自己。

那么接口如何适应这个呢?好吧,假设您确实超前考虑并且拥有通过组合添加功能的系统。

public interface Airport {
    // ...
}
public final class BasicAirport implements Airport {
   // ...
}
public final class PrintingAirport implements Airport {
    // ...
}

您可能不确定您不想稍后使用继承来保存 classes 之间的一些重复,但是因为您的 Airport 接口是 public 你需要做一些中间的 abstract class 或类似的东西。

你可以防御性地说“你知道吗,在我更好地了解我希望这个 API 去哪里之前,我将成为唯一能够实现接口的人” .

public sealed interface Airport permits BasicAirport, PrintingAirport {
    // ...
}
public final class BasicAirport implements Airport {
   // ...
}
public final class PrintingAirport implements Airport {
    // ...
}

2。表示具有不同形状的数据“案例”。

假设您向 Web 服务发送了一个请求,它将 return JSON 中的两个事件之一。

{
    "color": "red",
    "scaryness": 10,
    "boldness": 5
}
{
    "color": "blue",
    "favorite_god": "Poseidon"
}

当然,有些做作,但您可以很容易地想象一个“类型”字段或类似的字段来区分将出现的其他字段。

因为这是 Java,我们要将原始无类型 JSON 表示映射到 classes。让我们来玩一下这个情况。

一种方法是让一个 class 包含所有可能的字段,只需要一些 null 取决于。

public enum SillyColor {
    RED, BLUE
}
public final class SillyResponse {
    private final SillyColor color;
    private final Integer scaryness;
    private final Integer boldness;
    private final String favoriteGod;

    private SillyResponse(
        SillyColor color,
        Integer scaryness,
        Integer boldness,
        String favoriteGod
    ) {
        this.color = color;
        this.scaryness = scaryness;
        this.boldness = boldness;
        this.favoriteGod = favoriteGod;
    }

    public static SillyResponse red(int scaryness, int boldness) {
        return new SillyResponse(SillyColor.RED, scaryness, boldness, null);
    }

    public static SillyResponse blue(String favoriteGod) {
        return new SillyResponse(SillyColor.BLUE, null, null, favoriteGod);
    }

    // accessors, toString, equals, hashCode
}

虽然这在技术上可行,因为它确实包含所有数据,但在类型级安全性方面并没有太多收获。任何获取 SillyResponse 的代码都需要知道在访问对象的任何其他属性之前检查 color 本身,并且它需要知道哪些属性可以安全获取。

我们至少可以使 color 成为一个枚举而不是一个字符串,这样代码就不需要处理任何其他颜色,但它仍然远不理想。情况越复杂或数量越多,情况就变得更糟。

理想情况下,我们想要做的是为您可以打开的所有案例提供一些通用的超类型。

因为不再需要打开它,所以 color 属性 将不是绝对必要的,但根据个人喜好,您可以将其保留在界面上可访问的内容。

public interface SillyResponse {
    SillyColor color();
}

现在这两个子classes 将有不同的方法集,获取其中任何一个的代码可以使用instanceof 来确定它们有哪些。

public final class Red implements SillyResponse {
    private final int scaryness;
    private final int boldness;

    @Override
    public SillyColor color() {
        return SillyColor.RED;
    }

    // constructor, accessors, toString, equals, hashCode
}
public final class Blue implements SillyResponse {
    private final String favoriteGod;

    @Override
    public SillyColor color() {
        return SillyColor.BLUE;
    }

    // constructor, accessors, toString, equals, hashCode
}

问题在于,因为 SillyResponse 是一个 public 接口,任何人都可以实现它,而 RedBlue 不一定是唯一的子 classes 可以存在。

if (resp instanceof Red) {
    // ... access things only on red ...
}
else if (resp instanceof Blue) {
    // ... access things only on blue ...
}
else {
    throw new RuntimeException("oh no");
}

这意味着这种“哦不”的情况总是会发生。

旁白:在 java 15 之前,人们使用 "type safe visitor" pattern. I recommend not learning that for your sanity, but if you are curious you can look at code ANTLR 生成 - 它是一个由不同“形状”数据结构组成的大型层次结构。

密封 classes 让你说“嘿,这些是唯一重要的情况。”

public sealed interface SillyResponse permits Red, Blue {
    SillyColor color();
}

即使这些案例共享零个方法,该接口也可以像“标记类型”一样发挥作用,并且在您期望其中一个案例时仍然为您提供一个类型来编写。

public sealed interface SillyResponse permits Red, Blue {
}

此时您可能会开始看到与枚举的相似之处。

public enum Color { Red, Blue }

枚举说“这两个实例是仅有的两种可能性。”他们可以有一些方法和字段。

public enum Color { 
    Red("red"), 
    Blue("blue");

    private final String name;

    private Color(String name) {
        this.name = name;
    }

    public String name() {
        return this.name;
    }
}

但是所有实例都需要具有相同的方法和相同的字段,并且这些值需要是常量。在密封的层次结构中,您获得相同的“这些是仅有的两种情况”保证,但不同的情况可以具有非常量数据和彼此不同的数据 - 如果这有意义的话。

“密封接口 + 2 个或更多记录 classes” 的整个模式非常接近 rust 枚举等结构的意图。

这同样适用于具有不同行为“形状”但没有自己的要点的一般对象。

3。强制不变

有一些不变性,如不变性,如果您允许子classes,则无法保证。

// All apples should be immutable!
public interface Apple {
    String color();
}
public class GrannySmith implements Apple {
    public String color; // granny, no!

    public String color() {
        return this.color;
    }
}

并且这些不变量稍后可能会在代码中依赖,例如将对象提供给另一个线程或类似的东西。使层次结构密封意味着您可以记录并保证比允许任意子 classing.

更强的不变量

封顶

密封接口或多或少与密封 classes 具有相同的目的,当你想在 classes 之间共享实现时,你只需要使用具体继承,这超出了默认方法之类的东西可以给。

在 java 之前,有 15 位开发人员曾经认为代码可重用性是目标。但这并非在所有程度上都是正确的,在某些情况下,我们需要广泛的可访问性而不是可扩展性以获得更好的安全性和代码库管理。

此功能是关于在 Java 中启用更细粒度的继承控制。密封允许 类 和接口定义它们允许的子类型。

密封接口使我们能够使其能够清楚地推理所有可以实现它的类。