Hello World & the Build
Hello, World
Three things appear in the C column that OCaml never asks for: a header, a named entry point, and an explicit exit status.
let () = print_endline "Hello, World!"#include <stdio.h>
int main(void) {
printf("Hello, World!\n");
return 0;
}OCaml's
let () = … both runs the expression and asserts it produces nothing. C requires a function literally named main, and the int it returns becomes the process exit status — return 0 means success. The #include is not an import: it is a textual paste of stdio.h into this file before compilation, which is why the preprocessor gets its own section. Note also that the newline is part of the string; printf adds nothing, unlike print_endline.Formatted Output
The format strings are near-identical, and one of them is checked by the compiler while the other is a string the compiler is merely willing to inspect.
let name = "OCaml"
let year = 1996
let () = Printf.printf "%s appeared in %d\n" name year#include <stdio.h>
int main(void) {
const char *name = "C";
int year = 1972;
printf("%s appeared in %d\n", name, year);
return 0;
}OCaml took
printf's directives from C and made them part of the type system: "%s appeared in %d" is a value of the built-in format type, and passing the wrong argument types is a compile error with no way around it. C's format string is an ordinary char *, and printf is variadic with no type information at all — passing an int for %s compiles and reads that integer as a pointer, which is undefined behavior. GCC and clang do check literal format strings (-Wformat, on under -Wall), and that check is a courtesy from the compiler rather than a rule of the language.dune and opam vs make and the linker
Configuration rather than code, so neither column runs. The absence in the C column is the notable part.
(* dune-project *)
(lang dune 3.16)
(* bin/dune *)
(executable
(name main)
(libraries str))
(* dune works out the dependency order from the module
graph, and opam supplies the libraries. *)/* Makefile
* CFLAGS = -std=c17 -Wall -Wextra -O2
* demo: main.o geometry.o
* $(CC) -o $@ $^ -lm
*
* Each .c becomes a .o, and the LINKER joins them. There
* is no package manager: a dependency is a header your
* compiler can find plus a library your linker can find,
* installed by the system or vendored into the tree.
*/C has no package manager and no module system. A dependency is two separate things that must both be arranged: a header giving the compiler declarations (
-I) and a compiled library giving the linker definitions (-L, -l). Nothing checks that the header you compiled against matches the library you linked, which is a whole class of bug OCaml cannot have. The compilation model is also genuinely different: dune reads your modules and works out the order, while make is told each step explicitly and every .c file is compiled in isolation, seeing only what it has #included.Everything Is Declared
There Is No Type Inference
Two things are gone at once: the inference, and the polymorphism it was quietly providing.
(* Types are inferred everywhere, including the
polymorphism. This is 'a list -> int. *)
let count items = List.length items
let add first second = first + second
let () =
Printf.printf "%d\n" (add 3 4);
Printf.printf "%d\n" (count [ "a"; "b"; "c" ])#include <stdio.h>
/* Every type is written out, and the function works for
* exactly one element type. */
int count(const char *items[], int length) {
(void)items;
return length;
}
int add(int first, int second) {
return first + second;
}
int main(void) {
const char *words[] = {"a", "b", "c"};
printf("%d\n", add(3, 4));
printf("%d\n", count(words, 3));
return 0;
}OCaml infers
count : 'a list -> int and generalizes it, so one definition works for lists of anything. C has neither inference nor parametric polymorphism, so the element type is nailed down and a version for a different type is a second function. The (void)items; line is there to silence an unused-parameter warning under -Wextra, and is worth noticing as an idiom you will write often. The generic-programming section covers what C does instead, and it is not pretty.Integer Types and Widths
C has a family of integer types whose sizes are only partly specified, and the difference between signed and unsigned overflow is a difference in kind.
(* One int type, 63 bits, wrapping silently. There is
no unsigned int, and no choice to make. *)
let () =
Printf.printf "max_int = %d\n" max_int;
Printf.printf "wraps to = %d\n" (max_int + 1)#include <stdio.h>
#include <stdint.h>
#include <limits.h>
int main(void) {
/* int is at least 16 bits and usually 32. For a known
* width, use the types from stdint.h. */
printf("int is %zu bytes, max %d\n", sizeof(int), INT_MAX);
printf("int64_t max %lld\n", (long long)INT64_MAX);
/* Unsigned wraps DEFINEDLY; signed overflow does not. */
unsigned int wrapped = UINT_MAX;
printf("unsigned wraps to %u\n", wrapped + 1U);
return 0;
}An OCaml
int is 63 bits on a 64-bit platform — one bit is the garbage collector's tag — and it wraps silently. C gives you char, short, int, long, long long and unsigned versions of each, with only minimum widths guaranteed, which is why stdint.h exists and why int32_t and int64_t are what portable code uses. The rule that matters most: unsigned overflow wraps and is defined; signed overflow is undefined behavior, meaning the compiler may assume it never happens and optimize accordingly. That is not a wrapped value — it is a program with no defined meaning.Immutability Is a Qualifier
The default runs the other way, and
const makes a promise about one level of indirection rather than about a value.(* Immutable by default. A binding cannot be assigned,
and a record field cannot be either unless it was
declared mutable. *)
let () =
let count = 10 in
Printf.printf "%d\n" count#include <stdio.h>
int main(void) {
/* Mutable by default; const is the opt-out, and it
* applies to one level only. */
const int count = 10;
int values[] = {1, 2, 3};
const int *readable = values; /* cannot write *readable */
printf("%d %d\n", count, readable[0]);
values[0] = 99; /* but the array itself is fine */
printf("%d\n", readable[0]);
return 0;
}OCaml is immutable by default and you opt in with
mutable or a ref. C is mutable by default and you opt out with const — which qualifies exactly one level: const int * is a changeable pointer to unchangeable ints, and int * const is the reverse. The example shows the consequence: readable promises not to write through itself, and the underlying array is still writable through another name, so the second print shows 99. const is a statement about that access path, not about the object.typedef and Naming a Type
Both languages let you name a type. Only one of the names is a type the compiler will keep separate.
(* A type declaration introduces a name that is part of
the type system. type point = ... and the alias
type coordinate = int are both first-class. *)
type coordinate = int
type point = { x : coordinate; y : coordinate }
let () =
let origin = { x = 0; y = 0 } in
Printf.printf "(%d, %d)\n" origin.x origin.y#include <stdio.h>
/* typedef makes an ALIAS, not a new type. coordinate and
* int are interchangeable everywhere. */
typedef int coordinate;
typedef struct { coordinate x; coordinate y; } point;
int main(void) {
point origin = {0, 0};
coordinate plain = 5;
plain = origin.x; /* no conversion, same type */
printf("(%d, %d) %d\n", origin.x, origin.y, plain);
return 0;
}OCaml's
type coordinate = int is also an alias and interchanges freely — but OCaml can make a distinct type when you want one, either with a single-constructor variant or by hiding the definition behind a signature. C's typedef is only ever an alias: coordinate and int are the same type, so mixing them is silent. The typedef struct { … } point; form above is also worth learning because it lets you write point rather than struct point everywhere, which is what most C code does.static Controls Visibility
C has exactly one privacy control, and it is the keyword
static in front of a file-scope declaration.(* A .mli interface file lists what a module exports;
anything absent from it is private. Without an .mli
everything is public. *)
module Counter = struct
let internal_step = 1 (* would be hidden by an .mli *)
let bump value = value + internal_step
end
let () = Printf.printf "%d\n" (Counter.bump 41)#include <stdio.h>
/* static at file scope means "not visible to the linker",
* which is C's only privacy mechanism. */
static const int internal_step = 1;
static int bump(int value) {
return value + internal_step;
}
int main(void) {
printf("%d\n", bump(41));
return 0;
}OCaml controls visibility with a signature: an
.mli file lists what a module exports, and anything not listed is inaccessible and can be inlined or removed. C's static at file scope gives a declaration internal linkage, meaning other translation units cannot refer to it — which is the same idea with far less granularity, since there is no way to expose something to one file and not another. Note the overloaded keyword: static inside a function means something completely different, namely a variable that persists between calls, which is a hidden global with all the problems that implies.Memory Is Yours
There Is No Collector
The defining difference, and everything in the next three sections follows from it.
(* Allocation is invisible and deallocation does not
exist. The list below is heap-allocated and freed by
the collector when nothing can reach it. *)
let () =
let numbers = List.init 5 (fun index -> index * index) in
List.iter (Printf.printf "%d ") numbers;
print_newline ()#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *numbers = malloc(5 * sizeof(int));
if (numbers == NULL) return 1; /* malloc can fail */
for (int index = 0; index < 5; index++) {
numbers[index] = index * index;
printf("%d ", numbers[index]);
}
printf("\n");
free(numbers); /* and you must */
return 0;
}OCaml's collector means allocation is a bump of a pointer and deallocation is something you never think about. C gives you
malloc, which can fail and returns NULL when it does, and free, which you must call exactly once for every successful malloc. Calling it zero times leaks; calling it twice corrupts the allocator; using the pointer afterwards reads memory that may already belong to something else. None of those is an error the language reports — the tools that find them are the sanitizers (-fsanitize=address) and Valgrind, and running under them is not optional in serious C.Stack and Heap Are Visible
In C you choose where a value lives, and the wrong choice produces a pointer to memory that has already been reused.
(* Where a value lives is the compiler's business.
Returning a locally built record is ordinary. *)
type point = { x : int; y : int }
let make_point x y = { x; y }
let () =
let point = make_point 3 4 in
Printf.printf "(%d, %d)\n" point.x point.y#include <stdio.h>
struct point { int x; int y; };
/* Returning a struct BY VALUE copies it — safe. Returning
* a pointer to a local would dangle, which is the mistake
* this row exists to name. */
struct point make_point(int x, int y) {
struct point result = {x, y};
return result;
}
int main(void) {
struct point point = make_point(3, 4);
printf("(%d, %d)\n", point.x, point.y);
return 0;
}OCaml puts almost everything on the heap and lets the collector sort out lifetimes, so a locally constructed record can be returned without a thought. In C a local lives on the stack frame, which is reclaimed the instant the function returns — so returning the struct by value (a copy) is fine, and returning
&result gives a pointer to memory that the next function call will overwrite. Compilers warn about the obvious case; they cannot warn about the version where the pointer is stored in a struct first. Returning heap memory means malloc, and then the caller owns it and must free it — which is why C APIs document ownership in comments.Ownership Lives in the Documentation
Every C API answers the question "who frees this?", and it answers it in prose.
(* Returns a NEW string. Nothing needs to be said
about who frees it, because nobody does. *)
let duplicate text = String.sub text 0 (String.length text)
let () =
let owned = duplicate "hello" in
print_endline owned#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Returns a NEW string the CALLER must free. The only
* thing stating that is this comment. */
char *duplicate(const char *text) {
size_t length = strlen(text) + 1;
char *copy = malloc(length);
if (copy == NULL) return NULL;
memcpy(copy, text, length);
return copy;
}
int main(void) {
char *owned = duplicate("hello");
if (owned == NULL) return 1;
printf("%s\n", owned);
free(owned);
return 0;
}In OCaml the question does not exist. In C every function returning a pointer has an ownership convention — the caller frees it, or the callee retains it, or it points into a buffer the caller already owns — and the convention is expressed only in a comment or a manual page. Getting it wrong leaks or double-frees, and the compiler cannot help because the type
char * is identical in all three cases. This is exactly the problem Rust's ownership system was designed to move into the type system, and it is the single largest source of memory bugs in C.Growing a Buffer
Growing a collection is one call in OCaml and a capacity, a length, a doubling and a failure path in C.
(* A Buffer grows itself. There is no capacity to track
and no reallocation to get right. *)
let () =
let buffer = Buffer.create 4 in
for index = 1 to 5 do
Buffer.add_string buffer (string_of_int index)
done;
print_endline (Buffer.contents buffer);
Printf.printf "length = %d\n" (Buffer.length buffer)#include <stdio.h>
#include <stdlib.h>
int main(void) {
size_t capacity = 4;
size_t length = 0;
int *numbers = malloc(capacity * sizeof(int));
if (numbers == NULL) return 1;
for (int index = 1; index <= 5; index++) {
if (length == capacity) {
capacity *= 2;
/* Assign to a TEMPORARY: realloc returning NULL
* would otherwise lose the original pointer. */
int *grown = realloc(numbers, capacity * sizeof(int));
if (grown == NULL) { free(numbers); return 1; }
numbers = grown;
}
numbers[length++] = index;
}
for (size_t index = 0; index < length; index++) printf("%d", numbers[index]);
printf("\nlength = %zu\n", length);
free(numbers);
return 0;
}OCaml's
Buffer — and Dynarray in 5.2 — manages capacity for you. In C you track length and capacity separately and call realloc, which may move the block, so every existing pointer into it becomes invalid. The temporary variable in the example is the standard idiom and matters: writing numbers = realloc(numbers, …) loses the original pointer when realloc returns NULL, leaking the whole buffer at the exact moment memory was already short.A Linked List, Built by Hand
OCaml's list is exactly this data structure, which is worth seeing spelled out once — it explains why
:: is cheap and List.length is not.(* The linked list IS the built-in list type: allocated,
traversed and freed without a line of code. *)
let () =
let numbers = 1 :: 2 :: 3 :: [] in
List.iter (Printf.printf "%d ") numbers;
print_newline ();
Printf.printf "length = %d\n" (List.length numbers)#include <stdio.h>
#include <stdlib.h>
struct node { int value; struct node *next; };
int main(void) {
struct node *head = NULL;
/* Build 1 -> 2 -> 3 by consing onto the front. */
for (int value = 3; value >= 1; value--) {
struct node *node = malloc(sizeof(struct node));
if (node == NULL) return 1;
node->value = value;
node->next = head;
head = node;
}
int length = 0;
for (const struct node *cursor = head; cursor != NULL; cursor = cursor->next) {
printf("%d ", cursor->value);
length++;
}
printf("\nlength = %d\n", length);
/* And free it, one node at a time, holding next first. */
while (head != NULL) {
struct node *next = head->next;
free(head);
head = next;
}
return 0;
}The C column is what
1 :: 2 :: 3 :: [] builds: a chain of two-word blocks, each holding a value and a pointer to the next, with NULL standing in for []. Consing is one allocation and a pointer write, which is why it is O(1); List.length walks the chain, which is why it is O(n). The teardown loop is the part OCaml never makes you write, and note the shape of it: you must save next before freeing the node, because reading a freed node's field is undefined behavior. That single ordering constraint is behind a great many real-world crashes.Pointers
A ref Is Not a Pointer
These look like the same idea and are not: one is a box you allocated, the other is the address of something that already exists.
(* A ref is a heap-allocated record with one mutable
field. There is no address, and no arithmetic. *)
let () =
let counter = ref 0 in
let bump cell = cell := !cell + 1 in
bump counter;
bump counter;
Printf.printf "%d\n" !counter#include <stdio.h>
/* A pointer is an ADDRESS. Passing one lets the callee
* write to the caller's variable. */
void bump(int *cell) {
*cell = *cell + 1;
}
int main(void) {
int counter = 0;
bump(&counter);
bump(&counter);
printf("%d\n", counter);
return 0;
}OCaml's
ref is an ordinary record { mutable contents : 'a }, so ref 0 allocates a box on the heap and passing it around passes the box. C's &counter takes the address of an existing stack variable, so no allocation happens and the callee writes directly into the caller's frame. The syntax is a near-mirror — !cell reads, *cell reads; cell := x writes, *cell = x writes — which makes the difference easy to miss. The one that has no OCaml counterpart is what comes next: a pointer is a number you can do arithmetic on.Pointer Arithmetic
Walking a sequence by moving a pointer is idiomatic C, and there is nothing remotely like it in OCaml.
(* Indexing is the only way to reach an element, and it
is bounds-checked at runtime. *)
let () =
let numbers = [| 10; 20; 30; 40 |] in
for index = 0 to Array.length numbers - 1 do
Printf.printf "%d " numbers.(index)
done;
print_newline ()#include <stdio.h>
int main(void) {
int numbers[] = {10, 20, 30, 40};
int *cursor = numbers; /* the array decays to a pointer */
int *end = numbers + 4;
while (cursor != end) {
printf("%d ", *cursor);
cursor++; /* advances by sizeof(int) */
}
printf("\n");
return 0;
}An array name in C decays to a pointer to its first element in almost every context, and
cursor++ advances by sizeof(int) rather than by one byte — the arithmetic is scaled by the pointed-to type. That is how the standard library is written and how you will read most C. It is also completely unchecked: nothing stops cursor running past end, and doing so is undefined behavior rather than an exception. OCaml has no pointer type in the language at all; indexing is the only access, and numbers.(index) raises Invalid_argument on an out-of-range index.NULL Is Not option
The habit an OCaml programmer must keep by hand, because C will not keep it for them.
(* Absence has a different TYPE from presence, and the
compiler lists the case you did not handle. *)
let find_even numbers =
Array.find_opt (fun number -> number mod 2 = 0) numbers
let () =
(match find_even [| 1; 3; 4 |] with
| Some number -> Printf.printf "found %d\n" number
| None -> print_endline "none found");
(match find_even [| 1; 3; 5 |] with
| Some number -> Printf.printf "found %d\n" number
| None -> print_endline "none found")#include <stdio.h>
/* Absence is a pointer value of the SAME type. Nothing
* forces the check, and dereferencing NULL is undefined
* behavior — usually a crash, not always. */
const int *find_even(const int *numbers, int length) {
for (int index = 0; index < length; index++) {
if (numbers[index] % 2 == 0) return &numbers[index];
}
return NULL;
}
int main(void) {
const int with_even[] = {1, 3, 4};
const int without[] = {1, 3, 5};
const int *found = find_even(with_even, 3);
if (found != NULL) printf("found %d\n", *found);
else printf("none found\n");
found = find_even(without, 3);
if (found != NULL) printf("found %d\n", *found);
else printf("none found\n");
return 0;
}OCaml's
int option is a different type from int: you cannot use the value without unwrapping, and a missing branch is a compile-time warning naming it. C's NULL is a value of the very same pointer type, so *found compiles whether or not the check is there, and dereferencing NULL is undefined behavior — a segmentation fault on a normal machine, and something less predictable when the compiler has optimized around the assumption that it cannot happen. The discipline is to check every pointer that may be null immediately, at the point it is produced.Changing the Pointer Itself
A double pointer looks alarming and means exactly one thing: the callee needs to modify the caller's pointer, not what it points at.
(* A function RETURNS the advanced value and the
caller rebinds. Nothing the caller holds is modified. *)
let advance list = List.tl list
let () =
let numbers = [ 1; 2; 3 ] in
let cursor = advance (advance numbers) in
Printf.printf "%d\n" (List.hd cursor)#include <stdio.h>
/* To change the CALLER'S pointer, take the address of the
* pointer — hence the double star. */
static void advance(const int **cursor) {
*cursor = *cursor + 1;
}
int main(void) {
int numbers[] = {1, 2, 3};
const int *cursor = numbers;
advance(&cursor);
advance(&cursor);
printf("%d\n", *cursor);
return 0;
}The rule generalizes: to let a function change something the caller holds, pass the address of that thing. If the caller holds an
int, pass int *; if it holds an int *, pass int **. That is all a double pointer is, and it appears constantly in list manipulation and in functions that allocate on the caller's behalf. OCaml never needs it because nothing is modified in place — a function returns the new value and the caller rebinds, which is the same information flowing the other way.Strings Are Arrays of Bytes
A String Is a Pointer and a Convention
C has no string type. It has a pointer to bytes and an agreement that a zero byte marks the end.
(* A string carries its own length. Getting it is O(1)
and cannot be wrong. *)
let () =
let text = "hello" in
Printf.printf "length = %d\n" (String.length text);
Printf.printf "first = %c\n" text.[0]#include <stdio.h>
#include <string.h>
int main(void) {
/* No length is stored. strlen WALKS to the first zero
* byte, so it is O(n) and depends on that byte being
* there at all. */
const char *text = "hello";
printf("length = %zu\n", strlen(text));
printf("first = %c\n", text[0]);
printf("bytes including the terminator = %zu\n", sizeof("hello"));
return 0;
}An OCaml string knows its own length, which is stored in the heap block's header, so
String.length is a field read and a string may contain a zero byte harmlessly. A C string is char * plus the NUL-termination convention: strlen scans for the zero byte, so it is O(n), a string cannot contain one, and if the byte is missing — because a buffer was filled without room for it — strlen walks off the end into whatever follows. sizeof("hello") is 6 and strlen("hello") is 5, and that off-by-one is the source of an enormous number of buffer overflows.Building a String
Every string operation in C needs a destination buffer, and the size of that buffer is your problem.
(* Concatenation allocates a new string, and the sizes
take care of themselves. *)
let () =
let greeting = "Hello" in
let subject = "world" in
print_endline (greeting ^ ", " ^ subject ^ "!")#include <stdio.h>
int main(void) {
const char *greeting = "Hello";
const char *subject = "world";
char sentence[64];
/* snprintf takes the buffer SIZE and always
* terminates. sprintf does neither and is how buffer
* overflows get written. */
snprintf(sentence, sizeof(sentence), "%s, %s!", greeting, subject);
printf("%s\n", sentence);
return 0;
}OCaml's
^ allocates a fresh string of exactly the right size. C has no such thing: you supply a buffer and are responsible for it being large enough. Use snprintf, never sprintf — the n version takes the buffer size, refuses to write past it, and always NUL-terminates. The same distinction runs through the library: strncpy over strcpy, strncat over strcat, though strncpy has its own trap in that it does not always terminate. When the required size is not known in advance, snprintf with a null buffer returns the length it would have written, which is how you size a malloc.Comparing Strings
This is the single most common C mistake made by someone arriving from a language with real strings.
(* = compares CONTENTS. It works on strings and on
anything else, structurally. *)
let () =
let first = "hello" in
let second = String.concat "" [ "hel"; "lo" ] in
Printf.printf "%b\n" (first = second);
Printf.printf "%b\n" (first == second)#include <stdio.h>
#include <string.h>
int main(void) {
const char *first = "hello";
char second[6];
snprintf(second, sizeof(second), "%s%s", "hel", "lo");
/* == compares ADDRESSES. strcmp compares contents and
* returns 0 when they are equal. */
printf("%s\n", strcmp(first, second) == 0 ? "true" : "false");
printf("%s\n", first == second ? "true" : "false");
return 0;
}first == second in C compares the two pointers, so it is asking whether they are the same object, not whether they hold the same text — and it prints 0 here even though both spell "hello". Content comparison is strcmp, which returns 0 for equal and a negative or positive number for ordering, so the test reads strcmp(a, b) == 0 and the inverted-looking sense catches people too. OCaml's = compares contents structurally and its == is physical identity, which is the same pair of operations with the safe one as the default.Arrays & Bounds
Nothing Checks the Index
The most consequential missing feature, and the one that cannot be worked around by being careful in one place.
(* Every access is bounds-checked. An out-of-range index
raises Invalid_argument, which is catchable. *)
let () =
let numbers = [| 10; 20; 30 |] in
Printf.printf "%d\n" numbers.(1);
(try Printf.printf "%d\n" numbers.(10) with
| Invalid_argument message -> Printf.printf "caught: %s\n" message)#include <stdio.h>
int main(void) {
int numbers[] = {10, 20, 30};
printf("%d\n", numbers[1]);
/* numbers[10] would be UNDEFINED BEHAVIOR: it reads
* whatever is 40 bytes past the array. There is no
* exception to catch, so this row does not do it. */
int length = (int)(sizeof(numbers) / sizeof(numbers[0]));
printf("length = %d\n", length);
printf("caught: nothing — C does not check\n");
return 0;
}OCaml checks every array access and raises
Invalid_argument, which you can catch — the cost is a comparison per access, and Array.unsafe_get exists for the rare case where it matters. C checks nothing. numbers[10] reads whatever lives forty bytes past the array, which might be another variable, might be part of the stack frame, and might segfault; the behavior is undefined, which means the compiler is entitled to assume it never happens. Note also how the length is obtained: sizeof(numbers) / sizeof(numbers[0]) works only where the array's declaration is visible, because passing it to a function decays it to a pointer and sizeof then measures the pointer.Arrays Decay to Pointers
A C function cannot receive an array — only a pointer to its first element — which is why every C API that takes a buffer also takes a count.
(* An array is a value that knows its own length, and
passing it loses nothing. *)
let sum numbers = Array.fold_left ( + ) 0 numbers
let () =
let numbers = [| 1; 2; 3; 4 |] in
Printf.printf "%d\n" (sum numbers)#include <stdio.h>
/* The parameter LOOKS like an array and is a pointer, so
* the length has to travel separately. */
int sum(const int numbers[], int length) {
int total = 0;
for (int index = 0; index < length; index++) total += numbers[index];
return total;
}
int main(void) {
int numbers[] = {1, 2, 3, 4};
int length = (int)(sizeof(numbers) / sizeof(numbers[0]));
printf("%d\n", sum(numbers, length));
return 0;
}The parameter declaration
const int numbers[] is a lie the language permits: it means exactly const int *numbers, and sizeof(numbers) inside the function measures a pointer. So the length must be passed alongside, and the two can disagree — a caller passing the wrong count produces an out-of-bounds read with no diagnostic anywhere. OCaml arrays carry their length in the heap block header, so Array.length works wherever the array does and the pair can never get out of step. This one difference is responsible for a large share of C's security history.Two Dimensions
The two languages mean different things by a two-dimensional array, and the difference is about memory layout.
(* An array of arrays: each row is a separate value and
rows may have different lengths. *)
let () =
let grid = [| [| 1; 2; 3 |]; [| 4; 5; 6 |] |] in
Array.iter
(fun row -> Array.iter (Printf.printf "%d ") row; print_newline ())
grid#include <stdio.h>
int main(void) {
/* A true 2D array is ONE contiguous block, and every
* row has the same length by construction. */
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");
}
printf("contiguous: %zu bytes\n", sizeof(grid));
return 0;
}OCaml's
int array array is an array of pointers to separate row arrays, so rows can have different lengths (a ragged array) and traversing costs an extra indirection per row. C's int grid[2][3] is one contiguous block of six integers — sizeof(grid) is 24 bytes — laid out row by row, which is why it is faster to walk and why it cannot be ragged. C's equivalent of OCaml's layout is int *grid[2], an array of pointers, and the difference between those two declarations is a classic source of confusion.Structs vs Records
Structs
Structs and records line up well, and two things OCaml gives for free are absent: functional update and structural equality.
type point = { x : int; y : int }
let () =
let origin = { x = 0; y = 0 } in
let shifted = { origin with x = 5 } in
Printf.printf "(%d, %d)\n" origin.x origin.y;
Printf.printf "(%d, %d)\n" shifted.x shifted.y#include <stdio.h>
struct point { int x; int y; };
int main(void) {
struct point origin = {0, 0};
/* No functional update. Copy, then assign. */
struct point shifted = origin;
shifted.x = 5;
printf("(%d, %d)\n", origin.x, origin.y);
printf("(%d, %d)\n", shifted.x, shifted.y);
return 0;
}A C struct is a value laid out inline, and assigning one copies it field by field — which is why
shifted = origin then modifying shifted leaves origin alone. There is no { record with … }, so the copy-then-assign above is the idiom. There is also no == for structs: comparing two requires memcmp, which is wrong in general because a struct may contain padding bytes with unspecified contents. OCaml's polymorphic = compares any two values structurally, which is one of the conveniences you are giving up.Hiding a Type
C does have abstract types, and the mechanism is a struct declared in the header without its fields.
(* A signature hides the definition completely. Callers
see the name and the operations, and nothing else. *)
module Counter : sig
type t
val create : unit -> t
val bump : t -> t
val value : t -> int
end = struct
type t = int
let create () = 0
let bump counter = counter + 1
let value counter = counter
end
let () =
let counter = Counter.bump (Counter.bump (Counter.create ())) in
Printf.printf "%d\n" (Counter.value counter)#include <stdio.h>
#include <stdlib.h>
/* In a header you would declare only: struct counter;
* Callers can then hold a struct counter * and never see
* the fields. That is C's abstract type. */
struct counter { int total; };
struct counter *counter_create(void) {
struct counter *counter = malloc(sizeof(struct counter));
if (counter != NULL) counter->total = 0;
return counter;
}
void counter_bump(struct counter *counter) { counter->total += 1; }
int counter_value(const struct counter *counter) { return counter->total; }
int main(void) {
struct counter *counter = counter_create();
if (counter == NULL) return 1;
counter_bump(counter);
counter_bump(counter);
printf("%d\n", counter_value(counter));
free(counter);
return 0;
}An opaque struct —
struct counter; declared but not defined in the header — is C's equivalent of an OCaml signature hiding type t. Callers can hold a struct counter * and pass it around, and cannot see or touch the fields, because the compiler does not know them. The costs are that the type can only ever be used through a pointer (its size is unknown), that it therefore must be heap-allocated, and that the caller must call the matching destroy function. This pattern is everywhere in real C libraries — FILE * is the one you already use.Structs Have Padding
A struct is not the sum of its fields, and field order changes its size — something OCaml never exposes.
(* The layout of a record is the runtime's business.
Every field is one word, and you do not choose. *)
type mixed = { flag : bool; count : int }
let () =
let value = { flag = true; count = 42 } in
Printf.printf "%b %d\n" value.flag value.count#include <stdio.h>
struct mixed { char flag; int count; };
struct packed_better { int count; char flag; };
int main(void) {
struct mixed value = {1, 42};
printf("%d %d\n", value.flag, value.count);
/* Not 5 bytes: the int must be aligned, so the
* compiler inserts padding after the char. */
printf("sizeof mixed = %zu\n", sizeof(struct mixed));
printf("sizeof reordered = %zu\n", sizeof(struct packed_better));
return 0;
}C guarantees fields appear in declaration order and that each is aligned to its own requirement, so the compiler inserts padding bytes to make that true. A
char followed by an int costs 8 bytes rather than 5. Both structs above are the same size here because the trailing padding rounds each to a multiple of the alignment, but in larger structs reordering fields from widest to narrowest genuinely shrinks them, which matters when you have millions. OCaml gives you no control and no visibility: every field of a record is one word, and floats-only records get a special unboxed representation the runtime chooses.Passing by Value or by Pointer
C makes you choose between copying a struct and passing its address, and the choice has both a cost and a meaning.
(* Passing a record passes a pointer to it, always, and
since it is immutable that is indistinguishable from
passing a copy. *)
type large = { a : int; b : int; c : int; d : int }
let total value = value.a + value.b + value.c + value.d
let () = Printf.printf "%d\n" (total { a = 1; b = 2; c = 3; d = 4 })#include <stdio.h>
struct large { int a; int b; int c; int d; };
/* By value: the whole struct is COPIED at the call. */
static int total_by_value(struct large value) {
return value.a + value.b + value.c + value.d;
}
/* By const pointer: one word is passed, and the callee
* promises not to write. This is the usual choice. */
static int total_by_pointer(const struct large *value) {
return value->a + value->b + value->c + value->d;
}
int main(void) {
struct large value = {1, 2, 3, 4};
printf("%d %d\n", total_by_value(value), total_by_pointer(&value));
return 0;
}In OCaml a record is a heap block and passing it passes a pointer — always, invisibly, and safely, because the record cannot be modified. In C, passing by value copies every byte at each call, which is fine for a couple of words and wasteful for anything larger; passing
const struct large * costs one word and states that the callee will not write. The -> operator is just (*value).a spelled conveniently. The convention worth adopting is const pointer for input, non-const pointer for output, which is how nearly every C API is shaped.There Are No Sum Types
A Variant, Assembled by Hand
This is the largest single thing OCaml gives you that C does not, and the C column shows exactly what it costs to build by hand.
type shape =
| Circle of float
| Rectangle of float * float
| Point
let area = function
| Circle radius -> 3.14159 *. radius *. radius
| Rectangle (width, height) -> width *. height
| Point -> 0.0
let () =
List.iter
(fun shape -> Printf.printf "%.2f\n" (area shape))
[ Circle 1.0; Rectangle (2.0, 3.0); Point ]#include <stdio.h>
/* A tag, a union, and a struct holding both. Keeping the
* tag and the active member in step is entirely on you. */
enum shape_kind { SHAPE_CIRCLE, SHAPE_RECTANGLE, SHAPE_POINT };
struct shape {
enum shape_kind kind;
union {
struct { double radius; } circle;
struct { double width; double height; } rectangle;
} data;
};
double area(const struct shape *shape) {
switch (shape->kind) {
case SHAPE_CIRCLE: return 3.14159 * shape->data.circle.radius
* shape->data.circle.radius;
case SHAPE_RECTANGLE: return shape->data.rectangle.width
* shape->data.rectangle.height;
case SHAPE_POINT: return 0.0;
}
return 0.0;
}
int main(void) {
struct shape shapes[3];
shapes[0].kind = SHAPE_CIRCLE; shapes[0].data.circle.radius = 1.0;
shapes[1].kind = SHAPE_RECTANGLE; shapes[1].data.rectangle.width = 2.0;
shapes[1].data.rectangle.height = 3.0;
shapes[2].kind = SHAPE_POINT;
for (int index = 0; index < 3; index++) printf("%.2f\n", area(&shapes[index]));
return 0;
}An OCaml variant guarantees three things at once: the tag and the payload cannot disagree, the payload of the wrong constructor is unreachable, and every case is handled. The C construction gives you none of them. Reading
data.rectangle when kind is SHAPE_CIRCLE compiles and reinterprets the bytes; setting the tag and forgetting the payload compiles; and while GCC and clang do warn about a switch missing an enum case under -Wswitch, that warning disappears the moment a default label is added — which the trailing return here effectively does for safety. Every discriminated union in C is this, and it is why so much C uses a struct with unused fields instead.An enum Is Just an int
An
enum looks like a set of named constants and behaves like an integer that happens to have some names.(* Constant constructors form a real type. A value of
type color is one of exactly three things. *)
type color = Red | Green | Blue
let to_string = function
| Red -> "red" | Green -> "green" | Blue -> "blue"
let () = List.iter (fun color -> Printf.printf "%s " (to_string color)) [ Red; Green; Blue ];
print_newline ()#include <stdio.h>
enum color { RED, GREEN, BLUE };
const char *to_string(enum color color) {
switch (color) {
case RED: return "red";
case GREEN: return "green";
case BLUE: return "blue";
}
return "unknown";
}
int main(void) {
for (int index = 0; index < 3; index++) printf("%s ", to_string((enum color)index));
printf("\n");
/* An enum is an integer type. This is legal C: */
printf("%s\n", to_string((enum color)42));
return 0;
}OCaml's
color is a real type with exactly three values, and there is no way to produce a fourth. A C enum is an integer type whose members are named constants, and any integer may be cast into it — the last line above passes 42 and the switch falls through to the fallback. That is why the return "unknown" is not defensive clutter but a necessity, and why C code that receives an enum from outside its own module has to validate it. It is also why the enum members here are prefixed: they live in the ordinary identifier namespace, not inside the type.Representing "No Value"
With no sum type, C encodes absence inside the value — a sentinel — and every function picks its own.
(* option is a type. The absent case cannot be confused
with any valid value, whatever the value type is. *)
let find_index target numbers =
let rec search index =
if index >= Array.length numbers then None
else if numbers.(index) = target then Some index
else search (index + 1)
in
search 0
let () =
(match find_index 3 [| 1; 2; 3 |] with
| Some index -> Printf.printf "at %d\n" index
| None -> print_endline "absent");
(match find_index 9 [| 1; 2; 3 |] with
| Some index -> Printf.printf "at %d\n" index
| None -> print_endline "absent")#include <stdio.h>
/* Absence has to be encoded in the value's own range —
* here, a negative index. That works only because no
* valid index is negative. */
static int find_index(int target, const int *numbers, int length) {
for (int index = 0; index < length; index++)
if (numbers[index] == target) return index;
return -1;
}
int main(void) {
const int numbers[] = {1, 2, 3};
int found = find_index(3, numbers, 3);
if (found >= 0) printf("at %d\n", found); else printf("absent\n");
found = find_index(9, numbers, 3);
if (found >= 0) printf("at %d\n", found); else printf("absent\n");
return 0;
}OCaml's
option is a type: None is not an int and cannot be mistaken for one, whatever the value type is. C uses sentinels — -1 for an index, NULL for a pointer, 0 or EOF for a character — and each function chooses its own, documents it in prose, and relies on the caller checking. The approach breaks entirely when every value in the range is valid: a function returning any int has no spare value to mean "none", which is why so many C functions return a status code and write the real result through a pointer.Control Flow
Statements, Not Expressions
C's
if produces no value, so the variable has to be declared first and assigned in each branch.let () =
let temperature = 31 in
let advice =
if temperature > 30 then "stay inside"
else if temperature > 20 then "pleasant"
else "bring a coat"
in
print_endline advice#include <stdio.h>
int main(void) {
int temperature = 31;
const char *advice;
if (temperature > 30) advice = "stay inside";
else if (temperature > 20) advice = "pleasant";
else advice = "bring a coat";
printf("%s\n", advice);
return 0;
}In OCaml everything is an expression, so
if produces a value and the branches are required to agree on its type. C's if is a statement, so advice is declared uninitialized and then written in each branch — and if a branch is ever missed, it stays uninitialized and reading it is undefined behavior. The ternary operator condition ? a : b is C's expression form and does chain, though it reads poorly beyond two levels. Note the missing else hazard has no OCaml counterpart: there, an if without else must have type unit.switch Falls Through
Stacked
case labels are C's or-pattern, and they work because a case runs on into the next unless something stops it.let classify number =
match number with
| 0 -> "zero"
| 1 | 2 | 3 -> "small"
| _ -> "ordinary"
let () =
List.iter
(fun number -> Printf.printf "%d is %s\n" number (classify number))
[ 0; 2; 42 ]#include <stdio.h>
const char *classify(int number) {
switch (number) {
case 0:
return "zero";
case 1: /* deliberate fallthrough */
case 2:
case 3:
return "small";
default:
return "ordinary";
}
}
int main(void) {
int values[] = {0, 2, 42};
for (int index = 0; index < 3; index++)
printf("%d is %s\n", values[index], classify(values[index]));
return 0;
}OCaml's
| or-pattern says explicitly that several patterns share a branch. C achieves the same thing through fallthrough, which is the default: control runs from one case into the next until it meets a break or a return. That makes the or-pattern free and makes forgetting a break a real and common bug — modern compilers warn about implicit fallthrough between non-empty cases. C's switch also compares only integers and enum constants: no strings, no ranges, no destructuring, and no guards.Loops, break and continue
C has
break and continue; OCaml has neither, which is why its early exits look the way they do.(* OCaml has no break or continue. Stopping early means
recursion, or an exception used as a jump. *)
exception Found of int
let first_even numbers =
try
Array.iter (fun number -> if number mod 2 = 0 then raise (Found number)) numbers;
None
with Found number -> Some number
let () =
match first_even [| 1; 3; 4; 6 |] with
| Some number -> Printf.printf "%d\n" number
| None -> print_endline "none"#include <stdio.h>
int main(void) {
int numbers[] = {1, 3, 4, 6};
int found = -1;
for (int index = 0; index < 4; index++) {
if (numbers[index] % 2 != 0) continue;
found = numbers[index];
break;
}
if (found >= 0) printf("%d\n", found);
else printf("none\n");
return 0;
}OCaml's loops run to completion, so stopping early means either writing a recursive function whose base case is the exit condition, or raising an exception and catching it just outside the loop — the second is idiomatic and cheap, and is what the anchor column shows. C's
break and continue do the job directly, and goto is additionally respectable in one specific place: jumping to a cleanup label at the end of a function that has several failure points, which is the standard C answer to the absence of destructors and exceptions.do-while
C has a loop that tests its condition at the bottom, so the body always runs at least once. OCaml has no such form.
(* No do-while. A loop that must run once is written by
running the body, then looping. *)
let () =
let countdown = ref 3 in
let continue_ = ref true in
while !continue_ do
Printf.printf "%d " !countdown;
countdown := !countdown - 1;
continue_ := !countdown > 0
done;
print_newline ()#include <stdio.h>
int main(void) {
int countdown = 3;
do {
printf("%d ", countdown);
countdown -= 1;
} while (countdown > 0);
printf("\n");
return 0;
}The difference is small and the C form is genuinely more direct for anything read-then-check shaped, such as consuming input until it runs out. OCaml has
while and for and nothing else, so the same shape needs an extra flag or a recursive function. do { … } while (0) is also a C idiom with nothing to do with looping: it wraps a multi-statement macro so it behaves as a single statement inside an if without braces, which is one of the preprocessor's sharper edges.Functions & the Missing Closure
Function Pointers Capture Nothing
C has first-class function pointers and no closures at all, and the workaround shapes every callback API you will ever use.
(* A closure captures its environment automatically, so
partial application is ordinary. *)
let make_adder amount = fun value -> value + amount
let () =
let add_ten = make_adder 10 in
Printf.printf "%d\n" (add_ten 5);
Printf.printf "%d\n" (add_ten 32)#include <stdio.h>
/* A function pointer is an address and nothing else. The
* captured value has to travel as an extra parameter —
* the "context pointer" convention every C callback uses. */
typedef int (*transform)(int value, void *context);
static int add_amount(int value, void *context) {
return value + *(const int *)context;
}
static void apply(transform function, int value, void *context) {
printf("%d\n", function(value, context));
}
int main(void) {
int amount = 10;
apply(add_amount, 5, &amount);
apply(add_amount, 32, &amount);
return 0;
}An OCaml closure is a pair of a code pointer and the environment it captured, allocated automatically, so
make_adder 10 returns something that remembers 10. A C function pointer is a bare address with nowhere to store anything, so the captured data must be passed alongside — conventionally as a void *context that the callback casts back to the right type. That is why qsort's comparator takes no context and is famously awkward, and why almost every other C callback API takes one. The cast is unchecked: passing the wrong context type compiles and misreads the bytes.Returning More Than One Value
C functions return exactly one value, so the second result travels back through a pointer the caller supplies.
let divide_and_remainder numerator denominator =
(numerator / denominator, numerator mod denominator)
let () =
let (quotient, remainder) = divide_and_remainder 17 5 in
Printf.printf "%d remainder %d\n" quotient remainder#include <stdio.h>
/* Return one value; write the others through pointers.
* This "out parameter" convention is everywhere in C. */
int divide_and_remainder(int numerator, int denominator, int *remainder) {
*remainder = numerator % denominator;
return numerator / denominator;
}
int main(void) {
int remainder = 0;
int quotient = divide_and_remainder(17, 5, &remainder);
printf("%d remainder %d\n", quotient, remainder);
return 0;
}OCaml returns a tuple and destructures it, which costs one allocation and no ceremony. C's out parameter convention has the caller allocate the storage — here an ordinary local — and pass its address for the callee to write into. It is efficient and it is unchecked: nothing requires the callee to write to it, so a caller reading an out parameter after an error path may read an uninitialized value. C can also return a struct by value, which is closer to the OCaml version and is often the better choice; the out parameter dominates because it composes with the return-code error convention in the next section.
The Preprocessor
#include Is a Paste, Not an Import
The preprocessor is a separate language that runs first, knows nothing about C, and is the only metaprogramming C has.
(* Modules are a language construct. Opening one brings
its names into scope, and nothing is textually
copied. *)
module Geometry = struct
let pi = 3.14159
let circle_area radius = pi *. radius *. radius
end
open Geometry
let () = Printf.printf "%.2f\n" (circle_area 2.0)#include <stdio.h>
/* #define is textual substitution done before the
* compiler ever sees the file. It has no type and obeys
* no scope. */
#define PI 3.14159
#define CIRCLE_AREA(radius) (PI * (radius) * (radius))
int main(void) {
printf("%.2f\n", CIRCLE_AREA(2.0));
return 0;
}#include literally copies a file's text into yours before compilation, which is why headers need include guards, why compilation is slow, and why a macro defined in one header can break a completely unrelated file. #define is textual substitution with no types and no scope. Note the parentheses around (radius): without them, CIRCLE_AREA(1 + 1) expands to PI * 1 + 1 * 1 + 1, which is the classic macro bug. OCaml has nothing equivalent — its modules are a typed language construct, and its metaprogramming (ppx) operates on the parse tree with the types available.Conditional Compilation
C can delete code before compilation, which is how one source tree targets several platforms.
(* Compile-time configuration is a build-system concern.
The language has no #ifdef, and a runtime flag is
ordinary code. *)
let debug = false
let () =
if debug then print_endline "debugging";
print_endline "working"#include <stdio.h>
#define DEBUG 0
int main(void) {
#if DEBUG
printf("debugging\n");
#endif
printf("working\n");
return 0;
}#if and #ifdef remove code entirely, so it is never parsed and never type-checked — which is how a single C file supports Windows and Linux, and also why a typo inside an inactive branch can sit undetected for years. OCaml has no conditional compilation in the language: platform differences are handled by choosing different modules in the dune file, which means every alternative is still compiled and checked. The OCaml column's if debug then is a runtime check the optimizer will fold away, which covers the common case and cannot remove code that would not compile.Header Guards
The include guard is pure ceremony that exists only because
#include is textual, and every header in the world carries one.(* Modules are compiled once and referenced by name.
Including a module twice is not a concept that
exists. *)
module Geometry = struct
let pi = 3.14159
end
let () = Printf.printf "%.4f\n" Geometry.pi#include <stdio.h>
/* Every header needs this, because #include is a textual
* paste and a header reached twice would define its
* types twice:
*
* #ifndef GEOMETRY_H
* #define GEOMETRY_H
* struct point { int x; int y; };
* #endif
*
* #pragma once does the same thing in one line and is
* universally supported, if not standard.
*/
#define PI 3.14159
int main(void) {
printf("%.4f\n", PI);
return 0;
}Because
#include pastes a file's text, a header reached by two different paths — directly and through another header — would be pasted twice, and a duplicate struct definition is an error. The guard makes the second paste expand to nothing. OCaml has no equivalent problem: a module is compiled once into a .cmi/.cmo pair and referenced by name, so mentioning it from two places costs nothing and means nothing. This is one of the clearest illustrations of the difference between a real module system and a text-inclusion mechanism.Generic Programming
Generics Are void * and sizeof
C's answer to "works for any type" is to stop looking at types and move raw bytes, with the size passed in as data.
(* Parametric polymorphism: one function, any element
type, checked and inferred. *)
let swap array first second =
let temporary = array.(first) in
array.(first) <- array.(second);
array.(second) <- temporary
let () =
let numbers = [| 1; 2; 3 |] in
swap numbers 0 2;
Array.iter (Printf.printf "%d ") numbers;
print_newline ();
let words = [| "a"; "b"; "c" |] in
swap words 0 2;
Array.iter (Printf.printf "%s ") words;
print_newline ()#include <stdio.h>
#include <string.h>
/* One function for any type, by moving BYTES. The type
* system knows nothing about what is being swapped. */
static void swap(void *array, size_t first, size_t second, size_t item_size) {
unsigned char *bytes = array;
for (size_t offset = 0; offset < item_size; offset++) {
unsigned char temporary = bytes[first * item_size + offset];
bytes[first * item_size + offset] = bytes[second * item_size + offset];
bytes[second * item_size + offset] = temporary;
}
}
int main(void) {
int numbers[] = {1, 2, 3};
swap(numbers, 0, 2, sizeof(numbers[0]));
for (int index = 0; index < 3; index++) printf("%d ", numbers[index]);
printf("\n");
const char *words[] = {"a", "b", "c"};
swap(words, 0, 2, sizeof(words[0]));
for (int index = 0; index < 3; index++) printf("%s ", words[index]);
printf("\n");
return 0;
}OCaml's
swap is 'a array -> int -> int -> unit: one definition, every element type, fully checked, and the compiler knows the representation. The C version takes a void * and a sizeof, and works by copying bytes — which is genuinely generic and genuinely unchecked. Pass the wrong item_size and it silently corrupts memory; pass an array of the wrong type and nothing objects. This is how qsort and bsearch work, and it is why they take a comparator function pointer as well. The alternative C idiom is macros that generate a version per type, which is faster and type-checked and produces incomprehensible error messages.Sorting With a Comparator
The standard library's one generic algorithm, and it shows every cost of
void * genericity in a single call.(* List.sort takes a comparison and knows the element
type. Everything is checked. *)
let () =
let numbers = [ 3; 1; 2 ] in
List.iter (Printf.printf "%d ") (List.sort compare numbers);
print_newline ()#include <stdio.h>
#include <stdlib.h>
/* qsort's comparator receives const void *, so it must
* cast — and nothing checks the cast is right. */
static int compare_ints(const void *left, const void *right) {
int first = *(const int *)left;
int second = *(const int *)right;
return (first > second) - (first < second);
}
int main(void) {
int numbers[] = {3, 1, 2};
qsort(numbers, 3, sizeof(numbers[0]), compare_ints);
for (int index = 0; index < 3; index++) printf("%d ", numbers[index]);
printf("\n");
return 0;
}qsort takes the array, the count, the element size and a comparator whose parameters are const void * — so the comparator casts, and a mismatch between the cast and the actual element type compiles silently and produces garbage. Note the return expression: writing first - second is the obvious form and is wrong, because the subtraction can overflow for large values, which is undefined behavior. (a > b) - (a < b) is the standard safe idiom. OCaml's List.sort takes a typed comparison over a known element type, and the polymorphic compare works on anything.Error Handling
There Are No Exceptions
Every error in C is a value somebody has to look at, and nothing forces them to.
exception Too_large of int
let check value =
if value > 100 then raise (Too_large value) else value
let () =
Printf.printf "%d\n" (check 50);
(try Printf.printf "%d\n" (check 500) with
| Too_large value -> Printf.printf "too large: %d\n" value)#include <stdio.h>
#define ERROR_TOO_LARGE (-1)
/* Failure is a RETURN VALUE, and the caller must check
* it. Nothing propagates on its own. */
static int check(int value, int *result) {
if (value > 100) return ERROR_TOO_LARGE;
*result = value;
return 0;
}
int main(void) {
int result = 0;
if (check(50, &result) == 0) printf("%d\n", result);
if (check(500, &result) != 0) printf("too large: %d\n", 500);
return 0;
}OCaml exceptions propagate automatically until something catches them, so a function that cannot handle an error simply does not mention it. C has no such mechanism: an error is a return code, and an unchecked return code is a silently ignored failure. That is why the out-parameter convention from earlier is so pervasive — the return slot is taken by the status, so the actual result needs somewhere else to go. There is
setjmp/longjmp, which is a non-local jump and not an exception: it runs no cleanup, and using it to unwind past code holding a lock or a malloc leaks.errno and the Cleanup Label
C has no destructors, no
finally and no exceptions — so releasing what you acquired takes a deliberate structure.(* Fun.protect runs the finalizer whether or not the
body raises, so cleanup happens on every path. *)
let () =
let resource = ref true in
Fun.protect
~finally:(fun () -> resource := false; print_endline "released")
(fun () -> print_endline "using it");
Printf.printf "still held? %b\n" !resource#include <stdio.h>
#include <stdlib.h>
/* No destructors and no finally, so a function with
* several failure points jumps to one cleanup label.
* This is the one respectable use of goto. */
static int work(void) {
char *first = malloc(16);
char *second = NULL;
int status = 1;
if (first == NULL) goto done;
second = malloc(16);
if (second == NULL) goto done;
printf("using it\n");
status = 0;
done:
free(second);
free(first);
printf("released\n");
return status;
}
int main(void) {
return work();
}OCaml's
Fun.protect runs its ~finally on every exit path including an exception, which is the whole problem solved. In C the standard answer is a single cleanup label at the end of the function and a goto from each failure point, so every resource is released exactly once regardless of where the function gave up. Note that free(NULL) is defined and does nothing, which is what makes the unconditional frees above correct. The other half of C error reporting is errno, a global set by library calls, which must be read immediately because the next call will overwrite it.Asserting an Invariant
Both have
assert, and only one of them leaves you a program to continue with.(* assert raises Assert_failure, which is catchable and
reports the file and line. Compiling with -noassert
removes it. *)
let divide numerator denominator =
assert (denominator <> 0);
numerator / denominator
let () =
Printf.printf "%d\n" (divide 10 2);
(try Printf.printf "%d\n" (divide 10 0) with
| Assert_failure _ -> print_endline "assertion failed")#include <stdio.h>
#include <assert.h>
/* assert ABORTS the process — there is nothing to catch.
* Defining NDEBUG removes every assert from the build. */
static int divide(int numerator, int denominator) {
assert(denominator != 0);
return numerator / denominator;
}
int main(void) {
printf("%d\n", divide(10, 2));
/* divide(10, 0) would abort here, so this row checks
* the condition itself instead. */
int denominator = 0;
if (denominator == 0) printf("assertion would fail\n");
return 0;
}OCaml's
assert raises Assert_failure carrying the file and line, so it is an ordinary exception you can catch — and it participates in the type system, since assert false has type 'a and can stand in anywhere. C's assert prints a message and calls abort(), terminating the process with no unwinding and nothing to catch. Both disappear from a release build — OCaml with -noassert, C when NDEBUG is defined — which is why an assert must never contain a side effect the program depends on; that code vanishes with it.Undefined Behavior
A Category OCaml Does Not Have
This is the concept with no OCaml counterpart, and it is more dangerous than "the program crashes".
(* Every operation has a defined result. Division by
zero raises, out-of-range indexing raises, and
arithmetic wraps predictably. *)
let () =
(try Printf.printf "%d\n" (10 / 0) with
| Division_by_zero -> print_endline "caught division by zero");
Printf.printf "%d\n" (max_int + 1)#include <stdio.h>
#include <limits.h>
int main(void) {
/* 10 / 0 is UNDEFINED BEHAVIOR, not an exception — so
* this row does not perform it. Nor is INT_MAX + 1 a
* wrap: signed overflow is undefined, and the
* compiler may assume it cannot occur. */
int denominator = 2;
if (denominator != 0) printf("%d\n", 10 / denominator);
/* The defined way to ask the question: */
printf("would overflow? %d\n", INT_MAX > INT_MAX - 1 ? 0 : 1);
printf("unsigned wraps predictably: %u\n", (unsigned int)UINT_MAX + 1U);
return 0;
}In OCaml every operation has a defined result:
10 / 0 raises Division_by_zero, an out-of-range index raises Invalid_argument, and integer arithmetic wraps. C has a category called undefined behavior in which the standard imposes no requirements at all — and modern optimizers use that, deleting a null check because a pointer was already dereferenced, or removing an overflow test because signed overflow "cannot happen". So the failure is not necessarily a crash at the guilty line; it can be code elsewhere quietly disappearing. The defenses are the sanitizers (-fsanitize=undefined,address), a high warning level, and writing checks before the operation rather than after.Uninitialized Memory
OCaml has no way to create a binding without a value; C has several, and reading one is undefined behavior rather than a surprise value.
(* A binding always has a value; there is no way to
declare one without giving it something. *)
let () =
let total = 0 in
Printf.printf "%d\n" total#include <stdio.h>
#include <stdlib.h>
int main(void) {
/* An uninitialized local holds whatever was in that
* stack slot; reading it is undefined behavior. Always
* initialize at the point of declaration. */
int total = 0;
/* malloc does not zero its memory. calloc does. */
int *zeroed = calloc(4, sizeof(int));
if (zeroed == NULL) return 1;
printf("%d %d\n", total, zeroed[0]);
free(zeroed);
return 0;
}In OCaml a
let always binds something, and even an array must be created with an initial value for every slot. In C, int total; declares a variable whose contents are whatever the stack slot held, and reading it before assignment is undefined behavior — not "some arbitrary integer", which is why a debug build can behave differently from a release build. malloc likewise returns uninitialized bytes while calloc zeroes them. The rule is simple and worth following without exception: initialize at the point of declaration, and prefer calloc when zero is the sensible starting state.Strict Aliasing
A rule most C programmers meet only after it breaks something: two pointers of different types may not refer to the same object.
(* There is no way to reinterpret one type's bytes as
another. Conversions are explicit functions and always
defined. *)
let () =
let number = 1078530011 in
Printf.printf "%d\n" number;
Printf.printf "%f\n" (float_of_int number)#include <stdio.h>
#include <string.h>
int main(void) {
int number = 1078530011;
/* Reading an int through a float * is UNDEFINED, even
* though the bytes are right there. memcpy is the
* defined way to reinterpret. */
float reinterpreted;
memcpy(&reinterpreted, &number, sizeof(reinterpreted));
printf("%d\n", number);
printf("%f\n", (double)reinterpreted);
return 0;
}The strict aliasing rule lets the compiler assume an
int * and a float * never point at the same memory, which enables a great deal of optimization — and makes the classic pointer-cast reinterpretation undefined behavior, even though it looks like it obviously works and usually does until the optimizer is turned up. The defined way to reinterpret bytes is memcpy, which compilers recognize and compile to nothing. OCaml has no way to do this at all: conversions are explicit functions with defined semantics, and reinterpreting a representation requires Obj.magic, which is exactly as dangerous as it sounds.Concurrency
Threads
Both create a real operating-system thread. Only one of them can carry a typed result back.
(* A domain is an OS thread with its own minor heap.
Spawn returns a handle, join collects the result. *)
let () =
let worker = Domain.spawn (fun () ->
let total = ref 0 in
for index = 1 to 100 do total := !total + index done;
!total)
in
Printf.printf "%d\n" (Domain.join worker)#include <stdio.h>
#include <pthread.h>
/* A thread function takes and returns void *, so the
* result has to be carried out through the argument. */
static void *work(void *argument) {
int *total = argument;
*total = 0;
for (int index = 1; index <= 100; index++) *total += index;
return NULL;
}
int main(void) {
pthread_t worker;
int total = 0;
if (pthread_create(&worker, NULL, work, &total) != 0) return 1;
pthread_join(worker, NULL);
printf("%d\n", total);
return 0;
}OCaml 5's
Domain.spawn takes a closure returning any type and Domain.join returns that value, typed. A pthread function must have signature void *(*)(void *), so both the argument and the result are untyped pointers the callee casts — and the storage they point at must outlive the thread, which is why the total above lives in main's frame rather than the thread's. Note also that OCaml 5 domains each carry a minor heap and a share of the collector, so they are heavier than a pthread and you create a handful rather than hundreds.Shared Mutable State
Both reach 2000, and neither language ties the lock to the data it protects.
let () =
let lock = Mutex.create () in
let total = ref 0 in
let bump () =
for _ = 1 to 1000 do
Mutex.lock lock;
total := !total + 1;
Mutex.unlock lock
done
in
let first = Domain.spawn bump in
let second = Domain.spawn bump in
Domain.join first;
Domain.join second;
Printf.printf "%d\n" !total#include <stdio.h>
#include <pthread.h>
static int total = 0;
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
static void *bump(void *argument) {
(void)argument;
for (int index = 0; index < 1000; index++) {
pthread_mutex_lock(&lock);
total += 1;
pthread_mutex_unlock(&lock);
}
return NULL;
}
int main(void) {
pthread_t first;
pthread_t second;
pthread_create(&first, NULL, bump, NULL);
pthread_create(&second, NULL, bump, NULL);
pthread_join(first, NULL);
pthread_join(second, NULL);
printf("%d\n", total);
return 0;
}The two columns are structurally the same, and the difference is what happens when the locking is removed. In OCaml 5 the runtime guarantees a data race cannot corrupt memory — you get a wrong number, which is bad but bounded. In C an unsynchronized read and write to the same object is undefined behavior, so the compiler may cache the value in a register, reorder the accesses, or assume the race does not happen; the result is not merely a wrong count but a program with no defined meaning. C11 added
<stdatomic.h>, which gives properly defined atomic operations and is what you want for a counter like this one.Atomic Operations
Both languages gained a proper atomics story recently, and the two APIs line up closely.
(* OCaml 5's Atomic module gives lock-free operations
on a single location. *)
let () =
let counter = Atomic.make 0 in
for _ = 1 to 1000 do
ignore (Atomic.fetch_and_add counter 1)
done;
Printf.printf "%d\n" (Atomic.get counter)#include <stdio.h>
#include <stdatomic.h>
int main(void) {
atomic_int counter = 0;
for (int index = 0; index < 1000; index++) {
atomic_fetch_add(&counter, 1);
}
printf("%d\n", atomic_load(&counter));
return 0;
}OCaml 5's
Atomic and C11's <stdatomic.h> both give lock-free read, write, exchange and compare-and-set on a single location, and both are what you want for a shared counter instead of a mutex. The important difference is that C exposes the memory ordering — atomic_fetch_add_explicit takes a parameter choosing between sequential consistency, acquire/release and relaxed — while OCaml's Atomic is sequentially consistent and offers no choice. That is a fair summary of the whole pair: C hands you the machine, and OCaml picks the safe default.Where OCaml and C Meet
Calling C From OCaml
This is the point of the pair. Neither column runs on its own — the OCaml side needs the C stub compiled and linked beside it, which a browser runner cannot do.
(* An external declaration names a C function and gives
it an OCaml type. dune's (foreign_stubs (language c)
(names stubs)) compiles the C and links it in. *)
external double_it : int -> int = "caml_double_it"
let () = Printf.printf "%d\n" (double_it 21)/* The C side. value is the OCaml representation: an
* immediate integer or a pointer to a heap block, told
* apart by the low bit. */
#include <caml/mlvalues.h>
#include <caml/memory.h>
CAMLprim value caml_double_it(value input) {
CAMLparam1(input);
long number = Long_val(input); /* value -> C long */
CAMLreturn(Val_long(number * 2)); /* C long -> value */
}An
external declaration gives an OCaml type to a C function; the compiler emits a call and trusts you completely, because nothing checks that the C function's signature matches the OCaml type you claimed. On the C side, value is the universal OCaml representation: an odd word is a tagged immediate integer (hence Long_val shifting right and Val_long shifting left and setting the low bit), and an even word is a pointer to a heap block. That one-bit tag is exactly why an OCaml int is 63 bits rather than 64 — a fact from the very first section of this page, explained here.Why CAMLparam Exists
The single most important rule of the OCaml FFI, and the one whose violation produces bugs that appear months later under load.
(* Inside OCaml the collector can see every root: the
stack, the globals, and the registers. Allocation can
move a value and every reference is updated. *)
let () =
let first = String.make 3 'a' in
let second = String.make 3 'b' in
(* An allocation here may move both; nothing breaks. *)
print_endline (first ^ second)/* Inside a C function the collector CANNOT see your
* locals. CAMLparam and CAMLlocal register them as roots,
* so a value that moves during allocation is updated. */
#include <caml/mlvalues.h>
#include <caml/memory.h>
#include <caml/alloc.h>
CAMLprim value caml_join(value first, value second) {
CAMLparam2(first, second); /* register the arguments */
CAMLlocal1(result); /* and every local "value" */
mlsize_t total = caml_string_length(first) + caml_string_length(second);
result = caml_alloc_string(total); /* this may trigger a GC */
memcpy((char *)Bytes_val(result), String_val(first), caml_string_length(first));
memcpy((char *)Bytes_val(result) + caml_string_length(first),
String_val(second), caml_string_length(second));
CAMLreturn(result);
}OCaml's collector is moving: allocating can relocate live values and rewrite every reference it knows about. It knows about the OCaml stack and the globals; it knows nothing about a C function's locals. So any
value held in C across a call that might allocate must be registered as a root with CAMLparam (for parameters) and CAMLlocal (for locals), and released with CAMLreturn. Skip it and the code works perfectly until a collection happens to occur at that moment, after which you are holding a pointer to where the value used to be. Note also String_val: the pointer it yields is only valid until the next allocation, for exactly the same reason.When to Reach for C at All
Worth being honest about, since the FFI is easy to reach for and expensive to maintain.
(* Most reasons to drop to C are answered inside OCaml.
Bytes is a mutable byte buffer, Bigarray wraps memory
without copying, and ocamlopt emits native code. *)
let () =
let buffer = Bytes.create 4 in
Bytes.set buffer 0 'O';
Bytes.set buffer 1 'C';
Bytes.set buffer 2 'a';
Bytes.set buffer 3 'm';
print_endline (Bytes.to_string buffer);
Printf.printf "%d bytes\n" (Bytes.length buffer)#include <stdio.h>
#include <string.h>
/* The equivalent buffer, and the thing C still has that
* OCaml does not: exact control of layout and lifetime,
* and the ability to run with no runtime at all. */
int main(void) {
char buffer[5];
memcpy(buffer, "OCam", 4);
buffer[4] = '\0';
printf("%s\n", buffer);
printf("%zu bytes\n", sizeof(buffer) - 1);
return 0;
}Most of the classic reasons to drop into C are already answered in OCaml:
Bytes is a mutable byte buffer, Bigarray wraps externally-managed memory without copying, and ocamlopt produces native code that is frequently within a small factor of C. The reasons that remain are real but specific — binding an existing C library, which is by far the most common; needing precise control of memory layout or lifetime; and running somewhere OCaml's runtime cannot go, such as a kernel or a very small embedded target. Every FFI boundary is also a place the type system stops, so the smaller the surface the better.