C+
For builders · intermediate

Structs, ownership, and errors in C+

In the first guide you wrote functions, loops, and variables. That is enough for a script. Real programs need three more things: a way to group data, a clear answer to "who owns this," and a way to handle failure that does not blow up in your face.

C+ has a strong opinion on all three, and the opinions are the reason the code stays easy to audit. This guide walks each one with examples you can compile and run. As before, every snippet here was built with cpc exactly as written.

Grouping data: structs and methods

A struct bundles related fields. An impl block gives that struct behavior. Here is a counter:

struct Counter { value: i32 }

impl Counter {
    fn new() -> Counter { return Counter { value: 0 }; }
    fn read(self) -> i32 { return self.value; }
    fn inc(mut self) { self.value = self.value +% 1; return; }
}

fn main() -> i32 {
    let mut c: Counter = Counter::new();
    c.inc();
    c.inc();
    println(c.read());     // 2
    return 0;
}

Run it and you get 2. Three things are worth naming:

That :: for types and . for values is a hard rule in C+, not a style choice. When you read a call, the punctuation already tells you whether you're talking to a type or an instance.

Ownership, without the fight

This is the part people brace for, so let us make it concrete instead of scary. C+ has no &T and no &mut T. Instead, every parameter says how it wants the value, right in the signature.

The default is move. When you pass a value that owns something, the callee takes it, and the caller can no longer use it:

struct Token { id: i32 }
impl Token {
    fn drop(mut self) { return; }    // defining drop makes Token non-Copy
}

fn consume(t: Token) { return; }

fn main() -> i32 {
    let t: Token = Token { id: 7 };
    consume(t);            // t moves into consume
    println(t.id);         // using t after it moved
    return 0;
}

Try to build that and C+ stops you cold:

d.cplus:9:13: error[E0335]: use of moved value `t`
  |     println(t.id);

This is the audit loop doing its job. You did not ship a program with a dangling value and debug it later. The compiler named the exact line where you used something you had already given away. Once you have seen E0335 a few times, you read it in a second and move on.

The fix depends on what you meant. If consume should not have taken ownership in the first place, say so with borrow. A borrowed parameter lets the callee read the value while the caller keeps it:

struct Token { id: i32 }
impl Token {
    fn drop(mut self) { return; }
}

fn peek(borrow t: Token) -> i32 { return t.id; }

fn main() -> i32 {
    let t: Token = Token { id: 7 };
    let x: i32 = peek(t);     // borrow: caller keeps t
    println(x);               // 7
    println(t.id);            // 7, still valid
    return 0;
}

Now it builds and prints 7 twice. The whole ownership model is three markers you put on parameters: nothing means move, borrow means read and give back, mut means change in place. There is no separate reference type to thread through your code, and the rule the compiler enforces is simply this: a value, a borrow, or a pointer must not outlive the thing it points into. You declare intent once, at the boundary, and read it off the signature everywhere else.

Why did Token move but the earlier Counter copied freely? Because Token defines a drop. A type that owns a resource cannot be silently duplicated, so defining drop makes the type move-only. A plain struct of numbers like Counter has no such concern and copies. The compiler decides this from the type; you never annotate it.

Cleanup that always runs

A drop method is a destructor: it runs automatically when the value goes out of scope. Pair it with defer, which schedules any statement to run at scope exit, and you get cleanup you can actually trust. Both share one stack and unwind last-in-first-out:

struct Guard { id: i32 }
impl Guard {
    fn new(id: i32) -> Guard { return Guard { id: id }; }
    fn drop(mut self) { println(self.id); }
}

fn main() -> i32 {
    let a: Guard = Guard::new(1);
    let b: Guard = Guard::new(2);
    defer println(3);
    return 0;
}

This prints:

3
2
1

Read the order back: you set up a, then b, then deferred 3. At scope exit they pop in reverse, so 3 runs first, then b drops, then a. No garbage collector decides when this happens, and there is no finally block to remember. Whatever you open, you can release right next to where you opened it, and know it runs in the exact reverse order on every path out of the scope.

Errors are values, not explosions

C+ has no exceptions, no try, no catch, no hidden control flow. A function that can fail says so in its return type, using an enum, and the caller has to deal with each case. Here is a classifier:

enum ParseResult { Ok(i32), BadInput, Overflow }

fn classify(n: i32) -> ParseResult {
    if n < 0 { return ParseResult::BadInput; }
    if n > 1000 { return ParseResult::Overflow; }
    return ParseResult::Ok(n);
}

The readable way to consume that is guard let: pull out the happy-path value, or bail. The else block must leave the function (with return, break, or continue), so the value you unpacked is guaranteed to exist on every line after it:

fn handle(n: i32) -> i32 {
    guard let ParseResult::Ok(v) = classify(n) else { return 0 -% 1; };
    return v +% 100;        // v is in scope and valid here
}

fn main() -> i32 {
    println(handle(5));         // 105
    println(handle(0 -% 3));    // -1
    return 0;
}

handle(5) prints 105; handle(-3) hits the BadInput case, takes the else, and prints -1. The error never travels invisibly up the stack looking for a handler. It is a value the function returned, and the only way past it is to handle it. When you read handle, you can see every outcome it has, which is the whole point.

What you can build now

You can model data, decide who owns it, clean up deterministically, and handle failure as ordinary values. That is most of a real program. The pieces stay legible because each one is declared where it happens: mutation on the receiver, ownership on the parameter, cleanup in drop and defer, failure in the return type.

And the audit loop got deeper without getting harder. The borrow checker's E0335 and friends are not hurdles; they are the compiler catching, before anything runs, the exact mistakes that are hardest to find later, whether you wrote the line or a model did.

Where to go next


‹ Back to all guides