Opens in a new tab.
C / C++ / Programming Languages Markdown

The humble pointer

Just use the daggum &

Suppose I hand a function an integer:

int count = 41;
bump(count);

Will count be 42 afterward?

In several languages, the call gives no useful answer. You have to find the declaration of bump, ask an editor, remember an overload, or trust that nobody got clever before lunch.

C had a small and remarkably durable answer:

void bump(int *value) {
    *value += 1;
}

int count = 41;
bump(&count);

There it is. &count says that bump receives the address of count, and *value says that the function writes through that address. The machine will ultimately do something address-shaped, and the program says so without submitting a request to the Department of Abstraction.

Then C++ made it polite

C++ references let a function alias an existing object without using pointer syntax:

void bump(int& value) {
    value += 1;
}

int count = 41;
bump(count);

The declaration is fine. int& means a mutable lvalue reference, and writes to value reach the caller’s object.

The call site lost the plot, though. These all look the same:

inspect(count);    // int
observe(count);    // const int&
bump(count);       // int&

One copies, one aliases without permission to modify through that parameter, and one may change count. C++ made references pleasant to use by removing visible indirection. It also removed the little flare at the call site that said, “this function has my storage now.”

There were reasons. References avoid null as an ordinary state, make operator overloading possible, and keep generic interfaces from looking like a pointer convention. Fine. But losing the & was not some inevitable ascent into higher-level programming. We gave up local evidence because the calls looked nicer.

The baggage traveled well

Java did not copy C++ reference parameters wholesale. It did something broader and awfully familiar: primitive variables hold primitive values, while class, interface, and array variables hold references to objects. The syntax politely declines to mention this most of the time.

Yes, Java is always pass-by-value. For an object argument, the copied value is a reference. This sentence has consumed several million whiteboard markers since 1995.

final class Counter {
    int value;

    Counter(int value) {
        this.value = value;
    }
}

static void bump(Counter counter) {
    counter.value += 1;
}

var count = new Counter(41);
bump(count);                  // count.value is now 42

Nothing at bump(count) says that the object may be changed. Reassigning the parameter would not replace the caller’s count, because only the reference value was copied:

static void replace(Counter counter) {
    counter = new Counter(0);
}

replace(count);               // count still refers to the old Counter

You can learn the rule. Everybody does eventually. It still leaves bump(count) unable to tell copying an integer from sharing a mutable object. Even final Counter count only freezes the name. The Counter is free to carry on changing underneath it.

The C++ convenience became the atmosphere. Object access looks direct, indirection is automatic, and mutation through a shared reference is ordinary enough to need no mark at the call. Java removed pointer arithmetic and explicit dereference, which discarded a great deal of danger. It also discarded the small piece of syntax that told the reader where shared storage crossed a function boundary.

C# inherited most of this, then half-fixed it. Class instances still have Java-like reference semantics:

sealed class Counter
{
    public int Value;
}

static void Bump(Counter counter) => counter.Value++;

var count = new Counter { Value = 41 };
Bump(count);                  // count.Value is now 42

When a method needs the caller’s variable itself, C# requires ref in both places:

static void Replace(ref Counter counter)
{
    counter = new Counter { Value = 0 };
}

Replace(ref count);

Good! Unfortunately it only covers rebinding the variable. Bump(count) can still alter the object without so much as clearing its throat. The family kept a sharp distinction between “the reference value” and “the object behind it,” while making both look serenely value-like at the call.

Go remembers more than most

Go returns to explicit pointers for an ordinary function:

func bump(value *int) {
    (*value)++
}

count := 41
bump(&count)

The address and dereference operators mean what a C programmer expects. Go removes pointer arithmetic and adds garbage collection, but &count still announces that the callee receives access to the caller’s storage.

Pointer-receiver methods soften that rule:

type Counter struct {
    value int
}

func (counter *Counter) bump() {
    counter.value++
}

count := Counter{value: 41}
count.bump()                 // shorthand for (&count).bump()

If count is addressable, Go inserts the address operation for the pointer receiver. The call loses the &, but the declaration still says *Counter. More importantly, Go has no separate universe of class types whose values are silently references. Value and pointer receivers stay distinct in the type’s method sets. You have to choose.

Slices provide another quiet route to shared mutation:

func zeroFirst(values []int) {
    values[0] = 0
}

numbers := []int{1, 2, 3}
zeroFirst(numbers)           // numbers[0] is now 0

A slice is a small descriptor over a backing array. Copy the descriptor and you still share the elements. Maps and channels have similar identity-bearing semantics.

Still, this is a pretty good deal. Pointers remain pointers in parameter and receiver types. Structs are values unless the program asks for their address. One bit of method-call sugar does not turn every heap-shaped thing into an implicit reference forever. Go moved the default back in the right direction.

Swift does one very good thing

Swift’s inout parameters put mutation in both the declaration and the call:

func bump(_ value: inout Int) {
    value += 1
}

var count = 41
bump(&count)

The ampersand is required. It is not a general-purpose Swift pointer; it marks an in-out argument. Swift also enforces exclusive access while the argument is being modified. C would let overlapping accesses become tomorrow morning’s problem.

Swift describes inout with copy-in/copy-out semantics, even though implementations commonly modify storage in place as an optimization. That distinction matters. The language promises that a value goes in and an updated value comes back, not that you receive a stable raw address.

For value types, this is better than C++. The caller has to write the daggum &.

Now for the very Swift part. Classes are reference types, and mutation through them looks completely ordinary:

final class Counter {
    var value: Int

    init(_ value: Int) {
        self.value = value
    }
}

func bump(_ counter: Counter) {
    counter.value += 1
}

let count = Counter(41)
bump(count)                 // count.value is now 42

let count prevents rebinding the reference; it does not make the instance immutable. No inout, &, or mutating marker appears at the call or on the free function. Methods on class instances have the same problem through implicit self.

So inout is good. Genuinely. The class model then permits shared mutation with less local evidence than Go, where pointer identity remains visible in the receiver declaration. One nice feature does not wash the whole car.

OCaml marks the cell, not the call

Before getting to Rust, OCaml gets one half of the answer exactly right. Bindings are immutable by default. Mutable cells use a distinct 'a ref type, ! to read, and := to write:

let bump cell =
  cell := !cell + 1

let count = ref 41
let () = bump count

count was explicitly created as a reference, and the write inside bump cannot masquerade as ordinary rebinding. Mutable record fields similarly need mutable in the declaration and <- at the write. An int ref is not an int. Nice and tidy.

But bump count carries none of it. Is count a plain value, a reference cell, a record with mutable fields? Go find the binding, or ask the type checker. OCaml makes mutation explicit where the mutation happens, not necessarily where a mutable thing gets handed to somebody else.

So the mutable cell has its own type, which is the important part. The call is still plain. Rust’s move is to keep the first fact and restore the second.

Rust actually finishes the job

Rust takes OCaml’s type-level distinction and puts the ampersand back at the boundary:

fn bump(value: &mut i32) {
    *value += 1;
}

let mut count = 41;
bump(&mut count);

&mut count says that the function receives a mutable borrow. Unlike C’s pointer, that borrow comes with an exclusivity rule: while it is active, competing access to the same value is restricted by the compiler.

Everything lines up. mut count marks the binding, &mut count creates mutable access, &mut i32 puts that access in the function type, and *value reaches the borrowed integer. Rust did not discover visible address-taking. It took the notation seriously enough to build a static discipline around it.

What about an owned mut parameter?

fn bump_owned(mut value: i32) -> i32 {
    value += 1;
    value
}

let count = 41;
let next = bump_owned(count);

No trick there. bump_owned mutates the value it now owns. It cannot reach back and change the caller’s old binding. Changing count itself still requires bump(&mut count): type, call, and borrow checker all agree.

An existing mutable-reference binding can of course move the marker earlier:

let mut count = 41;
let handle: &mut i32 = &mut count;

bump(handle);

That is the same shape as C’s int *handle = &count; bump(handle);. The direct call no longer repeats address-taking because the access path already exists, but neither language invented it invisibly.

Cell and RefCell are the deliberate escape hatch. Their wrapper types advertise interior mutability and permit controlled mutation through shared references. For an ordinary mutable borrow, though, Rust has the whole thing: a distinct type, a marked call, and enforcement.

Rust is better than C at this. Obviously. The interesting bit is how much of the human-facing notation was already sitting in C.

What C got right

To be clear, C does not prove that bump(&count) mutates count. The function might only read it. C also permits aliasing, dangling pointers, hidden global mutation, and enough other excitement to keep sanitizers employed.

The & means something narrower and cleaner: this call exposes the storage of this object. Mutation is now possible through that path, and the callee’s pointer type says whether that path permits writing:

void inspect(const int *value); // may read through this pointer
void bump(int *value);          // may read or write through this pointer

inspect(&count);
bump(&count);

bump(&count) tells the story twice. The function takes int *; the call takes count’s address. If there is already an int *handle, then bump(handle) moves the evidence back to handle’s declaration. Of course it does. The address was already taken. C at least never turns the pointer into a value-looking alias on the way over.

I do not think this is a leaky abstraction. It is just faithful notation. The source names an address, the type describes access through it, and dereference marks the operation on the pointed-to object. Mutation does not happen by telepathy.

Pointers are often introduced as C’s dangerous low-level burden, the regrettable thing safer languages must hide before civilized programming can begin. Fair enough: unrestricted pointers are dangerous. But the notation itself preserves a useful semantic fact that some supposedly friendlier interfaces blur.

And yet, back in the early 1970s, it got the little human-facing bit right. Handing storage across a function boundary looked like handing storage across a function boundary. No effect system. No borrow checker. Just &, sitting there being honest.

We have spent fifty years making mutation safer, mostly with great results. Somewhere along the way, several languages also made it harder to see. Go backed away from that. Rust fixed it outright. Swift fixed it until a class walked into the room.

For the small question “might this function change that value?”, plain C remains wonderfully difficult to misunderstand:

bump(&count);

Sometimes the humble pointer is not a failure to abstract. Sometimes it is just the clearest thing in the room.