Refactoring to Streams 2.1
Modernizing old Java code to make it easier to understand and more maintainable
Java celebrated its 25th birthday in 2020. Code written in 1995 stilll runs today, without even having to recompile it. This is one of the biggest reasons why Java has had such success in enterprises.
Over the years, we have seen lots of improvements to make Java code easier to work with. Inner classes came in Java 1.1. A proper collection framework joined us in Java 1.2. Java 5 gave us better type safety with generics.
But the biggest improvement for Java programmers came in Java 8 with Streams and Lambdas. We can now write Java code in the declarative style, rather than imperative. This expresses better the "what", rather than the "how" of the program logic.
Since Java 8, we have had a constant stream of improvements to the Java Programming Language. Records, sealed classes, pattern matching, local variable type inference, and many more. They all serve to make it easier to craft great Java code.
Unfortunately a lot of Java code bases are still stuck in the dark ages of Java 6. This needs refactoring.
"Refactoring is a disciplined technique for restructuring an existing body of code, altering its internal structure without changing its external behavior." - Martin FowlerIn this one-day course we learn how and when to "refactor", focusing on the biggest improvement: Java Streams. However, we also show what else is new in the Java Programming Language.
This is a very hands-on course. Each section has exercises where we get to refactor an existing code base of a large ERP system with 330k LOC.
Here are some of the many things we will cover during this workshop:
- How to refactor to streams and lambdas with and without IDE assistance
- The place of streams and lambdas in the history of the JDK
- What is a functional interface?
- The four core functional interface types found in the java.util.function package
- How the lambda notation is a shorthand for an anonymous inner class based on a functional interface
- The long and short forms that lambdas can take depending on their complexity
- Method references as a further simplification of certain forms of lambda
- How default and static methods in interfaces can use lambdas to improve generality, correctness and readability
- When it might be unsafe to use methods like
Map.computeIfAbsent
- The concept of a stream and its relationship to iterable collections
- Why coding with streams follows the algorithm logic more naturally than using for/while loops
- How to create, transform and terminate streams using filters, mappings, matchers, collectors, reducers, etc
- Why we should use collectors rather than forEach to build collections from a stream
- Using the
Optional
class to avoid null checks, and how optionals are used with streams - How to handle exceptions in lambdas using sneaky throws (without Lombok)
- How functional interfaces, streams and optionals are optimized for the primitive types int, long and double
Who should take this course?
Our programmer stares at the Java code written a decade ago. The logic is all back to front. Eyes dart back and forth as they try to grasp the imperative control flow.
Java Streams and Lambdas promised to make this type of code flow better. But the code is old, very old. The original author moved on long ago. Why touch something that works? Or does it?
Some more staring ...
public boolean areFields(Collection<String> fieldNames) { if (fieldNames == null) return false; for (String fieldName: fieldNames) { if (!isField(fieldName)) return false; } return true; }
Translated symbol-for-symbol into English, this reads: "For each element of type String that is called fieldName and that comes from the fieldNames parameter do the following: if not the method call isField taking as parameter the fieldName, then immediately return false and if we get to the end of the for loop and we have not returned false, then return true.
Hmm, no wonder programmers get paid so much. What did the author mean? Ahh, light goes on. They wanted to make sure that isField(fieldName) is true for all items. Instead of this tricky boolean logic, a quick refactoring to use streams.
public boolean areFields(Collection<String> fieldNames) { if (fieldNames == null) return false; return fieldNames.stream().allMatch(this::isField); }
It now reads: return whether all items in the stream match the predicate this::isField
. Crystal clear.
A bit later this gem appears:
public Map<String, TreeSet<String>> getEntitiesByPackage( Set<String> packageFilterSet, Set<String> entityFilterSet) { Map<String, TreeSet<String>> entitiesByPackage = new HashMap<>(); // put the entityNames TreeSets in a HashMap by packageName for (String entityName : this.getEntityNames()) { ModelEntity entity = this.getModelEntity(entityName); String packageName = entity.getPackageName(); if (UtilValidate.isNotEmpty(packageFilterSet)) { // does it match any of these? boolean foundMatch = false; for (String packageFilter : packageFilterSet) { if (packageName.contains(packageFilter)) { foundMatch = true; } } if (!foundMatch) { continue; } } if (UtilValidate.isNotEmpty(entityFilterSet) && !entityFilterSet.contains(entityName)) { continue; } TreeSet<String> entities = entitiesByPackage.get(entity.getPackageName()); if (entities == null) { entities = new TreeSet<>(); entitiesByPackage.put(entity.getPackageName(), entities); } entities.add(entityName); } return entitiesByPackage; }
Riiiight, this is going to be fun. Boolean logic, two continue statements in the middle of the loop. After spending some time on the code, and extracting the matching logic into methods, it looks like this:
public Map<String, TreeSet<String>> getEntitiesByPackage( Set<String> packageFilterSet, Set<String> entityFilterSet) { return getEntityNames().stream() .map(this::getModelEntity) .filter(entity -> packageFilter(entity, packageFilterSet)) .filter(entity -> entityFilter(entity, entityFilterSet)) .collect(Collectors.groupingBy( ModelEntity::getPackageName, Collectors.mapping(ModelEntity::getEntityName, Collectors.toCollection(TreeSet::new)))); }
Only 11 lines of code instead of 38, with logic that is clearer to understand, utilizing streams and lambdas.
This is the type of fun we have in the Refactoring to Streams Course, ripping apart old dusty Java code and then reassembling it in a coherent and logical order.
Each section of the course has exercises that we need to complete. The refactorings above are two examples of how we can improve old Java code with Java Streams. There is much more. We also learn how we can manage checked exceptions and local variable access.
Your Instructor
Heinz Kabutz is the author of The Java Specialists’ Newsletter, a publication enjoyed by tens of thousands of Java experts in over 150 countries. He has been a Java Champion since 2005.
Course Curriculum
-
Start0. Introduction (19:39)
-
Start1. Default Methods (11:57)
-
Preview2. Lambdas and Static Methods (24:24)
-
Start3. Method References (13:34)
-
Start4. Iterable and Map forEach() (10:01)
-
Start5. removeIf() (15:29)
-
Start6. Map compound methods (50:19)
-
Start7. Stream allMatch(), anyMatch() and noneMatch() (16:07)
-
Start8. Stream map() and collect() (22:54)
-
Preview9. Collectors toCollection() (23:11)
-
Start10. Stream.filter() (12:20)
-
Start11. Collectors.toMap() (25:50)
-
Start12. Stream.reduce() (15:34)
-
Start13. Stream.flatMap() (8:09)
-
Start14. Optional Stream.findFirst() (14:32)
-
Start15. groupingBy() and mapping() (12:53)
-
Start16. Checked exceptions (22:03)
-
Start17. Performance (32:15)
-
Start18. Conclusion (61:27)