3 bad habits that Java developers should get rid of, and see if they are caught

**Foreword: **Want to break some bad habits? Let's start with null, functional programming, and getters and setters and see how to improve the code.

As Java developers, we will use some idioms, typical examples, such as: returning a null value, abuse of getter and setter, even when it is not necessary. Although in some cases, these usages may be appropriate, but they are usually dictated by habit or a stopgap measure for us to make the system work properly. In this article, we will discuss three situations that are common among Java beginners and even advanced developers, and explore how they can cause us trouble. It should be noted that the rules summarized in the article are not rigid requirements that should always be followed at all times. Sometimes, there may be a good reason to use these patterns to solve problems, but in general, these usages should be relatively reduced. First of all, we will start the discussion with the keyword Null, which is also one of the most frequently used but also the most dual-faced keywords in Java.

1. Returning Null (Returning Null)

Null has always been the best friend and worst enemy of developers, and this is no exception in Java. In high-performance applications, using null is a reliable way to reduce the number of objects. It indicates that the method has no value to return. Unlike throwing exceptions, if you want to notify the client that no value can be obtained, using null is a fast and low-overhead method, it does not need to capture the entire stack trace.

Outside of the environment of high-performance systems, the existence of null can lead to the creation of more cumbersome null return value checks, thereby breaking the application and causing NullPointerExceptions when dereferencing a null object. In most applications, there are three main reasons for returning null:

  1. Indicates that the element cannot be found in the list;

  2. Indicates that even if there is no error, no valid value can be found;

  3. Represents the return value under special circumstances.

Unless there are any performance reasons, there are better solutions to each of the above situations. They don't use null and force developers to deal with null situations. More importantly, the clients of these methods will not worry about whether the method will return null in some edge cases. In each case, we will design a concise method that does not return a null value.

No Elements (there are no elements in the collection)

When returning a list or other collection, you will usually see an empty collection returned to indicate that the elements of the collection cannot be found. For example, we can create a service to manage users in the database. The service is similar to the following (for brevity, some method and class definitions are omitted):

public class UserService {
    public List<User> getUsers() {
        User[] usersFromDb = getUsersFromDatabase();
        if (usersFromDb == null) {
            // No users found in database
            return null;
        }
        else {
            return Arrays.asList(usersFromDb);
        }
    }
}
UserServer service = new UserService();
List<Users> users = service.getUsers();
if (users != null) {
    for (User user: users) {
        System.out.println("User found: " + user.getName());
    }
}

Because we choose to return a null value when there is no user, we force the client to handle this situation before traversing the user list. If we return an empty list to indicate that the user was not found, then the client can completely remove the empty check and iterate through the users as usual. If there are no users, the loop is implicitly skipped, and you don’t have to deal with this situation manually; in essence, the function of looping through the user list is just like what we did for the empty list and filling the list, without manually processing anything One situation:

public class UserService {
    public List<User> getUsers() {
        User[] usersFromDb = getUsersFromDatabase();
        if (usersFromDb == null) {
            // No users found in database
            return Collections.emptyList();
        }
        else {
            return Arrays.asList(usersFromDb);
        }
    }
}
UserServer service = new UserService();
List<Users> users = service.getUsers();
for (User user: users) {
    System.out.println("User found: " + user.getName());
}

In the above example, we are returning an immutable empty list. This is an acceptable solution, as long as we record that the list is immutable and should not be modified (doing so may throw an exception). If the list must be mutable, we can return an empty mutable list, as shown in the following example:

public List<User> getUsers() {
    User[] usersFromDb = getUsersFromDatabase();
    if (usersFromDb == null) {
        // No users found in database
        return new ArrayList<>();    // A mutable list
    }
    else {
        return Arrays.asList(usersFromDb);
    }
}

Generally speaking, when no element is found, the following rules should be followed:

  1. Returning an empty set (or list, set, queue, etc.) indicates that no element is found.

  2. This not only reduces the special case handling that the client must perform, but also reduces inconsistencies in the interface (for example, we often return a list object instead of other objects).

Optional Value

In many cases, we want to notify the client that there is no optional value when there is no error, and return null at this time. For example, get the parameters from the web address. In some cases, the parameter may exist, but in other cases, it may not exist. The absence of this parameter does not necessarily indicate an error, but rather the functionality included when the user does not need to provide the parameter (such as sorting). If there are no parameters, return null; if provided, return the value of the parameter (for brevity, some methods have been removed):

public class UserListUrl {
    private final String url;
    public UserListUrl(String url) {
        this.url = url;
    }
    public String getSortingValue() {
        if (urlContainsSortParameter(url)) {
            return extractSortParameter(url);
        }
        else {
            return null;
        }
    }
}
UserService userService = new UserService();
UserListUrl url = new UserListUrl("http://localhost/api/v2/users");
String sortingParam = url.getSortingValue();
if (sortingParam != null) {
    UserSorter sorter = UserSorter.fromParameter(sortingParam);
    return userService.getUsers(sorter);
}
else {
    return userService.getUsers();
}

When no parameters are provided, null is returned, and the client must handle this situation, but in the signature of the getSortingValue method, there is nowhere to declare that the sorting value is optional. If the method's parameters are optional, and when there are no parameters, null may be returned. To know this fact, we must read the documentation related to the method (if documentation is provided).

Instead, we can make optionality explicitly return an Optional object. As we will see, when no parameters exist, the client still needs to deal with this situation, but now this requirement is clear. More importantly, the Optional class provides more mechanisms to handle missing parameters than simple null checks. For example, we can use the query method (a state test method) provided by the Optional class to simply check whether the parameter exists:

public class UserListUrl {
    private final String url;
    public UserListUrl(String url) {
        this.url = url;
    }
    public Optional<String> getSortingValue() {
        if (urlContainsSortParameter(url)) {
            return Optional.of(extractSortParameter(url));
        }
        else {
            return Optional.empty();
        }
    }
}
UserService userService = new UserService();
UserListUrl url = new UserListUrl("http://localhost/api/v2/users");
Optional<String> sortingParam = url.getSortingValue();
if (sortingParam.isPresent()) {
    UserSorter sorter = UserSorter.fromParameter(sortingParam.get());
    return userService.getUsers(sorter);
}
else {
    return userService.getUsers();
}

This is almost the same as the "null check" situation, but we have clarified the optionality of the parameters (that is, the client cannot access the parameters without calling get(), and if the optional parameter is empty, a NoSuchElementException will be thrown ). If we do not want to return a list of users based on the optional parameter in the web address, but use the parameter in some way, we can use the ifPresentOrElse method to do so:

sortingParam.ifPresentOrElse(
    param -> System.out.println("Parameter is :" + param),
    () -> System.out.println("No parameter supplied.")
);

This greatly reduces the impact of "empty checks". If we want to ignore parameters when they are not provided, we can use the ifPresent method:

sortingParam.ifPresent(param -> System.out.println("Parameter is :" + param));

In both cases, using the Optional object is better than returning null and explicitly forcing the client to handle situations where the return value may not exist, providing more ways to handle this optional value. With this in mind, we can formulate the following rules:

If the return value is optional, ensure that the client handles this situation by returning an Optional. The optional value contains a value when the value is found, and is empty when the value is not found.

Special-Case Value

The last common use case is a special use case, in which normal values ​​cannot be obtained, and the client should handle extreme cases that are different from other use cases. For example, suppose we have a command factory, and the client periodically requests commands from the command factory. If no command is available, the client should wait 1 second before requesting. We can achieve this by returning an empty command, and the client must process this empty command, as shown in the following example (for brevity, some methods are not shown):

public interface Command {
    public void execute();
}
public class ReadCommand implements Command {
    @Override
    public void execute() {
        System.out.println("Read");
    }
}
public class WriteCommand implements Command {
    @Override
    public void execute() {
        System.out.println("Write");
    }
}
public class CommandFactory {
    public Command getCommand() {
        if (shouldRead()) {
            return new ReadCommand();
        }
        else if (shouldWrite()) {
            return new WriteCommand();
        }
        else {
            return null;
        }
    }
}
CommandFactory factory = new CommandFactory();
while (true) {
    Command command = factory.getCommand();
    if (command != null) {
        command.execute();
    }
    else {
        Thread.sleep(1000);
    }
}

Since CommandFactory can return empty commands, the client is obliged to check whether the received command is empty, and if it is empty, it sleeps for 1 second. This creates a set of conditional logic that must be handled by the client itself. We can reduce this overhead by creating an "empty object" (sometimes called a special case object). "Null object" encapsulates the logic executed in the null scenario (sleeping for 1 second) into the object returned in the null scenario. For our command example, this means creating a SleepCommand that sleeps while executing:

public class SleepCommand implements Command {
    @Override
    public void execute() {
        Thread.sleep(1000);
    }
}
public class CommandFactory {
    public Command getCommand() {
        if (shouldRead()) {
            return new ReadCommand();
        }
        else if (shouldWrite()) {
            return new WriteCommand();
        }
        else {
            return new SleepCommand();
        }
    }
}
CommandFactory factory = new CommandFactory();
while (true) {
    Command command = factory.getCommand();
    command.execute();
}

As in the case of returning an empty collection, creating "empty objects" allows the client to implicitly handle special cases as if they were normal cases. But this is not always feasible; in some cases, the decision to deal with special circumstances must be made by the customer. This can be handled by allowing the client to provide default values, just like using the Optional class. In the case of Optional, the client can use the orElse method to get the included value or default value:

UserListUrl url = new UserListUrl("http://localhost/api/v2/users");
Optional<String> sortingParam = url.getSortingValue();
String sort = sortingParam.orElse("ASC");

If there is a provided sort parameter (for example, if Optional contains a value), this value will be returned. If there is no value, it will return "ASC" by default. The Optional class also allows the client to create default values ​​when needed, in case the default creation process is expensive (that is, only create default values ​​when needed):

UserListUrl url = new UserListUrl("http://localhost/api/v2/users");
Optional<String> sortingParam = url.getSortingValue();
String sort = sortingParam.orElseGet(() -> {
    // Expensive computation
});

Combining the usage of "empty objects" and default values, we can design the following rules:

If possible, use "empty objects" to handle the use of the null keyword, or allow the client to provide default values

2. Defaulting to Functional Programming (functional programming is used by default)

Since the introduction of stream and lambda expressions in JDK 8, there has been a trend towards functional programming, which is right. Before the advent of lambda expressions and streams, performing functional tasks was very cumbersome and caused a serious decrease in code readability. For example, the following code filters a collection in the traditional way:

public class Foo {
    private final int value;
    public Foo(int value) {
        this.value = value;
    }
    public int getValue() {
        return value;
    }
}
Iterator<Foo> iterator = foos.iterator();
while(iterator.hasNext()) {
    if (iterator.next().getValue() > 10) {
        iterator.remove();
    }
}

Although this code is compact, it does not tell us in an obvious way that when a certain condition is met, we will try to delete elements of the collection. Instead, it tells us that when there are more elements in the collection, the collection will be traversed, and elements with a value greater than 10 will be deleted (we can assume that filtering is in progress, but the part of deleting elements is obscured by the length of the code). We can use functional programming to compress this logic into one statement:

foos.removeIf(foo -> foo.getValue() > 10);

image.gif

This statement is not only more concise than iterative, but also accurately tells us its behavior. If we name the predicate and pass it to the removeIf method, we can even make it more readable:

Predicate<Foo> valueGreaterThan10 = foo -> foo.getValue() > 10;
foos.removeIf(valueGreaterThan10);

The last line of this code reads like an English sentence, telling us exactly what the sentence is doing. For code that seems so compact and extremely readable, it is desirable to try functional programming in any situation that requires iteration, but it is a naive idea. Not every situation is suitable for functional programming. For example, if we try to print a set of permutations and combinations of suit and card size in a deck of cards (every combination of suit and card size), we can create the following (see "Effective Java, 3rd Edition" to get this Example details):

public static enum Suit {
    CLUB, DIAMOND, HEART, SPADE;
}
public static enum Rank {
    ONE, TWO, THREE, FOUR, FIVE, SIX, SEVEN, EIGHT, NINE, TEN, JACK, QUEEN, KING;
}
Collection<Suit> suits = EnumSet.allOf(Suit.class);
Collection<Rank> ranks = EnumSet.allOf(Rank.class);
suits.stream()
    .forEach(suit -> {
        ranks.stream().forEach(rank -> System.out.println("Suit: " + suit + ", rank: " + rank));
    });

Although it is not complicated to read, this implementation is not the simplest. Obviously, we are trying to force the use of stream, and it is obviously more advantageous to use traditional iteration at this time. If we use the traditional iterative method, we can simplify the permutation and combination of suits and ranks to:

for (Suit suit: suits) {
    for (Rank rank: ranks) {
        System.out.println("Suit: " + suit + ", rank: " + rank);
    }
}

Although this style is not so flashy, it is much more straightforward. We can quickly understand that we are trying to traverse each suit and grade, and pair each grade with each suit. The larger the stream expression, the more tedious the functional programming is. Take the following code snippet created by Joshua Bloch on page 205, item 45 of "Effective Java, 3rd Edition" as an example to find all the phrases within the specified length contained in the dictionary on the path provided by the user:

public class Anagrams {
    public static void main(String[] args) throws IOException {
        Path dictionary = Paths.get(args[0]);
        int minGroupSize = Integer.parseInt(args[1]);
        try (Stream<String> words = Files.lines(dictionary)) {
            words.collect(
                groupingBy(word -> word.chars().sorted()
                           .collect(StringBuilder::new,
                               (sb, c) -> sb.append((char) c),
                               StringBuilder::append).toString()))
                .values().stream()
                    .filter(group -> group.size() >= minGroupSize)
                    .map(group -> group.size() + ": " + group)
                    .forEach(System.out::println);
        }
    }
}

Even the most experienced stream users may be confused by this implementation. It is difficult to understand the intent of the code in a short period of time, and it takes a lot of thinking to discover what the above stream operation is trying to achieve. This doesn't mean that streams must be complicated or verbose, just because they are not always the best choice. As we have seen above, using removeIf can simplify a complex set of statements into one easy-to-understand statement. Therefore, we should not try to replace every use case of traditional iteration with streams or even lambda expressions. Instead, when deciding whether to use functional programming or traditional methods, we should follow the following rules:

Functional programming and traditional iteration both have their advantages and disadvantages: simplicity and readability should prevail

Although it may be desirable to use Java's most dazzling and latest features in every possible scenario, it is not always the best approach. Sometimes, old-fashioned features work best.

3. Creating Indiscriminate Getters and Setters(滥用 getter 和 setter)

The first thing that novice programmers learn is to encapsulate class-related data in private fields and expose them through public methods. In actual use, create a getter to access the private data of the class, and create a setter to modify the private data of the class:

public class Foo {
    private int value;
    public void setValue(int value) {
        this.value = value;
    }
    public int getValue() {
        return value;
    }
}

Although this is a good learning practice for new programmers, this approach cannot be applied to intermediate or advanced programming without thinking. What usually happens in practice is that each private field has a pair of getter and setter to expose the internal content of the class to external entities. This can cause some serious problems, especially when private fields are mutable. This is not only a problem of setters, even when there are only getters. Take the following class as an example. This class uses getters to expose its unique fields:

public class Bar {
    private Foo foo;
    public Bar(Foo foo) {
        this.foo = foo;
    }
    public Foo getFoo() {
        return foo;
    }
}

Since we removed the setter method, this might seem wise and harmless, but it is not. Suppose another class accesses an object of type Bar and changes the underlying value of Foo without the Bar object knowing:

Foo foo = new Foo();
Bar bar = new Bar(foo);
// Another place in the code
bar.getFoo().setValue(-1);

In this example, we changed the underlying value of the Foo object without notifying the Bar object. If the value of the Foo object we provide destroys an invariant of the Bar object, this may cause some serious problems. For example, if we have an invariant that means that the value of Foo cannot be negative, then the above code snippet will silently modify this invariant without notifying the Bar object. When the Bar object uses the value of its Foo object, things may quickly develop in a bad direction, especially if the Bar object assumes this is immutable, because it does not expose the setter to directly redistribute the Foo object it holds. If the data is severely changed, this can even cause the system to fail. As the following example shows, the underlying data of the array is unintentionally exposed:

public class ArrayReader {
    private String[] array;
    public String[] getArray() {
        return array;
    }
    public void setArray(String[] array) {
        this.array = array;
    }
    public void read() {
        for (String e: array) {
            System.out.println(e);
        }
    }
}

public class Reader {
    private ArrayReader arrayReader;
    public Reader(ArrayReader arrayReader) {
        this.arrayReader = arrayReader;
    }
    public ArrayReader getArrayReader() {
        return arrayReader;
    }
    public void read() {
        arrayReader.read();
    }
}

ArrayReader arrayReader = new ArrayReader();
arrayReader.setArray(new String[] {"hello", "world"});
Reader reader = new Reader(arrayReader);
reader.getArrayReader().setArray(null);
reader.read();

Executing this code will result in a NullPointerException because when an instance object of ArrayReader tries to traverse the array, the array associated with that object is null. The disturbing thing about this NullPointerException is that it may happen long after the ArrayReader is changed, or even in completely different scenarios (for example, in different parts of the code, or even in different threads), which makes Debugging becomes very difficult.

If readers consider carefully, they may also notice that we can set the private ArrayReader field to final, because we have no way to reassign it after assigning it through the constructor. Although this seems to make the ArrayReader a constant and ensure that the ArrayReader object we return will not be changed, it is not. If you add final to a field, you can only ensure that the field itself is not reassigned (that is, you cannot create a setter for the field) and will not prevent the state of the object itself from being changed. Or we try to add final to the getter method, which is futile, because the final modifier on the method only means that the method cannot be overridden by subclasses.

We can even further consider that the ArrayReader object is defensively copied in the Reader's constructor to ensure that the object passed into it will not be tampered with after the object is provided to the Reader object. For example, the following situations should be avoided:

ArrayReader arrayReader = new ArrayReader();
arrayReader.setArray(new String[] {"hello", "world"});
Reader reader = new Reader(arrayReader);
arrayReader.setArray(null);    // Change arrayReader after supplying it to Reader
reader.read();    // NullPointerException thrown

Even with these three changes (adding a final modifier to the field, adding a final modifier to the getter, and a defensive copy of the ArrayReader provided to the constructor), we still haven't solved the problem. The problem is not the way we expose the underlying data, but because we were wrong in the beginning. To solve this problem, we must stop exposing the internal data of the class, but provide a way to change the underlying data while still following the class invariants. The following code solves this problem and introduces a defensive copy of the provided ArrayReader and marks the ArrayReader field as final. Because there is no setter, it should be like this:

Annotation: The following code in the original text has an error. The return value type of the setArrayReaderArray method in the Reader class should be void. This method is to replace the setter and should not produce a return value.

public class ArrayReader {
    public static ArrayReader copy(ArrayReader other) {
        ArrayReader copy = new ArrayReader();
        String[] originalArray = other.getArray();
        copy.setArray(Arrays.copyOf(originalArray, originalArray.length));
        return copy;
    }
    // ... Existing class ...
}

public class Reader {
    private final ArrayReader arrayReader;
    public Reader(ArrayReader arrayReader) {
        this.arrayReader = ArrayReader.copy(arrayReader);
    }
    public ArrayReader setArrayReaderArray(String[] array) {
        arrayReader.setArray(Objects.requireNonNull(array));
    }
    public void read() {
        arrayReader.read();
    }
}

ArrayReader arrayReader = new ArrayReader();
arrayReader.setArray(new String[] {"hello", "world"});
Reader reader = new Reader(arrayReader);
reader.read();
Reader flawedReader = new Reader(arrayReader);
flawedReader.setArrayReaderArray(null);    // NullPointerException thrown

If we look at this defective reader, it will still throw a NullPointerException, but when the invariant (using a non-empty array when reading) is destroyed, the exception will be thrown immediately, not at a later one time. This ensures rapid invalidation of the invariants, which makes debugging and finding the source of the problem much easier.

We can make further use of this principle. If there is no urgent need to change the state of the class, it is a good idea to make the fields of the class completely inaccessible. For example, we can delete all methods that can modify the state of the instance object of the Reader class to achieve complete encapsulation of the Reader class:

public class Reader {
    private final ArrayReader arrayReader;
    public Reader(ArrayReader arrayReader) {
        this.arrayReader = ArrayReader.copy(arrayReader);
    }
    public void read() {
        arrayReader.read();
    }
}

ArrayReader arrayReader = new ArrayReader();
arrayReader.setArray(new String[] {"hello", "world"});
Reader reader = new Reader(arrayReader);
// No changes can be made to the Reader after instantiation
reader.read();

To summarize this concept logically, it is a good idea to make the class immutable if possible. Therefore, after instantiating the object, the state of the object will never change. For example, we can create an immutable Car object as follows:

public class Car {
    private final String make;
    private final String model;
    public Car(String make, String model) {
        this.make = make;
        this.model = model;
    }
    public String getMake() {
        return make;
    }
    public String getModel() {
        return model;
    }
}

It should be noted that if the field of the class is not a basic data type, the client can modify the underlying object as described earlier. Therefore, immutable objects should return defensive copies of these objects, and clients are not allowed to modify the internal state of immutable objects. Note, however, that defensive copying reduces performance because a new object is created every time the getter is called. For this defect, optimization should not be carried out prematurely (ignoring immutability to ensure possible performance improvement), but this should be noted. The following code snippet provides an example of defensive copying of the return value of a method:

public class Transmission {
    private String type;
    public static Transmission copy(Transmission other) {
        Transmission copy = new Transmission();
        copy.setType(other.getType);
        return copy;
    }
    public String setType(String type) {
        this.type = type;
    }
    public String getType() {
        return type;
    }
}
public class Car {
    private final String make;
    private final String model;
    private final Transmission transmission;
    public Car(String make, String model, Transmission transmission) {
        this.make = make;
        this.model = model;
        this.transmission = Transmission.copy(transmission);
    }
    public String getMake() {
        return make;
    }
    public String getModel() {
        return model;
    }
    public Transmission getTransmission() {
        return Transmission.copy(transmission);
    }
}

This reminds us of the following principles:

Make the class immutable unless there is an urgent need to change the state of the class. All fields of an immutable class should be marked as private and final to ensure that the field will not be re-assigned, nor will it provide indirect access to the internal state of the field

Immutability also brings some very important advantages, for example, the class can be easily used in a multithreaded context (that is, two threads can share an object without worrying that one thread will change the state of the object when another thread accesses the state. ). In general, in many practical situations we can create immutable classes, much more than we realize, but we are used to adding getters or setters.

Conclusion

Many of the applications we create will eventually work, but in a large number of applications, some of the problems we accidentally introduce may only appear in the most extreme cases. In some cases, we do things out of convenience, or even out of habits, and we rarely pay attention to whether these habits are practical (or safe) in the scenarios we use. In this article, we delve into the three most common problems in practical applications, such as: empty return values, the charm of functional programming, sloppy getters and setters, and some practical alternatives. Although the rules in this article are not absolute, they do provide insights into some rare problems encountered in practical applications, and may help avoid some strenuous problems in the future.

Guess you like

Origin blog.csdn.net/doubututou/article/details/109112243