Tiny Steps

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

Ở bài viết này chúng ta sẽ tìm hiểu một Pattern Design đơn giản và cực kì phổ biến và hữu ích, nhưng đồng cũng có rất nhiều vấn đề mà sẽ chỉ ra tại sao các bạn nên hạn chế sử dụng.

Vấn đề

Giả sử trong hệ thống của các bạn có những Object cần tạo một lần và dùng chung xuyên suốt hệ thống như:

  • Các đối tượng hệ thống chứa dữ liệu chung (như database, file systems, printer,…). Phải đảm bảo rằng các đối tượng đó chỉ được khởi tạo một lần và là phiên bản duy nhất có thể được truy cập dễ dàng từ tất cả các phần của hệ thống
  • Khi muốn giữ data của một object ở mức tối thiểu và chỉ lưu suốt chương trình
  • Các đối tượng tồn tại duy nhất và dùng cho các thành phần khác như Logger, Network, Hardware Controller, …

Giải pháp

Những trường hợp như trên thì áp dụng Singleton là hoàn toàn hợp lý.

Định nghĩa: “Ensure a class only has one instance, and provide a global point of access to it”

Đúng như tên gọi “Singleton”, nó sinh ra với 3 nhiệm vụ chính như sau:

  • Đảm bảo một Object được khởi tạo duy nhất cho toàn bộ chương trình.
  • Cung cấp truy cập tới một object duy nhất đó tại bất cứ thời điểm, bất cứ nơi nào trong chương trình
  • Kiểm soát việc khởi tạo của chúng (ví dụ: ẩn Constructor của class đó).

Như vậy chắc các bạn đã hình dung Singleton dùng để làm gì và áp dụng cho những trường hợp nào rồi đúng không? Tiếp theo hãy cùng xem implement và sử dụng Singleton như thế nào nhé.

Implement Singleton in C++

Trước khi implement các bạn thử trả lời 2 câu hỏi sau nhé.

  • Làm thế nào có thể đảm bảo rằng một Class chỉ có một instance duy nhất?
  • Làm thế nào có thể truy cập đến instance duy nhất đó trên toàn bộ hệ thống?

Nếu như các bạn tạo một object theo cách thông thường là dùng new:

Singleton* instance = new Singleton();

Theo cách này các bạn sẽ phải nghĩ cách làm sao để giữ object này cho suốt toàn bộ chương trình, làm sao để truyền object bất cứ nơi nào của hệ thống để có dùng ở một nơi?

Sẽ cực kì khó đúng không? Vì mỗi lần các bạn khởi tạo theo các này, chương trình sẽ tạo một object mới.

Vậy có 2 điều chúng ta cần làm đó là:

  • Ẩn Constuctor để không cho tạo object bằng cách thông thường nữa: Đơn giản là chúng ta để Constructor dưới dạng private
  • Tiếp theo là chúng ta cần có một public method để trả ra instance duy nhất mỗi khi được gọi. Rất đơn giản, chúng ta sẽ dùng static để tạo sẵn một object và luôn trả ra object này. Vì như các bạn đã biết, biến static trong C/C++ sẽ tồn tại duy nhất và xuyên suốt chương trình.

Ok, chúng ta hãy implement một Singleton đơn giản theo 2 yêu cầu trên nhé

#include <string>
#include <iostream>
class Singleton
{
private:
    Singleton(std::string name) : m_name(name) {}
    ~Singleton() {}
    static Singleton* m_instance;
    std::string m_name;
public:

    static Singleton* getInstance(std::string name);
    static void deleteInstance();
    void show()
    {
        std::cout << "Singleton name: " << m_name << std::endl;
    }
};
Singleton* Singleton::getInstance(std::string name)
{
    if (m_instance == nullptr)
    {
        std::cout << "Create new object Singleton successfully" << std::endl;
        m_instance = new Singleton(name);
    }
    else
        std::cout << "Existing object Singleton, return it" << std::endl;
    return m_instance;
}

void Singleton::deleteInstance()
{
    if (m_instance) delete m_instance;
}

Singleton* Singleton::m_instance = nullptr;

int main() {
    auto instance = Singleton::getInstance("singleton 1");
    return 0;
}

Như các bạn đã thấy, chúng ta đã tạo một private static member

static Singleton* m_instance; bằng cách này biến này này sẽ tồn tại duy nhất trong suốt chương trình và chúng ta cũng để Constructor private để không khởi tạo object tùy ý, thay vào đó người dùng sẽ phải gọi Singleton::getInstance() vì dưới method này chúng ta sẽ kiểm soát nếu m_instance đã được khởi tạo thì không khởi tạo nữa mà chỉ trả ra đối tượng đó thôi.

Như vậy chúng ta đã có một Singleton đơn giản với C++ và thỏa mãn các yêu chỉ tồn tại duy nhất một đối tượng cho mọi lần gọi thông qua getInstance() và có thể gọi ở bất kì đâu tới đối tượng đó.

Tuy nhiên sau đây mình sẽ chỉ ra các vấn đề các bạn cần chú ý khi implement Singleton và tại sao nên hạn chế sử dụng nó.

Tại sao nên hạn chế sử dụng Singleton?

  • Thứ nhất rất dễ xảy ra xung đột khi khởi tạo từ nhiều threads

Ok, vấn đề chúng ta có thể sử lí đơn giản bằng cách thêm lock vào method getInstance() khi khởi tạo như sau:

#include <iostream>
#include <thread>
#include <mutex>
#include <vector>
#include <string>

class Singleton
{
private:
    Singleton(std::string name) : m_name(name) {}
    ~Singleton() {}
    static Singleton* m_instance;
    static std::mutex m_mutex;
    std::string m_name;
public:

    static Singleton* getInstance(std::string name);
    static void deleteInstance();
    //TODO
    void show()
    {
        std::cout << "Singleton name: " << m_name << std::endl;
    }
};

Singleton* Singleton::getInstance(std::string name)
{
    std::lock_guard<std::mutex> lock(m_mutex);
    if (m_instance == nullptr)
    {
        std::cout << "Create new object Singleton successfully" << std::endl;
        m_instance = new Singleton(name);
    }
    else
        std::cout << "Existing object Singleton, return it" << std::endl;
    return m_instance;
}

void Singleton::deleteInstance()
{
    if (m_instance) delete m_instance;
}

std::mutex Singleton::m_mutex;
Singleton* Singleton::m_instance = nullptr;

Bằng cách này chúng ta có thể tránh được race data khi có nhiều threads cùng khởi tạo.

  • Nhưng cách trên vẫn còn vấn đề là chúng ta rất khó kiểm soát được Instance đã được khởi tạo theo bằng threads nào, khởi tạo ở đâu?

Như ở ví dụ trên, nếu chúng ta gọi Singleton ở nhiều threads như sau:

int main() {
    int num_thread = 10;
    std::vector<std::thread> threads;
    threads.reserve(num_thread);
    for (int i = 0; i < num_thread; ++i) {
        threads.emplace_back([i]() {
            Singleton::getInstance("Instance " + std::to_string(i));
            });
    }
    for (int i = 0; i < num_thread; ++i) {
        threads[i].join();
    }
    auto instance = Singleton::getInstance("Name");
    instance->show();
    return 0;
}

Các bạn hãy thử chạy đoạn code trên nhiều lần nhé, kết quả là sẽ không thể chắc chắc được rằng cuối cùng thì Singleton được khởi tạo với name là gì, và như thế mỗi lần chúng ta sẽ nhận được một instance cuối cùng với tên khác nhau khi gọi instance->show() như sau:

Lần 1: "Singleto name: Instance 1"

Lần 2: "Singleto name: Instance 2"

Lần 3: "Singleto name: Instance 0"

Thật nguy hiểm đúng không.

  • Vấn đề tiếp rất khó để tham số hóa cho một object phức tạp khi muốn sử dụng Singleton và thể gây ra thiết kế lỗi.

Như ở ví dụ trên object đơn giản chỉ có một tham số đầu vào khi khởi tạo là m_name mà đã bị vấn đề như trường hợp 2 rồi. Vậy trong hệ thống có các object phức tạp hơn thì càng khó để áp dụng và kiểm soát hoàn toàn được nó

  • Non-const variable

Vì Singleton sử dụng Non-const static variable nên sẽ có rất nhất vấn đề khó kiểm soát, điển hình như ví dụ trên. Đó là một trong những điều nên tránh khi design

  • Rất khó để unitest cho Singleton

Trong thực tế rất khó để viết unitest cho từng thành phần của Singleton và khó để tạo một Singleton giả trong quá trình Runtime cho unistest, việc tạo Singleton không đúng có thể phá vỡ khả năng testing của hệ thống.

  • Việc implement Singleton rất đơn giản nhưng việc kiểm soát là cực khó, chúng ta luôn phải đảm bảo:

Ai chịu trách nhiệm destroy Singleton Instance? Như ở ví dụ của mình, trong một hệ thống phức tạp, có nhiều threads đang sử dụng cùng một Single, việc một thread xóa Singleton bằng cách gọi Singleton::deleteInstance() có thể ảnh hưởng đến threads khác. Điều này dẫn đến Undefined Behavior
Có thể bắt nguồn từ singleton không? Như ở ví dụ thứ hai, chúng ta không thể biết chính xác thread nào đã tạo Singleton.

Kết luận

Như vậy ở bài viết này, mình đã giới thiệu các trường hợp sử dụng Singleton, cách implement với C++. Singleton là Design Pattern đơn giản nhưng cũng rất kiểm soát như mình nêu ở trên, nên cách bạn hãy thực sự cẩn thận khi sử dụng Singleton trong hệ thống của mình 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 một ngày tốt lành ❤

Reference:

The GoF Design Patterns Reference
C++ Core Guidelines Explained by Rainer Grimm

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