Quantcast
Channel: Experimental - C++ Team Blog
Viewing all articles
Browse latest Browse all 130

proxy: Runtime Polymorphism Made Easier Than Ever

$
0
0

proxy is an open-source, cross-platform, single-header C++ library, making runtime polymorphism easier to implement and faster, empowered by our breakthrough innovation of Object-oriented Programming (OOP) theory in recent years. Consider three questions:

  1. Do you want to facilitate architecture design and matainance by writing non-intrusive polymorphic code in C++ as easily as in Rust or Golang?
  2. Do you want to facilitate lifetime management of polymorphic objects as easily as in languages with runtime Garbage Collection (GC, like Java or C#), without compromising performance?
  3. Have you tried other polymorphic programming libraries in C++ but found them deficient?

If so, this library is for you. You can find the implementation at our GitHub repo, integrate with your project using vcpkg (search for proxy), or learn more about the theory and technical specifications from P0957.

Overview

In C++ today, there are certain architecture and performance limitations in existing mechanisms of polymorphism, specifically, virtual functions (based on inheritance) and various polymorphic wrappers (with value semantics) in the standard. As a result, proxy can largely replace the existing “virtual mechanism” to implement your vision in runtime polymorphism, while having no intrusion on existing code, with even better performance.

All the facilities of the library are defined in namespace pro. The 3 major class templates are dispatch, facade and proxy. Here is a demo showing how to use this library to implement runtime polymorphism in a different way from the traditional inheritance-based approach:

// Abstraction
struct Draw : pro::dispatch<void(std::ostream&)> {
  template <class T>
  void operator()(const T& self, std::ostream& out) { self.Draw(out); }
};
struct Area : pro::dispatch<double()> {
  template <class T>
  double operator()(const T& self) { return self.Area(); }
};
struct DrawableFacade : pro::facade<Draw, Area> {};

// Implementation (No base class)
class Rectangle {
 public:
  void Draw(std::ostream& out) const
      { out << "{Rectangle: width = " << width_ << ", height = " << height_ << "}"; }
  void SetWidth(double width) { width_ = width; }
  void SetHeight(double height) { height_ = height; }
  double Area() const { return width_ * height_; }

 private:
  double width_;
  double height_;
};

// Client - Consumer
std::string PrintDrawableToString(pro::proxy<DrawableFacade> p) {
  std::stringstream result;
  result << "shape = ";
  p.invoke<Draw>(result);  // Polymorphic call
  result << ", area = " << p.invoke<Area>();  // Polymorphic call
  return std::move(result).str();
}

// Client - Producer
pro::proxy<DrawableFacade> CreateRectangleAsDrawable(int width, int height) {
  Rectangle rect;
  rect.SetWidth(width);
  rect.SetHeight(height);
  return pro::make_proxy<DrawableFacade>(rect);  // No heap allocation is expected
}

Configure your project

To get started, set the language level of your compiler to at least C++20 and get the header file (proxy.h). You can also install the library via vcpkg, which is a C++ library management software invented by Microsoft, by searching for “proxy”.

To integrate with CMake, 3 steps are required:

  1. Set up the vcpkg manifest by adding “proxy” as a dependency in your vcpkg.json file:
    {
    "name": "<project_name>",
    "version": "0.1.0",
    "dependencies": [
    {
      "name": "proxy"
    }
    ]
    }
  2. Use find_package and target_link_libraries commands to reference to the library proxy in your CMakeLists.txt file:
    find_package(proxy CONFIG REQUIRED)
    target_link_libraries(<target_name> PRIVATE msft_proxy)
  3. Run CMake with vcpkg toolchain file:
    cmake <source_dir> -B <build_dir> -DCMAKE_TOOLCHAIN_FILE=<vcpkg_dir>/scripts/buildsystems/vcpkg.cmake

What makes the “proxy” so charming

As a polymorphic programming library, proxy has various highlights, including:

  1. being non-intrusive
  2. allowing lifetime management per object, complementary with smart pointers
  3. high-quality code generation
  4. supporting flexible composition of abstractions
  5. optimized syntax for Customization Point Objects (CPO) and modules
  6. supporting general-purpose static reflection
  7. supporting expert performance tuning
  8. high-quality diagnostics.

In this section, we will briefly introduce each of the highlights listed above with concrete examples.

Highlight 1: Being non-intrusive

Designing polymorphic types with inheritance usually requires careful architecting. If the design is not thought through enough early on, the components may become overly complex as more and more functionality is added, or extensibility may be insufficient if polymorphic types are coupled too closely. On the other hand, some libraries (including the standard library) may not have proper polymorphic semantics even if they, by definition, satisfy same specific constraints. In such scenarios, users have no alternative but to design and maintain extra middleware themselves to add polymorphism support to existing implementations.

For example, some programming languages provide base types for containers, which makes it easy for library authors to design APIs without binding to a specific data structure at runtime. However, this is not feasible in C++ because most of the standard containers are not required to have a common base type. I do not think this is a design defect of C++, on the contrary, I think it is reasonable not to overdesign for runtime abstraction before knowing the concrete requirements both for the simplicity of the semantics and for runtime performance. With proxy, because it is non-intrusive, if we want to abstract a mapping data structure from indices to strings for localization, we may define the following facade:

struct at : pro::dispatch<std::string(int)> {
  template <class T>
  auto operator()(T& self, int key) { return self.at(key); }
};
struct ResourceDictionaryFacade : pro::facade<at> {};

It could proxy any potential mapping data structure, including but not limited to std::map<int, std::string>, std::unordered_map<int, std::string>, std::vector<std::string>, etc.

// Library
void DoSomethingWithResourceDictionary(pro::proxy<ResourceDictionaryFacade> p) {
  try {
    std::cout << p.invoke(1) << std::endl;
  } catch (const std::out_of_range& e) {
    std::cout << "No such element: " << e.what() << std::endl;
  }
}

// Client
std::map<int, std::string> var1{{1, "Hello"}};
std::vector<std::string> var2{"I", "love", "Proxy", "!"};
DoSomethingWithResourceDictionary(&var1);  // Prints "Hello"
DoSomethingWithResourceDictionary(&var2);  // Prints "love"
DoSomethingWithResourceDictionary(std::make_shared<std::unordered_map<int, std::string>>());  // Prints "No such element: {implementation-defined error message}"

Overall, inheritance-based polymorphism has certain limitations in usability. As Sean Parent commented on NDC 2017: The requirements of a polymorphic type, by definition, comes from its use, and there are no polymorphic types, only polymorphic use of similar types. Inheritance is the base class of evil.

Highlight 2: Evolutionary lifetime management

It is such a pain to manage lifetime of objects in large systems written in C++. Because C++ does not have built-in GC support due to performance considerations, users need to beware of lifetime management of every single object. Although we have smart pointers since C++11 (i.e., std::unique_ptr and std::shared_ptr), and various 3rd-party fancy pointers like boost::interprocess::offset_ptr, they are not always sufficient for polymorphic use with inheritance. By using the proxy complementary with smart pointers, clients could care less about lifetime management as if there is runtime GC, but without compromising performance.

Before using any polymorphic object, the first step is always to create it. In other programming languages like Java or C#, we can new an object at any time and runtime GC will take care of lifetime management when it becomes unreachable, at the cost of performance. But how should we implement it in C++? Consider the drawable example in the “Overview” section: given there are 3 drawable types in a system: Rectangle, Circle, and Point. Specifically,

  • Rectangles have width, height, transparency, and area
  • Circles have radius, transparency, and area
  • Points do not have any property; its area is always zero

A library function MakeDrawableFromCommand shall be defined as a factory function responsible for creating a drawable instance by parsing the command line.

Here is how we usually define the types with inheritance:

// Abstraction
class IDrawable {
 public:
  virtual void Draw(std::ostream& out) const = 0;
  virtual double Area() const = 0;
  // Don't forget the virtual destructor, otherwise `delete`ing a pointer of `IDrawable` may result in memory leak!
  virtual ~IDrawable() {}  
};

// Implementation
class Rectangle : public IDrawable {
 public:
  void Draw(std::ostream& out) const override;
  void SetWidth(double width);
  void SetHeight(double height);
  void SetTransparency(double);
  double Area() const override;
};
class Circle : public IDrawable {
 public:
  void Draw(std::ostream& out) const override;
  void SetRadius(double radius);
  void SetTransparency(double transparency);
  double Area() const override;
};
class Point : public IDrawable {
 public:
  void Draw(std::ostream& out) const override;
  constexpr double Area() const override { return 0; }
};

If we use std::string to represent the command line, the parameter type of MakeDrawableFromCommand could be const std::string&, where there should not be much debate. But what should the return type be? IDrawable*? std::unique_ptr<IDrawable>? Or std::shared_ptr<IDrawable>? Specifically,

  • If we use IDrawable*, the semantics of the return type is ambiguous because it is a raw pointer type and does not indicate the lifetime of the object. For instance, it could be allocated via operator new, from a memory pool or even a global object. Clients always need to learn the hidden contract from the author (or even need to learn the implementation details if the author and documentation are not available for consulting) and properly disposing of the object when the related business has finished via operator delete or some other way corresponding to how it was allocated.
  • If we use std::unique_ptr<IDrawable>, it means every single object is allocated individually from the heap, even if the value is potentially immutable or reusable (“flyweight”), which is potentially bad for performance.
  • If we use std::shared_ptr<IDrawable>, the performance could become better for flyweight objects due to the relatively low cost of copying, but the ownership of the object becomes ambiguous (a.k.a. “ownership hell”), and the thread-safety guarantee of copy-construction and destruction of std::shared_ptr may also add to runtime overhead. On the other hand, if we prefer std::shared_ptr across the whole system, every polymorphic type is encouraged to inherit std::enable_shared_from_this, which may significantly affect the design and maintenance of a large system.

For proxy, with the definition from the “Overview” section, we can simply define the return type as pro::proxy<DrawableFacade> without further concern. In the implementation, pro::proxy<DrawableFacade> could be instantiated from all kinds of pointers with potentially different lifetime management strategy. For example, Rectangles may be created every time when requested from a memory pool, while the value of Points could be cached throughout the lifetime of the program:

pro::proxy<DrawableFacade> MakeDrawableFromCommand(const std::string& s) {
  std::vector<std::string> parsed = ParseCommand(s);
  if (!parsed.empty()) {
    if (parsed[0u] == "Rectangle") {
      if (parsed.size() == 3u) {
        static std::pmr::unsynchronized_pool_resource rectangle_memory_pool;
        std::pmr::polymorphic_allocator<> alloc{&rectangle_memory_pool};
        auto deleter = [alloc](Rectangle* ptr) mutable
            { alloc.delete_object<Rectangle>(ptr); };
        Rectangle* instance = alloc.new_object<Rectangle>();
        std::unique_ptr<Rectangle, decltype(deleter)> p{instance, deleter};  // Allocated from a memory pool
        p->SetWidth(std::stod(parsed[1u]));
        p->SetHeight(std::stod(parsed[2u]));
        return p;  // Implicit conversion happens
      }
    } else if (parsed[0u] == "Circle") {
      if (parsed.size() == 2u) {
        Circle circle;
        circle.SetRadius(std::stod(parsed[1u]));
        return pro::make_proxy<DrawableFacade>(circle);  // SBO may apply
      }
    } else if (parsed[0u] == "Point") {
      if (parsed.size() == 1u) {
        static Point instance;  // Global singleton
        return &instance;
      }
    }
  }
  throw std::runtime_error{"Invalid command"};
}

The full implementation of the example above could be found in our integration tests. In this example, there are 3 return statements in different branches and the return types are also different. Lifetime management with inheritance-based polymorphism is error-prone and inflexible, while proxy allows easy customization of any lifetime management strategy, including but not limited to raw pointers and various smart pointers with potentially pooled memory management.

Specifically, Small Buffer Optimization (SBO, a.k.a., SOO, Small Object Optimization) is a common technique to avoid unnecessary memory allocation (see the second return statement). However, for inheritance-based polymorphism, there are few facilities in the standard that support SBO; for other standard polymorphic wrappers, implementations may support SBO, but there is no standard way to configure it so far. For example, if the size of std::any is n, it is theoretically impossible to store the concrete value whose size is larger than n without external storage.

The top secret making proxy both easy-to-use and fast is that it allows lifetime management per object, which had not been addressed in traditional OOP theory (inheritance-based polymorphism) ever before.

If you have tried other polymorphic programming libraries in C++ before, you may or may not find this highlight of lifetime management unique to proxy. Some of these libraries claim to support various lifetime management model, but do not allow per-object customization like proxy does.

Take dyno as an example. dyno is another non-intrusive polymorphic programming library in C++. Given an “interface” type I, dyno does not allow dyno::poly<I> to have a different lifetime management model. By default, dyno::poly<I> always allocates from the heap by the time this blog was written (see typename Storage = dyno::remote_storage). For example, if we want to take advantage of SBO, it is needed to override the Storage type, i.e., dyno::poly<I, dyno::sbo_storage<...>>, which is a different type from dyno::poly<I>. Therefore, dyno::poly<I> could not be used to implement features like MakeDrawableFromCommand above, where the optimal lifetime management model of each branch may differ. Whereas proxy does not have a second template parameter. Given a facade type F, pro::proxy<F> is compatible with any lifetime management model within the constraints of the facade.

Highlight 3: High-quality code generation

Not only does proxy allow efficient lifetime management per object, but also it could generate high quality code for every indirect call. Specifically,

  1. Invocations from proxy could be properly inlined, except for the virtual dispatch on the client side, similar to the inheritance-based mechanism.
  2. Because proxy is based on pointer semantics, the “dereference” operation may happen inside the virtual dispatch, which always generates different instructions from the inheritance-based mechanism.
  3. As tested, with “clang 13.0.0 (x86-64)” and ” clang 13.0.0 (RISC-V RV64)”, proxy generates one more instruction than the inheritance-based mechanism, while the situation is reversed with “gcc 11.2 (ARM64)”. This may infer that proxy could have similar runtime performance in invocation with the inheritance-based mechanism at least on the 3 processor architectures (x86-64, ARM64, RISC-V RV64).

More details of code generation analysis could be found in P0957.

Highlight 4: Composition of abstractions

To support reuse of declaration of expression sets, like inheritance of virtual base classes, the facade allows combination of different dispatches with std::tuple, while duplication is allowed. For example,

struct D1;
struct D2;
struct D3;
struct FA : pro::facade<D1, D2, D3> {};
struct FB : pro::facade<D1, std::tuple<D3, D2>> {};
struct FC : pro::facade<std::tuple<D1, D2, D3>, D1, std::tuple<D2, D3>> {};

In the sample code above, given D1, D2 and D3 are well-formed dispatch types, FA, FB and FC are equivalent. This allows “diamond inheritance” of abstraction without

  • syntax ambiguity
  • coding techniques like “virtual inheritance”
  • extra binary size
  • runtime overhead

Highlight 5: Syntax for CPOs and modules

Along with the standardization of Customization Point Objects (CPO) and improved syntax for Non-Type Template Parameters (NTTP), there are two recommended ways to define a “dispatch” type:

The first way is to manually overload operator() as demonstrated before. This is useful when a dispatch is intended to be defined in a header file shared with multiple translation units, e.g., in tests/proxy_invocation_tests.cpp:

template <class T>
struct ForEach : pro::dispatch<void(pro::proxy<CallableFacade<void(T&)>>)> {
 template <class U>
 void operator()(U& self, pro::proxy<CallableFacade<void(T&)>>&& func) {
  for (auto& value : self) {
   func.invoke(value);
  }
 }
};

The second way is to specify a constexpr callable object as the second template parameter. It provides easier syntax if a corresponding CPO is defined before, or the “dispatch” is intended to be defined in a module with lambda expressions, e.g. in tests/proxy_invocation_tests.cpp:

struct GetSize : pro::dispatch<std::size_t(), std::ranges::size> {};

Highlight 6: Static reflection

Reflection is an essential requirement in type erasure, and proxy welcomes general-purpose static (compile-time) reflection other than std::type_info.

In other languages like C# or Java, users are allowed to acquire detailed metadata of a type-erased type at runtime with simple APIs, but this is not true for std::function, std::any or inheritance-based polymorphism in C++. Although these reflection facilities add certain runtime overhead to these languages, they do help users write simple code in certain scenarios. In C++, as the reflection TS keeps evolving, there will be more static reflection facilities in the standard with more specific type information deduced at compile-time than std::type_info. It becomes possible for general-purpose reflection to become zero-overhead in C++ polymorphism.

As a result, we decided to make proxy support general-purpose static reflection. It’s off by default, and theoretically won’t impact runtime performance other than the target binary size if turned on. Here is an example to reflect the given types to MyReflectionInfo:

class MyReflectionInfo {
 public:
  template <class P>
  constexpr explicit MyReflectionInfo(std::in_place_type_t<P>) : type_(typeid(P)) {}
  const char* GetName() const noexcept { return type_.name(); }

 private:
  const std::type_info& type_;
};

struct MyFacade : pro::facade</* Omitted */> {
  using reflection_type = MyReflectionInfo;
};

Users may call MyReflectionInfo::GetName() to get the implementation-defined name of a type at runtime:

pro::proxy<MyFacade> p;
puts(p.reflect().GetName());  // Prints typeid(THE_UNDERLYING_POINTER_TYPE).name()

Highlight 7: Performance tuning

To allow implementation balance between extensibility and performance, a set of constraints to a pointer is introduced, including maximum size, maximum alignment, minimum copyability, minimum relocatability and minimum destructibility. The term “relocatability” was introduced in P1144, “equivalent to a move and a destroy”. This blog uses the term “relocatability” but does not depend on the technical specifications of P1144.

While the size and alignment could be described with std::size_t, the constraint level of copyability, relocatability and destructibility are described with enum pro::constraint_level, which includes none, nontrivial, nothrow and trivial, matching the standard wording. The defaults are listed below:

Constraints Defaults
Maximum size The size of two pointers
Maximum alignment The alignment of a pointer
Minimum copyability None
Minimum relocatability Nothrow
Minimum destructibility Nothrow

We can assume the default maximum size and maximum alignment greater than or equal to the implementation of raw pointers, std::unique_ptr with default deleters, std::unique_ptr with any one-pointer-size of deleters and std::shared_ptr of any type.

Note that the default minimum copyability is “None”, which means proxy could be instantiated from a non-copyable type like std::unique_ptr. However, if we never want to instantiate a proxy with non-copyable types (including std::unique_ptr) and want the proxy to be copyable, it is allowed to customize it in a facade definition:

// Abstraction
struct MyFacade : pro::facade</* Omitted */> {
  static constexpr auto minimum_copyability = pro::constraint_level::nontrivial;
};

// Client
pro::proxy<MyFacade> p0 = /* Omitted */;
auto p1 = p0;  // Calls the constructor of the underlying pointer type

In some cases where we clearly know we always instantiate a proxy with a raw pointer, and want to optimize the performance to the limit, it is allowed to add even more constraints in a facade definition, at the cost of reducing the scope of feasible pointer types:

// Abstraction
struct MyFacade : pro::facade</* Omitted */> {
  static constexpr auto minimum_copyability = pro::constraint_level::trivial;
  static constexpr auto minimum_relocatability = pro::constraint_level::trivial;
  static constexpr auto minimum_destructibility = pro::constraint_level::trivial;
  static constexpr auto maximum_size = sizeof(void*);
  static constexpr auto maximum_alignment = alignof(void*);
};

// Client
static_assert(std::is_trivially_copy_constructible_v<pro::proxy<MyFacade>>);
static_assert(std::is_trivially_destructible_v<pro::proxy<MyFacade>>);

IMPORTANT NOTICE: clang will fail to compile if the minimum_destructibility is set to constraint_level::trivial in a facade definition. The root cause of this failure is that the implementation requires the language feature defined in P0848R3: Conditionally Trivial Special Member Functions, but it has not been implemented in clang, according to its documentation, at the time this blog was written.

Highlight 8: Diagnostics

The design of proxy is SFINAE-friendly, thanks to the Concepts feature since C++20. If it is used incorrectly, compile error messages could be generated accurately at the spot. For example, if we call the constructor of proxy with a pointer, whose type does not meet the facade definition:

pro::proxy<MyFacade> p;
p.invoke<nullptr_t>();  // nullptr_t is not a valid dispatch type

Here is the error message gcc 11.2 will report:

<source>:550:22: error: no matching function for call to 'pro::proxy<MyFacade>::invoke<nullptr_t>()'
  550 |   p.invoke<nullptr_t>();
      |   ~~~~~~~~~~~~~~~~~~~^~
<source>:445:18: note: candidate: 'template<class D, class ... Args> decltype(auto) pro::proxy<F>::invoke(Args&& ...) requires (pro::details::dependent_traits<pro::details::facade_traits<F>, D>::dependent_t<pro::details::facade_traits<F>, D>::applicable) && (pro::details::BasicTraits::has_dispatch<D>) && (is_convertible_v<std::tuple<_Args2 ...>, typename D::argument_types>) [with D = D; Args = {Args ...}; F = MyFacade]'
  445 |   decltype(auto) invoke(Args&&... args)
      |                  ^~~~~~
<source>:445:18: note:   template argument deduction/substitution failed:
<source>:445:18: note: constraints not satisfied

Conclusion

We hope this has helped clarify how to take advantage of the library “proxy” to write polymorphic code easier. If you have any questions, comments, or issues with the library, you can comment below, file issues in our GitHub repo, or reach us via email at visualcpp@microsoft.com or via Twitter at @VisualC.

The post proxy: Runtime Polymorphism Made Easier Than Ever appeared first on C++ Team Blog.


Viewing all articles
Browse latest Browse all 130

Trending Articles