← All posts
Software Architecture

The importance of semantics when writing code

How Java's lambdas and Streams API affect code semantics, and when to use them vs. explicit approaches to keep your code readable and maintainable.

In recent years, we have seen several classic programming languages evolve and adopt features from functional programming. These features have enabled software engineers to leverage their code, making it shorter, faster, and sometimes more readable. However, no strategy is a silver bullet, and each has its trade-offs. The best approach is knowing when not to use it.

In Java, this evolution began with the release of Java 8, which introduced key functional programming features such as:

  • Lambda expressions
  • Streams API (java.util.stream)
  • Functional Interfaces (java.util.function) such as Predicate, Function and Consumer

In this case, we will focus on how programming languages have lost some of their semantics due to this phenomenon. We will also discuss when it is appropriate to write cool code and when to step back to more primitive approaches. While our primary focus will be Java, the concepts explained here can be applied to many modern programming languages.

When to use lambda expressions

Lambda expressions influence code semantics by shifting the focus from explicit structure to behavior. This is useful when dealing with simple actions. However, the more code we add to a lambda function, the less readable it becomes, and the more we need to add semantics to improve clarity.

They reduce boilerplate, emphasizing what the code does rather than how. A short lambda is intuitive:

Comparator<String> comparator = (s1, s2) -> s1.length() - s2.length();

They make operations like filtering and transformations more expressive when using streams:

names.stream().filter(name -> name.startsWith("A")).forEach(System.out::println);

On the other hand, their anonymous and inline nature makes stack traces harder to interpret. As lambdas grow in size, they also increase in complexity, making readability suffer compared to named methods.

Consider this complex lambda:

public void foo(String bar) {
    Function<String, String> validate = str -> {
        if (str.isEmpty()) return "Empty";
        return str.length() > 10 ? "Too long" : "Valid";
    };
    if (validate.apply(bar)) {
        System.out.println("Valid input!");
    }
}

Refactored with a named method, the intent becomes immediately clear:

public void foo(String bar) {
    if (isValidString(bar)) {
        System.out.println("Valid input!");
    }
}

private String isValidString(String str) {
    if (str.isEmpty()) return "Empty";
    return str.length() > 10 ? "Too long" : "Valid";
}

By adding a named function, we enhance semantics and improve code readability.

Using streams wisely

Java Streams is an incredible feature. Since its introduction, a lot of boilerplate code has been reduced, and in most cases, when used carefully, it makes the code more readable and concise.

Before Java 8, iterating over a list and modifying its elements required explicit loops and temporary collections:

public List<String> foo(List<String> bar, String prefix) {
    List<String> list = new ArrayList<>();
    for (String element : bar) {
        list.add(prefix + element);
    }
    return list;
}

With Streams, the same logic becomes more declarative:

public List<String> foo(List<String> bar, String prefix) {
    return bar.stream()
        .map(element -> prefix + element)
        .collect(Collectors.toList());
}

However, while Streams improve code clarity in many cases, there are situations where complex operations or performance-critical sections require extra attention. Consider:

stringList.stream()
    .filter(string -> string.size() > 5)
    .map(String::toUpperCase)
    .forEach(string -> System.out.println(string));

In such cases, a traditional loop can actually be more immediately understandable:

for (String string : stringList) {
    if (string.size() > 5) System.out.println(string.toUpperCase());
}

Ultimately, the best approach depends on the specific context and the balance between readability and conciseness. Streams are a powerful tool, but they should be used where they truly add value.

Conclusion

Code semantics are crucial for clarity, maintainability, and debugging. Well-structured code is easier to analyze and understand, reducing confusion. While lambda expressions and Streams minimize boilerplate, excessive use can harm readability. Balancing conciseness and clarity enhances software quality and makes it more maintainable in the long run.