Skip to content

java/2024_javadaylondon-amber-lab

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Notes

Text Blocks

This first part is about Text blocks, a new way to write strings of characters, added the the JDK 15.

Simple Text Blocks

This set of exercises shows you how you can use a text block for a multilines string of characters, instead of having a concatenation of several strings, each one line. By completing these exercises, you will learn:

  • how to create a text block, and correctly place the opening and closing triple double quotes,
  • analyze the string written in the text block, and make sure it is a multiline string,
  • control the position of the limit between the incidental white spaces and the text block itself,
  • control the line feed at the end of each line, removing it completely, or making sure that the trailing blank spaces are kept.

Records

This second part is about records, a new tool to model your immutable data, in the form of named tuples, added to the JDK 16.

Simple Records

This first set of exercises shows you the first basic elements you need to know on records:

  • how you can create a record class,
  • how you can add a custom constructor,
  • how you can make a record comparable and sort records in a list,
  • how you can define the constructor of a record, in its normal form or its compact form,
  • how you can add some validation rules on your records.

Less Simple Records

This second set makes you create a Range record, that implements the Iterable interface and some validation rules.

Harder Records

This third set makes you use defensive copy to ensure the immutability of the internal state of your record. A record may be built on mutable components, that should be copied when you create your record, and when you return the value of such a component.

It then makes you create your own equals() and hashCode() methods to define your own record identity.

Hard Records

This fourth set makes you analyze a CSV text file containing US cities, along with some more information. You will map each line of this file in a City record and compute various things on the list of cities that you will get:

  • sort them in the alphabetical order,
  • find the city with the largest population,
  • find the states references in this file,
  • then build the histogram of the population per state,
  • and lastly find the most populated state. This set makes you use records with the Stream API.

Challenge Records

This last set of exercises invites you to rewrite the code of the previous set, using records everywhere you can use them. Creating a record is (almost) free and can greatly improve the readability of your core, especially when you are writing complex data processing algorithms.

Sealed Types and Pattern Matching

This third part introduces a new paradigm that you can use to write your applications, called Data Oriented Programming. Data Oriented Programming is implemented in Java with three tools: records, sealed types, and pattern matching. It starts with a simple, classical object model: a Shape interface and two implementations: Rectangle and Square.

Let us take a look at the Shape interface and its two implementations: Circle and Square. You also have a record named Rectangle, that we will use later in this tutorial.

Computing the Surface of a Shape From the Interface

The first step of this tutorial is to implement the computeSurface() method of the ShapeProcessor class. The first idea that comes to mind would probably to add a surface() method to the Shape interface, and simply call this method from the computeSurface() method. The tutorial gives you the code to do that: you just need to add the surface() method to the Shape interface. The implementations of the method is already present in the records Circle and Square.

Adding a surface() method in the Shape interface is natural in Java. It comes with some safety: if you had a new method to an interface, the compiler tells you immediately in which classes you need to implement it.

But it also comes with a cost. As functional requirements are added to the Shape interface, new methods are added, and odds are that this simple interface will become very complex, very soon. Also, if a feature is not needed anymore, because all your client code depends on this interface, removing the corresponding method may become costly.

Not removing this code leads to the creation of dead code. That is, some code that is there, but that serves no business purpose. Dead code still has a maintenance cost in an application. You still need to compile it and to run the corresponding tests when you build it.

Another annoying point: odds are that your business code that needs to compute areas of shapes and thus depends on this interface, will not need 100% of the available methods in it. An update of this interface will have you recompile parts of your application for no real reason if this update brings no value to this business code.

Computing the Surface of a Shape From the ShapeProcessor Class

Let us move this code to the ShapeProcessor class. You can now remove the shape() method from the Shape interface and its implementations.

Using Pattern Matching for InstanceOf

The first step consists in moving this method outside your interface and its implementations.

This first, naive version could be the following:

public double computeSurface(Shape shape) {
    if (shape instanceof Square) {
        Square square = (Square)shape;
        return square.edge()*edge.square();
    } else if (shape instanceof Circle) {
        Circle circle = (Circle)shape;
        return Math.PI*circle.radius()*circle.radius();
    }
    return 0d;
}

Not only this code is ugly, it also loses the safety the previous code had: forgetting a case in the if-else code cannot be spot by the compiler anymore. Plus, there is no way you can unit test 100% of this code, because the return 0; is just there to make the compiler happy: this line of code cannot be executed.

You can make this code a little less ugly by using pattern matching for instanceof. Instead of checking the variable shape against the type Square and Circle, you can check it against two kind of patterns.

  1. You can check it against a type pattern, using the following syntax:
if (shape instanceof Square square) {
    // you can use square here
} 

That creates a pattern variable, square, that you can use wherever this variable makes sense, including the if branch. Try to refactor the computeSurface(Shape) using this pattern, and see how it can improve the readability of your code. 2. Starting with the JDK 21, you can also check it against a record pattern, using the following syntax:

if (shape instanceof Square(double edge)) {
    // you can use radius here
}

That creates a edge pattern variable, that takes the value of the edge of this square. You can also use this syntax on records that are built on several components, thus creating more than one pattern variable. Note that these pattern variables are initialized by calling the accessors of your record. This point is important in the case where your accessors are doing some defensive copy, for instance. Try to refactor the computeSurface(Shape) using this pattern, and see how it can further improve the readability of your code.

Using pattern matching for instanceof is nice, but it does not fix our first problem : the compiler does not help you in case for forgot to process an implementation of Shape.

Using Pattern Matching for Switch

This second step consists in using a switch expression to get rid of this if-else structure.

This block of if-else has the structure of a switch. Fortunately, since the JDK 21, you can switch on types, and use patterns as switch cases. Try to refactor the previous code so that it looks like the following:

switch (shape) {
    case Square square -> ...;
    case Circle circle -> ...;
}

You can use both type patterns or record patterns for your switch cases. Try them both, and see which one you prefer.

The code you will end up is more readable that the old-fashioned if-else, thanks to the use of switch expressions (a feature added in the JDK 14). But you still need to add a default case to it, to make the compiler happy.

Using Sealed Types

This third step consists in getting rid of this default case, using a sealed interface.

The fact is: you can tell the compiler that there is no other type than Circle and Square to implement the Shape interface, by sealing this interface. Sealed classes is a feature added to the JDK 17, that is very useful in this case. You can now seal any type in Java: interfaces, classes and abstract classes.

  • A sealed interface needs to declare its implementations.
  • A sealed abstract class needs to be sealed, or non-sealed. This is a way to create an extension point in an otherwise sealed hierarchy.
  • A sealed class needs to be final, sealed, or non-sealed.

When you seal a type, you need to tell the compiler what are the types allowed to extend this type. There are two ways to do that:

  1. either you add a permits clause in the declaration of your type,
  2. or you do not use this clause, but create auxiliary or nested types in this type, and the compiler will infer that these are the permitted types.

You can create a non-sealed class by just adding the non-sealed declaration to it.

public non-sealed abstract class AbstractShape implements Shape {
}

Try to seal the Shape interface. It should look like the following:

public sealed interface Shape permits ... {
}

After the permits keyword, you should have a coma separated list of the types allowed to implement Shape.

Do not forget to make Circle and Square to implement Shape, of course.

Now the compiler knows that a Shape object cannot be anything else than a Circle object or a Square object, and it can use this information when you create a switch expression 1on the Shape type.

Once you have sealed your Shape interface, try to remove the default case in your switch. You should see that this code is still compiling.

There is a Rectangle record available in the model package. Try to make it implement the Shape interface. Try to use record patterns for your switch case, to make your code even simpler. Remember that you can have more than one pattern variable in a pattern, this is something you can use for the Rectangle record, that has two components.

You should see a compiler error if you do that before you add Rectangle to the list of the permitted class.

Now that you have added an implementation to this sealed hierarchy, you should see a compiler error in your switch expression. The compiler is helping you again by telling you that you forgot a case in this switch. Just as it was helping you by telling you that you forgot to implement a method from an interface, in an implementing class. Your code is safe again.

Nested Patterns and Unnamed Patterns

Suppose that you need to add a component center to your circle, of type Point. Add this component to your Circle record.

If you used a record pattern to deconstruct your Circle in your switch expression, you should now see a compiler error. Using record patterns allows the compiler to check for the modification of your records. If you change your object model, the compiler sees it, and can use this information to help you.

To fix this code, you need to add the center component to your record pattern. You can do it in two ways. The first one is the following.

switch(shape) {
    case Circle(Point center, double radius) -> ...
}

In that case, you create a center pattern variable. From it, you can get the coordinates of this point.

But you can also nest your record patterns in that way.

switch(shape) {
    case Circle(Point(int px, int py), double radius) -> ...
}

In that case, you can deconstruct your circle and its center in one pass.

Note that, in this case, you do not need to create this pattern variable. Remember that creating a pattern variable calls the corresponding accessor, which can be costly, if, for instance, this accessor is doing so defensive copy.

So you can also use the unnamed pattern while deconstructing your circle, in that way.

switch(shape) {
    case Circle(Point _, double radius) -> ...
}

So you can also use the unnamed pattern while deconstructing your circle, in that way.

References

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages