Deep Dive into Lambda Expressions in Java

Hey there! This guide is all about mastering Lambdas in Java. You’ll learn everything you need to know, from the basics of creating and working with Lambdas to more advanced topics like functional interfaces and method references. Whether you’re a newbie or an experienced developer, this guide will help you level up your Lambda game. So, let’s dive into the world of functional programming with Java Lambdas!

Bonus: There are a few practice questions at the end of the writing that will help you prepare for your next interview and evaluate your knowledge.

TL;DR: Lambda Expressions are a powerful tool for Java developers that enable functional programming paradigms and provide a way to work with collections and streams in Java. By mastering Lambda Expressions, you as a developer will write better code, work more efficiently, and create more maintainable and scalable applications.

Table of Contents

  1. Introduction to Lambda Expressions

  2. Basic Syntax of Lambda Expressions

  3. Working with Collection using Lambda Expressions

  4. Method References and Constructor References.

  5. Best Practices and Tips for Working with Lambda in Java

  6. Interview/Practice Questions

  7. Conclusion

Introduction to Lambda Expressions

Lambda Expression was first introduced in Java 8 as a powerful new feature that allows developers to write short, anonymous functions that replace more verbose function definitions. They provide a way to make code more concise and expressive, allowing developers to express what they want to do rather than how they want to do it.

In essence, Lambda Expressions can be seen as a way of writing callbacks in Java. By passing a function as an argument to another function, they allow you to write code that is more modular and reusable, enabling better separation of concerns and more efficient development.

Basics Syntax of Lambda Expressions

Lambda expressions consist of 3 parts: the parameter list, an arrow (->) and a body, which can be an expression or a block of code. The parameter list specifies the input to the function, while the arrow separates the parameter list from the body. The body is the code that performs the function’s logic.

(x,y)->x+y;

The arrow separates the parameter list from the body. It can be thought of as saying “maps to” or “becomes”.

For example 1:

(x, y) -> x + ycan be read as "x and y map to x plus y".

The body is the code that performs the function’s logic. It can be an expression or a block of code. If the body is a single expression, it does not require braces.

For example 2:

x -> x * x is a lambda expression with a single expression body that returns the square of the input.

If the body contains multiple statements, it must be enclosed in braces.

For example 3:

(x, y) -> { int sum = x + y; return sum; } is a lambda expression with a block body that returns the sum of the input.

Functional Interfaces in Java

Lambda expressions work with functional interfaces, which are interfaces that have a single abstract method. They are used to represent the signature of the lambda expression. Functional interfaces can be defined by the developer or found in the Java library. Examples include the Predicate interface, which represents a function that takes an argument and returns a boolean value, and the Consumer interface, which represents a function that takes an argument and returns no value.

//filename: MyInterface.java
@FunctionalInterface
interface MyInterface {
    void doSomething(String input);
}

//filename: MyClass.java
public class MyClass {
   public static void main(String[] args) {
        MyInterface myLambda = (String input) ->                       System.out.println("Input: " + input);
  myLambda.doSomething("Hello World!"); // Output: Input: Hello World!
    }
}

In this example, MyInterfaceis a functional interface with a single method doSomething that takes a Stringparameter and returns void. The mainmethod creates a lambda expression that implements the doSomethingmethod, and then calls the method with the string "Hello World!" as the argument.

Type Inference in Lambda Expressions

Type inference is a feature in Java that allows the compiler to infer the type of a lambda expression’s parameters. This means that the developer does not have to specify the type of the parameter explicitly. The compiler determines the type of the parameter based on the context in which the lambda expression is used.

//filename: MyInterface.java
@FunctionalInterface
interface MyInterface {
    int doSomething(int x, int y);
}
//filename: MyClass.java
public class MyClass {
    public static void main(String[] args) {
        MyInterface myLambda = (x, y) -> x + y;
        int result = myLambda.doSomething(3, 5);
        System.out.println(result); // Output: 8
    }
}

In this example, MyInterface is a functional interface with a single method doSomething that takes two int parameters and returns an int. The main method creates a lambda expression that implements the doSomething method, and assigns it to the myLambda variable. Notice that the types of the parameters are not specified in the lambda expression because type inference is used to determine their types. The lambda expression simply adds the two input parameters and returns the result, which is then printed to the console.

In conclusion, functional interfaces and type inference are two essential concepts in Java that work together to enable lambda expressions. Functional interfaces provide the signature of the lambda expression, while type inference allows the developer to write more concise and expressive code.

Working with Collections using Lambda Expressions

Lambda expressions and streams provide a powerful tool for processing collections in Java. Streams are a sequence of elements that can be processed in parallel or sequentially, and lambda expressions are used to specify the operations that should be performed on each element in the stream. Here are some examples of how to use lambda expressions and streams to process collections:

Example 1: Filtering a List

Suppose we have a list of integers and we want to filter out all the even numbers. We can do this using a stream and a lambda expression as follows:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
List<Integer> filteredNumbers = numbers.stream()
                                    .filter(n -> n % 2 == 1)
                                    .collect(Collectors.toList());
System.out.println(filteredNumbers); // Output: [1, 3, 5, 7, 9]

In this example, we use the stream() method to create a stream from the numbers list. Then, we use the filter() method to apply a lambda expression that filters out all the even numbers (i.e., n % 2 == 1 means that the number is odd). Finally, we use the collect() method to convert the filtered stream back into a list.

Example 2: Mapping a List

Suppose we have a list of strings and we want to convert each string to uppercase. We can do this using a stream and a lambda expression as follows:

List<String> strings = Arrays.asList("hello", "world", "java");
List<String> upperCaseStrings = strings.stream()
                                   .map(String::toUpperCase)
                                   .collect(Collectors.toList());
System.out.println(upperCaseStrings); // Output: [HELLO, WORLD,JAVA]

In this example, we use the stream() method to create a stream from the strings list. Then, we use the map() method to apply a lambda expression that converts each string to uppercase. The String::toUpperCase method reference is used to represent a lambda expression that takes a string and returns its uppercase version. Finally, we use the collect() method to convert the mapped stream back into a list.

Example 3: Reducing a List

Suppose we have a list of integers and we want to compute the sum of all the numbers. We can do this using a stream and a lambda expression as follows:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream()
                .reduce(0, (a, b) -> a + b);
System.out.println(sum); // Output: 15

In this example, we use the stream() method to create a stream from the numbers list. Then, we use the reduce() method to apply a lambda expression that sums up all the numbers. The lambda expression takes two integers (a and b) and returns their sum. The reduce() method takes an initial value of 0 and applies the lambda expression to each element in the stream to compute the final sum.

These are just a few examples of how to use lambda expressions and streams to process collections in Java.

Method References

Method references are a shorthand notation for invoking a method on an object or a class. They allow us to simplify code that would otherwise require a lambda expression to be defined. There are four types of method references: static method references, instance method references, constructor references, and array constructor references.

  1. Static Method References: Static method references are used to invoke static methods on a class. They are defined using the syntax ClassName::methodName. Here's an example
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "Joe");
names.stream()
    .map(String::toUpperCase)
    .forEach(System.out::println);

In this example, we use the map operation to convert each name to upper case using the toUpperCase method. We then use the forEach operation to print each uppercased name to the console using the println method.

2. Instance Method References: Instance method references are used to invoke instance methods on an object. They are defined using the syntax objectName::methodName. Here's an example.

List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "Joe");
names.stream()
    .map(String::length)
    .forEach(System.out::println);

In this example, we use the map operation to get the length of each name using the length method. We then use the forEach operation to print each length to the console.

3. Constructor References: Constructor references are used to create new instances of a class. They are defined using the syntax ClassName::new. Here's an example:

List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "Joe");
List<Person> people = names.stream()
    .map(Person::new)
    .collect(Collectors.toList());

In this example, we use the map operation to create a new Person object for each name in the list using the Person constructor. We then collect the resulting Person objects into a new list using the toList collector.

4 . Array Constructor References: Array constructor references are used to create new arrays. They are defined using the syntax Type[]::new. Here's an example:

IntStream.range(0, 5)
    .mapToObj(int[]::new)
    .forEach(System.out::println);

In this example, we use the mapToObj operation to create a new integer array of length 5 using the int[] constructor reference. We then use the forEach operation to print each array to the console.

Best Practices and Tips for Working with Lambda in Java

  1. Keep Lambda Expressions Short and Simple: One of the primary benefits of Lambda expressions is that they allow for more concise code. However, it’s important not to overuse them or make them too complex. Keep Lambda expressions short and simple to maintain code readability. Here’s an example:
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "Joe");
// Don't do this
names.stream()
    .filter(name -> name.startsWith("A") || name.startsWith("B") || name.startsWith("C") || name.startsWith("D"))
    .forEach(System.out::println);
// Do this instead
names.stream()
    .filter(name -> "ABCD".contains(name.substring(0,1)))
    .forEach(System.out::println);

In this example, the first Lambda expression filters names based on their starting letter, but it is unnecessarily long and complex. The second Lambda expression uses a more concise approach to achieve the same result.

2 . Use Type Inference: Type inference is a feature in Java that allows the compiler to infer the type of a Lambda expression. This makes code more concise and easier to read. Here’s an example:

List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "Joe");
// Without type inference
names.stream()
    .map((String name) -> name.toUpperCase())
    .forEach(System.out::println);
// With type inference
names.stream()
    .map(name -> name.toUpperCase())
    .forEach(System.out::println);

In this example, the second Lambda expression uses type inference to remove the explicit type declaration. This makes the code easier to read and less verbose.

3. Use Method References When Appropriate: Method references are a shorthand notation for invoking a method on an object or a class. They can simplify code that would otherwise require a Lambda expression to be defined. Use method references when appropriate to make code more concise and readable. Here’s an example:

List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "Joe");
// Without method references
names.stream()
    .map(name -> name.length())
    .forEach(length -> System.out.println("Length: " + length));
// With method references
names.stream()
    .map(String::length)
    .forEach(length -> System.out.println("Length: " + length));

In this example, the second Lambda expression uses a method reference to invoke the length method on each String object in the list. This makes the code more concise and easier to read.

4. Be Careful with Stateful Operations: Stateful operations, such as distinct and sorted, can have unexpected results when used with parallel streams. Be careful when using these operations and be aware of their limitations. Here's an example:

List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "Joe");
// Without parallel stream
names.stream()
    .distinct()
    .sorted()
    .forEach(System.out::println);
// With parallel stream
names.parallelStream()
    .distinct()
    .sorted()
    .forEach(System.out::println);

In this example, the second Lambda expression uses a parallel stream to process the list. However, the distinct operation may not produce the expected results because it relies on stateful behaviour that can be disrupted by parallel processing.

5. Use Lambda Expressions to Implement Functional Interfaces: Lambda expressions can be used to implement functional interfaces, which are interfaces that define a single abstract method. This can be a powerful tool for writing concise and expressive code. Here’s an example:

6. Use Lambda Expressions with Collections: Lambda expressions are especially useful when working with collections, as they can simplify common operations such as filtering, mapping, and reducing. Here’s an example:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
// Filtering using a Lambda expression
List<Integer> evenNumbers = numbers.stream()
    .filter(number -> number % 2 == 0)
    .collect(Collectors.toList());
// Mapping using a Lambda expression
List<Integer> doubledNumbers = numbers.stream()
    .map(number -> number * 2)
    .collect(Collectors.toList());
// Reducing using a Lambda expression
int sum = numbers.stream()
    .reduce(0, (acc, number) -> acc + number);

In this example, the first Lambda expression filters out all odd numbers from the list, the second doubles all numbers in the list, and the third calculates the sum of all numbers in the list.

7. Avoid Side Effects: Lambda expressions should be free of side effects, meaning they should not modify any external state or have any other unintended consequences. This helps to keep code predictable and maintainable. Here’s an example:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

// Don't do this - Lambda expression has a side effect
int sum = 0;
numbers.forEach(number -> sum += number);


// Do this instead - Use a stream and the reduce operation
int sum = numbers.stream()
    .reduce(0, (acc, number) -> acc + number);

In this example, the first Lambda expression modifies the external sum variable, which is a side effect. The second example avoids this by using the reduce operation to calculate the sum.

8. Consider Performance: While Lambda expressions can simplify code, they can also have a performance impact. In general, Lambda expressions are slower than traditional Java code, so it’s important to consider performance when using them. Here’s an example:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
// Without a Lambda expression
for (int i = 0; i < numbers.size(); i++) {
    System.out.println(numbers.get(i));
}
// With a Lambda expression
numbers.forEach(System.out::println);

In this example, the second Lambda expression is more concise, but it may be slower than the traditional Java loop due to the overhead of creating and invoking the Lambda expression.

9 . Use IDE Support: Modern Java IDEs such as IntelliJ IDEA and Eclipse provide excellent support for working with Lambda expressions. They can help with code completion, refactoring, and debugging, making it easier to write and maintain Lambda-based code.

Interview Question

  1. How can Method References be used to simplify the following Lambda expression: (String s) -> System.out.println(s)?

  2. How does the following Lambda expression modify the state of an external variable, and what could be done to prevent this from happening?

int count = 0;
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
numbers.forEach(n -> {
    count += n;
});

3. Write a Lambda expression that takes a List of Strings and returns a new List with all Strings in uppercase.

4 . How can the use of Lambda expressions improve the performance of a large-scale data processing application, and what are some best practices for achieving this?

5. Can you provide an example of a custom functional interface that takes two arguments, and demonstrate how it can be used with a Lambda expression?

6. How can the use of Lambda expressions improve the readability and maintainability of an application, and what are some best practices for achieving this?

7. Write a Lambda expression that takes a Map of String keys and Integer values, and returns a new Map with all keys in uppercase and values multiplied by 2.

8 . How can the use of streams in conjunction with Lambda expressions improve the performance of data processing tasks in Java? Provide an example of how this can be achieved.

9. Can Lambda expressions be used with annotations in Java? If so, how can this be done?

10. Can Lambda expressions be used with non-final local variables? If so, what are the implications?

Conclusion

In summary, Lambda expressions are a powerful tool in Java that promote concise and maintainable code. We covered the basic syntax and demonstrated how to use Lambdas with collections, as well as Method and Constructor References to improve code quality. By following best practices, such as writing concise and testable code and avoiding code complexity, developers can use Lambdas to create efficient and maintainable code.
Thanks for Reading👋🏼

Did you find this article valuable?

Support slycreator by becoming a sponsor. Any amount is appreciated!