Tìm hiểu SmartPointer trong C++ - Phần 1
Một điều mà nó có thể nhanh chóng làm cho source code C++ của bạn trở lên phức tạp rối rắm đó chính là vấn đề về quản lý bộ nhớ. Trong quá trình lập trình, các lập trình viên sẽ mong muốn giảm thiểu tối đa các ảnh hưởng của việc quản lý bộ nhớ lên quá trình lập trình. Smart Pointers được tạo ra ...
Một điều mà nó có thể nhanh chóng làm cho source code C++ của bạn trở lên phức tạp rối rắm đó chính là vấn đề về quản lý bộ nhớ. Trong quá trình lập trình, các lập trình viên sẽ mong muốn giảm thiểu tối đa các ảnh hưởng của việc quản lý bộ nhớ lên quá trình lập trình.
Smart Pointers được tạo ra để giúp cho các lập trình viên có thể tạm quên đi việc quản lý bộ nhớ phức tạp này và giúp cho ứng dụng chạy hiệu quả hơn và chính xác hơn.
Trong chuỗi bài viết này, tôi sẽ cố gắng giải thích kỹ hơn về smart pointer cũng như cách sử dụng smart pointer trong các ứng dụng, từ đó giúp các bạn có thể tạo ra các ứng dụng tốt hơn, dễ sử dụng lại trong các ứng dụng khác.
Bài viết này sẽ chia thành các phần sau:
- Stack vs Heap (This part)
- Các loại smart pointer: unique_ptr, shared_ptr, weak_ptr, scoped_ptr, raw pointers
- Custom deleters
- Thay đổi deleter trong vòng đời của unique_ptr
Stack vs Heap
Giống như các ngôn ngữ lập trình khác, C++ cũng có một số vùng nhớ tương ứng với các vùng nhớ phần cứng riêng biệt. Đó là: static memory, stack, và heap. Trong bài viết này tôi chỉ tập trung vào stack và heap
Stack
Chúng ta cùng xem xét một ví dụ code C++ sau
int f(int a) { if (a > 0) { std::string s = "example string"; std::cout << s << ' '; } return a; }
Trong ví dụ trên, 2 biến a và s được lưu trong stack. Điều này có nghĩa rằng, a và s được đặt cạnh nhau trong bộ nhớ bởi vì chúng được đưa vào một hàng đợi được quản lý bởi trình biên dịch.
Một điều quan trong khi làm việc với stack đó là:
Các đối tượng lưu trong stack sẽ tự động huỷ khi thooát khỏi phạm vi của chúng
Trong C++, phạm vi của một object được đánh dấu bởi {}, ví dụ:
std::vector<int> v = {1, 2, 3}; // Đây không phải là một scope if (v.size() > 0) { // Đây là bắt đầu của một scope ... } // Đây là kết thúc của một scope
Có 3 cách để một object thoát khỏi scope
- Gặp một ngoặc móc đóng
- Gặp return
- Gặp exception trong scope hiện tại mà không xử lý caught trong cùng một scope
Trong ví dụ đầu tiên, s được huỷ tại ngoặc móc đóng của lệnh if, a được huỷ tại lệnh return của hàm f
Heap
Heap là nơi lưu các object được khởi tạo động bằng toán tử new hoặc malloc, calloc, realloc, các toán tử này sẽ trả về một con trỏ
int * pi = new int(42);
Ở ví dụ này, pi trỏ tới một object kiểu int được lưu ở heap
Để huỷ các object được khởi tạo trong heap, chúng ta cần gọi lệnh delete hoặc free
delete pi;
Ngược lại với stack, các object được khởi tạo trong heap sẽ không tự động được huỷ khi hết scope. Do đó chúng ta cần phải thực hiện huỷ object một cách thủ công. Sau khi gọi delete hay free, vùng nhớ trên heap sẽ được hệ điều hành đưa vào danh sách vùng nhớ không sử dụng và sẽ được hệ điều hành cung cấp cho các lệnh khởi tạo khác. Nếu chúng ta gọi delete hay free nhiều lần trên cùng một vùng nhớ, điều đó sẽ dẫn đến các behavior không mong muốn hoặc làm chương trình của chúng ta bị crash.
Vì thực hiện huỷ object một cách thủ công, khi chương trình trở nên phức tạp, chúng ta sẽ rất khó quản lý việc huỷ object này, và có thể dẫn tới leak memory.
Để tránh các vấn đề này, chúng ta cần sử dụng tới smart pointer.
RAII: It's magic
Resource Acquisition Is Initialization(RAII) là công nghệ nói một cách đơn giản đó là thực hiện một ràng buộc giữa object được khởi tạo trên heap với một object được lưu trong stack. Qua đó, khi object ở stack bị huỷ, tức là thoát khỏi scope của object, vùng nhớ trên heap cũng sẽ được huỷ theo.
Ví dụ sau sẽ giúp chúng ta hiểu rõ hơn công nghệ này.
Chúng ta khai báo một class SmartPointer như sau:
template <typename T> class SmartPointer { public: explicit SmartPointer(T* p) : p_(p) {} ~SmartPointer() { delete p_; } private: T* p_; };
Quay trở lại việc sử dụng biến pi ở bên trên, chúng ta sẽ viết thêm vào như sau:
int * pi = new int(42); { SmartPointer<int> sp(pi); }
Khi ra khỏi scope của sp, tức là tại ngoặc móc đóng, sp sẽ bị huỷ, trình biên dịch sẽ gọi vào phương thức huỷ của class SmartPointer, trong phương thức huỷ này, chúng ta thực hiện lệnh delete pi, tức là pi đã được huỷ thông qua việc huỷ sp.
Xem xét ví dụ sau
int * pi = new int(42); { SmartPointer<int> sp1(pi); SmartPointer<int> sp2 = sp1; }
Ở ví dụ này, sp1 và sp2 đều có con trỏ p_ trở tới pi, khi hết scope của chúng, lệnh delete p_ sẽ được thực hiện, do đó pi sẽ bị huỷ 2 lần, điều này sẽ dẫn đến các behavior không mong muốn, hoặc crash chương trình.
Đến đây tôi đã giải thích qua một chút về stack và heap, cũng như việc thực hiện ràng buộc các object được lưu trong stack và heap giúp quá trình quản lý bộ nhớ hiệu quả hơn. Ở bài viết tiếp theo, tôi sẽ giới thiệu một số loại smart pointer được sử dụng trong C++
Happy Coding.