Structure of new C++ FIDL Bindings

Author: yifeit@google.com

Date proposed: Dec 3, 2021

This document describes a structural overview of the new C++ bindings: how we can build it from parts of the existing C++ bindings, and how the API looks like down the road for the collection of C++ bindings.

Summary of changes

Rather than a monolithic whole, we view bindings as made up of two layers:

  • The domain objects (the generated FIDL structures e.g. struct, table, ... data). The domain objects are usable standalone.
  • The messaging layer (the necessary code to speak methods over a protocol, receive events, .... behavior).

Gluing those two layers together we have a message dispatcher. A message dispatcher is responsible for reading and writing messages over a transport, and invoking the corresponding logic in the messaging layer. The message dispatcher is not responsible for implementing encoding/decoding; that is reserved for the domain objects and the messaging layer.

Over time, gradually move away from the “LLCPP” name, and call it “wire”, e.g. wire C++ bindings, wire types, wire messaging layer.

The new C++ bindings is an extension on top of the wire bindings, supporting the domain types similar to those from HLCPP, henceforth termed “natural types”, over a messaging layer API that has the same shape as the wire messaging layer, and the same thread-safety properties.

In doing so, we would allow the two set of domain objects (wire/natural) to each further specialize in their intended niche in performance/ergonomics trade-offs, instead of trying to design one set of domain object to satisfy conflicting needs.

Domain objects

The unified bindings support two types of domain objects: wire objects and natural objects:

  • wire objects are optimized for in-place decoding. They are layout compatible with the FIDL wire format. In practice, wire objects are imported from LLCPP generated code.
    • This allows LLCPP to be “rebranded” as the wire subset of C++ bindings.
    • They are generated in the fuchsia_my_lib::wire namespace.
  • natural objects are high level domain objects that optimize for ergonomics. We'll generate slightly different domain object types in the unified bindings compared to those found in the HLCPP bindings, since we would like to make quite a few breaking API changes.
    • We could start prototyping the messaging layer with the HLCPP domain objects, then move to natural objects as they're implemented.
    • They are generated in the fuchsia_my_lib namespace (vs HLCPP domain objects which are generated in the fuchsia::my::lib namespace). This reflects our intention to users that the natural types are the default, since they are easier to use and reasonably performant. One should only turn to the wire objects when optimizing logic in the critical path, or when needing to precisely control memory allocation.

Wire domain objects should not depend on HLCPP nor natural domain objects

The wire domain objects are a popular choice among drivers, some of which place tight controls over memory allocations. By avoiding depending on HLCPP or natural domain objects, we offer an option to driver authors to rule out FIDL objects which implicitly allocate at build time. Those users could continue to depend on the wire subset of the C++ bindings.

Sharing types between wire and natural domain objects

For numerical types and types with equivalent layouts such as enums and bits, we should use the same C++ type for wire and natural objects, since the space for performance/ergonomics trade-offs there are negligible, and any differences are more accidental than purposeful.

Optional conversions to/from HLCPP

To ease migration, we may consider supporting constructing a natural object from the corresponding HLCPP object, and conversions to the HLCPP objects. We should not plan to do this until we have a compelling use case.

We propose externalizing all the conversion logic to a third set of libraries, for example generated under #include <fidl/my.library/hlcpp_migration.h>, that is not imported by either wire or unified libraries. This makes conversions explicit hence marginally harder to use (e.g. fidl::ToHLCPP<NaturalType>(natural_types) from library fuchsia_my_lib_hlcpp_migration) but easier to implement. The purpose of these conversion libraries is only to assist in piecewise migration. Over the long run, users should fully migrate their applications to natural types, thus dropping any HLCPP dependencies.

We should not generate user-defined conversion operators directly in the definition of natural objects. Doing so implies that the natural objects code would depend on the HLCPP domain objects code. However as discussed above, we'd like to avoid introducing a dependency edge from wire objects to HLCPP, so we would not be able to add those conversion operators to bits and enums. We could imagine using fidl::ToHLCPP or the like for bits and enums, and conversion constructors/operators for the rest, but that can be more confusing than always using fidl::ToHLCPP.

Dependency structure

From the perspective of build dependencies, this results in the following conceptual dependency graph:

Figure: natural domain objects depend on both wire bindings and HLCPP domainobjects.

Naming-wise, we'll adopt the following:

  • wire domain objects/wire types: the subset of C++ domain objects that are layout compatible with the FIDL wire format.
  • natural domain objects/natural types: the subset of C++ domain objects that focus on ergonomics and have recursive ownership semantics.
  • wire bindings: the subset of C++ FIDL bindings API that work with wire domain objects.
  • new C++ bindings: the full set of C++ FIDL bindings API that support both wire domain objects and natural domain objects.
  • HLCPP domain objects: the existing domain objects generated from fidlgen_hlcpp.
  • HLCPP bindings: the existing HLCPP bindings that use HLCPP domain objects.

New features in the natural domain objects

Non-resource types are copy & move constructible

In C++ it is idiomatic to use the copy constructor for deep copying, and provide a move constructor for users to optimize away the copy.

Resource types are move constructible

Resource types would have a guaranteed deleted copy constructor to prevent API breakages when it later acquires handles. Together with copy constructors above, we can remove fidl::Clone from natural domain objects - the remaining use case would be duplicating the handles within resource types, a fallible and rare operation.

Struct API sketch

Do not expose raw fields directly; prefer setters instead (except when supporting designated initializers). This has several benefits:

  • We can swap out the internal representation to be more efficient without breaking source compatibility.
  • We could add validation as fields are being set. For example, instead of triggering a late constraint validation failure on sending, we could panic from the setter in debug mode to catch some of these illegal states earlier.
Foo foo;
foo
    .set_foo(42)
    .set_bar(...);

// To support designated initializers, we could generate an auxiliary struct
// that is an aggregate, and which can be used to initialize the `Foo` struct.
// See full idea at https://godbolt.org/z/f53j3KfPG.
Foo foo{{
    // The inner brace constructs a |Foo::Storage_| which the users are
    // discouraged from spelling out directly.
    .foo = ...,
    .bar = ...
}};

Messaging layer

The messaging layer builds on top of the domain objects and adds APIs to send and receive FIDL messages over protocol endpoints.

Given the representative protocol below:

library fuchsia.example;

type GreetError = enum {
  NOT_UNDERSTOOD = 1;
};

protocol Speak {
    Greet(struct { msg string; }) -> (struct { s int32; foo string; });
    GreetTwo(struct { msg1 string; msg2 string; }) -> (struct { s int32; foo string; });
    Ask() -> (struct { answers vector<string>; });
    OneWay(struct { a int32; });
    EmptyAck() -> ();

    TryGreet(struct { msg string; }) -> (struct { reply string; }) error GreetError;
    TryEmptyAck() -> () error int32;

    -> OnWordSpoken(struct { word string; });
};

We propose the following generated New C++ bindings API.

Client

Below are the possible APIs on an asynchronous client. In all cases, the user may create a client like so:

#include <fidl/fuchsia.example/cpp/fidl.h>

fidl::Client<fuchsia_example::Speak> client(std::move(client_end), dispatcher);

Result callbacks vs response callbacks

In asynchronous two-way calls, the user passes a continuation in the form of a callback. There are some choices for surfacing errors:

  • Response callbacks are only invoked when the client receives a response from the server. They are suitable when there is no need to propagate framework error information on a per-call basis. The async client callbacks in HLCPP are response callbacks.

  • Result callbacks are invoked with a result object containing either a success or an error. They are suitable when the user needs to propagate errors for each FIDL call to their originators. For example, a server may need to make another FIDL call as part of fulfilling an existing FIDL call, and need to fail the original call in case of errors.

Result callbacks are the only option in the unified bindings, since they convey strictly more information than response callbacks, and meshes better with newer FIDL features such as unknown interactions. The user is free to return early if they are not interested in framework errors.

Natural API

Overall guiding principles:

  • Use fit::result in return values. fit::result is widely used in newer Fuchsia C++ code, and we should integrate with where the overall ecosystem is heading.
  • Do not flatten method requests/responses into multiple arguments. For example, the generated method for GreetTwo should not take multiple input arguments for msg1 and msg2. Rather, it should take a single struct (SpeakGreetTwoRequest) representing the request body. This makes things consistent when we support table and unions as top level request/responses.

Natural API, asynchronous

We define a fidl::Result<MethodMarker> templated class:

fidl::Result<FooMethod> represents the result of calling method FooMethod:

template <typename Method>
class fidl::Result { ... };

The precise definition that it expands to depends on the shape of the method:

  • When the method does not use the error syntax:
    • When the method response has no body: fidl::Result<FooMethod> inherits fit::result<fidl::Error>.
    • When the method response has a body: fidl::Result<FooMethod> inherits fit::result<fidl::Error, FooMethodPayload>, where fidl::Error is a type representing any framework error or protocol level terminal errors such as epitaphs.
  • When the method uses the error syntax:
    • When the method response payload is an empty struct: fidl::Result<FooMethod> inherits fit::result<fidl::ErrorsIn<FooMethod>> (see ErrorsIn below).
    • When the method response payload is not an empty struct: fidl::Result<FooMethod> inherits fit::result<fidl::ErrorsIn<FooMethod>, FooMethodPayload>.

ErrorsIn is used to implement error folding of framework and domain errors, such that one may query is_ok() once on the result object to determine whether the call succeeded at all layers of abstractions:

// |ErrorsIn<Method>| represents the set of all possible errors during
// |Method|:
// - Framework errors
// - Domain errors in the |Method| error syntax
class fidl::ErrorsIn<fuchsia_example::Speak::TryGreet> {
 public:
  bool is_framework_error();
  fidl::Error framework_error();

  bool is_domain_error();
  fuchsia_example::GreetError domain_error();

  // Prints a description of the error.
  std::string FormatDescription();
};

A result callback has the following signature:

[] (fidl::Result<Method>& result) { ... }

Now we present some examples using the types and FIDL definition above:

//
// Examples without error syntax
//

// Natural API, async, response has a body.
client->Greet({std::string("hi")}).Then(
    [] (fidl::Result<fuchsia_example::Speak::Greet>& result) {
      // fidl::Result<fuchsia_example::Speak::Greet> =
      //     fit::result<
      //        fidl::Error,
      //        fuchsia_example::Speak::GreetPayload>;

      assert(result.is_ok());
      // Access the payload.
      result->foo();
      result->bar();
    });

// Natural API, async, response has no body.
client->EmptyAck().Then(
    [] (fidl::Result<fuchsia_example::Speak::Greet>& result) {
      // fidl::Result<fuchsia_example::Speak::Greet> =
      //     fit::result<fidl::Error>;

      assert(result.is_ok());
      // No payload to access...
    });

//
// Error syntax and error folding example
//

// Natural API, async, response has a body that's not empty struct
client->TryGreet({std::string("hi")}).Then(
    [] (fidl::Result<fuchsia_example::Speak::TryGreet>& result) {
      // fidl::Result<fuchsia_example::Speak::TryGreet> =
      //     fit::result<
      //         fidl::ErrorsIn<fuchsia_example::Speak::TryGreet>,
      //         fuchsia_example::SpeakTryGreetPayload>;

      // Check both framework and domain error.
      if (!result.is_ok()) {
        FX_LOGS(ERROR) << "TryGreet failed: " << result.error_value();
        // Digging deeper, if desired.
        if (result.error_value().is_domain_error()) {
          FX_LOGS(ERROR) << "TryGreet failed with domain error: "
                         << result.error_value().domain_error();
        }
        return;
      }
      // Access the payload...
      result->reply();
    });

// Natural API, async, response has a body that's empty struct
client->TryEmptyAck().Then(
    [] (fidl::Result<fuchsia_example::Speak::TryGreet>& result) {
      // fidl::Result<fuchsia_example::Speak::TryGreet> =
      //     fit::result<fidl::ErrorsIn<fuchsia_example::Speak::TryEmptyAck>>;

      // Check both framework and domain error.
      if (!result.is_ok()) {
        FX_LOGS(ERROR) << "TryEmptyAck failed: " << result.error_value();
        return;
      }
      // No payload to access...
    });

Natural API, synchronous

For reference, there are many more uses of async clients compared to sync clients in HLCPP. Support for synchronous clients in the natural messaging API will be hinging on strong user needs. Nonetheless, we could imagine how a synchronous client could work, to not design ourselves into a corner should the need for synchronous clients arise. It can use the same fidl::Result<Method> treatment and error folding approach. The main difference is the delivery of the outcome (return value in case of sync vs callback in case of async).

// Construct a sync client.
// |fidl::SyncClient| is the natural counterpart to |fidl::WireSyncClient|.
fidl::SyncClient client(std::move(client_end));

// Natural API, sync.
fidl::Result<fuchsia_example::Speak::Greet> result =
    client->Greet({std::string("hi")});
// Alternatively, one may elide the template argument if using |fit::result|.
// Note that |fidl::Result| is just an alias.
fit::result result = client->Greet({std::string("hi")});

// Check error
bool ok = result.is_ok();
fidl::Error error = result.error_value();

// Use fields
int32_t s = result->s();
std::string foo = result->foo();

// Example for responses with absent bodies:
fit::result<fidl::Error> result = client->EmptyAck({std::string("hi")});

// Example for one way calls:
fit::result<fidl::Error> result = client->OneWay({42});

// Example for error syntax:
fit::result<fidl::ErrorsIn<TryEmptyAck>> result = client->TryEmptyAck();

Note that two-way methods with absent response bodies have the same return value as one-way methods.

Using wire domain objects for individual calls

In order to make method calls with wire domain types, the user would write .wire() in front of the method calls. .wire() returns a pointer to the wire client implementation used to make calls.

Wire API, synchronous

fidl::SyncClient client(std::move(client_end));

// Wire API, sync
fidl::WireResult<fuchsia_example::Greet> result =
    client.wire()->Greet(fidl::StringView("hi"));

// Check error
bool ok = result.ok();
fidl::Error error = result.error();

// Use fields
int32_t s = result->s;
fidl::StringView foo = result->foo;

Wire API, asynchronous

fidl::Client client(std::move(client_end), dispatcher);

// Wire API, async
client.wire()->Greet(fidl::StringView("hi")).Then(
    [] (fidl::WireUnownedResult<fuchsia_examples::Speak::Greet>& result) {
      // Check error
      bool ok = result.ok();
      fidl::Error error = result.error();

      // Use fields
      int32_t s = result->s;
      fidl::StringView foo = result->foo;
    });

Note that the wire API subset should be the identical as the API found in the LLCPP bindings, with the only difference being the additional .wire() to go from a client interface speaking natural types to a client interface speaking wire types.

Input argument optimizations

For a function that send a domain object of type T:

  • Take T if the domain object is a resource type, to always consume the object.
  • Take const T& otherwise, to skip a copy.

Out-of-scope features

No wire sync call flavors in C++14

C++14 does not support guaranteed return value optimization (RVO), which is necessary in the design of wire synchronous calls to avoid any copies. We can consider alternative approaches to supporting wire sync calls if a strong need arises.

The practical implication is that functions like client.wire().sync()->Greet(...) won't exist when compiling under C++14.

No caller-allocating flavors with natural objects

The use cases that call for controlling allocation don't intersect with those served by ergonomic but less performant natural domain objects.

Event handling

We define fidl::Event<FooMethod> to represent an event:

template <typename Method>
class fidl::Event<Method> { ... };
  • When the event does not use the error syntax:
    • When the event has a body, fidl::Event<FooMethod> inherits FooMethodPayload, i.e. the domain object type representing the event body. See https://fxbug.dev/42171535.
    • When the event has no body, fidl::Event<FooMethod> is empty.
  • When the event uses the error syntax:
    • When the success payload is not an empty struct, fidl::Event<FooMethod> inherits fit::result<DomainError, FooMethodPayload>, where DomainError is the corresponding domain error type for that event.
    • When the success payload is an empty struct, fidl::Event<FooMethod> inherits fit::result<DomainError>.

Note: an alternative to the second bullet is to simply omit the corresponding fidl::Event<FooMethod> in case of events with absent bodies. Here we choose to always leave in the argument since it matches the existing convention in LLCPP, and could make for less special cases in typing out a list of event handlers.

We'll generate interface classes like the following:

class fidl::AsyncEventHandler<fuchsia_example::Speak> {
  virtual void OnWordSpoken(
    fidl::Event<fuchsia_example::Speak::OnWordSpoken>&) {}
};

If needed, the cascading inheritance pattern could be used in event handlers to support choosing the domain object family on a per-event basis (wire or natural):

class fidl::AsyncEventHandler<fuchsia_example::Speak> : public
    fidl:WireAsyncEventHandler<fuchsia_example::Speak> {
 public:
  // Users may override this method to receive natural types...
  virtual void OnWordSpoken(
    fidl::Event<fuchsia_example::Speak::OnWordSpoken>& event) {}

  // Or override this method to receive wire types...
  virtual void OnWordSpoken(
    fidl::WireEvent<fuchsia_example::Speak::OnWordSpoken>* event) override {
    OnWordSpoken(fidl::Event<fuchsia_example::Speak::OnWordSpoken>{
      /* Logic to convert wire types to natural types, potentially */
      /* using a decoder. */
    });
  }
};

Server

We define fidl::Request<FooMethod> to represent the request of method FooMethod:

template <typename Method>
class fidl::Request<Method> { ... };
  • When the method request has a body, fidl::Request<FooMethod> inherits FooMethodRequest, i.e. the domain object type representing the request body.
  • When the method has a request but the request has no body, fidl::Request<FooMethod> is empty.

We'll generate interface classes like the following:

class fidl::Server<fuchsia_example::Speak> {
  using GreetRequest = fidl::Request<fuchsia_example::Speak::Greet>;

  // fuchsia_example::Speak::GreetCompleter speaks wire and natural types
  //
  // Exposed methods:
  // completer.Reply(fuchsia_example::SpeakGreetResponse);
  // completer.wire().Reply(int32_t s, fidl::StringView foo);
  // completer.wire().buffer(MemoryResource resource).Reply(int32_t s, fidl::StringView foo);
  virtual void Greet(GreetRequest& request, GreetCompleter::Sync& completer) = 0;
};

Sending natural domain objects as replies and events can be supported in a way that's similar to the client API, by adding methods that take natural types and hiding wire APIs behind a .wire() accessor.

Example server:

class MyServer : public fidl::Server<fuchsia_example::Speak> {
 public:
  void Greet(GreetRequest& request, GreetCompleter::Sync& completer) {
    // Access request.
    std::string msg = request.msg();
    // Send response.
    completer.Reply({ZX_OK, msg});
  }
};

Alternatives, unknowns, future work

Unknown interaction handling considerations

RFC-0138: Handling unknown interactions extends the result union used in method responses with a third variant:

type result = union {
    1: response struct { pong Pong; };
    2: err uint32;
    3: framework_err zx.Status;  // used to communication unknown method errors.
};

When exposing this feature in the unified bindings, we should aim for the following to make it safe and ergonomic:

  • Server authors should not be able to manually respond with the framework_err variant from inside a method handler. By definition, the method is known to the server if the code reaches a particular method handler.
  • Client users should still be able to use fit::result types, which has two variants, even when the result union on the wire has three variants.

To achieve these, our idea is to decouple the physical shape of the result union from the user API. On the server side, the framework_err is completely hidden from the user - the binding runtime knows when to response with that error. On the client side, the framework_err combined into other kinds of framework errors.

This means for example the server completer of a flexible two-way method using error syntax will take fit::result<DomainError, FooPayload>, and that of a flexible two-way method not using the error syntax will simply take FooPayload.

On the client side, users can ask the framework error object if the method was unknown:

class fidl::ErrorsIn<ErrorSyntaxFlexible> {
  bool is_framework_error();
  fidl::Error framework_error();
  // Potential API:
  // framework_error().is_unknown_interaction();

  bool is_domain_error();
  fuchsia_example::GreetError domain_error();

  std::string FormatDescription();
};

client->ErrorSyntaxFlexible().Then(
    [] (fidl::Result<ErrorSyntaxFlexible>& result) {
      if (result.is_error()) {
        if (result.error_value().is_unknown_interaction()) {
          // The server doesn't recognize this flexible method.
        }
      }
    });

Domain object compile time validations

Potentially worth exploring, but not part of our MVP, is the accepted RFC-0051: Safer structs for C++ which was never implemented in HLCPP.

Alternative client API ideas

Combine both wire and natural APIs onto the same client object

Earlier iterations of the design called for fidl::Client to support both wire and natural flavors on the same object, via function overloading. A corner case is that FIDL method calls with zero request arguments or with numerical request arguments may result in identical function parameters in one-way and synchronous functions. Because C++ doesn't support overload selection via return type, there is no way for the user to explicitly indicate whether the natural flavor or the wire flavor should be used.

// This is ambiguous. Should |result| contain a wire response or a natural response?
auto result = client->Ask_Sync();
// Same here.
auto result = client->OneWay(42);

There are a couple of solutions to this. We could use marker types to disambiguate between those (see source #1):

auto result_of_std_string = c->Ask(Sync, WithStdTypes);
auto result_of_char_start = c->Ask(Sync, WithWireTypes);

This approach has the unfortunate downside of adding even more runtime delegation and generated code: the WithWireTypes overload needs to forward its arguments and return values to the wire client implementation.

We could use some base class trickery to put the wire flavors under a LowLevel namespace (see source #2):

auto result_of_natural = c.Foo();
auto result_of_wire b = c.LowLevel::Foo();

This is feasible but requires adding LowLevel to the list of dangerous identifiers for name collision handling. Note that we probably wouldn't want to use c.Wire::Foo() since Wire is way more likely to collide with a user defined FIDL method called Wire (think wiring funds or wiring memory for example).

In contrast, as found in the earlier proposal, we could expose an accessor .wire() to return a pointer to the object for making wire requests (see source #3):

auto result_of_natural = c.Foo();
auto result_of_wire b = c.wire()->Foo();

Users who only wish to use the wire subset may create a fidl::WireClient<MyProtocol> instead. Calling wire flavors on a unified client is thus only reserved for cases where the majority of the calls benefit from the ergonomics of natural objects, but some particular methods would like to use the wire flavor for performance. Therefore, it looks like the last option is the easiest path to disambiguate, while only slightly burdening the wire flavor invocation (add .wire()).

Receiving mixed wire/natural objects in a server

A challenge here is receiving both types of requests. Users could implement the wire interface, then convert the arguments to natural types in a subset of methods in that interface via an adaptor that we would provide.