Overview
Passing a pointer (an address) to a function lets the callee access the caller’s storage. This enables in-place updates, out-parameters, and zero-copy APIs—but introduces hazards: null/dangling pointers, aliasing, lifetime, and bounds issues. This note gives a practical guide to pointer-parameter design: when to use pointers, how to express read-only vs read–write intent (const-correctness), and how to avoid common bugs.
Note
Think of a pointer parameter as a capability: “here is where you may read/write.” Good APIs make that capability explicit (read-only vs mutable, length, ownership).
Underlying Process
At a call site:
-
The caller computes the address of an object (stack, heap, global) and passes that address.
-
The callee receives the address as a parameter (e.g.,
int *p). -
Reads/writes use indirection (
*p,p[i]) to access the caller’s storage. -
On return, any writes through the pointer are visible to the caller; the pointer value itself (an integer-like address) was passed by value unless explicitly passed by reference-to-pointer.
Const-correctness
-
const T* p— callee may read*pbut must not modify it. -
T* p— callee may read and write*p. -
T* const p— the pointer variablepitself cannot be reassigned, but*pmay change. -
const T* const p— fixed pointer to read-only data.
Tip
Prefer
constparameters for inputs. Reserve mutable pointers for explicit out or in/out roles.
Example Execution
1) Out-parameter for status + result (C-style)
// Returns 0 on success; writes result into *out_sum.
int sum_u32(const uint32_t* arr, size_t n, uint64_t* out_sum) {
if (!arr || !out_sum) return -1;
uint64_t s = 0;
for (size_t i = 0; i < n; ++i) s += arr[i];
*out_sum = s; // write into caller's memory
return 0;
}
// Caller:
uint64_t s;
if (sum_u32(a, n, &s) == 0) { /* use s */ }-
arris read-only viaconst. -
out_sumis write-only in spirit; it should be documented as such.
2) In-place transform on a buffer
void scale_in_place(float* xs, size_t n, float c) {
if (!xs) return;
for (size_t i = 0; i < n; ++i) xs[i] *= c;
}- The function mutates the caller’s array via
xs[i].
3) Reference-to-pointer when callee must rebind storage
// Reallocate and update caller's pointer.
// Returns new length; sets *buf to new storage or NULL on failure.
size_t ensure_capacity(uint8_t** buf, size_t* cap, size_t need) {
if (*cap >= need) return *cap;
size_t new_cap = (*cap ? *cap * 2 : 64);
while (new_cap < need) new_cap *= 2;
uint8_t* p = (uint8_t*)realloc(*buf, new_cap);
if (!p) { /* leave old buffer intact */ return *cap; }
*buf = p; // rebind caller's pointer
*cap = new_cap;
return new_cap;
}- Pass
T**(pointer to pointer) when the callee must change where the caller points.
Performance and Design Trade-offs
-
Zero-copy: Pointers avoid copying large data structures (
O(1)passing). -
In-place vs out-of-place: In-place can be faster and memory-frugal, but harder to reason about. Out-of-place (returning a new value) is safer and often clearer.
-
Cache locality: Mutating through pointers is as local as your data layout; see Dynamic Arrays and Multidimensional Arrays for stride/layout implications.
-
API clarity: Use names and types to signal direction:
-
Inputs:
const T* in,size_t in_len -
Outputs:
T* out,size_t out_cap(capacity), return actual bytes written. -
In/out:
T* inout, document changes.
-
Tip
For bulk writes, pass capacity + length: the callee can avoid overflow and the caller can pre-size buffers.
Correctness and Reliability Considerations
1) Bounds and length
A pointer does not carry length. Always pair buffer pointers with explicit size (n) or use a slice/view type ({ptr,len}).
int fill(uint8_t* out, size_t cap) {
if (cap < 4) return -1; // guard
out[0]=1; out[1]=2; out[2]=3; out[3]=4;
return 4;
}2) Null, dangling, and lifetime
-
Null: check for
NULL(or forbid it contractually). -
Dangling: never return pointers to stack locals; ensure the pointee outlives its uses.
-
Reallocation: functions like
realloccan move storage—callers must update all aliases.
3) Aliasing and reentrancy
Two different pointer parameters can alias the same region, causing subtle bugs if written in the wrong order.
// Safe even when dst==src: iterate from end on overlap.
void memmove_like(uint8_t* dst, const uint8_t* src, size_t n) {
if (dst < src) for (size_t i=0;i<n;++i) dst[i]=src[i];
else if (dst > src) for (size_t i=n; i>0; --i) dst[i-1]=src[i-1];
}4) Const-correctness discipline
-
Mark inputs
const. -
Use
constto allow aliasing safely (compiler can assume read-only). -
Do not cast away
constunless the original object was non-const and you control its lifetime.
5) Ownership and allocation
Document who allocates and who frees:
-
Caller allocates, callee fills (classic out-param).
-
Callee allocates, caller frees (return pointer or
T** out). -
Shared ownership requires reference counts or conventions—avoid in low-level C unless necessary.
Warning
Mismatched allocator/free (
mallocvs custom pool) leads to crashes. Free with the same allocator family that allocated.
6) Thread-safety
Pointers cross thread boundaries as raw capabilities. If multiple threads write through aliases, use locks or restrict to immutable reads.
7) Language notes (brief)
-
C/C++: raw pointers as above; prefer
span<T>/std::span(C++) for{ptr,len}views; preferT&/const T&for single objects when mutation is controlled. -
Go: pass
*Tfor mutating a struct; slices and maps are reference-like already—passing a slice by value shares backing storage. -
Rust: uses borrows instead of raw pointers in safe code (
&T,&mut T) with compile-time lifetime checks; raw pointers exist inunsafe. -
Java/C#/Swift: ordinary references act as pointers to objects; arrays are reference types (mutations visible). Interop with native code uses explicit pointer wrappers.
Implementation Notes
-
Prefer half-open ranges (
[0..n)) in loops to avoid off-by-one errors when indexing throughp[i]. -
Avoid double free by centralizing ownership responsibility; use RAII in C++ and “free-after-transfer” patterns in C.
-
Validate alignment if the callee performs vectorized loads/stores; misaligned pointers can hurt performance or fault on strict platforms.
-
For binary protocols, define structs with explicit packing or use byte-wise access to avoid padding/endianness pitfalls.
Tip
Introduce lightweight view types early (
struct slice { void* ptr; size_t len; }). They document bounds and make APIs harder to misuse than rawT*.
Related Concepts
-
References vs pointers: references (
T&) are non-null, aliasing handles with simpler syntax; pointers (T*) can be null/reassigned. -
Handles/IDs: instead of exposing pointers, some APIs pass opaque IDs that the callee resolves internally—safer but less flexible.
-
Copy vs move: for large data, consider move (transfer ownership) rather than pointer mutation; see Pass-by-Value vs Reference.
Summary
Pointers-as-parameters are a powerful capability: they enable in-place updates, zero-copy I/O, and flexible memory management. Use them deliberately:
-
Mark inputs
const, reserve mutable pointers for out or in/out roles. -
Always pair pointers with length/capacity and document ownership.
-
Guard against null, dangling, aliasing, and bounds errors.
-
When the callee must rebind the caller’s pointer, pass a pointer to pointer (
T**) (or equivalent in your language). With these habits, pointer-based APIs are both fast and safe enough for systems work and high-performance DS&A code.