Methods
When you use duchess, you invoke methods via nice syntax like
java_list.get(0).to_string().execute()
// ^^^^^^^^^
// This is the method we are discussing here
How does this actually work (and why)?
Complication #1: Methods are invokable on more than just the object
Part of our setup is that define relatively ordinary looking inherent methods on the type that defines the method, e.g.:
impl Object {
fn to_string(&self) -> impl JavaMethod<String> { /* tbd */ }
}
This method will be invoked when people have a variable o: &Object
or o: Java<Object>
and they write o.to_string()
.
But it won't support our example of java_list.get(0).to_string()
,
because java_list.get(0)
returns a JvmOp
, not an Object
.
So, to define a method on Object
,
we need a way to put methods onto any JvmOp
that outputs an Object
.
Complication #2: Overridden or implemented methods create ambiguity
There are some complications in getting .
syntax to work.
We want users to be able to write m.foo()
but, in Java,
the same method foo
is often defined in multiple places,
particularly when it is overridden:
- on the class type itself
- maybe on supertypes, if it is overridden
- maybe on interfaces, if it is an interface method
We don't want users to get ambiguity errors when calling foo
.
We want them to get the most specific version of the method.
This is important not because we'll call the wrong thing -- the JVM handles the virtual dispatch.
But it can impact the return type.
Complication #3: We don't know the reflected signatures of all methods on every type
When generating code for one class X
, it may have a supertype Y
that is outside our java_package
macro invocation.
Or, it may have methods that return a value of type Z
that is outside our java_package
macro invocation.
While we can leverage Java reflection to know the Java methods of Y
and Z
, that doesn't tell us what the Rust methods are.
This is because users can subset the methods of Y
and Z
as well as making other changes,
such as renaming them to avoid overload conflicts.
So we have to support method dispatch with an incomplete view of the methods of Y
and Z
.
As one example of how this can be tricky, suppose that we attempted to resolve complication #2 by only generating methods on the "root location" that defined them.
Complication #4: Extension traits are not ergonomic
Ideally, we would define all methods as some kind of inherent method so that users do not need to import extension traits or deal with special-case preludes.
Outline and TL;DR
This section gives a brief overview of all the pieces of our solution and how they fit together In the following sections, we are going to walk through each part of the solution step by step.
- Inherent associated functions on the object types
- To support fully qualified dispatch, we add inherent associated functions (not methods, so no
self
parameter) to each Java class/interface. These are used if you write something likeObject::to_string(o)
. The parametero
must be animpl IntoJava<Object>
.
- To support fully qualified dispatch, we add inherent associated functions (not methods, so no
- Concept: newtyped references and
FromRef
- The design below leans heavily on a pattern of newtyped references.
- The idea is that given some reference
&X
, we define types likestruct Wrapper<X> { x: X }
with#[repr(transparent)]
. The transparent representation ensures thatX
andWrapper<X>
have the same layout in memory and are treated equivalently in ABIs and the like. - Now we can safely transmute from
&X
to&Wrapper<X>
.
- Concept: method resolution order
- Method resolution order is defined using Python's C3 algorithm. It is an ordering of the transitive supertypes (classes, interfaces) of
C
such that, ifX
extendsY
, thenX
appears beforeY
in the MRO.
- Method resolution order is defined using Python's C3 algorithm. It is an ordering of the transitive supertypes (classes, interfaces) of
- Modeling method resolution order (MRO) for a class/interface
C
withViewAs
structs- For each class/interface
C
, define an "view-as struct" that looks likeViewAsC<J, N>
-
A reference of type
&ViewAsC<J, N>
indicates a reference of type&J
that is being "viewed as" a reference of type&C
-
The
ViewAs
structs is a "newtyped reference" fromJ
, and soViewAsC<J, N>: FromRef<J>
. -
The
N
parameter indicates the "view" struct for the next type in the method resolution order when upcasting fromJ
- FIXME: We could probably refactor
N
away so that we just haveAsC<J>
and we use an auxiliary trait likeJ: MRO<C, Next = N>
.
- FIXME: We could probably refactor
-
ViewAsC<J, N>
derefs toN
.
-
- The
ViewAsC
structs are not nameable directly; instead theJavaObject
trait includes an associated type<C as JavaObject>::ViewOn<J>
that maps toViewAsC<J, M>
whereM
is the default MRO. - Define deref from
C
toC::ViewOn<C>
(i.e.,ViewAsC<C, M>
). - Example:
- Given
Foo extends Bar, Baz
, the typeFoo
would deref toViewAsFoo<Foo, ViewAsBar<Foo, ViewAsBaz<Foo, ()>>>
, which in turn derefs toViewAsBar<Foo, >
, which in turn derefs toViewAsBaz<Foo, ()>
, which in turn derefs to()
- Given
- For each class/interface
- Inherent methods on
ViewAs
structs- Next we add inherent methods like
fn to_string(&self) -> impl JavaMethod<java::lang::String> + '_
to the view as structs in which those methods are defined.- In the case of
to_string
, this would appear onViewAsObject<J, N>
, but also other classes that overridetoString
- The definition of this function just calls the inherent associated function
Object::to_string
- In the case of
- Rust's method dispatch will walk through the MRO, selecting the best method to use and invoking it
- Next we add inherent methods like
- Invocations on other
JvmOp
values withOfOpAs
structs- To support chained dispatch, we also need to support invocations on other
JvmOp
values. - We create a "view op as" struct that works exactly like
ViewAs
, e.g.,OfOpAsC<O, N>
- The difference is that
O
here is not aJavaObject
type but rather aimpl IntoJava<J>
for someJ
- The difference is that
- The
N
parameter models the MRO in an analogous way toViewAs
structs - Inherent methods are defined on the
OfOpAs
structs - Example:
- Given
Foo extends Bar, Baz
, and some opO
that produces aFoo
,O
would deref toOfOpAsFoo<O, OfOpAsBar<O, OfOpAsBaz<O, ()>>>
OfOpAsBar<O, OfOpAsBaz<O, ()>>
OfOpAsBaz<O, ()>
()
- ...and thus users can invoke
produce_foo().some_foo_method()
- Given
- To support chained dispatch, we also need to support invocations on other
Inherent associated functions on the object types
The first step is to create a "fully qualified" notation for each Java method:
impl Object {
fn to_string(
this: impl IntoJava<Object>
) -> impl JavaMethod<java::lang::String> {
...
}
}
This function does not take a self
parameter and so it can only be invoked using fully qualified form, e.g., Object::to_string(foo)
.
Concept: newtyped references and FromRef
The next step is that we define a trait FromRef
that we will use to define a pattern called 'newtyped references'.
The idea is that we want to be able to take a reference &J
and convert it into a view on that reference &View<J>
,
where View<J>
has the same data as J
but defines inherent methods.
We'll create a trait FromRef
to use for this pattern,
where View<J>: FromRef<J>
indicates that a view &View<J>
can be constructed from a &J
reference:
pub trait FromRef<J> {
fn from_ref(t: &J) -> &Self;
}
A view struct is just a newtype on the underlying J
type but with #[repr(transparent)]
:
#[repr(transparent)]
pub struct View<J> {
this: J,
}
The #[repr(transparent)]
attribute ensures that J
and View<J>
have the same layout in memory
and are treated equivalently in ABIs and the like.
Thanks to this, we can implement FromRef
like so:
impl FromRef<J> for View<J> {
fn from_ref(t: &J) -> &Self {
// Safe because of the `#[repr(transparent)]` attribute
unsafe { std::mem::transmute(t) }
}
}
Concept: Method resolution order (MRO)
The method resolution order for a type T
is an ordered list of its transitive supertypes such that, given two types X
and Y
in the list, if X
extends Y
then X
appears before Y
. This ensures that if we search linearly down the list, we will find the "most refined" version of a method first. We define the MRO for a type T
using Python's C3 algorithm.
Modeling method resolution order (MRO) for a class/interface C
with ViewAs
structs
For each class X
, we define a ViewAsObj
struct ViewAsXObj<J, N>
:
#[repr(transparent)]
struct ViewAsXObj<J, N> {
this: J,
phantom: PhantomData<N>,
}
The class has two type parameters:
- The parameter
J
identifies the original type from which we created the view; this will always be some sutype ofX
. - The
N
parameter represents the remainder ofJ
's method resolution order.
Deref chain
Each ViewAsObj struct includes a Deref that derefs to N:
impl<J, N> Deref for ViewAsXObj<J, N> {
type Target = N;
fn deref(&self) ->
}
Chaining ViewAsObj structs
So given interface Foo extends Bar, Baz
, the type Foo
would deref to
ViewAsFooObj<Foo, ViewAsBarObj<Foo, ViewAsBazObj<Foo, ()>>>
// --- ----------- ----------------------------------
// X J N
Each ViewAs
struct derefs to its N
parameter,
so ViewAsFooObj<Foo, ViewAsBarObj<Foo, ...>>
would deref to ViewAsBarObj<Foo, ...>
and so forth.
The FromRef
trait
Each op struct implements a trait FromRef<J>
:
trait FromRef<J> {
fn from_ref(r: &J) -> &Self;
}
The from_ref
method allows constructing an op struct from an &J
reference.
Implementing this method requires a small bit of unsafe code,
leveraging the repr(transparent)
attribute on each op struct:
impl<J> FromRef<J> for ObjectOp<J>
where
J: IntoJava<Foo>,
{
fn from_ref(r: &J) -> &Self {
// Safe because ObjectOp<J> shares representation with J:
unsafe { std::mem::transmute(r) }
}
}
Methods on ViewAsObj structs
The ViewAsObj
struct for a given Java type
also has inherent methods for each Java method.
These are implemented by invoking the fully qualified inherent functions.
For example, the ViewAsObj struct for Object
includes a to_string
method like so:
impl<J, N> ViewAsObjectObj<J, N>
where
J: Upcast<Object>,
{
pub fn to_string(&self) -> impl JavaMethod<java::lang::String> + '_ {
java::lang::Object::to_string(&self.this)
}
}
Naming op structs: the JavaObject::OfOp
associated type
We don't want ViewAsObj
structs to be publicly visible.
So we create them inside of a const _: () = { .. }
block.
But we do need some way to name them.
We expose them via associated types of the JavaView
trait:
trait JavaView {
type OfObj<J>: FromRef<J>;
type OfObjWith<J, N>: FromRef<J>
where
N: FromRef<J>;
}
The OfObj
associated type in particular provides the "default value" for N
that defines the MRO.
The OfObjWith
is used to supply an explicit N
value. For example:
const _: () = {
struct ViewAsFooObj<J, C> { ... }
impl JavaView for Foo {
type OfObj<J> = ViewAsFooObj<Foo, Bar::OfObjWith<Foo, Baz::OfObjWith<Foo, ()>>>;
// ------------ --- --------------------------------------------
// | | Method resolution order
// | Original type we are viewing onto (i.e., Self)
// The ViewAsFoo object
}
}
ViewAsOp
structs
The ViewAsObj
structs allow you to invoke methods on a java object reference like s: &java::lang::String
.
But they do not allow you to invoke methods on some random JvmOp
that happens to return a string.
For that, we create a very similar set of ViewAsOp
structs:
#[repr(transparent)]
struct ViewAsXOp<J, N> {
this: J,
phantom: PhantomData<N>,
}
These ViewAsOp
structs look exactly the same, but the J
here is not a java object but rather a JvmOp
.
Like the ViewAsObj
structs, they have inherent methods that call to the fully qualified inherent methods.
But the signature is slightly different; it is a &self
method, but the impl JavaMethod
that is returned does not capture the self
reference.
Instead, it copies the self.this
out.
This relies on the fact that all JvmOp
values are Copy
.
impl<J, N> ViewAsObjectObj<J, N>
where
J: IntoJava<Object>,
{
pub fn to_string(&self) -> impl JavaMethod<java::lang::String> {
java::lang::Object::to_string(self.this)
}
}
ViewAsOp
structs are exposed through associated types on JavaView
just like ViewAsObj
structs.
Q: Why not a self
method?
You might wonder why we take a &self
method and then copy out rather than just taking self
.
The reason is that the ViewAsObjectObj
traits are the output from Deref
impls
Deref impls on ops
We also have to add a Deref
impl to each of the op structs.
struct SomeOp { }
impl JvmOp for SomeOp {
type Output<'jvm> = Local<'jvm, Foo>;
}
impl Deref for SomeOp {
type Target = <Foo as JavaView>::OfOp<SomeOp>;
fn deref(&self) -> &Self::Target {
FromRef::from_ref(self)
}
}