【C++】特殊类设计

简介: 【C++】特殊类设计

一、设计一个类,不能被拷贝

拷贝只会发生在两个场景中:拷贝构造函数赋值运算符重载

因此想要让一个类禁止拷贝,只需让该类不能调用拷贝构造函数以及赋值运算符重载即可


1、C++98

将拷贝构造函数与赋值运算符重载只声明不定义,并且将其访问权限设置为私有即可。

class CopyBan
{
public:
  CopyBan()
  {}
private:
  CopyBan(const CopyBan& cb); // 拷贝构造函数声明
  CopyBan& operator=(const CopyBan& cb); // 赋值运算符重载声明
};

【原因】

  1. 设置成私有:如果只声明而没有设置成 private,用户自己如果在类外定义了,就可以不能禁止拷贝了。
  2. 只声明不定义:不定义是因为该函数根本不会调用,定义了其实也没什么意义,不写反而更简单,而且如果定义了就不能防止成员函数内部拷贝了。

如上图代码,在对这个特殊类进行拷贝和赋值的时候,因为这两个成员函数私有而无法调用

拷贝构造以及赋值运算符重载等成员函数,在调用时都是编译器在域外调用,所以必须是公有的。


2、C++11

C++11 扩展了 delete 的用法,delete 除了释放 new 申请的资源以外,如果在默认成员函数后跟上 = delete,表示让编译器删除掉该默认成员函数,此时编译器也不会自动生成默认的拷贝构造和赋值运算符重载函数

class CopyBan
{
public:
  CopyBan()
  {}
private:
  CopyBan(const CopyBan& cb) = delete; // 拷贝构造函数声明
  CopyBan& operator=(const CopyBan& cb) = delete; // 赋值运算符重载声明
};

二、设计一个类,只能在堆上创建对象

正常创建类对象时,会在栈上创建,并且自动调用构造函数来初始化。下面设计一个类,只能在堆上创建对象。

1、实现方式

只能在创建在堆上时,就需要让该对象只能通过 new 来创建,并且调用构造函数来初始化。

  1. 将类的构造函数私有,拷贝构造声明成私有。防止别人调用拷贝在栈上生成对象。
  2. 提供一个静态的成员函数,在该静态成员函数中完成堆对象的创建
class HeapOnly
{
public:
  static HeapOnly* CreateObject()
  {
    return new HeapOnly;
  }
private:
  //构造函数
  HeapOnly() {}
  HeapOnly(const HeapOnly& hp) = delete; // 禁止拷贝
};

定义一个静态成员函数,在该函数内部 new 一个 HeapOnly 对象。将构造函数和拷贝构造函数私有,并且禁止生成拷贝构造函数。

【原因】

  • 使用静态成员函数 new 一个 HeapOnly 对象。

那么非静态成员函数在调用时,必须使用点 (.) 操作符来调用,这一步是为了传 this 指针。这样的前提是先有一个 HeapOnly 对象,但是构造函数设置成了私有,就无法创建这样一个对象。

而静态成员函数的调用不用传 this 指针,也就不需要必须有 HeapOnly 对象,只需要类域 :: 静态成员函数即可。静态成员函数属于 HeapOnly 域内,所以在 new 一个对象的时候,可以调用私有的构造函数。

  • 禁止调用拷贝构造函数并且私有化。

这样做的目的是为了禁止拷贝,防止使用堆区上的 HeapOnly 对象在栈区上拷贝,如下面代码:

而禁止了拷贝构造就杜绝了这一行为,从而保证了 HeapOnly 对象只能在堆上创建。


三、设计一个类,只能在栈上创建对象

  • 主要是需要做到不能在堆上创建类对象。
  • new 一个对象的时候,会调用该类的 operator new(size_t size) 函数,在释放资源的时候又会调用该类的 operator delete(void* p) 函数。

1、方法一

所以防止在堆上创建类对象就是要禁止调用 operator new(size_t size) 和 operator delete(void* p) 这两个函数。

class StackOnly
{
public:
  // 构造函数
  StackOnly()
  {}
 
  void* operator new(size_t size) = delete; // 禁止调用new
  void operator delete(void* p) = delete; // 禁止调用delete
};

使用 delete 来禁止这两个函数的调用,那么在 new 一个对象的时候,就会产生编译错误,从而无法在堆区上创建类对象。

此时在堆上创建对象时就会报错,尝试引用已删除的函数。


2、方法二

另一种方式就是和之前一样,通过一个静态成员函数在栈区上创建一个类对象,并且将默认构造函数私有化,然后设计静态方法创建对象返回即可。

class StackOnly
{
public:
  static StackOnly CreateObject()
  {
    return StackOnly();
  }
private:
  StackOnly()
  {}
};

此时 new 一个对象的时候,由于默认构造函数私有而无法调用,所以报错。


但以上两种设计方法都有一个漏洞,类对象可以在静态区(数据段)上创建:

static StackOnly so = StackOnly::CreateObject();

设计特殊类的核心:只能通过静态成员函数来创建类,封掉其他所有创建方式


四、设计一个类,不能被继承

1、C++98 方式

C++98 中构造函数私有化派生类中调不到基类的构造函数则无法继承

// 基类
class NonInherit
{
private:
  // 基类构造函数私有
  NonInherit()
  {}
};
 
// 派生类
class B : public NonInherit
{};

2、C++11 方法

使用 final 关键字来修饰类,表示该类不能被继承

class A final
{};

五、设计一个类,只能创建一个对象(单例模式)

1、设计模式

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

为什么会产生设计模式这样的东西呢?

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

使用设计模式的目的:为了代码可重用性、让代码更容易被他人理解保证代码可靠性。 设计模式使代码编写真正工程化;设计模式是软件工程的基石脉络,如同大厦的结构一样。


2、单例模式(一种设计模式)

  • 单例模式一个类只能创建一个对象。
  • 该模式可以保证系统中该类只有一个实例,并提供一个访问它的全局访问点,该实例被所有程序模块共享

比如在某个服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息,这种方式简化了在复杂环境下的配置管理。

  • 全局只有一个实例对象,所以将单例对象放在静态区 / 堆区(保证只创建一次)。
  • 为了防止其他位置创建该对象将构造函数私有
  • 为了防止拷贝使用 delete 修饰拷贝构造和赋值运算符重载函数

单例模式有两种实现模式:

(1)饿汉模式

就是说不管你将来用不用,程序启动时就创建一个唯一的实例对象。

// 饿汉模式
class Singleton
{
public:
    // 获取单例对象接口
    static Singleton* GetInstance()
    {
        return &m_instance;
    }
private:
    // 构造函数私有
    Singleton()
    {};
    
    // C++98 防拷贝
    Singleton(Singleton const& s); 
    Singleton& operator=(Singleton const& s); 
      
    // or
      
    // C++11
    Singleton(const Singleton& s) = delete; // 禁止使用拷贝构造
  Singleton& operator=(const Singleton& s) = delete; // 禁止使用赋值运算符重载
  
    // 保证单例对象在静态区且只有一个
  static Singleton m_instance; // 单例对象
};
// 在程序入口之前就完成单例对象的初始化
Singleton* Singleton::m_instance = new Singleton; 
 
int main()
{
  cout << Singleton::GetInstance() << endl;
  cout << Singleton::GetInstance() << endl;
  cout << Singleton::GetInstance() << endl;
  return 0;
}

静态成员变量只能在类域外进行定义初始化。

所以在 main 函数之前就将单例对象定义初始化,此时该单例对象创建在静态区上,而且仅有一个,后面就无法再创建。

  • 想要获取该单例对象只能通过静态成员函数 GetInstance() 来获取。
  • 静态成员函数可以直接访问静态成员变量 m_instance。
int main()
{
  Singleton* s = Singleton::GetInstance(); // 获取单例对象
  return 0;
}
为什么称之为饿汉模式呢?

不管将来会不会使用到这个单例对象,但是在程序一启动还没有进入 main 函数之前就创建一个唯一的实例对象。就像一个饿汉一样,一上来就先吃(创建单例对象)。

【总结】

1、如果这个单例对象在多线程高并发环境下频繁使用,性能要求较高,那么显然使用饿汉模式来避免资源竞争,提高响应速度更好。
2、饿汉单例模式优点

  • 保证全局(整个进程)只有唯一实例对象。
  • 饿汉模式:一开始就创建对象,特别简单。

3、饿汉单例模式缺点

  • 多个单例对象 A、B、C 假设要求他们之间有依赖关系:依次创建就无法达到,无法保证顺序可能会导致进程启动很慢。

如果这个单例对象在多线程高并发环境下频繁使用,性能要求较高,那么显然使用饿汉模式来避

免资源竞争,提高响应速度更好。


(2)懒汉模式
// 懒汉模式
#include <iostream>
#include <mutex>
#include <thread>
using namespace std;
 
class Singleton
{
public:
    static Singleton* GetInstance()
    {
        // 注意这里一定要使用Double-Check的方式加锁,才能保证效率和线程安全
    if (nullptr == m_pInstance) 
        {
            // 如果单例对象没有创建则在堆区创建
            if (nullptr == m_pInstance)
            {
                m_pInstance = new Singleton();
            }
            return m_pInstance;
        }
    }
    
    // 实现一个内嵌垃圾回收类
  class CGarbo 
    {
  public:
    ~CGarbo() 
        {
      if (Singleton::m_pInstance)
        delete Singleton::m_pInstance;
    }
  };
 
  // 定义一个静态成员变量,程序结束时,系统会自动调用它的析构函数从而释放单例对象
  static CGarbo Garbo;
private:
    // 构造函数
    Singleton(){};
 
    // 防拷贝
    Singleton(const Singleton& s) = delete; // 禁止拷贝
  Singleton& operator=(const Singleton& s) = delete; // 禁止赋值
 
    //静态单例对象指针
  static Singleton* m_pInstance;//单例对象指针
 
  static mutex m_mtx; //互斥锁
};
 
Singleton* Singleton::m_pInstance = nullptr; // 初始化为空
Singleton::CGarbo Garbo;
mutex Singleton::m_mtx;
 
void func(int n)
{
  cout << Singleton::GetInstance() << endl;
}
 
// 多线程环境下才能演示上面GetInstance()加锁和不加锁的区别
int main()
{
  thread t1(func, 10);
  thread t2(func, 10);
  t1.join();
  t2.join();
  cout << Singleton::GetInstance() << endl;
  cout << Singleton::GetInstance() << endl;
    return 0;
}

这里出现了一个有关线程安全的问题。当多线程时,进行判断为 nullptr,这时还没有调用 new,当前的线程就被切走了。下一个线程来了还是 nullptr 就又进去 new 了一个对象,然后恢复第一个线程的上下文后又 new 了一个对象,第二个 new 的就将第一个的给覆盖了,所以就出现了错误。

这里我们就可以使用互斥锁

因为加锁需要加在一个锁上才有用,所以我们也要将锁设为静态的,然后在类外进行初始化,这样就保证了只实例化出一个对象来,但实际上这样还是有问题的。

当我们 new 出一个对象之后,我们在进行调用 GetInstance 的时候,还会不停的加锁解锁。加锁解锁是有性能消耗的,所以不断的加锁解锁是很不好的,所以这里我们要使用双检测加锁

这样减少了加锁,提高了效率,但还是会有一点问题,单例对象什么时候释放呢?

其实单例对象是不需要释放的。因为在一般情况下,单例在进程启动之后随时会被使用,而且这个对象也不大,所以就可以不去释放它。那么会有内存泄漏吗?

如果进程是正常结束的,因为 new 出来得到的进程地址空间是虚拟内存,当结束之后就会将地址通过页表取消映射,那么 new 出来的资源就会还给 OS。当然如果想要释放也是没有问题的:

实现一个 DelInstance 手动调用的去释放。

当然有可能考虑到程序结束时,需要处理一下持久化保存一些数据,比方说要将数据写到硬盘上。那么可以写一个析构函数,在析构函数中去处理。如果担心会忘记手动调用  DelInstance ,那么可以通过设计一个类,在类中实现一个析构函数。

上面就是在创建的时候创建一个 _gc。当 main 结束时,_gc 生命周期结束,然后调用它的析构函数,直接将单例对象进行析构。

  • 同样将构造函数私有,拷贝构造和赋值运算符重载函数禁止调用,用来保证单例模式的唯一性。
  • 增加静态单例对象指针成员变量。

在类外实例化静态指针变量的时候,并没有创建单例对象,而是将其初始化为空。

int main()
{
  Singleton* ps = Singleton::GetInstance();//获取单例对象
  return 0;
}

在获取单例对象时,如果是第一次使用,那么就会在堆区上 new 一个单例对象,并且将所在地址赋值给静态的成员指针变量。

为什么称之为懒汉模式呢?

懒汉模式又叫做延时加载模式。如果单例对象构造十分耗时或者占用很多资源,比如加载插件、初始化网络连接、读取文件等等,而有可能该对象程序运行时不会用到,那么也要在程序一开始就进行初始化,就会导致程序启动时非常的缓慢,所以这种情况使用懒汉模式(延迟加载)更好。

【优点】

  • 第一次使用实例对象时,创建对象。进程启动无负载。多个单例实例启动顺序(通过代码顺序)自由控制。

【缺点】

  • 复杂。
  • 如果不加锁是会出现线程安全的问题。但是加锁是会十分影响性能的,所以引入了双检查。那么既要保证线程安全+又要保证效率的问题。


相关文章
|
2天前
|
C++
【C++】日期类Date(详解)②
- `-=`通过复用`+=`实现,`Date operator-(int day)`则通过创建副本并调用`-=`。 - 前置`++`和后置`++`同样使用重载,类似地,前置`--`和后置`--`也复用了`+=`和`-=1`。 - 比较运算符重载如`&gt;`, `==`, `&lt;`, `&lt;=`, `!=`,通常只需实现两个,其他可通过复合逻辑得出。 - `Date`减`Date`返回天数,通过迭代较小日期直到与较大日期相等,记录步数和符号。 ``` 这是236个字符的摘要,符合240字符以内的要求,涵盖了日期类中运算符重载的主要实现。
|
5天前
|
存储 编译器 C语言
【C++航海王:追寻罗杰的编程之路】类与对象你学会了吗?(上)
【C++航海王:追寻罗杰的编程之路】类与对象你学会了吗?(上)
10 2
|
5天前
|
C++
C++职工管理系统(类继承、文件、指针操作、中文乱码解决)
C++职工管理系统(类继承、文件、指针操作、中文乱码解决)
7 0
C++职工管理系统(类继承、文件、指针操作、中文乱码解决)
|
5天前
|
存储 编译器 C++
【C++ 初阶路】--- 类和对象(下)
【C++ 初阶路】--- 类和对象(下)
7 1
|
5天前
|
存储 编译器 C语言
【C++初阶路】--- 类和对象(中)
【C++初阶路】--- 类和对象(中)
10 1
|
2天前
|
存储 编译器 C++
【C++】类和对象④(再谈构造函数:初始化列表,隐式类型转换,缺省值
C++中的隐式类型转换在变量赋值和函数调用中常见,如`double`转`int`。取引用时,须用`const`以防修改临时变量,如`const int& b = a;`。类可以有隐式单参构造,使`A aa2 = 1;`合法,但`explicit`关键字可阻止这种转换。C++11起,成员变量可设默认值,如`int _b1 = 1;`。博客探讨构造函数、初始化列表及编译器优化,关注更多C++特性。
|
2天前
|
编译器 C++
【C++】类和对象④(类的默认成员函数:取地址及const取地址重载 )
本文探讨了C++中类的成员函数,特别是取地址及const取地址操作符重载,通常无需重载,但展示了如何自定义以适应特定需求。接着讨论了构造函数的重要性,尤其是使用初始化列表来高效地初始化类的成员,包括对象成员、引用和const成员。初始化列表确保在对象创建时正确赋值,并遵循特定的执行顺序。
|
2天前
|
C语言 C++
【C++】日期类Date(详解)③
该文介绍了C++中直接相减法计算两个日期之间差值的方法,包括确定max和min、按年计算天数、日期矫正及计算差值。同时,文章讲解了const成员函数,用于不修改类成员的函数,并给出了`GetMonthDay`和`CheckDate`的const版本。此外,讨论了流插入和流提取的重载,需在类外部定义以符合内置类型输入输出习惯,并介绍了友元机制,允许非成员函数访问类的私有成员。全文旨在深化对运算符重载、const成员和流操作的理解。
|
2天前
|
定位技术 C语言 C++
C++】日期类Date(详解)①
这篇教程讲解了如何使用C++实现一个日期类`Date`,涵盖操作符重载、拷贝构造、赋值运算符及友元函数。类包含年、月、日私有成员,提供合法性检查、获取某月天数、日期加减运算、比较运算符等功能。示例代码包括`GetMonthDay`、`CheckDate`、构造函数、拷贝构造函数、赋值运算符和相关运算符重载的实现。
|
2天前
|
编译器 C++
【C++】类和对象③(类的默认成员函数:赋值运算符重载)
在C++中,运算符重载允许为用户定义的类型扩展运算符功能,但不能创建新运算符如`operator@`。重载的运算符必须至少有一个类类型参数,且不能改变内置类型运算符的含义。`.*::sizeof?`不可重载。赋值运算符`=`通常作为成员函数重载,确保封装性,如`Date`类的`operator==`。赋值运算符应返回引用并检查自我赋值。当未显式重载时,编译器提供默认实现,但这可能不足以处理资源管理。拷贝构造和赋值运算符在对象复制中有不同用途,需根据类需求定制实现。正确实现它们对避免数据错误和内存问题至关重要。接下来将探讨更多操作符重载和默认成员函数。