Skip to content

Mapping schema to generated types

c80k edited this page Apr 27, 2020 · 14 revisions

Enums

Schema enums are mapped to C# enums.

Example schema:

enum TestEnum {
  foo @0;
  bar @1;
  baz @2;
  qux @3;
  quux @4;
  corge @5;
  grault @6;
  garply @7;
}

Example output:

public enum TestEnum : ushort
{
    foo,
    bar,
    baz,
    qux,
    quux,
    corge,
    grault,
    garply
}

Every generated C# enum defines ushort as its base type because Cap'n Proto enums are always encoded with 16 bits.

Structs

For a struct definition like this:

struct SomeStruct {
    ...
}

the generated code looks like this:

public class SomeStruct : ICapnpSerializable
{
    public const UInt64 typeId = 0xa0a8f314b80b63fdUL;
    void ICapnpSerializable.Deserialize(DeserializerState arg_)
    {
        ...
    }

    public void serialize(WRITER writer)
    {
        ...
    }

    public void applyDefaults()
    {
        ...
    }

    void ICapnpSerializable.Serialize(SerializerState arg_)
    {
        serialize(arg_.Rewrap<WRITER>());
    }

    public struct READER
    {
        readonly DeserializerState ctx;
        public READER(DeserializerState ctx)
        {
            this.ctx = ctx;
        }

        public static READER create(DeserializerState ctx) => new READER(ctx);
        public static implicit operator DeserializerState(READER reader) => reader.ctx;
        public static implicit operator READER(DeserializerState ctx) => new READER(ctx);
        ...
    }

    public class WRITER : SerializerState
    {
        ...
    }

}

Domain class

SomeStruct is the domain class. It is independent of any serialization/deserialization context and provides somewhat better usability compared to the reader/writer classes. It is especially used for capability interfaces. However, using it costs some serialization/deserialization overhead compared to using the reader/writer classes directly. The ICapnpSerializable implementation does exactly that: It is for deserializing from DeserializerState or serializing to SerializerState.

The applyDefaults method sets every members to its schema-defined default value (in case it is specified). This does not happen automatically inside the constructor for performance reasons: Let's say you actually want to initialize the instance from a DeserializerState. Then, first applying default values would be a waste of time.

READER

The nested READER struct is for reading a Cap'n Proto message. As opposed to the domain class, it is just a view of raw message data. Hence, it is a very lightweight wrapper around its underlying DeserializerState. The implicit conversion operators make it even appear equivalent to the DeserializerState.

WRITER

The nested WRITER class is for building a Cap'n Proto message. Again, it is just a view. The WRITER class inherits from SerializerState. You may convert any SerializerState s into any specialization T by calling s.Rewrap<T>() (but ensure that the intended conversion actually makes sense).

Unions

Unions are generated pretty much like structs, but there are some particularities. Let's consider this example:

struct TestUnion {
  union0 :union {
    ...
  }

The generated code looks like this:

public class TestUnion
{
    ...
    public TestUnion.union0 Union0
    {
        get;
        set;
    }
    ...
    public class union0 : ICapnpSerializable
    {
        public const UInt64 typeId = 0xfc76a82eecb7a718UL;
        public enum WHICH : ushort
        {
            ....
            undefined = 65535
        }

        private WHICH _which = WHICH.undefined;
        private object _content;
        public WHICH which
        {
            get => _which;
            set
            {
                ...
            }
        }
        ...
        public struct READER...
        ...
        public class WRITER : SerializerState
        ...
    }
}

Like a struct, a union produces a domain class, a reader, and a writer. The domain class name starts with a lower-case letter to make it different from the property Union0, which gives access to the union. There is a union discriminator enum WHICH and a related property which. This property is also present in the READER and WRITER classes. When you construct a union, the intended usage pattern between domain class and the WRITER class is slightly different:

  • When using the WRITER class, you should set which first, then use the member property. This way around, the WRITER implementation can initialize that property accordingly (might be a pointer).
  • When using the domain class, you should just set the according member property. which is automatically synchronized to the content you just set.

All value-type members of a union are generated as nullable types, simply because they might be undefined. And Void-type members don't need a C# property.

Groups

Again, like a struct, a group produces a domain class, a reader, and a writer. It can be accessed by a property of its embedding type.

Lists

Lists are usually represented by .NET's IReadOnlyList<T>. Hence, a List-typed field produces a C# property of type IReadOnlyList<T> (or a subtype of it). The details vary across domain classes, readers, and writers. Usually means that there are exceptions:

  • Text - from a Cap'n Proto view nothing but a List of bytes - is represented by .NET string.
  • List(Void) is simply represented by an Integer (for element count). Because the elements themselves are Void, there is not much to say about them. Especially, we don't need indexers, getters, or setters.

Lists in domain classes

The generated code for, let's say list0: List(Struct0), looks like this:

public IReadOnlyList<Struct0>? List0
{
    get;
    set;
}

Thus, you may set the property to object which implements IReadOnlyList<Struct0>, maybe a List<Struct0> or an array Struct0[]. You may also keep/set null, in which case the field will be serialized as a null pointer.

Lists in readers

Generated code looks like this:

public IReadOnlyList<Struct0.READER>? List0 => ...;

So you get a list of readers, and the setter is missing (simply because it's a reader class, not intended for writing).

Lists in writers

Generated code looks like this:

public ListOfStructsSerializer<Struct0.WRITER> List0
{
    get => BuildPointer<ListOfStructsSerializer<Struct0.WRITER>>(0);
    set => Link(0, value);
}

Depending on the list's element type, you may also see a ListOfBitsSerializer, ListOfCapsSerializer<T>, ListOfPointersSerializer<T>, ListOfPrimitivesSerializer<T>, or ListOfTextSerializer<T>. Of course, all of them implement the IReadOnlyList<T> interface. But there's more:

  • An Init(int count) method initializes the list with a certain element count. Once the list is initialized, there is no way back. The size cannot be changed afterwards. This is because list storage is allocated directly in the underlying message frame. Allowing the size to be changeable would not only add complexity to the allocator: We'd also risk fragmentation, making the frame bigger than necessary. If you want the list to be encoded as null pointer, just never call Init().
  • Most list serializers expose setter methods for modifying the list's elements. ListOfStructsSerializer<T> doesn't because a Cap'n Proto List of Structs is always allocated in-place. Hence, the structs already exist with allocation. You may modify their members, but not the structs themselves.

It is important to understand that in 99.99% of all cases you will never need the List0 property setter. The writer object automatically initializes a list serializer, and normally, you would use just that. The setter is for linking a serializer which was allocated independently. This enables advanced use cases where you might want to reuse the same list instance twice.

Interfaces

Let this schema definition be our running example:

struct Foo {
  foo @0: UInt8;
  cap @1: MyInterface;
}

interface MyInterface {
  foo @0 (arg: Text) -> (cap: MyInterface);
  bar @1 (arg: Text, cap: MyInterface);
  baz @2 () -> (t1: Text, t2: Text);
  foobar @3 () -> (result: Foo);
}

The generated code looks like this:

[Proxy(typeof(MyInterface_Proxy)), Skeleton(typeof(MyInterface_Skeleton))]
public interface IMyInterface : IDisposable
{
    Task<CapnpGen.IMyInterface> Foo(string arg, CancellationToken cancellationToken_ = default);
    Task Bar(string arg, CapnpGen.IMyInterface cap, CancellationToken cancellationToken_ = default);
    Task<(string, string)> Baz(CancellationToken cancellationToken_ = default);
    Task<CapnpGen.Foo> Foobar(CancellationToken cancellationToken_ = default);
}

public class MyInterface_Proxy : Proxy, IMyInterface
{
    ...
}

public class MyInterface_Skeleton
{
    ...
}

public static class MyInterface
{
    ...
}

public static partial class PipeliningSupportExtensions_test
{
    ...
}

IMyInterface serves a two-fold purpose:

  • When you provide the capability, implement this interface.
  • When you consume the capability, the proxy implements this interface.

IMyInterface implements IDisposable to enable proper capability lifecycle management (deserves an extra page).

All methods are conceptually asynchronous, which becomes obvious from the Tasks being returned. Each method accepts an additional CancellationToken. Any proxy monitors the passed token and sends a FINISH message to the other party upon cancellation. The other party then attempts to cancel the ongoing operation. A capability implementation is free to evaluate the token and react accordingly to it.

MyInterface_Proxy and MyInterface_Skeleton provide the necessary serialization and glue code to make IMyInterface work in RPC context. You will usually not instantiate those classes directly, but instead rely on the runtime code to handle that for you.

The static class MyInterface contains parameter and result structs. Again, you'll usually not bother about that. PipeliningSupportExtensions_test provides extension methods for promise pipeling (which deserves its own page).

Generics

Generics are supported for both type definitions (structs, interfaces) and methods. The generator translates them straightforward into C# generics. But they unfold their beauty only in domain classes. The reader and writer classes represent generically-typed fields by a raw DeserializerState or SerializerState, respectively. The reason is that C# lacks template metaprogramming. Thus, choosing the right generic type arguments would rather be a brain-twist and probably not behave as expected.

Although Cap'n Proto supports generic method parameters (and so does capnproto-dotnetcore), there is no protocol-built-in way for the callee-side to determine the actual type arguments which were passed. Hence, the runtime will always substitute object.

Constants

Constant definitions are currently ignored, so do not produce any code.