大学的计算机相关专业第一门教学的计算机语言就是c语言,很多大学生面对从未接触过的计算机语言,可能会觉得很难以上门,从而放弃学习c语言。这篇博客写的主要是个人学习C语言时候的知识总结点,不能保证全部是正确的,如有错误请大佬们斧正一二。

目录

1、C语言

1.1什么是C语言

1.2C语言的优点

1.3语言标准

1.4使用C语言的步骤

1.5第一个C语言程序

1.6关键字

2、数据类型

2.1变量与常量

2.2数据类型关键字

2.3整数

2.4浮点数

2.5基本数据类型

3、格式化输入/输出和字符串

3.1printf()函数

3.2scanf()函数

3.3初识字符串

4、操作符

4.1操作符分类

4.2操作符的属性

4.3算术操作符

4.4移位操作符

4.5位操作符

4.6赋值操作符

4.7单目操作符

4.8关系操作符

4.9逻辑操作符

4.10条件操作符

4.11逗号操作符

4.12下标引用、函数调用和结构成员

5、语句

5.1空语句

5.2表达式语句

5.3复合语句

5.4控制语句

5.5C控制语句:循环

5.6C控制语句:分支

5.6C控制语句:goto

5.7函数调用语句

6、函数

6.1什么是函数

6.2函数的分类

6.2.1库函数

6.2.2自定义函数

6.3函数的参数

6.3.1实际参数(实参)

6.3.2形式参数(形参)

6.4函数的调用

6.4.1传值调用

6.4.2传址调用

6.5函数的嵌套调用和链式访问 

6.5.1嵌套调用

6.5.2链式访问

6.6函数的声明和定义

6.6.1 函数声明

6.6.2 函数定义

6.7函数递归

7、数组

7.1数组概念

7.2一维数组的创建和初始化

7.2.1数组的创建方式:

7.2.2数组的初始化

7.2.3一维数组的使用

7.2.4一维数组在内存中的存储

7.3二维数组的创建和初始化

7.3.1二维数组的创建

7.3.2二维数组的初始化

7.3.3二维数组的使用

​编辑​

7.3.4二维数组在内存中的存储

7.4数组越界

7.4.1数组下标取值越界

7.4.2指向数组的指针的指向范围越界

7.5数组作为函数参数

7.5.1数组名

8、指针

8.1初识指针

8.1.1内存地址

 8.1.2基地址

8.1.3指针变量

8.2指针类型

8.2.1指针+-整数

8.2.2指针的解引用

8.3特殊指针

8.3.1野指针

8.3.2空指针

8.4指针运算

8.4.1指针-指针

8.4.2指针的关系运算

8.5指针和数组

8.6二级指针

8.7字符指针

8.8指针数组

8.9数组指针

8.9.1数组名

8.9.2使用场景

8.10函数指针

8.10.1函数指针数组

8.10.2指向函数指针数组的指针

8.11回调函数

9、自定义数据类型

9.1结构体

9.1.1建立结构声明

9.1.2定义结构体变量

9.1.3结构体大小

9.1.4访问结构体成员

9.1.5结构作为参数传递

9.2枚举

9.3共用体(联合体)

10、文件操作

10.1为什么使用文件

10.2什么是文件

2.1程序文件

2.2数据文件

2.3文件名

10.3文件的打开和关闭

10.3.1文件指针

10.3.2文件的打开和关闭

10.4文件的顺序读写

10.5文件的随机读写

10.5.1fseek

10.5.2ftell

10.5.3rewind

10.6文本文件和二进制文件

10.7文件读取结束的判定

10.8文件缓冲区

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

11.1作用域

11.2链接

11.3存储期

11.4存储类别

11.4.1自动变量

11.4.2寄存器变量

11.4.3块作用域的静态变量

11.4.4外部链接的静态变量

11.4.5内部链接的静态变量

11.4.6存储类别说明符

11.5动态内存管理

11.5.1出现原因

11.5.2动态内存函数介绍

11.5.3动态内存错误

5.3.1对NULL指针的解引用操作

5.3.2对动态开辟空间的越界访问

5.3.3对非动态开辟内存使用free释放

5.3.4使用free释放一块动态开辟内存的一部分

5.3.5对同一块动态内存多次释放

5.3.6动态开辟内存忘记释放(内存泄漏)

12、字符串函数

12.1求字符串长度

12.1.1strlen函数

12.2长度不受限制的字符串函数

12.2.1strcpy函数

12.2.2strcat函数

12.2.3strcmp函数

12.3长度受限制的字符串函数介绍

12.3.1strncpy函数

12.3.2strncat函数

1.3.3strncmp函数

12.4字符串查找

12.4.1strstr函数

12.4.2strtok函数

12.5错误信息报告

12.5.1strerror函数

12.6常用字符串函数模拟实现

13、内存函数

13.1memcpy函数

13.2memmove函数

13.3memset函数

13.4memcmp函数

13.5常用内存函数模拟实现 

14、编译与预处理

14.1.1 ANSI C 标准

14.1.2程序的翻译环境和执行环境

14.1.3运行环境

14.2预处理详解

14.2.1预定义符号

14.2.2、#define

2.2.1#define定义表示符

2.2.2#define定义宏

2.2.3#define替换规则

2.4#和##

2.2.5带副作用的宏参数

2.2.6宏和函数对比

14.3#undef

14.4命令行定义

14.5条件编译

14.6文件包含


1、C语言

1.1什么是C语言

1972年,贝尔实验室的丹尼斯·里奇和肯·汤普逊在开发UNIX操作系统时设计了C语言,C语言是在B语言的基础上进行设计。C语言设计的初衷是将其作为程序员使用的一种编程工具,是一门通用计算机编程语言,广泛应用于底层开发。C语言的设计目标是提供供一种能以简易的方式编译、处理低级存储器、产 生少量的机器码以及不需要任何运行环境支持便能运行的编程语言。

1.2C语言的优点

在学习c语言的过程中,会发现它有着许多优点:

1、设计特性:融合了计算机科学理论和实践的控制特性,让用户能轻松地完成自顶向下地规划,结构化编程和模块化设计

2、高效性:充分利用了当前计算机的优势,相对更紧凑,而且运行速度很快

3、可移植性:在一种系统编写的C层序稍作修改或不修改就能在其他系统运行

4、强大而灵活:功能强大且灵活的UNIX操作系统大部分就是用C语言写的

5、面向程序员:利用C可以访问硬件、操作内存中的位

有优点自然也有缺点,C语言的缺点是非常致命的:

C语言使用指针,指针的使用会给程序员带来许许多多不经意的错误,而程序员往往难以察觉到,从而造成程序崩溃等重大问题

1.3语言标准

C语言发展之初,并没有所谓的C标准。1978年,布莱恩·柯林汉和丹尼斯·里奇合著的The C Programming Language(《C语言程序设计》)第一版是公认的C标准,通常称之为K&R C或经典C。随着C的不断发展,越来越广泛地应用于更多系统中,C社区意识到需要一个更全面、更新颖、更严格的标准。美国国家标准协会(ANSI)于1983年组建了一个委员会,开发了一套新标准,并于1989年正式公布。该标准(ANSI C)定义了C语言和C标准库。国际标准化组织于990年采用了这套C标准(ISO C)。ISO C和ANSI C是完全相同的标准。ANSI/ISO标准的最终版本通常叫做C89(C90)。另外,由于ANSI先公布C标准,因此业界认识通常使用ANSI C。1994年,ANSI/ISO联合委员会开始修改C标准,最终发布了C99标准。该委员会遵循了最初C90标准的原则,包括保持语言的精炼简单。委员会的用意不是在C语言中添加新特性,而是为了达到新的目标。

  1. 支持国际化编程 
  2. “调整现有实践致力于解决明显的缺陷”
  3. 为适应科学和工程项目中的关键数值计算,提高C的适应性

标准委员会在2011年发布了C11标准,提出了新的指导原则。处于对当前编程安全的担忧,不那么强调“信任程序员”目标了。C99标准并没有被所有供应商接受和支持,这使得C99的一些特性称为C11的可选项。

1.4使用C语言的步骤

第一步:定义程序的目标

在动手写程序之前,要在脑中有清晰的思路。想要程序去做什么首先自己要明确自己要做什么,思考你的程序需要哪些信息,要进行哪些计算和控 制,以及程序应该要报告什么信息。

第二步:设计程序

对程序应该完成什么任务有概念性的认识后,就应该考虑如何用程序来完成它。还要决定在程序(还可能是辅助文件)中如何表示数据,以及用什么方法处理数据。

第三步:编写代码

设计好程序后,就可以编写代码来实现它。

第四步:编译

编译源代码。

第五步:运行程序

最终生成的程序可通过单击或双击文件名或图标直接在操作系统中运行。

第六步:测试和调试程序

查找并修复程序错误的过程叫调试。

第七步:维护和修改代码

创建完程序后,你发现程序有错,或者想扩展程序的用途,这时就要修改程序。

1.5第一个C语言程序

相信很多人第一个C语言都是著名的“hello world”吧,短短的几行代码,就像麻雀一样,虽小但五脏俱全。

 看到这几行代码,初学者可能会觉得陌生,下面详细讲解一下这几个词是什么来的:

1、#include指令和头文件

作用:把stdio.h文件中的所有内容都输入该行所在的位置。实际上这是一种“拷贝-粘贴”的操作。是一条C预处理指令。通常C编译器在编译前会对源代码做一些准备工作,即预处理。所有的C编译器软件包都提供stdio.h文件。该文件中包含了供编译器使 用的输入和输出函数(如, printf())信息。该文件名的含义是标准输入/输 出头文件。通常,在C程序顶部的信息集合被称为头文件。

在大多数情况下,头文件包含了编译器创建最终可执行程序要用到的信息。stdio.h不是所有程序都包含,有一些程序没有用到。特定C实现的文档中应该包含对C库函数的说明。这些说明确定了使用哪些函数需要包含哪些头文件。

2、main()函数

int main(void)

这行代码标明该函数名为main。C程序一定是从main()函数开始执行(特殊例外情况不考虑在内)。除了main()函数,我们还可以任意命名其他函数,但main()函数有且仅有一个,是程序运行的开端。后续会学到更多的函数,但此处只需记得main()函数是程序的进口,有且仅有一个就行。

int是main()函数的返回类型,这表明main()函数返回的值是整数,返回的对象是操作系统,后续也会讲解这问题。

函数名后面的圆括号中包含一些传入函数的信息。该程序代码中并没有传递任何信息,因此圆括号里面内是单词void。也许我们可能看到过一些代码中的main()函数后面的圆括号并没有void,这是旧式C语言风格,C90标准接收这种形式11,但C99和C11标准不允许这种写。我们在浏览旧式代码的时候也许还会看到:void int()这种形式,理论上是成立的,但是我们写程序的时候最好不要这样写,因为这种写法一些编译器允许这样写,但是所有的标准都未认可这种写法。

当我们使用int main(void)这种标准形式写法,我们程序的可移植性将会得到大大的保障,把程序从一个编译器移到另一个编译器时就不会出现什么问题。

3、注释

在程序中,被/* */两个符号括起来的部分是程序的注释。写注释能让我们(包括他人)更容易明白我们自己所写的程序。注释可以放在任意地方,甚至我们写的代码和注释内容放同一行是没问题的。在/*和*/之间的内容都会被编译器忽略。

C99新增了另外一种风格的注释,普遍用于C++和Java。这种新风格使用//符号创建注释,仅限于单行。这种新注释是为了解决就形式注释存在的潜在问题。例如:

/* /* /0 */ * */ 1 --》允许嵌套注释 --》输出结果为1

/* / */0* /* */ 1 --》不允许嵌套注释 --》输出结果为0

有可能会出现 不想注释的内容给忽略 或 输出结果与预想结果不一致的可能 等错误情况

4、花括号、函数体和块

一般而言,所有的C函数都使用花括号标记函数提的开始和结束。这是规定,不能省略。只有花括号({})能起这种租用,原括号(())和方括号都不行([])。花括号还可用于把函数中的多条语句合并为一个单元或块。

5、声明

int num; 声明是C语言最重 要的特性之一。这个声明完成了两件事。其一,在函数中有一个名为 num的变量(variable)。其二,int表明num是一个整数(没有小数点或小数部分的数)。int是一种数据类型。编译器使用这些信息为num变量在内存中分配存储空间。分号在C语言中是大部分语句和声明的一部分。int是C语言的一个关键字(keyword),表示一种基本的C语言数据类型。关键字是语言定义的单词,不能做其他用途。

在C语言中,所有变量都必须先声明才能使用。这意味着必须列出程序中用到的所有变量名及其类型。C99和C11遵循C++的惯例,可以把声明放在块中的任何位置。首次使用变量之前一定要先声明它。

看到这里,初学者可能就有问题了,什么是数据类型?命名有什么规则吗?为什么要声明变量?

数据类型 :C 语言可以处理多种类型的数据,如整数、字符和浮点数。把变量声明 为整型或字符类型,计算机才能正确地储存、读取和解释数据。下一个知识点就是C语言中的各种数据类型详细讲解。

命名:给变量命名时要使用有意义的变量名或标识符

命名规则:可以使用小写字母、大写字母、数字和下划线(_)来命名。而且,名称的第一个字符必须是字符或下划线,不能是数字。

C语言的名称区分大小写,即把一个字母的大写和小写视为两个不同的字符。

声明变量的理由:把所有的变量放在一处,方便读者查找和理解程序的用途。如果变量名 都是有意义的,这样做效果很好。如果变量名无法 表述清楚,在注释中解释变量的含义。这种方法让程序的可读性更高;声明变量会促使你在编写程序之前做一些计划;声明变量有助于发现隐藏在程序中的小错误;如果事先未声明变量,C程序将无法通过编译。

6、赋值

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

7、printf

使用了C语言的一个标准函数printf,圆括号表明printf是一 个函数名。圆括号中的内容是从main()函数传递给printf()函数的信息。printf()函数的\n字符并未输出,\n的意思是换行,代表着一个换行符。打印换行符的效果与在键盘按下Enter键相同,换行符会影响程序输出的显示格式。换行符一个转义序列,转义序列用于代表难以表 示或无法输入的字符。如,\t代表Tab键,\b代表Backspace键(退格键)。每 个转义序列都以反斜杠字符(\)开始。在后面我们将详细探讨一下。

参数中的%d在打印时有什么作用?

对比发现,参数中的%d被数字1代替了,而1就是变量num的值。%d相 当于是一个占位符,其作用是指明输出num值的位置。%提醒 程序,要在该处打印一个变量,d表明把变量作为十进制整数打印。printf() 82 函数名中的f提醒用户,这是一种格式化打印函数。printf()函数有多种打印 变量的格式,包括小数和十六进制整数。在后面我们将详细介绍。

8、return语句

return 0;int main(void)中的int表明 main()函数应返回一个整数。C标准要求main()这样做。有返回值的C函数要有return语句。该语句以return关键字开始,后面是待返回的值,并以分号结尾。如果遗漏 main()函数中的 return 语句,程序在运行至最外面的右花括号 (})时会返回0。因此,可以省略main()函数末尾的return语句。

1.6关键字

关键字是C语言的词汇,它们对C而言比较特殊,不能用它们作为标识符。许多关键字用于指定不同的类型,如int。还有一些关键字用于控制程序中语句的执行顺序。

ISO C关键字
autoexternshort

while

breakfloatsigned_Alignas
caseforsizeof_Alignof
chargotostatic_Atomic
constifstruct_Bool
continueinlineswitch_Complex
defaultinttypedef_Generic
dolongunion_Imaginary
doubleregisterunsigned

_Noreturn

elserestrictvoid_Static_assert
enumreturnvolatile_Thread_local

如果使用关键字不当,编译器会将其视为语法错误。后面将对所有的关键字都做一个介绍。

2、数据类型

2.1变量与常量

有些数据类型在程序使用之前已经预先设定好了,在整个程序的运行过程中没有变化,这些称为常量。

其他数据类型在程序与运行期间可能会改变或被赋值,这些称为变量。

怎么定义变量?

数据类型(type) 变量名字(name);

type:一个有效的 C 数据类型

命名规则:可以使用小写字母、大写字母、数字和下划线(_)来命名。而且,名称的第一个字符必须是字符或下划线,不能是数字

变量又分为局部变量全局变量 (主函数外定义)

当一个程序代码里面有着同名的局部变量和全局变量则优先使用局部变量。

 那全局变量和局部变量有着什么区别呢?

主要是在于作用域和生命周期

作用域:程序设计概念,通常来说,一段程序代码中所用到的名字并不总是有效/可用的

1. 局部变量的作用域是变量所在的局部范围。

2. 全局变量的作用域是整个工程。

生命周期:变量的创建到变量的销毁之间的一个时间段

1. 局部变量的生命周期是:进入作用域生命周期开始,出作用域生命周期结束。

2. 全局变量的生命周期是:整个程序的生命周期。

C语言的常量分为以下几种:

1、字面常量

2、const 修饰的常变量

3、#define 定义的标识符常量

4、枚举常量

字面常量包括:整形常量字符型常量字符串常量。注意:不存在数组常量结构体常量等结构型的字面常量。但是存在结构型的符号常量。指的是直接输入到程序中的值

const是一个关键字,用const修饰的变量pai叫做常变量,在C语言中只是在语法层面限制了变量pai不能直接被改变,但是pai本质上还是一个变量,所以叫常变量

符号常量也叫明示常量,用#define预处理器指令来定义符号变量。#define预处理器指令和其他预处理指令一样,以#号作为一行的开始。ANSI和后来的标准都允许#号前面有空格或制表符,而且还允许在#和指令的其余部分之间有空格。但是旧版本的C要求指令从一行的最左边开始,,而且#和只能怪其余部分之间不能有空格。指令可以出现在源文件的任何地方。

预处理指令从#开始运行,到后面的第一个换行符为止。在预处理开始前,编译器会把多行物理行处理为一行逻辑行。

每行#define(预处理)都由3部分组成。第一部分是#define指令本身。第二部分是选定的缩写,也成为宏。有些宏代表值,这些宏被称为类对象宏。C语言还有类函数宏。第3部分被称为替换列表或替换体,一旦预处理器在程序中找到宏实例后,就会用替换体替换该宏。从宏变成最终替换文本的过程称为宏展开。注意,可以在#define行使用标准C注释,每条注释都会被一个空格代替。

#define PI 3.1415

#define MAX(x,y) (x)>(y)?(x):(y) 

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

枚举格式:

enum enumerate {枚举符1,枚举符2,...,枚举符N};

enum enumerate {枚举符1=值1,枚举符2=值2,...,枚举符N};

虽然枚举符是int类型,但是枚举变量可以是任意整数类 型,前提是该整数类型可以储存枚举常量。C语言是允许枚举变量使用++运算符。

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

enum spectrum {red, orange=100, yellow};

如果只给一个枚举常量赋值,没有对后面的枚举常量赋值,那么后面的常量会被赋予后续的值。

枚举的优点:

1. 增加代码的可读性和可维护性

2. 和#define定义的标识符比较枚举有类型检查,更加严谨。

3. 防止了命名污染(封装)

4. 便于调试

5. 使用方便,一次可以定义多个常量

枚举常用于switch语句中,充当case标签后面的表达式。

2.2数据类型关键字

C通过识别一些基本的数据类型来区分和使用这些不同的数据类型。

C语言的数据类型关键字
最初K&R给出的关键字C90标准添加的关键字C99标准添加的关键
intsigned_Bool
longvoid_Complex
short_Imaginary
unsingned
char
float
double

  

在C与语言中,用int关键字来表示基本的整数类型。后三个关键字(long,short和unsigned)和C90新增的signed用于提供基本证书类型的变式。另外,char类型也可以表示较小的整数。float、double和long double表示带小数点的数。_Bool类型表示布尔值(true或false),_Complex和_Imaginary分别表示复数和虚数。

通过关键字创建的类型,按计算机的存储方式可分为两大基本类型:整数类型浮点数类型

学习整数类型和浮点数类型之前,我们首先要知道几个概念

位,字节和字

位、字节和字是描述计算机数据单元或存储单元的术语

最小的存储单元是位(bit),可以储存0或1(或者说,位用于设 置“开”或“关”)。虽然1位储存的信息有限,但是计算机中位的数量十分庞 大。位是计算机内存的基本构建块。

字节(byte)是常用的计算机存储单位。对于几乎所有的机器,1字节均为8位。这是字节的标准定义,至少在衡量存储单位时是这样。既然1位可以表示0或1,那 么8位字节就有256(2的8次方)种可能的0、1的组合。通过二进制编码(仅用0和1便可表示数字),便可表示0~255的整数或一组字符。

字(word)是设计计算机时给定的自然存储单位。对于8位的微型计算 机(如,最初的苹果机),1个字长只有8位。从那以后,个人计算机字长增至16位、32位,直到目前的64位。计算机的字长越大,其数据转移越快, 允许的内存访问也更多。

现在的电脑都是32位或64位,我们可以写一个测试代码来查看一下各种类型数据的字节大小:

 可以敲敲键盘测试自己的编译软件会给自己返回什么答案。(sizeof作为一个关键字,这里用于判断变量或数据类型的字节大小)

常用的编译软件有:devc,vs2013,vs2017,vs2019,vs2022,vscode等等

我这里比较推荐devc,vs2022和vscode,devc比较适合新手使用,vs2022功能十分强大,vscode可以根据自己的需求安装插件

2.3整数

和数学的概念一样,在C语言中,整数是没有小数部分的数。例如,1111,2222和-1111都是整数,而3.14和2.7就不是整数。计算机是以二进制数字存储整数,以整数11来做一个例子:

而整数实质上是4个字节,剩下三个字节系统会给你自动补为0。

2.4浮点数

浮点数与数学中实数的概念差不多。3.14,3.15E10和3e-3都是浮点数。注意,在一个值后面加上一个小数点,这个值就成为一个浮点值。浮点数有许多的书写格式,浮点数和整数的存储方式不一样。计算机把浮点数分成效数部分和指数部分来表示,而且分开存储这两个部分。

 整数和浮点数的实际区别:

1、整数没有小数部分,浮点数有小数部分

2、浮点数可以表示的范围比整数大

3、对于一些算数运算,浮点数损失的精度更多

4、计算机的浮点数不能表示区间内所有的值。浮点数通常只是实际值的近似值

5、浮点运算比整数运算慢

2.5基本数据类型

2.5.1int型数据

C语言提供了许多整数类型,C语言让程序员针对不同情况选择不同的类型。特别是,C语言中的整数类型可表示不同的取值范围和正负值。一般情况使用int类型即可,但是为满足特定任务和机器的要求,还可以选择其他类型。ISO C规定int的取值范围最小为-32768~32767。一般而言,系统用一个特殊位的值表示有符号整数的正负号。

1、声明int变量

声明格式:

int 变量名;

int 变量名 = 值;

声明多个变量的时候,可以单独声明每个变量,也可以在int后面列出多个变量名,变量名之间用逗号(,)分隔。声明变量创建了变量,但是并没有给它们赋值。此时访问变量的值是未知的。程序中获取的途径有:1、赋值 2、通过函数获得值 3、初始化变量

2、初始化变量

初始化变量就是给变量赋一个初始值。在C语言中,初始化可以直接在声明中完成。只需要在变量名后面加上给赋值运算符( = )和变量值就可以了。

注意:不要把初始化的变量和未初始化的变量放在同一条声明,这给程序员会带来很糟糕的印象。

声明为变量创建和标记存储空间,并为其指定初始值

3、打印int值

用printf()函数打印int类型的值。%d指明了在一行中打印整数的位置。%d称为转换说明,它指定了printf()应使用什么格式 来显示一个值。格式化字符串中的每个%d都与待打印变量列表中相应的int 值匹配。这个值可以是int类型的变量、int类型的常量或其他任何值为int类型的表达式。作为计算机小白,低级的错误可不要犯,一定要确保转换说明的数量与待打印值的数量相同,不然会报错。

通常,C语言都假定整型常量是十进制,但是有一些情景需要我们使用八进制或者十六进制。八进制和十六进制技术系统在表达与计算机相关的值时很方便。怎么利用printf()函数输出不同进制的数呢?以十进制显示数字,使用%d;以八进制显示数字,使用%o;以十六进制显示数字,使用%x。如果需要显示各进制的前缀0、0x和0X,则必须分别使用%#o、%#x、%#X。

写一个相关代码来测试一下以上的内容,否则读者也不知道是否是正确的,读者也可以尝试敲敲代码,通过代码可以更好的吸收以上的知识点:

 代码没有报错,也能成功运行出结果,检验运行效果和上面是否一致。

4、其他整数类型

初学者可能认为对于整数来说,一个int型就可以满足大多数程序的需求,但我们还需要了解一下整型的其他形式。

C语言提供三个附属关键字修饰基本整数类型:shortlongunsigned

short int类型(或者简写为short)占用的存储空间可能比int类型少,常用于较小数值的场合以节省空间。与int类似,short是有符号类型。

long int或long占用的存储空间可能比int多,适用于较大数值的场合。与 int类似,long是有符号类型。

long long int或long long(C99标准加入)占用的储存空间可能比long多, 适用于更大数值的场合。该类型至少占64位。与int类似,long long是有符号 类型。

unsigned int或unsigned只用于非负值的场合。这种类型与有符号类型表 示的范围不同。例如,16位unsigned int允许的取值范围是0~65535,而不 是-32768~32767。用于表示正负号的位现在用于表示另一个二进制位,所 以无符号整型可以表示更大的数。 

在C90标准中,添加了unsigned long int或unsigned long和unsigned int或 unsigned short类型。C99标准又添加了unsigned long long int或unsigned long long。

在任何有符号类型前面添加关键字signed,可强调使用有符号类型的意图。例如,short、short int、signed short、signed short int都表示同一种类型。

5、整数溢出

当整数超出相应类型的取值范围会怎样呢?有的人可能会认为不会错误,也有人可能认为程序会报错,我们直接通过一段代码来进行判断:

程序并没有给我们的代码报错,而是正常输出结果,但结果可能会有些奇怪,但是确实是正常的。我们可以把无符号整数max_uint看作是汽车的历程表,当达到它能表示的最大值时,会重新从起始点开始。整数max_int也是类似的情况,最大的区别就是,在超过最大值时,unsigned int类型的变量从0开始,而int类型的变量从负数开始。当出现整数溢出这种情况,系统并不会通知我们,因此我们在编程的时候要注意整数溢出的问题。

 

 读者可以写写代码测试一下其他整数类型的有符号和无符号数据的最大值以及它们数据溢出发生的情况,下面附上climits中的符号常量(后面会介绍):

符号常量表示
CHAR_BITchar 的位数
CHAR_MAXchar的最大值
CHAR_MINchar 的最小值
SCHAR_MAXsigned char的最大值
SCHAR_MINsigned char 的最小值
UCHAR_MAXunsigned char的最大值
SHRT_MAXshort的最大值
SHRT_MINshort 的最小值
USHRT_MAXunsigned short 的最大值
INT_MAXint 的最大值
INT_MINint 的最小值
UNIT_MAXunsigned int的最大值
LONG_MAXlong 的最大值
LONG_MINlong 的最小值
ULONG_MAXunsignedlong 的最大值
LLONG_MAXlong long 的最大值
LLONG_MINlong long 的最小值
ULLONG_MAXunsignedlong long 的最大值

2.5.2char型数据

 char型数据用于存储字符(如,字母或标点符号),但它实际上也是整数类型,因为char类型实际上储存的是整数而不是字符。计算机使 用数字编码来处理字符,即用特定的整数表示特定的字符。美国最常用的编码是ASCII编码。标准ASCII码的范围是0~127,只需7位二进制数即可表示。通常,char 类型被定义为8位的存储单元。

ASCLL打印字符表
ASCII 码字符ASCII 码字符ASCII 码字符ASCII 码字符
十进位十六进位十进位十六进位十进位十六进位十进位十六进位
0322005638808050P10468h
03321!05739908151Q10569i
03422"0583A:08252R1066Aj
03523#0593B;08353S1076Bk
03624$0603C<08454T1086Cl
03725%0613D=08555U1096Dm
03826&0623E>08656V1106En
03927'0633F?08757W1116Fo
04028(06440@08858X11270p
04129)06541A08959Y11371q
0422A*06642B0905AZ11472r
0432B+06743C0915B[11573s
0442C,06844D0925C\11674t
0452D-06945E0935D]11775u
0462E.07046F0945E^11876v
0472F/07147G0955F_11977w
04830007248H09660`12078x
04931107349I09761a12179y
0503220744AJ09862b1227Az
0513330754BK09963c1237B{
0523440764CL10064d1247C|
0533550774DM10165e1257D}
0543660784EN10266f1267E~
0553770794FO10367g1277FDEL

 这里我列出ASCIl 打印字符,还有ASCLL非打印字符和扩展ASCLL打印字符我就不详细介绍了。

1、声明char类型变量

char类型变量的声明方式与其他类型变量的声明方式相同。

2、字符常量和初始化

如果要把一个字符常量初始化为字母 A,不必背下 ASCII 码,用计算机语言很容易做到。通过以下初始化把字母A赋给grade即可:

char grade = 'A';

在C语言中,用单引号括起来的单个字符被称为字符常量(character constant)。编译器一发现'A',就会将其转换成相应的代码值。单引号必不可少。如果忽略单引号,编译器会认为是一个变量名;如果写成双引号,编译器则认为是字符串(后面将介绍)。实际上,字符是以数值形式储存的,所以也可使用数字代码值来赋值。

想一想,如果char类型数据在printf()函数里面用%d输出会显示什么内容?

 可以看到%c形式输出就是我们的char型数据原来的值,而%d形式输出的就是其对应的ASCll的值,通过下面这张图片来更为深刻的了解其工作原理:

3、有符号还是无符号

有些C编译器把char实现为有符号类型,这意味着char可表示的范围 是-128~127。而有些C编译器把char实现为无符号类型,那么char可表示的 范围是0~255。根据C90标准,C语言允许在关键字char前面使用signed或unsigned。这样,无论编译器默认char是什么类型,signed char表示有符号类型,而 unsigned char表示无符号类型。

2.5.3_Bool类型

C99标准添加了_Bool类型,用于表示布尔值,即逻辑值true和false。因 为C语言用值1表示true,值0表示false,所以_Bool类型实际上也是一种整数 类型。但原则上它仅占用1位存储空间,因为对0和1而言,1位的存储空间足够了。程序通过布尔值可选择执行哪部分代码。(后面将详细介绍)

2.5.4float、double和long double

各种整数类型对大多数软件开发项目而言够用了。然而,面向金融和数 学的程序经常使用浮点数。C语言中的浮点类型有float、double和long double 类型。浮点类型 能表示包括小数在内更大范围的数。浮点数的表示类似于科学记数法(即用 小数乘以10的幂来表示数字)。该记数系统常用于表示非常大或非常小的数。

计数法示例
数字科学计数法指数计数法
3.14151.0×10 91.0e9
0.2125.6×10-55.6e-5

C标准规定,float类型必须至少能表示6位有效数字,且取值范围至少是 10 -37~10 +37。前一项规定指float类型必须至少精确表示小数点后的6位有效 143 数字,如33.333333。后一项规定用于方便地表示诸如太阳质量(2.0e30千克)、一个质子的电荷量(1.6e-19库仑)或国家债务之类的数字。通常, 系统储存一个浮点数要占用32位。其中8位用于表示指数的值和符号,剩下 24位用于表示非指数部分(也叫作尾数或有效数)及其符号。

C语言提供的另一种浮点类型是double(意为双精度)。double类型和 float类型的最小取值范围相同,但至少必须能表示10位有效数字。一般情况 下,double占用64位而不是32位。一些系统将多出的 32 位全部用来表示非 指数部分,这不仅增加了有效数字的位数(即提高了精度),而且还减少了 舍入误差。另一些系统把其中的一些位分配给指数部分,以容纳更大的指 数,从而增加了可表示数的范围。无论哪种方法,double类型的值至少有13 位有效数字,超过了标准的最低位数规定。

C语言的第3种浮点类型是long double,以满足比double类型更高的精度 要求。不过,C只保证long double类型至少与double类型的精度相同。

1.声明浮点型变量

浮点型变量的声明和初始化方式与整型变量相同

2.浮点型常量

在代码中,可以用多种形式书写浮点型常量。浮点型常量的基本形式 是:有符号的数字(包括小数点),后面紧跟e或E,最后是一个有符号数表示10的指数。正号可以省略。可以没有小数点或指数部分,但是不能同时省略两者。可以省略小数部分或整数部分,但是不能同时省略两者。

有的初学者可能在定义浮点型常量的时候,不小心在中间加空格,这是一种错误的书写格式。

默认情况下,编译器假定浮点型常量是double类型的精度。在浮点数后面加上f或F后缀可覆盖默认设置,编译器会将浮点型常量看作float类型;在浮点数后面加上f或F后缀可覆盖默认设置,编译器会将浮点型常量看作float类型;没有后缀的浮点型常量是double类型。

printf()函数使用%f和%lf转换说明打印十进制记数法的float和double类型浮点数,用%e打印指数记数法的浮点数。如果系统支持十六进制格式的浮点 数,可用a和A分别代替e和E。打印long double类型要使用%Lf、%Le或%La 转换说明。

 3、浮点值的上溢和下溢

当计算导致数字过大,超过当前类型能表达的范围时,就会发生上溢(overflow)。

当除以一个很小的数时,情况更为复杂。float类型的数以指 数和尾数部分来储存。存在这样一个数,它的指数部分是最小值,即由全部可用位表示的最小尾数值。该数字是float类型能用全部精度表示的最小数字。现在把它除以 2。通常,这个操作会减小指数部分,但是假设的情况 中,指数已经是最小值了。所以计算机只好把尾数部分的位向右移,空出第 1 个二进制位,并丢弃最后一个二进制数。以十进制为例,把一个有4位有效数字的数(如,0.1234E-10)除以10,得到的结果是0.0123E-10。虽然得到了结果,但是在计算过程中却损失了原末尾有效位上的数字。这种情况叫 作下溢(underflow)。

C语言把损失了类型全精度的浮点值称为低于正常的浮点值。

2.5.5复数和虚数类型

许多科学和工程计算都要用到复数和虚数。C99 标准支持复数类型和虚 数类型,但是有所保留。C语言有3种复数类型:float_Complex、double_Complex和 long double _Complex。C语言的3种虚数类型是float _Imaginary、double _Imaginary和long double _Imaginary。如果包含complex.h头文件,便可用complex代替_Complex,用imaginary 代替_Imaginary。这里不多加介绍,有兴趣可以通过其他途径学习。

到这里,我们已经学习了C语言的部分基本数据类型,对此做个总结:

关键字:基本数据类型由11个关键字组成:int、long、short、unsigned、char、float、double、signed、_Bool、_Complex和_Imaginary

有符号整型可用于表示正整数和负整数。

int ——系统给定的基本整数类型。C语言规定int类型不小于16位。

short或short int ——最大的short类型整数小于或等于最大的int类型整数。C语言规定short类型至少占16位。

long或long int ——该类型可表示的整数大于或等于最大的int类型整数。 C语言规定long类型至少占32位。

long long或long long int ——该类型可表示的整数大于或等于最大的long 类型整数。Long long类型至少占64位。

一般而言,long类型占用的内存比short类型大,int类型的宽度要么和 long类型相同,要么和short类型相同。

无符号整型:只能用于表示零和正整数,因此无符号整型可表示的正整数比有符号整型的大。在整型类型前加上关键字unsigned表明该类型是无符号 整型:unsignedint、unsigned long、unsigned short。单独的unsigned相当于 unsigned int。

字符类型:可打印出来的符号(如A、&和+)都是字符。根据定义,char类型表示 一个字符要占用1字节内存。

char ——字符类型的关键字。有些编译器使用有符号的char,而有些则 使用无符号的char。在需要时,可在char前面加上关键字signed或unsigned来 指明具体使用哪一种类型。

布尔类型:布尔值表示true和false。C语言用1表示true,0表示false。

_Bool ——布尔类型的关键字。布尔类型是无符号 int类型,所占用的空间只要能储存0或1即可。

实浮点类型:实浮点类型可表示正浮点数和负浮点数。

float ——系统的基本浮点类型,可精确表示至少6位有效数字。

double ——储存浮点数的范围(可能)更大,能表示比 float 类型更多的有效数字(至少 10位,通常会更多)和更大的指数。

long long ——储存浮点数的范围(可能)比double更大,能表示比 double更多的有效数字和更大的指数。

复数和虚数浮点数:虚数类型是可选的类型。复数的实部和虚部类型都基于实浮点类型来构 成

如何声明简单变量:

1.选择需要的类型。

2.使用有效的字符给变量起一个变量名。

3.按以下格式进行声明: 类型说明符 变量名;

4.可以同时声明相同类型的多个变量,用逗号分隔各变量名

5.在声明的同时还可以初始化变量

3、格式化输入/输出和字符串

相信很多人在看代码的时候都会看到scanf()和printf()这两个熟悉又陌生的函数,熟悉是基本每个程序都会出现到,陌生是我们从未接触过这两个函数,下面我们开始学习一下这两个函数是什么来的

3.1printf()函数

printf()函数是C语言标准输出函数,用于将格式化后的字符串输出到标准输出(对应终端的屏幕)。使用printf函数需要声明在头文件#include<stdio.h>下。

认识一个函数先了解它的原型

int printf ( const char * format, ... );

返回值:正确返回输出的字符总数,错误返回负值。

那怎么调用printf()函数呢?调用格式:

printf("格式化字符串", 输出表列)

格式化字符串包含三种对象,分别为:
(1)字符串常量;
(2)格式控制字符串;
(3)转义字符。

字符串常量原样输出,在显示中起提示作用。输出表列中给出了各个输出项,要求格式控制字符串和各输出项在数量和类型上应该一一对应。其中格式控制字符串是以 % 开头的字符串,在 % 后面跟有各种格式控制符,以说明输出数据的类型、宽度、精度等。后面将详细介绍字符串。

printf() 的格式控制字符串组成如下:

%[flags][width][.prec][length]type
%[标志][最小宽度][.精度][类型长度]类型

上面五个选项中,类型是必不可少的,type用于规定输出数据的的类型。下表列出一些转换说明和各自对应的输出类型

转换说明及其打印结果
转换说明输出示例
%a浮点数、十六进制数和p记数法 (C99/C11)printf("%a",3.14);输出0x1.91eb851eb851fp+1

%A

浮点数、十六进制数和p记数法 (C99/C11)printf("%A",3.14);输出0X1.91EB851EB851FP+1
%c单个字符printf("%c",a);输出字符a
%d有符号十进制整数printf("%d",10);输出十进制数字10
%e浮点数,e记数法printf("%e",3.14e10);输出3.140000e+10
%E浮点数,e 记数法printf("%E",3.14e10);输出3.140000E+10
%f浮点数,十进制记数法printf("%f",3.14);输出浮点数3.14
%g根据值的不同,自动选择%f 或%e。%e 格式用于指数小于-4 或者大于或等于精度时

printf("%g",0.00000123);输出1.23e-07

printf("%g",0.123);输出0.123

%G根据值的不同,自动选择%f 或%E。%E 格式用于指数小于-4 或者大于或等于精度时

printf("%G",0.00000123);输出1.23E-07

printf("%G",0.123);输出0.123

%i有符号十进制整数(与%d 相同)printf("%i",123);输出123
%o无符号八进制整数printf("%o",12);输出14
%p指针printf("%p","hello");输出00B07B30
%s字符串printf("%s","hello");输出hello
%u无符号十进制整数printf("%u",123);输出123
%x无符号十六进制整数,使用十六进制数 0fprintf("%x",123);输出0x7b
%X无符号十六进制整数,使用十六进制数 0Fprintf("%X",123);输出0x7B
%%打印一个百分号printf("%%");输出%

其中有一些我们在之前就有接触过(%d,%c,%u等等),这里并不是全部,但以上这些是比较常用的,希望读者可以多加练习从而记住。

下面我们介绍一下printf()函数中的标记

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

这里附上我当时学习的代码和运行截图:

 输出最小宽度(width)

用十进制整数来表示输出的最少位数。若实际位数多于指定的宽度,则按实际位数输出,若实际位数少于定义的宽度则补以空格或0。width的可能取值如下:

width描述示例
数值十进制整数printf("%06d",1000);输出:001000
*星号。不显示指明输出最小宽度,而是以星号代替,在printf的输出参数列表中给出printf("%0*d",6,1000);输出:001000

精度(.precision)

精度格式符以“.”开头,后跟十进制整数。可取值如下:

.precision描述
.数值进制整数。
(1)对于整型(d,i,o,u,x,X),precision表示输出的最小的数字个数,不足补前导零,超过不截断。
(2)对于浮点型(a, A, e, E, f ),precision表示小数点后数值位数,默认为六位,不足补后置0,超过则截断。
(3)对于类型说明符g或G,表示可输出的最大有效数字。
(4)对于字符串(s),precision表示最大可输出字符数,不足正常输出,超过则截断。
precision不显示指定,则默认为0
.*以星号代替数值,类似于width中的*,在输出参数列表中指定精度

在 printf() 的实现中,在调用 write 之前先写入 IO 缓冲区,这是一个用户空间的缓冲。系统调用是软中断,频繁调用,需要频繁陷入内核态,这样的效率不是很高,而 printf 实际是向用户空间的 IO 缓冲写,在满足条件的情况下才会调用 write 系统调用,减少 IO 次数,提高效率。

可以参考一下我之前写的这篇博客:https://blog.csdn.net/sakura0908/article/details/129342537

之前说过,大部分C函数都有一个返回值,这是函数计算并返回给主调程序的值。可以把返回值赋给一个变量,也可以用于计算,还可以作为参数传递。总之,可以想其他值一样使用。

printf()函数也有一个返回值,它返回打印字符的个数,不知道这个函数值的意义的话,当我们做C语言相关题目的时候总会写错。这里给读者一个很经典的测试案例,看一下读者能否得出正确结果

int main()
{
    int A=43;
    printf("%d\n",printf("%d",printf("%d",A)));
}

同一段代码,可能有些人得出的结果是不一样,但通过运行代码,通过系统获得的答案是4321,有可能和读者心目中的答案不一样,现在我们来剖析一下这段代码的结果,为什么是4321呢?

前面讲述了printf()函数的返回值是打印字符的个数,而且函数返回值也可以作为函数参数。结合下图应该就很容易理解了。

3.2scanf()函数

学完printf()函数,接下来学习scanf()函数,scanf()函数是C语言标准输入函数,通过键盘给程序中的变量赋值。使用scanf函数需要声明在头文件#include<stdio.h>下。scanf函数返回成功读取的项数。

scanf函数的原型:

int scanf(const char *format, ...);

scanf函数的使用格式:

1、scanf(“输入控制符”, 输入参数);

2、scanf(“输入控制符非输入控制符”, 输入参数);

3.2.1scanf(“输入控制符”, 输入参数)

功能:将从键盘输入的字符转化为“输入控制符”所规定格式的数据,然后存入以输入参数的值为地址的变量中

int main(void)
{
    int input;
    input = 10;
    printf("input = %d\n", input);

    //希望在程序里面可以修改input的值,就需要用到scanf函数
    scanf("%d", &input);
    printf("input = %d\n", input);

    return 0;
}

“输入控制符”和“输出控制符”是一模一样的。 比如一个整型数据,通过 printf 输出时用%d输出,通过 scanf 输入时同样是用%d。怎么理解scanf函数这一行的代码呢?

首先要理解的是从键盘输出的全部都是字符,比如从键盘输入123,它表示的并不是数字123,而是字符'1'、字符'2'和字符'3'。操作系统内核就是这样操作的,操作系统在接受键盘数据时都将它当成字符来接收的。这时就需要“输入控制符”将它转化一下。%d的含义就是要将从键盘输入的这些合法的字符转化成一个十进制数字。经过 %d 转化完之后,字符 123 就是数字 123 了。然后就要理解&这个符号是什么,&是一个取地址运算符,&后面加变量名表示“该变量的地址”,所以&input就表示变量input的地址。&input又称为“取地址input”,就相当于将数据存入以变量input的地址为地址的变量中。

ok,现在返回来看一下上面代码的scanf函数的用法,这句语句的意思就是:从键盘上输入字符 30,然后%d将这两个字符转化成十进制数 30,最后通过 “取地址 input” 找到变量 input 的地址,再将数字 30放到以变量 input 的地址为地址的变量中,即变量 input 中,所以最终的输出结果就是input=30

注意:
为什么不直接说“放到变量input中”?而是说“放到以变量input的地址为地址的变量中”?因为这么说虽然很绕口,但是能加强对 &input 的理解,这么说更能表达 &input的本质和内涵。很多人在学习 scanf 的时候,经常将“变量 i”和“变量 i 的地址”混淆,从而思维开始混乱,等深刻了解 &i 的含义之后就可以不那么说了。

3.2.2scanf(“输入控制符非输入控制符”, 输入参数);

这种用法现在应该是很少人使用或者基本就没人使用,但是总有头铁的人使用。经常有人问,为什么 printf 中可以有“非输出控制符”,而 scanf 中就不可以有“非输入控制符”。事实上不是不可以有,而是没有必要!

int main(void)
{
    int input;
    scanf("input = %d", &input);
    printf("input = %d\n", input);
    return 0;
}

在 printf 中,所有的“非输出控制符”都要原样输出。同样,在 scanf 中,所有的“非输入控制符”都要原样输入。所以在输入的时候,input= 必须要原样输入。比如要从键盘给变量 input赋值 30,那么必须要输入input=123才正确,少一个都不行,否则就是错误。

在使用scanf函数的时候,没有必要加\n,因为scanf中\n不起换行的作用,它不但什么作用都没有,我们还要原样将它输入一遍。

scanf函数允许一次给多个变量赋值。通过键盘给多个变量赋值与给一个变量赋值其实是一样的。比如给两个变量赋值就写两个 %d,然后“输入参数”中对应写上两个 “取地址变量” ;给三个变量赋值就写三个 %d,然后“输入参数”中对应写上三个 “取地址变量” 。

虽然 scanf 中没有加任何“非输入控制符”,但是从键盘输入数据时,给多个变量赋的值之间一定要用空格、回车或者 Tab 键隔开,用以区分给不同变量赋的值。而且空格、回车或 Tab 键的数量不限,只要有就行。一般都使用一个空格。

当用 scanf 从键盘给多个变量赋值时,scanf 中双引号内多个“输入控制符”之间千万不要加逗号。

最后重要的事情讲三次!scanf“输入参数”的取地址符&千万不要忘了!scanf“输入参数”的取地址符&千万不要忘了!scanf“输入参数”的取地址符&千万不要忘了!初学者很容易忘记这个符号,从而导致程序有问题,但是要记住printf函数的“输出参数”是不带取地址符的。

3.2.3注意事项

1、参数的个数一定要对应

和printf函数一样的,“输出控制符” 和 “输出参数” 无论在 “顺序上” 还是在 “个数上” 一定要一一对应。这句话同样对 scanf 有效,即 “输入控制符” 和 “输入参数” 无论在 “顺序上” 还是在 “个数上” 一定要一一对应。

2、输入的数据类型一定要与所需要的数据类型一致

在 scanf 中,对于从键盘输入的数据类型scanf中“输入控制符”的类型变量定义的类型,这三个类型一定要一致,否则就是错的。虽然编译的时候不会报错,但从程序功能的角度讲就是错的,则无法实现我们需要的功能。

写一个小案例测试一下:

 但是,很明显我们是输入了a的,为什么变量却显示未初始化呢?

在 scanf 中,从键盘输入的一切数据,不管是数字、字母,还是空格、回车、Tab 等字符,都会被当作数据存入缓冲区。
存储的顺序是先输入的排前面,后输入的依次往后排。按回车键的时候 scanf 开始进入缓冲区取数据,从前往后依次取。

但 scanf 中 %d 只识别“十进制整数”。对 %d 而言,空格、回车、Tab 键都是区分数据与数据的分隔符。当 scanf 进入缓冲区中取数据的时候,如果 %d 遇到空格、回车、Tab 键,那么它并不取用,而是跳过,继续往后取后面的数据,直到取到“十进制整数”为止。对于被跳过和取出的数据,系统会将它从缓冲区中释放掉。未被跳过或取出的数据,系统会将它一直放在缓冲区中,直到下一个 scanf 来获取。

但是如果 %d 遇到字母,那么它不会跳过也不会取用,而是直接从缓冲区跳出。所以上面这个程序,虽然 scanf 进入缓冲区了,但用户输入的是字母 a,所以它什么都没取到就出来了,而变量 input 没有值,即未初始化,所以输出就是 –858993460。

但如果将 %d 换成 %c,那么任何数据都会被当作一个字符,不管是数字还是空格、回车、Tab 键它都会取回。

3、使用 scanf 之前使用 printf 提示输入

程序写好之后,编译、链接、执行,然后弹出运行窗口,出现一个光标在那不停地闪。对于编写程序的人来说他知道要输入什么,但是对于用户而言,用户怎么知道是什么意思呢?所以之前的程序都缺少提示信息!因此在使用scanf之前,最好先用printf提示用户以什么样的方式输入,这样可以大大提高代码的质量。

3.3初识字符串

字符串是一个或多个字符的序列,比如:“hello world”。

双引号不是字符串的一部分。双引号仅告知编译器它括起来的是字符 串,正如单引号用于标识单个字符一样。

C语言没有专门用于储存字符串的变量类型,字符串都被储存在char类型的数组中。数组由连续的存储单元组成,字符串中的字符被储存在相邻的存储单元中,每个单元储存一个字符。

数组末尾位置的字符\0。这是空字符。C 语言用它标记字符串的结束。空字符不是数字0,它是非打印字符,其ASCII 码值是(或等价于)0。C中的字符串一定以空字符结束,这意味着数组的容量必须至少比待存储字符串中的字符数多1。字符串看上去比较复杂!必须先创建一个数组,把字符串中的字符逐个放入数组,还要记得在末尾加上一个\0。还好,计算机可以自己处理这些细 节。

那么,什么是数组?可以把数组看作是一行连续的多个存储单元。用更正式的说法是,数组是同类型数据元素的有序序列。后面会详细探讨。

如何利用printf函数和scanf函数输出输入字符串呢?输入控制符和输出控制符都是%s

%s告诉printf()打印一个字符串,%s告诉scanf()要接收一个字符串类型数据,我们不用把空字符放入字符串末尾,scanf()在读取输入时就已完成这项工作。注意,scanf函数在遇到第一个空白(空格、制表符或换行符)时就不再读取输入,如果我们要输入带有空格的字符串是不能使用scanf函数的,需要用到其他输出函数,后面再讲这些,scanf函数其实已经满足我们初学者大部分的需求

字符串与字符

字符串常量"x"和字符常量'x'不同。区别之一在于'x'是基本类型 (char),而"x"是派生类型(char数组);区别之二是"x"实际上由两个字符组成:'x'和空字符\0。通过下图更好的理解:

 在初次认识字符串的时候必不可少要认识的就是strlen这个字符串相关函数

strlen函数的原型:

size_t strlen ( const char * str );

strlen函数作用:计算字符串 str 的长度,直到空结束字符,但不包括空结束字符。

这个函数的作用好像和关键字sizeof的作用很相似,但实际上是有很大的区别的,下面来一个测试案例来直观的认识这一点:

 对于我们手动输入的字符串hello,两种方式得出的结果却不相同,这是为什么呢?sizeof就是一个计算数据类型所占空间大小的单目运算符,在计算字符串的空间大小时,包含了结束符\0的位置;而strlen是一个计算字符串长度的函数,使用时需要引用头文件#include <string.h>,不包含\0,即计算\0之前的字符串长度。

当我们使用printf函数输出一些字符串的时候,会发现一些“错误”情况,例如:

会发现我们预想的情况对比实际情况少了一个\和\t,这是为什么呢?

这是因为有转义字符的存在,转义字符顾名思义就是转变意思。转义字符以\或者\x开头,以\开头表示后跟八进制形式的编码值,以\x开头表示后跟十六进制形式的编码值。对于转义字符来说,只能使用八进制或者十六进制。

常用转义序列
转义字符意义示例
\a响铃printf("\a");电脑发出蜂鸣
\b退格,将当前位置移到前一列printf("123\b");输出12
\f换页,将当前位置移到下页开头
\n换行,将当前位置移到下一行开头printf("123\n");输出123换行
\r回车,将当前位置移到本行开头printf("123\r456");输出456
\t水平制表printf("\t123");输出8个空格123
\v垂直制表
\\反斜杠printf("\\");输出\
\'单引号printf("\'");输出‘
\"双引号printf("\”");输出“
\?问号printf("\?");输出?
\0oo八进制(oo必须是有效的八进制数,即每个o可表示0~7中的一个数)printf("\077");输出?(?的ASCll值为63)
\xhh十六进制(hh必须是有效的十六进制数,即每个h可表示0~f中的一个数)printf("\x3f");输出?

字符串就先认识到这里,后面再继续探讨字符串相关知识。

4、操作符

4.1操作符分类

算术操作符(+,-,*,/,%)

移位操作符(<<,>>)

位操作符(&,|,^)

赋值操作符(=,+=,-=,/=,*=,%=,<<=,>>=)

单目操作符(!,-,+,&,sizeof,~,--,++,*,(类型))

关系操作符(>,>=,<,<=,!=,==)

逻辑操作符(&&,||)

条件操作符(exp1?exp2:exp3)

逗号表达式(exp1,exp2,exp3,-expN)

下标引用、函数调用和结构成员([],(),->)

4.2操作符的属性

复杂表达式的求值有三个影响的因素:

  1. 操作符的优先级
  2. 操作符的结合性
  3. 是否控制求值顺序

两个相邻的操作符先执行哪一个?取决于它们的优先级。如果两者的优先级相同,取决于它们的结合性

操作符优先级
操作符描述用用法示例结果类型结合性是否控制求值顺序
()聚组(表达式)与表达式相同N/A
()函数调用rexp(rexp,...,rexp)rexpL-R
[]下标引用rexp[rexp]lexpL-R
.访问结构成员lexp.member_namelexpL-R
.>访问结构指针成员rexp->member_namelexpL-R
++后缀自增lexp ++rexpL-R
--后缀自减lexp --rexpL-R
逻辑反! rexprexpR-L
~按位取反~ rexprexpR-L
+单目,表示正值+ rexprexpR-L
-单目,表示负值- rexprexpR-L
++前缀自增++ lexprexpR-L
--前缀自减-- lexprexpR-L
*间接访问* rexplexpR-L
&取地址& lexprexpR-L
sizeof取其长度,以字节 表示sizeof rexp sizeof(类型)rexpR-L
(类型)类型转换(类型) rexprexpR-L
*乘法rexp * rexprexpL-R
/除法rexp / rexprexpL-R
%整数取余rexp % rexprexpL-R
+加法rexp + rexprexpL-R
-减法rexp - rexprexpL-R
<<左移位rexp << rexprexpL-R
>>右移位rexp >> rexprexpL-R
>大于rexp > rexprexpL-R
>=大于等于rexp >= rexprexpL-R
<小于rexp < rexprexpL-R
<=小于等于rexp <= rexprexpL-R
==等于rexp == rexprexpL-R
!=不等于rexp != rexprexpL-R
&位与rexp & rexprexpL-R
^位异或rexp ^ rexprexpL-R
|位或rexp | rexprexpL-R
&&逻辑与rexp && rexprexpL-R
||逻辑或rexp || rexprexpL-R
?:条件操作符rexp ? rexp : rexprexpN/A
=赋值lexp = rexprexpR-L
+=以...加lexp += rexprexpR-L
-=以...减lexp -= rexprexpR-L
*=以...乘lexp *= rexprexpR-L
/=以...除lexp /= rexprexpR-L
%=以...取模lexp %= rexprexpR-L
<<=以...左移lexp <<= rexprexpR-L
>>=以...右移lexp >>= rexprexpR-L
&=以...与lexp &= rexprexpR-L
^=以...异或lexp ^= rexprexpR-L
|=以...或lexp |= rexprexpR-L
,以...或rexp,rexprexpL-R

4.3算术操作符

算数操作符有+,-,*,/,%

加法运算符用于加法运算,使其两侧的值相加。相加的值(运算对象)可以是变量,也可以是常量

sum = num1 + num2

计算机会查看加法运算符右侧的两个变量,把它们相加,然后把和赋给变量sum

sum,num1,num2都是可修改的左值,每个变量都标识了一个可被赋值的数据对象。

但是表达式num1+num2是一个右值

减法运算符用于减法运算,使其左侧的数减去右侧的数,+和-运算符被称为二元运算符,这些运算符需要两个运算对象才能完成操作。

但+和-也能作为符号运算符,减号可用于标明或改变一个值的代数符号。

a = -100;

b = -a;

以这种形式使用的负号被称为一元运算符,一元运算符只需要一个运算对象,C90标准添加了一元+运算符,它不会改变运算对象的值或符号。

乘法运算符用*号来表示,使其两侧的值相乘。相乘的值(运算对象)可以是变量,也可以是常量

除法运算符用/号来表示,/左侧的值是被除数,右侧的值是除数。有着整数除法和浮点数除法,整数除法和浮点数除法不同。浮点数除法的结果是浮点数,而整数除法的结果是整数。整数是没有小数部分的数。在C语言中,整数除法结果的小数部分被丢弃,在一过程被称为截断。

当/操作符的两个操作符都为整数,执行整数除法;而只要有浮点数执行的就是浮点数除法。

求模运算符用%号来表示,求模运算符用于整数计算。求模运算符给出其左侧整数除以右侧整数的余数。求模运算符的两个操作数必须位整数,返回的是整除之后余数,不能用于浮点数。

4.4移位操作符

移位运算符有左移运算符(<<)和右移运算符(>>)。用二进制数做案例。

左移运算符(<<)将其左侧运算对象每一位的值向左移动其右侧运算对象指定的位数。左侧运算对象移出左末尾段位的值丢失,用0填充空出的位置。

(0000 0001)<< 2        //表达式

(0000 0100)                //结果值

 右移运算符(>>)将左侧运算对象每一位的值向右移动其右侧对象指定的位数。左侧运算对象移出右末端位的值。对于无符号类型,用0填充空出的位置(逻辑移位);对于有符号类型,其结果取决于机器,空出的位置可用0填充,或者用符号位(最左端的位)的副本填充(算术右移)。

移位运算符针对2的幂提供快速有效的乘法和除法:

number << n        number乘于2的n次幂

number >> n        如果number为非负,则用number除以2的n次幂

移位运算符还可用于从较大单元中提取一些位

4.5位操作符

位运算符有按位取反~,按位与&,按位或|,按位异或^

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

一元运算符~把1变为0,把0变为1。

~(1001 1010)  --- 》 (0110 0101)

二元运算符&通过逐位比较两个运算对象,生成一个新值。对于每个位,只有两个运算对象中响应的位都为1时,结果才为1(从真/假方面看,只有当两个位为都为真时,结果才为真)

(1001 0011) & (0011 1101) --- 》 (0001 0001 )

二元运算符|,通过逐位比较两个运算对象,生成一个新值。对于每个 位,如果两个运算对象中相应的位为1,结果就为1(从真/假方面看,如果两个运算对象中相应的一个位为真或两个位都为真,那么结果为真)。

(1001 0011) | (0011 1101) ---》 (1011 1111)

二元运算符^逐位比较两个运算对象。对于每个位,如果两个运算对象 中相应的位一个为1(但不是两个为1),结果为1(从真/假方面看,如果两 个运算对象中相应的一个位为真且不是两个为同为1,那么结果为真)。

(1001 0011) ^ (0011 1101) ---》 (1010 1110)

一道有趣的面试题:不能利用临时变量,如何实现两个数的交换,读者可以想想有哪几种方法?

4.6赋值操作符

赋值运算符可以给自己定义的变量重新赋值,例如:

int score = 60;

score = 99;

赋值运算符可以和前面的算术运算符,位运算符,移位运算符组成复合赋值符,这是C语言语法所允许的。

赋值运算符允许连续使用,例如:

a = b = c + 1;

4.7单目操作符

单目操作符包括!,~,+,-,&,sizeof,--,++,*,(类型)

这里就不介绍!,~,+,-,前面或后面已经有介绍了,这里主要介绍其他几个。

这里的&可不是按位与&,而是取地址符&,取地址符就是获取当前变量的内存地址,想要获得那个变量的地址,就用&后面跟上那个变量。&后只能跟变量,不能跟常量,因为常量是一个立即数,不是容器,没有地址。

前面就提到过sizeof一嘴,这里再对sizeof进行进一步了解,sizeof运算符以字节为单位返回运算对象的大小(在C中,1字节定义为char类型占用的空间大小。过去,1字节通常是8位,但是一些字符集可能使用更大的字节)。运算对象可 以是具体的数据对象(如,变量名)或类型。如果运算对象是类型(如, float),则必须用圆括号将其括起来。

C 语言规定,sizeof 返回 size_t 类型的值。这是一个无符号整数类型, 但它不是新类型。C有一个 typedef机制(后面再介绍),允许程序员为现有类型创建别名。

++和--,递增运算符和递减运算符,执行简单的任务,将其运算对象递增(减)1,该类运算符以两种形式出现。第1种方式,++出现在其作用的变量前面, 这是前缀模式;第2种方式,++出现在其作用的变量后面,这是后缀模式。 两种模式的区别在于递增行为发生的时间不同。下面用一些例子来仔细地查看两者地区别:

num1后缀递加是原来的值,num2前缀递加是原来的值+1,当我们再次打印num1的值的时候,发现num1的值实际上是加1了,这里就是前缀和后缀的区别了,前缀++先加1再赋值,后缀++先赋值再加1。初学者有可能在这里会陷入混乱。但是提供一个记忆方法:

 “前缀++处于前,所以理应先++后赋值;后缀++处于后,所以理应先赋值后++”

前缀加加和后缀加加除了上面的区别,还有着操作符优先级的优先关系,后缀比前缀的优先级要高,这是要牢记的!

间接访问操作符,通过一个指针访问它所指向的地址的过程称为间接访问或解引用指针。这个用于执行间接访问的操作符是单目操作符*。当我们定义了一个指针变量的时候,当我们使用*得到的值就是该指针变量指向地址的变量的值。

在C语言中,类型之间的转换是允许的。而(类型)就是其中一种,被称为强制类型转换

强制转换语法:目标类型 变量名 = (目标类型)源变量名

可以把高类型数据转换为低类型数据,虽然有可能存在隐患,但实际上是可以转换的,高数据类型转换为低数据类型会产生丢失

低数据类型到高数据类型排序如下:char -> short -> int -> long(unsigned char -> unsigned short -> unsigned int -> unsigned long

4.8关系操作符

关系操作符包括>,>=,<,<=,!=,==

就跟数学里的比较一样,但是要记住一点不能使用数学思维去写判断,下面用一个案例来说明原因:

 一些初学者可能会写出类似的判断语句,但是结果却不如意,因为当执行完第一个关系运算符之后会返回真(非0)或者假(0),此时进行第二个关系运算符运算的时候,结果很有可能和实际的结果不一样,在这种多个比较之间需要用到逻辑操作符(&&,||)

4.9逻辑操作符

逻辑操作符有逻辑与&&,逻辑或||和逻辑非!

3种逻辑运算符
逻辑运算符含义
&&
||

逻辑运算符两侧的条件必须都为真,整个表达式才为真。逻辑运算符的优先级比关系运算符低,所以不必在子表达式两侧加圆括号。

假设exp1和exp2是两个简单的关系表达式(如a > b或c == 1000),那么:

当且仅当exp1和exp2都为真时,exp1 && exp2才为真;

如果exp1或exp2为真,则exp1 || exp2为真;

如果exp1为假,则!exp1为真;如果exp1为真,则!exp1为假。

同时需要记得一点,逻辑运算符是执行短路求值的,何为短路求值

在逻辑与&&中,当第一个判断条件为假的时候直接结束,不对第二个判断条件进行判断;在逻辑或||中,当第一个判断条件为真的时候直接结束,不对第二个判断条件进行判断。

4.10条件操作符

C提供条件表达式(conditional expression)作为表达if else语句的一种便捷方式,该表达式使用?:条件运算符。该运算符分为两部分,需要 3 个运算对象。

条件运算符是C语言中唯一的三元运算符。

条件操作符的格式:expr1?expr2:expr3

如果 expr1 为真(非 0),那么整个条件表达式的值与 expr2 的值相同;如果expr1为假(0),那么整个条件表达式的值与 expr3的值相同。

但是条件操作符也是一个有趣的操作符,看下面这段代码:

int main(void)
{
	int a = 10, b = 20;
	int c = 0;

	c = (a > b ? a : b);

	printf("a=%d,b=%d,c=%d\n", a, b, c);    //运行之后发现c的值和a的值一样

	return 0;
}

运行之后发现c的值和a的值是一样的,条件操作符返回的是一个变量,可以赋值

4.11逗号操作符

表达式说明:表达式1,表达式2,表达式3,...... ,表达式n

逗号表达式的要领:
(1) 逗号表达式的运算过程为:从左往右逐个计算表达式。
(2) 逗号表达式作为一个整体,它的值为最后一个表达式(也即表达式n)的值。
(3) 逗号运算符的优先级别在所有运算符中最低。

4.12下标引用、函数调用和结构成员

[]下标引用操作符

下标引用操作符格式:操作数:一个数组名+一个索引值

下标引用操作符允许对数组单个元素直接赋值

()函数调用操作符

接收一个或者多个操作数:第一个操作数是函数名,剩余的操作数就是传递给函数的参数

访问一个结构的成员有两种方式:

. 结构体.成员名        ---》直接访问

-> 结构体指针->成员名     ---》间接访问

当我们通过访问结构体变量访问成员变量的时候,我们直接使用直接访问符(.),当我们通过访问结构体指针访问成员变量的时候,我们使用间接访问符(->)

通过一个案例来展示一下:

5、语句

C语言的语句可分为一下五类:

  1. 表达式语句
  2. 函数调用语句
  3. 控制语句
  4. 复合语句
  5. 空语句

5.1空语句

C最简单的语句就是空语句,它本身只包含一个分号。空语句本身并不执行任何任务,但有时还是有用。它所使用的场合就是语法要求出现一条完整的语句,但并不需要它执行任务。

5.2表达式语句

表达式(expression)由运算符和运算对象(运算符操作的对象)组成。简单的表达式是一个单独的运算对象,以此为基础可以建立复杂的表达式。运算对象可以是常量、变量或二者的组合。

C语言表达式的一个最重要的特性的,每个表达式都有一个值。要获得这个值,必须根据运算符优先级规定的顺序来执行操作。(后面对操作再做一个操作符的总结)

语句:语句是C程序的基本构建块。一条语句相当于一条完整的计算机指令。在C中,大部分语句都以分号结尾。

input = 4        //error

input = 4;       //语句

更确切地说,语句可以改变值或调用函数,虽然一条语句相当于一条完整地指令,但并不是所有地指令都是语句。

5.3复合语句

复合语句是用花括号括起来地一条或多条语句,复合语句也叫块。

5.4控制语句

控制语句用于控制程序的执行流程,以实现程序的各种结构方式,它们由特定的语句定义符组成,C语言有九种控制语句。 可分成以下三类:

1. 条件判断语句也叫分支语句:if语句、switch语句;

2. 循环执行语句:do while语句、while语句、for语句;

3. 转向语句:break语句、goto语句、continue语句、return语句。

这里先介绍一下break语句和continue语句

break语句和continue的作用:用来控制循环结构的,主要作用是停止循环

但break和continue也有着区别:

1、break用于跳出一个循环体或者完全结束一个循环,不仅不可结束其所在的循环,还可结束其外层循环

注意:(1)只能在循环体内和switch语句体内使用break

           (2)任何一种循环,在循环体中遇到break,系统将完全结束循环,开始执行循环之后的代码语句

           (3)当break出现循环体中的switch语句体内时,起作用只是跳出该switch语句体,并不能终止循环体的执行。若想强行终止循环体的执行,可以在循环体中,但并不在switch语句中设置break语句,满足某种条件则跳出本层循环体。

2、continue语句的作用是跳过本次循环体中剩下尚未执行的语句,立即进行下一次的循环条件判定,可以理解为只是中止(跳过)本次循环,接着开始下一次循环。

注意:(1)continue语句并没有使整个循环终止。
          (2)continue 只能在循环语句中使用,即只能在 for、while 和 do…while 语句中使用。

下面结合对应语境来一一分析

5.5C控制语句:循环

为什么会出现循环呢?有的时候,当我们访问多个变量的时候,只需要对每个变量进行逐一访问就行,但是当变量数目达到一个非常大的值的时候,访问变量是不是就成为一件非常繁琐的任务,这时候,C语言就设计了循环的语法,方便我们可以执行多个相同情况的工作。

循环语句一共有三个关键字:while,for,do while

5.5.1while

while语法结构:

while(表达式(expression))
 循环语句(statement);

while循环的测试条件执行比较,常用递增运算符执行递增。这里的循环语句可以是以分号结尾的简单语句,也可以是花括号括起来的复合语句。

 expression部分都使用关系表达式。也就是说,expression是值之间的比较,可以使用任何表达式。如果expression为真 (或者更一般地说,非零),执行statement部分一次,然后再次判断 expression。在expression为假(0)之前,循环的判断和执行一直重复进行。 每次循环都被称为一次迭代。while循环有一点非常重要:在构建while循环时,必须让测试表达式的 值有变化,表达式最终要为假。否则,循环就不会终止(可以用break和if终止)

要明确一点:只有在对测试条件求值时,才决定是终止还是继续循环。

while语句是使用入口条件的有条件循环。所谓“有条件”指的是语句部分的执行取决于测试表达式描述的条件。该表达式是一个入口条件,因为必须满足条件才能进入循环体。如果条件一开始就为假,就不会进入循环体。

很多初学者在初次使用while语句的时候可能不小心在后面添加了一个分号,在这种情况下,原本的循环语句只会执行一次,从而导致原有实现的功能无法实现,所以一定要注意while语句后面不能加分号。

while语句小结:

关键字:while

while语句创建了一个循环,重复执行直到测试表达式为假或0。while语 句是一种入口条件循环,也就是说,在执行多次循环之前已决定是否执行循 环。因此,循环有可能不被执行。循环体可以是简单语句,也可以是复合语句。

形式:

while ( expression )

         statement

在expression部分为假或0之前,重复执行statement部分。

while语句后面不能加分号,否则会导致循环体只执行一次

5.5.2while里的break和continue

if语句在下面将会介绍,这里也许有人看不懂,而且翻阅后面的内容再返回来分析这两段代码。

break在while循环中的作用: 在循环中只要遇到break,就停止后期的所有的循环,直接终止循环。 while中的break是用于永久终止循环的。

continue在while循环中的作用就是: continue是用于终止本次循环的,也就是本次循环中continue后边的代码不会再执行, 而是直接跳转到while语句的判断部分。进行下一次循环的入口判断。

5.5.3for

一些while循环是不确定循环。所谓不确定循环,指在测试表达式为假之前,预先不知道要执行多少次循环。在创建一个重复执行固定次数的循环中涉及了3个行为:

1.必须初始化计数器;

2.计数器与有限的值作比较;

3.每次循环时递增计数器。

for循环把上述3个行为(初始化、测试和更新)组合在一处。

for语法结构:

for(表达式1; 表达式2; 表达式3)
 循环语句;

关键字for后面的圆括号中有3个表达式,分别用两个分号隔开。第1个表达式为初始化部分,用于初始化循环变量的,只会在for循环开始时执行一次。第 2 个表达式为条件判断部分,是测试条件,在执行循环之前对表达式求值。如果表达式为假,循环结束。第3个表达式为调整部分,执行更新,在每次循环结束时求值。

 对比之前的while循环语句,可以发现while循环中依然存在循环的三个必须条件,但是由于风格的问题使得三个部分很可能偏离较远,例如:初始化部分放在while循环前面,判断条件放在while循环的开头,调整部分放在while循环的里面。当我们检查修i该while循环的条件的时候就会有点麻烦,所以,for循环这种三个条件放在一起的循环更容易让初学者接受和使用,for循环使用的频率是最高的。

for循环里面不能在循环体里面修改循环变量,这是为了防止for循环失去控制,而且,for循环提倡循环控制变量的取值采用“前闭后开区间”写法。

int i = 0;

//前闭后开的写法

for(i=0; i<10; i++) {}

//两边都是闭区间

for(i=0; i<=9; i++) {}

但是for循环也有这许多变种,当我们看到变种的for循环后不要惊讶,for循环缺失表达式的情况下也是成立的。可以省略一个或多个表达式(但是不能省略分号),只要循环中包含能结束循环的语句即可。直接用代码案例来展示:

int main()
{
    //情况1
    for (;;)
    {
        printf("hehe\n");
    }
    //for循环中的初始化部分,判断部分,调整部分是可以省略的,但是不建议初学时省略,容易导致问题。

    //情况2
    int i = 0;
    int j = 0;
    //这里打印多少个hehe?
    for (i = 0; i < 10; i++)
    {
        for (j = 0; j < 10; j++)
        {
            printf("hehe\n");
        }
    }

    //情况3
    //int i = 0;
    //int j = 0;
    //如果省略掉初始化部分,这里打印多少个hehe?
    for (; i < 10; i++)
    {
        for (; j < 10; j++)
        {
            printf("hehe\n");
        }
    }

    //情况4-使用多余一个变量控制循环
    int x, y;
    for (x = 0, y = 0; x < 2 && y < 5; ++x, y++)
    {
        printf("hehe\n");
    }
    return 0;
}

运行结果我就不截图了,读者可以copy一下代码到自己的编译软件上运行,这样更为直观的认识到for循环的变种。

注意:for循环和while循环一样,如果在for循环语句的后面加上分号,该循环体部分也只会执行一次。初学者也很容易把分号添加上去。

for语句小结:

关键字:for

for语句使用3个表达式控制循环过程,分别用分号隔开。initialize表达式在执行for语句之前只执行一次;然后对test表达式求值,如果表达式为真 (或非零),执行循环一次;接着对update表达式求值,并再次检查test表达 式。for语句是一种入口条件循环,即在执行循环之前就决定了是否执行循 环。因此,for循环可能一次都不执行。statement部分可以是一条简单语句或 复合语句。形式: for ( initialize; test; update ) statement 在test为假或0之前,重复执行statement部分。

for循环后面加分号会使循环体只执行一次

for循环是入口条件循环,即在循环的每次迭代之前检查 测试条件,所以有可能根本不执行循环体中的内容

5.5.4for里的break和continue

 我们发现在for循环中也可以出现break和continue,它们的意义和在while循环中是一样的。类似的代码出现的运行效果截图是不一样的,是因为for循环的更新部分和while的位置不一样,出现的效果就不一样。

5.5.5do while

C语言还有出口条件循环(exit-condition loop),即在循环的每次迭代之后检查测试条件,这保证 了至少执行循环体中的内容一次。这种循环被称为 do while循环。

do while语法结构:

do
 循环语句(statement);
while(表达式(expression));

statement可以是一条简单语句或复合语句。注意,do while循环以分号结尾。 do while循环在执行完循环体后才执行测试条件,所以至少执行循环体 一次;而for循环或while循环都是在执行循环体之前先执行测试条件。do while循环适用于那些至少要迭代一次的循环。

do while语句小结

关键字:do while

一般注解: do while 语句创建一个循环,在 expression 为假或 0 之前重复执行循环体中的内容。do while语句是一种出口条件循环,即在执行完循环体后才根 据测试条件决定是否再次执行循环。因此,该循环至少必须执行一次。

statement部分可是一条简单语句或复合语句。

形式: do

                statement

            while ( expression );

在test为假或0之前,重复执行statement部分。

do while语句后面一定要加分号,不然程序将会报错

5.5.6do while里的break和continue

 

 通过代码和运行效果图直接检验break语句和continue语句是最直观的。

5.6C控制语句:分支

分支结构(if和switch),让程序根据测试条件相应的行为。

5.6.1if语句

if语句的语法结构:

if(表达式)
    语句;

if(表达式)
    语句1;
else
    语句2;

//多分支    
if(表达式1)
    语句1;
else if(表达式2)
    语句2;
else
    语句3

如果表达式的结果为真,则语句执行。
在C语言中如何表示真假?
0表示假,非0表示真

 简单的if语句可以让程序选择执行一条语句,或者跳过这条语句。即使if语句由复合语句构成,整个if语句仍被视为一条语句。C还提供了if else形式,可以在两条语句之间作选择。如果要在if和else之间执行多条语句,必须用花括号把这些语句括起来成为一个块。

如果一个程序中有许多if和else,编译器如何知道哪个if对应哪个else?下面提供一个测试案例更直观的认识if else匹配的规则

int main(void)
{
	int number= 20;
	
	if (number > 6)
		if (number < 12)
			printf("number false\n");
	else
		printf("number true\n");

	return 0;
}

何时打印number true?当number小于或等于6时,还是number大于12时?换言之,else与第1个if还是第2个if匹配?答案是,else与第2个if匹配。当number为5的时候不响应;当number为10的时候相应number fasle;当number为20的时候响应number true。

规则是,如果没有花括号,else与离他最近的if匹配,除非最近的if被花括号括起来

 注意:要缩进“语句”,“语句”可以是一条简单语句或复合语句

注意:if语句后面不能有分号,如果不小心多加了分号在if语句后面,那无论判断是否为真都会执行if后面跟着的语句,初学者常常做出这种画蛇添足的行为,要克制住自己写分号的欲望。

if语句允许多层嵌套的写法,有时,选择一个特定选项后又引出其他选择,这种情况可以使用另一种嵌套 if。if else语句作为一条单独的语句,不必使用花括号。外层if也是一条单独的语句,也不必使用花括号。但是,当语句太长时,使用花括号能提高代码的可读性,而且还可防止今后在if循环中添加其他语句时 忘记加花括号。

if语句小结:

关键字:if、else

一般注解: 下面各形式中,statement可以是一条简单语句或复合语句。表达式为真 说明其值是非零值。

形式1: if (expression)

                        statement

如果expression为真,则执行statement部分。

形式2: if (expression)

                        statement1

             else

                        statement2

如果expression为真,执行statement1部分;否则,执行statement2部分。

形式3: if (expression1)

                        statement1

              else if (expression2)

                        statement2

              else

                        statement3

如果expression1为真,执行statement1部分;如果expression2为真,执行 statement2部分;否则,执行statement3部分。

5.6.2switch语句

switch语句用到的关键字有:switch、case和default

使用条件运算符和 if else 语句很容易编写二选一的程序。然而,有时程序需要在多个选项中进行选择。可以用if else if...else来完成。但是,大多数情况下使用switch语句更方便。

要对紧跟在关键字 switch 后圆括号中的表达式求值。

switch的语法结构:

switch ( 整型表达式)
{
    case 常量1:
        语句 <--可选
    case 常量2:
        语句 <--可选
    default : <--可选
        语句 <--可选
}

我们常见到的switch语句中的case后面都有一个break,这里的break又是有什么作用呢?

如果只希望处理某个带标签的语句,就必须在switch语句中使用break语句。break语句的实际效果是把语句列表划分为不同的分支部分。另外,C语言的case一般都指定一个值,不能使用一个范围。switch在圆括号中的测试表达式的值应该是一个整数值(包括char类型)。case标签必须是整数类型(包括char类型)的常量或整型常量表达式 (表达式中只包含整型常量)。不能用变量作为case标签。

假设case后面没有break会发生什么情况呢?会发生穿刺现象。用代码案例来展示是最好的:

如果我们进行判断的值与我们写的所有的case标签的值都不匹配怎么办?程序不会报错,也不会异常终止,因为在C语言中这种情况不是错误。但是很多程序功能都需要对这种情况做出不忽略的处理,C语言就提供default这个关键字来处理这种情况。

default 子句可以放在任何一个case标签可以出现的位置,当 switch 表达式的值并不匹配所有 case 标签的值时,这个 default 子句后面的语句就会执行。每个switch语句中只能出现一条default子句。

switch语句小结:

关键字:switch,case,defalut

程序根据expression的值跳转至相应的case标签处。然后,执行剩下的所有语句,除非执行到break语句进行重定向。expression和case标签都必须是 整数值(包括char类型),标签必须是常量或完全由常量组成的表达式。如 果没有case标签与expression的值匹配,控制则转至标有default的语句(如果有的话);否则,将转至执行紧跟在switch语句后面的语句。 形式:

switch ( expression )

{

        case label1 :

                 statement1//使用break跳出switch

        case label2 :

                statement2

        default :

                statement3

}

可以有多个标签语句,default语句可选。

当每个ceas标签后不加break,会发生穿刺现象

default子语句可以放在任意一个case标签可以出现的位置,当switch语句中有default子语句时,当出现与所有case标签不匹配的情况,将执行default子语句后面的语句

5.6C控制语句:goto

C语言提供了可以滥用的goto语句和标记跳转的标号。从理论上goto语句是没有必要的,实践中没有goto语句也可以写出我们需要的程序代码。但是在终止程序在多层嵌套的结构处理中,goto语句的作用就体现出来了,能一次跳出两层或多层循环。break语句只能从最内层循环退出到上一层的循环。

goto语句有两个部分:goto和标签名。标签的命名遵循变量命名规则

goto  标签(label);

标签(label): 执行语句(statement)

goto语句真正适合的场景如下:

for(...)
    for(...)
    {
        for(...)
        {
            if(disaster)
                goto error;
        }
    }
    …
error:
 if(disaster)
         // 处理错误情况

goto语句小结:

goto语句使程序控制跳转至相应标签语句。冒号用于分隔标签和标签语 句。标签名遵循变量命名规则。标签语句可以出现在goto的前面或后面。

形式:

goto label ;

label : statement

5.7函数调用语句

C语言函数调用的形式:函数名(实参列表);

把函数调用单独作为一个语句

函数参数:函数调用作为另一个函数调用时的实参。

调用函数并不一定要求包括分号。只有作为函数调用语句才需要有分号。如果作为函数表达式或函数参数,函数调用本身是不必有分号的。

下面开始正式介绍函数这一大知识点。 

6、函数

6.1什么是函数

首先,什么是函数?函数(function)是完成特定任务的独立程序代码。单元语法规则定义了函数的结构和使用方式。虽然C中的函数和其他语言中的函数、子程序、过程作用相同,但是细节上略有不同。

为什么使用函数?

首先,使用函数可以省去编写重复代码的苦差,当程序需要多次实现同种功能的时候,只需要编写一个合适的函数,就可以省去很多的时间和代码篇幅;其次,即使程序只完成某项任务一次,也值得使用函数。因为函数让程序更加模块化,从而提高了程序代码的可读性,更方便后期修改、完善。

6.2函数的分类

C语言中函数分为库函数和自定义函数。

6.2.1库函数

为什么有库函数?

1、在学习C语言编程的时候,总是在一个代码编写完成之后迫不及待的想知道结果,想 把这个结果打印到我们的屏幕上看看。这个时候我们会频繁的使用一个功能:将信息按照一定的格 式打印到屏幕上(printf)。

2、在编程的过程中我们会频繁的做一些字符串的拷贝工作(strcpy)。

3、在编程时我们也计算,总是会计算n的k次方这样的运算(pow)。

像上面我们描述的基础功能,它们不是业务性的代码。我们在开发的过程中每个程序员都可能用的到, 为了支持可移植性和提高程序的效率,所以C语言的基础库中提供了一系列类似的库函数,方便程序员进行软件开发。

学习库函数我们就非常需要一些辅助网站或者工具,例如:

  1. MSDN(Microsoft Developer Network)
  2. www.cplusplus.com
  3. http://en.cppreference.com(英文版)
  4. http://zh.cppreference.com(中文版)

这里附上第一个工具的下载链接:链接:https://pan.baidu.com/s/1HMd4INww7KHBvcSjMSZ6Og?pwd=1234 
提取码:1234

C语言常用的库函数有:IO函数,字符串操作函数,字符操作函数,内存操作函数,时间/日期函数,数学函数,其他库函数。使用库函数一定要包含#include对应的头文件,一些不常用的函数我们不知道其对应的头文件,这时候就需要通过工具来查看其对应的头文件。

6.2.2自定义函数

有的时候程序员需要实现的功能是非常复杂的,库函数也没有提供实现相应功能的函数,这个时候就需要就需要我们程序员来进行编写自定义函数,

自定义函数和库函数一样,有函数名,返回值类型和函数参数,但不一样的是自定义函数中,以上这些都是我们程序员设计,这就使得自定义函数实现的功能比库函数实现功能要复杂且精巧。

函数的组成语法:

ret_type fun_name(para1, * )
{
     statement;//语句项
}

名词解释:

ret_tyoe:返回类型

fun_name:函数名

paral:函数参数

这里先编写一下几个简单的函数来深刻认识一下自定义函数,例如找最大值函数和交换变量函数:

void swap(int x, int y)
{
	printf("x = %d,y = %d\n", x, y);
 
	int temp = 0;
	temp = x;
	x = y;
	y = temp;
 
	printf("x = %d,y = %d\n", x, y);
}
 
int main(void)
{
	int x = 10, y = 20;
	int m_max = max(x, y);
	printf("x和y里的最大值为:%d\n", m_max);
 
	swap(x, y);
 
	return 0;
}

 

6.3函数的参数

6.3.1实际参数(实参)

真实传给函数的参数叫实参实参可以是:常量、变量、表达式、函数等。

无论实参是何种类型的量,在进行该函数调用时,它们都必须有确定的值,以便把这些值传送给形参。

6.3.2形式参数(形参)

形式参数是指函数名后括号中的变量因为形式参数只有在函数被调用的过程中才实例化(分配内 存单元),所以叫形式参数。形式参数当函数调用完成之后就自动销毁了。因此形式参数只在函数中有效。

形参实例化之后其实相当于实参的一份临时拷贝

通过代码我们将更为深刻地认识实参和形参这两个概念:

6.4函数的调用

6.4.1传值调用

函数的形参和实参分别占有不同内存块,对形参的修改不会影响实参。

6.4.2传址调用

传址调用是把函数外部创建变量的内存地址传递给函数参数的一种调用函数的方式。

这种传参方式可以让函数和函数外边的变量建立起真正的联系,也就是函数内部可以直接操作函数外部的变量。

当我们不需要修改实参的时候,应该使用传值调用,防止在函数内修改我们不需要修改的实参;当我们需要修改实参的时候,应该使用传址调用,函数内部也能修改函数外部的变量。

6.5函数的嵌套调用和链式访问 

6.5.1嵌套调用

函数和函数之间可以根据实际的需求进行组合的,也就是互相调用的。

怎么理解这句话,在大型程序设计的时候,一个功能里面经常需要使用其他功能函数,C语言是允许自定义函数互相调用的。用一个简单的例子来检验一下C语言是否支持:

 函数可以嵌套调用,但是不能嵌套定义。

6.5.2链式访问

把一个函数的返回值作为另外一个函数的参数。

scanf和printf函数就可以进行链式访问。如果不嫌麻烦可以去翻阅前面的笔记或查阅这篇博客:C语言入门篇——输入输出篇_sakura0908的博客-CSDN博客

通过printf和scanf这两个函数应该能很好的理解链式访问这一知识点。 

6.6函数的声明和定义

6.6.1 函数声明

函数声明语法:

ret_type fun_name(para1, * );

1. 告诉编译器有一个函数叫什么,参数是什么,返回类型是什么。但是具体是不是存在,函数 声明决定不了。

2. 函数的声明一般出现在函数的使用之前。要满足先声明后使用。

3. 函数的声明一般要放在头文件中的。

声明函数时必须声明函数的类型,带返回值的函数类型应该与其返回值类型相同,而没有返回值的函数应该声明为void。如果没有声明函数的类型,旧版本的C编译器会假定函数的类型是int。这一管理源于C的早期,那时的函数绝大多数都是int类型,然而C99标准不再支持int类型函数的这种假定设置。

6.6.2 函数定义

函数定义语法:

ret_type fun_name(para1, * )
{
     //函数功能实现
     statement;//语句项
}

函数的定义是指函数的具体实现,交待函数的功能实现。

函数声明和函数定义常用于多文件编写的程序中,函数声明放在自定义的的头文件.h中,而函数定义放在一个对应的.c文件中,在主函数的.c文件中需要包含自定义的头文件之后才能使用自定义的函数。

6.7函数递归

程序调用自身的编程技巧称为递归( recursion)。 递归做为一种算法在程序设计语言中广泛应用。 一个过程或函数在其定义或说明中有直接或间接 调用自身的 一种方法,它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解, 递归策略只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。 递归的主要思考方式在于:把大事化小

递归的两个必要条件

  1. 存在限制条件,当满足这个限制条件的时候,递归便不再继续
  2. 每次递归调用之后越来越接近这个限制条件

在调试 factorial 函数的时候,如果你的参数比较大,那就会报错: stack overflow(栈溢出) 这样的信息。 系统分配给程序的栈空间是有限的,但是如果出现了死循环,或者(死递归),这样有可能导致一 直开辟栈空间,最终产生栈空间耗尽的情况,这样的现象我们称为栈溢出。

学习函数递归,就不得不说函数求阶乘这一经典案例,数学中,阶乘的定义是:一个正整数的阶乘factorial)是所有小于及等于该数的正整数,并且0的阶乘为1。自然数n的阶乘写作n!。

那如何解决上述的问题:

1. 将递归改写成非递归。

2. 使用static对象替代 nonstatic 局部对象。在递归函数设计中,可以使用 static 对象替代 nonstatic 局部对象(即栈对象),这不仅可以减少每次递归调用和返回时产生和释放 nonstatic 对象的开销,而且 static 对象还可以保存递归调用的中间状态,并且可为各个调用层所访问

7、数组

7.1数组概念

数组是由数据类型相同的一些列类型元素组成,需要使用数组时,通过声明数组告诉编译器数组中内含多少元素和这些元素的类型。编译器根据这些信息正确地创建数组。普通变量可以使用的类型,数组元素都可以用。要访问数组中的元素,通过使用数组下标数(也称为索引)表示数组中的各元素。数组元素的编号从0开始。

7.2一维数组的创建和初始化

7.2.1数组的创建方式:

type_t arr_name [const_n];
数组类型 数组名字[数组大小];
数组类型 数组名字[数组大小] = { 初始化值1,初始化值2,...,初始化值N};

名词解释:

type_t:数组的元素类型

arr_name:数组名字

const_n:常量表达式,用来指定数组的大小

数组创建,在C99标准之前,[]中要给一个常量才可以,不能使用变量。在C99标准支持了变长数组的概念(后面再介绍)

7.2.2数组的初始化

数组常用来存储程序需要的数据,在这种情况下,在程序一开始就初始化数组比较好。

数组化的初始化是指:在创建数组的同时给数组的内容一些合理初始值(初始化)。

int arr1[10] = { 1,2,3,4,5,6,7,8,9,10 };

int arr2[] = { 1,2,3,4,5 };

int arr3[10] = { 1,2,3,4,5};

char arr4[10] = { 'h','e','l','l','o' };

char arr5[] = { 'h','i' };

char arr6[] = "helloworld" ;

上面这一些初始化都是成立的,那数组对应的大小是多少?通过代码案例来直接查看:

int main(void)
{
	int arr1[10] = { 1,2,3,4,5,6,7,8,9,10 };
	int arr2[] = { 1,2,3,4,5 };
	int arr3[10] = { 1,2,3,4,5 };
	char arr4[10] = { 'h','e','l','l','o' };	
	char arr5[] = { 'h','i' };
	char arr6[] = "helloworld";

	printf("arr1的长度为:%d\n", sizeof(arr1) / sizeof(int));
	printf("arr2的长度为:%d\n", sizeof(arr2) / sizeof(int));
	printf("arr3的长度为:%d\n", sizeof(arr3) / sizeof(int));
	printf("arr4的长度为:%d\n", sizeof(arr4) / sizeof(char));
	printf("arr5的长度为:%d\n", sizeof(arr5) / sizeof(char));
	printf("arr6的长度为:%d\n", sizeof(arr6) / sizeof(char));

	return 0;
}

总结:

  1. 当创建数组的时候有初始化数组的大小,整个数组的长度就为数组下标引用符里面的值
  2. 当创建数组的时候没有初始化数组的大小,但有给数组一些合理初始值,初始值的个数就是数组的大小
  3. 当创建数组的时候有初始化数组的大小,但给数组的合理初始值个数少于数组大小时,系统给数组其他没初始化元素补0

7.2.3一维数组的使用

在操作符篇中((1条消息) C语言入门篇——操作符篇_sakura0908的博客-CSDN博客)详细介绍了下标引用操作符,它是数组访问的操作符,这里不多加介绍。

需要记住的是,数组的首元素的下标是从0开始而不是数学意义上的第一个(1)。

通过sizeof(数组名)/sizeof(数组元素类型)可以计算数组的元素个数。

int main(void)
{
    int arr[10] = { 0 };//数组的不完全初始化
    //计算数组的元素个数
    int sz = sizeof(arr) / sizeof(arr[0]);

    //对数组内容赋值,数组是使用下标来访问的,下标从0开始。所以:
    for (int i = 0; i < 10; i++)
    {
        arr[i] = i;
    }
    //输出数组的内容
    for (int i = 0; i < 10; i++)
    {
        printf("%d ", arr[i]);
    }
    return 0;
}

7.2.4一维数组在内存中的存储

那创建的数组在内存中的存储会是怎么样呢?是连续的一块内存空间存放,还是零零散散的内存空间存放,通过取地址符&访问数组每一个元素的地址就知道答案了:

int main()
{
    int arr[10] = { 0 };
    int sz = sizeof(arr) / sizeof(arr[0]);

    for (int i = 0; i < sz; ++i)
    {
        printf("&arr[%d] = %p\n", i, &arr[i]);
    }
    return 0;
}

通过运行效果可以看到每个数组元素的地址是连续的,随着数组下标的增长,数组元素的地址,也是有规律的递增,由此可以得知结论:数组在内存中是连续存放的

7.3二维数组的创建和初始化

怎么理解二维数组,一般有两种理解,第一种把它看成是行和列的元素组合,第二种把它看成是数组元素也是一个一维数组。两种理解方式都是可以的,看自己更能接受哪一种。通过下面图解更好地理解它:

7.3.1二维数组的创建

二维数组和一维数组的创建时类似的:

int arr1[5][5];

char arr2[5][5];

double arr3[5][5];

7.3.2二维数组的初始化

二维数组的初始化和一维数组的初始化有点类似,但也有着不同。

int arr[5][5] = {1,2,3,4};

int arr[5][5] = {{1,2},{4,5}};

int arr[][5] = {{2,3},{4,5}};

记住:二维数组如果有初始化,行可以省略,列不能省略

7.3.3二维数组的使用

二维数组也是数组,所以也是使用下标引用操作符来使用二维数组。通过for嵌套循环给二维数组赋值和打印。

int main()
{
	int arr[3][4] = { 0 };
	for (int i = 0; i < 3; i++)
	{
		for (int j = 0; j < 4; j++)
		{
			arr[i][j] = i * 4 + j;
		}
	}
	for (int i = 0; i < 3; i++)
	{
		for (int j = 0; j < 4; j++)
		{
			printf("%d ", arr[i][j]);
		}
		printf("\n");
	}
	return 0;
}

7.3.4二维数组在内存中的存储

一维数组在内存是连续存放的,那二维数组呢?

int main()
{
	int arr[3][4] = { 0 };
	int i = 0;
	for (i = 0; i < 3; i++)
	{
		int j = 0;
		for (j = 0; j < 4; j++)
		{
			printf("&arr[%d][%d] = %p\n", i, j, &arr[i][j]);
		}
	}
	return 0;
}

可以看到二维数组在内存中确实也是连续存储的,如下图图解:

7.4数组越界

所谓的数组越界,简单地讲就是指数组下标变量的取值超过了初始定义时的大小,导致对数组元素的访问出现在数组的范围之外,这类错误也是 C 语言程序中最常见的错误之一。

在 C 语言中,数组必须是静态的。换而言之,数组的大小必须在程序运行前就确定下来。由于 C 语言并不具有类似 Java 等语言中现有的静态分析工具的功能,可以对程序中数组下标取值范围进行严格检查,一旦发现数组上溢或下溢,都会因抛出异常而终止程序。也就是说,C 语言并不检验数组边界,数组的两端都有可能越界,从而使其他变量的数据甚至程序代码被破坏。

一般情况下,数组的越界错误主要包括两种:数组下标取值越界与指向数组的指针的指向范围越界。

7.4.1数组下标取值越界

数组的下标是有范围限制的。 数组的下规定是从0开始的,如果数组有n个元素,最后一个元素的下标就是n-1。 所以数组的下标如果小于0,或者大于n-1,就是数组越界访问了,超出了数组合法空间的访问。 C语言本身是不做数组下标的越界检查,编译器也不一定报错,但是编译器不报错,并不意味着程序就是正确的, 所以程序员写代码时,最好自己做越界的检查。

这里的-858993460是什么东西?

十进制数字-858993460转换为二进制数字之后为:1100 1100 1100 1100 1100 1100 1100 1100,二进制转换为十六进制数字之后为:0xCCCCCCCC。

给函数分配栈空间的时候,其中的数据有可能是别的函数使用过的,或者是垃圾数据,所以在使用这段栈空间之前,会先将这段栈空间全部用CC填充,0xCC在X86指令集中对应的汇编是int3。

int(interrupt)是中断,3是中断码,根据中断码进行相应操作,int3起到一个断点的作用,IDE的断点调试就是使用了int3指令,从而不会往下继续执行。

假设 jmp、jz 等跳转语句后面跟的地址值给错了,指向了函数的栈空间,当跳转到函数的栈空间时,如果之前的数据是一些敏感操作,这样就会很危险,但是填充了CC后,程序就会停下来,不会再执行。

这样程序出现内存越界时,调试器可以捕捉这个异常,而在Release下默认内存清零。

而且汉字“烫”的编码也是CC,所以有时候还可能看到很多“烫烫烫烫烫烫烫”。。。

7.4.2指向数组的指针的指向范围越界

指向数组的指针的指向范围越界是指定义数组时会返回一个指向第一个变量的头指针,对这个指针进行加减运算可以向前或向后移动这个指针,进而访问数组中所有的变量。但在移动指针时,如果不注意移动的次数和位置,会使指针指向数组以外的位置,导致数组发生越界错误。下面的示例代码就是移动指针时没有考虑到移动的次数和数组的范围,从而使程序访问了数组以外的存储单元.

int main(void)
{
    int i;
    int* p;
    int a[5];

    //数组a的头指针赋值给指针p
    p = a;
    for (i = 0; i < 10; i++)
    {
        //指针p指向的变量
        *p = i + 10;
        //指针p下一个变量
        p++;
    }
}

 for 循环会使指针 p 向后移动 10 次,并且每次向指针指向的单元赋值。但是,这里数组 a 的下标取值范围是 [0,4](即 a[0]、a[1]、a[2]、a[3] 与 a[4])。因此,后 5 次的操作会对未知的内存区域赋值,而这种向内存未知区域赋值的操作会使系统发生错误。

7.5数组作为函数参数

在实现具体的功能函数的时候,常常会将数组作为参数传入函数,但是初学者可能会出错,例如,当我们编写冒泡排序算法时,就需要传入数组然后进行排序,有的人也许就会写出下面的代码:

void bubble_sort(int arr[])
{
    int sz = sizeof(arr) / sizeof(arr[0]);
    for (int i = 0; i < sz - 1; i++)
    {
        int j = 0;
        for (j = 0; j < sz - i - 1; j++)
        {
            if (arr[j] > arr[j + 1])
            {
                int tmp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = tmp;
            }
        }
    }
}
int main()
{
    int arr[] = { 3,1,7,5,8,9,0,2,4,6 };
    bubble_sort(arr);
    for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); i++)
    {
        printf("%d ", arr[i]);
    }
    return 0;
}

​运行发现当我们进行了冒泡排序之后的数组元素排序和原数组元素排序是一样的,这是为什么呢?

在我们VS2022的编译软件里面,系统对下面这行代码进行了警告:

    int sz = sizeof(arr) / sizeof(arr[0]);

当我们对求出来的sz值进行打印的时候,发现的sz的值为1????这就是一个非常奇怪的地方了,之前介绍过,sizeof(数组名字)/sizeof(数组元素类型)的求值应该是数组元素的个数,那为什么sz的值为1呢?这里就需要我们了解数组名这个概念了。

7.5.1数组名

一般情况下,C语言中数组名在表达式中被解读为指向数组首元素的指针

C语言中数组名在表达式中被解读为指向数组首元素的指针, 即数组名在表达式中值为数组首元素的地址。但是也有着几种特殊情况:

  1. sizeof(数组名),计算整个数组的大小,sizeof内部单独放一个数组名,数组名表示整个数 组。
  2. &数组名,取出的是数组的地址。&数组名,数组名表示整个数组。

除此这两种情况之外,所有的数组名都表示数组首元素的地址。

最后附上数组冒泡排序的设计代码:

void bubble_sort(int arr[],int sz)
{
    for (int i = 0; i < sz - 1; i++)
    {
        for (int j = 0; j < sz - i - 1; j++)
        {
            if (arr[j] > arr[j + 1])
            {
                int tmp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = tmp;
            }
        }
    }
}
int main()
{
    int arr[] = { 3,1,7,5,8,9,0,2,4,6 };
    int sz = sizeof(arr) / sizeof(arr[0]);
    bubble_sort(arr,sz);
    for (int i = 0; i < sz; i++)
    {
        printf("%d ", arr[i]);
    }
    return 0;
}

8、指针

8.1初识指针

8.1.1内存地址

内存是电脑上特别重要的存储器,计算机中程序的运行都是在内存中进行的。所以为了有效使用内存,就把内存划分一个个小的内存单元,每个内存单元的大小是一个字节

为了能够有效的访问到内存的每个单元,就给内存单元进行了编号,这些编号被称为该内存单元的地址

  • 字节:字节是内存的容量单位,英文称为 byte,一个字节有8位,即 1byte = 8bits
  • 地址:系统为了便于区分每一个字节而对它们逐一进行的编号,称为内存地址,简称地址。

 8.1.2基地址

  • 单字节数据:对于单字节数据而言,其地址就是其字节编号。
  • 多字节数据:对于多字节数据而言,期地址是其所有字节中编号最小的那个,称为基地址。

变量是创建内存中的(在内存中分配空间的),每个内存单元都有地址,所以变量也是有地址的。这个时候就需要用到取地址符&,在我的操作符篇就有详细介绍过:

C语言入门篇——操作符篇_sakura0908的博客-CSDN博客

 

8.1.3指针变量

那地址可以作为一种数据来存储吗?C语言告诉我们是可以的,定义一个指针变量来存储地址。

在日常使用中,指针在不同的场合会代表下面几种含义:

  1. 指地址
    • 比如变量a的地址 &a,这是一个地址当然也是一个指针
    • 我们可以说指针 &a 指向变量 a
  2. 指指针变量
    • 比如 int *p; 此处变量p是指针变量,又常被简称指针

总结:指针就是地址,口语中说的指针通常指的是指针变量

指针变量:通过取地址操作符(&)取出变量的内存地址,把地址存放到一个变量中,这个变量就是指针变量。指针变量就是用来存放地址的变量(存放在指针的中的值都被当成地址处理)。

当我们用sizeof计算指针变量的时候,发现在32位系统和64位系统下的大小是不一样的:

这是为什么呢?

对于32位的机器,假设有32根地址线,那么假设每根地址线在寻址的时候产生高电平(高电压)和低电 平(低电压)就是(1或者0); 那么32根地址线产生的地址就会是:

00000000 00000000 00000000 00000000

00000000 00000000 00000000 00000001

...

11111111 11111111 11111111 11111111

这里就有2的32次方个地址。每个地址标识一个字节,那我们就可以给 (2^32Byte == 2^32/1024KB == 2^32/1024/1024MB==2^32/1024/1024/1024GB == 4GB) 4G的空闲进行编址。

总结:

  1. 在32位的机器上,地址是32个0或者1组成二进制序列,那地址就得用4个字节的空间来存储,所以一个指针变量的大小就应该是4个字节。
  2. 那如果在64位机器上,如果有64个地址线,那一个指针变量的大小是8个字节,才能存放一个地址。

指针的大小在32位平台是4个字节,在64位平台是8个字节。

8.2指针类型

在数据篇中介绍了很多C语言中的变量数据类型,指针也是有着不同的类型,

C语言入门篇——数据篇_sakura0908的博客-CSDN博客

指针的定义就是:type + *.

char  *pc = NULL;

int   *pi = NULL;

short *ps = NULL;

long  *pl = NULL;

float *pf = NULL;

double *pd = NULL;

8.2.1指针+-整数

当我们使用指针变量+-整数的时候,程序会给我们返回什么?

总结:指针的类型决定了指针向前或者向后走一步有多大(距离)

8.2.2指针的解引用

使用间接运算符有时候被称为解引用(dereferencing)一个指针。指针指向的内存位置被认为存储有一个对象,指针的类型决定了该对象的类型。不要混淆指针声明中的星号(*)和间接运算符。与乘法运算符 * 不同,间接运算符 * 是一元运算符,也就是说,间接运算符只有一个操作数。

解引用 "*"的作用是引用指针指向的变量值,引用其实就是引用该变量的地址,"解"就是把该地址对应的东西解开,解出来,就像打开一个包裹一样,那就是该变量的值了,所以称为"解引用"。

8.3特殊指针

8.3.1野指针

概念:指向一块未知区域的指针,被称为野指针。野指针是危险的。

野指针产生的原因:

  1. 指针定义时未被初始化:指针在被定义的时候,如果程序不对其进行初始化的话,它会随机指向一个区域,因为任意指针变量(出了static修饰的指针)它的默认值都是随机的
  2. 指针被释放时没有置空:我们在用malloc()开辟空间的时候,要检查返回值是否为空,如果为空,则开辟失败;如果不为空,则指针指向的是开辟的内存空间的首地址。指针指向的内存空间在用free()和delete释放后,如果程序员没有对其进行置空或者其他赋值操作的话,就会成为一个野指针
  3. 指针操作超越变量作用域:不要返回指向栈内存的指针或者引用,因为栈内存在函数结束的时候会被释放。

野指针危害:

  1. 引用野指针,相当于访问了非法的内存,常常会导致段错误(segmentation fault)
  2. 引用野指针,可能会破坏系统的关键数据,导致系统崩溃等严重后果

野指针的规避:

  1. 指针初始化
  2. 小心指针越界
  3. 指针指向空间释放即使置NULL
  4. 避免返回局部变量的地址
  5. 指针使用之前检查有效性

8.3.2空指针

很多情况下,我们不可避免地会遇到野指针,比如刚定义的指针无法立即为其分配一块恰当的内存,又或者指针所指向的内存被释放了等等。一般的做法就是将这些危险的野指针指向一块确定的内存,比如零地址内存。

概念:空指针即保存了零地址的指针,亦即指向零地址的指针

8.4指针运算

指针运算有:指针+-整数指针-指针指针的关系运算三种情况。

8.4.1指针-指针

指针与指针的相减操作,表示两个指针指向的内存位置之间相隔多少个元素(注意是元素,并不是字节数)。例如对于int类型的指针p和p1,p1-p的意义表示他们之间相隔多少个int类型的元素。

同样对于其他类型的指针变量之间相减的意义也是一样。

8.4.2指针的关系运算

指针变量之间的关系运算,指的是指向相同类型数据的指针之间进行的关系运算,不同类型的指针之间,或者指针与非0整数之间的比较是没有意义的。

标准规定: 允许指向数组元素的指针与指向数组最后一个元素后面的那个内存位置的指针比较,但是不允许与指向第一个元素之前的那个内存位置的指针进行比较。

8.5指针和数组

用指针引用数组元素:

引用数组元素可以用“下标法”,除了这种方法之外还可以用指针,即通过指向某个数组元素的指针变量来引用数组元素。数组包含若干个元素,元素就是变量,变量都有地址。所以每一个数组元素在内存中都占有存储单元,都有相应的地址。指针变量既然可以指向变量,当然也就可以指向数组元素。同样,数组的类型和指针变量的基类型一定要相同。

在数组篇中介绍过(C语言入门篇——数组篇_sakura0908的博客-CSDN博客),除了特殊情况

  1. sizeof(数组名),计算整个数组的大小,sizeof内部单独放一个数组名,数组名表示整个数组。
  2. &数组名,取出的是数组的地址。&数组名,数组名表示整个数组。

所有的数组名都表示数组首元素的地址。在代码测试案例中:

int main()
{
    int arr[] = {1,2,3,4,5,6,7,8,9,0};
    int *p = arr; //指针存放数组首元素的地址
    int sz = sizeof(arr)/sizeof(arr[0]);

    for(int i=0; i<sz; i++)
   {
        printf("&arr[%d] = %p   <====> p+%d = %p\n", i, &arr[i], i, p+i);
   }
    return 0;
}

这说明:可以直接通过指针来访问数组,使用解引用符就可以通过指针来访问数组的内容

8.6二级指针

指针变量也是变量,是变量就有地址,那地址变量的地址貌似也可以通过指针变量来存放。那这个指针变量被称之为二级指针(指向指针的指针)。C语言不限制指针的级数,每增加一级指针,在定义指针变量时就得增加一个星号*。实际开发中会经常使用一级指针和二级指针,几乎用不到高级指针。

  • 如果一个指针变量 p1 存储的地址,是另一个普通变量 a 的地址,那么称 p1 为一级指针
  • 如果一个指针变量 p2 存储的地址,是指针变量 p1 的地址,那么称 p2 为二级指针
  • 如果一个指针变量 p3 存储的地址,是指针变量 p2 的地址,那么称 p3 为三级指针
  • 以此类推,p2、p3等指针被称为多级指针

对于二级指针的运算有:

  • *ppa 通过对 ppa 中的地址进行解引用,这样找到的是 pa , *ppa 其实访问的就是 pa 
  • **ppa 先通过 *ppa 找到 pa ,然后对 pa 进行解引用操作: *pa ,那找到的是 a

8.7字符指针

char型指针实质上跟别的类型的指针并无本质区别,但由于C语言中的字符串以字符数组的方式存储,而数组在大多数场合又会表现为指针,因此字符串在绝大多数场合就表现为char型指针。

但我们常用字符指针来指针一个字符串,特别容易让人以为是把字符串hello world放到字符指针pstr里了,但本质是把字符串hello world的首字符的地址放到了pstr中,下例就是把一个常量字符串的首字符h的地址存放到指针变量pstr中。

int main(void)
{
    const char* pstr = "hello world";
    printf("pstr = %s\n", pstr);
    
    return 0;
}

 下面一道有趣的面试题:

int main(voidS)
{
    char str1[] = "hello world";
    char str2[] = "hello world";
    const char* str3 = "hello world";
    const char* str4 = "hello world";
    if (str1 == str2)
        printf("str1 and str2 are same\n");
    else
        printf("str1 and str2 are not same\n");

    if (str3 == str4)
        printf("str3 and str4 are same\n");
    else
        printf("str3 and str4 are not same\n");

    return 0;
}

 这里str3和str4指向的是一个同一个常量字符串。C/C++会把常量字符串存储到单独的一个内存区域,当 几个指针。指向同一个字符串的时候,他们实际会指向同一块内存。但是用相同的常量字符串去初始化 不同的数组的时候就会开辟出不同的内存块。所以str1和str2不同,str3和str4不同。

8.8指针数组

指针数组是指针还是数组?指针数组是数组,是存放指针的数组,数组有许多类型,如果一个数组中的所有元素保存的都是指针,那么我们就称它为指针数组。指针数组的定义形式一般为:

dataType *arrayName[length];

名字解释:

dataType*:数组里每个元素的类型

arrayName:数组名字

length:数组大小 

下列的这个案例代码可以认识和理解指针数组的概念: 

8.9数组指针

数组指针是数组和指针?数组指针是指针。类似,整形指针是能够指向整形数据的指针,那数组指针应该是能够指向数组的指针。

区分数组指针和指针数组的方法:

看结尾是什么,结尾是指针的话就是数组指针,结尾是数组的话就是指针数组

例子:int *p1[10];int (*p2)[10];

如何辨别例子的类型?再看复杂例子的时候,需要熟悉运算符的优先级,[]的优先级要高于*号,当变量名与[]结合的时候说明是一个数组变量,当变量名与*结合的时候说明是一个指针变量。所以p1是指针数组,p2是数组指针。

8.9.1数组名

一般情况下,C语言中数组名在表达式中被解读为指向数组首元素的指针

C语言中数组名在表达式中被解读为指向数组首元素的指针, 即数组名在表达式中值为数组首元素的地址。但是也有着几种特殊情况:

  1. sizeof(数组名),计算整个数组的大小,sizeof内部单独放一个数组名,数组名表示整个数 组。
  2. &数组名,取出的是数组的地址。&数组名,数组名表示整个数组。

除此这两种情况之外,所有的数组名都表示数组首元素的地址。

int main()
{
	int arr[10] = { 0 };
	printf("arr = %p\n", arr);
	printf("&arr= %p\n", &arr);
	printf("arr+1 = %p\n", arr + 1);
	printf("&arr+1= %p\n", &arr + 1);
	return 0;
}

 其实&arr和arr,虽然值是一样的,但是意义应该不一样的。 实际上: &arr 表示的是数组的地址,而不是数组首元素的地址。代码案例的 &arr 的类型是: int(*)[10] ,是一种数组指针类型数组的地址+1,跳过整个数组的大小,所以 &arr+1 相对于 &arr 的差值是40。

8.9.2使用场景

用数组指针来接受二维数组,在循环中方便使用

void print_arr(int(*arr)[5], int row, int col)
{
    int i = 0,j = 0;
    for (i = 0; i < row; i++)
    {
        for (j = 0; j < col; j++)
        {
            printf("%d ", arr[i][j]);
        }
        printf("\n");
    }
}

int main(void)
{
    int arr[3][5] = { 1,2,3,4,5,6,7,8,9,10 };
    //可以数组指针来接收
    print_arr(arr, 3, 5);
    return 0;
}

8.10函数指针

如果在程序中定义了一个函数,那么在编译时系统就会为这个函数代码分配一段存储空间,这段存储空间的首地址称为这个函数的地址。而且函数名表示的就是这个地址。既然是地址我们就可以定义一个指针变量来存放,这个指针变量就叫作函数指针变量,简称函数指针

那么这个指针变量怎么定义呢?虽然同样是指向一个地址,但指向函数的指针变量同我们之前讲的指向变量的指针变量的定义方式是不同的。例如:

int (*p)(int,int);

这个语句就定义了一个指向函数的指针变量 p。首先它是一个指针变量,所以要有一个“*”,即(*p);其次前面的 int 表示这个指针变量可以指向返回值类型为 int 型的函数;后面括号中的两个 int 表示这个指针变量可以指向有两个参数且都是 int 型的函数。所以合起来这个语句的意思就是:定义了一个指针变量 p,该指针变量可以指向返回值类型为 int 型,且有两个整型参数的函数。p 的类型为 int(*)(int,int)。

函数指针的定义方式:

函数返回值类型 (* 指针变量名) (函数参数列表);

“函数返回值类型”表示该指针变量可以指向具有什么返回值类型的函数;“函数参数列表”表示该指针变量可以指向具有什么参数列表的函数。这个参数列表中只需要写函数的参数类型即可。
函数指针的定义就是将“函数声明”中的“函数名”改成“(*指针变量名)”。但是这里需要注意的是:“(*指针变量名)”两端的括号不能省略,括号改变了运算符的优先级。如果省略了括号,就不是定义函数指针而是一个函数声明了,即声明了一个返回值类型为指针型的函数。

怎么判断一个指针变量是指向变量的指针变量还是指向函数的指针变量呢?

首先看变量名前面有没有“*”,如果有“*”说明是指针变量;其次看变量名的后面有没有带有形参类型的圆括号,如果有就是指向函数的指针变量,即函数指针,如果没有就是指向变量的指针变量。最后需要注意的是,指向函数的指针变量没有 ++ 和 -- 运算。

8.10.1函数指针数组

把函数的地址存到一个数组中,那这个数组就叫函数指针数组

函数指针数组的用途:转移表

使用转移表可以替代冗长的switch和if-else语句,分离了具体操作和选择代码,是一种良好的设计方案。和其他指针一样,对函数指针执行间接访问之前,必须把它初始化并指向某一个函数。

转移表最好的例子就是计算器,可以把计算器的基本功能写成函数,然后替换重复且复杂的语句

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }
int div(int a, int b) { return a / b; }
int main()
{
    int x, y;
    int input = 1;
    int ret = 0;
    int(*p[5])(int x, int y) = { 0, add, sub, mul, div }; //转移表
    while (input)
    {
        printf("*************************\n");
        printf(" 1:add           2:sub \n");
        printf(" 3:mul           4:div \n");
        printf("*************************\n");
        printf("请选择:");
        scanf("%d", &input);
        if ((input <= 4 && input >= 1))
        {
            printf("输入操作数:");
            scanf("%d%d", &x, &y);
            ret = (*p[input])(x, y);
        }
        else
            printf("输入有误\n");
        printf("ret = %d\n", ret);
    }
    return 0;
}

8.10.2指向函数指针数组的指针

指向函数指针数组的指针是一个 指针,指针指向一个 数组 ,数组的元素都是 函数指针 ;这就是它的解释,例如:

int (*(*p)[])(int, int);

首先,p和*结合说明是一个指针,之后与[]结合,说明是一个数组指针,再与*结合说明用一个指针指向了数组指针,之后又指向了一个函数的入口地址,该函数有两个int类型参数,返回值是int。

8.11回调函数

回调函数就是一个通过函数指针调用的函数。如果你把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用来调用其所指向的函数时,我们就说这是回调函数。回调函数不是由该函数 的实现方直接调用,而是在特定的事件或条件发生时由另外的一方调用的,用于对该事件或条件进 行响应。

对于"回调函数",中文其实可以理解为这么两种意思:1) 被回调的函数;2) 回头执行调用动作的函数。

其他大神的解释如:

把一段可执行的代码像参数传递那样传给其他代码,而这段代码会在某个时刻被调用执行,这就叫做回调。如果代码立即被执行就称为同步回调,如果在之后晚点的某个时间再执行,则称之为异步回调。

函数 F1 调用函数 F2 的时候,函数 F1 通过参数给 函数 F2 传递了另外一个函数 F3 的指针,在函数 F2 执行的过程中,函数F2 调用了函数 F3,这个动作就叫做回调(Callback),而先被当做指针传入、后面又被回调的函数 F3 就是回调函数。

9、自定义数据类型

自定义类型有三种,分别为结构体,枚举和联合体。

9.1结构体

设计程序时,最重要的步骤之一是选择表示数据的方法。在许多情况下,简单变量甚至是数组还不够。为此,C提供了结构变量提高你表示数据的能力,它能让你创造新的形式。

结构是一些值的集合,这些值称为成员变量。结构的每个成员可以是不同的变量,其中的成员可以是标量、数组、指针、基本数据类型甚至是其他结构体,例如学生这个结构体。学生有姓名,性别,成绩,班别等。使用结构体必须掌握以下的内容:为结构建立一个格式或样式,声明一个适合该样式的变量,访问结构变量的各个部分等。

9.1.1建立结构声明

结构声明描述了一个结构的组织布局,声明并未创建实际的数据对象,只描述了该对象由什么组成。
struct是关键字,它表明跟在其后的是一个结构;后面是一个可选的标记;在结构声明中,用一对花括号括起来的是结构成员列表。每个成员都用自己的声明来描述。成员可以是任意一种C的数据类型,甚至可以是其他结构。花括号后面的分号是声明所必需的,表示结构布局定义结束。可以把这个声明放在所有函数的外部,也可以放在一个函数定义的内部。如果把结构声明置于一个函数的内部,它的标记就只限于该函数内部使用。如果把结构声明置于函数的外部,那么该声明之后的所有函数都能使用它的标记。

struct student
{
    char name[20];
    char sex;
    int class_num;
    float scorse;
}

 在声明结构的时候,可以不完全的声明。例如:

struct
{
	int a;
	char b;
	float c;
}x;
struct
{
	int a;
	char b;
	float c;
}*p;

上面两个匿名结构体在声明的时候省略了结构体标签。当我们声明用一个结构体指针指向一个类型一样的匿名结构体变量时,会发生问题吗?貌似是没问题的吧,但是编译器会把上面两个声明当成完全不同的两个类型,所以是非法的。

上面我们提到结构体的成员变量里面可以有结构体,如果我定义的结构体中包含一个类型为该结构体本身的成员貌似也是可以的吧,初学者容易写出下面的代码:

struct Node
{
     int data;
     struct Node next;
};

但是我们通过运行我们的程序会得出程序报错的结果:

 正确的自引用的代码应该是:

struct Node
{
     int data;
     struct Node* next;
};

但是我们有的时候会使用typedef这个关键字给自定义数据起一个别名,如果在自引用中使用别名将会导致编译器无法识别结构体中的成员变量,程序将无法成功编译。

9.1.2定义结构体变量

在声明我们自定义的结构体类型后,我们就可以通过像基本数据类型一样定义和初始化结构体变量:

结构体类型 结构体变量名;

结构体类型 结构体变量名 = { 初始化值 };

声明结构数组和声明其他类型的数组类似。为了标识结构数组中的成员,可以采用访问单独结构的规则:在结构名后面加一个点运算符,再在点运算符后面写上成员名。

9.1.3结构体大小

在考试题和面试题中,我们常常考到结构体大小的计算问题,这里包含着一个特别热门的考点:结构体内存对齐。这里介绍几条结构体的对齐规则:

  1. 第一个成员在与结构体变量偏移量为0的地址处。
  2. 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。                                          对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。 VS中默认的值为8
  3. 结构体总大小为最大对齐数(每个成员变量都有一个对齐数)的整数倍。
  4. 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整 体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。

下面通过几个代码案例来掌握这几条规则:

//练习1
struct S1
{
	char c1;
	int i;
	char c2;
};

//练习2
struct S2
{
	char c1;
	char c2;
	int i;
};

//练习3
struct S3
{
	double d;
	char c;
	int i;
};

//练习4-结构体嵌套问题
struct S4
{
	char c1;
	struct S3 s3;
	double d;
};

和自己心目中的答案是一样的吗?下面通过图解来讲解一下下面几个案例:

为什么存在内存对齐呢?

大部分的参考资料都是如是说的:

1. 平台原因(移植原因): 不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特 定类型的数据,否则抛出硬件异常。

2. 性能原因: 数据结构(尤其是栈)应该尽可能地在自然边界上对齐。 原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。

总结:结构体的内存对齐是拿空间来换取时间的做法。

在设计结构体的时候,既要满足对齐,又要节省空间,就要让占用空间小的成员变量尽量集中在一起。这样就对空间和时间都有了保障。

在vs2022中,我们可以通过#pragma这个预处理指令,可以改变我们的默认对齐数。

#pragma pack(8)//设置默认对齐数为8
struct S1
{
    char c1;
    int i;
    char c2;
};
#pragma pack()//取消设置的默认对齐数,还原为默认

#pragma pack(1)//设置默认对齐数为1
struct S2
{
    char c1;
    int i;
    char c2;
};
#pragma pack()//取消设置的默认对齐数,还原为默认

 发现当我们修改默认对齐数时,两个成员变量类型相同的结构体的大小不一样。当结构体在对齐方式不合适的时候,我们就可以通过#pragma这个预处理指令自己更改默认对齐数。

9.1.4访问结构体成员

结构类似于一个“超级数组”,这个超级数组中,可以是一个元素为char 类型,下一个元素为forat类型,下一个元素为int数组。可以通过数组下标单独访问数组中的各元素,我们可以通过结构成员运算符(.)和->来访问结构体成员。但两个操作符也有着不同,当我们访问结构体变量的成员时,使用结构成员运算符进行直接访问;当我们访问结构体指针的成员时,使用->运算符进行间接访问。

总结:结构和联合运算符

成员运算符:.

一般注释:该运算符与结构或联合名一起使用,指定结构或联合的一个成员。如果 name是一个结构的名称,member是该结构模版指定的一个成员名,下面标识了该结构的这个成员: name.member name.member的类型就是member的类型。联合使用成员运算符的方式与结构相同。

间接成员运算符:->

一般注释: 该运算符和指向结构或联合的指针一起使用,标识结构或联合的一个成员。假设ptrstr是指向结构的指针,member是该结构模版指定的一个成员, 那么: ptrstr->member 标识了指向结构的成员。联合使用间接成员运算符的方式与结构相同。

9.1.5结构作为参数传递

函数的参数把值传递给函数。每个值都是一个数字——可能是int类型、 float类型,可能是ASCII字符码,或者是一个地址。只要结构成员是一个具有单个值的数据类型(即,int及其相关类型、 char、float、double或指针),便可把它作为参数传递给接受该特定类型的函数。当我们使用函数传参的时候,可以传结构体变量或者结构体指针。这两种方法都是可以的,但我们一般优先选结构体指针

原因很简单,参数是需要压栈的,会有时间和空间上的系统开销。如果传递一个结构体对象的是时候,结构体过大,参数压栈的系统开销比较大,所以会导致性能的下降。所以在变量和指针的选择上优先使用结构体指针。

9.2枚举

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

枚举格式:

enum enumerate {枚举符1,枚举符2,...,枚举符N};

enum enumerate {枚举符1=值1,枚举符2=值2,...,枚举符N = 值N};

虽然枚举符是int类型,但是枚举变量可以是任意整数类型,前提是该整数类型可以储存枚举常量。C语言是允许枚举变量使用++运算符。

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

enum spectrum {red, orange=100, yellow};

如果只给一个枚举常量赋值,没有对后面的枚举常量赋值,那么后面的常量会被赋予后续的值。

//enum spectrum { red, orange, yellow };
enum spectrum { red, orange = 100, yellow };

int main(void)
{
	enum spectrum a = red;
	enum spectrum b = orange;
	enum spectrum c = yellow;

	printf("a = %d\tb = %d\tc = %d\n", a, b, c);

	return 0;
}

枚举的优点:

1. 增加代码的可读性和可维护性

2. 和#define定义的标识符比较枚举有类型检查,更加严谨。

3. 防止了命名污染(封装)

4. 便于调试

5. 使用方便,一次可以定义多个常量

枚举常用于switch语句中,充当case标签后面的表达式。

enum case_num { zero,one,two};

int main(void)
{
	int input = 1;

	while (1)
	{
		printf("请输入input的值:");
		scanf("%d", &input);
		switch (input)
		{
		case zero:
			printf("input的值为0\n");
			break;
		case one:
			printf("input的值为1\n");
			break;
		case two:
			printf("input的值为2\n");
			break;
		default:
			break;
		}
	}
	return 0;
}

9.3共用体(联合体)

共用体(联合)也是一种特殊的自定义类型,这种类型定义的变量也包含一系列的成员,特征是这些成员公用同一块空间。联合的成员是共用同一块内存空间的,这样一个联合变量的大小,至少是最大成员的大小(因为联合至少得有能力保存最大的那个成员)。例如:

//联合类型的声明
union Un
{
     char c;
     int i;
};
//联合变量的定义
union Un un;

有一道有趣的面试题:判断当前计算机的大小端存储

什么是大端 / 小端?
大端(存储)模式,是指数据的低位保存在内存的高地址中,而数据的高位保存在内存的低地址中
小端(存储)模式,是指数据的低位保存在内存的低地址中,而数据的高位保存在内存的高地址中

为什么会有大小端之分呢?
因为在计算机系统中,我们以字节为存储单元,每个地址单元都对应着一个字节,一个字节为8bit。而在C语言中,不仅仅是一个字节来存储一个数据,除了一个字节的char,还有两个字节的short,四个字节的int等等(看具体编译器)。另外,对于位数大于8位的处理器,例如32位的处理器,由于寄存器的宽度大于一个字节,那么就有如何将多个字节进行排布的问题,于是就出现了大小端的问题。

这里介绍利用联合体求大小端的思路:一个成员是多字节,一个是单字节,给多字节的成员赋一个最低一个字节不为0,其他字节为0 的值,再用第二个成员来判断,如果第二个字节不为0,就是小端,若为0,就是大端。

//联合类型的声明
union Un
{
	int i;
	char c;

};
//联合变量的定义
union Un un;

int main(void)
{
	//printf("%d\n", sizeof(un));
	union Un u;
	u.i = 1;

	if (u.c == 1)
	{
		printf("小端\n");
	}
	else
	{
		printf("大端\n");
	}

	return 0;
}

联合体大小计算规则:

  • 联合的大小至少是最大成员的大小。
  • 当最大成员大小不是最大对齐数的整数倍的时候,就要对齐到最大对齐数的整数倍。

10、文件操作

10.1为什么使用文件

程序的数据是存放在内存中,当程序退出的时候,程序的数据自然就不存在了,等下一次运行我们的程序就需要重新输入自己的数据,这样就比较麻烦。数据持久化的方法一般有:吧数据存放在错案文件和存放到数据库等方式,我们使用文件的时候就可以把数据直接存放到电脑的硬盘上,做到了数据了持久化。

10.2什么是文件

文件(file)通常是在磁盘或固态硬盘上的一段已命名的存储区。对我们而言,stdio.h就是一个文件的名称,该文件中包含一些有用的信息。然 而,对操作系统而言,文件更复杂一些。例如,大型文件会被分开储存,或 者包含一些额外的数据,方便操作系统确定文件的种类。然而,这都是操作 系统所关心的,程序员关心的是C程序如何处理文件。

C把文件看作是一系列连续的字节,每个字节都能被单独读取。C提供两种文件模式:文本模式和二进制模式。

要区分文本内容和二进制内容、文本文件格式和二进制文件格 式,以及文件的文本模式和二进制模式。

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

为了规范文本文件的处理,C 提供两种访问文件的途径:二进制模式和 文本模式。在二进制模式中,程序可以访问文件的每个字节。而在文本模式 中,程序所见的内容和文件的实际内容不同。程序以文本模式读取文件时, 把本地环境表示的行末尾或文件结尾映射为C模式。

除了以文本模式读写文本文件,还能以二进制模式读写文本文件。

在程序设计中,我们一般谈的文件有两种:程序文件数据文件

2.1程序文件

包括源程序文件(后缀为.c),目标文件(windows环境后缀为.obj),可执行程序(windows环境 后缀为.exe)。

2.2数据文件

文件的内容不一定是程序,而是程序运行时读写的数据,比如程序运行需要从中读取数据的文件, 或者输出内容的文件。

2.3文件名

一个文件要有一个唯一的文件标识,以便用户识别和引用。

文件名包含3部分:文件路径+文件名主干+文件后缀,例如:D:\资料\vs\test

为了方便起见,文件标识常被称为文件名。

10.3文件的打开和关闭

10.3.1文件指针

缓冲文件系统中,关键的概念是“文件类型指针”,简称“文件指针”。 每个被使用的文件都在内存中开辟了一个相应的文件信息区,用来存放文件的相关信息(如文件的名 字,文件状态及文件当前的位置等)。这些信息是保存在一个结构体变量中的。该结构体类型是有系统声明的,取名FILE.

struct _iobuf {
        char *_ptr;
        int   _cnt;
        char *_base;
        int   _flag;
        int   _file;
        int   _charbuf;
        int   _bufsiz;
        char *_tmpfname;
       };
typedef struct _iobuf FILE;

 不同的C编译器的FILE类型包含的内容不完全相同,但是大同小异。 每当打开一个文件的时候,系统会根据文件的情况自动创建一个FILE结构的变量,并填充其中的信息, 使用者不必关心细节。 一般都是通过一个FILE的指针来维护这个FILE结构的变量,这样使用起来更加方便。

FILE* pf = NULL;

定义pf是一个指向FILE类型数据的指针变量。可以使pf指向某个文件的文件信息区(是一个结构体变 量)。通过该文件信息区中的信息就能够访问该文件。也就是说,通过文件指针变量能够找到与它关联 的文件。

10.3.2文件的打开和关闭

文件在读写之前应该先打开文件,在使用结束之后应该关闭文件。 在编写程序的时候,在打开文件的同时,都会返回一个FILE*的指针变量指向该文件,也相当于建立了指针和文件的关系。 ANSIC 规定使用fopen函数来打开文件,fclose来关闭文件。

//打开文件
FILE* fopen(const char* filename,const char* mode);
函数作用:打开文件
参数:filename:文件名
    mode:文件使用方式
返回值:成功:文件流指针
        失败:NULL

//关闭文件
int fclose(FILE* stream);
函数作用:关闭文件
参数:stream:文件指针流
返回值:成功:0
        失败:EOF(-1)
文件打开方式
文件使用方式含义如果至指定文件不存在
“r”(只读)为了输入数据,打开一个已经存在的文本文件出错
“w”(只写)为了输出数据,打开一个文本文件建立一个新的文件
“a”(追加)向文本文件尾添加数据建立一个新的文件
“rb”(只读)为了输入数据,打开一个二进制文件出错
“wb”(只写)为了输出数据,打开一个二进制文件建立一个新的文件
“ab”(追加)向一个二进制文件尾添加数据出错
“r+”(读写)为了读和写,打开一个文本文件出错
“w+”(读写)为了读和写,建议一个新的文件建立一个新的文件
“a+”(读写)打开一个文件,在文件尾进行读写建立一个新的文件
“rb+”(读写)为了读和写打开一个二进制文件出错
“wb+”(读写)为了读和写,新建一个新的二进制文件建立一个新的文件
“ab+”(读写)打开一个二进制文件,在文件尾进行读和写建立一个新的文件

 

10.4文件的顺序读写

功能函数名适用于
字符输入函数fgetc所有输入流
字符输出函数fputc所有输出流
文本行输入函数fgets所有输入流
文本行输出函数fputs所有输出流
格式化输入函数fscanf所有输入流
格式化输出函数fprintf所有输出流
二进制输入fread文件
二进制输出fwrite文件

 这里介绍fscanf,fprintf,fread,fwite,fgets,fputs这几个函数。

fscanf() 和 fprintf() 函数与前面使用的 scanf() 和 printf() 功能相似,都是格式化读写函数,两者的区别在于 fscanf() 和 fprintf() 的读写对象不是键盘和显示器,而是磁盘文件。

函数原型如下:
int fscanf ( FILE *fp, char * format, ... );
int fprintf ( FILE *fp, char * format, ... );

函数参数:
fp 为文件指针;
format 为格式控制字符串;
... 表示参数列表;

fprintf() 返回成功写入的字符的个数,失败则返回负数
fscanf() 返回参数列表中被成功赋值的参数个数

与 scanf() 和 printf() 相比,它们仅仅多了一个 fp 参数

 两个函数的测试代码为:

int main(void)
{
	FILE* pf1 = NULL;
	pf1 = fopen("test.txt", "w");
	if (pf1 == NULL)
	{
		printf("打开文件失败\n");
		return -1;
	}

	char buf[1024] = "helloworld!";
	fprintf(pf1, "%s", buf);
	fclose(pf1);

	return 0;
}

int main(void)
{
	FILE* pf2 = NULL;

	pf2 = fopen("test.txt", "r");
	if (pf2 == NULL)
	{
		printf("打开文件失败\n");
		return -1;
	}

	char data[1024] = { 0 };
	int ret = fscanf(pf2, "%s", data);

	printf("ret:%d\n", ret);

	printf("data:%s\n", data);
	fclose(pf2);
	return 0;
}

 

 fread() 函数用来从指定文件中读取块数据。所谓块数据,也就是若干个字节的数据,可以是一个字符,可以是一个字符串,可以是多行数据,并没有什么限制。fread() 的原型为:

size_t fread ( void *ptr, size_t size, size_t count, FILE *fp );

 fwrite() 函数用来向文件中写入块数据,它的原型为:

size_t fwrite ( void * ptr, size_t size, size_t count, FILE *fp );

 对参数的说明:

  • ptr 为内存区块的指针,它可以是数组、变量、结构体等。fread() 中的 ptr 用来存放读取到的数据,fwrite() 中的 ptr 用来存放要写入的数据。
  • size:表示每个数据块的字节数。
  • count:表示要读写的数据块的块数。
  • fp:表示文件指针。
  • 理论上,每次读写 size*count 个字节的数据。

size_t 是在 stdio.h 和 stdlib.h 头文件中使用 typedef 定义的数据类型,表示无符号整数,也即非负数,常用来表示数量。

返回值:返回成功读写的块数,也即 count。如果返回值小于 count:

  • 对于 fwrite() 来说,肯定发生了写入错误,可以用 ferror() 函数检测。
  • 对于 fread() 来说,可能读到了文件末尾,可能发生了错误,可以用 ferror() 或 feof() 检测。

这两个函数的测试代码如下:

int main(void)
{
	FILE* pf1 = NULL;
	pf1 = fopen("test1.txt", "wb+");
	if (pf1 == NULL)
	{
		printf("打开文件失败\n");
		return -1;
	}

	char buf[1024] = { "hadhasio" };
	fwrite(buf, sizeof(buf), 1, pf1);
	fclose(pf1);

	return 0;
}

int main(void)
{
	FILE* pf2 = NULL;

	pf2 = fopen("test1.txt", "rb+");
	if (pf2 == NULL)
	{
		printf("打开文件失败\n");
		return -1;
	}

	char data[1024] = { 0 };
	int ret = fread(data, sizeof(data), 1, pf2);

	printf("ret:%d\n", ret);

	printf("data:%s\n", data);
	fclose(pf2);
	return 0;
}

fgets,fputs函数

fgets() 函数用来从指定的文件中读取一个字符串,并保存到字符数组中

函数原型:
char *fgets ( char *str, int n, FILE *fp );

参数解释:str 为字符数组
                  n 为要读取的字符数目
                  fp 为文件指针

返回值:读取成功时返回字符数组首地址,也即 str;读取失败时返回 NULL;

                如果开始读取时文件内部指针已经指向了文件末尾,那么将读取不到任何字符,也返回 NULL

注意,读取到的字符串会在末尾自动添加 '\0',n 个字符也包括 '\0'。也就是说,实际只读取到了 n-1 个字符,如果希望读取 100 个字符,n 的值应该为 101。

需要重点说明的是,在读取到 n-1 个字符之前如果出现了换行,或者读到了文件末尾,则读取结束。这就意味着,不管 n 的值多大,fgets() 最多只能读取一行数据,不能跨行。在C语言中,没有按行读取文件的函数,我们可以借助 fgets(),将 n 的值设置地足够大,每次就可以读取到一行数据。

fgets() 遇到换行时,会将换行符一并读取到当前字符串。该示例的输出结果之所以和 demo.txt 保持一致,该换行的地方换行,就是因为 fgets() 能够读取到换行符。而 gets() 不一样,它会忽略换行符。

int main(void)
{
    FILE* fp = NULL;
    char str[1024] = { 0 };
    fp = fopen("test.txt", "r");
    if ( fp == NULL) 
    {
        perror("Fail to open file!");
        return -1;
    }

    while (fgets(str, sizeof(str), fp) != NULL)
    {
        printf("%s", str);
    }

    fclose(fp);
    return 0;
}

 fputs() 函数用来向指定的文件写入一个字符串

函数原型:

int fputs( char* str,FILE* fp);

参数:str 为要写入的字符串

        fp 为文件指针

返回值:写入成功返回非负数,失败返回 EOF

int main(void) 
{
    FILE* fp = NULL;
    char str[102] = { 0 }, strTemp[100] = { 0 };
    fp = fopen("test.txt", "a+");
    if (fp == NULL) 
    {
        perror("Fail to open file!");
        return -1;
    }

    printf("Input a string:");
    gets(strTemp);
    strcat(str, "\n");
    strcat(str, strTemp);
    fputs(str, fp);

    fclose(fp);
    return 0;
}

10.5文件的随机读写

10.5.1fseek

函数功能是把文件指针指向文件的开头,需要包含头文件stdio.h

函数原型:
int fseek(FILE *stream, long int offset, int whence)

参数:stream -- 这是指向 FILE 对象的指针,该 FILE 对象标识了流
    offset -- 这是相对 whence 的偏移量,以字节为单位
    whence -- 这是表示开始添加偏移 offset 的位置。它一般指定为下列常量之一:

返回值:
如果成功,则该函数返回零,否则返回非零值。
常量描述
SEEK_SET文件的开头
SEEK_CUR文件指针的当前位置
SEEK_END文件的末尾

 测试代码如下:

int main()
{
	FILE* fp = NULL;

	fp = fopen("test.txt", "w+");
	if (fp == NULL)
	{
		printf("打开文件失败\n");
		return -1;
	}
	fputs("This is sakura0908", fp);

	fseek(fp, 7, SEEK_SET);
	fputs(" C Programming Langauge", fp);

	fclose(fp);

	return(0);
}

10.5.2ftell

返回给定流 stream 的当前文件位置,需要包含头文件stdio.h

函数原型:
long int ftell(FILE *stream);

参数:stream -- 这是指向 FILE 对象的指针,该 FILE 对象标识了流

返回值:该函数返回位置标识符的当前值。如果发生错误,则返回 -1L,全局变量 errno 被设置为一个正值

测试代码案例如下:

int main()
{
    FILE* fp = NULL;
    int len;

    fp = fopen("test.txt", "r");
    if (fp == NULL)
    {
        perror("打开文件错误");
        return(-1);
    }
    fseek(fp, 0, SEEK_END);

    len = ftell(fp);
    fclose(fp);

    printf("test.txt 的总大小 = %d 字节\n", len);

    return(0);
}

10.5.3rewind

设置文件位置为给定流 stream 的文件的开头,需要包含头文件stdio.h

函数原型:
void rewind(FILE *stream);

参数:
stream -- 这是指向 FILE 对象的指针,该 FILE 对象标识了流

返回值:
该函数不返回任何值

 测试代码案例如下:

int main()
{
   char str[] = "This is runoob.com";
   FILE *fp;
   int ch;

   /* 首先让我们在文件中写入一些内容 */
   fp = fopen( "file.txt" , "w" );
   fwrite(str , 1 , sizeof(str) , fp );
   fclose(fp);

   fp = fopen( "file.txt" , "r" );
   while(1)
   {
      ch = fgetc(fp);
      if( feof(fp) )
      {
          break ;
      }
      printf("%c", ch);
   }
   rewind(fp);
   printf("\n");
   while(1)
   {
      ch = fgetc(fp);
      if( feof(fp) )
      {
          break ;
      }
      printf("%c", ch);
     
   }
   fclose(fp);

   return(0);
}

 

10.6文本文件和二进制文件

根据数据的组织形式,数据文件被称为文本文件或者二进制文件。 数据在内存中以二进制的形式存储,如果不加转换的输出到外存,就是二进制文件。 如果要求在外存上以ASCII码的形式存储,则需要在存储前转换。以ASCII字符的形式存储的文件就是文 本文件。 一个数据在内存中是怎么存储的呢? 字符一律以ASCII形式存储,数值型数据既可以用ASCII形式存储,也可以使用二进制形式存储。 如有整数10000,如果以ASCII码的形式输出到磁盘,则磁盘中占用5个字节(每个字符一个字节),而 二进制形式输出,则在磁盘上只占4个字节(VS2022测试)。

测试代码:

int main()
{
     int a = 10000;
     FILE* pf = fopen("test.txt", "wb");
     fwrite(&a, 4, 1, pf);//二进制的形式写到文件中
     fclose(pf);
     pf = NULL;
     return 0;
}

10.7文件读取结束的判定

牢记:在文件读取过程中,不能用feof函数的返回值直接用来判断文件的是否结束。

而是应用于当文件读取结束的时候,判断是读取失败结束,还是遇到文件尾结束。

1. 文本文件读取是否结束,判断返回值是否为 EOF ( fgetc ),或者 NULL ( fgets )

例如: fgetc 判断是否为 EOF 或 fgets 判断返回值是否为 NULL .

2. 二进制文件的读取结束判断,判断返回值是否小于实际要读的个数。

例如: fread判断返回值是否小于实际要读的个数。

int main(void)
{
    int c; // 注意:int,非char,要求处理EOF
    FILE* fp = fopen("test.txt", "r");
    if (fp == NULL) 
    {
        perror("File opening failed");
        return -1;
    }
    //fgetc 当读取失败的时候或者遇到文件结束的时候,都会返回EOF
    while ((c = fgetc(fp)) != EOF) // 标准C I/O读取文件循环
    {
        putchar(c);
    }

    if (ferror(fp))
        puts("I/O error when reading");
    else if (feof(fp))
        puts("End of file reached successfully");
    fclose(fp);

    return 0;
}

10.8文件缓冲区

ANSIC 标准采用“缓冲文件系统”处理的数据文件的,所谓缓冲文件系统是指系统自动地在内存中为程序 中每一个正在使用的文件开辟一块“文件缓冲区”。从内存向磁盘输出数据会先送到内存中的缓冲区,装 满缓冲区后才一起送到磁盘上。如果从磁盘向计算机读入数据,则从磁盘文件中读取数据输入到内存缓冲区(充满缓冲区),然后再从缓冲区逐个地将数据送到程序数据区(程序变量等)。缓冲区的大小根据C编译系统决定的。

 因为有缓冲区的存在,C语言在操作文件的时候,需要做刷新缓冲区或者在文件操作结束的时候关闭文 件。 如果不做,可能导致读写文件的问题。刷新缓冲区作用的函数有fflush函数和fclose函数

fflush函数:刷新缓冲区,某些编译器不支持,vs2022是不支持的。

fclose函数:关闭文件的时候,也会刷新缓冲区。

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

11.1作用域

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

块作用域:块是用一对花括号括起来的代码区域,块作用域变量的可见范围是从定义处到包含该定义的块的末尾。

以前,具有块作用域的变量都必须声明在块的开头。C99 标准放宽了这 一限制,允许在块中的任意位置声明变量。C99把块的概念扩展到包括for循环、while循环、 do while循环和if语句所控制的代码,即使这些代码没有用花括号括起来, 也算是块的一部分。

函数作用域:它只适用于语句标签,语句标签用于go语句。一个函数中的所有语句标签必须唯一。着即使一 个标签首次出现在函数的内层块中,它的作用域也延伸至整个函数。如果在两个块中使用相同的标签会很混乱,标签的函数作用域防止了这样的事情发 生。

函数原型作用域:只适用于在函数原型中声明的参数。唯一可能出现的冲突就是在同一个原型中,不止一次地使用同一个名字。函数原型作用域的范围是从形参定义处到原型声明结束。这意味着,编 译器在处理函数原型中的形参时只关心它的类型,而形参名(如果有的话) 通常无关紧要。而且,即使有形参名,也不必与函数定义中的形参名相匹 配。

文件作用域:任何在代码块之外声明的标识符都具有文件作用域。但是在同文件中编写的通过include指令包含到其他文件中的声明,就好像直接写在那些文件中一样,它们的作用域不限于头文件的文件尾。具有文件作用域的变量,从它的定义处到该定义所在文件的末尾均可见。由于这样的变量可用于多个函数,所以文件作用域变量也称为全局变量。

11.2链接

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

怎样修改链接属性?

关键字externstatic用于在声明修改标识符的链接属性

如果某个声明在正常情况下具有外部链接属性,在它前面加上static关键字可以使它的链接属性变为内部链接。

注:static只对外部链接的声明才有改变链接属性的效果。

extern关键字为一个标识符指定为外部链接属性,这样就可以访问在其他任何位置定义的这个实体

注: 当extern关键字用于源文件中一个标识符的第一次声明,它指定该标识符具有外部链接属性。但是,如果他用于该标识符的第2次或以后的声明,它并不会更改由第1声明所指定的链接属性

11.3存储期

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

静态存储期:如果对象具有静态存储期,那么它在程序的执行期间一直存在。

文件作用域变量具有静态存储期。注意,对于文件作用域变量,关键字 static表明了其链接属性,而非存储期。以 static声明的文件作用域变量具有内部链接。但是无论是内部链接还是外部链接,所有的文件作用域变量都具有静态存储期。

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

自动存储期:块作用区域的变量通常都具有自动存储期。当程序进入定义这些变量的块时,为这些变量分配内存;当退出这个块时,释放刚才为变量分配的内存。

动态分配存储期后面介绍

11.4存储类别

C提供了多种不同的模型或存储类别在内存中储存数据。目前所有编程示例中使用的数据都储存在内存中。从硬件方面来看,被储存的每个值都占用一定的物理内存,C 语言把这样的一块内存称为 对象)。对象可以储存一个或多个值。一个对象可能并未储存实际的值,但是它在储存适当的值时一定具有相应的大小(面向对象编程中的对象指的是类对象,其定义包括数据和允许对数据进行的操作,C不是面向对象编程语言,是面向过程编程语言)。

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

除动态分配存储期的5种存储类别如下:

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

11.4.1自动变量

默认情况下,声明在块或函数头中的任何变量都属于自动存储类别。

属于自动存储类别的变量具有自动存储期、块作用域且无链接

块作用域和无链接意味着只有在变量定义所在的块中才能通过变量名访问该变量(当然,参数用于传递变量的值和地址给另一个函数,但是这是间接的方法)。另一个函数可以使用同名的变量,但是该变量存储在不同内存位置的另一个变量。

变量具有自动存储期意味着,程序在进入该声明所在的块时变量存在,程序在退出该块时变量消失。原来该变量占用的内存位置现在可做他用。

关键字auto(存储类别说明符)用于修饰这种存储类别。

但它极少使用,因为代码块中的变量在缺省情况下就是自动变量。

auto 数据类型 数据变量名;

块中声明的变量仅限于该块及其包含的块使用。例如:

int loop(void)
{
    int m; // m的作用域
    scanf(“%d”, &m);
    {
        int i; // m和i的作用域
        for (i = m; i < n; i++)
            puts(“i is local to a sub-block\n”);
    }
    return m; // m的作用域,i已经消失了
}

上面的代码中,i仅在内层块可见。如果在内层块的前面或后面使用i,编译器会报错。变量n和m分别定义在函数头和外层块中,它们的作用域是整个函数,而且在调用函数到函数结束期间都一直存在。

如果内层块中声明的变量和外层块声明的变量同名会怎么样?

内层块会隐藏外层块的定义。但是离开内层块后,外层块变量的作用域又回到了原来的作用域。例如下面的示例:

int main(void)
{
    int x = 30;  //原始的x
    printf("x in outer block : % d at & %p\n", x, &x);
    {
        int x = 77; //新的x,隐藏了原始的x

        printf("x in inner block : % d at % p\n", x, &x);
    }

    printf("x in outer block : % d at % p\n", x, &x);

    while (x++ < 33) //原始的x
    {
        int x = 100; // 新的x,隐藏了原始的x
            x++;
        printf("x in while loop:% d at% p\n", x, & x);
        }
    printf("x in outer block : % d at % p\n", x, &x);

    return 0;
}

首先,程序创建了变量x并初始化为30,如第1条printf()语句所示。然后,定义了一个新的变量x,并设置为77,如第2条printf()语句所示。根据显示的地址可知,新变量隐藏了原始的x。第3条printf()语句位于第1个内层块后面,显示的是原始的x的值,这说明原始的x既没有消失也不曾改变。

while循环的测试条件中使用的是原始的x:while(x++ < 33)
在该循环中,程序创建了第3个x变量,该变量只定义在while循环中。所以,当执行到循环体中的x++时,递增为101的是新的x,然后printf()语句显示了该值。每轮迭代结束,新的x变量就消失。然后循环的测试条件使用并递增原始的x,再次进入循环体,再次创建新的x。在该例中,这个x被创建和销毁了3次。

注:该循环必须在测试条件中递增x,因为如果在循环体中递增x,那么递增的是循环体中的创建x,而非测试条件中使用的原始x。

自动变量的初始化

自动变量不会初始化,除非显式初始化它。考虑下面的声明:

int num;

int num1 = 10;

变量num1被初始化为10,但是num变量的值是之前占用会分配给num的空间的任意值,但大多数的情况下是任意值,而不是0

可以用 非常量表达式 初始化自动变量,前提是所用的变量已在前面定义过:

int num = 10;

int num2 = 10 * num1;

自动变量没有缺省的初始值,而显示初始化将在代码块的起始处插入一条隐式的赋值语句。

这个技巧造成4种结果:

1)自动变量的初始化较之赋值语句效率并无提高;

2)除了声明为const变量之外,在声明变量的同时进行初始化和先声明后赋值只有风格之差,并无效率之别。

3)这条隐式的赋值语句使自动变量在程序执行到它们所声明的函数(或代码块)时,每次都将重新初始化。

4)除非对自动变量进行显式的初始化,否则当自动变量创建时,它们的值总是垃圾(随机值)。

11.4.2寄存器变量

关键字register可以用于自动变量的声明,提示它们应该存储于机器的硬件寄存器而不是内存中,这类变量称为寄存器变量

register int num;

变量通常存储在计算机内存中。

如果幸运的话,寄存器变量存储在CPU的寄存器中,或者概括来说,存储在最快的可用内存中。通常,寄存器变量比存储于内存的变量访问起来效率更高。

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

注:声明变量为register类别与直接命令相比更像是一种请求。编译器必须根据寄存器或最快可用内存的数量衡量你的请求,或者直接忽略你的请求,所以可能不会如你所愿。在这种情况下,寄存器变量就变成普通的变量。即使这样,仍然不能对该变量使用地址符。
在函数头使用关键字register,便可请求形参是寄存器变量:

void macho(register int n);

绝大多数方面,寄存器变量和自动变量都一样。也就是说,它们都是块作用域、无链接和自动存储期。 寄存器变量的创建、销毁时间和自动变量相同,但它需要一些额外的工作。在一个使用寄存器变量的函数返回之前,这些寄存器先前存储的值必须恢复,确保调用者的寄存器变量未被破坏。许多机器使用运行堆栈来完成这个任务。当函数开始执行时,它把需要使用的所有寄存器的内容都保存到堆栈中,当函数返回时,这些值再复制回寄存器中。

11.4.3块作用域的静态变量

可以创建具有静态存储期、块作用域的局部变量。这些变量和自动变量一样,具有相同的作用域,但是程序离开它们所在的函数,这些变量不会消失。这种变量具有块作用域、无链接,但是具有静态存储期。计算机在多次函数调用之间会记录它们的值。在块中(提供作用域和无链解)以存储类别说明符static(提供静态存储期)声明这种变量。

void test(void)
{
    int num = 1;
    static int stay = 1;
    printf("fade = %d and stay = %d\n", num++, stay++);
}

int main(void)
{

    test();
    test();
    test();

    return 0;
}

静态变量stay保存了它被递增1后的值,但是num变量每次都是1.这表明初始化不同,调用test()都会初始化fnum,但是stay只在编译test()时被初始化一次。如果未显式初始化静态变量,它们会被初始化为0。

不能在函数的形参中使用static:

int test(static int num); //error

11.4.4外部链接的静态变量

外部链接的变量具有文件作用域、外部链接和静态存储期。

该类别有时称为外部存储类别,属于该类别的变量称为外部变量。

外部变量的声明:把变量的定义性声明放在所有函数外面便创建了外部变量。

为了指出该函数使用了外部变量,可以在函数中使用关键字extern再次声明。

注:如果一个源代码文件使用的外部变量定义在另一个源代码文件中,则必须用extern在该文件中声明该变量。

int num;        // 外部定义的变量

int num[10];        // 外部定义的变量

extern int test;        //被定义在另一个文件种,利用extern声明

初始化外部变量

外部变量只能使用常量表达式初始化文件作用域变量,如果未初始化外部变量,它们会被自动初始化为0。

定义变量就是声明了一个变量并且计算机为其预留了存储空间。

声明变量就是单纯的声明一个变量,不管这个变量是否获得存储空间。

注意:定义只能有一次,而声明可以有多次

第1次声明被称为定义式声明,第2次声明被称为引用式声明。关键字extern表明该声明不是定义,因为它指示编译器去别处查询其定义。

11.4.5内部链接的静态变量

该存储类别的变量具有静态存储期、文件作用域和内部链接。

在所有函数外部用储存说明符static定义的变量具有这种存储类别:

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

内部链接的静态变量只能用于同一个文件中的函数。可以使用存储类别说明符extern,在函数中重复声明任何具有文件作用域的变量。这样的声明并不会改变其链接属性。例如下面代码:

int traveler = 1;   // 外部链接
static int stayhome = 1; // 内部链接
int main(void)
{
    extern int traveler; // 使用定义在别处的 traveler
    extern int stayhome; // 使用定义在别处的 stayhome
    ...

    return 0;

}

对于该程序所在的翻译单元,traveler和stayhome都具有文件作用域,但是只有traveler可用于其它翻译单元(因为它具有外部链接)。这两个声明都使用了extern关键字,指明了main()中使用的这两个变量的定义在别处,但是这并未改变stayhome的内部链接属性。

总的来说,静态变量在程序运行之前创建,在程序的整个执行期间始终存在。它始终保持原先的值,除非给它赋一个不同的值或者程序结束。

11.4.6存储类别说明符

C语言有6个关键字作为存储类别说明符:auto、register、static、extern、_Thread_local和typedef

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

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

        3)static。当它用于函数定义时,或用于代码块之外的变量声明时,static关键字用于修改标识符的链接属性(从外部链接该为内部链接),但标识符的存储类型(静态存储)和作用域(文件作用域)不受影响。用这种方式声明的函数或变量只能在声明它们的源文件中访问。

        当它用于代码块内部的变量声明时,static关键字用于修改变量的存储类型,从自动变量修改为静态变量,但变量的链接属性和作用域不受影响。用这种方式声明的变量在程序执行之前创建,并在程序的整个执行期间一直存在,而不是每次在代码块开始执行时创建,在代码块执行完毕后销毁。

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

        5)typedef关键与任何内存存储无关,把它归于此类有一些语法上的原因。尤其是,在绝大多数情况下,不能在声明中使用多个存储类别的说明符,所以这意味着不能使用多个存储类别说明符作为tepedef的一部分。

        6)_Thread_local,它可以和static或extern一起使用

11.5动态内存管理

11.5.1出现原因

任何一个程序,正常运行都需要内存资源,用来存放诸如变量、常量、函数代码等等。这些不同的内容,所存储的内存区域是不同的,且不同的区域有不同的特性。因此我们需要研究财经处内存布局,逐个了解不同内存区域的特性。

每个C语言进程都拥有一片结构相同的虚拟内存,所谓的虚拟内存,就是从实际物理内存映射出来的地址规范范围,最重要的特征是所有的虚拟内存布局都是相同的,极大地方便内核管理不同的进程。例如三个完全不相干的进程p1、p2、p3,它们很显然会占据不同区段的物理内存,但经过系统的变换和映射,它们的虚拟内存的布局是完全一样的。

  • PM:Physical Memory,物理内存。
  • VM:Virtual Memory,虚拟内存。

将其中一个C语言含如进程的虚拟内存放大来看,会发现其内部包下区域:

  • 栈(stack)
  • 堆(heap)
  • 数据段
  • 代码段

虚拟内存中,内核区段对于应用程序而言是禁闭的,它们用于存放操作系统的关键性代码,另外由于 Linux 系统的历史性原因,在虚拟内存的最底端 0x0 ~ 0x08048000 之间也有一段禁闭的区段,该区段也是不可访问的。

虚拟内存中各个区段的详细内容:

栈内存

  • 什么东西存储在栈内存中?
    • 环境变量
    • 命令行参数
    • 局部变量(包括形参)
  • 栈内存有什么特点?
    • 空间有限,尤其在嵌入式环境下。因此不可以用来存储尺寸太大的变量。
    • 每当一个函数被调用,栈就会向下增长一段,用以存储该函数的局部变量。
    • 每当一个函数退出,栈就会向上缩减一段,将该函数的局部变量所占内存归还给系统。
  • 注意:
    栈内存的分配和释放,都是由系统规定的,我们无法干预。

数据段与代码段

  • 数据段细分成如下几个区域:
    • .bss 段:存放未初始化的静态数据,它们将被系统自动初始化为0
    • .data段:存放已初始化的静态数据
    • .rodata段:存放常量数据
  • 代码段细分成如下几个区域:
    • .text段:存放用户代码
    • .init段:存放系统初始化代码

堆内存

堆内存(heap)又被称为动态内存、自由内存,简称堆。堆是唯一可被开发者自定义的区段,开发者可以根据需要申请内存的大小、决定使用的时间长短等。但又由于这是一块系统“飞地”,所有的细节均由开发者自己把握,系统不对此做任何干预,给予开发者绝对的“自由”,但也正因如此,对开发者的内存管理提出了很高的要求。对堆内存的合理使用,几乎是软件开发中的一个永恒的话题。

  • 堆内存基本特征:
    • 相比栈内存,堆的总大小仅受限于物理内存,在物理内存允许的范围内,系统对堆内存的申请不做限制。
    • 相比栈内存,堆内存从下往上增长。
    • 堆内存是匿名的,只能由指针来访问。
    • 自定义分配的堆内存,除非开发者主动释放,否则永不释放,直到程序退出。

11.5.2动态内存函数介绍

5.2.1malloc

C语言提供动态内存开辟的函数malloc

函数原型:
#include <stdlib.h>
void* malloc(size_t size);
函数作用:申请堆内存

参数:size:内存块的大小,以字节为单位

返回值:该函数返回一个指针,指向已分配大小的内存。如果请求失败,则返回NULL

 这个函数向内存申请一块连续可用的空间,并返回指向这块空间的指针。

  • 如果开辟成功,则返回一个指向开辟好空间的指针。
  • 如果开辟失败,则返回一个NULL指针,因此malloc的返回值一定要做检查。
  • 返回值的类型是 void* ,所以malloc函数并不知道开辟空间的类型,具体在使用的时候使用者自己来决定。
  • 如果参数 size 为0,malloc的行为是标准是未定义的,取决于编译器。

5.2.2free

C语言提供另外一个函数free,专门是用来做动态内存的释放和回收的

函数原型:
#include <stdlib.h>
void free(viud* ptr);
函数功能:释放堆内存

参数:ptr:堆内存指针

返回值:无

 free函数用来释放动态开辟的内存。

  • 如果参数 ptr 指向的空间不是动态开辟的,那free函数的行为是未定义的。
  • 如果参数 ptr 是NULL指针,则函数什么事都不做。
int main(void)
{
	int* ptr = NULL;
	ptr = (int*)malloc(10 * sizeof(int));
	if (NULL != ptr)//判断ptr指针是否为空
	{
		int i = 0;
		for (i = 0; i < 10; i++)
		{
			*(ptr + i) = 0;
		}
	}
	free(ptr);//释放ptr所指向的动态内存
	ptr = NULL;

	return 0;
}

5.2.3calloc

C语言还提供了一个函数calloc,calloc函数也用来动态内存分配。

函数原型:
#include <stdlib.h>
void* calloc(size_t num,size_t size);
函数功能:申请堆内存

参数:num:所申请的堆内存的块数,所有的内存块是连续分布的、无间隔的
     size:所申请的一块堆内存的大小,单位是字节

返回值:如果成功,则返回指向分配号的堆内存的指针,失败则返回NULL
  •  函数的功能是为 num 个大小为 size 的元素开辟一块空间,并且把空间的每个字节初始化为0。
  • 与函数 malloc 的区别只在于 calloc 会在返回地址之前把申请的空间的每个字节初始化为全0。
  • malloc()申请的堆内存,默认情况下是随机值,一般需要用 bzero() 来清零。
  • calloc()申请的堆内存,默认情况下是已经清零了的,不需要再清零。
  • free()只能释放堆内存,并且只能释放整块堆内存,不能释放别的区段的内存或者释放一部分堆内存。
  • 释放内存的含义:

    • 释放内存意味着将内存的使用权归还给系统。
    • 释放内存并不会改变指针的指向。
    • 释放内存并不会对内存做任何修改,更不会将内存清零。
int main(void)
{
	int* p = (int*)calloc(10, sizeof(int));
	if (NULL != p)
	{
		//使用空间
	}
	free(p);
	p = NULL;
	return 0;
}

5.2.4realloc

realloc函数的出现让动态内存管理更加灵活。有时会我们发现过去申请的空间太小了,有时候我们又会觉得申请的空间过大了,那为了合理的时候内存,我们一定会对内存的大小做灵活的调整。那 realloc 函数就可以做到对动态开辟内存大小 的调整。

函数原型:
#include <stdlib.h>
void* realloc(void* ptr,size_t size);
函数功能:重新调整之前调用 malloc 或 calloc 所分配的 ptr 所指向的内存块的大小

参数:ptr:针指向一个要重新分配内存的内存块,该内存块之前是通过调用 malloc、calloc 或 realloc 进行分配内存的。
        如果为空指针,则会分配一个新的内存块,且函数返回一个指向它的指针。
    size:内存块的新的大小,以字节为单位。如果大小为 0,且 ptr 指向一个已存在的内存块,则 ptr 所指向的内存块会被释放,并返回一个空指针。

返回值:该函数返回一个指针 ,指向重新分配大小的内存。如果请求失败,则返回 NULL。

 这个函数调整原内存空间大小的基础上,还会将原来内存中的数据移动到 新 的空间。

realloc在调整内存空间的是存在两种情况:

1)原有空间之后有足够大的空间

2)原有空间之后没有足够大的空间

当是情况1 的时候,要扩展内存就直接原有内存之后直接追加空间,原来空间的数据不发生变化。 当是情况2 的时候,原有空间之后没有足够多的空间时,扩展的方法是:在堆空间上另找一个合适大小 的连续空间来使用。这样函数返回的是一个新的内存地址。

int main(void)
{
    int* ptr = (int*)malloc(100);
    if (ptr != NULL)
    {
        printf("malloc succeed\n");
    }
    else
    {
        perror("malloc fail");
        return -1;
    }

    //扩展容量
    ptr = (int*)realloc(ptr, 1000);

    int* p = NULL;
    p = realloc(ptr, 1000);
    if (p != NULL)
    {
        ptr = p;
    }
    free(ptr);

    return 0;
}

11.5.3动态内存错误

5.3.1对NULL指针的解引用操作

void test()
{
	int* p = (int*)malloc(INT_MAX / 4);
	*p = 20;//如果p的值是NULL,就会有问题
	free(p);
}

int main(void)
{
	test();
	return 0;
}

5.3.2对动态开辟空间的越界访问

void test()
{
	int i = 0;
	int* p = (int*)malloc(10 * sizeof(int));
	if (NULL == p)
	{
		return;
	}
	for (i = 0; i <= 10; i++)
	{
		*(p + i) = i;//当i是10的时候越界访问
	}
	free(p);
}


int main(void)
{
	test();
	return 0;
}

5.3.3对非动态开辟内存使用free释放

void test()
{
	int a = 10;
	int* p = &a;
	free(p);
}

int main(void)
{
	test();
	return 0;
}

5.3.4使用free释放一块动态开辟内存的一部分

void test()
{
	int* p = (int*)malloc(100);
	p++;
	free(p);//p不再指向动态内存的起始位置
}

int main(void)
{
	test();
	return 0;
}

5.3.5对同一块动态内存多次释放

void test()
{
	int* p = (int*)malloc(100);
	free(p);
	free(p);//重复释放
}

int main(void)
{
	test();
	return 0;
}

5.3.6动态开辟内存忘记释放(内存泄漏)

void test()
{
	int* p = (int*)malloc(100);
	if (NULL != p)
	{
		*p = 20;
	}
}

int main()
{
	test();
	while (1);

	return 0;
}

12、字符串函数

12.1求字符串长度

12.1.1strlen函数

C语言 strlen 函数用来求字符串的长度(包含多少个字符)。strlen() 函数从字符串的开头位置依次向后计数,直到遇见\0,然后返回计时器的值。最终统计的字符串长度不包括\0

函数头文件:
#include <string.h>

函数原型:
size_t strlen(const char* str);

参数:str:表示要求长度的字符串

返回值:字符串str的长度

sizeof 统计出的字符串长度比 strlen() 函数的统计值大 1。原因很简单,sizeof 统计了字符串结尾的\0,而 strlen() 函数没有。但是,sizeof 和 strlen() 函数的功能并不相同,strlen() 函数才是专门用来统计字符串长度,而 sizeof 不是。

sizeof() 和 strlen() 是 C 语言中两个非常常用的函数,它们都与计算内存大小有关,但是它们的作用是不同的。

sizeof() 和 strlen() 的主要区别在于:

  • sizeof() 是一个运算符,而 strlen() 是一个函数。
  • sizeof() 计算的是变量或类型所占用的内存字节数,而 strlen() 计算的是字符串中字符的个数。
  • sizeof() 可以用于任何类型的数据,而 strlen() 只能用于以空字符 '\0' 结尾的字符串。
  • sizeof() 计算字符串的长度,包含末尾的 '\0',strlen() 计算字符串的长度,不包含字符串末尾的 '\0'。

sizeof() 函数是一个运算符而不是函数,用于计算一个类型或变量所占用的内存字节数。可以用它来获取任何类型的数据的字节数,包括基本数据类型、数组、结构体、共用体等等。

sizeof:

sizeof(type)                //type:类型名
sizeof(variable)            //variable:变量名

strlen:

strlen(string)                //string:string 是一个以空字符 '\0' 结尾的字符串

测试案例代码如下:

int main(void)
{
	const char* str1 = "abcdef";
	const char* str2 = "bbb";
	if (strlen(str2) - strlen(str1) > 0)
	{
		printf("str2>str1\n");
	}
	else
	{
		printf("srt1>str2\n");
	}
	return 0;
}

12.2长度不受限制的字符串函数

12.2.1strcpy函数

C语言 strcpy() 函数用于对字符串进行复制(拷贝)。

函数头文件:
#include <string.h>

函数原型:
char* strcpy(char* strDestination, const char* strSource);

参数:strDestination:目的字符串,指向用于存储复制内容的目标数组。
        strSource:源字符串,要复制的字符串。

返回值:该函数返回一个指向最终的目标字符串 dest 的指针。

strcpy() 会把 strSource 指向的字符串复制到 strDestination。必须保证 strDestination 足够大,能够容纳下 strSource,否则会导致溢出错误。C语言提供一种限制长度的字符串函数strncpy。

  • 源字符串必须以 '\0' 结束。
  • 会将源字符串中的 '\0' 拷贝到目标空间。
  • 目标空间必须足够大,以确保能存放源字符串。
  • 目标空间必须可变

测试案例代码如下:

int main(void)
{
    char dest[50] = { 0 };
    char src[50] = { "sakura0908" };
    strcpy(dest, src);
    puts(dest);
    return 0;
}

 

12.2.2strcat函数

C 库函数 strcat 把 src 所指向的字符串追加到 dest 所指向的字符串的结尾。也可以说为用来将两个字符串连接(拼接)起来。

函数头文件:
#include <string.h>

函数原型:
char*strcat(char* strDestination, const char* strSource);

参数:strDestination:目的字符串,指向目标数组,该数组包含了一个 C 字符串,且足够容纳追加后的字符串。
     strSource:源字符串,指向要追加的字符串,该字符串不会覆盖目标字符串。

返回值:该函数返回一个指向最终的目标字符串 dest 的指针

strcat() 函数把 strSource 所指向的字符串追加到 strDestination 所指向的字符串的结尾,所以必须要保证 strDestination 有足够的内存空间来容纳两个字符串,否则会导致溢出错误。C语言提供一种限制长度的字符串函数strncat。
注意:strDestination 末尾的\0会被覆盖,strSource 末尾的\0会一起被复制过去,最终的字符串只有一个\0

  • 源字符串必须以 '\0' 结束。
  • 目标空间必须有足够的大,能容纳下源字符串的内容。
  • 目标空间必须可修改。

测试案例代码如下:

int main()
{
	char src[50], dest[50];

	strcpy(src, "This is source");
	strcpy(dest, "This is destination");

	strcat(dest, src);

	printf("最终的目标字符串: |%s|", dest);

	return(0);
}

12.2.3strcmp函数

C语言 strcmp() 函数用于对两个字符串进行比较(区分大小写)。

函数头文件:
#include <string.h>

函数原型:
int strcmp(const char *str1, const char *str2);

参数:str1 -- 要进行比较的第一个字符串。
    str2 -- 要进行比较的第二个字符串。

返回值:该函数返回值如下:
        如果返回值小于 0,则表示 str1 小于 str2。
        如果返回值大于 0,则表示 str1 大于 str2。
        如果返回值等于 0,则表示 str1 等于 str2。

strcmp() 会根据 ASCII 编码依次比较 str1 和 str2 的每一个字符,直到出现不到的字符,或者到达字符串末尾(遇见\0)。

注意,C语言标准并没有具体规定 strcmp() 函数的返回值是多少,大多数编译器选择了以下两种方案:

  1. 返回两个字符串的差值,即找到两个字符串中首个不相等的字符,然后返回这两个字符的差值;
  2. 返回 -1、0 或者 +1;

例如,glibc 库(GNU C 运行时库)中使用的是第一种方案,而微软编译器使用的是第二种方案 

测试案例代码如下:

int main() 
{
    char str1[50] = { 0 };
    char str2[50] = { 0 };
    int i = 1;
    do {
        printf("******第%d次输入******\n", i);
        gets(str1);
        gets(str2);
        i++;
    } while (strcmp(str1, str2));
    return 0;
}

12.3长度受限制的字符串函数介绍

12.3.1strncpy函数

C 库函数 strncpy把 src 所指向的字符串复制到 dest,最多复制 n 个字符。当 src 的长度小于 n 时,dest 的剩余部分将用空字节填充,相当于升级版的strcpy函数

函数头文件:
#include <string.h>

函数原型:
char *strncpy(char *dest, const char *src, size_t n);

参数:dest:指向用于存储复制内容的目标数组。
        src:要复制的字符串。
        n:要从源中复制的字符数。

返回值:该函数返回最终复制的字符串。

测试案例代码如下:

int main()
{
	char src[40] = { 0 };
	char dest[12] = { 0 };

	strcpy(src, "This is sakura0908");
	strncpy(dest, src, 10);

	printf("最终的目标字符串: %s\n", dest);

	return(0);
}

12.3.2strncat函数

C 库函数 strncat把 src 所指向的字符串追加到 dest 所指向的字符串的结尾,直到 n 字符长度为止,相当于升级版的strcat函数。

函数头文件:
#include <string.h>

函数原型:
char *strncat(char *dest, const char *src, size_t n);

参数:dest:指向目标数组,该数组包含了一个 C 字符串,且足够容纳追加后的字符串,包括额外的空字符。
        src:要追加的字符串。
        n:要追加的最大字符数。

返回值:该函数返回一个指向最终的目标字符串 dest 的指针。

 测试案例代码如下:

int main ()
{
   char src[50], dest[50];

   strcpy(src,  "This is source");
   strcpy(dest, "This is destination");

   strncat(dest, src, 15);

   printf("最终的目标字符串: |%s|", dest);
   
   return(0);
}

1.3.3strncmp函数

C 库函数strncmp把 str1 和 str2 进行比较,最多比较前 n 个字节,相当于升级版的strcmp函数。

函数头文件:
#include <string.h>

函数原型:
int strncmp(const char *str1, const char *str2, size_t n);

参数:str1:要进行比较的第一个字符串。
        str2:要进行比较的第二个字符串。
        n:要比较的最大字符数。

返回值:该函数返回值如下:
        如果返回值 < 0,则表示 str1 小于 str2。
        如果返回值 > 0,则表示 str1 大于 str2。
        如果返回值 = 0,则表示 str1 等于 str2。

 测试案例代码如下:

int main()
{
	char str1[15];
	char str2[15];
	int ret;


	strcpy(str1, "abcdef");
	strcpy(str2, "ABCDEF");

	ret = strncmp(str1, str2, 4);

	if (ret < 0)
	{
		printf("str1 小于 str2");
	}
	else if (ret > 0)
	{
		printf("str2 小于 str1");
	}
	else
	{
		printf("str1 等于 str2");
	}

	return(0);
}

 

12.4字符串查找

12.4.1strstr函数

C 库函数 strstr在字符串 haystack 中查找第一次出现字符串 needle 的位置,不包含终止符 '\0',用于查找字符串。

函数头文件:
#include <string.h>

函数原型:
char *strstr(const char *haystack, const char *needle);

参数:haystack:要被检索的 C 字符串。
        needle:在 haystack 字符串内要搜索的小字符串。

返回值:该函数返回在 haystack 中第一次出现 needle 字符串的位置,如果未找到则返回 NULL。

 测试案例代码如下:

int main()
{
	const char haystack[20] = "sakura0908";
	const char needle1[10] = "0908";
	const char needle2[10] = "hello";
	char* ret = NULL;

	ret = strstr(haystack, needle1);
	printf("子字符串是: %s\n", ret);

	ret = strstr(haystack, needle2);
	printf("子字符串是: %s\n", ret);
	
	return 0;
}

12.4.2strtok函数

C库函数strtok切割字符串,将str切分成一个个字串。

函数头文件:
#include <string.h>

函数原型:
char *strtok(char *str, const char *delim);

参数:str:要被分解的字符串
    delim:用作分隔符的字符(可以是一个,也可以是集合)

返回值:该函数返回被分解的第一个子字符串,若无可检索的字符串,则返回空指针

 测试代码案例如下:

int main()
{
    char str[] = "https://blog.csdn.net/sakura0908?spm=1010.2135.3001.5343";
    char* pch = NULL;
    printf("%s\n", str);
    pch = strtok(str, ":/?=.");
    while (pch != NULL)
    {
        printf("%s\n", pch);
        pch = strtok(NULL, " ,.-");
    }
    return 0;
}

  1. 第一次调用strtok(),传入的参数str是要被分割的字符串,而成功后返回的是第一个子字符串{http};
  2. 而第二次调用strtok的时候,传入的参数应该为NULL,使得该函数默认使用上一次未分割完的字符串继续分割 ,就从上一次分割的位置{//blog}作为本次分割的起始位置,直到分割结束。
  3. strtok函数的第一个参数不为 NULL ,函数将找到str中第一个标记,strtok函数将保存它在字符串中的位置。
  4. strtok函数的第一个参数为 NULL ,函数将在同一个字符串中被保存的位置开始,查找下一个标记。
  5. 如果字符串中不存在更多的标记,则返回 NULL 指针。

strtok注意事项:

1)delim 替换成 \0

在这个函数里strtok()在分解字符串的时候,第一个参数str是在不断变化的,这个函数是在改变原字符串,把原字符串通过第二个参数delim将所有的分割符{:?=.}替换成字符串结束标志字符{\0},则原字符串变化为{https\0//blog\0csdn\0net/sakura0908?spm=1010\02135\03001\05343\0}

错误案例代码如下:

int main() 
{
    char* str = "https://blog.csdn.net/sakura0908?spm=1010.2135.3001.5343";
    const char s[2] = "/";
    char* token;

    // 获取第一个子字符串 
    token = strtok(str, s);

    // 继续获取其他的子字符串 
    while (token != NULL)
    {
        printf("%s\n", token);
        token = strtok(NULL, s);
    }

    return 0;
}

注意:在这里,我实现函数的时候将字符串数组直接用指针指向它了,结果运行错误,后面发现虽然第一个参数是可以传指针,但我们要考虑空间内存布局,在strtok()函数里是delim的{分隔符}替换{ \0}改变原字符串,而我们用指针指向这个字符串的时候,其实指向的是字符串常量,它的内存分布在文字常量区是不可被改变的,所以出现了错误!

(2)delim分隔符

  • strtok()的第二个参数delim,delim里可以是所有分隔符的集合,第二个参数delim可以是{:?/.},用一个或多个分隔符去分解字符串都可以

(3)delim分隔符可不可以出现在第一个字符?
当strtok分解的字符串首字符就是分隔符,那么strtok()会忽略首个分隔符,直接从第二个分隔符往下继续分解,例如:{- aaa - bbb} 那么strtok()会忽略第一个{-},还是以{aaa - bbb}的字符串形式继续分解。

12.5错误信息报告

12.5.1strerror函数

C 库函数 strerro从内部数组中搜索错误号 errnum,并返回一个指向错误消息字符串的指针。strerror 生成的错误字符串取决于开发平台和编译器。

函数头文件:
#include <string.h>
#include <errno.h>

函数原型:
char *strerror(int errnum);

参数:errnum:错误号,通常是 errno

返回值:该函数返回一个指向错误字符串的指针,该错误字符串描述了错误 errnum

测试案例代码如下:

int main()
{
    FILE* pf = fopen("unexist.ent", "r");
    if (pf == NULL)
        printf("Error opening file unexist.ent: %s\n", strerror(errno));

    return 0;
}

字符分类函数
函数如果他的参数复合下列条件就返回值
iscntrl任何控制字符
isspace空白字符:空格‘ ’,换页‘\f’,换行'\n',回车‘\r’,制表符'\t'或者垂直制表符'\v'
isdigit十进制数字 0~9
isxdigit十六进制数字,包括所有十进制数字,小写字母a~f,大写字母A~F
islower小写字母a~z
isupper大写字母A~Z
isalpha字母a~z或A~Z
isalnum字母或者数字,a~z,A~Z,0~9
ispunct标点符号,任何不属于数字或者字母的图形字符(可打印)
isgraph任何图形字符
isprint任何可打印字符,包括图形字符和空白字符

12.6常用字符串函数模拟实现

 1、strlen模拟实现

size_t my_strlen(const char* str)
{
	assert(str);
	size_t ret = 0;
	while (*str)
	{
		str++;
		ret++;
	}
	return ret;
}

2、strcpy模拟实现

char* my_strcpy(char* dest, const char* src)
{
	assert(dest && src);
	char* ret = dest;
	while (*dest++ = *src++)
	{
		;
	}
	return ret;
}

3、strncpy模拟实现

char* my_strncpy(char* dest, const char* src, size_t sz)
{
	assert(dest && src);
	char* ret = dest;
	while (sz)
	{
		*dest++ = *src++;
		sz--;
	}
	if (sz)
	{
		while (--sz)
		{
			*dest++ = '\0';
		}
	}
	return dest;
}

4、strcat模拟实现

char* my_strcat(char* dest, const char* src)
{
	assert(dest && src);
	char* ret = dest;
	while (*dest)
	{
		dest++;
	}
	while (*dest++ = *src++)
	{
		;
	}
	return ret;
}

5、strncat模拟实现

char* my_strncat(char* dest, const char* src,int sz)
{
	assert(dest && src);
	char* ret = dest;
	while (*dest)
	{
		dest++;
	}
	while (sz--)
	{
		*dest++ = *src++;
	}
	dest = '\0';
	return ret;
}

6、strcmp模拟实现

int my_strcmp(const char* str1, const char* str2)
{
	int ret = 0;
	while (*str1 == *str2)
	{
		if (*str1 == '\0')
		{
			return 0;
		}
		str1++;
		str2++;
	}
    //代码一
	//if (*str1 > *str2)
	//{
	//	return 1;
	//}
	//else
	//{
	//	return -1;
	//}
    //代码二
	return *str1 - *str2;
}

7、strncmp模拟实现

int my_strncmp(const char* str1, const char* str2,int sz)
{
	int ret = 0;
	while (sz-- && !(ret = (unsigned char)*str1 - (unsigned char)*str2))
	{
		str1++;
		str2++;
	}
	return ret;
}

8、strstr模拟实现

char* my_strstr(const char* str1,const char* str2)
{
	assert(str1 && str2);
	const char* s1 = str1;
	const char* s2 = str2;
	char* cp = str1;
	while (*cp)
	{
		s1 = cp;
		s2 = str2;
		while (*s1 && *s2 && (* s1 == *s2))
		{
			s1++;
			s2++;
		}
		if (*s2 == '\0')
		{
			return cp;
		}
		cp++;
	}
	return NULL;
}

13、内存函数

13.1memcpy函数

C 库函数 memcpy 从存储区 str2 复制 n 个字节到存储区 str1。这个函数在遇到'\0'的时候并不会停下来。如果str1和str2有任何的重叠,复制的结果都是未定义的。

memcpy与strcpy的区别

1.复制的内容不同。strcpy只能复制字符串,而memcpy可以复制任意内容,例如字符数组、整型、结构体、类等。

2.复制的方法不同。strcpy不需要指定长度,它遇到被复制字符的串结束符"\0"才结束,所以容易溢出。memcpy则是根据其第3个参数决定复制的长度。

3.用途不同。通常在复制字符串时用strcpy,而需要复制其他类型数据时则一般用memcpy

函数头文件:
#include <string.h>

函数原型:
void *memcpy(void *str1, const void *str2, size_t n);

参数:str1:指向用于存储复制内容的目标数组,类型强制转换为 void* 指针。
    str2:指向要复制的数据源,类型强制转换为 void* 指针。
    n:要被复制的字节数。

返回值:该函数返回一个指向目标存储区 str1 的指针。

 测试案例代码如下:

int main()
{
	const char src[50] = "sakura0908";
	char dest[50] = { 0 };

	memcpy(dest, src, strlen(src) + 1);
	printf("dest = %s\n", dest);

	return(0);
}

13.2memmove函数

C库函数memmove用于内存拷贝的函数,没有类型限制,但是memmove使用要考虑内存重叠问题,memmove() 是比 memcpy() 更安全的方法。如果目标区域和源区域有重叠的话,memmove() 能够保证源串在被覆盖之前将重叠区域的字节拷贝到目标区域中,复制后源区域的内容会被更改。如果目标区域与源区域没有重叠,则和 memcpy() 函数功能相同

函数头文件:
#inlcude <string.h>

函数原型:
void * memmove(void * destination, const void * source, size_t num);

参数:destination:指向用于存储复制内容的目标数组,类型强制转换为 void* 指针。
    source:指向要复制的数据源,类型强制转换为 void* 指针。
    num:要被复制的字节数。

返回值:该函数返回一个指向目标存储区 destination 的指针。

 测试案例代码如下:

int main()
{
	char str[] = "memmove can be very useful......";
	memmove(str + 20, str + 15, 11);
	printf("str = %s\n", str);
	return 0;
}

13.3memset函数

定义变量时一定要进行初始化,尤其是数组和结构体这种占用内存大的数据结构。在使用数组的时候经常因为没有初始化而产生“烫烫烫烫烫烫”这样的野值,俗称“乱码”。

每种类型的变量都有各自的初始化方法,memset() 函数可以说是初始化内存的“万能函数”,通常为新申请的内存进行初始化工作。它是直接操作内存空间,mem即“内存”(memory)的意思

函数头文件:
#include <string.h>

函数原型:
void *memset(void *s, int c, unsigned long n);

参数:s:void* 型的指针变量,可以为任何类型的数据进行初始化,指向要填充的内存块
        c:要被设置的值。该值以 int 形式传递,但是函数在填充内存块时是使用该值的无符号字符形式
        n:要被设置为该值的字符数

返回值:该值返回一个指向存储区 s 的指针

 注意:

  • memset赋值为任意字符都是可以的,初始化成0是最常用的。int类型的一般都是赋值0或-1,其他的值都不行。

  • memset赋值的时候是按字节赋值,是将参数化成二进制之后填入一个字节。

  • 若str指向char型地址,value可为任意字符值;若str指向非char型,如int型地址,要想赋值正确,value的值只能是-1或0,因为-1和0转化成二进制后每一位都是一样的,设int型占4个字节,则-1=0XFFFFFFFF, 0=0X00000000。

测试案例代码如下:

int main()
{
	char str[50];

	strcpy(str, "This is sakura0908's csdn!");
	puts(str);

	memset(str, '$', 7);
	puts(str);

	return(0);
}

13.4memcmp函数

C 库函数 memcmp 把存储区 str1 和存储区 str2 的前 n 个字节进行比较。

函数头文件:
#include <string.h>

函数原型:
int memcmp(const void *str1, const void *str2, size_t n);

参数:str1:指向内存块的指针。
    str2:指向内存块的指针。
    n:要被比较的字节数。

返回值:
如果返回值 < 0,则表示 str1 小于 str2。
如果返回值 > 0,则表示 str1 大于 str2。
如果返回值 = 0,则表示 str1 等于 str2。

strcmp函数与memcmp函数的区别

二者都可以用于字符串的比较,但是二者是有比较大的差异的,因为strcmp是按照字节(byte-wise)比较的,并且比较的过程中会检查是否出现了"/0"结束符,一旦任意一个字符串指针前进过程中遇到结束符,将终止比较。而memcmp函数是用于比较两个内存块的内容是否相等,在用于字符串比较时通常用于测试字符串是否相等,不常进行byte-wise的字符串比较。如果要比较的对象中包含一些由于边界对齐需求而填入结构对象中的空格、联合 (union)结束的额外空格、字符串所分配的空间未使用完的部分引起的“holes”的话,最好使用memcmp来完成。这些“holes”的内容是不确定的,在执行byte-wise比较时结果也是不明确的。

测试案例代码如下:

int main()
{
    char str1[15];
    char str2[15];
    int ret;

    memcpy(str1, "abcdef", 6);
    memcpy(str2, "ABCDEF", 6);

    ret = memcmp(str1, str2, 5);

    if (ret > 0)
    {
        printf("str2 小于 str1");
    }
    else if (ret < 0)
    {
        printf("str1 小于 str2");
    }
    else
    {
        printf("str1 等于 str2");
    }

    return(0);
}

13.5常用内存函数模拟实现 

 1、memcpy模拟实现

void *my_memcpy(void* dest, const void* src, size_t num)
{
	assert(dest && src);
	void* ret = dest;
	
	while (num--)
	{
		*(char*)dest = *(char*)src;
		dest = (char*)dest + 1;
		src = (char*)src + 1;
	}
	return ret;
}

2、memmove模拟实现

void* my_memmove(void* dest, const void* src, size_t num)
{
	void* ret = dest;
	assert(dest && src);

	if (dest < src)
	{
		//从前->后
		while(num--)
		{
			*(char*)dest = *(char*)src;
			dest = (char*)dest + 1;
			src = (char*)src + 1;
		}
	}
	else
	{
		//从后->前
		while (num--)
		{
			*((char*)dest + num) = *((char*)src + num);
		}
	}
	return ret;
}

14、编译与预处理

14.1.1 ANSI C 标准

C语言发展之初,并没有所谓的C标准。1978年,布莱恩·柯林汉和丹尼斯·里奇合著的The C Programming Language(《C语言程序设计》)第一版是公认的C标准,通常称之为K&R C或经典C。随着C的不断发展,越来越广泛地应用于更多系统中,C社区意识到需要一个更全面、更新颖、更严格的标准。美国国家标准协会(ANSI)于1983年组建了一个委员会,开发了一套新标准,并于1989年正式公布。该标准(ANSI C)定义了C语言和C标准库。国际标准化组织于990年采用了这套C标准(ISO C)。ISO C和ANSI C是完全相同的标准。ANSI/ISO标准的最终版本通常叫做C89(C90)。另外,由于ANSI先公布C标准,因此业界认识通常使用ANSI C。1994年,ANSI/ISO联合委员会开始修改C标准,最终发布了C99标准。该委员会遵循了最初C90标准的原则,包括保持语言的精炼简单。委员会的用意不是在C语言中添加新特性,而是为了达到新的目标。

ANSI C是由美国国家标准协会(ANSI)及国际化标准组织(ISO)推出的关于C语言的标准。
ANSI C 主要标准化了现存的实现, 同时增加了一些来自 C++ 的内容 (主要是函数原型)
并支持多国字符集 (包括备受争议的三字符序列)。

14.1.2程序的翻译环境和执行环境

ANSI C 的任何一种实现中,存在两种不同的环境:

翻译环境:在该环境中,源代码被转换为可执行的机器指令

执行环境:用于实际执行代码

add.c
#include "add.h"


int add(int a, int b)
{
	return a + b;
}

test.c
#includde "add.h"
int main(void)
{
    int ret = add(3,4);
    printf("a + b = %d\n",ret);

    return 0;
}

组成一个程序的每个源文件通过编译过程分别转换成目标代码(object code)

每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序。

连接器同时也会引入标准C库函数中任何被该程序所用到的函数,且可以搜索程序员个人的程序库,将其需要的函数也链接到程序中。

举个例子:test.c、add.c、minu.c

14.1.3运行环境

程序执行过程:

  1. 程序必须载入内存中。在有操作系统的环境中:程序的载入一般由操作系统完成。在独立环境中:程序的载入必须手工安排,也可能是通过可执行代码置入只读内存来完成。
  2. 程序的执行便开始。接着便调用 main 函数。
  3. 开始执行程序代码,这个时候程序将使用一个运行时堆栈(stack),内存函数的局部变量和返回地址。程序同时也可以使用静态(staic)内存,存储与静态内存中的变量在整个执行过程中一直保留他们的值。
  4. 终止程序。正常终止 main 函数(也有可能是意外终止)。

14.2预处理详解

14.2.1预定义符号

__FILE__      //进行编译的源文件,表示当前源代码文件名的字符串字面量

__LINE__     //文件当前的行号,表示当前源代码文件中的行号的整型常量

__DATE__    //文件被编译的日期,预处理的日期

__TIME__    //文件被编译的时间,翻译代码的时间

__FUNCTION__  //文件被编译的函数名

__STDC__    //如果编译器遵循ANSI C,其值为1,否则未定义

__STDC_HOSTED__   //本机环境设置为1;否则设置为0

__STDC_VERSION__  //支持C99标准,设置为199901L:支持C11标准,设置201112L

void print(void) 
{
	printf("文件被编译的函数名:%s\n", __FUNCTION__);
}

int main(void)
{
	printf("进行编译的源文件:%s\n", __FILE__);
	printf("文件当前的行号:%d\n", __LINE__);
	printf("文件被编译的日期:%s\n", __DATE__);
	printf("文件被编译的时间:%s\n", __TIME__);
	printf("文件被编译的函数名:%s\n", __FUNCTION__);
	//printf("如果编译器遵循ANSI C,其值为1,否则未定义:%d\n", __STDC__);

	printf("---------------------\n");
	print();

	return 0;
}

 我使用的的编译器为vs2022版本,对于__STDC__这个符号没有定义,这些预定义符号都是语言内置的。

14.2.2、#define

(#define定义的标识符和宏和枚举一样,习惯用大写)(程序员的约定俗成)

2.2.1#define定义表示符

语法:#define name stuff

name:替换的名字

stuff:被替换之后的内容

根据上面的语法我们就可以写出下面的例子:

#define MAX 1000

#define DEBUG_PRINT printf("file:%s\tline:%d\t \

                         date:%s\ttime:%s\n" ,\

                        __FILE__,__LINE__ ,\        

                        __DATE__,__TIME__ )

stuff是可以分开几行写,只是需添加反斜杠(续航符)

这里有一个很特殊的问题了,之前语句篇(C语言入门篇——语句篇_sakura0908的博客-CSDN博客)中说语句都是用分号结尾,那这里的最后要不要添加分号呢?在一些场景中会容易导致问题(语法错误),建议不要加上分号

2.2.2#define定义宏

#define name(parament-list) stuff

name:替换的名字

parament-list:由逗号隔开的符号表(参数列表)

stuff:被替换之后的内容

介绍:#define 机制允许把参数替换到文本中,这种实现通常被称为宏(macro)或定义宏(define macro),parament-list 是一个由逗号隔开的符号表,他们可能出现在 stuff 中。

注意事项:

  • 参数列表的左括号必须与 name 紧邻。
  • 如果两者之间由任何空白存在,参数列表就会将其解释为 stuff 的一部分。

测试案例代码如下:

#define Multiply(X) X*X

int main(void)
{
	printf("X * X = %d\n", Multiply(3));

	return 0;
}

 

那么 Multiply(3+1) 的结果是什么?一些初学者可能认为是16,但当运行之后答案却不一样,这是什么原因呢?

怎么去理解这答案呢?要把宏定义中的参数列表作为一个整体完全替换。宏的参数是完成替换的,他不会提前完成计算,而是替换进去后再计算。替换是在预处理阶段时替换,表达式真正计算出结果是在运行时计算。如果想获得 3+1 相乘(也就是得到 4×4 = 16) 的结果,我们需要给他们添加括号:

#define Multiply(X) X*X
#define Multiply2(X) (X)*(X)

int main(void)
{
	printf("X * X = %d\n", Multiply(3 + 1));
	printf("X * X = %d\n", Multiply2(3 + 1));

	return 0;
}

 另外,整体再套一个括号!让代码更加严谨,防止产生不必要的错误。

#define Multiply3(X) ((X)*(X))

结论:所以用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,可以有效避免在使用宏时由于参数中的操作符或邻近操作符之间不可预料地相互作用。

2.2.3#define替换规则

在程序中扩展 #define 定义符号或宏时,需要涉及的步骤如下:

  1. 检查:在调用宏时,首先对参数进行检查,看看是否包含任何由 #define 定义的符号。如果包含,它们首先被替换。 替换:替换文本随后被插入到程序中原来的文本位置。
  2. 对于宏,函数名被它们的值替换。
  3. 再次扫描:最后,再次对结果文件进行扫描,看看是否包含任何由 #define 定义的符号。

如果包含,就重复上述处理过程。 

注意:宏参数和#define定义中可以出现#define定义的变量,但是对于宏绝对不能出现递归;当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索

2.4#和##

2.4.1#的作用

这里所说的#并不是#define和#include中的#,这里所说的#的作用是:把一个宏参数变成对应的字符串

这个#到底有什么实际的作用呢?在介绍#的作用的之前,我先向大家说明一下:字符串是有自动连接的特点的

    char arr[] = "hello ""world!";
    //等价于char arr[] = "hello world!";
    printf("helll ""world!\n");
    //等价于printf("helll world!\n");

int main(void)
{
	int age = 22;
	printf("The value of age is %d\n", age);
	double pi = 3.1415;
	printf("The value of pi is %f\n", pi);
	int* p = &age;
	printf("The value of p is %p\n", p);
	return 0;
}

 printf要打印的内容大部分是一样的,那么,为了避免代码冗余,我们可不可以将其封装成一个函数或是宏呢?这时就需要用到这个#了

#define print(data,format) printf("The value of "#data" is "format"\n",data)
int main()
{
	int age = 22;
	print(age, "%d");
	double pi = 3.1415;
	print(pi, "%f");
	int* p = &age;
	print(p, "%p");
	return 0;
}

2.4.2##的作用

##可以把位于它两边的符号合成一个符号。 它允许宏定义从分离的文本片段创建标识符

例如,下面定义的宏可以将传入的两个符号合成一个符号,测试案例代码如下:

#define STRCAT(x,y) x##y
int main(void)
{
	int xy = 100;
	printf("%d\n", STRCAT(x, y));//打印什么?
	return 0;
}

2.2.5带副作用的宏参数

在介绍带副作用的宏参数之前,我们先看看带有副作用是什么意思

int a = 10;	
int b = a + 1;//无副作用
int c = a++;//有副作用

代码中,b和c都想得到a+1的值,但不改变a的值。b得到a+1的值后,a的值并没有发生改变,所以无副作用;但是c得到a+1的值后,a的值也变化了,也就是有副作用。简单来说,代码执行后,除了达到我们想要的结果之外,还导致了其他问题的发生,我们就说该条语句带有副作用。

当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现的永久性效果。
例如,我们要比较a和b的大小,并将其较大值赋值给c,之后再将a和b同时加1。

#define MAX(x,y) ((x)>(y)?(x):(y))
int main(void)
{
	int a = 10;
	int b = 20;
	int c = MAX(a++, b++);
	printf("%d\n", c);
	return 0;
}

 

 这段代码看似没有问题,但是结果却是不正确的,因为该宏经过替换后,等价于以下代码:

int main(void)
{
	int a = 10;
	int b = 20;
	int c = ((a++)>(b++)?(a++):(b++));
	printf("%d\n", c);
	return 0;
}

经过替换后,我们一分析便可得出答案,c的最后的结果是21,并且代码执行后,a和b的值并不是同时加1,a的值变为了11,而b的值却变为了22。

 所以,当我们使用宏的时候,应该避免传入带有副作用的宏参数

2.2.6宏和函数对比

宏与函数的对比表
属性#define定义宏函数
代码长度每次使用时,宏代码都会被插入到程序中。除了非常 小的宏之外,程序的长度会大幅度增长函数代码只出现于一个地方;每 次使用这个函数时,都调用那个 地方的同一份代码
执行速度更快存在函数的调用和返回的额外开 销,所以相对慢一些
操作符优先级宏参数的求值是在所有周围表达式的上下文环境里, 除非加上括号,否则邻近操作符的优先级可能会产生 不可预料的后果,所以建议宏在书写的时候多些括 号函数参数只在函数调用的时候求 值一次,它的结果值传递给函 数。表达式的求值结果更容易预测
带有副作用的参数参数可能被替换到宏体中的多个位置,所以带有副作 用的参数求值可能会产生不可预料的结果函数参数只在传参的时候求值一 次,结果更容易控制
参数类型宏的参数与类型无关,只要对参数的操作是合法的, 它就可以使用于任何参数类型函数的参数是与类型有关的,如 果参数的类型不同,就需要不同 的函数,即使他们执行的任务是 不同的
调试宏是不方便调试的函数是可以逐语句调试的
递归宏是不能递归的函数是可以递归的

14.3#undef

#undef NAME 用于移除一个宏定义。(也不用在后面加分号)

#undef NAME

//如果现存的一个名字需要被重新定义,那么它的旧名字首先要被移除

测试案例代码如下:

#define TEST 1
int main(void)
{
    int a = TEST;
    printf("%d\n", TEST);

#undef TEST// 移除宏定义

    return 0;
}

14.4命令行定义

许多C编译器提供了一种能力,允许你在命令行中定义符号,用于启动编译过程。例如:当我们根据同一个源文件要编译出不同的一个程序的不同版本的时候,这个特性便起到了作用。(假定某个程序中声明了一个某长度的数组,但是一个机器的内存有限,我们需要一个很小的数组,但是另外一个机器的内存很大,我们需要一个较大的数组。)

#include <stdio.h>
int main()
{
	int array[ARRAY_SIZE];
	int i = 0;
	for (i = 0; i< ARRAY_SIZE; i++)
	{
		array[i] = i;
	}
	for (i = 0; i< ARRAY_SIZE; i++)
	{
		printf("%d ", array[i]);
	}
	printf("\n");
	return 0;
}

可以看到,代码中没有明确定义数组的大小。在编译这种代码时,我们需要使用命令行对数组的大小进行定义。

例如,在Linux环境下,编译指令如下:

gcc -D programe.c ARRAY_SIZE = 10

经过该编译指令后,便可以打印出0到9的数字。

14.5条件编译

条件编译,即满足条件就参与编译,不满足条件就不参与编译。

在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。

常见的条件编译指令有以下几种:
1.单分支的条件编译

#if 表达式
    //待定代码
#endif

如果#if后面的表达式为真,则“待定代码”的内容将参与编译,否则“待定代码”的内容不参与编译。

2.多分支的条件编译

#if 表达式
    //待定代码1
#elif 表达式
    //待定代码2
#elif 表达式
    //待定代码3
#else 表达式
    //待定代码4
#endif

多分支的条件编译类似于if-else语句,“待定代码1,2,3,4”之中只会有一段代码参与编译。

3.判断是否被定义

//第一种的正面
#if defined(表达式)
    //待定代码
#endif

//第一种的反面
#if !defined(表达式)
    //待定代码
#endif

如果“表达式”被#define定义过,则“第一种的正面”的“待定代码”将参与编译,否则不参与编译。“第一种的反面”的执行机制与“第一种的正面”恰好相反。

//第二种的正面
#ifdef 表达式
    //待定代码
#endif

//第二种的反面
#ifndef 表达式
    //待定代码
#endif

如果“表达式”被#define定义过,则“第二种的正面”的“待定代码”将参与编译,否则不参与编译。“第二种的反面”的执行机制与“第二种的正面”恰好相反。

4.嵌套指令

#include <stdio.h>
#define MIN 10
int main()
{
#if !defined(MAX)
#ifdef MIN
    printf("hello\n");
#else
    printf("world\n");
#endif
#endif
    return 0;
}

这里条件编译指令的嵌套类似于if-else语句的嵌套,详情可阅读此篇博客(C语言入门篇——语句篇_sakura0908的博客-CSDN博客),博友们可以类比理解。

注意:未满足条件编译指令的代码,在预处理阶段将被编译器自动删除,不参与后面的代码编译过程。
例如,以下代码:

#include <stdio.h>
int main()
{
    int i = 0;
    for (i = 0; i < 10; i++)
    {
        printf("%d\n", i);
#if 0
        printf("hello world!\n");
#endif
    }
    return 0;
}

因为#if后面的表达式为假,语句 #if 0 和 #endif 之间的代码将不参与编译,所以在预处理阶段过后,编译器编译的代码是:

//#include <stdio.h>
//预处理阶段头文件也被包含了
int main()
{
    int i = 0;
    for (i = 0; i < 10; i++)
    {
        printf("%d\n", i);
    }
    return 0;
}

所以,代码运行后只会打印0到9的数字。

14.6文件包含

我们知道,#include指令可以使被包含的文件参与编译,在预处理阶段,就会进行文件的包含
例如:

#include <stdio.h>

在预处理阶段,编译器会先删除该指令,并用stdio.h文件中的内容进行替换。

但是,文件的包含有两种:

#include <stdio.h>
#include "stdio.h"

一种是用尖括号将要包含的文件括起来,另一种是用双引号将要包含的文件引起来。这两种方法,在某些情况下似乎都可行,那么这两种方法到底有什么区别呢?

< >:如果使用尖括号的方式对头文件进行包含,那么当代码运行到预处理阶段,将对头文件进行包含时,编译器会自动去自己的安装路径下查找库目录,若库目录中含有该头文件,则将其进行包含,若库目录下不存在该头文件,则提示编译错误。

" ":如果使用双引号的方式对头文件进行包含,那么当代码运行到预处理阶段,将对头文件进行包含时,编译器会首先去正在编译的源文件目录下进行查找,若没有找到目标头文件,则再去库目录下进行查找,若两处都没有找到目标头文件,则提示编译错误。

这样看来,当我们要包含的头文件是库函数的头文件的时候,我们使用尖括号或者双引号都可以,但是当我们要包含的头文件是自定义的头文件时,我们只能用双引号进行头文件的包含

但是如果我们明明知道自己要包含的头文件是库函数的头文件,那我们就没有必要使用双引号去包含,因为那样会降低代码的效率。所以说,为了提高代码执行效率:
< >:一般用于包含C语言提供的库函数的头文件。
" ":一般用于包含自定义的头文件。

关于头文件,还有一点值得注意的是,当我们使用#include来包含头文件时,如果我们重复包含同一个头文件,那么在预处理阶段就会重复包含该头文件的内容,会大大加长代码量,导致代码冗余。

避免该问题的发生,有以下两种方法(以add.h为例):
方法一:

#ifndef __ADD_H__
#define __ADD_H__

//头文件内容

#endif

当第一次包含该头文件时,会用#define定义符号__ADD_H__,当第二次重复包含该头文件时,因为__ADD_H__已经被定义过,就无法再次包含该头文件的内容了。

方法二:

#pragma once

//头文件内容

只需在头文件开头加上这句代码,那么该头文件就只会被包含一次。

Logo

鲲鹏展翅 立根铸魂 深耕行业数字化

更多推荐