这一章在干嘛?

C 的高明之处在于让你精细控制程序的”细枝末节”——哪个函数能看见哪个变量(作用域)、变量活多久(存储期限)、内存什么时候分配什么时候回收。这一章先建立作用域、链接、存储期限三个概念,再讲五种存储类,然后是随机数函数的实战,最后登场的是手动管理内存的 malloc()free()。这是 C 内存世界的地图。

12.1 存储类:作用域、链接与存储期限

C 为变量提供了五种存储类(storage class)模型(本章末尾还会加第六种——动态分配)。描述一个变量要看三件事:存储期限(storage duration)——它在内存里待多久;作用域(scope)链接(linkage)——共同决定程序的哪些部分能按名字访问它。

作用域

  • 块作用域(block scope):定义在一对花括号内,从定义处可见到块结束;函数形参也算。C99 放宽了”必须写在块开头”的老规矩,还把 for/while/if 的控制部分也算作块:for (int i = 0; i < 10; i++) 里的 i 只在循环里可见。
  • 函数原型作用域:原型参数名从声明处到原型结束有效,编译器只关心类型(变长数组参数除外)。
  • 文件作用域(file scope):定义在所有函数之外,从定义处到文件末尾可见。能被多个函数使用的文件作用域变量也叫全局变量(global variable)
  • 还有个函数作用域,只用于 goto 的标签。

链接

int giants = 5;          // 文件作用域,外部链接
static int dodgers = 3;  // 文件作用域,内部链接
  • 外部链接(external linkage):多文件程序的任何地方都能用;
  • 内部链接(internal linkage):只在本文件可用,加 static 就是它;
  • 块作用域和原型作用域的变量无链接,是所在块的私有财产。

存储期限

  • 静态存储期限:整个程序运行期间都存在。文件作用域变量都属此类(注意:文件作用域加 static 改变的是链接,不是期限)。
  • 自动存储期限:块作用域变量的默认选项——进入块时分配内存,离开块就释放,内存像可以反复涂写的工作台。

五种存储类一览表

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

12.2 自动变量与寄存器变量

块内(或函数头)声明的变量默认就是自动存储类,也可显式写 auto int plox;——主要用于表达”我就是要覆盖外层定义”的意图。auto 是**存储类说明符(storage class specifier)**之一。

嵌套块的可见性:内层变量只在所在块可见;内层同名变量会”隐藏”外层定义,块结束外层变量重新可见。

程序清单 12.1 hiding.c

/* hiding.c -- variables in blocks */
#include <stdio.h>
int main()
{
    int x = 30;      /* 最初的 x    */
    printf("x in outer block: %d\n", x);
    {
        int x = 77;  /* 新的 x,隐藏了第一个 x */
        printf("x in inner block: %d\n", x);
    }
    printf("x in outer block: %d\n", x);
    while (x++ < 33) /* 最初的 x    */
    {
        int x = 100; /* 新的 x,隐藏了第一个 x */
        x++;
        printf("x in while loop: %d\n", x);
    }
    printf("x in outer block: %d\n", x);
    return 0;
}

输出:外层 x 是 30;内层块里 77;循环条件用外层 x(负责终止,30 加到 34),循环体里打印的是每次新建的内层 x(永远从 100 加到 101,输出三行 101),最后外层 x 是 34。这个例子不是鼓励你这么写,而是演示”块内定义变量”会发生什么。C99 还规定循环体本身就是子块:清单 12.2 forc99.c 里 for (int n = 1; ...) 的循环索引 n 隐藏外层 n(10),循环体里再定义的 n(30)又隐藏循环索引(需 gcc -std=c99 激活)。

自动变量的初始化

未初始化的自动变量是垃圾,它的地址更是雷区

自动变量不初始化时,值是这块内存上一次残留的任意内容,每次运行都可能不同。更进一步:千万不要返回自动变量的地址——函数一结束,局部变量随块一起消亡,返回的指针指向已经”人去楼空”的内存,用它就是未定义行为。要跨函数共享数据,用参数、返回值或静态/动态分配的内存。

自动变量可以用非常量表达式初始化,只要用到的变量已定义在前:int rance = 5 * ruth;

寄存器变量

register int quick; 是请求编译器把变量放进寄存器(register)或最快的内存以加速访问。它更像”请求”而非”命令”——寄存器不够编译器可以拒绝,变量退回普通自动变量。因为变量可能不在内存里,寄存器变量不能取地址(不能对它用 &)。形参也可以请求 register:void macho(register int n)

12.3 块作用域的静态变量

static 用在块内声明,变量获得静态存储期限:块结束后不消失,下次调用还能记着上次的值——但作用域和”无链接”不变,仍是这个块的私有财产。

程序清单 12.3 loc_stat.c

/* loc_stat.c -- using a local static variable */
#include <stdio.h>
void trystat(void);
 
int main(void)
{
    int count;
 
    for (count = 1; count <= 3; count++)
    {
        printf("Here comes iteration %d:\n", count);
        trystat();
    }
 
    return 0;
}
 
void trystat(void)
{
    int fade = 1;
    static int stay = 1;
 
    printf("fade = %d and stay = %d\n", fade++, stay++);
}

输出:fade 永远是 1(每次调用重新初始化),stay 依次是 1、2、3(记得上一次的自增)。两个声明长得一样,本质天差地别:

  • int fade = 1;运行时动作,每次调用都执行;
  • static int stay = 1; 不是运行时语句——静态变量在程序加载时就位,初始化只发生一次(用调试器单步会”跳过”这行)。不显式初始化则自动置 0。

注意 static 不能修饰形参:int wontwork(static int flu); 非法。

静态变量 = 会记事的局部变量

想让函数”记住”自己被调过几次、上轮算到哪了,不必动用全局变量——块内 static 就够了:作用域仍锁在函数内部,只把”寿命”延长到整个程序。封装与持久性两全。

12.4 外部变量与链接

外部变量(静态、外部链接)

定义写在所有函数之外就是外部变量(external variable):文件作用域 + 外部链接 + 静态期限。函数内可用 extern 再声明一次(可省略,写了是文档说明)。

程序清单 12.4 global.c

/* global.c -- uses an external variable */
#include <stdio.h>
int units = 0;    /* 一个外部变量 */
void critic(void);
int main(void)
{
    extern int units;    /* 可选的重复声明 */
 
    printf("How many pounds to a firkin of butter?\n");
    scanf("%d", &units);
    while ( units != 56)
        critic();
    printf("You must have looked it up!\n");
 
    return 0;
}
 
void critic(void)
{
    /* 省略了可选的重复声明 */
    printf("No luck, chummy. Try again.\n");
    scanf("%d", &units);
}

critic() 读入的新值,main() 的 while 循环立刻知道——两个函数操作的是同一个 units。陷阱:若在 main() 里写 int units;(不带 extern),会创建一个新的自动变量把外部 units 隐藏掉。

定义声明 vs 引用声明

int tern = 1;        // 定义声明:分配存储空间
extern int tern;     // 引用声明:指向别处已定义的 tern
  • extern 的意思是”去别处找定义”,本身不分配空间
  • 初始化只能伴随定义声明:extern char permis = 'Y'; 是错误;
  • 外部变量只能初始化一次,且只能用常量表达式int x2 = 2 * x; 不行;size_t z = sizeof(int); 可以)。

/图:定义并初始化变量——声明创建存储空间并赋初值

静态变量与内部链接

文件作用域的定义前加 static,变量就限定为本文件私有

int traveler = 1;         // 外部链接,其他文件可用
static int stayhome = 1;  // 内部链接,仅本文件可用

函数内的 extern int stayhome; 只是文档说明,不改变其内部链接。

多文件程序与链接

多文件共享一个外部变量的 ANSI 标准做法:一个文件放定义声明(可初始化),其他文件放引用声明(带 extern)。背后的功臣是链接器(linker)——它把你的目标代码、启动代码和库代码拼成可执行文件:

/图:编译器与链接器——多个源文件经编译产生目标代码,链接器把它们与库代码拼装成可执行文件

存储类说明符与函数

五个存储类说明符:autoregisterstaticexterntypedef(typedef 纯为语法凑数;一个声明最多用其中一个)。函数也有存储类:默认是外部函数(其他文件可调用);加 static 则是本文件私有,可避免与其他文件的同名函数冲突:

double gamma();           // 默认外部
static double beta();     // 本文件私有
extern double delta();    // 显式外部

慎用外部变量

“把所有变量都设成外部的,函数间就不用传参了”——很有诱惑力,但后患无穷:任何函数都可能偷偷改坏其他函数正在用的变量。几十年的集体经验证明这个风险远大于便利。保护性编程的金科玉律是”need to know”原则:每个函数的内部变量尽量私有,只共享必须共享的。用其他存储类前先问自己:真的需要吗?

12.5 随机数函数与静态变量

看一个把静态变量用出花样的实战:伪随机数(pseudorandom number)生成器。方案是”种子(seed)→ 生成新数 → 新数当新种子”——函数必须记住上次的种子,这正是静态变量的主场。

程序清单 12.7 rand0.c

/* rand0.c -- produces random numbers */
/* uses ANSI C portable algorithm */
static unsigned long int next = 1; /* the seed */
 
int rand0(void)
{
    /* magic formula to generate pseudorandom number */
    next = next * 1103515245 + 12345;
    return (unsigned int) (next/65536) % 32768;
}

next 是文件作用域的静态变量(内部链接),每次调用被”魔法公式”改写,返回 0~32767 的数。驱动程序连跑两次输出一模一样——种子固定为 1,序列自然重演,这就是”伪”随机。对策:加一个重置种子的函数,让同文件的两个函数共享 next:

程序清单 12.9 s_and_r.c

/* s_and_r.c -- file for rand1() and srand1() */
static unsigned long int next = 1; /* the seed */
 
int rand1(void)
{
    /* magic formula to generate pseudorandom number */
    next = next * 1103515245 + 12345;
    return (unsigned int) (next/65536) % 32768;
}
 
void srand1(unsigned int seed)
{
    next = seed;
}

不同种子产出不同序列。实际使用时用系统时间自动播种最方便:

#include <time.h>    /* time() 的原型 */
srand1((unsigned int) time(0));  /* 用时钟初始化种子 */

time(0) 返回系统时间(time_t 类型),值随时间变化,强转成 unsigned int 当种子。标准库同款是 rand()srand()(stdlib.h)。

掷骰子:roll_n_dice()

把 rand() 包装成任意面数的骰子:rand() % sides + 1 得到 1~sides 的随机数。清单 12.11 diceroll.c 把这个逻辑封装成完整模块:

/* diceroll.c(节选) */
#include "diceroll.h"
#include <stdlib.h>
int roll_count = 0;               /* 外部链接 */
static int rollem(int sides)      /* 本文件私有 */
{
    int roll;
    roll = rand() % sides + 1;
    ++roll_count;                 /* 统计调用次数 */
    return roll;
}
 
int roll_n_dice(int dice, int sides)
{
    int d, total = 0;
    if (sides < 2) { printf("Need at least 2 sides.\n"); return -2; }
    if (dice < 1)  { printf("Need at least 1 die.\n");   return -1; }
    for (d = 0; d < dice; d++)
        total += rollem(sides);
    return total;
}

配套头文件 diceroll.h(双引号包含让编译器在本地目录找):extern int roll_count;int roll_n_dice(int dice, int sides);。这里用上了全套招式:rollem() 声明为 static 成为私有助手;roll_count 是外部变量,头文件的 extern 让使用方也能看到——定义声明只有一份,引用声明随便多。主程序用 srand((unsigned int) time(0)) 播种后每次运行结果都不同。

12.6 分配内存:malloc() 与 free()

五种存储类的规矩都是打包好的。想更灵活?用库函数手动分配和释放内存。

malloc() 的机制:传入需要的字节数,它找一块合适的空闲内存——这块内存是匿名的,malloc 不给它名字,只返回首字节的地址(ANSI 下是 pointer-to-void 通用指针)。把地址存进指针变量,就能像数组名一样用:

double * ptd;
ptd = (double *) malloc(30 * sizeof(double));

从此 C 创建数组有三条路:常量尺寸的普通数组;C99 变长数组(VLA);动态数组(dynamic array)——malloc 在运行时按需分配,尺寸运行时说了算。malloc 失败返回空指针,必须检查。看完整例子:

程序清单 12.14 dyn_arr.c

/* dyn_arr.c -- dynamically allocated array */
#include <stdio.h>
#include <stdlib.h> /* for malloc(), free() */
 
int main(void)
{
    double * ptd;
    int max;
    int number;
    int i = 0;
 
    puts("What is the maximum number of type double entries?");
    scanf("%d", &max);
    ptd = (double *) malloc(max * sizeof (double));
    if (ptd == NULL)
    {
        puts("Memory allocation failed. Goodbye.");
        exit(EXIT_FAILURE);
    }
    /* ptd 现在指向一个 max 个元素的数组 */
    puts("Enter the values (q to quit):");
    while (i < max && scanf("%lf", &ptd[i]) == 1)
        ++i;
    printf("Here are your %d entries:\n", number = i);
    for (i = 0; i < number; i++)
    {
        printf("%7.2f ", ptd[i]);
        if (i % 7 == 6)
            putchar('\n');
    }
    if (i % 7 != 0)
        putchar('\n');
    puts("Done.");
    free(ptd);
 
    return 0;
}

逐段看:malloc(max * sizeof(double)) 按用户输入分配;ptd == NULL 时用 exit(EXIT_FAILURE) 终止(stdlib.h 定义了 EXIT_SUCCESS/EXIT_FAILURE 两个标准返回值);之后 ptd[i] 用法和数组名一模一样;结尾 free(ptd) 把内存还给”内存池”。把 malloc/free 想象成内存池管理:malloc 取出,free 归还,归还的可以复用。free 的参数必须是 malloc/calloc 返回的地址,不能拿它释放声明出来的数组。

malloc 与 free 必须成对出现

malloc 的内存不会自动回收,生命周期从 malloc 开始到 free 为止——程序员说了算。好习惯:每次 malloc,脑子里立刻登记一个对应的 free,就像开闸放水后记得关闸。

内存泄漏:malloc 的头号事故

只借不还,内存就会枯竭:

void gobble(double ar[], int n)
{
    double * temp = (double *) malloc( n * sizeof(double));
    /* ... */
    /* free(temp); // 忘了 free! */
}

gobble() 被循环调用 1000 次:第一次 malloc 拿走 16000 字节,函数结束时指针 temp(自动变量)消失了,但那 16000 字节既访问不到也没归还;第二次又拿走 16000 字节……循环跑完,16,000,000 字节被白白锁死——这就是内存泄漏(memory leak),程序很可能中途耗光内存。在函数末尾补上 free(temp); 就能防住。

calloc() 与变长数组

calloc() 是 malloc 的孪生兄弟:接受两个参数(单元个数、每单元字节数),并把内存的所有位清零

long * newmem;
newmem = (long *) calloc(100, sizeof(long));

同样用 free() 释放。VLA 与 malloc 的取舍:VLA 是自动存储,离开定义块自动释放、无需 free,但数组不能离开所在函数;malloc 的数组可以在函数间传递,用完在任意位置 free(free 的指针变量和 malloc 的不必是同一个,地址一致即可)。多维数组用 VLA 语法更舒服,malloc 则要写 int (* p2)[6]; 这类”指向数组的指针”声明。

三种内存模型

把程序的内存想象成三个区:静态区——编译时定好大小,程序启动即就位,运行全程存在(静态变量、字符串常量住这里);栈(stack)——自动变量的家,进块分配出块回收,全自动;动态分配区(堆)——malloc/free 手动控制,可以在 A 函数创建、B 函数释放——自由,但容易碎片化,速度通常比栈慢。

12.7 ANSI C 类型限定词

C90 加入了 constvolatile 两个类型限定词(type qualifier),C99 再加 restrict。C99 还规定限定词幂等——重复声明同一个限定词不算错。

const 限定词

简单变量:const int nochange = 12; 初始化后不可赋值、不可自增自减。也用于数组:const int days1[12] = {31, 28, ...};。指针的 const 就讲究了——看 const 在 * 的哪边

const float * pf;         /* pf 指向的值是常量,pf 本身可变 */
float * const pt;         /* pt 是常量指针,指向的值可变 */
const float * const ptr;  /* 两者都不可变 */
float const * pfc;        /* 等价于 const float * pfc */

口诀:const 在 * 左边修饰数据,在 * 右边修饰指针。最常见的用途是函数形参——告诉调用者”我只读不改”:void display(const int array[], int limit);。ANSI C 库就是这么标注的:char *strcat(char *, const char *); 第一个串被改(目标),第二个串只读(源)。

const 数据做全局变量是安全的(反正改不了)。跨文件共享 const 数据两个策略:一是老规矩——一个文件定义、其他文件 extern 引用;二是放进头文件并加 static,每个文件各得一份拷贝——数据是常量且一致,复制无妨:

/* constant.h */
static const double PI = 3.14159;

volatile 限定词

volatile 告诉编译器:这个变量的值可能被程序之外的力量改变(硬件时钟、并发程序共享的数据),别做激进优化。为什么需要它?

val1 = x;
/* 一段不使用 x 的代码 */
val2 = x;

聪明的编译器会把 x 缓存(caching)进寄存器,第二次直接读寄存器省一次访存——但如果 x 在这期间被硬件改了,val2 就拿到旧值。声明 volatile int loc1; 后,编译器对它的每次使用都老实访问内存。一个值可以既是 const 又是 volatile(如硬件时钟:程序不该改它 = const,硬件会改它 = volatile)。

restrict 限定词

C99 新增,只用于指针,含义是”这个指针是访问该数据块的唯一初始入口”——给编译器发优化许可证:

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

restar 是 malloc 块的唯一入口,可以加 restrict;par 与 ar 共享数据,不行。于是 restar[n] += 5; restar[n] += 3; 可被合并成 restar[n] += 8;(编译器保证没人中途动数据);对 par 同样合并就会算错,因为 ar 中途改了数据。restrict 有两个受众:编译器(放心优化)和程序员(必须守约,编译器查不出违约)。C99 库里 memcpy() 用 restrict 标注(要求源和目标不重叠),memmove() 不用(允许重叠、小心拷贝)。C99 还允许把限定词和 static 写进形参的方括号:double stick(double ar[static 20]); 表示实参指向至少 20 个元素的数组——又是优化提示兼用户约定。

本章要点回顾

  • 三要素:作用域(块/原型/文件)+ 链接(无/内部/外部)+ 存储期限(自动/静态)
  • 一句话记五种存储类:默认自动;static 在块内 = 记事的局部变量,在文件级 = 本文件私有;extern = 引用别处的定义
  • 外部变量慎用;函数间通信优先用参数和返回值
  • 随机数生成器是静态变量的教科书案例:种子必须跨调用存活
  • malloc()/free() 手动管理堆内存,必须成对出现防内存泄漏;calloc() 额外清零
  • const 锁数据或锁指针(看它在 * 哪边);volatile 防优化;restrict 发优化许可证