Java's missing feature: extension methods

What is an extension method?

An extension method is the ability to “add” a method directly to an existing type without creating a new derived type, recompiling, or otherwise modifying the existing type. When calling an extension method, there is no significant difference compared to calling the actual method defined in the type.

Why you need extension methods

Consider a function that, after fetching a string containing multiple item IDs from Redis (each item ID is separated by an English comma), first de-duplicates the item IDs (and is able to maintain the order of the elements), and then finally concatenates the individual item IDs using English commas.

// “123,456,123,789” String str = redisService.get(someKey)

The traditional way of writing.

String itemIdStrs = String.join(",", new LinkedHashSet<>(Arrays.asList(str.split(","))));

Using Stream write.

String itemIdStrs = Arrays.stream(str.split(",")).distingu().collect(Collectors.joining(","));

Assuming that extension methods can be implemented in Java, and that we have added the extension method toList for arrays (which turns arrays into lists), toSet for lists (which turns lists into LinkedHashSet), and join for collections (which joins the elements of a collection in string form using the given join), then we will be able to write code like this.

String itemIdStrs = str.split(",").toList().toSet().join(",");

I believe at this point you have the answer to why you need extension methods.

  • You can make direct enhancements to existing class libraries, instead of using tool classes

  • It is more comfortable to write code using the methods of the type itself than using tool classes

  • Code is easier to read because it is called in a chain rather than using static method nesting

How to implement extension methods in Java

Let’s start by asking the recent hit ChatGPT: !

ChatGPT

Well, ChatGPT thinks that the extension methods inside Java are the static methods provided through the tool classes :). So next I will introduce a brand new hack: the

Manifold (https://github.com/manifold-systems/manifold)

Preparation conditions

The principle of Manifold is the same as Lombok, it is also processed during compilation by annotation processors. So to use Manifold properly in IDEA, you need to install the Manifold IDEA plugin: !

manifold

Then add annotationProcessorPaths to the maven-compiler-plugin in the project pom

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

  ...

    <properties>
        <manifold.version>2022.1.35</manifold.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>systems.manifold</groupId>
            <artifactId>manifold-ext</artifactId>
            <version>${manifold.version}</version>
        </dependency>

        ...
    </dependencies>

    
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>8</source>
                    <target>8</target>
                    <encoding>UTF-8</encoding>
                    <compilerArgs>
                        <arg>-Xplugin:Manifold no-bootstrap</arg>
                    </compilerArgs>
                    <annotationProcessorPaths>
                        <path>
                            <groupId>systems.manifold</groupId>
                            <artifactId>manifold-ext</artifactId>
                            <version>${manifold.version}</version>
                        </path>
                    </annotationProcessorPaths>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

If your project uses Lombok, you need to add Lombok to the annotationProcessorPaths as well: the

<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path
<path
<groupId>systems.manifold</groupId
<artifactId>manifold-ext</artifactId>
<version>${manifold.version}</version>
</path>
</annotationProcessorPaths>

Writing extension methods

The split method of String, in the JDK, uses a string as an argument, i.e. String[] split(String). Let’s now add an extension method String[] split(char) for String: split by the given character.

Based on Manifold, write the extension method.

import manifold.ext.rt.api.Extension;
import manifold.ext.rt.api.This;
import org.apache.commons.lang3.StringUtils;

/**
* Extension methods for String
*/
@Extension
public final class StringExt {

public static String[] split(@This String str, char separator) {
return StringUtils.split(str, separator);
}
}

It can be found that it is still essentially a static method of the tool class, but there are some requirements.

  1. tool classes need to use Manifold’s @Extension annotation

  2. static method, the target type of the parameters, you need to use @This annotation

  3. The name of the package where the tool class is located needs to end with extensions.

– those who have used C# will smile, this is a parody of the C# extensions.

Regarding point 3, the reason for this requirement is that Manifold wants to quickly find the extensions in the project, avoiding annotation scans of all the classes in the project and improving the efficiency of the process.

Amazing! And as you can see, System.out.println(numStrs.toString()) actually prints the string form of the array object – not the address of the array object. Looking at the decompiled App.class, you see that the extension method calls are replaced with static method calls: !

toString

And the toString method for arrays uses the extension method ManArrayExt.toString(@This Object array) defined by Manifold for arrays: !

ManArrayExt

[Ljava.lang.String;@511d50c0 something, Goodbye, never again~

Because the extension method call is replaced with a static method call at compile time, there is no problem using Manifold’s extension method, even if the object calling the method is null, because the processed code is passing null as an argument to the corresponding static method. For example, if we extend a collection.


import manifold.ext.rt.api.Extension;
import manifold.ext.rt.api.This;

import java.util.Collection;

Collection; /**
* Extension methods for Collection
*/
@Extension
public final class CollectionExt {

public static boolean isNullOrEmpty(@This Collection<? > coll) {
return coll == null || coll.isEmpty();
}
}

Then called with.

List<String> list = getSomeNullableList();

// list will go into the if block if it's null and not trigger a null pointer exception
if (list.isNullOrEmpty()) {
// TODO
}

java.lang.NullPointerException, Goodbye, never again~

Array extension methods

In the JDK, there is no specific type for arrays, so what package should we put the extension class defined for arrays in? Looking at the source code of ManArrayExt, we find that Manifold provides a class manifold.rt.api.Array, which is used to represent arrays. For example, ManArrayExt provides the toList method for arrays: !

image

We see List<@Self(true) Object> written like this: @Self is used to indicate what type the annotated value should be. If it is @Self, i.e. @Self(false), it means that the annotated value is of the same type as the @This annotation; @Self(true) means it is the type of the element in the array.

For object arrays, we can see that the toList method returns the corresponding List (T is the type of the array element): !

image

However, in the case of primitive type arrays, the return value indicated by IDEA is

image

But I’m using Java, so how can an erasure generic have such a great feature as List – so you have to use the native type to receive this return value :)

image

– Make a wish: Project Valhalla will be in Java21 GA.

We often see in various projects that people wrap an object as Optional first and then filter, map, etc. With @Self’s type mapping, you can add a very practical approach to Object like this.


import manifold.ext.rt.api.Extension;
import manifold.ext.rt.api;
import manifold.ext.rt.api.This;

import java.util;

/*
* Extension methods for Object
*/
@Extension
public final class ObjectExt {

public static Optional<@Self Object> asOpt(@This Object obj) {
return Optional.ofNullable(obj);
}
}

Any object, then, will have the asOpt() method.

As opposed to the previous unnatural need to wrap it a bit: the

Optional.ofNullable(someObj).filter(someFilter).map(someMapper).orElseGet(someSupplier);

You can now naturally use Optional.

someObj.asOpt().filter(someFilter).map(someMapper).orElseGet(someSupplier);

Of course, Object is the parent of all classes, and it is prudent to think about whether this is appropriate.

Extending Static Methods

We all know that Java9 added factory methods to collections.

List<String> list = List.of("a", "b", "c");
Set<String> set = Set.of("a", "b", "c");
Map<String, Integer> map = Map.of("a", 1, "b", 2, "c", 3);

Isn’t that a lot to look forward to? Because if you’re not using Java9 and above (Java8: just report my ID), you’ll have to use a library like Guava – but ImmutableList.of is ultimately less natural than List.of.

No matter, Manifold says: “It doesn’t matter, I’ll do it”. To extend static methods based on Manifold, add @Extension to the static methods of the extension class as well.


import manifold.ext.rt.api.Extension;
import manifold.ext.rt.api.This;

import java.util.Arrays;
import java.util.Collections;
import java.util.List;

/**
* List extension methods
List; */
@Extension
public final class ListExt {

/**
* Returns an immutable List containing only one element
*/
@Extension
public static <E> List<E> of(E element) {
return Collections.singletonList(element);
}

/**
* Returns an immutable List containing multiple elements
*/
@Extension
@SafeVarargs
public static <E> List<E> of(E... elements) {
return Collections.unmodifiableList(Arrays.asList(elements));
}
}

Then you can trick yourself into using a later version of Java8 – you post what you want, I’ll use Java8.

BTW, since Object is the parent of all classes, if you add a static extension method to Object, that means you can access that static method directly from anywhere, without import – congratulations, unlocked “top-level functions “.

Recommendations

About Manifold

I’ve been following Manifold since 2019, when the Manifold IDEA plugin was still paid for, so I just did a simple experiment at that time. Looking at it again recently, the IDEA plugin is completely free, so I can’t wait to put it to good use. I’ve already used Manifold in a project to implement extension methods - the people involved said they were so addicted that they couldn’t live without it. If you have suggestions or questions about using it, feel free to discuss them with me.

Add extended methods with care

If we decide to use Manifold to implement extension methods in our project, then we must do “keep our hands to ourselves”.

First of all, it is what I said above, adding extension methods to Object or other classes that are very widely used in the project must be done very carefully, and it is better to discuss with the project team and let everyone decide together, otherwise it is easy to get confused.

In addition, if you want to add an extension method to a class, you must first think carefully about the question: “Is the logic of this method within the scope of this class’s responsibilities, whether there is a mix of business custom logic”. For example, the following method (to determine whether a given string is a legal parameter).

public static boolean isValidParam(String str) { return StringUtils.isNotBlank(str) && !” null”.equalsIgnoreCase(str); }

Obviously, isValidParam is not the responsibility of the String class, and you should keep isValidParam inside XxxBizUtils. Of course, if you change the method name to isNotBlankAndNotEqualsIgnoreCaseNullLiteral, that’s fine :)