Book Image

Java Coding Problems - Second Edition

By : Anghel Leonard
Book Image

Java Coding Problems - Second Edition

By: Anghel Leonard

Overview of this book

The super-fast evolution of the JDK between versions 12 and 21 has made the learning curve of modern Java steeper, and increased the time needed to learn it. This book will make your learning journey quicker and increase your willingness to try Java’s new features by explaining the correct practices and decisions related to complexity, performance, readability, and more. Java Coding Problems takes you through Java’s latest features but doesn’t always advocate the use of new solutions — instead, it focuses on revealing the trade-offs involved in deciding what the best solution is for a certain problem. There are more than two hundred brand new and carefully selected problems in this second edition, chosen to highlight and cover the core everyday challenges of a Java programmer. Apart from providing a comprehensive compendium of problem solutions based on real-world examples, this book will also give you the confidence to answer questions relating to matching particular streams and methods to various problems. By the end of this book you will have gained a strong understanding of Java’s new features and have the confidence to develop and choose the right solutions to your problems.
Table of Contents (16 chapters)
1
Text Blocks, Locales, Numbers, and Math
Free Chapter
2
Objects, Immutability, Switch Expressions, and Pattern Matching
14
Other Books You May Enjoy
15
Index

66. Dealing with completeness (type coverage) in pattern labels for switch

In a nutshell, switch expressions and switch statements that use null and/or pattern labels should be exhaustive. In other words, we must cover with explicit switch case labels all the possible values. Let’s consider the following example:

class Vehicle {}
class Car extends Vehicle {}
class Van extends Vehicle {}
private static String whatAmI(Vehicle vehicle) {
  return switch(vehicle) {
    case Car car -> "You're a car";
    case Van van -> "You're a van";
  };
}

This snippet of code doesn’t compile. The error is clear: The switch expression does not cover all possible input values. The compiler complains because we don’t have a case pattern label for Vehicle. This base class can be legitimately used without being a Car or a Van, so it is a valid candidate for our switch. We can add a case Vehicle or a default label. If you know that Vehicle will remain an empty base class, then you’ll probably go for a default label:

return switch(vehicle) {
    case Car car -> "You're a car";
    case Van van -> "You're a van";
    default -> "I have no idea ... what are you?";
  };

If we continue by adding another vehicle such as class Truck extends Vehicle {}, then this will be handled by the default branch. If we plan to use Vehicle as an independent class (for instance, to enrich it with methods and functionalities), then we will prefer to add a case Vehicle as follows:

return switch(vehicle) {
    case Car car -> "You're a car";
    case Van van -> "You're a van";
    case Vehicle v -> "You're a vehicle"; // total pattern
};

This time, the Truck class will match the case Vehicle branch. Of course, we can add a case Truck as well.

Important note

The Vehicle v pattern is named a total type pattern. There are two labels that we can use to match all possible values: the total type pattern (for instance, a base class or an interface) and the default label. Generally speaking, a total pattern is a pattern that can be used instead of the default label.

In the previous example, we can accommodate all possible values via the total pattern or the default label but not both. This makes sense since the whatAmI(Vehicle vehicle) method gets Vehicle as an argument. So, in this example, the selector expression can be only Vehicle or a subclass of Vehicle. How about modifying this method as whatAmI(Object o)?

private static String whatAmI(Object o) {
  return switch(o) {
    case Car car -> "You're a car";
    case Van van -> "You're a van";
    case Vehicle v -> "You're a vehicle"; // optional
    default -> "I have no idea ... what are you?";
  };
}

Now, the selector expression can be any type, which means that the total pattern Vehicle v is not total anymore. While Vehicle v becomes an optional ordinary pattern, the new total pattern is case Object obj. This means that we can cover all possible values by adding the default label or the case Object obj total pattern:

return switch(o) {
  case Car car -> "You're a car";
  case Van van -> "You're a van";
  case Vehicle v -> "You're a vehicle";  // optional
  case Object obj -> "You're an object"; // total pattern
};

I think you get the idea! How about using an interface for the base type? For instance, here is an example based on the Java built-in CharSequence interface:

public static String whatAmI(CharSequence cs) {
  return switch(cs) { 
    case String str -> "You're a string";
    case Segment segment -> "You're a Segment";
    case CharBuffer charbuffer -> "You're a CharBuffer";
    case StringBuffer strbuffer -> "You're a StringBuffer";
    case StringBuilder strbuilder -> "You're a StringBuilder";
  };
}

This snippet of code doesn’t compile. The error is clear: The switch expression does not cover all possible input values. But, if we check the documentation of CharSequence, we see that it is implemented by five classes: CharBuffer, Segment, String, StringBuffer, and StringBuilder. In our code, each of these classes is covered by a pattern label, so we have covered all possible values, right? Well, yes and no… “Yes” because we cover all possible values for the moment, and “no” because anyone can implement the CharSequence interface, which will break the exhaustive coverage of our switch. We can do this:

public class CoolChar implements CharSequence { ... }

At this moment, the switch expression doesn’t cover the CoolChar type. So, we still need a default label or the total pattern, case CharSequence charseq, as follows:

return switch(cs) { 
  case String str -> "You're a string";
  ...
  case StringBuilder strbuilder -> "You're a StringBuilder";
  // we have created this
  case CoolChar cool -> "Welcome ... you're a CoolChar";
  // this is a total pattern
  case CharSequence charseq -> "You're a CharSequence";
  // can be used instead of the total pattern
  // default -> "I have no idea ... what are you?";
};

Okay, let’s tackle this scenario on the java.lang.constant.ClassDesc built-in interface:

private static String whatAmI(ConstantDesc constantDesc) {
  return switch(constantDesc) { 
    case Integer i -> "You're an Integer";
    case Long l -> "You're a Long";
    case Float f -> " You're a Float";
    case Double d -> "You're a Double";
    case String s -> "You're a String";
    case ClassDesc cd -> "You're a ClassDesc";
    case DynamicConstantDesc dcd -> "You're a DCD";
    case MethodHandleDesc mhd -> "You're a MethodHandleDesc";
    case MethodTypeDesc mtd -> "You're a MethodTypeDesc";
  };
}

This code compiles! There is no default label and no total pattern but the switch expression covers all possible values. How so?! This interface is declared as sealed via the sealed modifier:

public sealed interface ClassDesc
  extends ConstantDesc, TypeDescriptor.OfField<ClassDesc>

Sealed interfaces/classes were introduced in JDK 17 (JEP 409) and we will cover this topic in Chapter 8. However, for now, it is enough to know that sealing allows us to have fine-grained control of inheritance so classes and interfaces define their permitted subtypes. This means that the compiler can determine all possible values in a switch expression. Let’s consider a simpler example that starts as follows:

sealed interface Player {}
final class Tennis implements Player {}
final class Football implements Player {}
final class Snooker implements Player {}

And, let’s have a switch expression covering all possible values for Player:

private static String trainPlayer(Player p) { 
  return switch (p) {
    case Tennis t -> "Training the tennis player ..." + t;
    case Football f -> "Training the football player ..." + f;
    case Snooker s -> "Training the snooker player ..." + s;
  };
}

The compiler is aware that the Player interface has only three implementations and all of them are covered via pattern labels. We can add a default label or the total pattern case Player player, but you most probably don’t want to do that. Imagine that we add a new implementation of the sealed Player interface named Golf:

final class Golf implements Player {}

If the switch expression has a default label, then Golf values will be handled by this default branch. If we have the total pattern Player player, then this pattern will handle the Golf values. On the other hand, if none of the default labels or total patterns are present, the compiler will immediately complain that the switch expression doesn’t cover all possible values. So, we are immediately informed, and once we add a case Golf g, the error disappears. This way, we can easily maintain our code and have a guarantee that our switch expressions are always up to date and cover all possible values. The compiler will never miss the chance to inform us when a new implementation of Player is available.

A similar logic applies to Java enums. Consider the following enum:

private enum PlayerTypes { TENNIS, FOOTBALL, SNOOKER }

The compiler is aware of all the possible values for PlayerTypes, so the following switch expression compiles successfully:

private static String createPlayer(PlayerTypes p) { 
  return switch (p) {
    case TENNIS -> "Creating a tennis player ...";
    case FOOTBALL -> "Creating a football player ...";
    case SNOOKER -> "Creating a snooker player ...";
  };
}

Again, we can add a default label or the total pattern, case PlayerTypes pt. But, if we add a new value in the enum (for instance, GOLF), the compiler will delegate the default label or the total pattern to handle it. On the other hand, if none of these are available, the compiler will immediately complain that the GOLF value is not covered, so we can add it (case GOLF g) and create a golf player whenever required.

So far, so good! Now, let’s consider the following context:

final static class PlayerClub implements Sport {};
private enum PlayerTypes implements Sport
  { TENNIS, FOOTBALL, SNOOKER }
sealed interface Sport permits PlayerTypes, PlayerClub {};

The sealed interface Sport allows only two subtypes: PlayerClub (a class) and PlayerTypes (an enum). If we write a switch that covers all possible values for Sport, then it will look as follows:

private static String createPlayerOrClub(Sport s) { 
  return switch (s) {
    case PlayerTypes p when p == PlayerTypes.TENNIS
      -> "Creating a tennis player ...";
    case PlayerTypes p when p == PlayerTypes.FOOTBALL
      -> "Creating a football player ...";
    case PlayerTypes p -> "Creating a snooker player ...";
    case PlayerClub p -> "Creating a sport club ...";
  };
}

We immediately observe that writing case PlayerTypes p when p == PlayerTypes.TENNIS is not quite neat. What we actually want is case PlayerTypes.TENNIS but, until JDK 21, this is not possible since qualified enum constants cannot be used in case labels. However, starting with JDK 21, we can use qualified names of enum constants as labels, so we can write this:

private static String createPlayerOrClub(Sport s) {
  return switch (s) {
    case PlayerTypes.TENNIS
      -> "Creating a tennis player ...";
    case PlayerTypes.FOOTBALL
      -> "Creating a football player ...";
    case PlayerTypes.SNOOKER
      -> "Creating a snooker player ...";
    case PlayerClub p 
      -> "Creating a sport club ...";
  };
}

Done! Now you know how to deal with type coverage in switch expressions.