The C+ guide
How to think in C+, and which construct to reach for when more than one would compile. For a first sitting read tour.md; for exact shapes, ref.md; for the rules themselves, spec.md and memory-model.md.
The guide's spine is on this page. These topics are large enough to stand alone:
| Topic | File | The decision it settles |
|---|---|---|
| Ownership | ownership.md | who owns a value, and how it travels |
| Errors | error-handling.md | what a fallible function returns |
| Packages | packages.md | how a project grows past one file |
| Platforms | platforms.md | where a per-OS difference belongs: a file, a dependency, or a value |
| Concurrency | concurrency.md | threads, async, generators — which shape the work wants |
| FFI | ffi.md | what crossing the C boundary costs and promises |
| Standard library | stdlib.md | which module answers a need |
| Testing | testing.md | what to write, where it runs, what it cannot check |
| Tooling | tooling.md | which command answers the question you have |
1. How to think in C+
Everything is a value, and every value has one owner. A struct is bytes,
assignment of a Copy struct is a copy, and an owning value (Text, Vec, a
struct containing one) moves rather than copies. When the owner's scope
ends, the value frees itself. That one rule replaces the garbage collector,
unique_ptr, and most of the discipline C asks of you.
Everything is explicit, so everything is visible. No implicit
conversions, no overloading, no exceptions, no closures capturing who knows
what, no macros rewriting your code. The cost is a few more tokens per line
(as, return, named fields); what you buy is that any line means what it
says without reading the rest of the file.
Danger is visible, not forbidden. C+ wears some of Rust's vocabulary but
keeps C's semantics — verify against this compiler, not against Rust
reflexes. There is no unsafe block, because every dangerous operation is
already loud at the point of use: *p and p[i] are the only derefs,
x as *T is the only way to make a pointer, extern fn is the marker for a
foreign call. The safe subset is checked; the raw tier is spelled out.
When the compiler and your intuition disagree, run cpc explain Exxxx.
The diagnostics are the designed teaching surface; this guide covers the
places where an error message alone can't tell you which redesign to pick.
2. Which parameter mode
The complete decision, in signature order of preference:
| The callee needs to… | Write | Caller sees |
|---|---|---|
| read the value | x: T (bare) |
keeps it |
| mutate the caller's value in place | ref x: T |
must pass a var; sees the writes |
| keep, store, return, or destroy it | take x: T |
loses it at the call |
Bare is the default for a reason: it is a borrow for every type, including
big structs — there is no "pass by value copies" tax to route around. Reach
for take only when the value must escape the callee (stored in a field,
returned, appended into a container): a bare borrow that escapes is E0337,
and the fix the message offers — take or .clone() — is a real fork:
take when the caller is done with it, .clone() when both sides need one.
Receivers are the same choice spelled this / ref this / take this.
When in doubt between ref this and returning a new value, prefer ref this — the house style mutates in place (set_*, append) and reserves
returns for reads. The full model, including views and drop order, is
ownership.md.
3. Which string
| You have / need | Use |
|---|---|
| a literal, a parameter, any read | str — the borrowed view; all read methods live here |
| a field, a return you own, building at runtime | text::Text — s.to_text() / text::from_str(s) |
| bytes for C | c"..." literal, or #str_ptr(s) + #str_len(s) |
Two habits prevent every string bug worth having:
- Views borrow.
t.trim()is a window intot's buffer, not a copy. While the view lives,tis write-locked; the borrow ends at the view's last use. A view bound at aletneeds a named owner —let s: str = t.clone();dies with E0513 because the clone was a temporary. Name the owner first. - Copies are spelled.
.to_text()is the only string copy you ever pay, and it appears in the source. If you never write it, you never allocate.
There is no + concatenation: interpolate ("x = ${n}") or append.
Interpolation straight into io::println allocates nothing; bound to a
variable it builds a Text.
4. Which error shape
The house convention, in one table (rationale and patterns in error-handling.md):
| The operation is… | Return | Consumed with |
|---|---|---|
| a mutator that can fail (append, insert, reserve) | status::Status |
if !s.is_ok() { … } or bind _ when the failure mode is OOM-only |
| a read that can miss (lookup, parse, index) | Option[T] |
match / guard let |
| a computation with a value and a reason | Result[T, E] or your own enum |
match / guard let … else Err(e) |
No panic exists. assert traps — use it for contract violations that mean
the program itself is wrong, never for input.
5. Which container
| Need | Use | Note |
|---|---|---|
| fixed count, known at compile time | [T; N] |
stack; bounds-checked; index with 0..n |
| growable sequence | vec::Vec[T] |
mutators return Status; reads return Option |
| borrowed window over either | T[] (slice) |
via Vec::as_slice — arrays do NOT coerce |
| keyed lookup | hash_map::HashMap[K, V] |
K: Hash + Eq — derive both with empty impls |
| one heap value, one owner | box::Box[T] |
stable address; unwrap() moves it back out |
| shared ownership, one thread | rc::Rc[T] |
with_mut gate for mutation |
| shared ownership, across threads | arc::Arc[T] |
Send + Sync iff T is |
| back-pointer that must not keep alive | rc::Weak / arc::Weak |
upgrade() -> Option |
The order is deliberate: prefer the top. Most designs that reach for
Rc/Arc early actually wanted a clearer owner.
6. Concurrency, in order of preference
- Partition + join.
thread::spawn_withmoves disjoint data into each worker;join()returns the results. No sharing, no race, no locks. - Scoped lend.
thread::scope()+s.lend::[T](value, worker)lends a local byrefand joins before the scope ends — the borrow checker enforces the join. - Channels (
stdlib/channel) when the shape is a pipeline. Arc+Mutex[T]last. Two guards in one scope deadlock — scope each lock.
async fn + await exist for I/O-bound work over the platform's reactor
(kqueue on Darwin, epoll on Linux and Android). Borrow-shaped types (str,
slices, ref params) are rejected in async fn signatures (E0900) — pass
owned Text / Vec. main may be async; the compiler drives it. From
other synchronous code, f.wait() drives a future to its value and
future::wait_or_cancel(f) is the cancellable form — both consume the
future. Cancellation, scoped lends, channels, and the async↔thread
bridge are concurrency.md.
7. Callbacks without closures
A stateful callback is a pair: a named fn and a *u8 context. Declare
the pair adjacently with the context defaulted, and callers can pass a bound
method with zero ceremony:
fn row(on_click: fn(str, *u8) = 0 as fn(str, *u8),
on_click_ctx: *u8 = 0 as *u8) { }
row(on_click: this.open_project) // compiler fills the ctx slot
The one mistake that matters: declaring the handler without the _ctx
slot. Callers then can never pass a method, and the error fires at their
call site where it can't be fixed (W0824 warns you at the declaration —
heed it). Same rule for struct fields that store handlers: store the *u8
beside the fn-pointer.
8. The traps that cost hours
Each of these is legal-looking code with a surprise in it. The long explanations live in the linked topic file.
- Integer literals evaluate through
i32beforeas.(1 << 40) as u64is not the mask you meant — it is 256. Widen the LEFT operand before the shift (1u64 << 40), or build the mask in aconst, which folds at the declared width and rejects overflow. A constant shift distance at or past the operand's width now warns (W0007); the general literal-width rule still has no warning, so the habit still matters. - No array→slice coercion.
[T; N]does not pass whereT[]is expected; go through aVecandas_slice, or index. - A view at a binding needs a named owner — and a brace-block tail
can promote a temporary's lifetime in ways an argument position
doesn't. When a
strbinding fights you, name theTextfirst. (ownership.md §views) - A consuming
matchis triggered by binding a name.Some(_)reads the tag only;Some(_v)binds — the leading underscore is privacy, not a wildcard — and consumes the value. (ownership.md §drop) take thisdoes not disarm drop. Return the payload and let scope exit free the shell; freeing manually double-frees.- A parenthesized deref opening an
ifmisparses — writeif { (*p).field } == x, braces first. House style does this everywhere. - Variadic C functions must be declared with
.... A fixed-arity declaration offcntlpasses garbage silently on AArch64. (ffi.md) - Two mutex guards in one scope deadlock. Scope each lock.
cpc check FILEcannot see imports. Any code with animportgoes throughcpc build/ project-modecpc check(E0852 otherwise).#platform()cannot gate an import. Both arms of anifon it compile on every platform; varying imports is what a_<platform>.cplusfile is for. (platforms.md)- A
```cplusfence inside a///comment is not a doctest. Only a bare three-backtick fence is extracted, so a tagged example silently never runs. (testing.md)
9. Style that the compiler doesn't enforce
- One blessed way per job; when the language offers a long and a short form (generic args in patterns), the short form is preferred where it resolves.
_nameis privacy — for fields, methods, and module items. Public is the default; make internals invisible rather than documented-as-internal.- Named arguments read as sentences at call sites (
slice(from: 1, to: 4)); use them whenever a call has two same-typed parameters. cpc fmtsettles layout arguments. If a file doesn't round-trip through the formatter, the file is wrong.