Porting C# Code

Translate observable behavior and managed lifetime assumptions into explicit, tested native C++23 design.

Flagship guideC# → C++Practical transformations

Port behavior, not punctuation

A successful port preserves the behavior that callers depend on while making native ownership, lifetime, threading, and platform constraints explicit. Sharp Runtime supplies familiar landmarks, but C# and C++ remain different languages with different execution models. Start by writing down what the code does, what it owns, which callbacks can escape, which failures are observable, and which runtime-only mechanisms it assumes.

Recommended order

Choose components and representations first. Translate the public shape second. Translate behavior in small tested slices third. A line-by-line transliteration usually postpones the hardest decisions until dangling pointers or semantic reductions are already hidden.

Establish the porting boundary

Before changing code, classify the source:

  • Portable domain logic: calculations, validation, state transitions, and data models can often become direct C++ values.
  • BCL-shaped logic: strings, collections, files, JSON, XML, networking, and tasks may map to Sharp Runtime components.
  • CLR-dependent logic: reflection, dynamic invocation, assembly loading, remoting, P/Invoke, GC finalization, and universal boxing require redesign.
  • Platform logic: paths, watchers, sockets, process behavior, terminals, and time zones need an evidence-backed target matrix.

Define the supported platforms and input contracts now. “It compiled on Linux” is not evidence of Windows runtime behavior; a matching method name is not evidence of every .NET edge case.

Select components before writing includes

Request direct physical needs. CMake resolves their transitive closure. This makes dependencies reviewable and prevents a port from relying accidentally on All.

set(SHARP_RUNTIME_COMPONENTS
    Collections.Core
    IO
    Text.Json
)
set(SHARP_RUNTIME_BUILD_TESTS OFF CACHE BOOL "" FORCE)
add_subdirectory(sharp-runtime)

target_link_libraries(ported_app PRIVATE
    SharpRuntime::Collections.Core
    SharpRuntime::IO
    SharpRuntime::Text.Json
)

Use the API inventory to identify a header’s owner. Do not add global include directories or list private/transitive targets merely to make compilation pass.

Namespaces

C# dotted namespaces become nested C++ namespaces. Modern C++23 nested namespace syntax is concise, but headers and sources still need normal declarations and definitions.

C#
namespace Game.Model;

public sealed class Score
{
    public int Value { get; }
}
C++ / Sharp Runtime
namespace Game::Model
{
    class Score final
    {
    public:
        explicit Score(int value) : value_(value) {}
        [[nodiscard]] int getValueProperty() const noexcept
        {
            return value_;
        }

    private:
        int value_;
    };
}

Do not put using namespace directives in public headers. Fully qualify types when two familiar namespaces contain the same short name.

Classes, structs, and value decisions

C# class says “managed reference type”; C++ class says nothing about heap allocation or shared identity. Decide the representation from semantics:

C# intentTypical C++ representationReview
Small immutable/value-like dataDirect class/struct valueCopy/move cost and equality
Unique polymorphic servicestd::unique_ptr<Interface>Virtual destructor and borrow lifetime
Shared long-lived identitystd::shared_ptr<T>Cycles and delayed deterministic cleanup
Observer of an existing ownerReference/raw pointer/weak_ptrProve the owner outlives every access
Optional valueNullable<T>/optional<T>Different from optional object ownership

A direct value should be the default when identity is not observable. Do not allocate every translated C# class with new; that recreates managed syntax without a collector.

Constructors and initialization

Use member initializer lists. Establish class invariants before the constructor body, validate inputs before publishing the object, and let members clean themselves up if construction throws.

C#
public sealed class Endpoint
{
    public string Host { get; }
    public int Port { get; }

    public Endpoint(string host, int port)
    {
        Host = host ?? throw new ArgumentNullException(nameof(host));
        if (port is < 1 or > 65535)
            throw new ArgumentOutOfRangeException(nameof(port));
        Port = port;
    }
}
C++ / Sharp Runtime
class Endpoint final
{
public:
    Endpoint(std::string host, int port)
        : host_(std::move(host)), port_(port)
    {
        if (host_.empty())
            throw System::ArgumentException("host");
        if (port_ < 1 || port_ > 65535)
            throw System::ArgumentOutOfRangeException("port");
    }

private:
    std::string host_;
    int port_;
};

The C++ value cannot be null; the example chooses empty as invalid because that is its own contract. If null and empty were distinct source values, use an optional parameter instead.

Inheritance and interfaces

Translate an interface to a native abstract base with a virtual destructor. Use override on every override and final where further inheritance is not intended.

C#
public interface IClock
{
    DateTime UtcNow { get; }
}
C++ / Sharp Runtime
class IClock
{
public:
    virtual ~IClock() = default;
    [[nodiscard]] virtual System::DateTime
        getUtcNowProperty() const = 0;
};

Pass the interface by reference for a synchronous borrow, unique_ptr for transfer, or shared_ptr for genuine shared ownership. The interface type itself does not choose ownership. C++ multiple inheritance, slicing, object layout, and cast rules remain native.

Properties

Explicit accessors are usually clearest. The repository also provides DDATA/IDATA for member-backed read/write properties, DGETTER/IGETTER for read-only properties, and a static getter pair. They generate names such as getWidthProperty() and setWidthProperty(...).

class Texture final
{
    DDATA(int, Width)
    DGETTER(int, Height)
};

// In the .cpp file:
IDATA(int, Width, Texture)
IGETTER(int, Height, Texture)

Prefer explicit methods when a setter validates, clamps, allocates, blocks, transfers ownership, or mutates several fields. The experimental Property<T> wrapper stores per-instance std::function delegates and is deliberately not the production default.

Nullable values and null coalescing

C#
int? limit = options.Limit;
int effective = limit ?? 100;

if (limit.HasValue)
    Console.WriteLine(limit.Value);
C++ / Sharp Runtime
System::Nullable<int> limit = options.limit();
const int effective = limit.GetValueOrDefault(100);

if (limit.getHasValueProperty())
    System::Console::WriteLine(
        limit.getValueProperty());

Value throws InvalidOperationException when absent. Do not implement ?? by substituting empty strings or zero globally. The correct fallback is domain-specific. A nullable smart pointer is usually redundant because the pointer already has an empty state.

Arrays, spans, and memory

Port owned one-dimensional arrays to std::vector<T> unless fixed compile-time extent or a higher-level collection is more appropriate. Use System::Array for familiar algorithms and Span/ReadOnlySpan for bounded synchronous borrows.

C#
static void ZeroMiddle(int[] values)
{
    values.AsSpan(1, values.Length - 2).Clear();
}
C++ / Sharp Runtime
void ZeroMiddle(std::vector<int>& values)
{
    if (values.size() < 2)
        throw System::ArgumentException("values");
    System::Span<int> all(values);
    all.Slice(1, static_cast<SharpRuntime::intcs>(
        values.size() - 2)).Clear();
}

A view does not retain its vector. Vector reallocation or destruction invalidates it. Current Memory<T> is also non-owning, so it is not an automatic answer for an async boundary. See array internals.

Collections

Choose a collection from behavior, not just its C# name:

C# patternCandidateNative decision
List<T>Sharp Runtime List-shaped type or std::vectorDo interfaces/versioned enumerators matter?
Dictionary<K,V>Runtime dictionary or unordered_mapComparer/hash policy, especially strings/floats
HashSet<T>Runtime set or unordered_setSame equality/hash invariant
Queue/StackMatching runtime or STL adapterException and enumeration behavior
BlockingCollectionCollections.BlockingCompletion, cancellation, blocking, shutdown
ObservableCollectionCollections.ObjectModelCallback lifetime and reentrancy

Mutation invalidates versioned enumerators and often native iterators/references. A concurrent collection protects its structure, not the thread safety of objects stored inside it.

Strings

Port ordinary text to std::string/SharpRuntime::String and use System::String helpers where their tested surface matches. Audit every stored index and length: Sharp Runtime uses UTF-8 bytes while .NET uses UTF-16 code units.

C#
string label = string.Join(", ", names)
    .Trim()
    .ToUpperInvariant();
C++ / Sharp Runtime
const std::string label = System::String::ToUpperInvariant(
    System::String::Trim(
        System::String::Join(", ", names)));

The example preserves the shape for ASCII-oriented data. Current invariant case conversion is still per-byte native casing, not full Unicode invariant behavior. Use a Unicode library when that distinction matters.

Exceptions

Throw Sharp Runtime exceptions where the implemented contract calls for them and catch by const reference. C++ unwinding runs destructors, so use RAII instead of finally for resource cleanup.

C#
try
{
    Load(path);
}
catch (IOException ex)
{
    Log(ex.Message);
}
finally
{
    stream.Dispose();
}
C++ / Sharp Runtime
try
{
    load(path); // scoped stream cleans up during unwind
}
catch (const System::IO::IOException& error)
{
    log(error.what());
}

Matching names do not imply identical message text, filters, serialization, stack traces, or CLR dispatch. Preserve the specific exceptions that callers actually use; do not translate every failure to runtime_error or catch ... and discard causes.

Delegates and lambdas

Typed callables map naturally to lambdas, function objects, function pointers, and std::function. Sharp Runtime supplies Action, Func, Predicate, and related aliases/shapes where useful.

C#
Func<int, int> twice = value => value * 2;
int result = twice(21);
C++ / Sharp Runtime
std::function<int(int)> twice =
    [](int value) { return value * 2; };
const int result = twice(21);

A capture chooses lifetime. [this] is a raw borrow. A value capture copies. Capturing a shared_ptr retains and can form a cycle; capturing a weak_ptr requires a lock/check at invocation. Empty callables are validated or ignored according to the specific API—never rely on a later bad_function_call.

Events

EventHandler<TEventArgs> intentionally combines the delegate signature, subscription list, and Raise/Invoke behavior because C++ has no event keyword. Add returns a token for Remove; operator+= discards the token. Raise snapshots the handlers so add/remove/clear during dispatch affects the next raise, not the current one.

System::EventHandler<System::EventArgs> changed;

const auto token = changed.Add(
    [](System::Object* sender, const System::EventArgs&) {
        (void)sender;
        System::Console::WriteLine("changed");
    });

changed.Raise(nullptr, System::EventArgs::Empty);
changed.Remove(token);

Test exception propagation and thread safety for the actual publisher. Unsubscribe before captured owners die. A replay hook exists for the small set of XNA-style events that replay prior state on subscription; it is not normal event semantics.

Tasks and async patterns

Sharp Runtime Task objects preserve useful state, cancellation, waiting, results, faults, continuations, and aggregate patterns. They do not add C# language async/await. Current task actions use native asynchronous machinery; continuations can run synchronously on the thread that completes the antecedent.

C#
await Task.Run(() => Rebuild(index), token);
Publish(index);
C++ / Sharp Runtime
System::Threading::Tasks::Task work(
    [&index, token] {
        token.ThrowIfCancellationRequested();
        rebuild(index);
    }, token);

work.Wait();
publish(index);

The reference capture in this synchronous-wait example is safe only because Wait completes before the scope ends. If the task escapes, retain an owner or copy the data. Observe faults, design cooperative cancellation, and avoid strong reference cycles from a task state back into its continuation.

Single-threaded Emscripten builds reject task paths that require pthreads. A pthread-enabled whole program also needs the browser/server isolation configuration required by Web Workers and shared memory.

Streams and readers/writers

Port against Stream capabilities, not an assumption that every stream reads, writes, seeks, and flushes identically. MemoryStream owns bytes; FileStream owns a native file handle; BinaryReader/Writer and text readers/writers borrow a Stream pointer and can optionally leave it open.

  • Scope owners with RAII and decide who closes the underlying stream.
  • Handle short reads and end-of-stream.
  • Do not retain a caller buffer across an async operation without an owner.
  • BinaryReader/Writer use little-endian primitives and UTF-8-oriented string routines in the implemented subset.
  • StreamReader has narrower decoding/BOM behavior than .NET; consult the I/O docs before porting arbitrary text files.

Filesystem

File, Directory, Path, metadata objects, random access, and FileSystemWatcher can reduce mechanical changes, but filesystem semantics remain native. Separators, roots, case sensitivity, permissions, links, sharing, rename atomicity, and error values differ by platform.

FileSystemWatcher is Linux/inotify-only at the pin and does not implement recursive watching. Callback self-disable is now safe; reconfiguration rules and notification masks remain documented reductions. Isolated storage now rejects path traversal through its confinement resolver, but it is not a complete adversarial OS sandbox.

Networking and HTTP

Separate address/DNS, sockets, HTTP messages, headers, JSON helpers, and WebSockets. Keep protocol behavior and transport security distinct. Current HttpClient transport is buffered plain HTTP/1.1; https:// is not supplied by a hidden TLS stack. WebSockets support ws://, not wss://.

Do not port a secure endpoint by changing only the type names

If the C# application requires TLS, certificate validation, redirects, proxy policy, HTTP/2, or secure WebSockets, provide an audited native transport or keep that subsystem outside the current Sharp Runtime transport.

DNS, raw ICMP, IPv6, interfaces, and socket permissions depend on the host. Async wrappers require explicit owner and buffer lifetime.

JSON

Text.Json supports documents, nodes, reader/writer shapes, options, converters, and curated template serialization over nlohmann JSON. It is not CLR reflection serialization. Prefer explicit native data shapes, validate required fields and numeric ranges, and test invalid JSON.

const std::vector<int> ids{3, 7, 11};
const std::string json =
    System::Text::Json::JsonSerializer::Serialize(ids);
const auto restored =
    System::Text::Json::JsonSerializer::Deserialize<
        std::vector<int>>(json);

HTTP/JSON helpers inherit both the JSON subset and the plain-HTTP transport boundary.

XML

Use Xml for reader/writer/document/XPath-shaped work and Xml.Linq for XName/XElement/XDocument-shaped object graphs. Do not infer complete schema, DTD, XPath extension, event, or LINQ provider semantics. Namespace and lexical handling has current remediation coverage, but every API used by the port still needs tests around malformed and namespaced input.

GC to RAII and explicit ownership

The collector no longer keeps reachable objects alive or breaks the need for deterministic resource protocols. For every reference field, local, callback, task, and container element, answer:

  1. Who owns the object?
  2. Can ownership transfer?
  3. Can more than one party keep it alive?
  4. Can the graph cycle?
  5. Which operations borrow it, and for how long?
  6. Which thread destroys it?

RAII members and unique_ptr should dominate. Add shared_ptr only when shared lifetime is a requirement, not as a blanket GC substitute. Break back-references with weak_ptr or a teardown protocol. Avoid finalizer-shaped cleanup; destructors and scoped guards are deterministic.

Reflection and activation

System::Type provides RTTI identity and hashing, not members, attributes, generic metadata, or dynamic invocation. Its classification properties are fixed stubs. General boxing is unavailable. Activator only supports explicitly implementable construction shapes; it is not assembly-driven activation.

Replace reflection with explicit registration, factories, templates, variants, generated tables, or virtual interfaces. This usually improves native link-time visibility and removes hidden dependencies.

Unsupported managed runtime behavior

  • Managed assemblies, JIT, GC reachability/finalization, and AppDomain assembly isolation.
  • General member reflection, DynamicInvoke, arbitrary boxing, and runtime generic metadata.
  • P/Invoke and remoting infrastructure.
  • C# language async/await, iterators, query expressions, pattern matching, and dynamic dispatch as syntax.
  • BinaryFormatter-style general object serialization.
  • CLR array covariance and universal object containers.

Some source patterns have good native equivalents; others should be removed from the architecture rather than emulated.

A complete small-class transformation

C#
public sealed class Counter
{
    public string Name { get; }
    public int? Limit { get; }
    public event EventHandler? Changed;
    public int Value { get; private set; }

    public Counter(string name, int? limit = null)
    {
        Name = name ?? throw new ArgumentNullException(nameof(name));
        Limit = limit;
    }

    public void Increment()
    {
        if (Limit.HasValue && Value >= Limit.Value)
            throw new InvalidOperationException("Limit reached.");
        Value++;
        Changed?.Invoke(this, EventArgs.Empty);
    }
}
C++ / Sharp Runtime
class Counter final : public System::Object
{
public:
    Counter(std::string name,
            System::Nullable<int> limit = {})
        : name_(std::move(name)), limit_(std::move(limit))
    {
        if (name_.empty())
            throw System::ArgumentException("name");
    }

    [[nodiscard]] const std::string&
        getNameProperty() const noexcept { return name_; }
    [[nodiscard]] const System::Nullable<int>&
        getLimitProperty() const noexcept { return limit_; }
    [[nodiscard]] int getValueProperty() const noexcept
        { return value_; }

    void Increment()
    {
        if (limit_.getHasValueProperty() &&
            value_ >= limit_.getValueProperty())
            throw System::InvalidOperationException(
                "Limit reached.");
        ++value_;
        Changed.Raise(this, System::EventArgs::Empty);
    }

    [[nodiscard]] const std::string&
        GetTypeName() const override
    {
        static const std::string name = "Counter";
        return name;
    }

    System::EventHandler<System::EventArgs> Changed;

private:
    std::string name_;
    System::Nullable<int> limit_;
    int value_ = 0;
};

The translation makes several choices explicit: empty is invalid under this constructor’s contract; values are owned directly; the nullable contains a value rather than a pointer; the event stores subscriptions and needs removal/lifetime discipline; Object participation is opt-in; and exception cleanup is ordinary C++ unwinding.

Verification checklist

  • Compile with C++23, warnings enabled, and only direct component targets.
  • Test normal, empty, malformed, boundary, and extreme numeric inputs.
  • Test copy/move and destruction for every resource-owning type.
  • Run ASan/UBSan for memory and undefined behavior; use TSan where concurrency is in scope.
  • Test Unicode with non-ASCII and malformed UTF-8, not just English literals.
  • Test callback removal, reentrancy, owner destruction, cancellation, and exception observation.
  • Test filesystem/network behavior on each claimed platform or narrow the claim.
  • Document every intentional reduction from the source contract.