Blog-Archiv

Dienstag, 9. März 2021

Java Interfaces as Component Boundaries

This article is about the original Java interface before version 1.8. It is about the role of interfaces as well-documented software-component boundaries. More precisely, it is about the compiler's behavior when it meets interfaces.

Since Java version 1.8, interfaces can contain implementations, both static and instance-bound ones, which was not possible before. Thus they are more like abstract classes now, but featuring multiple inheritance.

Abstract

In case you ever tried to compile a Java application from command line, you may have noticed that the Java compiler would not compile every class that your Main class depends on.

cd src/main/java
javac interfaces/compilation/Main.java

It would find any constructor call and compile the referenced class, recursively. But it would not search for classes that are behind interfaces, i.e. it would not compile classes implementing an interface that your Main class uses if there is no constructor call to these classes.

That means the compiler regards interfaces to be boundaries towards implementations that may be known at runtime only. Such would actually work (when accurately deployed), without losing strict type checks, because interfaces provide types.

Example

Try to compile following classes using the command line above (you can use any Java compiler, also above 1.8).

src/main/java
interfaces
compilation
Main.java
Drum.java
BassDrum.java
SnareDrum.java

Here is an interface:

package interfaces.compilation;

public interface Drum
{
    void sound();
}

Here is the application that uses that interface, but loads its implementations not through constructor calls but through reflective calls (not detectable by the compiler):

package interfaces.compilation;

public class Main
{
    public static void main(String[] args) throws Exception {
        final Drum bassDrum = (Drum) Class.forName("interfaces.compilation.BassDrum")
                .getDeclaredConstructor()
                .newInstance();
        bassDrum.sound();
        
        final Drum snareDrum = (Drum) Class.forName("interfaces.compilation.SnareDrum")
                .getDeclaredConstructor()
                .newInstance();
        snareDrum.sound();
    }
}

Here are two different implementations for the interface, in the very same package:

package interfaces.compilation;

public class BassDrum implements Drum
{
    public void sound()    {
        System.out.println("dumb");
    }
}

package interfaces.compilation;

public class SnareDrum implements Drum
{
    public void sound()    {
        System.out.println("jack");
    }
}

When all classes have been compiled and deployed, output of this application should be:

java -cp . interfaces.compilation.Main
dumb
jack


Having compiled using the command-line above, the resulting .class files should be in same directory as the .java sources. You will observe that just Main.java and Drum.java have been compiled:

src/main/java
interfaces
compilation
Main.class
Drum.class
Main.java
Drum.java
BassDrum.java
SnareDrum.java

That means the application would not work when launched:

java -cp . interfaces.compilation.Main
Exception in thread "main" java.lang.ClassNotFoundException: interfaces.compilation.BassDrum
	at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:583)
	at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:178)
	at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:521)
	at java.base/java.lang.Class.forName0(Native Method)
	at java.base/java.lang.Class.forName(Class.java:315)
	at interfaces.compilation.Main.main(Main.java:6)

Now try to replace the dynamic class loading by concrete constructor calls:

package interfaces.compilation;

public class Main
{
    public static void main(String[] args) {
        final Drum bassDrum = new BassDrum();
        bassDrum.sound();
        
        final Drum snareDrum = new SnareDrum();
        snareDrum.sound();
    }
}

Here is what you get:

src/main/java
interfaces
compilation
Main.class
Drum.class
BassDrum.class
SnareDrum.class
Main.java
Drum.java
BassDrum.java
SnareDrum.java

Now the compiler detected any dependency inside the Main.java source and compiled it. Launching the application now yields:

java -cp . interfaces.compilation.Main
dumb
jack

Component Boundaries

So why would we need dynamically loaded "components"?

Since Java 9 we need to ask: Why would we need "service modules"?

Software needs to be highly configurable. That means we want it to behave accordingly to the environment where it runs. We could choose some configuration library and implement lots of if-conditions and feature-toggles in our source code. Or we could define responsibilities via interfaces and rely on the right interface-implementations being deployed and thus present in CLASSPATH at runtime.

That's the way most Java applications work nowadays. Dependency injection (DI) has been invented to do that. Good DI containers require interfaces to represent component boundaries. Component-oriented software also demands a new responsibility: deployment. These people build together an application fitting to the customer.

Service Loading Example

Here is an example of dynamic service loading as it is possible since Java 1.6.

Mind that this still works in Java above or equal 9, but services are now defined in module-info.java files instead of META-INF/services/<name.of.interface> text files containing the fully qualified class-names of implementations.
src/main/java
interfaces
compilation
Main.java
Drum.java
BassDrum.java
SnareDrum.java
src/main/resources
META-INF
interfaces.compilation.Drum

The service definition file in META-INF directory must be named exactly like the fully-qualified class-name of the service interface, in this case interfaces.compilation.Drum. It contains the fully-qualified class-names of all implementations, separated by newlines:

fri.interfaces.compilation.BassDrum
fri.interfaces.compilation.SnareDrum

Here is the application using service implementations through the JDK's ServiceLoader utility class:

package interfaces.compilation;

import java.util.Iterator;
import java.util.ServiceLoader;

public class Main
{
    public static void main(String[] args) {
        final ServiceLoader<Drum> serviceLoader = ServiceLoader.load(Drum.class);
        final Iterator<Drum> iterator = serviceLoader.iterator();
        while (iterator.hasNext())    {
            final Drum drum = iterator.next();
            drum.sound();
        }
    }
}

Output of this application:

java -cp . interfaces.compilation.Main
dumb
jack

Conclusion

Java interfaces are more than just contracts between software components. Besides the new Java 8 features, the original Java interfaces could also provide "function pointers", i.e. class-methods that can be passed around as parameters, working even without the new @FunctionalInterface annotation. This will be subject of my next Blog.




Keine Kommentare: