c++单例实践

C++单例实践

在日常开发中,虽然太多的单例调用会让代码的耦合度变高,但是例如日志类这种,单例模式就变得非常有。所以这篇文章为大家介绍static 关键字相关知识以及如何实现自己的C++单例类。

static关键字

首先让我们请出今天的主角: static。C++中有一个关键字——static,用static修饰的变量或者函数,都会变得不同。根据cpp reference中关于static的描述,static主要有几个作用:

  1. 使全局变量变为内部链接性。
  2. 修饰块作用域变量,其静态存储时间跟随程序并且只会初始化一次。
  3. 修饰类成员,使其与类相关,而非对象。

修饰全局变量

首先补充一下全局变量相关的基础:
假设你在头文件中定义了一个变量,此时你在多个文件里都包含了这个头文件,因为你想在这些地方都共用这个变量。然后你编译代码,发现编译器报错并提示你“xxx重定义”。

// 1.h
int GlobalVar = 1;

// 1.cpp
#include "1.h"
int LocalVar = GlobalVar;

// main.cpp
#include "1.h"

int main()
{
    int var = GlobalVar;

    return 0;
}

问题出在哪呢?让我们回顾一下预处理相关的知识,包含一个头文件,编译器在预处理阶段就会将头文件中的内容展开。回到刚刚的问题,你在多个文件中都包含这个头文件,此时编译器发现有多个地方都声明并且定义了一个GlobalVar,所以就会报错。那么怎样能够在别的文件中使用这个变量呢?别担心,C++提供了extern关键字,来帮助你使用全局变量:

// 1.h
extern int GlobalVar;

// 1.cpp
#include "1.h"
int GlobalVar = 1;
int LocalVar = GlobalVar;

// main.cpp
#include "1.h"

int main()
{
    int var = GlobalVar;

    return 0;
}

你需要在1.h中使用extern声明这个变量,然后在1.cpp中定义这个全局变量。此时任何包含1.h的地方都能够正常使用这个全局变量了。
回到正轨,用static修饰这个全局变量,会有什么效果呢?用static修饰变量,那么这个变量将会变成内部链接性,什么叫内部链接性呢?就是说这个变量只在当前源文件才被可见,即使用extern修饰也不行

// 1.h
static int StaticGlobalVar = 2;
void funct();

// 1.cpp
#include "1.h"
int GlobalVar = StaticGlobalVar;
void funct()
{
    std::cout << "address of: " << std::addressof(StaticGlobalValue) << std::endl;
}

// main.cpp
#include "1.h"

int main()
{
    funct();
    std::cout << "address of: " << std::addressof(StaticGlobalValue) << std::endl;

    return 0;
}

虽然这里在多个地方使用没有问题,但是打印变量的地址你就会发现,在不同的文件中使用,实际上就相当于是创建了两个变量。

address of: 00007FF6BABC16F8
address of: 00007FF6BABC16F0

所以,这也就是内部链接,也就是内部可见性,外部不可见

修饰块内局部变量

static修饰局部变量,变量的存储周期将会发生变化:从第一次定义这个变量起,到程序结束。

#include <iostream>

void func()
{
    static int a = 0;
    int b = 0;
    a++;
    b++;

    std::cout << "a = " << a << "; b = " << b << std::endl;
}

int main()
{
    func();
    func();
    
    return 0;
}

上面代码运行输出为:

a = 1; b = 1;
a = 2; b = 1;

从这我们可以得知,static修饰局部变量之后,其存储空间变成了Static Storage Duration,也就是随程序退出而结束。

修饰类成员

static修饰类成员,该成员变成类的静态成员,属于类,而非属于对象。当static修饰类的成员函数时,相比于成员变量会有一些限制:类的静态成员函数,只能访问类的静态成员,不能访问非静态成员。 这里不难理解,毕竟在没有实例化对象的时候,类的非静态成员还没有创建,此时通过静态函数访问非静态成员就会导致未定义行为。

#include <iostream>

class MyClass
{
public:
    static void FuncStatic
    {
        std::cout << staticVar << std::endl;

        // error! 静态函数只能访问静态成员
        //std::cout << var << std::endl;
    }


private:
    static int staticVar;
    int var;
};

创建属于自己的单例类

通过对上面的介绍,我们已经拥有了一把能够解决单例模式的利剑,让我们一步一步来创建一个属于自己的单例类。暂且将这个类命名为MyInstanceClass

饿汉式单例

实现一个单例,有以下几个点要求:

  1. 全局只有一个实例
  2. 提供了一个全局访问点来访问该实例

要实现全局只有一个实例,意味着不允许自己创建对象,聪明的你可以想到,将构造函数声明成private,这样外部就没办法调用构造函数,也就谈不上创建对象了。

class MyInstanceClass
{
private:
    MyInstanceClass();

};

到这里,有的同学会问了:“构造函数私有化了,那还怎么创建唯一实例呢?” 还记得我们前面介绍过static变量吗?现在该到他出场的时候了。☝🤓我们可以定义一个static成员变量,众所周知,类的静态成员属于类,而不属于对象,这也就符合我们的要求:全局唯一实例。

class MyInstanceClass
{
private:
    MyInstanceClass();

private:
    static MyInstanceClass instance;
};

PS: 顺带插一句:关于为什么静态成员变量能够调用私有的构造函数,网上说的是,静态成员变量是属于类的,并且这个静态成员变量是由编译器去进行初始化的,这个操作在main函数运行之前执行(别问,问就是编译器做的)。这一块可以看一本经典的书:《程序员的自我修养——链接、装载与库》中的11.4节:

对于每个编译单元(.cpp),GCC编译器会遍历其中所有的全局对象,生成一个特殊的函数,这个特殊函数的作用就是对本编译单元里的所有全局对象进行初始化。我们可以通过对本节开头的代码进行反汇编得到一些粗略的信息,可以看到GCC在目标代码中生成了一个名为_GLOBAL__I_Hw的函数,由这个函数负责本编译单元所有的全局\静态对象的构造和析构

现在,我们实现第二个点:提供一个全局访问点来访问。
我们通过定义一个public的静态成员函数GetInstance来获取这个全局实例。为什么要是静态成员函数呢?关于这个问题,首先要明确一个点,静态成员变量是属于类的。如果声明的不是static函数,那么需要实例化一个对象才能调用,而由于构造函数私有化又不能实例化对象,所以只能使用静态成员函数。此外static成员变量,需要在cpp文件里面进行定义。

// .h
class MyInstanceClass
{
public:
    static MyInstanceClass* GetInstance()
    {
        return &instance;
    }

private:
    MyInstanceClass();

private:
    static MyInstanceClass instance;
};

//.cpp
// 定义
MyInstanceClass MyInstanceClass::instance;

同时为了防止能够通过拷贝构造函数来生成对象,我们显式的将拷贝构造函数和赋值运算符删除

// .h
class MyInstanceClass
{
public:
    static MyInstanceClass& GetInstance()
    {
        return instance;
    }

private:
    MyInstanceClass();
    MyInstanceClass(const MyInstanceClass&) = delete;
    MyInstanceClass& operator=(const MyInstanceClass&) = delete;

private:
    static MyInstanceClass instance;
};

通过调用MyInstanceClass::GetInstance()来获取这个实例。
看到这里,恭喜你🥳,你创建了一个饿汉型单例。什么叫“饿汉型单例”呢?顾名思义,饿汉饿汉,就是很饿了,马上就要吃东西,也就对应着这个单例实例在软件运行的时候就会创建

懒汉型单例

也许你是一个十分珍惜内存的开发者,这个单例在你不需要的时候,就占用了内存空间,这显然是不符合你的性格。优化!一定要优化🤬!聪明的你又想到了一个办法☝🤓,将成员变量改成指针,在需要用的时候再去创建不就好了。并且提供一个销毁函数,在程序退出的时候,调用析构函数,将占用的资源适当。像下面这样:

// .h
class MyInstanceClass
{
public:
    static MyInstanceClass& GetInstance()
    {
        if (instance == nullptr)
        {
            instance = new MyInstanceClass;
        }
        
        return *instance;
    }

    void Destroy()
    {
        if (instance)
        {
            delete instance;
            instance = nullptr;
        }
    }

private:
    MyInstanceClass();
    MyInstanceClass(const MyInstanceClass&) = delete;
    MyInstanceClass& operator=(const MyInstanceClass&) = delete;

private:
    static MyInstanceClass* instance;
};

// .cpp
MyInstanceClass* MyInstanceClass::instance = nullptr;

为了防止忘记手动析构变量(不要相信自己一定会记得),你用上智能指针,自动管理内存。

// .h
#include <memory>

class MyInstanceClass
{
public:
    static MyInstanceClass& GetInstance()
    {
        if (instance == nullptr)
        {
            instance.reset(new MyInstanceClass);
        }
    
        return *instance;
    }

private:
    MyInstanceClass();
    MyInstanceClass(const MyInstanceClass&) = delete;
    MyInstanceClass& operator=(const MyInstanceClass&) = delete;

private:
    static std::unique_ptr<MyInstanceClass> instance;
};

// .cpp
std::unique_ptr<MyInstanceClass> MyInstanceClass::instance;

恭喜你,创建了一个懒汉型单例(在第一次调用全局访问接口的时候,才初始化单例)。现在你拿着你的单例去应用到你的项目里面,发现十分的好用🤤,你尝试应用到更多的场景。正好你有一个应用多线程的项目,你也打蒜用你的单例,问题随之而来。

Double Check Lock Pattern(DCLP)

考虑一个多线程场景,两个线程A、B,在A线程第一次调用GetInstance,并判断instance == nullptr,满足条件准备构造的时候。线程B也调用了GetInstance,而此时线程A调用的instance = new MyInstanceClass;还没有返回,所以instance == nullptr仍然是满足的,此时又会调用一遍构造函数,此时就会导致内存泄漏(因为创建了两次,但是只保存了一个指针的地址)。image.png
为了解决这种多线程问题,你引入常见的处理多线程同步的机制——锁。在判断instance变量是否为nullptr时,加锁。

// .h 
#include <memory>
#include <mutex>

class MyInstanceClass
{
public:
    static MyInstanceClass& GetInstance()
    {
        std::lock_guard<std::mutex> locker(mutex_);
        if (instance == nullptr)
        {
            instance.reset(new MyInstanceClass);
        }
    
        return *instance;
    }

private:
    MyInstanceClass();
    MyInstanceClass(const MyInstanceClass&) = delete;
    MyInstanceClass& operator=(const MyInstanceClass&) = delete;

private:
    static std::unique_ptr<MyInstanceClass> instance;
    static std::mutex mutex_;
};

// .cpp
std::unique_ptr<MyInstanceClass> MyInstanceClass::instance;
std::mutex MyInstanceClass::mutex_;

但是细心的又双叒想到了问题(盲生,你发现了华点🕵),其实真正的内存创建只会发生一次,但每一次调用不管内存创建有没有执行,都会执行加锁解锁的操作,这是不必要的浪费。你又会说:优化!一定要优化🤬!
于是你选择在加锁之前,再进行一次判空的操作,如果已经初始化完成,就直接返回instance。就不需要每一次都进行昂贵的加解锁操作。

// .h 
#include <memory>
#include <mutex>

class MyInstanceClass
{
public:
    static MyInstanceClass& GetInstance()
    {
        if (instance == nullptr)
        {
            std::lock_guard<std::mutex> locker(mutex_);
            if (instance == nullptr)
            {
                instance.reset(new MyInstanceClass);
            }
        }
        
        return *instance;
    }

private:
    MyInstanceClass();
    MyInstanceClass(const MyInstanceClass&) = delete;
    MyInstanceClass& operator=(const MyInstanceClass&) = delete;

private:
    static std::unique_ptr<MyInstanceClass> instance;
    static std::mutex mutex_;
};

// .cpp
std::unique_ptr<MyInstanceClass> MyInstanceClass::instance;
std::mutex MyInstanceClass::mutex_;

这种双重检查的操作,我们称之为:Double Check Lock Pattern(DCLP)。然而,DCLP也不像你想象中的那么稳妥,在多线程场景下,仍然是会有问题的。简单来说就是:
instance = new MyInstanceClass
这句代码分成三个步骤:

  1. 创内存
  2. 调构造
  3. 赋变量

但是编译器可能会把第二、第三两个步骤调换顺序,导致另外一个线程获取的变量是一个没有调用构造函数的变量。具体分析可以看文末参考中的第7条链接。
image.png
那么有没有一种方法能够让你放心大胆的在各种场景去使用这个单例呢?答案当然是有,而且还不止一种。

std::call_once

C++11新增了一个函数std::call_once,函数原型如下:

template< class Callable, class... Args >
void call_once( std::once_flag& flag, Callable&& f, Args&&... args );

这个函数保证你所传入的f只调用一次,那么把你的单例类稍作修改,就可以实现只构造一次的需求。

// .h 
class MyInstanceClass
{
public:
    static MyInstanceClass& GetInstance()
    {
        static std::once_flag s_flag;
        std::call_once(s_flag, [&]() {
            instance.reset(new Singleton);
        });

        return *instance;
    }

private:
    MyInstanceClass();
    MyInstanceClass(const MyInstanceClass&) = delete;
    MyInstanceClass& operator=(const MyInstanceClass&) = delete;

private:
    static std::unique_ptr<MyInstanceClass> instance;
};

// .cpp
std::unique_ptr<MyInstanceClass> MyInstanceClass::instance;

Meyer’s Singleton

第二种方法采用在函数中创建一个静态局部变量的方式,利用函数内的静态变量只有在第一次调用,才会初始化的特性,你可以以一种非常简单的方式来实现单例模式。

// .h 
class MyInstanceClass
{
public:
    static MyInstanceClass& GetInstance()
    {
        static MyInstanceClass instance;
        return instance;
    }

private:
    MyInstanceClass();
    MyInstanceClass(const MyInstanceClass&) = delete;
    MyInstanceClass& operator=(const MyInstanceClass&) = delete;
};

拓展:

Storage Duration & Linkage

此部分内容参考Storage class specifiers - cppreference.com,想要详细了解的同学请到这个链接去查看,本文只做简单介绍。

Storage Duration(存储周期)

存储周期,也就是变量什么时候被销毁,根据不同情况主要分为四种:

  1. 自动存储周期(Automatic Storage Duration)

    这种存储周期一般结束于当前的程序块。例如块作用域中的局部变量在结束当时块时就自动销毁。又比如函数中的形参,在函数结束后,就自动销毁。

  2. 静态存储周期(Static Storage Duration)

    此类存储周期跟随程序的退出而结束。例如全局变量或者static修饰的局部变量。

  3. 线程存储周期(Thread Storage Duration)

    此类存储周期跟随线程的退出而结束。注意,这个仅在C++11及之后版本才存在,因为C++11引入了thread_local修饰符。

  4. 动态存储周期(Dynamic Storage Duration)

    此类存储周期的存储周期取决于使用者。例如手动调用new和delete创建的对象。

Linkage(链接性)

同样,链接性也分成三种:

  1. 无链接性(No Linkage)

    此类链接性代表仅仅只能在同一作用域才能访问。例如函数内的局部变量(没有被explicit修饰),局部类和其成员函数等。

  2. 内部链接性(Internal Linkage)

    能够被当前翻译单元访问称之为内部链接性。例如用static修饰的变量、函数等;

  3. 外部链接性(External Linkage)

    能够被其他翻译单元访问的称之为外部链接性。例如有名命名空间下的类、枚举等,使用extern声明的变量等都具有外部链接性。

Static Members

在声明类成员(成员变量和成员函数)时,在前面加上一个static,即可将此成员定义成一个类的静态成员。静态成员拥有静态存储周期以及内部链接性。

基础

当static修饰类成员时,这个类成员就不再与类的对象(object)相关,而是与类相关。直白一点说就是不管你定义多少个对象,类的静态成员始终只有一个,它是与类相关的。

class MyStaticClass
{
public:
    static void StaticFunc()
    {
        
    }

private:
    static int StaticVar;
};
Static Data Members

将类的成员变量用static修饰,它就成为了一个静态数据成员。需要注意的是,静态数据成员不能是mutable

如何定义以及初始化静态成员

根据给变量添加的不同的修饰符,同样也分几种情况:

  1. 普通静态成员

    这种成员不能在类里进行定义,需要在类外进行定义。但是从C++17开始,在static前面加上inline即可实现在类内定义。在类外的定义语法为:
    类型 类名::变量名;

    // .h
    class MyStaticClass
    {
    public:
        static void StaticFunc()
        {
            
        }
    
    private:
        static int StaticVar;
        inline static int InlineStaticVar = 1; 	// since C++17
    };
    
    //.cpp
    int MyStaticClass::StaticVar = 0;
    
  2. const静态成员

    如果一个整形变量或者枚举类型变量用const进行修饰时,其能够直接在类中进行定义。如果 LiteralType 的静态数据成员声明为 constexpr,则必须在类定义中使用初始化器对其进行初始化,初始化器中的每个表达式都是常量表达式。

    struct X
    {
        const static int n = 1;
        const static int m{2}; // since C++11
        const static int k;
    };
    
如何使用静态成员

访问类的静态成员有两种方式:

  1. 通过限定符(qualified)

    MyStaticClass::Func();

  2. 通过成员访问表达式(.、->)

    MyStaticClass().Func();

最后

通过一步一步的对所写的代码进行优化,我们最后实现了比较完美的单例:代码简单、线程安全、用时初始化,这都是这个单例的优点。希望看到这里,各位看官朋友能够有所收获,谢谢。

创作不易,如果对您有帮助,烦请点赞、收藏、关注支持一下,也欢迎各位大佬指点,谢谢。

参考

  1. Storage class specifiers - cppreference.com
  2. static members - cppreference.com
  3. 类的私有private构造函数 ,为什么要这样做 - onewayheaven - 博客园 (cnblogs.com)
  4. c++ - How static function is accessing private member function(constructor) of a class - Stack Overflow
  5. Initialization - cppreference.com
  6. C++11实现线程安全的单例模式(使用std::call_once)_c++11 线程安全-CSDN博客
  7. C++和双重检查锁定模式(DCLP)的风险_dclp认证-CSDN博客
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值