Difference in make_shared and normal shared_ptr in C++

Learn difference in make_shared and normal shared_ptr in c++ with practical examples, diagrams, and best practices. Covers c++, c++11, shared-ptr development techniques with visual explanations.

make_shared vs. shared_ptr: Understanding the Nuances in C++

Hero image for Difference in make_shared and normal shared_ptr in C++

Explore the key differences between std::make_shared and direct std::shared_ptr construction in C++11 and beyond, focusing on performance, memory management, and exception safety.

In modern C++, std::shared_ptr is a fundamental tool for managing dynamically allocated memory, preventing memory leaks, and handling object lifetimes in a multi-owner scenario. While you can construct a shared_ptr directly, the C++11 standard introduced std::make_shared as a preferred alternative. This article delves into the distinctions between these two methods, highlighting their implications for performance, memory layout, and exception safety.

The Basics: shared_ptr Construction

Before diving into make_shared, let's briefly review how std::shared_ptr can be constructed directly. A shared_ptr manages a raw pointer and a control block. The control block contains essential information like the reference count and a weak count. When you construct a shared_ptr from a raw pointer, these two components (the object itself and its control block) are typically allocated separately.

#include <memory>
#include <iostream>

class MyClass {
public:
    MyClass() { std::cout << "MyClass constructed!\n"; }
    ~MyClass() { std::cout << "MyClass destructed!\n"; }
    void greet() { std::cout << "Hello from MyClass!\n"; }
};

int main() {
    // Direct shared_ptr construction
    std::shared_ptr<MyClass> ptr1(new MyClass());
    ptr1->greet();
    // ... other operations
    return 0;
}

Direct std::shared_ptr construction from a raw pointer.

Introducing make_shared

std::make_shared is a factory function that creates and returns a std::shared_ptr. Its primary advantage lies in its ability to perform a single memory allocation for both the managed object and its control block. This co-location of data can lead to significant performance benefits and improved memory locality.

#include <memory>
#include <iostream>

class MyClass {
public:
    MyClass() { std::cout << "MyClass constructed!\n"; }
    ~MyClass() { std::cout << "MyClass destructed!\n"; }
    void greet() { std::cout << "Hello from MyClass!\n"; }
};

int main() {
    // Using std::make_shared
    std::shared_ptr<MyClass> ptr2 = std::make_shared<MyClass>();
    ptr2->greet();
    // ... other operations
    return 0;
}

Using std::make_shared for std::shared_ptr creation.

Key Differences and Advantages of make_shared

The choice between make_shared and direct shared_ptr construction boils down to several critical factors:

  1. Single Allocation: make_shared performs a single memory allocation for both the object and its control block. Direct construction, on the other hand, typically involves two separate allocations: one for the object (new MyClass()) and another for the control block by the shared_ptr constructor.
  2. Performance: A single allocation can be faster due to reduced overhead from the memory allocator. Furthermore, co-locating the object and control block can improve cache locality, leading to better runtime performance.
  3. Exception Safety: make_shared offers better exception safety in certain scenarios. Consider a function call like f(shared_ptr<T>(new T()), g()). If new T() succeeds, but g() throws an exception before the shared_ptr constructor is called, the T object allocated by new will leak. make_shared avoids this by ensuring the object and control block are created atomically.
  4. Memory Deallocation: With make_shared, the memory for the object and control block is deallocated together when the last shared_ptr and weak_ptr referencing the control block are destroyed. In direct construction, the object's memory is freed when its reference count drops to zero, but the control block might persist longer if weak_ptrs still exist, leading to potential memory overhead until the last weak_ptr is destroyed.
flowchart TD
    subgraph make_shared
        A[Single Allocation] --> B{Object + Control Block}
    end

    subgraph Direct shared_ptr
        C[Allocation 1] --> D{Object}
        E[Allocation 2] --> F{Control Block}
    end

    B --"Better Cache Locality"--> G[Performance Benefit]
    B --"Atomic Creation"--> H[Exception Safety]
    D --"Separate Lifetime"--> I[Control Block Persists Longer (with weak_ptr)]
    F --"Separate Lifetime"--> I

Comparison of memory allocation and benefits between make_shared and direct shared_ptr.

When Not to Use make_shared

While make_shared is generally preferred, there are a few scenarios where it might not be suitable:

  • Custom Deleters: If you need to provide a custom deleter for your object, make_shared cannot be used directly. You'll need to use the shared_ptr constructor that accepts a custom deleter.
  • Managing Arrays: make_shared is designed for single objects, not C-style arrays. For shared_ptrs managing arrays, you'd typically use std::shared_ptr<T[]>(new T[N]) with a custom deleter or std::unique_ptr.
  • Constructing from an Existing Raw Pointer: If you already have a raw pointer to an object that needs to be managed by a shared_ptr, you must use the shared_ptr constructor. make_shared always allocates a new object.
  • Memory Overhead with weak_ptr: In rare cases where an object is very large and many weak_ptrs outlive all shared_ptrs, the memory for the object (even if destructed) might be held longer by the control block allocated by make_shared. This is because the control block and object are in a single allocation. If the object and control block were separate, the object's memory could be reclaimed sooner.
#include <memory>
#include <iostream>

void customDeleter(int* p) {
    std::cout << "Custom deleter called!\n";
    delete p;
}

int main() {
    // Using custom deleter (cannot use make_shared)
    std::shared_ptr<int> ptr_with_deleter(new int(10), customDeleter);
    std::cout << *ptr_with_deleter << "\n";

    // Managing an existing raw pointer (cannot use make_shared)
    int* raw_ptr = new int(20);
    std::shared_ptr<int> ptr_from_raw(raw_ptr);
    std::cout << *ptr_from_raw << "\n";

    // Example of make_shared for comparison
    std::shared_ptr<int> ptr_make_shared = std::make_shared<int>(30);
    std::cout << *ptr_make_shared << "\n";

    return 0;
}

Scenarios where make_shared is not applicable.