Concept Subspace :: sus :: boxed :: DynConcept

template <class DynC, class ConcreteT>
concept DynConcept
requires
requires {
  // The types are not qualified or references.
  requires std::same_as<DynC, std::remove_cvref_t<DynC>>;
  requires std::same_as<ConcreteT, std::remove_cvref_t<ConcreteT>>;
  // The SatisfiesConcept bool tests against the concept.
  { DynC::template SatisfiesConcept<ConcreteT> } -> std::same_as<const bool&>;
  // The `DynTyped` type alias names the typed subclass. The `DynTyped` class
  // has two template parameters, the concrete type and the storage type (value
  // or reference).
  typename DynC::template DynTyped<ConcreteT, ConcreteT>;
  // The type-erased `DynC` must also satisfy the concept, so it can be used
  // in templated code still as well.
  requires DynC::template SatisfiesConcept<DynC>;
  // The typed class is a subclass of the type-erased `DynC` base class, and is
  // final.
  requires std::is_base_of_v<
      DynC, typename DynC::template DynTyped<ConcreteT, ConcreteT>>;
  requires std::is_final_v<
      typename DynC::template DynTyped<ConcreteT, ConcreteT>>;
  // The type-erased `DynC` can not be moved (which would slice the typed
  // subclass off).
  requires !std::is_move_constructible_v<DynC>;
  requires !std::is_move_assignable_v<DynC>;
}

A concept for generalized type erasure of concepts, allowing use of a concept-satisfying type T without knowing the concrete type T.

By providing a virtual type-erasure class that satisifies DynConcept along with a concept C, it allows the use of generic concept-satisfying objects without the use of templates. This means a function accepting a concept-satisying object as a parameter can:

  • Be written outside of the header.
  • Be a virtual method.
  • Be part of a non-header-only library API.

We can not specify a concept as a type parameter so there is a level of indirection involved and the concept itself is not named.

To type-erase a concept C, there must be a virtual class DynC declared that satisfies this DynConcept concept for all concrete types ConcreteT which satisfy the concept C being type-erased.

Performing the type erasure

To type-erase a concept-satisfying object (a "concept object") into the heap, use Box. For example Box<DynC> would hold a type-erased heap-allocated object that is known to satisfy the concept C. A Box should always be used when storing the function object beyond the current stack frame, such as in a class data member. It can also be done for ease of working with type-erased concepts.

// This function receives and uses a type-erased concept object.
void use_fn(sus::Box<sus::fn::DynFn<void(i32)>> b) { b(2); }

A Box holding a type-erased concept can be constructed with the from constructor method. It receives a concept object as an input, and moves it to the heap. Since this satisfies the From concept, the Box<DynC> can also be constructed with type deduction through sus::into().

auto f = [](i32 i) { fmt::println("{}", i); };
// Converts the lambda, which satisfies the `Fn<void(i32)>` concept
// into a `Box<DynFn<void(i32)>>` for the function argument.
use_fn(sus::into(f));

In performance-sensitive code, it can be necessary to avoid heap allocations while working with type-erased concept objects, or to work with a concept object without taking ownership of it. It is possible to receive a type-erased concept object by reference instead of through a Box.

// This function receives and uses a type-erased concept object.
void use_fn_ref(const sus::fn::DynFn<void(i32)>& b) { b(2); }

To get a type-erased reference from a concept object, pass it to sus::dyn(). The sus::dyn() function constructs a type-erasure on the stack and automatically converts to a reference to it.

auto f = [](i32 i) { fmt::println("{}", i); };
// Erases the type of the lambda, constructing a type-erased reference to a
// `DynFn` to pass as the function argument.
use_fn_ref(sus::dyn<sus::fn::DynFn<void(i32)>>(f));

Type erasure of concepts in the Subspace library

Some concepts in the Subspace library come with a virtual type-erasure class that satisfies DynConcept and can be type-erased into Box<DynC> for the concept C:

For some concepts in the Subspace library, Box<DynC> will also satisfy the concept C itself, without having use the inner type. See Box implements some concepts for its inner type.

Since the actual type is erased, it can not be moved or copied. While it can be constructed on the stack or the heap, any access to it other than its initial declaration must be through a pointer or reference, similar to Pin<T> types in Rust.

Examples

Implementing concept type-erasure

Providing the mechanism to type erase objects that satisfy a concept named MyConcept through a DynMyConcept class:

// A concept which requires a single const-access method named `concept_fn`.
template <class T>
concept MyConcept = requires(const T& t) {
  { t.concept_fn() } -> std::same_as<void>;
};

template <class T, class Store>
class DynMyConceptTyped;

class DynMyConcept {
  sus_dyn_concept(MyConcept, DynMyConcept, DynMyConceptTyped);

 public:
  // Pure virtual concept API.
  virtual void concept_fn() const = 0;
};
// Verifies that DynMyConcept also satisfies MyConcept, which is required.
static_assert(MyConcept<DynMyConcept>);

template <class T, class Store>
class DynMyConceptTyped final : public DynMyConcept {
  sus_dyn_concept_typed(MyConcept, DynMyConcept, DynMyConceptTyped, v);

  // Virtual concept API implementation.
  void concept_fn() const override { return v.concept_fn(); };
};

// A type which satiesfies `MyConcept`.
struct MyConceptType {
  void concept_fn() const {}
};

int main() {
  // Verifies that DynMyConcept is functioning correctly, testing it against
  // a type that satisfies MyConcept.
  static_assert(sus::boxed::DynConcept<DynMyConcept, MyConceptType>);

  auto b = [](Box<DynMyConcept> c) { c->concept_fn(); };
  // `Box<DynMyConcept>` constructs from `MyConceptType`.
  b(sus::into(MyConceptType()));

  auto d = [](const DynMyConcept& c) { c.concept_fn(); };
  // `MyConceptType` converts to `const MyConcept&` with `sus::dyn()`.
  d(sus::dyn<const DynMyConcept>(MyConceptType()));
}

An identical example to above, with a DynMyConcept class providing type erasure for the MyConcept concept, however without the use of the helper macros, showing all the required machinery:

// A concept which requires a single const-access method named `concept_fn`.
template <class T>
concept MyConcept = requires(const T& t) {
  { t.concept_fn() } -> std::same_as<void>;
};

template <class T, class Store>
class DynMyConceptTyped;

class DynMyConcept {
 public:
  // Pure virtual concept API.
  virtual void concept_fn() const = 0;
  template <class T>

  static constexpr bool SatisfiesConcept = MyConcept<T>;
  template <class T, class Store>
  using DynTyped = DynMyConceptTyped<T, Store>;

  DynMyConcept() = default;
  virtual ~DynMyConcept() = default;
  DynMyConcept(DynC&&) = delete;
  DynMyConcept& operator=(DynMyConcept&&) = delete;
};
// Verifies that DynMyConcept also satisfies MyConcept, which is required.
static_assert(MyConcept<DynMyConcept>);

template <class T, class Store>
class DynMyConceptTyped final : public DynMyConcept {
 public:
  // Virtual concept API implementation.
  void concept_fn() const override { return c_.concept_fn(); };

  constexpr DynMyConceptTyped(Store&& c) : c_(::sus::forward<Store>(c)) {}

 private:
  Store c_;
};

// A type which satiesfies `MyConcept`.
struct MyConceptType {
  void concept_fn() const {}
};

int main() {
  // Verifies that DynMyConcept is functioning correctly, testing it against
  // a type that satisfies MyConcept.
  static_assert(sus::boxed::DynConcept<DynMyConcept, MyConceptType>);

  auto b = [](Box<DynMyConcept> c) { c->concept_fn(); };
  // `Box<DynMyConcept>` constructs from `MyConceptType`.
  b(sus::into(MyConceptType()));

  auto d = [](const DynMyConcept& c) { c.concept_fn(); };
  // `MyConceptType` converts to `const MyConcept&` with `sus::dyn()`.
  d(sus::dyn<const DynMyConcept>(MyConceptType()));
}

Holding dyn() in a stack variable

When a function receives a type-erased DynC& by reference, it allows the caller to avoid heap allocations should they wish. In the easy case, the caller will simply call sus::dyn() directly in the function arguments to construct the DynC& reference, which ensures it outlives the function call.

In a more complicated scenario, the caller may wish to conditionally decide to pass an Option<DynC&> with or without a reference, or to choose between different references. It is not possible to return the result of sus::dyn() without creating a dangling stack reference, which will be caught by clang in most cases. This means in particular that lambdas such as those passed to functions like Option::map can not be used to construct the DynC& reference.

In order to ensure the target of the DynC& reference outlives the function it can be constructed as a stack variable before calling the function.

std::srand(sus::cast<unsigned>(std::time(nullptr)));

auto x = [](sus::Option<sus::fn::DynFn<std::string()>&> fn) {
  if (fn.is_some())
    return sus::move(fn).unwrap()();
  else
    return std::string("tails");
};

auto heads = [] { return std::string("heads"); };
// Type-erased `Fn<std::string()>` that represents `heads`. Placed on the
// stack to outlive its use in the `Option` and the call to `x(cb)`.
auto dyn_heads = sus::dyn<sus::fn::DynFn<std::string()>>(heads);
// Conditionally holds a type-erased reference to `heads`. This requires a
// type-erasure that outlives the `cb` variable.
auto cb = [&]() -> sus::Option<sus::fn::DynFn<std::string()>&> {
  if (std::rand() % 2) return sus::some(dyn_heads);
  return sus::none();
}();

std::string s = x(cb);

fmt::println("{}", s);  // Prints one of "heads" or "tails.

It can greatly simplify correctness of code to use owned type-erased concept objects through Box, such as Box<DynFn<std::string()>> in the above example. Though references can be useful, especially in simple or perf-critical code paths.