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:
- Do you want to facilitate architecture design and matainance by writing non-intrusive polymorphic code in C++ as easily as in Rust or Golang?
- 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?
- 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:
- 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" } ] }
- Use
find_package
andtarget_link_libraries
commands to reference to the libraryproxy
in yourCMakeLists.txt
file:find_package(proxy CONFIG REQUIRED) target_link_libraries(<target_name> PRIVATE msft_proxy)
- 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:
- being non-intrusive
- allowing lifetime management per object, complementary with smart pointers
- high-quality code generation
- supporting flexible composition of abstractions
- optimized syntax for Customization Point Objects (CPO) and modules
- supporting general-purpose static reflection
- supporting expert performance tuning
- 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,
Rectangle
s have width, height, transparency, and areaCircle
s have radius, transparency, and areaPoint
s 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 viaoperator 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 viaoperator 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 ofstd::shared_ptr
may also add to runtime overhead. On the other hand, if we preferstd::shared_ptr
across the whole system, every polymorphic type is encouraged to inheritstd::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, Rectangle
s may be created every time when requested from a memory pool, while the value of Point
s 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,
- Invocations from
proxy
could be properly inlined, except for the virtual dispatch on the client side, similar to the inheritance-based mechanism. - 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. - 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 thatproxy
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.