Functional Programming in Java

Gyuhang ShimGyuhang Shim
17 min read

Table of contents

Introduction

Since Java 8, Functional Programming is supported. Functional Programming techniques are designed and implemented using lambdas. Most of the Functional Programming libraries are available in java.util.function.

Imperative Programming

An Imperative Programming paradigm describes operations in terms of the program's state and the statements that change that state. It is the opposite concept of Declarative Programming.

  • The earliest imperative languages were machine languages (Assembly).

  • Over the past 20 years, many high-level languages like C, C++, and Java have been developed, but these languages are also imperative.

Declarative Programming

Declarative Programming focuses on what the program should accomplish rather than how to accomplish it.

  • Example: Web pages describe what should appear—titles, fonts, images—not how to render them on the screen.

  • Imperative programming languages specify algorithms but do not specify goals.

  • Declarative programming languages specify goals but do not specify algorithms.

Functional Programming

A paradigm that emerged to support a pure functional approach to problem-solving. It is a form of Declarative Programming.

  • Functions are treated as first-class citizens, so they can be passed as arguments to other functions or returned as function values.

  • The result of a function depends only on its input values.

  • Avoids side effects.

Functional vs Imperative

FeatureImperativeFunctional
Programmer's ViewHow to perform tasks and how to track the stateWhat information is needed and what transformations are required
State ChangesImportantNon-existent
Order of ExecutionImportantLess important
Main Flow ControlLoops, conditionals, function callsFunction calls, including recursion
Main UnitsInstances of structures or classesFunctions as first-class citizens and data collections

The Habitual Way

boolean found = false;
for(String city : cities) {
    if(city.equals("Chicago")) {
        found = true;
        break;
    }
}
System.out.println("Found chicago?:" + found);

A Better Way

System.out.println("Found chicago?:" + cities.contains(“Chicago"));

Tangible Improvements

  • No mutable variables.

  • No loop statements.

  • Less code.

  • Clearer: Allows us to focus on our main concern.

  • Reduces errors.

  • Easier to understand and maintain.

The Old Way

public class Prices {
    public static final List<BigDecimal> prices = Arrays.asList(
            new BigDecimal("10"), new BigDecimal("30"), new BigDecimal("17"),
            new BigDecimal("20"), new BigDecimal("15"), new BigDecimal("18"),
            new BigDecimal("45"), new BigDecimal("12"));

    public static void main(String[] args) {
        BigDecimal totalOfDiscountedPrices = BigDecimal.ZERO;
        for(BigDecimal price : prices) {
            if(price.compareTo(BigDecimal.valueOf(20)) > 0)
                totalOfDiscountedPrices =
                        totalOfDiscountedPrices.add(price.multiply(BigDecimal.valueOf(0.9)));
        }
        System.out.println("Total of discounted prices: " + totalOfDiscountedPrices);
        // Instead of directly computing with primitive types,
        // consider creating a separate class and providing operations through APIs.    }
}

The Old Way: problems

  • Primitive Obsession

  • Violates the Single Responsibility Principle (SRP)

Primitive Obsession: When the code relies too much on primitives. And primitive value controls the logic in a class and this primitive value is not type safe.

Single Responsibility Principle: There should never be more than one reason for a class to chang

A Better Way again

final BigDecimal totalOfDiscountedPrices =
    prices.stream()
        .filter(price -> price.compareTo(BigDecimal.valueOf(20)) > 0)
        .map(price -> price.multiply(BigDecimal.valueOf(0.9)))
        .reduce(BigDecimal.ZERO, BigDecimal::add);

System.out.println("Total of discounted prices: " + totalOfDiscountedPrices);

map

filter

reduce

Reduce (foldLeft)

The Improvements

  • The code is well-structured and not cluttered.

  • No need to worry about low-level operations.

  • Easier to improve or change the logic.

  • Control loops using library methods.

  • Efficient: Loops apply lazy evaluation.

  • Easy to modify for parallel execution where desired.

The Big Gains of Functional-Style Code

  • By not using mutable variables or reassigning variables, there are fewer bugs, and it's easier to write code considering concurrency.

  • Fewer mutable variables mean fewer errors in the code.

  • Easier to write thread-safe code, reducing concerns about concurrency issues.

  • Allows us to express our thoughts clearly in code.

  • Concise code reduces writing and reading time, making maintenance easier.

Why Code in Functional Style?

  • Iteration on Steroids

  • Enforcing Policies

  • Extending Policies

  • Hassle-Free Concurrency

Iteration on Steroids

We often use sets and maps to handle lists of objects, using loops. Even for simple iterations, we had to use for loops. Since Java 8, various methods are provided to handle these lists in different ways.

public static int sumByImperativeWay() {
    int sum = 0; // mutable variable
    for (int i : list) { // iteration
        sum += i;
    }
    return sum;
}

public static int sumByFunctionalWay() {
    // no iteration, no mutable variable, concise
    return list.stream().mapToInt(Integer::intValue).sum();
}

Enforcing Policies

  • Enterprise applications are maintained by policies.

    Examples:

    • Verifying that a specific operation has appropriate security credentials.

    • Ensuring that a transaction is fast and updates are performed correctly.

Transaction transaction = getFromTransactionFactory();
// operation to run within the transaction ..
checkProgressAndCommitOrRollbackTransaction();
UpdateAuditTrail();
  • Causes code duplication and increases maintenance costs.

  • Exceptions can cause transactions to fail, requiring careful code management.

runWithinTransaction((Transaction transaction) -> {
    // .. operation to run within the transaction ..
});
  • Instead of obtaining a transaction directly, use a well-managed function to perform only the desired operations.

  • Policy code for status and updates is abstracted and encapsulated.

  • No need to think about exceptions or transaction-related issues.

Extending Policies

  • 기업의 정책은 변경이 되고 확장이 된다. (여러 개의 연산을 삭제/추가)

  • 그래서 Core Logic 을 분석하고 이를 잘 사용할 수 있도록 코드를 작성해야 한다.

  • 이러한 복잡한 일련의 절차는 종종 한 개 이상의 Interface 들을 사용하여 구현한다.

  • 이러한 방법은 효율적이나 많은 수의 Interface 를 생산하게 되어 유지 보수가 어려워지는 단점이 존재한다.

Extending Policies: In Functional style

Corporate policies change and expand (adding/deleting multiple operations). Therefore, we need to analyze the core logic and write code that can use it effectively. Such complex procedures are often implemented using more than one interface. While efficient, this method produces many interfaces, making maintenance difficult.

Functional Style:

  • Use Functional Interfaces and Lambda Expressions to replace traditional methods of extending policies.

  • No need to create additional interfaces, so no need to implement methods.

  • We can focus only on implementing the core logic.

public class Camera {
    .. 
    public void setFilters(final Function<Color, Color>... filters) {
        filter =
            Stream.of(filters)
                .reduce((filter, next) -> filter.compose(next))
                .orElseGet(Function::identity);
    }
    ..
}
camera.setFilters(Color::brighter, Color::darker);

  • Implement the Decorator Pattern by chaining lambda expressions.

  • Similarly, policies can be extended by chaining lambda expressions easily.

  • No need to design interfaces to understand the class structure.

Hassle-Free Concurrency

When a large application is about to be deployed, performance issues may arise, revealing that the biggest bottleneck is in a module that processes large amounts of data. It's suggested that processing this module using multiple cores will improve performance immediately (it was being processed sequentially). In such cases, we need to modify the code to allow the module to operate in parallel. However, with code written in an imperative style, there's much to consider. But with code written in a functional style, there's little to change.

// sequential: 단일 thread 로 처리
sequential: findHighPriced(items.stream());

// parallel: thread pool 로 관리되는 multi threads 로 처리, Simple!!
parallel: findHighPriced(items.parallelStream());

Parallelization

  • Decide whether the lambda expressions should be executed concurrently.

  • Determine if they can be executed without race conditions or side effects.

  • Check if the results of concurrently executed lambda expressions depend on the order of processing.

  • For collections with small time/number, sequential processing is advantageous.

  • For collections with large time/number, parallel processing is advantageous.

Side Effect

An act that changes the state in the execution environment.

  • Modifying an object by calling an I/O function or another function, or changing the value of an lvalue.
x = 1 + 2; // side effect lvalue: x
y = i++; // side effect, i, y
1 * 2; // no side effect
func(Object object) { // side effect on object
    object.setValue(1);
}

Problems with Side Effects

public class RandomGenerator {
    public int seed { get; set; }
    public int getRandomValue() {
        Random(seed);
        seed++; // The state change of 'seed' is not apparent, making debugging difficult.
    }
}

Advantages Without Side Effects

  • The javac compiler can better optimize functions without side effects.

  • Functions without side effects can have their execution order rearranged, making optimization easier.

  • If F1 and F2 are functions performing independent tasks, it's easier to optimize or adjust the execution order.

Anonymous Inner Class

button.addActionListener(new ActionListener() {
    public void actionPerformed(ActionEvent e) {
        System.out.println(“Action added”);
    }
});
  • Without declaring a class that implements the ActionListener interface, we can implement the required methods directly.

  • Commonly used to add event handler logic.

  • Inconvenient because functions cannot be passed as arguments, requiring us to write such code.

Lambda Expression

button.addActionListener(
    event -> System.out.println(“Action added”)
);
  • Functions without names, consisting of arguments and a function body.

  • Syntax:

    •   (arg1, arg2, ...) -> { body }
      
        (type arg1, type arg2, ...) -> { body }
      

Lambda Expressions vs Anonymous Classes

AspectLambda ExpressionAnonymous Class
this ReferenceClass where the lambda expression is usedAnonymous class
CompiledConverted to a private methodMethod with the same signature
InstructioninvokedynamicSimilar instruction conversion principle

Lambda Expression

(int a, int b) -> { return a + b; }

() -> System.out.println("Hello World”);

(String s) -> { System.out.println(s); }

() -> 42 // Returns 42 without any arguments

() -> { return 3.1415 };

Functional Interface

An interface declared with only one abstract method.

Examples:

  • java.lang.Runnable

  • java.awt.event.ActionListener

We can create instances of functional interfaces using anonymous inner classes. Alternatively, we can use lambda expressions to simplify.

// The @FunctionalInterface annotation allows the compiler to check
// whether the interface is a functional interface.
@FunctionalInterface
public interface Worker {
    public void doSomeWork();
}

public class WorkerTester {
    public static void execute(Worker worker) {
        worker.doSomeWork();
    }

    public static void main(String[] args) {
        execute(new Worker() {
            @Override
            public void doSomeWork() {
                System.out.printf("do some heavy work");
            }
        });
        execute(() -> System.out.printf("do some work by lambda"));
    }
}

Lambda for Collection

// Data
final List<String> friends = Arrays.asList("Brian", "Nate", "Neal", "Raju","Sara", “Scott");

Collection Iteration

// The habitual way
for(String name : friends) {
    System.out.print(name);
}

// In lambda way, type inference, name: String name
friends.forEach(name -> System.out.println(name));

Collection Transform

// The habitual way
List<String> upperCaseList = new ArrayList<String>();
for(String name : friends) {
    upperCaseList.add(name.toUpperCase());
}

// In lambda way
friends.stream().map(name -> name.toUpperCase());

Function Composition

To transform objects over multiple stages, functions can be combined as follows. In functional languages, we use Function Composition to write code for combining operations.

symbols.map(Goods::getPrice)
    .filter(Goods.isPriceLessThan(500))
    .reduce(Goods::pickHigh)
    .get();

Collection - Find Elements

// The habitual way
List<String> nameStartsWithN = new ArrayList<String>();
for(String name : friends) {
    if (name.startsWith(“N”)
        nameStartsWith.add(name);
}

// In lambda way
friends.stream().filter(name -> name.startsWith(“N”));

Creating Fluent Interfaces using Lambda Expressions

public class Mailer {
    public void from(final String address) { /*... */ }
    public void to(final String address) { /*... */ }
    public void subject(final String line) { /*... */ }
    public void body(final String message) { /*... */ }
    public void send() { System.out.println("sending..."); }
    …
    public static void main(final String[] args) {
        Mailer mailer = new Mailer(); // Uncertain object lifespan
        mailer.from("build@agiledeveloper.com"); // Repeated object
        mailer.to("starblood@agiledeveloper.com"); // Repeated object
        mailer.subject("Build Notification"); // Repeated object
        mailer.body("...your code needs improvement...");
        mailer.send();
    }
}

Two smells

  1. Repeated reference variable usage

  2. Unclear lifecycle of the mailer object—how should it be handled? Is it reusable?

Points for Improvement

  • Repeated reference variable usage.

  • It's better to maintain a conversational state within the context of the object.

  • We can improve by using Method Chaining or the Cascade Method Pattern.

Implementing Method Chaining

  • Change the return type of methods from void to the instance of the object.

  • The returned object can be used to build a method chain.

public class MailBuilder {
    public MailBuilder from(final String address) { /*... */; return this; }
    public MailBuilder to(final String address) { /*... */; return this; }
    public MailBuilder subject(final String line) { /*... */; return this; }
    public MailBuilder body(final String message) { /*... */; return this; }
    public void send() { System.out.println("Sending..."); }

    public static void main(final String[] args) {
        new MailBuilder() // Someone might store the object
            .from("build@agiledeveloper.com")
            .to("starblood@agiledeveloper.com")
            .subject("Build Notification")
            .body("...improving...")
            .send();
    }
}
  • Let's make it more flexible using lambdas!

Improved Version

public class FluentMailer {
    private FluentMailer() {} // Prevent direct object creation
    public FluentMailer from(final String address) { /*... */; return this; }
    public FluentMailer to(final String address) { /*... */; return this; }
    public FluentMailer subject(final String line) { /*... */; return this; }
    public FluentMailer body(final String message) { /*... */; return this; }

    // Accepts a code block with a FluentMailer instance as an argument
    public static void send(final Consumer<FluentMailer> block) {
        final FluentMailer mailer = new FluentMailer();
        block.accept(mailer);
        System.out.println("Sending...");
    }

    public static void main(final String[] args) {
        // Use the 'mailer' object within a lambda expression - Loan Pattern
        FluentMailer.send(mailer -> // No need to call 'new'
            mailer.from("build@agiledeveloper.com")
                .to("starblood@agiledeveloper.com")
                .subject("Build Notification")
                .body("...much better..."));
    }
}

Lazy Evaluation

The goal is to execute computationally expensive tasks minimally.

  • func1() || func2(): func2 is not executed if func1 is true (short-circuiting).

  • func(func1(), func2()): Both func1 and func2 must be executed to perform func.

We can use lambda expressions to achieve lazy evaluation.

public static boolean evaluate(final int value) {
    System.out.println("Evaluating..." + value);
    simulateTimeConsumingOp(2000);
    return value > 100;
}

public static void eagerEvaluator(final boolean input1, final boolean input2) {
    System.out.println("eagerEvaluator called...");
    System.out.println("Accept?: " + (input1 && input2));
}

public static void lazyEvaluator(final Supplier<Boolean> input1, final Supplier<Boolean> input2) {
    System.out.println("lazyEvaluator called...");
    System.out.println("Accept?: " + (input1.get() && input2.get()));
}

System.out.println("// START:EAGER_OUTPUT");
eagerEvaluator(evaluate(1), evaluate(2));
System.out.println("// END:EAGER_OUTPUT");

System.out.println("// START:LAZY_OUTPUT");
lazyEvaluator(() -> evaluate(1), () -> evaluate(2));
System.out.println("// END:LAZY_OUTPUT");
START:EAGER_OUTPUT
Evaluating...1
Evaluating...2
eagerEvaluator called...
Accept?: false
END:EAGER_OUTPUT
START:LAZY_OUTPUT
lazyEvaluator called...
Evaluating...1
Accept?: false
END:LAZY_OUTPUT

Laziness of Streams

  • Streams have Intermediate and Terminal Operation methods.

  • Intermediate operation methods can form a chain.

  • The terminal operation is at the end of the chain.

  • map and filter are intermediate methods.

  • findFirst and reduce are terminal methods.

Leveraging laziness of Stream

Helper Methods for Lazy Evaluation:

private static int length(final String name) {
    System.out.println("Getting length for " + name);
    return name.length();
}

private static String toUpper(final String name) {
    System.out.println("Converting to uppercase: " + name);
    return name.toUpperCase();
}

Example:

List<String> names = Arrays.asList("Brad", "Kate", "Kim", "Jack", "Joe", 
        "Mike", "Susan", "George", "Robert", "Julia", "Parker", "Benson");

System.out.println("// START:CHAIN_OUTPUT");
final String firstNameWith3Letters = 
        names.stream()
            .filter(name -> length(name) == 3)
            .map(name -> toUpper(name))
            .findFirst()
            .get();
System.out.println(firstNameWith3Letters);

Hypothetical Eager Evaluation of Operations:

  • filter and map methods are lazy.

  • They pass lambda expressions to the next call chain without evaluation.

  • The expressions are evaluated only when the terminal operation findFirst is called.

Creating an infinite Stream of prime numbers

public static boolean isPrime(final int number) {
    return number > 1 &&
        IntStream.rangeClosed(2, (int) Math.sqrt(number))
            .noneMatch(divisor -> number % divisor == 0);
}

private static int primeAfter(final int number) {
    if (isPrime(number + 1))
        return number + 1;
    else
        return primeAfter(number + 1);
}

public static List<Integer> primes(final int fromNumber, final int count) {
    return Stream.iterate(primeAfter(fromNumber - 1), Primes::primeAfter)
        .limit(count)
        .collect(Collectors.<Integer>toList());
}

System.out.println("10 primes from 1: " + primes(1, 10));
// Output: 10 primes from 1: [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
  • Stream.iterate(seed, UnaryOperator): Generates an infinite sequence.

  • UnaryOperator: A function that takes one argument and returns a result.

  • limit(count): Returns a stream consisting of the elements of this stream, truncated to be no longer than count in length.

Pure OOP vs Hybrid OOP-Functional Style

  • OOP: Objects have changing states.

  • Hybrid: Lightweight objects are transformed into other objects.

Performance Concerns

Do these new features affect performance? Yes.

  • However, in most cases, these features improve performance.

  • Efficiency issues that take about 3% of the time can often be ignored.

  • Since Java 8, due to compiler optimizations, revamped invokedynamic, and optimized bytecode instructions, lambda expressions execute quickly.

// Both methods take approximately the same time to compute primesCount.
long primesCount = 0; // Approximately 0.0250944s
for(long number : numbers) {
    if(isPrime(number)) primesCount += 1;
}

final long primesCount = // Approximately 0.0253816s
    numbers.stream()
        .filter(number -> isPrime(number))
        .count();

Essential Practices to Succeed with Functional Style

  • More Declarative, Less Imperative

  • Favor Immutability

  • Reduce Side Effects

  • Prefer Expressions Over Statements

  • Design with Higher-Order Functions

Lexical Scoping in Closures

public static Predicate<String> checkIfStartsWith(final String letter) {
    return name -> name.startsWith(letter);
}
  • The compiler checks all scopes within the method to find that letter is a method parameter (Lexical Scoping).

  • The variable letter exists within the scope of the checkIfStartsWith method.

  • Lambda expressions can use variables within the method-level scope.

  • Such lambda expressions are called closures.

Passing Method References

Reference to an Instance Method

Parameterized Lambda Expression Method Call:

friends.stream().map(name -> name.toUpperCase());

Simplified Using Method Reference:

friends.stream().map(String::toUpperCase);

Reference to a Static Method

str.chars().filter(ch -> Character.isDigit(ch));

Simplified Using Method Reference:

str.chars().filter(Character::isDigit);

Reference to a Method on Another Object

str.chars().forEach(ch -> System.out.println(ch));

Simplified Using Method Reference:

str.chars().forEach(System.out::println);

Reference to a Method That Takes Parameters

people.stream().sorted((person1, person2) -> person1.ageDifference(person2));

Simplified Using Method Reference:

people.stream().sorted(Person::ageDifference);

Using a Constructor Reference

Supplier<Heavy> supplier = () -> new Heavy();

Simplified Using Constructor Reference:

Supplier<Heavy> supplier = Heavy::new;

Dominant Functional Programming Languages

Haskell

Supports pure functional programming with a strong static type system and lazy evaluation, offering mathematical precision and high levels of abstraction.

  • Learning Curve: Very steep due to pure functional programming and unique type system.

  • Advantages: Concise and mathematically rigorous syntax allows for high abstraction and safety.


Scala

Integrates object-oriented and functional programming, enabling powerful and flexible software development with high-level abstraction and JVM compatibility.

  • Learning Curve: Steep due to the combination of functional and object-oriented programming.

  • Advantages: Flexible and expressive syntax allows for effective design of complex systems.

Clojure

A functional programming language featuring concise and expressive syntax and immutable data structures, running on the JVM and effectively supporting concurrency.

  • Learning Curve: Steep due to Lisp-like syntax and functional paradigm.

  • Advantages: Enables powerful data processing and concurrency control through concise syntax.

Elixir

  • A functional programming language with strengths in concurrency and distributed systems, running on the BEAM virtual machine and offering high performance and fault tolerance.

    • Learning Curve: Slightly steep due to its concurrency model and ecosystem.

    • Advantages: Allows easy construction of high-performance distributed systems with concise and readable syntax.

Elm

A pure functional language supporting compile-time error prevention and safe front-end web application development, characterized by side-effect-free state management.

  • Learning Curve: Slightly steep due to pure functional programming and side-effect-free architecture.

  • Advantages: Enables development of stable and error-free front-end applications through intuitive and strict syntax.

F#

Combines functional and object-oriented programming to write concise and efficient code, featuring excellent compatibility with the .NET platform.

  • Learning Curve: Slightly steep if unfamiliar with the functional paradigm.

  • Advantages: Helps solve complex problems easily through concise and intuitive syntax.

Rust

Emphasizes memory safety and performance, featuring safe concurrency that prevents data races and memory errors in system programming.

  • Learning Curve: Steep due to complexity of memory safety and ownership models.

  • Advantages: Enables safe and high-performance system programming with clear and expressive syntax.

  • Type Safe, Strong Type Check, Parallel Friendly, Alternative for C/C++

OCaml

A multi-paradigm language combining functional, object-oriented, and imperative programming, offering a strong type system and fast execution speed.

  • Learning Curve: Slightly steep due to supporting multiple paradigms with powerful features.

  • Advantages: Write high-performance and reliable code with a strong type system and concise syntax.

Erlang

Designed for developing distributed systems and high-availability software, featuring a strong concurrency model and fault tolerance.

  • Learning Curve: Slightly steep due to the need to understand concurrency and distributed system concepts.

  • Advantages: Simplifies the development of high-availability systems with a concise and unique syntax different from imperative programming.

Kotlin

A modern and concise syntax with complete interoperability on the JVM, optimized for Android development and multi-platform development.

  • Learning Curve: Gentle due to syntax similar to Java.

  • Advantages: Improves code readability and productivity with concise and expressive syntax.

Functional Programming Languages Recommendation by Learning Curves and Pragmatic and Future vision

Considering current industry usage, salary levels, and future viability. Personal judgments are included. Levels increase with difficulty.

  1. Kotlin - Easy

  2. Scala - Difficult (Level 2)

    • First Salary
  3. Rust - Difficult (Level 2)

  4. Elixir - Difficult (Level 1)

    • Third Salary
  5. Clojure - Difficult (Level 2)

    • Fourth Salary
  6. F# - Difficult (Level 1)

    • Fifth Salary
  7. Haskell - Difficult (Level 3)

    • Second Salary

Languages like Haskell, Scala, Clojure, F#, Erlang, and Elixir have seen varying demand. Scala and Elixir have shown significant growth, while others like Haskell and Clojure have seen steady increases.

Further Reading

Book

  • Functional Programming in Java - Venkat Subramaniam

  • Purely Functional Data Structures - Chris Okasaki

  • The Little Schemer - Daniel P. Friedman

  • Programming in Scala - Martin Odersky

  • Learn You a Haskell for Great Good!: A Beginner's Guide - Miran Lipovača

0
Subscribe to my newsletter

Read articles from Gyuhang Shim directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Gyuhang Shim
Gyuhang Shim

Gyuhang Shim Passionate about building robust data platforms, I specialize in large-scale data processing technologies such as Hadoop, Spark, Trino, and Kafka. With a deep interest in the JVM ecosystem, I also have a strong affinity for programming in Scala and Rust. Constantly exploring the intersections of high-performance computing and big data, I aim to innovate and push the boundaries of what's possible in the data engineering world.