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::trim
on 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 String
s to Integer
s. 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 ofString
and return anotherString
. - In the functional interface
Function
, theapply
method takes aString
and returns aString
. - When
String::toUpperCase
is 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 inputString
is 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 ofString
and return anotherString
. - In the functional interface
Function
, theapply
method takes aString
and returns aString
. - When
String::toUpperCase
is 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 inputString
is 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
printGreeting
method is an instance method that can be invoked on any object of typeMascot
. - Functional interface match: The
printGreeting
method matches theaccept
method of theConsumer
interface because it operates on a single instance ofMascot
and 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 timeforEach
iterates over the list, it implicitly passes thecurrent Mascot
object 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::new
is a shortcut to call theUser
constructor for each name. This creates a newUser
object from each name. - Collect: The
User
objects 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::greet
is a method reference that points to thegreet
method of the currentGreeter
instance.- The method
executeGreeting
uses this method reference to pass thegreet
method as aRunnable
toperformGreet
.
‘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::show
is a method reference that points to theshow
method of the superclassBase
from the subclassDerived
.- The method
executeShow
uses 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
invokevirtual
orinvokeinterface
directly 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.