PONYλM2Modula-2

C.CodeCompared.To/Erlang

An interactive executable cheatsheet comparing C and Erlang

C17 (GCC) Erlang/OTP 26
Output & Basics
Hello, World
C requires an explicit #include, a typed int main(void) entry point, and an explicit return 0. An Erlang shell expression just runs — no wrapping function at all.
#include <stdio.h> int main(void) { printf("Hello, World!\n"); return 0; }
io:format("Hello, World!~n").
io:format/1 prints the string, and its ~n directive supplies the newline.
Sequencing multiple outputs
C separates statements with semicolons; Erlang shell expressions are separated by commas, with a single terminating period ending the whole sequence.
#include <stdio.h> int main(void) { printf("First\n"); printf("Second\n"); printf("Third\n"); return 0; }
io:format("First~n"), io:format("Second~n"), io:format("Third~n").
Both simply execute top to bottom in the order written.
Static Types vs. Fully Dynamic
No compile-time type checking at all
Erlang has no compile-time type checking at all — the deepest contrast with C, which checks every parameter and return type before the program can even run.
#include <stdio.h> int add(int x, int y) { return x + y; } int main(void) { printf("%d\n", add(2, 3)); return 0; }
Add = fun(X, Y) -> X + Y end, io:format("~p~n", [Add(2, 3)]).
Add("two", 3) would be accepted without complaint and only fail once that exact line actually executes, raising a badarith error at runtime instead of being rejected up front.
Dialyzer: opt-in static analysis, not a compiler requirement
Erlang's answer to static typing is opt-in: -spec annotations plus the separate Dialyzer analysis tool.
// C's type checking is unconditional and mandatory — there is no // "opting in" to it per function or per file: #include <stdio.h> int describe(int n) { return n; } int main(void) { printf("%d\n", describe(42)); return 0; }
Describe = fun(N) -> N end, io:format("~p~n", [Describe(42)]).
Dialyzer can catch a real class of type mismatches, but only for code that opts in and only when the tool is actually run — a fundamentally different guarantee from C's type checker, which runs unconditionally, for every function, as a mandatory part of compilation.
Manual Memory vs. Per-Process GC
No malloc/free at all — automatic, per-process garbage collection
There is no malloc/free pair to manage in Erlang — no leaks from a forgotten free, no double-frees, no use-after-free dangling pointers.
#include <stdio.h> #include <stdlib.h> int main(void) { int *numbers = malloc(5 * sizeof(int)); for (int i = 0; i < 5; i++) { numbers[i] = i * i; } printf("%d\n", numbers[4]); free(numbers); // forgetting this line leaks memory return 0; }
Numbers = [N * N || N <- lists:seq(0, 4)], io:format("~p~n", [lists:nth(5, Numbers)]). % No free() anywhere — Erlang's per-process garbage collector % reclaims Numbers automatically once nothing references it
Each Erlang process has its OWN independent heap and its own garbage collector — a genuinely different memory model from a single shared C heap, meaning one process's collection pause never stops any other process.
Per-process heaps: a genuinely new memory model
C has exactly one heap per operating-system process; Erlang gives every lightweight process its own independent, independently garbage-collected heap.
// C has one shared heap for the entire process — every malloc'd // block lives in the same address space, reachable by every part // of the program, with no per-task isolation at all: #include <stdio.h> #include <stdlib.h> int main(void) { int *shared = malloc(sizeof(int)); *shared = 42; printf("%d\n", *shared); free(shared); return 0; }
Value = 42, io:format("~p~n", [Value]). % This value lives on THIS process's own private heap — no other % Erlang process can reach it directly at all, and this process's % GC runs independently of every other process's GC
That per-process heap is a structural isolation boundary C's memory model has no equivalent for at all.
Mutable Variables vs. Single Assignment
C variables are freely reassignable; Erlang variables bind once
Watch out: an Erlang variable binds exactly once — there is no reassignment at all, the single biggest habit change coming from C.
#include <stdio.h> int main(void) { int x = 40; x = 41; // perfectly ordinary reassignment in C printf("%d\n", x); return 0; }
X = 40, % X = 41 below would be a {badmatch,41} error — Erlang variables % bind exactly once, period, enforced by the language itself: io:format("~p~n", [X]).
Attempting to rebind is a pattern-match failure ({badmatch, NewValue}), enforced structurally by the language, not just discouraged by convention.
No Pattern Matching vs. Pattern Matching
What C has no equivalent for: pattern matching
Pattern matching is a genuinely new capability for a C programmer, not different syntax for something C already has: Erlang's case matches against a value's SHAPE directly.
#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; }
Classify = fun(N) -> case N of 0 -> zero; N when N > 0 -> positive; _ -> negative end end, io:format("~p~n", [Classify(-5)]).
C's switch only matches literal integers/characters, with no destructuring at all — every "which shape is this value" decision must be an explicit chain of if/else over already-extracted fields. In Erlang, tuples, lists, and specific literals can all be destructured and matched in the same expression, with an optional when guard attached to any clause.
Destructuring a tuple: impossible in one step in C
Erlang's {X, Y} = Pair destructures a tuple into named variables in one matching expression.
#include <stdio.h> int main(void) { // C has no tuple type at all — the closest equivalent is a // struct, and destructuring it still means accessing fields // one at a time, never in a single matching expression: struct Pair { int x; int y; }; struct Pair pair = {3, 4}; printf("%d and %d\n", pair.x, pair.y); return 0; }
Pair = {3, 4}, {X, Y} = Pair, io:format("~p and ~p~n", [X, Y]).
C has no tuple type at all; the closest analogue, a struct, still requires accessing each field individually (pair.x, then pair.y) rather than pulling both out in a single destructuring step.
Structs vs. Tagged Tuples
A struct versus a tagged tuple
Where C declares a named struct layout at compile time, Erlang uses a tagged tuple — an ordinary tuple whose first element is a distinguishing atom, purely by convention.
#include <stdio.h> struct Point { int x; int y; }; int main(void) { struct Point point = {3, 4}; printf("%d and %d\n", point.x, point.y); return 0; }
Point = {point, 3, 4}, {point, X, Y} = Point, io:format("~p and ~p~n", [X, Y]).
No compile-time declaration checks that every "point" tuple actually has this shape; the shape is enforced only by the pattern match at the point of use.
Modeling "one of several shapes" — no union type needed
C's closest tool for "one of several shapes" is a hand-assembled tagged union; Erlang tagged tuples give the same idea directly, with pattern matching doing the discrimination safely.
#include <stdio.h> // C's closest tool for "one of several shapes" is a tagged union — // a struct with an explicit tag field plus a union of the possible // payloads, all wired up and checked by hand: enum ShapeType { CIRCLE, RECTANGLE }; struct Shape { enum ShapeType type; union { double radius; struct { double width, height; } rectangle; } data; }; int main(void) { struct Shape circle = { CIRCLE, { .radius = 5.0 } }; printf("%f\n", circle.data.radius); return 0; }
Circle = {circle, 5.0}, Rectangle = {rectangle, 3.0, 4.0}, Area = fun ({circle, Radius}) -> 3.14159 * Radius * Radius; ({rectangle, Width, Height}) -> Width * Height end, io:format("~p~n", [Area(Circle)]).
The C version needs an explicit tag field, a union of the possible payloads, and manual discipline to always check the tag before reading the right union member — reading the wrong one is undefined behavior, with no compiler help.
Null-Terminated Strings vs. Charlists
A null-terminated buffer versus a list of character codes
Erlang's double-quoted "hello" is secretly a LIST of integer character codes, [104, 101, 108, 108, 111] — not a byte buffer.
#include <stdio.h> #include <string.h> int main(void) { char text[] = "hello"; printf("%zu\n", strlen(text)); return 0; }
Text = "hello", io:format("~p~n", [length(Text)]).
C strings are a contiguous buffer of bytes terminated by a \0 sentinel — strlen must scan byte by byte until it finds that terminator. Erlang's length/1 is the same function used for any list, since a string genuinely is one.
No fixed buffer size — no C-style buffer overflow possible
Erlang has no fixed-size string buffers, so C's classic buffer-overflow bug category simply cannot occur.
#include <stdio.h> #include <string.h> int main(void) { // A fixed-size C buffer can overflow if the source string is too // long — a classic, genuinely dangerous C bug category: char buffer[6]; strcpy(buffer, "hello"); // fits exactly — but a longer source // string here would overflow buffer printf("%s\n", buffer); return 0; }
Text = "hello, this can be as long as you like", io:format("~p~n", [Text]).
C requires a pre-sized destination buffer for any string copy, and writing more bytes than the buffer holds is undefined behavior. Erlang's list-of-codes representation grows to whatever length the actual data requires.
No Equivalent to Pointers
Pointers: a concept Erlang has no equivalent for
Erlang has no pointers, no addresses, and no way to ask where a value lives in memory — a genuine ABSENCE for a C developer to internalize, not different syntax for the same idea.
#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; }
Value = 42, % Erlang has no addresses, no pointers, and no way to ask % "where does this value live in memory": io:format("~p~n", [Value]).
Erlang values are manipulated purely by binding, matching, and passing. C's &value (take the address) and *pointer (follow the address) expose the machine's actual memory layout directly — the foundation of C's performance and of nearly every one of its classic bug categories, none of which Erlang can have at all.
Undefined Behavior vs. Clean Crash
Undefined behavior versus a caught, well-defined runtime error
Integer division by zero is undefined behavior in C; in Erlang it is badarith — a well-defined, catchable, documented runtime error.
#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; }
try 10 div 0 of Result -> io:format("~p~n", [Result]) catch error:badarith -> io:format("crashed: badarith~n") end.
The C standard makes no guarantee about the outcome at all, and platforms genuinely differ (commonly a SIGFPE crash, but never guaranteed). Erlang raises exactly this exception, every time, on every platform, and try ... catch handles it predictably.
A whole-program crash versus an isolated, contained one
This is the deepest philosophical gap between the two languages: in C one bad memory access can end the whole program; in Erlang a process crash is a routine, local, contained event.
#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; }
Pid = spawn(fun() -> 1/0 end), % THIS process crashes — every other process, and the whole rest % of the system, keeps running completely unaffected: io:format("~p~n", [is_pid(Pid)]).
A segfault, stack overflow, or sufficiently severe undefined behavior in C takes down every unrelated task in the same OS process — there is no isolation boundary. When an Erlang process crashes, every other process keeps running, and a supervisor typically restarts the failed one within milliseconds.
Threads vs. Isolated Processes
Shared-memory threads versus share-nothing processes
C threads share one address space and coordinate with locks; Erlang processes share NOTHING and communicate only by message passing (! to send, receive to wait).
// C has no concurrency primitives built into the language at all — // threads are an OS-level library (pthreads) bolted on, and every // thread shares the SAME memory space by default, requiring // explicit locks/mutexes to coordinate safely: #include <stdio.h> int main(void) { printf("C threads share memory by default — Erlang processes do not\n"); return 0; }
Pid = spawn(fun() -> receive {greeting, Message} -> io:format("~s~n", [Message]) end end), Pid ! {greeting, "hello"}.
C has no concurrency primitives in the language itself — POSIX threads are an OS-level library bolted on, inviting classic data-race bugs whenever a lock is forgotten. Erlang's share-nothing design structurally rules out that entire category of shared-memory races.
Lightweight processes: orders of magnitude cheaper than OS threads
Erlang processes are orders of magnitude cheaper than OS threads — spawning ten thousand of them, as this example does, is unremarkable, everyday Erlang.
// A single OS thread in C typically costs megabytes of stack // space and real operating-system scheduling overhead — spawning // even a few thousand is a real resource commitment: #include <stdio.h> int main(void) { printf("OS threads are comparatively heavyweight\n"); return 0; }
Pids = [spawn(fun() -> ok end) || _ <- lists:seq(1, 10000)], io:format("~p~n", [length(Pids)]).
An OS thread in C typically costs megabytes of reserved stack and real kernel scheduling overhead — spawning tens of thousands is often impractical. An Erlang process is managed entirely by the BEAM runtime and starts at roughly three hundred machine words (a couple of kilobytes).
#define vs. -define
Both macro systems are textual substitution, not syntactic
A genuine, direct parallel: C's #define NAME Value and Erlang's -define(NAME, Value). are both preprocessor-level textual substitution.
#include <stdio.h> #define MAX_RETRIES 3 int main(void) { printf("%d\n", MAX_RETRIES); return 0; }
% 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]).
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.
Arrays vs. Lists
A fixed-size array versus a linked list
A C array is a fixed-size contiguous block with O(1) indexing; Erlang's [1, 2, 3, 4, 5] is a singly-linked list with the opposite performance profile.
#include <stdio.h> int main(void) { int numbers[] = {1, 2, 3, 4, 5}; for (int i = 0; i < 5; i++) { printf("%d ", numbers[i]); } printf("\n"); return 0; }
Numbers = [1, 2, 3, 4, 5], io:format("~p~n", [Numbers]).
Indexing the Nth element of an Erlang list is O(n), but prepending an element or matching the head off the front is O(1) — so idiomatic Erlang works from the head, never by index.
map and filter — no manual loop needed
Erlang's lists:map/2 transforms a whole list in one call — C has no built-in higher-order collection functions at all.
#include <stdio.h> int main(void) { int numbers[] = {1, 2, 3, 4, 5}; int doubled[5]; for (int i = 0; i < 5; i++) { doubled[i] = numbers[i] * 2; } for (int i = 0; i < 5; i++) { printf("%d ", doubled[i]); } printf("\n"); return 0; }
Numbers = [1, 2, 3, 4, 5], Doubled = lists:map(fun(N) -> N * 2 end, Numbers), io:format("~p~n", [Doubled]).
The C version means an explicit indexed loop, a separately allocated destination array, and hand-managed bounds; the Erlang version has no manual loop, no separate destination allocation, and no index bookkeeping.
Recursion & Tail Calls
A compiler courtesy versus a formal language guarantee
Tail-call elimination is a formal language guarantee in Erlang; in C it is merely an optimization some compilers sometimes perform.
#include <stdio.h> // GCC MAY optimize this into a loop with -O2, eliminating the // recursive stack growth — but the C STANDARD makes no such // guarantee at all; a sufficiently large N could still overflow // the stack, and whether it does depends entirely on compiler // flags and platform: long sumTo(long n, long accumulator) { if (n == 0) { return accumulator; } return sumTo(n - 1, accumulator + n); } int main(void) { printf("%ld\n", sumTo(5, 0)); return 0; }
SumTo = fun SumTo(0, Accumulator) -> Accumulator; SumTo(N, Accumulator) -> SumTo(N - 1, Accumulator + N) end, io:format("~p~n", [SumTo(5, 0)]).
Whether GCC turns sumTo's recursive call into a loop depends on the compiler, the optimization level, and even unrelated code changes — the C standard never requires it, so a sufficiently deep call could genuinely overflow the stack. Erlang guarantees proper tail calls in every implementation, at every optimization level.
Gotchas for C Developers
Atoms: a data type C has no equivalent for
An Erlang atom like ok is a self-evaluating, interned constant that springs into existence at the point of use — no declaration required, no enclosing type. C has nothing equivalent.
// C has no equivalent to an Erlang atom — the closest analogue is // an enum constant, which must be declared up front as part of a // specific enum type, unlike an atom, which needs no declaration // and belongs to no particular type at all: #include <stdio.h> enum Status { OK, FAILED }; int main(void) { enum Status status = OK; printf("%d\n", status); return 0; }
Status = ok, io:format("~p~n", [Status]).
C's closest analogue, an enum constant, must be declared upfront as part of one specific enum type, and prints as a bare integer by default (as shown — OK prints 0, not a name), unlike an atom, which always prints its own name.
No null pointer to worry about — but also no null shortcut
Erlang has no null concept whatsoever — the atom undefined conventionally means "no value," but it is an ordinary atom with no special language treatment.
#include <stdio.h> #include <stdlib.h> int main(void) { // NULL is a real, ever-present possibility for any C pointer — // dereferencing one that turns out to be NULL is a classic, // extremely common C crash: int *pointer = NULL; if (pointer == NULL) { printf("pointer is NULL\n"); } return 0; }
Value = undefined, % "undefined" is just an ordinary atom with no special language % treatment — Erlang has no actual null concept, and nothing % forces you to check for "undefined" the way NULL forces a check: io:format("~p~n", [Value]).
C's NULL is a genuine, structural hazard: any pointer might be NULL, and dereferencing one that is triggers a crash — one of the most common C bugs of all. In Erlang there is nothing to dereference, so the hazard disappears along with the shortcut.