Pointers and Ownership

Who keeps a document alive?

A drawing application opens three sketches, one after another, and the user closes each. The program below compiles, runs and exits normally. It also ends with three documents that no part of the program can reach or release. The counter proves it: main returns 0 only because three documents are still alive.

#include <string>
int live_documents = 0;
struct Document {
    std::string title;
    explicit Document(const std::string& t) : title(t) {
        ++live_documents;
    }
    ~Document() { --live_documents; }
};
void open_and_close(const std::string& title) {
    Document* doc = new Document(title);
    doc->title += " (edited)";
}   // doc, the pointer, ends here. The Document does not.
int main() {
    for (int i = 0; i < 3; ++i) open_and_close("sketch");
    return live_documents == 3 ? 0 : 1;
}

Nothing here is undefined, and nothing crashes. The defect is a missing answer to a question the code never asked: who is responsible for ending each document? The line with new, explained below, makes an object whose lifetime no block ends. In the real application the question is harder. The workspace owns the document, a toolbar edits it, and a status panel displays its title. All three can reach the same object, but that does not mean all three should destroy it. If closing the toolbar destroys the document, the workspace holds a dangling address. If nobody destroys it, every opened document leaks, as above.

A pointer answers where an object is. Ownership answers who keeps it alive and who eventually releases it. The questions are related, but they are not identical. This lesson uses a document and its observers to separate them. The lab uses different objects and an ownership graph. You will need references, structs and the automatic lifetime rules from module 7.

The goal is to read a piece of C++ and explain its lifetime contract. Memorizing the syntax of * and & is insufficient. A pointer can be non-null and still be invalid. A reference can outlive the object it names. A smart pointer can own an object correctly while a separate borrowed pointer has become stale.

An address is a value

For an object document, the expression &document obtains its address. A variable of type Document* can hold that address. Following a valid pointer accesses the object; for a class member, p->title is the usual notation. A pointer variable is itself an object, with its own lifetime and address, distinct from the pointed-to document.

A reference is bound during initialization and does not reseat. Assigning through a reference changes its referent; it does not make the reference name a different object. A pointer can be reassigned and can represent absence with nullptr. That makes pointers suitable for optional access, but creates another state an interface must handle.

#include <string>
struct Document { std::string title; int edits; };
int main() {
    Document doc{"rough sketch", 0};
    Document* selected = &doc;
    selected->title = "final sketch";
    selected->edits += 1;
    Document& same = doc;
    return same.edits == 1 && doc.title == "final sketch" ? 0 : 1;
}

Here the local doc object owns its string member. The raw pointer and reference borrow the document. Neither independently owns or extends its lifetime. The last expression runs while doc is alive; moving that expression outside the function through a returned address would change the analysis completely.

Null is not a lifetime certificate

An optional pointer interface should state what absence means. Perhaps there is no selected document, so a toolbar command does nothing and returns false. Check for null before accessing a member. The check makes the absent case safe; it does not prove that a non-null address still names a live document.

Does setting one pointer to nullptr clear every pointer to the same object?

No. Each pointer is a separate value. Other aliases retain their addresses. Ending the pointed-to object's lifetime also does not automatically clear borrowed pointers.

Dereferencing null, accessing an object after its lifetime has ended, and releasing the same dynamic object twice (a double delete, explained below) are undefined behavior. C++ does not promise a helpful error, a crash, or stale bytes. The compiler may optimize under the assumption that these operations never happen. What one browser build happened to display is not a rule of the language.

Our memory panel records what the learner's instrumentation reports. It is not AddressSanitizer. Do not intentionally execute a double delete to learn from its output: the execution has already lost its defined meaning. Study the mistake on paper instead, by counting: each allocation creates one release obligation, and a second release has no obligation left to discharge. On a native machine, sanitizers can help diagnose many invalid accesses; they do not change the source program's lifetime contract.

Draw responsibility separately from access

There are two kinds of arrow in a document system. An owning arrow means the source keeps the target alive. A borrowed arrow means the source can use the target while some other owner keeps it alive. A useful design diagram labels the difference rather than drawing all arrows alike, as the figure below does.

owns borrows borrows workspace document toolbar status
Three arrows reach the document, and each is labelled with its role. Only the workspace's arrow keeps the document alive; the toolbar and the status panel borrow.

Suppose closing the workspace destroys its document. Any borrowed toolbar pointer must stop being used before that event, or be cleared as part of the application protocol. The borrow is safe because of an ordering guarantee, not because raw pointers are automatically notified. A diagram should make the guarantee explainable in one sentence.

When several owners genuinely need independently to keep an object alive, the contract changes. The object ends when the last strong owner ends. That is a shared lifetime, not merely several parts reading the same object. Start with exclusive ownership and add shared ownership only when the application's lifetime needs it.

Dynamic lifetime: new and delete

Every object so far had automatic lifetime: it ended when its block ended. The expression new Draft("poster") creates an object with dynamic lifetime instead. The object lives in storage obtained at run time, informally called the heap, and the expression's value is the object's address. No block ends that lifetime. It ends when delete is applied to that address: delete runs the destructor, then gives the storage back.

So each new creates exactly one obligation: one delete, through a pointer that still holds the address. Zero deletes is a leak: the object and its storage stay until the program ends, as in the opening program, whose only copy of each address vanished with doc. A second delete of the same object is undefined behavior. And delete does not set the pointer it was given to null, and it cannot reach any other copy of the address; all of them now hold an invalid value, not a null one.

#include <string>
int live = 0;
struct Draft {
    std::string title;
    explicit Draft(const std::string& t) : title(t) { ++live; }
    ~Draft() { --live; }
};
int main() {
    Draft* draft = new Draft("poster");
    bool one_alive = live == 1;
    delete draft;      // destructor runs, storage is released
    draft = nullptr;   // this copy now says "no object"
    return one_alive && live == 0 && draft == nullptr ? 0 : 1;
}

The obvious repair for the opening program is one more line, delete doc;, at the end of open_and_close. It works until the function gains an early return between the new and the delete, or until a second pointer to the document is still in use somewhere else. A hand-written release must be correct on every path and in every part that holds the address. Module 7 already showed the cure for the first half of that: let an object's destructor do the releasing. The rest of this lesson is about objects whose whole job is that.

Exclusive ownership

std::unique_ptr<T> represents one owning handle to a dynamically managed object. When the handle is destroyed, reset, or assigned a different object, it releases the object it owned by calling its deleter: the operation the handle was told to release with, which is delete unless you supply another. A default-constructed std::unique_ptr is empty: it owns nothing, and compares equal to nullptr. It cannot be copied, because a second exclusive owner would contradict its contract.

std::make_unique<T>(arguments) creates the object and its owner in one expression, so no bare new result exists that a later line could forget to release. Transfer responsibility by moving the unique pointer into a destination. After that transfer the source unique pointer is empty. You can still destroy it, assign a new owner to it, or test it; you cannot follow it to the previous object.

#include <memory>
#include <string>
#include <utility>
struct Draft { std::string title; };
int main() {
    auto editor = std::make_unique<Draft>(Draft{"poster"});
    Draft* borrowed = editor.get();
    auto archive = std::move(editor);
    bool stable = !editor && borrowed == archive.get();
    return stable && archive->title == "poster" ? 0 : 1;
}

Moving this owning handle changes who owns the draft, but does not relocate the draft object. The borrowed address remains valid here because the new owner still keeps the same object alive. Resetting archive would end that guarantee. Moving the draft object itself would be a different operation.

Calling get() borrows an address. Calling release() relinquishes ownership and returns an address without destroying the object. These are very different operations. Using release() merely to pass a borrowed argument creates a leak unless another responsible owner immediately adopts the result. A function that only observes an object should not need the caller to relinquish responsibility.

The lifetime invariant

Access requires a live object

Every use through a borrowed address occurs while a responsible owner keeps that object alive. Exclusive ownership transfers responsibility without duplicating it.

An exclusive owner establishes responsibility when it receives a valid object. Moving transfers that responsibility. Destruction consumes it. Borrowing does not alter the ownership count. To justify a borrowed access, identify the owner and show that the owner cannot reset or end before the access finishes.

This reasoning extends to functions. A parameter const Document& normally expresses a required borrow for the call. A parameter Document* can express an optional borrow if the interface permits null. A parameter std::unique_ptr<Document> by value can express taking ownership. The signature helps, but documentation still matters: raw pointers are used in both owning and nonowning legacy interfaces.

Returning a pointer to an automatic local object fails the invariant. The local ends during the return, leaving the returned pointer without a live referent. Returning a unique owner transfers responsibility instead. Returning a reference to an object owned elsewhere can be safe only if the caller understands that external lifetime.

Shared ownership has a cost and a purpose

std::shared_ptr keeps an object alive while at least one strong owner remains. Copying a shared pointer adds an owner; destroying or resetting one removes an owner. The count of strong owners lives in a control block: a small bookkeeping record, created with the first shared owner, that holds the strong count, a count of weak observers, and the deleter. use_count() reports the strong count. The managed object is destroyed when the strong count reaches zero. The control block itself can remain after that while weak observers exist, because they still need somewhere to read "expired" from.

This is useful when ownership cannot be expressed by a simple enclosing scope. For example, a document can remain alive for a background export after its editor closes. It is not needed merely because several functions receive const references. Those functions can borrow while the caller owns the document.

The reference count is a lifetime mechanism, not general synchronization for the document's mutable fields. Shared pointer bookkeeping does not make concurrent edits race-free. Likewise, a displayed use_count() is an observation, not a reliable permission check for modifying a shared object. Another owner can appear between observing a count and acting on it.

Two shared pointers that are each constructed from the same raw address get two separate control blocks, each with a strong count of one. Each will release the object when its own count reaches zero, and the second release is undefined behavior. To add an owner, copy an existing std::shared_ptr. An object that must hand out owners of itself can derive from std::enable_shared_from_this. A raw address alone does not carry the ownership relationship.

The cycle nobody releases

Suppose a document strongly owns a history entry, and the history entry strongly owns the document. Closing the workspace removes one owner, but each object still has a strong owner supplied by the other. The counts never reach zero. This is a cycle of responsibilities, not a missing destructor body.

A back-reference can use std::weak_ptr when it should observe without keeping the object alive. A weak pointer does not contribute to the strong count. Before using it, call lock() and test the returned shared pointer. If successful, that returned owner keeps the object alive for the duration of the use.

Can you test expired(), then safely use an old borrowed address?

Not in general. expired() reports one instant. If any code that runs between the test and the use, or another thread, can release the last strong owner, the address is stale by the time you follow it. Only when nothing at all can intervene does the test settle the matter, and that is a property of the whole program, not of the two lines. A successful lock() obtains a strong owner instead; keep that result while accessing the object.

Replacing an owning back-reference with an observing one breaks the responsibility cycle. It does not automatically repair every application relationship. Decide which direction actually needs to keep the other alive. If both directions independently require ownership, the model needs a different external owner or an explicit lifecycle protocol.

Count owners and reason about cost

For a single-threaded teaching graph, count strong owning edges and external owners. Borrowed and weak edges add zero to the strong count. The following ledger computes the document's lifetime on one concrete sequence; it does not claim to simulate every internal detail of shared_ptr.

strong = 1             # workspace owns the document
strong += 1           # exporter takes a strong owner
assert strong == 2
strong -= 1           # workspace closes
assert strong == 1    # exporter still keeps it alive
strong -= 1           # exporter finishes
assert strong == 0    # no strong owner remains

In a model with constant-time pointer and counter operations, moving an exclusive handle and copying a shared handle use constant bookkeeping work. The last release can perform arbitrary object destruction, including destruction of other owned objects. Allocation, deallocation and thread synchronization have real costs that this simple model leaves out.

There is also a design cost. Exclusive ownership makes responsibility local and visible. Shared ownership permits more flexible lifetimes but makes the final destruction point depend on all owners. Borrowing avoids ownership bookkeeping but requires a lifetime guarantee. Choose by the responsibility the program needs, then consider performance.

A problem that looks different

A monitoring dashboard displays a temporary alert generated by a service. The alert should disappear when the service no longer retains it, unless a user has deliberately opened a detailed report that needs to finish. Which links should preserve the alert, and which should merely observe whether it still exists? Explain the ownership choices before choosing pointer types.

Practise

The lab distinguishes an absent pointer from a valid borrow, releases explicit allocations while counters watch the storage, transfers an exclusive owner, and repairs a strong-reference cycle. Its final challenge describes a small system in plain words and leaves the choice of members, and of who finishes what, to you. The memory view and timeline show instrumented behavior; they do not certify the absence of every possible memory bug.

Recap

You can now: distinguish access from ownership, pair each new with one release, transfer an exclusive owner, and acquire temporary ownership from a weak observer.

Invariant: a borrow is used only while the object remains alive; ownership transitions follow the interface's exclusive or shared policy.

Complexity achieved: constant handle bookkeeping in the stated model; destruction cost belongs to the objects actually released.

Failure mode: a dynamic object nobody releases leaks, a non-null pointer can dangle, independently adopting the same address can double-own it, and strong cycles keep each other alive.

In real software: smart pointers implement lifetime responsibilities; sanitizers diagnose many invalid accesses, and separate synchronization protects shared mutable state.

Retrieval: from module 7: why does an automatic owner clean up on a normal early return?

Check yourself

  1. After a std::unique_ptr is moved, the managed object stays at the same address. What now decides how long an address borrowed earlier stays valid?
  2. What does a successful weak-pointer lock guarantee for the resulting handle's lifetime?
  3. Why is a cycle a problem for strong ownership even if every destructor is correct?

Original SciMigo course material. C++ examples target C++17.

Preparing the guided lab…