C 语言学习笔记之进阶篇
xcbyao 小妖

0x00 前言概览

此文再对 C 的内存、数组、结构体、共用体、预处理器、头文件、函数和指针等方面做一个总结概括。

0x01 切入正题

  • printf() 转换说明修饰符

  • printf() 标记

  • scanf() 转换说明修饰符

  • 除了 %c,格式字符串中的空白(空格、制表符或换行符)意味着其他转换说明都会自动跳过待输入值前面所有空白

  • scanf("%c", &ch) 从输入中第 1 个字符开始读取,scanf(" %c",&ch) 从第 1 个非空白字符开始读取

  • strlen() 返回字符串中的字符数(包括空格和标点符号,不包括空字符 \0

  • printf()scanf() 都可以使用 * 修饰符来修改转换说明的含义,滞后赋值
    printf("This is :%*d\n", width, This); 变量 width 提供字段宽度

  • scanf("%*d %*d %d", &n); 跳过两个整数,把第 3 个整数拷贝给 n


  • 在程序中,最好用 #define 定义数值常量,用 const 关键字声明只读变量。

  • C99 规定使用趋零截断。即在某些运算中 -3.8 会转换成 -3;C 可以实现多重赋值。

  • 无论何种情况,只要 a 和 b 都是整数值,便可通过 a - (a/b)*b 计算 a%b

  • 循环测试更新循环放在一处,就不会忘记更新循环。尽管把两个操作合并在一个表达式中,降低了代码可读性,还容易产生计数错误,但是却可以精简代码。

  • 如果使用前缀形式后缀形式会对代码产生不同的影响,最好不要那样使用。如: b = ++i; 如果使用 i++,会得到不同的结果,应该使用下列语句: ++i; b = i; 此时使用 i++,也不会影响 b 的值。

  • 编译器可以自行选择先对函数中的哪个参数求值,提高了编译器的效率,但是如果在函数的参数中使用了递增/递减运算符,就会有一些问题,对此,如果一个变量出现在一个函数的多个参数中,或者一个变量多次出现在一个表达式中不要对该变量使用递增/递减运算符。

  • 根据 C 标准,声明不是语句,这与 C++ 不同。赋值和函数调用都是表达式。没有所谓的 “赋值语句” 和 “函数调用语句”,这些语句实际上都是表达式语句

  • 序列点(sequence point)是程序执行的点,在该点上,所有的副作用(side effect)都在进入下一步之前发生。语句中的分号标记了一个序列点。序列点有助于分析后缀递增何时发生。

  • C99 规定:actual argument(译为实参);formal parameter(译为形参

  • 一般而言,所有非零值都视为真,只有 0 被视为假。while(goats) 替换 while(goats !=0)

  • 单独分号表示空语句。使用带空语句的 while 语句,所有的任务都在测试条件中完成了,不需要在循环体中做什么。

  • C99 提供了 stdbool.h,该头文件让 bool 成为 _Bool 的别名,并把 true false 分别定义为 1 和 0 的符号常量。包含该头文件后,可以与 C++ 兼容,因为 C++ 把 booltruefalse 定义为关键字。

  • return 语句只能把被调函数中的一个值传回主调函数,传回两个值以上则需要指针。普通变量把值作为基本量,把地址作为通过 & 运算符获得的派生量,而指针变量把地址作为基本量,把值作为通过 * 运算符获得的派生量。

  • 当初始化列表中的值少于数组元素个数时,编译器会把剩余的元素都初始化为 0;通过 sizeof 自动计数的弊端:无法察觉初始化列表中的项数有误。


指定初始化器(C99)

如:int arr[8] ={ 31, 28, [4] = 31, 30, 31, [1] = 29 }; 最好在声明数组时使用符号常量来表示数组大小。

  • flizny == &flizny[0]; // 数组名是数组首元素的地址
    dates + 2 == &date[2] // 相同的地址
    *(dates + 2) == dates[2] // 相同的值

  • 只有在函数原型或函数定义头中, 才可以用 int ar[] 代替 int* ar 它们都表示 ar 是一个指向 int 的指针。int* ar 只能用于声明形参。int ar[] 表示指针 ar 指向的不仅是一个 int 类型值,还是一个 int 类型数组的元素。

  • 函数原型可以省略参数名(四种等价)int sum(int* ar, int n); int sum(int*, int); int sum(int ar[], int n); int sum(int [], int);

使用这种 “越界” 指针的函数调用更为简洁:a = s(m, m + SIZE);
ar[i]*(ar+i) 是等价的。ar 可以是数组名,或是指针变量。

  • while (start < end) {total += *start; // 把数组元素值加起来
    start++;} // 让指针指向下一个元素
    total += *start++; // 循环体压缩成一行代码

C 保证在给数组分配空间时,指向数组后面第一个位置的指针仍是有效指针,这使得 while 循环的测试条件有效。
虽然 *start++ 写法常用,但 *(start++) 写法更清楚。
(*start)++ 先使用 start 指向的值,再递增该值,而不是递增指针。这样指针将一直指向同一个位置,但是该位置上的值发生了变化。

指针的用法:

  • 指针与整数相加(减去)(整数会和指针所指向类型的大小(以字节为单位) 相乘,结果与初始地址相加,该指针指向首地址,ptr1 + 4&urn[4] 等价)
  • 递增(递减)指针(递增指向数组元素的指针让该指针移动至数组的下一个元素)
  • 指针求差(求差的两个指针分别指向同一个数组的不同元素,计算求出两元素之间的距离)
  • 比较(关系运算符可以比较两个指针的值,前提是都指向相同类型的对象)

指针减去一个指针得到一个整数,指针减去一个整数得到一个指针
使用指针前,必须先用已分配的地址初始化

  • const double* pd = rates; double const* pd = rates; pd 指向的 double 类型值声明为 const,这表明不能使用 pd 来更改它所指向的值

    const 放在 * 左侧,限定了指针指向的数据不能改变;const 放在 * 右侧,限定了指针本身不能改变。

  • double* const pc = rates; pc 指向数组来声明并初始化一个不能指向别处的指针

  • const double* const pc = rates; 该指针既不能更改它所指向的地址,也不能修改指向地址上的值

    把 const 指针赋给非 const 指针不安全,因为这样可以使用新的指针改变 const 指针指向的数据。把非 const 指针赋给 const 指针没问题,前提是只进行一级解引用。
    C++ 允许在声明数组大小时使用 const 整数,C 不允许;C++ 指针赋值检查更严格,不允许把 const 指针赋给非 const 指针,C 允许。

指向多维数组的指针

  • *zippo 是该数组首元素 zippo[0] 的值,zippo[0] 是该数组首元素 zippo[0][0] 的地址,即 *zippozippo[0]&zippo[0][0]等价。**zippo*&zippo[0][0] 等价。zippo 是地址的地址,必须解引用两次才能获得原始值。

  • int(*pz)[2]; pz 声明为指向一个数组的指针,该数组内含两个 int 类型值。
    int* pax[2]; pax 是一个内含两个指针元素的数组,每个元素为指向 int 的指针。
    pz[m][n] == *(*(pz + m) + n)

  • 一般而言,声明一个指向 N 维数组的指针时,只能省略最左边方括号中的值:int sum(int ar[][12], int row); 第一对方括号只用于表明这是一个指针,其他方括号用于描述指针所指向数据对象的类型。


  • 二维数组计算元素地址公式:
    double ary[5][8]; 求 ary[2][3] 的地址
    1
    (int)ary + sizeof(double[8])*2 + sizeof(double)*3
  • 三维数组计算元素地址公式:
    type ary[N][M][L]
    ary[i][j][k] address is:
    1
    (int)ary + sizeof(type[M][L])*i + sizeof(type[L])*j + sizeof(type)*k

变长数组(variable-length array, VLA, C99)

  • float a[n]; 声明 VLA 时不能初始化,其必须是自动存储类别,不能使用 static 或 extern,VLA 创建后不能改变大小。

  • int sum(int rows, int cols, int ar[rows][cols]); 前两个形参用作第三个形参二维数组 ar 的两个维度,必须在声明 ar 之前先声明这两个形参。
    C99/C11 规定,可以省略原型中的形参名,但在这种情况下,必须用星号来代替省略的维度:int sum(int, int, int ar[*][*]);

  • 变长数组名实际上是一个指针。这说明带变长数组形参的函数实际上是在原始数组中处理数组,因此可以修改传入的数组。

  • 变长数组允许动态内存分配,这说明可以在程序运行时指定数组大小。普通数组都是静态内存分配,即在编译时确定数组大小。


复合字面量

  • 字面量是除符号常量外的常量。(int ar[2]) {10, 20} 去掉数组名,留下的 int [2] 即复合字面量的类型名。

  • 复合字面量是匿名的,不能先创建再使用它,必须在创建的同时使用它。
    使用指针记录地址就是一种用法:

    1
    2
    int* pt;
    pt = (int [2]) {10, 20};
  • 把复合字面量作为实参传递给带有匹配形式参数的函数:

    1
    2
    3
    int sum(const int ar[], int n);
    int total;
    total = sum((int []) {4,4,4,5,5,5}, 6);

    第 1 个实参是内含 6 个 int 类型值的数组,和数组名类似,这同时也是该数组首元素的地址。这种用法的好处是,把信息传入函数前不必先创建数组,这是复合字面量的典型用法。

  • 复合字面量是提供只临时需要的值的一种手段。复合字面量具有块作用域。


字符串和字符串函数

  • 用双引号括起来的内容是字符串常量,被视为指向该字符串储存位置的指针,类似于把数组名作为指向该数组位置的指针,均视为地址。

  • 在数组形式中,ar 是地址常量,不能更改,如果改变了 ar,则改变了数组的存储位置。

  • 初始化数组把静态存储区的字符串拷贝到数组中,初始化指针只把字符串的地址拷贝给指针。声明数组将分配储存数据的空间,而声明指针只分配储存一个地址的空间。

  • 把指针初始化为字符串字面量时使用 const 限定符: const char* p = "Ke"; 否则可能导致内存访问错误。编译器可以使用内存中的一个副本来表示所有完全相同的字符串字面量。数组的指针元素所指向的字符串不必储存在连续的内存中,如果要改变字符串或为字符串输入预留空间,不要使用指向字符串字面量的指针。

  • 空字符是整数类型,一个字符,占 1 字节;空指针是指针类型,一个地址,通常占 4 字节。容易混淆是它们都可以用数值 0 来表示。

  • 使用 strchr() 查找换行符,find = strchr(line, '\n'); *find = '\0';

  • strcat() 两个参数
    strncat() 第 3 个参数指定最大添加字符数
    strcmp() 只会比较第 1 个空字符前面的部分。如果第 1 个字符串在第 2 个前面,返回负数;相同返回 0;后面返回正数。
    strncmp() 可以比较到字符不同的地方,第 3 个参数指定的字符数。
    strcpy() 把指向源字符串的第 2 个指针声明为指针、数组名或字符串常量;指向源字符串副本的第 1 个指针指向一个数据对象(如数组)
    strncpy() 第 3 个参数指明可拷贝的最大字符
    sprintf() 把数据写入字符串。可以把多个元素组合成一个字符串。第 1 个参数是目标字符串的地址,其余参数和 printf() 相同。

  • memcpy() 用于 copy 整型数组、浮点型,比较方便,strcpy() 遇到内存中的 0 会直接结束。

存储类别、链接和内存管理

  • 作用域描述程序中可访问标识符的区域

    • 块作用域,它把形参的作用域设置为整个函数体。

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

    • 函数原型作用域用于函数原型中的形参名,范围从形参定义处到原型声明结束,变量的定义在函数的外面,具有文件作用域。

    • 文件作用域变量也称为全局变量。源代码文件和所有的头文件都看成是一个包含信息的单独文件。这个文件被称为翻译单元,范围是整个翻译单元。

  • 链接

    • 具有块作用域、函数作用域或函数原型作用域的变量都是无链接变量。这意味着这些变量属于定义它们的块、函数或原型私有。

    • 具有文件作用域的变量可以是外部链接或内部链接。外部链接变量可以在多文件程序中使用,内部链接变量只能在一个翻译单元中使用。

    • 有时把“内部链接的文件作用域”简称为“文件作用域”,如static int dodgers = 3;,“外部链接的文件作用域”简称为“全局作用域”或“程序作用域”,如int giants = 5;

  • 存储期

    • 存储期描述通过这些标识符访问的对象的生存期:静态存储期、线程存储期、自动存储期、动态分配存储期。

    • 静态存储期,该对象在程序的执行期间一直存在。文件作用域变量具有静态存储期,static 表明了链接属性。

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

    • 块作用域变量通常都具有自动存储期。当程序进入定义这些变量的块时,为这些变量分配内存;当退出这个块时,释放内存。这种做法相当于把自动变量占用的内存视为一个可重复使用的工作区或暂存区。
      块作用域变量也能具有静态存储期。创建这样的变量要把变量声明在块中,且在声明前加上 static,变长数组稍有不同,它们的存储期从声明处到块的末尾,而不是从块的开始处到块的末尾。

  • 默认声明在块或函数头中的任何变量都属于自动存储类别,可以显式使用关键字 auto

    auto 在 C++ 中用法完全不同,如果编写 C/C++ 兼容的程序,最好不要使用 auto 作为存储类别说明符。

  • 变量具有自动存储期:程序在进入该变量的声明所在块时才为其分配内存,在退出该块时释放内存,变量消失。
    内层块中声明的变量会隐藏外层块同名变量的定义。但是离开内层块后,外层块变量的作用域又回到了原来的作用域,可以用 auto 强调。

  • 寄存器变量储存在寄存器而非内存中,所以无法获取寄存器变量的地址。

  • 可以创建具有静态存储期、无链接、块作用域的局部变量。这些变量和自动变量一样,具有相同的作用域,但是程序离开它们所在的函数后,这些变量不会消失。

  • 不能在函数形参中使用 static

  • 外部链接的静态变量称为外部存储类别,即外部变量。把变量的声明放在所有函数外创建。如果一个源代码文件使用的外部变量定义在另一个源代码文件中,则必须用 extern 在该文件中声明该变量。

  • 未初始化外部变量会被自动初始化为 0。这一原则也适用于外部定义的数组元素。与自动变量的情况不同,只能使用常量表达式初始化文件作用域变量(只要不是变长数组,sizeof 表达式可被视为常量表达式。)

C99 C11 标准要求编译器识别局部标识符的前 63 个字符和外部标识符的前 31 个字符。旧标准编译器识别局部标识符前 31 个字符和外部标识符前 6 个字符。
外部变量名比局部变量名的规则严格,因为外部变量名还要遵循局部环境规则,限制更多。外部变量只能初始化一次,且必须在定义该变量时进行。

1
2
3
int tern = 1; /* tern被定义 */
main() {
extern int tern; /* 使用在别处定义的tern */

第 1 次声明为变量预留了存储空间,该声明构成了变量的定义。第 2 次声明只告诉编译器使用之前已创建的 tern 变量,所以这不是定义。第 1 次声明被称为定义式声明(defining declaration),第 2 次声明被称为引用式声明(referencing declaration)。关键字 extern 表明该声明不是定义,它指示编译器去别处查询其定义。

  • 普通外部变量可用于同一程序中任意文件中的函数,但是内部链接的静态变量只能用于同一个文件中的函数。

  • C 语言有 6 个关键字作为存储类别说明符:autoregisterstaticextern_Thread_localtypedef。typedef 与任何内存存储无关。在绝大多数情况下,不能在声明中使用多个存储类别说明符,即不能使用多个存储类别说明符作为 typedef 的一部分。唯一例外的是 _Thread_local,它可以和 staticextern 一起使用。

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

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

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

  • 在同一个文件所有函数的外部声明的变量是外部变量,具有文件作用域、外部链接和静态存储期。
    如果在这种声明前面加上 static,那么其声明的变量具有文件作用域、内部链接和静态存储期。
    如果在函数中用 static 声明一个变量,则该变量具有块作用域、无链接、静态存储期。

  • 具有自动存储期的变量,如果未初始化,自动变量中是垃圾值,会被设置为 0。程序在编译时为具有静态存储期的变量分配内存,并在程序的运行过程中一直保留这块内存。

  • 具有块作用域的变量是局部的,属于包含该声明的块私有。
    具有文件作用域的变量对文件(或翻译单元)中位于其声明后面的所有函数可见。
    具有外部链接的文件作用域变量,可用于该程序的其他翻译单元。
    具有内部链接的文件作用域变量,只能用于其声明所在的文件内。

保护性程序设计的黄金法则:“按需知道”原则。尽量在函数内部解决该函数的任务,只共享那些需要共享的变量。

把文件名放在双引号中,指示编译器在本地查找文件,而不是到编译器存放标准头文件的位置去查找文件。“本地查找”的含义取决于具体的实现。一些常见的实现把头文件与源代码文件或工程文件放在相同的目录或文件夹中。


  • malloc() 函数接受一个参数:所需的内存字节数。会找到合适的空闲内存块,这样的内存是匿名的,但它会返回动态分配内存块的首字节地址。因此可以把该地址赋给一个指针变量,并使用指针访问这块内存。因为 char 表示 1 字节,其返回类型通常被定义为指向 char 的指针。

    1
    2
    double* ptd;
    ptd = (double*) malloc(n * sizeof(double));

    指针 ptd 被声明为指向一个 double 类型,而不是指向内含 n 个 double 类型值的块。使用强制类型转换更容易把 C 程序转换为 C++ 程序。

  • 通常,malloc() 要与 free() 配套使用。free() 的参数是之前 malloc() 返回的地址。动态分配内存的存储期从调用 malloc() 分配内存到 free() 释放内存为止。

  • 实际上也许在循环结束之前就已耗尽所有的内存。这类问题被称为内存泄漏(memory leak)。在函数末尾处调用 free() 可避免这类问题发生。

  • 分配内存还可以使用 calloc():接受两个无符号整数作为参数。第 1 个参数是所需的存储单元数量,第 2 个参数是存储单元的大小(以字节为单位)

    1
    2
    long* newmem;
    newmem = (long*) calloc(100, sizeof(long));
  • calloc() 还有一个特性:它把块中的所有位都设置为 0(在某些硬件系统中不是把所有位都设置为 0 来表示浮点值 0)可搭配 free()。

  • 使用动态内存通常比使用栈内存慢。

  • 静态数据(包括字符串字面量)占用一个区域,自动数据占用另一个区域,动态分配的数据占用第 3 个区域(通常被称为内存堆或自由内存)。


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

    • volatile int loc1; loc1 是一个易变的位置
      volatile int* ploc; ploc 是一个指向易变的位置的指针
      以上代码把 loc1 声明为 volatile 变量,把 ploc 声明为指向 volatile 变量的指针。原因是它涉及编译器的优化。

    • 可以同时用 const 和 volatile 限定一个值。例如,通常用 const 把硬件时钟设置为程序不能更改的变量,但是可以通过代理改变,这时用 volatile。只能在声明中同时使用这两个限定符,它们的顺序不重要: volatile const int loc; const volatile int* ploc;

  • restrict 允许编译器优化某部分代码以更好地支持计算。它只能用于指针,表明该指针是访问数据对象的唯一且初始的方式。
    int* restrict restar = (int *) malloc(10 * sizeof(int));

    • restrict 限定符可用于函数形参中的指针。这意味着编译器可以假定在函数体内其他标识符不会修改该指针指向的数据,编译器可以尝试对其优化,使其不做别的用途。例如,C 库有两个函数用于把一个位置上的字节拷贝到另一个位置。在 C99 中这两个函数的原型是:
      void* memcpy (void * restrict s1, const void * restrict s2, size_t n);
      memcpy() 要求两个位置不重叠

文件输入/输出

  • ch = getc(fp); 从 fp 指定的文件中获取一个字符
    putc(ch, fpout); 把字符 ch 放入 FILE 指针 fpout 指定的文件

    1
    2
    3
    4
    5
    6
    int ch;
    FILE * fp;
    fp = fopen("wacky.txt", "r");
    while (( ch = getc(fp)) != EOF) {
    putchar(ch); //处理输入
    }
  • fclose() 关闭指定的文件,必要时刷新缓冲区。对于较正式的程序,应该检查是否成功关闭文件。成功关闭返回 0,否则返回 EOF。
    if (fclose(fp) != 0) printf("Error in closing file %s\n", argv[1]);
    如果磁盘已满、移动硬盘被移除或出现 I/O 错误,都会导致调用 fclose() 函数失败。

  • fgets() 它的第 1 个参数和 gets() 函数一样,表示储存输入位置的地址(char* 类型);第 2 个参数是一个整数,表示待输入字符串的大小;最后一个参数是文件指针,指定待读取的文件。
    fgets(buf, STLEN, fp);buf 是 char 类型数组的名称,STLEN 是字符串大小,fp 是指向 FILE 的指针。

  • fputs() 第 1 个参数是字符串的地址;第 2 个是文件指针。该函数根据传入地址找到的字符串写入指定的文件中。

  • fgets() 读到一个换行符,会把它储存在字符串中
    gets() 会丢弃换行符
    fputs() 不会在输出的末尾添加换行符
    puts() 会自动在末尾添加一个换行符

  • fseek() 返回 int 类型值,第 1 个参数是 FILE 指针,指向待查找的文件,fopen() 应该已打开该文件;第 2 个参数是偏移量(offset),该参数表示从起始点开始要移动的距离,必须是一个 long 类型值,可以为正(前移)、负(后移)或 0(保持不动);第 3 个是模式,该参数确定起始点。
    根据 ANSI 标准,在 stdio.h 中规定了几个表示模式的明示常量。

1
2
3
fseek(fp, 0L, SEEK_SET); // 定位至文件开始处
fseek(fp, 2L, SEEK_CUR); // 从文件当前位置前移 2 个字节
fseek(fp, -10L, SEEK_END); // 从文件结尾处回退 10 个字节
  • ftell() 返回类型是 long,它返回的是文件中的当前位置。

必须掌握的 3 个技巧:

  1. 为结构建立一个格式或样式;
  2. 声明一个适合该样式的变量;
  3. 访问结构变量的各个部分。
  • 在结构声明中,用一对花括号括起来的是结构成员列表。每个成员都用自己的声明来描述。右花括号后面的分号是声明所必需的,表示结构布局定义结束。
    struct book { char title[MAXTITL]; char author[AXAUTL]; float value; } library;

  • 组合后的结构声明和结构变量定义不需要使用结构标记:
    struct book library = { "The Pious Pirate and the Devious Damsel", "Renee Vivotte", 1.95 };

  • struct book surprise = { .value = 10.99}; C99 和 C11 为结构提供了指定初始化器。

  • 和数组不同的是,结构名并不是结构的地址,因此要在结构名前面加上 & 运算符。

  • -> 运算符后面的结构指针和 . 运算符后面的结构名 工作方式相同
    如果 him 是指向 guy 类型结构 barney 的指针,下面的关系恒成立:
    barney.income == (*him).income == him->income 假设 him == &barney
    注意,通过指针访问结构成员时必须使用 -> 运算符。

  • 允许把一个结构赋值给另一个结构,但是数组不能。还可以把一个结构初始化为相同类型的另一个结构。

  • 把结构作为函数参数可以把结构的信息传送给函数;把结构作为返回值的函数能把结构的信息从被调函数传回主调函数。结构指针也允许这种双向通信。

  • 指针作为参数有两个优点:执行快,只需传递一个地址。缺点是无法保护数据。

  • 结构作为参数传递的优点:函数处理的是原始数据的副本,保护了原始数据。

  • 传递结构的两个缺点是:较老版本的实现可能无法处理这样的代码,而且传递结构浪费时间和空间。尤其是把大型结构传递给函数,这种情况下传递指针或只传递函数所需的成员更合理。

通常为了追求效率会使用结构指针作为函数参数,如需防止原始数据被意外修改,使用 const 限定符。按值传递结构是处理小型结构最常用的方法。

  • 复合字面量特性(C99)可用于结构和数组。如果只需要一个临时结构值,复合字面量很好用。例如,使用复合字面量创建一个数组作为函数的参数或赋给另一个结构。
    语法是把类型名放在圆括号中,后面紧跟一个用花括号括起来的初始化列表。(struct book) {“Idiot”, “Fyodor”, 6.99}

  • 伸缩型数组成员(C99),其声明的结构,最后一个数组成员具有一些特性。第 1 个特性是该数组不会立即存在。第2个特性是使用这个伸缩型数组成员可以编写合适的代码,就好像它确实存在并具有所需数目的元素一样。

    • 声明一个伸缩型数组成员有如下规则: 伸缩型数组成员必须是结构的最后一个成员; 结构中必须至少有一个成员; 伸缩数组的声明类似于普通数组,只是它的方括号是空的。

    • 带伸缩型数组成员的结构确实有一些特殊的处理要求:
      第一,不能用结构进行赋值或拷贝,这样做只能拷贝除伸缩型数组成员以外的其他成员。确实要进行拷贝, 应使用 memcpy() 函数。
      第二,不要以按值方式把这种结构传递给结构,要把结构的地址传递给函数。
      第三,不要使用带伸缩型数组成员的结构作为数组成员或另一个结构的成员。

    • 这种类似于在结构中最后一个成员是伸缩型数组的情况,称为 struct hack。除了伸缩型数组成员在声明时用空的方括号外,struct hack 特指大小为 0 的数组。然而,struct hack 是针对特殊编译器(GCC)的,不属于 C 标准。 这种伸缩型数组成员方法是标准认可的编程技巧。

  • 匿名结构(C11)是一个没有名称的结构成员。

  • 由于结构可以储存不同类型的信息,所以它是构建数据库的重要工具。
    储存在一个结构中的整套信息被称为记录(record),单独的项被称为字段(field)


  • 联合 是一种数据类型,它能在同一个内存空间中储存不同的数据类型(不是同时储存)。其典型的用法是,设计一种表以储存既无规律、事先也不知道顺序的混合类型。使用联合类型的数组,其中的联合都大小相等,每个联合可以储存各种数据类型;另一种用法是,在结构中储存与其成员有从属关系的信息。

  • 联合只能储存一个值,这与结构不同。有 3 种初始化的方法:把一个联合初始化为另一个同类型的联合;初始化联合的第 1 个元素;使用指定初始化器(C99)。

  • 匿名联合是一个结构或联合的无名联合成员。


  • 枚举类型 声明符号名称来表示整型常量。使用 enum 关键字可以创建一个新“类型”并指定它可具有的值(实际上,enum 常量是 int 类型,因此只要能使用 int 类型的地方就可以使用枚举类型)。枚举类型的目的是提高程序的可读性,它的语法与结构相同。

  • enum spectrum {red, orange, yellow, green, blue, violet}; enum spectrum color;
    类似地,其他标识符都是有名称的常量,这些符号常量被称为枚举符(enumerator)例如,在声明数组时,可以用枚举常量表示数组的大小;在 switch 语句中,可以把枚举常量作为标签。

C 枚举的一些特性并不适用于 C++。例如,C 允许枚举变量使用 ++ 运算符,C++ 不允许。所以如果编写的代码将来会并入 C++ 程序,那必须把上面例子的 color 声明为 int 类型才能兼容。

  • 默认情况下,枚举列表中的常量都被赋予 0、1、2 等,在枚举声明中,可以为枚举常量指定整数值。

  • C 使用名称空间标识程序中的各部分,即通过名称来识别。作用域是名称空间概念的一部分:两个不同作用域的同名变量不冲突。
    名称空间是分类别的。在特定作用域中的结构标记、联合标记和枚举标记都共享相同的名称空间,该名称空间与普通变量使用的空间不同。这意味着在相同作用域中变量和标记的名称可以相同,不会引起冲突,但是不能在相同作用域中声明两个同名标签或同名变量。
    C++ 不允许这样做,因为它把标记名和变量名放在相同的名称空间中。


  • typedef 工具是一个高级数据特性,利用 typedef 可以为某一类型自定义名称。这方面与 #define 类似,但是两者有三处不同:typedef 创建的符号名只受限于类型,不能用于值;typedef 由编译器解释,不是预处理器;在其受限范围内,typedef 比 #define 更灵活。

  • 用 typedef 来命名一个结构类型时,可以省略该结构的标签。

    • 数组名后面的 [] 和函数名后面的 () 具有相同的优先级。它们比 * 的优先级高。下面声明的 risk 是一个指针数组,不是指向数组的指针:int* risks[10];

    • [] 和 () 的优先级相同,都是从左往右结合,所以下面声明中,rusks 是一个指向数组的指针:int (* rusks)[10];


  • 为了指明函数类型,要指明函数签名,即函数的返回类型和形参类型。
    void ToUpper(char *); void (*pf)(char *); // pf 是一个指向函数的指针

    • 把函数名 ToUpper 替换为表达式 (*pf) 是创建指向函数指针最简单的方式。

    • 声明了函数指针后,可以把类型匹配的函数地址赋给它。这种情况,函数名可以用于表示函数的地址:pf = ToUpper;
      pf = ToUpper; (*pf)(mis);
      pf = ToUpper; pf(mis);

由于 pf 指向 ToUpper 函数,*pf 等价于 ToUpper 函数,所以 (*pf)(mis) 和 ToUpper(mis) 相同;由于函数名是指针,那么指针和函数名可以互换使用,所以 pf(mis) 和 ToUpper(mis) 相同。从 pf 的赋值表达式语句就能看出 ToUpper 和 pf 是等价的。


位操作

  • 按位与 常用于掩码(mask)。掩码指的是一些设置为开(1)或关(0)的位组合,0 看作不透明,1 看作透明。

    • 打开位(设置位)使用 按位或 需要打开一个值中特定位,同时保持其他位不变。

    • 关闭位(清空位)假设要关闭变量 flags 中的 1 号位。同样,MASK 只有 1 号位为1。可以这样做: flags &= ~MASK;

    • 切换位,使用 ^ 组合一个值和一个掩码。要切换 flags 中的 1 号位,flags ^= MASK;

    • 检查位的值,必须覆盖 flags 中的其他位,只用 1 号位和 MASK 比较:if ((flags & MASK) == MASK) puts("Wow!");为了避免信息漏过边界,掩码至少要与其覆盖的值宽度相同。

    • 移位运算符,针对 2 的幂提供快速有效的乘法和除法:number << n number 乘以 2 的 n 次幂 number >> n 如果 number 为非负,除以 2 的 n 次幂

      • 移位运算符还可用于从较大单元中提取一些位。例如,假设用一个 unsigned long 类型的值表示颜色值,低阶位字节储存红色的强度,下一个字节储存绿色的强度,第 3 个字节储存蓝色的强度。随后你希望把每种颜色的强度分别储存在 3 个不同的 unsigned char 类型的变量中。那可以使用:
        1
        2
        3
        4
        5
        6
        #define BYTE_MASK 0xff 
        unsigned long color = 0x002a162f;
        unsigned char blue, green, red;
        red = color & BYTE_MASK;
        green = (color >> 8) & BYTE_MASK;
        blue = (color >> 16) & BYTE_MASK;

        使用右移运算符将 8 位颜色值移动至低阶字节,然后使用掩码技术把低阶字节赋给指定的变量。

  • 操控位的第 2 种方法是位字段(bit field)。位字段是一个 signed int 或 unsigned int 类型变量中的一组相邻的位(C99 和 C11新增了 _Bool 类型的位字段)。位字段通过一个结构声明来建立,该结构声明为每个字段提供标签, 并确定该字段的宽度。

    1
    2
    3
    4
    5
    struct { 
    unsigned int code1 : 2;
    unsigned int code2 : 2;
    unsigned int code3 : 8;
    } prcode;
  • 一个字段不允许跨越两个 unsigned int 之间的边界。编译器会自动移动跨界的字段,保持 unsigned int 的边界对齐。一旦发生这种情况,第1个 unsigned int 中会留下一个未命名的“洞”。可以用未命名的字段宽度“填充”未命名的“洞”。使用一个宽度为 0 的未命名字段迫使下一个字段与下一个整数对齐。

C 预处理器和 C 库

  • #define 指令来定义明示常量(manifest constant)(也叫符号常量)
    预处理器指令从 # 开始运行,到后面的第 1 个换行符为止。指令的长度仅限于一行。在预处理开始前,编译器会把多行物理行处理为一行逻辑行。

  • 每行 #define 都由 3 部分组成:
    第 1 部分是 #define 指令本身。
    第 2 部分是选定的缩写,也称为宏。有些宏代表值,这些宏被称为类对象宏(object-like macro)。
    宏的名称中不允许有空格,而且必须遵循 C 变量的命名规则。
    第 3 部分(指令行的其余部分)称为替换列表或替换体。 一旦预处理器在程序中找到宏的实例后,就会用替换体代替该宏(有例外)。从宏变成最终替换文本的过程称为宏展开(macro expansion)。注意,可以在 #define 行使用标准 C 注释,每条注释都会被一个空格代替。

  • 预处理器不做计算,不对表达式求值,它只进行替换。双引号使替换的字符串成为字符串常量。

  • const int LIM = 50;
    static int data2[LIM]; // 无效
    非自动数组的大小应该是整型常量表达式,这意味着表示数组大小的必须是整型常量的组合、枚举常量和 sizeof 表达式,不包括 const 声明的值(在 C++ 中可以把 const 值作为常量表达式的一部分)

  • 可以把宏的替换体看作是记号(token)型字符串, 而不是字符型字符串。C 预处理器记号是宏定义的替换体中单独的“词”。用空白把这些词分开。替换体中有多个空格时,字符型字符串和记号型字符串的处理方式不同。
    解释为字符型字符串,把空格视为替换体的一部分;解释为记号型字符串,把空格视为替换体中各记号的分隔符。

  • C 允许在字符串中包含宏参数。在类函数宏的替换体中,# 号作为一个预处理运算符,可以把记号转换成字符串。例如,如果 x 是一个宏形参,那么 #x 就是转换为字符串 “x” 的形参名。这个过程称为字符串化 (stringizing)
    #define PSQR(x) printf("The square of " #x " is %d.\n",((x)*(x)))

  • 预处理器黏合剂:## 运算符
    用于类函数宏的替换部分,把记号组合为一个新的标识符 #define XNAME(n) x ## n

  • 变参宏:..._ _VA_ARGS_ _
    通过把宏参数列表中最后的参数写成省略号来实现。预定义宏 _ _VA_ARGS_ _ 可用在替换部分中,表明省略号代表什么。
    #define PR(...) printf(_ _VA_ARGS_ _)

  • 宏和函数的选择实际上是时间和空间的权衡。宏生成内联代码,即在程序中生成语句。如果调用 20 次宏,即在程序中插入 20 行代码。如果调用函数 20 次,程序中只有一份函数语句的副本,所以节省了空间。然而另一方面, 程序的控制必须跳转至函数内,随后再返回主调程序,这显然比内联代码花费更多的时间。

  • 宏的一个优点是,不用担心变量类型(宏处理的是字符串,不是实际的值)。只要能用 int 或 float 类型都可以使用 SQUARE(x) 宏。 C99 提供了第 3 种可替换的方法内联函数。

  • 在嵌套循环中使用宏更有助于提高效率。

  • #include <stdio.h> ←查找系统目录
    #include "hot.h" ←查找当前工作目录
    #include "/usr/biff/p.h" ←查找/usr/biff目录

头文件中最常用的形式:

  • 明示常量—— stdio.h 中定义的 EOF、NULL 和 BUFSIZE(标准 I/O 缓冲区大小)。

  • 宏函数—— getc(stdin) 通常用 getchar() 定义,而 getc() 经常用于定义较复杂的宏,ctype.h 通常包含 ctype 系列函数的宏定义。

  • 函数声明—— string.h 包含字符串函数系列的函数声明。在 ANSI C 和后面的标准中,函数声明都是函数原型形式。

  • 结构模版定义—— 标准 I/O 函数使用 FILE 结构,该结构中包含了文件、与文件缓冲区相关的信息。FILE 结构在 stdio.h 中。

  • 类型定义—— 标准 I/O 函数使用指向 FILE 的指针作为参数。通常 stdio.h 用 #define 或 typedef 把 FILE 定义为指向结构的指针。

  • #undef 用于“取消”已定义的 #define 指令。
    #define LIMIT 400
    #undef LIMIT

  • 使用其他指令创建条件编译
    #ifdef #else #endif

    • #ifdef 如果预处理器已定义了后面的标识符,则执行 #else 或 #endif 指令之前的所有指令并编译所有 C 代码(先出现哪个指令就执行到哪里)。如果预处理器未定义,且有 #else 指令,则执行 #else(如果需要) 和 #endif (必须存在)指令之间的所有代码。可以用这种方法调试程序:
      1
      2
      #ifdef JUST_CHECKING printf("i=%d, running total = %d\n", i, total); 
      #endif
  • #define SIZE 10 #include "arrays.h"
    当执行到 #include “arrays.h” 这行,处理 array.h 中的代码时,由于 SIZE 是已定义的,所以跳过了 #define SIZE 100 这行代码。可以利用这种方法,用一个较小的数组测试程序。测试完毕后,移除 #define SIZE 10 并重新编译。这样就不用修改头文件数组本身了。

  • #ifndef 指令判断后面的标识符是否是未定义的。

  • 在首次定义一个宏的头文件中用 #ifndef 指令激活定义,随后在其他头文件中的定义都被忽略。

  • #if 指令后面跟整型常量表达式

    1
    2
    3
    4
    #if SYS == 1
    #elif SYS == 2
    #else
    #endif
  • 较新的编译器提供另一种方法测试名称是否已定义,即用 #if defined (VAX) 代替 #ifdef VAX。优点是它可以和 #elif 一起使用

  • C标准规定了一些预定义宏

  • C99 标准提供一个名为 _ _func_ _ 的预定义标识符,它展开为一个代表函数名的字符串(该函数包含该标识符)。_ _func_ _ 必须具有函数作用域,而从本质上看宏具有文件作用域。因此 _ _func_ _ 是 C 语言的预定义标识符,而不是预定义宏。

  • #line 指令重置 _ _LINE_ __ _FILE_ _ 宏报告的行号和文件名。
    #line 10 “cool.c” // 把行号重置为 10,把文件名重置为 cool.c

  • #error 指令让预处理器发出一条错误消息,该消息包含指令中的文本。 如果可能的话,编译过程应该中断。

    1
    2
    3
    #if _ _STDC_VERSION_ _ != 201112
    #error Not C11
    #endif

  • 在程序设计中,泛型编程(generic programming)指那些没有特定类型,但是一旦指定一种类型,就可以转换成指定类型的代码。例如,C++ 在模板中可以创建泛型算法,然后编译器根据指定的类型自动使用实例化代码。C 没有这种功能,C11 新增了一种表达式,叫作泛型选择表达式,可根据表达式的类型选择一个值。泛型选择表达式不是预处理器指令,但是在一些泛型编程中它常用作 #define 宏定义的一部分。

  • 函数调用都有一定的开销,因为函数的调用过程包括建立调用、传递参数、跳转到函数代码并返回。使用宏使代码内联,可以避免这样的开销。C99 提供另一种方法:内联函数。

  • ANSI C 把指向 void 的指针作为一种通用指针,用于指针指向不同类型的情况。

  • 涉及的角度都以弧度为单位(1 弧度=180/π=57.296 度)pi 的值通过计算表达式4*atan(1)得到。


类型变体

  • C 专门为 float 和 long double 类型提供了标准函数。sqrtf() 是 sqrt() 的 float 版本,sqrtl() 是 sqrt() 的 long double 版本。
    利用 C11 新增的泛型选择表达式定义一个泛型宏,根据参数类型选择最合适的数学函数版本。C99 提供的 tgmath.h 定义了泛型类型宏。

  • 如果编译器支持复数运算,就会支持 complex.h 头文件。如 csqrtf()、csqrt() 和 csqrtl(),这些函数分别返回 float complex、double complex 和 long double complex 类型的复数平方根。

  • 通用工具库包含各种函数,包括随机数生成器、查找和排序函数、转换函数和内存管理函数。

    • atexit() 函数的用法:使用函数指针。需把退出时要调用的函数地址传递给 atexit()。函数名作为函数参数时相当于该函数的地址,所以该程序中把 sign_off 或 too_bad 作为参数。然后,atexit() 注册函数列表中的函数,当调用 exit() 时就会执行这些函数。ANSI 保证,在这个列表中至少可以放 32 个函数。最后调用 exit() 函数时,exit() 会执行这些函数(执行顺序与列表中的函数顺序相反,即最后添加的函数最先执行)

    • exit() 执行完 atexit() 指定的函数后,会完成一些清理工作:刷新所有输出流、关闭所有打开的流和关闭由标准 I/O 函数 tmpfile() 创建的临时文件。然后 exit() 把控制权返回主机环境,如果可能的话,向主机环境报告终止状态。 通常,UNIX 程序使用 0 表示成功终止,用非零值表示终止失败。

    • 快速排序算法在 C 实现中的名称是 qsort()。qsort() 函数排序数组的数据对象,其原型如下:
      void qsort(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *));

      • 第 1 个参数是指针,指向待排序数组的首元素。ANSI C 允许把指向任何数据类型的指针强制转换成指向 void 的指针,因此,qsort() 的第 1 个实际参数可以引用任何类型的数组。第 2 个参数是待排序项的数量。函数原型把该值转换为 size_t 类型。

      • 由于 qsort() 把第 1 个参数转换为 void 指针,所以 qsort() 不知道数组中每个元素的大小。为此,函数原型用第 3 个参数补偿这一信息,显式指明待排序数组中每个元素的大小。例如,如果排序 double 类型的数组,那么第 3个参数应该是 sizeof(double)。

      • 最后,qsort() 还需要一个指向函数的指针,这个被指针指向的比较函数用于确定排序的顺序。该函数应接受两个参数:分别指向待比较两项的指针。如果第 1 项的值大于第 2 项,比较函数则返回正数;如果相同,则返回 0;如果小于,则返回负数。qsort() 根据给定的其他信息计算出两个指针的值,然后把它们传递给比较函数。

      • qsort() 原型中的第 4 个函数确定了比较函数的形式: int (*compar)(const void *, const void *) 这表明 qsort() 最后一个参数是一个指向函数的指针,该函数返回 int 类型的值且接受两个指向const void的指针作为参数,这两个指针指向待比较项。


注意 C 和 C++ 中的 void*

  • C 和 C++ 对待指向 void 的指针有所不同。都可以把任何类型的指针赋给 void 类型的指针。qsort() 的函数调用中把 double* 指针赋给 void* 指针,但 C++ 要求在把 void* 指针赋给任何类型的指针时必须进行强制类型转换。而 C 没有这样的要求。

  • 强制类型转换,在 C 中是可选的,但在 C++ 中是必须的。


  • assert.h 头文件支持的断言库是一个用于辅助调试程序的小型库。它由 assert() 宏组成,接受一个整型表达式作为参数。assert() 宏是为了标识出程序中某些条件为真的关键位置,如果表达式求值为假,assert() 宏就在标准错误流(stderr)中写入一条错误信息,并调用 abort() 终止程序,abort() 函数原型在 stdlib.h 中。

  • assert() 好处:它不仅能自动标识文件和出问题的行号,还有一种无需更改代码就能开启或关闭 assert() 的机制。如果认为已经排除了程序的 bug,就可以把下面的宏定义写在包含 assert.h 的位置前面: #define NDEBUG 并重新编译程序,这样编译器就会禁用文件中的所有 assert() 语句。如果程序又出现问题,可以移除这条 #define 指令(或者把它注释掉),然后重新编译程序,这样就重新启用了assert() 语句。


  • string.h 库中的 memcpy() 和 memmove()
    不能把一个数组赋给另一个数组,所以要通过循环把数组中的每个元素赋给另一个数组相应的元素。有一个例外的情况是:使用 strcpy() 和 strncpy() 函数来处理字符数组。memcpy() 和 memmove() 提供类似的方法处理任意类型的数组。下面是这两个函数的原型:
    void *memcpy(void * restrict s1, const void * restrict s2, size_t n);
    void *memmove(void *s1, const void *s2, size_t n);

  • 可变参数:stdarg.h
    前面提到过变参宏,即该宏可以接受可变数量的参数。stdarg.h 为函数提供了一个类似的功能,但是用法比较复杂。必须按如下步骤进行:

  1. 提供一个使用省略号的函数原型;
  2. 在函数定义中创建一个va_list类型的变量;
  3. 用宏把该变量初始化为一个参数列表;
  4. 用宏访问参数列表;
  5. 用宏完成清理工作。

0x02 References

《C Primer Plus》

0x03 写在最后

学 C 到现在已经几个月了,不得不感慨道:C Primer Plus 这本书真的很经典,很多细节性的知识点都涉及到了,仅仅只是粗略地看了一遍,却让我对都涉及到了,仅仅只是粗略地看了一遍,却让我对 C 的一些程序设计原理和语法运用上有了更深层次的理解,收获颇深,这篇文章是我个人觉得后期可能需要查阅复习的知识点,故作此提炼精简,不定时更新精简吧。

计划中的高级篇应该是不会有了,C 的灵魂是指针,所以后期的目标就是能熟练运用指针和了解内存管理的知识,再总结一些专题文章,最后送读者一句话:基础不牢,地动山摇,望务必重视基础,往后方能更好更快地学习入门其他语言。

 Comments