System::Nullable
Represent optional values with native construction and equality rules, without confusing absence with pointer ownership.
Role and storage
System::Nullable<T> represents either no value or one value of T. It is an ordinary header-only C++ template backed by a private std::optional<T>; it is not a boxed object, does not derive from System::Object, and does not introduce CLR nullable metadata.
The type lives in System/Nullable.hpp and belongs to Core.Base. A default-constructed instance is absent. Constructors from const T& and T&& are intentionally implicit, mirroring the convenient C# conversion from T to T?.
#include <System/Nullable.hpp>
System::Nullable<int> missing;
System::Nullable<int> answer = 42;
if (answer.getHasValueProperty()) {
const int value = answer.getValueProperty();
(void)value;
}
Public surface
| Member | Behavior | Important detail |
|---|---|---|
getHasValueProperty() | Reports presence | noexcept; equivalent to the backing optional's has_value() |
getValueProperty() | Returns the contained value | Returns const T&; throws when absent |
GetValueOrDefault() | Returns the value or T{} | Returns by value |
GetValueOrDefault(defaultValue) | Returns the value or a supplied fallback | Returns by value; the fallback is passed as const T& |
Equals / GetHashCode | Default-equality and matching hash contract | Different from lifted operator== for floating NaN |
ToString | Empty text when absent; formatted contained value when present | Prefers T::ToString(), otherwise stream insertion |
explicit operator bool | Tests presence | A C++ convenience, not nullable-Boolean value conversion |
explicit operator T | Extracts the value | Uses the same throwing access as Value |
operator== / operator!= | Compares two nullable values, or presence against std::nullopt | Uses the backing optional's lifted underlying operator |
Value access and exceptions
getValueProperty() throws System::InvalidOperationException with the message “Nullable object must have a value.” when absent. Explicit conversion to T delegates to that accessor and throws the same exception. The returned property reference remains valid only while the Nullable object exists and is not assigned a replacement value.
GetValueOrDefault() never throws merely because the nullable is absent. The parameterless overload value-initializes T; that means zero for arithmetic values, but it is not necessarily the correct domain fallback. Prefer the explicit-default overload when zero, an empty string, or a default-constructed object would hide missing input.
System::Nullable<int> port;
const int conventional = port.GetValueOrDefault(); // 0
const int configured = port.GetValueOrDefault(8080); // 8080
if (!port) {
// Presence test only; no value was extracted.
}
Equality, ordering, and floating-point behavior
Equals treats two absent values as equal, an absent and present value as unequal, and two present values through Sharp Runtime's default equality policy. For floating types that policy follows .NET's Single.Equals/Double.Equals shape: two NaNs compare equal and signed zero values compare equal.
operator== deliberately has a different floating behavior. It models lifted C# == by using the underlying equality operator through std::optional. Two present NaNs therefore compare false, just as two nullable NaNs do under lifted == in C#. This is not an inconsistency to “fix”; it represents two distinct public equality surfaces.
| Operands | Equals | operator== |
|---|---|---|
| absent, absent | true | true |
| absent, present | false | false |
| equal ordinary values | true | true |
| NaN, NaN | true | false |
| +0.0, -0.0 | true | true |
NullableHelper::Compare orders absence before presence and compares two present values with the runtime's default comparison policy; floating NaN consequently orders before every present numeric value. NullableHelper::Equals delegates to the Equals surface. The helper has a different name because C++ cannot define a non-template Nullable beside Nullable<T> in the same namespace.
Hashing
An absent nullable hashes to zero. A present value uses the runtime's default hash policy. For floating values, NaN payloads are canonicalized so values considered equal by Equals receive the same hash; positive and negative zero also share a hash. Zero is a valid hash for a present value, so a collision with the absent state does not imply equality.
GetHashCode() returns the project-wide 32-bit intcs hash shape. It is suitable only where the consuming comparer uses the matching equality contract. Do not persist it, treat it as identity, or assume it equals a .NET process's hash.
Text conversion
ToString() returns an empty std::string for an absent value, matching the intended nullable display shape rather than claiming that absence and empty text are generally equivalent. For a present T, compile-time detection prefers a const T::ToString() convertible to std::string. Otherwise the implementation uses operator<< on a classic-locale std::ostringstream.
There is no format-provider overload, nullable format string, or reflection-based conversion. A type with neither a suitable member nor stream insertion can still be stored, but calling ToString() for that instantiation will not compile.
Ownership and template requirements
The contained value is stored inline according to std::optional's object model. Copying or moving a Nullable<T> copies or moves T; destroying it destroys a present T. It does not allocate merely to represent presence and does not extend the lifetime of an external object unless T itself is an owning smart pointer.
The template does not restrict T to CLR-style value types. That flexibility does not make every member available for every C++ type. The value-or-default and explicit-value operations return by value and therefore require the corresponding construction. Equality, hashing, and text conversion require the relevant operators or customization. The current equality and hash members are declared noexcept; a user-defined equality or hash that throws is not a safe instantiation for those calls.
Use Nullable<T> for optional value semantics. Use unique_ptr, shared_ptr, weak_ptr, or a documented raw pointer when the problem is object ownership, shared lifetime, expiry, or borrowing. Wrapping a pointer in Nullable usually adds a second absence mechanism rather than clarity.
C# comparison
| C# / .NET | Sharp Runtime / C++ | Porting consequence |
|---|---|---|
T? language syntax | System::Nullable<T> | Include the header and name the template explicitly |
.HasValue / .Value | getHasValueProperty() / getValueProperty() | Property syntax becomes accessor calls |
Nullable value returned by Value | Const reference returned by the property accessor | Do not retain the reference beyond the nullable object's lifetime |
Compiler lifts many operators over T? | Only the documented equality/presence operators are supplied here | Write explicit presence logic for arithmetic, relational, and custom operators |
Boxing and Nullable.GetUnderlyingType | No boxing service or CLR metadata | Use templates and explicit registries/type traits |
null literal | Default construction; comparisons with std::nullopt | There is no general managed-null conversion |
Usage pattern
#include <System/Nullable.hpp>
System::Nullable<int> parsePort(const std::string& text)
{
if (text.empty()) return {};
return std::stoi(text);
}
const auto port = parsePort("443");
if (port.getHasValueProperty()) {
connectTo(port.getValueProperty());
}
This example uses absence for empty input and extracts only after testing presence. std::stoi still has native exception behavior; Nullable does not translate parsing failures automatically.