自学内容网 自学内容网

C++--特殊类的设计

下面所实现类的源码:源码链接


不可拷贝类

在C++中,我们有时候需要设计一些不可拷贝的类,即不允许用户通过拷贝构造函数或赋值操作来创建该类的副本。这样设计通常是为了确保资源(如文件描述符、窗口句柄等)的唯一性,或者是为了避免在多线程环境中由于拷贝而导致的数据竞争等问题。

为什么要设计该类?

  • 资源管理: 某些资源只能有一个对象持有,如果允许拷贝,则可能导致多个对象共享同一资源,造成资源混乱。
  • 安全性: 防止对象状态被意外修改,特别是在多线程环境中,拷贝对象可能造成数据竞争和不一致性。
  • 性能考虑: 拷贝可能涉及到大量的数据复制,对于包含大量数据或者昂贵资源的对象来说,拷贝可能是非常耗时的操作。

在C++编程中,通常会将那些持有独占资源或者需要严格控制其生命周期的类设计为不可拷贝。如:

  1. 智能指针:
    • std::unique_ptr:这种智能指针的设计目的是独占管理一个动态分配的对象。由于unique_ptr的设计原则是保证每次只有一个指向对象的指针存在,因此它是不可拷贝的。不过,它支持移动语义,可以通过std::move来转移所有权。
    • 其他类似的独占资源管理类,如Boost库中的scoped_ptr(已废弃)或boost::unique_ptr
  2. 文件句柄或流:
    • 文件I/O相关的类,例如std::FILE*的包装类,通常不允许拷贝,因为文件句柄通常是独占的,拷贝会导致资源冲突。
    • 类似地,网络编程中的套接字对象也是不拷贝的。
  3. 图形界面对象:
    • 在GUI编程中,窗口句柄或类似对象通常也不允许拷贝,因为每个窗口都必须有一个唯一的句柄。
    • 比如Qt框架中的QWindow,它代表了一个平台窗口对象,这类对象通常是不可拷贝的。
  4. 数据库连接:
    • 数据库连接对象,如AQLAlchemy中的Connection对象,通常也是不可拷贝的,以保证数据库连接的唯一性和事务的完整性。
  5. 锁和互斥量:
    • 用于同步的类,例如std::mutexstd::lock_guardstd::unique_lock等,它们控制着并发环境下的资源访问,因此也是不可拷贝的。
  6. 其他独占资源管理器:
    • 任何负责独占管理(如内存、硬件设备等)的类都应该设计为不可拷贝。

如何实现该类?

要实现一个不可拷贝的类,我们需要禁止拷贝构造函数和赋值操作。可以通过以下方式达到这一目的:

方案一:使用delete关键字

这是C++11及以后版本推荐的方法。(如果你想对C++11语法有所了解的话,可以参考这篇博客( •̀ ω •́ )✧,参考博客

class NonCopyable
{
public:
// 默认构造函数
NonCopyable() = default;

// 禁用拷贝构造函数
NonCopyable(const NonCopyable&) = delete;

// 禁用赋值运算符
NonCopyable& operator=(const NonCopyable&) = delete;

// 可以提供移动构造函数和移动赋值运算符(如果需要)
NonCopyable(NonCopyable&&) = default;
NonCopyable& operator=(NonCopyable&&) = default;

// 其他成员函数
void doSomething()
{
// ...
}
};

方案二:将拷贝构造函数和赋值运算符设为私有

这是C++11之前常用的方法,但不如使用delete明确。

class NonCopyable
{
private:
// 私有拷贝构造函数和赋值运算符
NonCopyable(const NonCopyable&) = delete;
NonCopyable& operator=(const NonCopyable&) = delete;
public:
// 默认构造函数
NonCopyable() = default;

// 可以提供移动构造函数和移动赋值运算符(如果需要)
NonCopyable(NonCopyable&&) = default;
NonCopyable& operator=(NonCopyable&&) = default;

// 其他成员函数
void doSomething()
{
// ...
}

// 友元类或函数(如果需要)
// friend class SomeFriendClass;
};

注意: 不需要实现私有拷贝构造函数和赋值运算符,只需声明即可。

这种做法的目的是为了阻止类的外部代码使用这些函数。由于这些函数被声明为私有,它们只能在类的内部或者友元函数中被调用。然鹅,由于我们并不打算在类的内部或者友元函数中使用这些拷贝功能,因此没有必要为它们提供实现。

不实现这些函数的好处是:

  1. 避免潜在错误: 即使不小心在类的内部调用了这些函数,由于它们没有实现,编译器也会立即报错,从而提醒开发者注意。
  2. 清晰性: 通过仅声明不实现,明确表达了这些函数不应该被使用的意图。
  3. 减少代码量: 无需编写额外的、不会被使用的函数体。

补充:

  1. 继承: 如果不可拷贝类被用作基类,派生类默认会继承基类的拷贝构造函数和赋值运算符。如果派生类也需要是不可拷贝的,则需要在派生类中重复上述操作。
  2. 编译器警告: 确保编译器没有因为未实现的函数而发出警告。使用delete关键字可以避免这种警告。

类对象只能在堆上创建

在C++中,有时我们需要涉及一类对象,它们只能在堆上创建,而不能在栈上创建或者作为全局变量。这样的设计通常是为了满足某些特定的需求,例如对象的生命周期不确定、对象需要较大的内存空间、或者对象需要与其他对象共享数据等。

为什么要设计该类?

  • 动态分配内存: 对象的大小可能在编译时未知,或者对象的生命周期不确定,此时使用动态内存分配更为合适。
  • 资源管理: 对象可能需要管理资源(文件句柄、网络连接等),这些资源的生命周期需要与应用程序的其他部分紧密耦合。
  • 共享对象: 多个模块或对象之间需要共享同一个对象实例,堆上的对象可以通过指针或引用来共享。

在C++编程中,通常会将一些类的对象设计为只能在堆上创建。如:

  1. 大型数据结构: 如大数组、链表、树等,这些数据结构的大小可能在编译时不确定,或者它们的大小可能会在运行时改变(在需要时才分配和释放内存)。
  2. 资源管理器: 如文件处理类、数据库连接类等,这些类通常需要在堆上创建,以便更好地管理它们的生命周期。因为这些资源通常由操作系统或其他外部系统提供,并且需要在不再需要时显式释放。
  3. 单例模式: 单例模式通常要求对象只能有一个实例,并且该实例必须在整个程序的生命周期内保持可用,因此通常在堆上创建。(注意: 虽然单例模式本身不直接要求对象只能在堆上创建,但将单例类设计为只能在堆上创建对象可以帮助确保单例的唯一性和生命周期管理。)

如何实现该类?

实现一个只能在堆上创建的类,可以通过禁止默认的构造函数以及提供一个静态工厂方法来完成。此外,还可以通过私有化构造函数来限制对象的创建。

方案一:私有化构造函数

下面有关 模板参数包(Variadic Template Arguments)的使用,可以查看这篇博客中的“模板参数包”部分,参考博客

class HeapOnly
{
public:
template<class... Args>
static HeapOnly* CreateObj(Args&&... args)
{
return new HeapOnly(args...);
}

HeapOnly(const HeapOnly&) = delete;
HeapOnly& operator=(const HeapOnly&) = delete;

void Destroy()
{
delete this;
}
private:
HeapOnly()
{}

HeapOnly(int x, int y)
:_x(x), _y(y)
{}

~HeapOnly()
{
std::cout << "~HeapOnly()" << std::endl;
}

int _x;
int _y;
};

分析:

  • 将构造函数声明为私有,并提供一个静态工厂方法来创建对象,用户只能通过这个方法创建对象。所以可以在这个方法通过new在堆上创建对象,并返回,外部就只能得到一个在堆上的对象。
  • 禁用拷贝构造函数和赋值运算符,以防止对象被拷贝到栈上或作为函数参数传递。如防止这种情况:
HeapOnly* ho1 = HeapOnly::CreateObj(1, 2);
HeapOnly ho2(*ho2);
  • 禁用赋值运算符,因为会默认生成赋值运算符,而且会进行值拷贝。
  • 还需要一个方法用来释放创建的堆对象。

方案二:私有化析构函数

如果析构函数是私有的或受保护的,就不能在栈上在栈上创建对象。因为栈上的对象在超出作用域时会自动调用析构函数,而如果析构函数是私有的或受保护的(只能在友元或类内域调用),编译器就无法生成相应的调用代码,从而导致编译错误(并且这个工作是由编译器完成,外部是不能干涉这个动作的)。

创建的堆对象不会自动调用析构函数,需要手动释放空间。但是,我们不能直接在外部使用delete 进行释放对象的操作,因为 delete 底层会先调用析构函数,在释放内存空间,而析构函数是私有的,所以释放失败。

所以,还需要一个共有接口用来释放创建的堆对象,这个共有接口属于类内,可以调用析构函数(可以使用delete this,调用析构函数并释放空间)。

#include <iostream>
#include <memory>

class HeapOnly 
{
public:
    HeapOnly();

    HeapOnly(int x, int y) : _x(x), _y(y) {}

    // 如果需要释放内部资源,可以在这里实现
    void Destroy() 
    {
        // 如果有需要释放的资源,可以在这里释放
        delete this;  
    }
    
private:
    ~HeapOnly() 
    {
        std::cout << "~HeapOnly()" << std::endl;
    }
    int _x;
    int _y;
};
//
int main() 
{
    // 创建堆对象
    HeapOnly* ho1 = new HeapOnly(1, 2);
    ho1->Destroy();

    //HeapOnly ho; // 不能创建
    return 0;
}

方案三:智能指针

class HeapOnly 
{
private:
    int _x;
    int _y;

    HeapOnly(int x = 0, int y = 0) : _x(x), _y(y) 
    {
        std::cout << "HeapOnly object created with values: " << _x << ", " << _y << std::endl;
    }

    ~HeapOnly() 
    {
        std::cout << "HeapOnly object with values " << _x << " and " << _y << " destroyed." << std::endl;
    }

public:
    template<class... Args>
    static std::shared_ptr<HeapOnly> CreateObj(Args... args) 
    {
        return std::shared_ptr<HeapOnly>(new HeapOnly(args...), [](HeapOnly* ptr) { ptr->Destroy(); });
    }


    int getX() const 
    {
        return _x;
    }

    int getY() const 
    {
        return _y;
    }

    void Destroy()
    {
        delete this;
    }
};

int main() 
{
    // 使用 CreateObj 函数创建对象
    auto ho1 = HeapOnly::CreateObj(1);
    std::cout << "Value X: " << ho1->getX() << ", Value Y: " << ho1->getY() << std::endl;

    // 使用 CreateObj 函数创建另一个对象
    auto ho2 = HeapOnly::CreateObj(3, 4);
    std::cout << "Value X: " << ho2->getX() << ", Value Y: " << ho2->getY() << std::endl;

    return 0;
}

运行结果:

HeapOnly object created with values: 1, 0
Value X: 1, Value Y: 0
HeapOnly object created with values: 3, 4
Value X: 3, Value Y: 4
HeapOnly object with values 3 and 4 destroyed.
HeapOnly object with values 1 and 0 destroyed.

注意: 使用智能指针管理堆对象时,当智能指针要销毁堆对象时,默认使用的删除器是delete,也就是说,智能指针通过delete释放堆对象,也就会调用析构函数。而智能指针也不能调用私有的析构函数,所以编译时也会出错。

并且这个编译报错信息及其不正确,让你感受一下>︿<:

例如,下面这个基础的智能指针使用方法,这里析构函数是私有的:

class HeapOnly
{
public:
HeapOnly()
{}

HeapOnly(int x, int y)
:_x(x), _y(y)
{}

void Destroy()
{
delete this;
}

private:
~HeapOnly()
{
std::cout << "~HeapOnly()" << std::endl;
}

int _x;
int _y;
};

int main()
{
std::shared_ptr<HeapOnly> ho1(new HeapOnly(1, 2));
return 0;
}

报错信息:
在这里插入图片描述
这里报错信息说:

error C2664: “std::shared_ptr<HeapOnly>::shared_ptr(std::nullptr_t) noexcept: 无法将参数 1 从“HeapOnly *”转换为“std::nullptr_t”

单从字面上看,说 无法从“HeapOnly *”转换为“std::nullptr_t”,也就是说我们调用的是参数为std::nullptr_tstd::shared_ptr的构造函数,但是std::shared_ptr的构造函数不是拥有参数为T*的构造函数吗?为什么不会被调用呢?(╬▔皿▔)╯
在这里插入图片描述
其实,真正的原因就是:std::shared_ptr默认的删除器为delete,而delete会调用析构函数,这里析构函数是私有的,不能被直接调用就出错了。(◎﹏◎)

所以我们还要传递一个自定义删除器

int main()
{
std::shared_ptr<HeapOnly> ho1(new HeapOnly(1, 2), [](HeapOnly* ptr) { ptr->Destroy(); });
return 0;
}

并且,这里使用的是std::shared_ptr智能指针,如果使用std::unique_ptr智能指针,设计起来会更为恶心(◎﹏◎) 这里就不写了。

类对象只能在栈上创建

在C++中,设计一个只能在栈上创建对象的类,通常是为了确保对象的生命周期是确定的,并且避免动态内存分配带来的开销。这样的设计通常用于那些不需要动态创建或释放资源的情况,或者是为了提高性能,因为栈上的操作通常比堆上的操作更快。

为什么要设计该类?

  • 确定性生命周期: 当对象在栈上创建时,它的生命周期与包含它的函数的作用域相同。这意味着一旦函数执行结束,对象就会自动销毁,无需显式调用析构函数。
  • 避免内存泄漏: 由于栈对象在作用域结束时自动销毁,因此可以避免由于忘记释放内存而导致的内存泄漏问题。
  • 性能考虑: 栈上的内存分配通常比堆上的快,因为栈上分配不需要额外的内存管理开销。

在C++编程中,通常会将一些类的对象设计为只能在栈上创建。如:

  1. 锁类(Lock Classes): 在多线程编程中,锁类通常设计为只能在栈上创建,以确保在作用域结束时自动释放,避免死锁。
  2. 作用域守卫类(Scope Gurads): 用于在作用域结束时执行特定操作的类,如关闭文件、释放资源等。

如何实现该类?

实现一个只能在栈上创建的类,可以通过禁止默认的构造函数以及提供一个静态工厂方法来完成。此外,还可以通过私有化构造函数来限制对象的创建。

方案一:私有化构造函数

class StackOnly
{
public:
template<class... Args>
static StackOnly CreateObj(Args&&... args)
{
return StackOnly(args...);
}

StackOnly& operator=(const StackOnly&) = delete;
private:
StackOnly(int x = 0, int y = 0)
:_x(x), _y(y)
{}

int _x;
int _y;
};

分析:

  • 将构造函数声明为私有,并提供一个静态工厂方法来创建对象。用户只能通过这个方法创建对象。所以可以在这个方法通过在栈上创建对象,并返回,外部就只能得到一个在栈上的对象。
  • 禁用赋值运算符。主要目的是防止类的对象被赋值给另一个对象。这有助于确保类的对象具有唯一性和不可复制性。当一个类的对象设计为只能在栈上创建时,通常希望这些对象具有确定性的生命周期。删除赋值运算符可以防止一个已经存在的对象被重新赋值为另一个对象的状态。这样可以确保每个对象都是独立的,并且在其作用域内具有明确的生命周期。

注意:

  1. 这里不能禁用/私有拷贝构造函数,因为静态工厂方法中,是通过返回值的方法创建对象。也就是说,编译器会先调用构造函数创建一个临时对象,然后使用这个临时对象拷贝构造外部对象(编译器会优化为直接构造外部对象),这个过程需要使用拷贝构造函数,所以不能禁用/私有。
  2. 这种方法并不能完全防止使用new创建对象。
    • 不能直接通过new创建对象:由于构造函数是私有的,外部代码不能直接使用new StackOnly()来创建对象。
    • 可能通过其他方式间接创建:虽然直接创建被阻止,但类的拷贝构造没有被删除/私有,那么它就可以用来在堆上创建对象。
StackOnly so2 = StackOnly::CreateObj(1, 2);
StackOnly* so3 = new StackOnly(so2);

方案二:删除newdelete操作符

这种方案是最稳妥的~

因为new操作符是由两部分构成的:operator new构造函数。因为上面的原因,拷贝构造函数不能被禁用,那只能禁用operator new了。

在我们使用new时,其底层默认调用全局的operator new,但是我们可以在类内重载一个operator new,这样当我们再使用new时,会先使用类内的operator new,而不是全局的。

我们可以将类内重载的operator new显式删除,这样就可以确保无法通过 newdelete 创建类的对象。

class StackOnly
{
public:
template<class... Args>
static StackOnly CreateObj(Args&&... args)
{
return StackOnly(args...);
}

StackOnly& operator=(const StackOnly&) = delete;

void* operator new(size_t) = delete;
void operator delete(void*) = delete;

private:
StackOnly(int x = 0, int y = 0)
:_x(x), _y(y)
{}

int _x;
int _y;
};

不能被继承的类

在C++中,设计一个类使其不能被继承是一个常见的需求,尤其是在面向对象编程中,为了确保类的封装性和安全性,有时需要禁止其他类从这个类派生。

为什么要设计该类?

  • 封装性: 如果一个类的实现细节不应该被外部访问,防止外部代码通过继承来访问或修改私有成员。
  • 安全性: 为了防止派生类覆盖或修改基类的方法,从而破坏基类的行为。
  • API设计: 某些类设计为工具类和辅助类,仅用于提供一些静态方法或常量,这些类不应该被继承。
  • 避免滥用: 防止其他开发者通过继承来扩展类的功能,从而可能破坏类的原有设计或引入不兼容的变化。

在C++编程中,通常会将一些类设计为不能被继承。如:

  1. 标准库中的某些类:std::stringstd::vector等,这些类通常被设计为最终类(final class),以防止用户通过继承来修改其内部实现。
  2. 单例类: 单例模式要求一个类只有一个实例,并且这个实例是由类本身控制的。如果允许继承,子类可能会破坏单例的性质。
  3. 配置类: 用于存储应用程序配置信息的类。

如何实现该类?

方案一:使用final关键字(C++11及以后)

这是最直接和推荐的方法,因为它清晰地表达了类的意图,并且由编译器强制执行。

从 C++11 开始,可以使用 final 关键字来指定一个类不能被继承:

class NonInheritableClass final 
{
    // 类的定义
};

final关键字在C++11中引入,它主要用于防止类被继承或防止虚函数被覆盖。

1)防止类被继承: 当一个类被声明为 final,这个类不能被进一步继承。
2)防止虚函数被覆盖: 当一个虚函数被声明为fianl,这个虚函数在派生类中不能被重新定义。

class Base 
{
public:
    virtual void doSomething() final 
    {
        // 函数实现
    }
};

class Derived : public Base 
{
public:
    // 下面的代码会导致编译错误
    virtual void doSomething() override 
    {
        // 函数实现
    }
};

通过fianl关键字,代码在设计初期就可明确意图,避免了不必要的继承操作和函数重写。

  1. 设计意图清晰: 使用final明确表明了哪些部分不应该被更改,有助于其他开发者理解类的设计意图,减少误用。
  2. 性能优化: 在某些情况下,编译器可以利用final的信息进行优化,例如在调用fianl函数时可以直接展开,减少虚函数调用的开销。
  3. 与其他C++11特性的结合使用:
    • override关键字:fianl通常和override关键字一起使用,可以显式指出该函数是覆盖基类中某个虚函数,并且不在允许再被派生类覆盖。
class Base {
public:
    virtual void displayMessage() const {
        std::cout << "Base class message" << std::endl;
    }
};

class Derived : public Base {
public:
    void displayMessage() const override final {
        std::cout << "Derived class message" << std::endl;
    }
};

// 下面的代码会导致编译错误
class MoreDerived : public Derived {
public:
    void displayMessage() const override {
        std::cout << "MoreDerived class message" << std::endl;
    }
};

方案二:将构造函数设为私有或受保护的,并提供静态工厂方法

在 C++98 标准中,可以通过将构造函数(包括默认构造函数、拷贝构造函数等)私有化来阻止一个类被继承。这是因为派生类的构造函数需要调用基类的构造函数来初始化基类部分,如果基类的构造函数是私有的,那么派生类就无法调用它父类私有成员在派生类中不可见),从而无法创建派生类的对象。

class NonInheritableClass 
{
private:
    NonInheritableClass() {} // 私有构造函数

public:
    static NonInheritableClass CreateInstance() 
    {
        return NonInheritableClass();
    }

    // 其他公共成员
};

只能创建一个对象的类(单例模式)

引入

设计模式(Design Pattern)是一套被反复使用、多数人知晓的、经过分类的、代码设计经验的总结。

为什么会产生设计模式? 就像人类历史发展中,会产生兵法一样。最开始部落之间打仗时都是人拼人的对砍。后来春秋战国时期,七国之间经常打仗,就发现打仗也是有套路的,后来孙子就总结出了《孙子兵法》。孙子兵法也是类似。

使用设计模式的目的:

  1. 代码复用:在软件开发中,经常会遇到一些常见的问题,如对象创建、数据访问、状态管理等。设计模式提供了一种标准化的解决方案,使得这些常见问题可以通过复用已有的设计模式来解决,避免了重复造轮子。

  2. 提高代码可读性:设计模式是软件开发领域中的“最佳实践”,它们经过长时间的验证和优化,具有高度的可读性和可维护性。使用设计模式编写的代码,即使对于不熟悉具体业务逻辑的开发者来说,也能够通过理解设计模式的思想来快速上手。

  3. 增强代码可靠性:设计模式通常考虑了多种可能的场景和边界条件,因此使用设计模式编写的代码往往更加健壮和可靠。此外,设计模式还提供了灵活性和可扩展性,使得代码能够更好地适应未来的变化。

单例模式

单例模式(Singleton Pattern)确实是一种确保一个类只有一个实例,并提供一个全局访问点的设计模式。

单例模式的核心思想

  • 确保唯一性:通过某种机制确保一个类只有一个实例,这样在整个系统中,该类的所有功能都可以通过这个唯一的实例来访问。
  • 全局访问点:提供一个全局的访问点(通常是静态方法),使得系统中的其他部分可以方便地获取到这个唯一的实例。

单例模式的实现方式

单例模式的实现方式有多种,常见的包括:

  1. 饿汉式:在类加载时就创建实例,线程安全但可能导致资源浪费(如果实例一直未被使用)。
  2. 懒汉式:在第一次使用时才创建实例,节省资源但需要考虑线程安全问题。
  3. 双重检查锁定(DCL):一种优化的懒汉式实现,通过双重检查来确保线程安全和性能。
  4. 静态内部类:利用Java的类加载机制来保证线程安全和延迟加载。
  5. 枚举:一种更简洁且线程安全的实现方式,同时防止了反序列化和反射攻击。

单例模式的应用场景

  • 资源管理类:如内存池、数据库连接池等,这些资源需要被统一管理,避免重复创建和销毁(确保系统中只有一个数据库连接池对象,所有模块 都可以通过该对象获取数据库连接)。
  • 配置管理类:如读取配置文件、管理全局配置信息等,这些配置信息需要在整个系统中保持一致。
  • 多线程环境下的共享资源:如线程池、计数器等,这些资源需要在多线程环境下被安全地共享和访问。

设计思路:

经过上述几个类的设计,我们可以知道:一个类只能创建一个对象,那么其他用户不能随意调用构造函数创建对象,所以需要将构造函数私有化。

那么后一步需要考虑的是,该如何创建这个唯一的类对象?下面提供的两种实现方案(饿汉模式和懒汉模式),这两种模式的主要区别在于实例化的时机:饿汉模式在程序启动时立即创建实例,而懒汉模式则延迟到实际需要时才创建实例。


因为下面在实现时,使用到static关键字,这里简单的复习一下:

  1. 修饰局部变量:static用于修饰局部变量时,这个变量的存储位置会在程序执行期间保持不变,且只在程序执行到该变量的声明处初始化一次。(即使该变量所在函数多次被调用,static局部变量也只在第一次调用时初始化,之后的调用将不会重新初始化它。)
  2. 修饰全局变量或函数:static 用于修饰全局变量或函数时,限制了这些变量或函数的作用域,它们只能在定义它们的文件内部访问。有助于避免在不同文件之间的命名冲突。
  3. 修饰类的成员变量或函数: 在类内部,static成员变量或函数属于类本身,而不是类的任何特定对象。这意味着所有对象共享同一个static成员变量,无需每个对象都存储一份拷贝。static成员函数可以在没有类实例的情况下调用。并且,静态非常量数据成员,只能在类外定义和初始化,在类内仅是声明。

饿汉模式

饿汉模式(Eager Initialization)在类被加载/定义时就完成了实例的创建。由于C++中的静态变量在类加载时会被初始化,因此可以利用这一特性实现饿汉模式。这种方式的优点是简单、易于实现,并且天生是线程安全的,因为对象在编译期间就已经创建好了。

实现方法

  1. 私有构造函数: 将构造函数声明为私有,确保外部代码无法直接创建Singleton类的实例。
  2. 静态成员变量: 定义一个静态成员变量m_instance,该变量在类定义时初始化,确保单例对象在编译期间就已经创建好。
  3. 静态成员函数: 定义了一个静态成员函数GetInstance(),该函数返回单例对象的引用/指针。

指针方案

class Singleton
{
public:
static Singleton* GetInstance()
{
return &m_instance;
}

void print()
{
std::cout << "x = " << _x << ", y = " << _y << " ";
for (auto e : _vstr)
{
std::cout << e << " ";
}
std::cout << std::endl;
}
void addStr(const std::string& str)
{
_vstr.emplace_back(str);
}
private:
// 私有化构造函数  
Singleton(int x = 0, int y = 0, std::vector<std::string> vstr = { "xxx", "yyy" })
: _x(x), _y(y), _vstr(vstr)
{}

int _x;
int _y;
std::vector<std::string> _vstr;

static Singleton m_instance;
};
// 在类外定义
Singleton Singleton::m_instance(1, 1, { "aaa", "bbb" });

int main()
{
Singleton::GetInstance()->print();
Singleton::GetInstance()->print();
Singleton::GetInstance()->print();
return 0;
}

引用方案

static Singleton& GetInstance()
{
return m_instance;
}

注意事项:

使用引用方案时,像下面这样使用,要注意只有使用引用接收GetInstance()返回值时,s1s2才是同一个对象,两者打印的结果才是一致的。否则是两份拷贝,打印的结果不一致:

int main()
{
auto& s1 = hungry::Singleton::GetInstance();
auto& s2 = hungry::Singleton::GetInstance();

s1.print();
s2.print();
s2.addStr("ccc");
s1.print();
s2.print();

return 0;
}

运行结果:

x = 1, y = 1 aaa bbb
x = 1, y = 1 aaa bbb
x = 1, y = 1 aaa bbb ccc
x = 1, y = 1 aaa bbb ccc

分析

  • 静态的成员变量不存在类对象中,存放在静态区。因此在类内定义static Singleton m_instance; 不是 “类的成员变量是该类的对象” 的场景,所以不是递归定义问题。这个静态对象相当于全局对象,只是定义在类中,受类作用域的限制,并且可以访问类私有成员。并且要求在类外定义:
Singleton Singleton::m_instance(1, 1, { "aaa", "bbb" });
  • 编译期间初始化: 单例对象m_instance在编译期间就已经创建好了(在进入main()函数之前就已存在),这意味着它始终存在,即使程序未使用它。这可能会占用一定的内存资源。
  • 线程安全: 由于单例对象在编译期间创建,因此不需要额外的线程安全措施。
  • 全局访问点: 通过GetInstance()函数提供了一个全局访问点来访问单例对象,确保所有模块都可以通过这个函数访问同一个对象。

优缺点

优点

  1. 线程安全

    • 由于单例对象在类加载时就已经创建,因此不需要在 getInstance 方法中进行同步处理,天生就是线程安全的。
    • 不需要加锁或者其他复杂的同步机制,减少了多线程环境下可能出现的并发问题。
  2. 简单易懂

    • 饿汉模式的实现非常简单,容易理解和维护。
    • 没有复杂的逻辑,也没有额外的同步机制,代码清晰简洁。
  3. 确保单例对象的存在性

    • 在程序启动时单例对象就已经存在,可以立即使用。
    • 不会出现由于懒加载导致的单例对象尚未创建的情况。
  4. 延迟加载问题较少

    • 由于单例对象在类加载时就已经创建,因此不需要关心何时调用 getInstance 方法。
    • 程序员无需担心延迟加载的问题(运行时),可以随时访问单例对象。

缺点

  1. 内存占用

    • 单例对象在类加载时就已经创建,即使应用程序暂时不使用该单例对象,也会占用一定的内存资源。
    • 对于那些创建成本较高且不经常使用的单例对象来说,这可能是一种资源浪费。
  2. 性能开销

    • 如果单例对象的创建成本很高(如涉及大量的计算、磁盘I/O等),那么在程序启动时创建单例对象可能会导致启动时间延长。
    • 这种情况在需要快速启动的应用程序中尤为明显。
  3. 初始化顺序问题

    • 如果单例对象的构造依赖于其他静态变量或类的初始化,那么需要特别注意初始化顺序问题。
    • 如果初始化顺序不当,可能会导致未初始化的依赖问题。
  4. 可测试性差

    • 在单元测试中,如果单例对象在类加载时就已经创建,那么可能难以进行隔离测试。
    • 需要额外的手段(如使用依赖注入框架)来模拟单例对象的行为,以便更好地进行测试。
  5. 灵活性降低

    • 一旦单例对象被创建,就不能在运行时更改其状态。
    • 如果需要在不同的环境下使用不同的单例配置,饿汉模式可能会不够灵活。

懒汉模式

懒汉模式(Lazy Initialization)在需要时才创建实例,这可以节省资源,特别是当单例对象很庞大或者初始化开销很大时。然而,懒汉模式需要处理线程安全问题,以确保在多线程环境下只有一个实例被创建。

实现方法

  1. 定义一个私有的静态指针: m_pInstance用于指向单例对象。使用指针是为了动态分配、创建对象。
  2. 提供一个公有的静态方法来获取这个指针: GetInstance()在第一次调用此方法时(第一次要使用对象时),检查指针是否为空,如果为空则创建实例并赋值给指针。
  3. 将构造函数设为私有: 防止外部通过构造函数创建新的实例。
  4. 处理线程安全问题: 可以使用互斥锁(如std::mutex)来确保只有一个线程可以创建实例。
  5. 注意: 懒汉模式需要手动管理内存(如使用newdelete),这增加了内存泄漏的风险。可以使用智能指针/RAII机制来自动管理内存。然而,在使用智能指针时,需要注意确保智能指针的析构函数在程序结束时不会被多次调用(即避免重复释放内存)。一种常见的做法是使用静态局部变量结合智能指针来实现单例,这样既保证了线程安全又避免了手动管理内存的问题。
namespace lazy
{
class Singleton
{
public:
static Singleton* GetInstance()
{
// 使用双重检查锁定模式(Double-Checked Locking)  
if (m_pInstance == nullptr)
{
std::lock_guard<std::mutex> lock(m_mutex);
// 再次检查实例是否已经被创建,防止多个线程通过第一个if后同时创建实例  
if (m_pInstance == nullptr)
{
m_pInstance = new Singleton;
}
}
return m_pInstance;
}

static void DelInstance()
{
std::lock_guard<std::mutex> lock(m_mutex);
if (m_pInstance)
{
delete m_pInstance;
m_pInstance = nullptr;
}
}

void print()
{
std::cout << "x = " << _x << ", y = " << _y << " ";
for (auto e : _vstr)
{
std::cout << e << " ";
}
std::cout << std::endl;
}

void addStr(const std::string& str)
{
_vstr.emplace_back(str);
}

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

private:
// 私有化构造函数  
Singleton(int x = 0, int y = 0, std::vector<std::string> vstr = { "xxx", "yyy" })
: _x(x), _y(y), _vstr(vstr)
{}

~Singleton()
{
std::cout << "~Singleton()" << std::endl;
}

int _x;
int _y;
std::vector<std::string> _vstr;

static Singleton* m_pInstance;
static std::mutex m_mutex; // 添加互斥锁  

// 内部类
class GC
{
public:
~GC()
{
Singleton::DelInstance();
}
};
static GC gc;
};

Singleton* Singleton::m_pInstance = nullptr;
Singleton::GC Singleton::gc;
std::mutex Singleton::m_mutex; // 初始化互斥锁  
}
int main()
{
lazy::Singleton::GetInstance()->print();
lazy::Singleton::GetInstance()->print();
lazy::Singleton::GetInstance()->addStr("1111");
lazy::Singleton::GetInstance()->print();

return 0;
}

运行结果:

x = 0, y = 0 xxx yyy
x = 0, y = 0 xxx yyy
x = 0, y = 0 xxx yyy 1111
~Singleton()

分析

还要保证线程安全

  1. 互斥锁:我们添加了一个静态成员 std::mutex m_mutex 来确保线程安全。
  2. 双重检查锁定模式:在 GetInstance() 方法中,我们首先检查 m_pInstance 是否为 nullptr,如果是,则锁定互斥锁,并再次检查 m_pInstance 是否为 nullptr(因为在第一次检查后可能已经有另一个线程创建了实例)。这样做可以减少不必要的锁操作,提高效率。
  3. 锁保护:在 GetInstance()DelInstance() 方法中,我们使用 std::lock_guard<std::mutex> 来自动管理锁的生命周期,确保在方法执行完毕时锁会被释放。

避免内存泄漏
这里使用者可能会忘记调用DelInstance()释放资源,所以可以利用RAII机制,定义一个内部类及其对象,自动调用该函数:

// 在Singleton类内部
class GC
{
public:
~GC()
{
Singleton::DelInstance();
}
};
static GC gc;

因为GetInstance获得的对象的作用域是全局的,所以负责调用DelInstance()的对象的作用域也应该是全局的,可以将该变量设置为静态的。

注意: 这种写法与饿汉不同,饿汉是定义一个全局静态对象,在main()函数之前完成创建。而懒汉模式,是定义一个静态对象的指针,在第一次调用函数时,在真正创建唯一实例对象及初始化,且只会定义一次,后续再调用函数不会再创建对象,其生命周期是全局的。

优缺点

懒汉模式(Lazy Initialization)在单例模式实现中具有其独特的优缺点。以下是对懒汉模式优缺点的详细说明:

优点

  1. 资源高效利用

    • 懒汉模式最大的优点在于它仅在需要时才创建单例对象。这意味着如果程序在执行过程中从未访问过该单例对象,那么它就不会被创建,从而节省了内存和计算资源。
  2. 灵活性

    • 由于对象的创建被延迟到第一次使用时,懒汉模式为开发者提供了更大的灵活性。例如,可以在创建对象时执行一些需要特定上下文或条件的初始化操作。
  3. 适合按需加载

    • 在某些应用场景中,如插件系统或动态加载模块,懒汉模式非常适合按需加载对象。这有助于减少启动时间和资源消耗。

缺点

  1. 线程安全问题

    • 懒汉模式在多线程环境下需要额外的同步机制来确保线程安全。如果多个线程同时访问单例对象的创建逻辑,可能会导致多个实例被创建,从而违反单例模式的原则。
  2. 性能开销

    • 虽然懒汉模式在资源利用上更高效,但同步机制(如锁)的引入可能会增加性能开销。在高并发场景下,这可能会导致性能瓶颈。
  3. 实现复杂性

    • 实现线程安全的懒汉模式相对复杂。开发者需要仔细设计同步机制,以确保在多线程环境下的正确性和性能。
  4. 双重检查锁定问题

    • 为了优化性能,开发者可能会采用双重检查锁定(Double-Checked Locking)技术来减少锁的持有时间。然而,这种技术实现起来较为复杂,且容易出错。如果实现不当,可能会导致竞态条件(Race Condition)等问题。
  5. 对象销毁问题

    • 在懒汉模式中,单例对象的销毁通常是由开发者手动控制的。如果忘记在适当的时候销毁对象,可能会导致资源泄露。此外,由于单例对象的生命周期通常与程序的生命周期相同,因此在程序退出时销毁对象可能不是必要的。然而,在某些情况下(如动态加载的模块),可能需要显式地销毁单例对象以释放资源。

今天的分享就到这里了,如果,你感觉这篇博客对你有帮助的话,就点个赞吧!感谢感谢……


原文地址:https://blog.csdn.net/2201_75479723/article/details/142700607

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!