C++设计模式精讲:单例模式的各种面貌与实现技巧

995 阅读12分钟

第一章:C++ 单例模式的常用写法

单例模式是一种常见的设计模式,用于确保一个类只有一个实例,并提供一个全局访问点。在C++中实现单例模式,核心思想是控制构造函数、析构函数的访问级别,并确保全局唯一的访问实例。

1. 基本实现

最基本的单例模式实现涉及将构造函数、析构函数设为私有或受保护的,以防止外部通过new或delete创建或销毁实例。同时,提供一个静态方法用于访问此唯一实例。

class Singleton {
private:
    static Singleton* instance;
    Singleton() {}  // 私有构造函数

public:
    Singleton(const Singleton&) = delete;            // 禁止拷贝构造
    Singleton& operator=(const Singleton&) = delete; // 禁止赋值操作

    static Singleton* getInstance() {
        if (instance == nullptr) {
            instance = new Singleton();
        }
        return instance;
    }
};

Singleton* Singleton::instance = nullptr;

在这个例子中,getInstance 方法负责检查实例是否已经创建,如果没有,则创建一个新的实例。这种方式简单直接,但在多线程环境下可能会导致竞态条件。

2. 线程安全的改进

为了使单例模式在多线程中安全,可以通过加锁来确保实例创建的唯一性。

#include <mutex>

class Singleton {
private:
    static Singleton* instance;
    static std::mutex mutex;
    Singleton() {}

public:
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

    static Singleton* getInstance() {
        std::lock_guard<std::mutex> lock(mutex);
        if (instance == nullptr) {
            instance = new Singleton();
        }
        return instance;
    }
};

Singleton* Singleton::instance = nullptr;
std::mutex Singleton::mutex;

这种方式通过一个静态的互斥锁mutex确保在任何时候只有一个线程可以执行实例化代码,从而保证了线程安全。但是,每次调用getInstance时都会进行锁操作,可能会影响性能。

3. 双重检查锁定模式(Double-Checked Locking)

为了优化性能,可以使用双重检查锁定模式,在锁的外部再次检查实例是否已经创建。

class Singleton {
private:
    static Singleton* instance;
    static std::mutex mutex;
    Singleton() {}

public:
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

    static Singleton* getInstance() {
        if (instance == nullptr) {  // First check
            std::lock_guard<std::mutex> lock(mutex);
            if (instance == nullptr) {  // Double check
                instance = new Singleton();
            }
        }
        return instance;
    }
};

Singleton* Singleton::instance = nullptr;
std::mutex Singleton::mutex;

这种方法首先检查实例是否已存在,如果不存在,才进行加锁和第二次检查。这减少了锁的使用频率,从而提高了效率。

以上是单例模式在C++中的几种常见实现方式,每种方式都有其适用场景和性能考虑。在实际应用中,需要根据具体需求和环境选择合适的实现策略。

在第一章中,我确实没有涉及到使用 static 关键字的静态局部变量方式以及原子操作,这些都是提高代码效率和安全性的有效方法。让我们补充这部分内容。

4. 使用静态局部变量实现单例

在C++中,使用静态局部变量实现单例是一种更为简洁且线程安全的方法。自C++11起,局部静态变量的初始化保证了线程安全,这意味着在第一次调用函数时,静态变量会被初始化一次,并且这个初始化过程是线程安全的。

class Singleton {
public:
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

    static Singleton& getInstance() {
        static Singleton instance;
        return instance;
    }

private:
    Singleton() {}  // 私有构造函数
};

在这种实现中,getInstance 返回一个引用,对应于一个局部静态变量。这种方式避免了显示的锁操作,同时保证了线程安全,是实现单例模式的推荐方式。

5. 使用原子操作和std::atomic

使用 std::atomiccompare_exchange_strong 实现单例

compare_exchange_strong 方法试图将一个原子对象的值与提供的期望值(expected value)进行比较,如果相等,就用新的值替换原来的值。这是一个原子操作,适用于无锁编程。

以下是使用 compare_exchange_strong 实现的单例模式示例:

#include <atomic>
#include <memory>

class Singleton {
private:
    static std::atomic<Singleton*> instance;
    static std::mutex mutex;
    Singleton() {}

public:
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

    static Singleton* getInstance() {
        Singleton* tmp = instance.load(std::memory_order_relaxed);
        std::atomic_thread_fence(std::memory_order_acquire); // 获取内存栅栏
        if (tmp == nullptr) {
            std::lock_guard<std::mutex> lock(mutex);
            tmp = instance.load(std::memory_order_relaxed);
            if (tmp == nullptr) {
                tmp = new Singleton();
                std::atomic_thread_fence(std::memory_order_release); // 释放内存栅栏
                instance.store(tmp, std::memory_order_relaxed);
            }
        }
        return tmp;
    }
};

std::atomic<Singleton*> Singleton::instance(nullptr);

在这个实现中,compare_exchange_strong 没有直接展示,因为实际代码中通常结合使用互斥锁来确保只创建一次实例。但是,可以进一步优化以避免锁的使用:

static Singleton* getInstance() {
    Singleton* tmp = instance.load(std::memory_order_acquire);
    if (tmp == nullptr) {
        Singleton* expected = nullptr;
        tmp = new Singleton();
        if (!instance.compare_exchange_strong(expected, tmp,
                                              std::memory_order_release,
                                              std::memory_order_relaxed)) {
            delete tmp;
            tmp = expected;
        }
    }
    return tmp;
}

在这种优化的实现中,当第一个线程正在创建实例时,其他线程会在 compare_exchange_strong 处等待。如果第一个线程成功创建了实例,其他线程会接收到已创建的实例;如果创建失败,其他线程会尝试再次创建实例。这种方式减少了锁的使用,提高了效率,特别适用于实例创建开销较大且访问频繁的场景。

第二章:单例类的继承行为

在设计模式中,单例模式因其特殊的构造和全局访问性质,对继承行为有一定的限制。本章将深入探讨继承单例类时需要注意的问题、可能出现的挑战以及如何合理地应用继承来扩展单例类的功能。

1. 单例模式与继承的基本冲突

单例模式要求类的构造函数为私有或受保护,这意味着不能从外部直接创建类的实例,只能通过类自身提供的静态方法来获取唯一实例。这一限制直接影响了继承:

  • 私有构造函数:如果父类构造函数是私有的,则子类无法直接调用这个构造函数,从而无法完成派生。
  • 受保护构造函数:如果构造函数是受保护的,子类可以调用,但外部仍无法创建子类实例,这意味着单例的控制必须由子类内部逻辑自己管理。

2. 继承单例类的实现

考虑到单例模式的这些特性,如果需要通过继承来扩展单例类的功能,可以采用如下策略:

示例代码

假设有一个基本的日志记录器单例:

class Logger {
protected:
    Logger() {}  // 受保护构造函数,允许继承

public:
    Logger(const Logger&) = delete;
    Logger& operator=(const Logger&) = delete;

    static Logger& getInstance() {
        static Logger instance;
        return instance;
    }

    virtual void log(const std::string& message) {
        std::cout << "Log: " << message << std::endl;
    }
};

现在我们想要通过继承增加一些特殊的日志处理方式:

class FileLogger : public Logger {
public:
    FileLogger() {}  // 允许构造

    static FileLogger& getInstance() {
        static FileLogger instance;
        return instance;
    }

    void log(const std::string& message) override {
        std::ofstream file("log.txt", std::ios::app);
        file << "File Log: " << message << std::endl;
        file.close();
    }
};

在这个例子中,FileLogger 继承自 Logger 并重写了 log 方法以支持文件日志记录。通过保持构造函数为受保护,它确保了单例的约束同时允许继承。

3. 继承中的设计和考虑

在设计继承单例的类时,有几个关键点需要考虑:

  • 保持单例约束:确保每个子类也遵循单例模式的约束,包括控制实例的创建和全局访问。
  • 方法的重写和扩展:子类应当能够扩展或改变父类的行为,但这种改变需要保持对单例模式的尊重。
  • 资源管理和释放:考虑到单例通常在程序的整个生命周期内存在,子类在增加资源如文件句柄、网络连接时,应当小心管理这些资源的释放。

继承单例模式的类需要仔细设计,确保既满足单例的要求,也提供足够的灵活性来扩展功能。这种类型的设计在需要保持全局状态同时又需扩展功能的场合特别有用,如不同类型的日志记录、配置管理器等。

单例模式和 getInstance 方法的继承

原因一:类型不匹配

单例模式的核心是确保类的实例唯一,并通过一个静态方法 getInstance 全局访问这个实例。在基类 Logger 中,getInstance 返回的是 Logger 类型的引用。如果 FileLogger 继承了 LoggergetInstance,它将返回一个 Logger 类型的引用,而不是 FileLogger 类型的引用。

这会导致几个问题:

  • 类型安全:返回的实例类型不是 FileLogger,使用时可能需要类型转换,增加了错误的风险。
  • 功能访问:如果 FileLogger 添加了新的方法或属性,这些功能在通过 LoggergetInstance 返回的实例上不可用,除非进行类型转换。

原因二:单例的唯一性

每个单例类管理自己的唯一实例。如果 FileLogger 直接继承 LoggergetInstance,那么 FileLoggerLogger 会共享相同的实例,这违反了单例模式的基本原则,即每个单例类应有且只有一个实例。

解决方案:重写 getInstance

为了保持单例的完整性和独立性,同时确保返回正确的类型,FileLogger 需要重写 getInstance 方法来创建和管理自己的单例实例。这样,FileLogger 可以保持独立于 Logger,并且 getInstance 方法将返回正确类型的实例,无需任何类型转换即可访问所有 FileLogger 特有的方法和属性。

示例代码的重要性

通过在每个单例子类中重新实现 getInstance,我们确保了:

  • 类型安全和正确性。
  • 每个类维护自己的单例实例,符合单例模式的设计原则。

这种设计方法虽然在代码中增加了一些重复,但它提高了代码的健壮性和清晰性,并确保了在扩展类和维护类的单例状态时的正确性和易管理性。

第三章:单例模板的继承行为

在C++中,单例模式可以通过模板来实现更为灵活的设计,允许类似于工厂模式的用法,同时保持单例的特性。本章将探讨使用模板实现的单例类在继承时的行为和挑战,以及如何有效地使用模板来扩展单例模式的功能。

1. 模板单例的基本概念

单例模式的模板化意味着将单例类定义为模板类,从而可以为不同的类型生成唯一的实例。这种方法的好处在于它提供了极大的灵活性,使得单例模式可以应用于多种不同类型,每种类型都维护其独立的单例实例。

基本模板单例实现
template<typename T>
class Singleton {
public:
    static T& getInstance() {
        static T instance;
        return instance;
    }

protected:
    Singleton() {}
    ~Singleton() {}

    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;
};

在这个模板中,每个特化类型 T 都会拥有自己的 getInstance 方法和唯一实例。这种设计支持了类型的多样性和单一实例的需求。

2. 模板单例与继承

当单例模式与模板和继承结合时,会出现一些独特的设计和实现挑战。

继承模板单例

假设有一个具体的类需要继承自一个模板单例类:

class Logger : public Singleton<Logger> {
    friend class Singleton<Logger>;  // 允许Singleton访问Logger的私有构造函数

public:
    void log(const std::string& message) {
        std::cout << "Log: " << message << std::endl;
    }

private:
    Logger() {}
};

在这种情况下,Logger 类继承自 Singleton<Logger>,使得 Logger 成为一个模板单例的具体实现。这样设计的优点是,可以直接使用 SingletongetInstance 方法,同时为 Logger 类型提供全局访问点。

3. 模板单例的复用和扩展(基于CRTP的单例模式实现)

模板单例的一个重要优势是它的复用性和扩展性。通过模板参数,开发者可以为不同的类提供单例支持,而无需重写单例的管理逻辑。

这里是一个使用CRTP实现单例模式的例子:

template<typename T>
class Singleton {
public:
    static T& getInstance() {
        static T instance;
        return instance;
    }

protected:
    Singleton() {}  // 保护构造函数,防止外部构造
    ~Singleton() {}  // 保护析构函数

public:
    Singleton(Singleton const&) = delete;
    Singleton& operator=(Singleton const&) = delete;
};

// 实际的派生类
class Logger : public Singleton<Logger> {
    friend class Singleton<Logger>;  // 允许基类访问私有构造函数
public:
    void log(const std::string& message) {
        std::cout << "Log: " << message << std::endl;
    }

private:
    Logger() {}  // 私有构造函数
};

在这个实现中:

  • Singleton 模板类作为基类,提供了一个静态的 getInstance 方法。
  • Logger 类继承自 Singleton<Logger>,即应用了CRTP。这允许 Logger 通过 Singleton 的静态方法 getInstance 直接返回 Logger 类型的实例。

CRTP的优势

  • 类型安全getInstance 方法自然地返回正确的类型,不需要任何额外的类型转换。
  • 代码复用:基类的 getInstance 方法可以被多个派生类复用,每个派生类自动地维护其单例实例。
  • 性能优化:这种方式没有运行时的多态开销,因为所有的函数调用都在编译时被解析。

通过CRTP,你可以非常优雅地实现单例模式,同时保证代码的简洁和效率。这种方法特别适用于需要多个单例类型的场景,每个单例都可以通过相同的方式访问其唯一实例,同时保持实现的一致性和最小化代码重复。

常规单例与继承

在常规的单例实现中,如不使用 CRTP 的情况:

  • **基类 **Singleton 提供一个静态的 getInstance 方法,这个方法返回基类类型的引用或指针。
  • **派生类 **FileLogger 也需要维护自己的唯一实例。如果使用的是传统继承,派生类必须重新实现 getInstance 方法来确保返回的是其自身类型的实例。这是因为基类的 getInstance 方法返回的类型固定为基类类型,而不是当前派生类的类型。

这种情况下,如果派生类不实现自己的 getInstance 方法,它将无法确保返回的实例类型正确,也无法保证单例的唯一性(每个类类型一个实例)。

使用CRTP的单例模式

当使用 CRTP(Curiously Recurring Template Pattern)时,情况有所不同:

  • **基类 **Singleton 成为模板类,使用模板参数 T 表示将要成为单例的具体类型。
  • **派生类 **Logger 通过将自身类型作为模板参数传给 Singleton,继承自 Singleton<Logger>

在这种模式下:

  • getInstance** 方法** 是通过基类模板直接生成的,并且因为它是模板方法,它自然地对应于正确的派生类类型 T。这意味着每个派生类都有其自己的 getInstance 实现,这是由编译器自动生成的,确保了返回类型的正确性和单例的唯一性。

因此,在使用 CRTP 的情况下,每个派生类不需要单独实现 getInstance 方法,因为模板机制自动为每个具体的类类型提供了适当的实现。这是模板元编程的强大之处,它提供了类型安全、自动化,并且非常适合实现如单例模式这样的设计模式。

4. 构造函数的访问权限

在单例模式中,构造函数通常被设置为 privateprotected,以防止外部代码通过正常的构造途径创建类的实例。这是单例模式的一个核心特征,确保了类实例的全局唯一性。

没有 friend 声明的后果

在使用 CRTP(Curiously Recurring Template Pattern)实现单例时,基类 Singleton 需要能够访问派生类 Logger 的构造函数来创建其单例实例。如果不声明 Singleton<Logger>Logger 的友元类,则会导致以下问题:

  1. 构造函数访问受限
    • 如果 Logger 的构造函数是 private,那么即便是 Singleton<Logger> 也无法访问它,因为它只被 Logger 类自身和其友元类所允许访问。
    • 这将导致编译错误,因为 Singleton<Logger>::getInstance() 方法无法实例化 Logger 类的对象。
  2. 违反单例的封装性
    • 如果为了解决这个问题而将 Logger 的构造函数改为 public,则会违反单例模式的封装性,因为这允许外部代码直接创建 Logger 类的实例,从而破坏单例的全局唯一性。

使用 friend 声明的好处

通过在 Logger 类中添加 friend class Singleton<Logger>; 声明,可以解决上述问题:

  • 保持封装性Logger 的构造函数可以保持 private 访问级别,确保只有 Singleton<Logger> 可以创建其实例。
  • 维护单例性质:这样做既保持了单例的控制(通过 Singleton 模板),又防止了外部代码违规创建 Logger 实例。

结论

在使用模板实现单例模式的时候,声明基类为友元是一个常见且重要的做法,它确保了单例实现的正确性和类设计的封装性。这种设计允许单例模板类专门访问派生类的构造函数,而不必公开派生类的构造函数,从而严格控制实例的创建。这是一个优雅的解决方案,它既保持了面向对象设计的原则,也符合单例模式的要求。


第四章:单例模式实现总结表

特性普通单例继承单例继承模板单例
私有构造函数必需必需必需 (基类中定义)
静态实例方法必需必需 (每个子类实现)不必需 (由模板自动实现)
拷贝构造函数删除必需必需 (继承/重新声明)必需 (基类中定义)
赋值操作符删除必需必需 (继承/重新声明)必需 (基类中定义)
受保护/私有析构函数可选可选必需 (基类中定义)
友元类声明可选 (取决于需求)可选 (取决于需求)必需 (用于访问构造函数)
线程安全实现必需 (高并发环境)必需 (高并发环境)必需 (高并发环境)
延迟初始化可选可选可选

说明:

  • 私有构造函数:确保不能从类外部实例化对象,这是单例模式的基本要求。
  • 静态实例方法(通常是 getInstance):提供全局访问点并控制实例的创建。
  • 拷贝构造函数和赋值操作符删除:防止实例被复制或赋值,确保全局唯一性。
  • 受保护/私有析构函数:确保单例类的派生类可以适当地管理资源释放,特别是在继承模板单例中,基类通常会定义析构函数以防止外部代码错误地删除实例。
  • 友元类声明:在使用CRTP时,允许基类访问派生类的私有或受保护构造函数,这是必需的以确保可以实例化单例。
  • 线程安全实现:在多线程环境下,确保单例的实例化过程是安全的,通常通过锁或其他机制实现。
  • 延迟初始化:根据实例创建的成本和使用模式,可以选择实现延迟初始化,即在首次使用时才创建实例。