Hello World & Building
Hello, World
C#'s top-level statements hide the entry point; C++ still makes you write it. The
#include is not a using — it is the preprocessor pasting a file into yours before the compiler starts.Console.WriteLine("Hello, World!");#include <iostream>
int main() {
std::cout << "Hello, World!" << std::endl;
return 0;
}That distinction is worth having straight from the first row. A
using directive tells C# where to look for names that are already compiled into an assembly; #include copies text. So a C++ header is not a namespace, does not have to correspond to one, and re-parsing it for every source file is why C++ builds are slow. C++20 modules fix this and are rolling out unevenly across compilers and build systems.There is no runtime underneath
One compilation step rather than two, and nothing happens at run time that you did not write. The C++ binary has no metadata about its own types, which is why there is no reflection and no equivalent of
typeof returning something you can enumerate.// dotnet build → an assembly of IL, not machine code
// dotnet run → the CLR loads it, JIT-compiles each method the first
// time it is called, and manages the heap for you
//
// The same assembly runs on any platform with a .NET runtime.
long total = 0;
for (int index = 0; index < 1000; index++) total += index;
Console.WriteLine(total);// g++ -std=c++23 -O2 main.cpp -o program
//
// Out comes machine code for THIS processor and operating system.
// Nothing runs beneath it: no JIT, no collector, no metadata, no
// reflection. Startup is microseconds.
#include <iostream>
int main() {
long total = 0;
for (int index = 0; index < 1000; ++index) total += index;
std::cout << total << std::endl;
return 0;
}Two consequences a C# developer feels immediately. Everything that depends on run-time type information — serializers that read your properties, dependency injection containers that construct by type, mocking frameworks — has no C++ equivalent, and the replacement is code generation at build time or explicit registration you write. In return: startup in microseconds, no warmup, no collector pause, and a binary that does not need a runtime installed. NativeAOT is .NET moving toward the same trade, and it loses reflection for the same reason.
Value Types You Already Know
struct and class differ by ONE thing
🚨 This is the row to read twice. In C# the keyword decides value-versus-reference semantics; in C++ it decides only default access, and everything is a value type. A C++
class behaves like a C# struct.var pointOne = new Point(1, 2);
var pointTwo = pointOne; // a COPY: struct is a value type
pointTwo.X = 99;
var nodeOne = new Node(1);
var nodeTwo = nodeOne; // a REFERENCE: class is a reference type
nodeTwo.Value = 99;
Console.WriteLine($"struct: {pointOne.X}, class: {nodeOne.Value}");
struct Point { public int X; public int Y;
public Point(int x, int y) { X = x; Y = y; } }
class Node { public int Value;
public Node(int value) { Value = value; } }#include <iostream>
// In C++ the ONLY difference between struct and class is the default
// access: struct members are public, class members are private.
// BOTH are value types, and both are copied on assignment.
struct Point { int x; int y; };
class Node { public: int value; };
int main() {
Point pointOne{1, 2};
Point pointTwo = pointOne; // a copy
pointTwo.x = 99;
Node nodeOne{1};
Node nodeTwo = nodeOne; // ALSO a copy
nodeTwo.value = 99;
std::cout << "struct: " << pointOne.x << ", class: " << nodeOne.value << std::endl;
return 0;
}So the intuition to carry over is: treat every C++ type as if it were a C#
struct, and reach for a pointer or a reference when you want C# class behavior. There is no boxing, because there is nothing to box into — and no ref struct restriction either, since any type can live on the stack. Where C# ties the default to the kind of type — a struct is copied, a class is shared, and ref, in and out are the exceptions you write — C++ gives you one kind of type and has you choose the convention at each parameter, which is the next row.ref, in and out become &, const& and a return
The three C# parameter modifiers map almost exactly:
ref is &, in is const&, and out is best replaced by returning a value rather than writing through a parameter.int counter = 0;
Increment(ref counter); // caller writes ref too
var big = new Payload { Size = 1024 };
Report(in big); // by reference, read-only
if (TryParse("42", out int parsed))
Console.WriteLine($"{counter} {parsed}");
static void Increment(ref int value) => value++;
static void Report(in Payload payload) { }
static bool TryParse(string text, out int value) =>
int.TryParse(text, out value);
struct Payload { public int Size; }#include <iostream>
#include <optional>
#include <string>
struct Payload { int size; };
void increment(int& value) { value++; } // ref
void report(const Payload& payload) { (void)payload; } // in
// out has no direct equivalent — return an optional instead.
std::optional<int> tryParse(const std::string& text) {
try { return std::stoi(text); } catch (...) { return std::nullopt; }
}
int main() {
int counter = 0;
increment(counter); // no keyword at the call site
Payload big{1024};
report(big);
if (auto parsed = tryParse("42")) {
std::cout << counter << " " << *parsed << std::endl;
}
return 0;
}🚨 The difference that bites is that C++ does not mark the call site. C# makes you write
Increment(ref counter), so a reader can see the variable may change; in C++ increment(counter) looks identical to a by-value call, and only the signature says otherwise. That is why const& is written everywhere in good C++ — it is the only way a reader can tell a function will not modify what it was handed. There is no ref return ceremony either: returning int& just works, and dangling is your problem.You control what copying means
A C++ copy is a member-by-member copy, and a member that is itself a container copies its contents — so copying is deep by default, all the way down.
// A struct copy is a bitwise copy of the fields, always. There is no
// copy constructor to write and no way to intervene — which is why a
// struct holding a reference gives you a SHALLOW copy and nothing
// warns you.
var first = new Holder { Items = new List<int> { 1, 2 } };
var second = first; // copies the reference field
second.Items.Add(3);
Console.WriteLine($"first sees {first.Items.Count} items");
struct Holder { public List<int> Items; }#include <iostream>
#include <vector>
struct Holder {
std::vector<int> items;
// The compiler generates a DEEP copy: copying a Holder copies its
// vector, element by element. You can intervene if you need to.
Holder(const Holder& other) : items(other.items) {
std::cout << "copied " << items.size() << " items" << std::endl;
}
Holder(std::vector<int> items) : items(std::move(items)) {}
};
int main() {
Holder first{{1, 2}};
Holder second = first; // deep copy, and it announces itself
second.items.push_back(3);
std::cout << "first sees " << first.items.size() << " items" << std::endl;
return 0;
}That is the reverse of the C# situation, where a struct copy is shallow and a contained reference is shared. It is also the reason C++ has the rule of five: a class that manages a resource must say what copying, moving and destroying mean (copy constructor, copy assignment, move constructor, move assignment, destructor), or the generated versions will do the wrong thing. The modern answer is the rule of zero — hold members that manage themselves, such as
std::vector and std::unique_ptr, and write none of the five.Memory & Lifetime
using becomes the destructor
The bare braces are the whole construct. There is no
using keyword because a destructor runs at the closing brace unconditionally — including when an exception unwinds through it.using (var held = new Held("resource"))
{
Console.WriteLine("working");
}
Console.WriteLine("after the block");
class Held : IDisposable
{
private readonly string name;
public Held(string name) { this.name = name; Console.WriteLine($"opened {name}"); }
public void Dispose() => Console.WriteLine($"closed {name}");
}#include <iostream>
#include <string>
class Held {
public:
Held(std::string name) : name_(std::move(name)) {
std::cout << "opened " << name_ << std::endl;
}
~Held() { std::cout << "closed " << name_ << std::endl; }
private:
std::string name_;
};
int main() {
{
Held held("resource"); // no using, no IDisposable, no Dispose()
std::cout << "working" << std::endl;
} // ← destructor runs HERE
std::cout << "after the block" << std::endl;
return 0;
}🚨 The difference is who is responsible. C# puts the obligation on every call site: forget the
using and Dispose never runs — without a word from the compiler, since the analyzer rule that catches it is not on by default — which is why IDisposable has to propagate up through every type that holds one. C++ puts it in the type, so a caller cannot forget. This is RAII, it is the organizing idea of the language, and it is the thing most worth internalizing — files, locks, transactions and allocations are all objects whose destructors release what they hold.Smart pointers, and which one to reach for
unique_ptr is a pointer that owns what it points at and cannot be copied, so exactly one variable is responsible for freeing — and the compiler enforces it. shared_ptr is reference counting, opted into per object.// Every reference is equal, and the collector decides when the object
// dies. There is no way to say "this one variable owns it".
var owner = new Resource("buffer");
var alias = owner;
owner = null; // alias still keeps it alive
Console.WriteLine($"still reachable: {alias.Label}");
class Resource
{
public string Label { get; }
public Resource(string label) { Label = label; }
}#include <iostream>
#include <memory>
#include <string>
struct Resource {
std::string label;
~Resource() { std::cout << "freed " << label << std::endl; }
};
int main() {
// unique_ptr: exactly one owner, enforced by the compiler.
auto owner = std::make_unique<Resource>("buffer");
// auto alias = owner; // ← will not compile
auto moved = std::move(owner); // ownership transferred
std::cout << "owner is empty: " << (owner == nullptr) << std::endl;
// shared_ptr: reference counted, for genuinely shared ownership.
auto shared = std::make_shared<Resource>("cache");
auto second = shared;
std::cout << "references: " << shared.use_count() << std::endl;
return 0;
}The rule of thumb is
unique_ptr by default and shared_ptr only when ownership is genuinely shared, because the shared count is atomic and copying one is not free. Note that shared_ptr has the weakness .NET's collector does not: a cycle never reaches zero and leaks, which is what weak_ptr is for. A C# developer's instinct — that any reference may be kept anywhere — is exactly what these types exist to stop you doing accidentally.No finalizers, and no need for them
The entire Dispose pattern — the
Dispose(bool disposing) overload, the finalizer, GC.SuppressFinalize — exists to paper over non-deterministic cleanup. Delete all of it.// The Dispose pattern exists because a finalizer is the only backstop
// when a caller forgets Dispose — and it runs on a separate thread at
// an unpredictable time, possibly never before process exit.
//
// ~Held() { Dispose(false); } // finalizer
// GC.SuppressFinalize(this); // in Dispose()
//
// Every line of that ceremony exists to work around non-deterministic
// cleanup.
Console.WriteLine("the Dispose pattern is a workaround");#include <iostream>
// There is no backstop, because none is needed: the destructor runs
// deterministically, at the closing brace or when the owning object
// is destroyed. No finalizer, no SuppressFinalize, no Dispose(bool).
struct Held {
~Held() { std::cout << "closed, deterministically" << std::endl; }
};
int main() {
{ Held held; }
std::cout << "the pattern is unnecessary" << std::endl;
return 0;
}What replaces it is nothing: write a destructor and stop. The obligation that comes with that is real though — a destructor must not throw (an exception escaping one during stack unwinding terminates the program), and a base class you delete through needs a virtual destructor. Those two rules are the whole of what a C# developer has to learn here, and they replace considerably more ceremony than they add.
Types
Integers and their edges
C# and C++ agree that unsigned arithmetic wraps. They disagree sharply about signed overflow, and the C++ answer is the one with no C# counterpart at all.
int count = 42;
uint unsignedCount = 42;
long big = 1L << 62;
Console.WriteLine($"int max: {int.MaxValue}");
// Overflow at run time WRAPS silently by default, and is defined
// behavior (a constant expression that overflows will not compile):
int max = int.MaxValue;
Console.WriteLine($"int max + 1: {unchecked(max + 1)}");
// checked makes it throw instead:
try { Console.WriteLine(checked(max + 1)); }
catch (OverflowException) { Console.WriteLine("checked: threw"); }#include <cstdint>
#include <iostream>
#include <limits>
int main() {
std::int32_t count = 42;
std::uint32_t unsignedCount = 42;
(void)count; (void)unsignedCount;
std::cout << "int max: " << std::numeric_limits<std::int32_t>::max() << std::endl;
// Unsigned overflow wraps and is defined:
std::uint32_t wrapped = std::numeric_limits<std::uint32_t>::max();
std::cout << "uint max + 1: " << wrapped + 1 << std::endl;
// 🚨 SIGNED overflow is UNDEFINED BEHAVIOR — not wrapping, not an
// exception. The optimizer may assume it cannot happen.
return 0;
}🚨 There is no
checked in C++ and no OverflowException: signed overflow is undefined behavior, which means the compiler may assume it does not occur and optimize accordingly — a guard written as if (value + 1 < value) can be deleted as provably false. Use -fsanitize=undefined during development, and __builtin_add_overflow or C++26's checked arithmetic when it matters. The other difference is that int and long have no fixed size in C++, so <cstdint> is what you write when the width matters.Nullable becomes optional
int? is Nullable<int> and std::optional<int> is the same idea with the same storage strategy — a value plus a flag, no allocation.int? found = FindEven(new[] { 1, 3, 5 });
if (found is null)
Console.WriteLine("nothing found");
else
Console.WriteLine($"found {found.Value}");
static int? FindEven(int[] values)
{
foreach (var value in values)
if (value % 2 == 0) return value;
return null;
}#include <iostream>
#include <optional>
#include <vector>
std::optional<int> findEven(const std::vector<int>& values) {
for (int value : values) if (value % 2 == 0) return value;
return std::nullopt;
}
int main() {
std::optional<int> found = findEven({1, 3, 5});
if (!found.has_value()) {
std::cout << "nothing found" << std::endl;
} else {
std::cout << "found " << *found << std::endl;
}
return 0;
}Where they part company is reference types. C# added nullable reference annotations, so
string? versus string is checked by the compiler; C++ has no such thing, and a raw pointer may always be null with nothing tracking it. So std::optional is used where C# uses int?, and for reference-like cases the convention is a reference (which cannot be null) when the value is required, and a pointer when it is not. value_or(0) is ?? 0, and there is no ?. operator.var becomes auto, with a catch
auto and var both ask the compiler to write the type, and neither makes anything dynamic. The catch is what auto deduces.var count = 42; // int
var names = new List<string> { "a" }; // List<string>
// var never changes what the type IS — it is inference, not dynamic.
Console.WriteLine($"{count} {names.Count}");
foreach (var name in names) Console.WriteLine(name);#include <iostream>
#include <string>
#include <vector>
int main() {
auto count = 42; // int
auto names = std::vector<std::string>{"a"}; // std::vector<std::string>
std::cout << count << " " << names.size() << std::endl;
// 🚨 auto DROPS references and const by default, so this copies
// every element. The C# foreach never copies.
for (const auto& name : names) std::cout << name << std::endl;
return 0;
}🚨 Plain
auto strips references and const, so auto item = collection[0] makes a copy, and for (auto item : collection) copies every element as it goes — invisible, legal, and a common reason a loop is mysteriously slow. Write const auto& when you mean to observe and auto& when you mean to modify. C# has no equivalent trap because foreach over a reference-type collection never copies the objects.Strings
Strings are mutable and byte-oriented
Two differences at once, and the second is the one that causes bugs: a
std::string is mutable, and it holds bytes with no idea what encoding they are in.string text = "héllo";
string more = text + " world"; // a NEW string; String is immutable
Console.WriteLine(more);
Console.WriteLine(text.Length); // 5 — UTF-16 code units
Console.WriteLine(more.ToUpper());#include <algorithm>
#include <iostream>
#include <string>
int main() {
std::string text = "héllo"; // UTF-8 BYTES from the source file
text += " world"; // modified IN PLACE where it can be
std::cout << text << std::endl;
std::cout << text.size() << std::endl; // 12 — bytes, not characters
std::string shouted = text;
std::ranges::transform(shouted, shouted.begin(), ::toupper);
std::cout << shouted << std::endl;
return 0;
}So
size() counts bytes where Length counts UTF-16 code units — for "héllo" that is 6 against 5. Here Length happens to equal the number of characters, but not in general: an emoji such as 😀 is two UTF-16 code units, and a flag is four. Indexing, substrings and reversal are all byte operations, so any C# code that manipulates text by position has to be rethought rather than translated. There is also no StringBuilder, because it is unnecessary: appending to a std::string amortizes, exactly as List<T> does. std::format borrowed its mini-language from Python, so {:.2f} works but $"{name}" interpolation does not exist.Collections & LINQ
List and Dictionary
std::vector is List<T> and std::map is SortedDictionary (std::unordered_map being the plain Dictionary). The structured binding auto& [key, value] is deconstruction, and it works on any struct.var values = new List<int> { 3, 1, 4 };
values.Add(1);
values.Insert(0, 9);
Console.WriteLine($"{values.Count} {values[0]} {values[^1]}");
var counts = new SortedDictionary<string, int> { ["apple"] = 2, ["pear"] = 5 };
counts["plum"] = 1;
Console.WriteLine(counts.ContainsKey("fig"));
foreach (var pair in counts) Console.WriteLine($"{pair.Key} {pair.Value}");#include <iostream>
#include <map>
#include <string>
#include <vector>
int main() {
std::vector<int> values{3, 1, 4};
values.push_back(1);
values.insert(values.begin(), 9);
std::cout << values.size() << " " << values.front() << " " << values.back() << std::endl;
std::map<std::string, int> counts{{"apple", 2}, {"pear", 5}};
counts["plum"] = 1;
std::cout << (counts.contains("fig") ? "True" : "False") << std::endl;
for (const auto& [key, value] : counts) std::cout << key << " " << value << std::endl;
return 0;
}🚨 Two traps.
counts["fig"] on a missing key does not throw KeyNotFoundException — it inserts a default-constructed value and returns it, so a read-only-looking loop can grow the map; use .at() to throw or .contains() to ask. And values[10] on a three-element vector is undefined behavior rather than an ArgumentOutOfRangeException; .at(10) is the checked version. There is no ^1 index-from-end operator, so the last element is .back().LINQ becomes ranges
Ranges are LINQ, arrived at independently and about ten years later: lazy, composable, and evaluated only when something pulls values through.
Where is filter and Select is transform.var values = new List<int> { 1, 2, 3, 4, 5, 6 };
var total = values
.Where(value => value % 2 == 0)
.Select(value => value * value)
.Sum();
Console.WriteLine(total);#include <iostream>
#include <ranges>
#include <vector>
int main() {
std::vector<int> values{1, 2, 3, 4, 5, 6};
auto squaresOfEvens = values
| std::views::filter([](int value) { return value % 2 == 0; })
| std::views::transform([](int value) { return value * value; });
int total = 0;
for (int value : squaresOfEvens) total += value;
std::cout << total << std::endl;
return 0;
}The pipe reads left to right the way method chaining does, so the shape transfers directly. What does not transfer is the breadth — there is no
GroupBy, no OrderBy in views (sorting needs a container), no ToDictionary, and the fold operations live in <numeric> as std::accumulate or C++23's std::ranges::fold_left. | std::ranges::to<std::vector>() is ToList(). And there is no query syntax and no IQueryable, so nothing translates a range pipeline into SQL.Modifying while iterating
C#'s
List<T> and Dictionary<TKey, TValue> throw an InvalidOperationException when they are modified during a foreach (an array or a concurrent collection does not). C++ gives you nothing at all — the iterators simply become invalid, and using them is undefined behavior.var values = new List<int> { 1, 2, 3 };
try
{
foreach (var value in values)
if (value == 2) values.Add(99); // detected, and it throws
}
catch (InvalidOperationException)
{
Console.WriteLine("collection was modified");
}#include <iostream>
#include <vector>
int main() {
std::vector<int> values{1, 2, 3};
std::cout << "capacity before: " << values.capacity() << std::endl;
// Adding during iteration may REALLOCATE, which invalidates every
// iterator, reference and pointer into the vector. Nothing detects
// it: the loop reads freed memory and appears to work.
values.push_back(99);
std::cout << "capacity after: " << values.capacity() << std::endl;
for (int value : values) std::cout << value << ' ';
std::cout << std::endl;
return 0;
}The rules are per container and worth learning:
std::vector invalidates everything on reallocation and everything after the point of an insert or erase; std::list and std::map invalidate only what you erased. erase returns the next valid iterator, which is why the removal idiom is it = values.erase(it). C++20 added std::erase_if(values, predicate), which is RemoveAll and is what you should reach for first.Classes & Inheritance
virtual and override transfer almost exactly
Both languages require
virtual on the base and both accept override on the derived, so this is one of the closest correspondences on the page. The addition is the virtual destructor.Report(new Square());
static void Report(Shape shape) => Console.WriteLine($"area: {shape.Area()}");
class Shape
{
public virtual double Area() => 0;
}
class Square : Shape
{
public override double Area() => 4;
}#include <format>
#include <iostream>
struct Shape {
virtual double area() const { return 0; }
virtual ~Shape() = default; // ← the one addition C# does not need
};
struct Square : Shape {
double area() const override { return 4; }
};
void report(const Shape& shape) {
std::cout << std::format("area: {}", shape.area()) << std::endl;
}
int main() {
Square square;
report(square);
return 0;
}Three C++ specifics. A base class you delete through must have a virtual destructor, or the derived part is never destroyed — undefined behavior, and the compiler does not warn by default.
override is optional here, while C# needs it to override at all — leave it off there and the method hides the base one, with a warning — and it should always be written, because it turns a mistyped signature into a compile error. And report takes const Shape& rather than Shape: passing by value would slice the object down to its base, copying only the Shape part and calling the base area() — a bug C# cannot have, because class instances are never copied by value.Interfaces become abstract classes
There is no
interface keyword. The equivalent is an abstract class whose methods are all pure virtual — the = 0 — and which therefore cannot be instantiated.var box = new Box();
Console.WriteLine($"{box.Name()} {box.Size()}");
interface INamed { string Name(); }
interface ISized { int Size(); }
class Box : INamed, ISized
{
public string Name() => "box";
public int Size() => 3;
}#include <iostream>
#include <string>
// An "interface" is an abstract class with only pure virtual methods.
struct Named {
virtual std::string name() const = 0; // = 0 makes it pure virtual
virtual ~Named() = default;
};
struct Sized {
virtual int size() const = 0;
virtual ~Sized() = default;
};
// Multiple inheritance is allowed, of implementation as well as interface.
struct Box : Named, Sized {
std::string name() const override { return "box"; }
int size() const override { return 3; }
};
int main() {
Box box;
std::cout << box.name() << " " << box.size() << std::endl;
return 0;
}C++ allows multiple inheritance of implementation, not merely of interface, which is more power and a known hazard: inheriting the same base twice gives you two copies of its state unless you use
virtual inheritance, the diamond problem C# avoided by forbidding more than one base class. C# default interface methods carry no state, and when two of them meet in a diamond the compiler refuses the class until it picks one. Two things worth knowing: an interface with no data costs a vtable pointer per object, and templates plus concepts give you compile-time polymorphism with no virtual call at all, which is often the better tool where C# would use a generic constrained by an interface.Properties go, operators stay
Operator overloading transfers — it is one of the few places C++ and C# agree closely — but properties do not: a computed value is a method, and the call site says so with parentheses.
var total = new Money(150) + new Money(275);
Console.WriteLine(total);
readonly struct Money
{
private readonly int cents;
public Money(int cents) { this.cents = cents; }
public int Dollars => cents / 100; // a property
public static Money operator +(Money a, Money b) => new(a.cents + b.cents);
public override string ToString() => $"{cents / 100}.{cents % 100:00}";
}#include <format>
#include <iostream>
class Money {
public:
explicit Money(int cents) : cents_(cents) {}
int dollars() const { return cents_ / 100; } // a method, with parentheses
Money operator+(const Money& other) const { return Money(cents_ + other.cents_); }
friend std::ostream& operator<<(std::ostream& stream, const Money& money) {
return stream << std::format("{}.{:02d}", money.cents_ / 100, money.cents_ % 100);
}
private:
int cents_;
};
int main() {
std::cout << (Money(150) + Money(275)) << std::endl;
return 0;
}The gain from losing properties is that you can always tell by looking whether
money.dollars is a field or arbitrary code. The consequence for a library author is the one C# advice already warns about — there, turning a public field into a property breaks compiled callers and any caller that passes it by ref — except that C++ has no property to turn it into, so C++ code writes the accessor from the start. explicit has no C# equivalent and is worth adopting — without it, a single-argument constructor makes an implicit conversion the compiler will apply silently.Templates vs Generics
Templates are generics without the type at run time
C# generics are reified — the type argument survives to run time — which puts C# between Java's erasure and C++'s templates. C++ goes further the other way: a template is a recipe, and each instantiation is separately generated code.
Console.WriteLine(Largest(new List<int> { 3, 9, 2 }));
Console.WriteLine(Largest(new List<string> { "pear", "apple" }));
// One generic method exists at run time, and the type argument is
// REIFIED: typeof(Element) works, and List<int> stores ints unboxed.
static Element Largest<Element>(List<Element> values)
where Element : IComparable<Element>
{
var best = values[0];
foreach (var value in values)
if (value.CompareTo(best) > 0) best = value;
return best;
}#include <iostream>
#include <string>
#include <vector>
// A recipe. The compiler writes a SEPARATE function for each type
// used, so there is nothing generic left in the machine code.
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<int>({3, 9, 2}) << std::endl;
std::cout << largest<std::string>({"pear", "apple"}) << std::endl;
return 0;
}Because it is separately generated, a template can do things a C# generic cannot: specialize for a particular type, compute with the type, take non-type parameters (
template <int Size>), and inline everything so a comparison costs nothing. The prices are compile time, code size, and error messages that describe a failure in terms of instantiated types — which is what C++20 concepts exist to fix, and which map almost exactly onto C#'s where clauses.Concepts are where clauses
A concept is a
where clause with more expressive power: rather than "implements this interface", it says "these expressions must compile", which is how it can require an operator.Console.WriteLine(Smallest(new List<int> { 3, 9, 2 }));
// The constraint is declared, and the method is checked on its own —
// a caller passing a type that does not satisfy it fails at the CALL.
static Element Smallest<Element>(List<Element> values)
where Element : IComparable<Element>
{
var best = values[0];
foreach (var value in values)
if (value.CompareTo(best) < 0) best = value;
return best;
}#include <concepts>
#include <iostream>
#include <vector>
// The requirement, written down and enforced before instantiation.
template <typename Element>
concept LessThanComparable = requires(Element a, Element b) {
{ a < b } -> std::convertible_to<bool>;
};
template <LessThanComparable Element>
Element smallest(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 << smallest<int>({3, 9, 2}) << std::endl;
return 0;
}That is the practical difference. A C# constraint names what a type is — a base type, an interface,
new(), struct, class, notnull or unmanaged — never an expression that must compile, so "any type supporting +" is spelled by naming an interface that declares the operator, such as IAdditionOperators<T, T, T> or INumber<T>, both added only recently. A concept says it directly. The payoff a C++ developer notices first is the error message: a violated concept names the constraint instead of producing pages of instantiation backtrace.Errors
Exceptions, and a culture that avoids them
The mechanics line up, with two changes: catch by
const reference (catching by value can slice a derived exception down to its base), and there is no finally.try
{
Console.WriteLine(Parse("42"));
Console.WriteLine(Parse("oops"));
}
catch (ArgumentException error)
{
Console.WriteLine($"caught: {error.Message}");
}
finally
{
Console.WriteLine("finally runs");
}
static int Parse(string text)
{
if (!int.TryParse(text, out var value))
throw new ArgumentException($"not a number: {text}");
return value;
}#include <iostream>
#include <stdexcept>
#include <string>
int parse(const std::string& text) {
for (char character : text) {
if (character < '0' || character > '9') {
throw std::invalid_argument("not a number: " + text);
}
}
return std::stoi(text);
}
int main() {
try {
std::cout << parse("42") << std::endl;
std::cout << parse("oops") << std::endl;
} catch (const std::invalid_argument& error) { // by CONST REFERENCE
std::cout << "caught: " << error.what() << std::endl;
}
// No finally — a destructor does that job. See the Memory section.
std::cout << "finally runs" << std::endl;
return 0;
}The cultural difference is larger than the syntactic one. .NET throws freely and an exception carries a stack trace; C++ exceptions carry no trace at all, cost more when thrown, and whole domains — games, embedded, some finance — compile with
-fno-exceptions, which means a library that throws cannot be used there. So idiomatic modern C++ returns std::expected<T, E> for failures that are expected, and reserves exceptions for the genuinely exceptional. There is no when filter and no catch by exception property.std::expected is your TryParse, generalized
std::expected<int, std::string> is the Try pattern with the failure carrying information: one return value holding either the answer or an error, with the error type in the signature.if (int.TryParse("oops", out var value))
Console.WriteLine($"parsed {value}");
else
Console.WriteLine("failed: not a number");
// The Try- pattern is everywhere in .NET precisely because throwing
// for an expected failure is wasteful — but TryParse's bool reports
// only success or failure, never WHY.#include <expected>
#include <iostream>
#include <string>
std::expected<int, std::string> parse(const std::string& text) {
for (char character : text) {
if (character < '0' || character > '9') {
return std::unexpected("not a number");
}
}
return std::stoi(text);
}
int main() {
auto result = parse("oops");
if (result) {
std::cout << "parsed " << *result << std::endl;
} else {
std::cout << "failed: " << result.error() << std::endl;
}
return 0;
}That is the improvement over
TryParse: a bool can say something went wrong but not what. .NET APIs that need to explain a failure either throw or, like Base64.DecodeFromUtf8, return a status value such as OperationStatus.InvalidData. C++23's version composes too — and_then and transform chain operations that may fail without nesting if statements. The caller cannot reach the value without acknowledging the error case, since dereferencing a failed expected is undefined, so the check is not optional in practice.Span, stackalloc and unsafe
Span<T> is std::span
This is the row where a C# developer is already fluent.
Span<T> and std::span are the same thing — a pointer and a length, no ownership, no copying — and they behave identically here.int[] values = { 10, 20, 30, 40, 50 };
Span<int> middle = values.AsSpan(1, 3); // a VIEW, no copying
foreach (var value in middle) Console.Write($"{value} ");
Console.WriteLine();
middle[0] = 99; // writes through
Console.WriteLine(values[1]);#include <iostream>
#include <span>
#include <vector>
int main() {
std::vector<int> values{10, 20, 30, 40, 50};
std::span<int> middle(values.begin() + 1, 3); // a view, no copying
for (int value : middle) std::cout << value << ' ';
std::cout << std::endl;
middle[0] = 99; // writes through
std::cout << values[1] << std::endl;
return 0;
}The difference is what enforces the safety. C# makes
Span<T> a ref struct so the compiler forbids storing one on the heap, capturing it in a lambda or using it across an await. C++ has no such rule: a std::span may be stored anywhere, and it dangles the moment the container behind it reallocates or dies. So the C# habits transfer, but the guardrail does not — the discipline must be yours.unsafe becomes ordinary
Everything
unsafe gates is simply the language here. There is no fixed block because nothing moves objects behind your back — no compacting collector, so an address stays an address.// Pointers exist, behind the unsafe keyword and a project setting
// (AllowUnsafeBlocks, on for these examples), and inside a fixed block
// because the collector can MOVE objects:
unsafe
{
int[] values = { 1, 2, 3 };
fixed (int* pointer = values)
{
Console.WriteLine(*(pointer + 1));
}
}
// stackalloc gives you a stack buffer that no allocator touches.
Span<int> scratch = stackalloc int[3];
scratch[0] = 42;
Console.WriteLine(scratch[0]);
#include <iostream>
int main() {
// No keyword, no flag, no fixed block — nothing moves objects, so
// there is nothing to pin. This is just how C++ works.
int values[] = {1, 2, 3};
int* pointer = values;
std::cout << *(pointer + 1) << std::endl;
// A stack array IS stackalloc, and needs no special spelling.
int scratch[3] = {};
scratch[0] = 42;
std::cout << scratch[0] << std::endl;
return 0;
}Which is why a C# developer who has used
Span<T>, stackalloc and unsafe arrives better prepared than almost anyone: the mental model is already right, and what changes is that the guardrails are gone rather than that the ideas are new. The habits worth keeping are the ones fixed taught — know how long a pointer is valid, and never let one outlive what it points at.P/Invoke & The Boundary
P/Invoke, from the other side
extern "C" is the whole of what the C++ side must do: it turns off name mangling so the symbol is called total rather than _Z5totalPKll, and it fixes the calling convention.// The declaration you have written before, and what it implies:
//
// [DllImport("fastmath", CallingConvention = CallingConvention.Cdecl)]
// static extern long Total(long[] values, long count);
//
// long sum = Total(new long[] { 1, 2, 3 }, 3);
//
// The array is PINNED for the duration of the call and marshalled by
// the runtime. Get the signature wrong and there is no error — just a
// wrong number, or a crash some calls later.
Console.WriteLine("the declaration is written twice, in two languages");// The C++ side of that declaration. extern "C" is what stops the name
// being mangled to _Z5totalPKll, so DllImport can find it by name.
//
// g++ -std=c++23 -shared -fPIC fastmath.cpp -o libfastmath.so
#include <numeric>
extern "C" long total(const long* values, long count) {
return std::accumulate(values, values + count, 0L);
}
// Everything the boundary needs is here: C linkage, a plain pointer
// and a count, and no C++ types in the signature — no std::vector,
// no std::string, no class with virtual methods.The rule that follows is the one that decides how an interop layer is designed: only C types cross the boundary. No
std::vector, no std::string, no class with virtual methods, no exceptions — a C++ exception crossing into managed code is undefined. So a C++ library gets a thin C façade of functions taking pointers, lengths and opaque handles, which is exactly what every well-behaved native library ships. Keep the boundary coarse, because each crossing costs far more than a managed call.Who owns what, across the boundary
The paired
make and free functions are not clumsiness — they are the answer to the rule that memory must be released by the allocator that produced it, which the two runtimes cannot share.// The three rules that stop being automatic:
//
// 1. A pointer handed to native code is valid for the CALL only,
// unless the array was pinned or allocated unmanaged. The
// collector moves objects; storing that pointer is a dangling
// pointer with extra steps.
// 2. Memory allocated on one side must be freed on THAT side —
// malloc in C++ cannot be freed by the .NET allocator.
// 3. A crash in native code takes the whole process. There is no
// catching an access violation.
Console.WriteLine("three guarantees, suspended");#include <cstdlib>
#include <cstring>
// The convention that answers rule 2: the library allocates AND the
// library frees, so ownership never crosses.
extern "C" char* make_message(const char* name) {
const char* prefix = "hello, ";
char* result = static_cast<char*>(std::malloc(std::strlen(prefix) + std::strlen(name) + 1));
if (result == nullptr) return nullptr;
std::strcpy(result, prefix);
std::strcat(result, name);
return result;
}
extern "C" void free_message(char* message) {
std::free(message); // freed by whoever allocated it
}The practical checklist for a real interop layer: keep the boundary small and coarse, copy data across rather than holding pointers into the managed heap, pair every allocating function with a freeing one, return status codes rather than throwing, and test the native library on its own with a sanitizer build. A crash inside a .NET process is far harder to diagnose than the same crash in a small C++ test program — which is the single most useful thing to know before starting.
Build & Dependencies
NuGet has no equivalent
This is the part of the move that costs the most time and that no tutorial warns about. The reason C++ has no NuGet is not neglect — a compiled C++ artifact is only compatible with code built the same way.
// One line, one command, and it works the same everywhere:
//
// <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
// dotnet restore
//
// A dependency is a COMPILED assembly, and it works because IL is
// portable and the runtime defines binary compatibility.
Console.WriteLine("dependency handling is a solved problem here");// There is no equivalent, and this is the honest answer:
//
// CMakeLists.txt, plus one of:
// vcpkg install fmt (Microsoft's package manager)
// conan install . (the other one)
// FetchContent_Declare(...) (CMake downloads and builds the source)
// git submodule add ... (vendor it and build it yourself)
//
// A dependency is SOURCE that your build compiles with your flags,
// because a library built with a different standard version, standard
// library or compiler may not link with your code at all.
#include <iostream>
int main() {
std::cout << "dependency handling is not a solved problem here" << std::endl;
return 0;
}CMake is the closest thing to a standard and is a build system rather than a package manager; vcpkg and Conan sit on top of it, both in wide use with neither winning. Budget real time for this, expect the first build of a new dependency to fail, and treat a library shipping a working
CMakeLists.txt as a signal of quality. The genuine compensation is that you build from source with your own flags, so you can step into a dependency in the debugger — which a NuGet package does not let you do.