初识 C 语言


章节概要:C 语言的特点;计算机工作原理;高级计算机语言;编译器;编程步骤;C 编程机制

特点

  • 设计特性

    C 语言融合了计算机科学理论实践的控制特性
    C 语言的设计理念让用户轻松完成自顶向下的规划结构化编程模块化设计

  • 高效性

    C 语言充分利用计算机的优势,因此程序更紧凑运行速度很快
    C 语言具有通常汇编语言才具有的微调控制能力,可以更具具体情况微调程序以获得最大运行速度最有效地使用内存

  • 可移植性

    C 是可移植的语言,这意味着,在一种系统中编写的 C 程序稍作修改不修改就能在其他系统上运行
    注意程序中针对特殊硬件设备(如显示监视器)或操作系统特殊功能编写的部分通常不可移植
    C 语言与 UNIX 关系密切,UNIX 系统通常会将 C 编译器作为软件包的一部分
    供个人计算机使用的 C 编译器很多,因此在各个版本的操作系统上,都可以找到合适的 C 编译器

  • 强大而灵活

    C 语言功能强大而灵活
    UNIX 操作系统、其他语言(FORTRAN、Perl、Python、Pascal)等的编译器解释器都是用 C 语言编写

  • 面向程序员

    C 语言是为了满足程序员的需求而设计的,程序员可以利用 C 访问硬件操控内存中的位
    大多数 C 实现都有一个大型的库,包含众多有用的 C 函数,可以让程序员更加方便地使用 C 语言

通俗了解计算机工作原理

  • 简而言之,计算机的工作原理是:如果希望计算机做某些事,就必须为其提供特殊的指令列表(程序),确切地告诉计算机要做的事以及如何做。你必须用计算机能直接明白的语言(机器语言)创建程序。这是一项繁杂、乏味、费力的任务

高级计算机语言与编译器

  • 高级编程语言(如 C 语言)以多种方式简化了编程工作。首先,不必用数字码表示指令;其次,使用的指令更贴近你如何想这个问题,而不是类似计算机那样繁琐的步骤
  • 编译器是把高级语言程序翻译成计算机能理解的机器语言指令集的程序,在计算机看来,高级语言指令就是一堆无法理解的无用数据。由此,程序员进行高级思维活动,而编译器则负责处理冗长乏味的细节工作

使用 C 语言的 7 个步骤

  • C 是编译型语言,我们把编写 C 语言的程序分解为七个步骤:

    1、定义程序的目标
    2、设计程序
    3、编写代码
    4、编译
    5、运行程序
    6、测试与调试程序
    7、维护和修改代码

编程机制

  • 用 C 语言编写程序时,编写的内容被储存在文本文件中,该文件被称为源代码文件(source code file)。大部分 C 系统,都要求文件名以.c结尾
  • C 编程的基本策略是,用程序把源代码文件转换可执行文件(其中包含可直接运行的机器语言代码)
    典型的 C 实现通过编译链接两个步骤来完成这一过程。编译器把源代码转换成中间代码连接器把中间代码和其他代码合并,生成可执行文件

C 语言概述


章节概要:C 语言程序简单示例;#include与头文件;主函数main;注释;花括号;声明与变量;赋值;printf函数;return语句;C 语言程序基础结构;多条声明;打印多个值;多个函数;关键字和保留标识符

简单的 C 程序示例及分析

  • 示例程序

    #include <stdio.h>
    
    int main(void)                //一个简单的C程序
    {
        int num;                  //定义一个名为num的变量
        num = 1;                  //为num赋一个值
        printf("I am a simple "); //使用printf函数
        printf("computer.\n");
        printf("my favourite number is %d because it is first.\n", num);
        return 0;
    }
  • 程序分析及知识概要

    • #include指令和头文件

      1、#include <stdio.h>在程序的第一行,该语句作用相当于把stdio.h文件中的所有内容输入该行所在位置,本质上是一种“拷贝-粘贴”的操作。include文件提供了一种方便的途径共享许多程序共有的信息
      2、#include这行代码是一条 C预处理器命令,通常,C 编译器在编译前会对源码做一些准备工作,即预处理
      3、所有的 C 编译器软件都提供stdio.h头文件,该文件包含供编译器使用的IO 函数(I:input 输入,O:output 输出)。该文件名含义为标准输入/输出头文件。通常,C 程序顶部的信息集合被称为头文件

    • main()函数

      1、main()函数是程序的主函数,它是程序的入口点,从这里开始执行
      2、int main()int是函数的返回类型,表明函数返回操作系统的是整数,此处将在后续探讨
      3、如果浏览旧式的 C 代码,会发现程序如main()开始,C90 标准勉强接受这种形式,但 C99 和 C11 标准不允许,因此不要这样写
      4、你还会看到void main()的形式,部分编译器允许这样写,但所有标准都未认可,因此也不要这样写

    • 注释

      1、注释是一种记录程序信息的方式,被注释的部分不会被程序运行
      2、可以使用/*注释内容*/进行注释,此类注释可以换行注释,直到*/为止
      3、也可以使用//进行注释(C99 新加入),此类注释不能换行,直到行尾为止

    • 花括号

      1、程序中花括号{}main()括起来,一般而言,所有的C 函数使用花括号标记函数体的开始和结束
      2、花括号还可用于把函数中多条语句合并为一个单元

    • 声明与变量

      1、int num;这行代码叫做声明,声明是 C 语言中最重要的特性之一
      2、该条例中,声明完成了两件事。其一,函数中有一个名为num变量,其二,int表示num数据类型是一个整数
      3、int是 C 语言中的一个关键字,表示一种基本的 C 语言数据类型。关键字是语言定义的单词,不能用做其他用途,例如不可作为函数名或变量名
      4、num是一个标识符,也就是一个变量、函数或其他实体名称
      5、把变量声明正确的为数据类型(整型、浮点型、字符等),计算机才能正确的存储、读取和解释数据
      6、变量的命名,要尽可能使用有意义的变量名或标识符,如程序需要一个变量属羊,则可起名sheep_count。变量命名时仅可以使用大小写字母、数字和下划线,且第一个字母不能是数字

    • 赋值

      1、num = 1赋值表达式语句赋值是 C 语言的基本操作之一,意为“把值 1 赋给变量num
      2、在执行int num声明时,编译器在计算机内存中为变量num预留了空间,然后执行赋值表达式语句时,把值存储在预留的位置
      3、注意,赋值表达式语句从右侧把值赋给左侧,另外,该语句以分号;结尾

    • printf函数

      1、printf是 C 语言的一个标准函数,圆括号()表明printf是一个函数名圆括号中的内容是从main()函数传递printf函数的信息
      2、printf函数会查看双引号中的内容(字符串),并将其打印到屏幕上
      3、\n的作用是换行\n组合代表一个换行符。换行符是一个转义列表,用于代表难以表示无法输入的字符。如\t代表Tab 键\b代表Backspace 键,每个转义序列都以反斜杠\开始
      4、对比发现,参数中的%d被数字 1代替了,而 1 就是num的值。%d是一个占位符,其作用是指明输入num位置

    • return语句

      1、int main(void)中的int表明函数main返回一个整数,C 标准要求main()这样做。
      2、有返回值的 C 函数要有return语句,该语句以return关键字开始,后面是待返回的值,并以分号;结尾
      3、如果遗漏main函数末尾的return语句,程序在运行至最外面的}时,会自动返回一个默认值即 0。因此此处可以省略,但在其他有返回值的函数中不可省略,所以建议保留此习惯

  • 简单程序的结构

    • 程序由一个或多个函数组成,必须有main()函数。
    • 函数由函数头函数体组成,函数头包括函数名传入该函数的信息类型函数的返回值类型
    • 通过函数名后圆括号可以识别出函数,圆括号里可能为空可能有参数
    • 函数体花括号括起来,由一系列语句声明组成

    • 简言之,一个简单的 C 语言程序格式如下(大部分语句都以分号;结尾):

      #include <stdio.h>
      int main(void)
      {
          //语句
          return 0;
      }

进一步使用 C

  • 示例程序

    //把2英寻转换成英尺
    #include <stdio.h>
    int main(void)
    {
        int feet, fathoms;
        fathoms = 2;
        feet = 6 * fathoms;
        printf("There are %d feet in %d fathoms!\n", feet, fathoms);
        printf("Yes,I said %d feet!\n", 6 * fathoms);
        return 0;
    }
  • 程序分析及知识概要

    • 多条声明

      1、int feet, fathoms;语句,使用多条声明声明了两个变量,使用逗号,隔开,此语句与int feet;+int fathoms;等价

    • 打印多个值

      1、程序的第一个printf()进行了两次替换按顺序feetfathoms替换了两个%d
      2、第二个printf()说明待打印的值不一定是变量,只要可求值得出合适类型值的项即可

多个函数

  • 自己的函数添加到程序中,此处只做简单了解,后续学习:

    #include <stdio.h>
    void def(void)
    {
        printf("hello world!");
    }
    
    int main(void)
    {
        def();
        return 0;
    }

关键字和保留标识符

  • 下表中粗体 表示C90标准新增关键字,斜体 表示C99标准新增关键字,粗斜体 表示C11标准新增关键字

    关键字 关键字 关键字 关键字
    auto extern short while
    break float signed _Alignas
    case for sizeof _Alignof
    char goto static _Atomic
    const if struct _Bool
    continue inline switch _Complex
    default int typedef _Generic
    do long union _Imaginary
    double register unsigned _Noreturn
    else restrict void _Static_assert
    enum return volatile _Thread_local

数据和 C


章节概要:交互式程序;变量与常量数据;数据;位、字节、字;存储单元换算;数据类型关键字;C 语言基本数据类型;进制打印显示;可移植类型;使用程序获得数据类型大小

交互式程序

  • 示例程序

    #include <stdio.h>
    int main(void)
    {
        float weight;
        float value;
        printf("Please enter your weight in pounds:");
        scanf("%f", &weight);
        value = 1700.0 * weight * 14.5833;
        printf("your weight in platinum is worth $%.2f.\n", value);
        return 0;
    }
  • 新元素简单分析

    1、新的变量声明,使用float浮点数类型,浮点类型可以储存带小数的数字,详细说明见本章后面部分
    2、为了打印新类型的变量(浮点数),printf处使用%f处理浮点值
    3、%.2f用于精确控制输出,指定保留小数后两位
    4、scanf()函数用于读取键盘的输入%f说明scanf()读取输入浮点数&weight告诉scanf()把输入的值赋给名为weight变量
    5、scanf()函数使用&符号表明找到weight变量的地点,下章将详细讨论,目前请按照这样写

变量与常量数据

  • 变量:有些数据类型在程序运行期间可能会改变被赋值,这些称为变量
  • 常量:有些数据类型在程序使用之前已经预先设定好了,整个程序运行过程中没有变化,称为常量

数据

  • 位、字节、字

    位、字节、字是描述计算机数据单元存储单元的术语,这里主要指储存单元

    (bit):最小的储存单元。可以储存0 或 1,是计算机内存的基本构成块
    字节(byte):常用的计算机存储单位,字节集合,一个字节可以储存8 位。这是字节的标准定义,至少在衡量存储单位时是这样
    (word):是设计计算机时给定的自然存储单位,对于 8 位的微型计算机,一个字长只有 8 位。从那以后,个人计算机字节增至 16 位、32 位,直至目前的 64 位。计算机的字长越大数据转移越快,允许的内存访问也更多

  • 存储单元换算

    1 TB=1024 GB
    1 GB=1024 MB
    1 MB=1024 KB
    1 KB=1024 Bytes(字节)
    1 Byte(字节)=8 bits(位)
    1 Word(字)=2 Bytes(字节)

  • 整数

    • 和数学概念一样,整数是没有小数部分的数,例如 2、-23、2456 都是整数
    • 计算机以二进制数字存储整数,如整数 7 二进制写为 111,因此要在8 位字节中存储该数字,需要前 5 位设置为0后 3 位设置为1

  • 浮点数

    • 带有小数点的数就是浮点数,例如 2.75、3.16E7、7.00、2e-8 都是浮点数
    • 注意,在一个值后面加上小数点,该值就成为一个浮点数,所以7 是整数7.00 是浮点数
    • 此处简要介绍 e/E 计数法(科学计数法):3.16E73.16e7 表示 3.16 * 107
    • 这里关键要理解浮点数整数储存方案不同,计算机把浮点数分成小数部分指数部分表示,而且分开存储这两部分7.0写成0.7E1,这里,0.7 是小数部分1 是指数部分。计算机在内部使用二进制2 的幂进行储存,后续探讨(此处图例以十进制下理解为例)

数据类型关键字

最初 K&R 给出的关键字 C90 标准添加的关键字 C99 标准添加的关键字
int signed _Bool
long void _Complex
short _Imaginary
unsigned
char
float
double

C 语言基本数据类型

  • int类型

    1、C 语言中的整数类型可表示不同的取值范围正负值,一般情况下使用int能应付绝大多数情况
    2、int类型是有符号整型,即int的值必须是整数,可以是正整数、负整数、0
    3、int类型的取值范围计算机系统而异,一般而言,储存一个int占用一个机器字长
    4、早期16 位的取值范围为-215 ~ 215-1,即-32768 ~ 32767ISO C 规定int最小范围-32768 ~ 32767
    5、一般来说,系统会用一个特殊位的值(未使用的第 16 位)表示有符号整数正负号
    6、使用%d打印整数类型,%d称为转换说明,他指定应该用什么格式显示一个值
    7、显示不同进制:使用%d显示十进制,%o显示八进制,%x显示十六进制。显示前缀使用%#o%#x%#X

    #include <stdio.h>
    int main(void)
    {
        int x = 100;
        printf("dec=%d ; octal=%o ; hex=%x  \n", x, x, x);
        printf("dec=%#d ; octal=%#o ; hex=%#X", x, x, x);
        return 0;
    }
  • 其他整数类型

    1、short类型:占用空间可能比int少,有符号类型
    2、long类型:占用空间可能比int多,有符号类型
    3、long long类型(C99 加入):占用空间可能比long多,至少 64 位,有符号类型
    4、unsigned类型:非负整型16 位取值范围 0 ~ 216-1,即0 ~ 65535
    5、C90 后,新增unsigned shortunsigned longC99 后,新增unsigned long long
    6、在任何有符号类型前添加关键字signed,可强调使用有符号类型的意图
    7、空间“可能”多与少是因为 C只规定short不能多于intlong不能少于int
    8、现在个人计算机常见设置是,long long64 位long32 位int16 位32 位short16 位
    9、打印时,使用%u打印unsigned类型,使用%ld打印long类型,使用%lld打印long long类型,使用%hd打印short类型

  • 浮点数类型

    1、浮点类型能表示包括小数在内更大范围的数,浮点数的表示类似科学计数法。在计算机中,科学计数法中的 10 的指数,跟写在字母e后面,如 1.02 * 103记作1.02e3
    2、单精度浮点数float,C 语言规定其至少能表示6 位有效数字,且取值范围至少是 10-37 ~ 1037。通常,系统储存一个浮点数要占用 32 位其中 8 位用于表示指数的值和符号剩下 24 位用于表示非指数部分及其符号
    3、双精度浮点数double,其与float类型的最小取值范围相同,但必须至少能表示10 位有效数字,一般来说,double占用 64 位而非 32 位。一些系统将多出来的 32 位全用来表示非指数部分,不仅增加有效数字位数(即精度),还减少了舍入误差;另一些系统把其中一些位分配给指数部分,以容纳更大的指数,增加可表示数的范围。无论哪种类型,double类型的值至少有 13 位有效数字
    4、long double,可以满足比double更高的精度要求,不过,C 只保证long double类型至少与double类型的精度相同
    5、浮点数后面加上fF后缀覆盖默认设置,编译器会将浮点型常量看做float类型,如2.3f9.11E9F;使用lL后缀使数字成为long double类型;没有后缀的浮点型常量是double类型
    6、打印浮点值时,使用%f打印float类型,用%e打印指数计数法的浮点值,如果系统支持十六进制的浮点数,使用%a打印十六进制的浮点值,打印doublelong double要使用%Lf%Le%La的转换说明

  • char字符类型

    1、char类型用来储存字符,如字母标点符号
    2、从技术层面来看,char整数类型,因为char类型实际储存的是整数而不是字符。计算机使用数字编码处理字符,即用特定整数代表特定字符
    3、C 常用编码为ASCII编码,其中如整数 65代表大写字母 A整数 97代表小写字母 a整数 48代表数字 0
    4、标准ASCII 码范围为0~127,只需 7 位二进制数即可表示。通常,char被定义为8 位的存储单元
    5、C 语言把1 字节定义为char类型占用的(bit)
    6、char赋值时,需要传入char 字符类型的数据,即单引号''包裹的字符,如char set = 'A'。此外也可使用ASCII 码进行赋值,如char set = 65
    7、有一些代表行为非打印字符,如换行、退格、回车、蜂鸣等,这些字符打印不出来。如需要表示这些字符,可以使用ASCII 码,比如蜂鸣:char beep = 7。此外也可以使用转义字符,如char beep = '\a'
    8、使用%c打印char类型字符,如果使用%d打印,则会打印字符对应 ACSII 码的整数

    转义序列 含义
    \a 警报(ANSI C)
    \b 退格
    \f 换页
    \n 换行
    \r 回车
    \t 水平制表符
    \v 垂直制表符
    \\ 反斜杠()
    \‘ 单引号(‘)
    \0oo 八进制值(oo必须是有效的八进制数,即每个o可表示0~7中的一个数)
    \xhh 十六进制值(hh必须是有效的十六进制数,即每个h可表示0~f中的一个数)
  • _Bool布尔类型

    1、C99标准新增了_Bool类型,用于表示布尔值,即逻辑值truefalse
    2、因为 C 语言用值 1表示true值 0表示false,所以_Bool类型实质上也是一种整数类型
    3、原则上它仅占用1 位存储空间,因为对与0 和 1而言,一位的存储空间足够了

  • 可移植类型:stdint.hinttypes.h

    1、C99新增两个头文件stdint.hinttypes.h,以确保 C 语言各类型各系统功能正常
    2、C 语言为现有类型创建了更多类型名,这些新类型名被定义在stdint.h
    3、如在精确宽度整数类型中int32_t表示 32 位的有符号整数类型。在使用32 位系统时,头文件会把int32_t当做int别名;而在int16 位long32 位的系统中,系统会把int32_t当做long的别名。然后,使用int32_t类型编写程序并包含stdint.h头文件时,编译器会把intlong替换成与当前系统匹配的类型
    4、如果系统不支持精确宽度整数类型,可以使用最小宽度类型,例如int_least8_t可容纳 8 位有符号整数值的类型中宽度最小的类型的一个别名
    5、如果更关心速度而非空间,则可使用最快最小宽度类型,如int_fast8_t被定义为系统中8 位有符号值而言运算最快的整数类型
    6、如果需要最大整数类型,最大的有符号整数类型intmax_t可储存任何有效的有符号整数值。类似的,uintmax_t表示最大的无符号整数类型,这种类型可能比long longunsigned long long更大
    7、C 标准针对这种输入和输出,提供了一些字符串宏来显示可移植类型,例如inttypes.h中定义了PRId32字符串宏,代表打印32 位有符号值合适转换说明(如 d 或 l)

    #include <stdio.h>
    #include <inttypes.h>
    int main(void)
    {
        int32_t me32;
        me32 = 45933945;
        printf("me32 = %" PRId32 "\n", me32);
        return 0;
    }
  • 复数和虚数

    1、许多科学工程计算都要用到复数和虚数C99支持复数和虚数,但是有所保留
    2、复数类型:有float_Complexdouble_Complexlong double_Complex。例如float_Complex变量应包含两个float类型的值,分别表示复数的实部和虚部
    3、虚数类型:有float_Imaginarydouble_Imaginarylong double_Imaginary
    4、如果包含complex.h头文件,便可用complex代替_Complex,用imaginary代替_Imaginary,还可以用 1 代替-1 的平方根

  • 其他类型

    1、C 语言中没有字符串类型,却也能很好的处理字符串,详见后续
    2、C 语言还有一些基本类型衍生的其他类型,如数组、指针、结构、联合,详见后续
    3、本章程序案例简单使用到了指针,如scanf()函数用到的&前缀,便创建了一个指针,告诉scanf()把数据放在何处

获取类型大小

  • 可以使用sizeof()获取以字节为单位的类型大小C99C11提供%zd匹配sizeof()返回值,其余不支持的编译器可用%u%lu代替

    #include <stdio.h>
    int main(void)
    {
        printf("Type int has a size of %zd bytes.\n", sizeof(int));
        printf("Type char has a size of %zd bytes.\n", sizeof(char));
        printf("Type float has a size of %zd bytes.\n", sizeof(float));
        return 0;
    }

字符串和格式化输入输出


章节概要:字符串简介;char 类型数组与 null 字符;strlen()函数;常量与 C 预处理器;明示常量;printf()函数;参数传递;scanf()函数;scanf多个输入与返回值

引入示例

  • 示例程序

    #include <stdio.h>
    #include <string.h>
    #define DENSITY 62.4 // 定义人体密度
    int main(void)
    {
        float weight, volume;
        int size, letters;
        char name[40];
        printf("Hi! What's your first name?\n");
        scanf("%s", name);
        printf("%s,whats's your weight in pounds?\n", name);
        scanf("%f", &weight);
        size = sizeof(name);
        letters = strlen(name);
        volume = weight / DENSITY;
        printf("well, %s, your volume is %2.2f cubic feet\n", name, volume);
        printf("Also, your first name has %d letters,\n", letters);
        printf("and we have %d bytes to store it.\n", size);
        return 0;
    }
  • 新元素简单分析

    • 数组储存字符串。在该程序中,用户输入的名被储存在数组中,该数组占用内存中40 个连续的字节,每个字节储存一个字符值
    • 使用%s转换说明处理字符串的输入和输出。注意,在scanf中,name没有&前缀,而weight有(稍后解释,&weightname都是地址)
    • 用 C预处理器字符常量DENSITY定义为 62.4
    • 用 C 函数strlen()获取字符串的长度

字符串简介

1、字符串一个或多个字符序列,如"I came from America"
2、双引号"不是字符串的一部分,仅是告知编译器括起来的是字符串,就如单引号'用于标识单个字符一样

char 类型数组与 null 字符

1、C 语言没有专门用于存储字符串的变量类型,字符都被储存在char类型数组中。数组连续的存储单元组成,字符串的字符被储存在相邻的存储单元中,每个单元储存一个字符
2、数组末尾位置有一个空字符(\0),C 语言用空字符(null)标记字符串结束。这意味着数组容量必须比存储字符数多 1
3、数组同类型数据元素有序序列方括号[]表示这是一个数组
4、使用%s来转换打印一个字符串
5、字符串字符char不是同一种类型,因为字符串最后有空字符标识,而char只存储该字符
6、使用strlen()函数可以得到字符串的字符长度,且strlen()不会计入空字符

常量与 C 预处理器

  • 为什么要使用常量

    1、使用常量名比数字表达的信息更多,如area = PI * darea = 3.14 * d相比更加直观
    2、对于程序中多次使用同一个常量时,如果常量的值需要修改只需要修改常量值即可

  • 如何创建符号常量

    • 可以定义变量,将其值定义为所需的值,但这样程序可能会无意间改变它的值
    • 使用C 预处理器定义,格式为#define 常量名 值编译程序时,所有的常量名都会被替换为它们的值
      • 定义常量时,习惯上建议全用大写,以此告知他人这是一个常量,提高程序可读性;此外也有小众习惯使用c_变量名k_变量名表示常量
      • 注意define常量名后的内容用于替换符号常量,不要将#define NUM 20写成#define NUM = 20,这样定义的WORD值为=20而非20
    • C90标准新增限定词const,表示只读,也可用此作为常量使用(其只表明只读变量),如const float PI=3.14
  • 明示常量

    • C 头文件limits.hfloat.h分别提供与整数与浮点数类型大小限制相关的说明,如limits.h中有类似以下代码:

      #define INT_MAX +32767
      #define INT_MIN -32768
    • 这些明示常量代表int类型可表示的最大值和最小值该头文件会为这些明示常量提供不同的值,如果系统使用32 位的 int且程序包含limits.h头文件,则可以使用printf()%d转换输出该常量 32 位 int 的值

    • 如果系统使用4 字节的 int该头文件将提供符合 4 字节的对应值

    • 下为limits.h中的一些明示常量:

      明示常量 含义
      CHAR_BIT char 类型的位数
      CHAR_MAX char 类型的最大值
      CHAR_MIN char 类型的最小值
      SCHAR_MAX signed char 类型最大值
      SCHAR_MIN signed char 类型最小值
      UCHAR_MAX unsigned char 类型的最大值
      SHRT_MAX short 类型的最大值
      USHRT_MAX unsigned short 类型的最大值
      INT_MAX int 类型的最大值
      UINT_MAX unsigned int 类型的最大值
      LONG_MAX long 类型的最大值
      ULONG_MAX unsigned long 类型的最大值
      LLONG_MAX long long 类型的最大值
      ULLONG_MAX unsigned long long 类型的最大值
    • 相似的,float.h头文件下也有一些明示常量:

      明示常量 含义
      FLT_MANT_DIG float 类型的尾数位数
      FLT_DIG float 类型的最小有效数字位数(十进制)
      FLT_MIN_10_EXP 带全部有效数字的 float 类型的最小负指数(以 10 为底)
      FLT_MAX_10_EXP float 类型的最大正指数(以 10 为底)
      FLT_MIN 保留全部精度的 float 类型最小正数
      FLT_MAX float 类型的最大正数
      FLT_EPSILON 1.00 和比 1.00 大的最小 float 类型值之间的差值

printf()函数

  • 请求printf()打印数据的指令要与待打印数据类型相匹配。例如,打印整数使用%d打印字符使用%c。这些符号称为转换说明,它们指定如何把数据转换成可显示的形式

  • ANSI C标准为printf()提供的转换说明

    转换说明 输出
    %a 浮点数、十六进制数和 p 计数法
    %A 浮点数、十六进制数和 p 计数法
    %c 单个字符
    %d 有符号十进制整数
    %e 浮点数,e 计数法
    %E 浮点数,e 计数法
    %f 浮点数,十进制计数法
    %g 根据值的不同,自动选择%f 或%e。%e 格式用于指数小于-4 或者大于等于精度时
    %G 根据值的不同,自动选择%f 或%E。%E 格式用于指数小于-4 或者大于等于精度时
    %i 有符号十进制整数(与%d 相同)
    %o 无符号八进制整数
    %p 指针
    %s 字符串
    %u 无符号十进制整数
    %x 无符号十六进制整数,使用十六进制数 0f
    %X 无符号十六进制整数,使用十六进制数 0F
    %% 打印一个百分号
  • prinft()的转换说明修饰符,在%转换字符之间插入修饰符可修饰基本转换说明

    修饰符 含义
    标记 本表格下一张表格描述了 5 种标记(-、+、空格、#、0),可以不使用标记或使用多个标记,如%-10d
    数字 最小字段宽度,如果该字段不能容纳待打印内容则会使用更宽的字段,如%4d
    .数字 精度。对于%e%f转换,表示小数点右边数字位数 ; 对于%g转换,表示有效数字的最大位数 ; 对于%s转换,表示待打印字符最大数量 ; 对于整型转换,表示待打印数字的最小位数。如有必要,使用前导 0 达到这个位数,只使用.表示其后跟随一个 0,所以%.f%.0f相同。如%5.2f表示打印一个字段宽度为 5,小数点后有 2 位数字的浮点数
    h 和整型转换说明一起使用,表示 short int 或 unsigned short int 类型的值,如%hu%hx%6.4hd
    hh 和整型转换说明一起使用,表示 signed char 或 unsigned char 类型的值,如%hhu%hhx%6.4hhd
    j 和整型转换说明一起使用,表示 intmax_t 或 uintmax_t 的值,这些类型定义在stdint.h
    l 和整型转换说明一起使用,表示 long int 或 unsigned long int 类型的值
    ll 和整型转换说明一起使用,表示 long long int 或 unsigned long long int 类型的值
    L 和浮点转换说明一起使用,表示 long double 的值
    t 和整型转换说明一起使用,表示 ptrdiff_t 类型的值,ptrdiff_t 是两个指针差值的类型(C99)
    z 和整型转换说明一起使用,表示 size_t 类型的值,size_t 是 sizeof 返回的类型(C99)

    注:%u标记不能把数字和符号分开,会报错!!

  • printf()中的标记

    标记 含义
    - 待打印项左对齐,即从字段左侧开始打印该项,如%-20s
    + 有符号值若为正,则在值前面显示加号(正号),若为负则显示减号(负号),如%+6.2f
    空格 有符号值若为正,则在值前面显示前导空格(不显示任何符号),若为负则显示减号(负号)覆盖前导空格,如% 6.2f
    # 把结果转换成另一种形式。如果是%o格式,则从 0 开始 ; 如果是%x格式,则从 0x 开始 ; 如果是浮点格式,#则保证了即使后面没有任何数字也打印一个小数点 ; 如果是%g格式,#防止结果后面的 0 被删除
    0 对于数值格式,使用前导 0 代替空格填充字段宽度 ; 对于整数格式,如果出现-标记或指定精度,则忽略该标记

参数传递

  • 参数传递机制因实现而异,下面以本机系统中的本程序分析参数传递。该调用告诉计算机把变量 n1、n2、n3、n4的值传递给程序,是一种常见的传参方式

    #include <stdio.h>
    int main(void)
    {
        float n1 = 3.0;
        double n2 = 3.0;
        long n3 = 2000000000;
        long n4 = 1234567890;
        printf("%ld  %ld  %ld  %ld", n1, n2, n3, n4);
        return 0;
    }

    1、程序把传入的值放入被称为内存区域,计算机根据变量类型(不是转换说明)把值放入中。
    2、因此,n1被储存在中,占 8 字节(float 被转换成 double 类型)。同样,n2中也占8 字节,而n3、n4分别占4 字节
    3、然后,控制转到printf(),其根据转换说明中读取值。%ld转换说明表明应读取 4 字节,所以printf()读取前 4 字节作为第 1 个值。这是n1 前半部分,将被解释成long 类型整数,根据下一个转换说明,printf()再读取 4 字节,这是n1 后半部分,将被解释为第 2 个 long 类型整数
    4、类似的,继续读取第 3、4 个%ld,读取为n2 的前、后半部分,并解释成两个 long 类型整数
    5、因此,对于n3、n4虽然用对了转换说明,但还是读错了字节

scanf()函数

  • scanf()是最通用的输入函数,因为它可以读取不同格式的数据,其将输入的字符串转换成整数浮点数字符字符串

  • scanf()使用指向变量的指针,而printf()使用变量、常量、表达式

  • scanf()多个输入

    1、可以通过scanf("%d%d",&n,&m)的格式输入多个数据
    2、scanf()函数允许把普通字符放在格式字符串中,除空格外的字符必须与输入字符串严格匹配
    3、如scanf("%d,%d",&n,&m),用户必须输入两个整数,并以逗号分隔
    4、除了%c,其他转换说明都会自动跳过待输入值前面的所有空白

  • scanf()返回值

    scanf()函数返回成功读取的项数
    如果没有成功读取任何项,且需要读取一个数字而用户输入一个数值字符串,其便返回0
    scanf()检测到“文件结尾”时,会返回EOF。这是stdio.h中定义的特殊值,通常会用#define将其定义为-1

  • ANSI C标准为scanf()准备的转换说明和转换说明修饰符

    • scanf转换说明修饰符printf的基本一致,但过程上从转换输出变成了解释输入,具体使用方法参考printf的表格

运算符、表达式和语句


章节概要while循环简述;运算符;赋值术语;sizeof运算符和size_t类型;表达式、语句和块;语句术语;类型转换;强制类型转换;带参数的函数;形参实参

while 循环简述

  • 示例程序

    #include <stdio.h>
    #define ADJUST 6.37
    int main(void)
    {
        const double SCALE = 0.333;
        double shoe, foot;
        printf("Shoe size (men's)   foot length\n");
        shoe = 3.0;
        while (shoe < 10.5)
        {
            foot = SCALE * shoe + ADJUST;
            printf("%10.1f %15.2f inches\n", shoe, foot);
            shoe = shoe +1;
        }
        printf("If the shoe fits , wear it.\n");
        return 0;
    }
  • while 循环

    1、当条件语句时,执行循环体圆括号内为关系表达式花括号内为循环体
    2、该程序中,程序判断shoe < 18.5是否为真,执行循环体内的代码,到达花括号再次判断表达式,为继续执行,当条件语句时,结束循环

运算符

  • 赋值运算符: =

    1、C 语言中,等号=意为赋值而非相等赋值运算符右侧的值赋给左侧
    2、当赋值运算符连用时,如a=b=c=10,仍按照从右向左方式链式赋值,即c=10b=ca=b

    • 几个术语:数据对象、左值、右值、项

      数据对象:赋值表达式语句的目的是把储存到内存位置上,用于储存数据存储区域统称为数据对象
      左值:是 C 语言的术语,用于标识特定数据对象名称表达式。因此,对象指的是实际的数据储存,而左值是用于标识定位储存位置标签
      右值:指的是赋值可修改左值,且本身不是左值
      :学习名称时,被称为的就是运算对象(如,赋值运算符左侧的项)。运算对象运算符操作的对象

  • 基本算术运算符

    1、加法运算符+减法运算符-乘法运算符*除法运算符/
    2、加和减都被称为二元运算符
    3、C 语言中的除法,若变量类型为整数,则除法的小数部分被舍弃

  • 符号运算符: +和-

    1、+-还可以做符号运算符,用作正负号
    2、用作正负号时,为一元运算符

  • 取模(余)运算符

    1、用于整数运算,得到相除的余数,如9 % 2 = 1
    2、负数求模C99后,若第一个运算对象负数,那么取模的结果为负数,反之亦然。如11 % -5 = 1-11 % 5 = -1-11 % -5 = -1

  • 递增/减运算符: ++和--

    1、下文以++为例
    2、两种形式:++出现在变量前,为前缀模式++出现在变量后,为后缀模式
    3、该运算符作用为变量自加一,如a++意为a = a + 1
    4、当单独使用递增运算符时,使用哪种形式都没关系
    5、当使用较为复杂时,则会不同。如q = 2*++a;,意为a 递增 1,后 2*a,再将结果赋给 q;而q = 2*a++;,意为2*a,后将结果赋给 q,再 a 递增 1
    6、由于前后缀模式的以上特性会对代码产生不同的影响,因此最好单独使用(如需复合使用时,可以先单独自增再使用)

  • sizeof 运算符和 size_t 类型

    1、第 3 章已介绍,sizeof运算符用于以字节为单位返回运算对象的大小
    2、C 语言规定,sizeof返回size_t类型数值,这是一个无符号整数类型,其为语言定义标准类型
    3、C99后使用%zd用于转换显示size_t类型,如不支持可以使用%u%lu代替

表达式、语句和块

  • 表达式

    1、表达式运算符运算对象组成。最简单的表达式单个运算对象,以此为基础可以建立复杂的表达式
    2、运算对象可以是常量变量二者的组合
    3、每个表达式都有一个值,如q=5*2作为一个整体的值为 10;表达式q>3的值为布尔值,为truefalse,即值为 1 或 0

  • 语句

    1、语句是 C 程序的基本构建块一条语句相当于一条完整的计算机指令,C 中大部分语句都以分号;结尾
    2、最简单的语句为空语句,只有一个分号构成;C 把末尾加上一个分号表达式看做语句,因此8;3+4;这些语句也没问题,只是在程序中什么都不做

    • 副作用、序列点、完整表达式

      1、副作用:副作用是对数据对象文件修改。例如语句states=50;,其副作用修改变量 states 的值为 50。这似乎是主要目的,而在C 语言的角度看,主要目的对表达式求值,如表达式4+6求值得10,给出表达式states=50求值得50
      2、序列点:是程序执行的点,在该点上,所有的副作用都在进入下一步之前发生。语句中的分号标记了一个序列点,另外,任何完整表达式的结束也是一个序列点
      3、完整表达式:指这个表达式不是另一个更大表达式子表达式

  • 复合语句(块)

    复合语句:是用花括号括起来的一条或多条语句复合语句也称为

类型转换

  • 通常,在语句表达式中,应使用类型相同的变量和常量。但是,如果使用混合类型,C 会采用一套规则进行自动类型转换,虽然这很便利,但有一定危险性,尤其是在无意间混合使用类型的情况下

  • 基本的类型转换规则

    1、当类型转换出现在表达式时,无论是unsigned还是signedcharshort都会被自动转换成int,如有必要会被自动转换成unsigned int(如果shortint大小相同unsigned short就比int大,此时unsigned short会被转换成unsigned int)。由于都是较小类型转换为较大类型,所以这些转换被称为升级
    2、涉及两种类型运算,两个值会被分别转换成两种类型的更高级别
    3、类型的级别从高至低依次是:long doubledoublefloatunsigned long longlong longunsigned longlongunsigned intint例外的情况是,当longint大小相同时unsigned int级别比long的级别高。之所以shortchar没有列出,是因为它们已经被升级成了intunsigned int
    4、在赋值表达式语句中,计算的最终结果会被转换成被赋值变量的类型,因此该过程可能导致类型升级或降级
    5、当作为函数参数传递时,charshort被转换成intfloat被转换成double。第九章将介绍,函数原型会覆盖自动升级

  • 强制类型转换

    • 通常,应该避免自动类型转换,尤其是类型降级,但如果能小心使用,类型转换也很方便
    • 当需要进行精确的类型转换,或者在程序中表明类型转换的意图,此时要用到强制类型转换
    • 强制类型转换:在某个量前面放置用圆括号()括起来的类型名,该类型名即是希望转换成的目标类型圆括号和它括起来的类型名构成了强制类型转换运算符,其通用形式为(type),例子为score = (int)1.6 + (int)1.7

带参数的函数

  • 示例程序

    #include <stdio.h>
    void pound(int n)           // 定义函数
    {
        while (n-- > 0)
        {
            printf("#");
        }
        printf("\n");
    }
    
    int main(void)
    {
        int times = 5;
        char ch = '!';
        float f = 6.0;
        pound(times);
        pound(ch);
        pound(f);
        return 0;
    }
  • 参数-形参、实参

    • 首先,看函数头void pound(int n),如果函数不接受任何参数,那么圆括号中应写上void。由于该函数接受一个int类型的参数,所以其中包含一个int类型的变量 n的声明。参数名应遵循 C 语言的命名规则
    • 声明参数就创建了被称为形式参数变量(简称形参),该例中,形式参数是 int 类型的变量 n;像pound(10)这样的函数调用会把 10 赋给 n ,我们称函数调用传递的值实际参数(简称实参)
    • 函数调用pound(10)实参 10传递给函数,函数将 10 赋给形参
    • 变量名是函数私有的,即在函数中定义的变量名不会别处的相同名称发生冲突
  • 函数调用

    • 现在,来学习函数调用,如第一次调用pound(times)times的值5被赋给n,因此函数打印了5 个井号和一个换行符
    • 第二次调用pound(ch),此时chchar类型变量,被初始化为!,其ASCII 码33。由于函数形参类型为int,与char不匹配,所以程序开头的函数原型发挥了作用;原型函数声明,描述函数返回值参数pound原型说明了两点:

      1、函数没有返回值(函数名前关键字为void)
      2、函数有一个int类型的形参

    • 函数原型告诉编译器,函数接受一个int类型的参数,当编译器执行到pound(n)时,参数ch被自动转换成int类型,于是从1 字节的 33变成了4 字节的 33。于此类型,第三次调用pound(f)也使得float类型转换成合适的int类型

C 控制语句:循环


章节概要:再探while循环;while循环语句;迭代;关系运算符与关系表达式;真(true)与假(false);bool布尔变量;for循环;for的几种使用示例;复合赋值运算符;出口条件循环do-while;循环嵌套;数组简介;函数返回值的使用

再探 while 循环

  • 示例程序

    #include <stdio.h>
    int main(void)
    {
        long num;
        long sum = 0L;
        int status;
        printf("Please enter an integer to be summed");
        printf("(q to quit):");
        status = scanf("%ld", &num);
        while (status == 1)
        {
            sum = sum + num;
            printf("Please enter next integer (q to quit):");
            status = scanf("%ld", &num);
        }
        printf("Those integer sum to %ld.\n", sum);
        return 0;
    }
  • 新元素分析

    • sum初始值为0L,为long类型的0,而非int类型的0
    • ==相等运算符,用于判断前后值是否相等,不要与=赋值运算符混淆
    • scanf()函数的返回值,返回成功读取项的数量,因此此处读取1 个整数,则成功后返回1

while 循环语句

  • while 循环语句格式

    while(关系表达式){
        循环体;
    }
  • 迭代:在循环的关系表达式假(0)之前,循环的判断和执行一直重复进行每一次循环都被称为一次迭代

  • 循环条件

    • 构建循环时,必须让测试表达式的值有变化,表达式最终要为(0),否则循环就不会停止
    • 可以使用while(1)来构建简单的死循环,之后会将到如何破除循环
    • 注意循环终止的时间,只有大括号内的语句循环执行,注意哪些语句需要循环执行,哪些不需要

关系运算符与关系表达式

  • 关系运算符

    ==相等运算符,用于判断前后值是否相等,不要与=赋值运算符混淆
    !=不等运算符,用于判断前后值是否不相等
    <小于运算符,用于判断前值是否小于后值
    >大于运算符,用于判断前值是否大于后值
    <=小于等于运算符,用于判断前值是否小于等于后值
    >=大于等于运算符,用于判断前值是否大于等于后值

  • 真(true)与假(false)

    1、关系表达式会产生真(true)假(false)的值,真(true)值通过打印会得到为1假(false)值通过打印会得到为0
    2、因此while循环判断的实际为表达式的真假值
    3、而在 C 语言中,一般所有非 0 的值都可以被识别为真(true),只有0被识别为假(false)

  • _Bool 布尔变量

    1、C99新增了_Bool布尔类型变量,其只能储存真(true)假(false),所有其他非零数值都会被转换为真(true)
    2、stdbool.h头文件让bool成为了_Bool的别名,还把truefalse分别定义为10符号常量
    3、且使用该头文件的代码可以与C++兼容,因为C++booltruefalse定义为关键字

for 循环

  • 示例程序

    #include <stdio.h>
    int main(void)
    {
        const int NUMBER = 22;
        int count;
        for (count = 1; count <= NUMBER; count++)
        {
            printf("Be my Valentine\n");
        }
        return 0;
    }
  • for 循环格式

    for(初始化;测试条件;执行更新){
        循环体
    }

    1、for后面的括号中有三个表达式,分别用两个分号;隔开
    2、第 1 个表达式是初始化,只会在for循环开始时执行一次
    3、第 2 个表达式是测试条件测试条件真(true)执行循环体测试条件假(false)结束循环
    4、第 3 个表达式是执行更新每次循环结束时求值

  • for 的灵活性

    • for循环十分灵活,可以利用三个表达式完成几乎所有需要的条件判断,使用for循环能更轻松清楚地完成遍历

    • 逗号运算符,使得循环头可以包含更多表达式,如for(i=0,a=10; i<a; i=i+2,a++)

    • for循环的其他几种妙用

      #include <stdio.h>
      int main(void)
      {
          //输出20内平方表
          printf("数字        平方\n");
          for (int i = 1; i <= 20; i++)
          {
              printf("%-11d %-11d\n", i, i * i);
          }
          return 0;
      }
      #include <stdio.h>
      int main(void)
      {
          //输出ASCII码表
          printf("字符        ASCII码\n");
          for (char i = 'A'; i <= 'z'; i++)
          {
              printf("%-11c %-11d\n", i, i);
          }
          return 0;
      }
      #include <stdio.h>
      int main(void)
      {
          //输出20内的、平方小于350的偶数
          //注: &&为“与”,表示两者皆满足;i+=2同i=i+2
          for (int i = 0; i <= 20 && i * i < 350; i += 2)
          {
              printf("%d\n", i);
          }
          return 0;
      }

复合赋值运算符

  • 复合赋值运算符+=-=*=/=%=

  • 符号赋值运算符+=表示加法赋值,如a+=2意义等同于a=a+2,其余以此类推

出口条件循环 do-while

  • while循环的一种变种while入口处判断do-while出口处判断

  • 特点为第一次执行,无论如何do-while循环体至少执行一次,出口处再判断是否下次循环。如下程序即使count 初始值大于 22,也会执行一次do 内循环体

  • 示例程序

    #include <stdio.h>
    int main(void)
    {
        int count=1;
        do
        {
            printf("Be my Valentine\n");
            count++;
        } while (count < 22);
        return 0;
    }

循环嵌套

  • 示例程序

    #include <stdio.h>
    int main(void)
    {
        //输出乘法表
        for (int i = 1; i <= 9; i++)
        {
            for (int j = 1; j <= i; j++)
            {
                printf("%d*%d=%-2d  ", j, i, i*j);
            }
            printf("\n");
        }
        return 0;
    }
  • 循环嵌套

    1、循环嵌套指在循环内包含另一个循环,执行顺序为外层循环过程中执行多次内层循环,两个循环的大括号分别标识自己的循环体部分
    2、如上述示例程序乘法表,通过最内层printf能得知,使用j表示乘法表第一个数字,使用i表示第二个数字,使用i*j表示乘法的积
    3、通过j<=i循环条件防止出现2*3后再次出现3*2重复
    4、建议自己运行一次程序,感受循环的顺序,也可以改变几个数值,看看程序的变化

数组简介

  • 在许多程序中,数组很重要。数组可以作为一种储存多个相关元素便利方式,将在第十章详细介绍

  • 数组

    1、数组是按顺序存储的一系列类型相同的值,如 10 个char类型数值或 10 个int类型数值
    2、整个数组有一个数组名,通过整数下标访问数组中单独的项元素

  • 数组声明与使用

    数组声明:示例int a[15],表示声明一个内涵 15 个元素的整数数组
    数组使用:通过下标访问指定元素。数组的第一个元素a[0]第二个元素a[1],以此类推。实际上数组元素的使用与同类型变量相同
    陷阱:考虑到 C 执行速度,C 编译器不会检查数组下标是否正常,注意数组元素不要超出定义的范围
    下标:用于标识数组元素的数字叫做下标索引偏移量。下标必须是整数且要从 0 开始计数

  • for 循环中使用数组

    • for循环数组使用,可以通过利用循环变量的变化来切换数组的元素,示例如下:

      #include <stdio.h>
      int main(void)
      {
          int a[10];
          //循环输入
          for (int i = 0; i < 10; i++)
          {
              scanf("%d", &a[i]);
          }
          //循环输出
          for (int i = 0; i < 10; i++)
          {
              printf("%-5d  ", a[i]);
          }
          return 0;
      }

函数返回值的使用

  • 对于有返回值的函数,函数最后的return语句表示函数的返回值,即执行完函数后,函数返回的值

  • 编写一个有返回值的函数,需要注意以下几点:

    1、定义函数时,确定函数的返回类型
    2、使用return表明待返回的值

  • 示例程序

    #include <stdio.h>
    
    // double 函数名 表明函数返回一个double类型的值
    double power(double n, int p)
    {
        double pow = 1;
        for (int i = 1; i <= p; i++)
        {
            pow *= n;
        }
        // 返回pow的值
        return pow;
    }
    
    int main(void)
    {
        // a^b
        double a;
        int b;
        printf("输入底数:");
        scanf("%lf", &a);
        printf("输入指数:");
        scanf("%d", &b);
        // 调用函数
        printf("乘方结果:%lf", power(a, b));
        return 0;
    }

C 控制语句:分支和跳转


章节概要if语句;if-else 语句与 else-if 语句;ifelse 的配对和嵌套 ifgetchar()putchar()函数;ctype.h系列的字符函数;逻辑运算符;备选拼写:iso646.h头文件;条件(三目)运算符;循环辅助:continuebreakswitch 语句;goto 语句

if 语句

  • 示例程序

    #include <stdio.h>
    int main(void)
    {
        int a, b;
        scanf("%d%d", &a, &b);
        if (a > b)
        {
            printf("Sure,A>B!\n");
        }
        printf("Over!");
        return 0;
    }
  • if 语句

    1、if语句被称为分支语句选择语句,因为它相当于一个交叉点,程序要在两条分支中选择一条执行
    2、程序如果对分支表达式求值为,则执行执行语句,否则跳过执行语句
    3、if语句的通用形式如下

    if (分支表达式){
        执行语句;
    }

if-else 语句与 else-if 语句

  • if-else 语句

    1、简单的if语句可以让程序选择执行一条语句或跳过,而if-else语句可以在两条语句之间做选择
    2、程序如果对分支表达式求值为,则执行执行语句 1,否则执行执行语句 2
    3、if-else语句的通用形式如下

    if (分支表达式){
        执行语句1;
    }
    else{
        执行语句2;
    }
  • else-if 语句

    1、else-if语句多重选择语句,可以在多个分支之间做选择
    2、程序会根据表达式是否为真逐步判断,特别注意,如果第一个表达式为真,则不会继续向下执行
    3、else-if语句的通用形式如下

    if (分支表达式1){
        执行语句1;
    }
    else if (分支表达式2){
        执行语句2;
    }
    else{
        执行语句3;
    }

if 与 else 的配对和嵌套 if

  • 当一个程序有多个ifelse如果没有花括号else将与离它最近if配对,除非最近的 if 被花括号括起来

  • 有关if的嵌套,与for的嵌套基本雷同,只需要注意不同嵌套的花括号包括的范围即可

getchar()与 putchar()函数

  • getchar()与 putchar()的使用

    1、getchar()函数用于从标准输入流读取一个字符,并将其存储在变量中
    2、如把字符储存进变量ch,则写为ch = getchar(),其等效于scanf("%c", &ch)
    3、putchar()函数用于打印它的参数
    4、如打印ch的值,则写为putchar(ch),其等效于printf("%c",ch)
    5、由于这些函数只处理字符,所以比scanfprintf更快更轻量,而且不需要转换说明

  • 探索如何工作的程序示例

    #include <stdio.h>
    int main(void)
    {
        char ch;
        ch = getchar();    // 读取第一个字符
        while (ch != '\n') // 当不为换行符时循环,即一行字符未结束时
        {
            if (ch == ' ') // 留下空格不变
            {
                putchar(ch);
            }
            else
            {
                putchar(ch + 1); //其他字符改变+1
            }
            ch = getchar(); //获取下一个字符
        }
        putchar(ch); //打印换行符
        return 0;
    }
    • 该程序可以进行优化,将如下形式的循环替换为后者

      ch = getchar();
      while (ch != '\n'){
          ...
          ch = getchar();
      }
      while ( (ch=getchar()) != '\n'){
          ...
      }
    • 这样的写法体现了C 特有的编程风格——把两个行为合并成一个表达式

    • 程序中的putchar(ch+1);语句,再次演示了字符实际上是作为整数储存

ctype.h 系列的字符函数

  • C 有一系列专门用于处理字符函数ctype.h头文件包含了这些函数的原型。这些函数接受一个字符作为参数,如果该字符属于某特殊的类别,则返回true,否则返回false

  • ctype.h字符测试函数

    函数名 如果是下列参数,返回值为 true
    isalnum() 字母或数字
    isalpha() 字母
    isblank() 标准的空白字符(空格、换行、水平制表符)或其他本地指定为空白的字符
    iscntrl() 控制字符,如Ctrl+B
    isdigit() 数字
    isxdigit() 十六进制数字符
    isgraph() 除空格以外的任意可打印字符
    islower() 小写字母
    isupper() 大写字母
    isprint() 可打印字符
    ispunct() 标点符号(除空格和字母数字以外的任何可打印字符)
    isspace() 空白符(空格、换行、换页、回车、垂直或水平制表符、其他本地定义的空白符)
  • ctype.h字符映射函数

    函数名 行为
    tolower() 如果参数是大写字符,则函数返回小写,否则返回原始参数
    toupper() 如果参数是小写字符,则函数返回大写,否则返回原始参数

逻辑运算符

  • C 语言的ifwhile语句通常需要使用关系表达式作为测试条件。有时需要多个关系表达式组合,来判断多个条件的逻辑关系逻辑运算符便可满足这一需求

  • 3 种逻辑运算符

    1、&&逻辑与:如果连接的两个表达式都为 true,则返回 true
    2、||逻辑或:如果连接的两个表达式至少有一个为 true,则返回 true
    3、!逻辑非:如果表达式为 true,则返回 false;如果表达式为 false,则返回 true

  • 逻辑运算符优先级

    1、!优先级最高&&优先级次之||优先级最低
    2、!的优先级仅次于圆括号,比乘法运算符还高。&&||的优先级都比关系运算符低比赋值运算符高
    3、因此,表达式a>b && b>c || b>d相当于((a>b) && (b>c)) || (b>d)

  • 备选拼写:iso646.h 头文件

    • 由于 C 使用标准美式键盘开发,部分键盘并没有美式键盘的符号。使用iso646.h头文件,可以使用andornot分别代替&&||!

条件(三目)运算符

  • C 提供条件(三目)运算符作为表达if-else的一种便捷方式,常用于为一个变量判断赋值或输出的值时使用

  • 三目运算符的使用

    • 基本语法测试条件 ? 结果true执行表达式 : 结果false执行表达式
    • 例如a = (num<0) ? -y : y;等效于如下语句:
      if (num < 0) {
          a = -y;
      } else {
          a = y;
      }
    • 三目运算符也可以嵌套使用,使用小括号表明不同层级部分

循环辅助:continue 和 break

  • 语句功能

    continue语句:在循环过程中,如果执行到continue语句,则会从continue执行处跳过本层本次循环的剩余内容,继续执行下一次循环
    break语句:在循环过程中,如果执行到break语句,则会从break执行处终止本层循环跳过未执行内容不再进行下一次本层循环

  • 语句优势

    1、可以更加灵活的控制循环的执行
    2、能够减少不必要的if-else层级缩进,提高代码的可读性
    3、使代码语句结构更清晰紧凑

switch 语句

  • 使用条件运算符if-else语句很容易编写二选一的程序,然而有时程序需要从多个选项中选择,尽管可以使用else if实现,但大多情况下switch更加方便

  • 基本语法

    switch (表达式){
        case 表达式的可能值1:
            语句;
            break;
        case 表达式的可能值2:
            语句;
            break;
        ...
        default:
            语句;
            break;
    }
  • 注意事项

    1、break语句使程序离开switch语句,直接执行switch后的下一条语句,如果没有break,则会按顺序条件成立处向后所有case内语句执行完毕,直到default语句后退出
    2、C 语言的case一般都指定一个值不能使用一个范围
    3、关于switch不使用break向后执行的特性,可以在特定的地方设定break,来利用这个特性,如下设计统计字母出现次数的程序

    #include <stdio.h>
    int main(void)
    {
        char ch;                              // 输入字母
        int a_ct, b_ct, c_ct, d_ct, e_ct;     // 统计abcde的次数
        a_ct = b_ct = c_ct = d_ct = e_ct = 0; // 初始化为0
        printf("enter some text;enter # to quit");
        while ((ch = getchar()) != '#')
        {
            switch (ch)
            {
            case 'a': // 检测a时执行,并会向下执行'A'的语句
            case 'A': // 不论大写小写a,都会执行语句计入统计
                a_ct++;
                break; // break终止继续执行
            // 以此类推,此处省略bcde的case
            }
        }
        printf("A   B   C   D   E\n");
        printf("%-4d%-4d%-4d%-4d%-4d", a_ct, b_ct, c_ct, d_ct, e_ct);
        return 0;
    }
  • swith 与 if-else

    1、通常而言,switch能干的if-else都能实现,但switch的运行速度更快
    2、当需要判断一个范围浮点变量或表达式时,switch无法实现
    3、switch通常只是if-else优化,对比之下,仍是if-else泛用性更强

goto 语句

  • 早期版本的BASICFORTRAN所依赖的goto语句,在 C 中仍然可用,但非常不建议使用,即使没有goto语句 C 语言也仍能运行良好,且逻辑更加清晰

  • 基本语法

    语句标签(:part1):语句
    
    goto 语句标签;

字符输入输出与输入验证


章节概要:单字符 I/O:getcharputchar;缓冲区;完全缓冲 I/O 与行缓冲 I/O;结束键盘输入(C 处理文件的方式);文件;流;检测文件结尾;C 语言的EOF;重定向和文件;UNIX、Linux 和 DOS 重定向(流的输送)、重定向注意事项;创建更友好的用户界面;处理缓冲输入的换行符;处理混合数值字符输入的错误;输入验证

单字符 I/O:getchar 与 putchar

  • 在第七章中提到过,getchar()putchar()每次只处理一个字符。可能这种方法过于笨拙,但这种方法很适合计算机。而且,这是绝大多数文本处理程序所用的核心方法

  • 详细用法请参照前一章

  • 为何输入的字符能直接显示在屏幕上?如果用一个特殊字符(如#)来结束输入,就无法在文本中使用这个字符。是否有更好的办法结束输入?首先要了解C 程序如何处理键盘输入,尤其是缓冲标准输入文件的概念

缓冲区

  • 缓冲输入与缓冲区

    缓冲输入:对于程序输入,大部分系统在用户按下Enter之前不会重复打印刚输入的字符,即在重复输入字符时,不会在终端出现H(输入)H(输入后处理输出的字符)e(第二格输入的字符)elllloo这样的情况。这种输入形式称为缓冲输入
    缓冲区:用户输入的字符会先被收集并储存在一个被称为缓冲区临时储存区,按下Enter时,程序才可使用用户输入的字符

  • 为什么要有缓冲区

    1、把若干字符作为一个块传输比逐个发送这些字符节约时间
    2、如果用户打错字符,可以直接通过键盘修正,当最后按下Enter时,传输的是正确的输入
    3、虽然缓冲输入好处很多,但某些交互式程序也需要无缓冲输入,比如在游戏中,希望按下一个按键立刻执行相应的指令。因此缓冲输入和无缓冲输入都有用武之地

  • 完全缓冲 I/O 与行缓冲 I/O

    完全缓冲输入:指当缓冲区被填满时才刷新缓冲区(内容被发送至目的地),通常出现在文件输入中。缓冲区大小取决于系统,常见512 字节4096 字节
    行缓冲输入:指在出现换行刷新缓冲区键盘输入通常是行缓冲输入,所以在按下Enter时才刷新缓冲区

  • 使用缓冲输入还是无缓冲输入

    1、ANSI C后续的 C 标准都规定输入是缓冲的,不过最初 K&R 把这个决定权交给了编译器的编写者
    2、ANSI C决定把缓冲输入作为标准输入的原因是:一些计算机不允许无缓冲输入
    3、ANSI C没有提供调用无缓冲输入标准方式,这意味着能否进行无缓冲输入取决于计算机系统

结束键盘输入(C 处理文件的方式)

  • 文件、流和键盘输入

    • 文件

      1、文件储存器储存信息的区域。通常,文件都保存在某种永久储存器中(如硬盘、U 盘、DVD 等)。
      2、毫无疑问,文件对于计算机系统相当重要。例如你编写的C 程序就保存在文件中,用来编译 C 程序程序也保存在文件
      3、某些程序需要访问指定的文件。当编译储存在名为echo.c文件中的程序时,编译器打开echo.c文件并读取其中的内容,当编译器处理完后,会关闭该文件
      4、其他程序,例如文字处理器,不仅要打开读取关闭文件,还要把数据写入文件

    • C 语言与文件

      1、C 是一门强大、灵活的语言,有许多用于打开读取写入关闭文件的库函数
      2、从较低层面上,C 可以使用主机操作系统基本文件工具直接处理文件,这些直接调用操作系统函数被称为底层 I/O。但计算机系统各不相同,所以不可能为普通的底层 I/O函数创建标准库
      3、从较高层面上,C 还可以通过标准 I/O 包来处理文件。这涉及创建用于处理文件的标准模型和一套标准 I/O 函数。这一层面上,具体的C 实现负责处理不同系统的差异,以便用户使用统一的界面

    • 流(stream)

      1、从概念上看,C 程序处理的是,而不是直接处理文件
      2、是一个实际输入或输出映射的理想化数据流。这意味着不同属性不同种类的输入,由属性更统一来表示
      3、于是,打开文件的过程就是把文件关联,而且读写都通过来完成

  • 文件结尾

    • 操作系统检测文件结尾

      1、计算机操作系统要以某种方式判断文件的开始和结束,其中一种方法是,在文件末尾放一个特殊的字符标记文件结尾
      2、CP/MIBM-DOSMS-DOS的文本文件曾经都用过这种方法。如今这些操作系统可以使用内嵌的Ctrl+Z字符来标记文件结尾
      3、这曾经是操作系统使用的唯一标记,不过现在有一些其他选择,如记录文件的大小。所以现代的文本文件不一定有嵌入的Ctrl+Z,但如果有,操作系统将其视为一个文件结尾的标记,如后附图
      4、操作系统使用的另一种方法储存文件大小的信息。如果文件有3000 字节,那么读到 3000 字节时便达到文件的末尾
      5、MS-DOS及其相关系统使用这种方法处理二进制文件,因为用这种方法可以在文件中储存所有的字符。新版的DOS也使用这种方式处理文本文件UNIX使用这种方式处理所有的文件

    • C 语言检测文件结尾

      1、在 C 语言中,用getchar()读取文件检测到文件结尾时,会返回特殊值EOF(End Of Line 缩写)。scanf()检测到文件结尾时也返回EOF
      2、通常,EOF被定义在stdio.h文件中#define EOF (-1)

    • 关于 EOF

      1、为什么是选用-1?因为getchar()函数的返回值介于0~127,这些值对应标准字符集。但是如果系统能识别拓展字符集,则返回值可能在0~255。但无论哪种情况,-1都不对应任何字符,所以选用-1 标记文章结尾
      2、某些系统也许把EOF定义为-1 以外的值,但是定义的值一定与输入字符所产生的返回值不同。如果包含stdio.h文件,并使用EOF符号,就不必担心值不同的问题。这里关键要理解EOF是一个值,标志着检测到文件结尾并不是在文件中找得到的符号
      3、如何在程序中使用?把getchar()返回值EOF比较,如果不同没有到达文件结尾,即如下:while((ch = getchar()) != EOF)

    • 键盘模拟文件结尾条件

      #include <stdio.h>
      int main(void)
      {
          int ch;
          while ((ch = getchar()) != EOF)
          {
              putchar(ch);
          }
          return 0;
      }

      1、不用定义EOF,因为stdio.h已经定义过了
      2、不用担心EOF实际值,因为EOFstdio.h中用#define预处理指令定义,可直接使用
      3、变量ch的类型从char变成了int,因为char的变量只能表示0~255无符号整数,但EOF值是-1。还好getchar()函数实际返回值类型int,所以它可以读取EOF字符。如果实现使用有符号char类型,也可以把ch声明为char,但最好还是用更通用的形式
      4、由于getchar()返回类型int,如果把getchar()返回值赋给char变量,一些编译器可能会警告甚至丢失数据
      5、ch整数不会影响putchar(),该函数仍然会打印等价字符
      6、使用该程序进行键盘输入,要设法输入EOF字符,不能只输入字符 EOF,也不能只输入数值-1(会被当做一个连字符和一个数字 1)。正确的方法是找出当前系统的要求,如大多数UNIXLinux系统中在一行开始使用Ctrl+Z传输文件结尾信号,而Windows系统中在一行开始使用Ctrl+Z传输文件结尾信号,所以在程序中需要设立不同的提示语提醒用户

重定向和文件

  • 默认情况下,C 程序使用标准 I/O包查找标准输入作为输入源,这就是前面介绍过的stdin 流,它是把数据读入计算机的常用方式

  • 程序使用文件的两种方式

    1、显式使用特定的函数打开、关闭、读取、写入文件,将在第 13 章介绍
    2、设计能与键盘和屏幕互动的程序,通过不同的渠道重定向输入至文件和从文件输出,下面主要介绍此类重定向

  • UNIX、Linux 和 DOS 重定向

    • UNIX(运行命令行模式)、Linux(ditto)和 Windows 命令行(注意使用cmd而不是终端)提示都能重定向输入输出重定向输入让程序使用文件而不是键盘输入,重定向输出让程序输出至文件而不是屏幕

    • 重定向输入

      1、假设已经编译了echo.c程序,并生成了一个名为echo可执行文件(Windows 中为echo.exe注意后续命令注意使用带有文件后缀的名字)。想要运行该程序,在命令行对应目录中输入可执行文件名:./echoWindows下输入echo.exe,即可执行可运行文件
      2、现在,假设要用该程序处理名为 passage文本文件(.txt),文件中储存的是可识别的字符。此处由于操作对象是字符,所以使用文本文件。使用此命令代替上面的命令:./echo < passageWindows下输入echo.exe < passage.txt
      3、<符号是 UNIX 和 DOS/Windows 的重定向运算符。该运算符使passage 文件stdin 流相关联,把文件中的内容导入 echo 可执行程序

    • 重定向输出

      1、类似的,假设要用echo 可执行程序程序输出的内容发送到名为 passage 的新文件,便可以使用./echo > passageWindows 下输入echo.exe > passage.txt
      2、>符号是第二个重定向运算符,创建了一个名为passage 的新文件,然后把 echo 的输出写入该文件。通常会擦除该文件的内容,然后替换新的内容,在下一行开始处按下Ctrl+D(UNIX)或Ctrl+Z(DOS)即可结束该程序

    • 组合重定向

      1、现在假设你希望制作一份mywords 文件副本,并命名为 savewords,输入./echo < mywords > savewords,Windows 下输入echo.exe < mywords.txt > savewords.txt
      2、下面的命令也起作用,因为命令重定向运算符的顺序无关,如:./echo > savewords < mywords
      3、在一条命令中,输入文件名和输出文件名不能相同,如:./echo < mywords > mywords <==错误,原因是> mywords在输入之前已导致原 mywords 长度被截断为 0

    • 其他重定向注意事项

      1、重定向运算符连接一个可执行程序一个数据文件不能直接连接两个可执行文件或连接两个数据文件
      2、使用重定向运算符不能读取多个文件的输入,也不能把输出定向至多个文件
      3、文件名和运算符之间空格不是必须的,且有些系统不能使用空格
      4、UNIX、Linux 或 Windows/DOS 还有>>运算符,该运算符可以把数据添加到现有文件的末尾不覆盖原内容,而|运算符能把一个文件的输出连接到另一个文件的输入

创建更友好的用户界面

  • 使用缓冲输入

    • 示例程序(待优化程序)

      #include <stdio.h>
      int main(void)
      {
          int guess = 1;
          printf("在1-100想一个数,输入y或n表示当前显示的数是否是你想的数\n");
          printf("数字是1吗\n");
          while (getchar() != 'y')
          {
              printf("那么,是%d吗\n", ++guess);
          }
          printf("好的,这便是你想的数字");
          return 0;
      }
    • 该示例程序有以下问题,对于用户的体验有影响

      1、缓冲输入要求用户按下Enter发送,这一动作也传递了换行符,程序必须妥善处理这个换行符
      2、用户的输入可能并不会按照人为约定只输入y 或 n,仍有其他输入的可能,也需要对应进行处理

  • 处理缓冲输入的换行符

    • 该程序每次输入 n 时,程序便打印了两条消息。这是由于程序读取 n作为用户否定了数字 1,另外读取了一个换行符作为用户否定了数字 2。此外输入 no,会打印三条语句,程序将 n 和 o 分别当做了一次响应,外加换行符的一次响应

    • 优化 1:跳过剩余输入

      1、使用while循环,循环丢弃输入行最后剩余的内容,包括换行符
      2、这种方法还能把nono way都视为简单的n(因为只使用第一个字符,其余字符被丢弃)

      while(getchar() != 'y')
      {
          printf("那么,是%d吗\n", ++guess);
          while(getchar() != '\n')
          {
              continue; //跳过剩余输入行
          }
      }
    • 优化 2:使用变量储存响应以进行判断

      1、上述方法 1虽然解决了换行符的问题,但程序仍会将f视为n
      2、可以添加一个char类型变量储存响应,再用if判断筛选其他响应

      char response;
      while((response = getchar()) != 'y')
      {
          if (response == 'n')
          {
              printf("那么,是%d吗\n", ++guess);
          }
          else
          {
              printf("未知输入\n");
          }
          while(getchar() != '\n')
          {
              continue; //跳过剩余输入行
          }
      }
  • 混合数值和字符输入

    • 示例程序(待优化程序)

      #include <stdio.h>
      void display(char cr, int lines, int width)
      {
          // 该函数用于输出
          int row, col;
          for (row = 1; row <= lines; row++)
          {
              for (col = 1; col <= width; col++)
              {
                  putchar(cr);
              }
              printf("\n");
          }
      }
      
      int main(void)
      {
          int ch, rows, cols;
          printf("输入要打印的字符、行数、每行个数\n");
          while ((ch = getchar()) != '\n')
          {
              scanf("%d%d", &rows, &cols);
              display(ch, rows, cols);
              printf("输入另一组数据继续,输入换行退出\n");
          }
          printf("Bye!");
          return 0;
      }
    • 该示例程序有以下问题,对于用户的体验有影响

      1、getchar()scanf()各自使用都能完成各自的任务,但尽可能不要将它们混用getchar()读取每个字符,包括空格换行制表符scanf()跳过空格换行制表符,上述程序便因此出错
      2、当程序输出完第一组数据,就直接退出了,无法输入第二组数据
      3、在第一次输入的最后一个数字后的换行符scanf()将其留在了输入队列里,而getchar()不会跳过换行符。所以进入下一次迭代时,getchar()便读取了该换行符,将其赋给ch

  • 处理混合数值字符输入的错误

    • 优化:跳过一轮输入结束下一轮输出开始之间所有的换行符和空格

      /*修改主函数的while循环*/
      while ((ch = getchar()) != '\n')
      {
          if((scanf("%d%d", &rows, &cols)) != 2)
          {
              break;
          }
          display(ch, rows, cols);
          while(getchar() != '\n')
          {
              continue;
          }
          printf("输入另一组数据继续,输入换行退出\n");
      }

输入验证

  • 实际应用中,用户不一定会按照程序的指令行事,用户的输入程序期望的输入不匹配时常发生。因此需要输入验证预料一些可能的输入错误,并提前编写处理错误的程序

  • 假设编写了一个处理非负整数的循环,提前推演可能出现的错误,便可以按如下方式处理:

    • 防止出现负数,使用关系表达式排除此种情况:

      long n;
      scanf("%ld", &n); // 获取第一个值
      while (n >= 0)  // 判断是否为非负数
      {
          // 处理n(此处省略处理语句)
          scanf("%ld", &n);   // 继续获取下一个值
      }
    • 防止输入错误类型的值,判断scanf()返回值排除,并结合上处错误改进:

      long n;
      while (scanf("%ld", &n)==1 && n>=0)
      {
          // 处理n(此处省略处理语句)
      }
    • 对于上处程序,当用户输入错误的值,会直接结束程序。此外还可以提示用户再次输入正确的值,但这种情况下,需要处理有问题的输入。因为scanf()错误输入仍会留在输入队列,可以使用getchar()函数逐字读取输入,还可以将其结合在一个函数内,按如下改进:

      long get_long(void)
      {
          long input;
          char ch;
          while (scanf("%ld", &input) != 1)
          {
              while ((ch = getchar()) != '\n')
              {
                  putchar(); // 处理错误的输入
              }
              printf("输入有误,请重新输入\n");
          }
          return input;
      }

函数


章节概要:复习函数;函数概述;函数创建与使用;函数参数与返回值;ANSI C 函数原型;旧式声明问题及解决;递归;递归演示;递归的基本原理;尾递归;递归和倒序计算;递归的优缺点;编译多源代码文件的程序;使用(自建)头文件;查找地址:&运算符;更改主调函数中的变量;指针简介;指针基本概念;间接运算符:*;声明指针;使用指针在函数间通信

复习函数

  • 函数概述

    • 函数:完成特定任务独立程序代码单元语法规则定义了函数的结构使用方式

    • 功能

      1、执行某些动作:如printf()把数据打印到屏幕
      2、找出一个值供程序使用:如strlen()指定字符串长度返回给程序

    • 优点

      1、可以省去编写重复代码的苦差
      2、让程序更加模块化
      3、提高代码的可读性
      4、方便后期修改完善

  • 函数创建与使用

    • 示例程序

      /*定义并使用starbar函数打印40个星号*/
      #include <stdio.h>
      
      void starbar(void); // 函数原型
      
      int main() // 主函数
      {
          starbar();  // 调用函数
          printf("hello world\n");
          starbar();
          return 0;
      }
      
      void starbar(void)  // 定义函数
      {
          for (int i=1;i<=40;i++){
              putchar('*');
          }
          putchar('\n');
      }
    • 函数基础

      • 基本术语

        1、函数原型:告诉编译器函数starbar()相关信息。其指明了函数的返回值类型和函数接收的参数类型,这些信息称为函数的签名
        2、函数调用:表明在此执行函数
        3、函数定义:指定函数具体要做什么工作
        4、补充函数原型函数定义可以在同一步完成,即函数原型void starbar(void)后可以直接跟花括号进行定义。此外函数原型可以置于main()主函数声明变量处

      • 函数类型

        1、函数变量一样有多种类型,任何程序在使用函数前都要声明函数类型
        2、void starbar(void);中第一个void表明函数返回类型void,即没有返回值starbar函数名;第二个void表明函数没有参数分号表明仅在声明函数不是在定义函数
        3、因此,函数名前类型仅表明函数返回值类型,而不是参数类型

      • 函数的跨文件调用

        1、程序把starbar()main()放在一个文件中,也可以将它们分别放在两个文件
        2、把函数都放在一个文件中的单文件形式比较容易编译,而使用多个文件方便在不同程序中使用同一个函数
        3、如果把函数放在单独的文件中,要把#defineinclude指令也放入该文件,稍后会讨论如何跨文件调用函数

  • 函数参数与返回值

    • 示例程序

      #include <stdio.h>
      
      int plus_multiply(int num1, int num2, int num3)
      {
          int result;
          result = (num1 + num2) * num3;
          return result;
      }
      
      int main(void)
      {
          int a, b, c;
          printf("计算(a+b)*c的结果,请分别输入a,b,c的值:\n");
          scanf("%d%d%d", &a, &b, &c);
          printf("%d", plus_multiply(a, b, c));
          return 0;
      }
    • 形参与实参

      1、形式参数函数定义函数头中声明的变量,称为形参
      2、实际参数:出现在函数调用圆括号内的表达式,称为实参

    • 函数参数的定义与使用

      1、在函数原型圆括号里,写入需要传入的参数类型,以及参数名,即定义形参。语法为void def (类型 形参1, 类型 形参2, ...),实例如void def (int a, float b)
      2、使用形参,需要先传入实参。在调用函数时,按顺序传入指定类型的值(即传入实参)

    • 返回值

      1、如果说参数是方便主调函数前往被调函数,那返回值便是方便被调函数前往主调函数。函数中为return语句后的值
      2、返回值的类型即为函数原型中定义的函数类型

ANSI C 函数原型

  • 旧式声明

    1、在ANSI C标准之前,声明函数的方案有缺陷,因为只需要声明函数类型,不用声明任何参数
    2、如int imin()这个函数声明,只需要告知编译器init()返回 int 类型的值
    3、然而,以上函数声明并未给出imin()函数的参数个数和类型。因此,如果调用imin()时使用的参数个数不对类型不匹配,编译器根本不会察觉

  • 问题所在

    • 示例程序

      #include <stdio.h>
      
      int imax(); // 旧式声明
      
      int main(void)
      {
          printf("%d和%d的最大值是%d\n", 3, 5, imax(3));
          printf("%d和%d的最大值是%d\n", 3, 5, imax(3.0, 5.0));
          return 0;
      }
      
      int imax(n, m)
      int n, m;
      {
          return (n > m ? n : m);
      }
      3和5的最大值是1606416656
      3和5的最大值是3886
    • 问题分析

      1、由于不同系统的内部机制不同,所以出现问题的具体情况也不同,下面介绍PCVAX的情况
      2、主调函数把它的参数储存在被称为临时储存区被调函数读取这些参数,而这两个过程并未互相协调
      3、主调函数根据函数调用中的实际参数决定传递的类型被调函数根据它的形式参数读取值。因此,函数调用imax(3)一个整数放在中,当函数开始执行时,它从读取两个整数,而实际只存放了一个待读取的整数,所以读取的第二个值是当时恰好在栈中其他值
      4、第二次使用imax()函数时,它传递的是float类型的值。这次把两个double类型的值放在中(当 float 作为参数传递会被升级成 double)。两个 double的值就是两个 64 位的值,所以弓128 位的数据被存放在中。当imax()读取两个 int值时,即读取前 64 位,于是出现错误

  • 解决方案

    • 针对参数不匹配的问题,ANSI C标准要求在声明函数时还要声明变量的类型,即使用函数原型来声明函数的返回类型、参数数量、参数类型

    • 未标明imax()函数有两个int类型的参数,可以使用下面两种函数原型来声明

      int imax(int, int);
      int imax(int a, int b);
    • 第一种形式使用以逗号分隔的类型列表,第二种形式在类型后面添加了变量名。注意,这里的变量名假名不必与函数定义的形式参数一致

递归

  • 递归:C 函数允许调用它自己,这种调用称为递归。递归有时难以捉摸,有时却很方便实用

  • 递归演示

    • 示例程序

      #include <stdio.h>
      
      void up_and_down(int n)
      {
          printf("Level %d: n location %p\n", n, &n); // #1
          if (n < 4)
          {
              up_and_down(n + 1);
          }
          printf("Level %d: n location %p\n", n, &n); // #2
      }
      
      int main(void)
      {
          up_and_down(1); // 调用递归函数
          return 0;
      }
      Level 1: n location 000000000061FE00
      Level 2: n location 000000000061FDD0
      Level 3: n location 000000000061FDA0
      Level 4: n location 000000000061FD70
      Level 4: n location 000000000061FD70
      Level 3: n location 000000000061FDA0
      Level 2: n location 000000000061FDD0
      Level 1: n location 000000000061FE00
    • 程序分析

      1、main()函数调用up_and_down(),称为第 1 级递归,然后up_and_down()调用自己,这次调用称为第 2 级递归,第 2 级再次调用,称为第 3 级递归,以此类推
      2、%p&用于显示变量的内存地址,稍后解释
      3、首先,main()函数调用带参数 1up_and_down()函数,此时n=1语句#1打印Level 1,由于n<4,执行调用实际参数n+1(即 2)的up_and_down()(第 2 级)
      4、第 2 级n=2,所以语句#1打印Level 2,以此类推继续递归调用
      5、当执行到第 4 级n=4if语句n<4false,所以跳过执行不再调用自己,第 4 级继续执行语句#2,打印Level 4
      6、此时第 4 级调用结束,控制返回它的主调函数(即第 3 级),第 3 级继续执行语句#2,打印Level 3,以此类推,直到第 1 级返回main()函数
      7、注意,每级递归的变量 n都属于每级递归私有,这点从程序输出的地址可以得出

  • 递归的基本原理

    1、每级递归调用都有自己的变量。也就是说,第 1 级的 n第 2 级的 n完全不同
    2、每次函数调用都会返回一次。当函数执行完毕,控制权将返回到上一级递归,程序必须按顺序逐级返回递归
    3、递归函数中位于递归调用之前的语句,均按被调函数的顺序执行。例如上例按序Level 1Level 2Level 3Level 4
    4、递归函数中位于递归调用之后的语句,均按被调函数相反的顺序执行。例如上例按序Level 4Level 3Level 2Level 1
    5、虽然每级递归都有自己的变量,但是并没有拷贝函数的代码。程序按顺序执行函数中的代码,递归调用相当于又从头开始按序执行函数的代码。除了为每次递归调用创建变量外,递归调用非常类似一个循环语句
    6、递归函数必须包含能让递归调用停止的语句。通常都使用if语句或其他等价的测试条件函数形参等于某特定值时终止递归。因此,每次递归调用的形参都要使用不同的值

  • 尾递归

    • 尾递归:把递归调用置于函数末尾。是最简单的递归形式,因为它相当于循环

    • 阶乘计算示例(5 的阶乘:1*2*3*4*5;0 的阶乘=1)

    #include <stdio.h>
    
    long fact(int n) // 直接使用for循环的函数
    {
        long ans;
        for (ans = 1; n > 1; n--) // 此处顺带初始化ans为1
        {
            ans *= n;
        }
        return ans;
    }
    
    long rfact(int n) // 使用递归的函数
    {
        long ans;
        if (n > 0)
        {
            ans = n * rfact(n - 1);
        }
        else
        {
            ans = 1;
        }
        return ans;
    }
    
    int main(void)
    {
        int num;
        do
        {
            printf("请输入一个0~12之间的整数:\n");
            scanf("%d", &num);
        } while (num < 0 || num > 12);
        printf("循环得到的结果:%ld\n", fact(num));
        printf("递归得到的结果:%ld\n", rfact(num));
        return 0;
    }
  • 递归和倒序计算

    • 递归处理倒序时非常方便,比循环更便捷

    • 示例程序:打印整数二进制

      #include <stdio.h>
      
      // 递归函数
      void to_binary(unsigned long n)
      {
          int r;
          r = n % 2;
          if (n >= 2)
              to_binary(n / 2);
          putchar(r == 0 ? '0' : '1');
      }
      
      int main(void)
      {
          unsigned long number;
          printf("输入整数:\n");
          scanf("%lu", &number);
          to_binary(number);
          return 0;
      }
  • 递归的优缺点

    1、递归既有优点也有缺点
    2、优点是递归对于某些编程问题提供了最简单的解决方案
    3、缺点是一些递归算法快速消耗计算机的内存资源,此外不便于阅读和维护

编译多源代码文件的程序

  • 使用多个函数最简单的方法是把它们都放在同一个文件中,然后像编译只有一个函数的文件那样编译该文件即可。其他方法因操作系统而异,下面举例说明

  • UNIX

    1、假定UNIX 系统中安装了UNIX C 编译器 cc,假设file1.cfile2.c是两个内涵 C 函数的文件
    2、使用cc file1.c file2.c可以将两个文件编译成一个名为a.out的可执行文件,并生成两个名为file1.ofile2.o的目标文件
    3、如果后来改动了file1.c而没有改动file2.c,可以使用cc file1.c file2.o来编译(如果file2.o文件还存在)

  • Linux

    1、假定Linux 系统中安装了GNU C 编译器 GCC,假设file1.cfile2.c是两个内涵 C 函数的文件
    2、使用gcc file1.c file2.c可以将两个文件编译成一个名为a.out的可执行文件,并生成两个名为file1.ofile2.o的目标文件
    3、如果后来改动了file1.c而没有改动file2.c,可以使用gcc file1.c file2.o来编译(如果file2.o文件还存在)

  • DOS 命令行编译器

    1、绝大多数DOS 命令行编译器的工作原理和UNIX 的 cc 命令类似,只不过使用不同名称而已
    2、一个区别是,对象文件的拓展名.obj而不是.o

  • Windows 和 Mac 的 IDE 编译器

    1、WindowsMac使用的集成开发环境 IDE的编译器是面向项目的,这种 IDE 的编译器要创建项目运行单文件程序
    2、对于多文件程序,需要使用相应的菜单命令,把源代码加入一个项目中。要确保所有源代码文件都在项目列表中列出

  • 使用头文件

    1、如果把main()放在第一个文件中函数定义放在第二个文件中,那么第一个文件仍然要使用函数原型
    2、而把函数原型放在头文件中,就不用每次使用函数文件都写出函数的原型
    3、此外,我们常常使用C 预处理器(#define)定义符号常量,也可以将其写入头文件,使用时只需要包含(#include)该头文件即可。这样更有利于维护修改,也利于对常量的管理
    4、因此,将函数原型字符常量放在头文件,是一个十分良好的编程习惯
    5、#include "xxx.h"命令可以引入自定义的头文件,使用双引号"",且引号内如果是同目录可以直接写文件名,不同目录也可以使用相对路径绝对路径

    • 如下案例,编写一个模拟酒店收费管理的程序,注意标注的文件名来区分文件,请使用多源代码文件编译方法编译文件(程序运行仍会从usehotel.cmain()主函数开始)

      /* hotel.h */
      #define QUIT 5
      #define HOTEL1 180.00
      #define HOTEL2 225.00
      #define HOTEL3 225.00
      #define HOTEL4 355.00
      #define DISCOUNT 0.95
      #define STARS "**************************************************"
      
      // 显示选择列表
      int menu(void);
      
      // 返回预定天数
      int getnights(void);
      
      // 计算费用并显示结果
      void showprice(double rate, int nights);
      /* hotel.c */
      #include <stdio.h>
      #include "hotel.h"
      int menu(void)
      {
          int code, status;
          printf("\n%s\n", STARS);
          printf("enter the number to desired hotel:\n");
          printf("1) XXX Hotel1            2) XXX Hotel2\n");
          printf("3) XXX Hotel3            4) XXX Hotel4\n");
          printf("5) Quit\n");
          printf("%s\n", STARS);
          while ((status = scanf("%d", &code)) != 1 || (code < 1 || code > 5))
          {
              if (status != 1)
                  scanf("%*s"); //处理非整数输入
              printf("Enter an integer from 1 to 5:\n");
          }
          return code;
      }
      
      int getnights(void)
      {
          int nights;
          printf("How many nights are you needed?\n");
          while (scanf("%d", &nights) != 1)
          {
              scanf("%*s"); //处理非整数输入
              printf("Enter an integer, such as 2\n");
          }
          return nights;
      }
      
      void showprice(double rate, int nights)
      {
          int n;
          double total = 0.0;
          double factor = 1.0;
          for (n = 1; n <= nights; n++, factor *= DISCOUNT)
              total += rate * factor;
          printf("The total cost will be &%0.2f.\n", total);
      }
      /* usehotel.c */
      #include <stdio.h>
      #include "hotel.h"
      int main(void)
      {
          int nights;
          double hotel_rate;
          int code;
          while ((code = menu()) != QUIT)
          {
              switch (code)
              {
                  case 1:
                      hotel_rate = HOTEL1;
                      break;
                  case 2:
                      hotel_rate = HOTEL2;
                      break;
                  case 3:
                      hotel_rate = HOTEL3;
                      break;
                  case 4:
                      hotel_rate = HOTEL4;
                      break;
                  default:
                      printf("Oops!\n");
                      break;
              }
              nights = getnights();
              showprice(hotel_rate,nights);
          }
          printf("Thank you and goodbye\n");
          return 0;
      }
    • 此外,函数也可以直接定义在头文件内,因此上述程序写为单源代码文件的方式可以精简如下

      /* hotel.h */
      #include <stdio.h> // 注意引入头文件
      #define QUIT 5
      #define HOTEL1 180.00
      #define HOTEL2 225.00
      #define HOTEL3 225.00
      #define HOTEL4 355.00
      #define DISCOUNT 0.95
      #define STARS "**************************************************"
      
      // 显示选择列表
      int menu(void)
      {
          int code, status;
          printf("\n%s\n", STARS);
          printf("enter the number to desired hotel:\n");
          printf("1) XXX Hotel1            2) XXX Hotel2\n");
          printf("3) XXX Hotel3            4) XXX Hotel4\n");
          printf("5) Quit\n");
          printf("%s\n", STARS);
          while ((status = scanf("%d", &code)) != 1 || (code < 1 || code > 5))
          {
              if (status != 1)
                  scanf("%*s"); //处理非整数输入
              printf("Enter an integer from 1 to 5:\n");
          }
          return code;
      }
      
      // 返回预定天数
      int getnights(void)
      {
          int nights;
          printf("How many nights are you needed?\n");
          while (scanf("%d", &nights) != 1)
          {
              scanf("%*s"); //处理非整数输入
              printf("Enter an integer, such as 2\n");
          }
          return nights;
      }
      
      // 计算费用并显示结果
      void showprice(double rate, int nights)
      {
          int n;
          double total = 0.0;
          double factor = 1.0;
          for (n = 1; n <= nights; n++, factor *= DISCOUNT)
              total += rate * factor;
          printf("The total cost will be &%0.2f.\n", total);
      }
      /* usehotel.c */
      同上例文件,写法不变
      此外由于hotel.h中引入了stdio.h,而本文件又调用了hotel.h,所以可以不再调用stdio.h,即删除本文件的#include <stdio.h>

查找地址:&运算符

  • 指针是 C 语言中最重要的(有时也是最复杂的)概念之一,用于存储变量的地址。前面使用的scanf()函数中就使用地址作为参数

  • 如果主调函数不使用return返回的值,则必须通过地址才能修改主调函数中的值

  • 一元&运算符的用法

    • 一元&运算符给出变量的储存地址,如果pooh是变量名,那么&pooh是变量的地址。可以把地址看做是变量在内存中的位置

    • PC 地址通常使用十六进制表示,%p是输出地址的转换说明

    • 示例程序:查看不同函数中同名变量分别储存在什么位置

      #include <stdio.h>
      
      void mikado(int bah)
      {
          int pooh = 10;
          printf("In mikado(), pooh= %d and &pooh= %p\n", pooh, &pooh);
          printf("In mikado(), bah= %d and &bah= %p\n", bah, &bah);
      }
      
      int main(void)
      {
          int pooh = 2, bah = 5;
          printf("In main(), pooh= %d and &pooh= %p\n", pooh, &pooh);
          printf("In main(), bah= %d and &bah= %p\n", bah, &bah);
          mikado(pooh);
          return 0;
      }
      In main(), pooh= 2 and &pooh= 000000000061FE1C
      In main(), bah= 5 and &bah= 000000000061FE18
      In mikado(), pooh= 10 and &pooh= 000000000061FDDC
      In mikado(), bah= 2 and &bah= 000000000061FDF0
    • 输出解析

      1、两个pooh地址不同,两个bah的地址也不同,因此证实计算机把它们看做4 个独立的变量
      2、函数调用mikado(pooh)实参 pooh=2传递给了形参 bah。注意这种传递只传递了值,涉及的两个变量并未改变
      3、注意第 2 点并非在所有语言都成立。如FORTRAN中,子例程会影响主调例程原始变量。子例程变量名可能与原始变量不同,但它们的地址相同。但在 C 中不是这样,每个C 函数都有自己的变量,这样可以防止原始变量被调函数的副作用意外修改,但也带来了一些麻烦

更改主调函数中的变量

  • 有时需要在一个函数中改变其他函数的变量,则需要使用指针

  • 程序示例(错误的方式使用函数交换两个变量的)

    #include <stdio.h>
    
    void change(int u, int v)
    {
        int temp;
        temp = u;
        u = v;
        v = temp;
        printf("u:%d    v:%d\n",u,v);
    }
    
    int main(void)
    {
        int x = 5, y = 10;
        printf("before: %d %d\n", x, y);
        change(x, y);
        printf("after: %d %d\n", x, y);
        return 0;
    }
  • 问题解析

    1、但显然该程序main()xy的值并未交换,而交换函数change()内的uv的值是交换的。问题出现在把结果传回main()时。
    2、change()的变量并不是main()的变量,因此交换的并不会影响main()中的
    3、能否使用return将值传回main()?当然可以,但return只能把被调函数中的一个值传回,但现在要传回两个值,因此需要使用指针

指针简介

  • 基本概念

    • 指针:一个内存地址变量(或数据对象)。正如char类型变量值是字符int类型变量值是整数指针变量的值是地址

    • 假设一个指针变量名ptr,则可以编写ptr = &pooh这条语句

      1、对于这条语句,我们说ptr 指向 pooh
      2、指针 ptr地址 &pooh区别是,指针 ptr变量地址 &pooh常量。或者说,指针 ptr可修改左值地址 &pooh右值
      3、我们当然还可以把 ptr 指向别处,如ptr = &bah,现在ptr 指向 bahbah 的地址

    • 要创建指针变量,要先声明指针变量的类型。假设想把ptr声明为储存 int 类型变量地址的指针,就要使用下面介绍的新运算符

  • 间接运算符:*

    1、假设已知ptr = &bah;,即ptr 指向 bah
    2、使用间接运算符*,可以找出储存在 bah 中,语句为:val = *ptr;,意为找出 ptr 指向的值
    3、该运算符有时也称为解引用运算符。但注意不要将其与二元乘法运算符混淆,虽然符号相同,但语法功能不同
    4、将语句ptr = &bahval = *ptr放在一起,其功能作用相当于此赋值语句val = bah;
    5、由此可见,使用地址间接运算符可以间接完成上面赋值语句的功能,这也是其名称的由来

  • 声明指针

    • 声明指针变量示例

      int * pi;               // 指向int类型变量的指针
      char * pc;              // 指向char类型变量的指针
      float * pf, * pg;       // 指向float类型变量的指针
    • 声明解析

      1、类型说明符表明了指针所指向对象类型星号表明声明的变量是一个指针
      2、*指针名之间的空格可有可无,通常在声明时使用空格,在解引用时省略空格
      3、pc 指向的值(即*pc)是char类型,而pc 本身的类型描述为”指向 char 类型的指针
      4、在大部分系统内部,该地址由一个无符号整数表示。但不要指针认为是整数类型,为此,ANSI C专门为指针提供了%p格式转换说明

  • 使用指针在函数间通信

    • 在上节改变主调函数中的变量中的程序,通过函数调换两个变量的值不能成功,在此可以通过指针实现

    • 示例程序

      #include <stdio.h>
      
      void change(int *u, int *v)
      {
          int temp;
          temp = *u;
          *u = *v;
          *v = temp;
      }
      
      int main(void)
      {
          int x = 5, y = 10;
          printf("before: %d %d\n", x, y);
          change(&x, &y);
          printf("after: %d %d\n", x, y);
          return 0;
      }
    • 程序解析

      1、该函数传递的不是x 和 y,而是他们的地址。这意味着出现在change()原型和定义中的形参 u 和 v地址作为它们的。因此应把他们声明为指针。由于x 和 y整数,所以u 和 v指向整数的指针
      2、在函数体中声明了一个交换值时必需的临时变量 temp,通过temp = *u;x 的值存储在temp
      3、注意,u 的值&x,这意味着可以用*u表示x 的值,这正是我们需要的。不要写成temp = u;,该语句意为把x 的地址赋给temp(u 的值就是 x 的地址),而不是 x 的值
      4、于是,通过这种形式进行交换,就可以做到修改主调函数的值的需求了


数组与指针


章节概要:数组;数组复习;初始化数组;只读数组;指定初始化器;指定初始化器的特性;数组元素赋值;数组边界;多维数组;二维数组;二维数组的声明;其他多维数组;指针和数组;指针处理数组;函数、数组与指针(声明数组形参);使用指针形参;指针操作;不要解引用未初始化的指针;保护数组中的数据;对形参使用const;其他const内容;指针和多维数组;通过指针表示二维数组的值;数组指针与指针、多维数组深入;指向多维数组的指针;指针的兼容性;C constC++ const;函数和多维数组指针;变长数组(VLA);复合字面量

数组

  • 数组复习

    • 声明示例

      int main(void)
      {
          float candy[365];       // 内含365个float类型元素的数组
          char code[12];          // 内含12个char类型元素的数组
          int states[50];         // 内含50个int类型元素的数组
          ...
      }
    • 数组使用规则

      1、前面介绍过,数组数据类型相同一系列元素组成。需要使用数组时,通过声明数组告诉编译器数组内含有多少元素元素类型编译器根据这些信息正确的创建数组
      2、普通变量可以使用的类型数组元素都可以用
      3、方括号[]表示candycodestates都是数组,方括号中的数字表示数组中的元素个数
      4、要访问数组中的元素,通过使用数组下标数(也称为索引)表示数组中的各个元素。数组元素编号0开始,所以candy[0]表示candy第 1 个元素candy[364]表示第 365 个元素,即最后一个元素

  • 初始化数组

    • 数组通常被用来储存程序需要的数据。例如,一个内涵12 个整数元素的数组可以储存 12 个月的天数。这种情况下,在程序一开始初始化数组比较好

    • 初始化数组的方法

      • 存储单个值变量有时也称为标量变量,我们已经很熟悉如何初始化这种变量(代码中PI已被定义为):

        int fix = 1;
        float flax = PI * 2;
      • 而 C 使用新的语法初始化数组

        int power[8] = {1, 2, 4, 6, 8, 16, 32, 64};       // ANSI C开始支持这种初始化
      • 语法解析

        1、如上所示,用以逗号分隔值列表(用花括号括起来)来初始化数组各值之间逗号分隔(逗号和值之间可以使用空格)
        2、根据上面的初始化,把1赋给数组首元素 power[0]2赋给power[1]按序以此类推(注意 64 赋给的末元素是 power[7])
        3、不支持ANSI C的编译器会把这种初始化识别为错误,在数组声明前加上关键字static即可解决(12 章将讨论此关键字)

    • 使用 const 声明数组

      1、有时需要把数组设置为只读,这样只能从数组中检索值,不能把新值写入
      2、要创建只读数组,应该用const声明和初始化数组,即const int days[12] = {..., ..., ...}
      3、这样修改后,程序在运行过程中不能修改数组的内容。一旦声明为const,便不能再给它赋值

    • 自动适配数组大小

      #include <stdio.h>
      int main(void)
      {
          const int days[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31};
          for (int index = 0; index < sizeof(days) / sizeof(days[0]); index++)
          {
              printf("Month %2d has %d days.\n", index + 1, days[index]);
          }
          return 0;
      }
    • 注意事项

      1、 使用数组前必须先初始化。与普通变量类似,在使用数组元素必须先给它们赋初值,否则编译器使用的值是内存相应位置上的现有值(即都是垃圾值,会干扰程序运行)。只要初始化至少1 个元素的值,其余未初始化的值也会被初始化为 0
      2、如果初始化数组省略方括号中的数字编译器会根据初始化列表中的项数来确定数组大小(如上”自动适配数组大小”)
      3、使用sizeof()计算数组大小(字节)时,sizeof(days)整个数组的大小,sizeof(days[0])是数组中一个元素的大小

  • 指定初始化器

    • C99新增加了一个新特性指定初始化器。利用该特性可以初始化指定的数组元素。例如只初始化最后一个元素

      1、传统 C 初始化:int arr[6] = {0, 0, 0, 0, 0, 212};
      2、C99规定,可以在初始化列表中使用带方括号下标指明待初始化的元素int arr[6] = { [5] = 212 };

    • 复杂示例

      #include <stdio.h>
      int main(void)
      {
          int arr[12] = {31, 28, [4] = 31, 30, 31, [1] = 29};
          for (int i = 0; i < 12; i++)
          {
              printf("index:%-3d    value:%d\n", i, arr[i]);
          }
          return 0;
      }
      index:0      value:31
      index:1      value:29
      index:2      value:0
      index:3      value:0
      index:4      value:31
      index:5      value:30
      index:6      value:31
      index:7      value:0
      index:8      value:0
      index:9      value:0
      index:10     value:0
      index:11     value:0
    • 特性解析

      1、以上输出揭示了指定初始化器两个重要特性
      2、如果指定初始化器后面有更多值,如该例[4] = 31, 30, 31,那么后面的值将被用于初始化指定元素后面的元素(即 arr[5]和 arr[6]被初始化为 30 和 31)
      3、如果再次初始化指定元素,那么最后的初始化将会取代之前的初始化(如 arr[1]先被初始化为 28,后被指定初始化[1] = 29初始化为 29)
      4、如果未指定元素大小,如int arr[] = {1, [6]=4, 9, 10};,编译器将会把数组大小设置为足够装得下初始化的值(即该例下标应为 0~8,共 9 个元素)

  • 数组元素赋值

    • 示例程序

      #include <stdio.h>
      int main(void)
      {
          int arr[50];
          for (int i = 0; i < 50; i++)
          {
              arr[i] = i*2;
          }
          arr[6] = 10;
          return 0;
      }
    • 示例解析

      1、声明数组后,可以借助数组下标给数组元素赋值,如已定义int arr[20];则可使用arr[6] = 10;来赋值对应元素
      2、注意多个元素赋值应通过循环遍历依次赋值。C不允许数组作为一个单元赋给另一个数组初始化外不允许使用花括号列表赋值

  • 数组边界

    1、在使用数组时,要防止数组下标越界,必须确保下标有效的值
    2、假设有int doofi[20];声明,则使用时数组下标应在0~19 的范围内
    3、编译器不会检查这种错误,但是一些编译器会发出警告,然后继续编译程序
    4、在 C 标准中,使用越界下标的结果是未定义的。这意味着程序可能看上去可以运行,但是运行结果很奇怪,或异常终止
    5、C 语言为什么会允许这种事发生?这要归功于C 信任程序员的原则。编译器没必要捕获所有下标错误,这会降低运行速度;C 相信程序员能编写正确的代码不检查边界,这样程序运行速度更快

多维数组

  • 概念引入与分析

    1、假如需要记录5 年内每个月的降水量,应该如何更方便的储存数据?
    2、第一种方案,创建60 个变量,分别储存每个月的数据。但显然十分麻烦
    3、第二种方案,使用内涵 60 个元素的数组每个元素恰好表示每月的数据。这种更加可行,但无法分辨年份
    4、第三种方案,创建5 个分别内涵 12 个元素的数组,以此分辨年份。但这种方案也很麻烦,且不能满足更多年份的需求
    5、第四种方案,使用二维数组,下面介绍此种方案

  • 二维数组

    • 结合上例,可以将二维数组理解为数组的数组主数组5 个元素(表示 5 年),这 5 个元素每个元素都是内涵 12 个元素的数组(表示每年 12 个月)
  • 二维数组的声明

    1、使用float rain[5][12]声明符合本需求的二维数组
    2、分开来看,rain[5]表示数组rain 有 5 个元素,至于每个元素的情况,要看声明的其余部分
    3、float[12]说明每个元素的类型float[12]。即rain 的每个元素本身都是一个内含 12 个 float 类型值数组

  • 示例程序(十分重要,请理解代码,教程夹杂在代码中)

    #include <stdio.h>
    
    #define MONTHS 12
    #define YEARS 5
    #define LINE "========================================================="
    
    int main(void)
    {
        // 声明二维数组并初始化(这样初始化换行为了方便查看,也可以按规定格式写在一行)
        float rain[YEARS][MONTHS] = {
            {4.3, 4.3, 4.3, 3.0, 2.0, 1.2, 0.2, 0.2, 0.4, 2.4, 3.5, 6.6},
            {8.5, 8.2, 1.2, 1.6, 2.4, 0.0, 5.2, 0.9, 0.3, 0.9, 1.4, 7.3},
            {9.1, 8.5, 6.7, 4.3, 2.1, 0.8, 0.2, 0.2, 1.1, 2.3, 6.1, 8.4},
            {7.2, 9.9, 8.4, 3.3, 1.2, 0.8, 0.4, 0.0, 0.6, 1.7, 4.3, 6.2},
            {7.6, 5.6, 3.8, 2.8, 3.8, 0.2, 0.0, 0.0, 0.0, 1.3, 2.6, 5.2}};
        int year, month;
        float subtot, total;
    
        /* 输出年总降水与年均降水 */
        printf("%s\n", LINE);
        printf("年份               降水量(英尺)\n");
        // year遍历年份,total计算所有年份(5年)总降水量
        for (year = 0, total = 0; year < YEARS; year++)
        {
            // month遍历月份,subtot计算每年总降水量
            for (month = 0, subtot = 0; month < MONTHS; month++)
            {
                subtot += rain[year][month];
            }
            printf("%4d %20.1f\n", 2018 + year, subtot);
            total += subtot;
        }
        printf("年均降水:%.1f\n", total / YEARS);
        printf("%s\n", LINE);
    
        /* 输出降水详情 */
        printf("     Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec\n");
        for (year = 0; year < YEARS; year++)
        {
            printf("%d ", 2018 + year);
            for (month = 0; month < MONTHS; month++)
            {
                printf("%.1f ", rain[year][month]);
            }
            printf("\n");
        }
        printf("%s", LINE);
        return 0;
    }
    =========================================================
    年份               降水量(英尺)
    2018                 32.4
    2019                 37.9
    2020                 49.8
    2021                 44.0
    2022                 32.9
    年均降水:39.4
    =========================================================
         Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
    2018 4.3 4.3 4.3 3.0 2.0 1.2 0.2 0.2 0.4 2.4 3.5 6.6
    2019 8.5 8.2 1.2 1.6 2.4 0.0 5.2 0.9 0.3 0.9 1.4 7.3
    2020 9.1 8.5 6.7 4.3 2.1 0.8 0.2 0.2 1.1 2.3 6.1 8.4
    2021 7.2 9.9 8.4 3.3 1.2 0.8 0.4 0.0 0.6 1.7 4.3 6.2
    2022 7.6 5.6 3.8 2.8 3.8 0.2 0.0 0.0 0.0 1.3 2.6 5.2
    =========================================================
  • 其他多维数组

    • 前面讨论的二维数组相关内容都适用三维数组更多维的数组。例如可以通过int box[10][20][30];声明一个三维数组
    • 同样面对更多维的数组,需要更多的循环嵌套遍历,理解好每层循环控制的元素,才能够准确地操控数组
    • 实际使用中,更多维的数组出现概率不高,通常只需要二维数组就能完成大多数需求

指针和数组

  • 第 9 章介绍过指针,指针提供一种以符号形式使用地址的方法。因为计算机的硬件指令非常依赖地址,指针在某种程度上把程序员想要传达的指令更接近机器的方式表达,因此使用指针的程序很有效率

  • 指针处理数组

    • 引入

      1、指针能有效地处理数组数组表示法其实是在变相地使用指针
      2、举个简单的例子,数组名是数组的首元素地址,即arr == &arr[0]成立
      3、两者都是常量,运行过程中不会改变。但是可以将它们赋值指针变量,然后可以修改指针变量的值

    • 示例程序

      #include <stdio.h>
      #define SIZE 4
      
      int main(void)
      {
          short dates[SIZE];
          short *pti;
          double bills[SIZE];
          double *ptf;
          pti = dates; // 把数组地址赋给指针,数组名是数组首元素地址
          ptf = bills;
          printf("%23s %15s\n", "short", "double");
          for (int index = 0; index < SIZE; index++)
              printf("pointers + %d: %10p %10p\n", index, pti + index, ptf + index);
          return 0;
      }
                        short          double
      pointers + 0: 000000000061FE00 000000000061FDE0
      pointers + 1: 000000000061FE02 000000000061FDE8
      pointers + 2: 000000000061FE04 000000000061FDF0
      pointers + 3: 000000000061FE06 000000000061FDF8
    • 程序解析

      1、第 2 行起打印两个数组的地址,下一行打印的是指针+1 后的地址。地址为十六进制,因此DFDE
      2、系统中,地址按字节编址short2 字节double8 字节。在 C 中,地址+1指的是增加一个存储单元。对数组而言,这意味着地址+1 后下一个元素的地址,而不是下一个字节的地址
      3、这便是为何必须声明指针所指向的对象类型的原因之一。只知道地址不够,还需要知道储存对象需要多少字节,否则指针无法正确取回地址上的

    • 特性总结补充

      1、指针的值它所指向对象的地址。地址的表示方式依赖于计算机内部的硬件,大部分都是按字节编址,即内存中每个字节按顺序编号。一个较大对象(如 double 的 8 字节)的地址通常是第一个字节的地址
      2、在指针前面使用*运算符可以得到该指针所指向对象的值
      3、指针+1,指针的值递增所指向类型的大小(字节为单位)

函数、数组与指针(声明数组形参)

  • 引入

    1、假设要编写一个处理数组的函数,该函数返回数组中所有元素之和
    2、此时注意,由于数组名是数组首元素地址,所以实参是一个存储对应类型值的地址而不是数值,因此传参时应把它赋给一个指针形式参数,即形参应为指向对应类型的指针
    3、此时函数获得了该数组首元素的地址,且知道需要找出的值的数据类型,但并未获得数组元素个数,有两种方法:一种方式可以在函数中需要时直接人为写入数组元素个数(比如for遍历时的条件直接写入元素个数)来告知此信息,但这样不利于维护;另一种方式是创建形参,将元素个数传入函数

  • 示例程序

    #include <stdio.h>
    #define SIZE 10
    
    // 此处也可写为:
    // int def(int * ar, int n)
    int def(int ar[], int n)
    {
        int sum = 0;
        for (int i = 0; i < n; i++)
            sum += ar[i];
        printf("ar的大小是 %zd bytes\n", sizeof(ar));
        return sum;
    }
    
    int main(void)
    {
        int arr[SIZE] = {20, 10, 5, 39, 4, 16, 19, 26, 31, 20};
        long answer;
        answer = def(arr, SIZE);
        printf("数值相加为 %d\n", answer);
        printf("arr的大小是 %zd bytes\n", sizeof(arr));
        return 0;
    }
    ar的大小是 8 bytes
    数值相加为 190
    arr的大小是 40 bytes
  • 程序解析

    1、函数第 1 个形参告诉函数数组地址数据类型第 2 个形参告诉函数数组的元素个数
    2、只有在函数原型函数定义头时,才可以用int ar[]代替int * ar(某些编译器可能对于前者会报警报 warning)。在这种情况下,int * arint ar[]都表示ar是一个指向 int 的指针,但是int ar[]只能用于声明形参int ar[]提醒读者,不仅是一个int 类型值,还是一个int 类型数组的元素
    3、arr大小是40 字节,因为其内含 10 个 int 类型值ar只有8 字节,因为其是指向arr数组首元素的指针我们的系统使用8 字节存储地址(其他系统可能不同),所以指针变量大小8 字节

  • 声明数组形参

    • 因为数组名是该数组首元素的地址,作为实参数组名要求形参是一个与之相配的指针。只有这种情况下,C 才会把int ar[]int * ar解释成一样

    • 注意上方示例程序函数原型函数定义写在一起,因此对于下方的解释,应按照函数定义标准,而非函数原型标准

    • 由于函数原型可以省略参数名,所以下面 4 种原型等价

      int def(int * ar, int n);
      int def(int *, int);
      int def(int ar[], int n);
      int def(int [], int);
    • 函数定义不能省略参数名,因此只有下面 2 种的定义等价

      int def(int * ar, int n){
          // 省略函数内代码
      }
      
      int def(int ar[], int n){
          // 省略函数内代码
      }

使用指针形参

  • 函数要处理数组必须知道何时开始、何时结束。上节已展示一种方式标识函数开始与元素个数,而这并非唯一途径。第二种方式是传递两个指针,一个表明数组开始处,一个表明数组结束处

  • 程序示例

    #include <stdio.h>
    #define SIZE 10
    
    int def(int *start, int *end)
    {
        int sum = 0;
        while (start < end)
        {
            sum += *start;
            start++;
        }
        return sum;
    }
    
    int main(void)
    {
        int arr[SIZE] = {20, 10, 5, 39, 4, 16, 19, 26, 31, 20};
        long answer;
        answer = def(arr, arr + SIZE);
        printf("数值相加为 %d", answer);
        return 0;
    }
  • 程序解析

    1、指针start开始指向arr数组的首元素地址,所以赋值表达式sum += *start首元素的值加给sum(前面讲过*指针变量表示获取对应地址的)
    2、表达式start++递增指针变量start,使其指向数组下一个元素(前面讲过指针递增 1相当于递增对应类型的大小,此处即为递增 int 类型的大小)
    3、程序的while循环,使用第二个指针 end来设定范围,告知函数数组的大小
    4、while循环的条件使用了小于,即循环处理的最后一个元素end所指向位置的前一个元素。这是由于end(arr + SIZE)指向的位置实际是数组最后一个元素(arr[9])的后面(并不存在的 arr[10]),本机测试时arr[9]地址尾缀fe14end尾缀fe18。C 保证在给数组分配空间时,指向数组后面第一个位置的指针仍是有效的指针
    5、因此结合第 4 条,如果按照常规逻辑end应被传入arr + SIZE -1以正确指向最后的元素arr[9]。只是这种写法既不简洁也不好记,于是使用了上面的写法

指针操作

  • 可以对指针进行哪些操作?C 提供一些基本的指针操作,下面的程序演示一些不同的操作

  • 程序示例

    #include <stdio.h>
    int main(void)
    {
        int urn[5] = {100, 200, 300, 400, 500};
        int *ptr1, *ptr2, *ptr3;
    
        ptr1 = urn;     // 把一个地址赋给指针
        ptr2 = &urn[2]; // 把第一个地址赋给指针
    
        // 1.解引用指针,以及获得指针的地址
        printf("1. ptr1 = %p, *ptr1 = %d, &ptr1 = %p\n", ptr1, *ptr1, &ptr1);
    
        // 2.指针加法
        ptr3 = ptr1 + 4;
        printf("2. ptr1+4 = %p, *(ptr1+4) = %d\n", ptr1 + 4, *(ptr1 + 4));
    
        // 3.指针递增
        ptr1++;
        printf("3. ptr1 = %p, *ptr1 = %d, &ptr1 = %p\n", ptr1, *ptr1, &ptr1);
    
        // 4.指针递减
        ptr2--;
        printf("4. ptr2 = %p, *ptr2 = %d, &ptr2 = %p\n", ptr2, *ptr2, &ptr2);
    
        --ptr1; // 恢复初始值
        ++ptr2; // 恢复初始值
    
        // 5.指针减另一个指针
        printf("5. ptr2 = %p, ptr1 = %p, ptr2-ptr1 = %td\n", ptr2, ptr1, ptr2 - ptr1);
    
        // 6.指针减一个整数
        printf("6 .ptr3 = %p, ptr3-2 = %p\n", ptr3, ptr3 - 2);
    
        return 0;
    }
    1. ptr1 = 000000000061FE00, *ptr1 = 100, &ptr1 = 000000000061FDF8
    2. ptr1+4 = 000000000061FE10, *(ptr1+4) = 500
    3. ptr1 = 000000000061FE04, *ptr1 = 200, &ptr1 = 000000000061FDF8
    4. ptr2 = 000000000061FE04, *ptr2 = 200, &ptr2 = 000000000061FDF0
    5. ptr2 = 000000000061FE08, ptr1 = 000000000061FE00, ptr2-ptr1 = 2
    6. ptr3 = 000000000061FE10, ptr3-2 = 000000000061FE08
  • 指针变量的基本操作

    • 赋值

      1、可以把地址赋给指针。例如,用数组名带地址运算符的变量名(&a)、另一个指针等进行赋值
      2、该例中,urn 数组的首地址赋给ptr1,其地址编号尾号FE00,变量ptr2获得数组 urn 的第 3 个元素的地址,即urn[2]的地址

    • 解引用

      1、*运算符给出指针指向地址储存的值
      2、因此,*ptr的初值是100,该值储存在编号尾号FE00地址

    • 取址

      1、和所有变量一样,指针变量也有自己的地址。对指针而言,&运算符给出指针本身地址
      2、该例中,ptr1储存在内存编号尾号FDF8的地址上,其为编号尾号FE00的地址(即 urn 的地址)
      3、因此,&ptr1是指向ptr1的指针,ptr1是指向urn[0]的指针

    • 指针和整数相加

      1、可以使用+运算符指针和整数相加,或者整数和指针相加
      2、无论哪种情况,整数都会和指针所指向类型的大小(字节为单位)相乘,再与初始地址相加
      3、因此ptr1 + 4&urn[4]等价。如果结果超出数组范围,计算结果是未定义的,除非超出数组末尾第一个位置(前面讲过,C 保证该指针有效)

    • 指针减去一个整数

      1、可以使用-运算符从一个指针减去一个整数指针必须是第 1 个运算对象整数第 2 个运算对象
      2、其运算规则指针+整数相同

    • 递增指针

      1、递增指向数组的元素指针可以让该指针移动到数组下一元素
      2、因此,ptr1++相当于把 ptr1 的值+4(因为本系统 int 为 4 字节),ptr1指向urn[1]
      3、注意程序中还输出了ptr1 的地址,其并未发生变化。,因为指针变量也是变量,变量不会因为值发生变化移动位置

    • 递减指针

      1、当然,除了递增指针,还可以递减指针
      2、其使用方法递增指针相同

    • 指针求差

      1、可以计算两个指针差值。通常,求差的两个指针分别指向同一个数组的不同元素,通过计算求出两元素之间的距离差值的单位数组类型单位相同
      2、该例中,ptr2 - ptr1 = 2意为这两个指针所指向的两个元素相隔2 个 int,而不是 2 字节
      3、只要两个指针都指向相同的数组,C 都能保证运算有效。如果指向不同数组,求差运算可能会得出一个值,或者导致运行时错误

    • 比较

      1、使用关系运算符可以比较两个指针的值,前提是它们指向相同类型的对象

  • 不要解引用未初始化的指针

    • 注意事项:千万不要解引用未初始化的指针,如下:

      int *pt;    // 未初始化的指针
      *pt = 5;    // 严重的错误
    • 原因说明

      1、为何不行?第 2 行的意思是把 5 存储在 pt 指向的位置。但pt 未初始化,其是一个随机值,所以不知道 5 将存储在何处
      2、这可能不会出什么错,也可能会擦写数据或代码,或者导致程序崩溃
      3、创建一个指针时,系统只分配了储存指针本身的内存,并未分配存储数据的内存。因此使用指针前,必须先用已分配的地址初始化

保护数组中的数据

  • 引入

    1、编写一个处理基本类型函数时,需要选择是传递还是指针。通常都是传递数值,只有程序需要在函数内改变该值时,才会传递指针
    2、对于数组别无选择,必须传递指针。因为这样效率更高,如果按值传递,则必须分配足够空间将值拷贝新数组
    3、C 通常按值传递数据,这样可以保证数据完整性,使用的是原始数据的副本而非原始数据本身,这样便可以保护原数据。但对于数组,我们需要一种方式来保护数组中的数据

  • 对形参使用 const

    1、如果函数的意图不是修改数组中的数据内容,那么函数原型函数定义可以使用只读——const关键字
    2、const告诉编译器,函数不能修改指针指向的数组的内容。如若使用arr[i]++这样的表达式会生成错误信息
    3、函数声明例如void def(const int ar[]),这样便可以保护数组的数据

  • 其他 const 内容

    • 其他 const 的使用

      1、之前我们使用const创建变量。虽然使用#define也能创建符号常量,但const更为灵活,可以创建const数组、const指针和指向const的指针。
      2、下面举例(默认已声明int arr[5];)
      3、如指向const指针const int *ptr = arr;,将不允许通过 ptr 修改指向数据的值。但注意arr并未被声明为const,所以仍可通过 arr 修改元素的值:arr[0] = 10;。此外也可以让 ptr 指向别处ptr++; // 指向arr[1]
      4、此外可以声明并初始化一个不能指向别处const指针,特别注意const的位置int * const ptr = arr;可以用这种指针修改指向的值,但不能更改指向的地址
      5、如果创建指针时使用两次const,这样便既不能修改指向地址的数据不能修改指向的地址const int * const ptr = arr;

    • 其他 const 的规则

      1、把constconst数据的地址初始化为指向const的指针合法的,但const数据的地址只能赋给指向const的指针,赋值给普通指针非法的
      2、这个规则非常合理,否则通过普通指针就能修改const数组的数据
      3、因此,对函数的形参使用const不仅能保护数据,还能让函数处理const数组

      int arr[5] = {};
      const int locked[5] = {};
      const int *ptr1;
      int *ptr2;
      
      ptr1 = arr;       // 有效(指向非const数据的地址)
      ptr1 = locked;    // 有效(只是不能通过ptr1改变指向的值,但可以更改ptr1指向的对象。指向const数据的地址)
      ptr1 = &arr[3];   // 有效(指向非const数据的地址)
      ptr2 = arr;       // 有效(普通指针指向普通地址)
      ptr2 = locked;    // 无效(普通指针不能指向const数据的地址,只能通过指向const的指针指向此数据)

指针和多维数组

  • 引入

    • 假设有int arr[4][2];的声明。数组名 arr是该数组的首元素地址。在本例中,arr首元素是一个内含两个 int 值数组(由 arr 声明时第二维为[2]表明),所以arr这个数组(即内含两个 int 值的数组)的地址
  • 从指针属性进一步分析(可结合下方辅助理解示例来理解)

    1、因为arr首元素地址(即第一维首元素arr[0]的地址),所以在地址上arr = &arr[0],其值相同。而arr[0]本身是一个内含两个整数数组,所以arr[0]的值和它的首元素(一个整数)地址(即&arr[0][0])相同,即在地址上arr[0] = &arr[0][0](此处可以像第一维一样,假设将二维数组的arr[0]看做一维的arr,将二维数组的arr[0]的元素arr[0][0]arr[0][1]分别看做一维的arr[0]arr[1],方便理解)
    2、简而言之,arr是一个占用两个 int 大小对象地址(因为其一个元素(arr[0])内含两个 int 值),而arr[0]是一个占用一个 int 大小对象地址。但由于内含两个整数的数组(arr[0])和这个整数(arr[0][0])起始于同一个地址,所以arrarr[0]地址相同。综上,在地址上arr = arr[0] = &arr[0] = &arr[0][0],因为都指向整个二维数组的起始地址(即最根本的第一个数值的位置arr[0][0])
    3、给指针或地址+1,其值会增加对应类型大小的数值。在这方面,arrarr[0]不同,因为如上所言arr指向的对象占两个 int 大小,而arr[0]指向对象占一个 int 大小。因此arr + 1arr[0] + 1的值不同
    4、解引用(使用*符)指针或通过数组下标[],可以得到引用对象的。因为arr[0]作为数组名数组首元素(即arr[0][0])的地址,所以解引用*(arr[0])得到的是存储在arr[0][0]上的。与此类似,arr作为数组名代表首元素(即arr[0])的地址,但arr[0]本身还是一个地址,其地址是&arr[0][0],所以解引用*arr就是&arr[0][0](注意这里解引用后*arr的值就是地址,而不是数值),此时再次解引用**arr就相当于*&arr[0][0],即取得arr[0][0]指向的
    5、简而言之,arr作为数组名(首元素地址),是地址的地址,必须解引用两次才能获得原始值。此处地址的地址或者指针的指针就是双重间接的例子

  • 针对上述 1、2 条的辅助理解示例

    #include <stdio.h>
    int main(void)
    {
        int arr[4][2];
        printf("%p\n", arr);        // 数组名为首元素地址,起始首元素为arr[0],即指向&arr[0]
        printf("%p\n", &arr[0]);    // 与上者相等
        printf("%p\n", arr[0]);     // 数组名为首元素地址,起始首元素为arr[0][0],即指向&arr[0][0]
        printf("%p\n", &arr[0][0]); // 与上者相等
        printf("%p\n", &arr[0][1]); // 从第二维递增一个下标,证明arr[0]是一个占用一个 int 大小对象的地址
        printf("%p\n", &arr[1]);    // 从第一维递增一个下标,证明arr是一个占用两个 int 大小对象的地址
        return 0;
    }
    000000000061FE00
    000000000061FE00
    000000000061FE00
    000000000061FE00
    000000000061FE04
    000000000061FE08
  • 针对上述 3、4、5 条的辅助理解示例

    #include <stdio.h>
    int main(void)
    {
        int arr[4][2] = {{2, 4}, {6, 8}, {1, 3}, {5, 7}};
        printf("%p\n", arr);
        printf("%p\n", arr + 1); // 增加对应元素类型的大小(两个int类型大小)
        printf("%p\n", arr[0]);
        printf("%p\n", arr[0] + 1); // 增加对应元素类型的大小(一个int类型大小)
        printf("=================\n");
        printf("%d\n", arr[0][0]); // 直接通过数组下标获取值
        printf("%d\n", *arr[0]);   // 解引用arr[0]
        printf("%p\n", *arr);      // 解引用一次arr,其值仍为地址,即arr[0][0]的地址
        printf("%d\n", **arr);     // 解引用两次,获得指向的值
        return 0;
    }
    000000000061FE00
    000000000061FE08
    000000000061FE00
    000000000061FE04
    =================
    2
    2
    000000000061FE00
    2
  • 通过指针表示二维数组的值

    • 前面我们了解过,对于int arr[4][2]该例:arr + 1,其值+8(两个 int),而arr[0] + 1,其值+4(一个 int)

    • 但要注意,与arr[2][1]数值等价的指针表示法是*(*(arr+2) + 1),理解如下

      表达式 相对上一步的含义
      arr 二维数组首元素地址,(每个元素都是内含两个 int 的一维数组),即第一维首元素arr[0]的地址
      arr +2 二维数组第 3 个元素的地址,即第一维从第一个元素arr[0]变为第三个元素arr[2],值为其地址
      *(arr+2) 二维数组第 3 个元素的首元素地址,即第二维首元素arr[2][0]的地址
      *(arr+2) + 1 二维数组第 3 个元素的第 2 个元素的地址,即第二维从第一个元素arr[2][0]变为第二个元素arr[2][1],值为其地址
      *(*(arr+2) + 1) 解引用该地址,取得arr[2][1]的值
    • 图片演示指针表示法

    • 以上分析并不是为了说明用指针表示法代替常用的数组表示法(即下标直接引用),而是表述程序恰巧使用一个指向二维数组的指针,而且要通过该指针获取值,最好用简单的数组表示法

数组指针与指针、多维数组深入

  • 指向多维数组的指针

    • 如何声明一个指针变量 pz指向一个二维数组(如int arr[4][2]的第一层的arrarr[1])?

    • 声明方法

      1、对于声明指向arrarr[1]这样的数组,只声明为指向 int 类型不够,因为这种指针指向一个 int 类型的值,但需要指向的元素内含两个 int 类型的值的数组
      2、因此应按照int (*pz)[2]这种格式声明,这种声明的pz便指向一个内含两个 int 类型的值的数组,将其声明为指向数组的指针。为什么使用圆括号(),因为[]的优先级高于*,考虑下条声明
      3、对于int *pax[2]这条声明。按照优先级pax先与[2]结合成为一个内含两个元素的数组,然后*表示pax 数组内含两个指针。因此这条代码声明了两个指向 int 的指针
      4、补充解释,int (*pz)[2]pz是一个储存一个地址的指针,其储存的地址指向内含两个 int 类型的值的数组;而int *pax[2]pax是一个储存两个地址指针数组,其储存的地址指向一个 int 类型的值

    • 辅助理解示例

      #include <stdio.h>
      int main(void)
      {
          int arr[4][2] = {{2, 4}, {6, 8}, {1, 3}, {5, 7}};
          int(*pz)[2];
          pz = arr; // 指向&arr[0],其为内含两个int类型值的数组
      
          printf("%p\n", &arr[0]);
          printf("%p\n", pz);
          printf("%p\n", pz + 1);     // +1直接增加了两个int类型值的大小
          printf("%p\n", &arr[1]);    // 证明了pz+1 相当于从arr[0]指向了arr[1]
          printf("%p\n", pz[0]);      // 因为pz指向arr[0],所以相当于&arr[0][0] (具体还需结合下方“数组指针的[]使用来理解”)
          printf("%p\n", pz[0] + 1);  // 相当于从arr[0][0] +1后指向arr[0][1],所以增加了一个int类型值的大小
          printf("%p\n", &arr[0][1]); // 证明了pz[0]+1 指向 arr[0][1]
          printf("%p\n", *pz);        // 使用 * 解运算pz,相当于解运算arr[0],即 *arr[0],arr[0]指向其首元素地址,于是便得到 &arr[0][0]
          printf("%p\n", *pz + 1);    // 相当于arr[0][0] +1后指向arr[0][1],增加一个int类型值大小
          printf("=================\n");
          printf("%d\n", *pz[0]);           // pz[0]即指向 &arr[0][0],解运算得到arr[0][0]的值
          printf("%d\n", **pz);             // 第一次解运算 *pz得到指向arr[0][0]的地址 &arr[0][0],解运算得到值
          printf("%d\n", pz[2][1]);         // 关于数组指针加[]的事宜,下方将详细讨论
          printf("%d\n", *(*(pz + 2) + 1)); // 按前节方法解释,相当于得到arr[3][1]的值
          return 0;
      }
      000000000061FDF0
      000000000061FDF0
      000000000061FDF8
      000000000061FDF8
      000000000061FDF0
      000000000061FDF4
      000000000061FDF4
      000000000061FDF0
      000000000061FDF4
      =================
      2
      2
      3
      3
    • 数组指针的[]使用

      1、如前所述,虽然pz 是一个指针,不是数组名,但仍可以使用pz[2][1]这种写法
      2、可以用数组表示法指针表示法表示一个数组元素,既可以使用数组名,也可以使用指针名
      3、三者的等价关系如下陈述。需要注意数组名[m][n]调用到的数值与arr[m][n]的值是对应的,pz 调用时,如果 pz不指向数组首元素地址,则对应的值arr[指向元素 + m][指向元素 + n]

      arr[m][n] == *(*(arr+m) + n)
      pz[m][n] == *(*(pz+m) + n)
  • 指针的兼容性

    • 指针之间的赋值数值类型之间的赋值严格。例如,不用类型转换就可以把int 类型值赋给double 类型变量,但两个类型的指针不能这样做,如下两例:

      int n=5;
      double x;
      int *pl = &n;
      double *pd = &x;
      /*-------------------------------*/
      x = n;      // 数值类型,隐式类型转换
      pd = pl;    // 指针,编译时错误
      int *pt;
      int (*pa)[3];
      int arr1[2][3];
      int arr2[3][2];
      int **p2;             // 一个指向指针的指针
      /*--------------------------------*/
      pt = &arr1[0][0];     // 都是指向int的指针
      pt = arr1[0];         // 数组名是首元素地址,都是指向int的指针
      pt = arr1;            // 无效,首元素地址指向&arr[0],是一个指向三个int类型值的数组
      pa = arr1;            // 相当于&arr[0],都是指向内含三个int类型元素数组的指针
      pa = arr2;            // 无效,arr2为指向两个int类型值的数组
      pa = &pt;             // 都是指向int *的指针,&pt为指向指针pt的地址
      *p2 = arr2[0];        // 都是指向int的指针,p2此处进行了一次解运算,指向int类型而非指向指针
      p2 = arr2;            // 无效,p2为指向指针的指针
    • 多重解引用注意事项:

      • 示例程序

        int x=20;
        const int y=23;
        int *p1 = &x;
        const int *p2 = &y;
        const int **pp2;
        p1 = p2;        // 不安全,把const指针赋给非const指针
        p2 = p1;        // 有效,把非const指针赋给const指针
        pp2 = &p1;      // 不安全,嵌套指针类型赋值
      • 示例解析

        1、前面提到过,把const指针赋给const指针不安全的,因为这样可以使用新的指针改变const指针指向的数据编译器在编译时,可能会给出警告,执行这样的代码是未定义的
        2、但把const指针赋给const指针没问题,前提是只进行一级解引用。但当进行两级解引用时,这样的赋值也不安全,如下描述

      • 非 const 赋值 const 时的两级解引用

        const int **pp2;
        int *p1;
        const int n=13;
        pp2 = &p1;        // 允许,但是这会导致const限定符失效(根据第一行代码,不能通过**pp2修改它所指向的内容)
        *pp2 = &n;        // 有效,两者都声明为const,但是这将导致p1指向n(*pp2已被修改)
        *p1 = 10;         // 有效,但是这将改变n的值(但是根据第三行代码,不能修改n的值)
      • 示例解析

        1、发生了什么?如前所示,标准规定了通过const指针更改const数据未定义的
        2、例如使用gcc编译包含以上代码的程序,导致n最终值为13(未更改)。但是在相同系统下使用clang来编译,n最终的值是10(已更改)。两个编译器都给出指针类型不兼容警告
        3、当然您可以忽略这些警告,但最好不要相信程序运行的结果,因为这些结果都是未定义的

  • C const 和 C++ const

    1、C 和 C++中const用法很相似,但并不完全相同
    2、区别之一是,C++允许声明数组大小使用const整数,而 C不允许
    3、区别之二是,C++的指针赋值检查更严格。C++不允许const指针赋给const指针,而 C 允许。如下例,但如果通过 p1 更改 y,其行为是未定义的

    const int y;
    const int *p2 = &y;
    int *p1;
    p1 = p2;    // C++不允许这样做,C可能只发出警告
  • 函数和多维数组指针

    • 示例程序

      #include <stdio.h>
      
      #define ROWS 3
      #define COLS 4
      
      void sum_rows(int ar[][COLS], int rows)
      {
          int r, c, tot;
          for (r = 0; r < rows; r++)
          {
              tot = 0;
              for (c = 0; c < COLS; c++)
                  tot += ar[r][c];
              printf("row %d: sum = %d\n", r, tot);
          }
      }
      
      void sum_cols(int ar[][COLS], int rows)
      {
          int r, c, tot;
          for (c = 0; c < COLS; c++)
          {
              tot = 0;
              for (r = 0; r < rows; r++)
                  tot += ar[r][c];
              printf("col %d: sum = %d\n", c, tot);
          }
      }
      
      int sum2d(int ar[][COLS], int rows)
      {
          int r, c, tot = 0;
          for (r = 0; r < rows; r++)
              for (c = 0; c < COLS; c++)
                  tot += ar[r][c];
          return tot;
      }
      
      int main(void)
      {
          int junk[ROWS][COLS] = {{2, 4, 6, 8}, {3, 5, 7, 9}, {12, 10, 8, 6}};
          sum_rows(junk, ROWS);
          sum_cols(junk, ROWS);
          printf("Sum of all elements = %d\n", sum2d(junk, ROWS));
          return 0;
      }
      row 0: sum = 20
      row 1: sum = 24
      row 2: sum = 36
      col 0: sum = 17
      col 1: sum = 19
      col 2: sum = 21
      col 3: sum = 23
      Sum of all elements = 80
    • 程序解析

      1、在函数声明int ar[][COLS],第 1 个方括号[]是空的,空的方括号表明 ar 是一个指针。所以该语句等效于int (*ar)[COLS],如前面数组指针提到的,后面的COLS用于告知指针指向的元素(子数组)内含多少个对应数据类型大小
      2、该程序把数组名 junk(即首元素地址,即子数组)和符号常量 ROWS作为参数传递给函数。由于int ar[][COLS]的声明,每个函数都把ar视为内含数组元素数组
      3、注意,armain中的junk都使用数组表示法。因为 ar 和 junk类型相同,都是指向内含 4 个 int 值的数组的指针
      4、一般而言,声明一个指向 N 维数组的指针时,只能省略最左边的方括号中的值,因为其只用于表明这是一个指针,其他方括号则用于描述指针指向数据对象的类型(内含多少个对应什么数据类型的大小)

变长数组(VLA)

  • 引入

    /*如上节的函数示例*/
    int sum2d(int ar[][COLS], int rows)
    {
        int r, c, tot = 0;
        for (r = 0; r < rows; r++)
            for (c = 0; c < COLS; c++)
                tot += ar[r][c];
        return tot;
    }

    1、为什么使用函数操作二维数组时,只将行数(ROWS)作为函数的形参,而列数(COLS)内置在函数体内?
    2、我们可以使用sum2d()函数对int arr1[5][4]int arr2[100][4]int arr3[2][4]等数组求各元素之和,是因为这些数组的列数固定为 4行数传递形参 rowsrows是一个变量。但如果要对int arr4[6][5]计算,则不能使用这个函数,必须新建一个COLS 为 5的函数,因为C 规定,数组的维数必须是常量不能用变量代替
    3、要创建一个能处理任意大小二维数组的函数,比较繁琐(必须把数组作为一位数组传递,然后让函数计算每行的开始处)。鉴于此,C99新增了变长数组,允许使用变量表示数组的维度

  • 变长数组

    • 声明示例

      int quarters = 4;
      int regions = 5;
      double arr[regions][quarters];      // 一个变长数组
    • 变长数组的特性

      1、变长数组有一些限制:变长数组必须自动储存类别,这意味着无论在函数中声明还是作为函数形参声明不能使用staticextern储存类别说明符(第 12 章介绍)。而且不能在声明中初始化它们
      2、变长数组不能改变大小变长数组的“”不是指可以修改已创建数组的大小一旦创建了变长数组,其大小保持不变。这里的“”指的是在创建数组时,可以使用变量指定数组的维度
      3、由于变长数组是 C 语言的新特性,目前完全支持这一特性的编译器不多

    • 示例程序(程序要求编译器支持变长数组)

      #include <stdio.h>
      
      #define ROWS 3
      #define COLS 4
      
      // 带变长数组形参的函数
      int sum2d(int rows, int cols, int ar[rows][cols])
      {
          int r, c, tot = 0;
          for (r = 0; r < rows; r++)
              for (c = 0; c < cols; c++)
                  tot += ar[r][c];
          return tot;
      }
      
      int main(void)
      {
          int i, j;
          int rs = 3, cs = 10;
          int junk[ROWS][COLS] = {{2, 4, 6, 8}, {3, 5, 7, 9}, {12, 10, 8, 6}};                // 3*4数组
          int morejunk[ROWS - 1][COLS + 2] = {{20, 30, 40, 50, 60, 70}, {5, 6, 7, 8, 9, 10}}; // 2*6数组
          int varr[rs][cs];                                                                   // 3*10变长数组
          // 为变长数组赋值
          for (i = 0; i < rs; i++)
              for (j = 0; j < cs; j++)
                  varr[i][j] = i * j + i;
      
          printf("3*4 array: sum = %d\n", sum2d(ROWS, COLS, junk));
          printf("2*6 array: sum = %d\n", sum2d(ROWS - 1, COLS + 2, morejunk));
          printf("3*10 VLA: sum = %d\n", sum2d(rs, cs, varr));
          return 0;
      }
      3*4 array: sum = 80
      2*6 array: sum = 315
      3*10 VLA: sum = 165
    • 示例解析

      1、声明一个带二维变长数组参数的函数,需要注意前两个形参rowscols用作第三个形参二维数组ar[rows][cols]两个维度参数数组 ar的声明需要使用前两个参数,因此必须先声明前两个参数,使用int sum2d(int ar[rows][cols], int rows, int cols)这种无效的顺序声明函数原型是错误的
      2、前面提到过 C 标准规定,可以省略函数原型中的形参名,但这种情况下必须用星号*代替省略的维度int sum2d(int, int, int ar[*][*])。(注意是函数原型不是函数定义,如果是函数定义仍必须完整写出类型、变量名等信息)
      3、需要注意的是,在函数定义时的形参列表中声明的变长数组并非实际创建数组。和传统的语法类似,变长数组名实际上是一个指针。这说明函数实际上还是在原始数组中处理数组,因此可以更改传入的数据
      4、变长数组还允许动态内存分配,这说明可以在程序运行时指定数组的大小普通数组都是静态内存分配,即在编译时确定数组大小(12 章将详细讨论)

复合字面量

  • 引入

    1、假设给带int 类型形参的函数传递一个值,应传递int 类型的变量,但也可以传递int 类型的常量,比如5
    2、C99 之前,对于带数组形参的函数,可以传递数组,但没有等价的数组常量,于是 C99 新增了复合字面量
    3、字面量是除符号常量以外的常量。如:5int的字面量;81.3double的字面量;Ychar的字面量;hello是字符串字面量
    4、于是,C99 认为如果有代表数组结构内容复合字面量,会更方便

  • 复合字面量

    1、对于数组,复合字面量类似数组初始化列表(实际可以看做常量数组,类似我们给普通变量赋值时写的581.3这样的字面量常量),前面是用括号()括起来的类型名。如(int [2]){10,20};,括号内的int [2]便是复合字面量的类型名
    2、初始化有数组名的数组时可以省略数组大小复合字面量也可以省略大小编译器自动计算数组当前元素个数(int []){50,20,90}
    3、因为复合字面量匿名的,所以不能先创建再使用,必须在创建的同时使用它。使用指针记录地址就是一种用法,即如果有int *pt;,则可以通过pt = (int [2]){10,20};pt指针记录地址,后通过pt使用这个常量数组(匿名只是无法通过名称调用,但仍储存在固定的内存地址上,因此可以使用指针调用)。复合字面量类型名也代表首元素地址,因此和数组规则相同,*pt10pt[1]20
    4、还可以把复合字面量作为实参传给带有匹配形参函数。这也是复合字面量的典型用法,其好处是把信息传入函数不必先创建数组
    5、注意,复合字面量是提供只临时需要的值的一种手段复合字面量具有块作用域(12 章详细介绍),一旦离开定义复合字面量的,程序无法保证该字面量是否存在。也就是说,复合字面量定义在最内层的花括号内

  • 综合应用示例

    #include <stdio.h>
    
    #define COLS 4
    
    // 计算一维数组各元素的和
    int sum(const int ar[], int n)
    {
        int i, total = 0;
        for (i = 0; i < n; i++)
            total += ar[i];
        return total;
    }
    
    // 计算二维数组各元素的和
    int sum2d(const int ar[][COLS], int rows)
    {
        int r, c, tot = 0;
        for (r = 0; r < rows; r++)
            for (c = 0; c < COLS; c++)
                tot += ar[r][c];
        return tot;
    }
    
    int main(void)
    {
        int total1, total2, total3;
        int *pt1;
        int(*pt2)[COLS];
        // 使用指针记录匿名的复合字面量的地址,以后续调用
        pt1 = (int[2]){10, 20};
        pt2 = (int[2][COLS]){{1, 2, 3, -9}, {4, 5, 6, -8}};
    
        total1 = sum(pt1, 2);
        total2 = sum2d(pt2, 2);
        // 将复合字面量作为实参传入函数形参
        total3 = sum((int[]){4, 4, 4, 5, 5, 5}, 6);
        printf("total1 = %d\n", total1);
        printf("total2 = %d\n", total2);
        printf("total3 = %d\n", total3);
        return 0;
    }

字符串和字符串函数


章节概要:表示字符串和字符串 I/O;在程序中定义字符串;数组表示法与指针表示法;字符串数组;字符串输入;分配空间;gets()函数;fgets()函数;gets_s()函数;scanf()函数;字符串输出;puts()函数;fputs()函数;printf()函数;自定义输入/输出函数;字符串函数;strlen()函数;strcat()函数;strncat()函数;strcmp()函数;strncmp()函数;strcpy()函数;strncpy()函数;其他字符串函数;ctype.h字符函数和字符串;字符串示例:字符串排序;排序指针而非字符串;选择排序算法;命令行参数;字符串转换为数字;atoi()与其类别函数;strtol()与其类别函数

表示字符串和字符串 I/O

  • 第四章介绍过,字符串是以空字符(\0)结尾的char 类型数组。因此,可以把上一章学到的数组和指针的知识应用于字符串

  • 在程序中定义字符串

    • 字符串字面量(字符串常量)

      1、用双引号""括起来的内容称为字符串字面量,也叫做字符串常量。双引号中的字符和编译器自动加入末尾\0字符,都作为字符串储存在内存中
      2、从ANSI C起如果字符串字面量之间没有间隔,或者用空白字符分隔,C 会将其视为串联起来的字符串字面量。如char word[50] = "hello,"" how are" " you?"等价于char word[50] = "hello, how are you?"。如果要在字符串内部使用双引号,则必须通过反斜杠\进行转义。如printf("\"Hello\", Jimmy said");
      3、字符串常量属于静态存储类别,这说明如果在函数中使用字符串,该字符串只会被储存一次,并在整个程序的生命周期内存在,即使函数被调用多次
      4、用双引号括起来的内容被视为指向该字符串存储位置指针(该字符串首字符地址),这类似于把数组名作为指向该数组位置指针
      5、因此,如果使用printf()打印"hello",使用%s将打印整个字符串,使用%p将打印该字符串首字符地址。既然整个字符串表示首字符地址,那么使用%c输出解引用的*"hello",结果便是首字符 h不是整个字符串

    • 字符串数组和初始化

      1、定义字符串数组时,必须让编译器知道需要多少空间
      2、声明示例const char word[10] = "hello",其中const表明不会更改这个字符串(可省略)。这种形式初始化比标准的数组初始化简单的多:const char word[10] = {'h', 'e', 'l', 'l', 'o', '\0'},注意最后的空字符,如果没有这个空字符,这就不是一个字符串,而是一个字符数组
      3、在指定数组大小时,要确保数组的元素个数至少比字符串长度多 1(为了容纳空字符)。所有未被使用的元素都会被自动初始化为 0(这里的 0 是 char 形式的空字符,不是数字字符 0)
      4、通常,让编译器确定数组大小很方便。对于字符串(字符数组)也一样,省略数组初始化声明中的大小,编译器会自动计算数组的大小
      5、字符数组名和其他数组名一样,是该数组首元素的地址

    • 数组表示法与指针表示法

      • 前面介绍的声明为数组表示法,如const char arr[] = "hello";(const可省略),此外还可以用指针表示法创建字符串,如const char * pt = "hello";(const不可省略)

      • ptarr都是该字符串的地址,且字符串本身决定预留的存储空间,尽管如此,这两种形式并不完全相同

      • 数组表示法

        1、数组形式(arr[])在计算机的内存中分配为一个内含 6 个元素的数组(预留出空字符),每个元素被初始化为字符串字面量对应的字符
        2、通常字符串都作为可执行文件的一部分存储在数据段中,当把程序载入内存时,也载入了字符串。字符串存储在静态存储区中,但是程序在开始运行时才会为数组分配内存,此时才将字符串拷贝到数组中。这时字符串有两个副本,一个是静态内存中的字符串字面量,另一个是arr 数组中的字符串
        3、随后,编译器将数组名 arr识别为数组的首元素地址的别名。在数组形式中,arr地址常量不能更改 arr,否则更改意味着改变了数组的存储位置。所以可以进行类似arr+1这样的操作,标识数组的下一个元素,但不能进行类似++arr这样的操作,递增运算符只可以用于可修改的左值不能用于常量

      • 指针表示法

        1、指针形式(*pt)也使得编译器字符串静态存储区预留 6 个元素的空间。另外一旦开始执行程序,他会为指针变量 pt留出一个储存位置,并把字符串的地址存储在指针变量
        2、该指针变量最初指向字符串首字母,但是它的值可以改变。因此可以使用递增运算符,如++pt;将指向第二个字符(即 e)
        3、字符串字面量被视为const数据,由于pt 指向这个数据,所以应该把pt声明为指向const数据指针。这意味着不能用 pt 改变它所指向的数据,但可以改变 pt 的值(即指向的地址)
        4、如果把一个字符串拷贝给一个数组(即使用数组表示法),则可以随意改变数据,除非把数组声明const

    • 字符串数组

      • 创建一个字符串数组通常很方便,可以通过数组下标访问多个不同的字符串

      • 字符串数组-数组表示法

        const words[5][40] = {
            "Hello, my name is Lisa.",
            "I'm 16 years old.",
            "How about you?"
        };
      • 字符串数组-指针表示法

        const *words[5] = {
            "Hello, my name is Lisa.",
            "I'm 16 years old.",
            "How about you?"
        };

字符串输入

  • 想把一个字符串读入程序,首先必须预留储存该字符串的空间,然后用输入函数获取该字符串

  • 分配空间

    • 第一件事便是分配空间,以储存后续读入的字符串。这意味着要为字符串分配足够的空间不要指望计算机在读取时顺便计算它的长度,再分配空间(计算机不会这样做)

    • 错误示例

      char *name;
      scanf("%s",name);

      1、虽然可能通过编译(大概率会报警报),但在读取name时,name很可能会擦写程序中的数据或代码,导致程序异常终止
      2、因为scanf()要把信息拷贝到参数的指定地址,而name是个未初始化的指针,所以可能指向任何地方

    • 正确分配空间

      1、最简单的方法是,在声明时显式指明数组的大小char name[81];
      2、还有一种方法是使用C 库函数分配内存,第 12 章介绍

  • gets()函数

    • gets()的使用

      char word[81];
      gets(word);
      puts(word);

      1、在读取字符时,scanf()配合%s只能读取一个单词(遇到空格就停止),但程序经常要读取一整行输入
      2、gets()函数就用于读取整行输入,直至遇到换行符。然后丢弃换行符储存其他字符,并在这些字符末尾添加一个空字符使其成为一个字符串
      3、gets()常常与puts()函数配对使用,该函数用于显示字符串,并在末尾添加换行符

    • gets()的危险性

      1、某些编译器对于使用gets()的程序报出警告,但并非全部编译器都会这样做。其他编译器可能在编译过程中给出警告,但不会引起你的注意
      2、问题出现在gets()唯一参数字符串名(word),它无法检查数组是否装得下输入行
      3、如果输入的字符串过长,会导致缓冲区溢出,即多余的字符超出了指定的目标空间。如果这些多余字符只是占用了尚未使用的内存,就不会立刻出现问题;如果它们擦写掉了程序中的其他数据,会导致程序异常终止,或者还有其他情况
      4、如果出现上述情况,会报出Segmentation fault(分段错误),这条消息说明该程序试图访问未分配的内存。该函数的不安全行为造成了安全隐患,过去有些人通过系统编程,利用gets()插入和运行一些破坏系统安全的代码

    • gets()被遗弃

      1、由于gets()不安全性,不久C 社区许多人都建议编程时摒弃gets()。制定C99标准的委员会将这些建议放入了标准,承认gets()大量问题并建议不要再使用它
      2、尽管如此,在标准中保留gets()也合情合理,因为现有程序中含有大量使用该函数的代码。而且只要使用得当,其的确是一个很方便的函数
      3、好景不长,C11采取了更强硬的手段,直接从标准中废除了gets()函数。既然标准已经发布,那么编译器就必须调整支持。然而实际使用中,编译器为了兼容以前的代码,大部分仍继续支持gets()函数,但部分编译器已按标准废除gets()

  • fgets()函数

    • fgets()函数可以作为gets()的替代品,其通过第二个参数限制读入的字符数来解决溢出问题。但该函数设计用于处理文件输入,一般情况可能不那么好用

    • fgets()gets()的区别

      1、fgets()函数的第二个参数指定了读入字符的最大数量。如果该参数值n,那么fgets()将读取n-1个字符,或者读到遇到的第一个换行符
      2、如果fgets()读到一个换行符,会把它保存在字符串中。而gets()函数会舍弃换行符
      3、fgets()第三个参数指明要读入的文件。如果读入从键盘输入的数据,则以stdin(标准输入)作为参数,该标识符定义在stdio.h

    • fgets()的使用

      char word[14];
      fgets(word, 14, stdin);
      fputs(word, stdout);

      1、假设输入 17 个字符,该程序输出仅会输出前 13 个字符(fgets只读入第二个参数-1个字符)
      2、假设输入 6 个字符fgets()函数会将末尾的换行符储存起来,如果使用puts()函数打印则会附带一个换行符,如果使用fputs()则不会
      3、fgets()由于储存换行符的特性常常与fputs()配对使用fputs()第二个参数指明它要写入的文件,如果要显示在屏幕上,则应使用stdout(标准输出)作为参数
      4、fgets()函数返回指向 char指针。如果一切顺利,该函数返回地址与传入的第一个参数相同。但是,如果函数读到文件结尾,它将返回一个空指针(null pointer),该指针保证不会指向有效数据,在代码中可以用数字 0 代替,不过 C 语言中用宏NULL代替更常见

    • 示例程序

      #include <stdio.h>
      int main(void)
      {
          char word[10];
          puts("输入字符串(单独换行结束):");
          // fgets输入,返回值不等于空指针(文件结尾)且判断第一个字符不为换行符
          while (fgets(word, 10, stdin) != NULL && word[0] != '\n')
              fputs(word, stdout);
          puts("Done.");
          return 0;
      }
      by the way, the gets() function
      by the way, the gets() function
      hello, world
      hello, world
      how about you?
      how about you?
      
      Done.
    • 示例解析

      1、虽然word的长度被设置为10(即实际读入 9 个字符),但处理更长的字符串时貌似并没有问题,原理如下
      2、之前提到过,程序的输入使用了缓冲区,所以输入更长的字符串储存在缓冲区等待处理。第一轮while迭代按fgets()函数要求只读入 9 个字符(即by the wa,储存为by the wa\0),然后处理(即输出)该字符串,fputs()打印且不换行
      3、随后进入第二轮迭代,从缓冲区中读取剩余未读取的字符y, the ge并存储,再次输出,由于fputs()的输出未换行,所以与上次输入拼接在了一起。以此类推

    • 特殊处理(需要对应操作时可参考处理思路)

      • 处理换行符(查找换行符,将其替换为空字符)

        while (word[i] != '\n')   // 假设 \n 在 word 中,while循环检测跳过非换行符的部分
            i++;
        word[i] = '\0';           // 将换行符替换为空字符
      • 处理留在缓冲区中的多余字符(丢弃多余字符)

        while (getchar() != '\n') // 读取但不存储输入,包括\n
            continue;
  • gets_s()函数

    • C11新增的gets_s()函数和fgets()类似,用一个参数限制读入的字符数,其语法为gets_s(字符串名, 字符数),但由于是可选标准,所以某些编译器可能不支持

    • gets_sfgets()的区别

      1、gets_s只从stdin标准输入中读取数据,所以不需要第三个参数
      2、如果gets_s读到换行符,会舍弃换行符而不是储存它
      3、如果gets_s读到最大字符数没有读到换行符,将会执行以下几步。首先把目标数组首字符设置为空字符读取并丢弃随后的输入直至换行符文件结尾,然后返回空指针。接着调用依赖实现的处理函数(或你选择的其他函数),可能会终止或退出程序
      4、第二个特性说明,只要不超过最大值,这两个函数几乎完全一样;第三个特性说明,要使用gets_s函数还需进一步学习

  • scanf()函数

    • scanf()gets()fgets()的区别

      1、最主要的区别在于它们如何确定字符串的末尾scanf()更像获取单词函数,而非获取字符串函数
      2、如果预留的储存区装得下输入行,gets()fgets()会读取第一个换行符所有的字符
      3、scnaf()两种方式确定输入结束,无论哪种方式,都从第一个非空白字符作为字符串开始。如果使用%s转换说明,则以下一个空白字符作为字符串结束;如果指定了字段宽度,如%10s,那么将读取 10 个字符读到第一个空白符停止

字符串输出

  • puts()函数

    • puts()的使用

      char word[81] = "hello world";
      puts(word);

      1、puts()函数很容易使用,只需要把字符串地址传递给它即可。puts()只能用来打印字符串
      2、puts()在显示字符串时会自动在其末尾添加一个换行符
      3、puts()如何知道在哪停止?其在检测到空字符(\0)时就停止输出,所以必须确保有空字符典型错误为打印字符数组char word[81] = {'h','e','l','l','o'},字符数组是没有空字符

  • fputs()函数

    • fputs()puts()的区别

      1、fputs()puts()就如同fgets()gets()相似,前者后者针对文件定制的版本
      2、fputs()函数的第二个参数指明要写入数据的文件,如果打印在屏幕上,可以用stdout(标准输出)作为参数
      3、与puts()不同,fputs()不会末尾添加换行符

  • printf()函数

    • puts()等函数一样,printf()也把字符串地址作为参数。printf()输出字符串虽然用起来不如puts()方便,但其更加全能可控

    • printf()不会在每个字符串末尾添加换行符,所以必须人为指定(使用\n)在哪里使用换行符

自定义输入/输出函数

  • 不一定非要使用 C 库的标准函数,当然我们也可以自己通过getchar()putchar()这两个功能更简单的函数写一个我们自己需求的函数

  • 打印字符串,不添加\n

    #include <stdio.h>
    // 因为不需要改变字符串值,所以使用const保护数据
    void put1(const char *str)
    {
        while (*str != '\0')
            // str++指的是指针指向地址递增,而不是指向的数值递增
            putchar(*str++);
    }
  • 一个能统计打印字符个数的 puts()函数

    #include <stdio.h>
    int put2(const char *str)
    {
        int count = 0;
        // 当str指向空字符时,*str值为0,即false,循环结束
        while (*str)
        {
            putchar(*str++);
            count++;
        }
        putchar('\n'); // puts额外添加的换行符,单独输出
    }
  • 优化的 fgets()函数,读取整行输入并用空字符代替换行符(即不存储换行符),或读取一部分输入舍弃其余部分(即越界部分不存储)

    #include <stdio.h>
    // 函数的返回值是字符串(char *s_gets)
    char *s_gets(char *str, int n)
    {
        char *ret_val; // 创建指针(同时也是存储字符串的变量)
        int i = 0;
        ret_val = fgets(str, n, stdin); // fgets()返回指向char的指针,如果顺利的话返回地址与传入的第一个参数相同(注意 ret_val 会和 str 指向同一个地址),如果读到文件结尾返回NULL
        if (ret_val)                    // 即,ret_val != NULL,判断是否读到文件结尾
        {
            while (str[i] != '\n' && str[i] != '\0') // 忽略跳过正常字符
                i++;
            if (str[i] == '\n') // 出现换行符替换为空字符,即不存储换行符
                str[i] = '\0';
            else                          // 否则就是读到了空字符
                while (getchar() != '\n') // 丢弃该输入行的其余字符
                    continue;
        }
        return ret_val;
    }

字符串函数

  • C 库提供了多个处理字符串函数ANSI C把这些函数原型放在string.h头文件中(即使用需要调用string.h头文件)

  • strlen()函数

    • strlen()函数用于统计字符串有效字符的个数(不含空字符)

    • 使用strlen()截断字符串

      #include <stdio.h>
      #include <string.h>           // 特别注意需要头文件
      
      void fit(char *str, int size)
      {
          if (strlen(str) > size)   // 判断字符串是否比截断位置长
              str[size] = '\0';     // 将第size+1位置的字符替换成空字符
      }

      1、由于puts()函数检测到空字符停止输出的特性,所以将第size+1位置替换为空字符,便可实现在此处停止输出
      2、若size传入40,则意味着输出显示 40 个字符,因为str[size]size作为下标,实际修改的是第 41 位的值
      3、可以使用puts(arr + (size+1))(arr 代指主函数中原字符串名)输出剩余字符,但被替换为空字符的字符已被修改无法输出arr + (size+1)实际就是被替换字符下一个字符地址,即跳过被修改的\0位置向后输出

  • strcat()函数

    1、strcat()函数用于拼接字符串
    2、strcat()接收两个字符串作为参数。将第二个字符串备份拼接第一个字符串末尾,并把拼接后的新字符串作为第一个字符串第二个字符串不变
    3、strcat()返回第一个参数,即拼接后的第一个字符串的地址
    4、注意,该函数无法检测拼接第二个字符串后第一个字符串是否越界,需要小心使用

  • strncat()函数

    1、strncat()函数也用于拼接字符串,其为strcat()更优选,可以预防越界
    2、相比strcat()strncat()多了第三个参数用于指定最大添加字符数。其在拼接时拼接到指定大小遇到空字符停止

  • strcmp()函数

    • strcmp介绍

      1、strcmp()函数用于检测字符串的内容是否相等
      2、一般程序中,直接比对两个字符串名,比对的是他们的地址是否相同,而非值是否相同strcmp()便用于检测值是否相同。比如作为循环条件判断arr != "Quit"就是典型错误,因为比对地址永远会得到false
      3、strcmp()比较的是字符串,而不是整个数组。例如一个数组占用40 字节,但其仅储存5 个字符+1 个空字符共计6 字节。此时strcmp()函数只会比较该数组第一个空字符前面的部分。所以可以用strcmp()比较存储在不同大小数组中的字符串

    • strcmp()返回值

      1、当两个字符串比对完全相同,则返回 0不同返回非零值
      2、strcmp()按照顺序依次比对字符,如果比对字符(ASCII 码值)前者比后者大(C 和 A),则返回 1;如果后者比前者大(A 和 C),则返回 -1
      3、有些系统对于上面的结果可能为2 与 -2,这些系统的返回值为两者的ASCII 码值。但无论如何,正负规律不变
      4、注意ASCII 码大写字母小写字母比对,相差 32,即小写 = 大写 + 32

  • strncmp()函数

    1、strncmp()函数也用于检测字符串的内容是否相等
    2、strncmp()相比strcmp()多了第三个参数,用于限定最大对比到第多少位
    3、strncmp()可以限制只比对多少位,例如需要查找以 astro 开头的单词,strncmp(arr, "astro", 5)则表示只比对前五位是不是astro。而strcmp()一直比对,直至发现不同,这一过程可能会持续到字符串末尾,且比对全部字符

  • strcpy()函数

    1、strcpy()函数用于拷贝字符串
    2、前面提到过,如果pts1pts2都是指向字符串的指针,那么pts2 = pts1这个语句拷贝的是字符串的地址而不是字符串本身
    3、strcpy()函数接受两个参数strcpy(str1, str2),其将后者(地址)指向的字符串拷贝前者(地址)。拷贝出来的字符串(前者)被称为目标字符串最初的字符串(后者)被称为源字符串。实际过程中,strcpy()函数先创建源字符串内容的副本,再让目标字符串指向该副本。如果其第一个参数数组不必指向数组的开始。须注意拷贝至的目标字符串需要保证有足够空间容纳源字符串副本
    4、strcpy()返回类型指向 char 的指针,其返回值第一个参数的值,即一个字符地址

  • strncpy()函数

    1、strncpy()函数也用于拷贝字符串
    2、strncpy()相比strcpy()多了第三个参数,用于指定可拷贝的最大字符数

  • sprintf()函数

    1、sprintf()函数声明在stdio.h头文件中,其用于将数据写入字符串
    2、sprintf()可以把多个元素组合成一个字符串,其接受的第一个参数目标字符串地址,其余参数和printf()相同,即格式字符串待写入项的列表
    3、示例:sprintf(str, "%s, %6.2f %s", "there are", prize, "yuan.");

  • 其他字符串函数

    函数 作用
    char *strchr(const char *str, char c) 如果 str 字符串包含 c 字符,返回指向 str 中首次出现 c 字符位置的指针,如果未找到 c 字符,返回空指针(末尾的空字符也在查找范围内)
    char *strrchr(const char *str, char c) 如果 str 字符串包含 c 字符,返回指向 str 中最后一次出现 c 字符位置的指针,如果未找到 c 字符,返回空指针(末尾的空字符也在查找范围内)
    char *strpbrk(const char *s1, const char *s2) 如果 s1 字符串中包含 s2 字符串中的任意字符,返回指向 s1 字符串首位置的指针,否则返回空指针
    char *strstr(const char *s1, const char *s2) 该函数返回指向 s1 字符串中 s2 字符串出现的首位置,如果没有找到,返回空指针
  • ctype.h 字符函数和字符串

    • 第 7 章中介绍了ctype.h系列与字符相关函数,这些函数虽然不能处理整个字符串,但可以通过自定义编写处理字符串中的字符

    • 例如可以自己编写for循环遍历字符串中的字符,然后对这些字符使用ctype.h中的函数实现一些功能

字符串示例:字符串排序

  • 示例程序

    #include <stdio.h>
    #include <string.h>
    
    #define SIZE 81 // 限制字符串长度
    #define LIM 20  //可读入最多行数
    #define HALT "" //空字符串停止输入
    
    // 字符串-指针-排序函数
    void stsrt(char *strings[], int num)
    {
        char *temp;
        int top, seek;
        // 选择排序
        for (top = 0; top < num - 1; top++)
            for (seek = top + 1; seek < num; seek++)
                if (strcmp(strings[top], strings[seek]) > 0)
                {
                    // 交换指针
                    temp = strings[top];
                    strings[top] = strings[seek];
                    strings[seek] = temp;
                }
    }
    
    // 前面“自定义函数”中提到过的自定义 s_gets()函数
    char *s_gets(char *str, int n)
    {
        char *ret_val; // 创建指针(同时也是存储字符串的变量)
        int i = 0;
        ret_val = fgets(str, n, stdin); // fgets()返回指向char的指针,如果顺利的话返回地址与传入的第一个参数相同(注意 ret_val 会和 str 指向同一个地址),如果读到文件结尾返回NULL
        if (ret_val)                    // 即,ret_val != NULL,判断是否读到文件结尾
        {
            while (str[i] != '\n' && str[i] != '\0') // 忽略跳过正常字符
                i++;
            if (str[i] == '\n') // 出现换行符替换为空字符,即不存储换行符
                str[i] = '\0';
            else                          // 否则就是读到了空字符
                while (getchar() != '\n') // 丢弃该输入行的其余字符
                    continue;
        }
        return ret_val;
    }
    
    int main(void)
    {
        char input[LIM][SIZE]; // 储存输入的数组
        char *ptstr[LIM];      // 内含指针变量的数组
        int ct = 0;            // 输入计数
        int k;                 //输出计数
        printf("最多输入%d行,我将会对它们排序,在一行开始处回车以停止输入\n", LIM);
        // 输入行数在范围内 && 输入数据正常 && 第一个字符不是空字符
        while (ct < LIM && s_gets(input[ct], SIZE) != NULL && input[ct][0] != '\0')
        {
            ptstr[ct] = input[ct]; //设置指针指向字符串
            ct++;
        }
        // 函数排序
        stsrt(ptstr, ct);
        printf("排序后:\n");
        for (k = 0; k < ct; k++)
            puts(ptstr[k]);
        return 0;
    }
    最多输入20行,我将会对它们排序,在一行开始处回车以停止输入
    O that I was where I would be,
    Then would I be where I an not;
    But there I an I must be,
    And where I would be I can not.
    
    排序后:
    And where I would be I can not.
    But there I an I must be,
    O that I was where I would be,
    Then would I be where I an not;
  • 排序指针而非字符串

    1、该程序的巧妙之处,在于排序的是指向字符串的指针,而不是字符串本身
    2、最初,ptrst[0]指针被设置为input[0]ptrst[1]指针被设置为input[1],以此类推,这意味着指针ptrst[i]指向数组input[i]首字符。每个input[i]都是内含 81 个元素的数组,每个ptrst[i]都是一个单独的指针变量
    3、排序过程中,将ptrst重新排列,并未改变input。例如按字母顺序input[1]应在input[0]前面,程序便交换它们的指针(即ptrst[0]指向input[1]的开始,而ptrst[1]指向input[0]的开始)。这样做比strcpy()函数交换字符串内容简易快速的多,且还保留了input原始顺序

  • 选择排序算法

    1、上例中排序函数使用的排序算法选择排序,具体通过两层循环处理以下操作(简称内层循环变量为 j,外层循环变量为 i)
    2、内层循环负责依次每个未排序的元素(第 j 个元素)与所排序位置元素(第 i 个元素)比较。如果第 j 个元素第 i 个元素前面,则交换两者
    3、内层循环结束时,第 i 个元素便是这一轮排序中的最值,然后外层循环将 i 加 1(比对下一位置),继续重复这一过程

命令行参数

  • 命令行简介

    1、在图形界面普及之前都是用命令行界面UNIXDOS就是例子。命令行是在命令行环境中,用户为运行程序输入命令的行
    2、假设一个文件中有一个名为fuss程序,在UNIX环境中运行该程序命令行$ fuss,或者Windows命令提示模式下是C> fuss($ 和 C> 都是命令行自带的的行首标识)
    3、命令行参数是同一行的附加项,比如$ fuss -r Ginger,一个C 程序可以读取并使用这些附加项

  • 示例程序

    /*test.c*/
    #include <stdio.h>
    
    int main(int argc, char *argv[])
    {
        int count;
        printf("The command line has %d arguments:\n", argc - 1);
        for (count = 1; count < argc; count++)
            printf("%d: %s\n", count, argv[count]);
        printf("\n");
        return 0;
    }
  • 编译后使用终端 cmd运行命令行输出结果

    C> test.exe resistance is futile
    The command line has 3 arguments:
    1: resistance
    2: is
    3: futile
  • 程序解析

    1、C 编译器允许main()没有参数或者有两个参数(一些实现会允许更多参数,属于对标准的扩展)
    2、main()两个参数时,第一个参数表示命令行中字符串数量。过去,这个int 类型参数称为argc(参数计数 argument count)
    3、系统用空格表示一个字符串的结束下一个字符串的开始。因此test.exe resistance is futile一共 4 个字符串,其中后 3 个test.exe使用
    4、该程序把命令行字符串储存在内存中,并把每个字符串的地址存储在指针数组中,而该数组的地址则被存储在main()第二个参数中。按照惯例,这个指向指针指针称为argv(参数值 argument value)
    5、如果系统允许,就把程序本身的名称赋给argv[0],把随后的第一个字符串赋给argv[1],以此类推。在我们的例子中,argv[0]指向test.exeargv[1]指向resistanceargv[2]指向isargv[3]指向futile
    6、main()中的形式参数与其他带形参的函数相同,许多程序员用不同的形式声明argv。如int main(int argc, char **argv),其中**argv*argv[]等价argv也是一个指向指针的指针

字符串转换为数字

  • 引入

    1、数字(如 213)既能以字符串形式(‘2’,’1’,’3’,’\0’)存储,也能以数值形式(int 类型值 213)存储
    2、C 进行数值运算要求用数值形式,但在屏幕上显示数字要求用字符串形式,因为屏幕显示的是字符printf()sprintf()函数通过转换说明将数字从数值形式转换成字符串形式,而scanf()可以把输入的字符串转换成数值形式
    3、假设你编写的程序需要使用数值命令形参,但是命令形参数被读取为字符串。因此要使用数值必须先把字符串转换成数字。C 的stdlib.h中有一些函数专门用于处理这类问题

  • atoi()示例程序

    #include <stdio.h>
    #include <stdlib.h>
    
    int main(int argc, char *argv[])
    {
        int i, times;
        // 使用atoi()函数转换为数值比对
        if (argc < 2 || (times = atoi(argv[1])) < 1)
            printf("Usage:%s, positive-number\n", argv[0]);   // 提示语句
        else
            for (i = 0; i < times; i++)
                puts("Hello, good looking!");
        return 0;
    }
  • atoi()与其类别函数

    1、atoi()函数用于将字符串转换为int 数值形式
    2、atoi()函数能处理字符串开头数字部分,例如123123hello都会被转换123。但如果开头不是整数,如hello123hello这种字符串,在我们的C 实现中会返回 0,但C 标准规定这种行为是未定义
    3、因此,使用有错误检测功能strtol()函数(马上介绍)会更安全
    4、除了stoi()外,stdlib.h还包含了其他一些类似函数的原型,如下表

    函数 作用
    atoi() 将字符串转换成 int 类型数值形式
    atof() 将字符串转换成 float 类型数值形式
    atol() 将字符串转换成 long 类型数值形式
  • strtol()示例程序

    #include <stdio.h>
    #include <stdlib.h>
    
    // 前面自定义函数提到的自定义s_gets()函数
    char *s_gets(char *str, int n)
    {
        char *ret_val; // 创建指针(同时也是存储字符串的变量)
        int i = 0;
        ret_val = fgets(str, n, stdin); // fgets()返回指向char的指针,如果顺利的话返回地址与传入的第一个参数相同(注意 ret_val 会和 str 指向同一个地址),如果读到文件结尾返回NULL
        if (ret_val)                    // 即,ret_val != NULL,判断是否读到文件结尾
        {
            while (str[i] != '\n' && str[i] != '\0') // 忽略跳过正常字符
                i++;
            if (str[i] == '\n') // 出现换行符替换为空字符,即不存储换行符
                str[i] = '\0';
            else                          // 否则就是读到了空字符
                while (getchar() != '\n') // 丢弃该输入行的其余字符
                    continue;
        }
        return ret_val;
    }
    
    int main(void)
    {
        char str[30];
        char *end;
        long value;
        s_gets(str, 30);
    
        value = strtol(str, &end, 10); // 十进制
        printf("10进制:%ld,终止于%s (%d)\n", value, end, *end);
        value = strtol(str, &end, 16); // 十六进制
        printf("16进制:%ld,终止于%s (%d)\n", value, end, *end);
        return 0;
    }
    10
    10进制:10,终止于 (0)
    16进制:16,终止于 (0)
    10atom
    10进制:10,终止于atom (97)
    16进制:266,终止于tom (116)
  • strtol()与其类别函数

    1、atrtol()函数用于将字符串转换为long 数值形式
    2、strtol()第一个参数接受一个指向待转换字符串指针第二个参数接受一个指针的地址,该指针被设置为标识输入数字结束字符地址第三个参数接受一个整数,表示以什么进制读入数字
    3、示例中,如果end指针指向一个字符,那么解引用*end就是一个字符。第一次转换读到空字符结束(‘1’,’0’,’\0’),此时end指向空字符,打印end会显示一个空字符,后面的%d输出的*end显示的是空字符ASCII 码(即 0)
    4、第二次转换,以十进制读入字符串,end的值是字符’a’地址,所以打印end显示字符串”atom”;以十六进制读入,函数将atom字符 a识别为合法的十六进制数,所以将十六进制数10a转换为266
    5、该函数最多可以支持转换三十六进制,即a~z都可以用作数字
    6、除了strtol()外,stdlib.h还包含了其他一些类似函数的原型,如下表

    函数 作用
    strtol() 将字符串转换成 long 类型数值形式
    strtoul() 将字符串转换成 unsigned long 类型数值形式
    strtod() 将字符串转换成 double 类型数值形式(只以十进制转换,只需要两个参数)

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


章节概要:储存类别(引入);标识符、表达式与左值;作用域、链接与存储期;作用域;翻译单元和文件;链接;储存期;使块作用域变量具有静态存储期;C 的存储类别;自动变量;内层块与外层块变量同名隐藏;编译器对 C99 和 C11 的支持;寄存器变量;块作用域的静态变量;外部链接的静态变量;内部链接的静态变量;存储类别补充;多文件;储存类别说明符;储存类别和函数;存储类别的选择;随机数函数和静态变量;随机数实现原理;自动重置种子;分配内存:malloc()free()calloc()函数;ANSI C 类型限定符;volatile类型限定符;restrict类型限定符;_Atomic类型限定符

储存类别(引入)

  • C 提供了多种不同的模型存储类别内存中存储数据。要理解这些存储类别,需要先复习一些概念和术语

  • 概念术语复习

    1、截至目前,所有的示例程序数据都存储在内存
    2、从硬件层面来看,被存储的每个都占用一定的物理内存,C 把这样的一块内存称为对象。对象可以存储一个或多个值。一个对象可能并未存储实际的值,但当它存储适当的值时一定具有相应的大小(面向对象编程中对象指的是类对象,其定义包括数据和允许对数据进行的操作,但 C 不是面向对象编程语言)
    3、从软件层面来看,程序需要一种方法来访问对象。可以通过声明变量来完成,如int entity = 3;,该声明创建了一个名为entity标识符标识符是一个名称,这种情况下,标识符可以用来指定特定对象的内容,其遵循变量的命名规则,该例中,标识符 entity即是软件(即 C 程序)指定硬件内存中的对象的方式

  • 标识符、表达式与左值

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

    1、变量名不是指定对象的唯一途径
    2、pt是一个标识符,它指定了一个储存地址对象。但是,表达式*pt不是标识符,因为它不是一个名称,但它确实指定了一个对象,这种情况下,它与entity指定的对象相同
    3、一般而言,那些指定对象表达式被称为左值。所以entity既是标识符也是左值*pt既是表达式也是左值
    4、按照这个思路,ranks + 2 * entity不是标识符(不是名称)也不是左值(不指定内存位置上的内容)。但是表达式*(ranks + 2 * entity)是一个左值,因为它指定了特定内存位置的值,即ranks第七个元素
    5、所有这些示例中,如果可以使用左值改变对象中的值,该左值就是一个可修改的左值
    6、例如const char *pc = "Behold";这条声明,创建了一个标识符为 pc对象,存储字符串的地址。由于可以设置pc重新指向其他字符串,所以标识符 pc是一个可修改的左值(该const只保证字符串内容不被修改)。由于*pc指向存储’B’字符的数据对象,所以*pc是一个左值,但不是可修改的左值;与此类似,因为字符串字面量本身指定了存储字符串的对象,所以它也是一个左值,但也不是可修改的左值

  • 新概念引入

    1、可以用存储期描述对象,所谓存储期是指对象内存保留了多长时间
    2、标识符用于访问对象,可以用作用域链接描述标识符,标识符的作用域链接表明了程序哪些部分可以使用它
    3、不同的存储类别具有不同的存储期作用域链接
    4、标识符可以在源代码的多文件共享、可用于特定文件任意函数中、可仅限于特定函数中使用,甚至只在函数中某部分使用
    5、对象可存在于程序的执行期,也可仅存在于它所在函数的执行期。对于并发编程对象可以在特定线程的执行期存在

作用域、链接与存储期

  • 作用域

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

    • 块作用域

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

      1、到目前为止,示例程序中使用的变量几乎都具有块作用域
      2、是用一对花括号括起来代码区域。例如整个函数体是一个,函数中的任意复合语句也是一个
      3、定义在中的变量具有块作用域块作用域变量可见范围是从定义处到包含该定义的块的末尾。另外,虽然函数形参声明在函数的左花括号之前,但它们也具有块作用域,属于函数体这个,同理对于一些复合语句也是这样(如for()循环)

    • 函数作用域

      1、函数作用域仅用于goto语句标签。这意味着即使一个标签首次出现在函数的内层块中,它的作用域也延伸至整个函数
      2、如果在两个块使用相同的标签会很混乱,标签函数作用域防止了这样的事情发生

    • 函数原型作用域

      1、函数原型作用域用于函数原型中的形参名int def(int mouse, double large);函数原型作用域范围是从形参定义处到函数原型声明结束
      2、这意味着,编译器在处理函数原型中的形参时,只关注它的类型,而形参名(如果有的话,因为可以省略)通常无关紧要。而且即使函数原型有形参名,也不必函数定义中的形参名相匹配
      3、只有在变长数组中,形参名才有用void def(int n, int m, arr[n][m])方括号必须使用函数原型已声明的名称

    • 文件作用域

      #include <stdio.h>
      int units = 0;        // 具有文件作用域
      
      void def(void)
      {
          ...
      }
      
      int main(void)
      {
          ...
          return 0;
      }

      1、变量定义在函数外面,具有文件作用域。具有文件作用域变量,从定义处到该定义所在文件末尾可见
      2、如示例units变量,具有文件作用域def()main()都可以使用它(更准确的说,units具有外部链接文件作用域,稍后讲解)
      3、由于这样的变量可用于多个函数,所以文件作用域变量也被称为全局变量

    • 翻译单元和文件

      1、你认为的多个文件编译器中可能以一个文件出现。例如,通常在源代码(.c 拓展名)中包含一个或多个头文件(.h 拓展名),头文件会依次包含其他头文件,所以会包含多个单独的物理文件
      2、但是,C 预处理实际上是用包含的头文件内容替换#include指令。所以编译器源代码文件所有头文件都看成是一个包含信息的单独文件,这个文件被称为翻译单元
      3、描述一个具有文件作用域变量时,它的实际可见范围整个翻译单元。如果程序由多个源代码文件组成,那么该程序也将由多个翻译单元组成,每个翻译单元对应一个源代码文件它所包含的文件

  • 链接

    • 基本概念

      1、C 变量有 3 种链接属性外部链接内部链接无链接
      2、具有块作用域函数作用域函数原型作用域变量都是无链接变量。这意味着这些变量属于定义它们的块、函数或函数原型私有
      3、具有文件作用域变量可以是外部链接内部链接外部链接变量可以在多文件程序中使用,内部链接变量只能在一个翻译单元中使用

    • 非正式用语的简称

      1、C 标准用内部链接的文件作用域描述仅限于一个翻译单元的作用域,用外部链接的文件作用域描述可延伸至其他翻译单元的作用域,这些是正式用语
      2、对于程序员而言这些用语太长了。因此一些程序员把内部链接的文件作用域简称为文件作用域,把外部链接的文件作用域简称为全局作用域程序作用域

    • 如何知道文件作用域变量内部链接还是外部链接?可以看外部定义是否使用了储存类别说明符static

      int giants = 5;             // 文件作用域,外部链接
      static int dodgers = 3;     // 文件作用域,内部链接
      /*该文件和同一程序的其他文件,都可以使用giants,但dodgers属文件私有*/
      int main(void)
      {
          ...
          return 0;
      }
  • 存储期

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

    • 静态存储期

      1、如果对象具有静态存储期,那么它在程序执行期间一直存在文件作用域变量具有静态存储期
      2、注意,对于文件作用域变量关键字static表明其链接属性,而非存储期。所有的文件作用域变量都具有静态存储期

    • 线程存储期

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

    • 自动存储期

      1、块作用域通常都具有自动存储期。当程序进入定义这些变量的时,为这些变量分配内存退出这个时,释放刚才分配的内存
      2、这种做法相当于把自动变量占用的内存视为一个可重复使用工作区暂存区。例如一个函数调用结束时,其变量占用的内存可用于存储下一个被调函数的变量
      3、变长数组稍有不同,它们的存储期声明处块的末尾,而不是从块的开始末尾
      4、我们到目前为止使用的局部变量都是自动类别。然而,块作用域变量也能具有静态存储期,为此需要把变量声明在块中并在声明前加上关键字static

    • 使块作用域变量具有静态存储期

      void more(int number)
      {
          int index;
          static int ct=0;
          ...
      }

      1、该程序中,变量ct由于声明在块中且使用static关键字,因此ct 存储在静态内存中,从程序被载入程序结束期间都一直存在
      2、注意,它的作用域定义在more()函数中,只有在执行该函数时,程序才能使用ct访问它所指向的对象
      3、但是,该函数可以给其他函数提供该存储区地址,以便间接访问对象,例如通过指针形参返回值

    • 动态分配存储期在本章后面介绍。C 使用作用域链接存储期为变量定义了多种存储方案。由于教程不涉及并发程序设计,所以不再赘述这方面的内容

C 的存储类别

  • C 有 5 种存储类别自动寄存器静态块作用域静态外部链接静态内部链接。如下表:

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

    • 属于自动存储类别变量具有自动存储期块作用域无链接,其拥有这三者的所有特点默认情况下,声明在函数头中的任何变量都属于自动存储类别

    • auto关键字

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

      1、为了更清楚表达你的意图,可以显式使用关键字auto
      2、关键字auto存储类别说明符。但要注意autoC++中用法完全不同

    • 内层块与外层块变量同名隐藏

      #include <stdio.h>
      
      int main(void)
      {
          int x = 30; // 原始的 x=30
          printf("outer block:%p %d\n", &x, x);
          // 定义一个单独的块
          {
              int x = 77; // 新的 x,隐藏了原始的 x
              printf("inner block:%p %d\n", &x, x);
          }
          // 离开块后,仍使用原始的 x
          printf("outer block:%p %d\n", &x, x);
          while (x++ < 33) // 原始的 x
          {
              int x = 100; // 新的 x,隐藏了原始的 x
              x++;
              printf("while loop :%p %d\n", &x, x);
          }
          printf("outer block:%p %d\n", &x, x);
          return 0;
      }
      outer block:000000000061FE1C 30
      inner block:000000000061FE18 77
      outer block:000000000061FE1C 30
      while loop :000000000061FE14 101
      while loop :000000000061FE14 101
      while loop :000000000061FE14 101
      outer block:000000000061FE1C 34

      1、如果内层块外层块变量同名内层块将会隐藏外层块的定义,但当离开内层块后,外层块变量作用域又回到了原来的作用域
      2、程序中,较难理解的是while()循环部分。while()循环的条件内使用的x原始的 x,其每次先参与while判断,再自增 1(因此参与判断的 x 的值分别为 30、31、32、33,当 x=33 时循环中断,但由于x++所以 x 仍然自增 1)。while()循环的内部每次执行循环都会创建一个新的 x=100(此时进入块内,与原始的 x 已无关系,原始 x 已被隐藏),由于块作用域每次循环结束就销毁新建的 x,因此实际创建了三个 x,又由于自动存储期每次创建的 x复用前面销毁的 x 的地址,所以三次输出都是101地址相同
      3、我们的编译器创建while()循环体中的x时,并未复用前一个(即程序中单独的那对花括号)销毁的 x 的地址,有的编译器会这样做(则whilex复用前面FE18的地址)

    • 没有花括号的块

      1、前面提到一个C99特性:作为循环if语句的一部分,即使不使用花括号也是一个块
      2、更完整的说,整个循环是它所在块子块循环体是整个循环块子块。相似的,if语句是一个,其相关联的子语句if语句子块

    • 编译器对 C99 和 C11 的支持

      1、有些编译器不支持 C99/C11的这些作用域规则(如 Microsoft Visual Stutio 2012)
      2、有些编译器会提供激活这些规则的选项,如gcc默认支持部分 C99 特性,但要用-std=c99选项激活其他特性,命令行输出gcc -std=c99 文件名激活
      3、与此类似,gccclang都要使用-std=c1x-std=c11选项,才支持C11 特性

  • 寄存器变量

    1、变量通常存储在内存中。但如果幸运的话寄存器变量将被存储在CPU 的寄存器中,或者概括地说,存储在最快的可用内存中。与普通变量相比,访问处理这些变量的速度更快
    2、由于寄存器变量存储在寄存器中,而非内存中,所以无法获取寄存器变量的地址
    3、绝大多数方面,寄存器变量自动变量一样(块作用域、无链接、自动存储期)。使用存储类别说明符register即可声明寄存器变量,如register int quick;
    4、前面之所以说“如果幸运的话”,是因为声明变量为register直接命令相比更像是一种请求编译器会根据实际寄存器最快可用内存数量衡量你的请求,或者直接忽略。如果忽略寄存器变量就变成普通变量,但仍然不能对该变量使用地址运算符

  • 块作用域的静态变量

    • 静态变量静态的意思是该变量内存原地不动,并不是值不变。具有文件作用域的变量自动具有必须静态存储期

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

    • static关键字

      #include <stdio.h>
      
      void def(void)
      {
          int fade = 1;
          static int stay = 1; // 块作用域的静态变量,具有静态存储期
          printf("fade = %d, stay = %d\n", fade++, stay++);
      }
      
      int main(void)
      {
          for (int i = 1; i <= 3; i++)
          {
              printf("%d: ", i);
              def();
          }
          return 0;
      }
      1: fade = 1, stay = 1
      2: fade = 1, stay = 2
      3: fade = 1, stay = 3

      1、在中,以存储类别说明符static以声明块作用域的静态变量
      2、从程序的运行结果可以看出,静态变量 stay保存了它递增 1后的值,但fade每次都是1。这表明了初始化的不同:每次调用def()都会初始化 fade,但stay只在编译时被初始化一次
      3、不能在函数形参中使用static,如int wontwork(static int flu);

  • 外部链接的静态变量

    • 外部链接的静态变量具有块作用域外部链接静态存储期。该类别有时称为外部存储类别,属于该类别的变量称为外部变量

    • 外部存储类别的使用

      #include <stdio.h>
      
      int Errupt;       // 外部定义的变量
      double Up[100];   // 外部定义的数组
      extern char Coal; // 如果只是本文件的声明,extern可省略,如果Coal被定义在另一个源代码文件中,则必须需要extern声明
      
      int main(void)
      {
          extern int Errupt;  // 可选的声明
          extern double Up[]; // 可选的声明
          return 0;
      }

      1、把变量定义性声明放在所有函数外便创建了外部变量
      2、为了指出该函数使用外部变量,可以在函数内使用关键字extern再次声明。如果一个源代码文件使用的外部变量定义另一个源代码文件中,则必须用extern关键字声明该变量
      3、注意示例中,main()中声明Up 数组时不用指明数组大小,因为前面外部变量第一次声明已经提供了数组大小信息。此外main()中的这两条声明可以省略,使用extern再次声明只是为了说明告知main()要使用这两个变量
      4、如果main()中的两条声明都去除extern关键字(如int Errupt;)会怎样?这相当于在main()创建了一个自动变量,它是一个独立局部变量与原来的外部变量 Errupt 不同。此时原先的外部变量仍然存在,只是在内(即main()中)执行语句时,块作用域的变量(即main()中声明的 Errupt)会隐藏原先文件作用域(即外部变量 Errupt)的同名变量。因此如果不得已要使用与外部变量同名局部变量,最好在局部变量声明时显式使用auto存储类别说明符,以清楚告知他人你的这种意图

  • 内部链接的静态变量

    • 内部链接的静态变量具有静态存储期文件作用域内部链接

    • static关键字

      static int svil = 1;  // 静态变量,内部链接
      int main(void)
      {
          ...
          return 0;
      }

      1、在所有函数外部(这点与外部变量相同),用存储类别说明符static定义的变量具有这种存储类别
      2、普通的外部变量可用于同一程序任意文件任意函数,但是内部链接的静态变量只能用于同一个文件任意函数
      3、同样可以使用储存类别说明符extern,在函数中再次重复声明任何具有文件作用域的变量。这样的声明不会改变其链接属性

存储类别补充

  • 多文件

    • 只有当程序多个翻译单元组成时,才体现区别内部链接外部链接的重要性

    • C 的变量共享

      1、复杂的 C 程序通常由多个单独的源代码文件组成。有时,这些文件可能要共享一个外部变量。C 通过在一个文件中进行定义式声明,然后在其他文件中进行引用式声明来实现共享
      2、也就是说,除了一个定义式声明外,其他声明都要使用extern关键字,而且,只有定义式声明才能初始化变量
      3、如果外部变量定义在一个文件里,其他文件在使用它前必须先用extern声明它。也就是说,在文件中对外部变量进行定义式声明但是单方面允许其他文件使用其他文件在用extern声明前不能直接使用

  • 储存类别说明符

    • C 语言共有6 个关键字作为存储类别说明符,分别是autoregisterstaticextern_Thread_localtypedef

    • 存储类别说明符

      1、typedef与任何内存存储无关,把它归为此类有一些语法上的原因。尤其是,在绝大多数情况下,不能声明使用多个存储类别说明符,所以这意味着不能使用多个存储类别说明符作为typedef的一部分
      2、_Thread_local唯一例外,其可以和staticextern一起使用
      3、auto表明变量自动存储期,只能用于块作用域变量声明中。由于中的变量本就具有自动存储期,显式使用auto大多情况是为了明确表达要使用与外部变量同名局部变量的意图
      4、register也只用于块作用域的变量,它把变量归为寄存器存储类别,请求最快速度访问该变量,同时还保护变量地址不被获取
      5、static说明符创建的对象具有静态存储期载入程序时创建对象程序结束对象消失
      6、extern表明声明的变量定义在别处

  • 储存类别和函数

    • 函数也有存储类别,可以是外部函数(默认)或静态函数。此外C99新增了第三种类别——内联函数,将在 16 章介绍

    • 示例程序

      double gamma(double);   // 该函数默认为外部函数
      static double beta(int, int);
      extern double delta(double, int);

      1、外部函数可以被其他文件的函数访问静态函数只能用于其定义所在的文件
      2、在同一个程序中,其他文件的函数可以调用gamma()delta(),但不能调用bate(),因为以static存储类别说明符创建的函数属于特定模块私有
      3、这样做避免了名称冲突的问题,由于beta()受限于它所在的文件,所以其他文件中可以使用与之同名的函数
      4、通常的做法是:使用extern声明定义在其他文件中的函数。这么做是为了表明当前文件中的使用的函数被定义在别处。除非使用static关键字,否则一般函数声明都默认为extern

  • 存储类别的选择

    • 对于使用哪种存储类别的回答,大多数是自动存储类别,要知道默认类别就是自动存储类别

    • 你可能会认为外部存储类别很不错,把所有变量设置成外部变量,就不需要参数指针在函数间传递信息了。然而,如果这样做,很可能A 函数违背你的意图,私自修改B 函数要使用的变量。无数程序员的经验表明,随意使用外部存储类别的变量导致的后果远远超过它带来的便利

    • 保护性程序设计的黄金法则是:“按需知道”原则。尽量在函数内部解决该函数的任务只共享那些需要共享的变量。因此,在使用某类别前,考虑一下是否有必要这样做

随机数函数和静态变量

  • C 语言的随机数函数是一个使用内部链接静态变量函数ANSI C库提供了一个rand()函数生成随机数

  • 生成随机数有多种算法,ANSI C 允许 C 实现针对特定机器使用最佳算法。然而,ANSI C 标准还提供了一个可移植标准算法,在不同系统生成相同的随机数。实际上,rand()伪随机数生成器,意思是可预测生成数字的实际序列,但是,数字在其取值范围均匀分布

  • 实现原理分析

    • 随机函数

      • 为了看清程序内部情况,我们使用可移植ANSI 版本(标准版本),而不是编译器内置rand()函数(实际使用可以直接使用编译器内置版本,只是生成的公式可能不同)

      • 随机函数示例(多文件编译)

        /* rand0.c —— 随机数函数文件 */
        
        static unsigned long int next = 1; // 种子
        
        unsigned int rand0(void)
        {
            // 生成伪随机数的公式
            next = next * 1103515245 + 12345; // 更新种子的公式
            return (unsigned int)(next / 65536) % 32768;  // 返回随机数的公式,(unsigned int)为强制类型转换
        }
        /* r_drive0.c 主运行文件,与 rand0.c 函数文件一起编译 */
        
        #include <stdio.h>
        
        extern unsigned int rand0(void); // 声明跨文件调用外部函数
        
        int main(void)
        {
            for (int i = 0; i < 5; i++)
            {
                printf("%d\n", rand0());  // 输出随机数
            }
            return 0;
        }
        16838
        5758
        10113
        17515
        31051
      • 示例解析

        1、可移植版本方案开始于一个“种子数字。该函数使用该种子生成新的数,这个新数又成为新的种子。然后新的种子可用于生成更新的种子,以此类推。因此这个种子需要是一个静态变量保持时刻存在并记录种子以便函数访问
        2、如示例rand0.c函数文件中,seed就是种子,其在ANSI 方案中默认被初始化为 1生成随机数函数中,先利用公式seed 自身数值更新 seed 的值(即生成新种子),然后return利用公式返回一个按照公式生成伪随机数return%32768说明返回的数一定在0~32767之间
        3、r_drive0.c主运行文件和rand0.c一起编译并运行。输出证明调用正常,结果确实生成了伪随机数。但如果多运行几次,会发现每次生成的伪随机数都是按照相同规律出现的(即生成的随机数队列是一样的,都是 16838、5758、10113…)。这是因为,每次程序开始运行时,随机数生成都开始于种子 1
        4、为了解决这一问题,我们需要想种办法让每次程序开始时种子不同,即需要一种办法重置种子

    • 重置种子

      • 我们可以引入另一个函数srand1()重置种子来解决随机数队列相同的问题(srand1()相当于 C 库的srand()函数)

      • 关键在于,要让随机数函数中的next 变量,成为只供rand0()srand1()访问内部链接静态变量(“按需知道”原则)

      • 重置种子示例(多文件编译)

        /* rand0.c 函数文件 */
        
        static unsigned long int next = 1; // 种子
        
        unsigned int rand0(void)
        {
            // 生成伪随机数的公式
            next = next * 1103515245 + 12345; // 更新种子的公式
            return (unsigned int)(next / 65536) % 32768;  // 返回随机数的公式,(unsigned int)为强制类型转换
        }
        
        // 添加 srand1() 函数
        void srand1(unsigned int seed)
        {
            next = seed;  // 重置种子
        }
        /* r_drive0.c 主运行文件,与 rand0.c 函数文件一起编译 */
        
        #include <stdio.h>
        #include <stdlib.h>
        
        extern unsigned int rand0(void); // 声明跨文件调用外部函数
        extern int rand1(void);          // 声明跨文件调用外部函数
        
        int main(void)
        {
            unsigned seed;
            printf("输入你想要的随机数的种子\n");
            // while判断输入是否有效合法
            while (scanf("%u", &seed) == 1)
            {
                srand1(seed); // 重置种子
                for (int i = 1; i <= 5; i++)
                    printf("%d\n", rand0()); // 输出随机数
                printf("输入您的下一个种子,或输入q退出\n");
            }
            return 0;
        }
      • 示例解析

        1、由于next具有内部链接文件作用域静态变量,意味着rand0()srand1()都能使用它,但其他文件的函数无法访问它。这符合了程序设计“按需知道”的原则
        2、现在的示例程序中,由于每次运行执行了srand1()重置种子,所以种子都是输入的数字,因此随机数函数随机性更强
        3、但这样重置种子还是比较麻烦,有没有什么办法能自动重置种子,答案是有的

  • 项目中使用随机数(自动重置种子)

    • 项目中使用随机数,不需要再编写rand0.c函数文件了,前面只是为了方便看清内部的操作。项目中,可以直接使用编译器提供的版本:rand()函数srand()函数,它们被集成在stdlib.h头文件中,与前面rand0.c中定义的函数使用方法相同

    • 此外,在项目中很多时候需要程序自动重置种子生成随机数,思路如下

      1、如果C 实现允许访问一些可变的量(如,系统时钟),可以用这些值初始化种子
      2、例如ANSI C有一个time()函数集成在time.h头文件中,可以返回系统时间。该返回值是一个可运算类型,且随时间变化而变化,便很适合做自动重置种子
      3、time()返回类型time_t,这与srand()接受的unsigned int类型不符,但我们可以使用强制类型转换(注意别忘了time.h头文件):srand( (unsigned int)time(0) );
      4、一般而言,time()接受的参数是一个time_t类型对象地址,而时间值就存储在传入的地址上(将时间值存入传入的地址)。当然也可以传入空指针 0作为参数,这样只不过仅能通过返回值机制提取值

分配内存:malloc()和 free()

  • 我们前面讨论的存储类别有一个共同之处:在确定用哪种存储类别后,根据已制定好的内存管理规则,自动选择作用域存储期。然而,还有更灵活的选择,即用库函数分配管理内存

  • malloc()函数

    • C 可以在程序运行时分配更多的内存,主要的工具是malloc()函数,其被包含在stdlib.h

    • 函数使用

      1、malloc()函数用于主动分配内存。其接受一个参数,表示所需的内存字节数,返回动态分配内存块首字节地址
      2、malloc()函数会找到合适的空闲内存块,这样的内存是匿名的。也就是说,该函数可以分配内存,但不会为其赋名
      3、可以把返回的首字节地址赋值给一个指针变量,并使用指针访问这块内存

    • 函数的返回类型

      1、由于char表示1 字节,所以曾经malloc()返回类型通常被定义为指向 char 的指针
      2、然而从ANSI C标准起,C 使用一个新的类型——指向 void 的指针。该类型相当于一个通用指针,将这种类型赋值任意其他类型的指针,完全不用考虑类型匹配的问题
      3、malloc()函数还可用于返回指向数组的指针指向结构的指针等,所以通常该函数返回值会被强制转换对应匹配的类型。在ANSI C中,应该坚持使用强制类型转换提高代码可读性(可选,因为第 2 条的说明)
      4、如果malloc()分配内存失败,将返回空指针

    • 使用malloc()创建数组

      #include <stdlib.h> // 注意不要忘了头文件
      
      double *ptd;
      ptd = (double *)malloc(30 * sizeof(double));

      1、要使用malloc()创建一个数组,除了要用malloc()请求一块内存,还需要一个指针记录这块内存的位置
      2、示例中,sizeof(double)表示一个 double 类型所需的内存大小30 * sizeof(double)表示需要 30 个 double 的大小(即可以看做 30 个元素)
      3、(double *)强制类型转换,仅用于提高代码可读性,因为malloc()返回值指向 void 的指针,可以不用考虑类型匹配问题
      4、注意,指针 ptd被声明指向一个 double 类型,而不是指向内含 30 个 double 的块。回忆一下,数组名是该数组首元素地址,因此如果让ptd指向这个块的首元素,就能像使用数组名一样使用它。也就是说,可以使用表达式ptd[0]ptd[1]分别访问该首元素第二个元素,以此类推

    • malloc()创建的数组的特点

      1、malloc()创建的数组和曾经学过的变长数组,都可以创建动态数组
      2、动态数组普通数组的不同在于,前者可以在运行时选择数组的大小分配内存(详细介绍见变长数组)
      3、此外C99前不允许double item[n];,因为n 不允许是变量,但可以ptd = malloc(n * sizeof(double));,因此malloc()创建数组变长数组更灵活

  • free()函数

    • 通常,malloc()要与free()配套使用free()可以释放之前malloc()分配的内存,其也被包含在stdlib.h头文件中

    • 函数使用

      1、free()函数用于释放malloc()分配的内存,其接受一个参数,即之前malloc()返回的地址
      2、因此,动态分配内存存储期就是从调用malloc()分配内存开始,到调用free()释放内存为止。试想malloc()free()管理着一个内存池,每次调用malloc()便分配给程序使用,每次调用free()便将内存归还内存池,以便重复使用这些内存
      3、free()只能接受malloc()分配的内存地址,不能释放通过其他方式(如:声明)分配内存

    • free()的重要性

      1、静态内存的数量在编译时是固定的,在程序运行期间也不会改变自动变量使用的内存数量在程序执行期间自动增加或减少。但是动态分配的内存只会增加,除非用free()进行释放
      2、假设一个程序需要大量调用malloc()分配内存,但没有使用free()释放内存,就可能在运行时耗尽所有可分配的内存导致程序出错
      3、这类为题称为内存泄漏,因此需要及时使用free()释放内存避免这类问题发生

  • calloc()函数

    • 分配内存还可以使用calloc()函数,其用法与malloc()大致相同

    • 函数使用

      #include <stdlib.h>
      
      long *newmem;
      newmem = (long *) calloc(100, sizeof(long));

      1、和malloc()类似,calloc()ANSI C返回指向 char 的指针,在ANSI C返回指向 void 的指针
      2、calloc()接受两个参数(ANSI C规定是size_t类型),第一个参数是所需的存储单元数量第二个参数存储单元大小(以字节为单位)
      3、calloc()还有一个特性,它会把所有位设置为 0。此外,free()也可以释放calloc()分配的内存

ANSI C 类型限定符

  • 引入

    1、我们通常用类型存储类别来描述一个变量。C90新增了两个属性:恒常性易变性,可以分别用关键字constvolatile来声明,以这两个关键字创建的类型限定类型
    2、C99标准新增第三个限定符restrict,用于提高编译器优化
    3、C11标准新增第四个限定符_AtomicC11提供了一个可选库,由stdatomic.h管理,以支持并发程序设计,而且_Atomic可选支持项
    4、C99类型限定符新增了一个属性:它们现在是幂等的。这个属性的意思是,可以在一条声明多次使用同一个限定符,多余的限定符将被忽略

  • const类型限定符在之前第 4 章第 10 章已经详细介绍过,在此略过

  • volatile类型限定符

    • 作用及用途

      1、volatile类型限定符告知计算机,代理(而不是该变量所在的程序)可以改变该变量的
      2、通常,它被用于硬件地址以及其他程序同时运行的线程共享数据。例如,一个地址上可能存储着当前的时钟时间,无论程序做什么,地址上的随时间变化而改变。或者一个地址用于接受另一台计算机传入的信息

    • 使用语法

      /* volatile语法与const一样 */
      volatile int loc1;    // loc1是一个易变的位置
      volatile int * ploc;  // ploc是一个指向易变位置的指针
    • 为什么列入 ANSI 标准

      val1 = x;
      /*
      省略中间一些不使用x的代码
      */
      val2 = x;

      1、开始可能认为volatile是个可有可无的概念,为何ANSI要将其放入标准?是因为它涉及编译器的优化
      2、智能的(进行优化的)编译器会注意到以上代码使用了 2 次 x,但并未改变它的,于是编译器x 的值临时存储在寄存器中,然后再val2使用x时,才从寄存器中(而非原始内存位置上)读取 x 的值,以节约时间。这个过程被称为高速缓存
      3、通常高速缓存是个不错的优化方案,但如果一些其他代理在以上两条语句间改变了 x 的值,就不能这样优化了。如果没有volatile关键字编译器不知道这种事情是否发生,因此为了安全起见,编译器不会进行高速缓存。这便是ANSI 前的情况
      4、现在,如果声明中没有volatile关键字,编译器会假定变量的值使用过程中不变,然后再尝试优化代码

  • restrict类型限定符

    • 作用及用途

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

    • 深入理解优化部分代码

      int ar[10];
      int *restrict restar = (int *)malloc(10 * sizeof(int));
      int *par = ar;
      /*-----------------------------------------------------*/
      for (int n = 0; n < 10; n++)
      {
          par[n] += 5;
          restar[n] += 5;
          ar[n] += 2;
          par[n] += 3;
          restar[n] += 3;
      }

      1、这里,指针restar是访问malloc()所分配内存唯一且初始的方式,因此可以用restrict限定它;而指针par既不是访问 ar 数组中数据初始方式,也不是唯一方式,所以不用将其设置为restrict
      2、由于之前声明了 restarrestrict编译器可以把for()中涉及restar两条语句简化替换为restar[n] += 8;(原本是先+5,后+3)。但是如果将for()中与par相关的两条语句简化替换为par[n] += 8;不可以,因为par两次访问相同数据之间,用 ar 改变过该数据的
      3、在本例中,如果未使用restrict编译器就必须假设最坏的情况(即两次使用指针之间,其他标识符可能更改了数据)。如果使用了restrict编译器则可以选择捷径优化计算

  • _Atomic类型限定符

    • 简介概述

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

    • 示例程序

      #include <stdatomic.h>
      
      int hogs;                 // 普通声明
      hogs = 12;                // 普通赋值
      /*---------------可以替换为---------------*/
      _Atomic int hogs;         // hogs是一个原子类型的变量
      atomic_store(&hogs, 12);  // stdatomic.h中的宏

      1、这里,在hogs中存储的12是一个原子过程其他线程不能访问 hogs
      2、编写这一代码的前提,是编译器要支持这一新特性


文件输入/输出


章节概要:与文件进行通信;文本模式和二进制模式;I/O 的级别;标准文件;标准 I/O;fopen()函数;文件指针;getc()putc()函数;fclose()函数;指向标准文件的指针;简单的文件压缩程序;文件 I/O;fprintf()fscanf()函数和rewind()函数;fgets()fputs()函数;随机访问:fseek()stell()fgetpos()fsetpos()函数;其他标准 I/O 函数;ungetc()函数;fflush()函数;setvbuf()函数;二进制 I/O:fread()fwrite()feof()ferror()函数

与文件进行通信

  • 有时,需要程序文件读取信息把信息写入文件。这种程序与文件交互的形式就是文件重定向(第 8 章介绍过)。这种方法很简单,但是有一定限制,尤其对于交互性程序。C 提供了更强大的文件通信方法,可以在程序中打开文件,然后使用特殊的I/O 函数与文件交互。在研究这些方法之前,先简要介绍下文件的性质

  • 文件是什么

    1、文件通常是在磁盘或固态硬盘上的一段已命名的存储区
    2、对我们而言,stdio.h就是一个文件名称,该文件中包含一些有用的信息。对于操作系统而言,文件更复杂一些
    3、例如,大型文件会被分开存储,或者包含一些额外的数据,方便操作系统确定文件的种类。然而这些都是操作系统所关心的,程序员关心的是C 程序如何处理文件
    4、C 把文件看做是一系列连续的字节每个字节都能被单独读取。这与UNIX环境中的文件结构相对应。由于其他环境中可能无法完全对应这个模型,C 提供两种文件模式文本模式二进制模式

  • 文本模式和二进制模式

    • 首先,要区分文本内容二进制内容文本文件格式二进制文件格式以及文件的文本模式二进制模式

    • 文本与二进制的文件格式和内容

      1、所有文件内容都以二进制形式(0 或 1)存储
      2、但是,如果文件最初使用二进制编码的字符(如 ASCII 或 Unicode)表示文本,该文件就是文本文件,其中包含文本内容
      3、如果文件中的二进制值表示机器语言代码数值数据图片音乐编码等,该文件就是二进制文件,其中包含二进制内容

    • 文本与二进制模式

      1、由于各个操作系统文件识别与管理方式不同,为了规范文本文件的处理,C 提供两种访问文件的途径二进制模式文本模式
      2、在二进制模式中,程序可以访问文件的每个字节。而在文本模式中,程序所见的内容和文件的实际内容不同
      3、程序以文本模式读取文件时,把本地环境表示的行末尾文件结尾映射为C 模式。例如 C 的文本模式MS-DOS平台,读取文件时,会把\r\n(MS-DOS 中表示的行末尾)转换成\n写入文件时,恰好与此相反
      4、虽然 C 提供了二进制模式文本模式,但这两种模式的实现可以相同。因为UNIX只使用一种文件格式,这两种模式对UNIX实现完全相同,对Linux也是如此

  • I/O 的级别

    1、除了选择文件的模式,大多数情况下,还可以选择I/O两个级别(即处理文件访问的两个级别)
    2、底层 I/O使用操作系统提供的基本 I/O 服务标准高级 I/O使用C 库标准包stdio.h头文件定义
    3、因为无法保证所有的操作系统都使用相同的底层 I/O 模型,C 标准只支持标准 I/O 包
    4、有些实现会提供底层库,但是 C 标准建立了可移植的 I/O 模型,我们主要讨论这些I/O

  • 标准文件

    1、C 程序会自动打开3 个文件,它们被称为标准输入标准输出标准错误输出
    2、在默认情况下,标准输入是系统的普通输入设备,通常为键盘标准输出标准错误输出是系统的普通输出设备,通常为显示屏
    3、通常,标准输入为程序提供输入,它是getchar()scanf()使用的文件;程序通常输出标准输出,它是putchar()puts()printf()使用的文件
    4、第 8 章提到的重定向,便是将其他文件视为标准输入标准输出,但并不会重定向标准错误输出。这样很好,否则只能打开文件才能看到错误信息了

标准 I/O

  • 标准 I/O 的优点

    1、与底层 I/O相比,标准 I/O 包除了可移植以外,还有两个好处
    2、第一,标准 I/O有许多专门的函数简化了处理不同 I/O的问题。例如printf()不同形式的数据转换成与终端相适应的字符串输出
    3、第二,输入和输出都是缓冲的。也就是说,一次转移一大块信息而不是一字节信息(通常至少 512 字节)。例如程序读取文件时,一块数据拷贝缓存区,这种缓冲极大地提高了数据传输速率

  • 示例程序

    /*注意本程序使用了命令行参数,需要使用命令行运行并传入参数*/
    #include <stdio.h>
    #include <stdlib.h> // 提供exit()的原型
    
    // 使用命令行参数
    int main(int argc, char *argv[])
    {
        int ch;
        FILE *fp; // 文件指针
        unsigned long ct = 0;
        // 判断命令行参数是否成功读取
        if (argc != 2)
        {
            // 如果读取有误输出提示语并退出
            printf("Usage: %s filename\n", argv[0]);
            exit(EXIT_FAILURE);
        }
        // 判断文件是否成功打开
        if ((fp = fopen(argv[1], "r")) == NULL)
        {
            printf("Can't open %s\n", argv[1]);
            exit(EXIT_FAILURE);
        }
        // 将文件内容打印到屏幕并记录文件的字符数
        while ((ch = getc(fp)) != EOF)
        {
            putc(ch, stdout); // 等效于putchar(ch)
            ct++;
        }
        fclose(fp); // 关闭文件
        printf("\n%d", ct);
        return 0;
    }
  • fopen()函数

    • 程序可以使用fopen()打开文件,其声明在stdio.h头文件

    • fopen()第一个参数待打开的文件名称,更准确的说,是一个包含该文件名字符串地址第二个参数一个字符串,用于指定打开文件的模式

    • fopen()的模式字符串

      模式字符串 含义
      “r” 以读模式打开文件
      “w” 以写模式打开文件,将现有文件的长度截为 0,如果文件不存在,则创建一个新文件
      “a” 以写模式打开文件,在现有文件末尾添加内容,如果文件不存在,则创建一个新文件
      “r+” 以更新模式打开文件(即可以读写文件)
      “w+” 以更新模式打开文件(即可以读写文件),将现有文件的长度截为 0,如果文件不存在,则创建一个新文件
      “a+” 以更新模式打开文件(即可以读写文件),在现有文件末尾添加内容,如果文件不存在,则创建一个新文件。可以读整个文件,但只能从末尾添加内容
      “rb”,”wb”,”ab”,”rb+”,”r+b”,”wb+”,”w+b”,”ab+”,”a+b” 与上一个模式类似,但是以二进制模式而不是文本模式打开文件
      “wx”,”wbx”,”w+x”,”wb+x”,”w+bx” (C11)类似非 x 模式,但是如果文件已存在或以独占模式打开文件,则打开文件失败

      1、像UNIXLinux这样只有一种文件类型的系统,带 b 字母的模式和不带 b 字母的模式相同
      2、C11新增了带 x 字母写模式,与以前的写模式相比具有更多特性。第一,如果以传统的写模式打开一个现有文件,fopen()会把该文件长度截为 0,就会丢失该文件的内容;但使用带 x 的写模式,即使fopen()操作失败,也不会删除源文件内容。第二,如果环境允许x 模式独占特性使得其他程序或线程无法访问正在被打开的文件

    • fopen()与文件指针

      1、程序成功打开文件后,fopen()将返回文件指针其他 I/O 函数可以使用这个指针指定该文件
      2、文件指针(该例中的 fp)的类型指向 FILE 的指针FILE是一个定义在stdio.h中的派生类型
      3、文件指针并不指向实际的文件,它指向一个包含文件信息数据对象,其中包含操作文件I/O 函数所用的缓冲区信息

  • getc()和 putc()函数

    • getc()putc()getchar()putchar()作用类似,所不同的是,要告诉getc()putc()函数使用哪一个文件

    • getc接受一个参数,即文件指针putc()接受两个参数,第一个是待写入的字符,第二个是文件指针

    • 使用方式

      1、ch = getchar();的意思是从标准输入获取一个字符,而ch = getc(fp);的意思是从fp 指定的文件获取一个字符。与此类似,putc(ch, fpout);的意思是把字符 ch放入FILE 指针 fpout 指定的文件
      2、示例程序中,putc(ch, stdout);stdout作为第二个参数stdout作为与标准输出相关联的文件指针,定义在stdio.h中,所以putc(ch, stdout);putchar(ch);作用相同
      3、如果getc()读取一个字符时发现是文件结尾,将返回特殊值EOF

  • fclose()函数

    • 程序可以使用fclose()关闭文件,必要时刷新缓冲区,其接受一个参数,即文件指针,如fclose(fp)函数关闭 fp 指定的文件

    • 对于较正式的程序,应该检查是否成功关闭文件。如果成功关闭,fclose()返回 0,否则返回EOF

  • 指向标准文件的指针

    标准文件 文件指针 通常使用的设备
    标准输入 stdin 键盘
    标准输出 stdout 显示器
    标准错误 stderr 显示器

简单的文件压缩程序

/* 将文件压缩成原来的1/3*(仅压缩大小) */
/* 同样本程序依然使用命令行参数传入参数 */
#include <stdio.h>
#include <stdlib.h> // 提供 exit() 的原型
#include <string.h> // 提供 strcpy()、strcat()的原型
#define LEN 40

int main(int argc, char *argv[])
{
    FILE *in, *out; // 声明两个文件指针
    int ch;
    char name[LEN]; // 存储输出文件名
    int count = 0;

    // 检查命令行参数
    if (argc < 2)
    {
        fprintf(stderr, "Usage: %s filename\n", argv[0]);
        exit(EXIT_FAILURE);
    }

    // 设置输入
    if ((in = fopen(argv[1], "r")) == NULL) // 以读模式打开文件
    {
        fprintf(stderr, "I couldn't open the file '%s' \n", argv[1]);
        exit(EXIT_FAILURE);
    }

    // 设置输出
    strncpy(name, argv[1], LEN - 5); // 拷贝文件名,LEN-5留出5字符添加文件后缀名
    name[LEN - 5] = '\0';
    strcat(name, ".red");                 // 在文件名后添加.red后缀
    if ((out = fopen(name, "w")) == NULL) // 以写模式打开文件
    {
        fprintf(stderr, "Can't create output file\n");
        exit(3);
    }

    // 拷贝数据
    while ((ch = getc(in)) != EOF)
        if (count++ % 3 == 0)
            putc(ch, out); // 打印每三个字符的第一个字符

    // 关闭文件,收尾
    if (fclose(in) != 0 || fclose(out) != 0)
        fprintf(stderr, "Error in closing files\n");
    return 0;
}

文件 I/O

  • fprintf()、fscanf()函数和 rewind()函数

    • fprintf()fscanf()函数的工作方式与我们熟悉的printf()scanf()相似,只是前两者需要用第一个参数指定待处理的文件

    • rewind()可以让程序回到文件开始处,其接受一个参数,即文件指针

    • 示例程序

      /* 将终端输入的字符存入文件,并从文件再次读取输出到终端 */
      #include <stdio.h>
      #include <stdlib.h>
      #include <string.h>
      #define MAX 41
      
      int main(void)
      {
          FILE *fp;
          char words[MAX];
          if ((fp = fopen("wordy", "a+")) == NULL)
          {
              fprintf(stdout, "Can't open 'wordy' file\n");
              exit(EXIT_FAILURE);
          }
          puts("输入单词将其添加到文件中,在新的一行输入#符号退出");       // 终端提示语
          while ((fscanf(stdin, "%40s", words) == 1) && (words[0] != '#')) // 从 stdin 接受至多40字符存入 words,且首字符不为#
              fprintf(fp, "%s\n", words);                                  // 向 fp 指向的文件输出字符串,内容为 words 的字符
          puts("文件预览:");                                              // 终端提示语
          rewind(fp);                                                      // 回到文件开始处
          while (fscanf(fp, "%s", words) == 1)                             // 从 fp 指向的文件接受字符,存入 words
              puts(words);                                                 // 输出到屏幕上
          fclose(fp);
          return 0;
      }
  • fgets()和 fputs()函数

    • 第 11 章简要介绍过这两个函数,在此简单回顾

    • fgets()函数

      1、语法fgets(words,STLEN,fp);第一个参数存储字符串的地址第二个参数输入字符串的大小第三个参数文件指针
      2、fgets()读取输入,直到第一个换行符后,或文件结尾,或STLEN-1个字符,在末尾添加一个空字符使其成为字符串
      3、fgets()遇到 EOF时将返回 NULL 值,可以利用这一特性检查是否到达文件结尾;如果未遇到 EOF返回第一个参数的地址

    • fputs()函数

      1、语法fputs(words,fp);第一个参数所需输出字符串的地址第二个参数文件指针
      2、fputs在打印字符串时不会在末尾添加换行符

随机访问:fseek()和 stell()

  • 有了fseek()函数,便可把文件看做是数组,在fopen()打开的文件中直接移动到任意字节处

  • 注意,fseek()3 个参数返回 int 类型的值;ftell()返回 long 类型的值,表示文件中的当前位置

  • 示例程序

    #include <stdio.h>
    #include <stdlib.h>
    #define CNTL_Z '\032' // DOS 文本文件中的文件结尾标记
    #define SLEN 81
    
    int main(void)
    {
        char file[SLEN];
        char ch;
        FILE *fp;
        long count, last;
    
        puts("输入文件名:");
        scanf("%80s", file);
        if ((fp = fopen(file, "rb")) == NULL) // 只读模式
        {
            puts("无法打开");
            exit(EXIT_FAILURE);
        }
        fseek(fp, 0L, SEEK_END); // 定位到文件末尾
        last = ftell(fp);
        for (count = 1L; count <= last; count++)
        {
            fseek(fp, -count, SEEK_END);    // 回退(倒序输出)
            ch = getc(fp);
            if (ch != CNTL_Z && ch != '\r') // MS-DOS 文件
                putchar(ch);
        }
        fclose(fp);
        return 0;
    }
  • fseek()函数

    • fseek的使用

      1、fseek()第一个参数FILE 指针,指向待查找的文件fopen()应该已经打开该文件
      2、fseek()第二个参数偏移量,该参数表示从设置的起始点开始要移动的距离。该参数必须是一个long 类型的值(L 后缀表明其值是long 类型),可以为(前移)、(后移)或者0(不动)
      3、fseek()第三个参数模式,由该参数确定起始点。模式如下表(旧的实现可能缺少这些定义,可以用数值 0L、1L、2L分别表示这三种模式)

      模式 偏移量的起始点
      SEEK_SET 文件开始处
      SEEK_CUR 当前位置
      SEEK_END 文件末尾
    • fseek()的一些示例

      fseek(fp, 0L, SEEK_SET);      // 定位至文件开始处
      fseek(fp, 10L, SEEK_SET);     // 定位至文件中第10个字节
      fseek(fp, 2L, SEEK_CUR);      // 从文件当前位置前移2字节
      fseek(fp, 0L, SEEK_END);      // 定位至文件结尾
      fseek(fp, -10L, SEEK_END);    // 从文件结尾回退10字节
    • 如果一切正常fseek()返回值为 0;如果出现错误(如试图移动的距离超出文件范围),其返回值为-1

  • ftell()函数

    • ftell()函数的返回类型long,它返回的是参数指向文件当前位置距离文件开始处的字节数

    • 实例要素分析

      1、示例中首先使用了fseek(fp, 0L, SEEK_END)当前位置设置在文件末尾
      2、此时ftell(fp)的值就是文件开始处结尾字节数last = ftell(fp)将其赋给last
      3、然后再是for循环中使用了last作为条件范围

  • fgetpos()和 fsetpos()函数

    • 函数介绍

      1、fseek()ftell()潜在问题是,他们都把大小限制在long 能表示的范围内。鉴于此,ANSI C新增了两个处理较大文件新定位函数fgetpos()fsetpos()
      2、这两个函数不使用 long 类型的值表示位置,他们使用一种新类型——fpos_t(file position type,文件定位类型)。fpos_t类型不是基本类型,它根据其他类型来定义
      3、fpos_t变量数据对象可以在文件中指定一个位置,它不能是数组类型,除此之外,没有其他限制。实现可以提供一个满足特殊平台要求的类型,例如fpos_t可以实现为结构

    • 函数使用简介

      1、fgetpos()的函数原型为int fgetpos(FILE * restrict stream, fpos_t * restrict pos);
      2、调用该函数时,它把fpos_t 类型的值放在pos 指向的位置上,该值描述文件中当前位置文件开头的字节数。如果成功,返回 0,如果失败,返回非 0
      3、fsetpos()的函数原型为int fsetpos(FILE *stream, const fpos_t *pos);
      4、调用该函数时,使用pos 指向位置上的fpos_t 类型值来设置文件指针指向偏移该值后指定的位置。如果成功,返回 0,如果失败,返回非 0。其中,fpos_t类型的值应通过之前调用的fgetpos()获得

其他标准 I/O 函数

  • ungetc()函数

    1、函数原型int ungetc(int c, FILE *fp)
    2、ungetc()函数把c 指定的字符放回输入流
    3、如果把一个字符放回输入流,下次调用标准输入函数时将读取该字符

  • fflush()函数

    1、函数原型int fflush(FILE *fp)
    2、调用fflush()函数引起输出缓冲区中所有的未写入数据被发送到fp 指定的输出文件,这个过程被称为刷新缓冲区。如果fp空指针,所有输出缓冲区都被刷新
    3、在输入流中使用fflush()效果是未定义的。只要最近一次操作不是输入操作,就可以用该函数来更新流

  • setvbuf()函数

    1、函数原型int setvbuf(FILE * restrict fp, char * restrict buf, int mode, size_t size)
    2、setvbuf()函数创建了一个供标准 I/O 函数替换使用的缓冲区。在打开文件后未对流进行其他操作前调用该函数
    3、指针 fp识别待处理的流buf指向待使用的存储区mode的选择有三种_IOFBF表示完全缓冲_IOLBF表示行缓冲_IONBF表示无缓冲
    4、如果buf值不是 NULL,则必须创建一个缓冲区,如果把NULL作为buf 的值,该函数会为自己分配一个缓冲区
    5、如果函数操作成功,返回 0,否则返回非 0 值

  • 二进制 I/O:fread()和 fwrite()

    • 引入介绍

      1、之前用到的标准 I/O 函数都是面向文本的,用于处理字符字符串。如何在文件中保存数值信息
      2、用fprintf()函数%f转换说明只是把数值保存为字符串。例如有double num = 1./3.;声明,有fprintf(fp, "%f", num);语句num也不过只被存储为8 个字符0.333333,存储后,读取文件时就无法将其恢复为更高的精度。一般而言,fprintf()数值转换为字符数据,这种转换可能改变值
      3、为保证数值在存储前后一致最精确的做法是使用与计算机相同位组合来存储。因此,double 类型的值应该存储在一个double 大小的单元中。如果以程序所用的表示法把数据存储在文件中,则称以二进制形式存储数据
      4、对于标准 I/Ofread()fwrite()函数用于以二进制形式处理数据

    • fwrite()函数

      char buffer[256];
      fwrite(buffer, 256, 1, fp);
      /*-----------------------*/
      double earnings[10];
      fwrite(earnings, sizeof(double), 10, fp);

      1、函数原型size_t fwrite(const void * restrict ptr, size_t size, size_t nmemb, FILE * restrict fp)
      2、fwrite()二进制数据写入文件。其中指针 ptr待写入数据块的地址size表示待写入数据块的大小(以字节为单位),nmemb表示待写入数据块的数量(也是一次性写入数据块的数量),fp表示待写入的文件
      3、注意fwrite()第一个参数类型是指向 void 的指针(通用类型指针),因此示例中分别传入指向 char 的指针指向 double 的指针都是合法的
      4、fwrite()函数返回成功写入项的数量。正常情况下返回值就是nmemb,出现错误返回值就会比 nmemb 小

    • fread()函数

      double earnings[10];
      fread(earnings, sizeof(double), 10, fp);

      1、函数原型size_t fread(void * restrict ptr, size_t size, size_t nmemb, FILE * restrict fp)
      2、fread()函数用于读取fwrite()写入文件的数据。其接受参数fwrite()一致,其中ptr待读取文件数据内存中的地址fp指定待读取的文件
      3、fread()函数返回成功读取项的数量。正常情况下返回值就是nmemb,出现错误或读到文件结尾返回值就会比 nmemb 小

  • feof()和 ferror()函数

    1、函数原型int feof(FILE *fp)int ferror(FILE *fp)
    2、如果标准输入函数返回EOF,则通常表明函数已到达文件结尾。然而当出现读取错误时,函数也会返回EOFfeof()ferror()用于区分两种情况
    3、当上一次输入调用检测到文件结尾时,feof()返回非 0 值,否则返回 0;当读或写出现错误ferror()返回非 0 值,否则返回 0


结构和其他数据形式


章节概要:初识结构体;建立结构声明;定义结构变量;初始化结构;访问结构成员;结构的初始化器;结构数组;声明结构数组;标识结构数组的成员;嵌套结构;指向结构的指针;声明和初始化结构指针;用指针访问成员;向函数传递结构的信息;传递结构成员;传递结构的地址;传递结构;其他结构特性;使用结构数组的函数;结构与内存分配;结构中的字符指针与malloc();复合字面量和结构;伸缩型数组成员;匿名结构;把结构内容保存到文件;链式结构;联合简介;枚举类型;共享名称空间;typedef简介;其他复杂的声明;类型声明黄金法则;函数指针

初识结构体

  • 示例问题:创建图书目录

    • 引入

      1、假如你需要打印一份图书目录,要打印每本书的各种信息(书名、作者、出版社、日期、页数等)。这些信息其中一些可以存储在字符数组中,其他一些又需要int 数组float 数组
      2、用7 个不同的数组分别记录比较繁琐,尤其是如果你需要多份列表(一份按书名排序,一份按作者排序等)。如果能把图书目录的信息都包含在一个数组里更好,每个元素包含一本书相关信息
      3、我们需要一种既能包含字符串又能包含数字数据形式,而且还要保持各信息的独立。C 的结构体就满足这种情况下的需求

    • 示例程序

      #include <stdio.h>
      #include <string.h>
      #define MAXTITL 41 // 书名最大长度+1
      #define MAXAUTL 31 // 作者姓名最大长度+1
      
      // 之前自己自定义的 s_gets 函数
      char *s_gets(char *str, int n)
      {
          char *ret_val; // 创建指针(同时也是存储字符串的变量)
          int i = 0;
          ret_val = fgets(str, n, stdin); // fgets()返回指向char的指针,如果顺利的话返回地址与传入的第一个参数相同(注意 ret_val 会和 str 指向同一个地址),如果读到文件结尾返回NULL
          if (ret_val)                    // 即,ret_val != NULL,判断是否读到文件结尾
          {
              while (str[i] != '\n' && str[i] != '\0') // 忽略跳过正常字符
                  i++;
              if (str[i] == '\n') // 出现换行符替换为空字符,即不存储换行符
                  str[i] = '\0';
              else                          // 否则就是读到了空字符
                  while (getchar() != '\n') // 丢弃该输入行的其余字符
                      continue;
          }
          return ret_val;
      }
      
      // 定义结构模板:标记是 book
      struct book
      {
          char title[MAXTITL];
          char author[MAXAUTL];
          float value;
      }; // 结构模板结束,注意分号
      
      int main(void)
      {
          struct book library; // 把 library 声明为一个 book 类型的变量
          printf("输入书名:");
          s_gets(library.title, MAXTITL); // 使用 s_gets 函数访问将信息写入 library.title,即结构中的 title 变量
          printf("输入作者:");
          s_gets(library.author, MAXAUTL);
          printf("输入价钱:");
          scanf("%f", &library.value);
          printf("书名:%s\n作者:%s\n价钱:%.2f", library.title, library.author, library.value);
          return 0;
      }
  • 建立结构声明

    • 结构声明描述了一个结构组织布局,声明类似下面这样:

      struct book
      {
          char title[41];
          char author[31];
          float value;
      };
    • 声明分析

      1、该声明描述了一个由两个字符数组一个 float 类型变量组成的结构。该声明并未创建实际的数据对象,只描述该对象由什么组成
      2、有时我们把结构声明称为模板,因为它勾勒出结构是如何存储数据的。但注意此模板并非C++的模板,C++的模板要更为强大
      3、定义时,首先是关键字struct,表明跟在其后的是一个结构,后面是一个可选的标记(该例中为 book),稍后程序中可以使用该标记引用该结构。所以后面程序可以声明struct book library;,这把library声明为一个使用 book 结构布局结构变量
      4、在结构声明中,用一对花括号括起来的是结构成员列表。每个成员都用自己的声明来描述,成员可以是任意一种数据类型,甚至可以是其他结构右花括号后面的分号是必需的,表示结构布局定义结束
      5、可以把结构声明放在所有函数外部(如本例),也可以放在一个函数定义的内部。如果放在函数内部,则该结构标记仅供该函数内部使用

  • 定义结构变量

    • 定义结构变量

      1、结构两层含义。一层含义是结构布局(未让编译器为数据分配空间);另一层含义(同时也是下一步)是创建一个结构变量
      2、程序中创建结构变量的一行是struct book library;,编译器执行这行代码便创建了一个结构变量 library。编译器使用 book 模板为该变量分配空间,这些分配的空间都与名称 library结合在一起
      3、在结构变量的声明中,struct book所起的作用相当于一般声明中的intfloat。例如,你也可以定义两个struct book类型的变量,或者定义指向struct book类型结构指针
      4、如struct book doyle, panshin, *ptbook;,其中的doylepanshin都是以 book 为模板定义的独立的结构变量指针 ptbook可以指向任何book 类型的结构变量

    • 补充说明

      • 计算机而言,struct book library;这条声明,是以下声明简化

        struct book
        {
            char title[41];
            char author[31];
            float value;
        } library;  // 声明的右花括号后跟变量名
      • 换言之,声明结构的过程和定义结构变量的过程可以组合成一个过程。如下,组合后可以省略结构标记

        struct
        {
            char title[41];
            char author[31];
            float value;
        } library;
      • 然而,如果打算多次使用结构模板,就要使用带标记的形式。所以不建议使用以上形式单独组合使用

  • 初始化结构

    • 我们可以通过int count = 0;int fibo[4] = {0,1,2,3};初始化变量和数组结构变量能否初始化

    • 初始化一个结构变量初始化数组语法类似(但要注意 ANSI C 前不能用自动变量初始化结构):

      struct book library = {
          "This is book name",
          "author name",
          1.95
      };
    • 简言之,可以使用一对花括号括起来的初始化列表初始化结构变量,各项用逗号分隔。示例之所以换行是为了提高代码可读性

  • 访问结构成员

    1、结构就类似于一个超级数组,想要访问这个超级数组中的元素,需要使用结构成员运算符——.
    2、例如library.value即访问libraryvalue部分。你可以像使用任何float 变量那样使用library.value
    3、本质上,.title.author.value就相当于book 结构下标

  • 结构的初始化器

    • C99C11为结构提供了指定初始化器,其语法与数组指定初始化器类似

    • 结构的初始化器使用点运算符成员名标识特定的元素。例如先初始化 value再初始化 author,示例如下:

      struct book library = {
          .value = 10.99,
          .author = "author name"
      };
    • 与数组类似,在指定初始化器后面的普通初始化器,会初始化指定成员后面的成员。如下示例,value先被指定初始化10.01,后被普通初始化覆盖19.11(因为其模板定义时紧跟在 author 后面,即为 author 后面的成员)

      struct book library = {
          .value = 10.99,
          .author = "author name",
          19.11
      };

结构数组

  • 示例拓展

    • 引入

      1、接下来,我们要使上面的程序拓展成可以处理多本书每本书的信息都可以用一个book 类型结构变量来表示
      2、可以使用这一类型的结构数组来处理多本书,如下面的示例程序
      3、注意示例程序创建了一个内含100 个结构变量结构数组。由于该数组是自动存储类别的对象,信息被存储在中。如此大的数组需要很大一块内存,这可能导致一些问题,比如栈溢出。这是由于编译器可能使用了一个默认大小的栈,要修正这个问题,可以使用编译器选项设置栈大小10000,或者也可以创建静态或外部数组(这样不会存储在栈内)

    • 示例程序

      #include <stdio.h>
      #include <string.h>
      #define MAXTITL 41 // 书名最大长度+1
      #define MAXAUTL 31 // 作者姓名最大长度+1
      #define MAXBKS 100 // 书籍最大数量
      
      // 之前自己自定义的 s_gets 函数
      char *s_gets(char *str, int n)
      {
          char *ret_val; // 创建指针(同时也是存储字符串的变量)
          int i = 0;
          ret_val = fgets(str, n, stdin); // fgets()返回指向char的指针,如果顺利的话返回地址与传入的第一个参数相同(注意 ret_val 会和 str 指向同一个地址),如果读到文件结尾返回NULL
          if (ret_val)                    // 即,ret_val != NULL,判断是否读到文件结尾
          {
              while (str[i] != '\n' && str[i] != '\0') // 忽略跳过正常字符
                  i++;
              if (str[i] == '\n') // 出现换行符替换为空字符,即不存储换行符
                  str[i] = '\0';
              else                          // 否则就是读到了空字符
                  while (getchar() != '\n') // 丢弃该输入行的其余字符
                      continue;
          }
          return ret_val;
      }
      
      struct book
      {
          char title[MAXTITL];
          char author[MAXAUTL];
          float value;
      };
      
      int main(void)
      {
          struct book library[MAXBKS]; // book 类型结构的数组
          int count = 0, index;
          printf("输入书名,在新行行首换行停止程序:");
          // 计数小于最大书籍数 && 输入title正常 && 不停止程序
          while (count < MAXBKS && s_gets(library[count].title, MAXTITL) != NULL && library[count].title[0] != '\0')
          {
              printf("现在输入作者:");
              s_gets(library[count].author, MAXAUTL);
              printf("现在输入价钱:");
              scanf("%f", &library[count++].value); // 输入结束后,count++
              while (getchar() != '\n')             // 清理输入行(scanf输入会保留换行符)
                  continue;
              if (count < MAXBKS)
                  printf("输入下一本书名:");
          }
      
          if (count > 0)
          {
              printf("这是你的书籍信息单:\n");
              for (index = 0; index < count; index++)
                  printf("%s  %s  %.2f\n", library[index].title, library[index].author, library[index].value);
          }
          else
              printf("没有书籍信息");
          return 0;
      }
  • 声明结构数组

    1、声明结构数组和声明其他类型的数组类似,例如struct book library[50]
    2、以上代码把library声明为一个内含 50 个元素的数组。数组的每个元素都是一个book 类型结构
    3、因此,library[0]第 1 个 book 类型结构变量library[1]第 2 个 book 类型结构变量
    4、数组名 library本身不是结构名,它只是一个数组名

  • 标识结构数组的成员

    1、为了标识结构数组成员,可以采用访问单独结构的规则:结构名后加一个点运算符,再写成员名
    2、只是对于结构数组,结构名为library[0]这种形式,而非library(library 只是数组名)
    3、因此访问对象library[0].titlelibrary[1].author等等

嵌套结构

  • 示例程序

    #include <stdio.h>
    #define LEN 20
    
    struct names
    {
        char first[LEN];
        char last[LEN];
    };
    
    struct guy
    {
        struct names handle; // 嵌套结构
        char favfood[LEN];
        char job[LEN];
        float income;
    };
    
    int main(void)
    {
        // 初始化结构变量
        struct guy fellow = {
            {"Ewen", "Villard"},
            "grilled salmon",
            "personality coach",
            68112.00
        };
        printf("朋友信息:\n");
        printf("名:%s  姓:%s\n", fellow.handle.first, fellow.handle.last);
        printf("喜欢的食物:%s\n职业:%s\n收入:%.2f",fellow.favfood, fellow.job, fellow.income);
        return 0;
    }
  • 示例解析

    1、首先注意如何在结构声明创建嵌套结构。和声明int 类型变量一样,先进行声明struct names handle,该声明表示handle是一个struct names类型的变量(当然文件中应提前声明 names)
    2、其次注意如何访问嵌套结构成员。此时应使用两次点运算符,如fellow.handle.first,意为找到fellow中嵌套的handle,再找到handlefirst 成员
    3、初始化结构变量时,嵌套的结构也仍需按照初始化语法进行初始化,即需要用花括号包裹

指向结构的指针

  • 为何要使用指向结构的指针

    1、就像指向数组的指针数组本身更容易操控(如排序问题)一样,指向结构的指针通常比结构本身更容易操控
    2、在一些早期的 C 实现中,结构不能作为参数传递给函数,但是可以传递指向结构的指针
    3、即使能传递一个结构,传递指针通常更有效率
    4、一些用于表示数据的结构中包含指向其他结构的指针

  • 示例程序

    #include <stdio.h>
    #define LEN 20
    
    struct names
    {
        char first[LEN];
        char last[LEN];
    };
    
    struct guy
    {
        struct names handle;
        char favfood[LEN];
        char job[LEN];
        float income;
    };
    
    int main(void)
    {
        struct guy fellow[2] = {
            {
                {"Ewen", "Villard"},
                "grilled salmon",
                "personality coach",
                68112.00
            },
            {
                {"Rodney", "Swillbelly"},
                "tripe",
                "tabloid editor",
                432400.00
            }
        };
        struct guy *him;      // 一个指向结构的指针
        him = &fellow[0];     // 指针指向 fellow[0]
        printf("%p %p\n", &fellow[0], &fellow[1]);
        printf("%p %p\n", him, him + 1);
        printf("him->income: %.2f  (*him).income: %.2f\n", him->income, (*him).income);
        him ++;               // 指向下一个结构
        printf("him->favfood: %s  (*him).handle.last: %s\n", him->favfood, (*him).handle.last);
        return 0;
    }
    000000000061FD70 000000000061FDC4
    000000000061FD70 000000000061FDC4
    him->income: 68112.00  (*him).income: 68112.00
    him->favfood: tripe  (*him).handle.last: Swillbelly
  • 声明和初始化结构指针

    1、声明结构指针很简单:struct guy *him;。首先是关键字struct,其次是结构标记guy,然后是一个星号*,其后跟着指针名him。这个语法和其他指针声明一样
    2、该声明并未创建一个新的结构,但是指针 him可以指向任意现有的guy 类型结构
    3、和数组不同的是,结构变量名并不是结构变量的地址,因此要在结构变量名前面加上取地址运算符&
    4、在本例中,fellow是一个结构数组,这意味着fellow[0]才是一个结构。所以要让him指向fellow[0]
    5、比较输出的前两行,发现him + 1相当于him指向的地址+84(十六进制 DC4 - D70 = 54,换算十进制 84)。这是因为每个 guy 结构都占84 字节内存(20+20+20+20+4)
    6、在有些系统中,一个结构的大小可能大于各成员大小之和,这是因为系统对数据进行校准的过程中产生了一些缝隙。例如有些操作系统必须把每个成员都放在偶数地址4 的倍数的地址上

  • 用指针访问成员

    1、第一种方法是使用->运算符,有以下关系:him == &barney,那么him->income即是barney.income。换句话说,指向结构的指针后面的->运算符结构变量名后面的.运算符工作方式相同。这里要注意him是一个指针him->income指针所指结构一个成员
    2、第二种方法是:如果him == &fellow[0],那么*him == fellow[0],因为&*是一对互逆运算符。因此有fellow[0].income == (*him).income。注意必须使用圆括号,因为.*优先级更高

向函数传递结构的信息

  • 传递结构成员

    #include <stdio.h>
    
    struct funds
    {
        double bankfund;
        double savefund;
    };
    
    double sum(double x, double y)
    {
        return (x + y);
    }
    
    int main(void)
    {
        struct funds stan = {
            4032.27,
            8543.94
        };
        // 传参
        printf("%.2f", sum(stan.bankfund, stan.savefund));
        return 0;
    }

    1、只要结构成员是一个具有单个值的数据类型,即可把它作为参数传递给接受该类型的函数
    2、当然,如果需要在被调函数中修改主调函数中成员的值,需要传递成员的地址(&stan.bankfund)

  • 传递结构的地址

    #include <stdio.h>
    
    struct funds
    {
        double bankfund;
        double savefund;
    };
    
    double sum(const struct funds *money) // 参数是一个指针
    {
        return (money->bankfund + money->savefund); // 通过指针访问成员
    }
    
    int main(void)
    {
        struct funds stan = {
            4032.27,
            8543.94
        };
        // 传参
        printf("%.2f", sum(&stan));
        return 0;
    }

    1、这次将结构的地址作为参数,sum()函数使用指向 funds 结构的指针(money)作为参数,,把地址&stan传给函数,使money指向结构变量 stan
    2、函数中,通过指针访问成员(->运算符),获取stan.bankfundstan.savefund的值
    3、由于该函数并不能改变指针所指向值内容,所以把money声明为一个指向 const 的指针

  • 传递结构

    #include <stdio.h>
    
    struct funds
    {
        double bankfund;
        double savefund;
    };
    
    double sum(struct funds moolah) // 参数是一个结构
    {
        return (moolah.bankfund + moolah.savefund); // 通过结构变量访问成员
    }
    
    int main(void)
    {
        struct funds stan = {
            4032.27,
            8543.94
        };
        // 传参
        printf("%.2f", sum(stan));
        return 0;
    }

    1、对于允许把结构作为参数的编译器(一些旧的实现不允许这样做),可以通过上述示例方式传递结构
    2、函数sum()被调用时,创建了一个名为moolah自动结构变量,其各成员初始化stan 结构变量相应成员的值的副本
    3、因此,传递指针的方式使用的是原始的结构进行计算,而这种方式使用的是新创建的 moolah 副本进行计算,因此该程序使用moolah.bankfund访问成员

  • 其他结构特性

    • 结构赋值

      1、现在的 C 允许把一个结构赋值给另一个结构(但是数组不能这样做)
      2、也就是说,如果n_datao_data都是相同类型的结构,可以这样做:o_data = n_data
      3、这条语句把n_data每个成员的值都赋给o_data相应成员。即使成员是数组也能完成赋值

    • 结构作为返回值

      1、现在的 C,函数不仅能把结构作为参数传递,还能把结构作为返回值返回
      2、例如一个常规的函数,接受一个指向结构的指针作为参数,并通过指针改变数据。现在还可以在函数定义单独的结构变量,在函数内对该结构变量进行操作,最后将其作为返回值返回。
      3、例如函数void def(void),函数内定义结构变量person,函数返回语句return person;,主函数有同类型结构变量 person_data,便可以通过person_data = def();def()函数内person作为返回值赋给 person_data

  • 使用结构数组的函数

    1、整体传参方式和之前传递一个数组类似。数组名就是首元素地址(数组地址),可以将其传给指针,另外该函数还需要访问结构模板
    2、函数定义时参数定义大致为double sum(struct funds money[], int n);,其中money[]就是一个指针(也可以写为*money,这样写是为了提醒他人这是一个数组地址),n为数组元素个数
    3、在主函数中调用函数sum(jones, 5)(其中 jones 被定义为struct funds jones[5])。数组名 jones就是首元素地址,因此指针 money初始值相当于money = &jones[0]
    4、因为money指向jones 的首元素,所以money[0]就是jones[0]另一个名称。与此类似,money[1]就是第二个元素,在函数中便使用money[下标].成员名访问成员

结构与内存分配

  • 结构中的字符指针与 malloc()

    • 问题分析

      1、到目前为止,我们在结构中都是使用字符数组存储字符串,能否像学习字符串时使用指向 char 的指针存储字符串?
      2、通常而言是可行的,但是实际使用时,也会出现指向 char 的指针存储字符串时的通病——内存分配
      3、使用这样的方式存储字符串,由于指针所指向的位置未被分配,因此其存储的位置地址可以是任何值,这可能会篡改程序其他数据,导致程序崩溃
      4、因此如果要用结构存储字符串,用字符数组较为简单,如果使用指针,误用可能导致严重的问题,因此最好配合malloc()函数提前分配内存

    • 指针与malloc()函数

      #include <stdio.h>
      #include <string.h> // 提供 strcpy()、strlen()的原型
      #include <stdlib.h> // 提供 malloc()、free()的原型
      #define SLEN 81
      
      struct namect
      {
          char *fname; // 使用指针存储字符串
          char *lname;
          int letters; // 统计名字字符数
      };
      
      // 之前自定义的 s_gets() 函数
      char *s_gets(char *str, int n)
      {
          char *ret_val; // 创建指针(同时也是存储字符串的变量)
          int i = 0;
          ret_val = fgets(str, n, stdin); // fgets()返回指向char的指针,如果顺利的话返回地址与传入的第一个参数相同(注意 ret_val 会和 str 指向同一个地址),如果读到文件结尾返回NULL
          if (ret_val)                    // 即,ret_val != NULL,判断是否读到文件结尾
          {
              while (str[i] != '\n' && str[i] != '\0') // 忽略跳过正常字符
                  i++;
              if (str[i] == '\n') // 出现换行符替换为空字符,即不存储换行符
                  str[i] = '\0';
              else                          // 否则就是读到了空字符
                  while (getchar() != '\n') // 丢弃该输入行的其余字符
                      continue;
          }
          return ret_val;
      }
      
      // 获取信息
      void getinfo(struct namect *pst) // 传递指针
      {
          char temp[SLEN];
          printf("输入名字:");
          s_gets(temp, SLEN);
          // 分配内存以存储名字
          pst->fname = (char *)malloc(strlen(temp) + 1); // 分配存储 temp+1 所需的大小
          strcpy(pst->fname, temp);                      // 拷贝字符串
          printf("输入姓氏:");
          s_gets(temp, SLEN);
          pst->lname = (char *)malloc(strlen(temp) + 1);
          strcpy(pst->lname, temp);
      }
      
      // 处理信息
      void makeinfo(struct namect *pst)
      {
          // 计算名字字符个数
          pst->letters = strlen(pst->fname) + strlen(pst->lname);
      }
      
      // 打印信息
      void showinfo(struct namect *pst)
      {
          printf("%s  %s  %d\n", pst->fname, pst->lname, pst->letters);
      }
      
      // 释放分配的内存
      void cleanup(struct namect *pst)
      {
          free(pst->fname);
          free(pst->lname);
      }
      
      int main(void)
      {
          struct namect person; // 名为person的结构变量
          getinfo(&person);
          makeinfo(&person);
          showinfo(&person);
          cleanup(&person);
          return 0;
      }
  • 复合字面量和结构

    1、C99复合字面量特性不仅可以用于数组,还可以用于结构。如果只需要一个临时结构值,复合字面量很好用
    2、可以使用复合字面量创建一个结构作为函数的参数赋给另一个结构
    3、语法与数组复合字面量相似,将类型名放在圆括号中,后面紧跟一个用花括号括起来的初始化列表,示例如下

    struct book
    {
        char title[20];
        char author[20];
        float value;
    };
    
    int main(void)
    {
        struct book readfirst;
        readfirst = (struct book){"food","Nick",11.25};   // 使用复合字面量创建临时结构值赋值
        return 0;
    }
  • 伸缩型数组成员

    • 简介

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

    • 声明伸缩型数组成员

      struct flex
      {
          int count;
          double average;
          double score[];   // 伸缩型数组成员
      };

      1、伸缩型数组成员必须是结构的最后一个成员
      2、结构中必须至少有一个成员
      3、伸缩数组的声明类似于普通数组,只是方括号中是空的

    • 使用伸缩型数组成员

      • 声明一个struct flex类型的结构变量时,不能用score去做任何事,因为没有给这个数组预留存储空间

      • 实际上,C99的意图不是让你声明struct flex类型的结构变量,而是希望你声明指向 struct flex 类型的指针,然后用malloc()函数来分配足够的空间,以存储struct flex类型结构的常规内容伸缩型数组成员所需的额外空间

      • 例如,假设用score表示一个内含 5 个 double 类型值数组,可以这样做:

        struct flex * pf;   // 声明一个指针
        // 请求为一个结构和一个数组分配存储空间
        pf = malloc(sizeof(struct flex) + 5 * sizeof(double));    // 一个struct flex的空间 + 5个double的空间
      • 现在有足够储存空间存储countaverage和一个内含 5 个 double 类型值数组,可以用指针 pf访问这些成员:

        pf -> count = 5;        // 访问 count 成员
        pf -> score[2] = 18.5;  // 访问数组的一个元素
      • 此时,可以将5 个 double 类型值5换为一个变量 n,就可以更方便的伸缩数组大小。即malloc()分配时,malloc(sizeof(struct flex) + n * sizeof(double)),其中n就表示伸缩型数组成员元素个数

    • 一些特殊的处理要求

      1、第一,不能用结构进行赋值或拷贝(即*pf1 = *pf2这样),这样做只会拷贝除伸缩型数组成员外其他成员。如果确实要拷贝,应使用memcpy()函数(第 16 章介绍)
      2、不要以按值方式把这种结构传递给函数。原因相同,按值传递一个参数与赋值类似。应该把结构的地址传给函数
      3、不要使用带伸缩型数组成员的结构作为数组成员另一个结构的成员

  • 匿名结构

    • 匿名结构是一个没有名称的结构成员C11中,可以用嵌套匿名成员结构定义结构:

      struct person
      {
          int id;
          struct {char first[20]; char last[20];};    // 匿名结构
      };
    • 特点

      1、假设需要一个struct person类型的结构变量 ted初始化 ted的方式与初始化一个嵌套结构方式相同:struct person ted = {12, {"Ted", "Grass"}};
      2、但是在访问 ted 时,相比嵌套结构简化了步骤。只需把firstlast看作person 的成员,使用ted.first即可访问,而不需要像嵌套结构那样使用ted.xxxxx.first访问
      3、当然,这样看来也可以把firstlast直接作为person 的成员匿名特性嵌套联合中更加有用,后续介绍

把结构内容保存到文件

  • 引入

    1、由于结构可以存储不同类型的信息,所以它是构建数据库的重要工具。我们要把这些信息存储在文件中,并且能再次检索
    2、数据库文件可以包含任意数量的此类数据对象。存储在一个结构中的整套信息被称为记录单独的项被称为字段
    3、或许存储记录最没效率的方法是用fprintf()。首先便是当结构的成员更多时,fprintf()所需要使用的转换说明也更多;其次检索时还存在问题,因为程序要知道一个字段结束另一个字段开始位置
    4、更好的方案是使用fread()fwrite()函数读写结构大小单元。回忆一下,这两个函数使用与程序相同的二进制表示法,如fwrite(&primer, sizeof(struct book), 1, pbooks);。定位到primer 结构变量开始的位置,并把结构中的所有字节都拷贝到pbooks 所指文件中,sizeof(struct book)告诉函数待拷贝的一块数据的大小(即 struct book 类型的大小),1表示一次拷贝一块数据

  • 示例程序

    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #define MAXTITL 40
    #define MAXAUTL 40
    #define MAXBKS 10 // 最大书籍数量
    
    struct book
    {
        char title[MAXTITL];
        char author[MAXAUTL];
        float value;
    };
    
    // 之前自定义的 s_gets() 函数
    char *s_gets(char *str, int n)
    {
        char *ret_val;
        int i = 0;
        ret_val = fgets(str, n, stdin);
        if (ret_val)
        {
            while (str[i] != '\n' && str[i] != '\0')
                i++;
            if (str[i] == '\n')
                str[i] = '\0';
            else
                while (getchar() != '\n')
                    continue;
        }
        return ret_val;
    }
    
    int main(void)
    {
        struct book library[MAXBKS]; // 定义结构数组
        int ct = 0, index, filecount;
        FILE *pbooks;
        int size = sizeof(struct book);
    
        // 打开文件
        if ((pbooks = fopen("book.dat", "a+b")) == NULL)
        {
            fputs("无法打开book.dat\n", stderr);
            exit(1);
        }
    
        // 读取数据文件中已经存储的数据,存入library,并打印之前存储的数据
        rewind(pbooks); // 定位到文件开始处
        // 遍历的 ct 小于数组元素个数 && 读取二进制内容并写入library正常
        while (ct < MAXBKS && fread(&library[ct], size, 1, pbooks) == 1)
        {
            if (ct == 0)
                puts("book.dat当前的内容:"); // 首次的提示语
            printf("%s  %s  %.2f\n", library[ct].title, library[ct].author, library[ct].value);
            ct++;
        }
    
        // filecount 记录以前已写到结构数组第几个元素。判断目前结构数组是否有多余位置存储新数据
        filecount = ct;
        if (ct == MAXBKS)
        {
            fputs("book.dat文件已满\n", stderr);
            exit(2);
        }
    
        // 向library写入新数据
        puts("请添加新的书籍名称:(在新的一行回车停止)");
        // 遍历的 ct 小于数组元素个数 && 读取书籍名称正常 && 不在新的一行回车停止程序
        while (ct < MAXBKS && s_gets(library[ct].title, MAXTITL) != NULL && library[ct].title[0] != '\0')
        {
            puts("现在输入作者:");
            s_gets(library[ct].author, MAXAUTL);
            puts("现在输入价钱(或编号):");
            scanf("%f", &library[ct].value);
            while (getchar() != '\n') // 清理输入行
                continue;
            if (ct < MAXBKS)
                puts("输入下一本书的标题:");
            ct++;
        }
    
        // 输出添加后的library,并写入数据文件
        if (ct > 0)
        {
            puts("这是新的书单列表:");
            for (index = 0; index < ct; index++)
                printf("%s  %s  %.2f\n", library[index].title, library[index].author, library[index].value);
            fwrite(&library[filecount], size, ct - filecount, pbooks);
        }
        else
        {
            puts("没有新书写入");
        }
        fclose(pbooks);
        return 0;
    }
  • 示例重点

    1、以a+b模式打开文件a+模式允许程序读取文件并可以在末尾追加内容b表明程序将使用二进制文件格式
    2、选择二进制模式是因为fread()fwrite()要使用二进制文件。rewind()函数确保文件指针处于文件开始处,为读文件做好准备
    3、写入新的数据时,我们本也可以用一个循环文件末尾使用fwrite()一次添加一个结构,但示例中使用fwrite()一次写入一块数据filecount表示第一个新写入的结构的下标,表达式ct - filecount就是新添加书籍的数量
    4、虽然结构中有些内容是文本,但value成员不是文本。如果使用文本编辑器查看book.dat,其中文本部分内容显示正常,但数值部分内容不可读,甚至可能乱码

链式结构

  • 结构有很多种用途,除了前面介绍的,还有一种是创建新的数据形式

    1、计算机用户已经开发出的一些数据形式比我们提到过的数组简单结构能更有效地解决特定问题
    2、这些形式包括队列二叉树哈希表图表,许多这样的形式都由链式结构组成
    3、通常,每个结构都包含一两个数据项一两个指向其他同类型结构指针。这些指针把一个结构另一个结构链接起来,并提供一种路径能遍历整个彼此链接的结构

  • 二叉树演示

    1、下图是一个二叉树结构的示意图
    2、考虑有10 个节点的树的情况下,他有 210-1 个(或 1023 个)节点,可以存储 1023 个单词
    3、如果这些单词以某种规则排列,自上而下逐级查找最多只需要移动 9 次即可找到;如果放在数组中,至多需要遍历1023 个元素才能找到

联合简介

  • 概述

    1、联合是一种数据类型,它能在同一个内存空间中存储不同的数据类型(不是同时存储)
    2、其典型的用法是,设计一种表以存储既无规律事先也不知道顺序混合类型
    3、使用联合类型数组,其中的联合都大小相等,每个联合可以存储各种数据类型

  • 声明联合

    • 创建联合和创建结构的方式相同,需要一个联合模板和一个联合变量

      // 声明联合模板
      union hold
      {
          int digit;
          double bigfl;
          char letter;
      };
      
      // 声明联合变量
      union hold fit;       // hold 类型的联合变量
      union hold save[10];  // 内含10个联合变量的数组
      union hold *pu;       // 指向 hold 类型联合变量的指针
    • 联合与结构的不同

      1、根据如上示例声明的结构,可以存储一个 int 类型一个 double 类型一个 char 类型的值(三种类型都可以存储)
      2、然而,声明的联合,只能存储一个 int 类型一个 double 类型一个 char 类型的值(只能存储其一)

  • 初始化联合

    union hold valA;
    valA.letter = 'R';
    union hold valB = valA;             // 用另一个联合来初始化
    union hold valC = {88};             // 初始化联合的第一个元素:digit 成员
    union hold valD = {.bigfl = 118.2}; // 指定初始化器

    1、可以初始化联合,但要注意,联合只能存储一个值
    2、共有三种初始化的方法:把一个联合初始化为另一个同类型的联合;初始化联合的第一个元素;或者根据C99标准,使用指定初始化器

  • 使用联合

    // 使用联合
    fit.digit = 23;       // 把 23 存储在 fit 中,占 2 字节
    fit.bigfl = 2.0;      // 清除 23,存储 2.0,占 8 字节
    fit.letter = 'h';     // 清除 2.0,存储 h,占 1 字节
    
    // 使用指针访问联合
    pu = &fit;            // 此处 pu 是一个指向联合的指针
    x = pu->digit;        // 相当于 x = fit.digit

    1、点运算符表示正在使用哪种数据类型
    2、在联合中,一次只储一个值。即使有足够的空间,也不能同时存储一个char 类型值和一个int 类型值。编写代码时要注意当前存储在联合中的数据类型
    3、和用指针访问结构一样,用指针访问联合也要用->运算符

  • 联合的一种用途

    • 引入

      1、联合主要用途之一是,在结构中存储与其成员有从属关系的信息
      2、例如,用一个结构表示一辆汽车。如果汽车属于驾驶者,就用一个结构成员描述这个所有者;如果汽车被租赁,就用一个成员来描述其租赁公司

    • 示例思路

      struct owner    // 个人拥有者信息
      {
          char socsecurity[12];
          ...
      };
      
      struct leasecompany   // 租赁公司信息
      {
          char name[40];
          char headquarters[40];
          ...
      };
      
      union data    // 创建联合
      {
          struct owner owncar;            // 创建成员,为 owner 类型结构变量
          struct leasecompany leasecar;   // 创建成员,为 leasecompany 类型结构变量
      };
      
      struct car_data     // 使用结构表示一辆车的信息
      {
          char make[15];
          int status;   // 人为约定私有为 0, 租赁为 1
          union data onwerinfo;
          ...
      };
    • 示例说明

      1、假设有flitscar_data 类型结构变量
      2、如果flits.status == 0(即私有),程序将设计为使用flits.ownerinfo.owncar.socsecurity
      3、如果flits.status == 1(即租赁),程序将设计为使用flits.ownerinfo.leasecar.name

  • 匿名联合

    • 匿名联合匿名结构的工作原理相同,即匿名联合是一个结构或联合的无名联合成员

    • 例如,我们可以把上面程序的car_data重新定义:

      struct owner    // 个人拥有者信息
      {
          char socsecurity[12];
          ...
      };
      
      struct leasecompany   // 租赁公司信息
      {
          char name[40];
          char headquarters[40];
          ...
      };
      
      struct car_data     // 使用结构表示一辆车的信息
      {
          char make[15];
          int status;   // 人为约定私有为 0, 租赁为 1
          union         // 创建匿名联合
          {
              struct owner owncar;            // 创建成员,为 owner 类型结构变量
              struct leasecompany leasecar;   // 创建成员,为 leasecompany 类型结构变量
          };
          ...
      };
    • 现在,像匿名结构一样,可以用flits.owncar.socsecurity代替原先的flits.owninfo.owncar.socsecurity

枚举类型

  • 简介

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

  • 声明与使用枚举类型

    // 声明枚举类型
    enum spectrum {red, orange, yellow, green, blue, violet};   // 枚举声明
    enum spectrum color;    // 声明枚举变量
    
    // 使用枚举类型
    color = blue;
    if (color == yellow)
        ...;
    for (color = red; color <= violet; color++)
        ...;

    1、第一个声明创建了spectrum作为标记名,允许把enum spectrum作为一个类型名使用。第二个声明使color作为该类型的变量
    2、第一个声明中的花括号内的标识符枚举了spectrum 变量可能有的值。因此,color可能的值是redorangeyellow等。这些符号常量被称为枚举符
    3、虽然枚举符int 类型,但是枚举变量可以是任意整数类型,前提是该整数类型可以存储枚举常量
    4、例如,spectrum枚举符范围是0 ~ 5,所以编译器可以用unsigned char类型来表示 color 变量

  • C 与 C++的枚举兼容性

    1、C 枚举的一些特性并不适用于 C++
    2、例如,C 允许枚举变量使用++运算符,但 C++ 标准不允许
    3、所以,如果需要和 C++ 标准兼容,必须把上面例子color声明为int 类型

  • enum 常量

    1、bluered到底是什么?从技术层面看,它们是int 类型的常量
    2、例如假定有前面的枚举声明,printf("red = %d, orange = %d", red, orange)的输出为red = 0, orange = 1
    3、red成为一个有名称的常量代表整数 0。类似的,其他标识符都是有名称的常量,分别代表1~5
    4、只要能使用整型常量的地方就都能使用枚举常量。例如声明数组时,可以用枚举常量表示数组的大小;在switch语句中,可以把枚举常量作为标签
    5、默认情况下,枚举列表中的常量都被赋予012

  • 枚举常量赋值

    1、枚举声明中,可以为枚举常量指定整数值。如enum levels {low = 100, medium = 500, high = 2000};
    2、如果只给一个枚举常量赋值,没有对后面的枚举常量赋值,那么后面的常量会被赋予后续的值
    3、例如enum feline{cat, lynx = 10, puma, tiger};cat的值为0(默认),lynxpumatiger的值分别为101112

  • 共享名称空间

    1、C 语言使用名称空间(namespace),标识程序中的各部分,即通过名称来识别
    2、作用域名称空间概念的一部分:两个不同作用域同名变量不冲突,两个相同作用域同名变量冲突
    3、名称空间分类别的,在特定作用域中的结构标记联合标记枚举标记共享相同的名称空间该名称空间与普通变量使用的空间不同
    4、例如在 C 中同时声明struct rect {double x; double y;};int rect;不会产生冲突
    5、尽管如此,以两种不同的方式使用相同的标识符会造成混乱。另外C++ 不允许这样做,因为它将标识名变量名放在相同的名称空间

typedef 简介

  • typedef 简介

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

  • typedef 的使用

    1、假设用BYTE表示1 字节的数组,只需像定义char 类型变量一样定义BYTE,然后在定义前面加上关键字typedef:即typedef char BYTE;
    2、随后,便可以使用BYTE定义变量,如BYTE x, y[10], *z;
    3、该定义的作用域取决于typedef定义所在的位置typedef中使用的名称遵循变量的命名规则
    4、通常typedef定义中用大写字母表示被定义的名称,以提醒用户这个类型名是一个符号缩写

  • typedef 的一些用途

    • typedef一些特性#define功能重合

      typedef char BYTE;
      #define BYTE char
    • 但是typedef也有#define没有的功能

      char * STRING;
      typedef char * STRING;
      #define STRING char *

      1、没有typedef关键字的话,编译器会把STRING识别为指向 char 的指针
      2、使用typedef编译器STRING解释成类型的标识符,该类型指向 char 的指针
      3、使用typedefSTRING name, sign;相当于char *name, *sign;,声明的两个变量都是指针
      4、但如果使用#defineSTRING name, sign;会相当于char *name, sign;,则只有 name 是指针

    • 还可以把typedef用于结构

      typedef struct complex{   //此处 complex 标识名可省略
          float real;
          float imag;
      } CONPLEX;
      
      struct complex num1;
      COMPLEX num2;
    • 此外,typedef更常用于给复杂的类型命名,如typedef char (* FRPTC())[5] TYPENAME;

其他复杂的声明

  • C 允许用户自定义数据形式。虽然我们常用的是一些简单的形式,但是根据需要有时也会用到一些复杂的形式

  • 复杂声明可使用的符号

    符号 含义
    * 表示一个指针
    () 表示一个函数
    [] 表示一个数组
  • 一些复杂声明的示例

    int board[8][8];        // 声明一个指向 int 数组的数组
    int **ptr;              // 声明一个指向指针的指针,被指向的指针指向 int
    int *risks[10];         // 声明一个内含10个元素的数组,每个元素都是一个指向 int 的指针
    int (*rusk)[10];        // 声明一个指向数组的指针,该数组内含10个 int 类型的值
    int *oof[3][4];         // 声明一个3*4的二维数组,每个元素都是指向 int 的指针
    int (*uuf)[3][4];       // 声明一个指向3*4二维数组的指针,该数组中内含 int 类型的值
    int (*uof[3])[4];       // 声明一个内含3个元素的数组,其中每个指针都指向一个内含4个 int 类型值的数组
  • 符号优先级

    1、要看懂以上的声明,需要注意符号的优先级
    2、数组名后面的[]()具有相同优先级,他们*优先级更高
    3、[]()优先级相同,因此正常情况下,自左向右看

  • 类型声明黄金法则

    详见我的另一篇博文,点击跳转:类型声明黄金法则

  • 对于这种复杂声明,使用typedef作用逐渐明显

函数指针

  • 通过上一节的学习可知,可以声明一个指向函数的指针。通常,函数指针用作另一个函数的参数,告诉该函数要使用哪一个函数

  • 函数指针简介与声明

    1、假设有一个指向 int 类型变量的指针,该指针就存储着这个int 类型变量存储在内存位置的地址。同样,函数也有地址,因为函数的机器语言实现载入内存的代码组成,指向函数的指针中存储着函数代码的起始处地址
    2、声明一个数据指针时,必须声明指针所指向的数据类型。对于声明函数指针,必须声明指针指向的的函数类型。为此,要指明函数签名,即函数的返回类型形参类型
    3、例如现有函数原型void ToUpper(char *);,声明函数指针应为void (*pf)(char *);。注意由于运算符优先级必须要使用圆括号,否则void *pf(char *);意为pf 是一个返回字符指针的函数

  • 使用函数指针

    void ToUpper(char *);
    void ToLower(char *);
    int round(double);
    
    void (*pf)(char *);
    pf = ToUpper;         // 有效,ToUpper是该类型函数的地址
    pf = ToLower;         // 有效,ToUpper是该类型函数的地址
    pf = round;           // 无效,round与指针类型不匹配
    pf = ToLower();       // 无效,ToLower()不是地址
    
    pf = ToUpper;
    char mis[] = "Nina Metier";
    (*pf)(mis);           // 语法1
    pf(mis);              // 语法2

    1、声明了函数指针后,可以把类型匹配函数地址赋给它。在这种上下文中,函数名可以用作表示函数的地址
    2、既然可以用数据指针访问数据,就可以用函数指针访问函数。只是有两种逻辑上不一致的语法能这样做
    3、语法 1中,由于pf 指向ToUpper函数,那么*pf相当于ToUpper函数。所以(*pf)(mis)ToUpper(mis)等效
    4、语法 2中,由于函数名指针,那么指针和函数名可以互换使用(从 pf 的赋值表达式就能看出 ToUpper 和 pf 是等价的)。因此pf(mis)ToUpper(mis)等效

  • 函数指针作为参数

    1、作为函数的参数数值指针最常见的用法之一,函数指针亦如此。考虑以下函数原型void show(void (*fp)(char *), char *str);
    2、它声明了两个形参fpstrfp是一个函数指针str是一个数据指针。更具体地说,fp指向的函数接受char *类型返回类型为 voidstr指向一个char 类型的值
    3、可以这样调用函数show(ToUpper, mis);。在函数内仍可以通过函数指针 fp调用函数(*fp)(mis);


位操作


章节概要:二进制数、位、字节;二进制整数;有符号整数;二进制浮点数;其他进制数;八进制;十六进制;进制赋值;C 按位运算符;按位逻辑运算符;移位运算符;常用用法;掩码;打开位;关闭位;切换位;检查位的值;乘除次幂;位字段;简介与声明;位字段的使用;位字段越界问题;位字段示例;对齐特性;对齐特性介绍;_Alignof运算符;_Alignas说明符;使用示例

二进制数、位、字节

  • 二进制整数

    1、通常,1 字节(1 byte)包含8 位(8 bits)。C 语言用字节表示存储系统字符集所需的大小,所以 C 字节可能是8 位9 位16 位或其他值
    2、不过,描述存储器芯片数据传输率中所用的字节指的是8 位字节。简化起见,我们假定1 字节8 位(计算机界常用八位组这个术语特指8 位字节)
    3、可以从左往右给这 8 位分别编号7~0。在一字节中,编号是 7的位称为高阶位编号是 0的位称为低阶位,每一位的编号对应2 的相应指数
    4、8 位字节能表示的最大的数是11111111,即255;能表示的最小的数是00000000,即0
    5、因此一字节可存储0~255 范围内256 种值,程序可以用1 字节存储自-128 至 127同样256 种值

  • 有符号整数

    • 符号量表示法

      1、如何表示有符号整数取决于硬件,而不是 C 语言
      2、也许表示有符号数最简单的办法是用 1 位(如高阶位)存储符号剩下 7 位表示数字本身。用这种符号量表示法,表示的范围为-127~127
      3、这种方法的缺点是有两个 0,即 +0 和 -0,很容易混淆,而且用两个位组合表示一个数字有些浪费

    • 二进制补码

      1、二进制补码避免了这个问题,是当今最常用的系统
      2、二进制补码用 1 字节中的后 7 位表示0~127,通常高阶位设置为 0,表明值为正。如果高阶位是 1,则表明值为负,然后从一个9 位组合100000000(即二进制的 256)减去一个负数的位组合,结果是该负值的量
      3、假设一个负值的位组合10000000。作为一个无符号整数,该组合表示 128;作为一个有符号整数,该组合表示的值为负(高阶位是 1),值为100000000 - 10000000该数位-128。类似的,10000001表示-12711111111表示-1。这种方法可以表示-128~127 范围内的数
      4、要得到一个二进制补码数相反数,最简单的方法是反转每一位然后+1。如1 是 00000001,则表示-1应为11111110+1,即11111111

    • 二进制反码

      1、二进制反码通过反转位组合中的每一位形成一个负数。例如00000001 是 111111110 即为-1
      2、但这种方法也有一个-0,且仅能表示-127~127 之间的数

  • 二进制浮点数

    • 浮点数两部分存储二进制小数二进制指数

    • 二进制小数

      1、一个普通的浮点数 0.527,表示如下:5/10 + 2/100 + 7/1000。从左往右,各分母都是10 的递增次幂
      2、类似的,在二进制小数中,使用2 的递增次幂作为分母
      3、所以二进制小数 .101表示为1/2 + 0/4 + 1/8,换为十进制0.5 + 0 + 0.125,即0.625
      4、许多分数不能用十进制精确表示(如 1/3),同样的,许多分数也不能用二进制精确表示。二进制表示法只能精确表示多个 1/2 的幂的和,像1/32/5就不能精确表示

    • 浮点数表示法

      1、为了在计算机中表示一个浮点数,要留出若干位(因系统而异)存储二进制分数,其他位存储指数
      2、一般而言,数字的实际值是由二进制小数乘以2 的指定次幂组成
      3、在实际计算时,一个浮点数*4,那么二进制小数不变,而是指数*2二进制分数不变
      4、如果一份浮点数乘以一个不是 2 的幂的数,会改变小数部分,如有必要,也会改变指数部分

其他进制数

  • 计算机界通常使用八进制计数系统和十六进制计数系统。因为8 和 16都是2 的幂,比十进制更接近计算机的二进制系统

  • 八进制

    1、八进制八进制计数系统。该系统基于 8 的幂,用0~7 表示数字满八进一
    2、了解八进制最简单的办法是,每个八进制位对应3 个二进制位。八进制转二进制时,每一位对应3 位二进制位,通过二进制对应 2 的幂凑出这一位八进制的值
    3、例如,八进制的6转换二进制为110(4+2+0),八进制的35转换二进制为011101(三位拆开看,0+2+1,4+0+1)。左侧的 0 可以省略,中间和右侧的 0 不能省略

  • 十六进制

    1、十六进制十六进制计数系统。该系统基于 16 的幂,用0~9表示正常的0~9,用A~F表示10~15满十六进一
    2、因此十进制的17用十六进制表示为11(16+1),十进制的33表示为21(16*2+1),十进制的30表示为1E(16+14)
    3、每个十六进制位对应4 个二进制位,因此两个十六进制位恰好对应一个8 位字节,此外十六进制与二进制的转换方法与八进制类似
    4、例如十六进制E1转换二进制为11100001(四位拆开看,8+4+2+0,0+0+0+1)

  • 进制赋值

    1、为变量赋值时可以分别使用0b0o0x表示赋值的数分别为二进制八进制十六进制
    2、例如可以这样写:num = 0b1001num = 0o1007num = 0x100F

C 按位运算符

  • C 提供按位逻辑运算符移位运算符。在下面的例子中,为了方便理解,使用二进制计数法写出值

  • 按位逻辑运算符

    • 4 个按位逻辑运算符都用于整型数据,包括char。之所以叫做按位运算,是因为这些操作针对每一个位进行,不影响它左右两边的位

    • 按位取反~

      1、一元运算符~可以将二进制中的1 变成 00 变成 1。如表达式~(10011010)结果为 01100101
      2、假设变量 val已被赋值2。在二进制中,00000010表示2,那么~val的值是11111101,即253
      3、注意,只是使用运算符不会改变 val 的值,只是创建了一个可以使用或赋值新值newval = ~val。如果需要改变val 的值需要对 val 赋值val = ~val

    • 按位与&

      1、二元运算符&通过逐位比较两个运算对象,生成一个新值
      2、对于每个位,只有两个运算对象相应的位都为 1时,对应结果位值为 1
      3、如表达式(10010011) & (00111101)结果为 00010001

    • 按位或|

      1、二元运算符|通过逐位比较两个运算对象,生成一个新值
      2、对于每个位,只要两个运算对象相应的位至少有一个为 1时,对应结果位值为 1
      3、如表达式(10010011) | (00111101)结果为 10111111

    • 按位异或^

      1、二元运算符^通过逐位比较两个运算对象,生成一个新值
      2、对于每个位两个运算对象相应的位不同时,对应结果位值为 1
      3、如表达式(10010011) ^ (00111101)结果为 10101110

    • 对于&|^运算符,同样也有复合运算符&=|=^=

  • 补充:按位运算的性质

    1、异或^:有结合律(a ^ (b ^ c) = (a ^ b) ^ c)和交换律(a ^ b = b ^ a),没有分配律
    2、常用的异或结论a ^ 0 = aa ^ a = 0,在方程中常会用来消元或移项
    3、加法结论a + b = 2 * (a & b) + (a ^ b),因为a ^ b无进位加法a & b可以获取需要进位的位2 * (a & b)等同于将需要进位的位左移一位。该结论常用于证明a + b >= a ^ b,因为在非负整数前提下a & b >= 0

  • 移位运算符

    • 下面介绍 C 的移位运算符,移位运算符向左或向右移动位。示例中同样使用二进制数便于理解

    • 左移运算符<<

      1、左移运算符将其左侧的运算对象每一位的值向左移动指定位数
      2、左侧运算对象移出左末尾端的值丢失,用0填补空缺位置
      3、如表达式(10001010) << 2结果为 00101000

    • 右移运算符>>

      1、右移运算符将其左侧的运算对象每一位的值向右移动指定位数
      2、左侧运算对象移出右末尾端的值丢失。对于无符号类型,用0填补空缺位置;对于有符号类型,结果取决于机器,空出的位置可用 0 填充,或者用符号位(即最左端的位)副本填充
      3、如表达式(10001010) >> 2结果可能为 00100010 或 11100010

    • 示例程序:数字转为二进制

      #include <stdio.h>
      #include <limits.h> // 提供 CHAR_BIT 的定义,CHAR_BIT 表示 char 中的位数
      
      // 整数转换为二进制字符串
      char *itobs(int n, char *ps)
      {
          int i;
          int size = CHAR_BIT * sizeof(int);
          for (i = size - 1; i >= 0; i--, n >>= 1)
              ps[i] = (01 & n) + '0';
          ps[size] = '\0'; // 添加空字符
      }
      
      // 4位一组显示二进制字符串
      void show_bstr(char *str)
      {
          int i = 0;
          while (str[i]) // 不是空字符串
          {
              putchar(str[i]);
              // 每4位添加空格
              if (++i % 4 == 0 && str[i])
                  putchar(' ');
          }
      }
      
      int main(void)
      {
          char bin_str[CHAR_BIT * sizeof(int) + 1]; // CHAR_BIT * sizeof(int) 表示 int 类型的位数,+1 留出一位给空字符
          int number;
          puts("输入一个数字:");
          while (scanf("%d", &number) == 1)
          {
              itobs(number, bin_str);
              printf("%d is ", number);
              show_bstr(bin_str);
              putchar('\n');
          }
          return 0;
      }

      1、itobs()函数中有对01 & n求值。01是一个八进制掩码,其只有0 号位为 1,因此01 & n就是n 最后一位的值(值为 1 或 0)
      2、但对于数组而言,需要的是字符不是数值,因此该值+ '0'(或加上对应 ASCII 码值 48)即可完成转换。其结果存放在数组倒数第二个元素中(size - 1)
      3、然后,循环执行i--n >>= 1,。i--移动到数组前一个元素n>>=1使所有位右移 1。进入下一轮迭代,处理的是n新的最右端的值,将其结果存储在倒数第三个元素中,以此类推

    • 示例程序:切换一个值的后 n 位

      // 该函数可被上个示例调用
      int invert_end(int num, int bits)
      {
          int mask = 0;
          int bitval = 1;
          while (bits-- > 0)
          {
              mask |= bitval;
              bitval <<= 1;
          }
          return num ^ mask;
      }

      1、~运算符切换一个字节的所有位,而不是选定的少数位。但是^运算符可用于切换单个位
      2、while循环用于创建所需的掩码 mask。起初,mask所有位都为 0,第一轮循环将mask0 号位设置为1,第二轮将1 号位设置为1,以此类推
      3、循环bits次,mask后 bits 位就都被设置为 1。最后,num ^ mask运算即得所需结果

运算符常用用法

  • 掩码

    • 按位与运算符常用于掩码。所谓掩码指的是一些设置为开(1)或关(0)的位组合

    • 掩码的认识与使用

      1、假设定义符号常量MASK2,其二进制00000010,研究该语句:flags = flags & MASK;
      2、使用按位与运算符任何位与 0 组合得 0,因此对该语句只有1 号位值不变(因为 MASK 只有 1 号位为 1)。这个过程叫做使用掩码,因为掩码中的 0 隐藏了 flags 中相应的位
      3、可以这样类比,把掩码中的0 看做不透明1 看做透明。因此只有MASK 为 1 的位可见

  • 打开位(设置位)

    • 有时,需要打开一个值特定位,同时保持其他位不变。这种情况可以使用按位或运算符

    • 打开位的使用

      1、对于flags = flags | MASK;语句,可以通过设置 MASK 的值来控制打开 flags 的特定位
      2、因为使用|运算符,任何位与 0 组合结果都为本身;任何位与 1 组合结果都为 1
      3、例如需要设置 flags 的1 和 3 号位为开(1),则可以设置MASK10(二进制为 00001010),通过flags |= MASK;实现

  • 关闭位(清空位)

    • 打开特定的位类似,有时也需要在不影响其他位的情况下关闭指定的位

    • 关闭位的使用

      1、对于flags = flags & ~MASK;语句,可以通过设置 MASK 的值来控制关闭 flags 的特定位
      2、因为使用&运算符,任何位与 1 组合结果都为本身;任何位与 0 组合结果都为 0
      3、例如需要设置 flags 的1 和 3 号位为关(0),则可以设置MASK10(二进制为 00001010),通过flags &= ~MASK;实现
      4、根据个人喜好,也可以不使用~MASK直接使用 MASK,这种表示方法便以 0 标记需要关闭的位MASK应为11110101,语句为flags &= MASK;

  • 切换位

    • 切换位指的是打开已关闭的位,或关闭已打开的位。这种情况可以使用按位异或运算符

    • 切换位的使用

      1、对于flags ^= MASK;语句,可以通过设置 MASK 的值切换指定位的状态
      2、因为使用^运算符,任何位与 1 组合结果都会切换(1 变 0,0 变 1);任何位与 0 组合结果都不变
      3、例如需要切换 flags 的1 和 3 号位的状态(0),则可以设置MASK10(二进制为 00001010),通过flags ^= MASK;实现

  • 检查位的值

    • 有时需要检查确认某位的值,例如flags 的 1 号位是否为 1不能像下面这样直接比较(即使 flags 的 1 号位为 1,其他位也会导致结果为假)

      if (flags == MASK)
          puts("Wow!");     // 不能正常工作
    • 必须先覆盖 flags 的其他位只用 1 号位比较

      int flags, MASK1, MASK0;
      // MASK1 用于比较flags 1号位是否为 1,MASK0 用于比较1号位是否为 0
      flags = 0b1011;
      MASK1 = 0b0010;
      MASK0 = 0b1101;
      
      // 比较flags的 1号位是否为 1
      if((flags & MASK1) == MASK1)    // & 优先级比 == 低,所以需要括号
          puts("Wow!");
      
      // 比较flags的 1号位是否为 0
      if((flags | MASK0) == MASK0)
          puts("Wow!");
  • 乘除次幂

    • 移位运算符针对2 的幂提供快速有效的乘除法(类似十进制移动小数点快速乘除 10)

      number << n;      // number 乘以 2 的 n次幂
      number >> n;      // number 为非负时,number 除以 2 的 n次幂

位字段

  • 简介与声明

    1、操控位的第二种方法是位字段位字段是一个signed intunsigned int类型变量中的一组相邻的位
    2、位字段通过一个结构声明来建立,该结构声明为每个字段提供标签,并确定该字段的宽度
    3、随后,可通过普通的结构成员运算符.单独给这些字段赋值
    4、由于每个字段恰好为 1 位,所以只能赋值 1 或 0结构变量被存储在int 大小的内存单元中,但是在本例中只使用了其中 4 位

    // 建立一个 4个 1位 的字段:
    struct {
        unsigned int autfd : 1;
        unsigned int bldfc : 1;
        unsigned int undln : 1;
        unsigned int itals : 1;
    } prnt;
    
    // 为字段单独赋值:
    prnt.itals = 0;
    prnt.undln = 1;
  • 位字段的使用

    • 带有位字段的结构提供一种记录设置的方便途径。许多设置(如字体的粗体或斜体)就是简单的二选一,例如开或关真或假等。如果只需要使用 1 位,就不需要使用整个变量

    • 有时,某些设置也有多个选择,因此需要多位来表示。这没问题,字段不限制 1 位大小,可以使用如下的示例。只是,要确保赋值不超过字段可容纳范围

      // 创建 2个 2位 的字段和 1个 8位 的字段:
      struct {
          unsigned int code1 : 2;   // 2位 为 2位二进制,即可表示 2^2 个数,范围为 0~3
          unsigned int code2 : 2;
          unsigned int code3 : 8;
      } prcode;
      
      // 赋值:
      prcode.code1 = 0;
      prcode.code2 = 3;
      prcode.code3 = 102;
  • 位字段越界问题

    1、如果声明的总位数超过了一个 unsigned int 类型的大小,会用到下一个 unsigned int 类型存储位置
    2、一个字段不允许跨越两个unsigned int之间的边界编译器会自动移动跨界的字段,保持unsigned int边界对齐
    3、一旦发生这种情况,第一个 unsigned int中会留下一个未命名的”。可以使用未命名的字段宽度填充未命名的洞;可以使用一个宽度为 0 的未命名字段迫使下一个字段与下一个整数对齐
    4、示例中,stuff.field1stuff.field2之间,有一个2 位的空隙stuff.field3存储在下一个 unsigned int 中
    5、字段存储在一个 int 中的顺序取决于机器。另外,不同的机器中两个字段边界的位置也有区别。由于这些原因,位字段通常都不容易移植

    struct {
        unsigned int field1 : 1;
        unsigned int : 2;
        unsigned int field2 : 1;
        unsigned int : 0;
        unsigned int field3 : 1;
    } stuff;
  • 位字段示例

    • 打印方框属性

      1、在屏幕上表示一个方框的属性。为简化问题,假设方框具有如下几种属性
      2、方框是透明或不透明
      3、填充色:黑、红、绿、黄、蓝、紫、青、白
      4、边框可见或隐藏
      5、边框颜色:与填充色相同的可能
      6、边框使用实线点线或虚线

    • 示例程序

      #include <stdio.h>
      #include <stdbool.h>
      
      /* 线的样式 */
      #define SOLID 0
      #define DOTTED 1
      #define DASHED 2
      /* 三原色 */
      #define BLUE 4
      #define GREEN 2
      #define RED 1
      /* 混合色 */
      #define BLACK 0
      #define YELLOW (RED | GREEN)
      #define MAGENTA (RED | BLUE)
      #define CYAN (BLUE | GREEN)
      #define WHITE (RED | GREEN | BLUE)
      
      // 定义颜色的字符串
      const char *colors[8] = {"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"};
      
      // 定义位字段
      struct box_props
      {
          bool opaque : 1;               // 1 为透明,0 为不透明
          unsigned int fill_color : 3;   // 左侧位表示蓝色,中间位绿色,右侧位红色,通过三原色调色表示其他色
          unsigned int : 4;              // 填充位
          bool show_border : 1;          // 1 为可见,0 为不可见
          unsigned int border_color : 3; // 同 fill_color
          unsigned int border_style : 2; // 0、1、2分别表示实线、点线、虚线
          unsigned int : 2;              // 填充位
      };
      
      // 显示设置的函数
      void show_setting(struct box_props *pb)
      {
          printf("Box is %s.\n", pb->opaque == true ? "opaque" : "transparent");
          printf("The fill color is %s.\n", colors[pb->fill_color]);
          printf("Border %s.\n", pb->show_border == true ? "shown" : "not shown");
          printf("The border color is %s.\n", colors[pb->border_color]);
          printf("The border style is ");
          switch (pb->border_style)
          {
          case SOLID:
              printf("solid.\n");
              break;
          case DOTTED:
              printf("dotted.\n");
              break;
          case DASHED:
              printf("dashed.\n");
              break;
          default:
              printf("unknown.\n");
          }
      }
      
      int main(void)
      {
          /* 创建并初始化 box_props 结构 */
          struct box_props box = {true, YELLOW, true, GREEN, DASHED};
      
          printf("Original box settings:\n");
          show_setting(&box);
      
          box.opaque = false;
          box.fill_color = WHITE;
          box.border_color = MAGENTA;
          box.border_style = SOLID;
          printf("\nModified box settings:\n");
          show_setting(&box);
          return 0;
      }

对齐特性

  • 对齐特性介绍

    1、C11对齐特性用位填充字节更自然,它们还代表 C 在处理硬件相关问题上的能力。在这种上下文中,对齐指的是如何安排对象在内存中的位置
    2、例如,为了效率最大化,系统可能要把一个double 类型值存储在4 字节内存地址上,但却允许把char存储在任意地址
    3、大多数程序员都对对齐不以为然,但是,有些情况又受益于对齐控制。例如把数据从一个硬件位置转移到另一个位置,或者调用指令同时操作多个数据项

  • _Alignof 运算符

    1、_Alignof运算符给出一个类型对齐要求,在关键字_Alignof后面的圆括号写上类型名即可:size_t d_align = _Alignof(float);
    2、假设d_align值是 4,意思是float 类型对象对齐要求是 4。也就是说,4是存储该类型值相邻地址字节数
    3、一般而言,对齐值都应该是2 的非负整数次幂较大的对齐值被称为stricterstronger较小的对齐值被称为weaker

  • _Alignas 说明符

    1、_Alignas说明符指定一个变量一个类型对齐值。但是,不应该要求该值小于基本对齐值
    2、例如,如果float 类型基本对齐值是 4,就不该请求其对齐值1 或 2
    3、该说明符用作声明的一部分,说明符后面的圆括号内包含对齐值的类型_Alignas(double) char c1;_Alignas(8) char c2;char _Alignas(double) c_arr[sizeof(double)];
    4、注意,Clang 3.2 版本要求_Alignas(type)说明符在类型说明符后面,如上第三个示例。但后来Clang 3.3 版本也支持了前两种在后的顺序GCC 4.7.3 版本也能识别这两种顺序

  • 使用示例

    #include <stdio.h>
    
    int main(void)
    {
        double dx;
        char ca;
        char cx;
        double dz;
        char cb;
        char _Alignas(double) cz; // 设置变量 cz 的对齐值为double类型的对齐值
    
        printf("char alignment:   %zd\n", _Alignof(char));   // 利用_Alignof()获取char类型对齐值
        printf("double alignment: %zd\n", _Alignof(double)); // 利用_Alignof()获取double类型对齐值
        printf("cz alignment:     %zd\n", _Alignof(cz));     // 利用_Alignof()获取cz类型对齐值
        printf("&dx: %p\n", &dx);
        printf("&ca: %p\n", &ca);
        printf("&cx: %p\n", &cx);
        printf("&dz: %p\n", &dz);
        printf("&cb: %p\n", &cb);
        printf("&cz: %p\n", &cz);
        return 0;
    }
    char alignment:   1
    double alignment: 8
    cz alignment:     8
    &dx: 000000000061FE18
    &ca: 000000000061FE17
    &cx: 000000000061FE16
    &dz: 000000000061FE08
    &cb: 000000000061FE07
    &cz: 000000000061FE00

C 预处理器和 C 库


章节概要:翻译程序的第一步;明示常量:#define;明示常量简介;记号;重定义常量;在#define中使用参数;用宏参数创建字符串:#运算符;预处理器粘合剂:##运算符;变参宏:...__VA_ARGS__;文件包含:#include;使用头文件;头文件常用形式;头文件的使用价值;其他指令;#undef指令;从 C 预处理器角度看已定义;条件编译;预定义宏;#line#error#pragma;泛型选择_Generic;函数说明符;内联函数inline_Noreturn说明符;C 库;数学库math.h;类型变体;tgmath.h库;通用工具库stdlib.h;断言库assert.h;字符串库string.h;可变参数库stdarg.h

翻译程序的第一步

  • 预处理之前,编译器必须对该程序进行一些翻译处理

    1、首先,编译器把源代码中出现的字符映射源字符集。该过程处理多字节字符三字符序列——字符拓展让 C 更加国际化
    2、编译器定位每个反斜杠后面跟着换行符的实例(注意此处的换行符指的是按下Enter在源代码中换行产生的字符,而不是符号表征\n),并删除它们。也就是说,将后面示例中的多个物理行转换成一个逻辑行
    3、编译器把文本划分成预处理记号序列空白序列注释序列(记号是由空格、制表符或换行符分隔的项)。这里要注意的是,编译器将用空格替换每一条注释
    4、最后,程序已经准备好进入预处理阶段预处理器查找一行中#开始预处理指令

    // 多个物理行
    printf("That's wond\
          erful!\n");
    // 将被替换为一个逻辑行
    printf("That's wonderful!\n");
    
    // 源码中的注释
    int/*这是一条注释*/fox;
    // 将被替换为空格
    int fox;

明示常量:#define

  • #define预处理器指令和其他预处理器指令一样,以#作为一行的开始。之前我们大量使用#define指令来定义明示常量(符号常量),但该指令还有许多其他用途

  • 明示常量简介

    1、#define指令可以出现在源文件任何地方,其定义从指令出现处文件末尾有效
    2、预处理器指令#开始运行,到后面的第一个换行符为止。也就是说,指令的长度仅限一行(然而,预处理开始前,编译器会把多个物理行处理为一行逻辑行,所以可以使用\换行)
    3、每行#define都由三部分组成。第一部分是#define指令本身;第二部分是选定的缩写,也称为;第三部分称为替换列表替换体
    4、有些代表,这些被称为类对象宏,C 语言还有类函数宏,稍后讨论。宏的名称不允许有空格,且必须遵循 C 变量命名规则
    5、一旦预处理器在程序中找到宏的实例后,就会用替换体代替该宏(也有例外)。从变成最终替换文本的过程称为宏展开

  • 简单示例

    #include <stdio.h>
    
    #define TWO 2
    // 反斜杠利用预处理前的翻译处理规则,把定义延续到下一行
    #define OW "Consistency is the last refuge of the unimagina\
    tive. - Oscar Wilde\n"
    
    #define FOUR TWO*TWO
    #define PX printf("X is %d.\n", x);
    #define FMT "X is %d.\n"
    
    int main(void)
    {
        int x = TWO;
        // 调用类函数宏 PX
        PX;
        x = FOUR;
        // 调用 FMT 字符串
        printf(FMT, x);
        printf("%s", OW);
        // 字符串内并不会调用宏,仅为正常字符
        printf("TWO: OW\n");
        return 0;
    }
    X is 2.
    X is 4.
    Consistency is the last refuge of the unimaginative. - Oscar Wilde
    TWO: OW
  • 记号

    1、从技术角度看,可以把宏的替换体看做是记号型字符串,而不是字符型字符串
    2、C预处理器记号是宏定义的替换体单独的词,用空白把这些分开
    3、例如#define FOUR 2*2中,该宏定义有一个记号,即2*2;在#define SIX 2 * 3中,则有三个记号,即2*3
    4、如果预处理器把该替换体解释为字符型字符串,则用2 * 3替换SIX,即额外的空格是替换体的一部分。如果将其解释为记号型字符串,则用3 个的记号2*3(将原先空格视为各记号的分隔符)来替换SIX
    5、实际应用中,也有一些 C 编译器把宏替换体视为字符串而不是记号。只有在更复杂的情况下,二者的区别才有实际意义

  • 重定义常量

    1、假设先把 LIMIT 定义为 20,稍后在该文件中又把它定义为 25,这个过程称为重定义常量
    2、不同的实现采用不同的重定义方案,除非新定义旧定义相同,否则可能将其视为错误。另外一些实现允许重定义,但会给出警告
    3、ANSI标准采用第一种方案,即新旧定义完全相同才允许重定义。此处的相同相同的记号,如#define FOUR 2*2#define FOUR 2 * 2并不相同
    4、如果需要重定义宏,可以使用#undef指令(稍后介绍)

在 #define 中使用参数

  • 简介概述

    1、在#define使用参数可以创建外形和作用与函数类似类函数宏
    2、带有参数的宏看起来很像函数,因为这样的宏也使用圆括号
    3、类函数宏定义的圆括号内可以有一个或多个参数,随后这些参数出现在替换体

  • 示例程序

    #include <stdio.h>
    
    #define SQUARE(X) X *X
    
    int main(void)
    {
        int x = 5;
        int z;
        printf("x = %d\n", x);
        z = SQUARE(x);
        printf("SQUARE(x):     %d\n", z);
        z = SQUARE(2);
        printf("SQUARE(x):     %d\n", z);
        z = SQUARE(x + 2);
        printf("SQUARE(x + 2): %d\n", z);
        z = SQUARE(++x);
        printf("SQUARE(++x):   %d\n", z);
        return 0;
    }
    x = 5
    SQUARE(x):     25
    SQUARE(x):     4
    SQUARE(x + 2): 17
    SQUARE(++x):   42

    1、前两行的结果与预期相符,但第三行的SQUARE(x + 2)的结果为17,你可能认为其结果是7*7 = 49。实际原因是,预处理器不做计算不求值只替换字符序列
    2、因此预处理器出现 x 的地方替换为 x+2,即SQUARE(x + 2)表达式的值为 5+2*5+2 = 17。要解决这个问题,需要将宏定义改为#define SQUARE(x) (x)*(x),这样表达式的值为(5+2)*(5+2) = 49。因此,使用足够多的括号可以确保运算和结合正确顺序
    3、尽管如此,还是无法避免最后一种情况,SQUARE(++x)变成了++x*++x递增了两次 x。此处运算为6*7 = 42,有些编译器会在乘法运算之前完成第二次递增,结果可能为7*7 = 49。但无论哪种解释,结果都不是我们想要的6*6 = 36,因此应避免++x递增递减运算符作为宏参数

  • 用宏参数创建字符串:#运算符

    • 引入

      1、有#define PSQR(X) printf("the square of X is %d", ((X) * (X)));宏定义
      2、假设这样使用宏PSQR(8),输出为the square of X is 64
      3、此时双引号字符串内的X被视为普通文本,而不是一个可被替换的记号

    • #运算符

      1、C 允许在字符串中包含宏参数。在类函数宏替换体中,#作为一个预处理运算符,可以把记号转换成字符串
      2、例如如果X是一个宏形参,那么#X就是转换为字符串 X 的形参名。这个过程称为字符串化
      3、例如有#define PSQR(X) printf("the square of " #X " is %d", ((X) * (X)));定义
      4、此时定义y=5PSQR(y);输出为the square of y is 25PSQR(2 + 4);输出为the square of 2 + 4 is 36

  • 预处理器粘合剂:##运算符

    1、与#类似,##运算符可以用于类函数宏的替换部分。而且,##还可用于类对象宏的替换部分
    2、##两个记号组合成一个记号。例如有#define XNAME(N) x ## N的定义
    3、此时XNAME(y)展开为xyXNAME(4)展开为x4

  • 变参宏:…和__VA_ARGS__

    1、一些函数(如printf())接受数量可变的参数stdvar.h头文件提供了工具(稍后介绍),让用户自定义带可变参数函数
    2、C99/C11也对提供了这样的工具,通过把宏参数列表最后的参数写成...来实现这一功能。这样,预定义宏__VA_ARGS__可用在替换部分中,表明省略号代表什么
    3、例如有#define PR(X, ...) printf("Message " #X ":" __VA_ARGS__)定义
    4、此时PR(1, "hello");输出为Message 1: helloPR(2)输出为Message 2:

文件包含:#include

  • 简介概述

    1、当预处理器发现#include指令时,会查看后面的文件名,并把文件的内容包含到当前文件中(即替换源文件中的#include指令)
    2、#include两种形式#include <stdio.h>#include "mystuff.h",即尖括号双引号
    3、尖括号告诉预处理器标准系统目录中查找文件,双引号告诉预处理器首先在当前目录中查找,后查找标准系统目录
    4、集成开发环境(IDE)也有标准路径系统头文件的路径。许多IDE提供菜单选项指定尖括号时的查找路径。使用双引号时有些编译器会搜索源代码文件所在目录,有的会搜索当前工作目录,有的会搜索项目文件所在目录
    5、ANSI C不为文件提供统一的目录模型,因为不同计算机所用系统不同。一般而言,命名文件的方法因系统而异,但是尖括号双引号的规则与系统无关
    6、C 语言习惯用.h后缀表示头文件,这些文件包含需要放在程序顶部的信息。头文件经常包含一些预处理器指令
    7、包含一个大型头文件不一定显著增加程序大小。在大部分情况下,头文件的内容编译器生成最终代码时所需要的信息,而不是添加到最终代码中的材料

  • 使用头文件

    • 浏览任何一个标准头文件都可以了解头文件基本信息,头文件中最常用的形式如下

    • 头文件常用形式

      1、明示常量:例如头文件stdio.h中定义的EOFNULLBUFSIZE(标准 I/O 缓冲区大小)
      2、宏函数:例如getchar()通常用getc(stdin)定义,而getc()经常用于定义较复杂的宏头文件ctype.h通常包含ctype 系列函数宏定义
      3、函数声明:例如头文件string.h,包含字符串系列函数函数声明
      4、结构模板定义标准 I/O 函数使用FILE 结构,该结构中包含了文件和文件缓冲区相关的信息。FILE 结构头文件stdio.h
      5、类型定义标准 I/O 函数使用指向 FILE 的指针作为参数。通常,头文件stdio.h#definetypedefFILE定义为指向结构的指针。类似的,size_ttime_t定义在头文件中

    • 头文件的使用价值

      1、许多程序员都在程序中使用自己开发的标准头文件。如果开发一系列相关的函数或结构,那么这种方法特别有价值
      2、另外,还可以使用头文件声明外部变量其他文件共享。在源代码中定义一个文件作用域外部链接变量int a = 0;,然后在与之关联的头文件中进行引用式声明extern int a;,这样这行代码便会出现在包含了该头文件的其实文件
      3、需要包含头文件的另一种情况是,使用具有文件作用域内部链接const限定符变量或数组const防止值被意外修改,内部链接static意味着每个包含该头文件的文件都获得一份副本。因此不需要在一个文件中进行定义式声明,在其他文件中进行引用式声明

其他指令

  • #undef 指令

    1、#undef指令用于取消已定义的#define指令
    2、也就是说,假如有#define LIMIT 400,可以通过#undef LIMIT移除上面的定义。现在就可以把LIMIT重新定义为一个新值
    3、如果想使用一个名称,又不确定之前是否已经使用过,为安全起见,可以先用#undef取消定义

  • 从 C 预处理器角度看已定义

    #define LIMIT 1000          // LIMIT是已定义的
    #define GOOD                // GOOD是已定义的
    #define A(X) ((-(X))*(X))   // A是已定义的
    int q;                      // q不是宏,因此是未定义的
    #undef GOOD                 // GOOD取消定义,是未定义的

    1、预处理器识别标识符时,遵循与 C 相同的规则(标识符的命名规则)
    2、当预处理器预处理器指令中发现一个标识符时,会将该标识符当做已定义的未定义的
    3、这里的已定义表示由预处理器定义。如果标识符是同一个文件中由前面的#define创建的一个宏名,而且没有用#undef关闭,那么该标识符已定义
    4、如果标识符不是宏,假设是一个文件作用域C 变量,那么该标识符对预处理器而言就是未定义的

  • 条件编译

    • 可以使用其他指令创建条件编译。也就是说,可以使用这些指令告诉编译器根据编译时的条件执行或忽略信息(或代码)块

    • #ifdef#else#endif指令

      // 使用语法
      #ifdef MAVIS              // 如果已经用 #define 定义了 MAVIS,则执行下面的指令
          #include "horse.h"
          #define STABLES 5
      #else                     // 如果没有用 #define 定义 MAVIS,则执行下面的指令
          #include "cow.h"
          #define STABLES 15
      #endif
      
      // 实际使用示例
      #define JUST_CHECKING 1
      int total = 0;
      for(int i = 1; i <= LIMIT; i++)
      {
          total += 2 * i*i + 1;
      #ifdef JUST_CHECKING
          printf("i=%d total=%d\n", i, total);
      #endif
      }

      1、这里使用的较新的编译器和 ANSI 标准支持缩进格式。如果使用旧的编译器,则必须左对齐所有指令或至少左对齐#
      2、#ifdef指令说明,如果预处理器已定义了后面的标识符,则执行#else#endif指令之前所有指令
      3、如果预处理器未定义后面的标识符,且有#else指令,则执行#else#endif之间所有代码

    • #ifndef指令

      1、#ifndef指令#ifdef指令用法类似,也可以和#else#ifdef一起使用,但他们的逻辑相反
      2、#ifndef判断后面的标识符是否是未定义的,常用于定义之前未定义的常量

    • #if#elif指令

      1、#if指令很像C 语言的if,后面跟整型常量表达式,如果表达式为非零,则表达式为真。同样#elif很像C 语言的else if
      2、较新的编译器提供另一种方法测试名称是否已定义,即用#if defined (VAX)代替#ifdef VAX
      3、这里的defined是一个预处理运算符,如果它的参数用#defined定义过,则返回 1,否则返回 0。这种方法的好处是可以和#elif一起使用

  • 预定义宏

    • C 规定了一些预定义宏,如下表:

      含义
      __DATE__ 预处理的日期(Mmm dd yyyy 形式的字符串字面量,如 Nov 23 2013)
      __FILE__ 表达当前源代码文件名的字符串字面量
      __LINE__ 表示当前源代码文件中行号的整型常量
      __STDC__ 设置为 1 时,表明实现遵循 C 标准
      __STDC_HOSTED__ 本机环境设置为 1,否则设置为 0
      __STDC_VERSION__ 支持 C99 标准,设置为 199901L;支持 C11 标准,设置为 201112L
      __TIME__ 翻译代码的时间,格式为 hh:mm:ss
    • C99提供__func__预定义标识符,它展开为一个代表函数名的字符串(该函数包含该标识符)。那么,__func__必须具有函数作用域而不是文件作用域,因此__func__预定义标识符而不是预定义宏

  • #line 和 #error

    • #line指令重置__LINE____FILE__宏报告的行号和文件名,可以这样使用:

      #line 1000            // 把当前行号重置为 1000
      #line 10 "cool.c"     // 把当前行号重置为 10,文件名重置为 cool.c
    • #error指令让预处理器发出一条错误信息,该消息包含指令中的文本。如果可能的话,编译过程应该中断,可以这样使用:

      #if __STDC_VERSION__ != 201112L
          #error Not C11
      #endif
  • #pragma

    1、在现在的编译器中,可以通过命令行参数IDE 菜单修改编译器的一些设置#pragma编译器指令放入源代码
    2、例如在开发C99时,标准被称为C9X,可以使用下面的编译指示让编译器支持C9X#pragma c9x on
    3、一般而言,编译器都有自己的编译指示集。例如,编译指示可能用于控制分配给自动变量的内存量,或者设置错误检查的严格程度,或者启用非标准语言特性
    4、C99还提供_Pragma预处理器运算符,该运算符把字符串转换成普通的编译指示。该运算符不使用#符号,所以可以把它作为宏展开的一部分
    5、例如_Pragma("nonstandardtreatmenttypeB on")等价于#pragma nonstandardtreatmenttypeB on

  • 泛型选择 _Generic

    • 引入

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

    • _Generic

      1、这是一个泛型选择表达式的示例:_Generic(x, int:0, float:1, double:2, default:3)
      2、_GenericC11的关键字,后面的圆括号中包含多个用逗号分隔。第一个项是一个表达式,后面的每个项都由一个类型一个冒号一个值组成
      3、第一个项类型匹配哪个标签整个表达式的值就是该标签后的值。如上面例子的xint 类型,则值为int:后的值(即 0),如果没有类型匹配的标签,表达式的值就是default:后的值
      4、泛型选择语句switch语句类似,只是前者用表达式的类型匹配标签,后者用表达式的值匹配标签
      5、下面是一个把泛型选择语句宏定义组合的例子:

      /* 使用 \符号 把一条逻辑行分为多条物理行 */
      #define MYTYPE(X) _Generic((X),\
          int: "int",\
          float: "float",\
          double: "double",\
          default: "other"\
      )

函数说明符

  • 内联函数简介

    1、通常,函数调用都有一定的开销,因为函数的调用过程包含建立调用传递参数跳转到函数代码返回
    2、使用使代码内联,可以避免这样的开销C99还提供另一种方法:内联函数
    3、C99标准中对其的叙述是:把函数变成内联函数意味着尽可能快地调用该函数,其具体效果由实现定义。因此把函数变成内联函数编译器可能会用内联代码替换函数调用,并执行一些其他的优化,也可能不起作用

  • 创建内联函数

    #include <stdio.h>
    inline static void eatline()        // 内联函数定义/原型
    {
        while(getchar() != '\n')
            continue;
    }

    1、创建内联函数的定义有多种方法,标准规定一个具有内部链接的函数可以成为内联函数,还规定内联函数的定义调用该函数的代码必须在同一文件
    2、因此,最简单的办法是使用函数说明符inline存储类别说明符static创建内联函数
    3、由于并未给内联函数预留单独的代码块,所以无法获得内联函数的地址(实际上可以获得,不过这样做之后,编译器会生成一个非内联函数)。另外,内联函数无法在调试器中显示
    4、内联函数应该比较短小,较长的函数变成内联并为节约多少时间,因为执行函数体的时间比调用函数的时间长得多

  • _Noreturn 函数

    1、C99新增inline关键字时,它是唯一的函数说明符。后来C11新增了第二个函数说明符_Noreturn
    2、_Noreturn表明调用完成后函数不返回主函数exit()函数是_Noreturn函数的一个示例,一旦调用exit(),它便不会再返回主调函数
    3、_Noreturn的目的是告诉用户和编译器,这个特殊的函数不会把控制返回主调程序。告诉用户避免滥用该函数,通知编译器可优化这些代码

C 库

  • 最初,并没有官方的 C 库。后来基于 UNIX 的 C 实现成为了标准ANSI C委员会主要以这个标准为基础,开发了一个官方的标准库。在意识到 C 语言应用范围扩大后,委员会重新定义了这个库,使之可以应用于其他系统

  • 数学库 math.h

    函数原型 描述(1 弧度 = 180/π = 57.296°)
    double acos(double x) 返回余弦值为 x 的角度(0 ~ π 弧度)
    double asin(double x) 返回正弦值为 x 的角度(-π/2 ~ π/2 弧度)
    double atan(double x) 返回正切值为 x 的角度(-π/2 ~ π/2 弧度)
    double atan2(double y, double x) 返回正切值为 y/x 的角度(-π/2 ~ π/2 弧度)
    double cos(double x) 返回 x 的余弦值,x 的单位为弧度
    double sin(double x) 返回 x 的正弦值,x 的单位为弧度
    double tan(double x) 返回 x 的正切值,x 的单位为弧度
    double exp(double x) 返回 x 的指数函数的值
    double log(double x) 返回 x 的自然对数值
    double log10(double x) 返回 x 的以 10 为底的对数值
    double pow(double x, double y) 返回 x 的 y 次幂
    double sqrt(double x) 返回二次根下 x 的值
    double cbrt(double x) 返回三次根下 x 的值
    double ceil(double x) 返回不小于 x 的最小整数值
    double fabs(double x) 返回 x 的绝对值
    double floor(double x) 返回不大于 x 的最大整数值
  • 类型变体

    1、基本的浮点型数学函数接受double 类型的参数,并返回 double 类型的值。当然,也可以把floatlong double参数传递给这些函数,它们也能正常工作
    2、这样做很方便,但不是最好的处理方式。例如如果不需要双精度float单精度速度会更快long double 类型的值传给double可能会损失精度,值可能不是原来的值
    3、C 标准专门为floatlong double提供了标准函数,即原函数名后加上fl后缀。因此sqrtf()sqrt()float 版本sqrtl()sqrt()long double 版本
    4、可以利用C11新增的泛型选择表达式定义一个泛型宏,根据参数类型选择合适的数学函数版本,如下:

    #include <stdio.h>
    #include <math.h>
    
    // 主体为 _Generic(X) (X),注意分辨,_Generic部分用于选择版本(即函数名),与后面的(X)拼接形成函数
    #define SQRT(X) _Generic((X),\
        long double: sqrtl,\
        float: sqrtf,\
        default: sqrt)(X)
    // 这样定义,在调用时只需要调用 SQRT(X) 就可以自动选择最合适的版本
  • tgmath.h 库

    1、C99提供的tgmath.h头文件中定义了泛型类型宏,其效果与上面类型变体的程序示例类似
    2、tgmath.h创建一个泛型类型宏,与原来 double 版本的函数名同名。其会根据参数类型自动选择展开对应版本
    3、complex.h中声明了与复数运算相关的函数,例如csqrtf()csqrt()csqrtl(),也分别对应不同版本。如果提供这些支持,tgmath.h中的sqrt()也能展开为相应的复数平方根函数
    4、如果包含tgmath.h,要调用sqrt()函数而不是sqrt(),可以把被调用的函数名括起来(sqrt)(x)

  • 通用工具库 stdlib.h

    • exit()atexit()函数

      • 引入

        1、在前面的章节已经使用过exit()函数。而且,在main()返回系统时将自动调用exit()
        2、ANSI 标准还新增了一些不错的功能,注重最重要的是可以指定在执行exit()调用的特定函数
        3、atexit()通过注册要在退出时调用的函数提供这一特性atexit()函数接受一个函数指针作为参数

      • atexit()的用法

        1、函数使用函数指针。要使用atexit()函数,只需把退出时要调用的函数地址传递给atexit()即可(函数名作为函数参数时相当于函数地址)
        2、传入参数后,atexit()注册函数列表中的函数,当调用exit()时就会执行这些函数ANSI保证,在这个列表中至少可以放 32 个函数
        3、最后调用exit()时,exit()执行这些函数执行顺序列表中函数顺序相反,即最后添加的函数最先执行
        4、atexit()注册的函数应该不带任何参数返回类型为 void。通常这些函数会执行一些清理任务,如更新监视程序的文件重置环境变量

      • 程序示例

        #include <stdio.h>
        #include <stdlib.h>
        
        void sign_off(void)
        {
            puts("Thus terminates another magnificent program from");
            puts("Seesaw Software");
        }
        
        void too_bad(void)
        {
            puts("Seesaw Software extends its heartfelt condolences");
            puts("to you upon the failure of your program");
        }
        
        int main(void)
        {
            int n;
            atexit(sign_off); // 注册 sign_off 函数
            puts("Enter an integer:");
            if (scanf("%d", &n) != 1)
            {
                puts("That's no integer!");
                atexit(too_bad); // 注册 too_bad 函数
                exit(EXIT_FAILURE);
            }
            printf("%d is %s\n", n, (n % 2 == 0) ? "even" : "odd");
            return 0;
        }
        Enter an integer:
        212
        212 is even
        Thus terminates another magnificent program from
        Seesaw Software
        Enter an integer:
        a
        That's no integer!
        Seesaw Software extends its heartfelt condolences
        to you upon the failure of your program
        Thus terminates another magnificent program from
        Seesaw Software
    • qsort()函数

      • qsort()的用法

        1、对较大型的数组而言,快速排序算法是最有效的排序算法之一快速排序算法在 C 实现的名称是qsort()
        2、qsort()函数排序数组的数据对象,其原型为void qsort(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *));
        3、第一个参数指针,指向待排序数组的首元素(void 类型指针,可以引入任何类型数组)
        4、第二个参数待排序项的数量。函数原型将该值转换为 size_t 类型
        5、第三个参数是待排序数组每个元素的大小(由于第一个参数转换为 void 指针,所以函数不知道每个元素的大小,因此需要补偿该信息)
        6、第四个参数是一个指向函数的指针,指向一个自定义的比较函数,用于确定排序的顺序
        7、自定义的比较函数应接受两个参数,分别指向待比较两项的指针返回值int 类型返回正数则告知qsort()交换,相等返回 0返回负数不操作(见示例程序)

      • 程序示例

        /* 程序自动随机生成一组浮点数数组,并排序 */
        #include <stdio.h>
        #include <stdlib.h>
        
        #define NUM 40 // 数组元素数
        
        // 随机生成数组
        void fillarray(double ar[], int n)
        {
            for (int index = 1; index < n; index++)
                ar[index] = (double)rand() / ((double)rand() + 0.1);
        }
        
        // 打印数组
        void showarray(double ar[], int n)
        {
            int index;
            for (index = 0; index < n; index++)
            {
                printf("%9.4f ", ar[index]);
                if (index % 6 == 5)
                    putchar('\n');
            }
            if (index % 6 != 0)
                putchar('\n');
        }
        
        // 自定义排序函数,按从小到大排序
        int mycomp(const void *p1, const void *p2)
        {
            // 要使用指向对应类型(double)的指针来访问这两个值
            const double *a1 = (const double *)p1;
            const double *a2 = (const double *)p2;
        
            // 如果此处符号改为 > ,则从大到小排序
            if (*a1 < *a2)
                return -1;
            else if (*a1 == *a2)
                return 0;
            else
                return 1;
        }
        
        int main(void)
        {
            double vals[NUM];
            fillarray(vals, NUM);
            puts("Random list:");
            showarray(vals, NUM);
            qsort(vals, NUM, sizeof(double), mycomp);
            puts("\nSorted list:");
            showarray(vals, NUM);
            return 0;
        }
        Random list:
          0.0000    0.0022    0.2390    1.2191    0.3910    1.1021
          0.2027    1.3835    20.2830   0.2508    0.8880    2.2179
          25.4866   0.0236    0.9308    0.9911    0.2507    1.2802
          0.0939    0.9760    1.7217    1.2054    1.0326    3.7892
          1.9635    4.1137    0.9241    0.9971    1.5582    0.8955
          35.3798   4.0579    12.0460   0.0096    1.0109    0.8506
          1.1529    2.3614    1.5876    0.4825
        
        Sorted list:
          0.0000    0.0022    0.0096    0.0236    0.0939    0.2027
          0.2390    0.2507    0.2508    0.3910    0.4825    0.8506
          0.8880    0.8955    0.9241    0.9308    0.9760    0.9911
          0.9971    1.0109    1.0326    1.1021    1.1529    1.2054
          1.2191    1.2802    1.3835    1.5582    1.5876    1.7217
          1.9635    2.2179    2.3614    3.7892    4.0579