【C++】特殊类设计

2023-10-17 08:14:18 浏览数 (1)

在某些特定的场景下,我们需要设计一些特殊的类,下面我们来学习几种常见特殊类的设计。

设计一个类,不能被拷贝

关于如何设计一个不允许被拷贝的类,其实我们之前在 C 11 右值引用和移动语义 学 default 和 delete 关键字的时候就讲过,这里我们再来回顾一下。

要设计一个不允许被拷贝的类一共有两种方式:

C 98 方式

传统的设计思路是将拷贝构造函数定义为私有,这样类外部就不能调用拷贝构造函数来构造对象了;但是在类内我们仍然可以调用拷贝构造函数来构造对象 (类内不受访问限定符的限制),那么就可能会导致需要进行深拷贝的类在拷贝构造时只完成了浅拷贝从而运行时崩溃 (同一块堆空间重复析构);

所以正确的做法是 将拷贝构造函数定义为私有,同时拷贝构造函数只声明,不实现,这样即使在类内调用了拷贝构造函数,编译器也能在链接时检查出来 (符号表的合并与重定位失败);如下:

代码语言:javascript复制
//设计一个类,不允许被拷贝
class CopyBan {
public:
    CopyBan() {
        _ptr = new int[10]{ 0 };
    }

    ~CopyBan() {
        delete[] _ptr;
    }

    //在类内进行拷贝
    void func() {
        CopyBan tmp(*this);
        //...
    }

private:
    //将拷贝构造定义为私有,并且只声明不实现
    CopyBan(const CopyBan& cb);

private:
    int* _ptr;
};

C 11 方式

C 11 中提供了一种更为便捷的方法 – 在函数声明加上 =delete 即可,delete 关键字可以阻止函数的自动生成,我们称被 delete 修饰的函数为删除函数;并且使用这种方法也不再需要将拷贝构造函数定义为私有。如下:

代码语言:javascript复制
class CopyBan {
public:
    CopyBan() {
        _ptr = new int[10]{ 0 };
    }

    ~CopyBan() {
        delete[] _ptr;
    }

    //在类内进行拷贝
    void func() {
        CopyBan tmp(*this);
        //...
    }

    //将构造函数使用delete关键字修饰 -- C  11
    CopyBan(const CopyBan& cb) = delete;

private:
    int* _ptr;
};

设计一个类,不能被继承

设计一个不能被继承的类有两种方式:

C 98 方式

将父类的构造函数私有,这样子类无法调用父类的构造函数完成父类成员的初始化工作,从而达到父类无法被继承的效果。

代码语言:javascript复制
class NonInherit {
public:
    static NonInherit CreateObj()
    {
        return NonInherit();
    }

private:
    NonInherit() {}

private:
    int _a = 0;
};

C 11 方式

C 11 提供了另外一种方式 – 使用 final 关键字来修饰,被 final 修饰的类不能被继承。

代码语言:javascript复制
class NonInherit final{
public:
    NonInherit() {}

private:
    int _a = 0;
};

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

对于一般的类来说,一共可以在三个不同的存储位置创建对象:

  1. 栈上创建对象,对象出局部作用域自动销毁;
  2. 通过 new 关键字在堆上创建对象,对象出局部作用域不会自动销毁,需要我们手动进行 delete;如果不手动释放,则该资源会在进程退出即 main 调用结束返回时自动销毁;
  3. 通过 static 关键字在静态区 (已初始化全局数据区) 创建对象,对象的作用域为定义时所在的局部域,而对象的生命周期伴随着整个进程,即静态资源也是在 main 调用结束后由操作系统自动回收。

要设计一个只能在堆上创建的类,一共有两种方式:

将构造函数声明为私有,同时删除拷贝构造函数,然后提供一个静态的成员函数,在该静态成员函数中完成堆对象的创建

通过将构造函数声明为私有,我们可以防止在类外部构造对象,不管是在栈区、堆区还是静态区;但是我们的目的是要能够在堆上创建对象,所以我们需要单独提供一个 CreateObj 成员函数,由于该类内不受访问限定符的限制,所以我们可以调用构造函数来创建一个堆上的对象并返回指向它的指针。

但是 CreateObj 函数必须是静态的,因为如果是普通成员函数,则其第一个参数是隐藏的 this 指针,所以想要调用这个函数来创建对象就必须先有一个对象,然而在构造私有的情况下我们是不可能在类外通过其他方式创建出对象的,这就好比先有鸡还是先有蛋的问题;但静态成员函数没有 this 指针,所以可以通过类名 域作用限定符 的方式进行调用,而不需要通过通过对象调用。

最后,我们需要删除拷贝构造函数,防止在类外通过下面这种取巧的方式来创建栈区或静态区的对象:

代码实现如下:

代码语言:javascript复制
class HeapOnly {
public:
    static HeapOnly* CreateObj()
    {
        return new HeapOnly;
    }

    HeapOnly(const HeapOnly& ho) = delete;

private:
    HeapOnly() {}
};

将析构函数声明为私有,然后提供一个专门的成员函数,在该成员函数中完成堆对象的析构

对于在栈区创建的对象来说,其出了局部作用域会自动调用析构函数进行析构,对于在静态区创建的对象来说,它也会在 main 函数调用完毕后自动调用析构函数进行析构;而如果我们将析构函数私有,那么在定义此类对象时编译器会自动报错。

而对于在堆上创建的对象来说,编译器并不会主动调用析构函数来回收其资源,而是由用户手动进行 delete 或 进程退出后由操作系统回收,所以编译器并不会报错;但需要注意的是,对于自定义类型的对象,delete 会首先调用其析构函数完成对象资源的清理,然后再调用 operator delete 释放对象的空间,所以这里我们不能使用 delete 关键字来手动释放 new 出来的对象,因为调用析构函数会失败。

所以我们需要一个 Destroy 成员函数,通过它来调用析构函数完成资源的清理;同时,Destroy 函数也不必声明为静态类型,因为只有类的对象才需要调用它;最后,我们也不需要再删除拷贝构造函数了,因为拷贝构造出来的栈对象或静态对象仍然无法调用析构函数。

代码实现如下:

代码语言:javascript复制
class HeapOnly {
public:
    HeapOnly() {}

    void Destroy()
    {
        this->~HeapOnly();
    }

private:
    ~HeapOnly() {}
};

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

要设计一个只能在栈上创建的类,也有两种方式:

在类中禁用 operator new 和 operator delete 函数

new 和 delete 是 C 中的关键字,其底层通过调用 operator new 和 operator delete 函数来开辟与释放空间;如果类中没有重载 operator new 和 operator delete 函数,那么 new 和 delete 会去调用全局的 operator new 和 operator delete 函数,特别注意,这两个函数是普通的全局函数,而不是运算符重载,只是它们的函数名是这样。

所以,我们可以在类中重载 operator new 和 operator delete 函数,然后将它们声明为删除函数,这样就不能通过 new 和 delete 在堆上创建与销毁对象了;但是这样有一个缺陷,我们只是禁止了在堆上创建对象,但是我们仍然可以在静态区创建对象,与类的要求不符。

代码语言:javascript复制
class StackOnly {
public:
    StackOnly() {}
    ~StackOnly() {}

    void* operator new(size_t size) = delete;
    void operator delete(void* p) = delete;
};

构造私有,提供一个在栈上创建对象的静态成员函数

第二种方式和设计一个只能在堆上创建对象的类的思路一样,但是注意不能删除拷贝构造函数,否则就不能通过下面这种方式来构造栈对象了 StackOnly st = StackOnly::CreateObj()

但是,不禁用拷贝构造又会导致可以通过拷贝构造创建出静态区上的对象 (方式二比方式一的区别在于方式二不能直接通过构造函数创建出位于静态区上的对象,而只能通过拷贝构造);所以我们设计出的只能在栈上创建对象的类是有缺陷的。

代码实现如下:

代码语言:javascript复制
class StackOnly {
public:
    static StackOnly CreateObj()
    {
        return StackOnly();
    }

private:
    StackOnly() {}
};

设计一个类,只能创建一个对象 (重点)

  • 设计模式

设计模式(Design Pattern)是一套被反复使用的、多数人知晓的、经过分类的代码设计经验的总结。设计模式的产生过程类似于兵法的产生过程 – 在夏商周时代,由于打仗比较少,所以每次打仗基本都是单纯的对砍,人多就能获胜;但是随着周朝分封制的推行以及周王朝的衰落,各诸侯国进入春秋战国时代,经常互相征战,仗大多了就发现打仗也是要动脑子的,有许多的套路,于是有人就总结出了《孙子兵法》。设计模式也是如此,代码写的多了自然也就有人去总结一些固定的套路。

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

  • 单例模式

我们之前其实已经接触过一些设计模式了,比如迭代器模式适配器/配接器模式,而只能创建一个对象的类被称为单例模式单例模式可以保证系统中该类只有一个实例,并提供一个访问它的全局访问点,该实例被所有程序模块共享。比如在某个服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息,这种方式简化了在复杂环境下的配置管理。

单例模式有两种实现方式:饿汉模式和懒汉模式。

  • 饿汉模式

饿汉模式的做法是将构造函数私有,然后删除拷贝构造和赋值重载函数;由于单例模式全局只允许有一个唯一对象,所以我们可以定义一个静态类对象作为类的成员,然后提供一个 GetInstance 接口来获取这个静态类对象。

需要注意的是,类的静态成员是属于整个类的,并且静态成员变量只能在类内声明,在类外定义,定义时需要指明类域;同时,由于我们是通过 GetInstance 接口来获取这个唯一对象,所以 GetInstance 也必须是静态函数。

饿汉模式的特点是在类加载的时候就创建单例对象,因此其实例化在程序运行之前 (main 函数调用之前) 就已经完成。饿汉模式的简单实现如下:

代码语言:javascript复制
class Singleton {
public:
    static Singleton& GetInstance()
    {
        return _sins;
    }

    //功能示例函数
    void func() 
    {
        //对类中的成员变量进行增删查改或进行其他操作
    }

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

private:
    Singleton() {}

private:
    static Singleton _sins;  //静态对象的声明
    
private:
    //类的其他成员变量 -- 此类要管理的数据
};
Singleton Singleton::_sins;  //静态对象的定义

由于饿汉模式的对象在 main 函数前就被创建,所以它不存在线程安全问题,但是它也存在一些缺点:

  1. 有的单例对象构造十分耗时或者需要占用很多资源,比如加载插件、 初始化网络连接、读取文件等等,会导致程序启动时加载速度慢。
  2. 饿汉模式在程序启动时就创建了单例对象,所以即使在程序运行期间并没有用到该对象,它也会一直存在于内存中,浪费了一定的系统资源。
  3. 当多个单例类存在初始化依赖关系时,饿汉模式无法控制。比如A、B两个单例类存在于不同的文件中,我们要求先初始化A,再初始化B,但是A、B谁先启动初始化是由OS自动进行调度控制的,我们无法进行控制。
  • 懒汉模式

针对饿汗模式存在的这些缺陷,有人又提出了懒汉模式,懒汉模式的特点是在第一次使用实例对象时才创建对象。懒汉模式的简单实现如下:

代码语言:javascript复制
class Singleton {
public:
    static Singleton& GetInstance()
    {
        //第一次进入时创建类对象,以后进入直接返回类对象
        if (_psins == nullptr)
        {
            _psins = new Singleton;
        }
        return *_psins;
    }

    //功能示例函数
    void func()
    {
        //对类中的成员变量进行增删查改或进行其他操作
    }

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

private:
    Singleton() {}

private:
    static Singleton* _psins;  //静态单例对象指针的声明

private:
    //类的其他成员变量 -- 此类要管理的数据
};
Singleton* Singleton::_psins = nullptr;  //单例对象指针的定义

由于懒汉模式是在第一次使用单例对象时才去创建单例对象,所以就不存在程序启动加载慢以及不使用对象浪费系统资源的问题了,同时,我们也可以通过在程序中先使用A对象再使用B对象的方式来控制有初始化依赖关系的单例对象的实例化顺序。

懒汉模式的线程安全问题与双检查加锁

但是懒汉模式也引入了新的问题 – 单例对象的创建是线程不安全的。对于饿汉模式来说,由于其单例对象在程序运行之前就已经创建好了,所以程序运行过程中我们直接获取该对象即可,不用再去创建对象,所以不存在对象创建时的线程安全问题。但对于懒汉模式来说,由于其单例对象是在第一次使用时才创建的,那么在多线程模式下,就有可能会存在多个线程并行/并发的去执行 _psins = new Singleton ` 语句,从而导致前面创建出来单例对象指针被后面的覆盖,最终发生内存泄露。

所以我们需要对判断单例对象是否创建以及创建单例对象的过程进行加锁,如下:

代码语言:javascript复制
class Singleton {
public:
    static Singleton& GetInstance()
    {
        //第一次进入时创建类对象,以后进入直接返回类对象
        _smtx.lock();
        if (_psins == nullptr)
        {
            _psins = new Singleton;
        }
        _smtx.unlock();

        return *_psins;
    }

    //功能示例函数
    void func()
    {
        //对类中的成员变量进行增删查改或进行其他操作
    }

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

private:
    Singleton() {}

private:
    static Singleton* _psins;  //静态单例对象指针的声明
    static std::mutex _smtx;        //保证懒汉模式创建单例对象的过程是线程安全的

private:
    //类的其他成员变量 -- 此类要管理的数据
};
Singleton* Singleton::_psins = nullptr;  //单例对象指针的定义
std::mutex Singleton::_smtx;

虽然上面的代码已经可以解决懒汉模式单例对象创建时的线程安全问题了,但是还可以再优化一下 – 由于只有第一次调用单例对象时 _psins才为空,所以其实 _smtx真正有意义的保护只有一次,但是我们却每次都要先加锁才能对 _psins的状态进行判断,最后再解锁,而加锁解锁的过程是有消耗的。为了避免这种不必要的性能消耗,有人想出了双检查加锁机制。通过双检查加锁,我们可以在兼顾效率的同时,保证懒汉模式的线程安全。如下:

代码语言:javascript复制
static Singleton& GetInstance()
{
    //第一次进入时创建类对象,以后进入直接返回类对象
    //双检查加锁
    if (_psins == nullptr)
    {
        _smtx.lock();
        if (_psins == nullptr)
        {
            _psins = new Singleton;
        }
        _smtx.unlock();
    }

    return *_psins;
}

拓展/知识衔接:

  • 我们可以将这里 C 的知识和 linux 操作系统的知识串联起来 – 懒汉模式的全局唯一实例就相当于共享资源,它被当前进程下的所有线程所共享,所以不仅仅创建单例对象的过程是不安全的,访问单例对象数据的过程也是不安全的
  • 只是单例对象创建的线程安全问题我们可以在类中通过加锁来保证,而单例对象数据的线程安全则只能由用户手动加锁进行保护,这一点和我们上一节学习的 智能指针 中 shared_ptr的线程安全问题是一样的。
  • linux 提供了线程同步和互斥机制来保证共享资源的安全,具体来说,我们可以通过对共享资源访问的过程进行加锁来保证该资源只能多个线程串行访问;同时,为了避免某一线程竞争锁的能力过强或持续的申请锁,linux 又提供了条件变量;最后,为了能够在不访问共享资源的前提下就能够掌握共享资源的使用情况,从而高效的对共享资源进行管理与分配,linux 又提供了信号量。

封装 RAII 实现对加锁解锁的自动管理

细心的同学可能会发现,其实我们上面的程序还存在一个bug – 当第一次创建单例对象失败,即 new 失败抛异常时,程序会因为互斥锁 lock 之后没有 unlock 而崩溃。这个问题我们可以通过传统的 try-catch的方式来解决;当然,更好的解决方式是通过智能指针,即专门封装一个用于管理锁的类。如下:

代码语言:javascript复制
//专门对加锁解锁进行管理锁的类 -- 智能指针
template<class Mutex>
class LockGuard {
public:
    //RAII
    LockGuard(Mutex& mtx)
        :_mtx(mtx)
    {
        _mtx.lock();
    }

    ~LockGuard()
    {
        _mtx.unlock();
    }

private:
    Mutex& _mtx;  //注意:这里必须使用引用,因为锁不允许拷贝
};

//懒汉模式
class Singleton {
public:
    static Singleton& GetInstance()
    {
        //第一次进入时创建类对象,以后进入直接返回类对象
        //双检查加锁
        if (_psins == nullptr)
        {
            //_smtx.lock();
            //if (_psins == nullptr)
            //{
            //	_psins = new Singleton;
            //}
            //_smtx.unlock();

            //使用智能指针来管理锁
            LockGuard<std::mutex> lg(_smtx);
            if (_psins == nullptr)
            {
                _psins = new Singleton;
            }
        }

        return *_psins;
    }

    //功能示例函数
    void func()
    {
        //对类中的成员变量进行增删查改或进行其他操作
    }

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

private:
    Singleton() {}

private:
    static Singleton* _psins;  //静态单例对象指针的声明
    static std::mutex _smtx;        //保证懒汉模式创建单例对象的过程是线程安全的

private:
    //类的其他成员变量 -- 此类要管理的数据
};
Singleton* Singleton::_psins = nullptr;  //单例对象指针的定义
std::mutex Singleton::_smtx;

这里有一个细节需要注意 – 在 LockGard 类中,_mtx 成员变量必须是引用类型,因为锁是不允许拷贝的。

同时,mutex库中其实提供了 LockGuard 类,我们可以直接使用库中的 lock_guard 类来对加锁解锁的自动管理。

单例对象的资源释放与保存问题

一般来说单例对象都是不需要考虑释放的,因为不管是饿汉模式还是懒汉模式,单例对象都是全局的,全局资源在程序结束后会被自动回收 (进程退出后OS会解除进程地址空间与物理内存的映射)。但是我们也可以手动对其进行回收。需要注意的是,有时我们需要在回收资源之前将资源的相关数据保存到文件中,这种情况下我们就必须手动回收了。

我们可以在类中定义一个静态的 DelInstance 接口来回收与保存资源 (此函数不会被频繁调用,因此不需要使用双检查加锁)。如下:

代码语言:javascript复制
//手动释放资源,并保存相关数据
static void DelInstance()
{
    //保存数据到文件
    //TODO

    //回收资源 -- 使用库中的lock_guard类
    std::lock_guard<std::mutex> lg(_smtx);
    if (_psins != nullptr)
    {
        delete _psins;
        _psins = nullptr;
    }
}

当然,我们也可以定义一个内部的GC类,然后通过在Singleton类中定义一个静态的GC类对象,使得程序在结束回收该GC对象时自动调用 GC 类的析构从而完成资源回收与数据保存工作;这样可以避免忘记调用 DelInstance 接口从而丢失数据的情况。如下:

代码语言:javascript复制
//专门对加锁解锁进行管理锁的类 -- 智能指针
template<class Mutex>
class LockGuard {
public:
    LockGuard(Mutex& mtx)
        :_mtx(mtx)
        {
            _mtx.lock();
        }

    ~LockGuard()
    {
        _mtx.unlock();
    }

private:
    Mutex& _mtx;  //注意:这里必须使用引用,因为锁不允许拷贝
};

//懒汉模式
class Singleton {
public:
    static Singleton& GetInstance()
    {
        //第一次进入时创建类对象,以后进入直接返回类对象
        //双检查加锁
        if (_psins == nullptr)
        {
            //_smtx.lock();
            //if (_psins == nullptr)
            //{
            //	_psins = new Singleton;
            //}
            //_smtx.unlock();

            //使用智能指针来管理锁
            LockGuard<std::mutex> lg(_smtx);
            if (_psins == nullptr)
            {
                _psins = new Singleton;
            }
        }

        return *_psins;
    }

    //功能示例函数
    void func()
    {
        //对类中的成员变量进行增删查改或进行其他操作
    }

    //手动释放资源,并保存相关数据
    //static void DelInstance()
    //{
    //	//保存数据到文件
    //	//TODO

    //	//回收资源 -- 使用库中的lock_guard类
    //	std::lock_guard<std::mutex> lg(_smtx);
    //	if (_psins != nullptr)
    //	{
    //		delete _psins;
    //		_psins = nullptr;
    //	}
    //}

    //也可以定义一个GC内部类,完成资源回收与数据保存工作
    class GC {
        public:
        ~GC()
        {
            //保存数据到文件
            //TODO

            //回收资源 -- 使用库中的lock_guard类
            std::lock_guard<std::mutex> lg(_smtx);
            if (_psins != nullptr)
            {
                delete _psins;
                _psins = nullptr;
            }
        }
    };

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

private:
    Singleton() {}

private:
    static Singleton* _psins;  //静态单例对象指针的声明
    static std::mutex _smtx;   //保证懒汉模式创建单例对象的过程是线程安全的
    static GC _gc;             //完成资源回收与数据保存工作

private:
    //类的其他成员变量 -- 此类要管理的数据
};
Singleton* Singleton::_psins = nullptr;  //单例对象指针的定义
std::mutex Singleton::_smtx;
Singleton::GC Singleton::_gc;

懒汉模式的一种简便实现方式

学到这里,大家其实可以发现懒汉模式需要注意的细节还是比较多的,特别是双检查加锁与 new 抛异常未执行 unlock 从而导致程序崩溃的问题。于是有人给出了另一种实现懒汉模式的思路,如下:

代码语言:javascript复制
class Singleton {
public:
    static Singleton& GetInstance()
    {
        static Singleton sins;
        return sins;
    }

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

private:
    Singleton() {}
};

大家仔细思考可以发现,这是一种非常巧妙的实现方式:

  1. 它符合单例模式的特点 – 全局只有一个单例对象。由于 sins是静态局部对象,所以当我们第二次及以后再执行 static Singleton sins时,编译器并不会再去创建新的单例对象;同时,虽然sins是局部静态对象,但是其生命周期是全局的,并不影响使用。
  2. 它符合懒汉模式的特点 – 只有在第一次使用单例对象时才去创建对象。
  3. 由于此方法不需要在堆上创建单例对象,并且 C 11 标准规定了局部静态对象的初始化是线程安全的,所以此方法绕开了传统懒汉模式的线程安全问题与 new 抛异常问题。

上面这种实现方式的缺点就是不稳定,因为只有在 C 11 及其之后的标准中局部静态对象的初始化才是线程安全的,而在 C 11 之前的版本中并不能保证;但是我们并不知道我们的代码将来会不会在一些比较老的编译器上运行。

最后需要说明的是,在实际开发中,单例模式的应用场景非常广泛,但是绝大多数情况下我们都是使用饿汉模式,只有在极少数的特殊场景下才会使用懒汉模式。

0 人点赞