Java的检查异常只是奇怪的联合类型(附实例)

这个有趣的事实在我的脑海里已经有一段时间了,最近reddit上一个关于 "用密封接口偷渡检查异常 "的帖子让我在这里写了这篇文章。就是说,Java在它还没有变得很酷的时候就有了联合类型!(如果你仔细看的话)。(如果你仔细看的话)。

什么是联合类型?

Ceylon 是一种被低估的JVM语言,但从未真正起飞,这太糟糕了,因为它引入的概念非常优雅(例如,他们如何在union类型之上实现nullable类型作为语法糖,这比任何使用Option类型的单体或kotlin的临时类型系统扩展好得多)。

所以,这些概念之一就是联合类型。目前最流行的语言之一是TypeScript,尽管C++、PHP和Python也有类似的东西。(事实上,联合类型是否被标记并不与本帖相关)。

如果你理解了Java的交叉类型A & B (意味着某物 是A的子类型也是B的子类型),那么就很容易理解联合类型A | B(意味着某物是A或B中任何一个的子类型)。TypeScript展示了一个简单的例子

function printId(id: number | string) {
  console.log("Your ID is: " + id);
}
// OK
printId(101);
// OK
printId("202");
// Error
printId({ myID: 22342 });

结构类型与名义类型

这样的联合类型(或交集类型)是一种结构性类型,与我们在Java中通过名义类型所做的相反,在那里你必须在每次要使用这个联合时为它声明一个命名的类型。例如,在jOOQ中,我们有这样的东西:

interface FieldOrRow {}
interface Field<T> extends FieldOrRow {}
interface Row extends FieldOrRow {}

很快,Java 17的发行版将把上述类型的层次结构封存如下(为了简洁起见,省略了Field<T>Row 的子类型):

sealed interface FieldOrRow permits Field<T>, Row {}
sealed interface Field<T> extends FieldOrRow permits ... {}
sealed interface Row extends FieldOrRow permits ... {}

在结构上或名义上做这些事情都是有利有弊的。

结构性类型化

  • 优点:你可以在任何地方创建任何类型集的临时联盟
  • 优点:你不必改变现有的类型层次结构,当你无法访问它时,这一点至关重要,例如,当你想做类似上述number | string 类型的事情时。这有点像Java 8中引入的JSR 308类型注释。

名义类型化

  • 优点:你可以将文档附加到类型上,并在形式上(而不是结构上)重新使用它。TypeScript和许多其他语言为这种东西提供了类型别名,所以你可以同时拥有两个世界,尽管别名被删除了,这意味着你保留了复杂的结构类型,导致了奇怪的错误信息。
  • 优点:你可以封住类型的层次,以允许在子类型之间进行穷举检查(例如,在上面,只能有Field<T>Row 的子类型FieldOrRow 。一个结构化的联合类型是由联合类型描述隐含地 "密封 "的(不知道是不是这样叫的),但是对于名义类型,你可以确保没有其他人可以扩展类型层次,(除非你使用non-sealed 关键字明确地允许它)。

归根结底,像结构类型和名义类型这样的东西往往是一个硬币的两面,利弊主要取决于品味和你对代码库的控制程度。

那么,检查过的异常是如何联合类型的呢?

当你声明一个抛出检查性异常的方法时,该方法的返回类型实际上就是这样一种联合类型。看看这个Java中的例子:

public String getTitle(int id) throws SQLException;

调用方现在必须使用try-catch来*"检查 "*这个方法调用的结果,或者声明重新抛出被检查的异常:

try {
    String title = getTitle(1);
    doSomethingWith(title);
}
catch (SQLException e) {
    handle(e);
}

如果早期的Java有联合类型而不是检查过的异常,我们可能会这样声明,而不是如下:

public String|SQLException getTitle(int id);

同样地,这个方法的调用者也必须*"检查 "*这个方法调用的结果。没有简单的方法来重新抛出它,所以如果我们真的想重新抛出,我们需要一些语法糖,或者一直重复同样的代码,Go风格:

// Hypothetical Java syntax:
String|SQLException result = getTitle(1);

switch (result) {
    case String title -> doSomethingWith(title);
    case SQLException e -> handle(e);
}

很明显,这样一个JEP 406风格的开关模式匹配语句或表达式可以实现穷举检查,就像现有的JEP 409密封类方法一样,唯一的区别是,现在所有的东西都是结构类型的,而不是名义上的类型。

事实上,如果你声明了多个检查过的异常,比如JDK的反射API:

public Object invoke(Object obj, Object... args)
throws 
    IllegalAccessException, 
    IllegalArgumentException,
    InvocationTargetException

有了联合类型,这将只是这样,而不是这样:

// Hypothetical Java syntax:
public Object
    | IllegalAccessException
    | IllegalArgumentException
    | InvocationTargetException invoke(Object obj, Object... args)

以及来自 catch 块的 union 类型语法,它可以检查穷尽性(是的,我们在catch 有 union 类型!)...

try {
    Object returnValue = method.invoke(obj);
    doSomethingWith(returnValue);
}
catch (IllegalAccessException | IllegalArgumentException e) {
    handle1(e);
}
catch (InvocationTargetException e) {
    handle2(e);
}

还是可以用开关模式匹配的方法来检查穷尽性:

// Hypothetical Java syntax:
Object
    | IllegalAccessException
    | IllegalArgumentException
    | InvocationTargetException result = method.invoke(obj);

switch (result) {
    case IllegalAccessException, 
         IllegalArgumentException e -> handle1(e);
    case InvocationTargetException e -> handle2(e);
    case Object returnValue = doSomethingWith(returnValue);
}

这里的一个微妙的注意事项是,异常是Object 的子类型,所以我们必须把这种情况放在最后,因为它 "支配 "着其他的情况(关于支配的讨论见JEP 406)。同样,我们可以证明穷尽性,因为所有参与联合类型的类型都有一个开关情况。

我们可以用检查过的异常来模拟联合类型吗?

你知道Jeff Goldblum会怎么说

但本博客还是知道要这么做。假设对于每一个可能的类型,我们都有一个合成的(代码生成的?)检查异常来包装它(因为在Java中,异常是不允许通用的):

// Use some "protective" base class, so no one can introduce 
// RuntimeExceptions to the type hierarchy
class E extends Exception {

    // Just in case you're doing this in performance sensitive code...
    @Override
    public Throwable fillInStackTrace() {
        return this;
    }
}

// Now create a wrapper exception for every type you want to represent
class EString extends E {
    String s;
    EString(String s) {
        this.s = s;
    }
}
class Eint extends E {
    int i;
    Eint(int i) {
        this.i = i;
    }
}

这样做的好处是我们不需要等待瓦尔哈拉在泛型中支持原始类型,也不需要重化它们。我们已经模拟了这一点,正如你在上面看到的。

接下来,我们需要一个任意度数的开关仿真(22度可能就够了)。这里有一个2度的开关:

// Create an arbitrary number of switch utilities for each arity up 
// to, say 22 as is best practice
class Switch2<E1 extends E, E2 extends E> {
    E1 e1;
    E2 e2;

    private Switch2(E1 e1, E2 e2) {
        this.e1 = e1;
        this.e2 = e2;
    }

    static <E1 extends E, E2 extends E> Switch2<E1, E2> of1(E1 e1) {
        return new Switch2<>(e1, null);
    }

    static <E1 extends E, E2 extends E> Switch2<E1, E2> of2(E2 e2) {
        return new Switch2<>(null, e2);
    }

    void check() throws E1, E2 {
        if (e1 != null)
            throw e1;
        else
            throw e2;
    }
}

最后,我们可以用catch块来模拟我们的穷举检查开关:

// "Union type" emulating String|int
Switch2<EString, Eint> s = Switch2.of1(new EString("hello"));

// Doesn't compile, Eint isn't caught (catches aren't exhaustive)
try {
    s.check();
}
catch (EString e) {}

// Compiles fine
try {
    s.check();
}
catch (EString e) {}
catch (Eint e) {}

// Also compiles fine
try {
    s.check();
}
catch (EString | Eint e) {}

// Doesn't compile because Eint "partially dominates" EString | Eint
try {
    s.check();
}
catch (Eint e) {}
catch (EString | Eint e) {}

"很好",是吧?我们甚至可以设想在catch块中进行重构,这样我们就可以自动地从辅助的 "E "类型中解开值。

由于我们在Java中已经有了 "联合类型"(在catch块中),并且由于检查过的异常声明可以被改造成与方法的实际返回类型组成的联合类型,我仍然希望在某个遥远的未来,会有一个更强大的Java,这些 "联合类型"(还有交叉类型)会被做成第一类。像jOOQ 这样的API将大大受益于此。

猜你喜欢

转载自juejin.im/post/7126361505775222797