Hello, World & Output
Hello, World
C# top-level statements let a program be one line; the compiler wraps it in a generated
Main. C has no such shorthand — main is written out, and stdio.h must be included before printf can be named.Console.WriteLine("Hello, World!");#include <stdio.h>
int main(void) {
printf("Hello, World!\n");
return 0;
}Console.WriteLine appends the newline and knows how to render whatever you hand it; printf takes a format string and believes it. The #include is the other difference worth naming: it pastes the header's text into your file before compilation, so it is textual substitution rather than a reference to a compiled assembly.Interpolation Versus Conversion Specifiers
C# interpolation asks each value how it wants to be rendered. C's conversion specifiers each name a machine type, and the arguments carry no type information at runtime, so
printf has no way to notice when you name the wrong one.int count = 42;
double ratio = 1.5;
string name = "sharp";
Console.WriteLine($"{count} {ratio} {name}");
Console.WriteLine("{0} {1} {2}", count, ratio, name);#include <stdio.h>
int main(void) {
int count = 42;
double ratio = 1.5;
const char *name = "sharp";
/* Every value's type is named at the call site, and naming it wrong is
undefined behavior rather than a compile error. */
printf("%d %.1f %s\n", count, ratio, name);
printf("%d %.1f %s\n", count, ratio, name);
return 0;
}The absence of anything like
{0} is the point. C# boxes the arguments or uses an interpolated-string handler, either way carrying the type along; C's variadic arguments are raw bytes on the stack, so %d against a long reads the wrong width. Modern compilers special-case printf and warn, which is a courtesy rather than a language rule.struct Is a Value, class Is a Reference
A struct Copies; A class Aliases
This is the distinction the rest of the page depends on. Assigning a C#
struct copies every field; assigning a class copies only the reference, so both names see the same object. In C everything behaves like the struct case unless you explicitly take an address.var firstPoint = new PointValue { X = 1, Y = 2 };
var secondPoint = firstPoint;
secondPoint.X = 99;
Console.WriteLine($"{firstPoint.X} {secondPoint.X}");
var firstBox = new PointObject { X = 1, Y = 2 };
var secondBox = firstBox;
secondBox.X = 99;
Console.WriteLine($"{firstBox.X} {secondBox.X}");
struct PointValue { public int X; public int Y; }
class PointObject { public int X; public int Y; }#include <stdio.h>
typedef struct { int x; int y; } Point;
int main(void) {
/* Assignment copies — this is C's only behavior for a struct. */
Point first_point = { 1, 2 };
Point second_point = first_point;
second_point.x = 99;
printf("%d %d\n", first_point.x, second_point.x);
/* To alias, you take an address explicitly. There is no 'class'. */
Point first_box = { 1, 2 };
Point *second_box = &first_box;
second_box->x = 99;
printf("%d %d\n", first_box.x, second_box->x);
return 0;
}C makes the choice visible at every use —
. for a value and -> for a pointer — while C# makes it a property of the type you declared once, far from the assignment that surprises you. That is why "is this a struct or a class?" is the first question when a C# assignment does not behave as expected, and why record struct exists to give value semantics a friendlier surface.null and NULL Are the Same Zero
A C# reference that is
null and a C pointer that is NULL are both the address zero. The difference is entirely in what happens when you follow one: C# checks and throws, C does not check.PointObject? maybePoint = null;
if (maybePoint is null)
{
Console.WriteLine("null");
}
else
{
Console.WriteLine(maybePoint.X);
}
class PointObject { public int X; }#include <stdio.h>
typedef struct { int x; } Point;
int main(void) {
Point *maybe_point = NULL;
/* This check is the one the CLR performs for you on every dereference. */
if (maybe_point == NULL) {
printf("null\n");
} else {
printf("%d\n", maybe_point->x);
}
return 0;
}A
NullReferenceException is the runtime having done this comparison and found zero — which is why it is an exception with a stack trace rather than a crash. C's equivalent is a segmentation fault delivered by the operating system when the hardware refuses the access, with no message and no line number. Nullable reference types move the same question to compile time, which is the only place it can be answered for free.A Collector, And Doing Without One
new Versus malloc and free
C#
new allocates on a managed heap and the collector reclaims it whenever it likes. C's malloc returns an address or NULL, and reclaiming it is a line you write.var buffer = new int[4];
for (int index = 0; index < buffer.Length; index++)
{
buffer[index] = index * 10;
}
Console.WriteLine(string.Join(" ", buffer));
// Nothing is freed here. The collector decides when.#include <stdio.h>
#include <stdlib.h>
int main(void) {
size_t count = 4;
int *buffer = malloc(count * sizeof(int));
if (buffer == NULL) return 1; /* allocation can FAIL, visibly */
for (size_t index = 0; index < count; index++) {
buffer[index] = (int) index * 10;
}
for (size_t index = 0; index < count; index++) {
printf("%d", buffer[index]);
if (index + 1 < count) printf(" ");
}
printf("\n");
free(buffer); /* and someone must remember this */
return 0;
}Two things are visible in C and invisible in C#: allocation can fail and must be checked, and reclamation is a statement at a place you can point to. What C# buys is that neither can be got wrong; what it costs is that "when is this memory released?" has no answer more precise than "later". That imprecision is exactly why
IDisposable had to be invented for things that are not memory.What Actually Lives on the Stack
A C# local of value type lives in the stack frame; a local of reference type is a reference in the frame pointing at the heap. C locals are in the frame unless you allocated them, so the two pictures line up once you know which C# type you have.
int onStack = 42;
int[] onHeap = new int[] { 42 };
Console.WriteLine(onStack);
Console.WriteLine(onHeap[0]);
Console.WriteLine(sizeof(int));#include <stdio.h>
#include <stdlib.h>
int main(void) {
int on_stack = 42; /* in the frame */
int *on_heap = malloc(sizeof(int)); /* the POINTER is in the frame */
if (on_heap == NULL) return 1;
on_heap[0] = 42;
printf("%d\n", on_stack);
printf("%d\n", on_heap[0]);
printf("%zu\n", sizeof(int));
free(on_heap);
return 0;
}The C column is the honest diagram of the C# one: a value type is the thing itself, a reference type is an address to somewhere else. That is why an array of a thousand small structs is one contiguous block while an array of a thousand small classes is a thousand pointers plus a thousand objects — a difference that shows up as cache behavior long before it shows up as memory usage.
Span<T> Is a Pointer and a Length
Span<T> Is a Pointer and a Length
A
Span<T> is exactly two things — where the data starts and how many elements there are — and it does not own or copy them. This is C#'s version of the pair a C function takes as two parameters, made a single type the compiler can check.int[] numbers = { 10, 20, 30, 40 };
Span<int> window = numbers.AsSpan(1, 2);
window[0] = 99;
Console.WriteLine(string.Join(" ", numbers));
Console.WriteLine(window.Length);#include <stdio.h>
/* The C convention Span<T> formalizes: a pointer and a count, together. */
typedef struct {
int *data;
size_t length;
} IntSpan;
int main(void) {
int numbers[4] = { 10, 20, 30, 40 };
IntSpan window = { numbers + 1, 2 }; /* no copy — a VIEW */
window.data[0] = 99;
for (size_t index = 0; index < 4; index++) {
printf("%d", numbers[index]);
if (index + 1 < 4) printf(" ");
}
printf("\n");
printf("%zu\n", window.length);
return 0;
}Writing through the span changes the original array, because there was only ever one array — the same aliasing a C programmer expects from a pointer into the middle of a buffer. The reason
Span<T> cannot be stored in a field or captured in a lambda is this exact picture: it is a pointer with no ownership, so the runtime confines it to the stack where its lifetime is obvious.stackalloc Is an Array in the Frame
stackalloc reserves space in the current stack frame rather than on the heap, so there is nothing for the collector to track and nothing to free. Assigned into a Span<T> it is ordinary safe code — no unsafe needed. C does the same thing by declaring a local array.Span<int> scratch = stackalloc int[4];
for (int index = 0; index < scratch.Length; index++)
{
scratch[index] = index * 10;
}
int total = 0;
foreach (int value in scratch) total += value;
Console.WriteLine(total);#include <stdio.h>
int main(void) {
/* A local array IS stackalloc. No malloc, no free. */
int scratch[4];
for (size_t index = 0; index < 4; index++) {
scratch[index] = (int) index * 10;
}
int total = 0;
for (size_t index = 0; index < 4; index++) total += scratch[index];
printf("%d\n", total);
return 0;
}Both columns share the same hazard, and it is the reason
stackalloc in a loop is a bug: the frame is a fixed budget, and nothing reclaims a stack allocation until the function returns. C# limits the damage by forbidding a Span<T> from escaping the frame it points into; C offers no such protection, which is why returning a pointer to a local is the classic C mistake.unsafe and fixed
unsafe Gives You C's Pointers Back
Inside an
unsafe block C# has real pointers, real dereferencing, and real pointer arithmetic scaled by the pointed-to type — the same rules as C. The C# column is illustrative here because compiling it needs the AllowUnsafeBlocks option, which the runner's throwaway project does not set; the C column runs.// Requires <AllowUnsafeBlocks>true</AllowUnsafeBlocks> in the project file.
unsafe
{
int[] numbers = { 10, 20, 30 };
fixed (int* start = numbers)
{
int* cursor = start;
int total = 0;
for (int index = 0; index < 3; index++)
{
total += *cursor;
cursor++; // advances by sizeof(int), exactly as in C
}
Console.WriteLine(total);
}
}#include <stdio.h>
int main(void) {
int numbers[3] = { 10, 20, 30 };
int *cursor = numbers;
int total = 0;
for (int index = 0; index < 3; index++) {
total += *cursor;
cursor++; /* advances by sizeof(int) */
}
printf("%d\n", total);
return 0;
}The two loops are the same program. What C# adds is the
fixed block wrapped around it, and that block is the whole story of the difference: the garbage collector moves objects to compact the heap, so an address taken without pinning could be stale by the next instruction. C has no collector and therefore no need to pin — an address stays valid until you free the thing.Why fixed Exists At All
The safe way to get the same effect without
unsafe: Span<T> gives you the pointer-and-length view, and the runtime tracks it so nothing needs pinning. The row shows both halves of the same idea — the C column is what the pinned pointer would be pointing at.int[] numbers = { 10, 20, 30 };
// No unsafe, no fixed: the runtime knows about the span and
// will not move what it refers to while it is alive.
Span<int> view = numbers;
int total = 0;
for (int index = 0; index < view.Length; index++)
{
total += view[index];
}
Console.WriteLine(total);#include <stdio.h>
int main(void) {
int numbers[3] = { 10, 20, 30 };
/* Nothing here can move, so nothing needs pinning. */
int *view = numbers;
size_t length = 3;
int total = 0;
for (size_t index = 0; index < length; index++) {
total += view[index];
}
printf("%d\n", total);
return 0;
}This is why "just use
Span<T>" is the standard advice for the performance work that used to require unsafe: it expresses the same pointer-and-length idea while remaining something the collector understands. Reach for fixed only when an address must leave the managed world entirely — which in practice means P/Invoke.Layout, Padding & StructLayout
Field Order Changes the Size
Both languages insert padding so each field lands on its natural alignment. The difference that matters for interop: the CLR is allowed to reorder your fields unless you tell it not to, so
[StructLayout(LayoutKind.Sequential)] is the promise that the declaration order is the memory order. StructLayout and Marshal both live in System.Runtime.InteropServices, which is not one of the implicit usings — the directive has to be written, and in a top-level program it must come before any statement.using System.Runtime.InteropServices;
Console.WriteLine(Marshal.SizeOf<Padded>());
Console.WriteLine(Marshal.SizeOf<Packed>());
[StructLayout(LayoutKind.Sequential)]
struct Padded { public byte Flag; public long Value; public byte Small; }
[StructLayout(LayoutKind.Sequential)]
struct Packed { public long Value; public byte Flag; public byte Small; }#include <stdio.h>
#include <stdint.h>
typedef struct {
unsigned char flag; /* 1, then 7 bytes of padding */
int64_t value; /* 8 */
unsigned char small; /* 1, then 7 more to round the struct out */
} Padded;
typedef struct {
int64_t value; /* 8 */
unsigned char flag; /* 1 */
unsigned char small; /* 1, then 6 to round out */
} Packed;
int main(void) {
printf("%zu\n", sizeof(Padded));
printf("%zu\n", sizeof(Packed));
return 0;
}Twenty-four bytes against sixteen, from reordering three fields — and the numbers agree across the two columns only because of the
Sequential attribute. Without it the CLR may pack the struct however it likes, and a struct handed to a C function would have its fields in the wrong places with no error anywhere. This attribute is the foundation every P/Invoke signature is built on.Strings, Counted and Immutable
A Counted String Versus a Terminated One
A C# string stores its length, so
Length is a field read and the string may contain any character including \0. A C string is an address whose end is the first zero byte, which nothing records and every operation must rediscover.string message = "Hello, World!";
Console.WriteLine(message.Length);
Console.WriteLine(message.Substring(0, 5));
string withZero = "ab\0cd";
Console.WriteLine(withZero.Length);#include <stdio.h>
#include <string.h>
int main(void) {
const char *message = "Hello, World!";
/* strlen is a LOOP looking for the zero byte. */
printf("%zu\n", strlen(message));
/* A substring needs somewhere to live and a terminator of its own. */
char window[6];
memcpy(window, message, 5);
window[5] = '\0';
printf("%s\n", window);
/* A C string CANNOT contain a zero byte — that is where it ends. */
const char with_zero[] = "ab\0cd";
printf("%zu\n", strlen(with_zero));
return 0;
}C reports 2 where C# reports 5, and that disagreement is the whole lesson: a zero byte is data on one side and a terminator on the other. It is also the single most common P/Invoke surprise — marshalling a C# string to
char* produces a NUL-terminated copy, so anything after an embedded zero is silently lost on the way out.Arrays Know Their Length
The Length Travels With the Array
A C# array carries its length, so
Length works anywhere the array goes and every index is checked against it. A C array name turns into a bare pointer the moment it is passed anywhere, losing the size entirely — which is why C functions take a count beside the pointer.int Sum(int[] values)
{
int total = 0;
for (int index = 0; index < values.Length; index++)
{
total += values[index];
}
return total;
}
int[] numbers = { 10, 20, 12 };
Console.WriteLine(Sum(numbers));
Console.WriteLine(numbers.Length);#include <stdio.h>
/* The count MUST be passed: 'values' is a pointer here, not an array,
and sizeof(values) would give the size of a pointer. */
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] = { 10, 20, 12 };
size_t count = sizeof(numbers) / sizeof(numbers[0]); /* only works HERE */
printf("%d\n", sum(numbers, count));
printf("%zu\n", count);
return 0;
}The
sizeof(numbers) / sizeof(numbers[0]) idiom works in main and would silently give 1 or 2 inside sum, because there the name is a pointer. Passing the pair by hand at every call is precisely the discipline Span<T> packages up — which is why a P/Invoke signature for this function takes an array and a length, and why getting the length wrong corrupts memory rather than throwing.Methods, ref and out
ref and out Are Pointers
A
ref or out parameter passes the variable's address so the method can write through it. C spells the same thing with a pointer parameter and an explicit & at the call site.void Divide(int numerator, int denominator, out int quotient, out int remainder)
{
quotient = numerator / denominator;
remainder = numerator % denominator;
}
Divide(17, 5, out int quotient, out int remainder);
Console.WriteLine($"{quotient} {remainder}");#include <stdio.h>
/* out parameters are pointer parameters. */
static void divide(int numerator, int denominator,
int *quotient, int *remainder) {
*quotient = numerator / denominator;
*remainder = numerator % denominator;
}
int main(void) {
int quotient = 0;
int remainder = 0;
divide(17, 5, "ient, &remainder);
printf("%d %d\n", quotient, remainder);
return 0;
}The difference C# adds is definite-assignment analysis: an
out parameter must be assigned before the method returns, and the caller may not read the variable before the call. C promises neither, so a function that returns early having written only one of the two out-parameters leaves the other holding whatever it held before — which is why C code initializes them at the declaration, as the column above does.Exceptions Versus Return Codes
Throwing Versus Returning a Status
A C# exception unwinds the stack, runs every
finally on the way, and carries a message and a trace. C has no such mechanism: failure is a value the function returns, and ignoring it is legal.bool TryParseNumber(string text, out int value)
{
return int.TryParse(text, out value);
}
if (TryParseNumber("123", out int parsed))
{
Console.WriteLine(parsed);
}
if (!TryParseNumber("nope", out int _))
{
Console.WriteLine("error: bad number");
}#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
/* Returns 1 on success and writes *out; 0 on failure. The Try- pattern,
which C# borrowed from exactly this convention. */
static int try_parse_number(const char *text, long *out) {
char *end = NULL;
errno = 0;
long value = strtol(text, &end, 10);
if (errno != 0 || end == text || *end != '\0') return 0;
*out = value;
return 1;
}
int main(void) {
long parsed = 0;
if (try_parse_number("123", &parsed)) {
printf("%ld\n", parsed);
}
long ignored = 0;
if (!try_parse_number("nope", &ignored)) {
printf("error: bad number\n");
}
return 0;
}The
Try- pattern is C# adopting C's convention for the cases where an exception is too expensive or too dramatic, and the two columns are almost the same shape as a result. Note what C requires that C# does not: errno must be cleared before the call, and strtol signals "not a number at all" only by leaving the end pointer where it started.using Versus Manual Cleanup
using Versus goto cleanup
using calls Dispose when the scope ends, on every path out including an exception. C's equivalent is the goto cleanup idiom — one exit block at the bottom, jumped to from each failure point, maintained by hand.string Process(bool shouldFail)
{
using var resource = new Resource("first");
using var second = new Resource("second");
if (shouldFail) return "failed early";
return "finished";
}
Console.WriteLine(Process(true));
Console.WriteLine(Process(false));
class Resource : IDisposable
{
private readonly string name;
public Resource(string name) { this.name = name; }
public void Dispose() => Console.WriteLine($"closing {name}");
}#include <stdio.h>
static const char *process(int should_fail) {
const char *result = "finished";
if (should_fail) {
result = "failed early";
goto cleanup; /* every early exit must remember to jump */
}
cleanup:
/* Reverse order is yours to arrange, too. */
printf("closing second\n");
printf("closing first\n");
return result;
}
int main(void) {
printf("%s\n", process(1));
printf("%s\n", process(0));
return 0;
}The reverse ordering is not decoration — resources are released in the opposite order they were acquired, which matters when the second depends on the first. C# guarantees it; the C column achieves it by writing two lines in the right order and trusting the next person to keep the pattern when they add a third. This is also the reason
IDisposable exists at all: the collector handles memory but has nothing to say about file handles or sockets.Generics Versus void*
Generics Versus void* and a Size
A C# generic method is compiled once for all reference types and specialized per value type, and the type is checked at the call site. C's only equivalent is a function taking
void *, the element size, and a comparison callback — which is exactly what qsort is.T Largest<T>(T first, T second) where T : IComparable<T>
{
return first.CompareTo(second) >= 0 ? first : second;
}
Console.WriteLine(Largest(4, 7));
Console.WriteLine(Largest("apple", "pear"));#include <stdio.h>
#include <string.h>
/* No types, so: two addresses, and a function that knows how to compare them. */
static const void *largest(const void *first, const void *second,
int (*compare)(const void *, const void *)) {
return compare(first, second) >= 0 ? first : second;
}
static int compare_int(const void *left, const void *right) {
int a = *(const int *) left, b = *(const int *) right;
return (a > b) - (a < b);
}
static int compare_string(const void *left, const void *right) {
return strcmp(*(const char *const *) left, *(const char *const *) right);
}
int main(void) {
int four = 4, seven = 7;
printf("%d\n", *(const int *) largest(&four, &seven, compare_int));
const char *apple = "apple", *pear = "pear";
printf("%s\n", *(const char *const *) largest(&apple, &pear, compare_string));
return 0;
}Every cast in the C column is a place the compiler has stopped helping: pass
compare_string with two int addresses and it compiles cleanly and reads the integers as pointers. The type parameter is the thing C# has and C does not, and the casts are what fill the hole. This is also why the void *-plus-callback shape shows up in every C library that wants to be reusable.P/Invoke: Where They Actually Meet
A P/Invoke Signature Mirrors the Header
This is the row the page exists for. A
[DllImport] declaration is a C prototype restated in C# — the C# column below is the declaration you would write for the C function beside it. It cannot run here (there is no shared library to load), so it is illustrative; the C side runs.// The C function beside this:
// long combine(long first, long second, long third);
//
// The C# declaration that binds to it. Note that C's long is 64-bit on
// Linux and macOS but 32-bit on Windows, so nint/long is a platform
// decision rather than a free choice.
[DllImport("libcombine", EntryPoint = "combine")]
static extern long Combine(long first, long second, long third);
// Calling it would look exactly like any other static method:
// Console.WriteLine(Combine(2, 5, 8));
Console.WriteLine("declaration only");#include <stdio.h>
/* The exported function a [DllImport] would bind to. */
long combine(long first, long second, long third) {
return first + second * third;
}
int main(void) {
printf("%ld\n", combine(2, 5, 8));
return 0;
}Three things have to agree for this to work and none of them is checked at compile time: the symbol name, the parameter widths, and the calling convention. Getting the width wrong is the quiet one — a C
long is 8 bytes on Linux and macOS and 4 on Windows, so a signature that is right on one platform silently reads garbage on another. Using the stdint.h names on the C side and the matching fixed-width C# types removes the ambiguity entirely.Gotchas For C# Developers
C's int and long Are Not Fixed Widths
C# integer types name their width exactly:
int is always 32 bits and long always 64. C's int, long and short have only minimum widths, which is why stdint.h exists and why portable C uses its names.Console.WriteLine(sizeof(int));
Console.WriteLine(sizeof(long));
Console.WriteLine(int.MaxValue);#include <stdio.h>
#include <stdint.h>
int main(void) {
/* sizeof(long) is 8 on Linux/macOS 64-bit and 4 on 64-bit Windows.
int32_t and int64_t mean the same thing everywhere. */
printf("%zu\n", sizeof(int32_t));
printf("%zu\n", sizeof(int64_t));
printf("%d\n", INT32_MAX);
return 0;
}This is the first thing that bites in P/Invoke, and it bites silently. A C#
long is 64 bits everywhere; a C long is not, so a binding written and tested on Linux can truncate on Windows with no error. nint and nuint exist for the pointer-sized cases, and naming int32_t/int64_t on the C side of an interface is the only way to make the two declarations provably agree.Nothing Checks the Index
C# checks every array index against the length and throws
IndexOutOfRangeException. C does not check, and reading past the end is undefined behavior — usually it just returns whatever bytes are next, which is what makes the bug so hard to find.int[] numbers = { 10, 20, 30 };
int index = 5;
if (index >= numbers.Length)
{
Console.WriteLine($"index {index} out of range [0:{numbers.Length}]");
}
else
{
Console.WriteLine(numbers[index]);
}#include <stdio.h>
int main(void) {
int numbers[3] = { 10, 20, 30 };
size_t count = 3;
size_t index = 5;
/* This check is the one the CLR emits for you. Omit it and numbers[5]
reads past the array with nothing to object. */
if (index >= count) {
printf("index %zu out of range [0:%zu]\n", index, count);
} else {
printf("%d\n", numbers[index]);
}
return 0;
}The comparison had to be written, and it had to use an unsigned type so a "negative" index wraps to something enormous and still fails the test. C#'s check is not free either — it is a compare and a branch on every index — but the JIT removes most of them by proving the bound once, which is why
foreach over an array is usually faster than indexing it in a loop.C Reads Top to Bottom, Once
C# resolves names across the whole compilation regardless of order, so a method may call one declared below it. A C compiler reads the file once from the top, so anything used must already have been declared — which is what header files and forward declarations are for.
// Helper is defined below and that is fine —
// C# resolves the whole compilation, not one line at a time.
Console.WriteLine(Helper(21));
static int Helper(int value) => value * 2;#include <stdio.h>
/* The forward declaration. Without it the compiler reaches the call in
main() having never heard of helper, and this does not compile. */
static int helper(int value);
int main(void) {
printf("%d\n", helper(21));
return 0;
}
static int helper(int value) {
return value * 2;
}This single-pass model is why a C project has a
.h beside every .c, why headers need include guards, and why build times grow with the amount of text pasted in. C#'s assembly-wide resolution is also what lets partial classes and generated code work at all — the compiler never needs to have seen a declaration before the line that uses it.