PONYλM2Modula-2

C++.CodeCompared.To/C#

An interactive executable cheatsheet comparing C++ and C#

C++23 (GCC) C# 14 (.NET 10)
Hello World & Compilation
Hello, World
One line, and not because C# is hiding anything — the compiler synthesizes the class and entry point that C++ makes you write.
#include <iostream> int main() { std::cout << "Hello, World!" << std::endl; return 0; }
Console.WriteLine("Hello, World!");
These are top-level statements: the compiler wraps the file in a generated class with a Main, so the ceremony still exists and you no longer type it. The exit code works as you would expect — falling off the end means zero, and return 3; sets three. Console.WriteLine appends the newline and formats whatever you hand it by calling ToString, so there is no stream-operator chain and no format string to get wrong; Console.Write is the version without the newline.
IL and the JIT, not object files
The absence of header files is the difference you feel first, and it comes directly from what a compiled assembly contains.
// C++ compiles straight to machine code for one target: // // .cpp + headers → .o (machine code) → linker → executable // // Every translation unit re-parses every header it includes. The // binary is tied to one architecture and one ABI, and the ABI is not // standardized — two compilers cannot link each other's output. #include <iostream> int main() { std::cout << "machine code, one target, at build time" << std::endl; return 0; }
// C# compiles to IL, which is JIT-compiled to machine code at runtime: // // .cs → IL in an assembly (.dll) → JIT → machine code // // An assembly carries full type METADATA, so referencing one needs no // header — the compiler reads the types out of the .dll itself. That // is why C# has no declaration/definition split and no include graph. Console.WriteLine("IL at build time, machine code at startup");
Because a .dll carries complete type metadata, referencing a library means pointing at the assembly — there is nothing to #include, no declaration to keep in sync with a definition, no include guards and no include graph to recompile. Two consequences worth planning for: startup pays JIT cost (mitigated by tiered compilation, and avoidable with Native AOT, which trades away reflection), and the metadata that makes this work also makes decompiling trivial, so shipped C# is far more readable than shipped C++.
Value Types vs Reference Types
class and struct are not near-synonyms
This is the row to read twice. A C++ programmer reads class and struct as interchangeable, and in C# choosing between them decides copy semantics, storage, nullability and equality all at once.
#include <iostream> // In C++ these differ ONLY in default access. Both are value types, // both copy on assignment, both can live on the stack. struct PointStruct { int x; int y; }; class PointClass { public: int x; int y; }; int main() { PointStruct first{1, 2}; PointStruct second = first; // a COPY second.x = 99; PointClass third{1, 2}; PointClass fourth = third; // also a COPY fourth.x = 99; std::cout << first.x << " " << third.x << std::endl; return 0; }
var first = new PointStruct { X = 1, Y = 2 }; var second = first; // a COPY — struct is a VALUE type second.X = 99; var third = new PointClass { X = 1, Y = 2 }; var fourth = third; // a REFERENCE — both names, one object fourth.X = 99; Console.WriteLine($"{first.X} {third.X}"); // In C# this is the deepest distinction in the language. struct PointStruct { public int X; public int Y; } class PointClass { public int X; public int Y; }
A struct is a value type: it copies on assignment, lives inline (on the stack as a local, or embedded in its containing object), cannot be null, and compares by field. A class is a reference type: assignment copies the reference, the object is always on the heap, it can be null, and == compares identity by default. So the C++ column prints 1 1 and the C# column prints 1 99. The practical rule is that class is the default and struct is for small immutable value-like things — 16 bytes or so — because copying a large struct is exactly as expensive as it sounds.
Boxing
Boxing is the seam between the value world and the reference world, and it is the one performance trap in C# with no C++ counterpart.
#include <any> #include <iostream> int main() { int value = 42; // std::any is an explicit, opt-in type-erasing box. Nothing // converts to it silently. std::any boxed = value; std::cout << std::any_cast<int>(boxed) << std::endl; return 0; }
int value = 42; // A value type assigned to object is BOXED: heap-allocated, copied in, // and it happens implicitly with nothing at the call site to say so. object boxed = value; int unboxed = (int)boxed; Console.WriteLine($"{unboxed} {boxed.GetType().Name}"); // Two boxes of the same value are different objects. object again = value; Console.WriteLine(ReferenceEquals(boxed, again));
Assigning a value type to object, or to an interface it implements, allocates a heap box and copies the value into it — silently, with nothing at the call site to warn you. In a hot loop this is the difference between zero allocations and millions, which is why it dominates C# performance advice in game development. Generics avoid it (they are reified, so List<int> genuinely stores ints), and the modern APIs are built to avoid it too. The rule to carry over: watch for a value type crossing into object, dynamic, or a non-generic interface.
ref, out and in
C# splits C++'s single reference parameter into three keywords, and requires the caller to name the one being used.
#include <iostream> void doubleInPlace(int& value) { value *= 2; } // may modify void observe(const int& value) { std::cout << value << " "; } int main() { int reading = 21; doubleInPlace(reading); // no marker at the call site observe(reading); std::cout << std::endl; return 0; }
void DoubleInPlace(ref int value) { value *= 2; } void Observe(in int value) { Console.Write($"{value} "); } void TryParse(string text, out int result) { result = int.Parse(text); } int reading = 21; DoubleInPlace(ref reading); // "ref" is REQUIRED at the call site too Observe(reading); TryParse("7", out int parsed); // out: must be assigned by the callee Console.WriteLine(parsed);
The three differ in obligation: ref must be initialized by the caller and may be changed, out need not be initialized and must be assigned before the method returns, and in is a read-only reference — the closest thing to const T&, used to pass a large struct without copying. The part worth stealing is that the call site must repeat the keyword, so DoubleInPlace(ref reading) announces that the argument may change, which the equivalent C++ call does not. Note that reference types need none of this: they are already passed by reference.
Memory & Deterministic Cleanup
RAII becomes IDisposable and using
The garbage collector handles memory. It does not handle files, sockets, locks or GPU handles, which is what IDisposable is for.
#include <iostream> #include <string> class Transaction { public: explicit Transaction(std::string name) : name_(std::move(name)) { std::cout << "begin " << name_ << std::endl; } ~Transaction() { std::cout << "end " << name_ << std::endl; } private: std::string name_; }; void work() { Transaction transaction("payment"); // cleanup guaranteed by the TYPE std::cout << "working" << std::endl; } int main() { work(); return 0; }
void Work() { // "using" calls Dispose at scope exit, including on an exception. // The CALLER must remember to write it — the type cannot force it. using var transaction = new Transaction("payment"); Console.WriteLine("working"); } Work(); class Transaction : IDisposable { private readonly string name; public Transaction(string name) { this.name = name; Console.WriteLine($"begin {name}"); } public void Dispose() => Console.WriteLine($"end {name}"); }
A finalizer (~Transaction) exists and is almost always the wrong tool: it runs at an unpredictable time on a separate thread, may not run at all, and having one makes the object take two collections to reclaim. using is the real mechanism, and the difference from RAII is where the obligation sits — a C++ destructor is guaranteed by the type, while using is written by each caller, who can forget. The compensations are that the compiler warns when a disposable is not disposed and that using var (no braces needed) makes it a one-word habit.
A generational, compacting collector
C#'s collector is generational and compacting, which is a meaningfully different bargain from a reference-counted or manually-managed heap.
#include <iostream> #include <memory> #include <vector> int main() { // Deterministic: freed when the last owner drops it, and the // address never moves. Fragmentation is your problem. std::vector<std::shared_ptr<int>> holders; for (int index = 0; index < 3; index += 1) { holders.push_back(std::make_shared<int>(index)); } std::cout << holders.size() << " freed at scope exit" << std::endl; return 0; }
var holders = new List<object>(); for (int index = 0; index < 3; index++) { holders.Add(new int[16]); } // Objects MOVE during collection — the heap is compacted, so there is // no fragmentation and allocation is a pointer bump. Console.WriteLine($"{holders.Count} collected whenever the GC decides"); Console.WriteLine($"gen 0 collections so far: {GC.CollectionCount(0)}");
Allocation is a pointer bump into a contiguous nursery, so it is genuinely faster than malloc; short-lived objects die in generation 0 and cost almost nothing to collect; and compaction means fragmentation does not exist. The prices: objects move, so a raw address is only valid while pinned (see the unsafe section), and collection pauses are longer than Go's because .NET tuned for throughput rather than pause time — server GC and background collection reduce but do not eliminate them. For a C++ programmer the mental shift is that allocation rate matters more than allocation count, and the thing to optimize is what survives generation 0.
Classes, Properties & Records
Properties instead of getters and setters
A property is a method pair that reads like a field, which removes the reason C++ style guides insist on writing accessors for everything.
#include <iostream> class Rectangle { public: Rectangle(double width, double height) : width_(width), height_(height) {} // Changing a public field into a computed one later breaks every // caller, so the convention is to write accessors up front. double width() const { return width_; } void set_width(double value) { width_ = value; } double area() const { return width_ * height_; } private: double width_; double height_; }; int main() { Rectangle card(3.0, 4.0); card.set_width(6.0); std::cout << card.width() << " " << card.area() << std::endl; return 0; }
var card = new Rectangle(3.0, 4.0); card.Width = 6.0; // looks like a field, may run code Console.WriteLine($"{card.Width} {card.Area}"); class Rectangle(double width, double height) // primary constructor { public double Width { get; set; } = width; // auto-property public double Height { get; init; } = height; // set only at creation public double Area => Width * Height; // computed, reads as a field }
Because card.Width is already a method call, turning a stored value into a computed one — or adding validation, or logging — changes no caller and requires no recompilation of dependents. That is why C# code exposes properties where C++ exposes get_x()/set_x(). Three modifiers to know: get; set; is read/write, get; init; allows assignment only in an object initializer (the closest thing to a const member you can still set at construction), and => declares an expression-bodied read-only property. The primary constructor on the class line is C# 12 and later.
Records and value equality
A record is a reference type that behaves like a value: it compares by field and prints its contents, which is what C++ needs a defaulted operator== and a stream operator to achieve.
#include <iostream> struct Point { int x; int y; bool operator==(const Point& other) const = default; }; std::ostream& operator<<(std::ostream& stream, const Point& point) { return stream << "Point { x = " << point.x << ", y = " << point.y << " }"; } int main() { Point origin{0, 0}; Point same{0, 0}; std::cout << std::boolalpha << origin << " " << (origin == same) << std::endl; return 0; }
var origin = new Point(0, 0); var same = new Point(0, 0); var moved = origin with { X = 5 }; // copy with one field changed Console.WriteLine($"{origin} {origin == same} {moved}"); // One line generates the constructor, ToString, Equals, GetHashCode, // the deconstructor, and a "with" expression for non-destructive copies. record Point(int X, int Y);
The generated members are the ones every value-like type needs, and the one with no C++ equivalent is with: a non-destructive copy that changes named fields, which makes immutable-by-default designs practical. Note that a record is still a reference type — heap-allocated, nullable — that has merely had value semantics given to it, which is a distinction the previous section makes matter. record struct gives you a value type with the same generated members, and is the closer analogue of the C++ column here.
Inheritance & Interfaces
Single inheritance, and virtual is opt-in both ways
C# allows one base class and many interfaces, which removes the diamond problem, virtual bases, and object slicing in one decision.
#include <iostream> #include <memory> class Shape { public: virtual ~Shape() = default; virtual double area() const = 0; }; // Multiple inheritance is allowed, which is why virtual bases and the // diamond problem exist. class Square : public Shape { public: explicit Square(double side) : side_(side) {} double area() const override { return side_ * side_; } private: double side_; }; int main() { std::unique_ptr<Shape> shape = std::make_unique<Square>(3.0); std::cout << shape->area() << std::endl; return 0; }
Shape shape = new Square(3.0); Console.WriteLine(shape.Area); abstract class Shape { public abstract double Area { get; } } // ONE base class, any number of interfaces. No diamond problem, // no virtual bases, no object slicing. class Square(double side) : Shape { // "override" is REQUIRED, not optional — a typo cannot silently // create a new method instead of overriding one. public override double Area => side * side; }
Two smaller differences matter daily. override is mandatory rather than the optional courtesy it is in C++, so the classic bug where a slightly-wrong signature silently declares a new method is a compile error. And object slicing cannot happen: a Shape variable holds a reference, never a sliced copy, so assigning a Square to it keeps the whole object. Methods are non-virtual by default exactly as in C++, and sealed is the counterpart of marking a class final. Multiple implementation inheritance is approximated by default interface methods, added in C# 8.
Interfaces are declared, not structural
C# interfaces are nominal — a type must name the interface — which is the opposite of both C++ templates and Go's implicit satisfaction.
#include <iostream> #include <string> // A template accepts anything with the right shape, checked at // instantiation — structural, with no declaration required. template <typename Speaker> void introduce(const Speaker& speaker) { std::cout << speaker.speak() << std::endl; } struct Dog { std::string speak() const { return "woof"; } }; int main() { introduce(Dog{}); return 0; }
void Introduce(ISpeaking speaker) => Console.WriteLine(speaker.Speak()); Introduce(new Dog()); Console.WriteLine(new Dog() is ISpeaking); interface ISpeaking { string Speak(); } // The relationship is DECLARED. Dog must name ISpeaking to satisfy it, // so an interface written later cannot be applied to an existing type. class Dog : ISpeaking { public string Speak() => "woof"; }
The tradeoff is the usual one for nominal typing: nothing satisfies an interface by accident, tooling can enumerate implementations, and the runtime can dispatch on it — but you cannot make a third-party type satisfy an interface it does not declare, which needs an adapter class. Note that C# also has generics with constraints, so the structural style is available: void Introduce<T>(T speaker) where T : ISpeaking gets monomorphized dispatch instead of an interface call, at the cost of still requiring the declaration. The prefix I is a universal convention, not a rule.
Templates vs Generics
Generics are reified but constrained
C# generics sit between C++ templates and Java's erased ones: the type survives to runtime, and the body is checked up front against a declared constraint.
#include <iostream> #include <vector> // The body is checked at INSTANTIATION, so anything the type supports // is allowed — including arithmetic, without declaring it. template <typename Element> Element largest(const std::vector<Element>& values) { Element best = values[0]; for (const Element& value : values) { if (value > best) { best = value; } } return best; } int main() { std::cout << largest(std::vector<int>{3, 9, 2}) << std::endl; std::cout << largest(std::vector<double>{1.5, 0.5}) << std::endl; return 0; }
// The constraint is part of the signature, so the body is checked ONCE. // Without "where", the compiler would reject > as unproven. T Largest<T>(List<T> values) where T : IComparable<T> { T best = values[0]; foreach (T value in values) { if (value.CompareTo(best) > 0) best = value; } return best; } Console.WriteLine(Largest(new List<int> { 3, 9, 2 })); Console.WriteLine(Largest(new List<double> { 1.5, 0.5 }));
"Reified" means List<int> genuinely stores ints with no boxing, and typeof(T) works at runtime — unlike Java, where the type is erased. Value-type instantiations are specialized like a template; reference-type ones share one compiled body. What you give up against C++ is the metaprogramming: no specialization, no non-type parameters (so no std::array<int, 3>), no variadic type parameters, and the body may only use what the constraint proves. C# 11 added INumber<T>, which finally makes generic arithmetic expressible — for years it simply was not.
Collections & LINQ
Collections, and arrays that know their length
The containers map across almost one to one. The array is where they diverge, and it is the difference that removes a whole class of C++ bug.
#include <iostream> #include <string> #include <unordered_map> #include <vector> int main() { std::vector<int> readings{12, 7, 30}; readings.push_back(4); std::unordered_map<std::string, int> stock; stock["widget"] = 7; int raw[3] = {1, 2, 3}; // does NOT know its own length once passed std::cout << readings.size() << " " << stock.at("widget") << " " << sizeof(raw) / sizeof(raw[0]) << std::endl; return 0; }
List<int> readings = [12, 7, 30]; // collection expression, C# 12 readings.Add(4); var stock = new Dictionary<string, int> { ["widget"] = 7 }; int[] raw = [1, 2, 3]; // an OBJECT that carries its length Console.WriteLine($"{readings.Count} {stock["widget"]} {raw.Length}"); // Indexing a missing key throws; TryGetValue is the non-throwing form. Console.WriteLine(stock.TryGetValue("sprocket", out int count) ? count : 0);
A C# array is a heap object carrying its own length, so raw.Length is always right and never depends on whether the array has decayed to a pointer — the sizeof(raw)/sizeof(raw[0]) idiom and its failure mode simply do not exist. Every index is bounds-checked, throwing IndexOutOfRangeException rather than being undefined; the JIT elides most of those checks in loops it can prove safe. Note Dictionary's indexer throws on a missing key rather than inserting like std::unordered_map::operator[], which is the safer default, and TryGetValue is the comma-ok form.
LINQ vs ranges
C++20 ranges and LINQ solve the same problem, and LINQ arrived in 2007 — a good deal of what ranges look like is downstream of it.
#include <iostream> #include <ranges> #include <vector> int main() { std::vector<int> readings{1, 2, 3, 4, 5, 6}; auto pipeline = readings | std::views::filter([](int value) { return value % 2 == 0; }) | std::views::transform([](int value) { return value * value; }); int total = 0; for (int value : pipeline) { total += value; } std::cout << total << std::endl; return 0; }
int[] readings = [1, 2, 3, 4, 5, 6]; // Method syntax — lazy, exactly like a ranges view. int total = readings.Where(value => value % 2 == 0) .Select(value => value * value) .Sum(); // Query syntax compiles to the same calls. var squares = from value in readings where value % 2 == 0 select value * value; Console.WriteLine($"{total} {string.Join(",", squares)}");
Both are lazy, and both compose. The differences are in cost and reach: a ranges pipeline monomorphizes and typically compiles to the same code as a hand-written loop, while LINQ allocates an iterator object per stage and dispatches through delegates, so it is measurably slower — enough that Unity code often avoids it in per-frame paths. In exchange LINQ reaches further: the same query syntax runs against a database through IQueryable, where the expression tree is translated to SQL rather than executed. There is no C++ equivalent of that.
Error Handling
Exceptions, with one root and a finally
The mechanism is the one you know. Three details differ, and the first is a guarantee C++ cannot make.
#include <iostream> #include <stdexcept> #include <string> int parsePort(const std::string& text) { int value = std::stoi(text); if (value < 0) { throw std::out_of_range("negative"); } return value; } int main() { try { std::cout << parsePort("8080") << std::endl; std::cout << parsePort("-1") << std::endl; } catch (const std::exception& problem) { std::cout << "failed: " << problem.what() << std::endl; } // No finally: cleanup rides on destructors instead. return 0; }
int ParsePort(string text) { int value = int.Parse(text); if (value < 0) throw new ArgumentOutOfRangeException(nameof(text), "negative"); return value; } try { Console.WriteLine(ParsePort("8080")); Console.WriteLine(ParsePort("-1")); } catch (ArgumentOutOfRangeException problem) when (problem.ParamName == "text") { Console.WriteLine($"failed: {problem.Message.Split('(')[0].Trim()}"); } finally { Console.WriteLine("always runs"); }
Everything throwable derives from Exception, so catch (Exception) genuinely catches everything — where C++ permits throw 42, making catch (const std::exception&) incomplete and catch (...) give you no object. finally exists because there are no destructors to carry cleanup, and it is what using compiles into. And when is an exception filter that decides whether to catch without unwinding first, so the original stack is preserved for a crash dump — genuinely useful, and with no C++ equivalent. Note that C# has no noexcept and no exception specifications; that experiment was studied and deliberately not repeated.
Nullability
Nullable reference types
C# retrofitted null-tracking onto a language where every reference was already nullable, which is a harder problem than designing it in and shows in the result.
#include <iostream> #include <string> // A pointer may be null and the type does not say. A reference may // not be null and the compiler does not check that either — binding // one to a dereferenced null pointer is undefined behavior. void report(const std::string* text) { if (text == nullptr) { std::cout << "(none)" << std::endl; return; } std::cout << *text << std::endl; } int main() { std::string value = "hello"; report(&value); report(nullptr); return 0; }
#nullable enable // string — the compiler WARNS if null can reach it // string? — explicitly nullable, and using it unchecked warns void Report(string? text) { if (text is null) { Console.WriteLine("(none)"); return; } Console.WriteLine(text); // narrowed to non-null here } Report("hello"); Report(null); string? maybe = null; Console.WriteLine(maybe?.Length ?? -1); // ?. short-circuits, ?? defaults
With nullable reference types enabled, string means "should not be null" and string? means "may be", and the compiler performs flow analysis — after if (text is null) return; the variable is narrowed to non-null. The crucial caveat is that this is warnings, not enforcement: the annotations are erased at runtime, nothing stops a library compiled without them from handing you a null, and ! suppresses the warning outright. So it is closer to a linter than to Rust's Option. The operators are worth adopting mentally: ?. short-circuits, ?? supplies a default, ??= assigns only if null.
Methods, Delegates & Lambdas
Delegates and events
A delegate is roughly std::function with two additions: it is a real type in the type system, and it composes.
#include <functional> #include <iostream> #include <vector> int main() { // std::function type-erases and usually allocates. Combining // several handlers means holding a container of them yourself. std::vector<std::function<void(int)>> handlers; handlers.push_back([](int value) { std::cout << "first " << value << std::endl; }); handlers.push_back([](int value) { std::cout << "second " << value << std::endl; }); for (const auto& handler : handlers) { handler(7); } return 0; }
// A delegate is a first-class type, and it is MULTICAST: += chains // handlers onto one object, and invoking it calls them all in order. Action<int> handlers = value => Console.WriteLine($"first {value}"); handlers += value => Console.WriteLine($"second {value}"); handlers(7); // Func<...> is the returning form; the last type parameter is the result. Func<int, int> square = value => value * value; Console.WriteLine(square(7));
Multicast is the part with no C++ counterpart — += chains handlers onto a single delegate and invoking it calls each in turn, which is why the observer pattern needs no library here. event builds on that with encapsulation: outside code may += and -= but cannot invoke or clear it. A delegate also carries its receiver, so instance.Method is a valid delegate with the object bound in, replacing std::bind and pointer-to-member syntax. The cost is an allocation per delegate and an indirect call, so per-frame game code prefers passing a struct constrained to an interface.
Lambda capture has no capture list
C# captures by reference always, with no way to ask for a copy — and unlike C++, that can never dangle.
#include <iostream> #include <string> int main() { std::string prefix = "total: "; int running = 0; // The capture list is explicit and per-variable. Capturing by // reference and outliving the scope is a dangling reference. auto add = [&running, prefix](int value) { running += value; std::cout << prefix << running << std::endl; }; add(3); add(4); return 0; }
string prefix = "total: "; int running = 0; // No capture list. Variables are captured by REFERENCE, and the // compiler moves them to a heap object so nothing can dangle. Action<int> add = value => { running += value; Console.WriteLine($"{prefix}{running}"); }; add(3); add(4); Console.WriteLine(running); // 7 — the outer variable really changed
The compiler rewrites captured locals into fields of a generated heap class, so the variable outlives the scope it was declared in and the closure and the enclosing method genuinely share it — which is why running is 7 after the calls. A C++ lambda capturing by reference and outliving the scope is undefined behavior, and [&] makes that one character wide. The costs here are an allocation for the closure and the fact that you cannot opt out of sharing: to capture a snapshot you assign to a fresh local first. C# 9 added static lambdas, which refuse to capture at all — useful for proving a hot path allocates nothing.
Pattern Matching
Switch expressions and patterns
C# grew a real pattern-matching system between versions 7 and 11, and it does the work std::visit and if constexpr share between them in C++.
#include <iostream> #include <string> #include <variant> int main() { std::variant<int, std::string> message = std::string("hello"); std::visit([](const auto& value) { using Held = std::decay_t<decltype(value)>; if constexpr (std::is_same_v<Held, int>) { std::cout << "number " << value << std::endl; } else { std::cout << "text " << value << std::endl; } }, message); return 0; }
object message = "hello"; // A switch EXPRESSION: produces a value, matches on type and shape. string described = message switch { int number and > 100 => $"large number {number}", int number => $"number {number}", string text => $"text {text}", null => "nothing", _ => "something else", }; Console.WriteLine(described); // Patterns work in `is` too, with narrowing. if (message is string { Length: > 3 } longText) Console.WriteLine($"long: {longText}");
A switch expression produces a value rather than executing statements, so it fits in an initializer and needs no mutable variable. The patterns compose: type patterns bind (int number), relational patterns compare (> 100), and/or/not combine them, property patterns destructure ({ Length: > 3 }), and list patterns match sequences. Exhaustiveness is checked only for types the compiler can enumerate — enums and closed hierarchies — so a warning rather than the hard error Rust gives. The trailing _ is the discard arm.
async/await vs Coroutines
async/await, fifteen years earlier
C# shipped async/await in 2012 and essentially every language that has it since copied this design, C++ included — but C++ copied only the language half.
#include <iostream> #include <thread> // C++20 coroutines are a LOW-LEVEL mechanism: the language provides // co_await and the machinery to suspend, and you (or a library like // cppcoro or asio) must supply the promise type, the awaiter, and the // scheduler. There is no std::task in C++23. int main() { // So the ordinary answer remains a thread and a join. int result = 0; std::thread worker([&result]() { result = 42; }); worker.join(); std::cout << "result " << result << std::endl; return 0; }
// async/await shipped in C# 5 (2012) with the whole stack included: // the state machine, Task, the scheduler, and the library support. async Task<int> ComputeAsync() { await Task.Delay(1); // suspends; does NOT block a thread return 42; } int result = await ComputeAsync(); Console.WriteLine($"result {result}");
The compiler rewrites an async method into a state machine that suspends at each await, releasing the thread rather than blocking it, so a server handles thousands of concurrent operations on a small thread pool. C++20 gives you co_await and the customization points and no std::task, no scheduler and no async standard library, so using it means adopting asio or cppcoro and writing promise types. The traps in C# are worth naming: async void cannot be awaited and its exceptions crash the process (use it only for event handlers), and calling .Result on a Task deadlocks in contexts with a synchronization context.
Where C# Meets C++
Span<T> is std::span, and it cannot escape
This is where modern C# stops being a different world: Span<T> is std::span, and the runtime enforces the lifetime rule that C++ leaves to you.
#include <iostream> #include <span> #include <vector> int sum(std::span<const int> values) { int total = 0; for (int value : values) { total += value; } return total; } int main() { std::vector<int> readings{12, 7, 30, 4}; // A span outliving its vector dangles, and this compiles. std::cout << sum(readings) << " " << sum(std::span(readings).subspan(1, 2)) << std::endl; return 0; }
int Sum(ReadOnlySpan<int> values) { int total = 0; foreach (int value in values) total += value; return total; } int[] readings = [12, 7, 30, 4]; // Span is a ref struct: the compiler REFUSES to let it be boxed, stored // in a field, captured by a lambda, or held across an await — so it // cannot outlive what it points at. Console.WriteLine($"{Sum(readings)} {Sum(readings.AsSpan(1, 2))}"); // stackalloc, with no unsafe block needed when it feeds a Span. Span<int> scratch = stackalloc int[4]; scratch[0] = 7; Console.WriteLine(scratch[0]);
Span<T> is a ref struct, a type the compiler forbids from being boxed, stored in a class field, captured by a lambda, or held across an await — all the ways it could outlive its target. So the dangling-span bug that compiles in C++ does not compile here. It views arrays, strings, stackalloc memory and native pointers uniformly, which is why the modern .NET APIs are written against it: parsing, formatting and I/O now run with zero allocations. If you arrived expecting C# to be unusable for hot paths, this and the next row are the reasons that stopped being true around .NET Core 2.1.
unsafe, fixed, and pinning
C# has real pointers. The wrinkle a C++ programmer will not expect is that an address is only valid while you hold the object still.
#include <iostream> #include <vector> int main() { std::vector<int> readings{1, 2, 3}; // Every pointer is a raw pointer, nothing is marked, and the // address is stable because nothing moves objects behind you. int* data = readings.data(); std::cout << *(data + 1) << std::endl; return 0; }
// Pointers exist, in a block that must be marked — and the project // must opt in with <AllowUnsafeBlocks>. This row is illustrative // because the runner does not enable that flag. // // int[] readings = [1, 2, 3]; // // unsafe // { // // The GC MOVES objects, so the array must be PINNED first. // // Outside fixed, the address could be stale by the next line. // fixed (int* data = readings) // { // Console.WriteLine(*(data + 1)); // } // } // // GCHandle.Alloc(obj, GCHandleType.Pinned) is the long-lived form, and // pinning fragments the heap — so pin narrowly and briefly. int[] readings = [1, 2, 3]; Console.WriteLine(readings[1]);
The compacting collector moves objects, so taking a pointer into managed memory requires fixed, which pins the object for the duration of the block. Outside it the address may be stale by the next statement — a failure mode with no C++ analogue, since nothing relocates your objects there. Pinning also works against the collector by fragmenting the heap, so the guidance is to pin narrowly and briefly, and to prefer Span<T>, which gets most of the benefit with none of the pinning. Unmanaged memory from NativeMemory.Alloc never moves and needs no pinning at all.
P/Invoke & Native Interop
P/Invoke calls your C++ from C#
This is why a C++ programmer usually ends up writing C# at all — the engine or the core library is C++ and the tools, editor and gameplay layer are C#.
// The C++ side: an ordinary function, exported with C linkage so it // gets an unmangled symbol P/Invoke can find. // // extern "C" __attribute__((visibility("default"))) // int add_native(int first, int second) { return first + second; } // // Built as a shared library (libnative.so / native.dll / libnative.dylib). #include <iostream> extern "C" int add_native(int first, int second) { return first + second; } int main() { std::cout << add_native(2, 3) << std::endl; return 0; }
// The C# side declares the signature; the runtime finds the symbol and // generates the marshalling stub: // // [LibraryImport("native")] // private static partial int add_native(int first, int second); // // Console.WriteLine(add_native(2, 3)); // // LibraryImport (source-generated, .NET 7+) is preferred over the older // DllImport, which generates its stub at runtime and blocks Native AOT. // Standing in for the native call: a built-in that is itself native code. Console.WriteLine(Math.Abs(-5) + 0); Console.WriteLine(System.Runtime.InteropServices.Marshal.SizeOf<int>());
The rules that matter in practice: blittable types (integers, floats, and structs made only of them) cross the boundary with no conversion and no cost, while strings, arrays and anything with a managed layout are marshalled, which allocates and copies. The boundary is a real call and a real transition, so the design rule matches every other FFI — cross rarely with a lot of work, not often with a little. [StructLayout(LayoutKind.Sequential)] is required to make a struct match a C one, since the runtime is otherwise free to reorder fields. Unreal's C++/Blueprint split and Unity's C#-over-C++ engine are both this boundary.
What you keep and what you trade
Worth ending on the split, because for most C++ programmers the question is not whether to switch languages but which side of a boundary each piece belongs on.
// What C++ keeps that C# does not offer: // // Deterministic destruction → using / IDisposable, by convention // const-correctness → readonly, in, and immutability by design // Templates as metaprogramming → generics that only avoid duplication // Multiple inheritance → one base, many interfaces // Zero-overhead abstraction → mostly, since .NET Core // Predictable latency → GC pauses, tunable but present // No runtime, no dependencies → a runtime, or Native AOT #include <iostream> int main() { std::cout << "you control everything, and must" << std::endl; return 0; }
// What you get for it: // // Bounds-checked arrays, no undefined behavior as a category // No headers, no include graph, no ODR, no link-order surprises // One toolchain: dotnet build / test / run / publish, plus NuGet // LINQ, async/await, properties, records, pattern matching // Reflection and metadata that make tooling and serialization free // Span<T> and Native AOT where the hot path needs them // // The honest boundary: for a game engine, a driver, an allocator or a // hard-latency system, C++ is still the answer. For the tools, the // editor, the gameplay layer and the services around it, C# will be // faster to write and safer to change — which is exactly how Unreal and // Unity both ended up split along that line. Console.WriteLine("you control what matters, and delegate the rest");
The trade is narrower than it was. .NET Core closed most of the throughput gap, Span<T> made allocation-free hot paths ordinary, and Native AOT removes the runtime and the JIT for startup-sensitive work. What remains genuinely C++'s is deterministic destruction, hard latency bounds, direct hardware access, and the metaprogramming that makes zero-overhead abstraction possible. Notice that both major game engines drew the line in the same place, and that is a reasonable default: keep C++ where the microseconds and the memory layout matter, and take C# for everything built on top of it.