C和指针读书笔记
前言
《C和指针》读书笔记,会按章节排序
笔记
快速上手
- 函数参数若指定为 const ,表示函数不会修改这个参数。
- 字符串就是一串以 NUL 字节结尾的字符,NUL作为字符串终止符,不属于字符串。 NUL也就是 ‘\0’
- 字符串常量以双引号起止。比如 “Hello” ,在内存中占据6个字节,分别是:H e l l o 和 NUL
基本概念
- 代码的翻译阶段:
- 程序的各个源代码先被转换成目标代码,也就是 .o 文件。编译阶段
- 然后各个目标代码会被链接器捆绑到一起,并且链接器还会将所需的各种库链接到程序中;这些目标代码和各种库捆绑到一起,形成最终的可执行文件,也就是 .out 文件。寻找各种库,会在系统库和用户指定的函数库中寻找。链接阶段
- 编译阶段:先解析(parse);若有优化选项的话,还要进行优化(optimizer)
- 对于字符注意一下 三字母词 。这些都以??开头,比如说
??)
表示的是]
。所以当你打出??):
的时候,在控制台的输出会是]:
'\100'
:这里的100是八进制,表示64。也就是ASCII码值为64的字符,也就是@
- 同4所讲,
'\x40'
:是用16进制来计算,也表示的是@
数据
C语言中仅有4种基本类型:整形、浮点型、指针和聚合类型(数组和结构体等)
整形家族 包括字符、短整型、整形和长整型,他们都有 有符号 和 无符号 两种版本
指针变量 也是一个变量,他存储的值是一个内存地址
字符串常量,比如”Hello”,这个常量的值是一个 指向字符的指针 ,也就是一个地址,所以不能将”Hello”赋值给一个字符数组。
int *a;
说明了 *a 是一个int型变量,这样才进而说明了a是指向一个int变量的指针。 *号表示取地址看个等价的代码
1
2
3
4char *message = "Hallo";
/* 等价于 */
char *message;
message = "Hallo"; // PS 这里是message,而不是*messagetypeof :
1
2typeof char *ptr_to_char; // 这里ptr_to_char就是指向char的指针类型
ptr_to_char a; // 声明了一个指向char的指针变量a常量 : 被声明为常量之后,是无法被修改的。比如
int const a = 9;
之后无法对这个a再赋新值。看看常量和指针的结合
1
2
3int const *pci; // 指向整形常量的指针,可以修改指针的值,但是不能修改指向的int的值
int * const cpi; // 指向整形的常量指针,可以修改指向的int的值,但是不能改变指针让他指向另外的地址
int const * const cpci; // 都不能修改编译器可以确认4种不同类型的 作用域 :文件作用域、函数作用域、代码块作用域和原型作用域
标识符(变量、函数等等)都有链接属性,三种:none 、internal 和 external PS 不是很懂。。
有3个地方用来存储变量( 存储类型 ): 普通内存 、 运行时堆栈 、 硬件寄存器。在代码块之外声明的变量称之为静态变量(static),存放在静态内存中,也就是不属于堆栈的内存,这种变量在整个程序执行时都生存着;在代码块内部声明的变量称之为自动变量(auto),存放在堆栈中,当开始执行代码块时生成这种变量,代码块执行结束,变量被销毁。
对于代码块内的变量可以加上
static
修饰符,强行将它变成静态变量,存放在静态内存中,整个程序运行时间都生存。但是即使这样,它的作用域还是只能在代码块中,在代码块外,它还是无法被使用。注意:形参不能声明为静态变量
给自动变量加
register
修饰符,说明该变量存放在寄存器中,寄存器中的访问速度比内存更快。注意:存放在寄存器中的数量是有限的,如果太多的变量声明为register,只有一部分会存放在寄存器中,那么其他的只能按照正常的变量来处理,将其存放在内存中。关于 static 关键字
用于函数定义或用于代码块之外的变量声明时:static用于修改标识符的链接属性,从external改为internal;但是标识符的存储类型和作用域不受影响;说明了该标识符只能在文件内部使用
在代码块内部使用static来声明变量时:static用来修改变量的存储类型,变成了静态变量;但是链接属性和作用域是不变的
注意:函数的代码总是存放在静态内存中。
在代码块中的变量声明成external的话,它是一个在外部被定义的静态变量,存放在静态内存中,不再是一个自动变量
操作符和表达式
移位操作符分为逻辑移位和算术移位。逻辑移位插入0,算术移位插入1
位操作符:AND(&) 、 OR(|) 、 XOR(^) 对他们的操作数的各个位分别执行与、或、异或操作
~ 操作符按位取反
& 操作符获得变量的地址
在mac上
sizeof(int)
是4,sizeof(char)
是1,注意getchar()
返回的是int, EOF 也是int型sizeof(a = b + 1);
是对表达式求字节数,这里并没有对a赋值注意:表达式中的字符型( char ) 和短整型( short int ) 在使用之前会先被转换成为整形,如下
1
2char a, b, c; // abc都是1个字节
a = b + c; // b和c先被转化成整形,也就是4个字节,在相加;得到结果后再截断成一个字节存储进a中这样做是为了提高精度,因为如果涉及到位运算的话,很容易将8位的其中某些位数给删除掉,增加到32位就会减小这种错误的概率
如果操作符的各个操作数不是同样的类型(位数不同),那么位数少的类型会先转化成位数多的类型,再进行运算
指针
内存的地址以字节( byte )为单位,每个地址就是一个字节,8位。
为了存储更大的值,经常把2个或更多的字节合并在一起来组成更大的内存单位,也就是 字 。字经常由2个或是4个字节组成。所以第一个字的地址为100的话,第二个字的地址可能是102或是104
注意:以4字节的字为例。虽然一个字包含4个字节,但是这个字还是只有一个地址,至于它的地址是最左边字节的地址还是最右边的字节的地址,这由机器来决定。(高位地址和低位地址)
注意一下 * 的使用
1
2
3int a = 112; // a的值是112,假设a的地址是100
int *b = &a; // 这里是将a的地址100,赋给指针b
printf("%d", *b); // 这里是取得b指针指向的地址内的值,也就是 112指针变量不是整形变量,所以
d = 10 - *d;
这样是错的。不能将一个int型赋值给一个指针变量表达式是一个值,若这个值的存储位置没有清晰定义的话,这个表达式只能是右值,不能是左值
*cp++
这个表达式中 ++ 优先级高于 * 。执行步骤- ++操作符先生成一份cp的拷贝
- 对cp执行加一的操作
- 现在cp++执行完成,他是第一步中cp的拷贝;再执行*操作,取这个拷贝指向的地址的值
NULL
'\0'
的int值都是 0指针运算很智能。就以指针加一的运算为例。
- char类型占据一个字节,所以指向char的指针加一,也就是地址加一,指向下一个char
- float类型可能占据4个字节,那么指向float的指针加一,其实是地址加4,结果也是指向下一个float
两个指针可以相减(这两个指针必须指向同一个数组中的元素),相减的结果的类型是 ptrdiff_t ,一种有符号整数类型。结果是两指针在内训中的距离,注意还是以数组元素的长度为单位,不是以字节为单位。
对指针执行
< <= > >=
操作也是可以的,同样,两指针必须指向同一数组的元素可以对任意两指针执行相等或不相等的测试,指向同一地址的两指针相等
函数
- 函数原型很重要,如果没有函数原型,就调用函数(ps:函数调用发生在函数定义之前),那么默认的是返回整形。这时举个例子,调用的函数返回的是float,float存放在内存中的是二进制,但是编译器会将他解释成int;如果将这个函数返回值赋给float,这个值也是int型转化的float,不是原来的float。
- C函数的参数都是传值调用,就是传递拷贝,不改变原值;传指针的话,可以改变指针指向的值,但是也是传值调用,因为传递的是指针的拷贝
- ADT 抽象数据类型
- 可变参数列表是通过宏来实现的,这些宏位于 stdrag.h 头文件中。PS 有点不懂
数组
数组名是一个指向其他类型的指针常量(比如指向int型的),不能修改它的指向。也就是说数组名是一个指针,但是不能被赋值。
注意,
int a[10]; sizeof(a);
使用sizeof时,并不是求指针的长度,而是数组的长度1
2
3
4
5int a = 10;
int *b = &a;
int c[10];
sizeof(b); // b的大小是8字节,因为他是指针
sizeof(c); // c的大小是40字节,因为他是int型数组,有10个4字节的int数组的下标引用和指针操作是一样的。一下几种写法等价
1
2
3
4array[2];
*(array + 2);
*(2 + array);
2[array];指针有时效率比下标引用快
注意,数组和指针还是有区别的,
1
2int a[5]; // 分配了5个int的内存空间,a指向第一个int内存空间,并没有说明a指针存放在哪里
int *b; // 分配了一个指针变量的内存空间,用来存放b指针,但是没有分配int内存空间,也没有说明b指向哪里注意,数组名是个指针常量,不能被修改它的指向。但是,当它被当成参数传递给函数时,函数接收到的是一个指向数组第一个元素的指针变量,可以修改它的指向。
1
int strlen(char *string); // 使用这样的函数声明来传递数组名
再看看数组和指针的区别
1
2char message1[] = "Hello";
char *message2 = "Hello";二维数组的数组名是一个指向一维数组的指针,多维依次照推
声明一个指向数组的指针
1
2
3
4int matrix[3][10];
int (*p)[10] = matrix; // p指向三维数组的第一行
// ps 要避免下面的声明
int (*p)[]; // 最好要声明长度,因为方便指针的运算函数传递多维数组时的声明形式如下:
1 | int matrix[3][10]; |
1.1111111111111112e+3211111111111111121111111111111111. 指针数组:元素都是指针的数组:int *p[10];
p是一个数组,长度为10,元素是int型指针
1.1111111111111112e+3211111111111111121111111111111111. 看看这段代码
1 | /* size of char is 1, size of char * is 8*/ |
字符串、字符和字节
字符串以一个位全为0的NUL字节结尾,注意是字节,但是这个字节不属于字符串
字符串长度,就是包含的字符数,不包括NUL结尾字节,使用
strlen
函数取得1
size_t strlen(char const *string); //这里返回的是size_t,无符号整形
因为无符号整形是不会为负数的,所以注意这两种写法
1
2if (strlen(x) >= strlen(y)) ... // right
if (strlen(x) - strlen(y) >= 0) ... // wrong第一种是对的,但是第二种是错的。因为两个无符号整形相减还是无符号整形,不可能是负数,所以if里面的判断始终正确。无符号整形要和有符号的计算时,最好先强制转化成int型
strcpy
用拷贝字符串,strcat
用于连接字符串;他们返回值都是新的字符串的首地址,也就是指向首字符的指针strcmp
用于两字符串比较,a小于b,返回负值;a等于b,返回0;a大于b,返回正值查找一个字符:区分大小写
1
2char *strchr(char const *str, int ch); // 在str中找第一个ch字符,返回指向ch的指针,若没有返回NULL指针
char *strrchr(char const *str, int ch); // 同上,找的是最后一个ch字符查找任意几个字符:区分大小写
1
char *strpbrk(char const *str, char const *group); // 在str中查找group中的字符,返回第一个找到的字符的指针
查找一个子串
1
char *strstr(char const *s1, char const *s2); // 若s2是一个空字符串,就返回s1
返回错误信息:
1
char *strerror(int error_number);
memcpy()
函数可以将任意字节数的内容复制到另一个地方,和strcpy
不同的是,他碰到NUL字节不会停止工作
结构和联合
使用结构将不同类型的值存储在一起,显然数组无法完成这个工作
数组元素可以通过下标来访问,因为他们的长度相同;但是结构不行,因为他的成员类型不同
通过 . 来访问成,也就是
结构.成员
还可以通过 -> 来访问成员,但是适用于结构指针,也就是
结构指针->成员
注意结构的自引用:
- 结构体的成员是结构体本身,这是不合法的。不能确定内存大小,无限递归了
- 结构体的成员是指向结构体本身的指针,这是合法的。可以确定内存大小,因为指针大小是8字节
不完整声明,用于处理两个互相引用的结构体:
1
2
3
4
5
6
7struct B; // B的不完整声明
struct A {
struct B *partner;
}
struct B {
struct A *partner;
}假设px是指向结构体的指针,那么
px+1
是非法的;这里不像数组*px->c.b
先执行->,然后执行 . 操作,最后执行 * 操作关于结构体边界对齐的三个基本原则
- 普通数据成员对齐规则:第一个数据成员放在offset为0的地方,以后每个数据成员存储的起始位置要从该成员大小的整数倍开始(比如int在32位机为4字节,则要从4的整数倍地址开始存储)。
- 结构体成员对齐规则:如果一个结构里有某些结构体成员,则该结构体成员要从其内部最大元素大小的整数倍地址开始存储。(struct a里存有struct b,b里有char,int,double等元素,那b应该从8的整数倍开始存储。)
- 结构体大小对齐规则:结构体大小也就是sizeof的结果,必须是其内部成员中最大的对齐参数的整数倍,不足的要补齐。
offsetof(struct, member);
函数可以求出member的存储首地址和结构体的存储首地址的偏移量,以字节为单位在函数调用时,传递结构体不是一个好的方案,可以使用下面的传参方是
1 | void func(register struct const *structP); |
传递指向结构体的指针可以提高效率,因为指针的size为8,一般来说是比结构体小很多的。另外添加const修饰,可以防止结构体被修改;另外增加register修饰,将这个指针存放在寄存器上面,可以提高指针访问成员(->)操作的效率。PS,如果想对结构体进行修改的话,去掉const修饰
1.222222222222222e+32. 联合的所有成员引用的是 内存中的相同位置 ,当你想在 不同时刻 将 不同的东西 存放在 相同的位置 时,就可以使用联合
1 | union { |
float 和 int 都占据32位的数据,上面的代码先将3.14159这一浮点数以二进制的存放在32位的内存上面;然后fi.i会以int的形式读取相同内存位置上的这32位数据
动态内存分配
数组的长度在运行时才可以知道,但是我们却经常给他一个常量,那么在编译时就会分配给数组以长度大小的内存。所以可以使用动态分配内存
malloc
和free
函数用于执行动态内存的分配和释放。他们维护着一块内存池malloc
从内存池中取出一块 连续 内存,并将指向这块内存的指针返回;当不需要这块内存时,使用free
释放,并将该快内存归还给内存池1
2void *malloc (size_t size); // 参数是字节数
void free(void *pointer); // 参数是malloc返回的指针如果
malloc
无法返回指定大小的内存,就会返回一个NULL指针。他返回的内存的起始位置满足边界对齐的要求void *calloc(size_t num_elements, size_t element_size);
函数在返回内存时,会先将这块内存初始化,全部置为0void realloc(void *ptr, size_t new_size);
函数用于扩大或缩小已经分配出来的内存块(ptr)传递给
free
函数的指针必须是malloc
calloc
realloc
函数返回的指针,不然会报错动态内存不再使用时,却没有释放,会导致内存泄漏。有些OS,所有的执行程序共享一个内存池,持续的内存泄漏会导致内存池被榨干,导致所有的程序瘫痪,这时只能重启OS;对于其他OS,内存泄漏也会导致该程序的内存池被榨干,导致程序崩溃
使用结构和指针
- 链表的各个节点,在物理内存上并不相邻
- 关于单链表和双链表的插入操作,没有细看
高级指针话题
函数指针:指向函数的指针,再取值之前也要先初始化
1
2int f(int);
int (*pf)(int) = &f; // 初始化函数指针,注意这里的 & 是可选的函数指针被初始化之后,调用函数方式有如下几种:
1
2
3
4int ans;
ans = f(25);
ans = (*pf)(25);
ans = pf(25);- 第一种:编译器先将函数名 隐式 的转化成函数指针,然后找到内存中函数的位置,开始执行
- 第二种:先对pf函数指针取地址,获得函数名(f);再将函数名转化成函数指针。。。多此一举;最后找到内存中的位置,执行函数
- 第三种:这就很直接了,直接到对应的地址执行函数
回调函数:函数指针;转移表:函数指针数组
命令行参数:
1
int main(int argc, char **argv);
main函数的2个参数,argc表示命令行参数的数目,argv指向一个指针数组的首地址。这个argv可以认为是指向的一个字符串数组的第一个。这个字符串数组的第一个元素是程序的名称
字符串常量出现在表达式中的时候,它的值是一个指针常量
1
"xyz" + 1; // 对指向x的指针常量加一,也就是这个表达式的值是指向y的指针,因为 "xyz" 表示一个指向char的指针
预处理器
编译C程序的第一步称为 预处理阶段 ,主要任务就是:删除注释、插入被#include指令包含的文件的内容、定义和替换#define指令定义的符号以及确定代码的部分内容是否应该根据一些条件编译指令进行编译
#define name stuff
每当有符号name出现,预处理器就用stuff将其替换。来几个例子:1
2
3
4
5
6
7
8
// 一行定义不完,就加反斜杠#define name(paraneter_list) stuff
,或称之为宏,本质就是文本的替换1
之后使用
SQUARE(5)
就等同于5 * 5
;文本替换宏相比与函数,不存在函数调用/返回的额外开销,所以更快
使用
#undef
来移除一个宏定义条件编译:可以选择代码的一部分是被正常编译还是被完全忽略。用于支持条件编译的基本结构是
#if
和#endif
指令1
2
3
statements其中的 constant-expression 是常量表达式,由预处理器进行求值,如果为真,就编译statements,否则就不处理他们。看个例子:
1
2
3
4
printf("x=%d, y=%d", x, y);条件编译还有
#elif
#else
这两条指令条件编译可以测试符号是否被定义
1
2
3
4
5
6
7// 下面两句等价 用于判断一个宏是否被定义
// 下面两句等价 用于判断一个宏是否没被定义#include
指令替换文件的内容到这条指令的位置,这样一个头文件被包含到10个源文件中,他就被编译10次;这会涉及一些开销,不过我们不用在意#include <filename>
库文件。编译器会定义一系列的标准位置,这条文件包含指令会在这些标准位置里寻找库文件。例如,UNIX OS里,编译器会在 /usr/include 目录里面查找库文件。编译器有一个命令行选项,可以让你添加目录,那么这个目录也会是搜索的一个target#include "filename"
现在源文件的当前目录下面寻找,没有的话再到库文件里面寻找一个头文件可能被一个源文件包含几次,也就是多重包含。解决这个问题的方法是在编写头文件时使用如下方法:
1 |
|
这样,当第一次包含时,正常编译;第二次包含时,由于已经定义了 _HEADERNAME_H 就不再编译了
输入/输出函数
对C程序而言,所有的I/O操作只是简单的从程序 移进或移出字节的事情 。所有这种字节流被称为 流 。程序只需关心 创建正确的输出字节数据和正确的解释从输入读取的字节数据 。不需要关心特定的I/O设备
有2种缓冲区(buffer),输入缓冲区和输出缓冲区。这些缓冲区都是一块内存区域。程序输出数据会先写入输出缓冲区,然后再写入文件或输出给设备;输入数据(文件读取数据,键盘输入数据)也会先写入输入缓冲区,然后再交给程序。
一般来说,这些缓冲区都是要先填满,再给到设备或是程序的。所以使用printf时,先将要print的数据写入缓冲区,不会立即显示在屏幕上,如果这时候程序崩溃了,他可能就永远无法显示出来了
流分为 文本流 和 二进制流
文本流在不同OS中某些特性会不同:一种是文本行的最大长度;另一种是文本行的结束方式,在MS-DOS系统中使用回车符和换行符表示一行结束,在UNIX中使用换行符表示一行结束。
但是在代码中不必操心,库函数会帮你处理这些差异。比如在MS-DOS上执行c程序,输出时会将文本中的换行符转化成回车符/换行符的形式写入文件;输入时,从文本读取的回车符/换行符也会转化成换行符交给程序
系统运行时至少提供3个流:标准输入(stdin),标准输出(stdout)和标准错误(stderr);这些流的名字分别是
stdin
stdout
stderr
;他们都是一个指向FILE结构体的指针。FILE结构体在stdio.h中定义,与物理磁盘上面的文件没有关系。标准输入默认是键盘,标准输出默认是屏幕。可以修改默认的输入和输出设备,也就是重定向,比如:
1
./program < data > answer
上面的命令将从文件data读取数据,将数据输出到answer文件
关于流的处理流程
- 使用
fopen
函数打开一个流,这里你必须指定需要访问的文件或是设备,以及访问方式:读、写、读写;该函数验证文件或设备的存在性,并初始化与这个流对应的FILE结构体。一个流对应一个FILE结构 - 根据需要对该文件或设备进行读写
- 最后使用
fclose
函数关闭流,不再对文件和设备进行操作 - 注意:标准流的I/O更加的简单,因为他们不需要打开和关闭。标准流就是 stdin, stdout, stderr
- 使用
FILE *fopen(char const *name, char const *mode);
函数打开流,name是文件或设备的名称,mode是读写模式。成功返回一个FILE结构指针,这个结构体代表一个新建的流;失败返回NULL指针,errno会提示问题的性质
1 | FILE *input; |
打开失败的类似输出: data : No such file or directory
int fclose(FILE *f);
函数关闭流。对于输出流,在关闭之前,先刷新缓冲区。执行成功,返回0;执行失败,返回EOF当一个流被打开之后可以用于输入和输出,包括字符,文本行和二进制的输入和输出3种类型
字符I/O
从流中读取字符
1
2
3int fgetc(FILE *stream);
int getc(FILE *stream);
int getchar(void);getchar从标准输入流中读取,所以不需要指定流。注意:读取字符,但是返回的还是int型;没有更多字符的话返回EOF
向流中写入字符
1
2
3int fputc(int character, FILE *stream);
int putc(int character, FILE *stream);
int putchar(int character);失败返回EOF
未格式化的行I/O:简单读取或写入字符串
从流中读取行
1
2char *fgets(char *buffer, int buffer_size, FILE *stream);
char *gets(char *buffer);最好不适用gets。 **这里的buffer就是之前所说的缓冲区,原来这个缓冲区使我们自己建的,还指明了大小** 。fgets可以这样理解:从流中读取最多 buffer_size - 1 个字符到存储到缓冲区,若到了行尾就不读了,返回的是指向缓冲区首地址的指针。这里的 **从流中读取** 可以理解成 **从文件中读取** ;读取完成后,**存放到程序自己制定的buffer缓冲区中**
向流中写入行
1
2int fputs(char const *buffer, FILE *stream);
int puts(char const *buffer);注意:可以写入多行,就是buffer里面有多少写多少
格式化的行I/O:也就是
scanf
printf
家族函数二进制I/O:效率最高,使用
fread
fwrite
函数
1 | size_t fread(void *buffer, size_t size, size_t count, FILE *stream); |
buffer解释为一个或多个值的数组;count表示数组中有多少个值,也就是要读取或写入的元素个数;size是缓冲区中每个元素的字节数;返回的是实际读取或写入的元素个数,不一定和count相等
1 | struct VALUE { |
int fflush(FILE *stream);
函数迫使输出流缓冲区内的数据进行物理写入。可以这样理解,上面几段代码中的buffer是一种缓冲区,对于写入操作而言,buffer缓冲区中的值写入流;流写入一个与之对应的真正的缓冲区,这个缓冲区就是第二点里说道的缓冲区,当该缓冲区满时,将他写入文件。long ftell(FILE *stream);
返回流当前的位置,他的返回值表示距离文件起始的偏移量。在二进制中,这个值就是距离文件起始的字节数int fseek(FILE *stream, long offset, int from);
将流中的当前的‘指针’,从from偏移offset个位置,也就是改变下一次读写的位置。from参数可以是SEEK_SET(起始位置)
SEEK_CUR(当前位置)
SEEK_END(结尾位置)
标准函数库
int rand(void);
函数返回一个0-RAND_MAX(至少是32767)的整形。为了避免每次运行程序时得到相同的结果,可以使用void srand(unsigned int seed);
设置不同的种子。比如:1
srand((unsigned int) time(0)); // 使用现在的时间
经典抽象数据类型
- C语言中创建堆栈(stack)的方式有3种:静态数组,动态分配内存数组,链式堆栈
- 树的4中常见遍历方式:
- 前序遍历:先检查当前节点,在递归遍历左子树和右子树
- 中序遍历:先遍历左子树,再检查当前节点,再遍历右子树
- 后序遍历:先遍历右子树,再检查当前节点,再遍历左子树
- 层次遍历:逐层检查,先根节点,再它的孩子,再它的孙子…
运行时环境
- 程序包括几个区段:数据区(data)、代码段(text)