19. 数据的存储类别

一、什么是存储类别

  C 提供了多种不同的模型或存储类别(storage class)在内存中储存数据。

  从硬件方面来看,被储存的每个值都占用一定的物理内存,C 语言把这样的一块内存称为 对象(object)。对象可以储存一个或多个值。一个对象可能并未储存实际的值,但是它在储存适当的值时一定具有相应的大小。

  从软件方面来看,程序需要一种方法访问对象。这可以通过声明变量来完成:

int entity = 3;

  该声明创建了一个名为 entity 的标识符(identifier)。标识符是一个名称,在这种情况下,标识符可以用来指定(designate)特定对象的内容。标识符遵循变量的命名规则。在该实例中,标识符 entity 即是软件(即 C 程序)指定硬件内存中的对象的方式。该声明还提供了储存在对象中的值。

  变量名不是指定对象的唯一途径,例如以下的示例:

int *pt = &entity;
int ranks[10];

  第 1 行声明中,pt 是一个标识符,它指定了一个存储地址的对象。但是,表达式 *pt 不是标识符,因为它不是一个名称。然而,它确实指定了一个对象,这这种情况下,它与 entity 指定的对象相同。

  我们可以用 存储期(storage duration)描述对象,所谓存储期是指对象在内存中保留了多长时间。标识符用于访问对象,我们可以用 作用域(scope)和 链接(linkage)描述标识符,标识符的作用域和链接表明了程序的哪些部分可以使用它。

  不同的存储类别具有不同的存储期、作用域 和 链接。标识符可以在源代码的多文件中共享,也可以用于特定文件的任意函数中,还可以在仅限于特定函数中使用,甚至只在函数中某部分使用也可以。对象可存在于程序的执行期,也可以仅存在于它所在函数的执行期。对于并发编程,对象可以在特定线程的执行期存在。我们可以通过函数调用的方式显示分配和释放内存。

1.1、作用域

  作用域(scope)描述程序中可访问标识符的区域。一个 C 变量的作用域可以是 块作用域、函数作用域、函数原型作用域 或 文件作用域。

  块作用域(block scope) 是用一对花括号括起来的代码区域。例如,整个函数体是一个块,函数中的任意复合语句也是一个块。定义在块中的变量具有块块作用域,块作用域变量的可见范围是从定义处到包含该定义的块的末尾。另外,虽然函数的形式参数声明在函数的左括号之前,但是它们也具有块作用域,属于函数体这个块。

  以前,具有块作用域的变量都必须声明在块的开头。C99 标准放宽了这一限制,允许在块中的任意位置声明变量。为了使用这个新特性,C99 把块的概念扩展到班会 for 循环、while 循环、do…while 循环 和 if 语句所控制的代码,即使这些代码没有用花括号括起来,也算是块的一部分。

double bolck(double cleo)
{
    double partrick = 0.0;
    int i;
    for(i = 0; i < 10; i++)
    {
        double q = cleo * i;    // q的作用域开始
        ...
        partrick += q;
    }                           // q的作用域结束
    ...
    return partrick;
}

  函数作用域(function scope)仅用于 goto 语句的标签。这意味着即使一个标签首次出现在函数的内层块中,它的作用域也延伸至整个函数。

void function(void)
{
    int i = 0;

    FLAG:
    for(i = 0; i < 10; i++)
    {
        if(i == 5)
        {
            goto FLAG;
        }
    }
}

  函数原型作用域(function prototype scope)用于函数原型中的形参名函数原型作用域的范围是从形参定义处到原型声明结束。这意味着,编译器在处理函数原型中的形参时只关心它的类型,而形参名(如果有的话)通常无关紧要。而且,即使有形参名,也不必于函数定义中的形参名相匹配。只有在变长数组中,形参名才有用。变长数组中方括号中必须使用在函数原型中已声明的名称。

int prototype(int, int);

int prototype(int a, int b)
{
    return a + b;
}

  变量的定义在函数的外面,具有 文件作用域(file scope)具有文件作用于的变量,从它的定义处到该定义所在文件的末尾均可见。文件作用域变量也称为 全局变量(global varibale)。

int units = 0;      // 该变量具有文件作用域

void critic(void)
{
    ...
}

1.2、链接

  C 变量具有 3 种链接属性:外部链接内部链接无链接。具有 块作用域函数作用域函数原型作用域 的变量都是 无链接变量。这意味着这些变量属于定义它们的 块、函数 或 原型 私有。具有 文件作用域 的变量可以是 外部链接内部链接外部链接变量 可以在多文件中使用,内部链接变量只能在一个翻译单元(即一个源代码文件和它包含的头文件)中使用。我们可以通过查看外部定义中是否使用了存储类别说明符 static 来判断 文件作用域 的变量是 内部链接 还是 外部链接。

int aiants = 5;             // 文件作用域,外部链接
static int dodgers = 5;     // 文件作用域,内部链接

int main(void)
{
    ...
    return 0;
}

1.3、存储期

  作用域链接 描述了 标识符的可见性存储期 描述了通过这些标识符访问的 对象的生存期。C 对象有 4 种存储期:静态存储期线程存储期自动存储期动态分配存储期

  如果对象具有 静态存储期,那么它在程序的执行期间一直存在文件作用域变量 具有 静态存储期。对于 文件作用域变量,关键字 static 表示了其 链接属性,而非存储期。以 static 声明的 文件作用域变量 具有 内部链接。但是无论是内部链接还是外部链接,所有的文件作用域变量都具有静态存储期。

  线程存储期 用于并发程序设计,程序执行可被分为多个线程。具有 线程存储期 的对象,从被声明到线程结束一直存在以关键字 _Thread_local 声明一个对象时,每个线程都获得该变量的私有备份

  块作用域 的变量通常具有 自动存储期当程序进入定义这些变量的块时,为这些变量分配内存;当退出这个块时,释放刚才为变量分配的内存。这种做法相当于把自动变量占用的内存视为一个可重复使用的工作区或暂存区。例如,一个函数调用结束后,其变量占用的内存可用于储存下一个被调用函数的变量。

void bore(void)
{
    int index = 0;
    index++;
    return index;
}

  然而,块作用域变量也能具有静态存储期。为了创建这样的变量,要把变量声明在块中,且在声明前面加上关键字 static。

void more(void)
{
    static int count= 0;
    count++;
    return count;
}

  这样,变量 count 储存在静态内存中,它从程序被载入到程序结束期间都存在。但是,它的作用域定义在 more() 函数块中。只有在执行该函数时,程序才能使用 count 访问它指定的对象(但是,该函数可以给其它函数提供该存储区的地址以便间接访问该对下个,例如通过指针形参或返回值)。

二、五种存储类别

存储类被 存储期 作用域 链接 声明方式
自动 自动 块内
寄存器 自动 块内,使用关键字 register
静态外部链接 静态 文件 外部 所有函数外
静态内部链接 静态 文件 内部 所有函数外,使用关键字 static
静态无链接 静态 块内,使用关键字 static

2.1、自动变量

  属于 自动存储类别 的变量具有 自动存储期块作用域无链接默认情况下,声明在块或函数头中的任意变量都属于自动给存储类别。为了更清楚地表达你的意图,我们可以显示使用关键字 auto。关键字 auto 是 存储类别说明符(storage-class specifier)。

  块作用域和无链接意味着只有在变量定义所在的块中才能通过变量名访问该变量。另一个函数可以使用同名变量,但是该变量是储存在不同内存位置上的另一个变量。

  变量具有自动存储期意味着,程序在进入该变量声明所在的块时变量存在,程序在退出该块时变量消失、原来该变量占用的内存位置现在可做它用。

  如果内层块中声明的变量与外层块中的变量同名,那么内层块会隐藏外层块的定义。但是离开内层块后,外层块变量的作用域又回到原来的作用域。

  自动变量不会初始化,除非显示初始化它。

int main(void)
{
    int repid = 0;	// 自动变量
    return 0;
}

2.2、寄存器变量

  变量通常储存在计算机内存中。如果幸运的话,寄存器变量储存在 CPU 的寄存器中,或者概括地说,储存在最快地可用内存中。与普普通变量相比,访问和处理这些变量的速度更快。由于寄存器变量储存在寄存器而非内存中,所以无法获取寄存器变量的地址。寄存器变量 也是 块作用域无链接自动存储期。使用存储类别说明符 register 便可声明寄存器变量。

int main(void)
{
    register int quick;		// 寄存器变量
    return 0;
}

  声明变量为 register 类别与直接命令相比,它更像是一种请求。编译器必须根据寄存器或最快可用内存的数量衡量你的请求,或者直接忽略你的请求。在这种情况下,寄存器变量就会变成普通的自动变量。即使这样,仍然不能对该变量使用取地址运算符。

  在函数头中使用关键字 register,便可直接请求形参是寄存器变量。

void macho(register int n)

可声明为 register 的数据类型有限;

2.3、块作用域的静态变量

  静态的意思是该变量在内存中原地不动,并不是说它的值不变。具有 文件作用域 的变量自动具有(也必须是)静态存储期。我们可以创建具有静态存储期、块作用域的局部变量。这些变量和自动变量一样,具有相同的作用域,但是程序离开它们所在的函数后,这些变量不会消失。也就是说,这种变量具有块作用域、无链接,但是具有静态存储期。计算机在多次函数调用之际会记录它们的值。在块中(提供块作用域和无链接)以存储类别说明符 static(提供静态存储期)声明这种变量。

#include <stdio.h>

int getCount(void);

int main(void)
{
    int i = 0;
    int count = 0;

    for(i = 0; i < 5; i++)
    {
        count = getCount();
        printf("count: %d\n", count);
    }

    return 0;
}

int getCount(void)
{
    static int count = 0;	// 块作用域的外部变量
    return ++count;
}

不能在函数的形参中使用 static 关键字;

2.4、外部链接的静态变量

  外部链接的静态变量 具有 文件作用域外部链接静态存储期。该类别有时称为 外部存储类别(enternal strorage class),属于该类别的变量称为 外部变量(external variable)。把变量的定义声明(defining declaration)放在所以后函数的外面便创建了外部变量。为了指出该函数使用了外部变量,我们可以在函数中使用关键字 extern 再次声明。如果一个源代码文件使用的外部变量定义在另一个源代码文件中,则必须用 extern 在该文件中声明该变量。

int errupt;                 // 外部定义的变量
double up[100];             // 外部定义的数组
extern char coal;           // 如果coal被定义在另一个文件中

void next(void);

int main(void)
{
    extern int errupt;      // 可选的声明
    extern double up[];     // 可选的声明

    return 0;
}

void next(void)
{
    ...
}

  注意,在main() 中声明 up[] 数组(这是可选的声明)时不用指明数组大小。这是因为第 1 次声明已经提供了数组大小信息。main() 中的两条 extern 声明完全可以忽略,因为外部变量具有文件作用域,所以 errupt 和 up[100] 从声明处到文件结尾都可见。它们出现在那里,仅为了说明 main() 函数要使用着两个变量。

  如果省略函数中 extern 关键字,相当于创建了一个自动变量。例如:

int errupt;

  这使得编译器在 main() 创建了一个名为 errupt 的自动变量,它时一个独立的局部变量,与原来的外部变量 errupt 不同。该局部变量仅 main() 中可见,但是外部变量 errupt 对于该文件中的其它函数也可见。简而言之,在执行块中的语句时,块作用域中的变量将 “隐藏” 文件作用域中的同名变量。如果不得已要使用与外部变量同名的局部变量,可以在局部变量的声明中使用 auto 存储类别说明符明确表达这种意图。

  外部变量和自动类似,也可以被显示初始化。与自动变量不同的是,如果未初始化外部变量,它们会被自动初始化为 0。这一原则也适用于外部定义的数组元素。与自动变量的情况不同,只能使用常量表达式初始化文件作用域变量。

关键字 extern 表示该声明不是定义,因为它指示编译器去别处查询其定义;

外部变量只能初始化一次,且必须在定义该变量时进行;

2.5、内部链接的静态变量

  该存储类别的变量具有 静态存储期、文件作用域 和 内部链接。在所有函数外部用存储类别说明符 static 定义的变量具有这种存储类别。

static int a = 1;	// 静态变量,内部链接

int main(void)
{
    ...
    return 0;
}

  普通的外部变量可用于同一程序中任意文件中的函数,但是内部链接的静态变量只能用于同一个文件中的函数。我们可以使用存储类别说明符 extern ,在函数中重复声明任意具有文件作用域的变量。这样的声明并不会改变其链接属性。

int traveler = 1;	            // 外部链接
static int stayhome = 1;        // 内部链接

int main(void)
{
    extern int traveler;        // 使用定义在别处的traveler
    extern int stayhome;        // 使用定于在别处的stayhome

    return 0;
}

  对于该程序所在的翻译单元,traveler 和 stayhome 都具有文件作用域,但是只有 traveler 可用于其它翻译单元(因为它具有外部链接)。这两个声明都使用了 extern 关键字,指明了 main() 中使用的这两个变量的定义都在别处,但是这并未改变 stayhome 的内部链接属性。

三、存储类别说明符

  C 语言有 6 个关键字作为存储类别说明符:auto、register、static、extern、_Thread_local 和 typedef。

  auto 说明符表明变量是自动存储期,只能用于块作用域的变量声明中。由于在块中声明的变量本身就具有自动存储期,所以使用 auto 主要是为了明确表达要使用与外部变量同名的局部变量的意图。

  register 说明符也只用于块作用域的变量,它把变量归为寄存器存储类别,请求最快速度访问该变量。同时,还保护了该变量的地址不被获取。

  用 static 说明符创建的对象具有静态存储期,载入程序时创建对象,当程序结束时对象消失。如果 static 用于文件作用域声明,作用域受限于该文件。如果 static 用于块作用域声明,作用域则受限于该块。因此,只要程序在运行对象就存在并保留其值,但是只有在执行块内的代码时,才能通过标识符访问。块作用域的静态变量无链接。文件作用域的静态变量具有内部链接。

  extern 说明符表明声明的变量定义在别处。如果包含 extern 的声明具有文件作用域,则引用的变量必须具有外部链接。如果包含 extern 的声明具有块作用域,则引用的变量可能具有外部链接或内部链接,这取决于该变量的定义式声明。

四、存储类别与函数

  函数也有存储类别,可以是 外部函数(默认)或 静态函数。C99 新增了第 3 种类别 —— 内联函数。外部函数 可以被其它文件的函数访问,但是 静态函数 只能用于其定义所在的文件。以 sattic 存储类别说明符创建的函数属于特定模块私有;用 extern 关键字声明定义在其它文件中的函数。这样做是为了表明当前文件中使用的函数定义在别处。除非使用 static 关键字,否则一般函数声明都默认为 extern。

double gamma(double);               // 该函数默认为外部函数
static double beta(int, int);       // 静态函数,特定模块私有
extern double delta(double, int);   // 外部函数,该函数被定义其它处

五、ANSI C类型限定符

  C90 还新增了两个属性:恒常性(constancy)和 易变性(colatility)。这两个属性可以分别用关键字 constvolatitle 来声明,以这两个关键字创建的变量是 限定类型(qualified type)。C99标准 新增了第 3 个限定符:restrict,用来提高编译器优化。C11 标准新增了第 4 个限定符:_Atomic。C11 提供了一个可选库,由 stdatomic.h 管理,以便支持并发程序设计。C99 为类型限定符增加了一个新属性:它们现在是幂等的(idempotent),其意思是可以在一条声明中多次使用同一个限定符,多余的限定符将被忽略。

5.1、const类型限定符

【1】、const 修饰基本数据类型变量

  以 const 关键字声明的对象,其值不能通过赋值或递增、递减来修改。但是,我们可以初始化 const 变量。

const int nochange = 3;

  该声明让 nochange 称为只读变量。初始化后,就不能再改变它的值。

【2】、const 修饰数组

  我们可以用 const 关键字创建不能修改的数组。

const int array[5] = {1,2,3,4,5};

【3】、const 修饰指针变量

  const 修饰指针变量时,要区分是限定指针本身为 const ,还是限定指针指向的值为 const。

const float * pf;

  该语句表明了 pf 指向的值不能被改变,而 pf 本身的值可以改变。

float * const pf;

  该语句表明了 pf 本身的值不能更改,pf 必须指向同一个地址,但是它所指的值可以改变。

const float * const pf;

  该语句表明了 pf 既不能指向别处,它所指向的值也不能改变。

float const * pf;

  如果把 const 放在类型名之后,* 之前,说明该指针不能用于改变它所指向的值。简而言之,const 方法 * 左侧任意位置,限定了指针指向的数据不能改变;const 放在 * 右侧,限定了指针本身不能改变。

  const 关键字的常见用法是声明为函数形参的指针。ANSI C 库 遵循这种做法。如果一个指针仅用于给函数访问值,应将其什么声明为一个指向 const 限定类型的指针。如果要用指针更改主调函数中的数据,就不使用 const 关键字。

【4】、const 修饰全局变量

  我们可以使用 const 修饰全局变量,限制程序更改它的值。然而,在文件中共享 const 数据要小心,我们可以采取两种策略:一种是:遵循外部变量的常用规则,即在一个文件中使用定义式声明,在其它文件中使用引用式声明(用 extern 关键字)。另一种方案是:把 const 变量放在一个头文件中,然后在其它文件中包含该文件。

  使用第二种方案时,必须在头文件中用关键字 static 声明全局静态 const 变量。如果去掉 static,那么在 file1.c 和 file2.c 中包含该头文件将导致每个文件中都有一个相同标识的定义声明,C 标准不允许这样做。

5.2、volatile类型限定符

  volatile 限定符告知计算机,代理(而不是变量所在的程序)可以改变该变量的值。通常,它被用于硬件地址以其在其它程序或同时运行的线程中共享数据。

  假如有下面的一段代码:

value1 = x;
// 一些不使用x的代码
value2 = x;

  智能的(进行优化的)编译器会注意到以上代码使用了两次 x,当并未改变它的值。于是编译器把 x 的值临时储存在寄存器中,然后在 value2 需要使用 x 时,才从寄存器中(而不是从原始内存位置上)读取 x 的值,以节约时间。这个过程被称为 高速缓存(caching)。通常,高速缓存时个不错的优化方案,但是如果一些其它代理在以上两条语句之间改变了 x 的值,就不能这样优化了。如果没有 volatitle 关键字,编译器就不知道这种事情是否发生。因此,为安全起见,编译器不会进行告诉缓存。这是 ANSI C 之前的情况。现在,如果声明中没有 volatile 关键字,编译器会假定变量的值在使用过程中不变,然后在尝试优化代码。

可以用 const 和 valatitle 限定一个值;

5.3、restrict类型限定符

  restrict 关键字允许编译器优化某部分代码以便更好地支持计算。它只能用于指针,表明该指针式访问数据对象的唯一且初始的方式。

  例如,下面的一段代码:

int array[10];
int * restrict restar = (int *)malloc(10 * sizeof(int));
int *par = array;

  这里,指针 restar 是访问由 malloc() 所分配内存的唯一且初始的方式。因此,可以用 restrict 关键字限定它。而指针 par 既不是访问 array 数组中数据的初始方式,也不是唯一方式。所以不能把它设置为 restrict。

  restrict 限定符还可用于函数形参中的指针。这意味着编译器可以假定在函数体内其它标识符不会修改该指针指向的数据,而且编译器可以尝试对齐优化,使其不做别的用途。

  restrict 关键字由两个读者。一个是编译器,该关键字告诉编译器可以自由假定一些优化方案。另一个是用户,该关键字告诉用户要使用满足 restrict 要求的参数。总而言之,编译器不会检查用户是否遵循这一限制,但是无视它后果自负。

5.4、_Atomic类型限定符

  并发程序设计把程序执行分成可以同时执行的多个线程。这给程序设计带来了新的挑战,包括如何管理访问相同数据的不同线程。C11 通过包含可选的头文件 stdatomic.h 和 threads.h 提供了一些可选的(不是必须实现的)管理方法。值得注意的是,要通过各种宏函数来访问原子类型。当一个线程对一个原子类型的对象执行原子操作时,其它线程不能访问该对象。

posted @ 2023-03-21 12:04  星光映梦  阅读(55)  评论(0编辑  收藏  举报