04 | Closed classes: How to stop runaway scalability

The feature of closed classes was first released as a preview version in JDK 15. In JDK 16, improved enclosing classes are again released as a preview. Finally, closed classes were officially released in JDK 17.

So, what is a closed class? The English word for closed classes is “sealed classes”. From the name, we can feel that a closed class is first a Java class, and then it is still closed.

We all know what Java classes mean. So, what does “closed” mean? The literal meaning is to seal something up so that the things inside can’t get out and the things outside can’t get in, so it can be checked and counted.

“Closed” and “can be counted” may seem like very common words, but in fact they are not easy to understand. Let’s learn about closed classes step by step through cases and code.

Read the case

In object-oriented programming languages, studying classes that represent shapes is a common teaching case. In today’s review case, let’s start with the shape class and study how to determine whether a shape is a square.

The following code is the definition of a simple, abstract shape class. The name of this abstract class is Shape. It has an abstract method area(), which is used to calculate the area of a shape. It also has a public attribute id, which is used to identify the object of this shape.

package co.ivi.jus.sealed.former;

public abstract class Shape {<!-- -->
    public final String id;
    
    public Shape(String id) {<!-- -->
        this.id = id;
    }
    
    public abstract double area();
}

We all know that a square is a shape. Square can be used as an extension class of the shape class. Its code can look like below.

package co.ivi.jus.sealed.former;

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;
    }
}

So, how do you tell whether a shape is a square? The answer to this question seems simple on the surface, just determine whether the object of this shape is an instance of a square. An example of this judgment could look like the following.

static boolean isSquare(Shape shape) {<!-- -->
    return (shape instanceof Square);
}

You can think about it, can this really determine whether a shape is a square? Take a few seconds to think about your answer, and we’ll move on to the next step.

Case analysis

In fact, the above example only determines “whether a shape object is an instance of a square”. But in fact, even if a shape object is not a square class, it may still be a square. What does that mean? For example, there is an object, indicating that its class is a rectangle or diamond. If the length of each side of this object is the same, it is actually a square, but its class is a rectangle or rhombus class, not a square class. Therefore, the above code is still flawed and cannot always correctly determine whether a shape is a square.

In detail, let’s look at the next piece of code, and you will have a more intuitive understanding of this defect. We all know that a rectangle is also a shape, and it can also be used as an extension class of the shape class. The following code defines a rectangle. The name of this class is Rectangle, which is an extension class of Shape.

package co.ivi.jus.sealed.former;

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;
    }
}

After reading the code here, I think you may have a better idea about the question “how to determine whether a shape is a square”. Yes, a square is a special rectangle. If the length and width of a rectangle are equal, then it is also a square. The code above that “determines whether a shape is a square” does not take into account the special case of rectangles, so it is a flawed implementation.

Knowing the rectangle class, we can improve our judgment. Improved code to take the rectangle into consideration. It could look like the following.

public static boolean isSquare(Shape shape) {<!-- -->
    if (shape instanceof Rectangle rect) {<!-- -->
        return (rect.length == rect.width);
    }
    
    return (shape instanceof Square);
}

After writing the above code, it seems that you can breathe a sigh of relief: Hey, we finally solved this difficult square.

But actually, we haven’t solved this problem yet. Because a square is also a special type of rhombus, the above code is flawed if an object is an instance of the rhombus class. What’s even more embarrassing is that a square is a special trapezoid or a special polygon. As we learn step by step, we know that there are many special forms of shapes that are squares, and we do not know those shapes outside the scope of our knowledge, and of course we cannot mention them exhaustively.

This is really a bit maddening!

What’s the problem? Unlimited scalability is the root of the problem. Just like in the real world, we have no way to enumerate how many special forms of shapes are squares; in the computer world, we have no way to enumerate how many shapes of objects can be squares. If we can’t solve the exhaustive problem of shape classes, it won’t be easy for us to use code to determine whether a shape is a square.

The solution to the problem is to limit the extensibility of extensible classes.

How to limit scalability?

You may ask, isn’t scalability an important indicator of object-oriented programming? Why limit scalability? In fact, one of the best practices of object-oriented programming is to limit scalability to a predictable and controllable range, rather than unlimited scalability.

With an extensible class, subclasses and parent classes may interact with each other, causing unpredictable behavior.

For classes involving sensitive information, increasing scalability is not necessarily a priority. Try to avoid the influence of parent classes or subclasses.

Although we use the Java language to discuss inheritance issues, these are actually common problems with object-oriented mechanisms. Even they are not just problems with object-oriented languages. For example, there are similar problems in the design and implementation of C language.

Due to the security issues of inheritance, we have two points to reflect on when designing APIs:

Does a class have real extensibility requirements? Can it use the final modifier?

Is it necessary for subclasses to override a method? Can it use the final modifier?

Limiting unpredictable scalability is an important goal in achieving safe and robust code.

The Java language before JDK 17 limited extensibility to only two methods, using private classes or final modifiers. Obviously, private classes are not public interfaces and can only be used internally; and the final modifier completely gives up extensibility. Either fully open or fully closed, scalability can only wander between the two extremes of possibility. Fully closed has no scalability at all, and fully open faces inherent security flaws. This either-or situation is sometimes very frustrating, especially when designing public interfaces.

After JDK 17, there is a third method. This method is to use Java’s sealed keyword. A class modified with the class modifier sealed is a closed class; an interface modified with the class modifier sealed is a closed interface. Enclosing classes and enclosing interfaces restrict other classes or interfaces that can extend or implement them.

By placing the limits of scalability within a predictable and controllable range, closed classes and closed interfaces open up a middle ground between the two extremes of fully open and fully closed, providing new possibilities for interface design and implementation.

How to declare a closed class

So, how to use closed classes? The concept of closed class involves two types of classes. The first is an extended parent class, and the second is an extended subclass. Usually, we call the first type a closed class and the second type a permission class.

The declaration of a closed class uses the sealed class modifier, and then, after all extends and implements statements, uses permits to specify which subclasses are allowed to extend the closed class. For example, using the sealed class modifier, we can declare the shape class as a closed class. In the example below, Shape is a closed class, and there are only two subclasses that can extend it, namely Circle and Square. In other words, the shape class defined here only allows two subclasses: circle and square.

package co.ivi.jus.sealed.modern;

public abstract sealed class Shape permits Circle, Square {<!-- -->
    public final String id;

    public Shape(String id) {<!-- -->
        this.id = id;
    }

    public abstract double area();
}

The permitted subclasses specified by the permits keyword must be in the same module or package as the enclosing class. If the closed class and the permissioned class are in the same module, they can be in different package spaces, as in the example below.

package co.ivi.jus.sealed.modern;

public abstract sealed classShape
    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();
}

If extended subclasses and closed classes are allowed in the same source code file, the closed class does not need to use the permits statement. The Java compiler will retrieve the source file and add permitted subclasses to the closed class at compile time. For example, in the following two declarations of Shape closed classes, one closed class uses the permits statement, and the other closed class does not use the permits statement. However, the two declarations have exactly the same runtime effect.

package co.ivi.jus.sealed.improved;

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
    }
}

package co.ivi.jus.sealed.improved;

public abstract sealed classShape
         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
    }
}

However, if you read “Code Improvement”, you will be inclined to always use the permits statement. Because in this case, readers of the code can clearly know at a glance which permission classes this closed class supports without having to look for the context. This will bring a lot of convenience to readers of the code, including saving time and making fewer mistakes.

How to declare permission class

The declaration of a permission class needs to meet the following three conditions:

  • The permission class must be in the same module or package space as the enclosing class, that is to say, the enclosing class must be able to access its permission class during compilation;
  • The permission class must be a direct extension class of the closed class;
  • A permission class must declare whether it will remain closed:
  • Permitted classes can be declared final, thus turning off extensibility;
  • Permitted classes can be declared as sealed, thereby continuing restricted extensibility;
  • Permitted classes can be declared non-sealed, allowing unrestricted extensibility.

For example, in the following example, the permission class Circle is an unsealed class; the permission class Square is a closed class; the permission class ColoredSquare is a final class; and ColoredCircle is neither a closed class nor a permission class.

package co.ivi.jus.sealed.propagate;

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
    }
}

It should be noted that since the permission class must be a direct extension of the enclosing class, the permission class is not transitive. **In other words, in the above example, ColoredSquare is a permitted class of Square, but not a permitted class of Shape.

Case review

At this point, let’s look back at the previous case. How to determine whether a shape is a square? Can a closed class help us solve this problem? If closed classes are used, the answer to this question is readily apparent.

First, we need to define the shape class as a closed class. In this way, all shape subclasses can be exhausted. We then look for a permitted class that can be used to represent a square. After finding these permission classes, as long as we can determine whether the object of this shape is a square, the problem is solved.

For example, in the following code, the shape is defined as a closed class Shape. Moreover, the closed class Shape has only two ultimate permission classes. One permission class is Circle, which represents a circle, and one permission class is Square, which represents a square.

package co.ivi.jus.sealed.improved;

public abstract sealed classShape
         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
    }
}

Since Shape is a closed class, within the scope of this code, a Shape object is either an instance of a circle Circle or an instance of a square Square, and there is no other possibility.

In this case, the problem of determining whether a shape is a square becomes relatively simple. As long as we can determine whether a shape object is an instance of a square, this problem is solved.

static boolean isSquare(Shape shape) {<!-- -->
    return (shape instanceof Square);
}

This kind of logic does not hold true in the scenario in the case analysis section. Why does it hold true now? The fundamental reason is that in the scenario of the case analysis section, the Shape class is an unrestricted class. We have no way to know all its extension classes, so we have no way to exhaust all the possibilities of the square. In the scenario of using closed classes, all extension classes of the Shape class are known to us, so we have a way to check the specifications of each extension class to make a correct judgment on this issue.