Method references in Java are part of the broader set of features introduced and enhanced in Java 8 and beyond. They are a concise way to refer to methods of classes or objects. Method references are especially useful in the context of lambda expressions, where they are used to write cleaner, more readable code. This article is a deep dive into understanding and using method references in your Java programs.
What are method references?
Method references are a shorthand way to write lambda expressions that call a single method. Rather than implementing a method in a functional interface, a method reference simply points to an existing method. Method references are most useful for replacing lambda expressions that do nothing but call an existing method.
Method references are best used to simplify or enhance the clarity of your code. There are a few cases where they are particularly useful:
- Simple method delegation: Use method references for straightforward delegations that don’t require modifying or processing arguments. For example,
list.forEach(System.out::println)is clearer than a lambda expression likelist.forEach(item -> System.out.println(item)). - Reusable logic: If the same logic or method is used repeatedly across your code, encapsulating it in a method and then referencing it can reduce duplication. An example is using
String::trimon multiple streams or collections. - Enhancing readability: In cases where the method name clearly describes the action being performed, method references can make your code more readable. For example, the purpose of
map(String::toLowerCase)is immediately clear.
The structure of a method reference
The following is a classic example of a method reference:
ClassName::staticMethod
The code instructs the program to “use the static method from this class” instead of writing a lambda.
Method references vs. lambda expressions
Now let’s compare two examples. The code below uses a lambda expression to convert Strings to Integers:
strings.stream().map(s -> Integer.parseInt(s));
Here’s the same operation using a method reference:
strings.stream().map(Integer::parseInt);
Both examples do the same thing: converting Strings to Integers. However, the method reference is shorter, which makes it easier to read and understand. You might also notice that the Integer.parseInt method contract fits with the map method, which is a Function that takes and returns a value:
Stream map(Function super T, ? extends R> mapper);
The parseInt method receives a parameter and returns a value as expected from the functional interface Function:
public static int parseInt(String s) throws NumberFormatException { … }
Instance method of a particular object instance
Sometimes we need a way to refer to a method of an existing object instance. Here’s the basic syntax:
objectInstance::methodName
And here’s a simple example:
// Lambda Expression way
String prefix = "Mr. ";
names.stream().map(str -
Here are some common use cases where we might want to refer to a method of an existing object instance:
// String processing
String prefix = "Hello, ";
list.stream().map(prefix::concat);
// Collection methods
List result = new ArrayList();
list.forEach(result::add);
// Custom object
Formatter formatter = new Formatter();
data.stream().map(formatter::format);
That’s it! If you are calling a method on an existing object, you can use object::method rather than x -> object.method(x).
Next, we’ll consider a more complex scenario for instance method references.
Instance method of an arbitrary object of a particular type
In this scenario, rather than static methods, we use a method reference associated with instances of a specific class. This allows us to dynamically apply the method to various instances of that class. Such method references are used in functional programming to invoke instance-specific methods within functional interfaces, enhancing code flexibility and reuse.
For example, the toUpperCase() method of the String class does not take any parameters and returns a String. At first glance, this does not match the Function interface directly, as it is designed to take one input and produce one output. However, when you use a method reference like String::toUpperCase in the context of the map() function in a stream of String objects, Java treats it as an instance method reference of an arbitrary object of a particular type.
Elements of an instance method reference
Why does String::toUpperCase work in this context? Let’s break it down:
First, we have the type of String::toUpperCase. In this case, String::toUpperCase is an instance method reference. The instance method reference
refers to the toUpperCase() method that is called on instances of String.
Next, consider the expected signature. The map() function in Java’s Stream API expects a Function super T, ? extends R>. In the case of a stream of String objects, this becomes Function.
Then, we look at matching the signature:
- The
toUpperCase()method can be invoked on an instance ofStringand return anotherString. - In the functional interface
Function, theapplymethod takes aStringand returns aString. - When
String::toUpperCaseis used, each element of the stream (aString) is passed as the instance on which thetoUpperCase()method is invoked. This effectively turns the method into one that matchesFunction, where the inputStringis the instance itself, and the output is the result of thetoUpperCase()method.
Behind the scenes, when you write String::toUpperCase, Java binds each element of the stream (String object) to the toUpperCase() method. This means String::toUpperCase is interpreted as s -> s.toUpperCase() where s is each element of the stream.
Expected signatures in method references
Method references only work when the method’s signature is the same as the method in the expected functional interface. As an example, the map() function in Java’s Stream API expects a particular signature: Function super T, ? extends R>. In the case of a stream of String objects, this becomes Function.
Matching the signature
- The
toUpperCase()method can be invoked on an instance ofStringand return anotherString. - In the functional interface
Function, theapplymethod takes aStringand returns aString. - When
String::toUpperCaseis used, each element of the stream (aString) is passed as the instance on which thetoUpperCase()method is invoked. This effectively turns the method into one that matchesFunction, where the inputStringis the instance itself, and the output is the result of thetoUpperCase()method.
When you write String::toUpperCase, Java binds each element of the stream (String object) to the toUpperCase() method. This means String::toUpperCase is interpreted as s -> s.toUpperCase(), where s is each element of the stream.
This ability to implicitly treat instance methods as functions accepting their instance as a parameter is a powerful feature of Java’s lambda expressions and method references. It allows you to write very clean and expressive code. This example also shows the flexibility of Java’s type inference and method reference mechanics in the context of its functional programming capabilities.
Printing elements of a List
Printing list elements is another common use case that requires using an instance method of an arbitrary object of a particular type in Java. This time, we’ll use the List interface and its method .forEach in combination with the Consumer functional interface.
Suppose you have a list of Mascot objects. Each one has a method, printGreeting(), that prints the mascot’s name. You want to print the name of each person in the list. Here’s how to do it using a method reference:
import java.util.List;
import java.util.Arrays;
public class Mascot {
private String name;
public Mascot(String name) { this.name = name; }
public void printGreeting() {
System.out.println("Hello, I'm " + name + ", welcome to Java!");
}
public static void main(String[] args) {
List mascots = Arrays.asList(new Mascot("Duke"), new Mascot("Juggy"));
// Print a greeting from each mascot using a method reference
mascots.forEach(Mascot::printGreeting);
}
}
Note that the forEach method in Java’s Iterable interface (which List extends) expects a single argument that matches the Consumer functional interface you saw in a previous example. The Consumer interface is designed to execute an operation on a single input argument. In this case, the accept method takes one parameter of type T and returns no result (void), which means it operates via side effects (modifying state, printing output, etc.).
Matching the method reference
When you use Mascot::printGreeting as a method reference, you are referring to the printGreeting method defined within the Mascot class:
public void printGreeting() {
System.out.println("Hello, I'm " + name + ", welcome to Java!");
}
This method takes no parameters and returns no value (void), and is called on instances of the Mascot class.
How Mascot::printGreeting fits Consumer
The forEach method is designed to work with a Consumer super T>, where T is the type of elements in the Iterable (in this case, Mascot). Let’s see how Mascot::printGreeting fits into this framework:
- Type compatibility: The
printGreetingmethod is an instance method that can be invoked on any object of typeMascot. - Functional interface match: The
printGreetingmethod matches theacceptmethod of theConsumerinterface because it operates on a single instance ofMascotand matches the signaturevoid accept(Mascot mascot). - Method reference adaptation: In the context of
Mascot::printGreeting, Java treats this method reference as an implementation ofConsumer. Each timeforEachiterates over the list, it implicitly passes thecurrent Mascotobject toprintGreeting.
Constructor references
Constructor references are a powerful feature often used with streams and collections. They provide a clean, concise way to create new objects from elements of streams or collections. Consider the following example, where constructor references simplify the process of converting data within collections.
In this example, we convert a List of names to user objects:
public class User {
private String name;
public User(String name) {
this.name = name;
}
// Omitted getters and setters
}
List names = Arrays.asList("John", "Mary", "Bob");
List users = names.stream()
.map(User::new) // Creates a new User for each name
.collect(Collectors.toList());
System.out.println(users);
Here’s the output from the above code:
[John, Mary, Bob]
Notice the elements of this code:
- Data: You start with a list of names (
["John", "Mary", "Bob"]). - Stream: The names are turned into a stream using
names.stream(). - User objects:
User::newis a shortcut to call theUserconstructor for each name. This creates a newUserobject from each name. - Collect: The
Userobjects are collected into a list usingcollect(Collectors.toList()).
This example shows how User::new makes the code cleaner by replacing more complex lambdas like name -> new User(name).
References to instance methods in superclass and current class types
In Java, the keywords super and this play crucial roles in contextually referring to the current object and its superclass. When used with method references, these keywords give you a clear way to refer to methods of the current instance or methods of the superclass instance. This can be particularly useful in cases where method names are overridden, or when you want to preserve the semantics of your code for readability and maintenance.
‘this’ with method references
We use this within method references to refer to the method of the current instance of the class. It’s particularly useful when you need to pass an instance method of the current object as a parameter to another method, often done in event handling or callback scenarios.
Here’s a simple example where the this keyword is used in a method reference within the context of the current class:
public class Greeter {
public void greet() {
System.out.println("Hello, welcome!");
}
public void performGreet(Runnable action) {
action.run();
}
public void executeGreeting() {
// Using 'this' with method reference to refer to the current instance's method
performGreet(this::greet);
}
public static void main(String[] args) {
new Greeter().executeGreeting();
}
}
In this example:
this::greetis a method reference that points to thegreetmethod of the currentGreeterinstance.- The method
executeGreetinguses this method reference to pass thegreetmethod as aRunnabletoperformGreet.
‘super’ with method references
Using super with method references allows you to refer specifically to methods of a superclass that might have been overridden in the current class. This is useful in subclass contexts where you need to invoke the superclass’s version of a method.
Let’s consider a situation where a class overrides a method from its superclass and wants to refer to the superclass’s method version using a method reference:
class Base {
public void show() {
System.out.println("Base class show method.");
}
}
public class Derived extends Base {
@Override
public void show() {
System.out.println("Derived class show method.");
}
public void performShow(Runnable action) {
action.run();
}
public void executeShow() {
// Using 'super' with method reference to refer to the superclass's method
performShow(super::show);
}
public static void main(String[] args) {
new Derived().executeShow();
}
}
In this example:
super::showis a method reference that points to theshowmethod of the superclassBasefrom the subclassDerived.- The method
executeShowuses this method reference to ensure that the superclass method is used, even though it is overridden in the current class.
Practical applications of method references
You can use method references wherever functional interfaces are applicable. They are particularly useful in stream operations, event listener setups, and scenarios involving methods from functional programming interfaces like Function, Consumer, Predicate, and Supplier. Here are some examples:
- Stream operations: In the Java Streams API, method references are commonly used in
map,filter,forEach, and other operations. For example,stream.map(String::toUpperCase)uses a method reference to transform each element of a stream to uppercase. - Event listeners: In GUI applications or event-driven programming, method references simplify the attachment of behavior to events. As an example,
button.addActionListener(this::methodName)binds the method directly as an event listener. - Constructors and factories: When using Java Streams and other APIs that require object generation, constructor references provide a shorthand for object creation, such as
Stream.generate(ClassName::new).
Advantages and disadvantages of using method references
Method references enhance the expressive power of Java, allowing developers to write more functional-style programming. Their integration with Java’s existing features like streams and lambdas helps developers write clean, maintainable code. Features like method references are part of Java’s ongoing integration of functional programming patterns.
Using method references has a few advantages:
- Readability: Code written using method references is clearer and more succinct.
- Efficiency: Method references help reduce boilerplate code compared to traditional anonymous class implementations.
- Maintainability: Method references are easier than lambdas to understand and modify.
While method references can simplify and clarify code, they also have potential downsides that can lead to less maintainable code:
- Overuse in complex scenarios: Avoid using method references for methods with complex operations or when transformations on parameters are needed before method application. For instance, if a lambda involves modifying parameters before a method call, replacing it with a method reference might obscure what’s happening, as in
stream.map(x -> process(x).save()) versus stream.map(MyClass::save). - Reduced readability: Method references should not be used if they will make your code harder to understand. This can happen if the method being referenced is not self-explanatory or involves side effects that are not immediately clear from the context of use.
- Debugging challenges: Debugging might be slightly more challenging with method references because they do not provide as much inline detail as lambdas. When something goes wrong, it might be harder to pinpoint if a method reference is passing unexpected values.
By keeping these best practices and pitfalls in mind, developers can effectively use method references to write cleaner, more efficient Java code while avoiding common traps that might lead to code that is hard to read and maintain.
Are method references better than lambdas?
Method references can yield better performance than lambda expressions, especially in certain contexts. Here are the key points to consider regarding the performance benefits of method references:
- Reduced code overhead: Method references often result in cleaner and more concise code. They do not require the boilerplate code that lambdas might (such as explicit parameter declaration), which can make the resulting bytecode slightly more efficient. This reduction in code complexity can make optimization by the Java virtual machine easier, potentially leading to better performance.
- JVM optimization: The JVM has optimizations specifically tailored for method references, such as invoking
invokevirtualorinvokeinterfacedirectly without the additional overhead of the lambda wrapper. These optimizations can potentially make method references faster or at least as fast as equivalent lambda expressions. - Reuse of method reference instances: Unlike lambda expressions, method references do not capture values from the enclosing context unless explicitly required. This often allows the JVM to cache and reuse instances of method references more effectively than lambda expressions, which might capture different values and thus may require separate instances each time they are created.
- Specific scenarios: The performance advantage of method references can be more noticeable in specific scenarios where the same method is reused multiple times across a large dataset, such as in streams or repetitive method calls within loops. In these cases, the reduced overhead and potential caching benefits can contribute to better performance.
- Benchmarking is key: While theoretical advantages exist, the actual performance impact can vary depending on the specific use case, the complexity of the lambda expression, and the JVM implementation. It’s generally a good practice to benchmark performance if it is a critical factor.