Chapter 55 The Object Model, Honestly
Porting C# names into C++ does not import the CLR with them. Sharp-runtime retains the APIs that help source translation, but its actual object model is standard C++: values, explicit interfaces, RAII, smart pointers where reference identity matters, and RTTI for the small amount of runtime type identity that survives. This chapter makes those seams explicit so that familiar names such as Object, String, and Delegate do not invite managed-runtime assumptions.
55.1 Object is opt-in, not universal
Real .NET gives every value an object ancestry. C++ has no equivalent root, and sharp-runtime does not simulate one. System::Object is abstract because GetTypeName() is pure virtual. At the audited revision, exactly two runtime classes derive from it: DateTime and DateTimeOffset. Streams, collections, exceptions, strings, and almost every other public type do not.
For the few opt-in classes, the surface is useful and concrete:
-
•
Equals(const Object*) defaults to pointer identity;
-
•
static Equals handles nulls and then dispatches to the first object;
-
•
ReferenceEquals is strictly pointer equality;
-
•
GetHashCode() derives a non-negative value from the object’s address;
-
•
ToString() returns the stable name supplied by GetTypeName(); and
-
•
non-virtual GetType() uses typeid(*this), so a base pointer still reports its most-derived RTTI identity.
The GetTypeNameHPP() and GetTypeNameCPP() macros only remove override boilerplate. They are not a hidden registration system, and their existence does not make Object universal.
55.2 String is an API vocabulary, not storage
System::String has a deleted constructor and destructor. It is a static utility class whose methods accept and return std::string; the actual string value type is std::string, also exposed as SharpRuntime::String. This keeps familiar method names such as IsNullOrEmpty, StartsWith, Split, and Trim available without wrapping every character buffer in a new heap-aware object.
The adaptation changes what null means. A std::string value is never a null object, so String::IsNullOrEmpty can only test emptiness. Code whose behavior distinguishes null from "" must retain that state separately, for example with std::optional<std::string>.
System::Text::StringBuilder is a separate, real mutable object backed by one std::string. Its chunk enumerator preserves .NET’s API shape but yields one chunk, not the linked character chunks used by the CLR implementation. Chapter 56 follows these type families and their include paths in more detail.
55.3 Exceptions join the C++ hierarchy
System::Exception derives directly from std::exception, not from Object. This makes sharp-runtime exceptions catchable by ordinary C++ infrastructure while preserving a .NET-shaped message, inner exception, data map, source, help link, and HRESULT. The base HRESULT is 0x80131500; what() exposes the stored message.
Some managed facilities have no honest representation. StackTrace returns an empty string because the runtime captures no managed stack. GetBaseException() is absent because returning a base value would slice a derived C++ exception without a virtual clone protocol. Reflection-dependent TargetSite and serialization-dependent GetObjectData are absent for the same reason.
There is no central ThrowHelper; concrete methods construct and throw the appropriate type directly. API parity therefore includes the exception class and message at each site, not merely the fact that some exception escaped.
55.4 Lifetime is RAII first
System::GC cannot collect anything. Its mutating operations are no-ops and its queries return documented constants or sentinel values rather than live heap statistics. Value types use value semantics; ownership-bearing types use destructors and standard smart pointers. std::shared_ptr appears where managed reference identity genuinely matters, while std::unique_ptr represents exclusive ownership. There is no project-specific smart pointer or tracing heap.
System::IDisposable remains a one-method capability interface. Implementations are expected to release deterministically, avoid throwing in normal disposal, and tolerate repeated calls. It does not replace destructors or establish another object root. A caller either uses RAII or invokes Dispose() explicitly when the API’s semantic close point matters.
That split becomes important when disposal can report an error. A C++ destructor is implicitly noexcept; allowing an I/O exception to escape during stack unwinding can terminate the process. Sharp-runtime audit work found and fixed several such destructor paths. An explicit Dispose() or Close() is therefore the place to observe failures; the destructor remains a best-effort safety net.
55.5 Delegates have three representations
Most delegate-shaped declarations are simple std::function aliases. They model one callable target and deliberately have no C# multicast equality or late binding. Two concrete runtime facilities cover cases where that simplification is insufficient.
System::Delegate is a real multicast base. It stores either one erased void() callable or an invocation list of shared delegates, and implements Combine, Remove, RemoveAll, ordered Invoke, and GetInvocationList. Equality of entries is pointer identity rather than a CLR method-target pair. A single-target object must already be owned by shared_ptr before GetInvocationList() can return itself; otherwise shared_from_this throws std::bad_weak_ptr. DynamicInvoke always throws because C++ has no reflection-driven object[] invocation.
System::MulticastAction<Args...> is the event-field alternative. It uses subscriber tokens and snapshot invocation, allowing one subscription to be removed safely even when a callback mutates the list during dispatch. EventHandler<T> adds sender/event-argument shape and an optional replay hook. These specialized types solve observable event semantics; they do not make every std::function multicast.
55.6 Type is identity without reflection
System::Type::From<T>() and RTTI-backed equality provide genuine type identity. That is sufficient for dictionary keys, service registries, and equality tests. It is not enough to reconstruct CLR metadata. Predicates such as IsClass, IsValueType, IsAbstract, and IsInterface return fixed placeholder values and are explicitly not mutually consistent. Ported code may compile against them but must not branch on them.
System::Activator follows the same boundary. Its template CreateInstance<T>() can default-construct a type known at compile time; overloads that accept a runtime Type, assembly name, or constructor argument array are absent. The honest object model is therefore smaller than .NET’s, but every surviving mechanism has a clear C++ owner: RTTI for identity, templates for construction, smart pointers for shared lifetime, and explicit interfaces for capabilities.