c++单例实践

news/2025/9/21 17:10:17/文章来源:https://www.cnblogs.com/codegb/p/19103844

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仍然是满足的,此时又会调用一遍构造函数,此时就会导致内存泄漏(因为创建了两次,但是只保存了一个指针的地址)。
6f58dfea08f8075f8c56fd822868a853

为了解决这种多线程问题,你引入常见的处理多线程同步的机制——锁。在判断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. 赋变量

但是存在一种内存reorder的情况,编译器可能会把第二、第三两个步骤调换顺序,导致另外一个线程获取的变量是一个没有调用构造函数的变量。具体分析可以看文末参考中的第7条链接。

6d55c39c30e3169483f60ca141dcebc6那么有没有一种方法能够让你放心大胆的在各种场景去使用这个单例呢?答案当然是有,而且还不止一种。

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;
};

❗注意:
如果MyInstanceClass位于一个静态库,且这个静态库被多个DLL或者可执行程序链接,那么这个单例就失效了,你在不同编译单元调用的GetInstance获取到的都不是同一个。在 C++ 中,static 局部变量(如 static Singleton instance)具> 有:

  • 内部链接性(internal linkage)或无链接性(no linkage) —— 它不是全局符号,不会被导出。
  • 它的“作用域”仅限于当前编译单元(translation unit)。
  • 但它的“存储位置”是在 .bss 或 .data 段,每个目标文件都有一份独立的存储空间。

👉 所以当多个模块(如 pluginA.so 和 pluginB.so)各自链接了包含该函数的目标文件时,每个模块都会:

  • 拥有该函数的副本(因为函数代码被复制进模块)
  • 拥有该函数内 static 变量的独立副本(因为变量在各自的数据段)

拓展:

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++11const 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博客
  8. Compiler Memory Re-Ordering-githubio
  9. Understanding memory reordering

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/908901.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

NOIP 模拟赛九

gcd+DP+数据结构/贪心+整体 DPReverse Card \((a+b)\mid b\cdot \gcd(a, b)\) 计数。 先化式子,记 \(g=\gcd(a, b), a=ag, b=bg\) 。 \(g(a+b)\mid g^2b\) ,即 \((a+b)\mid gb\) 。 又 \(\gcd(a + b, b)=\gcd(a, b) …

个人项目-软件工程第二次作业 - Nyanya-

这个作业属于哪个课程 计科23级34班这个作业要求在哪里 个人项目这个作业的目标 进行个人编程,设计论文查重算法Github仓库 https://github.com/username/PaperCheck一、PSP表格PSP2.1 Personal Software Process Sta…

go语言中的复杂数据类型

go语言中的复杂数据类型package mainimport ("fmt" )func main() {// 基本类型var a int = 10var b float64 = 3.14var c bool = truevar d byte = Avar e rune = 中var f string = "Hello, Go!"v…

详细介绍:互联网医院品牌IP的用户体验和生态构建

详细介绍:互联网医院品牌IP的用户体验和生态构建pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas",…

实用指南:认知语义学中的象似性对人工智能自然语言处理深层语义分析的影响与启示

pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", …

支持 SSL 中等强度密码组(SWEET32) - 漏洞检查与修复

突发奇想,把漏洞修复的事情也记录一个文档,之前也修复过很多的漏洞,但是总是修复了就完事了,没有留存记录,以后的漏洞我会留一个tag专门记录,如果正好其他人也有遇到的这样的问题,可以很快速的有一个处理方向和…

C# WPF CommunityToolkit.MVVM (测试一)

MainWindow.xaml<Window x:Class="CommunityToolkit.MVVM_RelayCommand_测试.MainWindow"xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"xmlns:x="http://schemas.m…

linux kernel synchronization rcu

Read Copy Update /RCU 可以单个写,多个读,在内核中常用于更新链表。对比顺序锁,只能用指针访问资源,读数据无需加锁,避免多次读数据。 应用场景:多个读 少量写 写相较于读具有更高优先级 rcu保持数据指针的引用…

完整教程:机器学习入门,用Lima在macOS免费搭建Docker环境,彻底解决镜像与收费难题!

pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", …

go语言中的基本数据类型

go语言中的基本数据类型package mainimport ("fmt" )func main() {// 整型var a int = 10var b int8 = -8var c uint16 = 65535var d int64 = 1234567890// 浮点型var e float32 = 3.14var f float64 = 2.71…

实用指南:rsync +生产级 lsyncd 实时同步方案

实用指南:rsync +生产级 lsyncd 实时同步方案2025-09-21 16:51 tlnshuju 阅读(0) 评论(0) 收藏 举报pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: …

锁定Nvidia驱动版本

在 Ubuntu 系统中,NVIDIA 显卡驱动通常通过系统的包管理器(如 apt)进行管理和更新。要防止 NVIDIA 驱动程序自动更新,你可以锁定当前安装的驱动版本,这样即使系统进行了更新,驱动程序也会保持在当前版本。以下是…

第二十一章-sql 注入-union 联合注入 (1)

用户须知1.免责声明:本教程作者及相关参与人员对于任何直接或间接使用本教程内容而导致的任何形式的损失或损害,包括但不限于数据丢失、系统损坏、个人隐私泄露或经济损失等,不承担任何责任。所有使用本教程内容的个…

Android开发参考

WorkManager https://www.cnblogs.com/octsun/category/2471458.html

求出e的值

//题意:利用公式e = 1 + 1/1! + 1/2! + 1/3! + ... + 1/n! 求e ; //输入:只有一行,该行包含一个整数n(2<=n<=15),表示计算e时累加到1/n!。 //输出:输出只有一行,该行包含计算出来的e的值,要求打印小数…

CSP-S模拟24

前言: 没写完的话就先咕着,先滚去学文化课了。 \(T1:\) 炒币 \(T2:\) 凑数 \(T3:\) 同构 \(T4:\) 重建

今年CSP...

我要晋级,我要晋级,我要晋级,我要晋级考的依托。J组,阅读程序第二道,第二层for循环的";"号没看到。大概86.5~88.5左右,还是江苏,晋级有点悬啊。能考88.5还是有点意外,以前都没真正意义上做过一张试卷…

实用指南:VGG改进(9):融合Axial Attention的VGG16架构

pre { white-space: pre !important; word-wrap: normal !important; overflow-x: auto !important; display: block !important; font-family: "Consolas", "Monaco", "Courier New", …

0voice-2.1.1-io多路复用select/poll/epoll

select之前的模式:\(1\) 请求 , \(1\) 线程好处:代码逻辑简单 缺点:不利于并发, \(1 \ k\) 并发量左右select 提供文件集合 fd_set,集合的大小

Transformer与ViT

前言: Transformer 结构非常重要,需要认真学习一遍 李沐老师课程 Transformer 论文 Transformer 代码 Transformer 自测题目 [Transformer 博客](Transformer/BERT/实战 | 冬于的博客 (ifwind.github.io)) 一.Trans…