Beyong New and Delete: from auto_ptr to unique_ptr

This guide walks through a legacy pattern built around std::auto_ptr, something still found in many older C++ codebases. The pattern usually involves a helper like cdup that deep‑copies an object and returns it as an auto_ptr, and classes that store these copies and access them through get() checks and raw pointer calls. It worked in its time, but it no longer fits modern C++ and creates unnecessary ownership confusion.

Here, we break down how the pattern works, why it causes problems, and how to refactor it step by step using std::unique_ptr. The goal is not just to replace one smart pointer with another, but to make ownership explicit, improve readability, and align the code with modern C++ practices.

The Legacy Pattern

A typical example looks like this: a cdup utility that deep‑copies an object into an auto_ptr, and a class that stores those copies and uses get() for access and null checks.

namespace sys_Util {
    template <typename T>
    inline std::auto_ptr<T> cdup(T* t) {
        return t ? std::auto_ptr<T>(new T(*t))
                 : std::auto_ptr<T>(0);
    }
}

class WidgetOwner {
    std::auto_ptr<Widget> m_widget;
public:
    explicit WidgetOwner(Widget* w)
        : m_widget(sys_Util::cdup(w)) {}

    void process() {
        if (m_widget.get()) {
            m_widget.get()->doWork();
        }
    }

    Widget* getWidget() { return m_widget.get(); }
};

At first glance, this seems harmless. But once you look closer, the cracks show.

Evaluation of the Pattern

The pattern deep‑copies the object, wraps it in auto_ptr, and uses defensive get() checks before access.

The Problems of the Pattern

  1. auto_ptr is deprecated and removed

    It was deprecated in C++11 and removed entirely in C++17, so the code simply won’t compile on modern standards.

  2. Broken copy semantics

    Copying an auto_ptr silently transfers ownership, leaving the source null. This is one of the most dangerous behaviors in old C++ codebases.

std::auto_ptr<Widget> a(new Widget);
std::auto_ptr<Widget> b = a;  // a becomes null! (silent move)
  1. Confusing ownership
    Passing a raw pointer to the constructor looks like ownership transfer, but the class actually makes a deep copy. The caller must still delete the original pointer, which is easy to forget.
WidgetOwner owner(raw);  // Does it take ownership? No, it copies.
delete raw;              // Must remember to delete (if not leaked)
  1. Clunky access syntax
    m_widget.get()->method() is verbose and error‑prone compared to modern pointer semantics.
m_widget.get()->doWork();     // Too verbose
(*m_widget.get()).property;   // Ugly
  1. Awkward null checks

    Every access requires if (ptr.get()), which is harder to read than necessary.

  2. Raw pointer exposure

    Returning T* invites accidental deletion or dangling references.

Widget* getWidget() { return m_widget.get(); }
// Caller might delete it, or hold it past owner lifetime
  1. Exception safety issues
    new T(*t) can leak if the copy constructor throws an exception.
new T(*t)  // If T(const T&) throws, memory leaks

Thread Safety of the Pattern

No synchronization, no atomicity — not safe for concurrent access. Copy operation on auto_ptr are not atomic.

Memory Safety of the Pattern

The pattern itself doesn’t leak, but raw pointer containers or circular references with auto_ptr can cause trouble.

Refactoring: Moving to unique_ptr

We keep the original behavior: the pointer may be null, and deep copies must still work. But we want clearer ownership and modern syntax.

Step 1: Replace auto_ptr with unique_ptr

std::unique_ptr<Widget> m_widget;

This immediately removes silent moves and clarifies ownership.

Step 2: Remove the cdup Utility

Instead of a custom deep‑copy helper, use std::make_unique directly:

auto copy = original
    ? std::make_unique<Widget>(*original)
    : nullptr;

This eliminates an entire utility and makes intent clearer .

Step 3: Fix the Constructor

The old constructor took a raw pointer and deep‑copied it, which was ambiguous.

We replace it with two explicit options: copy or move (take ownership).

explicit WidgetOwner(const Widget& w)
    : m_widget(std::make_unique<Widget>(w)) {}

explicit WidgetOwner(std::unique_ptr<Widget> w)
    : m_widget(std::move(w)) {}

Null handling becomes explicit and predictable.

Step 4: Add Natural Access Operators

To avoid get() everywhere, we add operator->, operator*, and operator bool.

Widget* operator->() { return m_widget.get(); }
Widget& operator*()  { return *m_widget; }
explicit operator bool() const { return m_widget != nullptr; }

This makes usage clean and modern:

if (m_widget) {
    m_widget->doWork();
}

Step 5: Improve Null Check Syntax

Instead of:

if (m_widget.get())

We now simply write:

if (m_widget)

Or use a guard clause:

if (!m_widget) return;

Step 6: Fix Raw Pointer Exposure

Returning raw pointers is risky. We replace it with safer alternatives:

Widget& getWidget() {
    if (!m_widget) throw std::runtime_error("Widget is null");
    return *m_widget;
}

Or, if needed:

Widget* getWidget() { return m_widget.get(); }  // Document: can be null

Complete Refactored Example

Before: Legacy Code

// sys_util.h
namespace sys_Util {
    template<typename T>
    inline std::auto_ptr<T> cdup(T* t) {
        return t ? std::auto_ptr<T>(new T(*t)) : std::auto_ptr<T>(0);
    }
}

// database_handler.h
class DatabaseHandler {
    std::auto_ptr<Database> m_db;
    std::auto_ptr<Logger> m_logger;

public:
    DatabaseHandler(Database* db, Logger* logger) 
        : m_db(sys_Util::cdup(db))
        , m_logger(sys_Util::cdup(logger)) {}

    void query(const std::string& sql) {
        if (m_db.get()) {
            m_db.get()->execute(sql);
        }
        if (m_logger.get()) {
            m_logger.get()->log(sql);
        }
    }

    Database* getDatabase() { return m_db.get(); }
    Logger* getLogger() { return m_logger.get(); }
};

// main.cpp
Database* db = new Database("localhost:5432");
Logger* logger = new Logger("app.log");
DatabaseHandler handler(db, logger);
handler.query("SELECT * FROM users");
delete db;      // Confusing - does handler own this?
delete logger;  // Did we just double delete?

After: Modern Code

// sys_util.h - DELETED (no longer needed)

// database_handler.h
class DatabaseHandler {
    std::unique_ptr<Database> m_db;
    std::unique_ptr<Logger> m_logger;

public:
    // Clear: we make copies
    explicit DatabaseHandler(const Database& db, const Logger& logger) 
        : m_db(std::make_unique<Database>(db))
        , m_logger(std::make_unique<Logger>(logger)) {}

    // Clear: we take ownership (handles null case)
    explicit DatabaseHandler(std::unique_ptr<Database> db, std::unique_ptr<Logger> logger) 
        : m_db(std::move(db))
        , m_logger(std::move(logger)) {}

    void query(const std::string& sql) {
        if (m_db) {          // Clean bool check
            m_db->execute(sql);  // Natural syntax
        }
        if (m_logger) {
            m_logger->log(sql);
        }
    }

    // Safe access with reference
    Database& getDatabase() {
        if (!m_db) throw std::runtime_error("Database not initialized");
        return *m_db;
    }

    // Or optional access
    Database* getDatabaseOrNull() { return m_db.get(); }

    // Natural operators for direct use
    explicit operator bool() const { return m_db && m_logger; }
    Database* operator->() { return m_db.get(); }
};

// main.cpp - clear ownership
Database db("localhost:5432");     // Stack object
Logger logger("app.log");           // Stack object
DatabaseHandler handler(db, logger); // Clear: we copy
handler.query("SELECT * FROM users");
// No confusion - everything automatically cleaned up

// Handling null case
std::unique_ptr<Database> maybe_db = getDatabaseOrNull();  // Could be null
std::unique_ptr<Logger> maybe_logger = getLoggerOrNull();
DatabaseHandler nullable_handler(std::move(maybe_db), std::move(maybe_logger));
if (nullable_handler) {
    nullable_handler.query("SELECT * FROM users");
}

Migration Summary Table

A concise comparison of old vs. new patterns can be found here.
| Aspect | Before (auto_ptr) | After (unique_ptr) |
|——–|——————–|———————|
| Smart pointer | std::auto_ptr<T> | std::unique_ptr<T> |
| Copy utility | sys_Util::cdup(t) | std::make_unique<T>(*t) |
| Null check | if (ptr.get()) | if (ptr) |
| Member access | ptr.get()->method() | ptr->method() |
| Dereference | *ptr.get() | *ptr |
| Constructor | explicit A(T* t) | explicit A(const T& t) or explicit A(std::unique_ptr<T> t) |
| Exposure | T* get() (dangerous) | T& get() (safe) or T* get() (documented) |
| Bool check | Manual get() | explicit operator bool() |

Migration Checklist

A practical step‑by‑step list covering mechanical replacement, operator additions, interface cleanup, and correctness verification.

Phase 1: Mechanical Replacement

  • [ ] Replace std::auto_ptr<T> with std::unique_ptr<T>
  • [ ] Replace cdup(ptr) with ptr ? std::make_unique<T>(*ptr) : nullptr
  • [ ] Change raw pointer constructor parameters to const T& or std::unique_ptr<T>
  • [ ] Delete the cdup utility function

Phase 2: Add Convenience Operators

  • [ ] Add operator->() and operator*()
  • [ ] Add explicit operator bool() for null checks
  • [ ] Keep get() temporarily for compatibility

Phase 3: Clean Up Access Patterns

  • [ ] Replace ptr.get()->method() with ptr->method()
  • [ ] Replace if (ptr.get()) with if (ptr)
  • [ ] Replace *ptr.get() with *ptr

Phase 4: Improve Interfaces

  • [ ] Change T* get() to T& get() with null check (if never null)
  • [ ] Or keep T* get() but document null possibility
  • [ ] Consider encapsulation instead of exposing pointers

Phase 5: Verify Correctness

  • [ ] Ensure all null handling preserved
  • [ ] Run tests to verify behavior unchanged
  • [ ] Compile with C++17 or later to confirm no auto_ptr usage

Common Pitfalls and Solutions

Covers the three main issues: missing null checks, losing null checks during migration, and constructors that can’t represent null.

Pitfall 1: Forgetting Null Checks

// Wrong - if m_widget is null, this crashes
m_widget->doWork();

// Right - check first
if (m_widget) m_widget->doWork();

Pitfall 2: Losing the Null Check During Migration

// Before - had check
if (m_widget.get()) { ... }

// After - still need check, just cleaner
if (m_widget) { ... }  // Not removed!

Pitfall 3: Not Handling Null in Constructor

// Wrong - assumes non-null
explicit WidgetOwner(const Widget& w) : m_widget(std::make_unique<Widget>(w)) {}
// Can't represent null owner

// Right - handle null case
explicit WidgetOwner(std::unique_ptr<Widget> w) : m_widget(std::move(w)) {}
WidgetOwner null_owner(nullptr);  // Works

Conclusion

The migration from auto_ptr to unique_ptr is more than a mechanical replacement—it’s an opportunity to:

  1. Clarify ownership – unique_ptr makes exclusive ownership explicit
  2. Improve syntax – Operators provide natural pointer semantics
  3. Enhance safety – No silent moves, clear null handling
  4. Modernize code – Comply with C++11/14/17 standards

The pattern preserves the original behavior (deep copy with optional null) while making the code cleaner, safer, and more maintainable. Every auto_ptr replaced is a step toward modern C++.