C++的存储持续性,作用域与链接性,单例模式简介
[TOC]
实际编程中对 static extern 等关键字以及全局变量, 局部变量, 静态变量, 作用域等概念有一些模糊的认识, 很多时候凑合着用,直到出现一些问题后谷歌别人的回答总觉得意犹未尽, 没有总结到位, 因此我花时间又回炉重造了一下 《C++ prime plus》的第九章内容, 总算理清楚了很多东西. 很多东西是第一遍无法深刻理解直到应用中出了问题才会理解深刻. 本文是对第九章内容的笔记以及添加了对对象中的 static 关键字的理解, 最后再浅谈一下应用此知识的单例模式.
基础概念
存储持续性(storage duration)
C++ 中使用三种(C++11 是四种)方案存储数据, 每种区别在于保留在内存中的时间.
- 自动存储持续性(automatic storage duration):随着代码块/函数的执行时被自动创建, 执行完后被自动释放. 存放的内存空间为栈.
- 静态存储持续性(static storage duration):生命周期等于整个程序的生命周期,为函数外定义的变量/static 定义的变量.
- 线程存储持续性(thread storage duration)(C++11起):
thread_local
关键字声明的变量其生命周期与所属的线程一样长. - 动态存储持续性(dynamic storage duration):new 分配的内存, 直到 delete 手动使其释放, 有时被称为自由存储(free store)或堆(heap).
对于其中涉及到的内存模型,简单介绍如下:
可执行文件(可能包含静态库动态库)被复制到物理内存中, 通过 MMU (内存管理单元)映射为程序运行的虚拟内存, 在虚拟的连续内存上分配第二个图中的内存结构.
包括向下生长的栈, 向上生长的堆(方向根据编译平台可能不一样), 以及编译期间确定长度的数据区以及代码区.
截图来源于《程序员的自我修养-链接, 装载与库》.
作用域(scope): 描述文件(翻译单元)的多大范围可见. 例如:
- 函数体/代码块内定义的变量作用域在从定义处到函数/代码块结束的区间.
- namespace 内定义的变量作用域为 namespace 内.
- 函数原型作用城 ( function prototype scope )即函数声明时的括号内使用的名称只在括号内使用, 这也解释了为什么这些名称是什么以及是否出现都不重要的原因.
- 全局静态数据的作用域在所有将其 include 进去的文件.
- 局部变量隐藏全局变量的效果与 C 一致, 这里不再阐述.
链接性(linakage): 描述名称(变量/函数/对象等)如何在不同单元(文件/namespace 等)间共享. 分为如下情形:
- 外部链接(External Linkage)
如果最终的可执行文件由多个程序文件链接而成, 一个标识符在任意程序文件中即使声明多次也都代表同一个变量或函数, 则这个标识符具有 External Linkage.具有 External Linkage 的标识符编译后在符号表中是 GLOBAL 的符号. - 内部链接(Internal Linkage)
如果一个标识符在某个程序文件中即使声明多次也都代表同一个变量或函数, 则这个标识符具有 Internal Linkage. 具有 Internal Linkage 的标识符编译后在符号表中是 LOCAL 的符号. - 无链接(No Linkage)
除以上情况之外的标识符都属于 No Linkage 的, 例如函数的局部变量, 以及不表示变量和函数的其它标识符. 自动存储变量只有无链接属性. - 模块链接性(module linkage)
C++20 引入的新概念, 这里不展开.
依据上面 3 个维度的分类可以将变量分为 5 种:
存储描述 | 持续性 | 作用域 | 链接性 | 如何声明 |
---|---|---|---|---|
自动 | 自动 | 代码块 | 无 | 在代码块中 |
寄存器 | 自动 | 代码块 | 无 | 在代码块中, 使用关键字 register |
静态,无链接性 | 静态 | 代码块 | 无 | 在代码块中, 使用关键字 static |
静态,外部链接性 | 静态 | 文件 | 外部 | 不在任何函数内, 声明时需要使用关键字 extern |
静态,内部链接性 | 静态 | 代码块 | 内部 | 不在任何函数内, 使用关键字 static |
“单定义规则”(One Definition Rule ODR)
C++ 提供了两种变量声明. 一种是定义声明(defining declaration) 或简称为定义(definition), 它给变量分配存储空间: 另一种是引用声明(referencing declaration)或简称为声明(declaration), 它不给变量分配存储空间, 因为它引用已有的变量. 引用声明使用关键字 extern
, 且不进行初始化; 否则, 声明为定义, 导致分配存储空间.定义只能在一处, 而引用则不限制次数.
函数的存储特性与链接性
- 所有的函数(非成员函数)都是静态的, 存储在代码区, 默认外部链接的(加与不加 extern 都一样).
如果想把函数限制为内部链接性的,则需要加上关键字 static. - 单定义规则也适用于非内联函数.
- 内联函数不受单定义规则约束,C++ 要求同一个函数的所有内联定义都必须相同.
语言链接性(language linkage)
此部分在 extern "C"
中详细解释.
静态变量的初始化
静态初始化
零初始化的(zero-Initialized)
如果没有显式地初始化静态变量, 编译器将把它设为 0. 在默认情况下, 静态数组和结构将每个元素或成员的所有位都设置为 0 . 对于标量类型, 零将被强制转换为合适的类型. 例如, 在 C 代代码中, 空指针用 0 表示,但内部可能采用非零表示 .常量表达式初始化
编译期间计算初始化:int x = 1 + 2;
C++11 中引入了 constexpr 关键字方便区分常量表达式初始化.
动态初始化
程序执行时确定值, 完成初始化.
存储说明符( storage class specifier )与cv-限定符(cv-quafifier)汇总
上面从变量的性质对 extern static 关键字进行了说明. 这一节会把所有的存储说明符与 cv-限定符简单汇总一下.
在同一个声明不能使用多个说明符, 但 thread_local 除外,它可与 static 或 extern 结合使用.
auto
C++11 之前的作用是声明变量为自动变量, 但是变量不声明也一眼能看出其性质, 因此大家几乎没有使用此关键字, 变得非常鸡肋.
C++11 增加了类型推导的功能, 考虑到不想增加关键字, 就把没人用的 auto 用来声明类型自动推导. 使用了 auto 关键字以后, 编译器会在编译期间自动推导出变量的类型, 这样我们就不用手动指明变量的数据类型了.
auto 仅仅是一个占位符, 在编译器期间它会被真正的类型所替代.
例子:
1 | int n = 20; |
auto 和 const 的结合
1 | int x = 0; |
当类型不为引用时, auto 的推导结果将不保留表达式的 const 属性;
当类型为引用时, auto 的推导结果将保留表达式的 const 属性.
auto 的限制
- 不能在函数的参数中使用(C++14 中 lambda 表达式中可以使用 auto 在参数中).
- 不能作用于类的非静态成员变量(也就是没有 static 关键字修饰的成员变量)中.
- 关键字不能定义数组.
- 不能作用于模板参数(C++14 中不成立).
auto 的应用
对于繁琐且一目了然的类型, 使用 auto 节省打字时间, 例如迭代器.
1
2
3
4
5
6std::vector<std::vector<int>> v;
//std::vector<std::vector<int>>::iterator i = v.begin();
auto i = v.begin(); //能少打很多字符字
for(auto ele in v){
std::cout << ele << std::endl;
}用于泛型编程中减少类型模板参数的设置.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
using namespace std;
class A{
public:
static int get(void){
return 100;
}
};
class B{
public:
static const char* get(void){
return "class B get";
}
};
template <typename T1, typename T2> //需要增加一个类型模板参数 T2
void func(void){
T2 val = T1::get();
cout << val << endl;
}
int main(void){
//调用时也要手动给模板参数赋值
func<A, int>();
func<B, const char*>();
return 0;
}
使用 auto 后:
1 | //只需要一个模板参数 |
register
最初是由 C 语言引入的,它建议编译器使用 CPU 寄存器来存储自动变量, 旨在提高访变量的速度.
1 | register int count_fast ; |
C++11 之前的它的作用是提示表明变量用得很多, 编译器可对其做特殊处理.
在 C++11 以后, 这种提示作用也失去了, 关键字 register 只是显式地指出变量是自动的, 与 C++11 之前的 auto 功能一致. 保留关键字 register 的重要原因是, 避免使用了该关键字的现有代码非法.
static
static 在作用域为整个的声明时, 表示内部链接性.
被用于局部声明中表示局部变量的存储持续位为静态的.
static 成员变量
用在类中的 static 成员变量, 则是所有类的实例都可以访问的静态数据, 也就是说它是属于类的, 而不是类的对象的.
1 | class Test{ |
有几点需要注意:
- 静态成员变量必须初始化, 而且只能在类体外进行.
- 静态成员变量既可以通过对象名访问, 也可以通过类名访问, 但要遵循 private,protected 和 public 关键字的访问权限限制. 当通过对象名访问时, 对于不同的对象, 访问的是同一份内存.
静态成员函数
静态成员函数也是静态存储持续性的, 只能访问静态成员. 编译器在编译一个普通成员函数时, 会隐式地增加一个形参 this
, 并把当前对象的地址赋值给 this
, 所以普通成员函数只能在创建对象后通过对象来调用, 因为它需要当前对象的地址. 而静态成员函数可以通过类来直接调用, 编译器不会为它增加形参 this
,它不需要当前对象的地址, 所以不管有没有创建对象, 都可以调用静态成员函数.
extern
对全局变量的声明作用这里不再赘述. 这里介绍一下extern "C"
的使用.
C++ 是在 C 的基础上发展而来的,虽然 C++ 可以向下兼容 C ,但是也无法完全无法无缝使用. 例子如下, 标准库一般具有一下结构. 可以解释为什么需要使用 extern "C"
.
1 |
|
这需要从 C 与 C++ 的区别说起, C++ 比 C 丰富的一个功能是支持重载, 与之对应的是 C++ 的编译器通过 name mangling 实现了重载. 在代码里下面的两个函数的名字是一样的, 但是对于编译后的程序而言, 他们是完全不同的函数. 前者类似于_fun_int_int
, 后者类似于_fun_double_doublee
. 这就是不同语言的 language linkage 的区别.
1 | void fun(int,int); |
下面通过直接对比相同代码分别用 C 与 C++ 的编译器(GCC) 得到的函数修饰的例子来展示.
代码如下
1 |
|
使用 C 编译器gcc -c test.c -o tc
使用 Linux 下的 nm
命令查看生成的可执行文件的符号信息, 输出如下:
1 | 0000000000000000 D g_prefix |
使用 C++ 编译器 g++ test.c -o tcpp
nm
输出如下:
1 | 0000000000601040 B __bss_start |
可以看到 hello 函数在不同编译器下生成的符号分别为 hello
与 _Z5helloPKc
._Z5helloPKc
每个字符的含义这里不展开. 我们由此可以知道 extern "C"
的必要性了.
extern "C"
的具体用法
在 C++ 中引用 C 语言中的函数和变量, 在包含 C 语言头文件(假设为 cExample.h)时, 需进行下列处理:
1
2
3
4extern "C"
{
}在 C 中引用 C++ 中的函数和变量时, 应该仅将 C 文件中将 C++ 中定义的函数声明为 extern 类型.
1 | //C++头文件 cppExample.h |
volatile
关键字 volatile 表明, 即使程序代码没有对内存单元进行修改, 其值也可能发生变化.
虽然本程序没有对内存进行修改, 但是有其他修改其值的来源. 例如, 一个指针指向某个硬件位置, 其中包含了来自串口的时间或信息. 在这种情况下硬件(而不是程序)可能修改其中的内容. 或者两个程序可能互相影响, 共享数据.
volatile 的作用是为改善编译器的优化能力. 例如, 假设编译器发现某程序在几条语句中两次使用了某变量的值, 则编译器可能不是让程序查找这个值两次, 而是将这个值缓存到寄存器中. 这种优化假设变量的值在这 2 次使用之间不会变化, 如果将变量声明为 volatile, 则告诉编译器不要进行这种优化.
mutable
用来说明即使结构 (或类〕变量为 const,声明为 mutable 的成员也可以被修改.
1 | struct data{ |
thread_local
thread_local 表明被声明的变量拥有线程存储期(对象的存储在线程开始时分配, 而在线程结束时解分配), 在多个线程中任意一个线程里, 表现的都为一个线程静态变量. 至于其链接性通过 extern 或者 static 关键字来进行指定(除了静态的成员变量,它是永远外部链接性的).
例子:
1 |
|
输出:
1 | thread[t2]: x = 2 |
可以看到 thread_local 变量只会在每个线程最开始被调用的时候进行初始化. 并且只会被初始化一次.
注意: thread_local 作为类成员变量时必须是 static 的.
const 的链接性
与 C 不同, C++ 对 const 的链接性进行了修改. 在代码块外部定义的 const 变量不是默认的外部链接变量而是内部链接变量, 等同于 static 关键字.
如果希望常值变量是内部链接的话, 再加上 extern 即可, 其余用法与表现与 non-const 的 extern 一致.
1 | extern const int a = 1; |
全局变量
如果想在多个文件之间共享数据(增加外部链接性), 有如下 3 种思路.
- extern 外部链接变量, 需要使用的地方 extern 声明该变量即可, 注意有可能会被无外部链接性的本地变量覆盖.
- 头文件中声明变量为内部链接变量(static / const), 需要共享数据的文件包含头文件, 即可使用.
- 对于对象型变量可以考虑使用单例模式, 保证一个类仅有一个实例, 并提供一个访问它的全局访问点, 该实例被所有程序模块共享.
非对象的全局变量
全局变量虽然不用传参, 直接访问即可但是代价也很大会导致程序不可靠, 因此推荐只在 const 的外部常量数据作为全局变量. 例如:
1 | const char * months[12]= |
对象的全局变量-单例模式
如果需要共享一个对象, 最好只构造出一个实例出来, 并且在多线程时考虑到线程安全问题.
单例类满足下面的特性:
- 私有化类的构造函数, 以防止外界创建单例类的对象.
- 使用类的私有静态指针变量指向类的唯一实例.
- 使用一个公有的静态方法获取该实例.
在 C++11 标准下,《Effective C++》提出了一种非常优雅的单例模式实现, 使用函数内的 local static 对象. 这样, 只有当第一次访问 getInstance()
方法时才创建实例. 这种方法也被称为 Meyers’ Singleton.
懒汉版(Lazy Singleton)
单例实例在被调用时才会被构造出来. C++0x 之后该实现是线程安全的, C++0x 之前仍需加锁.
1 | class Singleton |
饿汉版(Eager Singleton)
单例实例在程序运行时被立即执行初始化. 由于在 main 函数之前初始化, 所以没有线程安全的问题. 但是潜在问题在于 non-local static 对象(函数外的 static 对象)在不同编译单元中的初始化顺序是未定义的. 也即, static Singleton m_instance
和 static Singleton& getInstance()
二者的初始化顺序不确定, 如果在初始化完成之前调用 getInstance()
方法会返回一个未定义的实例.
1 | class Singleton |
单例类模板
不可能对所有的单例类都手动写一遍上面的代码, 可以考虑采用多重继承(继承单例抽象类), 或者制作一个单例的模板, 当我们需要这个单例类模板时, 只需要在自己类里通过 friend 添加为友元即可.
1 |
|
main.cpp
1 |
|
输出:
1 | mstr = abc |
可以看到 main 函数里定义的两个 Test 的实例地址都一致, 也就是同一个实例, 改变一个成员变量, 通过另一个指针访问的结果也被改变了.
参考链接:
https://en.cppreference.com/w/cpp/language/storage_duration
https://zhuanlan.zhihu.com/p/37469260
https://murphypei.github.io/blog/2020/02/thread-local
https://www.cnblogs.com/lifexy/p/8810877.html
https://www.cnblogs.com/rollenholt/archive/2012/03/20/2409046.html
C++的存储持续性,作用域与链接性,单例模式简介
https://www.chuxin911.com/c++_storage_duration_scope_linkage_20220119/