Hello, World & Output
Hello, World
The statement syntax is the language you already write. What is missing is the scaffolding: no
#include, no main, no return 0. A C# file whose contents are bare statements is the program, and the compiler writes the entry point for you.#include <stdio.h>
int main(void) {
printf("Hello, World!\n");
return 0;
}Console.WriteLine("Hello, World!");Console.WriteLine appends the newline and knows how to render whatever you hand it; printf takes a format string and believes it. The exit status still exists — falling off the end of the statements means zero, and return 3; at the top level sets three — but nothing forces you to write it. C# also has Console.Write for the no-newline case, which is the closer match to printf.Formatting Without a Format String
C#’s interpolated string puts the expressions where the conversions used to be. The compiler reads the types out of the expressions, so there is no
%d-versus-%ld to get wrong and no way to pass three arguments to a format string that expects four.#include <stdio.h>
int main(void) {
const char *name = "Ada";
int count = 3;
double average = 2.5;
printf("%s has %d items averaging %.2f\n", name, count, average);
return 0;
}string name = "Ada";
int count = 3;
double average = 2.5;
Console.WriteLine($"{name} has {count} items averaging {average:F2}");The precision did not disappear, it moved: what was
%.2f is now the :F2 format specifier after the expression. The bigger change is that a mismatch is now a compile error rather than a garbage read — printf("%s", 42) compiles under most settings and prints whatever lives at address 42, while there is no way to spell that mistake in an interpolated string.Writing to Standard Error
Both languages keep the two streams separate, and both buffer them separately. The C# spelling is a property rather than a global
FILE *.#include <stdio.h>
int main(void) {
printf("this is the result\n");
fprintf(stderr, "this is a diagnostic\n");
return 0;
}Console.WriteLine("this is the result");
Console.Error.WriteLine("this is a diagnostic");Console.Out and Console.Error are TextWriter objects, so anything that accepts a TextWriter can be pointed at either one — or at a file, or at an in-memory buffer — which is the piece C makes you build by hand out of FILE * plumbing.Types Have One Size Everywhere
int Is 32 Bits. Everywhere.
C guarantees only minimum ranges, so
long is 64 bits on Linux and 32 on Windows and you reach for <stdint.h> when it matters. C# fixes every width in the language definition, and sizeof on a primitive is a compile-time constant you rarely need.#include <stdio.h>
#include <limits.h>
int main(void) {
printf("char %zu\n", sizeof(char));
printf("short %zu\n", sizeof(short));
printf("int %zu\n", sizeof(int));
printf("long %zu\n", sizeof(long));
printf("range %d to %d\n", INT_MIN, INT_MAX);
return 0;
}Console.WriteLine($"sbyte {sizeof(sbyte)}");
Console.WriteLine($"short {sizeof(short)}");
Console.WriteLine($"int {sizeof(int)}");
Console.WriteLine($"long {sizeof(long)}");
Console.WriteLine($"range {int.MinValue} to {int.MaxValue}");The two columns print different numbers for
long on a 64-bit Unix build and identical ones on Windows — which is the whole point. In C#, long is System.Int64 by definition, and the names Int32, UInt64 and friends are the real type names that int and ulong are keywords for. The one width that is not fixed is nint/nuint, which exist precisely to match a C intptr_t.Signed Overflow Wraps, and Can Be Made to Throw
Signed overflow in C is undefined behavior: the optimizer is entitled to assume it never happens, which is why the same expression can print different things at
-O0 and -O2. C# specifies wrapping, and gives you a keyword to turn wrapping into an exception where you would rather find out.#include <stdio.h>
#include <limits.h>
int main(void) {
/* Undefined behavior. gcc -fwrapv makes it wrap; without it, the
compiler may assume the addition never overflows and fold the
comparison away entirely. */
int biggest = INT_MAX;
unsigned int wrapped = (unsigned int)biggest + 1u;
printf("unsigned wrap: %u\n", wrapped);
return 0;
}int biggest = int.MaxValue;
unchecked {
Console.WriteLine($"wrapped: {biggest + 1}");
}
try {
checked {
Console.WriteLine(biggest + 1);
}
} catch (OverflowException) {
Console.WriteLine("checked: OverflowException");
}Unchecked is the default for ordinary arithmetic, so C# behaves like C compiled with
-fwrapv — but it is specified to, which means no optimizer is allowed to reason from the assumption that it did not happen. The C column has to launder the addition through unsigned, where wrapping is defined, to have anything safe to print at all.char Is 16 Bits, byte Is the Byte
This is the type most likely to trip you up. A C
char is a byte and doubles as the smallest integer type. A C# char is a UTF-16 code unit; the byte-sized type is called byte, and it is unsigned.#include <stdio.h>
int main(void) {
char letter = 'A';
printf("letter %c is %d, %zu byte(s)\n",
letter, letter, sizeof(letter));
/* A "character" outside ASCII is several bytes in UTF-8. */
printf("snowman occupies %zu bytes\n", sizeof("\u2603") - 1);
return 0;
}char letter = 'A';
Console.WriteLine($"letter {letter} is {(int)letter}, {sizeof(char)} bytes");
byte smallest = 200; // unsigned, 0..255 — the C 'unsigned char'
Console.WriteLine($"byte {smallest}");
string snowman = "\u2603";
Console.WriteLine($"snowman is {snowman.Length} char(s), " +
$"{Encoding.UTF8.GetByteCount(snowman)} UTF-8 bytes");A C#
char holds one UTF-16 code unit, so a character outside the Basic Multilingual Plane takes two of them and string.Length counts code units, not letters. The mental model that survives the move is: C# byte ≈ C unsigned char, C# char ≈ C char16_t, and any time you want real bytes out of text you go through Encoding.Zero Is Not False
The idiom
if (count) does not compile in C#. A condition must be a bool, and no integer or pointer converts to one implicitly — which also means the classic if (result = compute()) typo is a type error rather than a silent assignment.#include <stdio.h>
#include <string.h>
int main(void) {
int count = 0;
if (!count) {
printf("count is zero\n");
}
const char *found = strchr("hello", 'z');
if (!found) {
printf("no z in hello\n");
}
return 0;
}int count = 0;
if (count == 0) {
Console.WriteLine("count is zero");
}
int found = "hello".IndexOf('z');
if (found < 0) {
Console.WriteLine("no z in hello");
}
// if (count) { } // error CS0029: cannot convert int to bool
// if (found = 3) { } // error too — assignment yields int, not boolC# also drops the null-pointer-as-false idiom:
if (reference) is a type error and you write if (reference is not null). Losing the shorthand costs a few characters per condition and removes an entire family of bugs, because the compiler now knows the difference between "this integer happens to be nonzero" and "this is a truth value."A Missing Value Instead of a Sentinel
When a C function must report "no answer" for an integer it picks a value that cannot occur —
-1, SIZE_MAX, INT_MIN — and every caller has to know which. C# gives the type an extra state instead: int? is an int plus a "has a value" flag.#include <stdio.h>
#include <string.h>
/* Returns -1 for "not found", which forces every caller to know that
-1 is not a real index. */
static int index_of(const char *text, char wanted) {
const char *hit = strchr(text, wanted);
return hit ? (int)(hit - text) : -1;
}
int main(void) {
int position = index_of("hello", 'l');
if (position >= 0) {
printf("found at %d\n", position);
}
if (index_of("hello", 'z') < 0) {
printf("z is absent\n");
}
return 0;
}int? IndexOfOrNull(string text, char wanted) {
int position = text.IndexOf(wanted);
return position < 0 ? null : position;
}
int? found = IndexOfOrNull("hello", 'l');
if (found.HasValue) {
Console.WriteLine($"found at {found.Value}");
}
int? missing = IndexOfOrNull("hello", 'z');
Console.WriteLine($"z is absent: {missing is null}");
Console.WriteLine($"default when absent: {missing ?? -1}");A
Nullable<int> is a struct holding an int and a bool, so it costs a few extra bytes rather than a heap allocation. The ?? operator supplies a fallback, which is how you get back to the sentinel deliberately at the one place that wants one. Note that the C column’s function silently misbehaves if anyone ever wants to store a negative index; the C# one cannot.new, And No free
new, And Nothing to Match It
Every
malloc in your C has exactly one free somewhere, and getting that correspondence right is a design constraint on the whole program. In C# the allocation is new and there is no counterpart: the collector reclaims an object once nothing can reach it.#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *numbers = malloc(4 * sizeof(int));
if (numbers == NULL) {
return 1;
}
for (int index = 0; index < 4; index++) {
numbers[index] = index * index;
}
printf("%d %d %d %d\n", numbers[0], numbers[1], numbers[2], numbers[3]);
free(numbers);
return 0;
}int[] numbers = new int[4];
for (int index = 0; index < 4; index++) {
numbers[index] = index * index;
}
Console.WriteLine(string.Join(" ", numbers));
// No free. Once the array is unreachable the collector takes it,
// at a time of its own choosing.
Console.WriteLine($"heap in use: {GC.GetTotalMemory(false) > 0}");Three C failure modes vanish with the
free: the leak, the double free, and the use-after-free. What you give up is the timing — you no longer know when the memory comes back, and you cannot ask for it back sooner. Notice too that the C column has to check malloc for NULL; new throws OutOfMemoryException instead of returning nothing, so there is no branch to forget.A Reference Keeps Its Object Alive
In C, freeing memory that something still points at is a bug you can only find by testing. In C# the direction is reversed: as long as any reachable reference names an object, the object exists — so the situation cannot arise.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
char *first = malloc(16);
strcpy(first, "alive");
char *second = first; /* two pointers, one block */
printf("before: %s\n", second);
free(first);
/* Reading *second here is undefined behavior — it may print the
old text, garbage, or crash. We do not do it. */
printf("after free, second must not be read\n");
return 0;
}var first = new StringBuilder("alive");
var second = first; // two references, one object
Console.WriteLine($"before: {second}");
first = null; // one reference dropped
// The variable second still names the object, so it still exists.
Console.WriteLine($"after: {second}");Dropping
first is not a deallocation, it is one fewer way to reach the object. This is the single largest change in how you design data structures: shared ownership needs no protocol, no reference counts and no "who frees this" comment, because reachability is the ownership rule.The One free You Still Write: IDisposable
The collector handles memory and nothing else. File handles, sockets, database connections and native allocations still need releasing at a known moment, and C# marks those types
IDisposable so that using can close them on the way out of the block — including on the way out through an exception.#include <stdio.h>
int main(void) {
FILE *handle = fopen("/tmp/c-csharp-dispose.txt", "w");
if (handle == NULL) {
return 1;
}
fprintf(handle, "written\n");
fclose(handle); /* miss this and the handle leaks */
handle = fopen("/tmp/c-csharp-dispose.txt", "r");
char line[32] = {0};
if (fgets(line, sizeof line, handle) != NULL) {
printf("read back: %s", line);
}
fclose(handle);
return 0;
}string path = Path.Combine(Path.GetTempPath(), "c-csharp-dispose.txt");
using (var writer = new StreamWriter(path)) {
writer.WriteLine("written");
} // Dispose() runs here, even if WriteLine threw
using (var reader = new StreamReader(path)) {
Console.WriteLine($"read back: {reader.ReadLine()}");
}
File.Delete(path);The rule of thumb that transfers cleanly: memory is automatic, handles are not. A
using block is the C# spelling of the goto cleanup ladder, and it is exception-safe in a way the ladder never is. The shorter using var writer = new StreamWriter(path); form disposes at the end of the enclosing scope instead, which reads more like an ordinary declaration.Which Values Live on the Stack
C decides stack-or-heap by how you allocated. C# decides by the type: a
struct lives wherever its container lives — a local goes on the stack — while a class instance is always on the heap and the local holds only a reference to it.#include <stdio.h>
#include <stdlib.h>
struct Point { int x; int y; };
int main(void) {
struct Point on_stack = { 1, 2 }; /* the struct itself */
struct Point *on_heap = malloc(sizeof *on_heap);
on_heap->x = 3;
on_heap->y = 4;
printf("stack %d,%d heap %d,%d\n",
on_stack.x, on_stack.y, on_heap->x, on_heap->y);
free(on_heap);
return 0;
}Point onStack = new Point(1, 2); // the struct itself, in the frame
Label onHeap = new Label(3, 4); // a reference; the object is elsewhere
Console.WriteLine($"stack {onStack.X},{onStack.Y} heap {onHeap.X},{onHeap.Y}");
struct Point(int x, int y) {
public int X { get; } = x;
public int Y { get; } = y;
}
class Label(int x, int y) {
public int X { get; } = x;
public int Y { get; } = y;
}The consequence you feel first is copying. Assigning a
struct copies every field, exactly like assigning a C struct; assigning a class reference copies a machine word and leaves one object. The two type declarations at the bottom of the C# column differ by a single keyword and produce completely different runtime behavior — which is why "is it a struct or a class?" is the first question to ask about any C# type.References Instead of Pointers
Assigning an Object Copies the Handle
This row is the C pointer semantics you already know, wearing no punctuation. There is no
* and no ->, so nothing on the page announces that two names share one object — you have to know the type is a class.#include <stdio.h>
#include <stdlib.h>
struct Counter { int value; };
int main(void) {
struct Counter *first = malloc(sizeof *first);
first->value = 1;
struct Counter *second = first; /* aliases */
second->value = 99;
printf("first sees %d\n", first->value);
free(first);
return 0;
}var first = new Counter { Value = 1 };
var second = first; // aliases — no punctuation says so
second.Value = 99;
Console.WriteLine($"first sees {first.Value}");
class Counter {
public int Value;
}Change
class Counter to struct Counter and the same program prints 1, because the assignment then copies the whole value. That is the entire difference, and it is invisible at the use site — which is why C# style leans on record and immutability to make aliasing stop mattering rather than to make it visible.ref and out Instead of an Address
The C way to let a function write to your variable is to pass its address and dereference. C# has that pattern built into the call:
ref for a variable the callee reads and writes, out for one it must assign, and both spell the keyword at the call site too, so a reader can see which arguments the call can modify.#include <stdio.h>
static void swap(int *left, int *right) {
int held = *left;
*left = *right;
*right = held;
}
static int divide(int numerator, int denominator, int *quotient) {
if (denominator == 0) {
return 0;
}
*quotient = numerator / denominator;
return 1;
}
int main(void) {
int first = 1, second = 2;
swap(&first, &second);
printf("%d %d\n", first, second);
int quotient = 0;
if (divide(7, 2, "ient)) {
printf("quotient %d\n", quotient);
}
return 0;
}void Swap(ref int left, ref int right) {
(left, right) = (right, left);
}
bool Divide(int numerator, int denominator, out int quotient) {
if (denominator == 0) {
quotient = 0;
return false;
}
quotient = numerator / denominator;
return true;
}
int first = 1, second = 2;
Swap(ref first, ref second);
Console.WriteLine($"{first} {second}");
if (Divide(7, 2, out int quotient)) {
Console.WriteLine($"quotient {quotient}");
}The compiler enforces what C only documents: a
ref argument must be initialized before the call, an out argument must be assigned before the callee returns, and neither can be null the way a pointer can. The out int quotient inside the if is a declaration — the variable comes into scope right there, which is why the TryParse family reads as well as it does.null, and the Compiler Warning About It
A null reference throws
NullReferenceException where a null pointer dereference would be undefined behavior — a diagnosed failure instead of a segfault-if-you-are-lucky. Newer C# goes further and tracks, in the type system, which references you promised could not be null.#include <stdio.h>
#include <string.h>
static size_t length_of(const char *text) {
if (text == NULL) { /* the check nothing forces you to write */
return 0;
}
return strlen(text);
}
int main(void) {
printf("%zu\n", length_of("hello"));
printf("%zu\n", length_of(NULL));
return 0;
}int LengthOf(string? text) => text?.Length ?? 0;
Console.WriteLine(LengthOf("hello"));
Console.WriteLine(LengthOf(null));
string definitely = "never null"; // no ? — the compiler holds you to it
Console.WriteLine(definitely.Length);
object nothing = null!;
try {
Console.WriteLine(nothing.ToString());
} catch (NullReferenceException) {
Console.WriteLine("NullReferenceException, not a segfault");
}The
? on string? is the declaration that this one may be missing; without it the compiler warns at every place you might assign null. text?.Length is the short-circuit — it yields null rather than dereferencing — and ?? supplies the fallback, so the two together are the C null check compressed into punctuation. The trailing ! on null! is how you overrule the compiler when you know better, and it is the one place the safety is yours to lose.Arrays Know Their Length
The Array Knows How Long It Is
The
sizeof(array) / sizeof(array[0]) trick works only where the array has not decayed to a pointer, which is why every C function that takes an array takes a length beside it. A C# array carries its length with it, so the parameter list gets shorter.#include <stdio.h>
static int sum(const int *values, size_t count) {
int total = 0;
for (size_t index = 0; index < count; index++) {
total += values[index];
}
return total;
}
int main(void) {
int numbers[] = { 3, 1, 4, 1, 5 };
size_t count = sizeof numbers / sizeof numbers[0];
printf("count %zu, sum %d\n", count, sum(numbers, count));
return 0;
}int Sum(int[] values) {
int total = 0;
for (int index = 0; index < values.Length; index++) {
total += values[index];
}
return total;
}
int[] numbers = { 3, 1, 4, 1, 5 };
Console.WriteLine($"count {numbers.Length}, sum {Sum(numbers)}");An array does not decay in C#: pass it anywhere and it is still an array, still knows its length, and still bounds-checks. The length lives in the object header next to the type, so
Length is a field read rather than a computation. Once you have this, the entire class of "caller and callee disagree about the count" bugs is unreachable.Reading Past the End Is an Exception
Indexing past the end of a C array reads whatever is there. It is the most consequential undefined behavior in the language and the root of most memory-corruption bugs. Every C# array subscript is checked, and a bad one throws.
#include <stdio.h>
int main(void) {
int numbers[3] = { 10, 20, 30 };
/* numbers[3] would read past the end — undefined behavior, so we
do the check by hand instead. */
int wanted = 3;
if (wanted >= 0 && wanted < 3) {
printf("%d\n", numbers[wanted]);
} else {
printf("index %d is out of range\n", wanted);
}
return 0;
}int[] numbers = { 10, 20, 30 };
try {
Console.WriteLine(numbers[3]);
} catch (IndexOutOfRangeException) {
Console.WriteLine("index 3 is out of range");
}
Console.WriteLine(numbers[^1]); // ^1 is "one from the end"
Console.WriteLine(string.Join(",", numbers[1..])); // a rangeThe check costs a compare and a branch per subscript, and the just-in-time compiler removes most of them — a
for loop bounded by Length is proved safe and emits no check at all. The ^1 and 1.. syntax comes with the bounds-checking: the runtime knows the length, so "from the end" and "a slice" can be language features rather than pointer arithmetic you write yourself.Two Kinds of Two-Dimensional Array
C’s
int grid[2][3] is one contiguous block, and int *rows[2] is an array of pointers to separate blocks. C# has both, spelled int[,] and int[][] — and unlike C, the first one keeps both dimensions at run time.#include <stdio.h>
int main(void) {
/* One contiguous block of 6 ints. */
int grid[2][3] = { { 1, 2, 3 }, { 4, 5, 6 } };
for (int row = 0; row < 2; row++) {
for (int column = 0; column < 3; column++) {
printf("%d ", grid[row][column]);
}
}
printf("\n");
/* Rows of different lengths need an array of pointers. */
int shortRow[] = { 7 };
int longRow[] = { 8, 9, 10 };
int *rows[2] = { shortRow, longRow };
printf("%d %d\n", rows[0][0], rows[1][2]);
return 0;
}int[,] grid = { { 1, 2, 3 }, { 4, 5, 6 } };
for (int row = 0; row < grid.GetLength(0); row++) {
for (int column = 0; column < grid.GetLength(1); column++) {
Console.Write($"{grid[row, column]} ");
}
}
Console.WriteLine();
int[][] rows = { new[] { 7 }, new[] { 8, 9, 10 } };
Console.WriteLine($"{rows[0][0]} {rows[1][2]}");
Console.WriteLine($"row lengths: {rows[0].Length} and {rows[1].Length}");Note the subscript syntax differs:
grid[row, column] for the rectangular one, rows[row][column] for the jagged. The rectangular form is the closer match to C’s contiguous layout and is what you want for numeric work; the jagged form is an array of array objects and is the one that permits ragged rows. C loses the row length of a jagged structure entirely, which is why the C column can only print elements it already knows exist.A New Array Is Always Zeroed
malloc hands back whatever was in that memory; calloc zeroes it and costs a little more. C# has no uninitialized allocation at all — every field and element starts at the type’s default, and reading a local before assigning it is a compile error.#include <stdio.h>
#include <stdlib.h>
int main(void) {
/* malloc: contents are indeterminate, so we must not print them. */
int *fromMalloc = malloc(3 * sizeof(int));
free(fromMalloc);
/* calloc: zeroed, and now safe to read. */
int *fromCalloc = calloc(3, sizeof(int));
printf("%d %d %d\n", fromCalloc[0], fromCalloc[1], fromCalloc[2]);
free(fromCalloc);
int uninitialized_local;
uninitialized_local = 0; /* reading it first would be undefined */
printf("%d\n", uninitialized_local);
return 0;
}int[] numbers = new int[3];
Console.WriteLine(string.Join(" ", numbers)); // 0 0 0
string[] names = new string[2];
Console.WriteLine($"reference default is null: {names[0] is null}");
int local;
// Console.WriteLine(local); // error CS0165: use of unassigned local
local = 0;
Console.WriteLine(local);The zeroing is not free — a large
new byte[1_000_000] pays for it — but it removes the whole "did I initialize that?" category, and the compiler’s definite-assignment analysis catches the local case with no runtime cost at all. When you genuinely want the malloc behavior for a hot buffer, GC.AllocateUninitializedArray is the deliberate opt-out.Strings Are Objects, Not char*
A string Is an Object, Not a char *
A C string is a convention: a pointer, and a zero byte somewhere after it. A C# string is an object with a stored length, a known encoding, and methods — and no amount of contents can make it lie about how long it is.
#include <stdio.h>
#include <string.h>
int main(void) {
const char *greeting = "hello";
printf("length %zu\n", strlen(greeting)); /* walks to the NUL */
printf("upper: ");
for (size_t index = 0; index < strlen(greeting); index++) {
putchar(greeting[index] - 'a' + 'A');
}
printf("\n");
return 0;
}string greeting = "hello";
Console.WriteLine($"length {greeting.Length}"); // stored, not walked
Console.WriteLine($"upper: {greeting.ToUpperInvariant()}");
Console.WriteLine($"starts with he: {greeting.StartsWith("he")}");strlen is O(n) and the C column calls it once per loop iteration, which is the classic accidental O(n²). Length is a field read. The other thing to notice is ToUpperInvariant: uppercasing is a locale-sensitive operation on real text, and the C column’s arithmetic works only for unaccented ASCII — which is most of why C string handling looks simple.Strings Are Immutable; StringBuilder Is the Buffer
In C you own the buffer and write into it. In C# every operation that "changes" a string returns a new one, which is why building text in a loop needs a different tool —
StringBuilder is the growable char buffer you would have written by hand.#include <stdio.h>
#include <string.h>
int main(void) {
char buffer[64];
buffer[0] = '\0';
for (int index = 1; index <= 5; index++) {
char piece[8];
snprintf(piece, sizeof piece, "%d,", index);
strncat(buffer, piece, sizeof buffer - strlen(buffer) - 1);
}
printf("%s\n", buffer);
/* Mutation in place: only possible because we own the array. */
char editable[] = "hello";
editable[0] = 'H';
printf("%s\n", editable);
return 0;
}var builder = new StringBuilder();
for (int index = 1; index <= 5; index++) {
builder.Append(index).Append(',');
}
Console.WriteLine(builder.ToString());
string greeting = "hello";
// greeting[0] = 'H'; // error: no setter on the indexer
string capitalized = "H" + greeting[1..];
Console.WriteLine(capitalized);
Console.WriteLine($"original unchanged: {greeting}");Immutability is what makes it safe to hand a string to another thread, use it as a dictionary key, and let two variables share one object without copying — the aliasing that would be a hazard for a
char * is harmless when nothing can write through it. The cost is that naive concatenation in a loop allocates a new string each round, which is exactly the strcat-in-a-loop performance trap in a different costume.No Terminator, So a Zero Byte Is Just Data
Because the length is stored rather than found, a C# string may contain a zero character with no consequence. In C that same byte truncates the value at every function that takes a
char * — the source of a long list of security bugs.#include <stdio.h>
#include <string.h>
int main(void) {
/* The zero byte ends the string as far as every str* function
is concerned, even though 11 bytes were written. */
char text[] = "safe\0evil";
printf("strlen says %zu\n", strlen(text));
printf("printed: %s\n", text);
printf("array is %zu bytes\n", sizeof text);
return 0;
}string text = "safe\0evil";
Console.WriteLine($"Length says {text.Length}");
Console.WriteLine($"printed: {text.Replace("\0", "<NUL>")}");
Console.WriteLine($"contains a zero char: {text.Contains('\0')}");The practical upshot at an interop boundary is that a C# string can hold something a C function will silently truncate. When you marshal one out to native code you are converting to a NUL-terminated representation, and if the string contains an embedded zero the native side sees a shorter string than you sent — which is worth remembering before you pass user input through
[DllImport].== Compares Text, Not Addresses
This is the one place where C# quietly breaks the rule that
== on reference types compares references. string overloads it to compare contents, so the strcmp reflex is unnecessary — and the C habit of writing if (a == b) for strings, which is a bug in C, is correct here.#include <stdio.h>
#include <string.h>
int main(void) {
char first[] = "hello";
char second[] = "hello";
/* == compares the two addresses, which differ. */
printf("pointers equal: %d\n", first == second);
printf("contents equal: %d\n", strcmp(first, second) == 0);
printf("ordering: %d\n", strcmp("apple", "banana") < 0);
return 0;
}string first = new string(new[] { 'h', 'e', 'l', 'l', 'o' });
string second = "hello";
Console.WriteLine($"same object: {ReferenceEquals(first, second)}");
Console.WriteLine($"contents equal: {first == second}");
Console.WriteLine($"ordering: {string.CompareOrdinal("apple", "banana") < 0}");The C column’s first line prints 0 and is the bug every C programmer has written once. The C# column builds its first string at run time precisely so the two are different objects — a plain
"hello" literal would be interned and share one object, making ReferenceEquals accidentally true. CompareOrdinal is the byte-order comparison strcmp does; plain string.Compare is culture-aware and will not agree with it.Split Instead of strtok
strtok writes zero bytes into your buffer, keeps its position in a static variable, and therefore cannot be used on a string literal, from two loops at once, or from two threads. Split has none of those properties because it allocates.#include <stdio.h>
#include <string.h>
int main(void) {
/* Must be a writable copy: strtok modifies it. */
char text[] = "red,green,blue";
char *piece = strtok(text, ",");
while (piece != NULL) {
printf("[%s]", piece);
piece = strtok(NULL, ",");
}
printf("\n");
return 0;
}string text = "red,green,blue";
foreach (string piece in text.Split(',')) {
Console.Write($"[{piece}]");
}
Console.WriteLine();
Console.WriteLine(string.Join(" | ", text.Split(',')));
Console.WriteLine($"original intact: {text}");The trade is allocation for safety:
Split hands back an array of new strings, where strtok reuses the buffer you gave it. When the allocation matters, C# has the same idea without it — text.AsSpan().Split(...) and MemoryExtensions.IndexOf work over a view into the original characters, which is the closest thing to what strtok was trying to be.A Struct With Functions Is a Class
The Struct-Plus-Functions Pattern, Built In
The C pattern is a struct and a family of functions that take a pointer to it as their first argument. C# folds the first argument into the syntax: the functions move inside the type and the receiver is called
this.#include <stdio.h>
struct Rectangle {
double width;
double height;
};
static double rectangle_area(const struct Rectangle *self) {
return self->width * self->height;
}
static void rectangle_scale(struct Rectangle *self, double factor) {
self->width *= factor;
self->height *= factor;
}
int main(void) {
struct Rectangle shape = { 3.0, 4.0 };
printf("area %.1f\n", rectangle_area(&shape));
rectangle_scale(&shape, 2.0);
printf("scaled area %.1f\n", rectangle_area(&shape));
return 0;
}var shape = new Rectangle { Width = 3.0, Height = 4.0 };
Console.WriteLine($"area {shape.Area():F1}");
shape.Scale(2.0);
Console.WriteLine($"scaled area {shape.Area():F1}");
class Rectangle {
public double Width;
public double Height;
public double Area() => Width * Height;
public void Scale(double factor) {
Width *= factor;
Height *= factor;
}
}Nothing conceptual changed —
shape.Area() passes shape as the hidden first argument exactly as rectangle_area(&shape) does. What you gain is that the name lookup is scoped to the type, so there is no rectangle_ prefix to invent, and the compiler will not let you call circle_area on a rectangle.struct Copies Like C; class Does Not
A C#
struct is the C struct you know: assigning it, passing it, and returning it all copy every field. A class with the same fields behaves the opposite way. This row runs the same code against both so the difference is the only variable.#include <stdio.h>
struct Counter { int value; };
static void bump_by_value(struct Counter copy) {
copy.value += 1; /* the caller never sees this */
}
static void bump_by_pointer(struct Counter *shared) {
shared->value += 1; /* the caller does */
}
int main(void) {
struct Counter counter = { 0 };
bump_by_value(counter);
printf("after by-value: %d\n", counter.value);
bump_by_pointer(&counter);
printf("after by-pointer: %d\n", counter.value);
return 0;
}var asValue = new ValueCounter { Value = 0 };
Mutate.Bump(asValue);
Console.WriteLine($"after struct pass: {asValue.Value}");
var asClass = new ClassCounter { Value = 0 };
Mutate.Bump(asClass);
Console.WriteLine($"after class pass: {asClass.Value}");
static class Mutate {
public static void Bump(ValueCounter copy) { copy.Value += 1; }
public static void Bump(ClassCounter shared) { shared.Value += 1; }
}
struct ValueCounter { public int Value; }
class ClassCounter { public int Value; }The two
Bump methods are byte-identical bodies and the two types differ by one keyword, so the different results come from nothing but value-versus-reference semantics. The rule that transfers from C: use a struct when the thing is a value (a point, a length, a colour), keep it small and preferably immutable, and reach for a class when identity matters. A large mutable struct combines C’s copying costs with C#’s invisible syntax and is the worst of both.Properties: Fields With Code Behind Them
When a C struct field needs validation you hide the struct behind an accessor pair and every caller changes. C# lets a field become a pair of methods without the call sites noticing, which is why public fields are rare in C# even though they cost nothing.
#include <stdio.h>
struct Thermostat {
int celsius; /* callers write it directly — no way to validate */
};
static void thermostat_set(struct Thermostat *self, int wanted) {
if (wanted < -50) wanted = -50;
if (wanted > 50) wanted = 50;
self->celsius = wanted;
}
int main(void) {
struct Thermostat unit = { 20 };
thermostat_set(&unit, 500);
printf("clamped to %d\n", unit.celsius);
printf("fahrenheit %d\n", unit.celsius * 9 / 5 + 32);
return 0;
}var unit = new Thermostat();
unit.Celsius = 500; // looks like a field write
Console.WriteLine($"clamped to {unit.Celsius}");
Console.WriteLine($"fahrenheit {unit.Fahrenheit}");
class Thermostat {
private int celsius = 20;
public int Celsius {
get => celsius;
set => celsius = Math.Clamp(value, -50, 50);
}
public int Fahrenheit => Celsius * 9 / 5 + 32;
}The call site
unit.Celsius = 500 is a method call wearing assignment syntax, and value is the implicit parameter holding the right-hand side. Fahrenheit has only a getter and stores nothing at all — a computed field, which C can only express as a function call. The habit to build: declare properties from the start (public int Celsius { get; set; } generates the trivial pair), because turning a public field into a property later changes the binary interface even though the source looks the same.Constructors Instead of an init Function
C has no way to insist that a struct be initialized, so the convention is a
thing_init function and the discipline to call it. A C# constructor cannot be skipped: there is no syntax that produces an object without running one.#include <stdio.h>
#include <stdlib.h>
struct Connection {
const char *host;
int port;
int is_open;
};
static struct Connection *connection_create(const char *host, int port) {
struct Connection *self = malloc(sizeof *self);
if (self == NULL) return NULL;
self->host = host;
self->port = port;
self->is_open = 1;
return self;
}
int main(void) {
struct Connection *link = connection_create("example.test", 8080);
printf("%s:%d open=%d\n", link->host, link->port, link->is_open);
/* Nothing stops this: a struct nobody initialized. */
struct Connection forgotten = { 0 };
printf("forgotten port %d\n", forgotten.port);
free(link);
return 0;
}var link = new Connection("example.test", 8080);
Console.WriteLine($"{link.Host}:{link.Port} open={link.IsOpen}");
// var forgotten = new Connection(); // error: no parameterless constructor
class Connection {
public string Host { get; }
public int Port { get; }
public bool IsOpen { get; private set; }
public Connection(string host, int port) {
if (port <= 0) throw new ArgumentOutOfRangeException(nameof(port));
Host = host;
Port = port;
IsOpen = true;
}
}Declaring any constructor removes the implicit parameterless one, so
new Connection() stops compiling and there is no way to reach the half-built state the C column demonstrates. { get; } with no setter means the property can only be assigned in the constructor, which is C#’s const-after-construction — a shape C can only approximate by hiding the struct definition in a .c file.virtual Instead of a Hand-Rolled vtable
Polymorphism in C is a struct of function pointers that each "subclass" fills in, plus the discipline of passing the right object to the right table. C# has that table built into every class with a
virtual method, and picks the entry for you.#include <stdio.h>
struct ShapeOperations {
double (*area)(const void *self);
};
struct Square { double side; };
struct Circle { double radius; };
static double square_area(const void *self) {
return ((const struct Square *)self)->side * ((const struct Square *)self)->side;
}
static double circle_area(const void *self) {
double radius = ((const struct Circle *)self)->radius;
return 3.14159265358979 * radius * radius;
}
int main(void) {
struct ShapeOperations squareOps = { square_area };
struct ShapeOperations circleOps = { circle_area };
struct Square square = { 3.0 };
struct Circle circle = { 1.0 };
printf("%.2f\n", squareOps.area(&square));
printf("%.2f\n", circleOps.area(&circle));
return 0;
}Shape[] shapes = { new Square(3.0), new Circle(1.0) };
foreach (Shape shape in shapes) {
Console.WriteLine($"{shape.Area():F2}");
}
abstract class Shape {
public abstract double Area();
}
class Square(double side) : Shape {
public override double Area() => side * side;
}
class Circle(double radius) : Shape {
public override double Area() => Math.PI * radius * radius;
}The generated code is the same idea — an object header pointing at a method table — but the compiler builds and populates the table, and the
void * casts that make the C version type-unsafe are gone. That also makes the C# column able to put both shapes in one array and loop over them, which the C version cannot do without a tagged union or a common header struct.record: Value Equality Without memcmp
Comparing two C structs means
memcmp — which is wrong the moment there is padding between fields or a pointer that should be followed — or a hand-written field-by-field function. A record generates the correct comparison, a hash code that agrees with it, and a readable ToString.#include <stdio.h>
#include <string.h>
struct Point { int x; int y; };
static int point_equals(const struct Point *left, const struct Point *right) {
return left->x == right->x && left->y == right->y;
}
int main(void) {
struct Point first = { 1, 2 };
struct Point second = { 1, 2 };
/* memcmp would also compare any padding bytes, which are
indeterminate — so the comparison is written by hand. */
printf("equal: %d\n", point_equals(&first, &second));
printf("first is (%d, %d)\n", first.x, first.y);
return 0;
}var first = new Point(1, 2);
var second = new Point(1, 2);
Console.WriteLine($"equal: {first == second}");
Console.WriteLine($"first is {first}");
Console.WriteLine($"moved: {first with { Y = 99 }}");
record struct Point(int X, int Y);One line of declaration produced the constructor, two read-only properties,
Equals, GetHashCode, ==, ToString, and the with expression that copies-and-changes. The struct keyword keeps it a value type; plain record Point(int X, int Y) would be a reference type with the same value semantics for comparison — which is the combination C has no way to express.Collections Instead of Hand-Rolling
List Instead of realloc
The grow-a-buffer loop — track capacity, double it,
realloc, check for failure, remember that the old pointer is now invalid — is the same code in every C program. List<T> is that code, once, correctly.#include <stdio.h>
#include <stdlib.h>
int main(void) {
size_t capacity = 2;
size_t count = 0;
int *numbers = malloc(capacity * sizeof *numbers);
if (numbers == NULL) return 1;
for (int value = 1; value <= 5; value++) {
if (count == capacity) {
capacity *= 2;
int *grown = realloc(numbers, capacity * sizeof *numbers);
if (grown == NULL) { free(numbers); return 1; }
numbers = grown; /* the old pointer is now dangling */
}
numbers[count++] = value * value;
}
for (size_t index = 0; index < count; index++) {
printf("%d ", numbers[index]);
}
printf("\n");
free(numbers);
return 0;
}var numbers = new List<int>();
for (int value = 1; value <= 5; value++) {
numbers.Add(value * value);
}
Console.WriteLine(string.Join(" ", numbers));
Console.WriteLine($"count {numbers.Count}, capacity {numbers.Capacity}");The growth strategy is the same doubling, and
Capacity exposes it — you can pre-size with new List<int>(1000) exactly as you would pick a first malloc size. What is gone is the realloc hazard: there is no old pointer for anyone to have kept, because callers hold a reference to the list object rather than to its backing array.Dictionary Instead of Writing a Hash Table
C has no hash table in its standard library, so every project either writes one or links a third-party one.
Dictionary<TKey, TValue> is in the language’s base library and knows how to hash strings, integers, and any type that implements GetHashCode.#include <stdio.h>
#include <string.h>
/* A fixed-size open-addressing table, which is the smallest honest
version of what C makes you write. */
#define SLOTS 16
struct Entry { const char *key; int value; int used; };
static unsigned hash_of(const char *key) {
unsigned hash = 2166136261u;
while (*key) { hash = (hash ^ (unsigned char)*key++) * 16777619u; }
return hash;
}
static void put(struct Entry *table, const char *key, int value) {
unsigned slot = hash_of(key) % SLOTS;
while (table[slot].used && strcmp(table[slot].key, key) != 0) {
slot = (slot + 1) % SLOTS;
}
table[slot].key = key;
table[slot].value = value;
table[slot].used = 1;
}
static int get(const struct Entry *table, const char *key, int fallback) {
unsigned slot = hash_of(key) % SLOTS;
while (table[slot].used) {
if (strcmp(table[slot].key, key) == 0) return table[slot].value;
slot = (slot + 1) % SLOTS;
}
return fallback;
}
int main(void) {
struct Entry table[SLOTS] = {0};
put(table, "apples", 3);
put(table, "pears", 7);
printf("apples %d\n", get(table, "apples", -1));
printf("plums %d\n", get(table, "plums", -1));
return 0;
}var counts = new Dictionary<string, int> {
["apples"] = 3,
["pears"] = 7,
};
Console.WriteLine($"apples {counts["apples"]}");
Console.WriteLine($"plums {(counts.TryGetValue("plums", out int found) ? found : -1)}");
foreach ((string fruit, int count) in counts) {
Console.WriteLine($"{fruit}: {count}");
}The C column is fifty lines that still cannot grow, cannot delete, and leaks the distinction between "absent" and "stored -1".
TryGetValue is the C# idiom that avoids that last problem for good: it returns a bool and writes the value through an out parameter, so a missing key is never confused with a stored one. Indexing a dictionary with a key it does not hold throws rather than inserting, which is the opposite of what several scripting languages do.foreach Over Anything That Can Be Walked
C’s
for (i = 0; i < n; i++) works because arrays are contiguous and you know the count. foreach asks the collection for a cursor instead, so the same loop shape walks an array, a list, a dictionary, a set, or something computed lazily as you go.#include <stdio.h>
struct Node { int value; struct Node *next; };
int main(void) {
struct Node third = { 3, NULL };
struct Node second = { 2, &third };
struct Node first = { 1, &second };
/* An array and a list need two different loops. */
int numbers[] = { 10, 20 };
for (size_t index = 0; index < 2; index++) {
printf("%d ", numbers[index]);
}
for (struct Node *walk = &first; walk != NULL; walk = walk->next) {
printf("%d ", walk->value);
}
printf("\n");
return 0;
}int[] numbers = { 10, 20 };
var linked = new LinkedList<int>(new[] { 1, 2, 3 });
foreach (int value in numbers) { Console.Write($"{value} "); }
foreach (int value in linked) { Console.Write($"{value} "); }
Console.WriteLine();
// The same loop shape over something generated on demand:
foreach (int square in Squares(4)) { Console.Write($"{square} "); }
Console.WriteLine();
IEnumerable<int> Squares(int howMany) {
for (int index = 1; index <= howMany; index++) {
yield return index * index; // produces one, then suspends
}
}yield return is the part with no C equivalent short of writing a state machine by hand: the method suspends at each yield and resumes where it left off when the loop asks for the next value, so Squares(1000000) allocates nothing and computes only what is consumed. Modifying a collection while a foreach walks it throws InvalidOperationException — a diagnosed version of the invalidated-iterator bug.LINQ: The Loop as an Expression
Filtering, mapping, and aggregating are three loops in C, each with its own index variable and accumulator. LINQ names those operations, so the code says what it computes rather than how it walks.
#include <stdio.h>
int main(void) {
int numbers[] = { 3, 1, 4, 1, 5, 9, 2, 6 };
size_t count = sizeof numbers / sizeof numbers[0];
int total = 0;
int matches = 0;
for (size_t index = 0; index < count; index++) {
if (numbers[index] % 2 == 0) {
total += numbers[index] * numbers[index];
matches++;
}
}
printf("%d even values, squares total %d\n", matches, total);
return 0;
}int[] numbers = { 3, 1, 4, 1, 5, 9, 2, 6 };
var evenSquares = numbers.Where(value => value % 2 == 0)
.Select(value => value * value);
Console.WriteLine($"{evenSquares.Count()} even values, squares total {evenSquares.Sum()}");
Console.WriteLine(string.Join(" ", numbers.OrderByDescending(value => value).Take(3)));Every one of those operators returns a lazy sequence, so nothing is computed until
Count() or Sum() asks — which is also the trap, because the chain above is walked twice, once per aggregate. Add .ToArray() when you intend to consume a result more than once. The loop is not gone, it moved: the cost model is roughly one delegate call per element per operator, which is a real price you would not pay in the C column.Exceptions Instead of Return Codes
An Exception Cannot Be Ignored
A C function reports failure by returning a value, and nothing stops the caller from ignoring it — most of the time nothing even warns. A thrown exception unwinds until something catches it, so the failure path is taken whether the caller wrote code for it or not.
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
int main(void) {
errno = 0;
const char *input = "not a number";
char *end = NULL;
long parsed = strtol(input, &end, 10);
if (end == input || *end != '\0') {
printf("parse failed, and only the check found out\n");
} else {
printf("parsed %ld\n", parsed);
}
/* The same call with the result ignored compiles silently: */
strtol("also bad", NULL, 10);
printf("ignored result, program continues\n");
return 0;
}try {
int parsed = int.Parse("not a number");
Console.WriteLine($"parsed {parsed}");
} catch (FormatException error) {
Console.WriteLine($"parse failed: {error.Message}");
}
// The non-throwing sibling, for when failure is expected and cheap:
Console.WriteLine(int.TryParse("42", out int good) ? $"got {good}" : "no");
Console.WriteLine(int.TryParse("bad", out int _) ? "yes" : "TryParse said no");C# offers both styles on purpose:
Parse throws because a malformed number is usually a bug, while TryParse returns a bool because parsing user input is not. The rule of thumb the base library follows — throw for the unexpected, return a status for the routine — is worth adopting, because exceptions are expensive to throw (they capture a stack trace) and cheap only when they do not happen.finally Instead of goto cleanup
The
goto cleanup ladder is the correct C answer to "several things were acquired and any of them might fail." try/finally is the same structure with the labels removed and, crucially, with the guarantee that it also runs when the failure came from further down the call stack.#include <stdio.h>
#include <stdlib.h>
int main(void) {
int result = 1;
char *first = NULL;
char *second = NULL;
first = malloc(16);
if (first == NULL) goto cleanup;
second = malloc(16);
if (second == NULL) goto cleanup;
printf("both buffers acquired\n");
result = 0;
cleanup:
free(second);
free(first);
printf("cleanup ran, result %d\n", result);
return 0;
}try {
Console.WriteLine("both buffers acquired");
throw new InvalidOperationException("something failed deeper down");
} catch (InvalidOperationException error) {
Console.WriteLine($"caught: {error.Message}");
} finally {
Console.WriteLine("cleanup ran");
}The C ladder handles failures this function detected. It cannot handle a failure three frames down that longjmps past it, which is why C code that uses
setjmp tends not to use the ladder. finally runs during unwinding regardless of where the throw came from, and using is sugar for a try/finally that calls Dispose — so most real C# cleanup is written as using rather than as an explicit finally.Division by Zero Has an Answer
Integer division by zero is undefined behavior in C — in practice a
SIGFPE on x86 and something else elsewhere. C# specifies it: integer division throws, and floating-point division follows IEEE 754 and produces infinity.#include <stdio.h>
#include <math.h>
int main(void) {
int denominator = 0;
/* 1 / denominator here is undefined behavior; typically SIGFPE.
The check is the only portable protection. */
if (denominator == 0) {
printf("integer: caller must check\n");
}
double zero = 0.0;
printf("double: %f\n", 1.0 / zero);
printf("isinf: %d\n", isinf(1.0 / zero));
return 0;
}int denominator = 0;
try {
Console.WriteLine(1 / denominator);
} catch (DivideByZeroException) {
Console.WriteLine("integer: DivideByZeroException");
}
double zero = 0.0;
Console.WriteLine($"double: {1.0 / zero}");
Console.WriteLine($"isinf: {double.IsInfinity(1.0 / zero)}");The floating-point halves agree, because both languages defer to IEEE 754 —
1.0 / 0.0 is positive infinity in each, and 0.0 / 0.0 is NaN in each. Only the integer case differs, and it differs in the direction of being specified: a C# program that divides by zero fails the same way on every platform, with a stack trace naming the line.An Error Type That Carries Its Details
An
errno value is an integer; anything else the failure knew has to be recovered from a global, a string table, or another out-parameter. An exception is an object, so a custom type can carry whatever the failure knew, and the catch clause selects on the type.#include <stdio.h>
#include <string.h>
#define ERROR_NONE 0
#define ERROR_TOO_SHORT 1
#define ERROR_NO_DIGIT 2
static int validate(const char *password, size_t *shortfall) {
size_t length = strlen(password);
if (length < 8) {
*shortfall = 8 - length;
return ERROR_TOO_SHORT;
}
if (strpbrk(password, "0123456789") == NULL) {
return ERROR_NO_DIGIT;
}
return ERROR_NONE;
}
int main(void) {
size_t shortfall = 0;
int status = validate("abc", &shortfall);
if (status == ERROR_TOO_SHORT) {
printf("too short by %zu\n", shortfall);
}
printf("second check: %d\n", validate("abcdefghij", &shortfall));
return 0;
}void Validate(string password) {
if (password.Length < 8) {
throw new TooShortException(8 - password.Length);
}
if (!password.Any(char.IsDigit)) {
throw new ArgumentException("needs a digit", nameof(password));
}
}
try { Validate("abc"); }
catch (TooShortException error) { Console.WriteLine($"too short by {error.Shortfall}"); }
try { Validate("abcdefghij"); }
catch (ArgumentException error) { Console.WriteLine($"rejected: {error.Message}"); }
class TooShortException(int shortfall) : Exception($"short by {shortfall}") {
public int Shortfall { get; } = shortfall;
}The
catch clause dispatches on type, so adding a third failure mode does not renumber anything and does not require every caller to learn a new constant. Two rules worth carrying over from the base library: derive from Exception (not from SystemException), and catch the narrowest type you can actually handle — a bare catch { } swallows the out-of-memory and stack-overflow cases along with your parse error.The Preprocessor Is Almost Gone
No #include: Namespaces and Assemblies
A
#include pastes text into your translation unit before the compiler sees it, which is why every header needs a guard and why compilation is slow. C# has no textual inclusion at all: types are found by name in the assemblies you reference, and using only shortens the names.#include <stdio.h>
#include <math.h>
#include <string.h>
/* Each one pasted a few thousand lines of declarations above this point,
and every one of those headers needed an include guard to survive
being pasted twice. */
int main(void) {
printf("%.4f\n", sqrt(2.0));
printf("%zu\n", strlen("hello"));
return 0;
}// System, System.Text and four more are already in scope for every
// example on this page. This line is what a project-local one looks like:
using Shapes;
Console.WriteLine($"{Math.Sqrt(2.0):F4}");
Console.WriteLine("hello".Length);
Console.WriteLine(Circle.Describe());
namespace Shapes {
static class Circle {
public static string Describe() => "a circle";
}
}A
using directive imports names into scope; it does not read a file, cannot be conditional on anything, and costs nothing at run time. Because there is no textual inclusion there are no include guards, no header/source split, no forward declarations, and no order dependence — a type can be used above the line that declares it, which is why the helper types on this page sit at the bottom of every example.No Function-Like Macros — And Why That Is a Relief
The macro that evaluates its argument twice is the oldest trap in C. C# keeps
#if and #define-as-a-flag but has no function-like macros at all, so the trap has no way to exist.#include <stdio.h>
#define SQUARE(x) ((x) * (x))
int main(void) {
int counter = 2;
/* Expands to ((counter++) * (counter++)) — counter is incremented
twice, and the order is unspecified. */
printf("%d\n", SQUARE(counter++));
printf("counter is now %d\n", counter);
return 0;
}int Square(int value) => value * value;
int counter = 2;
Console.WriteLine(Square(counter++)); // argument evaluated exactly once
Console.WriteLine($"counter is now {counter}");The columns print different numbers on purpose: the C macro increments
counter twice and multiplies two different values, while the C# method takes one argument, evaluated once, before the call. The performance argument for macros is gone too — a small method like this is inlined by the just-in-time compiler, and [MethodImpl(MethodImplOptions.AggressiveInlining)] is there for when you want to insist.What Survives: #if and [Conditional]
Conditional compilation is the one preprocessor feature C# keeps, and it is deliberately weaker: the symbols are flags with no values, so
#if VERSION > 3 cannot be written, and a #define must precede the first token in the file — which is why the symbols below come from the build rather than from a line of source. There is also a second mechanism C has no equivalent for: an attribute that erases calls to a method rather than the method itself.#include <stdio.h>
#define BUILD_LEVEL 2
#if BUILD_LEVEL >= 2
#define TRACE(message) printf("[trace] %s\n", message)
#else
#define TRACE(message) ((void)0)
#endif
int main(void) {
TRACE("starting");
printf("working\n");
TRACE("done");
return 0;
}using System.Diagnostics;
#if NET
Console.WriteLine("[#if] this build defines NET");
#endif
BuildLog.Trace("starting");
Console.WriteLine("working");
BuildLog.Never("this call is erased, arguments and all");
static class BuildLog {
[Conditional("NET")]
public static void Trace(string message)
=> Console.WriteLine($"[trace] {message}");
[Conditional("NOT_DEFINED_ANYWHERE")]
public static void Never(string message)
=> Console.WriteLine($"[never] {message}");
}With
[Conditional], the compiler removes the call sites when the symbol is not defined — including the evaluation of the arguments, which is how Debug.Assert costs nothing in a release build. The call to Never above compiles to no instructions at all. That is the same effect the C macro achieves, but the method is still a real method with real type checking, and it can be defined in one assembly and called from another. NET is defined by the .NET SDK for every modern target, alongside DEBUG or RELEASE for the configuration.Functions, Overloads, and Delegates
One Name, Several Signatures
C has one namespace for functions, which is why the standard library is full of families like
abs/labs/llabs/fabs — the type is spelled in the name because it cannot be spelled in the signature. C# picks the overload from the argument types.#include <stdio.h>
#include <stdlib.h>
#include <math.h>
/* One namespace for functions, so the argument type goes in the name. */
static void describe_int(int value) {
printf("an int: %d\n", value);
}
static void describe_text(const char *value) {
printf("a string: %s\n", value);
}
int main(void) {
printf("%d\n", abs(-5));
printf("%ld\n", labs(-5L));
printf("%.1f\n", fabs(-5.5));
describe_int(42);
describe_text("text");
return 0;
}Console.WriteLine(Math.Abs(-5)); // int overload
Console.WriteLine(Math.Abs(-5L)); // long overload
Console.WriteLine(Math.Abs(-5.5)); // double overload
Console.WriteLine(Text.Describe(42));
Console.WriteLine(Text.Describe("text"));
static class Text {
public static string Describe(int value) => $"an int: {value}";
public static string Describe(string value) => $"a string: {value}";
}C11’s
_Generic is the closest C gets — #define ABS(x) _Generic((x), int: abs, double: fabs)(x) dispatches on the expression’s type at compile time — but it is a macro, it cannot be extended by a caller, and the resulting error messages name the macro rather than your call. C# overload resolution happens at compile time on the static types too, so it is the same kind of decision, but it works on user types, follows inheritance, and needs no macro. The rule that occasionally surprises: the choice is made from the declared type of the argument, not the runtime type, so passing an object that happens to hold a string picks the object overload.Optional Arguments Without Three Wrappers
C’s answer to an optional parameter is another function:
open and open2, or a flags word, or varargs. C# gives the parameter a default and lets the caller name the ones it wants to set.#include <stdio.h>
static void log_message(const char *text, const char *level, int show_time) {
printf("[%s]%s %s\n", level, show_time ? " 12:00" : "", text);
}
/* The two-argument and one-argument forms are separate functions. */
static void log_simple(const char *text) {
log_message(text, "info", 0);
}
int main(void) {
log_simple("started");
log_message("careful", "warn", 0);
log_message("detailed", "info", 1);
return 0;
}void LogMessage(string text, string level = "info", bool showTime = false) {
Console.WriteLine($"[{level}]{(showTime ? " 12:00" : "")} {text}");
}
LogMessage("started");
LogMessage("careful", "warn");
LogMessage("detailed", showTime: true); // skip the middle one by nameA named argument also documents the call site —
Copy(source, target, overwrite: true) reads better than Copy(source, target, 1) — and it lets you skip past a default without repeating it. The catch worth knowing: the default value is compiled into the caller, so changing a default in a library does not affect already-compiled callers until they are rebuilt.A Delegate Is a Function Pointer That Carries State
A C function pointer is an address, so anything the callee needs beyond its arguments has to travel in a
void * context parameter that every layer must thread through. A delegate bundles the code and the captured state into one value.#include <stdio.h>
/* The context parameter exists only because a function pointer
cannot carry anything. */
static void for_each(const int *values, size_t count,
void (*action)(int value, void *context),
void *context) {
for (size_t index = 0; index < count; index++) {
action(values[index], context);
}
}
static void add_to_total(int value, void *context) {
*(int *)context += value;
}
int main(void) {
int numbers[] = { 1, 2, 3, 4 };
int total = 0;
for_each(numbers, 4, add_to_total, &total);
printf("total %d\n", total);
return 0;
}void ForEach(int[] values, Action<int> action) {
foreach (int value in values) {
action(value);
}
}
int[] numbers = { 1, 2, 3, 4 };
int total = 0;
ForEach(numbers, value => total += value); // total is captured
Console.WriteLine($"total {total}");
Func<int, int> doubler = value => value * 2;
Console.WriteLine(doubler(21));The lambda captures
total by reference, so the void * plumbing disappears — the compiler builds a hidden class to hold the captured variable and the delegate points at a method on it. Action<T> is the built-in name for "takes a T, returns nothing" and Func<T, TResult> for "takes a T, returns a TResult", so most code never declares a delegate type at all. The cost is one small allocation per closure, which is why hot loops sometimes still pass state explicitly.params Instead of stdarg.h
A C variadic function cannot know how many arguments it got or what types they were —
printf only works because you tell it in the format string, and lying about it is undefined behavior. A params parameter is an ordinary typed array the compiler fills in.#include <stdio.h>
#include <stdarg.h>
/* The count must be passed explicitly; va_arg cannot check anything. */
static int sum_of(int count, ...) {
va_list arguments;
va_start(arguments, count);
int total = 0;
for (int index = 0; index < count; index++) {
total += va_arg(arguments, int);
}
va_end(arguments);
return total;
}
int main(void) {
printf("%d\n", sum_of(3, 1, 2, 3));
printf("%d\n", sum_of(0));
return 0;
}int SumOf(params int[] values) {
int total = 0;
foreach (int value in values) {
total += value;
}
return total;
}
Console.WriteLine(SumOf(1, 2, 3));
Console.WriteLine(SumOf());
Console.WriteLine(SumOf(new[] { 4, 5 })); // an array works tooThe array knows its own length, so the leading count is gone, and every element is type-checked at the call site —
SumOf(1, "two") does not compile, where sum_of(2, 1, "two") does and then reads a pointer as an int. The one thing to watch is allocation: each call with loose arguments builds an array, which is why string.Format has hand-written overloads for one, two and three arguments before it falls back to params.Generics Instead of void *
Generics Instead of void *
C has two ways to write a container that works for any type:
void * with casts, which loses all checking, or a macro that pastes the type in, which loses readable errors. A generic type keeps the type in the signature and checks it.#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* A void * stack: nothing prevents pushing an int and popping a
char *, and the compiler cannot help. */
struct Stack { void **items; size_t count; };
static void push(struct Stack *self, void *item) {
self->items = realloc(self->items, (self->count + 1) * sizeof(void *));
self->items[self->count++] = item;
}
static void *pop(struct Stack *self) {
return self->count ? self->items[--self->count] : NULL;
}
int main(void) {
struct Stack stack = { NULL, 0 };
int first = 42;
push(&stack, &first);
printf("%d\n", *(int *)pop(&stack)); /* the cast is a promise */
free(stack.items);
return 0;
}var numbers = new Stack<int>();
numbers.Push(42);
Console.WriteLine(numbers.Pop()); // an int, no cast, no boxing
var names = new Stack<string>();
names.Push("hello");
Console.WriteLine(names.Pop().ToUpperInvariant());
// numbers.Push("wrong type"); // error CS1503 at compile timeThe runtime specializes a generic over each value type, so
Stack<int> stores raw ints in an int[] — no boxing, no indirection, and the same memory layout you would have written by hand. That is the part that distinguishes it from Java’s erased generics: the type argument survives to run time, so typeof(T) works and there is no hidden pointer per element.Constraints: Saying What T Must Support
A C macro-template fails at the point of expansion with an error about a line the reader never wrote. A generic constraint states the requirement in the declaration, so the failure is reported at the call site in terms of the type the caller passed.
#include <stdio.h>
/* The macro compiles only if the type happens to support <, and the
error, when it fails, names this line rather than the caller's. */
#define MAXIMUM(a, b) ((a) > (b) ? (a) : (b))
int main(void) {
printf("%d\n", MAXIMUM(3, 7));
printf("%.1f\n", MAXIMUM(2.5, 1.5));
return 0;
}T Maximum<T>(T left, T right) where T : IComparable<T>
=> left.CompareTo(right) >= 0 ? left : right;
Console.WriteLine(Maximum(3, 7));
Console.WriteLine(Maximum(2.5, 1.5));
Console.WriteLine(Maximum("apple", "banana"));
// Maximum(new object(), new object()); // error: object is not IComparableThe constraint is also what makes the body legal: without
where T : IComparable<T> the compiler would reject left.CompareTo, because it cannot know the type has it. Modern C# adds where T : INumber<T>, which lets a generic method use + and < directly — the last case where a macro was genuinely more capable than a generic.Pointers Are Still Here: Span and stackalloc
Span Is a Pointer and a Length, Checked
The
(pointer, length) pair you pass everywhere in C has a name in C#: Span<T>. It is a view into memory that someone else owns, it costs nothing to create, and every access through it is bounds-checked against the length it carries.#include <stdio.h>
/* Summing part of an array means passing a pointer into the middle
and a count, and trusting the caller got both right. */
static int sum(const int *values, size_t count) {
int total = 0;
for (size_t index = 0; index < count; index++) {
total += values[index];
}
return total;
}
int main(void) {
int numbers[] = { 1, 2, 3, 4, 5, 6 };
printf("whole %d\n", sum(numbers, 6));
printf("middle %d\n", sum(numbers + 2, 3)); /* elements 2,3,4 */
return 0;
}int Sum(ReadOnlySpan<int> values) {
int total = 0;
foreach (int value in values) {
total += value;
}
return total;
}
int[] numbers = { 1, 2, 3, 4, 5, 6 };
Console.WriteLine($"whole {Sum(numbers)}");
Console.WriteLine($"middle {Sum(numbers.AsSpan(2, 3))}");
Span<int> writable = numbers.AsSpan(0, 2);
writable[0] = 100; // writes through to the array
Console.WriteLine($"numbers[0] is now {numbers[0]}");A span does not copy — the write through
writable lands in the original array, exactly as a write through numbers + 0 would. What the runtime adds is the length: slicing past the end throws instead of quietly reading a neighbour, and the compiler will not let a span outlive the stack frame it was created in, which is the lifetime rule C leaves entirely to you. A method taking ReadOnlySpan<char> can be called with a string, an array, or a stackalloc buffer with no conversion at all.stackalloc Is alloca With a Type
A scratch buffer that dies with the function is a stack array in C. C# spells it
stackalloc, and the result is a Span<T> — so it is bounds-checked and can be passed to any method that takes a span, without leaving safe code.#include <stdio.h>
int main(void) {
char buffer[16]; /* on the frame, gone at return */
int written = snprintf(buffer, sizeof buffer, "%d-%d", 12, 34);
printf("wrote %d chars: %s\n", written, buffer);
int scratch[4] = { 0 };
for (int index = 0; index < 4; index++) {
scratch[index] = index * 10;
}
printf("%d %d %d %d\n", scratch[0], scratch[1], scratch[2], scratch[3]);
return 0;
}Span<char> buffer = stackalloc char[16];
bool ok = 12.TryFormat(buffer, out int written);
buffer[written++] = '-';
34.TryFormat(buffer[written..], out int more);
written += more;
Console.WriteLine($"wrote {written} chars: {buffer[..written]}");
Span<int> scratch = stackalloc int[4];
for (int index = 0; index < scratch.Length; index++) {
scratch[index] = index * 10;
}
Console.WriteLine(string.Join(" ", scratch.ToArray()));This is how the base library formats numbers without allocating:
TryFormat writes into a caller-supplied span, which is the snprintf contract with the buffer size carried by the buffer. The same warning applies as in C — the buffer lives on the frame, so a large stackalloc in a loop overflows the stack, and the usual rule is a few hundred bytes at most with a heap fallback above that.Real Pointers, Behind the unsafe Keyword
C# does have
*, &, -> and pointer arithmetic. They live behind the unsafe keyword, which the project must opt into with <AllowUnsafeBlocks>, and inside such a block the memory model is C’s: no bounds checks, and you are responsible for lifetimes.#include <stdio.h>
int main(void) {
int numbers[] = { 1, 2, 3, 4 };
int *walk = numbers;
int total = 0;
for (int index = 0; index < 4; index++) {
total += *walk++;
}
printf("total %d\n", total);
return 0;
}// Requires <AllowUnsafeBlocks>true</AllowUnsafeBlocks> in the project file,
// which the runner used by this page does not enable.
unsafe {
int[] numbers = { 1, 2, 3, 4 };
fixed (int* start = numbers) { // pin it so the collector cannot move it
int* walk = start;
int total = 0;
for (int index = 0; index < 4; index++) {
total += *walk++;
}
Console.WriteLine($"total {total}");
}
}The extra word is
fixed, and it is the piece with no C counterpart: the collector is allowed to move a live object during a collection, so taking its address is meaningless unless you pin it first. Pinning for long stretches fragments the heap, which is why the modern advice is to reach for Span<T> instead — it gets you the same pointer-and-length shape with no pinning, no unsafe, and bounds checks the compiler mostly optimizes away.Reinterpreting Bits Without a Union
The C way to see a
double as bytes is a union or a memcpy — a cast through *(long *)&value is a strict-aliasing violation the optimizer may act on. C# has explicit conversion helpers that do the same thing with defined behavior.#include <stdio.h>
#include <string.h>
#include <stdint.h>
int main(void) {
double value = 1.5;
/* memcpy is the portable, aliasing-safe reinterpretation. */
uint64_t bits;
memcpy(&bits, &value, sizeof bits);
printf("bits %llx\n", (unsigned long long)bits);
unsigned char bytes[sizeof(double)];
memcpy(bytes, &value, sizeof bytes);
printf("first byte %02x\n", bytes[0]);
return 0;
}double value = 1.5;
long bits = BitConverter.DoubleToInt64Bits(value);
Console.WriteLine($"bits {bits:x}");
byte[] bytes = BitConverter.GetBytes(value);
Console.WriteLine($"first byte {bytes[0]:x2}");
Console.WriteLine($"little endian: {BitConverter.IsLittleEndian}");
Console.WriteLine($"round trip: {BitConverter.Int64BitsToDouble(bits)}");Both columns print the same bit pattern on a little-endian machine, because both are reinterpreting the same IEEE 754 double.
BitConverter uses the platform’s byte order, exactly as the memcpy does, so a wire format still needs an explicit BinaryPrimitives.WriteInt64BigEndian or the equivalent byte-swapping you would write in C. C# also has [StructLayout(LayoutKind.Explicit)] with [FieldOffset], which is a genuine union.Where They Still Meet: P/Invoke
Making a struct Match a C struct
The runtime is free to reorder the fields of a C# struct. When the struct has to match a C declaration,
[StructLayout(LayoutKind.Sequential)] pins the order and lets you set the packing, so the two languages agree on offsets.#include <stdio.h>
#include <stddef.h>
struct Header {
unsigned char kind;
int length;
unsigned char flags;
};
int main(void) {
printf("sizeof %zu\n", sizeof(struct Header));
printf("kind %zu\n", offsetof(struct Header, kind));
printf("length %zu\n", offsetof(struct Header, length));
printf("flags %zu\n", offsetof(struct Header, flags));
return 0;
}using System.Runtime.InteropServices;
Console.WriteLine($"sizeof {Marshal.SizeOf<Header>()}");
Console.WriteLine($"kind {Marshal.OffsetOf<Header>(nameof(Header.Kind))}");
Console.WriteLine($"length {Marshal.OffsetOf<Header>(nameof(Header.Length))}");
Console.WriteLine($"flags {Marshal.OffsetOf<Header>(nameof(Header.Flags))}");
[StructLayout(LayoutKind.Sequential)]
struct Header {
public byte Kind;
public int Length;
public byte Flags;
}The two columns print the same offsets, including the same padding — which is the point: without the attribute the runtime may pack the two
byte fields together and the layout would silently stop matching. Pack = 1 in the attribute is the equivalent of #pragma pack(1), and LayoutKind.Explicit with [FieldOffset(n)] gives you a union.malloc and free, From C#
Sometimes you need memory the collector does not own — a buffer to hand to a native library that will keep it, or one whose address must not change.
Marshal and NativeMemory are exactly malloc and free, with the same obligations.#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
size_t count = 4;
int *block = malloc(count * sizeof *block);
if (block == NULL) return 1;
for (size_t index = 0; index < count; index++) {
block[index] = (int)index * 7;
}
printf("%d %d %d %d\n", block[0], block[1], block[2], block[3]);
free(block);
return 0;
}using System.Runtime.InteropServices;
int count = 4;
IntPtr block = Marshal.AllocHGlobal(count * sizeof(int));
try {
for (int index = 0; index < count; index++) {
Marshal.WriteInt32(block, index * sizeof(int), index * 7);
}
int[] copied = new int[count];
Marshal.Copy(block, copied, 0, count);
Console.WriteLine(string.Join(" ", copied));
} finally {
Marshal.FreeHGlobal(block); // the free you are back to writing
}Every rule you know applies again inside this block: the memory is not zeroed, the collector will not reclaim it, and forgetting the
FreeHGlobal is a genuine leak. That is why it sits inside a try/finally — the exception safety the rest of C# gives you for free has to be written out by hand here. NativeMemory.Alloc and NativeMemory.AlignedAlloc are the newer, span-friendly spellings of the same thing.Calling a C Function: [DllImport]
This is the destination the whole page points at. You declare the C function’s signature in C#, name the shared library, and the runtime finds the symbol and builds the call — no wrapper code, no build-system changes on the C side.
#include <stdio.h>
#include <string.h>
/* The C side is an ordinary exported function. Compiled into a shared
library it is what the C# declaration on the right binds to. */
size_t string_length(const char *text) {
return strlen(text);
}
int main(void) {
printf("%zu\n", string_length("hello"));
return 0;
}using System.Runtime.InteropServices;
// Binds to strlen in the platform C library. Calling it needs that
// library present under this name, which the runner does not guarantee.
[DllImport("libc", EntryPoint = "strlen")]
static extern nuint StringLength([MarshalAs(UnmanagedType.LPUTF8Str)] string text);
// Console.WriteLine(StringLength("hello"));
Console.WriteLine("declaration only");Three things must agree and only one of them is checked at compile time: the library name, the symbol name, and the exact signature — a mismatched parameter width corrupts the stack rather than raising an error, and a missing library surfaces as
DllNotFoundException at the first call. nuint is the type that exists for this: it is size_t, 64 bits on a 64-bit platform and 32 on a 32-bit one. The [MarshalAs] attribute is what turns the UTF-16 string into the NUL-terminated UTF-8 bytes the C side expects, and it allocates and frees a native copy around the call.Getting C-Shaped Bytes Out of a string
When you hand text to a C library you are handing it bytes in some encoding, terminated by a zero. C# strings are UTF-16 with a stored length, so both halves of that need doing explicitly — which is worth seeing once, because it is what
[MarshalAs] does for you behind the scenes.#include <stdio.h>
#include <string.h>
int main(void) {
const char *text = "caf\u00e9"; /* UTF-8 source bytes */
printf("bytes %zu\n", strlen(text));
for (size_t index = 0; index < strlen(text); index++) {
printf("%02x ", (unsigned char)text[index]);
}
printf("\n");
return 0;
}string text = "caf\u00e9";
Console.WriteLine($"chars {text.Length}");
byte[] utf8 = Encoding.UTF8.GetBytes(text);
Console.WriteLine($"bytes {utf8.Length}");
Console.WriteLine(string.Join(" ", utf8.Select(one => one.ToString("x2"))));
// What a C function would actually receive: the bytes plus a terminator.
byte[] terminated = new byte[utf8.Length + 1];
utf8.CopyTo(terminated, 0);
Console.WriteLine($"terminated length {terminated.Length}, last byte {terminated[^1]}");The two columns print the same four bytes, and the C# column prints a character count of four against a byte count of five — the é is one UTF-16 code unit and two UTF-8 bytes. That gap is the whole interop hazard in miniature: a length that means characters on one side and bytes on the other. When the native side wants UTF-16,
Encoding.Unicode and UnmanagedType.LPWStr are the matching pair.