PONY λ M2 Modula-2

Erlang.CodeCompared.To/C

An interactive executable cheatsheet comparing Erlang and C

Erlang/OTP 26 C17 (GCC)
Output & Running
Hello, World
io:format("Hello, World!~n").
#include <stdio.h> int main(void) { printf("Hello, World!\n"); return 0; }
Both languages spell the newline explicitly rather than appending it automatically: Erlang's ~n directive and C's \n escape. Unlike Erlang's shell-expression sandbox, every runnable C program needs an explicit int main(void) entry point and an explicit return 0 to signal success.
Formatted output with placeholders
Name = "Ada", Age = 36, io:format("~s is ~p years old~n", [Name, Age]).
#include <stdio.h> int main(void) { const char *name = "Ada"; int age = 36; printf("%s is %d years old\n", name, age); return 0; }
Both use a template string with placeholders and a separate argument list — io:format's ~s/~p and printf's %s/%d. The critical difference: Erlang checks the number and shape of arguments against the format string at the point of the call and raises a clean error on mismatch; C's printf performs no such check at all — passing the wrong type for a %d is undefined behavior, not a caught error.
Two Roads From Types
Dynamic-but-safe vs. static-but-unsafe
% Erlang: no type declared, but a type mismatch is caught cleanly % at the moment it happens, as an ordinary crash: Add = fun(X, Y) -> X + Y end, io:format("~p~n", [Add(2, 3)]).
#include <stdio.h> int add(int x, int y) { return x + y; } int main(void) { printf("%d\n", add(2, 3)); return 0; }
This pairing does not fit the usual "dynamic vs. static" story the same way Haskell, Scala, Go, F#, or Rust do. C's types are checked at compile time, but the type system offers few safety guarantees — implicit numeric conversions, unchecked pointer casts, and array-to-pointer decay all quietly paper over real mistakes. Erlang has no compile-time checking at all, but every type mismatch it does not catch produces a clean, isolated crash — never memory corruption, never silently wrong output from a language-level type confusion.
Manual Memory Management
The reversed contrast: here, ERLANG has the garbage collector
% Erlang: every process has its own heap, collected automatically — % memory management is invisible, always: List = [1, 2, 3], io:format("~p~n", [List]).
#include <stdio.h> #include <stdlib.h> int main(void) { int *numbers = malloc(3 * sizeof(int)); numbers[0] = 1; numbers[1] = 2; numbers[2] = 3; printf("%d %d %d\n", numbers[0], numbers[1], numbers[2]); free(numbers); // YOUR job — forgetting this leaks memory forever return 0; }
Every other statically-typed language in this comparison (Haskell, Scala, Go, F#) still has a garbage collector — Rust was the exception, with its ownership model. C has neither a garbage collector nor ownership tracking: malloc and free are ordinary function calls, and the compiler enforces nothing about pairing them correctly. Erlang's automatic, per-process GC — invisible in every single example on this page — is the feature C asks you to reimplement by hand, forever, in every single program.
Forgetting to free — or freeing twice — is undefined behavior
% Erlang: there is no "free" to forget — a value is reclaimed % automatically once nothing references it anymore: Value = [1, 2, 3], io:format("~p~n", [Value]).
#include <stdio.h> #include <stdlib.h> int main(void) { int *value = malloc(sizeof(int)); *value = 42; printf("%d\n", *value); free(value); // free(value); a second time here would be a DOUBLE FREE — undefined // behavior, not a clean error the way an Erlang badarg or badmatch is. return 0; }
A double-free or a use-after-free in C is not a clean, catchable error the way every Erlang crash is — it is undefined behavior, which might crash immediately, might corrupt unrelated memory, or might appear to work fine until a much later, unrelated failure. Erlang has no free at all, so this entire category of bug is structurally impossible — not merely rare or caught, but inexpressible.
No Pattern Matching, No Tagging
What replaces `case`: `switch` and `if`
Classify = fun(N) -> case N of 0 -> zero; N when N > 0 -> positive; _ -> negative end end, io:format("~p~n", [Classify(-5)]).
#include <stdio.h> const char *classify(int n) { if (n == 0) { return "zero"; } else if (n > 0) { return "positive"; } return "negative"; } int main(void) { printf("%s\n", classify(-5)); return 0; }
C has no pattern matching at all, and no destructuring — every "which case is this" decision is an explicit if/else or switch over already-extracted values, never a match against a value's shape. This is the same gap Go has relative to Erlang, but more pronounced: C's switch only works on integers and characters, not even the boolean-condition-chain flexibility Go's bare switch offers.
Tagged Tuples vs. Structs
Records vs. structs
Point = {point, 3, 4}, {point, X, Y} = Point, io:format("~p and ~p~n", [X, Y]).
#include <stdio.h> struct Point { int x; int y; }; int main(void) { struct Point point = { .x = 3, .y = 4 }; printf("%d and %d\n", point.x, point.y); return 0; }
Erlang's -record(point, {x, y}). desugars to a tagged tuple — a naming convenience, indistinguishable at runtime from any other tuple of the same shape. C's struct Point { int x; int y; } is a genuinely distinct type, but with no runtime tag or identity check whatsoever — a struct with the identical field types and no tag at all can be silently reinterpreted as a Point via a pointer cast, something neither Erlang's runtime tuple tag nor most other statically-typed languages' type systems would permit.
Modeling "one of several shapes" needs a manual tag
Shapes = [{circle, 5}, {rectangle, 3, 4}], Areas = lists:map(fun ({circle, Radius}) -> 3.14159 * Radius * Radius; ({rectangle, Width, Height}) -> Width * Height end, Shapes), io:format("~p~n", [Areas]).
#include <stdio.h> enum ShapeTag { CIRCLE, RECTANGLE }; struct Shape { enum ShapeTag tag; union { double radius; struct { double width; double height; } rectangle; } data; }; double area(struct Shape shape) { if (shape.tag == CIRCLE) { return 3.14159 * shape.data.radius * shape.data.radius; } return shape.data.rectangle.width * shape.data.rectangle.height; } int main(void) { struct Shape circle = { .tag = CIRCLE, .data = { .radius = 5.0 } }; printf("%f\n", area(circle)); return 0; }
Erlang's tagged tuples give you "one of several shapes" for free, checked automatically at match time. C has no built-in tagged-union type — a "tagged union" is a pattern you build yourself from an enum (the tag) plus a union (the payload), and nothing stops you from reading the wrong union member for the current tag; the compiler enforces none of the discipline Erlang's pattern match enforces automatically.
Charlists vs. Null-Terminated Strings
A different, but related, string representation
% Erlang: "abc" is a LIST of character codes: Value = "abc", io:format("~w~n", [Value]). % shows [97,98,99]
#include <stdio.h> #include <string.h> int main(void) { char value[] = "abc"; // a null-terminated array of characters printf("%zu\n", strlen(value)); return 0; }
Both languages represent a string as, fundamentally, a sequence of individual characters rather than an opaque built-in type — Erlang's "abc" is a linked list of integer codes; C's "abc" is a contiguous array of char, terminated by an invisible \0 byte. C's null-termination is the load-bearing difference: strlen must scan forward until it finds that byte, an O(n) operation with no length stored anywhere, unlike Erlang's list (also O(n) to measure, but for the different reason of being a linked structure).
No bounds checking — a classic C hazard with no Erlang analogue
% Erlang: reading or writing past a list's end simply cannot % happen — there is no raw memory buffer to overrun: Value = "hi", io:format("~p~n", [length(Value)]).
#include <stdio.h> #include <string.h> int main(void) { char buffer[3]; // room for "hi" + the null terminator — exactly enough strcpy(buffer, "hi"); // strcpy(buffer, "this is way too long"); here would silently write // PAST the end of "buffer" — undefined behavior, not a caught error, // and famously the root cause of a huge share of real-world security // vulnerabilities in C programs over the decades. printf("%s\n", buffer); return 0; }
This has no Erlang parallel whatsoever: Erlang lists and binaries have no fixed-size backing buffer for a careless write to overrun, because there is no raw memory address exposed to the program at all. In C, an oversized strcpy into a fixed buffer is a buffer overflow — one of the most consequential classes of bug in the language's history, and something the type system does nothing whatsoever to prevent.
Pointers: A Wholly New Concept
Pointers: a concept Erlang has no equivalent for
% Erlang has no addresses, no pointers, and no way to ask % "where does this value live in memory": Value = 42, io:format("~p~n", [Value]).
#include <stdio.h> int main(void) { int value = 42; int *pointer = &value; // "&" takes the address of value printf("%d\n", *pointer); // "*" dereferences — reads through the pointer return 0; }
This is a genuinely new concept, not a different syntax for something Erlang already has. Erlang programmers never see a memory address; every value is manipulated by binding, matching, and passing, never by "where it lives." C's &value (take the address) and *pointer (follow the address to the value) expose the machine's actual memory layout directly — the foundation for both C's performance and nearly every one of its classic bug categories.
Clean Crash vs. Undefined Behavior
A caught runtime error vs. undefined behavior
try 10 div 0 of Result -> io:format("~p~n", [Result]) catch error:badarith -> io:format("crashed: badarith~n") end.
#include <stdio.h> int main(void) { int numerator = 10; int denominator = 0; // numerator / denominator here would be UNDEFINED BEHAVIOR in C — // typically a crash (SIGFPE) on most platforms, but the C standard // does not guarantee ANY particular outcome at all: printf("skipped to avoid actually invoking undefined behavior\n"); return 0; }
Erlang's badarith is a well-defined, catchable, documented runtime error — dividing by zero always produces exactly this exception, every time, on every platform. Integer division by zero in C is undefined behavior — the C standard makes no guarantee about what happens at all, and different platforms genuinely differ (commonly a SIGFPE crash, but not guaranteed). This example intentionally does not execute the division, since undefined behavior is by definition unpredictable to demonstrate reliably.
An isolated crash vs. the whole program going down
% Erlang: THIS process crashes — every other process, and the % whole rest of the system, keeps running completely unaffected: Pid = spawn(fun() -> 1/0 end), io:format("~p~n", [is_pid(Pid)]).
#include <stdio.h> int main(void) { // A segfault or an unhandled signal in C typically takes down the // ENTIRE process — there is no per-task isolation boundary the way // an Erlang process provides. (Not actually triggered here, since a // real segfault would stop the test harness, not just this example.) printf("in C, one bad memory access can end the whole program\n"); return 0; }
This is the deepest philosophical gap between the two languages. An Erlang process crashing is a routine, expected, local event — every other process keeps running, and a supervisor typically restarts the failed one within milliseconds. A C program has no equivalent isolation boundary at all: a segfault, a stack overflow, or sufficiently severe undefined behavior can bring down the entire process, taking every unrelated task running inside it down at the same time. Erlang's "let it crash" philosophy is only viable because the BEAM guarantees a crash stays contained — a guarantee C's runtime model does not offer.
Processes vs. Threads
C has no concurrency primitive built into the language at all
Pid = spawn(fun() -> io:format("running in a process~n") end), io:format("~p~n", [is_pid(Pid)]).
// C has no concurrency built into the LANGUAGE at all — threads come // from an external OS-level library (POSIX threads / pthreads), not // core C syntax: // #include <pthread.h> // // void *run(void *argument) { // printf("running on a thread\n"); // return NULL; // } // // int main(void) { // pthread_t thread; // pthread_create(&thread, NULL, run, NULL); // pthread_join(thread, NULL); // return 0; // } #include <stdio.h> int main(void) { printf("illustrating pthreads by comment — see above\n"); return 0; }
Erlang's spawn/1 is core language syntax, available everywhere, with no import needed. C has nothing playing this role at all — pthread_create comes from POSIX, an operating-system API bolted on top of the language, not part of C itself, and Compiler Explorer's single-file execution sandbox does not link against it, hence the commented-out illustration.
Threads share memory directly — locks are your responsibility
% Erlang processes share NOTHING — there is no shared counter to % protect, and therefore no possibility of a data race on it: self() ! {increment}, receive {increment} -> io:format("incremented safely~n") end.
// pthreads share the process's memory directly. Protecting a shared // counter needs an explicit mutex — nothing in the language enforces // this, unlike Rust's compiler-checked Send/Sync or Erlang's isolation: // pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; // pthread_mutex_lock(&lock); // counter++; // pthread_mutex_unlock(&lock); #include <stdio.h> int main(void) { printf("illustrating a mutex-protected counter by comment — see above\n"); return 0; }
This is the most manual, most error-prone version of the "shared vs. isolated state" theme that runs through every Erlang comparison on the site. Go's goroutines, Rust's threads, and Clojure's atoms all share memory too, but each offers some help — a race detector, a compiler-checked Send/Sync bound, or a managed reference type. Plain C pthreads offer none of that: a forgotten pthread_mutex_lock is a silent data race, discoverable only by the corruption or crash it eventually causes, sometimes much later and far from the actual mistake.
`-define` vs. `#define`
Both macro systems are textual substitution, not syntactic
% Erlang macros are defined at the top of a module (-module required, % so this is illustrated as a comment in this expression-level sandbox): % -define(MAX_RETRIES, 3). % Usage: ?MAX_RETRIES expands to 3 wherever it appears. io:format("~p~n", [3]).
#include <stdio.h> #define MAX_RETRIES 3 int main(void) { printf("%d\n", MAX_RETRIES); return 0; }
This is a genuine, direct parallel: Erlang's -define(NAME, Value). and C's #define NAME Value are both preprocessor-level textual substitution — the macro name is replaced with its expansion before the code is even compiled, with no awareness of scope, types, or syntax structure. Neither is the more powerful syntactic/hygienic macro system Clojure, Rust, or Elixir offer (which operate on parsed code, not raw text) — Erlang and C agree here in a way neither agrees with most other languages in this comparison.
Lists vs. Arrays
Linked lists vs. fixed-size arrays
[Head | Tail] = [1, 2, 3], io:format("~p and ~p~n", [Head, Tail]).
#include <stdio.h> int main(void) { int numbers[] = {1, 2, 3}; int head = numbers[0]; printf("%d\n", head); return 0; }
Erlang lists grow and shrink freely and support [Head | Tail] destructuring directly. C arrays have a size fixed at creation (here, by the initializer) with no destructuring syntax at all — numbers[0] is manual index arithmetic, and there is no built-in "the rest of the array" the way Erlang's Tail is built in; you would track a separate length and starting offset by hand.
Recursion
Factorial
Factorial = fun Factorial(0) -> 1; Factorial(N) when N > 0 -> N * Factorial(N - 1) end, io:format("~p~n", [Factorial(5)]).
#include <stdio.h> int factorial(int n) { if (n == 0) { return 1; } return n * factorial(n - 1); } int main(void) { printf("%d\n", factorial(5)); return 0; }
The base case and recursive case survive the translation, expressed as an if rather than separate clauses. Like Rust, Go, and Clojure (without an explicit recur), C's standard makes no tail-call optimization guarantee — deep recursion can genuinely overflow the call stack, where an Erlang developer relies on the BEAM's guaranteed tail-call elimination without a second thought.
Gotchas for Erlang Developers
`=` means something completely different
X = 40, % X = 41 would be a {badmatch,41} error — Erlang variables bind once: io:format("~p~n", [X]).
#include <stdio.h> int main(void) { int x = 40; x = 41; // perfectly ordinary reassignment — no restriction at all printf("%d\n", x); return 0; }
Erlang variables genuinely cannot be rebound within a scope, enforced everywhere with no opt-out. C variables are ordinary, freely mutable storage by default — there is no let/let mut distinction, no immutable-by-default convention, and no restriction of any kind (the closest C gets is the const qualifier, which must be requested explicitly and is more limited than Erlang's guarantee).
Build tools: rebar3/Mix vs. make/CMake
% rebar3 (Erlang's build tool): % rebar3 new app myapp — create new project % rebar3 compile — compile % rebar3 shell — start a shell with the project loaded % rebar3 eunit — run tests % rebar3 release — build a deployable release
// C has no single official build tool — make and CMake are the most // common, but neither ships WITH the language the way rebar3 ships // with Erlang or Cargo ships with Rust: // gcc -o myprogram main.c — compile directly // make — build via a handwritten Makefile // cmake -B build && cmake --build build — a higher-level generator
This is a real ecosystem difference: Erlang (via rebar3) and most other languages in this comparison ship with one dominant, official build tool. C has none — make, CMake, Meson, and hand-written compiler invocations all coexist as community conventions, none blessed by a language standard, a fragmentation Erlang developers rarely encounter within their own ecosystem.