Day712. 封闭类-Java8后最重要新特性

Posted 阿昌喜欢吃黄桃

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Day712. 封闭类-Java8后最重要新特性相关的知识,希望对你有一定的参考价值。

封闭类:刹住失控的扩展性

Hi,我是阿昌,今天学习记录的是关于如何刹住失控的扩展性:封闭类

封闭类这个特性,首先在 JDK 15 中以预览版的形式发布。

在 JDK 16 中,改进的封闭类再次以预览版的形式发布。

最后,封闭类在 JDK 17 正式发布。

那么,什么是封闭类呢?封闭类的英文,使用的词汇是"sealed classes"。

从名字我们就可以感受到,封闭类首先是 Java 的类,然后它还是封闭的。

Java 的类,我们都知道什么意思。那么,“封闭”又是什么意思呢?

字面的意思,就是把一些东西封存起来,里面的东西出不去,外面的东西也进不来,所以可查可数。

一、阅读案例

面向对象的编程语言中,研究表示形状的类,是一个常用的教学案例。

下面的这段代码,就是一个简单的、抽象的形状类的定义。

这个抽象类的名字是 Shape。

它有一个抽象方法 area(),用来计算形状的面积。它还有一个公开的属性 id,用来标识这个形状的对象。

public abstract class Shape 
    public final String id;
    
    public Shape(String id) 
        this.id = id;
    
    
    public abstract double area();

我们都知道,正方形是一个形状。

正方形可以作为形状这个类的一个扩展类。

它的代码可以是下面的样子。

public class Square extends Shape 
    public final double side;
    
    public Square(String id, double side) 
        super(id);
        this.side = side;
    
    
    @Override
    public double area() 
        return side * side;
    

那么,到底怎么判断一个形状是不是正方形呢?

这个问题的答案,表面上看起来很简单,只要判断这个形状的对象是不是一个正方形的实例就可以了。

这个判断的例子,看起来可以是下面的样子。

static boolean isSquare(Shape shape) 
    return (shape instanceof Square);

你可以思考一下,这样是不是真的能判断一个形状是正方形?

花几秒钟想想你的答案。

二、案例分析

其实,上面的这个例子,判断的只是“一个形状的对象是不是一个正方形的实例”。

但实际上,一个形状的对象即使不是一个正方形的类,它也有可能是一个正方形。

什么意思呢?

比如说有一个对象,表示它的类是长方形或者菱形的类。

如果这个对象的每一个边的长度都是一样的,其实它就是一个正方形,但是表示它的类是长方形或者菱形的类,而不是正方形类。

所以,上面的这段代码还是有缺陷的,并不总是能够正确判断一个形状是不是正方形。详细地,我们来看下一段代码,你就对这个缺陷有一个更直观的了解了。

我们都知道,长方形也是一个形状,它也可以作为形状这个类的一个扩展类。

下面的这段代码,定义的就是一个长方形。这个类的名字是 Rectangle,它是 Shape 的扩展类。

public class Rectangle extends Shape 
    public final double length;
    public final double width;
    
    public Rectangle(String id, double length, double width) 
        super(id);
        this.length = length;
        this.width = width;
    
    
    @Override
    public double area() 
        return length * width;
    

代码读到这里,对于“怎么判断一个形状是不是正方形”这个问题,我觉得你可能已经有了一个更好的思路。

没错,正方形是一个特殊的长方形。如果一个长方形的长和宽是相等的,那么它也是一个正方形。

上面的那段“判断一个形状是不是正方形”的代码,就没有考虑到长方形的特例,所以它是有缺陷的实现。

知道了长方形这个类,我们就能改进我们的判断了。

改进的代码,要把长方形考虑进去。它看起来可以是下面的样子。

public static boolean isSquare(Shape shape) 
    if (shape instanceof Rectangle rect) 
        return (rect.length == rect.width);
    
    
    return (shape instanceof Square);

写完上面的代码,似乎就可以长舒一口气:哎,这难缠的正方形,我们终于搞定了。

但其实,这个问题我们还没有搞定。因为正方形也是一个特殊的菱形,如果一个对象是一个菱形类的实例,上面的代码就有缺陷。更令人窘迫的是,正方形还是一个特殊的梯形,还是一个特殊的多边形。

随着我们学习一步一步的深入,我们知道还有很多形状的特殊形式是正方形,而且我们并不知道我们知识范围外的那些形状,当然更不能提穷举它们了。

这,实在有点让人抓狂!问题出在哪里呢?无限制的扩展性,是问题的根源

正如现实世界里,我们没有办法穷举到底有多少形状的特殊形式是正方形;在计算机的世界里,我们也没有办法穷举到底有多少形状的对象可以是正方形。

如果我们解决不了形状类的穷举问题,我们就不太容易使用代码来判断一个形状是不是正方形。

而解决问题的办法,就是限制可扩展类的扩展性

三、怎么限制住扩展性?

可扩展性不是面向对象编程的一个重要指标吗?

为什么要限制可扩展性呢?

其实,面向对象编程的最佳实践之一,就是要把可扩展性限制在可以预测和控制的范围内,而不是无限的可扩展性。

一个可扩展的类,子类和父类可能会相互影响,从而导致不可预知的行为。涉及敏感信息的类,增加可扩展性不一定是个优先选项,要尽量避免父类或者子类的影响。

虽然我们使用了 Java 语言来讨论继承的问题,但其实这些是面向对象机制的普遍问题,甚至它们也不单单是面向对象语言的问题,比如使用 C 语言的设计和实现,也存在类似的问题。

由于继承的安全问题,我们在设计 API 时,有两个要反省思考的点:

一个类,有没有真实的可扩展需求,能不能使用 final 修饰符?
一个方法,子类有没有重写的必要性,能不能使用 final 修饰符?

限制住不可预测的可扩展性,是实现安全代码、健壮代码的一个重要目标。JDK 17 之前的 Java 语言,限制住可扩展性只有两个方法,使用私有类或者 final 修饰符。

显而易见,私有类不是公开接口,只能内部使用;

而 final 修饰符彻底放弃了可扩展性。

要么全开放,要么全封闭,可扩展性只能在可能性的两个极端游走。全封闭彻底没有了可扩展性,全开放又面临固有的安全缺陷,这种二选一的状况有时候很让人抓狂,特别是设计公开接口的时候。

JDK 17 之后,有了第三种方法。

这个办法,就是使用 Java 的 sealed 关键字。使用类修饰符 sealed 修饰的类是封闭类

使用类修饰符 sealed 修饰的接口是封闭接口。

封闭类和封闭接口限制可以扩展或实现它们的其他类或接口。

通过把可扩展性的限制放在可以预测和控制的范围内,封闭类和封闭接口打开了全开放和全封闭两个极端之间的中间地带,为接口设计和实现提供了新的可能性。

四、怎么声明封闭类

那么,怎么使用封闭类呢?

封闭类这个概念,涉及到两种类型的类。

第一种是被扩展的父类,第二种是扩展而来的子类。

通常地,我们把第一种称为封闭类,第二种称为许可类

封闭类的声明使用 sealed 类修饰符,然后在所有的 extends 和 implements 语句之后,使用 permits 指定允许扩展该封闭类的子类。

比如,使用 sealed 类修饰符,我们可以把形状这个类声明为封闭类。

下面的这个例子中,Shape 是一个封闭类,可以扩展它的子类只有两个,分别为 Circle 和 Square。

也就是说,这里定义的形状这个类,只允许有圆形和正方形两个子类。

public abstract sealed class Shape permits Circle, Square 
    public final String id;

    public Shape(String id) 
        this.id = id;
    

    public abstract double area();

由 permits 关键字指定的许可子类(permitted subclasses),必须和封闭类处于同一模块(module)或者包空间(package)里。

如果封闭类和许可类是在同一个模块里,那么它们可以处于不同的包空间里,就像下面的例子。

public abstract sealed class Shape
    permits co.ivi.jus.ploar.Circle,
            co.ivi.jus.quad.Square 
    public final String id;

    public Shape(String id) 
        this.id = id;
    

    public abstract double area();

如果允许扩展的子类和封闭类在同一个源代码文件里,封闭类可以不使用 permits 语句,Java 编译器将检索源文件,在编译期为封闭类添加上许可的子类。

比如下面的两种 Shape 封闭类的声明,一个封闭类使用了 permits 语句,另外一个封闭类没有使用 permits 语句。

但是,这两个声明具有完全一样的运行时效果。

public abstract sealed class Shape 
    public final String id;

    public Shape(String id) 
        this.id = id;
    

    public abstract double area();

    public static final class Circle extends Shape 
        // snipped
    

    public static final class Square extends Shape 
        // snipped
    

public abstract sealed class Shape
         permits Shape.Circle, Shape.Square 
    public final String id;

    public Shape(String id) 
        this.id = id;
    

    public abstract double area();

    public static final class Circle extends Shape 
        // snipped
    

    public static final class Square extends Shape 
        // snipped
    

使用 permits 语句。

因为这样的话,代码的阅读者不需要去翻找上下文,也能一目了然地知道这个封闭类支持哪些许可类。

这会给代码的阅读者带来很多的便利,包括节省时间以及少犯错误。

五、怎么声明许可类

许可类的声明需要满足下面的三个条件:

  • 许可类必须和封闭类处于同一模块(module)或者包空间(package)里,也就是说,在编译的时候,封闭类必须可以访问它的许可类;
  • 许可类必须是封闭类的直接扩展类;
  • 许可类必须声明是否继续保持封闭:
    • 许可类可以声明为终极类(final),从而关闭扩展性;
    • 许可类可以声明为封闭类(sealed),从而延续受限制的扩展性;
    • 许可类可以声明为解封类(non-sealed), 从而支持不受限制的扩展性。

许可类 Circle 是一个解封类;

许可类 Square 是一个封闭类;

许可类 ColoredSquare 是一个终极类;

而 ColoredCircle 既不是封闭类,也不是许可类。

public abstract sealed class Shape 
    public final String id;

    public Shape(String id) 
        this.id = id;
    

    public abstract double area();
    
    public static non-sealed class Circle extends Shape 
        // snipped
    
    
    public static sealed class Square extends Shape 
        // snipped
    
    
    public static final class ColoredSquare extends Square 
        // snipped
    

    public static class ColoredCircle extends Circle 
        // snipped
    

需要注意的是,由于许可类必须是封闭类的直接扩展,因此许可类不具备传递性

也就是说,上面的例子中,ColoredSquare 是 Square 的许可类,但不是 Shape 的许可类。

六、案例回顾

回头看看前面的案例,怎么判断一个形状是不是正方形呢?

封闭类能帮助我们解决这个问题吗?如果使用了封闭类,这个问题的答案也就呼之欲出了。

首先,我们要把形状这个类定义为封闭类。

这样,所有形状的子类就可以穷举了。

然后,我们寻找可以用来表示正方形的许可类。找到这些许可类后,只要我们能够判断这个形状的对象是不是一个正方形,问题就解决了。比如下面的代码,形状被定义为封闭类 Shape。

而且,Shape 这个封闭类只有两个终极的许可类。

一个许可类是表示圆形的 Circle,一个许可类是表示正方形的 Square。

public abstract sealed class Shape
         permits Shape.Circle, Shape.Square 
    public final String id;

    public Shape(String id) 
        this.id = id;
    

    public abstract double area();

    public static final class Circle extends Shape 
        // snipped
    

    public static final class Square extends Shape 
        // snipped
    

由于 Shape 是个封闭类,在这段代码的许可范围内,一个形状 Shape 的对象要么是一个圆形 Circle 的实例,要么是一个正方形 Square 的实例,没有其他的可能性。

这样的话,判断一个形状是不是正方形这个问题就变得比较简单了。

只要能够判断出来一个形状的对象是不是一个正方形的实例,这个问题就算是解决了。

static boolean isSquare(Shape shape) 
    return (shape instanceof Square);

这样的逻辑在案例分析那一小节的场景中并不成立,为什么现在就成立了呢?

根本的原因,在案例分析那一小节的场景中,Shape 类是一个不受限制的类,我们没有办法知道它所有的扩展类,因此我们也就没有办法穷尽正方形的所有可能性。

而在使用封闭类的场景下,Shape 类的所有扩展类,我们都是已知的,所以我们就有办法检查每一个扩展类的规范,从而对这个问题做出正确的判断。

七、总结

可扩展性的限定方法有四个:

  1. 使用私有类;
  2. 使用 final 修饰符;
  3. 使用 sealed 修饰符;
  4. 不受限制的扩展性。

在我们日常的接口设计和编码实践中,使用这四个限定方法的优先级应该是由高到低的。

最优先使用私有类,尽量不要使用不受限制的扩展性。如果要丰富你的代码评审清单,有了封闭类后,你可以加入下面这一条:

一个类,如果有真实的可扩展需求,能不能枚举,可不可以使用 sealed 修饰符?

  • 知道 Java 支持封闭类,并且能够使用封闭类编写代码;
    • 面试问题:你知道封闭类吗?会不会使用它?
  • 了解封闭类的原理和它要解决的问题,知道限制住扩展性的办法;
    • 面试问题:面向对象编程的可扩展性有什么问题吗?该怎么处理这些问题?
  • 能够有意识地使用封闭类来限制类或者接口的扩展性。
    • 面试问题:你写的这段代码,是不是应该使用 final 修饰符或者 sealed 修饰符?

如果你的代码里使用了封闭类,无论是面试的时候还是工作的时候,一定能够给人深刻的印象。因为,这意味着你已经了解了可扩展性的危害,并且有办法降低这种危害的影响,有能力编写出更健壮的代码。

原来正方形变化多端!封闭类可以把无限多种情况变成有限数量的情况,从而达到了不可控到可控的目的。虽然在添加新许可类时必须修改封闭类(打破了开闭原则),但解决了因无限扩展可能导致的诡异Bug,保证的程序的正确性(没有比正确运行更重要的了)。


以上是关于Day712. 封闭类-Java8后最重要新特性的主要内容,如果未能解决你的问题,请参考以下文章

Day709.JShell -Java8后最重要新特性

Day721. 外部函数接口 -Java8后最重要新特性

Day720. 外部内存接口 -Java8后最重要新特性

Day710.文字块-Java8后最重要新特性

Day719. 矢量运算 -Java8后最重要新特性

Day722. 空指针烦恼 -Java8后最重要新特性