Using Checked Pointers in Carbon for Safer C++ Interop
Explains how Carbon’s checked pointers add runtime bounds and null safety to raw pointers from C++, with a worked example, trade‑offs, and steps to try the feature.
23 Dec 2025, 00:34 UTC

The problem: raw pointers still invite undefined behavior
When you call into C++ libraries from Carbon, you often receive raw pointers. Those pointers carry no runtime information about their validity, bounds, or nullness. A mistaken index or a dangling reference can silently corrupt memory, leading to crashes that are hard to reproduce.
Thesis: checked pointers give you runtime safety while allowing gradual migration
Carbon’s checked pointer type bundles a raw address with bounds and null information. The compiler inserts lightweight checks when the pointer is used, turning out‑of‑bounds or null dereferences into a clear panic instead of undefined behavior. Because the checks are inserted only at the boundary between C++ and Carbon, existing C++ code can stay unchanged while you gain safety in the Carbon side.
How checked pointers work
A checked pointer is declared with the checked keyword:
checked ptr p = ...;
The type carries three pieces of metadata:
- the address of the object
- the lower bound (usually zero for arrays)
- the upper bound (the number of elements)
- a null flag
When you dereference p[i] or perform pointer arithmetic, the generated code checks that i is within the bounds and that the pointer is not null. If a check fails, the runtime triggers a panic with a diagnostic message.
Interoperating with existing C++ code
Carbon allows an implicit conversion from a raw T* to checked ptr when the compiler can prove the source pointer originates from a known‑valid object (e.g., a pointer returned by a C++ function annotated with [[carries_dependency]] or passed through a trusted wrapper). In practice, you write a thin Carbon wrapper:
// Carbon wrapper around a C++ function that returns a raw pointer
extern "C++" fn get_data() -> ptr<i32>;
fn use_data() -> checked ptr<i32> {
let raw = get_data(); // raw ptr<i32>
// The compiler inserts a runtime check that raw is not null
// and, if the size is known, validates bounds.
return raw; // implicit conversion to checked ptr<i32>
}
If the source pointer cannot be proven safe, you must perform an explicit conversion and handle the possible failure:
fn maybe_checked(p: ptr<i32>) -> Result<checked ptr<i32>, Error> {
if p.is_null() {
return Err(Error::NullPointer);
}
// Assume we know the array length from another source
return Ok(checked ptr<i32>{p, 0, length});
}
Worked example: array traversal with bounds checking
Consider a simple function that sums the first n elements of an integer array. In plain C++ this would be:
int sum_c(int* arr, int n) {
int total = 0;
for (int i = 0; i < n; ++i) {
total += arr[i]; // UB if i >= actual length
}
return total;
}
The Carbon version using a checked pointer looks like this:
fn sum_checked(arr: checked ptr<i32>, n: i32) -> i32 {
let mut total = 0;
let mut i = 0;
while i < n {
// The generated code checks: 0 <= i < arr.length and arr != null
total += arr[i];
i = i + 1;
}
return total;
}
If you call sum_checked with an n that exceeds the actual array length, the runtime will panic with a message similar to:
panic: checked pointer bounds violation: index 10 >= length 5
Instead of silently corrupting memory, the program stops and points directly to the offending line.
Trade‑offs and limitations
Checked pointers are still experimental. The Carbon compiler nightly builds implement the feature, but:
- Runtime checks add overhead; each dereference incurs a branch and a comparison. In tight loops this can be measurable.
- Tooling support (debuggers, IDE integrations) lags behind mature C++ sanitizers like AddressSanitizer or UndefinedBehaviorSanitizer.
- Because the feature relies on the compiler’s ability to infer bounds, you may need to annotate functions or provide wrapper types when the size is not known at the call site.
To assess the overhead in your own code, you can build the same function twice:
- With checked pointers (default debug build).
- With the
--releaseflag, which attempts to eliminate checks when the compiler can prove they are unnecessary.
Comparing the execution times of the two builds gives you a concrete sense of the cost.
Actionable next steps
- Clone the Carbon compiler repository from GitHub and checkout the latest nightly branch.
- Build the compiler with
./bazel build //toolchains:carbon_toolchain(or the equivalent build command for your platform). - Write a small test program that passes a raw pointer from a C++ function into a Carbon function expecting a
checked ptr. - Run the program under the default build to see the panic on an intentional out‑of‑bounds access.
- Re‑run with
--releaseand measure the difference in runtime. - If the overhead is acceptable for your safety‑critical modules, adopt checked pointers for the boundary layer; otherwise, consider static analysis or selective use of
uncheckedpointers in performance‑critical hot paths.
By starting with the interop boundary, you gain immediate protection against the most common pointer‑related bugs while keeping the option to tune performance later.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.