如何优雅地在C++库中实现实例化设计(一)https://developer.aliyun.com/article/1464310
- 初始化控制:
- 基类为单例:基类控制单例的创建,可以确保单例在使用前被正确初始化。
- 派生类为单例:每个派生类都需要正确地初始化其单例。
- 都为单例:基类和所有派生类都需要正确地初始化其单例。
方面 | 基类为单例 | 派生类为单例 | 都为单例 |
可测试性 | 中等:单例模式可能会使测试变得复杂,因为它会在测试间保持状态。 | 同上 | 同上 |
可调试性 | 中等:如果单例的初始化导致问题,可能需要跟踪类的代码来找出问题。 | 高:可以在每个派生类中跟踪问题。 | 低:需要在基类和所有派生类中跟踪问题。 |
代码可读性 | 高:所有与单例相关的代码都在一个地方。 | 中等:代码分散在每个派生类中。 | 低:代码分散在基类和所有派生类中。 |
性能 | 高:只需要初始化和存储一个基类的实例。 | 中等:需要初始化和存储每个派生类的一个实例。 | 低:需要初始化和存储基类和所有派生类的实例。 |
兼容性 | 高:类内部的静态成员在所有C++标准中都受到支持。 | 同上 | 同上 |
灵活性 | 低:更改单例的实现需要更改基类的代码。 | 高:可以很容易地更改派生类的单例实现。 | 低:更改单例的实现需要更改基类和所有派生类的代码。 |
健壮性 | 高:基类可以确保单例在使用前被正确初始化。 | 中等:每个派生类都需要正确地初始化其单例。 | 低:基类和所有派生类都需要正确地初始化其单例。 |
可重用性 | 中等:可以在多个上下文中使用基类,但每个上下文都会共享一个单例。 | 高:可以在多个上下文中使用每个派生类,每个上下文都可以有自己的单例。 | 低:所有的类都是单例,限制了他们的重用性。 |
抽象性 | 高:基类提供了一个统一的接口。 | 低:每个派生类都有自己的接口。 | 中等:基类提供了一个统一的接口,但每个派生类也有自己的接口。 |
并发控制 | 高:基类可以确保并发访问的安全。 | 中等:每个派生类都需要自己确保并发访问的安全。 | 低:基类和所有派生类都需要自己确保并发访问的安全 |
方案一: 派生类为单例
如果派生类需要以单例形式存在,那么可以在该派生类中实现单例模式。然后你可以为用户提供一个方法,以获取该类的唯一实例的基类指针或引用。
假设我们有一个Animal
基类和一个Dog
派生类,Dog
是一个单例,那么可以这样实现:
class Animal { public: virtual ~Animal() = default; virtual void speak() const = 0; }; class Dog : public Animal { public: static Dog& getInstance() { static Dog instance; return instance; } void speak() const override { std::cout << "Woof!\n"; } private: Dog() = default; // private constructor Dog(const Dog&) = delete; // no copy Dog& operator=(const Dog&) = delete; // no copy assignment };
然后,用户可以通过以下方式获取Dog
的单例并使用它:
Animal& myDog = Dog::getInstance(); myDog.speak(); // Outputs "Woof!"
请注意,因为单例模式需要控制类的实例化过程,所以Dog
的构造函数是私有的,这阻止了用户直接创建Dog
的实例。此外,为了防止复制Dog
的实例,我们也禁用了复制构造函数和复制赋值运算符。所有这些都确保了Dog
的单例性质。
这种设计方式的优点是你可以保持基类接口的多态性,同时为某些派生类提供单例的实例化方式。然而,它也有一个缺点,那就是用户不能直接创建派生类的实例,只能通过你提供的方法来获取实例。
如果你想在基类中持有一个指向派生类单例的引用(或者指针),那么你需要在基类中声明一个静态成员,类型是基类指针。
这里有一个例子,它展示了如何在基类中保存对派生类单例的引用:
class BaseClass { public: static BaseClass* instance; // Pointer to the singleton instance. BaseClass() { // Constructor can be called multiple times. } virtual void doSomething() = 0; // Pure virtual function. }; BaseClass* BaseClass::instance = nullptr; // Initialize static member. class SingletonDerived : public BaseClass { public: static SingletonDerived& getInstance() { static SingletonDerived instance; // Guaranteed to be destroyed, instantiated on first use. BaseClass::instance = &instance; // Update the base class's pointer. return instance; } SingletonDerived(const SingletonDerived&) = delete; // Delete copy constructor. void operator=(const SingletonDerived&) = delete; // Delete copy assignment operator. private: SingletonDerived() {} // Make constructor private. ~SingletonDerived() {} // Make destructor private. void doSomething() override { // Implementation of the virtual function. } };
在这个例子中,基类 BaseClass
有一个静态成员 instance
,这是一个指向 BaseClass
的指针。当 SingletonDerived
的单例被创建时(即,当 getInstance
第一次被调用时),BaseClass::instance
被更新为指向 SingletonDerived
的单例。
这样,你就可以通过 BaseClass::instance
访问到派生类的单例。请注意,因为 instance
是基类指针,所以你只能通过它访问到基类的成员和虚函数,除非你将它强制转换为派生类指针。
如果你希望预先初始化好单例对象,并且确保用户在使用库时无需进行任何初始化步骤,这完全是可行的。在这种情况下,你可以使用静态初始化来创建单例对象。这种方法的好处是线程安全,并且保证了对象在首次使用之前就已经创建。
例如,对于Animal
基类和Dog
派生类,你可以这样实现:
class Animal { public: virtual ~Animal() = default; virtual void speak() const = 0; }; class Dog : public Animal { public: static Dog& getInstance() { static Dog instance; return instance; } void speak() const override { std::cout << "Woof!\n"; } private: Dog() = default; Dog(const Dog&) = delete; Dog& operator=(const Dog&) = delete; };
然后,在库内部,你可以创建一个全局的Animal
引用,指向Dog
的单例:
Animal& g_Animal = Dog::getInstance();
这样,Dog
的单例就会在程序启动时自动创建,并且用户可以直接使用g_Animal
,无需进行任何初始化。
请注意,这种方法的缺点是库内部的全局变量g_Animal
对所有用户都是可见的,这可能会导致命名冲突。为了避免这种问题,你可以将全局变量放在命名空间中,或者提供一个函数来返回Animal
的引用:
Animal& getAnimal() { return Dog::getInstance(); }
这样,用户可以通过调用getAnimal
函数来使用Animal
:
Animal& myAnimal = getAnimal(); myAnimal.speak(); // Outputs "Woof!"
这种设计方式的优点是简单易用,用户只需要调用一个函数就可以使用你的库。然而,它的缺点是缺乏灵活性,如果你在未来需要添加更多的动物类型,你可能需要更改你的库和用户的代码。
- 总结:
以上所述的都是在派生类中实现单例模式的方式。
- 第一种方式是在派生类中实现单例模式,并提供一个静态的
getInstance()
方法来获取该单例。这种方式的优点是保持了基类接口的多态性,同时为某些派生类提供单例的实例化方式。但是,它的缺点是用户不能直接创建派生类的实例,只能通过getInstance()
方法来获取实例。 - 第二种方式是在基类中保持一个对派生类单例的引用或指针。这样,你就可以通过基类的静态成员来访问派生类的单例。这种方式的优点是可以在基类中统一管理所有派生类的单例,但是它的缺点是增加了基类和派生类之间的耦合度。
- 第三种方式是在库内部创建一个全局的引用,指向派生类的单例。这样,用户可以直接使用这个全局的引用,而无需调用
getInstance()
方法。这种方式的优点是简化了用户的使用,但是它的缺点是全局的引用可能会导致命名冲突。
这些方式都有各自的优点和缺点,你可以根据你的需求和应用场景选择合适的方式来实现派生类的单例模式。
方案二:基类为单例
如果你的基类Animal
也需要以单例形式存在,并且该基类会在一个库中被实例化为一个特定的派生类,那么你可以在基类中定义一个静态方法,该方法返回一个引用到该类的唯一实例。然后在派生类库中,你可以在全局范围内初始化该单例。
首先,你的基类可能看起来像这样:
class Animal { public: virtual ~Animal() = default; virtual void speak() const = 0; // 允许设置Animal单例,只能设置一次 static void setInstance(Animal* instance) { assert(!instance_ && "Instance already set!"); instance_ = instance; } // 获取Animal单例的引用 static Animal& getInstance() { assert(instance_ && "Instance not set!"); return *instance_; } private: static Animal* instance_; }; // 初始化静态成员 Animal* Animal::instance_ = nullptr;
然后,在你的派生类库中,你可以在全局范围内创建一个派生类的实例,并将其设置为Animal
的单例:
class Dog : public Animal { public: void speak() const override { std::cout << "Woof!\n"; } }; // 创建Dog实例并设置为Animal单例 Dog g_Dog; struct Initializer { Initializer() { Animal::setInstance(&g_Dog); } } g_Initializer;
在这个例子中,g_Dog
是Dog
的一个实例,g_Initializer
是一个全局对象,它在构造函数中将g_Dog
设置为Animal
的单例。因为全局对象在程序启动时就会被创建,所以Animal
的单例会在程序启动时自动被设置。
然后,用户可以通过Animal::getInstance
来获取Animal
的单例:
Animal& myAnimal = Animal::getInstance(); myAnimal.speak(); // Outputs "Woof!"
这种设计方式的优点是用户只需要知道Animal
接口,而不需要知道具体的派生类。然而,它的缺点是需要用户在每次使用Animal
之前都调用getInstance
,这可能会增加用户的使用复杂性。同时,你需要确保Animal
的单例在用户开始使用之前就已经被设置,否则getInstance
将会失败。
如果你希望在类的外部声明一个指针来持有单例对象的引用,那么这也是可以做到的。在这种情况下,你的基类和派生类可以不包含任何静态成员。然后,你可以在全局范围内声明一个指针,该指针用于保存单例对象的引你的基类和派生类可以像这样:
class Animal { public: virtual ~Animal() = default; virtual void speak() const = 0; }; class Dog : public Animal { public: void speak() const override { std::cout << "Woof!\n"; } };
然后,你可以在全局范围内声明一个Animal
指针,该指针用于保存单例对象的引用:
Animal* g_Animal = nullptr;
在你的派生类库中,你可以在全局范围内创建一个派生类的实例,并将其地址赋给g_Animal
:
Dog g_Dog; struct Initializer { Initializer() { g_Animal = &g_Dog; } } g_Initializer;
在这个例子中,g_Dog
是Dog
的一个实例,g_Initializer
是一个全局对象,它在构造函数中将g_Dog
的地址赋给g_Animal
。因为全局对象在程序启动时就会被创建,所以g_Animal
会在程序启动时自动被设置。
然后,用户可以通过g_Animal
来访问Animal
的单例:
g_Animal->speak(); // Outputs "Woof!"
这种设计方式的优点是简单明了,用户可以直接使用g_Animal
,无需调用任何函数。然而,它的缺点是g_Animal
对所有用户都是可见的,这可能会导致命名冲突。为了避免这种问题,你可以将g_Animal
放在命名空间中,或者提供一个函数来返回Animal
的引用。同时,你需要确保g_Animal
在用户开始使用之前就已经被设置,否则用户可能会访问到一个空指针。
上述方法都是介绍了如何在基类中实现单例模式,同时派生类不是单例的情况。具体来说:
- 第一种方式是在基类
Animal
中设置和获取单例实例的静态方法,并在派生类库中全局初始化Animal
的单例。这个设计方式的优点是,用户只需要知道Animal
接口,而不需要知道具体的派生类。然而,它的缺点是需要用户在每次使用Animal
之前都调用getInstance
,这可能会增加用户的使用复杂性。 - 第二种方式是在全局范围内声明一个指针,该指针用于保存单例对象的引用。这种设计方式的优点是简单明了,用户可以直接使用
g_Animal
,无需调用任何函数。然而,它的缺点是g_Animal
对所有用户都是可见的,这可能会导致命名冲突。同时,你需要确保g_Animal
在用户开始使用之前就已经被设置,否则用户可能会访问到一个空指针。
这些方式都有各自的优点和缺点,你可以根据你的需求和应用场景选择合适的方式来实现基类的单例模式。同时,你需要确保基类的单例在用户开始使用之前就已经被设置,否则可能会出现错误。
类内创建静态成员对象和类外声明指针的实例化方式对比
- 简单性:
- 类内静态成员:适中。需要在类内部正确处理单例模式。
- 类外指针:简单。只需要一个全局指针和对象。
- 封装性:
- 类内静态成员:高。单例实例的所有相关处理都在类内部进行。
- 类外指针:低。单例实例的处理在类外部进行。
- 命名冲突:
- 类内静态成员:低。通过类方法访问单例实例。
- 类外指针:可能存在。全局指针在任何地方都可以访问。
- 初始化控制:
- 类内静态成员:高。类控制单例实例的创建时间和方式。
- 类外指针:低。取决于全局对象的初始化顺序。
- 线程安全:
- 类内静态成员:高。可以通过Meyers’单例或call_once保证。
- 类外指针:低。如果在运行时进行初始化,需要手动同步。
- 内存管理:
- 类内静态成员:自动。实例会在程序退出时被正确销毁。
- 类外指针:自动。实例会在程序退出时被正确销毁。
- 用户使用难易程度:
- 类内静态成员:适中。用户必须调用特定的类方法来访问实例。
- 类外指针:高。用户可以直接访问全局指针。
方面 | 类内静态成员 | 类外指针 |
可扩展性 | 低:更改单例实现可能需要更改类的内部代码。 | 高:可以通过更改指针指向的对象来更改单例的实现。 |
可测试性 | 中等:单例模式可能会使测试变得复杂,因为它会在测试间保持状态。 | 同上 |
可调试性 | 中等:如果单例的初始化导致问题,可能需要跟踪类的代码来找出问题。 | 高:全局指针可以很容易地在调试器中查看和修改。 |
代码可读性 | 高:所有与单例相关的代码都在一个地方。 | 中等:代码分散在声明指针的地方和初始化指针的地方。 |
维护性 | 中等:所有与单例相关的代码都在一个地方,但是更改可能会影响类的其他部分。 | 高:只需要维护指针和它指向的对象。 |
性能 | 高:不需要额外的指针解引用。 | 中等:需要额外的指针解引用,但这通常不会有太大影响。 |
兼容性 | 高:类内部的静态成员在所有C++标准中都受到支持。 | 高:全局指针在所有C++标准中都受到支持。 |
灵活性 | 低:更改单例的实现需要更改类的代码。 | 高:可以很容易地更改指针指向的对象。 |
健壮性 | 高:类可以确保单例在使用前被正确初始化。 | 低:需要确保全局指针在使用前被正确初始化。 |
可重用性 | 中等:可以在多个上下文中使用类,但每个上下文都会共享一个单例。 | 高:可以在多个上下文中使用全局指针,每个上下文都可以有自己的单例。 |