CodeGym /Courses /C++ SELF /Passing std::unique_ptr to functions

Passing std::unique_ptr to functions

C++ SELF
Level 43, Lesson 0
Available

1. Introduction

When you start writing functions, it's tempting to have everything look the same: "pass an argument — the function does work — returns a result". But with std::unique_ptr there's a nuance: it is not just a "pointer", it is a ownership contract. And a contract is strict: if two participants both think they "own the same thing", sooner or later double free (or some other fun) will happen.

It's important to mentally accept one idea: the parameter type should tell a story. Not in a comment "// ownership here", but right in the signature. In good C++ code you can often understand "who owns the object after the call" just by looking at the parameter type. This is not aesthetics — it's a way to avoid bugs like "crashes once a week on tester Pete's machine".

By the way, requirements for unique_ptr operations (like correct reset() and postconditions of move-assignment) are formalized carefully in the standard library — down to specific notes and wording fixes.

2. Pass by value: function takes ownership

When you see a by-value parameter of type std::unique_ptr<T>, it almost always means: the function becomes the owner of the resource. That is, after the call the caller will no longer own it (they "handed over the keys"), and inside the function the resource will be destroyed automatically at scope exit — unless you pass it further on.

This is a powerful and very honest contract: the signature literally says "I take ownership". And a nice bonus: you cannot accidentally forget that ownership moved, because the compiler will force you to write std::move(...) at the call site.

Mini example: "consume" an object

In our educational mini-app we'll have a simple entity Note and an "active note" owned by the application.

#include <iostream>
#include <memory>
#include <string>
#include <utility>

struct Note {
    std::string title;
};

void consume_note(std::unique_ptr<Note> note) {
    if (note) {
        std::cout << "Consumed: " << note->title << '\n'; // Consumed: Buy milk
    }
} // note will be destroyed here automatically

int main() {
    auto n = std::make_unique<Note>(Note{"Buy milk"});
    consume_note(std::move(n));

    std::cout << std::boolalpha << (n == nullptr) << '\n'; // true
}

Note the "ritual" std::move(n). It looks like a "magic word", but the meaning is human: "I hand over this ownership". Without std::move the compiler won't let you — and that's great.

When this is appropriate

Passing a unique_ptr by value is convenient when the function really should become the owner, for example: save the object somewhere, push it to a queue, add to a container, "take it into storage".

Imagine we have a notes storage std::vector<std::unique_ptr<Note>>. Then a function "add note to the database" logically takes ownership.

#include <memory>
#include <string>
#include <utility>
#include <vector>

struct Note {
    std::string title;
};

void add_note(std::vector<std::unique_ptr<Note>>& notes,
              std::unique_ptr<Note> note) {
    notes.push_back(std::move(note));
}

int main() {
    std::vector<std::unique_ptr<Note>> notes;

    auto n = std::make_unique<Note>(Note{"Read C++ book"});
    add_note(notes, std::move(n));
}

A neat point here: add_note takes ownership, and the container then owns the object.

A small but important readability note

If a function takes std::unique_ptr<T> by value, as a reader of the code you should expect that:

  • the object may be destroyed inside the function;
  • the object may be passed further (one more std::move);
  • the caller should assume that after the call "they no longer have it".

So it's like handing a coat to the cloakroom: you get a ticket, but the coat is no longer with you.

3. Pass by reference: function manipulates the owner

Sometimes the function should not become the permanent owner, but it needs to change the caller's owner state. For example, replace the current active note with a new one, clear the note (make it empty), or "initialize if empty".

For this the parameter is made a reference to unique_ptr: std::unique_ptr<T>&.

It's important to catch the meaning: the reference here is not to T, but to the owner object (unique_ptr). So the function does not "take the note in its arms", it "reaches into your cloakroom ticket and changes what is there".

Mini example: set active note

#include <memory>
#include <string>
#include <utility>

struct Note {
    std::string title;
};

void set_active(std::unique_ptr<Note>& active, std::unique_ptr<Note> next) {
    active = std::move(next); // ownership moves into active
}

int main() {
    std::unique_ptr<Note> active;

    auto n = std::make_unique<Note>(Note{"Plan weekend"});
    set_active(active, std::move(n));
}

Here the balance matters: the parameter next by value means "I give the note", and active by reference means "change my owner".

When this is appropriate

Passing std::unique_ptr<T>& is appropriate when the function should:

  • replace the owner's object;
  • "fill" it if it was empty;
  • forward ownership from that owner (for example, extract the active object and move it to storage — but that is very close to "extract" and requires care).

Later (in a companion lecture) you'll examine get()/reset()/release()/swap() operations. There it becomes especially clear how easy it is to "cut yourself" at the ownership boundary if you treat unique_ptr like a regular pointer.

4. Observe, but do not own: const T& and const T*

When you write a function "print the note", "compute the title length", "check that the title is not empty" — the function should not own the object. It only needs to use the object.

And here typical beginner mistakes begin: they drag std::unique_ptr<T> into parameters because "we use unique_ptr everywhere now". As a result the function unexpectedly requires std::move and "takes the note", while you only wanted to read fields. It's like asking a friend for a pen "for a second", and then walking off with it because formally they gifted it to you.

The correct way to "just read": const Note&

If the object must exist, take const T&.

#include <iostream>
#include <string>

struct Note {
    std::string title;
};

void print_note(const Note& note) {
    std::cout << note.title << '\n';
}

int main() {
    Note n{"Hello"};
    print_note(n); // Hello
}

The reference says: "the object exists", const says: "I won't modify it".

If the object may be absent: const Note*

If a note may be "not selected" (i.e. the unique_ptr is empty), it's more convenient to express this via a pointer const T*:

#include <iostream>
#include <string>

struct Note {
    std::string title;
};

void print_note_ptr(const Note* note) {
    if (note) {
        std::cout << note->title << '\n';
    } else {
        std::cout << "(no active note)\n"; // (no active note)
    }
}

Here the contract is even more honest: nullptr means "no object".

How to call if we have a unique_ptr

If you have std::unique_ptr<Note> active;, you can pass an observer without touching ownership.

Minimally it looks like this (you will study the get() operation in detail later; for now treat it as "give the address"):

#include <memory>

void print_note_ptr(const Note* note);

int main() {
    std::unique_ptr<Note> active;
    print_note_ptr(active.get()); // either an address or nullptr
}

The main idea: observation is separated from ownership. unique_ptr is about ownership. T*/const T* and T&/const T& are about usage.

Rare but useful case: const std::unique_ptr<T>&

Sometimes a function should "look" not only at the object itself, but at the owner state: whether it's empty, what its deleter is (later), or you simply don't want to expose a T*/T& externally (for example, you write a utility to diagnose the owner).

Then you can accept const std::unique_ptr<T>&. That means: "I don't take ownership, I don't modify the owner, I just look at it".

#include <iostream>
#include <memory>

struct Note {
    int id{};
};

void debug_owner(const std::unique_ptr<Note>& p) {
    std::cout << std::boolalpha << static_cast<bool>(p) << '\n';
}

int main() {
    std::unique_ptr<Note> p;
    debug_owner(p); // false
}

Keep in mind a practical rule: if you want to work with the object itself, it's usually clearer and simpler to use const T& or const T*. const unique_ptr<T>& means "I want to talk specifically about the owner".

5. Decision criteria: what to pass as a parameter

When choosing a signature, it's useful to ask yourself one question: what will happen to ownership before and after the call? If you can answer that in plain words, the signature usually follows almost automatically.

Below is a decision table. Keep it in mind as a cheat sheet until the habit forms.

Function intent (semantic) Parameter signature What it means "in plain English"
The function takes ownership and will decide the resource fate
std::unique_ptr<T>
"Give me the object, you won't have it anymore"
The function may replace the owner's object (manipulates the owner)
std::unique_ptr<T>&
"Give me access to your owner, I may change what it owns"
The function just reads the object, the object must exist
const T&
"I'll look but not modify; the object must exist"
The function reads the object, but the object may be absent
const T*
"I'll look if it's there; otherwise I'll cope"
The function observes the owner itself (check emptiness, etc.)
const std::unique_ptr<T>&
"I read the owner state but won't touch ownership"

Note that in this table "by value or by reference" is only part of the decision. Often the correct answer is simply "not at all: unique_ptr is not needed here, take const T&".

6. Practical mini-code: active note and archive

Now let's assemble a small piece of code that demonstrates all three roles: create a note, set active, print active, add to archive. It's not a full application, but it's a good skeleton that's easy to extend.

Model and functions

#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include <vector>

struct Note {
    std::string title;
};

std::unique_ptr<Note> make_note(std::string title) {
    return std::make_unique<Note>(Note{std::move(title)});
}

void set_active(std::unique_ptr<Note>& active, std::unique_ptr<Note> next) {
    active = std::move(next);
}

void print_note_ptr(const Note* note) {
    if (note) {
        std::cout << "Active: " << note->title << '\n';
    } else {
        std::cout << "Active: (none)\n";
    }
}

void archive(std::vector<std::unique_ptr<Note>>& storage, std::unique_ptr<Note> note) {
    if (note) {
        storage.push_back(std::move(note));
    }
}

int main() {
    std::vector<std::unique_ptr<Note>> archive_storage;
    std::unique_ptr<Note> active;

    print_note_ptr(active.get());                 // Active: (none)

    auto n = make_note("Buy milk");
    set_active(active, std::move(n));

    print_note_ptr(active.get());                 // Active: Buy milk

    archive(archive_storage, std::move(active));  // active note moved to archive
    print_note_ptr(active.get());                 // Active: (none)
}

Here the whole "ownership drama" is visible without a single comment about ownership — because types and std::move make it explicit. That's good style: less hidden magic, more transparent agreements.

7. Typical mistakes

Mistake #1: accepting std::unique_ptr<T> by value where you only wanted to "just read".
This is classic: you write void print(std::unique_ptr<Note> n), then wonder why std::move is required and why the note disappeared after printing. This is not a "C++ quirk", it's your contract: a by-value parameter for unique_ptr means "give ownership". For printing it's almost always better to use const Note& (if the object must exist) or const Note* (if it may be absent).

Mistake #2: trying to call a "consuming" function without std::move.
A beginner sees consume(p) and thinks: "I'm just passing the variable". But unique_ptr is not copyable, and passing by value requires a copy or a move. If the function takes std::unique_ptr<T>, the call must look like consume(std::move(p)). If you don't like std::move — you probably don't want to give ownership and should change the signature.

Mistake #3: using an object after std::move as if nothing happened.
After set_active(active, std::move(n)) the variable n usually becomes empty. It's valid, you can reassign it, compare it with nullptr, but you must not dereference it without checking. This is a common cause of "it worked before, then it crashed".

Mistake #4: "helping" by making a manual copy of unique_ptr via a raw pointer.
Sometimes a student does something terrible: takes T* raw = p.get(); (or worse — obtains a T* somewhere) and then creates a second std::unique_ptr<T> from the same address. This breaks the whole idea of "single owner": two owners will try to free the same resource. If you need shared ownership — use a different tool; here the rule is simple: one resource — one unique_ptr.

Mistake #5: accepting std::unique_ptr<T>& when you actually wanted to "take ownership".
The reverse situation: you want the function to take the object "for good", but you write foo(std::unique_ptr<T>&). Then the caller is not required to do std::move, and the call site does not make it obvious that ownership will move. As a result the code becomes less honest. If the function should be the owner, accept std::unique_ptr<T> by value: that forces the caller to explicitly confirm the transfer of ownership.

1
Task
C++ SELF, level 43, lesson 0
Locked
Parcel for the courier
Parcel for the courier
1
Task
C++ SELF, level 43, lesson 0
Locked
Active session
Active session
1
Task
C++ SELF, level 43, lesson 0
Locked
Note without transferring ownership
Note without transferring ownership
1
Task
C++ SELF, level 43, lesson 0
Locked
Job queue
Job queue
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION