Back to blog

C++ Pointers: Hướng Dẫn Toàn Diện từ Raw đến Smart

cppc++pointersmemory-managementmodern-cpp
C++ Pointers: Hướng Dẫn Toàn Diện từ Raw đến Smart

Pointers là tính năng khiến C++ vừa cực kỳ mạnh mẽ, vừa nổi tiếng là khó nhằn. Nó cho bạn quyền truy cập trực tiếp vào memory — khả năng thao tác address, xây dựng các data structures phức tạp, và viết code chạy sát hardware. Nhưng power đi kèm responsibility: dùng sai pointer, bạn sẽ gặp crash, memory leaks, hoặc security vulnerabilities.

Bài này đưa bạn từ raw pointer cơ bản qua smart pointers hiện đại, cover mọi thứ bạn cần để viết C++ code an toàn và hiệu quả.

Bạn Sẽ Học Được

✅ Raw pointer fundamentals: khai báo, dereferencing, và address-of
✅ Pointer arithmetic và cách nó map vào memory layout
✅ Mối quan hệ giữa pointers và arrays
✅ References vs pointers: khi nào dùng cái nào
✅ Dynamic memory với new/delete và tại sao nó nguy hiểm
✅ Smart pointers: unique_ptr, shared_ptr, weak_ptr
✅ RAII và ownership semantics
✅ Các bugs pointer thường gặp và cách tránh
✅ Best practices C++ hiện đại cho memory safety


Raw Pointer Cơ Bản

Pointer là một biến lưu trữ memory address. Chỉ vậy thôi. Mọi pointer, bất kể type, đều giữ một address — một con số xác định vị trí trong memory.

Khai Báo và Khởi Tạo

#include <iostream>
 
int main() {
    int value = 42;
 
    // Ba cách khai báo tương đương
    int* p1 = &value;   // Ưa thích: * đi với type
    int *p2 = &value;   // C-style: * đi với biến
    int * p3 = &value;  // Cũng được, ít phổ biến
 
    // & là toán tử "address-of" (lấy địa chỉ)
    std::cout << "value:   " << value << '\n';    // 42
    std::cout << "address: " << &value << '\n';   // 0x7ffd... (địa chỉ hex nào đó)
    std::cout << "p1:      " << p1 << '\n';       // cùng địa chỉ
 
    // * là toán tử "dereference" (đi theo địa chỉ)
    std::cout << "*p1:     " << *p1 << '\n';      // 42
 
    // Sửa đổi bản gốc thông qua pointer
    *p1 = 100;
    std::cout << "value:   " << value << '\n';    // 100
 
    return 0;
}

Mental Model

Hãy nghĩ memory như một mảng khổng lồ các bytes. Mỗi byte có một index (address của nó). Pointer đơn giản chỉ lưu một trong những index đó.

Khi bạn viết *p1, bạn đang nói: "Đi đến address lưu trong p1, và đưa cho tôi value ở đó."

Null Pointers

Pointer không trỏ đến gì valid nên được set thành nullptr:

int* ptr = nullptr;  // C++ hiện đại (C++11+)
int* old = NULL;     // C-style, tránh dùng trong modern code
int* bad = 0;        // Chạy được nhưng mơ hồ — 0 là int hay pointer?
 
// Luôn check trước khi dereference
if (ptr != nullptr) {
    std::cout << *ptr << '\n';
} else {
    std::cout << "ptr is null\n";
}
 
// Viết tắt (pointers có tính truthy/falsy)
if (ptr) {
    std::cout << *ptr << '\n';
}

Rule: Luôn khởi tạo pointers. Pointer chưa khởi tạo chứa garbage address — dereference nó là undefined behavior.


Pointer Arithmetic

Pointer arithmetic là một trong những feature mạnh nhất (và nguy hiểm nhất) của C++. Khi bạn cộng hoặc trừ từ pointer, compiler tự động scale theo size của type mà pointer trỏ đến.

Cách Hoạt Động

#include <iostream>
 
int main() {
    int arr[] = {10, 20, 30, 40, 50};
    int* p = arr;  // Trỏ đến arr[0]
 
    // Cộng 1 di chuyển sizeof(int) bytes, KHÔNG phải 1 byte
    std::cout << "*p:       " << *p << '\n';        // 10
    std::cout << "*(p + 1): " << *(p + 1) << '\n';  // 20
    std::cout << "*(p + 2): " << *(p + 2) << '\n';  // 30
 
    // Increment di chuyển đến phần tử tiếp theo
    p++;
    std::cout << "*p after p++: " << *p << '\n';  // 20
 
    // Trừ pointer cho ra số phần tử, không phải số bytes
    int* start = arr;
    int* end = arr + 5;
    std::cout << "Elements: " << (end - start) << '\n';  // 5
 
    // Toán tử so sánh hoạt động trên pointers
    if (start < end) {
        std::cout << "start nằm trước end\n";
    }
 
    return 0;
}

Minh Họa Memory Layout

p + 1 không cộng 1 byte — nó cộng sizeof(int) (thường là 4 bytes). Nên p + 1 di chuyển từ 0x1000 đến 0x1004.

Vùng Nguy Hiểm

int arr[] = {10, 20, 30};
 
int* p = arr;
int* danger = p + 100;  // Compile bình thường! Không kiểm tra giới hạn
// *danger = 42;         // Undefined behavior: ghi vào vùng memory ngẫu nhiên
 
// Đây là nguyên nhân gốc của lỗ hổng buffer overflow
char buffer[10];
char* cp = buffer;
// Ghi quá buffer làm hỏng memory liền kề
// *(cp + 100) = 'X';  // Buffer overflow!

Giải pháp hiện đại: Dùng std::array hoặc std::vector với .at() để có bounds-checked access. Chỉ dùng pointer arithmetic cho performance-critical code mà bạn đã verify bounds rồi.


Pointers và Arrays

Trong C++, arrays và pointers có mối quan hệ sâu sắc. Tên array decay (suy biến) thành pointer đến phần tử đầu tiên trong hầu hết context.

Array Decay

#include <iostream>
 
void printSize(int* arr) {
    // sizeof(arr) là size của pointer, KHÔNG phải array!
    std::cout << "sizeof trong hàm: " << sizeof(arr) << '\n';  // 8 (trên 64-bit)
}
 
int main() {
    int arr[] = {1, 2, 3, 4, 5};
 
    // sizeof hoạt động đúng trong scope khai báo
    std::cout << "sizeof arr: " << sizeof(arr) << '\n';  // 20 (5 * 4 bytes)
 
    // Array decay thành pointer khi truyền vào hàm
    printSize(arr);  // Mất thông tin size!
 
    // Tên array và pointer đến phần tử đầu tiên hoán đổi được
    int* p = arr;           // Decay ngầm định
    int* p2 = &arr[0];     // Tường minh — cùng kết quả
 
    // Những cách này tương đương
    std::cout << arr[2] << '\n';     // 30 — ký hiệu subscript
    std::cout << *(arr + 2) << '\n'; // 30 — pointer arithmetic
    std::cout << *(p + 2) << '\n';   // 30 — qua pointer
    std::cout << p[2] << '\n';       // 30 — subscript trên pointer cũng được!
 
    return 0;
}

Tại Sao std::array và std::vector Tốt Hơn

#include <array>
#include <vector>
#include <iostream>
 
// std::array: size cố định, stack-allocated, biết size của nó
void printArray(const std::array<int, 5>& arr) {
    std::cout << "Size: " << arr.size() << '\n';  // 5 — size được giữ nguyên!
    for (int val : arr) {
        std::cout << val << ' ';
    }
    std::cout << '\n';
}
 
// std::vector: dynamic size, heap-allocated, biết size của nó
void printVector(const std::vector<int>& vec) {
    std::cout << "Size: " << vec.size() << '\n';
 
    // Truy cập có bounds-checking
    try {
        std::cout << vec.at(100) << '\n';  // Throw std::out_of_range
    } catch (const std::out_of_range& e) {
        std::cout << "Out of range: " << e.what() << '\n';
    }
}
 
int main() {
    std::array<int, 5> arr = {10, 20, 30, 40, 50};
    printArray(arr);
 
    std::vector<int> vec = {10, 20, 30, 40, 50};
    printVector(vec);
 
    return 0;
}

Pointers to Pointers

Pointer có thể trỏ đến pointer khác. Điều này tạo ra nhiều level of indirection.

#include <iostream>
 
int main() {
    int value = 42;
    int* ptr = &value;      // Pointer to int
    int** pptr = &ptr;      // Pointer to pointer to int
    int*** ppptr = &pptr;   // Ba level deep (hiếm gặp trong thực tế)
 
    std::cout << "value:    " << value << '\n';     // 42
    std::cout << "*ptr:     " << *ptr << '\n';      // 42
    std::cout << "**pptr:   " << **pptr << '\n';    // 42
    std::cout << "***ppptr: " << ***ppptr << '\n';  // 42
 
    // Sửa đổi thông qua double pointer
    **pptr = 100;
    std::cout << "value sau **pptr = 100: " << value << '\n';  // 100
 
    return 0;
}

Các use case phổ biến cho pointer-to-pointer:

  • Thay đổi pointer bên trong hàm (truyền int** để thay đổi nơi int* trỏ đến)
  • Mảng 2D dynamic (mảng các pointers đến mảng)
  • C-style APIs trả dữ liệu qua output parameters

Giải pháp hiện đại: std::vector<std::vector<int>> cho mảng 2D, std::optional hoặc return values thay vì output parameters.


Function Pointers

Functions cũng sống trong memory, nên bạn có thể có pointers đến chúng:

#include <iostream>
#include <functional>
 
// Hàm thường
int add(int a, int b) { return a + b; }
int multiply(int a, int b) { return a * b; }
 
// Hàm nhận function pointer làm tham số
int apply(int a, int b, int (*operation)(int, int)) {
    return operation(a, b);
}
 
int main() {
    // Khai báo function pointer
    int (*op)(int, int) = &add;  // & là optional cho functions
    std::cout << "add: " << op(3, 4) << '\n';  // 7
 
    op = multiply;  // Gán lại cho hàm khác
    std::cout << "multiply: " << op(3, 4) << '\n';  // 12
 
    // Truyền function pointer làm argument
    std::cout << "apply add: " << apply(3, 4, add) << '\n';        // 7
    std::cout << "apply multiply: " << apply(3, 4, multiply) << '\n';  // 12
 
    // Giải pháp hiện đại: std::function (linh hoạt hơn)
    std::function<int(int, int)> func = add;
    std::cout << "std::function: " << func(3, 4) << '\n';  // 7
 
    // Cũng hoạt động với lambdas
    func = [](int a, int b) { return a - b; };
    std::cout << "lambda: " << func(10, 3) << '\n';  // 7
 
    return 0;
}

Type alias giúp function pointers dễ đọc hơn:

// Không có alias — khó đọc
int (*getOperation(char op))(int, int);
 
// Có alias — gọn hơn nhiều
using BinaryOp = int (*)(int, int);
BinaryOp getOperation(char op);
 
// Hoặc tốt hơn nữa với std::function
using BinaryOp = std::function<int(int, int)>;

const và Pointers

const với pointers có bốn biến thể. Đọc chúng từ phải sang trái để hiểu:

int value = 42;
int other = 99;
 
// 1. Pointer to non-const: có thể sửa value và pointer
int* p1 = &value;
*p1 = 100;        // OK: sửa value
p1 = &other;      // OK: thay đổi nơi p1 trỏ đến
 
// 2. Pointer to const: không sửa được value, có thể đổi pointer
const int* p2 = &value;    // Đọc: "p2 là pointer to const int"
// *p2 = 100;              // LỖI: value là const
p2 = &other;               // OK: bản thân pointer không phải const
 
// 3. Const pointer to non-const: có thể sửa value, không đổi được pointer
int* const p3 = &value;    // Đọc: "p3 là const pointer to int"
*p3 = 100;                 // OK: value không phải const
// p3 = &other;            // LỖI: pointer là const
 
// 4. Const pointer to const: không sửa được cả hai
const int* const p4 = &value;  // Đọc: "p4 là const pointer to const int"
// *p4 = 100;                  // LỖI: value là const
// p4 = &other;                // LỖI: pointer là const

Quy tắc đọc từ phải sang trái:

Khai báoĐọc là
int* pp là pointer to int
const int* pp là pointer to const int
int* const pp là const pointer to int
const int* const pp là const pointer to const int

Best practice: Dùng const mọi nơi có thể. Nếu hàm không sửa đổi data được trỏ đến, dùng const:

// Tốt: cam kết không sửa đổi string
void print(const std::string* s) {
    std::cout << *s << '\n';
}
 
// Tốt hơn: dùng const reference
void print(const std::string& s) {
    std::cout << s << '\n';
}

References vs Pointers

References là aliases — tên khác cho biến đã tồn tại. Chúng giống pointers nhưng hành vi khác:

#include <iostream>
 
int main() {
    int value = 42;
 
    // Reference: phải khởi tạo, không thể null, không thể gán lại
    int& ref = value;
    ref = 100;
    std::cout << value << '\n';  // 100 — ref CHÍNH LÀ value
 
    // Pointer: có thể null, có thể gán lại, phải dereference
    int* ptr = &value;
    *ptr = 200;
    std::cout << value << '\n';  // 200
 
    int other = 50;
 
    // Reference không thể gán lại
    ref = other;  // Gán VALUE của other vào value, KHÔNG rebind ref
    std::cout << value << '\n';  // 50 (value thay đổi, ref vẫn alias value)
 
    // Pointer có thể gán lại
    ptr = &other;  // Giờ ptr trỏ đến other
    *ptr = 999;
    std::cout << other << '\n';  // 999
    std::cout << value << '\n';  // 50 (không đổi)
 
    return 0;
}

Khi Nào Dùng Cái Nào

Tình huốngDùng ReferenceDùng Pointer
Tham số hàm (chỉ đọc)const T&—
Tham số hàm (sửa đổi)T&—
Tham số optional (có thể null)—T*
Có thể gán lại (đổi nơi trỏ đến)—T*
Class member (optional)—T* hoặc smart pointer
Trả về từ factory—unique_ptr<T>
Operator overloadingT& (chaining)—

Nguyên tắc chung: Ưu tiên references. Dùng pointers khi cần null hoặc reseating.

Các Pattern Tham Số Hàm

#include <string>
#include <iostream>
 
// Pattern 1: Truy cập chỉ đọc — const reference
void display(const std::string& name) {
    std::cout << "Hello, " << name << '\n';
}
 
// Pattern 2: Sửa đổi bản gốc — non-const reference
void toUpper(std::string& s) {
    for (char& c : s) {
        c = std::toupper(c);
    }
}
 
// Pattern 3: Tham số optional — pointer
void greet(const std::string& name, const std::string* title = nullptr) {
    if (title) {
        std::cout << *title << " " << name << '\n';
    } else {
        std::cout << name << '\n';
    }
}
 
// Pattern 4: Chuyển ownership — smart pointer (xem phần tiếp theo)
 
int main() {
    std::string name = "alice";
 
    display(name);            // "Hello, alice"
    toUpper(name);
    display(name);            // "Hello, ALICE"
 
    std::string dr = "Dr.";
    greet(name);              // "ALICE"
    greet(name, &dr);         // "Dr. ALICE"
 
    return 0;
}

Dynamic Memory: new và delete

new cấp phát memory trên heap (free store) và trả về pointer. delete giải phóng nó. Đây là cách cũ — bạn hiếm khi nên dùng trực tiếp trong modern C++.

Cách Dùng Cơ Bản

#include <iostream>
 
int main() {
    // Cấp phát một int
    int* p = new int(42);
    std::cout << *p << '\n';  // 42
    delete p;                 // Giải phóng memory
 
    // Cấp phát mảng
    int* arr = new int[5]{10, 20, 30, 40, 50};
    for (int i = 0; i < 5; i++) {
        std::cout << arr[i] << ' ';
    }
    std::cout << '\n';
    delete[] arr;  // Phải dùng delete[] cho mảng!
 
    return 0;
}

5 Lỗi Chí Mạng khi Quản Lý Memory Thủ Công

// Lỗi 1: Memory Leak — quên delete
void leak() {
    int* p = new int(42);
    // Ôi, hàm return mà không delete
    // Memory bị leak — mất cho đến khi chương trình kết thúc
}
 
// Lỗi 2: Double Delete — delete hai lần
void doubleFree() {
    int* p = new int(42);
    delete p;
    // delete p;  // UNDEFINED BEHAVIOR! Crash, corruption, hoặc tệ hơn
}
 
// Lỗi 3: Use After Free — truy cập memory đã delete
void useAfterFree() {
    int* p = new int(42);
    delete p;
    // std::cout << *p;  // UNDEFINED BEHAVIOR! Có thể in 42, crash, hoặc bất kỳ thứ gì
}
 
// Lỗi 4: Mismatched delete — dùng delete thay vì delete[]
void mismatchedDelete() {
    int* arr = new int[100];
    // delete arr;    // SAI! Phải dùng delete[] cho mảng
    delete[] arr;     // Đúng
}
 
// Lỗi 5: Dangling Pointer — trỏ đến local đã hết hạn
int* dangling() {
    int local = 42;
    return &local;  // CẢNH BÁO: local chết khi hàm return
    // Pointer trả về trỏ đến rác
}

Đây là lý do smart pointers tồn tại. Mỗi bug trên đều được ngăn chặn bằng cách dùng unique_ptr và shared_ptr.


Smart Pointers: Cách Hiện Đại

Smart pointers là RAII wrappers tự động quản lý heap memory. Chúng được giới thiệu trong C++11 và nên là lựa chọn mặc định cho dynamic allocation.

std::unique_ptr — Sở Hữu Độc Quyền

unique_ptr sở hữu memory độc quyền. Khi unique_ptr bị hủy (ra khỏi scope), memory được giải phóng. Nó không thể copy, chỉ có thể move.

#include <memory>
#include <iostream>
 
class Database {
public:
    Database(const std::string& name) : name_(name) {
        std::cout << "Connected to " << name_ << '\n';
    }
    ~Database() {
        std::cout << "Disconnected from " << name_ << '\n';
    }
    void query(const std::string& sql) {
        std::cout << "[" << name_ << "] " << sql << '\n';
    }
private:
    std::string name_;
};
 
int main() {
    // Tạo với make_unique (ưa thích — exception safe)
    auto db = std::make_unique<Database>("production");
    db->query("SELECT * FROM users");
 
    // Không copy được — ownership là độc quyền
    // auto db2 = db;  // COMPILE ERROR
 
    // Có thể move — chuyển ownership
    auto db2 = std::move(db);
    // db giờ là nullptr
    if (!db) {
        std::cout << "db rỗng sau move\n";
    }
    db2->query("SELECT * FROM orders");
 
    // db2 bị hủy ở đây — "Disconnected from production" được in
    return 0;
}

Dùng unique_ptr khi:

  • Có một owner duy nhất rõ ràng cho resource
  • Bạn implement factory pattern
  • Cần polymorphism (base pointer đến derived objects)
  • Cái này cover 90%+ nhu cầu dynamic allocation

std::shared_ptr — Sở Hữu Chung

shared_ptr dùng reference counting. Memory được giải phóng khi shared_ptr cuối cùng trỏ đến nó bị hủy.

#include <memory>
#include <iostream>
#include <vector>
 
class Config {
public:
    Config(std::string env) : env_(std::move(env)) {
        std::cout << "Config created for " << env_ << '\n';
    }
    ~Config() {
        std::cout << "Config destroyed for " << env_ << '\n';
    }
    const std::string& env() const { return env_; }
private:
    std::string env_;
};
 
class Service {
public:
    Service(std::string name, std::shared_ptr<Config> config)
        : name_(std::move(name)), config_(std::move(config)) {}
 
    void run() {
        std::cout << name_ << " running in " << config_->env() << '\n';
    }
private:
    std::string name_;
    std::shared_ptr<Config> config_;
};
 
int main() {
    auto config = std::make_shared<Config>("production");
    std::cout << "ref count: " << config.use_count() << '\n';  // 1
 
    {
        Service auth("AuthService", config);
        Service api("ApiService", config);
        std::cout << "ref count: " << config.use_count() << '\n';  // 3
 
        auth.run();
        api.run();
    }  // auth và api bị hủy, ref count giảm xuống 1
 
    std::cout << "ref count: " << config.use_count() << '\n';  // 1
    // Config bị hủy khi config ra khỏi scope
    return 0;
}

Dùng shared_ptr khi:

  • Nhiều phần code thực sự cần shared ownership
  • Hệ thống caching nơi objects được reference từ nhiều nơi
  • Observer patterns với shared state

Cảnh báo: shared_ptr có overhead (reference count, control block). Đừng dùng làm mặc định — hãy thử unique_ptr trước.

std::weak_ptr — Phá Vỡ Vòng Lặp

weak_ptr là non-owning reference đến object được quản lý bởi shared_ptr. Nó giải quyết vấn đề circular reference.

#include <memory>
#include <iostream>
 
class Node {
public:
    std::string name;
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev;  // weak_ptr phá vỡ cycle!
 
    Node(std::string n) : name(std::move(n)) {
        std::cout << "Node " << name << " created\n";
    }
    ~Node() {
        std::cout << "Node " << name << " destroyed\n";
    }
};
 
int main() {
    auto a = std::make_shared<Node>("A");
    auto b = std::make_shared<Node>("B");
    auto c = std::make_shared<Node>("C");
 
    // Forward links: shared_ptr (ownership)
    a->next = b;
    b->next = c;
 
    // Backward links: weak_ptr (non-owning)
    c->prev = b;
    b->prev = a;
 
    // Truy cập weak_ptr an toàn
    if (auto locked = c->prev.lock()) {
        std::cout << "C's prev: " << locked->name << '\n';  // B
    }
 
    // Kiểm tra object được reference có còn tồn tại không
    std::weak_ptr<Node> observer = a;
    if (!observer.expired()) {
        auto locked = observer.lock();
        std::cout << "Node vẫn còn sống: " << locked->name << '\n';
    }
 
    return 0;
    // Tất cả nodes bị hủy — không memory leak!
}

Nếu không có weak_ptr: Nếu prev là shared_ptr, A→B→C→B sẽ tạo cycle — không node nào bao giờ được giải phóng.

Sơ Đồ Quyết Định Smart Pointer


RAII: Triết Lý Memory Của C++

RAII (Resource Acquisition Is Initialization) là idiom quan trọng nhất trong C++. Ý tưởng: gắn vòng đời resource với vòng đời object.

#include <fstream>
#include <mutex>
#include <memory>
#include <iostream>
 
// RAII ví dụ 1: File handle
void processFile(const std::string& path) {
    std::ifstream file(path);  // Mở file trong constructor
    if (!file.is_open()) {
        std::cerr << "Không mở được " << path << '\n';
        return;
    }
 
    std::string line;
    while (std::getline(file, line)) {
        std::cout << line << '\n';
    }
    // file đóng tự động bởi destructor — kể cả khi exception được throw!
}
 
// RAII ví dụ 2: Mutex lock
std::mutex mtx;
 
void safeIncrement(int& counter) {
    std::lock_guard<std::mutex> lock(mtx);  // Lock trong constructor
    counter++;
    // Unlock tự động bởi destructor — kể cả khi exception được throw!
}
 
// RAII ví dụ 3: Custom resource wrapper
class Socket {
public:
    Socket(const std::string& host, int port) {
        // Giả lập kết nối
        fd_ = 42;  // Thực tế đây sẽ là socket() + connect()
        std::cout << "Connected to " << host << ":" << port << '\n';
    }
 
    ~Socket() {
        if (fd_ >= 0) {
            // close(fd_);  // Thực tế, đóng socket
            std::cout << "Socket closed\n";
        }
    }
 
    // Ngăn copying (một socket = một connection)
    Socket(const Socket&) = delete;
    Socket& operator=(const Socket&) = delete;
 
    // Cho phép moving
    Socket(Socket&& other) noexcept : fd_(other.fd_) {
        other.fd_ = -1;
    }
 
private:
    int fd_ = -1;
};
 
int main() {
    {
        Socket conn("localhost", 8080);
        // Dùng connection...
    }  // Socket đóng tự động ở đây
 
    return 0;
}

Pattern RAII:

  1. Acquire resources trong constructor
  2. Release resources trong destructor
  3. Compiler đảm bảo destructor chạy khi object rời scope — kể cả trong exceptions
  4. Smart pointers là RAII wrapper cho heap memory

Các Patterns và Idioms Phổ Biến

Pimpl Pattern (Pointer to Implementation)

Ẩn implementation details sau một pointer. Giảm compile-time dependencies và giữ headers gọn.

// widget.h — public header
#include <memory>
#include <string>
 
class Widget {
public:
    Widget(const std::string& name);
    ~Widget();  // Phải khai báo — unique_ptr cần complete type để delete
 
    // Move operations
    Widget(Widget&&) noexcept;
    Widget& operator=(Widget&&) noexcept;
 
    void doWork();
 
private:
    struct Impl;  // Forward declaration — không cần #include!
    std::unique_ptr<Impl> pImpl_;
};
 
// widget.cpp — implementation
#include "widget.h"
#include <iostream>
// Include heavy headers chỉ ở đây, không phải trong .h
 
struct Widget::Impl {
    std::string name;
    int counter = 0;
    // Có thể include heavy types: database connections, network sockets, v.v.
};
 
Widget::Widget(const std::string& name) : pImpl_(std::make_unique<Impl>()) {
    pImpl_->name = name;
}
 
Widget::~Widget() = default;  // Phải ở .cpp nơi Impl là complete
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;
 
void Widget::doWork() {
    pImpl_->counter++;
    std::cout << pImpl_->name << " work #" << pImpl_->counter << '\n';
}

Observer Pattern với weak_ptr

#include <memory>
#include <vector>
#include <algorithm>
#include <iostream>
 
class EventEmitter {
public:
    using Listener = std::shared_ptr<std::function<void(const std::string&)>>;
 
    void subscribe(Listener listener) {
        listeners_.push_back(listener);
    }
 
    void emit(const std::string& event) {
        // Dọn dẹp listeners đã expired và notify các listeners còn active
        listeners_.erase(
            std::remove_if(listeners_.begin(), listeners_.end(),
                [](const std::weak_ptr<std::function<void(const std::string&)>>& wp) {
                    return wp.expired();
                }),
            listeners_.end()
        );
 
        for (auto& wp : listeners_) {
            if (auto sp = wp.lock()) {
                (*sp)(event);
            }
        }
    }
 
private:
    std::vector<std::weak_ptr<std::function<void(const std::string&)>>> listeners_;
};

Các Cạm Bẫy Thường Gặp và Cách Tránh

Cạm Bẫy 1: Trả Về Reference Đến Local

// SAI: reference đến local đã bị hủy
const std::string& getName() {
    std::string name = "Alice";
    return name;  // Dangling reference! name bị hủy ở đây
}
 
// ĐÚNG: return by value (copy elision giúp hiệu quả)
std::string getName() {
    std::string name = "Alice";
    return name;  // Moved/elided — thực tế không copy
}

Cạm Bẫy 2: Slicing với Pointers và References

class Base {
public:
    virtual void speak() { std::cout << "Base\n"; }
    virtual ~Base() = default;  // Luôn virtual destructor!
};
 
class Derived : public Base {
public:
    void speak() override { std::cout << "Derived\n"; }
};
 
// SAI: slicing (chỉ copy phần Base)
void sliced(Base b) {
    b.speak();  // Luôn in "Base"!
}
 
// ĐÚNG: dùng reference hoặc pointer cho polymorphism
void polymorphic(Base& b) {
    b.speak();  // In "Derived" nếu b thực sự là Derived
}
 
void polymorphicPtr(Base* b) {
    b->speak();  // Cũng hoạt động với pointer
}
 
// TỐT NHẤT: dùng smart pointer cho ownership
void owned(std::unique_ptr<Base> b) {
    b->speak();
}

Cạm Bẫy 3: Quên Virtual Destructor

class Base {
public:
    // KHÔNG CÓ virtual destructor:
    ~Base() { std::cout << "Base destroyed\n"; }
};
 
class Derived : public Base {
    int* data_ = new int[1000];
public:
    ~Derived() {
        delete[] data_;
        std::cout << "Derived destroyed\n";
    }
};
 
// Cái này leak memory!
Base* p = new Derived();
delete p;  // Chỉ gọi ~Base(), không gọi ~Derived()! data_ bị leak
 
// FIX: làm destructor virtual
// virtual ~Base() { ... }

Cạm Bẫy 4: Share unique_ptr

// SAI: cố share unique_ptr
auto ptr = std::make_unique<Widget>("w1");
// auto copy = ptr;  // COMPILE ERROR — không thể copy unique_ptr
 
// Nếu thực sự cần sharing, nâng cấp lên shared_ptr
auto shared = std::make_shared<Widget>("w1");
auto copy = shared;  // OK — ref count = 2

Tổng Kết Best Practices Hiện Đại

Những Quy Tắc Vàng

  1. Không bao giờ dùng new/delete trực tiếp — dùng make_unique và make_shared
  2. Mặc định dùng unique_ptr — chỉ dùng shared_ptr khi ownership thực sự shared
  3. Ưu tiên references hơn pointers cho tham số hàm
  4. Dùng const mọi nơi có thể — const T& cho read-only, const T* const khi cần
  5. Luôn khởi tạo pointers — nullptr nếu chưa có giá trị
  6. Làm destructors virtual trong base classes có virtual functions
  7. Tuân theo Rule of Five — nếu bạn define một trong destructor, copy/move constructors, hoặc copy/move assignments, define tất cả năm (hoặc = delete cái không cần)

Bảng Tóm Tắt Pointer C++ Hiện Đại

TaskCách CũCách Hiện Đại
Cấp phát single objectnew T(args)std::make_unique<T>(args)
Cấp phát shared objectnew T(args)std::make_shared<T>(args)
Cấp phát mảngnew T[n]std::vector<T>(n)
Truyền chỉ đọcconst T*const T&
Truyền có sửa đổiT*T&
Tham số optionalT* (null = vắng mặt)std::optional<T> (C++17)
Null pointerNULL hoặc 0nullptr
C-style cast(int*)ptrstatic_cast<int*>(ptr)
Duyệt mảngfor (int i = 0; ...)Range-based for (auto& x : arr)
Function pointervoid (*fp)(int)std::function<void(int)>

Khi Nào Raw Pointers Vẫn OK

Raw pointers không phải là evil — chúng chỉ là non-owning. Dùng chúng khi:

  • Observing không cần ownership (tham số hàm không nhận ownership)
  • Giao tiếp với C APIs (phải convert smart pointers với .get())
  • Performance-critical inner loops nơi overhead smart pointer quan trọng
  • Implement data structures riêng (linked lists, trees)

Điểm phân biệt then chốt: owning raw pointers là nguy hiểm; non-owning raw pointers thì ổn.

// OK: non-owning raw pointer (không quản lý lifetime)
void process(const Widget* widget) {
    if (widget) {
        widget->doWork();
    }
}
 
// OK: caller quản lý lifetime với smart pointer
auto widget = std::make_unique<Widget>("w1");
process(widget.get());  // Truyền raw pointer để observe

Bài Tập Thực Hành

Test hiểu biết của bạn với những bài tập sau:

Bài 1: Tìm Bug

int* createArray(int size) {
    int arr[size];  // VLA — không phải standard C++!
    for (int i = 0; i < size; i++) {
        arr[i] = i * 10;
    }
    return arr;  // Cái gì sai ở đây?
}
Đáp án

arr là mảng local trên stack. Trả về pointer đến nó tạo ra dangling pointer — memory không valid sau khi hàm return. Fix: dùng std::vector<int> và return by value, hoặc std::make_unique<int[]>(size).

Bài 2: Sửa Memory Leak

void processData() {
    int* data = new int[1000];
    // ... xử lý có thể throw ...
    if (data[0] < 0) {
        return;  // Leak!
    }
    delete[] data;
}
Đáp án

Nếu early return được chạy, delete[] không bao giờ được gọi. Fix: dùng auto data = std::make_unique<int[]>(1000); — destructor chạy bất kể hàm exit kiểu nào.

Bài 3: Chọn Smart Pointer Đúng

Bạn đang xây scene graph cho game. Mỗi node có một parent và nhiều children. Smart pointer nào nên dùng cho parent vs children?

Đáp án
  • Children: std::vector<std::unique_ptr<Node>> — parent sở hữu children
  • Parent: raw pointer Node* hoặc std::weak_ptr<Node> — child không sở hữu parent

Cách này ngăn circular references và làm ownership rõ ràng.


Bài Viết Liên Quan


Kết Luận

Pointers là con dao hai lưỡi của C++. Raw pointers cho bạn quyền kiểm soát trực tiếp memory — thiết yếu cho systems programming, game engines, và embedded systems. Nhưng quyền kiểm soát đó đòi hỏi kỷ luật.

Modern C++ đã tiến hóa để giữ sức mạnh trong khi giảm đáng kể nguy hiểm:

  • unique_ptr xử lý 90%+ nhu cầu dynamic allocation — zero overhead, tự động cleanup
  • shared_ptr cover các trường hợp còn lại nơi ownership thực sự shared
  • References thay thế pointers hoàn toàn cho hầu hết tham số hàm
  • RAII đảm bảo resources luôn được cleanup, kể cả trong exceptions

Thứ tự ưu tiên rõ ràng: bắt đầu với stack allocation → dùng unique_ptr khi cần heap → dùng shared_ptr chỉ khi ownership thực sự shared → dùng raw pointers chỉ để non-owning observation.

Làm chủ hierarchy này, bạn sẽ viết C++ vừa high-performance vừa memory-safe.

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.