
In the field of C++ development, smart pointers are an extremely important mechanism that greatly enhances the safety and convenience of memory management. Among them, shared_ptr is a key member of the smart pointer family, which uses reference counting to allow multiple pointers to share ownership of the same object. When the last shared_ptr pointing to the object is destroyed, the memory occupied by the object is automatically released, effectively avoiding the risk of memory leaks.
In the ByteDance C++ second interview, candidates are required to implement shared_ptr from scratch. This question aims to deeply assess the interviewee’s understanding and application of core knowledge in C++ memory management, object lifecycle control, template programming, and operator overloading. To successfully complete this challenge, candidates need to have a clear grasp of the underlying operating principles of shared_ptr and be able to present it accurately with rigorous, efficient code that adheres to C++ best practices. Next, let us delve into how to gradually implement a simplified version of shared_ptr.
Part 1What is shared_ptr?
In the world of C++ programming, memory management has always been a topic that developers both love and hate. Manual memory management is like walking a tightrope; a slight misstep can lead to serious issues such as memory leaks and dangling pointers, making programs fragile. To address these tricky problems, C++11 introduced the powerful tool of smart pointers, with std::shared_ptr being one of the best.
std::shared_ptr is a smart pointer provided by the C++ standard library that employs a reference counting mechanism, allowing multiple pointers to share ownership of the same object. This is akin to several friends jointly owning a toy, where each person has a certain right to use it. When the last friend no longer needs the toy (i.e., the reference count is 0), the toy is automatically reclaimed, thus avoiding various troubles that may arise from manual memory management.
For example, in a graphics rendering engine, multiple modules may need to access the same texture resource. By using std::shared_ptr, these modules can share references to the texture resource without worrying about resource release issues. When all modules no longer use the texture, std::shared_ptr will automatically release the memory occupied by the texture, significantly improving the safety and efficiency of memory management.
In practical use, operations with std::shared_ptr are very flexible. For instance, we can create a std::shared_ptr object using the std::make_shared function, which not only simplifies the syntax but also improves efficiency by allocating memory for the object and the control block in one go. Additionally, std::shared_ptr supports assignment, comparison, and other operations, making code writing more natural and intuitive.
1.1Reasons for Its Existence
The emergence of shared_ptr is similar to that of unique_ptr, both aimed at solving the issues caused by the paired use of raw pointers with new and delete, leading to dangling pointers, memory leaks, and double freeing of memory.
However, the scenarios for shared_ptr and unique_ptr differ. Here, it mainly involves a raw pointer being passed between different code blocks, or the memory pointed to being relatively large, which can be divided into many small parts, but they need to share data with each other.
1.2 shared_ptrFeatures
shared_ptr has two features:
- Feature 1: It encapsulates raw pointers, allowing C++ programmers to no longer worry about when to release allocated memory.
- Feature 2: Sharing, pointers using shared_ptr can share data in the same memory.
The idea is that this type of smart pointer implements a reference counting mechanism. Even if one shared_ptr pointer relinquishes its “ownership” of the heap memory (reference count decreases by 1), it does not affect other shared_ptr pointers pointing to the same heap memory (the heap memory is only automatically released when the reference count reaches 0).
1.3 Advantages of shared_ptr
(1) Automatic Release: When the last std::shared_ptr goes out of scope, the reference count becomes zero, and it automatically calls the object’s destructor, preventing memory leaks. In a network communication module, we might create a std::shared_ptr to manage a connection object. When all operations related to that connection are completed, and the std::shared_ptr pointing to that connection object goes out of scope, the connection object will be automatically released without needing to manually call the destructor. This way, we do not have to worry about neglecting to release connection resources correctly, greatly enhancing the reliability of the code.
#include <iostream>
#include <memory>
// Simulate network connection object
class Connection {
public:
Connection() { std::cout << "Connection established\n"; }
~Connection() { std::cout << "Connection released\n"; }
void send(const std::string& data) {
std::cout << "Sending data: " << data << "\n";
}
};
// Simulate network communication module
void handleConnection() {
// Create shared_ptr to manage connection object (reference count=1)
auto conn = std::make_shared<Connection>();
// Simulate multiple operations sharing this connection
auto task1 = [conn]() { conn->send("Task1 data"); };
auto task2 = [conn]() { conn->send("Task2 data"); }; // Reference count increases to 3
task1();
task2();
// task1/task2's lambda destructs, reference count decreases back to 1
} // conn goes out of scope, reference count drops to zero, automatically calls Connection destructor
int main() {
handleConnection();
// Output example:
// Connection established
// Sending data: Task1 data
// Sending data: Task2 data
// Connection released
return 0;
}
(2) Object Sharing: Multiple std::shared_ptr can point to the same object, making resource sharing implementation much simpler. In a game development project, multiple game objects may need to share the same texture resource. By using std::shared_ptr, we can easily allow these game objects to share references to the texture without having to copy texture data for each object, saving memory space and improving resource utilization.
#include <iostream>
#include <memory>
#include <vector>
// Simulate texture resource class
class Texture {
public:
Texture(const std::string& path) : m_path(path) {
std::cout << "Loaded texture: " << m_path << "\n";
}
~Texture() { std::cout << "Unloaded texture: " << m_path << "\n"; }
void render(int x, int y) const {
std::cout << "Rendering texture at (" << x << ", " << y
<< ") from: " << m_path << "\n";
}
private:
std::string m_path;
};
// Game object base class (using shared texture)
class GameObject {
public:
GameObject(std::shared_ptr<Texture> texture) : m_texture(texture) {}
virtual void draw() = 0;
protected:
std::shared_ptr<Texture> m_texture; // Smart pointer to shared texture
};
// Specific game object: Character
class Character : public GameObject {
public:
using GameObject::GameObject;
void draw() override {
m_texture->render(100, 200); // Use shared texture
std::cout << "Character drawn\n";
}
};
// Specific game object: Background
class Background : public GameObject {
public:
using GameObject::GameObject;
void draw() override {
m_texture->render(0, 0); // Use the same texture (may scale or crop)
std::cout << "Background drawn\n";
}
};
int main() {
// 1. Load texture (only once)
auto sharedTexture = std::make_shared<Texture>("assets/hero.png");
// 2. Create multiple game objects sharing the texture
Character hero(sharedTexture);
Background scene(sharedTexture);
// 3. Render without worrying about texture lifecycle
hero.draw();
scene.draw();
// 4. At the end of main, all objects destruct, reference count drops to zero, texture is automatically released
return 0;
}
(3) Exception Safety: std::shared_ptr‘s reference counting is automatically managed and will not leak memory due to function exceptions. In complex database operations, multiple steps may be involved, any of which could throw an exception. If we use std::shared_ptr to manage a database connection object, even if an exception occurs during the operation, std::shared_ptr will ensure that the connection object is correctly released, avoiding memory leaks and dangling resource issues, thus ensuring the robustness of the program.
#include <iostream>
#include <memory>
#include <stdexcept>
// Simulate database connection class
class DatabaseConnection {
public:
DatabaseConnection(const std::string& url) : m_url(url) {
std::cout << "Connected to database: " << m_url << "\n";
}
~DatabaseConnection() {
std::cout << "Disconnected from database: " << m_url << "\n";
}
void executeQuery(const std::string& sql) {
if (sql.empty()) throw std::runtime_error("Empty SQL query");
std::cout << "Executed: " << sql << "\n";
}
private:
std::string m_url;
};
// High-risk operation: complex database transaction that may throw exceptions
void riskyDatabaseOperation(std::shared_ptr<DatabaseConnection> conn) {
conn->executeQuery("BEGIN TRANSACTION"); // Step 1: Start transaction
// Simulate a potentially failing operation (e.g., constraint violation)
conn->executeQuery("UPDATE users SET balance = -100 WHERE id = 123"); // Step 2: May throw exception
conn->executeQuery("COMMIT"); // Step 3: Commit transaction (won't execute if exception occurs)
}
int main() {
try {
// 1. Create shared database connection
auto dbConn = std::make_shared<DatabaseConnection>("mysql://localhost:3306");
// 2. Execute high-risk operation (even if an exception is thrown internally, conn will be released)
riskyDatabaseOperation(dbConn);
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << "\n";
// No need to manually release dbConn! shared_ptr will automatically destruct when stack unwinds
}
return 0;
}
Part 2How to Use shared_ptr
2.1 Creating shared_ptr Objects
In C++, there are two common ways to create std::shared_ptr objects. One is to use the std::make_shared function, which can allocate memory for the object and control block in one go, making it more efficient and concise. For example, to create a std::shared_ptr pointing to an int type object, you can write:
auto ptr1 = std::make_shared<int>(42);
This creates a std::shared_ptr that points to an int object with a value of 42.
Another way is to use a raw pointer to construct a std::shared_ptr, but this method requires that the raw pointer must be allocated with new, otherwise it will lead to undefined behavior. For example:
int* rawPtr = new int(10);
std::shared_ptr<int> ptr2(rawPtr);
Here, a raw pointer rawPtr is created first, and then it is used to construct the std::shared_ptr object ptr2. However, this method is relatively cumbersome and error-prone, as it requires manual management of the raw pointer’s lifecycle, so it is recommended to use std::make_shared.
2.2 Reference Count Related Operations
One of the core features of std::shared_ptr is reference counting, and through some member functions, we can operate and query the reference count. The use_count function returns how many std::shared_ptr point to the same object, mainly for debugging purposes. For example:
auto ptr3 = std::make_shared<int>(100);
std::cout << "ptr3's reference count: " << ptr3.use_count() << std::endl;
auto ptr4 = ptr3;
std::cout << "After assignment, ptr3's reference count: " << ptr3.use_count() << std::endl;
In the above code, when ptr3 is created, its reference count is 1; when ptr3 is assigned to ptr4, they point to the same object, and the reference count becomes 2.
The unique function is used to determine whether the current std::shared_ptr is the only pointer pointing to the object. If it is, it returns true; otherwise, it returns false. Continuing from the previous code:
if (ptr3.unique()) {
std::cout << "ptr3 is the only pointer to the object" << std::endl;
} else {
std::cout << "ptr3 is not the only pointer to the object" << std::endl;
}
Since ptr3 and ptr4 share the object, ptr3.unique() will return false.
The reset function is used to reset std::shared_ptr, and it has two common usages. When called without parameters, it decreases the reference count, and if the reference count becomes 0, it releases the pointed object and sets the current std::shared_ptr to null. For example:
ptr3.reset();
std::cout << "After reset, ptr3's reference count: " << ptr3.use_count() << std::endl;
At this point, ptr3‘s reference count becomes 0 (if there are no other std::shared_ptr pointing to that object), the object is released, and ptr3 becomes a null pointer.
When calling reset with parameters, it first decreases the reference count of the original object, then makes std::shared_ptr point to a new object. For example:
ptr4.reset(new int(200));
std::cout << "After ptr4 points to a new object, reference count: " << ptr4.use_count() << std::endl;
Here, ptr4 first decreases the reference count of the original object, then points to a new int object with a value of 200, and the reference count becomes 1.
2.3 Using as a Regular Pointer
std::shared_ptr overloads the * and -> operators, allowing it to be used like a regular pointer. The * operator can dereference std::shared_ptr to access the value of the object it points to. For example:
auto ptr5 = std::make_shared<int>(50);
int value = *ptr5;
std::cout << "Value pointed to by ptr5: " << value << std::endl;
Here, the value pointed to by ptr5 is obtained and assigned to value.
The -> operator is used to access member functions or member variables of the pointed object. Suppose there is a custom class MyClass:
class MyClass {
public:
void print() {
std::cout << "This is a member function of MyClass" << std::endl;
}
};
auto ptr6 = std::make_shared<MyClass>();
ptr6->print();
In this code, ptr6->print() calls the print member function of the MyClass object, just as conveniently as using a regular pointer. This design of overloading operators makes std::shared_ptr usage more intuitive and natural, allowing operations on objects without additional function calls.
Part 3Issues and Solutions of Circular References
3.1 Example of Circular Reference
Although std::shared_ptr performs excellently in memory management, if its reference counting mechanism is not carefully considered during use, circular references may occur. Circular references refer to two or more objects that reference each other’s std::shared_ptr, leading to a situation where the reference count can never reach zero, ultimately causing memory leaks.
Below is a specific code example demonstrating how circular references occur:
#include <iostream>
#include <memory>
class B;
class A {
public:
std::shared_ptr<B> b_ptr;
~A() {
std::cout << "A destroyed" << std::endl;
}
};
class B {
public:
std::shared_ptr<A> a_ptr;
~B() {
std::cout << "B destroyed" << std::endl;
}
};
int main() {
auto a = std::make_shared<A>();
auto b = std::make_shared<B>();
a->b_ptr = b;
b->a_ptr = a;
return 0;
}
In the above code, class A and class B hold std::shared_ptr to each other. When the main function ends, the local variables a and b are destroyed, and their reference counts decrease by 1. However, since a‘s b_ptr points to b and b‘s a_ptr points to a, the reference counts of a and b can never drop to 0, causing the destructors of both A and B not to be called, and memory cannot be released, resulting in memory leaks. This issue of circular references can be difficult to detect in actual projects, especially in complex object relationships, as it gradually consumes system resources and affects program performance and stability.
3.2 Using std::weak_ptr to Break Circular References
To solve the circular reference problem of std::shared_ptr, C++ introduced std::weak_ptr. std::weak_ptr is a weak reference smart pointer that does not own the object it points to and does not increase the object’s reference count. It is mainly used to solve the circular reference problem of std::shared_ptr while providing a safe way to access the objects managed by std::shared_ptr.
One of the core features of std::weak_ptr is its lock method, which attempts to obtain a std::shared_ptr pointing to the referenced object. If the object has already been released, the lock method will return a null std::shared_ptr. This way, it is possible to safely check whether the object is still valid, avoiding dangling pointer issues.
Below is a code example using std::weak_ptr to solve the above circular reference problem:
#include <iostream>
#include <memory>
class B;
class A {
public:
std::shared_ptr<B> b_ptr;
~A() {
std::cout << "A destroyed" << std::endl;
}
};
class B {
public:
std::weak_ptr<A> a_weak;
~B() {
std::cout << "B destroyed" << std::endl;
}
};
int main() {
auto a = std::make_shared<A>();
auto b = std::make_shared<B>();
a->b_ptr = b;
b->a_weak = a;
return 0;
}
In this improved code, class B no longer holds a std::shared_ptr to class A, but instead uses std::weak_ptr to reference A. When the main function ends, the reference count of a will drop to 0 due to the destruction of the local variable a, and the A object will be correctly released. Subsequently, the reference count of b will also drop to 0, and the B object will be released, thus avoiding the memory leak problem caused by circular references.
When needing to access an object through std::weak_ptr, the lock method can be used, for example:
class B {
public:
std::weak_ptr<A> a_weak;
void accessA() {
auto temp = a_weak.lock();
if (temp) {
// Safely access A object
temp->someFunction();
} else {
std::cout << "A object has been released" << std::endl;
}
}
~B() {
std::cout << "B destroyed" << std::endl;
}
};
In the above code’s accessA method, the lock method is first used to obtain a std::shared_ptr to the A object and check if it is null. If it is not null, the members of the A object can be safely accessed; if it is null, it indicates that the A object has been released, avoiding the risk of dangling pointers.
Part 4Thread Safety of shared_ptr
4.1 Thread Safety of Reference Counting
In a multi-threaded environment, the reference counting operations of std::shared_ptr are thread-safe, which is an important feature. When multiple threads simultaneously copy, assign, or destroy the same std::shared_ptr, the incrementing and decrementing of its reference count is achieved through atomic operations, without the need for additional locking mechanisms. This is akin to having a public counter that multiple threads can simultaneously increase or decrease without encountering counting errors.
For example, in a multi-threaded file processing system, multiple threads may simultaneously read or process the same file object, with each thread holding a std::shared_ptr pointing to that file object. When a thread completes processing the file, the std::shared_ptr it holds goes out of scope, and the reference count is automatically decremented. This process is thread-safe and will not lead to reference count errors due to concurrent operations, ensuring that the file object is correctly released when all threads no longer need it. The implementation of atomic operations makes the reference count management of std::shared_ptr efficient and reliable in multi-threaded environments, significantly reducing the risk of memory management errors caused by multi-threaded operations.
4.2 Thread Safety of Object Access
Although the reference counting of std::shared_ptr is thread-safe, accessing the object it points to is not. When multiple threads simultaneously access and modify the same object through std::shared_ptr, without appropriate synchronization measures, data races and undefined behavior may occur. For example, in a multi-threaded bank account management system, multiple threads may simultaneously perform withdrawal and deposit operations on the same account object. If they directly access the account object through std::shared_ptr without any synchronization control, inconsistencies may arise, such as one thread reading the account balance and not updating it before another thread reads the same balance and performs operations, ultimately leading to incorrect account balance calculations.
To ensure safe access to the object pointed to by std::shared_ptr, synchronization mechanisms such as std::mutex (mutex) and std::lock_guard (RAII-style lock management class) are typically required. Below is a code example using std::mutex to protect access to shared objects:
#include <iostream>
#include <memory>
#include <mutex>
#include <thread>
class Counter {
public:
Counter() : value(0) {}
void increment() {
std::lock_guard<std::mutex> lock(mtx);
++value;
}
int get() {
std::lock_guard<std::mutex> lock(mtx);
return value;
}
private:
int value;
std::mutex mtx;
};
int main() {
auto counter = std::make_shared<Counter>();
std::thread threads[10];
for (int i = 0; i < 10; ++i) {
threads[i] = std::thread([counter]() {
for (int j = 0; j < 100; ++j) {
counter->increment();
}
});
}
for (auto&& th : threads) {
th.join();
}
std::cout << "Final counter value: " << counter->get() << std::endl;
return 0;
}
In the above code, the Counter class contains a std::mutex member variable mtx to protect access to the value member variable. In the increment and get member functions, std::lock_guard<std::mutex> is used to automatically manage the lock’s lifecycle, automatically locking when entering the function and unlocking when leaving. Thus, when multiple threads simultaneously call the increment function, the lock mechanism ensures that only one thread can modify value at a time, avoiding data races and ensuring thread safety.
Note: While std::shared_ptr ensures the thread safety of reference counting, accessing the object itself is not thread-safe. If multiple threads need to modify the object pointed to by std::shared_ptr, additional synchronization measures (such as using std::mutex) are required to ensure thread safety.
4.3 Modifying the Object Pointed to by std::shared_ptr in Multi-threading
If multiple threads need to access and modify the object pointed to by std::shared_ptr simultaneously, using std::mutex can ensure thread safety. Here is an example demonstrating how to use std::mutex to protect access and modification of shared objects.
We create a shared counter object, and multiple threads will simultaneously access and modify that counter. Without std::mutex protection, the counter’s value may be incorrect due to data races. By adding a mutex to the code block that accesses and modifies the counter, we can ensure that each thread accesses the resource in order, avoiding data races.
#include <iostream>
#include <memory>
#include <thread>
#include <mutex>
#include <vector>
class Counter {
public:
int value;
Counter() : value(0) {}
void increment() {
++value;
}
int getValue() const {
return value;
}
};
void thread_func(std::shared_ptr<Counter> counter, std::mutex& mtx) {
for (int i = 0; i < 100; ++i) {
std::lock_guard<std::mutex> lock(mtx); // Lock to protect access to counter
counter->increment();
}
}
int main() {
auto counter = std::make_shared<Counter>();
std::mutex mtx;
std::vector<std::thread> threads;
// Start 10 threads, each performing 100 increment operations on counter
for (int i = 0; i < 10; ++i) {
threads.emplace_back(thread_func, counter, std::ref(mtx));
}
// Wait for all threads to finish
for (auto&& t : threads) {
t.join();
}
std::cout << "Final counter value: " << counter->getValue() << std::endl; // Expected output 1000
return 0;
}
In this example, the Counter class object is managed by std::shared_ptr and shared among multiple threads. In the thread_func function, before each call to counter->increment(), std::lock_guard<std::mutex> locks mtx to ensure that each access to increment() is atomic. std::lock_guard is an RAII-style lock manager that automatically releases the lock at the end of the code block. Starting 10 threads, each thread performs 100 increment operations on the shared counter. By using std::mutex, we ensure that the modifications to the counter are thread-safe.
The program outputs: Final counter value: 1000; without the mutex, counter->increment() may experience competition among multiple threads, leading to a final count value lower than the expected 1000. Using std::mutex to protect access to shared resources ensures thread safety and guarantees that the final counter value is 1000.