Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Investigation: Bind Java Generics As C# Generics? #918

Open
jonpryor opened this issue Nov 16, 2021 · 8 comments
Open

Investigation: Bind Java Generics As C# Generics? #918

jonpryor opened this issue Nov 16, 2021 · 8 comments
Labels
java-interop Runtime bridge between .NET and Java proposal Issue raised for discussion, we do not even know if the change would be desirable yet

Comments

@jonpryor
Copy link
Member

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()?

@jonpryor jonpryor added the enhancement Proposed change to current functionality label Nov 16, 2021
@jonpryor
Copy link
Member Author

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);
    }
}

@jonpryor
Copy link
Member Author

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.

@jonpryor
Copy link
Member Author

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.

@jpobst
Copy link
Contributor

jpobst commented Nov 17, 2021

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).

@jonpryor
Copy link
Member Author

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. :-(

@jpobst
Copy link
Contributor

jpobst commented Nov 17, 2021

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.

@jpobst
Copy link
Contributor

jpobst commented Jan 6, 2022

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

@jpobst
Copy link
Contributor

jpobst commented Mar 15, 2022

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

jonpryor pushed a commit that referenced this issue Apr 19, 2022
Fixes: #967

Context: dotnet/android-libraries#504
Context: #424
Context: #586
Context: #918

Java generics continues to be a "difficulty" in creating bindings.
Consider [`ActivityResultContracts.RequestPermission`][0]:

	// Java
	public abstract /* partial */ class ActivityResultContract<I, O> {
	    public abstract Intent createIntent(Context context, I input);
	}
	public /* partial */ class /* ActivityResultContracts. */ RequestPermission
	    extends ActivityResultContract<String, Boolean>
	{
	    @OverRide public Intent createIntent(Context context, String input) {…}
	}

The JNI Signature for
`ActivityResultContracts.RequestPermission.createIntent()` is
`(Landroid/content/Context;Ljava/lang/String;)Landroid/content/Intent;`,
i.e. `class-parse` & `generator` believe that the `input` parameter
is of type `String`, which we bind by default as `string`.  Thus:

	// C#
	public abstract partial class ActivityResultContract {
	    public abstract Intent CreateIntent (Context? context, Java.Lang.Object? input) => …
	}
	public partial class /* ActivityResultContracts. */ RequestPermission {
	    public override Intent CreateIntent (Context? context, string? input) => …
	}

This fails to compile with a [CS0115][1]:

	'RequestPermission.CreateIntent(Context?, string?)': no suitable method found to override

as the `input` parameter of `RequestPermission.CreateIntent()` changes
from `Java.Lang.Object?` to `string?`.

We can attempt to address this via Metadata:

	<attr
	  path="/api/package[@name='androidx.activity.result.contract']/class[@name='ActivityResultContracts.RequestPermission']/method[@name='createIntent' and count(parameter)=2 and parameter[1][@type='android.content.Context'] and parameter[2][@type='java.lang.String']]"
	  name="managedType"
	>Java.Lang.Object</attr>

This fixes one error, as `generator` now emits:

	public partial class /* ActivityResultContracts. */ RequestPermission {
	    [Register ("createIntent", "(Landroid/content/Context;Ljava/lang/String;)Landroid/content/Intent;", "")]
	    public override unsafe global::Android.Content.Intent CreateIntent (global::Android.Content.Context context, global::Java.Lang.Object input)
	    {
	        const string __id = "createIntent.(Landroid/content/Context;Ljava/lang/String;)Landroid/content/Intent;";
	        IntPtr native_input = JNIEnv.NewString (input);
	        try {
	            JniArgumentValue* __args = stackalloc JniArgumentValue [2];
	            __args [0] = new JniArgumentValue ((context == null) ? IntPtr.Zero : ((global::Java.Lang.Object) context).Handle);
	            __args [1] = new JniArgumentValue (native_input);
	            var __rm = _members.InstanceMethods.InvokeAbstractObjectMethod (__id, this, __args);
	            return global::Java.Lang.Object.GetObject<global::Android.Content.Intent> (__rm.Handle, JniHandleOwnership.TransferLocalRef);
	        } finally {
	            JNIEnv.DeleteLocalRef (native_input);
	            global::System.GC.KeepAlive (context);
	            global::System.GC.KeepAlive (input);
	        }
	    }
	}

The `override` method declaration is correct.

However, this introduces a [CS1503][2] error:

	IntPtr native_input = JNIEnv.NewString (input);
	// Error CS1503 Argument 1: cannot convert from 'Java.Lang.Object' to 'string?'

The workaround is to remove `createIntent()` ~entirely, and manually
bind it, as done in dotnet/android-libraries#512.

Fix this issue by always emitting a cast to `(string)` as
part of the `JNIEnv.NewString()` invocation, instead emitting:

	IntPtr native_input = JNIEnv.NewString ((string?) input);

This works because `Java.Lang.Object` defines an
[explicit conversion to `string?`][3], and if a `Java.Lang.String`
instance is provided to the `input` parameter, it's equivalent to
calling `.ToString()`.

This fix allows the original suggested Metadata solution to work.

[0]: https://developer.android.com/reference/androidx/activity/result/contract/ActivityResultContracts.RequestPermission
[1]: https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/compiler-messages/cs0115
[2]: https://docs.microsoft.com/en-us/dotnet/csharp/misc/cs1503
[3]: https://github.com/xamarin/xamarin-android/blob/a58d4e9706455227eabb6e5b5103b25da716688b/src/Mono.Android/Java.Lang/Object.cs#L434-L439
@jpobst jpobst changed the title Bind Java Generics As C# Generics? Investigation: Bind Java Generics As C# Generics? Sep 12, 2022
@jpobst jpobst added java-interop Runtime bridge between .NET and Java proposal Issue raised for discussion, we do not even know if the change would be desirable yet and removed enhancement Proposed change to current functionality labels Feb 13, 2023
@jpobst jpobst removed their assignment Oct 10, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
java-interop Runtime bridge between .NET and Java proposal Issue raised for discussion, we do not even know if the change would be desirable yet
Projects
None yet
Development

No branches or pull requests

2 participants