# 语法

# 语法特点

  • 用 C 语言编写的源代码,一般保存为两种文件:
    • 源文件(source file):扩展名为 .c
    • 头文件(header file):扩展名为 .h
  • C 语言是编译型语言。源代码不能直接作为程序运行,需要用 gcc 等工具,编译成可执行文件,才能运行。
  • C 语言是强类型语言。所有变量,都必须声明数据类型,才能使用。

# 主函数

下例是一个 C 语言源文件 test.c :

#include <stdio.h>    // 导入头文件 stdio.h ,从而可以使用 printf()、scanf() 等函数

int main(void){       // 定义主函数
    int x = 0;        // 创建一个变量
    printf("Please enter an integer: ");// 在终端显示一行字符串,给用户看
    scanf("%d", &x);                    // 从终端读取一个值,保存到变量中
    printf("You entered: %d\n ", x);    // 将变量的值打印到终端
    return 0;                           // 终止函数,并返回一个数值
}
  • 用 C 语言编程时,需要定义有且仅有一个 main() 函数,又称为主函数,作为程序运行的入口。

    • 每个函数包含一组语句。编译器会按从上往下的顺序,依次执行这些语句。
    • 主函数执行的最后一条语句,通常是 return 0 ,表示终止函数并返回数值 0 。而主函数一旦终止,该程序也运行结束,又称为程序退出。
      • 退出码通常为 0 ,表示该程序正常终止。
      • 如果退出码不为 0 ,则表示该程序异常终止。
  • 定义主函数时,有几种格式:

    • 没有返回值,也没有形参:
      void main(void)   // 括号内的 void 可以省略,也表示没有形参
      
    • 返回值为 int 类型,但没有形参:
      int main(void)
      
    • 返回值为 int 类型,形参用于接收命令行参数:
      int main(int argc, char *argv[])
      
      假设将该源文件编译为 test ,然后在终端执行 test a b 1 2 。则操作系统会运行该程序,并传入参数:
      • 将 argv 数组的长度,传给 argc 。本例取值为 5 。
      • 将所有命令行参数(包括程序名称),保存为一个 char * 型指针数组,传给 argv 。本例 argv[0] 取值为 test

# 语句

  • 语句(statement),是 C 语言代码的基本组成单位。

    • 每条语句必须以分号 ; 结尾。否则编译器不能识别每条语句到第几个字符结束。
    • 通常在每条语句末尾,加一个换行符。因为如果将多条语句写在同一行,则比较拥挤,不方便人类阅读。
  • C 语言的语句分为多种:

    • 空语句
      • :仅有一个分号,不执行任何操作。
    • 声明语句
      • :比如声明某个变量的数据类型、声明某个函数头。
    • 执行语句
      • :比如给某个变量赋值、调用某个函数、执行一个表达式。
    • 复合语句
      • :用花括号 { } 包住的多条语句,称为一个语句块。
  • 缩进

    • 一个语句块,应该在每行开头增加一层缩进,从而方便人类阅读。
    • 如果不加缩进,则代码难以阅读,但并不影响程序的运行。
    • 每层缩进,建议使用两个 Tab ,相当于 8 个空格的长度。如下:
      if (x == 1) {
              return x;
      }
      
    • 如果一个语句块,只包含一行语句,则可以省略花括号。如下:
      if (x == 1)
              return x;
      
    • 同时最多使用三层缩进,避免代码太复杂。
  • 可以将 C 语言代码中的某些字符,声明为注释,从而被编译器忽略。

    • 单行注释:可以在某行插入 // ,将该符号右侧的所有字符,声明为注释。
    • 多行注释:可以在第 m 行插入 /* ,在第 n≥m 行插入 */ ,从而将这两个符号之间的所有字符,声明为注释。
    • 注释通常用于介绍一段代码的用途、希望达到的效果。而代码本身是怎么写的,应该让人一看就懂,不需要讲解。

# 变量

# 标识符

  • 变量名、函数名等名称,统称为标识符。

    • 每个标识符的命名格式:
      • 可以由阿拉伯数字、英文字母、下划线几种字符组成。
      • 不能以数字开头。
      • 字母区分大小写。
    • intchar 等名称,是 C 语言的关键字,用户不能创建与它们同名的标识符。
  • C/C++ 中,标识符包含英文字母时,应该大写还是小写?

    • 变量名、函数名采用下划线命名法,所有字母都小写。例如:my_name
    • 结构体名、类名采用大驼峰命名法,每个单词的首字母大写。例如 MyName
    • 常量名采用下划线命名法,所有字母都大写。例如:MY_NAME

# 创建

  • 变量,是一个有名字的容器,可以存储一个值,然后读取该值。如下:

    int x;    // 声明一个 int 类型的变量,命名为 x
    x = 1;    // 给变量 x 赋值为整数 1
    printf("%d\n", x);  // 读取变量 x 的值,打印到终端
    
  • 使用变量时,应该遵守该流程:声明变量 -> 赋值 -> 读值

    • 如果不声明变量的数据类型,C 语言编译器就不知道,应该给该变量分配多大容量的内存空间,作为容器来存储值。
      • 例如 char 类型的变量,占用 1 byte 大小的内存空间,或者说容量为 1 byte 。
      • 例如 int 类型的变量,占用 4 bytes 大小的内存空间,或者说容量为 4 bytes 。
      • 虽然变量的声明语句,决定了分配多大容量的内存空间。但编译器执行到变量的赋值语句时,才会实际分配内存空间。
    • 可以在声明变量数据类型的同时,进行赋值,这称为变量的定义语句。例:
      int x = 1;
      
      • 变量的定义(definition)语句,相当于声明(declaration)+赋值(assignment)。
      • 同一个变量,只能声明一次数据类型,但可以多次声明 extern 。不过声明 extern 的同时,不能进行赋值。
      • 换句话说,同一个变量,只能存在一个定义语句,可以存在多个声明语句。
    • 可以在一个语句中,同时声明、赋值多个变量:
      int x, y;
      int a = 1, b =2;
      
    • 语法错误:
      x = 1;      // 这里会报错变量 x undeclared ,不能赋值或读值
      int x;
      
    • 语法错误:
      int x;      // 第一次声明变量时,会在当前作用域创建该变量
      int x;      // 创建一个变量之后,不能再次声明它的数据类型
      
    • 如果用户声明了一个变量,尚未进行第一次赋值(称为变量的初始化),就读取该变量的值,如下。则会读取到一个不可预测的值,可能导致程序出错。
      int x;
      printf("%d\n", x);
      
      • 为了避免这种情况,某些编译器,会在创建变量时,自动赋一个默认值。比如执行 int x; 时,自动给变量 x 赋值为 0 。
      • 为了避免这种情况,某些编译器,会检查代码中的各个变量,如果一个变量尚未赋值就被读取,则抛出 warning 报错。
      • 为了避免这种情况,用户可以养成习惯,总是在声明变量的同时,赋一个默认值,比如 int x = 1; 。不过这样增加了一次主动赋值操作,会略微增加一点程序运行耗时。
  • 声明变量的数据类型时,还可以添加 const、extern、static 等关键字,声明变量的特殊属性。

# 作用域

  • 创建一个变量之后,它就会一直占用内存空间。什么时候才会删除该变量,回收其内存空间呢?
    • C 语言不支持用户主动删除一个变量。而是等到当前代码块执行结束时,自动删除变量。
    • 每个变量在创建之后、删除之前,可以在程序的某些区域访问该变量(读值、赋值)。这些区域,称为该变量的作用域(scope)。

根据作用域的不同,将变量分为几类:

  • 局部变量

    • {} 代码块中声明的变量,属于局部变量。
    • 当该代码块执行结束时,会自动删除其中所有局部变量。
    • 例:
      {
          int x = 0;              // 创建局部变量 x
          printf("%d\n", x);      // 可以在当前代码块中,访问局部变量 x
          {
              printf("%d\n", x);  // 可以在嵌套内层代码块中,访问局部变量 x
          }
      }
      printf("%d\n", x);          // 在外层代码块中,访问不到局部变量 x 。这里会报错变量 x undeclared
      
    • 访问变量 x 时,会先检查当前代码块中,是否存在名为 x 的变量。如果不存在,则检查外层代码块中,是否存在名为 x 的变量,存在的话就使用它。
      int x;
      {
          printf("%d\n", x);  // 当前代码块不存在名为 x 的变量,因此使用外部代码块的 int x 变量
          char x = 'A';
          printf("%c\n", x);  // 当前代码块存在名为 x 的变量,因此使用它
      }
      
  • 静态变量

    • 声明变量时,如果添加关键字 static ,则会将它声明为静态变量。
    • 静态变量的特点是,在其作用域结束之后,不会被编译器自动销毁。等到下次执行当前代码块时,会继续使用该变量。
    • 例如,循环执行以下代码块,每次执行,都会重新创建局部变量 x ,并赋值为 0 :
      while(1){
          int x = 0;
          x++;
          printf("%d\n", x);
      }
      
      如果将变量 x 声明为静态变量,则它只会被创建一次,随后每次循环,取值因为 x++; 而不断递增:
      while(1){
          static int x = 0; // C 语言程序在编译时,会事先创建静态变量并初始化。等到正式执行该代码块时,会忽略静态变量的定义语句
          x++;
          printf("%d\n", x);
      }
      
    • 优点:
      • 可以保留变量的值,方便下次使用。
    • 缺点:
      • 让变量一直存活,一直占用内存空间。
  • 全局变量

    • 在函数之外声明的变量,属于全局变量。
    • 全局变量的作用域,从定义该变量的那一行语句开始,到当前源文件的最后一行结束。
    • C 语言不允许在一个函数内嵌套定义另一个函数,因此所有函数、全局变量,都是存在于全局代码块中的标识符。
    • 优点:
      • 全局变量,可以在所有函数内访问,方便这些函数之间分享信息。
    • 缺点:
      • 全局变量会一直存活,一直占用栈区内存空间。因此,如果用户只需要短期使用一个变量,应该创建局部变量。
      • 不方便知道哪些函数使用了全局变量。因此不敢随便修改全局变量,不知道会影响哪些函数。换句话说,增加了代码的耦合度。
    • 例:
      int a = 0;        // 全局变量
      extern int b;     // 外部变量
      
      int main(void){
          int a = 0;    // 局部变量
          return 0;
      }
      
  • 外部变量

    • 声明变量时,如果添加关键字 extern ,则会将它声明为外部变量。表示该变量的定义语句不在当前代码块,请编译器到外部代码块(通常是 include 的其它文件),寻找该变量的定义语句。
    • 例:
      #include <stdio.h>
      
      int x = 0;
      
      int main(){
          extern int x;         // 此时编译器会到外部代码块,寻找变量 int x ,于是发现全局变量 int x
          // extern int x = 0;  // 语法错误。用 extern 声明变量的同时,不能进行赋值
          printf("%d\n", x);
          return 0;
      }
      
    • 如果声明了一个外部变量,但从未使用它(读取、赋值),则编译器不会寻找该变量的定义语句。因此,即使不存在该变量的定义语句,也不会报错。
      #include <stdio.h>
      
      int main(){
          extern int x;
          return 0;
      }
      
    • 所有函数,默认都为外部函数,不必再用 extern 声明。
      • 这看起来挺方便,但用户可能在几个源文件中,定义同名的函数,发生冲突。
      • 为了避免冲突,可以将函数用 static 声明,只允许从当前文件访问,不允许从其它文件访问。

根据存储方式的不同,将变量分为几类:

  • auto 类型
    • 用关键字 auto 声明的变量,属于自动类型。
    • 创建这种变量时,编译器会自动分配内存空间给它。作用域结束时,编译器会自动回收它的内存空间。
    • 比如局部变量、函数的形参,它们默认属于 auto 变量。
      int main(){
          int x = 0;  // 相当于 auto int x = 0;
          return 0;
      }
      
  • static 类型
  • extern 类型
  • register 类型
    • 用关键字 register 声明的变量,会存储在 CPU 芯片的寄存器中。
    • 普通的变量,存储在计算机内存中。程序每次读写该变量,需要将变量从内存拷贝到寄存器中,才能被 CPU 直接读写。
    • 如果将变量一直存储在 CPU 寄存器中,就能被 CPU 更快地读写。不过一般程序没必要这么追求速度。
    • register 只能用于声明局部变量,不能声明全局变量。
    • register 类型的变量,不支持通过 & 获取内存地址。

# 数据类型

  • 每个变量,有且仅有一种数据类型,决定了该变量占用多少内存空间、允许存储什么类型的值。

  • C 语言中,变量的数据类型分为几类:

    • 空类型(void)
      • 表示不存在值。
      • 例如很多函数的参数、返回值为 void 类型。
    • 基本数据类型
      • 整型 short、int、long
      • 浮点型 float、double
      • 字符型 char
    • 数组
    • 指针
    • 结构体
    • 共用体
    • 枚举
  • 每个变量在声明数据类型之后,不允许修改数据类型。但表达式的返回值,可以修改数据类型。分为两种情况:

    • 自动类型转换
      • 不同数据类型的几个值,在混合运算时,会自动转换成其中容量最大的那个数据类型。
      • 转换顺序为:char,short -> int -> unsigned int -> long -> unsigned long -> double
      • float 参与运算时,一律转换为 double 再运算。甚至两个 float 之间的运算,都要转换为 double 再运算。这是为了让浮点数的运算结果,尽量精确,虽然这会增加几个字节的内存开销。
      • 例:
        printf("%d\n", 1+'A');    // 运算结果为 int 类型,输出为:66
        printf("%lf\n", 1+3.14);  // 运算结果为 double 类型,输出为:4.140000
        float x = 1+3.14;         // 1+3.14 的运算结果为 double 类型,赋值给变量 x 时,会强制转换为 float 类型
        
    • 强制类型转换
      • 进行赋值时,= 右侧的表达式返回值,会强制转换成 = 左侧的变量数据类型。
        float x = 1;        // 此时整数 1 会被转换成浮点数
        printf("%f\n", x);  // 输出为:1.000000
        printf("%f\n", 1);  // 如果直接打印整数 1 ,则不兼容 %f 格式控制符,输出为:0.000000
        
      • (数据类型名)(表达式) 的格式,可以强制转换表达式返回值的数据类型。
        printf("%d\n", (int)3.14);    // 浮点型转换成整型时,会丢掉小数部分。输出为:3
        printf("%lf\n", 1/3);         // 运算结果为 int 类型,输出为:0.000000
        printf("%lf\n", (float)1/3);  // 运算结果为 float 类型,输出为:0.333333
        
  • sizeof(变量名)sizeof(数据类型名) 的格式,可以查询一个变量或数据类型,占用的内存大小,单位为 bytes 。

    • sizeof 不是函数,而是一个关键字,称为容量运算符。
    • sizeof 的返回值,在编译期间就会确定。
    • 例:
      printf("%d\n", sizeof(int));  // 输出为:4
      
    • 字节(byte)是计算机常用的存储单位,而位(bit)是最小的存储单位。
    • 每个字节,由 8 个 bit 组成。
  • 用关键字 typedef ,可以给某个数据类型,创建一个别名。

    • 例:
      typedef unsigned int u16;   // 给 unsigned int 数据类型,创建 u16 别名。最后一个字段就是别名
      u16 x;
      
      typedef struct{...} Horse;  // 将结构体命名为 Horse ,像定义一个类
      Horse h1;
      
    • 使用 #define 定义宏,能实现与 typedef 相似的效果,但有时效果不同。
      #define int_ptr int *
      int_ptr x,y;  // 会被替换为 int *x,y; ,考虑到运算符的优先级,这相当于 int *x; 和 int y;
      
      typedef int * int_ptr;
      int_ptr x,y;  // 相当于 int *x; 和 int *y;
      

# 整数

  • 在数学中,整数是指不含小数点的数字。

  • 在 C 语言中,整数通常存储在以下数据类型的变量中:

    • short
      • 称为短整型(short integer)
      • 占用内存空间的容量为 2 字节。
    • int
      • 称为整型(integer)
      • 容量通常为 4 字节。
        • 在 16 位 CPU 上,容量为 2 字节。
      • 例:
        int x;
        x = 3;      // 3 是 int 型常量,可以被赋值给 int 型变量
        x = 3.14;   // 3.14 是 double 型常量,如果赋值给 int 型变量,则会丢掉小数部分,只剩整数部分
        printf("%d\n", x);
        
    • long
      • 称为长整型(long integer)
      • 在 32 位 CPU 上,long 的容量为 4 字节。在 64 位 CPU 上,long 的容量为 8 字节。
    • long long
      • 称为双长整型。
      • 容量为 8 字节。
      • 32 位 CPU 不支持使用该数据类型,64 位 CPU 才支持。
  • short 的容量为 2 字节,也就是使用 16 bits 来存储数字。那么能存储多大的数字?

    • 将这 16 个二进制位,从 0 开始编号。第 0 位称为最低位,第 15 位称为最高位。
    • 默认将最高位,用于存储正负号,称为符号位。取值 0 表示正号,取值 1 表示负号。
    • 除了最高位之外,其它位可以自由赋值。
      • 绝对值最大的正数,二进制值为 0111 1111 1111 1111,等于十进制的 2^15 - 1 = 32767
      • 绝对值最大的负数,二进制值为 1111 1111 1111 1111,等于十进制的 -32767
      • 另外,0000 0000 0000 0000 转换成十进制是 0 ,1000 0000 0000 0000 转换成十进制是 -0 。而 -0 没有用处,通常将这个数值用于表示 -32768 。
    • 因此,如果用 short 型变量存储一个整数,则取值范围为 ``-(2^15)+(2^15-1),也就是-32768+32767` 。
    • 用户给 short 型变量赋值时,如果超过正常取值范围,则会导致二进制的最高位被修改,正负号反转,该现象称为数值溢出。如下:
      short x = 32767;
      printf("%d\n", x);  // 输出为:32767 ,正常
      
      x = 32768;
      printf("%d\n", x);  // 输出为:-32768
      
      x++;
      printf("%d\n", x);  // 输出为:-32767
      
      x = -32768;
      printf("%d\n", x);  // 输出为:-32768 ,正常
      
      x--;
      printf("%d\n", x);  // 输出为:32767
      
    • 同理,如果 int 的容量为 4 字节,则取值范围为 -(2^31)+(2^31-1)
    • 为了扩大变量的取值范围,可以不记录正负号,将最高位也用于存储数值。这样的变量,称为无符号型变量(unsigned)。例如:
      unsigned char   // 取值范围为 0 至 2^8 -1 = 255
      unsigned short  // 取值范围为 0 至 2^16 -1 = 65535
      unsigned int
      unsigned long
      
      • 与之相对,默认数据类型的变量,称为有符号型变量(signed)。
      • 浮点数 float、double 必须记录正负号。因为浮点数按照 IEEE754 标准进行存储,而且浮点数的取值范围本就很大,一般不需要扩大范围。

# 浮点数

  • 十进制的整数,转换成二进制形式,比较简单,只需要反复除以 2 。但十进制的小数,转换成二进制形式,比较麻烦。

  • C 语言按照 IEEE754 标准来存储包含小数的数字。以 float x = 3.14; 为例,存储流程如下:

    1. 将十进制的 3.14 ,转换成二进制,得到 11.0010 0011 1101 0111 0000 1010 ... ,是一个无限长度的小数。
    2. 计算机只能存储有限长度的数字,多余的位数会被丢弃,导致误差。这里假设上述二进制值,被截断为 11.0010 0011 1101 然后存储。
    3. 为了记录小数点的位置,将上述二进制值,改写为 1.10010 0011 1101 << 1 的形式。表示将 1.10010 0011 1101 按位左移 1 次,就会得到 11.0010 0011 1101
      • 此时,小数点的位置可以左右浮动,因此称为浮点数(floating point number)。
    4. 在二进制中,左数第一个数字总是 1 ,因此可以省略不记录。最终只需要记录 10010 0011 1101 ,称为尾数。再记录 << 1 ,称为指数。
    5. float 的容量为 32 bits ,这些 bit 的用途如下:
      • 第 31 个 bit ,用作符号位。
      • 第 23 至 30 个 bit ,总共 8 位,用于存储指数。
        • 因此指数的取值范围为 -2^7 = -128+2^7 = +128 。换算成十进制,float 的取值范围为 2^-1282^+128
      • 第 0 至 22 个 bit ,总共 23 位,用于存储尾数。
        • 考虑到,尾数左端还有一个二进制 1 被省略了,因此尾数能存储的十进制值最大为 2**24 - 1 = 16777215 ,总共 8 位长度。但这样不能存储大于 16777215 的 8 位十进制数。
        • 保险起见,建议用户使用 float 时,最多存储 7 位十进制数字(不包括小数点)。超出长度的部分,取值随机不可控,不会四舍五入。如下:
          float x = 1234567.89;
          printf("%f\n", x);  // 输出为:1234567.875000
          
          float x = 12345670000.0;
          printf("%f\n", x);  // 输出为:12345669632.000000
          
      • 综上,float 的取值范围很大,超过大部分用户的需求。但 float 的精度有限,不一定满足用户的需求。
  • 在 C 语言中,浮点数通常存储在以下数据类型的变量中:

    • float
      • 称为单精度浮点型。
      • 容量为 4 字节。
      • 最多保留 7 位十进制有效数字。
    • double
      • 称为双精度浮点型。
      • 容量为 8 字节。
      • 最多保留 15 位十进制有效数字。
    • long double
      • 称为长双精度浮点型。
      • 容量为 16 字节。

# 字符

  • 在人类世界,除了数字,还有英文字母、汉字等文字符号,统称为字符。
  • 在 C 语言中,
    • ASCII 编码的字符(比如阿拉伯数字、英文字母),可以存储在 char 类型的变量中。
      char a = 'A';   // 将单个字符 'A' 存储到 char 型变量中,实际上存储的是 'A' 的 ASCII 码值 65
      char b = 65;    // 将整数 65 转换成 char 类型,然后存储到 char 型变量中
      printf("%d\n", 'A' == 66);  // 这个表达式为真,输出为:1
      
    • 非 ASCII 编码的字符(比如汉字),需要转换成十六进制值,以字符串的形式存储。

# 常量

  • 常量有多种类型,它们的特点是取值不会变化。

    • 以下常量,由用户写在源代码中。每次使用它们,用户就需要再写一次它们的值,挺麻烦。
      printf("%d\n", 1);        // 整数常量
      printf("%f\n", 3.14);     // 浮点数常量
      printf("%c\n", 'A');      // 字符常量
      printf("%s\n", "Hello");  // 字符串常量
      
    • 以下常量,称为符号常量。它们绑定了一个标识符作为名称,用户可通过标识符引用它们的值。
      #define PI 3.14         // 通过宏定义,创建一个常量
      printf("%f\n", PI);
      
      const float pi = 3.14f; // 通过关键字 const 将一个变量声明为常量,禁止赋值
      printf("%f\n", pi);
      pi = 3.0;               // 对 const 类型的变量赋值,会报错:read-only variable
      
  • 整数常量,细分为几种:

    1   // 如果用户写入一串数字,不包含小数点,则会被编译器视作整数常量,默认采用 int 数据类型存储在内存中
    1L  // 如果以字母 L 或 l 结尾,则采用 long 类型
    1U  // 如果以字母 U 或 u 结尾,则采用 unsigned int 类型
    
  • 浮点数常量,细分为几种:

    3.14   // 如果用户写入一串数字,包含小数点,则会被编译器视作浮点数常量,默认采用 double 数据类型存储在内存中
    3.14F  // 如果以字母 F 或 f 结尾,则采用 float 类型
    3.14L  // 如果以字母 L 或 l 结尾,则采用 long double 类型
    printf("%f\n", -31.4e-1f);  // 用户还可按照科学计数法,写入一个浮点数。这里输出为:-3.140000
    

    例:

    printf("%f\n", 3);    // 3 是 int 型常量,不兼容 %f 格式控制符,输出为:0.000000
    printf("%f\n", 3.);   // 3. 是 double 型常量,兼容 %f 格式控制符,输出为:3.000000
    
    float x;
    x = 3;       // 3 是 int 型常量,可以被赋值给 float 型变量,此时会发生强制类型转换
    x = 3.14;    // 3.14 是 double 型常量,可以被赋值给 float 型变量,此时会发生强制类型转换
    x = 3.14f;   // 3.14f 是 float 型常量,可以被赋值给 float 型变量
    x = 3f;      // 语法错误。3 是 int 型常量,不能声明为 float 类型
    

# 数组

  • int、float、char 等数据类型的变量,每个变量只能存储一个值。

  • 数组(array)类型的变量,每个变量可以存储多个值,称为多个元素(element)。

    • 创建一个数组时,必须确定该数组的内存容量,即包含多少个元素、这些元素是什么数据类型。
      • 这是为了告诉编译器,分配多少内存空间来存储该数组。
      • 数组中的元素总数,称为该数组的长度。数组的内存容量,等于元素总数 * 单个元素的容量。
      • 同一数组的各个元素,必须是同一数据类型。
    • 例:
      int array[10];                  // 创建一个数组,可以存储 10 个元素,这些元素必须都是 int 数据类型
      printf("%d\n", sizeof(array));  // 输出为 40
      
      int array[];                    // 语法错误,没有定义数组长度
      
      int array[] = {0,1,2,3};        // 定义数组时,可以不指定数组长度,而是赋值一个数组常量,自动采用其长度
      printf("%d\n", sizeof(array));  // 输出为 16
      
      int array[10] = {0,1};          // 定义数组时,可以指定数组长度,同时赋值一部分元素
      printf("%d\n", sizeof(array));  // 输出为 40
      
    • 数组中的各个元素,从 0 开始编号,又称为下标。通过 array[n] 的格式,可以访问数组中第 n 个元素。
      int array[10];
      array[0] = 4;             // 给数组中第 0 个元素赋值
      printf("%d\n", array[0]); // 读取数组中第 0 个元素
      
  • 数组中的元素,可以是 int、float、char 等基本数据类型,也可以是数组类型。

    • 例:创建一个二维数组
      int array[2][3];                  // 定义一个二维数组。第一维长度为 2 ,第二维长度为 3 ,就像数学中 2 行 3 列的矩阵
      int array[2][3] = {0,1,2,3};      // 按顺序对前 4 个元素赋值,这相当于赋值为 {{0,1,2}, {3}}
      int array[2][3] = {{0,1,2}, {3}};
      int array[][3]  = {{0,1,2}, {3}}; // 定义数组时,可以不指定数组长度,而是赋值一个数组常量,自动采用其长度
      
    • 例:用 for 语句输入、输出一个二维数组
      int main(void){
          int array[2][3];
          int i = 0, j = 0;
          for (i; i < 2; i++){
              for (j = 0; j < 3; j++){
                  printf("Please enter an integer: ");
                  scanf("%d", &array[i][j]);
              }
          }
      
          int num = sizeof(array) / sizeof(int);      // 计算数组的总长度
          int len2 = sizeof(array[0]) / sizeof(int);  // 计算数组的第二维长度
          int len1 = num  / len2;                     // 计算数组的第一维长度
      
          printf("The array is:\n");
          for (i = 0; i < len1; i++)
          {
              for (j = 0; j < len2; j++)
                  printf("%d ", array[i][j]);
              printf("\n");
          }
          return 0;
      }
      

# 指针

  • 指针(pointer)是一种特殊的数据类型。

    • int、float、char 等数据类型的变量,是存储一个数字或字符,用户不关心这个值存储在内存的哪个地址。
    • 指针类型的变量,是存储一个内存地址,或者说指向一个内存地址。
      • 用户知道了内存地址,就可以读取从该地址开始的任意个 bytes 内存空间的数据,或者写入数据到这块内存空间。
      • 优点:使用指针,用户可以自由访问任意内存地址。
      • 缺点:风险大。比如用户访问一个不存在的内存地址,可能导致 C 语言程序崩溃。
  • 指针变量可以存储任意内存地址,常见的用途是,存储普通变量的内存地址。

    • 例:
      int x = 1;
      int *p = &x;  // 创建一个 int * 类型的指针变量,指向变量 x 的内存地址
      
    • 创建指针变量时,格式为 数据类型名 * 变量名
    • 指针变量,必须与目标变量的数据类型相同。
      • 比如 int * 类型的指针,只能存储 int 类型变量的内存地址。
      • 这是因为,只知道一个内存地址,却不知道数据类型,就不知道应该读取从该地址开始的多少个 bytes 的数据,也不知道解析成整数还是浮点数。
    • & 称为取地址运算符,用于获取一个变量的内存地址。
    • 使用 * 指针变量名 的格式,可以取消引用指针,变为引用指针指向的那个内存地址中的数据。
      printf("%p\n", p);  // 读取指针 p 存储的内存地址,输出为:0x7fff2477fb74
      printf("%d\n", *p); // 读取指针 p 指向的那个内存地址中的数据,输出为:1
      scanf("%d", p);     // 读取一个整数,存储到指针 p 所指的内存地址中。这里 p 本身就是一个地址,不需要使用 &p 取地址
      
      p = &x;             // 给指针 p 赋值,存储变量 x 的内存地址
      *p = 1;             // 给指针 p 所指的内存地址赋值,存储整数 1
      
      (*p)++;             // 让变量 x 的值自增
      printf("%d\n", x);  // 输出为:2
      
  • 自增 ++ 、自减 -- 与取消引用运算符 * 的优先级相同,并且都是右结合性。

    • (*p)++ 是先引用 p 指向的那个内存地址中的数据,然后让该数据递增。
    • *p++ 等价于 *(p++) ,是先让 p 所存储的内存地址递增,然后引用该地址中的数据。
    • p++ 并不是让内存地址增加 1 byte ,而是增加一倍的 sizeof(*p)
  • 一个指针变量,本身占用多少内存空间?

    • 在 32 位 CPU 上,每个内存地址的长度为 4 bytes ,因此每个指针变量,占用 4 bytes 内存空间,与 long 变量的容量相同。
    • 在 64 位 CPU 上,每个内存地址的长度为 8 bytes ,因此每个指针变量,占用 8 bytes 内存空间,与 long 变量的容量相同。
    • 例如 int * 类型的指针变量、char * 类型的指针变量,都是存储一个内存地址。这里的 intchar 只是表示如何解读内存地址中的数据,并不表示指针本身的容量。
    • 例:
      int x = 1;
      int *a = &x;
      
      char y = 'A';
      char *b = &y;
      
      printf("%d, %d\n", sizeof(x), sizeof(y)); // 输出为:4, 1
      printf("%d, %d\n", sizeof(a), sizeof(b)); // 输出为:8, 8
      
  • 指针变量存储的内存地址,是一个十六进制数字。如果转换成字符串形式,则方便在终端输入、输出。例:

    char str[] = "Hello";   // 创建一个指针
    printf("%p\n", str);    // 输出为:0x7ffcd1cf33d0
    
    long addr = (long)str;  // 将任意类型的指针,强制转换成 long 型变量
    printf("%lx\n", addr);  // 输出为:7ffcd1cf33d0
    
    char *p = (char *)addr; // 将 long 型变量,强制转换成任意类型的指针
    printf("%s\n", p);      // 输出为:Hello
    
    char buffer[20];
    sprintf(buffer, "%lx", addr);   // 将 long 型变量,打印到字符串中
    printf("%s\n", buffer);         // 输出为:7ffcd1cf33d0
    
    long addr2 = 0;
    sscanf(buffer, "%lx", &addr2);  // 从字符串中读取内存地址,保存为 long 型变量
    printf("%lx\n", addr2);         // 输出为:7ffcd1cf33d0
    
  • 上述指针是指向一个基本数据类型的变量,用法简单。下面介绍一些用法复杂的指针:

    • 空指针
      • :这种指针的取值为 NULL ,即没有存储内存地址。
      • NULL 是一个宏,取值为 (void *) 0
      • 例:
        int *p = NULL;      // 创建一个 int * 类型的指针变量,同时初始化为空指针
        printf("%p\n", p);  // 输出为:nil
        printf("%d\n", p);  // 输出为:0
        printf("%d\n", NULL==0);  // 输出为:1
        printf("%d\n", *p); // 程序报错 Segmentation fault
        
    • 野指针
      • :这种指针的取值是未知的,不知道指向哪个内存地址。
      • 新建一个指针变量时,它的取值是未知的,属于野指针。
        int *p;
        printf("%p\n", p);  // 输出是一个未知的地址
        
      • 用户应该避免使用野指针,因为访问未知的内存地址,可能导致程序崩溃。为了避免用户不小心使用野指针,可以在创建指针时,将它初始化为空指针。
        int *p = NULL;
        printf("%p\n", p);  // 输出为:nil
        printf("%d\n", *p); // 报错 Segmentation fault ,因为 p 存储的地址无效,根据 %d 从该内存地址读取一个整数值,会导致程序崩溃
        
    • 通用指针
      • :即 void * 类型的指针,又称为无类型指针,它可以指向任意数据类型的对象。
      • 例:
        int x = 1;
        void *vp = &x;
        int *p = vp;          // 通用指针,可以赋值给明确数据类型的指针
        
        printf("%d\n", *p);
        printf("%d\n", *vp);  // 语法错误。通用指针不能取消引用,因为不知道对象的数据类型
        
    • 常量指针
      • :这种指针所指的变量,会被编译器视作常量(不管它是否用 const 声明),禁止修改。
      • 例:
        int x = 1;
        const int *p = &x;  // 也可写作 int const ,只要写在 * 的左侧
        *p = 2;             // *p 是只读的,禁止修改。编译时会报错:assignment of read-only location
        p  = NULL;          // p 的取值可以修改
        
      • 如果想避免一个变量被直接修改,建议创建 const 变量。如果想避免一个变量被指针修改,建议创建 const 指针。
        const int x = 0;    // 关键字 const 并不会修改变量的存储方式,只是禁止用户对该变量赋值
        x = 1;              // 编译时会报错:assignment of read-only variable
        
        int *p = &x;        // 编译时会警告:initialization discards ‘const’ qualifier from pointer target type
        *p = 1;             // 用户可以通过指针,访问常量的内存地址,强行修改其值
        printf("%d\n", *p);
        
        const int *p = &x;  // 为了禁止用户,通过指针修改一个变量,可以将变量的内存地址,保存在常量指针中
        *p = 1;             // *p 是只读的,禁止修改。编译时会报错:assignment of read-only location
        
      • 如果用户将一个常量指针,赋值给普通指针,则编译时会发出警告,以免用户通过指针,修改所指变量。
        const char *x = "Hello";
        char *y = "Hi";
        x = y;  // 普通指针,可以赋值给常量指针,但反之会发出警告
        y = x;  // 编译时会警告:assignment discards ‘const’ qualifier from pointer target type
        
    • 指针常量
      • :这种指针本身是常量,不能修改其存储的内存地址。
      • 例:
        int x = 1;
        int *const p = &x;  // 注意 const 写在 * 的右侧,修饰变量 p ,而不是修饰 int
        *p = 2;             // *p 的取值可以修改
        p  = NULL;          // 语法错误。p 的取值是只读的,禁止修改
        
      • 例:
        const int *const p = &x;  // 一个指向常量的,指针常量
        
    • 数组指针
      • C 语言中的数组,底层是基于指针进行工作的。
        • 数组名,本身是一个指针,指向数组第一个元素的内存地址。
        • 当用户通过 array[n] 访问数组中第 n 个元素时,相当于访问指针 array+n
        • array+n 是在 array 首地址上,向后偏移 n 倍的 sizeof(*array)
        • array+n 是数组第 n 个元素的内存地址。
        • *(array+n) 是数组第 n 个元素的值,等价于 array[n]
        • 数组中第一个元素,是 array[0] ,这里 0 表示偏移量。而 C 语言初学者大多会误以为,数组中第一个元素,是 array[1]
      • 例:以指针的方式,访问一维数组
        int array[10] = {0,1,2,3};
        printf("%p\n", array);      // 输出为:0x7fff7185d120 。因为 array 等价于 &array[0]
        printf("%d\n", *array);     // 输出为:0 。因为 *array 等价于 array[0]
        printf("%d\n", *(array+1)); // 输出为:1 。因为 *(array+1) 等价于 array[1]
        printf("%d\n", *(array++)); // 语法错误。因为 array 存储的地址是只读的,不允许修改
        
      • 例:以指针的方式,访问二维数组
        int array[5][5];
        
        array == array[0]
        array[0] == &array[0]   // array[0] 表示数组第一维的起始地址。对它 & 取地址,依然会得到该地址
        
        array[0] == &array[0][0]
        array[1] == &array[1][0]
        
        *array  == array[0]
        **array == array[0][0]
        *(*array + 1) == array[0][1]
        **(array + 1) == array[1][0]
        *(*(array + 1) + 1) == array[1][1]
        
      • 例:以数组的方式,访问指针所指内存
        char *pstr = malloc(10*sizeof(char));
        pstr[0] = 'H';
        pstr[1] = 'i';
        pstr[2] = '\0';
        printf("%s\n", pstr); // 输出为:Hi
        // 可以用 realloc() 多次调整内存容量,然后以数组的方式访问该内存空间,从而实现一个动态容量的数组
        
    • 指针数组
      • :这是创建一个数组,每个元素都是一个指针。
      • 例:
        int *array[5], x=1;
        array[0] = &x;
        printf("%d\n", *array[0]);
        
    • 函数指针
      • 函数名本身也是一个指针,指向函数代码的内存地址。因此可以创建一个指针,存储函数的地址,然后通过该指针调用该函数。
      • 例:
        #include <stdio.h>
        
        int sum(int a, int b){    // 定义一个函数
            return a + b;
        }
        
        int main(){
            printf("%p\n", sum);  // 输出为:0x400420
            int (*psum)(int, int) = sum;  // 定义一个函数指针
                // 开头的 int 表示函数返回值的数据类型
                // (*psum) 表示这是一个函数指针。如果不加括号,就变成了指针函数
                // (int, int) 表示函数各个形参的数据类型
            printf("%d\n", psum(1, 2));   // 输出为:3 。此时 psum 可以像函数名 sum 一样调用
            return 0;
        }
        
      • 例:
        int (*array[5])(int, int);   // 定义一个数组,每个元素是一个函数指针
        array[0] = sum;
        printf("%d\n", array[0](1, 2));
        
    • 指针函数
      • :这种函数的返回值,是一个指针。
      • 例:
        #include <stdio.h>
        
        int c = 0;
        
        int *psum(int a, int b){
            c = a + b;
            return &c;  // 这里变量 c 不能是局部变量,否则函数运行结束之后会自动销毁局部变量,导致在函数外读取 &c 内存地址时出错
        }
        
        int main(){
            printf("%p\n", psum(1, 2));   // 输出为:0x601038
            printf("%d\n", *psum(1, 2));  // 输出为:3
            return 0;
        }
        

# 字符串

  • C 语言中,可以创建 char 类型的变量、常量,包含一个字符。

    printf("%c\n", 'A');      // 'A' 是一个字符常量。有且仅有一个字符,用单引号包住
    char x = 'A';             // 将 'A' 这个字符常量,存储到变量 x 中
    
  • C 语言中,可以创建字符串常量,包含任意个字符串。

    printf("%s\n", "Hello");  // 字符串常量。可以包含零个、一个或多个字符,用双引号包住
    
    • 如果两个字符串常量,被任意个空字符分隔,则会自动拼接为一个字符串常量。
      printf("%s\n", "Hello"  "World"); // 输出为:HelloWorld
      printf("%s\n", "Hello"            // 输出为:HelloWorld
                      "World");
      
  • C 语言中,没有字符串型变量,如何用变量存储一个字符串?可采取以下几种方案:

    • 用数组存储字符串:
      char str[] = "Hello";        // 创建一个 char 数组,用于存储字符串
      printf("%d\n", sizeof(str)); // 输出为:6
      printf("%s\n", str);         // 输出为:Hello
      
      • "Hello" 属于字符串常量。字符串常量可以有零个、一个或多个字符,用双引号包住。
      • 缺点:需要事先知道字符串的长度,据此创建足够长度的数组。
      • 缺点:字符串用完之后,不能立即销毁数组,该数组会继续占用内存空间。
    • 存储字符串常量时,编译器会自动在末尾添加一个空字符,用于表示字符串的结束。
      char str[] = "";
      printf("%d\n", sizeof(str)); // 输出为:1
      
      • 空字符,是 ASCII 码表中定义的第一个字符,码值为 0 ,符号为 NUL ,用转义字符表示为 \0
      • 因此,一个字符串,占用的内存字节数 = 字符总数 + 1 。
    • char * 类型的指针,可以存储一个 char 型变量的内存地址:
      char c = 'A';
      char *p = &c;
      printf("%c\n", *p);   // 输出为:A
      
    • char * 类型的指针,也可以存储一个字符数组的首地址,或者一个字符串的首地址:
      char str[] = "Hello"; // 这会将字符串常量中的每个字符,保存为字符数组中的一个元素
      printf("%s\n", str);  // 输出为:Hello
      
      char *pstr = "Hello"; // 将字符串常量(的首地址),赋值给 char * 型指针
      printf("%s\n", pstr); // 输出为:Hello
      
      printf("%s\n", *pstr);        // *pstr 等于第一个字符的值,此时不属于字符串,按照 %s 格式输出会导致程序出错
      printf("%c\n", *pstr);        // 输出为:H 。等价于 pstr[0]
      printf("%c\n", *(pstr + 1));  // 输出为:e 。等价于 pstr[1]
      printf("%s\n", pstr + 1);     // 输出为:ello 。这是从第二个字符开始的字符串
      
    • char ** 类型的指针,可以存储多个字符串:
      char array1[][10] = {"Hello", "World"}; // 创建二维字符数组,第一维的每个元素可以存储一个字符串
      char *array2[] = {"Hello", "World"};    // 创建 char * 型指针数组,每个元素可以存储一个字符串的首地址
      array2[0] = array1[0];
      printf("%s\n", array2[0]);  // 输出为:Hello
      
      char **ppstr = array2;      // 创建 char ** 型指针。它属于二级指针,可以被 * 取消引用两次
      printf("%s\n", *ppstr);     // 输出为:Hello 。相当于 ppstr[0]
      printf("%c\n", **ppstr);    // 输出为:H 。相当于 ppstr[0][0]
      
      char **ppstr = malloc(sizeof(char *));  // 动态分配一块内存空间,用于存储 char ** 型指针
      *ppstr = malloc(sizeof(char *) * 5);    // 动态分配 5 块内存空间,用于存储 5 个 char * 型指针
      *ppstr = "Hello";				  // 使用第一个 char * 指针,存储一个字符串常量的首地址
      *(ppstr + 1) = "World";		// 使用第二个 char * 指针
      int i;
      for (i = 0; i < 5; i++){
          printf("%s\n", *(ppstr + i));
      }
      
    • C++ 原生提供了 String 数据类型,用于存储字符串:
      #include <string>
      string str = "Hello";
      
      C 语言可以用指针,模仿 String 数据类型:
      typedef char * String;
      String str = "Hello";
      printf("%s\n", str);
      
  • 如何拷贝字符串?

    • 不能将一个字符串,赋值给一个已经初始化字符数组:
      char str[] = "Hello";
      str = "Hello";    // 语法错误。数组在创建之后,就不能同时给多个元素赋值
      
      char str2[10];
      str2 = str;       // 语法错误。str2 是一个数组名,不能给它赋值
      
    • 可以将一个字符串(的首地址),赋值给一个指针:
      char *pstr = "Hello";
      printf("%s\n", pstr); // 输出为:Hello
      
      char str[] = "World";
      pstr = str;
      printf("%s\n", pstr); // 输出为:World
      
    • 可以调用 <string.h> 的 strcpy() 等函数。

# 结构体

  • 结构体,这种数据类型与数组有些相似。对比两者:

    • 数组可以存储多个值,称为多个元素。结构体也可以存储多个值,称为多个成员。
    • 数组存储的各个值,必须是同一数据类型。而结构体存储的各个值,可以是不同数据类型。
    • 数组存储的各个值,只有从 0 开始的编号。而结构体存储的各个值,像变量一样定义,拥有一个名称标识符。
  • 结构体要用关键字 struct 定义。

    • 例:
      struct Horse{       // 定义一个结构体,名为 Horse
          char name;      // 定义该结构体的一个成员,名为 name ,类型为 char
          int age;
          // int age = 0; // 语法错误。在定义结构体时不允许给成员赋值
      };                  // 定义结构体的末尾,记得加分号,将这多行视作一条定义语句
      
    • 定义一种结构体之后,就像在 C 语言中创建了一种新的数据类型,用户可通过 struct 结构体名 的格式,使用该数据类型创建变量。
      struct Horse h1;            // 声明一个 struct Horse 类型的变量
      struct Horse h2 = {'J', 5}; // 声明结构体变量时,可以按顺序给各个成员赋值
      
    • 可以在定义结构体的同时,使用该数据类型创建变量。
      struct Horse{
          char name;
          int age;
      } h1, h2 = {'J', 5};
      
    • 通过 结构体变量名.成员名 的格式,可以访问结构体中指定一个成员,其中 . 称为成员运算符。
      h1.name = 'J'; // 访问结构体的 name 成员,对其赋值。标识符 h1 记录了该结构体的内存地址,而 .name 是访问结构体中的一个偏移地址,该地址存储了 name 变量
      h1.age = 5;
      
      h1 = h2;       // 同种结构体的两个变量之间,可以相互赋值
      h1 = {'J', 5}; // 语法错误。创建结构体变量之后,禁止同时给多个成员赋值
      
    • 例:创建结构体类型的数组,其中每个元素都是一个结构体
      struct Horse array[5];
      array[0].age = 5;
      array[1] = array[0];
      
    • 例:创建结构体类型的指针
      struct Horse *p = &array[0];
      (*p).age = 5; // 相当于 array[0].age
      p->age = 5;   // (*p).age 可改写为 p->age
      
      • 考虑到运算符的优先级,*p.age 相当于 *(p.age) ,而不是 *(p).age 。为了避免误解,建议改写为 p->age
  • 一个结构体,占用多少内存?

    • 定义一种结构体之后,它的内存容量就固定了,理论上等于各个成员的内存容量之和,实际上编译器可能自动添加几个字节来对齐内存地址。
      struct Horse{
          char name;
          int age;
      };
      printf("%d\n", sizeof(struct Horse)); // 输出为:8
      
    • 为什么要对齐内存地址?
      • 32 位 CPU 读写内存时,会将内存地址每 4 字节分为一行,批量访问。比如地址 0x0000~0x0003 是一行,地址 0x0004~0x0007 是下一行。
      • 如果一个变量存储时,横跨两行内存,则 CPU 需要读取两行内存,耗时增加。
      • 因此,编译器存储一个内存容量为 N>1 的变量时,会分配一个取值为 N 的整数倍的内存地址,从而让该变量只存储在一行内存中,避免耗时增加。
      • 同理,64 位 CPU 读写内存时,会将内存地址每 8 字节分为一行。
    • 本例中,name 变量的容量为 1 字节,age 变量的容量为 4 字节。
      • 如果在 name 变量之后,紧跟着存储 age 变量,则 age 变量的地址不能对齐到 4 的整数倍。
      • 因此,编译器会在 name 变量之后偏移 3 字节,再存储 age 变量。这 3 字节的内存空间,没有用于存储数据,被浪费了。
    • 为了减少因为对齐而浪费的内存空间,建议将容量较大的成员变量,越放在前面定义。
  • 对比结构体与 C++ 的类:

    • 结构体只是把一些变量封装在一起。而类的功能更多,比如封装、继承、多态,更适合面向对象编程。
    • 结构体是一种值类型,存储的是变量的实际值。而类是引用类型,存储的是对象的地址。
    • 结构体不能在定义时,初始化成员。而类可以。

# 共用体

  • 共用体,这种数据类型与结构体相似,但要求各个成员共用一个内存地址。

    • 一个结构体的各个成员,存储在不同内存地址中,只是地址相邻。
    • 一个共用体中的各个成员,存储地址都是共用体的首地址。因此,如果先后给多个成员赋值,则最后存储的那个成员,会覆盖之前存储的成员。
  • 例:

    union Horse {            // 共用体要用关键字 union 定义,定义格式与结构体相同
        char name;
        int age;
    };
    
    union Horse h1;
    h1.name = 'J';
    printf("%c\n", h1.name);  // 输出为:J
    printf("%c\n", h1);       // 输出为:J 。通过共用体变量的名称,也能读取成员的值,因为所有成员共用一块内存空间
    
    h1.age = 5;               // 此时会覆盖 h1.name 的值
    printf("%c\n", h1.name);  // 输出为空
    printf("%d\n", h1.age);   // 输出为:5
    
    printf("%d\n", sizeof(h1)); // 输出为:4 。因为一个共用体变量的内存容量,取决于容量最大的那个成员
    
  • 例:假设用户想存储一段数据,但它的数据类型可能变化。直接的解决方案是,给该数据创建不同数据类型的多个变量:

    int data_i;
    long data_l;
    float data_f;
    

    但这样同时只有一个变量会被使用,其它变量浪费内存空间。不如创建一个共用体,节省内存空间:

    union Data
    {
      int i;
      long l;
      float f;
    } data;
    

# 枚举

  • 枚举,是用关键字 enum ,定义一种数据类型,并声明可能出现的各种取值。

    enum Day{Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday}; // 创建一个名为 Day 的枚举类型,并声明它可能出现的各种取值
    enum Day d1;  // 创建一个 Day 枚举类型的变量 d1
    d1 = Sunday;  // 给枚举变量赋值
    
  • 枚举的每项取值,称为一个枚举元素。

    • 枚举元素,属于符号常量,像宏定义。
      Monday = 0;       // 语法错误。符号常量是只读的,不能赋值
      enum Day{0, 1};   // 语法错误。数字常量不能用作符号常量
      enum Day{"Monday", "Tuesday"};  // 语法错误。字符串常量不能用作符号常量
      
    • 枚举元素,在底层默认以整数的形式存储,从 0 开始编号。
      printf("%d\n", Sunday); // 输出为:6
      printf("%d\n", d1);     // 输出为:6
      printf("%d\n", d1 == Sunday); // 输出为:1
      d1 = 6;       // 可以给枚举变量赋值一个整数,这里相当于赋值为 Sunday
      d1 = 9;       // 如果赋值一个超出枚举范围的整数,编译器并不会报错
      
    • 一个枚举变量,虽然声明了可能出现的各种取值,但用户的赋值超出枚举范围时,编译器并不会报错。因此用户需要自己检查,赋值是否合理。
      enum Tips{Hello, World};
      d1 = Hello;   // Hello 属于 enum Tips 的枚举值,不属于 enum Day 的枚举值。但编译器依然允许给 d1 赋值 Hello
      d1 = hello;   // 此时编译器会报错 hello 是一个未被声明的标识符
      
    • 在定义枚举类型时,用户可以指定各个元素的底层存储值(可以为 int、char 类型)。
      enum Day{Monday='M', Tuesday='T', Wednesday, Thursday, Friday, Saturday, Sunday='S'};
      
  • 例:通过枚举,定义 bool 数据类型

    enum Bool{false=0, true=1};
    enum Bool b1 = true;
    

# 运算符

  • 很多语句包含一个表达式。表达式用于对变量、常量进行某种运算,得到一个值。

  • 一个表达式,由运算符(又称为操作符)和操作数组成。

    • 运算符,表示运算的类型,分为多种。
    • 操作数,是运算符的操作对象。
      • 例如 x = 1 + 2 中, =+ 是运算符,x12 是操作数。
  • 赋值运算符:

    a = 表达式  // 将表达式的值,存储到变量 a 中
    
    • 赋值运算符两侧的内容,分别称为左值、右值。
      • 左值只能填一个变量名。
      • 右值可以填一个表达式。
    • 可以在 = 的左侧加一个算术运算符,从而在赋值之前,先执行算术运算。例:
      int a = 10;
      a += 1+2;
      printf("%d\n", a);  // 输出为 13
      
    • a = 表达式 整体也是一个表达式,称为赋值表达式。这个表达式的返回值,是当前的赋值。因此,可以在一个语句中,使用多个赋值运算符,从右到左依次赋值。例:
      int x, y;
      x = y = 1;      // 相当于 x = ( y = 1)
      
    • 不能在创建变量的同时,多重赋值:
      int x = y = 1;  // 这里会报错变量 y 未定义
      
  • 算术运算符:

    a + b   // 加法
    a - b   // 减法
    a * b   // 乘法
    a / b   // 除法
    a % b   // 取模
    
    • 两个数字做除法时,
      • 如果两个数字,都是整型,则返回值会是整型。此时采用商作为返回值,而余数会被丢弃。
      • 如果任一数字,是浮点型,则返回值会是浮点型。此时余数以小数的形式保留。
      • 例:
        printf("%d\n", 1/2);    // 输出为:0
        printf("%f\n", 1/2.0);  // 输出为:0.500000
        printf("%f\n", 2/2.0);  // 输出为:1.000000 ,这里即使能整除,没有余数,返回值也是浮点型
        
    • 取模运算,是对两个整型数字,做除法,然后返回余数。例:
      printf("%d\n", 0%2);    // 输出为:0
      printf("%d\n", 1%2);    // 输出为:1
      
  • 关系运算符:

    a >  b  // 大于
    a <  b  // 小于
    a >= b  // 大于等于
    a <= b  // 小于等于
    a == b  // 等于
    a != b  // 不等于
    
    • 关系运算符,又称为比较运算符,用于比较两个值的大小。
    • 这些比较运算,用汉语描述,有两种结果:真、假。
      • 在 C 语言中,这两种结果分别用整型 1、0 表示。如下:
        printf("%d\n", 1>2);  // 1>2 这个表达式,输出为:0
        
      • 在 C++ 语言中,这两种结果分别用布尔值 true、false 表示。
  • 逻辑运算符:

    condition1 && condition2  // 逻辑与。如果两个条件表达式都为 true ,则逻辑运算的结果为 true ,否则结果为 false
    condition1 || condition2  // 逻辑或。如果两个条件表达式,至少一个为 true ,则逻辑运算的结果为 true 。如果两个都为 false ,则结果为 false
    ! condition1              // 逻辑非。如果表达式取值为 true ,则f反转为 false 。如果表达式取值为 false ,则反转为 true
    
    • 逻辑运算符,用于组合两个条件表达式。例:
    printf("%d\n", (1>2) && (3>2)); // 输出为:0
    printf("%d\n", (1>2) || (3>2)); // 输出为:1
    printf("%d\n", !(1>2));         // 输出为:1
    
  • 位运算符:

    a & b    // 按位与。检查两个操作数的二进制位(bit),如果第 n 位都为 1 ,则结果的第 n 位为 1 ,否则结果的第 n 位为 0
    
    a | b    // 按位或。如果 a 或 b 的第 n 位为 1 ,则结果的第 n 位为 1 。如果两者都为 0 ,则结果的第 n 位为 0
    a ^ b    // 按位异或。如果 a 与 b 的第 n 位不相同,则结果的第 n 位为 1 ,否则结果的第 n 位为 0
    a << 3   // 按位左移。将操作数的二进制值左移几位,然后在右边空位补上 0
    a >> 3   // 按位右移。
    ~a       // 按位取反。将操作数每一位的值,取反
    
    • 位运算用于修改操作数的二进制值,也就是修改某些位(bit)。要求操作数必须是整型。
  • 上述运算符,书写格式大多为 操作数1 运算符 操作数2 ,存在两个操作数,因此属于二元运算符。

  • 以下是自增、自减运算符,属于一元运算符:

    ++  // 自增
    --  // 自减
    
    • 使用 ++ ,可以在读取一个变量的取值的同时,将该变量的取值加 1 。
    • 如果 ++ 放在变量的左侧,则称为前缀自增。会先将该变量的值加 1 ,然后读取该变量的值。例如 b=++a 相当于 a=a+1; b=a;
    • 如果 ++ 放在变量的右侧,则称为后缀自增。会先读取该变量的值,将该变量的值加 1 。例如 b=a++ 相当于 b=a; a=a+1;
    • 例:
      int a=0;
      printf("%d\n", ++a);  // 输出为:1
      printf("%d\n", a);    // 输出为:1
      a=0;
      printf("%d\n", a++);  // 输出为:0
      printf("%d\n", a);    // 输出为:1
      
    • -- 可以在读取一个变量的取值的同时,将该变量的取值减 1 ,用法与 ++ 同理。
  • 以下是条件运算符,它属于三元运算符:

    表达式1 ? 表达式2 : 表达式3
    
    • 语法为:若表达式 1 结果为 true ,则执行表达式 2 并返回运算结果。若表达式 1 结果为 false ,则执行表达式 3 并返回运算结果。
    • 例:
      int a;
      a = 'A'==65 ? 1 : 0;
      
  • 如果一个表达式中,包含多个运算符,则需要考虑各个运算符的优先级、结合性。

    • 如果几个运算符的优先级不同,则找到优先级最高的那个运算符,优先执行它。
    • 如果几个运算符的优先级相同,则按照从左到右的顺序,逐个执行这些运算符,称为左结合性。但某些运算符是右结合性。
      • 例如 printf("%d\n", 1<3<2); 的输出为 1 ,因为先计算最左侧的 < 运算符,发现 1<3 的运算结果为 1 。然后计算 1<2 ,结果为 1。
    • 各种运算符,按优先级从大到小排列如下:
      • () 括号、[] 下标
        • 例如 if(x-- > 2) 等价于 if((x--) > 2)
        • 例如 if(!x || y--) 等价于 if((!x) || (y--))
      • ! 逻辑非、++ 自增、-- 自减、+ 正号、- 负号、& 取地址、* 取消引用
        • 这些运算符都是右结合性。
        • 自增、自减运算符只能作用于变量。例如 ++x++ 按右结合性来看,等价于 ++(x++) ,左边的 ++ 作用于表达式 (x++) ,因此语法错误。
      • * 乘法、/ 除法、% 取模
      • + 加法、- 减法
      • << 按位左移、>> 按位右移
      • 关系运算符
      • && 逻辑与、|| 逻辑或
      • 条件运算符
      • = 赋值
      • , 逗号
        • 逗号的优先级最低。
        • 由逗号连接的多个表达式,称为逗号表达式。C 语言会按左结合性,依次计算各个表达式,然后返回最后一个表达式的值,作为整个逗号表达式的结果。
        • 例:
          int a = 0;
          printf("%d\n", (a++, a++, a));  // 输出为 2
          
    • 上面介绍了多种运算符的优先级、结合性,实际应用中,不建议全部记忆它们。如果一个表达式包含多个运算符,则建议插入多个 ( ) ,显示区分优先级,更容易阅读。
      • 例如将 if(!x || y--) 改写为 if((!x) || (y--))

# 流程控制

  • C 语言提供了以下几种语法,来控制程序的运行流程:
    • 选择结构
      • if
      • match-case
    • 循环结构
      • for
      • while
      • do-while

# if

  • if 语句用于判断条件、选择分支,最简单的格式如下:

    if(condition){
        statement_block
    }
    
    • 如果 condition 条件表达式结果为 true ,则会执行 statement_block 语句块。否则,跳过 statement_block 。
    • 例:
      if(1 > 0)
          printf("yes\n");
      
  • 可选加上 else 语句,表示条件表达式为 false 时,执行什么操作。如下:

    if(condition){
        statement_block
    }
    else{
        statement_block
    }
    
  • 可选加上任意个 else if 语句,从而依次判断多个条件表达式。如下:

    if(condition){
        statement_block
    }
    else if(condition){
        statement_block
    }
    else if(condition){
        statement_block
    }
    
  • condition 怎么写?

    • 通常是一个表达式,使用比较运算符,或者逻辑运算符。使得表达式的运算结果为整型 1 或 0 ,表示条件为真或假。
    • 也可以是其它类型的表达式,使得运算结果为其它数字、字符、字符串,此时都视作条件为真。如下:
      if(1 + 2) // 结果为 3
      if(' ')   // 结果为一个空格字符
      if("")    // 结果为一个空字符串
      

# switch

  • 如果一个表达式,存在多个可能的取值,需要分情况处理。则可以用多层 else if ,但用 switch 语句更简洁。格式如下:

    switch(表达式){
    case value1:    // case 语句的结尾有冒号
        语句块
        break;
    case value2:    // 多个 case 可以共用一个语句块
    case value3:
        语句块
        break;
    default:
        语句块
    }
    

    运行流程如下:

    • 首先执行 switch 表达式,得到一个值。然后从上到下检查各个 case ,如果 switch 值与某个 case 值相等,则执行该 case 的语句块。
      • switch 表达式的运算结果,必须是数字或字符、字符串类型。
      • case 的值,必须是常量,不能是变量。
    • 执行每个 case 语句块时,
      • 如果遇到了 break 语句,则会结束整个 switch 语句。
      • 如果该 case 不存在 break 语句,则会继续检查下一个 case 。
      • 实际应用中,程序员通常会在每个 case 语句块中写一个 break 语句,很少不写 break 。因此,Python 语言简化了语法,执行一个 case 语句块之后,总是立即结束 switch 语句。
    • 如果没有执行任何 case 语句块,则执行 default 语句块。不过,default 语句块可以省略不写。
  • 例:

    int x=0;
    switch(a+1){
    case 0:
        printf("hello\n");
        break;
    case 1:
    case '1':
        printf("world\n");
        break;
    default:
        printf("default\n");
    }
    

# for

  • for 语句用于在某个条件为真时,循环执行某个语句块。格式如下:
    for(表达式1; 表达式2; 表达式3){
        循环体语句块
    }
    
    运行流程如下:
    • 首先执行一次表达式 1 。
    • 然后判断表达式 2 (属于条件表达式)的结果是真是假。
      • 如果表达式 2 为真,则执行作为循环体的语句块。然后执行一次表达式 3 ,再次判断表达式 2 的真假。
      • 如果表达式 2 为假,则退出 for 循环。
    • 执行循环体时,
      • 如果遇到 break 语句,则会退出该层循环结构。
      • 如果遇到 continue 语句,则会结束本次循环,跳到开始下一次循环。
  • 例:打印一连串数字
    int i;
    for(i=0; i<5; i++)
        printf("%d\n", i);
    

# while

  • while 语句相当于简化版的 for 语句。格式如下:
    while(条件表达式){
        循环体;
    }
    
    运行流程如下:
    • 判断条件表达式的结果是真是假。
      • 如果为真,则执行作为循环体的语句块。然后再次判断条件表达式。
      • 如果为假,则退出 while 循环。

# do-while

  • do-while 语句与 while 语句类似,但是先执行一次循环体,然后判断条件表达式。格式如下:
    do{
        循环体;
    }
    while(条件表达式);  // 注意这句结尾有分号
    

# goto

  • goto 用于跳转到指定一个标签,执行从这里开始的代码。
  • 优点:可以随意改变程序的运行流程,灵活性大。
  • 缺点:灵活性太大,导致源代码难以被人类阅读。因此尽量不用 goto 。
  • 例:
    #include <stdio.h>
    
    int main(){
        step1: printf("hello\n");
        step2: printf("world\n");
        goto step1;   // 此时跳转到 step1 处执行代码,而 step1 下面又遇到这个 goto ,导致死循环
        return 0;
    }
    

# 函数

  • 函数,是将一个语句块封装起来,可以被其它语句调用。

  • 如何定义一个函数?

    • 语法格式如下:
      返回值类型  函数名  (参数列表)  {
          语句块
      }
      
      • 第一行称为函数头,它定义了返回值类型、函数名、参数列表。
      • 不能在函数语句块内,嵌套定义另一个函数。
    • 例:
      #include <stdio.h>
      
      void hello(){ // 定义一个 hello() 函数。它的返回值类型为 void ,表示没有返回值。参数列表为空,表示该函数没有参数
          printf("Hello\n");
          printf("World\n");
      }
      
      int main(){
          hello();  // 调用 hello() 函数
          return 0;
      }
      
    • 例:
      #include <stdio.h>
      
      int sum(int x, int y){  // 定义一个 sum() 函数。它的返回值类型为 int ,参数列表为 (int x, int y)
          return x + y;
      }
      
      int main(){
          sum(1, 2);                  // 调用 sum() 函数,并且不接收函数返回值
          printf("%d\n", sum(1, 2));  // 调用 sum() 函数,并且将函数返回值传给 printf 函数
          return 0;
      }
      
      • 定义 sum() 函数时,参数列表为 (int x, int y) ,表示用户调用该函数时,必须输入两个 int 类型的值。
      • 因此执行 sum(1, 2) 可以调用该函数,但不能执行以下语句:
        sum(1)        // 语法错误。需要 2 个值,实际输入了 1 个值
        sum(1, 2, 3)  // 语法错误。需要 2 个值,实际输入了 3 个值
        sum(1, 3.14)  // 3.14 是 float 类型的值,可以被赋值给 int y ,此时发生强制类型转换
        sum(1, 'A')   // 'A' 是 char 类型的值,可以被赋值给 int y ,此时发生强制类型转换
        
  • 如何调用一个函数?

    • 编写 函数名(参数列表) 即可调用一个函数。
    • 调用一个函数之前,必须先声明该函数,否则编译器找不到该函数的存在。
      • 因此,通常将 main() 函数,放在源文件的末尾,放在其它函数之后。
    • C 语言规定,调用函数的语句,只能放在某个函数内部,不能放在函数之外的全局代码块。
      #include <stdio.h>
      
      printf("%s\n", "Hello");  // 语法错误。不能在全局代码块中调用函数
      
      int main(){
          return 0;
      }
      
    • 在一个函数中,可以调用其它任意个函数。前者称为"主调函数",后者称为"被调函数"。
    • 调用一个函数时,编译器会按以下流程工作:
      1. 创建该函数的形参。
      2. 执行该函数的语句块。
      3. 函数执行结束,返回一个值给主调函数。
  • 定义函数时,声明的参数,又称为形参。用户调用函数时,传入的参数,又称为实参。

    • 调用 sum(1, 2) 时,相当于执行以下代码:
      int x = 1;  // 创建形参 x ,赋值为 1
      int y = 2;  // 创建形参 y ,赋值为 2
      
    • 形参由实参赋值,也就是拷贝了一份实参的值。因此,即使被调函数修改形参的值,也不会影响主调函数中实参的值。除非实参是一个指针,被调函数可以修改该指针所指对象的值。
    • 当函数执行结束时,所有形参会被自动销毁,因为它们属于函数内临时创建的局部变量。
  • 函数的返回值。

    • 编译器执行函数的语句块时,会从上到下执行其中的各条语句,
      • 如果遇到 return 语句,则会立即返回一个值,并结束该函数。
      • 如果不存在 return 语句,则执行了该语句块的最后一行语句,就结束该函数。
    • 当函数运行结束之后,应该返回一个值,表示该函数的运行结果,让函数外部的代码知晓。
      • 例如有的函数,负责执行一些数学运算,然后将运算结果 return 到函数外部。
      • 例如有的函数,负责修改一些磁盘文件,然后将修改结果 return 到函数外部。
      • 函数运行结束时,局部变量会被自动销毁。因此函数应该 return 一个值告诉外部,否则外部难以知道该函数的运行结果。
    • 除了 return 语句,函数还可以通过以下方式,将运行结果告诉外部:
      • 修改全局变量
      • 获得外部变量的内存地址,通过指针来修改其值
    • 函数的返回值,可以是任意数据类型,由用户自己决定。
      • 如果返回值类型为 void ,则表示没有返回值,此时不能使用 return 语句,或者只能使用 return;

# 预处理指令

  • 如果某行源代码以 # 开头,则会被编译器视作预处理指令。

    • 预处理指令,可以写在源代码的任意位置,会作用于全局代码块。
    • 预处理指令,会被编译器在编译时执行、保存结果,而不是等到程序启动时才执行。
    • C 语言的可执行语句,会被编译为机器代码,等到程序启动时才执行。而预处理指令,不属于可执行语句,末尾不需要加分号 ;
  • #include 头文件名 是一种预处理指令,用于将一个文件的源代码,导入当前文件。

    • 例:
      #include <stdio.h>      // 如果文件名用尖括号包住,则编译器会到系统默认目录(即环境变量 INCLUDE 指定的目录)及其子目录中,寻找名为 stdio.h 的文件
      #include "myheader.h"   // 如果文件名用双引号包住,则编译器会到源文件所在目录及其子目录中,寻找该文件名。如果没找到,再到系统默认目录下寻找。
      #include "/root/test.h" // 也可以指定文件的绝对路径。但这样麻烦,每次文件路径变化,就需要修改 include 指令
      
  • #define 表达式1 表达式2 是一种预处理指令,用于定义一个宏(macro)。

    • 编译器发现了宏,则会将源代码中所有 表达式1 ,替换成 表达式2 ,然后再开始编译源代码。
    • 例:
      #define  PI  3.14       // 定义一个宏,作为符号常量
      printf("%f\n", PI);     // 这行代码在编译之前,会被替换成 printf("%f\n", 3.14);
      #undef   PI             // 取消一个宏
      
      #define  sum(x,y)  x+y    // 定义一个包含参数的宏
      printf("%d\n", sum(1,2)); // 这行代码在编译之前,会被替换成 printf("%d\n", 1+2);
      
    • 编译器内置了几个宏,可供用户调用:
      printf("%s\n", __FILE__); // 这行代码,所在的源文件名称
      printf("%d\n", __LINE__); // 这行代码,所在的源文件行号
      
    • 宏定义的原理是字符串替换,与函数传参并不同,需要注意运算符的优先级。
      #define  mul(x,y)  x*y
      printf("%d\n", mul(1+1, 2));  // 会被替换成 printf("%d\n", 1+1*2);
      
      #define  mul(x,y)  (x)*(y)
      printf("%d\n", mul(1+1, 2));  // 会被替换成 printf("%d\n", (1+1)*(2));
      
    • 给宏定义中的参数加上井号 # 前缀,就会嵌入该变量的标识符名称,作为一个字符串。
      #define DEBUG(x) printf("This variable is: "#x"=%d \n", x)
      int i = 0;
      DEBUG(i);   // 输出为:This variable is: i=0
      
    • 某些程序员,会使用宏,替换 C 语言的语法结构。这样编写的代码,与 C 语言的语法差异大,难以被其他人阅读。
      #define IF    if(
      #define THEN  ){
      #define ELSE  }else(
      #define FI    ;}
      
  • #if ... #else ... #endif 是一种预处理指令,用于根据条件,判断是否编译某些源代码。

    • 例:
      #if 1 > 2               // 如果条件为真,则编译以下源代码
          printf("Hello\n");
      #else                   // 如果条件为假,则编译以下源代码
          printf("%f\n", 3);
      #endif
      
    • 例:
      #if defined PI     // 检查是否定义了名为 PI 的宏
          #undef PI      // 取消定义 PI
          #define PI 3   // 重新定义 PI
      #endif
      
    • 例:
      #if ...
          ...
      #elif ...   // 多路选择结构
          ...
      #else
          ...
      #endif
      

# 头文件

  • 假设用户需要在一个源文件中,调用另一个源文件中的变量、函数等标识符,则可以这样做:

    1. test1.c 文件中,编写以下代码:
      int x = 1;
      
      int sum(int a, int b){
          return a + b;
      }
      
    2. test2.c 文件中,调用 test1.c 中的标识符:
      #include <stdio.h>
      
      int main(){
          extern x;   // 将 x 声明为外部变量,请编译器到外部代码块,寻找该变量的定义语句
          printf("%d\n", x);
          printf("%d\n", sum(1,2)); // 所有函数,默认都为外部函数,不必再用 extern 声明
          return 0;
      }
      
    3. 执行 gcc test2.c test1.c 编译。
      • 此时需要指定所有源文件,将它们的源代码合并在一起,编译得到一个程序。
      • 如果执行 gcc test2.c 编译,则会报错找不到 x、sum 标识符,它们的定义语句不在 test2.c 文件中。
  • 使用 #include 指令,可以将一个文件的源代码,导入当前文件。因此上述示例,可以改进为以下做法:

    1. 编写 test1.c 文件。
    2. test2.c 文件中,导入 test1.c 的源代码:
      #include <stdio.h>
      #include "test1.c"  // include 指令会将 test1.c 的源代码复制到此处
      
      int main(){
          printf("%d\n", x);  // 访问变量 x ,编译器会自动找到该变量的定义语句,来自 test1.c 源代码
          printf("%d\n", sum(1,2));
          return 0;
      }
      
    3. 执行 gcc test2.c 编译。
      • 此时只需编译 test2.c 文件,因为它已经包含了 test1.c 的内容。
  • #include 指令可以导入任意文件,只要文件内容是 C 语言源代码。

    • 用户编写大量源代码时,为了分类管理,一般将源代码保存为两种文件:
      • 头文件(header file):扩展名为 .h ,用于存放一些适合共享的源代码,准备被其它文件导入。
      • 源文件(source file):扩展名为 .c ,用于存放头文件以外的源代码。
  • 因此上述示例,可以改为导入头文件:

    1. 编写 test1.c 文件。

    2. 编写 test1.h 文件:

      int x;                  // 声明变量,也可以赋值
      
      int sum(int a, int b);  // 声明函数原型,也就是写一遍函数头,用分号 ; 结尾
      
      • 头文件中,可以定义一个完整的函数。也可以只声明函数原型,此时需要在编译时,指定所有源文件,编译器才能找到该函数的定义语句。
      • 函数原型,又称为函数签名(function signiture),它代表了一个函数的外部特征。
      • 声明函数原型时,可以省略形参的名称,只写形参的数据类型。但不推荐这样做,降低了代码的可读性。
    3. test2.c 文件中,导入 test1.h 的源代码:

      #include <stdio.h>
      #include "test1.h"
      
      int main(){
          printf("%d\n", x);
          printf("%d\n", sum(1,2));
          return 0;
      }
      
    4. 执行 gcc test2.c test1.c 编译。

  • 一个头文件可能被某个源文件重复导入。

    • 假设 test1.h 被导入了 test2.h 。如果 test3.h 再导入 test1.h 和 test2.h ,则会重复导入 test1.h 。
    • 重复导入的情况下,可能重复定义同一个标识符,导致编译器报错。
    • 为了避免重复导入,通常将头文件的全部内容,用以下指令包住:
      #ifndef _TEST1_H
          #define _TEST1_H  // 这里创建一个宏,用于表示该头文件是否已被导入。通常将每个字母大写,并加上下划线
          ...               // 这里放置 test1.h 的原本内容
      #endif