Guide to Functional Interfaces and Lambda Expressions in Java

Introduction

Java is an object-oriented language, imperative in its essence (contrasting with the declarative practice that is functional programming). Nonetheless, it was possible to apply functional principles to Java programs prior to version 8, however it required additional work to bypass the innate structure of the language and resulted in convoluted code. Java 8 brought about ways to harness the true efficacy and ease to which functional programming aspires.

This guide aims to provide a holistic view into functional programming, a concept that appears rather esoteric to the developer of OOP background. Because of this, material is oftentimes scattered and scarce. We will first establish an understanding of the core concepts of functional programming and the ways in which Java implements them.

Because there's a lot of misunderstanding regarding Functional Programming for those with an OOP background - we'll start out with a primer on Functional Programming and its benefits.

Then, we'll dive into Lambda Expressions as Java's implementation of first-class functions, as well as functional interfaces, followed by a quick look at Java's function package.

Primer on Functional Programming

Functional programming is a programming paradigm that revolves around - well, functions. Although object-oriented programming also employs functions, the building blocks of the program are the objects. Objects are used to mediate the state and behavior patterns inside the program, while functions are there to take care of the control flow.

Functional programming separates behavior from objects.

Functions then have the liberty to act as first-class entities. They can be stored in variables and can be arguments or the return values of other functions without needing to be accompanied by an object. These discrete entities are termed first-class functions, while the functions enclosing them are named higher-order functions.

Functional programming also has a different approach towards the program state. In OOP, the desired outcome of an algorithm is achieved by manipulating the state of the program. Functional practice refrains from causing state changes altogether. The functions are generally pure, meaning that they do not cause any side effects; they don't alter global variables, perform IO or throw exceptions.

There exist purely functional languages, some of which enforce the use of immutable variables. There also exist purely object-oriented languages. Java is a multi paradigm language; it has the ability to teeter between different programming styles and utilize the benefits of multiple paradigms in the same code-base.

The Benefits of Functional Programming

Functional programming, among all else, offers flexibility. We can create layers of generalization. We can scaffold behavioral patterns and customize them by passing in additional instructions when needed.

Object-oriented programming also has ways to create these patterns, though they depend on the use of objects. Interfaces, for example, can be used to create a scaffold, and each class implementing the interface can tailor the behavior defined in its own way. Then again, an object should always be there to carry the variants. Functional programming provides a more elegant way.

Furthermore, functional programming uses pure functions. Since pure functions can not alter states outside of their scope, they do not have the power to affect one another; each function is fully independent. This gives programmers the ability to dispose of the functions when they are no longer needed, alter the execution order at will, or execute functions in parallel.

Since pure functions are not dependent on external values, re-executing the code with the same arguments will result in the same outcome every time. This supports the optimization technique called memoization (not "memorization"), the process of caching the results of an expensive execution sequence to retrieve them when needed elsewhere in the program.

Additionally, the ability to treat functions as first-class entities allows for currying - the technique of subdividing the execution sequence of a function to perform at separate times. A function with multiple parameters can be partially executed at the point where one parameter is supplied, and the rest of the operation can be stored and delayed until the next parameter is given.

Lambda Expressions in Java

Functional Interfaces and Lambda Expressions

Java implements the basic block of functional programming, the pure first-class functions, in the form of lambda expressions.

Lambda expressions are the couriers via which Java moves around a set of behavior.

Lambda expressions, by and large, have the structure of:

(optional list of parameters) -> {behavior}

Then again, this structure is subject to change. Let's see the lambdas in action first and elaborate on the adapted versions of their syntax later on. We'll start off by defining a functional interface:

public interface StringConcat{
    String concat(String a, String b);
}

A functional interface is an interface that has exactly one abstract method.

We can then implement this interface's method, through a lambda expression:

StringConcat lambdaConcat = (String a, String b) -> {return a + " " + b;};

With this implementation, the concat() method now has a body and can be used later on:

String string1 = "german";
String string2 = "shepherd";

String concatenatedString = lambdaConcat.concat(string1, string2);
System.out.println(concatenatedString);

Let's take a step back and peel away at what we just did. The StringConcat interface holds a single abstract method (concat()) which takes two string parameters and is expected to return a string value.

StringConcat is an interface and can not be instantiated. On the right-hand side of the assignment, the compiler expects to find an instantiation of a class that implements StringConcat, not a function. Yet, the code works seamlessly.

Java is inherently object-oriented. Everything is an object in Java (more accurately, everything extends into an Object class), including lambda expressions.

Even though we get to treat lambdas as first-class functions, Java interprets them as objects. Intrinsic in that, the lambda expression assigned to be of type StringConcat is essentially an implementing class and therefore has to define the behavior for StringConcat's method.

The concat() method can be called in the same way object methods are called (lambdaConcat.concat()), and it behaves as defined by the lambda expression:

At the end of the program execution, console should read:

german shepherd

Lambdas as Arguments

Lambdas shine more when they're passed in as arguments to methods, instead of used as utility classes. Let's implement a function that filters through a list of people to find a set statistically likely to be "likable" by some set standard.

Note: Our standard for "friendliness" will be set just for illustrational purposes, and doesn't reflect any real research or statistical analysis.

The function will accept a mass and bias to filter out the mass ending up with a group of people that are, according to the opinion applied, "nice people":

filter(mass, bias){
    //filter the mass according to bias
    return nicePeople
}

The bias in the parameter list will be a function - a lambda expression - that the higher-order function refers to decide the appeal of each person in the mass.

Let's start by creating a Person class to represent a person:

enum PetPreference {
    DOGPERSON, CATPERSON, HASAPETSNAKE
}

public class Person {
    private String name;
    private int age;
    private boolean extrovert;
    private PetPreference petPreference;
    private List<String> hobbies;

    // Constructor, getters, setters and toString()
}

The Person class is assigned various fields to outline each of their characters. Each Person has a name, age, a sociability signifier, a pet preference selected among a set of constants, and a list of hobbies.

With a Person class, let's go ahead, defining a Bias functional interface with a test() function. The test() function will, naturally, be abstract and without implementation by default:

public interface Bias {
    boolean test(Person p);
}

Once we implement it, the test() function will test a person for their likability, according to some set of biases. Let's go ahead and define the filter() function as well, which accepts a list of people and a Bias for filtering:

public static List<Person> filter(List<Person> people, Bias bias) {
    List<Person> filteredPeople = new ArrayList<>();
    for (Person p : people) {
        if (bias.test(p)) {
            filteredPeople.add(p);
        }
    }
    return filteredPeople;
}

Based on the result of the test() function, we either add or skip adding a person to the filteredPeople list, which is, well, how filters work. Keep in mind that the actual implementation of the test() function still doesn't exist, and will only gain body after we define its body as a lambda function.

Since the filter() method accepts the Bias functional interface, we can anonymously create the lambda function in the filter() call:

Person p1 = new Person("David", 35, true, PetPreference.DOGPERSON, "neuroscience", "languages", "traveling", "reading");
Person p2 = new Person("Marry", 35, true, PetPreference.CATPERSON, "archery", "neurology");
Person p3 = new Person("Jane", 15, false, PetPreference.DOGPERSON, "neurology", "anatomy", "biology");
Person p4 = new Person("Mariah", 27, true, PetPreference.HASAPETSNAKE, "hiking");
Person p5 = new Person("Kevin", 55, false, PetPreference.CATPERSON, "traveling", "swimming", "weightlifting");

List<Person> people = Arrays.asList(p1, p2, p3, p4, p5);

System.out.println(filter(people, p -> p.isExtrovert()));

Finally, this is where it all comes together - we've defined the body of the functional interface via a lambda expression:

p -> p.isExtrovert()

The lambda expression gets evaluated and compared against the signature of Bias's test() method and this body is then used as the test() method's check, and returns a true or false based on the value of the isExtrovert() method.

Keep in mind that we could've used anybody here, since Bias is a "plug-and-play" functional interface.

The ability to create a method that can adjust its approach in this manner is a delicacy of functional programming.

The filter() method is a higher-degree function that takes another function as its parameter according to which it alters its behavior, where the other function is fully fluid.

There exist myriad ways in which we can select a Person to hang out with. Putting the ethics of filtering like this to the side, we may choose to hang out with people of a certain age scope, prefer extroverts, or we may be desperate to find someone who would go to the gym with us yet be disinclined to share their cat stories.

Various selection criteria can be chained together as well.

Of course, it is possible to create different methods to serve each scenario - yet does it make sense to purchase different drills to use on different materials when you can simply change the drill bits?

Free eBook: Git Essentials

Check out our hands-on, practical guide to learning Git, with best-practices, industry-accepted standards, and included cheat sheet. Stop Googling Git commands and actually learn it!

The filter() method provides flexibility. It defines the main behavior, selecting. Later on, in the program, we can use this method for any selection and just pass in "how to".

It's worth noting that the filter() method starts by creating a new ArrayList, as functional practice refrains from changing the state of the program. Instead of operating on and manipulating the original list, we start with an empty list which we later populate with desired Persons.

The list holding only the extroverts is then passed to list() to be displayed in the console:

[
Person{name='David', age=35, extrovert=true, petPreference=DOGPERSON, hobbies=[neuroscience, languages, traveling, reading]}, 
Person{name='Marry', age=35, extrovert=true, petPreference=CATPERSON, hobbies=[archery, neurology]}, 
Person{name='Mariah', age=27, extrovert=true, petPreference=HASAPETSNAKE, hobbies=[hiking]}
]

This example showcases the flexibility and liquidity of functional interfaces and their lambda-created bodies.

Lambdas and Interfaces

So far, the lambda expressions were ascribed to an interface. This will be the norm any time we want to implement first-class functions in Java.

Consider the implementation of arrays. When an array's elements are needed somewhere in the code, we call the array by its assigned name and access its elements through that name instead of moving the actual set of data around. And since we have declared it to be an array of one type, every time we want to operate on it, the compiler knows that the variable name is referring to an array and that this array stores objects of a significant type. The compiler thus can decide the capabilities of this variable and the actions it can perform.

Java is a statically-typed language - it requires this knowledge for every variable.

Every variable must state its name and its type before it can be used (this is called declaring a variable). Lambda expressions are not an exception to this rule.

When we want to use lambda expressions, we need to let the compiler know the nature of the encapsulated behavior. The interfaces we bind to lambda expressions are there to provide this information; they act as footnotes the compiler can refer to.

We could carry the name and type information along with the lambda expression itself. However, more often than not, we will use the same type of lambdas to create a variety of particular behaviors.

It's good practice to avoid redundancy in the code; typing the same information many times over will only make our code error-prone and our fingers weary.

Lambda Expression Syntax

Lambdas come in many flavors. While the lambda operator (->) is set firm, brackets and type declarations can be removed in some circumstances.

Lambda takes its simplest form when there only exists one parameter and one operation to perform inside the function body.

c -> c.isExtrovert()

We no longer need parentheses around the parameter, no type declaration needed, no curly brackets enclosing the statement, and no requirement to use the return keyword.

The lambda expression can take more than one parameter or may not take any. In those cases, we are bound to include parentheses:

() -> System.out.println("Hello World!")
(a, b) -> System.out.println(a + b)

If the function body includes more than one statement, the curly braces and, if the return type is not void, the return keyword are also required:

(a, b) -> {
String c = a + b;
return c;
}

The type declaration for the parameters can be omitted fully. Though if one parameter amongst many has its type declared, others are required to follow in its footsteps:

(a, b) -> System.out.println(a + b)
(String a, String b -> System.out.println(a + b)

Both statements above are valid. However, the compiler would complain if the program were to use the expression below:

(String a, b) -> System.out.println(a + b)

Functional Interfaces

@FunctionalInterface

Any interface with a single abstract method qualifies to be a functional interface; there is no additional requirement. Yet, a distinction may be necessary for large codebases.

Let's take the Bias interface from Lambdas as Arguments, and add another abstract method to it:

public interface Bias {
    boolean test(Person p);
    boolean concat(String a, String b);
}

The Bias interface was connected to a lambda expression, yet the compiler does not complain if we add another method to the interface, which turns it from a functional interface to a regular one.

The compiler has no way of knowing that Bias was supposed to be a functional interface until it encounters the lambda expression bound to it. Since a regular interface can have many abstract methods (and since there is no indication that this interface is not like any other), the compiler will blame the lambda expression for it tries to bind to a non-functional interface.

To avoid this, Java provides a way to mark the interfaces that serve lambda expressions, explicitly:

@FunctionalInterface
public interface Bias {
    boolean test(Person p);
}

The @FunctionalInterface annotation will let the compiler know that this interface is meant to be functional, and therefore any additional abstract method is not welcome here.

The compiler can now interfere on the spot when someone makes the mistake of adding another method to this interface, though the chances of that are lowered yet again by the @FunctionalInterface mark.

Default and Static Methods

Up until Java 8, interfaces were limited to having abstract methods and constants. Along with functional programming support came the addition of default and static methods to interface definitions.

An abstract method defines a skeleton for the method to be implemented. A default method, on the other hand, is no mere skeleton; it is explicitly defined. Yet, an implementing class is given the option to override the default methods. If they don't, the default implementation kicks in:

public interface Doggo {
    default void bark(){
        System.out.println("Woof woof");
    }
}

Let's implement this interface without implementing the bark() method:

static class GermanShepherd implements Doggo {}

Now, let's instantiate it and take a look at the default implementation kicking in:

GermanShepherd rinTinTin = new GermanShepherd();
rinTinTin.bark();
Woof woof

A static method of an interface, on the other hand, is the private property of that interface. It can only be called via the interface name and cannot be overridden by the implementing classes:

public interface Doggo {
    default void bark(){
        System.out.println("Woof woof");
    }
    static void howl(){
        System.out.println("owooooo");
    }
}

Let's implement the interface:

static class GermanShepherd implements Doggo {}

And instantiate a GermanSheperd:

GermanShepherd rinTinTin = new GermanShepherd();
rinTinTin.bark();
Doggo.howl();

This results in:

Woof woof
owooooo

The java.util.function Package

The extent of information functional interfaces provide is limited. The method definitions can easily be generalized to cover common use cases, and they can be fairly flexible in their implementations.

The return type of the abstract method can be any of the primitive types (integer, string, double, etc.) or can be void. Any classes that are defined inside the program can also be declared as the return type, though the generic type would cover all.

The same logic applies to the parameter types. Even though the number of parameters to a method can still vary, there exists a logical limit for the sake of code quality. The list of names that can be assigned to a function is also limitless, though it rarely matters.

In the end, we are left with a handful of permutations that can cover most of the common use cases.

Java employs 43 predefined functional interfaces, in the java.util.function package, to serve these scenarios. We can group them in five groups:

Function<E,F>: Takes an object, operates on it, returns an object.
Predicate<E>: Takes an object, performs a test, returns a Boolean. 
Consumer<E>: Takes an object, consumes it, returns void.
Supplier<E>: Does not take any data, returns an object.
Operator<E>: Takes an object, operates on it, returns the same type of object.

In their individual guides - we'll be covering each of these groups separately.

Conclusion

In this guide, we've taken a holistic look at Functional Programming in Java and its implementation. We've covered Functional Interfaces, as well as Lambda Expressions as the building blocks for functional code.

Last Updated: March 29th, 2023
Was this article helpful?

Improve your dev skills!

Get tutorials, guides, and dev jobs in your inbox.

No spam ever. Unsubscribe at any time. Read our Privacy Policy.

Make Clarity from Data - Quickly Learn Data Visualization with Python

Learn the landscape of Data Visualization tools in Python - work with Seaborn, Plotly, and Bokeh, and excel in Matplotlib!

From simple plot types to ridge plots, surface plots and spectrograms - understand your data and learn to draw conclusions from it.

© 2013-2024 Stack Abuse. All rights reserved.

AboutDisclosurePrivacyTerms