Skip to content

Instantly share code, notes, and snippets.

@jonpryor
Last active July 18, 2018 11:05
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jonpryor/e04a9e5a9a14fdd209e791235d6a961e to your computer and use it in GitHub Desktop.
Save jonpryor/e04a9e5a9a14fdd209e791235d6a961e to your computer and use it in GitHub Desktop.

Binding Java 8 Interfaces from C♯

Help wanted! :-)

Background

Xamarin.Android binds the Android Java API, which means that all Java language features need to be "bound" to corresponding C# language features. For many language constructs, this binding is simple, e.g. type names are (usually) unchanged and identical, while other language features are changed in subtle ways, e.g. the Pascal-ification of method names for use from C#.

Binding Java interfaces straddles the line between simple and complicated. Conceptually, a Java interface is identical to a C# interface: it has a name, it has a set of implemented interfaces, it can declare methods that an implementing type must provide.

There are many ways where they are not identical. In particular, Java interfaces are versionable; that is, new methods may be added to an interface over time without breaking existing classes which implement that interface:

$ cat <<EOF > Example.java
interface Example {
}
EOF

$ cat <<EOF > ImplementsExample.java
class ImplementsExample implements Example {
}
EOF

$ cat <<EOF > App.java
class App {
    public static void main (String[] args) {
        Example e = new ImplementsExample ();
    }
}
EOF

In the initial version, the Example interface doesn't do anything, and the app doesn't do anything either:

$ javac *.java
$ java App

If we change the Example interface and without recompiling the implementing class, the app still executes

$ cat <<EOF > Example.java
interface Example {
    void newMethod();
}
EOF
$ javac Example.java
$ java App
# no output, no error

Note that this diverges from C#: attempting to do the same thing, with the Example interface in a separate assembly, and the app will fail to start:

Unhandled Exception:
System.TypeLoadException: Could not load type 'ImplementsExample' from assembly 'C, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null'.
[ERROR] FATAL UNHANDLED EXCEPTION: System.TypeLoadException: Could not load type 'ImplementsExample' from assembly 'C, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null'.

Export MONO_LOG_LEVEL=info and we see why:

Mono: no implementation for interface method Example::NewMethod() in class ImplementsExample

C# interfaces aren't versionable.

If we update the Java app to invoke the new method without updating the ImplementsExample class, then it throws an AbstractMethodError:

$ cat <<EOF > App.java
class App {
    public static void main (String[] args) {
        Example e = new ImplementsExample ();
        e.newMethod();
    }
}
EOF
$ javac App.java
$ java App
Exception in thread "main" java.lang.AbstractMethodError: ImplementsExample.newMethod()V
    at App.main(App.java:4)

Note that this is an AbstractMethodError, which can be caught, allowing calling code to trap and appropriate handle the error if the type hasn't been updated to implement the new interface methods.

(It's not necessarily a great versioning system, but it works, for various definitions of "work.")

For many years, Xamarin.Android did nothing to support this; should you hit such a scenario, the app would fail to load with the aforementioned TypeLoadException. This has been addressed in Xamarin.Android 6.1 by using a pre-packaging step which would "fixup" the IL to add any missing methods with an implementation which throws Java.Lang.AbstractMethodError.

The Problem: Java 8 Interface Default Methods

Java 8 throws a slight wrench in the works by adding two new language features regarding interfaces:

Binding interface static methods is straightforward: Java interfaces can contain static fields, and binding fields these required placing them into a "parallel" class to the interface. We just use this exist existing parallel class to contain the static methods.

Binding interface default methods, on the other hand, introduce a number of new challenges, particularly around versioning. Not only can new default interface methods be introduced, in a manner which makes for a saner versioning strategy in Java than the previous "Let's throw an AbstractMethodError and let the app sort it out!" strategy, but Java also allows interface methods declared in "previous" versions to be turned into default methods in future releases.

This has significant ramifications around binding and versioning.

Consider the java.util.Iterator interface. In Java 7, it was:

public interface Iterator<E> {
    boolean         hasNext();
    E               next();
    void            remove();
}

In Java 8:

public interface Iterator<E> {
    default void    forEachRemaining(Consumer<? super E> action);
    boolean         hasNext();
    E               next();
    default void    remove();
}

This both adds a new default method, and alters the pre-existing Iterator.remove() method to likewise be default.

Supporting Interface Default Methods

Java.Interop issue 25 suggests three ways to deal with interface default methods:

  1. Ignore default methods
  2. Expose default methods as "normal" interface methods
  3. Move default methods into a separate interface
  4. "Versioning magic"

I haven't thought of a fifth idea. Suggestions welcome.

Each of these have different tradeoffs for both users of interfaces, implementors of interfaces, and maintainers of Java library bindings who wish to bind interfaces in a version-aware manner.

Let's apply these different approaches to java.util.Iterator.

Ignoring Default Methods

Ignoring default methods means that default methods won't be bound within the generated interface binding. The C# interface won't contain them.

The binding for Iterator<E> would thus be:

public interface IIterator {
    bool                HasNext {get;}
    Java.Lang.Object    Next();
}

Users: Users don't have any access to default methods. Whether this is an actual problem or not depends on the interface; in the case of Iterator<E>, C# code is more likely to use LINQ-like constructs than want to use Iterator<E>.forEachRemaining(), so this might not be a significant loss.

This won't be the case for all interfaces.

Implementors: Implementors don't need to worry about default methods. The resulting Java Callable Wrappers will implicitly use the Java default interface implementation.

partial class MyIterator : Java.Lang.Object, Java.Util.IIterator {
    public  bool                HasNext            {get {return false;}}
    public  Java.Lang.Object    Next()             {return null;}
}

If an implementation wants to override a default method, the ExportAttribute custom attribute can be used.

partial class MyIterator {
    [Export]
    public  void                Remove()           {}
}

Maintainers: If default methods are only added, then this implies no potential ABI break. When an existing method is turned into a default method, as is done with Iterator<E>.remove(), then an "ignore default methods" approach would result in an ABI break: Java.Lang.IIterator.Remove() was previously exposed, and now it isn't.

The only way to fix this would be for the maintainer to keep track of their supported public API, track changes that are introduced when updating the bound Java library, and use metadata fixups to re-introduce the removed method.

<attr
    path="/api/package[@name='java.util']/interface[@name='Iterator&lt;E&gt;' or @name='Iterator']/method[@name='remove']"
    name="abstract">true</attr>

Extension methods to invoke default methods

As an extension to this idea, we could emit extension methods for the default methods:

public static partial class Iterator {
    public static void ForEachRemaining(this IIterator self, Consumer action);
}

This would allow users to invoke the default methods, while not requiring that implementors worry about them. It could also help the implementors know the appropriate prototype for [Export]-based "overrides".

Expose default methods as "normal" interface methods

"Exposing default methods" means that we ignore the "defaultness" of a method and treat it as a normal method, wrt interface binding. The C# interface contains all of them:

The binding for Iterator<E> would thus be:

public interface IIterator {
    [JavaInterfaceDefaultMethod]
    void                ForEachRemaining(Consumer action);
    bool                HasNext {get;}
    Java.Lang.Object    Next();
    [JavaInterfaceDefaultMethod]
    void                Remove();
}

Users: Users have access to all the default interface methods, and can invoke them normally.

Implementors: When a C# class implements a Java interface, it must implement all methods. This includes the default methods.

We should also expose "helpers" to allow explicitly calling the default method implementation. This would allow:

partial class MyIterator : Java.Lang.Object, Java.Util.IIterator {
    public  bool                HasNext            {get {return false;}}
    public  Java.Lang.Object    Next()             {return null;}
    
    // Use the default implementation for these methods
    public  void                ForEachRemaining(Consumer action)
    {
        Iterator.DefaultForEachRemaining(this, action);
    }

    public  void                Remove()
    {
        Iterator.DefaultRemove(this);
    }
}

Implementing all methods might not be problematic, or it might be. For example, the java.util.Map interface declares 11 default methods, all of which would need to be implemented from a C# subclass.

Additionally, while we could (should!) bind the default method implementation for non-static invocation, that doesn't mean that the existence of such methods will be at all obvious to developers. This is a documentation and IDE issue.

Maintainers: This implementation approach is "status quo": Java interfaces could always have methods added, so this doesn't change the world in any way. Additionally, with the use of [JavaInterfaceDefaultMethod], we can improve the pre-packaging step so that if a new default method is added to an interface, we can fixup any pre-existing assemblies to likewise contain the required new interface methods, and have the generated method body invoke the default interface implementation. Binary compatibility is preserved.

Source compatibility is not preserved, but again, that is the current status quo.

Move default methods into a separate interface

Moving default methods into a separate interface involves bifurcating the Java interface into two interfaces: one containing the required (non-default) methods, and one containing the optional (default) methods:

public interface IIterator {
    bool                HasNext {get;}
    Java.Lang.Object    Next();
}
public static partial class Iterator {
    public interface IDefaultMethods {
        [JavaInterfaceDefaultMethod]
        void                ForEachRemaining(Consumer action);
        [JavaInterfaceDefaultMethod]
        void                Remove();
    }
}

Users: Users have access to the default methods via the [JavaInterfaceName].IDefaultMethods interface:

IIterator iterator = ...
(iterator as Iterator.IDefaultMethods)?.Remove();

Implementors: If an implementor only needs the required methods, then then need only implement the required interface:

partial class MyIterator : Java.Lang.Object, Java.Util.IIterator {
    public  bool                HasNext            {get {return false;}}
    public  Java.Lang.Object    Next()             {return null;}
}

If the optional methods need to be implemented, then all of them need to be implemented:

partial class MyIterator : Iterator.IDefaultMethods {
    public  void                ForEachRemaining(Consumer action)
    {
        ...
    }

    public  void                Remove()
    {
        ...
    }
}

Maintainers: This solution is similar to the "ignore them" approach: if default methods are only added, then this implies no potential ABI break for the required methods interface; the optional methods interface may break, in a fashion considered "acceptable". When an existing method is turned into a default method, as is done with Iterator<E>.remove(), then the "move default methods into a separate interface" approach would result in an ABI break: Java.Lang.IIterator.Remove() was previously exposed, and now it isn't.

The only way to fix this would be for the maintainer to keep track of their supported public API, track changes that are introduced when updating the bound Java library, and use metadata fixups to re-introduce the removed method.

<attr
    path="/api/package[@name='java.util']/interface[@name='Iterator&lt;E&gt;' or @name='Iterator']/method[@name='remove']"
    name="abstract">true</attr>

"Versioning magic"

This isn't a separate approach, so much as vague notions a possible way to address the maintainer shortcomings in the "ignore them" and the "move default methods into a separate interface" approaches.

Both of these require that the maintainer keep track of the current API so that when the API changes in an incompatible manner, the breakage can be addressed in a compatible manner, allowing existing binaries to work.

What if this maintainer work could be automated?

We have no specifics on how this could be automated, what the workflow would look like, whether the IDE would require changes... It's a vague hand wavy notion.

Help?

Android Nougat v7.0 (API-24) is using Java 8 interfaces, so we need to pick one of these solutions before we can have an API stable binding.

The question is, which one?

Current Xamarin.Android API-N previews are following the "ignore them" strategy, which has the added benefit that we can migrate from an "ignore them" world order to an "expose them all" or "bifurcate them" world order without a huge compatibility break (with enough manual metadata fixups to preserve compatibility). Using either of the other two approaches limits migration to any other binding strategy in the future.

Feedback welcome on how Java 8 interfaces should be bound in Xamarin.Android.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment