Investigation: Bind Java Generics As C# Generics?
Java generics are based upon type erasure, in which all generic type parameters are "erased" with a corresponding "raw" type. Consider:
package java.util;
public /* partial */ class ArrayList<E> {
public boolean add(E element) {/* ... */ }
}
When the above is compiled, Java bytecode specifies an ArrayList.add(java.lang.Object), as seen with javap -s java.util.ArrayList:
public class java.util.ArrayList<E> …
…
public boolean add(E);
descriptor: (Ljava/lang/Object;)Z
The descriptor is what's important to Java.Interop, as that's what is used for JNI method lookup.
Here is an abbreviated version of what a Java.Interop binding looks like:
#nullable enable
using System;
using System.Reflection;
namespace Java.Lang {
public class Object {
public static T GetObject<T>(IntPtr value)
{
return Activator.CreateInstance<T>();
}
}
}
namespace Java.Util {
public class ArrayList : Java.Lang.Object {
[Android.Runtime.Register ("add", "(Ljava/lang/Object;)Z", "GetAdd_Handler")]
public virtual bool Add(Java.Lang.Object element) => false;
static Delegate? cb_add_;
static Delegate GetAdd_Handler ()
{
if (cb_add_ == null)
cb_add_ = (Func<IntPtr, IntPtr, IntPtr, bool>) n_Add;
return cb_add_;
}
static bool n_Add (IntPtr jnienv, IntPtr native__this, IntPtr native_element)
{
var __self = Java.Lang.Object.GetObject<ArrayList>(native__this);
var element = Java.Lang.Object.GetObject<Java.Lang.Object>(native_element);
return __self.Add (element);
}
}
}
namespace Android.Runtime {
[AttributeUsage (AttributeTargets.Method)]
public class RegisterAttribute : Attribute {
public RegisterAttribute(string name, string signature, string connector)
{
}
}
public static class JNIEnv {
public static void RegisterJniNatives (Type type, string methods)
{
if (string.IsNullOrEmpty (methods)) {
return;
}
string[] members = methods.Split ('\n');
for (int i = 0; i < members.Length; ++i) {
string method = members [i];
if (string.IsNullOrEmpty (method))
continue;
string[] toks = members [i].Split (new[]{':'}, 4);
Delegate callback;
if (toks [2] == "__export__") {
continue;
}
Type callbackDeclaringType = type;
if (toks.Length == 4) {
// interface invoker case
callbackDeclaringType = Type.GetType (toks [3], throwOnError: true)!;
}
#if false
// Added for .NET 6 compat in xamarin/xamarin-android@80e83ec804 ; ignore
while (callbackDeclaringType.ContainsGenericParameters) {
callbackDeclaringType = callbackDeclaringType.BaseType!;
}
#endif // false
Func<Delegate> connector = (Func<Delegate>) Delegate.CreateDelegate (typeof (Func<Delegate>),
callbackDeclaringType, toks [2]);
callback = connector ();
}
}
}
}
class MyList : Java.Util.ArrayList {
public override bool Add (Java.Lang.Object element) => true;
}
class App {
public static void Main ()
{
string MyList_members =
"add:(Ljava/lang/Object;)Z:GetAdd_Handler\n" +
"";
Android.Runtime.JNIEnv.RegisterJniNatives (typeof (MyList), MyList_members);
}
}
Notice that the Java ArrayList<E> type parameter is not present.
The reason for this is in part a design decision that "marshal methods" be located within the declaring type. For ArrayList, ArrayList.n_Add() is a marshal method, and in order to use that marshal method, we use Reflection to lookup and invoke the Get…Handler method, and pass off the delegate instance returned by ArrayList.GetAdd_Handler() to JNIEnv::RegisterNatives() (not shown here). There is thus an implicit constraint that we be able to lookup the Get…Handler() method via Reflection, and invoke it.
Another reason is that when invoking JNIEnv::RegisterNatives(), you can only register methods for a class, not for an instance. Consequently, all function pointers (delegates) must be "known" and available at class registration time.
What happens if ArrayList becomes ArrayList<T>, and nothing else changes?
namespace Java.Util {
public class ArrayList<T> : Java.Lang.Object {
[Android.Runtime.Register ("add", "(Ljava/lang/Object;)Z", "GetAdd_Handler")]
public virtual bool Add(T element) => false;
static Delegate? cb_add_;
static Delegate GetAdd_Handler ()
{
if (cb_add_ == null)
cb_add_ = (Func<IntPtr, IntPtr, IntPtr, bool>) n_Add;
return cb_add_;
}
static bool n_Add (IntPtr jnienv, IntPtr native__this, IntPtr native_element)
{
var __self = Java.Lang.Object.GetObject<ArrayList<T>>(native__this);
var element = Java.Lang.Object.GetObject<T>(native_element);
return __self.Add (element);
}
}
}
Then we enter some "interesting" interactions: if MyList is non-generic:
class MyList : Java.Util.ArrayList<string> {
public override bool Add (string element) => true;
}
then the app still works without error.
If we make MyList generic:
class MyList<T> : Java.Util.ArrayList<T> {
public override bool Add (T element) => true;
}
then we need to update our registration line. If our registration is "reified":
Android.Runtime.JNIEnv.RegisterJniNatives (typeof (MyList<string>), MyList_members);
then everything is fine.
If our registration isn't reified:
Android.Runtime.JNIEnv.RegisterJniNatives (typeof (MyList<>), MyList_members);
Then it fails:
Unhandled exception. System.ArgumentException: Late bound operations cannot be performed on types or methods for which ContainsGenericParameters is true. (Parameter 'target')
at System.Delegate.CreateDelegate(Type type, Type target, String method, Boolean ignoreCase, Boolean throwOnBindFailure)
at System.Delegate.CreateDelegate(Type type, Type target, String method)
at Android.Runtime.JNIEnv.RegisterJniNatives(Type type, String methods) in …/Program.cs:line 79
at App.Main() in …/Program.cs:line 97
The above discussion raises the question: how does Xamarin.Android cause a non-reified generic type to be registered?
Firstly, there are few restrictions on writing generic types; this is perfectly fine:
partial class MyRunnable<T> : Java.Lang.Object, Java.Lang.IRunnable {
public void Run() {}
}
When built, we'll create a Java Callable Wrapper for MyRunnable, which has a static constructor which will register the type:
public /* partial */ class MyRunnable_1 /* … */
{
/** @hide */
public static final String __md_methods;
static {
__md_methods =
"n_run:()V:GetRunHandler:Java.Lang.IRunnableInvoker, Mono.Android, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null\n" +
"";
mono.android.Runtime.register ("MyRunnable`1, HelloWorld", MyRunnable_1.class, __md_methods);
}
}
The mono.android.Runtime.register(…) invocation eventually hits JNIEnv.RegisterJniNatives(). Note that the assembly-qualified name MyRunnable`1, HelloWorld is a non-reified type, akin to typeof(MyRunnable<>).
MyRunnable<T> works on Xamarin.Android, with the restriction that Java code cannot create instances of MyRunnable_1; they can only be created by C# code. However, once created, Java code can use those instances without any problem.
Thus, the question: How do we bind Java generic types as C# generic types, while staying within the limitations JNIEnv::RegisterNatives()?
For simplicity, the "non-reified" registration demo, all at once:
#nullable enable
using System;
using System.Reflection;
namespace Java.Lang {
public class Object {
public static T GetObject<T>(IntPtr value)
{
return Activator.CreateInstance<T>();
}
}
}
namespace Java.Util {
public class ArrayList<T> : Java.Lang.Object {
[Android.Runtime.Register ("add", "(Ljava/lang/Object;)Z", "GetAdd_Handler")]
public virtual bool Add(T element) => false;
static Delegate? cb_add_;
static Delegate GetAdd_Handler ()
{
if (cb_add_ == null)
cb_add_ = (Func<IntPtr, IntPtr, IntPtr, bool>) n_Add;
return cb_add_;
}
static bool n_Add (IntPtr jnienv, IntPtr native__this, IntPtr native_element)
{
var __self = Java.Lang.Object.GetObject<ArrayList<T>>(native__this);
var element = Java.Lang.Object.GetObject<T>(native_element);
return __self.Add (element);
}
}
}
namespace Android.Runtime {
[AttributeUsage (AttributeTargets.Method)]
public class RegisterAttribute : Attribute {
public RegisterAttribute(string name, string signature, string connector)
{
}
}
public static class JNIEnv {
public static void RegisterJniNatives (Type type, string methods)
{
if (string.IsNullOrEmpty (methods)) {
return;
}
string[] members = methods.Split ('\n');
for (int i = 0; i < members.Length; ++i) {
string method = members [i];
if (string.IsNullOrEmpty (method))
continue;
string[] toks = members [i].Split (new[]{':'}, 4);
Delegate callback;
if (toks [2] == "__export__") {
continue;
}
Type callbackDeclaringType = type;
if (toks.Length == 4) {
callbackDeclaringType = Type.GetType (toks [3], throwOnError: true)!;
}
Func<Delegate> connector = (Func<Delegate>) Delegate.CreateDelegate (typeof (Func<Delegate>),
callbackDeclaringType, toks [2]);
callback = connector ();
}
}
}
}
class MyList<T> : Java.Util.ArrayList<T> {
public override bool Add (T element) => true;
}
class App {
public static void Main ()
{
string MyList_members =
"add:(Ljava/lang/Object;)Z:GetAdd_Handler\n" +
"";
Android.Runtime.JNIEnv.RegisterJniNatives (typeof (MyList<>), MyList_members);
}
}
How do we bind Java generic types as C# generic types?
Part of the answer is to move marshal methods outside of the declaring type, as was done with interfaces. This is kinda/sorta hinted at/formalized in Issue #795, with the jnimarshalmethods. namespace:
namespace jnimarshalmethods.java.util {
static partial class ArrayList {
static Delegate? cb_add_;
static Delegate GetAdd_Handler () => …;
}
}
We would then use the "interface-form" [Register] usage, so that we lookup the method from not the declaring class, but instead from our "known non-generic" jnimarshalmethod class:
namespace Java.Util {
public class ArrayList<T> : Java.Lang.Object {
[Android.Runtime.Register ("add", "(Ljava/lang/Object;)Z", "GetAdd_Handler:jnimarshalmethods.java.util.ArrayList")]
public virtual bool Add(T element) {…}
}
}
However, we still need to have only a single delegate instance returned from GetAdd_Handler(), and thus a single implementation of n_Add():
namespace jnimarshalmethods.java.util {
static partial class ArrayList {
static bool n_Add (IntPtr jnienv, IntPtr native__this, IntPtr native_element)
{
// what goes here?
}
}
}
The "what goes here" is the primary question about making this work, in anything resembling a "general" case.
The "what goes here" is the primary question…
…and calls the entire jnimarshalmethods.* idea into doubt: if we have a generic ArrayList<T> type, there is no way for a non-generic ArrayList.n_Add() method to refer to ArrayList<T> without knowing what T is. What can you use, that doesn't become JniEnvironment.CurrentRuntime.ValueManager.GetValue<IJavaPeerable>() invocation + reflection?
(There's another issue with jnimarshalmethods.*, in that some bound methods are protected, and moving the marshal method outside of the declaring class means that we ned to turn those methods into internal protected…)
Thus suggests that marshal methods should remain in the declaring class, so that Object.GetObject<ArrayList<T>>(…) can be expressed.
How should JNIEnv.RegisterJniNatives (typeof (MyList<>), members) be handled then? JNIEnv.RegisterJniNatives() could "substitute" object for all type parameters, thus causing it to implicitly register typeof(MyList<object>) / ArrayList<object>.n_Add(), but that means when Java code calls List.add() on an instance that's a MyList<int> instance…what happens? (Nothing good, I'm sure.)
More investigation required.
Is there a benefit to having both a non-generic static marshal method AND a non-generic instance marshal method? It seems like we could use the static invoker to resolve the this type, and then the instance invoker could resolve the generic type(s).
namespace Java.Util
{
interface IArrayListInvokerInterface
{
bool Add_Invoker (IntPtr native_element);
static Delegate? cb_add_;
static Delegate GetAdd_Handler () => cb_add_ ??= (Func<IntPtr, IntPtr, IntPtr, bool>) n_Add;
static bool n_Add (IntPtr jnienv, IntPtr native__this, IntPtr native_element)
{
var __self = Java.Lang.Object.GetObject<IArrayListInvokerInterface> (native__this);
return __self.Add_Invoker (native_element);
}
}
public class ArrayList<T> : Java.Lang.Object, IArrayListInvokerInterface
{
[Android.Runtime.Register ("add", "(Ljava/lang/Object;)Z", "GetAdd_Handler:Java.Util.IArrayListInvokerInterface")]
public virtual bool Add (T element) => false;
bool IArrayListInvokerInterface.Add_Invoker (IntPtr native_element)
{
var element = Java.Lang.Object.GetObject<T> (native_element);
return Add (element);
}
}
}
Placing the static invokers in an interface also avoids needing an ArrayList<T> for JNIEnv.RegisterJniNatives (typeof (IArrayListInvokerInterface), members).
Is there a benefit to having both a non-generic static marshal method AND a non-generic instance marshal method?
I think your idea is to register the static IArrayListInvokerInterface.n_Add() method with JNIEnv::RegisterNatives(), and IArrayListInvokerInterface.n_Add() would "delegate" to the instance method, which is in turn responsible for marshaling.
I think that could (eventually) work with "Java.Interop" bindings (#909), but not "Xamarin.Android" bindings, unless we constrain T to IJavaPeerable. If we want to support ArrayList<int>, then that should work when using "Java.Interop" bindings, as JniEnvironment.CurrentRuntime.ValueManager.GetValue<int>(…) is a valid construct; it'll "just" require that on the Java side you have an ArrayList<java.lang.Integer>, and it'll "unbox" the value into an int.
In Xamarin.Android, Object.GetObject<T>() is constrained to IJavaObject, and thus int can't be used so long as n_Add() is using Object.GetObject<T>(). (This could be changed to instead use e.g. JavaConvert.FromJniHandle(), but that's not public API).
Or we update Xamarin.Android to similarly use JniRuntime.JniValueManager.GetValue<T>(), which should work, but hasn't actually been tested.
The downside to this "indirection" is that you're paying for for an indirection for all Java-to-managed invocations. This could be "fine" if there were a way to avoid the indirection (sigh jnimarshalmethod-gen? sigh), but there isn't currently a way to do so. :-(
I guess the indirection is only paid for on generic types. Non-generic types could still be done with only the static marshal method.
It seems likely that there will be some additional cost for generic types, no matter which route we take.
I think I successfully implemented my suggestion as a prototype here: https://github.com/jpobst/GenericBindingPrototype/blob/main/Generic-Binding-Lib/Additions/Example.GenericType.cs https://github.com/jpobst/GenericBindingPrototype/blob/main/Generic-Binding-Lib-Sample/MainActivity.cs
It uses the "constrain to JLO" method as that seemed easier to implement.
It appears to be about 2-3% slower than the method we use today.
| Average of 5 runs, Debug configuration, Pixel 3a XL | Elapsed |
|---|---|
Today - ErasedGenericType - 100K invocations |
720.6 ms |
Prototype - GenericType<T> - 100K invocations |
737.8 ms |
For completeness, I thought I'd explore a reflection based solution (with caching) to determine the performance impact.
This method would not require "invoker" interfaces, by adding a single public method to IJavaObject:
public static class JavaObjectExtensions
{
delegate void JavaDelegate0 ();
delegate void JavaDelegate1 (Java.Lang.Object? p0);
delegate void JavaDelegate2 (Java.Lang.Object? p0, Java.Lang.Object? p1);
static Dictionary<int, object> method_delegates = new Dictionary<int, object> ();
public static object? Invoke (this IJavaObject peerable, int signature, string method, params Java.Lang.Object? [] args)
{
// Our cached versions have to be unique per T (List<string> is different than List<int>)
// Hash code needs to be fast, and a unique combination of type and method signature
// ie: better than this
signature = peerable.GetType ().GetHashCode () + signature;
if (args.Length == 0) {
var del = GetOrCreateDelegate<JavaDelegate0> (peerable, signature, method);
del ();
return null;
}
if (args.Length == 1) {
var del = GetOrCreateDelegate<JavaDelegate1> (peerable, signature, method);
del (args [0]);
return null;
}
if (args.Length == 2) {
var del = GetOrCreateDelegate<JavaDelegate2> (peerable, signature, method);
del (args [0], args [1]);
return null;
}
// etc.
throw new NotImplementedException ("DynamicInvoke fallback not implemented");
}
static T GetOrCreateDelegate<T> (IJavaObject peerable, int signature, string method) where T : Delegate
{
// Check for cached delegate
if (method_delegates.TryGetValue (signature, out var d))
return (T) d;
var type = peerable.GetType ();
var member = type.GetMethod (method);
if (member is null)
throw new NotImplementedException ("Could not find requested invoker methods");
var del = member.CreateDelegate<T> (peerable);
method_delegates.Add (signature, del);
return del;
}
}
This can be called from any static context:
static void n_PerformanceMethod_Ljava_lang_Object_ (IntPtr jnienv, IntPtr native__this, IntPtr native_p0)
{
var __this = global::Java.Lang.Object.GetObject<IJavaObject> (jnienv, native__this, JniHandleOwnership.DoNotTransfer);
var p0 = global::Java.Lang.Object.GetObject<global::Java.Lang.Object> (native_p0, JniHandleOwnership.DoNotTransfer);
__this!.Invoke (1, "InvokePerformanceMethod", p0);
}
public partial class GenericType<T> : global::Java.Lang.Object where T : global::Java.Lang.Object
{
public virtual unsafe void PerformanceMethod (T p0) { }
public void InvokePerformanceMethod (global::Java.Lang.Object obj) => PerformanceMethod (obj.JavaCast<T> ());
}
The performance isn't as bad as expected, thanks to the caching. Generally less than 1 additional ms per 1K calls.
| Debug configuration | Pixel 3a XL | Pixel 6 Pro |
|---|---|---|
| Today (no generics) - 100K invocations | 727 ms | 284 ms |
| Reflection Prototype - 100K invocations | 810 ms (+11.5%) | 321 ms (+13%) |
Pros:
- Simpler
- Doesn't require public invoker interfaces
Cons:
- Slower (for generic types only)
- Cache could grow with tons of different types