The sharp-runtime object model: Object, strings, exceptions, delegates and collections
Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. sharp-runtime statements were read at next @ 41b918c9, which CNA 009d40f5 does not pin; CNA statements at 009d40f5. The examples were syntax-checked with g++ -std=c++23 -fsyntax-only against the CNA headers and that sharp-runtime checkout (helper functions such as Log are illustrative declarations); nothing was executed. Test names are quoted from the sharp-runtime tree, not run.
Porting C# names into C++ does not import the CLR with them. sharp-runtime keeps the .NET names that make source translation mechanical, but its object model is ordinary C++: values, explicit interfaces, RAII, smart pointers where reference identity matters, and RTTI for the little runtime type identity that survives. This page makes those seams explicit for the types CNA's public API is built from (Object, String, exceptions, IDisposable, delegates and events, Type, TimeSpan and the collections) so that familiar names do not invite managed-runtime assumptions. Statements about sharp-runtime were read at next @ 41b918c9; statements about CNA at snapshot 009d40f5.
Object is opt-in, not universal
.NET gives every value an object ancestry. C++ has no such root and sharp-runtime does not simulate one. System::Object is abstract because GetTypeName() is pure virtual, and in the whole library exactly three classes derive from it: DateTime, DateTimeOffset and System::Timers::Timer. Streams, collections, exceptions, strings and almost every other public type do not. For the classes that opt in, the surface is small and concrete:
Equals(const Object*)defaults to pointer identity; the staticEquals(a, b)returns true for the same pointer, false when exactly one is null, and otherwise dispatches toa->Equals(b);ReferenceEqualsis strictly pointer equality.GetHashCode()hashes the object's address and masks it with0x7fffffff, so it is non-negative and stable for the object's lifetime but differs between runs.ToString()returns the name supplied byGetTypeName(), and the non-virtualGetType()wrapstypeid(*this), so a base pointer still reports its most-derived RTTI identity.
The GetTypeNameHPP() and GetTypeNameCPP(CLASS, NAME) macros only remove the override boilerplate (the second defines the function with a function-local static string); they are not a registration system. CNA's XNA layer is the opposite case: 63 class declarations in CNA's public headers derive from System::Object (GraphicsResource, the audio types, content-pipeline types and more), because CNA's porting checklist requires every such class to override GetTypeName() with the fully qualified .NET name (the per-file checklist). "Is it an Object?" therefore has different answers in CNA's surface and in the runtime beneath it.
// DateTime participates in the optional Object surface.
System::DateTime now = System::DateTime::getNowProperty();
const System::Object* object = &now;
System::Type dynamicType = object->GetType(); // most-derived RTTI identity
// Stream does not: this is ordinary C++ inheritance, not a CLR tree.
std::shared_ptr<System::IO::Stream> bytes = std::make_shared<System::IO::MemoryStream>();
// const System::Object* impossible = bytes.get(); // unrelated types, does not compile
String is an API vocabulary, not storage
System::String has a deleted constructor and a deleted destructor. It is a static utility whose methods (IsNullOrEmpty, Compare, the IndexOf/LastIndexOf family, StartsWith, Split, Substring, the Trim family, PadLeft/PadRight) accept and return std::string. The string value type is std::string itself, also exported as SharpRuntime::String. That keeps familiar method names available without wrapping every character buffer in a new object, and it changes two things a port must know.
Null. A std::string is never a null object, so String::IsNullOrEmpty can only test emptiness. Code whose behaviour distinguishes null from "" has to keep that state separately, for example in a std::optional<std::string>.
The unit of length. Every public index, length and count in sharp-runtime's text APIs is a UTF-8 storage byte, where .NET's is a UTF-16 code unit. The project recorded this as a permanent, declared deviation on 2026-08-17 (ticket #2015): Encoding::GetCharCount of U+1F600 is 4 here and 2 in .NET, and StringBuilder(u8"éA").Remove(1, 1) leaves the byte pair c3 41, which is not well-formed UTF-8. The project's argument is that a byte index into UTF-8 is the exact analogue of a code-unit index into UTF-16, including the ability to split a character (.NET's StringBuilder.Remove can leave a lone surrogate the same way), and tests named TextUnitContractTests.Decl2015_* pin that the unit is consistent across the component. Code that passes positions between sharp-runtime text APIs and anything that counts characters must convert explicitly; the same care applies to the UTF-8 strings CNA's IME events deliver (text input and IME).
System::Text::StringBuilder, by contrast, is a real mutable object backed by one std::string: overloaded Append (strings, characters, integers including longcs, floating point, bool), AppendLine, Insert, Remove and Replace. Its ChunkEnumerator keeps .NET's API shape but always yields exactly one chunk holding the whole buffer, because the CLR's linked list of character chunks does not exist here.
std::string asset = "Content/Textures/player.png";
bool isTexture = System::String::StartsWith(asset, "Content/Textures/");
std::optional<std::string> absent; // the optional owns nullability
bool missing = !absent || System::String::IsNullOrEmpty(*absent);
std::vector<std::string> parts = System::String::Split("Content/Textures/hero.png", '/');
std::string trimmed = System::String::Trim(parts.back());
System::Text::StringBuilder sb;
sb.Append("Score: ").Append(42).AppendLine();
for (const std::string& chunk : sb.GetChunks()) {
Log(chunk); // exactly one iteration
}
Exceptions join the C++ hierarchy
System::Exception derives directly from std::exception, not from Object, so sharp-runtime exceptions are catchable by ordinary C++ code while still carrying the .NET shape: a message (getMessageProperty(), also returned by what()), an inner exception held as std::exception_ptr, a Data map of strings, Source, HelpLink and an HResult that defaults to 0x80131500 (COR_E_EXCEPTION). StackTrace returns an empty string because no managed stack is captured.
Four members are absent on purpose, and the header says why: GetBaseException() would have to return a copy through the base class, which slices a derived C++ exception without a virtual clone protocol; ToString() would need a GetType()-equivalent class name, which the no-reflection boundary rules out; TargetSite and GetObjectData depend on reflection and serialization. The public headers declare about 114 exception classes (counted as class …Exception : public …); 49 derive directly from SystemException, 15 directly from Exception, and the rest through intermediate bases such as IOException and ArgumentException, which is .NET's own two-level shape. There is no central ThrowHelper: each method constructs and throws its specific type, so parity means the right class and message at each site, not merely that something was thrown.
CNA builds on this directly. Its XNA code throws the .NET-shaped types (System::ArgumentOutOfRangeException, System::ObjectDisposedException), and nine of its exception headers declare XNA's documented protected (SerializationInfo, StreamingContext) constructor, among them ContentLoadException.hpp, NetworkSessionJoinException and StorageDeviceNotConnectedException. Only four of the nine (ContentLoadException, NetworkException with its subclass NetworkSessionJoinException, and StorageDeviceNotConnectedException) write and restore anything: sharp-runtime's SerializationInfo became a real name-to-value store on 2026-09-20 (b36d6503), and since System::Exception itself still has no serialization constructor, each of those declares GetObjectData and writes and restores its own message and inner exception through ExceptionSerialization.hpp. The round trip is in-process: an inner exception travels as a std::exception_ptr and no bytes are written anywhere. The other five (GuideAlreadyVisibleException, GamerPrivilegeException, GameUpdateRequiredException, GamerServicesNotAvailableException and NetworkNotAvailableException) declare the constructor for signature compatibility and ignore its arguments, so restoring one from a store yields a default message (read from the source; not executed).
try {
throw System::ArgumentOutOfRangeException("index");
} catch (const System::Exception& ex) {
Log(ex.getMessageProperty());
} catch (const std::exception& ex) {
Log(ex.what()); // non-sharp-runtime C++ failures remain observable too
}
Lifetime is RAII first
System::IDisposable is one pure virtual Dispose() and a virtual destructor, nothing more: a capability interface, not a second hierarchy root. Its header states the contract: Dispose() must be safe to call more than once, must release everything the instance holds and should not generally throw; where C# has using, C++ callers use RAII or call Dispose() explicitly. CNA wires it into GraphicsResource, which adds .NET's protected Dispose(bool) overload (Tutorial 89 shows the pattern for a game's own types).
The split between destructor and Dispose() matters when releasing can fail. A C++ destructor is implicitly noexcept, and an I/O exception escaping during stack unwinding terminates the process. An explicit Dispose() or Close() is the place where a failure can be observed and handled; the destructor is a best-effort safety net.
System::GC has nothing to collect. Every Collect overload, SuppressFinalize and KeepAlive is a no-op; GetTotalMemory, GetTotalAllocatedBytes and the GCMemoryInfo fields return zero, GetTotalPauseDuration returns TimeSpan::Zero, every TryStartNoGCRegion overload returns false, the full-GC notification waits (WaitForFullGCApproach and WaitForFullGCComplete, every overload) return GCNotificationStatus::NotApplicable rather than Succeeded because RegisterForFullGCNotification monitors nothing, and MaxGeneration stays the compatibility constant 2. Value types use value semantics, std::shared_ptr appears where managed reference identity genuinely matters, std::unique_ptr expresses exclusive ownership, and there is no project-specific smart pointer or tracing heap. Ported code may keep defensive GC calls so that it compiles, but it must not read heap telemetry into those answers.
Delegates: three representations
C# delegates are one mechanism; sharp-runtime uses three, each for a different shape of problem.
| Representation | What it is | Use it for |
|---|---|---|
Action, ActionT<T>, Func… and most …Callback/…EventHandler aliases | using aliases over std::function: one target, no multicast, no BeginInvoke/EndInvoke | the common single-callback parameter |
System::Delegate | a real multicast base with an invocation list | code that builds up and later shrinks a combined delegate |
System::MulticastAction<Args...> and System::EventHandler<TEventArgs> | subscriber lists with tokens and snapshot invocation | event fields |
System::Delegate
Delegate derives from std::enable_shared_from_this and stores either one type-erased ErasedInvoke (std::function<void()>) or a list of std::shared_ptr<Delegate>. Combine, Remove (which returns the source pointer unchanged when the value is absent), RemoveAll (repeated Remove until nothing changes), ordered Invoke and GetInvocationList are implemented. A single-target delegate must already be owned by a shared_ptr before GetInvocationList() can return itself, otherwise shared_from_this throws std::bad_weak_ptr. Equality follows what C++ can compare: two delegates wrapping the same plain function pointer are equal, a delegate wrapping a lambda or other closure is equal only to itself, and multicast delegates compare element by element, with GetHashCode() folded consistently. getTargetProperty() always returns nullptr, and DynamicInvoke always throws NotImplementedException, because C++ has no late-bound object[] invocation.
MulticastAction: event fields without delegate equality
C# removes a handler with -= because delegates compare by target and method; std::function values have no such equality, so value comparison cannot drop one specific subscription. MulticastAction solves that with tokens: Add returns a Token (std::uint64_t; an empty handler is ignored and yields InvalidToken, which is 0), Remove(token) removes exactly that subscription and returns whether it did, operator+= adds a fire-and-forget subscriber, operator=(handler) replaces the whole list as C#'s field = handler does, and = nullptr clears it. Invocation walks a snapshot of the list in subscription order, so a handler that subscribes or unsubscribes during dispatch affects only the next invocation. The header names its motivating case: re-parenting a transform hierarchy in cna-extended, where a node must drop its specific old subscription without clearing everyone else's. In CNA the type backs TextInputEXT::TextInput, TextEditing and TextEditingCandidatesEXT in TextInputEXT.hpp and the device connect and disconnect events.
EventHandler: the event keyword C++ does not have
In C#, EventHandler<T> is only a delegate type; the subscriber list and its invocation are generated by the compiler behind the event keyword. C++ has no such keyword, so sharp-runtime's EventHandler<TEventArgs> is both the type and the list: operator+= (which discards the token), Add returning a Token, Remove(token), Clear(), Empty(), Size(), and Raise(sender, e) with Invoke as an alias. Raise copies the handler list before calling anything; without that snapshot, a handler that removes itself mid-raise destroyed a std::function the loop was still using, observed in practice as an escaping std::bad_function_call. An empty handler is a no-op at Add; storing it used to make Raise throw the same exception.
SetReplayHook(hook) models an XNA quirk once instead of per event: when a hook is set, every newly added handler is first passed to it, so the owner can replay state that already exists. CNA's NetworkSession.cpp sets it on GamerJoined in the constructor, which is how subscribing reports every gamer already in the session (network sessions). How CNA uses EventHandler fields for XNA events such as Game::Exiting is on CNA's translation conventions.
// Tier 1: a single-target alias.
System::ActionT<int> onScoreChanged = [](int score) { UpdateHud(score); };
onScoreChanged(100);
// Tier 2: real multicast combining.
auto first = std::make_shared<System::Delegate>(System::Delegate::ErasedInvoke([] { Log("first"); }));
auto second = std::make_shared<System::Delegate>(System::Delegate::ErasedInvoke([] { Log("second"); }));
std::shared_ptr<System::Delegate> both = System::Delegate::Combine(first, second);
both->Invoke(); // first, then second
both = System::Delegate::Remove(both, first); // only second remains
// Tier 3: an event field, removed by token. TextInput is a CNA MulticastAction<charcs>.
using Microsoft::Xna::Framework::Input::TextInputEXT;
auto token = TextInputEXT::TextInput.Add([](SharpRuntime::charcs c) { ApplyText(c); });
// ... when the text box closes, drop exactly this subscription:
TextInputEXT::TextInput.Remove(token);
Type is identity without reflection
System::Type::From<T>() (and FromTypeInfo) wraps a std::type_info, and equality and GetHashCode() come from it. That is genuine type identity, enough for dictionary keys, service registries and equality tests, and it is what CNA uses: the design module's type converters and property descriptors return Type::From<TComponent>(), and content-pipeline types answer getDefaultSerializerTypeProperty() with Type::From<T>(). It is not enough to reconstruct CLR metadata. getNameProperty() and getFullNameProperty() return the RTTI name, which most compilers mangle. The five predicates (getIsClassProperty(), getIsValueTypeProperty(), getIsAbstractProperty(), getIsSealedProperty() and getIsInterfaceProperty()) return fixed values whatever T is (IsClass true, the other four false), and the header states that they are not mutually consistent: Type::From<int>() reports a class that is not a value type. They exist so that ported code calling them compiles; code must not branch on them, and no CNA production code does.
System::Activator keeps exactly the part of reflection-free construction that C++ can express. CreateInstance<T>() value-initialises T{}; the variadic CreateInstance<T>(args...), the compile-time counterpart of CreateInstance(Type, object[]), forwards to a constructor for a non-aggregate class type and uses braces otherwise (so CreateInstance<std::vector<int>>(3, 7) builds three sevens, and CreateInstance<int>(2.5) stays a narrowing compile error); CreateInstancePtr<T>() returns a std::unique_ptr. Overloads that take a runtime Type, an assembly name or CreateInstanceFrom are absent rather than stubbed, because there is no System.Reflection for them to resolve against.
TimeSpan
System::TimeSpan is the duration type under CNA's GameTime, Game::TargetElapsedTime and the sensor update intervals. It stores 100-nanosecond ticks (TicksPerMillisecond is 10,000, TicksPerSecond 10,000,000), and its getTotal…Property() accessors return double. It carries one diagnostic surprise: static copy_count and move_count counters for test instrumentation. They have been std::atomic<int> since 2026-07-07 (9c2cb0ae), read and reset with relaxed ordering because only the final count matters. The description of CNA's devices-tsan preset in CMakePresets.json still mentions a pre-existing race in the TimeSpan copy constructor; that text describes an older sharp-runtime, and a ThreadSanitizer report there against 41b918c9 would be new, not the known one.
Fail-fast collections and the mutation counter
sharp-runtime's mutable collections implement .NET's fail-fast rule: modifying a collection while an enumerator over it is open makes the enumerator's next step throw InvalidOperationException. The mechanism is one shared type, System::Collections::detail::MutationCounter, used by List, Dictionary, HashSet, LinkedList, Queue, Stack, SortedDictionary, SortedList and OrderedDictionary, by the non-generic ArrayList, Hashtable, Queue, Stack and BitArray, by ObjectModel::Collection, and by the JSON node containers. A collection bumps it on each effective structural change; an enumerator snapshots it and compares before touching storage. SortedSet keeps a single version on its underlying tree that its views share. CNA reuses the type in NetworkSessionProperties.hpp.
It is a class rather than an integer because a bare field got three things wrong, each reproduced before the change: ++version_ on a signed intcs at its maximum is undefined behaviour (fourteen UBSan reports, one per collection), so the counter is unsigned; a 32-bit counter returns to a snapshot value after 232 mutations and the equality guard then accepts a stale enumerator, so it is 64 bits wide; and an implicitly generated assignment copied the source collection's counter into the destination, so an enumerator over the destination saw no change although every element it could reach had been destroyed (six AddressSanitizer use-after-free or overflow reports). Assigning a collection now advances the destination's own counter.
Two deviations from .NET's exact _version rules are deliberate and documented in Dictionary.hpp. Remove bumps the version although .NET's does not: .NET's array-backed entries survive a removal, but this port's enumerator wraps a std::unordered_map iterator, and erasing the element it points to invalidates it (confirmed with an AddressSanitizer reproduction), so an enumeration must fail cleanly instead of crashing. Clear bumps it for the same reason. OrderedDictionary::EnsureCapacity bumps only when the capacity actually grows, as .NET does; it previously never bumped, so an enumerator open across a capacity-triggered reallocation went undetected. Dictionary's non-const operator[] returns a ValueProxy so that reading a missing key throws KeyNotFoundException while assignment still inserts. List<T> separates its tracked surface (operator[], getItem, setItem, GetEnumerator) from STL begin()/end(), which follow std::vector invalidation rules instead; Tutorial 89 shows both.
The honest object model is smaller than .NET's, but every surviving mechanism has a clear C++ owner: RTTI for identity, templates for construction, smart pointers for shared lifetime, explicit interfaces for capabilities, and tokens where C++ callables cannot be compared. What sharp-runtime refuses to emulate, and why, is on parity and verification.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- Architecture
- Architecture: required sibling repositories
- Maintainer workflow
- Conventions