Skip to content

简明 C 标准库

摘要

考虑到课程教学进度,本节课不再讲解 C 标准库的实现。

本次辅学的目的是:

  • 按照考试要求,把标准库中大家应该知道的内容介绍一遍。
  • 结合一些应用的例子,串讲一下之前的语法重点。

从历年卷来看,对标准库的考察内容仅限于下面的这些库,本节课也仅叙述下面这些库:

  • ctype.h
  • stdio.h
  • string.h
  • stdlib.h
  • math.h

导引:C 标准库,学些什么?

为什么要学标准库?

有一个粗浅的理由,就是图方便。假设有下面这个声明在 main 函数中数组:

int a[100];

我们知道,这个数组不会被初始化。如果想要把这个数组全部置为 0,你会怎么写呢?

首先会有的想法肯定是:

for (int i = 0; i < 100; ++i)
    a[i] = 0;

其实标准库提供了一个函数 memset,用来设置一段内存的值:

memset(a, 0, sizeof(a));

是不是很方便呢?

再来一个例子,把输入中所有的小写字母转换成大写字母。假设输入已经存储在 char 数组 str 中,根据现有的知识,你会不会这样写:

for(int i = 0; i < strlen(str); ++i)
    if ('a' <= str[i] && str[i] <= 'z')
        str[i] -= 'a' - 'A';

其实标准库提供了判断字符大小写的函数,也提供了转换大小写的函数:

for(int i = 0; i < strlen(str); ++i)
    if (islower(str[i]))
        str[i] = toupper(str[i]);

下面的这段代码,是不是比上面的代码简洁,并且一眼看过去就能明白意思呢?

总而言之,C 标准库功能强大,它的重要性和语言本身一样。学会使用标准库,让你少造轮子,提高代码的可读性、简洁性和正确性。

标准库里有什么?

💡在你的印象中,标准库是什么样的?里面有什么东西?

每一个标准库会定义这些内容:

  • 类型定义
  • 函数

几个耳熟能详的例子,你能说说它们是什么吗?

  • NULLEOF
  • printfscanf
  • FILE

接下来的每一节是一个标准库,会分为背景、内容、使用三个部分。其中「背景」部分会介绍一些前置知识「内容」部分会介绍标准库中的内容「使用」部分会介绍一些使用的例子「内容」模块中代码段内被注释掉的内容,表示不作要求。

背景知识

为了能跟上接下来的内容,确认一下大家都有的知识:

  • 会阅读函数原型 / 函数声明 / 函数签名(它们说的是一个东西

    int main(void);
    
  • 指针的概念

    • 内存、内存地址、对象
    • 指针也是一个对象,它的值是一个内存地址
    • 指针具有类型信息,决定了指针的运算方式

      例子

      int p[10];
      char q[10];
      int *p1 = p, *p2 = &p[1];
      char *q1 = q, *q2 = &q[1];
      

      请问,p1 + 1p2 - p1q1 + 1q2 - q1 的值是多少?类型是什么?

      如果直接对他们的值进行加减,结果应当是多少?

    • 数组与指针

      例子

      int a[10];
      int *p;
      

      请问:aa[0]&a[0]&a*ap*p&p 的类型是什么?

      请问,执行 p = &a 报告

      warning: initialization of `int *` from incompatible pointer type `int (*)[10]`
      

      是什么原因?

    • 多维数组比较复杂,等正文讲完还有时间再回来讲

<ctype.h>

头文件 <ctype.h> 声明了几个可以用于识别和转换字符的函数。

背景:ASCII 字符集

ASCII 字符集中的数字和字母大家应该都很熟了,这边再对两类大家接触比较少的字符分类简单做个介绍。

  • 打印字符:0x20 ~ 0x7E
  • 控制字符:0x00 ~ 0x1F0x7F

小提示

你真的需要去记这些字符的编码吗?

选择填空之类的题目可能会考到,但也是有技巧的。你最多需要记忆的是大小写字母之间相差 32,但只要题目不是让你必须填数字,你就可以用 'a' - 'A' 来代替。

内容

这个头文件中只有函数,没有特别的类型和宏。

这些函数的意义和用法非常显然,因此我也不做注释。

  • 字符判断函数

    int isalnum(int c);
    int isalpha(int c);
    // int iscntrl(int c);
    int isdigit(int c);
    // int isgraph(int c);
    int islower(int c);
    // int isprint(int c);
    // int ispunct(int c);
    int isspace(int c);
    int isupper(int c);
    // int isxdigit(int c);
    
  • 字符转换函数

    int tolower(int c);
    int toupper(int c);
    

使用

<ctype.h> 中的函数对 ASCII 字符大致作了如下划分:

控制字符那些不用管,不要求的函数不用管。只要记得 isspaceisblank 的区别就行了。

isspaceisblank 的区别

  • 空格字符:isblank 仅判断空格 和水平制表符 \t
  • 空白字符:isspace 判断空格 、水平制表符 \t、换行符 \n、回车符 \r、换页符 \f、垂直制表符 \v

<ctype.h> 的实现:宏与位运算

阅读本节需要具备的知识

  • 位运算

知道可以用位来标志某些状态。与或非等位运算的概念。

知道宏函数是怎么展开的。

不知道?速通一下!

首先你应该对二进制有一定概念。假如我们有 4 个二进制位 0000,我们就可以用它来标记 4 种状态。我们不妨设:

  • 第一位表示是否是数字
  • 第二位表示是否是小写字母
  • 第三位表示是否是大写字母
  • 第四位表示是否是字母

(注意,最右侧的位是最低位,最左侧的位是最高位

那么对于字符 c,我们可以用二进制串 1010 来表示它的属性。

使用位运算和掩码可以判断属性。比如,我们要判断一个字符是否是数字,把它的串与掩码 0001 进行与运算。因为 0001 中前三位都是 0,结果肯定也是 0。而最后一位是 1,结果将由参与运算的串决定。掩码中的前三位好像把那些无关的位“掩盖”掉了。最后结果如果是零,那么就说明不具有该属性;如果非零,就说明具有该属性。

接下来是宏。宏是纯粹的字符替换。如果宏使用了圆括号,那么它就是一个函数宏:

#define MEAN(X, Y) (((X) + (Y)) / 2)

那么只要编译器看到代码中有 MEAN 后跟一个圆括号,就会开始匹配和替换。比如,MEAN(1, 2) 将被替换为 (((1) + (2)) / 2)MEAN(a, b) 将被替换为 (((a) + (b)) / 2)

🤚停一停,先别看下面的内容。思考一下,你会怎么实现上面的那些函数?

你会不会在想这样的代码:

int isalnum(int c)
    { /* test for alphanumeric character */
    return (('0' <= c && c <= '9') ||
            ('a' <= c && c <= 'z') ||
            ('A' <= c && c <= 'Z'));
    }

<ctype.h> 中的函数通常使用宏来实现。使用宏实现时需要注意以下因素:

  • 虽然宏可能比函数快,但是它们通常会产生更大的代码。如果在很多地方扩展,这个程序可能大到让你无法想象。
  • 宏的参数可能会被求值多次,具有副作用的宏参数会导致意外。

    举个例子

    #define SQUARE(X) ((X) * (X))
    SQUARE(x++); // x++ * x++
    

    使用者以为它只会让 x 自增一次,但是实际上它会让 x 自增两次。

    会产生不安全行为的宏

    标准库中,只有 getcputc 可能会产生这种不安全行为。

<ctype.h> 中定义了一个查找表 _Ctype,两个映射表 _Tolower_Toupper。每个字符都被编入查找表中,使用位运算就能判断出字符的类型。

ctype.h
#define _DI 0x20 /* '0'-'9' */
#define _LO 0x10 /* 'a'-'z' */
#define _UP 0x02 /* 'A'-'Z' */
#define _XA 0x200 /* 'a'-'z', 'A'-'Z' */
extern const short *_Ctype, *_Tolower, *_Toupper;
#define isalnum(c) (_Ctype[(int)(c)] & (_DI|_LO|_UP|_XA))
#define tolower(c) _ToLower[(int)(c)]

读一下上面的代码,想象一下 _Ctype 这个查找表的样子。

考考你,对于 ASCII 字符集,这个查找表有多大?

你能想一想映射表的实现吗?

同样,给出函数版本的实现:

isalnum.c
#include <ctype.h>

int isalnum(int c)
    { /* test for alphanumeric character */
    return (_Ctype[c] & (_DI|_LO|_UP|_XA));
    }

下面两幅图分别展示了 _Ctype_Toupper 的样子:

`_Ctype`

_Ctype

`_Toupper`

_Toupper

非常地简单,对吧?这可比 'a' <= c && c <= 'z' 这样的判断要快得多。

<math.h>

背景知识:函数的定义域与值域

还记得 double 表示的范围吗?

从上图你可以看到,浮点数所能表示的数值,实际上是数轴上的一个个点。毕竟只有 64 bit 嘛,只能编码有限个点。

double 类型(8 字节 IEEE 浮点数)所能表示的极限值为:

  • 最大正值:\(1.7976931348623157 \times 10^{308}\)
  • 最小正值:\(2.2250738585072014 \times 10^{-307}\)

我们都知道数学函数有定义域和值域,<math.h> 中的函数也有,只是大家平常使用的时候可能不太关注罢了。

  • 如果函数的输入参数位于定义域外(比如 asin 输入了不在 \([-1, 1]\) 的值,会发生定义域错误。
  • 如果结果不能被表示为 double 值,发生值域错误。上溢返回 HUGE_VAL,下溢返回 0

因为我们没有学 C 的错误处理,所以我们不知道怎么捕获这些错误。有兴趣的同学可以学习 <error.h> 中的内容。

内容

  • HUGE_VAL
    

    GCC 定义的宏

    INFINITY
    NAN
    

    上面这两个宏起初不在标准库中,由 GCC 定义。

    据说在 C99 以后,INFINITY 被标准库纳入,我没有查证。

  • 函数(仅举一些常用的

    double acos(double x);
    double asin(double x);
    double atan(double x);
    double cos(double x);
    double sin(double x);
    double tan(double x);
    double exp(double x);
    double log(double x);
    double log10(double x);
    double pow(double x, double y);
    double sqrt(double x);
    double ceil(double x);
    double fabs(double x);
    double floor(double x);
    

使用

  • 输入输出全都是 double 类型(注意隐式类型转换带来的影响
  • 三角函数均为弧度制。角度到弧度的转换公式为:\(\theta = \frac{\pi}{180} \times \alpha\)
  • 没有 PI 这个宏。
    • 可以使用 atan(1)*4 代替。
    • GCC 定义了一些数值宏,它们都以 M_ 开头,比如 M_PI。它们默认为 double 类型。如果你需要其他精度,可以添加 l 后缀,比如 M_PIl

<string.h>

背景知识:字符串

字符串字符数组一定要区别开来。字符串是以空字符 \0 结尾的字符数组。

char name[13] = "StudyTonight";
char name[10] = {'c','o','d','e','\0'};

<string.h> 的函数只负责操作字符串,不负责操作字符数组!

还记得数组传入函数的时候会退化成指针吗?函数无法获知数组的长度,因此空字符是帮助函数判断字符串结束、避免越界的唯一方法

当然,strn 系列函数提供了指定长度的参数。

你能想起哪些东西是字符串吗?

  • 用双引号引起的字符序列是字符串。编译器会自动添加空字符。
  • scanf 使用 %s 读取的字符序列是字符串。scanf 会自动添加空字符。
  • ......

内容

  • 类型

    size_t
    
  • NULL
    
  • 函数

    • 复制函数

      void *memcpy(void *dest, const void *src, size_t n);
      void *memmove(void *dest, const void *src, size_t n);
      char *strcpy(char *dest, const char *src);
      char *strncpy(char *dest, const char *src, size_t n);
      
    • 连接函数

      char *strcat(char *dest, const char *src);
      char *strncat(char *dest, const char *src, size_t n);
      
    • 比较函数

      int memcmp(const void *s1, const void *s2, size_t n);
      int strcmp(const char *s1, const char *s2);
      int strncmp(const char *s1, const char *s2, size_t n);
      
    • 查找函数

      void *memchr(const void *s, int c, size_t n);
      char *strchr(const char *s, int c);
      // size_t strcspn(const char *s1, const char *s2);
      // char *strpbrk(const char *s1, const char *s2);
      // char *strrchr(const char *s, int c);
      // size_t strspn(const char *s1, const char *s2);
      char *strstr(const char *s1, const char *s2);
      // char *strtok(char *s1, const char *s2);
      
    • 其他函数

      void *memset(void *s, int c, size_t n);
      // char *strerror(int errnum);
      size_t strlen(const char *s);
      

使用

使用前,自己计算字符串长度和剩余空间,这是编程者的责任。或者使用带 n 的函数。

  • 有些函数可能返回空指针,记得测试返回的指针。

<stdlib.h>

头文件 <stdlib.h> 是一个大杂烩,为了定义和声明那些没有明显归属的宏和函数。我们仅介绍常用的部分:整形数学、算法、文本转换、环境接口和存储分配。

背景:指针的概念

请先阅读指针笔记。

这里再强调一下声明和 malloc 的区别:

你能解释一下声明的时候内存发生了什么变动吗?

下面的代码段,每一行执行时,发生了什么?有内存被分配吗?

int p;
int *q;
q = &p;
q = (int *)malloc(sizeof(int));

有一位同学写出了下面这样错误的代码,你能指出错误吗?

// 一个错误的链表头插入函数
struct Node* create_linked_list() {
    struct Node* head = NULL;
    struct Node* current = (struct Node*)malloc(sizeof(struct Node));
    while (1) {
        int data;
        printf("请输入节点值(输入-1退出):");
        scanf("%d", &data);
        if (data == -1) {
            break;
        }
        current->data = data;
        current->next = NULL;
        if (head == NULL) {
            head = current;
        } else {
            head->next = current;
        }
    }
    return head;
}

内容

  • RAND_MAX
    EXIT_FAILURE
    EXIT_SUCCESS
    
  • 函数

    • 伪随机序列产生函数

      int rand(void);
      void srand(unsigned int seed);
      
    • 整数算术函数

      int abs(int n);
      div_t div(int numer, int denom);
      long labs(long n);
      ldiv_t ldiv(long numer, long denom);
      
    • 查找和排序函数

      void *bsearch(const void *key, const void *base, size_t n, size_t size, int (*compar)(const void *, const void *));
      void qsort(void *base, size_t n, size_t size, int (*compar)(const void *, const void *));
      
    • 文本转换(好用的)

      double atof(const char *str);
      int atoi(const char *str);
      long atol(const char *str);
      double strtod(const char *str, char **endptr);
      long strtol(const char *str, char **endptr, int base);
      unsigned long strtoul(const char *str, char **endptr, int base);
      
    • 环境通信(不介绍)

      // void abort(void);
      // int atexit(void (*func)(void));
      // void exit(int status);
      // char *getenv(const char *name);
      // int system(const char *string);
      
    • 内存管理(重难点)

      void *calloc(size_t nobj, size_t size);
      void free(void *ptr);
      void *malloc(size_t size);
      void *realloc(void *ptr, size_t size);
      

      注意,内存拷贝函数却在 <string.h> 中。

  • 类型

    div_t // int quot, rem;
    ldiv_t // long quot, rem;
    

使用

  • randsrand 用于产生伪随机数。srand 用于设置随机数种子,rand 用于产生随机数。rand 产生的随机数范围是 \([0, RAND\_MAX]\)
  • abslabs 用于求绝对值。divldiv 用于求商和余数。
  • qsort 的用例:

    int cmpfunc (const void * a, const void * b) {
        return ( *(int*)a - *(int*)b );
    }
    qsort(values, 5, sizeof(int), cmpfunc);
    
  • 文本转换函数非常好用。你再也不用写这样的代码了(当然 atoi 函数的具体实现要比这复杂得多

    int atoi(char *str) {
        int res = 0;
        for (int i = 0; str[i] != '\0'; ++i)
            res = res * 10 + str[i] - '0';
        return res;
    }
    
  • malloc 记得参数是字节数,千万记得乘上 sizeof。为了防止自己忘记,也可以坚持使用 calloc。且它会自动初始化内存为 0

  • realloc 相当于结合了 mallocmemcpyfree 的功能。

<stdio.h>

关于文件输入输出 ......

这边列出了文件输入输出的内容,给大家复习的时候参考用。咱们这节课不讲。

背景知识:流

背景故事:早期计算机混乱的输入输出模型

早期程序的输入输出无法独立于设备。比如,在上个世纪 60 年代的 FORTRAN IV 中,在磁带机上需要用 READ INPUT TAPE 5,读取磁盘上需要用 READ INPUT DISK 1。这样的代码在不同的设备上需要修改,非常不方便。UNIX 系统将这些混乱的设备交互封装在设备处理程序中。在 UNIX 系统看来,外围设备有三种类型:字符设备(Character devices、块设备(Block devices)和网络设备(Network devices。这三种设备都被抽象为文件,使用统一的文件操作接口。

Ken Thompson UNIX 设计统一的内部文本形式前,文本表示也是十分混乱的。结束一行是使用回车还是回车加换行,还是换行符,还是更神奇的字符?终端能不能识别和展开制表符?怎样用键盘标志文件结束?这些问题的答案和终端的生产厂商一样多UNIX 使用系统调用 ioctl 来设置一个设备的各种参数,负责对内部换行约定和各种终端之间的需要转换的字符进行处理

流的概念

各种输入输出设备实在是太多了(终端、磁带驱动器、结构化存储设备 ......。为了统一概念,C 语言中的输入和输出设备全都和逻辑数据相对应。

流就是字符序列。

不管系统、硬件是怎么实现的。输入输出到了 C 程序这里,就统一为逻辑上的了。

关联到一个特定的文件。

操作系统中的文件

计算机系统中的文件可以分为两类:

  • 文本文件:如果一个文件中的二进制值都是用来表示字符的,那么这个文件就是文本文件。
  • 二进制文件:如果一个文件中的二进制值代表其他数据,比如机器语言代码或者数值数据、图片或音乐,这个文件就是二进制文件。

二进制文件存储的内容比较复杂,操作系统不敢随意变动。但文本文件内容简单,各类操作系统早就有了自己的处理方式。

即使到今天,不同操作系统处理文本文件的方式仍然具有差异。

差异 UNIX Windows MacOS
换行符 \n
LF
\r\n
CRLF
\n ( 较早的 MacOS 使用\r )
LF
文件结束符 ^D
Ctrl+D
^Z
Ctrl+Z
^D
Ctrl+D

都是历史的锅!

甚至有的操作系统这样处理文本文件:

  • 要求文本文件中每一行的长度相同,否则用空白字符填充。
  • 在每行开始标出行的长度。

流的类型

C 语言提供两种流:文本流和二进制流。

  • 文本流:程序所见的内容可能和实际内容不同。如果将文件以文本模式打开,那么在读取文件时,会把本地环境表示的换行符或文件结尾映射为 C 语言中的 \nEOF
  • 二进制流:程序所见的内容和实际内容一致。如果将文件以二进制模式打开,那么在读取文件时,会把文件中的每一个字节都映射为 C 语言中的 char

举个例子

这是一个 MS-DOS 上的文本文件。如果作为二进制流打开,程序会看见以下字节:

Rebecca clutched the\r\n
jewel-encrusted scarab\r\n
to her heaving bosom.\r\n
^Z

如果作为文本流打开,程序会看见以下字节:

Rebecca clutched the\n
jewel-encrusted scarab\n
to her heaving bosom.\n

内容

  • 类型

    size_t
    FILE
    fpos_t
    
  • stderr
    stdin
    stdout
    NULL
    EOF
    SEEK_CUR
    SEEK_END
    SEEK_SET
    // BUFSIZ
    // FOPEN_MAX
    // FILENAME_MAX
    
  • 函数

    • 文件操作函数(不做要求)
    // int remove(const char *filename);
    // int rename(const char *old, const char *new);
    // FILE *tmpfile(void);
    
    • 文件访问函数
    int fclose(FILE *stream);
    // int fflush(FILE *stream);
    FILE *fopen(const char *filename, const char *mode);
    FILE *freopen(const char *filename, const char *mode, FILE *stream);
    // void setbuf(FILE *stream, char *buf);
    // int setvbuf(FILE *stream, char *buf, int mode, size_t size);
    
    • 格式化的输入输出函数
    int fprintf(FILE *stream, const char *format, ...);
    int fscanf(FILE *stream, const char *format, ...);
    int printf(const char *format, ...);
    int scanf(const char *format, ...);
    int sprintf(char *str, const char *format, ...);
    int sscanf(const char *str, const char *format, ...);
    // int vfprintf(FILE *stream, const char *format, va_list arg);
    
    • 字符输入输出函数
    // int fgetc(FILE *stream);
    // char *fgets(char *str, int n, FILE *stream);
    // int fputc(int c, FILE *stream);
    int fputs(const char *str, FILE *stream);
    // int getc(FILE *stream);
    int getchar(void);
    // char *gets(char *str);
    // int putc(int c, FILE *stream);
    int putchar(int c);
    int puts(const char *str);
    // int ungetc(int c, FILE *stream);
    
    • 直接输入输出函数(考试不管)
    size_t fread(void *ptr, size_t size, size_t nobj, FILE *stream);
    size_t fwrite(const void *ptr, size_t size, size_t nobj, FILE *stream);
    
    • 文件定位函数(考试不管)
    //int fgetpos(FILE *stream, fpos_t *pos);
    int fseek(FILE *stream, long offset, int origin);
    //int fsetpos(FILE *stream, const fpos_t *pos);
    long ftell(FILE *stream);
    void rewind(FILE *stream);
    
    • 错误处理函数(不做要求)
    // void clearerr(FILE *stream);
    // int feof(FILE *stream);
    // int ferror(FILE *stream);
    // void perror(const char *str);
    

使用

以下是课内内容,仅列出,不做详细介绍:

  • printf/ scanf这几个格式化输入 / 输出函数的使用。
  • %d%p 等转换说明的使用。
  • \n\r 等常见转义序列的使用。
  • 基本的打开、关闭文件 fopenfclose 的使用。
  • rwab 等模式的使用。
  • 在一个 FILE 中用 fscanffprintf 读写数据。
    • freadfwrite 课上应该会讲,但是编程也不会要求的。

下面对几个知识点作一点规范介绍:

转换说明

规范的说明请看这里:

printf 的转换说明

转换说明中,% 后面要跟 4 个组成部分。除了最后一个部分,其他都是可选的:

%[标志][最小字段宽度][.精度][h/l/L]指定转换类型的字符
  • 零或更多个标志,说明转换中的变化。

    char meaning
    - 左对齐(右侧填充空白)
    + 总是打印符号(包括正号)
    如果无符号,打印空格
    0 0 填充
  • 一个可选的最小字段宽度:一个星号或者一个十进制整数。

  • 一个可选的精度:小数点后跟一个星号或一个十进制整数。
    • 如果是整数,表示最小数字位数。
    • 如果是浮点数,表示小数点后的最大位数。

举个例子

scanf 的转换说明

普遍误区

针对同学们普遍的误区做几点强调说明:

  • scanf
    • %s 只读取非空白字符,遇到空白字符就结束。
    • %c 只读取一个字符,遇到空白字符也会读取。

    还记得上面 <ctype.h> 刚刚讲的空白字符吗?

    • 如果转换与字符匹配失败,这个输入字符会留在输入流中。在你下一次读取字符时,它可能会捣乱!
    • 这个函数的参数是什么类型?什么时候该加取值符 &

输入输出函数的返回值

大家平常使用可能不太关注。但还是有考察的可能:

  • printffprintfsprintfsnprintf
    • 正常:返回传输的字符数
    • 异常:返回负数
  • scanffscanfsscanf
    • 正常:返回成功匹配并赋值的输入项数
    • 异常:返回 EOF
文件定位(不做介绍)
  • 三种修改文件定位符的可能:
    • ungetc 将字符退回流中(不要在 PTA OJ 系统上使用!不要在生产环境中使用。总之,就是不建议碰这个东西。
    • fseekftellrewind:较老的传统的文件定位函数
    • fgetposfsetposrewind:任意大小、结构的文件,使用 fpos_t 类型,它不能进行任何计算。