Tiny Steps

Learn from tiny steps every day, and make a big difference in your journey

Trong bài viết này mình sẽ giới thiệu một nguyên tắc rất cơ bản nhưng lại cực kì quan trọng trong mọi tình huống khi anh em làm việc với class interface. Nếu không để ý sẽ rất dễ gây ra undefined behaviors.

Đặt vấn đề

Như các bạn đã biết C++ là một ngôn ngữ vô cùng khó khăn, vì lập trình viên luôn luôn phải tự quản lý resources như: resources management, life cycle of object, memory management,… nếu như không kiểu soát được object mỗi khi khởi tạo, gán, copy, hay hủy object thì sẽ nguy hiểm. Chính vì vậy C++ cung cấp bộ cung cụ có tên rule of three/five để giúp lập trình viên kiểm soát việc này.

Hiểu một cách đơn giản thì bộ công cụ này luôn đảm bảo việc implement object theo cơ chế RAII (Resource Acquisition is Initialization) một cách an toàn và hiệu quả

  • Rule of Three: Từ C++98 Mỗi object sẽ bao gồm:
    • Destructor
    • Copy Constructor
    • Copy Assignment Operator
  • Rule of Five: Từ C++11 có thêm 2 hàm nữa cho mục đích Move Semantics
    • Move Constructor
    • Move Assignment Operator

Bởi vì C++ khi copies và assign-copies các user-defined objects trong nhiều tình huống khác nhau (truyền/trả về theo giá trị từ functions, thao tác với container, STL, v.v.), các functions sẽ được gọi, nếu lập trình viên không implement các hàm này thì chương trình sẽ gọi hàm gọi các hàm mặc định được compiler tự động tạo ra một cách ngầm định (vì là mặc định nên sẽ dẫn đến một số tình huống không mong muốn mà mình sẽ trình bày ở phần dưới)

Nếu các default functions được xác định ngầm định thì sẽ không quản lí được các resources đặc biệt (raw pointers, POSIX, network, files, v.v.), Destructor cũng không free vùng nhớ, xóa, hay giải phóng đối với các resource này.

Implementation

Dưới đây là cách implement của các functions trên một khi chúng ta implement một class

class MyClass{
private:
    char* data_ = nullptr;
public:
    // Default Constructor
    MyClass() = default;

    // Constructor
    MyClass(const char* data){
        if (data)
        {
            std::size_t n = std::strlen(data) + 1;
            data_ = new char[n];   
            std::memcpy(data_, data, n); 
        }
    }

    // Destructor 
    ~MyClass(){
        std::cout << "Destructor.\n";
        delete[] data_;
    }

    //Copy Constructor
    MyClass(const MyClass& other){
        std::cout << "Copy Constructor.\n";
        if (other.data_)
        {
            std::size_t n = std::strlen(other.data_) + 1;
            data_ = new char[n];   
            std::memcpy(data_, other.data_, n); 
        }
    }

    //Copy Assignment Operator.
    MyClass& operator=(const MyClass& other){
        std::cout << "Copy Assignment Operator.\n";
        if (other.data_)
        {
            std::size_t n = std::strlen(other.data_) + 1;
            data_ = new char[n];   
            std::memcpy(data_, other.data_, n); 
        }
        return *this;
    }

    //Move Constructor.
    MyClass(MyClass&& other){
        std::cout << "Move Constructor.\n";
        data_ = std::exchange(other.data_, nullptr);
    }

    //Move Assignment Operator.
    MyClass operator=(MyClass&& other){
        std::cout << "Move Assignment Operator.\n";
        std::swap(data_, other.data_);
        return *this;
    }

    char* get(){
        return data_;
    }
};

Khi nào các functions trên được gọi?

Có rất nhiều tình huống Destructor, Copy Constructor, Copy Assignment Operator, Move Constructor, Move Assignment Operator được gọi như:

  • Objects out of scope (gọi Destructor)
  • Gán các objects với nhau
  • Truyền objects vào functions, class, …
  • Objects trả ra từ fuctions

Cụ thể mình đưa đưa ra một vài ví dụ, để các bạn nắm được một số tình huống cơ bản để áp dụng và phòng trách lỗi không cần thiết, đồng thời nâng cao hiệu suất của chương trình.

  • Destructor: Khi object out of scope nếu objects nằm trên stack sẽ tự được gọi, các temporary objects, các objects được cấp phát bộ nhớ sau đó được gọi delete, các smart pointer tới objects
    {
        MyClass m1; // object out of scope
    }

    MyClass* m2 = new MyClass();
    delete m2; // delete allocation

    {
        std::shared_ptr<MyClass> m3 = std::make_shared<MyClass>();
    } // auto delete 
  • Copy Constructor: Khi truyền object vào bằng value, gán objects,… thì các temporary object sẽ được tạo ra bằng cách copy từ object ban đầu
 MyClass a;
 foo(a); // pass by value

 MyClass b = a; // temporary object
  • Copy Assigment Operator: Khi gán 2 objects đã được khởi tạo trước đó cho nhau
    MyClass a, b;
    b = a; 
  • Move Constructor and Move Assignment Operator

Phân biệt giữa move và copy, như các bạn thấy ở hình dưới, nếu copy thì toàn bộ dữ liệu của object 1 sẽ được copy sang một vùng mới để cho object2. Trong khi nếu move thì không cần tạo vùng nhớ mới, object2 sẽ sử dụng chính dữ liệu của object1 và object1 lúc này sẽ không có kiểm soát vùng nhớ đó nữa.

  • Chúng được gọi khi:
    • Khi object khởi tạo hay truyền với std::move()
    • Với perfect forwarding, khi object được pass với std::forward và nó không phải là lvalue
    • Trong một số tình huống với temporary, objects trả ra từ functions compiler sẽ tối ưu bằng cách goi move

Một số vấn đề nếu không tuân thủ “Rule of three/five

  • Destructor:

Như trên mình đã đưa ra một vài ví dụ Destructor sẽ được gọi, tuy nhiên nếu objects của chúng ta mà chứa dữ liệu được cấp phát tại heap allocation, hay các resource files, network,… thì chúng ta nên xóa, hủy để tránh memory leak, hay xung đột resource, như ở ví dụ trên, nếu Destructor của mình không được implement thì sẽ bị memory leak

Hãy thử comment phần free memory trong hàm destructor của mình và thử nhé:

int main(){
    const char* name ="TinySteps.dev"; 
    MyClass m1(name);
    return 0;
}

Biên dịch và kiểm tra thử với valgrind xem memory có bị leak không bằng cách

$ g++ main.cpp -o run
$ valgrind –tool=memcheck –leak-check=yes ./run

Như các bạn thấy ở phần mũi tên đỏ mình chỉ, chương trình bị leak 14 bytes tương ứng với độ dài dữ liệu mình đã cấp phát.

Ok, giờ bỏ comment để free vùng nhớ tại destructor nhé

Chương trình hoàn toàn không bị leak memory nữa.

  • Shadow copy and Deep Copy:

Nếu chúng ta không implement copy constructor, assignment thì trong hầu hết các trường hợp compiler sẽ tự động tạo ngầm định các hàm này. Vấn đề là các hàm mặc đình này sẽ chỉ copy được các dữ liệu trên vùng nhớ stack, đối với các dữ liệu trên heap chúng sẽ không copy sang vùng nhớ mới mà sẽ tự động gán object còn lại tới vùng nhớ chung (đó được gọi là Shadow Copy)

Vậy chúng ta cần imlement copy constructor và assignment cho những dữ liệu trên heap hay những resource đặc biệt để chương trình copy một cách chính xác nhất (Deep Copy)

Như ở hình dưới, nếu sử dụng Shadow copy sẽ dẫn đến một số undefined-behavior như:

Nếu giả sử object1 xóa vùng nhớ chung mà object2 không biết mà vẫn đang hoặc sẽ thực thi trên vùng nhớ đó thì chương trình sẽ bị core dump, segmentfault,….

Hình ảnh minh họa (nguồn internet)

Kiểm chứng với đoạn code sau nhé:

struct Foo{
    int *data = nullptr;   
    Foo() = default; 

};

int main(){
    Foo f1;

    f1.data = new int[1];
    
    Foo f2 = f1;

    std::cout << "f1 addr: " << f1.data << std::endl;
    std::cout << "f2 addr: " << f2.data << std::endl;

    std::cout << "f1: " << *f1.data << std::endl;
    std::cout << "f2: " << *f2.data << std::endl;

    *f1.data = 2;
    std::cout << "f2: " << *f2.data << std::endl;

    return 0;
}

Kết quả:

Như các bạn thấy mình không implement deep copy constructor khi đó 2 object sẽ dùng chung resource memory, lúc này pointer trong 2 objects cùng trỏ tới một vùng nhớ.

Nếu mình thay đổi data từ object1 thì data của object2 cũng bị thay đổi theo, dẫn đến output như trên

Cách khắc phục khi thay đổi data từ object1 thì object2 không bị thay đổi là là implement copy constructor sử dụng deep copy như sau:

struct Foo{
        int *data = nullptr;   
        Foo() = default; 

        Foo(const Foo& f){
            data = new int[sizeof(int)];
            memcpy(data, f.data,sizeof(int) );
        }
    };

Chạy lại nào, object2 không bị thay đổi đúng không nào.

  • Khi nào thì trình biên dịch sẽ tự động tạo default functions trên?

Trong hầu hết các trường hợp thì compiler sẽ tự động tạo default cho rule of five nếu người dùng không tạo functions của riêng mình hoặc chúng ta có thể tạo mặc định bằng từ khóa default như sau:

class Foo
{
public:
   Foo() = default;
   
   Foo(Foo const& other) = default;
   Foo& operator=(Foo const& other) = default;
   
   Foo(Foo&& other) = default;
   Foo& operator=(Foo&& other) = default;
   
   ~Foo() = default;
};

  • Nếu object chứa các const variable hay references thì comiler sẽ không thể tự tạo operator =
  • Nếu không dùng, hoặc muốn chặn việc copy, move, assign cho một số trường hợp đặc biệt, chúng ta có thể delete các function này bằng cách.
class Foo
{
public:
   Foo() = default;
   
   Foo(Foo const& other) = delete;
   Foo& operator=(Foo const& other) = delete;
   
   Foo(Foo&& other) = delete;
   Foo& operator=(Foo&& other) = delete;
   
   ~Foo() = delete;
};

Kết luận

Như vậy trong bài viết này mình đã giới thiệu cho các bạn về Rule of three/five, hi vọng các bạn tuân thủ và áp dụng một cách hiệu quả trong việc resource management và đặc biệt cho move semantics để tránh làm giảm hiệu năng của chương trình.

Hi vọng bài viết hữu ích cho các bạn, nếu các bạn thấy bài viết hay thì hãy chia sẻ cho các anh em lập trình khác cùng biết nhé. Cám ơn các bạn đã ghé đọc ^^. Chúc các bạn làm việc hiệu quả  ^^

Bình luận về bài viết này