Truffle Interop 2.0

This document is targeted at guest language and tool implementers. It is recommended to read the Truffle Library Tutorial first, before proceeding.

Motivation

In Truffle Version 1.0 RC15 a new API called “Truffle Libraries” was introduced. Truffle Libraries allow users to use polymorphism with support for profiling/caching. With Interop 2.0 it is planned to use Truffle Libraries for the interoperability protocol. The current interoperability APIs are mature and well-tested and already adopted by languages and tools.

Here is a list of arguments why current interoperability APIs were changed and Interop 2.0 was introduced:

Compatibility

The change from interop 1.0 to 2.0 was done in a compatible way. Therefore, the old interop should continue to work and adoption can be incremental. This means that if one language still calls using the old interop API and the other language has already adopted the new interop API, a compatibility bridge will map the APIs. If you are curious about how this works, look for the class DefaultTruffleObjectExports for new interop calls to old interop. And LegacyToLibraryNode for old interop calls to new interop. Note that using the compatibility bridge may cause performance regressions. That is why languages should migrate as early as possible.

Interop Protocol Changes

Interop 2.0 comes with many protocol changes. Th is section is intended to provide rationales for these changes. For fully detailed reference documentation see the InteropLibrary Javadoc. Note: Every deprecated API describes its migration path in the Javadoc tagged by @deprecated.

Replace IS_BOXED and UNBOX with Explicit Types

There are some problems with the IS_BOXED/UNBOX design:

The following new messages were introduced in InteropLibrary as a replacement:

boolean isBoolean(Object)
boolean asBoolean(Object)
boolean isString(Object)
String  asString(Object)
boolean isNumber(Object)
boolean fitsInByte(Object)
boolean fitsInShort(Object)
boolean fitsInInt(Object)
boolean fitsInLong(Object)
boolean fitsInFloat(Object)
boolean fitsInDouble(Object)
byte asByte(Object)
short asShort(Object)
int asInt(Object)
long asLong(Object)
float asFloat(Object)
double asDouble(Object)

The InteropLibrary specifies default implementations for the receiver types Boolean, Byte, Short, Integer, Long, Float, Double, Character, and String. This design is extendable to support new values like big numbers or a custom String abstraction as Java primitive types are no longer directly used. It is no longer recommended to use primitive types in specializations directly, as the set of interop primitive types may change in the future. Instead, always use the interop library to check for a particular type, e.g., use fitsInInt instead of instanceof Integer.

By using the new messages it is possible to emulate the original UNBOX message like this:

@Specialization(limit="5")
Object doUnbox(Object value, @CachedLibrary("value") InteropLibrary interop) {
    if (interop.isBoolean(value)) {
      return interop.asBoolean(value);
    } else if (interop.isString(value)) {
      return interop.asString(value);
    } else if (interop.isNumber(value)) {
      if (interop.fitsInByte(value)) {
        return interop.asByte(value);
      } else if (interop.fitsInShort(value)) {
        return interop.asShort(value);
      } else if (interop.fitsInInt(value)) {
        return interop.asInt(value);
      } else if (interop.fitsInLong(value)) {
        return interop.asLong(value);
      } else if (interop.fitsInFloat(value)) {
        return interop.asFloat(value);
      } else if (interop.fitsInDouble(value)) {
        return interop.asDouble(value);
      }
    }
    throw UnsupportedMessageException.create();
}

Note: It is not recommended to unbox all primitive types like this. Instead a language should only unbox to the primitive types it actually uses. Ideally an unbox operation is not needed and the interop library is directly used to implement the operation, like this:

@Specialization(guards = {
                "leftValues.fitsInLong(l)",
                "rightValues.fitsInLong(r)"}, limit="5")
long doAdd(Object l, Object r,
             @CachedLibrary("l") InteropLibrary leftValues,
             @CachedLibrary("r") InteropLibrary rightValues) {
       return leftValues.asLong(l) + rightValues.asLong(r);
}

Explicit Namespaces for Array and Member Elements

The generic READ and WRITE messages were originally designed with primarily JavaScript use-cases in mind. With the adoption of interop by more languages, it became apparent that there is a need for explicit namespaces for arrays and object members. Over time, the interpretation of READ and WRITE was changed to represent array accesses when used with numbers and object member accesses when used with strings. The HAS_SIZE message was reinterpreted as whether the value contains array elements with additional guarantees, e.g., that array elements were iterable between index 0 and size.

For better interop between languages, there is a need for an explicit Hash/Map/Dictionary entry namespace. Originally it was intended to reuse the generic READ/WRITE namespace for this. For JavaScript, this was possible, as the dictionary and member namespaces were equivalent. Most languages, however, separate Map entries from Object members, which leads to ambiguous keys. It is not possible for the source language (the protocol implementer) to know how this conflict needs to be resolved. Instead, by having explicit namespaces we can let the target language (the protocol caller) decide how to resolve the ambiguity. For example, whether dictionary or member elements should take precedence can now be decided in the target language operation.

The following interop messages were changed:

READ, WRITE, REMOVE, HAS_SIZE, GET_SIZE, HAS_KEYS, KEYS

The updated protocol with separate member and array namespace in InteropLibrary looks like this:

Object Namespace:

hasMembers(Object)
getMembers(Object, boolean)
readMember(Object, String)
writeMember(Object, String, Object)
removeMember(Object, String)
invokeMember(Object, String, Object...)

Array Namespace:

hasArrayElements(Object)
readArrayElement(Object, long)
getArraySize(Object)
writeArrayElement(Object, long, Object)
removeArrayElement(Object, long)

Array access messages no longer throw UnknownIdentifierException; they instead throw InvalidArrayIndexException. This was a bug in the original design, where the accessed number needed to be converted to the identifier string in the UnknownIdentifierException.

Replaced KeyInfo with Individual Messages

In the previous section, we did not mention the KEY_INFO message. The KEY_INFO message was useful to query all properties of a member or array element. While this was a conveniently small API, it was often inefficient as it required the implementer to return all the key info properties. At the same time, it is rare that the caller really needed all key info properties. With Interop 2.0 we removed the KEY_INFO message. Instead, we introduced explicit messages for each namespace, to address this issue.

Object Namespace:

isMemberReadable(Object, String)
isMemberModifiable(Object, String)
isMemberInsertable(Object, String)
isMemberRemovable(Object, String)
isMemberInvocable(Object, String)
isMemberInternal(Object, String)
isMemberWritable(Object, String)
isMemberExisting(Object, String)
hasMemberReadSideEffects(Object, String)
hasMemberWriteSideEffects(Object, String)

Array Namespace:

isArrayElementReadable(Object, long)
isArrayElementModifiable(Object, long)
isArrayElementInsertable(Object, long)
isArrayElementRemovable(Object, long)
isArrayElementWritable(Object, long)
isArrayElementExisting(Object, long)

Note: The array namespace no longer supports querying for read or write side-effects. These messages might be reintroduced but, at the moment, there was no use-case. Also, the array namespace does not allow invocations.

Remove Return Type for TO_NATIVE

The TO_NATIVE message was renamed to toNative in the InteropLibrary with the difference that it no longer returns a value, but performs the native transition as a side-effect if supported by the receiver. This allows the caller of the message to simplify their code. No cases the toNative transition required to return a different value were found. The default behaviour of toNative was changed to not return any value.

Minor Changes

The following messages were mostly unchanged. The NEW message was renamed to instantiate to be consistent with isInstantiable.

Message.IS_NULL         -> InteropLibrary.isNull
Message.EXECUTE         -> InteropLibrary.execute
Message.IS_INSTANTIABLE -> InteropLibrary.isInstantiable
Message.NEW             -> InteropLibrary.instantiate
Message.IS_EXECUTABLE   -> InteropLibrary.isExecutable
Message.EXECUTE         -> InteropLibrary.execute
Message.IS_POINTER      -> InteropLibrary.isPointer
Message.AS_POINTER      -> InteropLibrary.asPointer

Stronger Assertions

Many new assertions were introduced as part of the migration. The concrete pre-post and invariant conditions are described in the Javadoc. Unlike the old interop nodes, cached libraries can only be used when adopted as part of the AST.

No More Unchecked/Checked Exceptions

With Interop 2.0 InteropException.raise was deprecated. While possible, it is considered an anti-pattern to rethrow checked exceptions as unchecked exceptions. With Truffle Libraries the target language nodes are directly inserted into the AST of the caller so there is no longer a limiting CallTarget that does not support checked exceptions. Together with additional support for checked Exceptions from Truffle DSL, it should no longer be necessary to use the raise methods. Instead, a new create factory method was introduced for all interop exception types.

It is planned to remove stack traces from interop exceptions in order to improve their efficiency, as interop exceptions are intended to be always immediately caught and never be rethrown. This was deferred until the compatibility layer can be removed.

Migration

With the use of Truffle Libraries for interop, most existing interop APIs had to be deprecated. The following comparison of Interop 1.0 with Interop 2.0 is designed to help migrate existing uses of interop.

Fast-Path Sending Interop Messages

This is the fast-path way of sending interop messages embedded in an operation node. This is the most common way of sending interop messages.

Interop 1.0:

@ImportStatic({Message.class, ForeignAccess.class})
abstract static class ForeignExecuteNode extends Node {

    abstract Object execute(Object function, Object[] arguments);

    @Specialization(guards = "sendIsExecutable(isExecutableNode, function)")
    Object doDefault(TruffleObject function, Object[] arguments,
                    @Cached("IS_EXECUTABLE.createNode()") Node isExecutableNode,
                    @Cached("EXECUTE.createNode()") Node executeNode) {
        try {
            return ForeignAccess.sendExecute(executeNode, function, arguments);
        } catch (UnsupportedTypeException | ArityException | UnsupportedMessageException e) {
            // ... convert errors to guest language errors ...
        }
    }
}

Interop 2.0:

abstract static class ForeignExecuteNode extends Node {

    abstract Object execute(Object function, Object[] arguments);

    @Specialization(guards = "functions.isExecutable(function)", limit = "2")
    Object doDefault(Object function, Object[] arguments,
                    @CachedLibrary("function") InteropLibrary functions) {
        try {
            return functions.execute(function, arguments);
        } catch (UnsupportedTypeException | ArityException | UnsupportedMessageException e) {
            // ... convert errors to guest language errors ...
        }
    }
}

Note the following differences:

Slow-Path Sending Interop Messages

It is sometimes necessary to call interop messages from the runtime without the context of a node:

Interop 1.0:

ForeignAccess.sendRead(Message.READ.createNode(), object, "property")

Interop 2.0:

InteropLibrary.getFactory().getUncached().read(object, "property");

Note the following differences:

Custom Fast-Path Sending Interop Messages

Sometimes Truffle DSL cannot be used and the nodes need to be written manually. Both APIs allow you to do so:

Interop 1.0:


final class ForeignExecuteNode extends Node {

    @Child private Node isExecutableNode = Message.IS_EXECUTABLE.createNode();
    @Child private Node executeNode = Message.EXECUTE.createNode();

    Object execute(Object function, Object[] arguments) {
        if (function instanceof TruffleObject) {
            TruffleObject tFunction = (TruffleObject) function;
            if (ForeignAccess.sendIsExecutable(isExecutableNode, tFunction)) {
                try {
                    return ForeignAccess.sendExecute(executeNode, tFunction, arguments);
                } catch (UnsupportedTypeException | ArityException | UnsupportedMessageException e) {
                    // TODO handle errors
                }
            }
        }
        // throw user error
    }
}

Interop 2.0:

static final class ForeignExecuteNode extends Node {

    @Child private InteropLibrary functions = InteropLibrary.getFactory().createDispatched(5);

    Object execute(Object function, Object[] arguments) {
        if (functions.isExecutable(function)) {
            try {
                return functions.execute(function, arguments);
            } catch (UnsupportedTypeException | ArityException | UnsupportedMessageException e) {
                // handle errors
                return null;
            }
        }
        // throw user error
    }
}

Note the following differences:

Implementing/Exporting Interop Messages

To implement/export interop library messages, see the following example:

Interop 1.0:

@MessageResolution(receiverType = KeysArray.class)
final class KeysArray implements TruffleObject {

    private final String[] keys;

    KeysArray(String[] keys) {
        this.keys = keys;
    }

    @Resolve(message = "HAS_SIZE")
    abstract static class HasSize extends Node {

        public Object access(KeysArray receiver) {
            return true;
        }
    }

    @Resolve(message = "GET_SIZE")
    abstract static class GetSize extends Node {

        public Object access(KeysArray receiver) {
            return receiver.keys.length;
        }
    }

    @Resolve(message = "READ")
    abstract static class Read extends Node {

        public Object access(KeysArray receiver, int index) {
            try {
                return receiver.keys[index];
            } catch (IndexOutOfBoundsException e) {
                CompilerDirectives.transferToInterpreter();
                throw UnknownIdentifierException.raise(String.valueOf(index));
            }
        }
    }

    @Override
    public ForeignAccess getForeignAccess() {
        return KeysArrayForeign.ACCESS;
    }

    static boolean isInstance(TruffleObject array) {
        return array instanceof KeysArray;
    }
}

Interop 2.0:

@ExportLibrary(InteropLibrary.class)
final class KeysArray implements TruffleObject {

    private final String[] keys;

    KeysArray(String[] keys) {
        this.keys = keys;
    }

    @ExportMessage
    boolean hasArrayElements() {
        return true;
    }

    @ExportMessage
    boolean isArrayElementReadable(long index) {
        return index >= 0 && index < keys.length;
    }

    @ExportMessage
    long getArraySize() {
        return keys.length;
    }

    @ExportMessage
    Object readArrayElement(long index) throws InvalidArrayIndexException {
        if (!isArrayElementReadable(index) {
            throw InvalidArrayIndexException.create(index);
        }
        return keys[(int) index];
    }
}

Note the following differences:

Integration with DynamicObject

The old interop allowed for specifying a foreign access factory through ObjectType.getForeignAccessFactory(). This method is now deprecated and a new method, ObjectType.dispatch(), was introduced. Instead of a foreign access factory, the dispatch method needs to return a class that exports the InteropLibrary with an explicit receiver:

Interop 1.0:

public final class SLObjectType extends ObjectType {

    public static final ObjectType SINGLETON = new SLObjectType();

    private SLObjectType() {
    }

    public static boolean isInstance(TruffleObject obj) {
        return SLContext.isSLObject(obj);
    }

    @Override
    public ForeignAccess getForeignAccessFactory(DynamicObject obj) {
        return SLObjectMessageResolutionForeign.ACCESS;
    }
}

@MessageResolution(receiverType = SLObjectType.class)
public class SLObjectMessageResolution {

    @Resolve(message = "WRITE")
    public abstract static class SLForeignWriteNode extends Node {...}

    @Resolve(message = "READ")
    public abstract static class SLForeignReadNode extends Node {...}
    ...

Interop 2.0:

@ExportLibrary(value = InteropLibrary.class, receiverType = DynamicObject.class)
public final class SLObjectType extends ObjectType {

    public static final ObjectType SINGLETON = new SLObjectType();

    private SLObjectType() {
    }

    @Override
    public Class<?> dispatch() {
        return SLObjectType.class;
    }

    @ExportMessage
    static boolean hasMembers(DynamicObject receiver) {
        return true;
    }

    @ExportMessage
    static boolean removeMember(DynamicObject receiver, String member) throws UnknownIdentifierException {...}
    // other exports omitted
 }

Note the following differences:

Extending Interop

The languages implemented with Truffle rarely need to extend interop, but they might need to extend their own language specific protocol:

Interop 1.0:

Interop 2.0: