c语言基础知识
Code in GitHub: c
堆、栈、静态区
内存分为堆、栈、静态区
-
栈
存放局部变量、函数形参等
我们常说的栈溢出就是指这块栈区资源被耗尽从而导致的错误
-
堆
存放动态开辟的内存(如使用malloc、calloc等开辟)
-
静态区
存放全局变量、static修饰的变量等
举例说明函数调用时的压栈情况
解释:
- 首先内存中为main函数开辟一块空间,并在这块空间中从上到下的为main函数的局部变量开辟空间,如上图内存最底部的这一块
- 然后要调用函数了(这里是Add函数),首先将实参传入(注意!绝大部分c编译器传实参的时候是倒着传的,比方说这里是Add(a, b),那么它是先传b再传a),为实参b开辟空间,再为实参a开辟空间,如上图蓝色框框那一块(我们这里说说是a和b,实际上它是形参x和y,因为Add函数的形参是x和y,因此实际上是先为y开辟空间,再为x开辟空间)
- 然后为Add函数开辟空间,并在这块空间中从上到下的为Add函数的局部变量开辟空间(这里是变量z),如上图紫色框框那一块
- 然后从内存中x那块区域和y那块区域拿到数据,相加之后放到Add的那块空间中的z的空间中
压栈操作
数据结构:
-
线性数据结构
- 顺序表 - 内存中开辟一块连续的内存空间
- 链表 - 内存中不连续,随机找位置存储
- 栈
- 队列
-
树形数据结构
- 二叉树
- …
-
图
所以上面说的函数调用时的压栈操作说的就是栈的操作
因此在函数传参的时候,比方说传入一个结构体,如果传结构体对象,参数压栈系统开销较大,因此类似结构体传参的时候,我们要传地址
栈区的默认使用规则
-
栈区的默认使用是先使用高地址处空间,再使用低地址处空间
也就是说如果有如上图的数组arr,那么数组最后一个元素10将会先入栈被放置到高地址处空间,而数组第一个元素1将会最后一个入栈被放置到低地址处空间
-
这样做的话也可以保证这一点就是:数组随着下标的增长地址是由低到高变化
-
且值得注意的是:for循环中的i变量是放到数组末尾元素地址后面的第三个地址那块空间的
那么为什么上图变量i会在数组arr后面呢?(变量i在栈空间的高地址处)那是因为看上面的代码,我们将变量i声明在数组arr前面;如果变量i声明在arr后面,那在栈空间中变量i就会处于低地址处
打印
%d - 整型
%c - 字符
%s - 字符串
%f - 浮点数字,小数
%lf - 双精度浮点数(double)
%p - 以地址形式打印
%o - 8进制数字
%x - 16进制数字
技巧
%2d - 打印两位数,不够的用空格补齐(右对齐),类似的还有%3d、%4d等
%-2d - 打印两位数,不够的用空格补齐(左对齐),类似的还有%-3d、%-4d等
c语言库函数文档
-
微软的MSDN
-
http://en.cppreference.com (第一手资料)
类型long
C语言标准规定
sizeof(long)>=sizeof(int)
所以有些编译器中long长度就是4byte,而有些地方是8byte
小数强制转float
float w = 95.1;
直接这么写的话默认95.1会被编译器认为是一个double类型的小数,如果把一个double类型的放到float类型里面去会发生精度丢失
因此应该这么写:
float w = 95.1f;
加一个f,明确告诉编译器这是一个float类型的小数
scanf中蕴含的取地址操作的知识点
int n1 = 0;
int n2 = 0;
scanf("%d%d", &n1, &n2); // 或者scanf("%d,%d", &n1, &n2); 这样写的话输入的时候数字之间的逗号不能丢掉!同理scanf("%d#%d", &n1, &n2);那么数字之间的#不能丢掉!
首先使用int在内存中申请两个地址,用于存放n1和n2,之后使用scanf的时候我们希望将数存到n1和n2,怎么做到的呢?直接告诉它n1和n2的地址在哪,找到地址就能存了,那么怎么告诉它呢?使用&即可。
那如果是需要将输入内容保存到一个字符串数组呢?可以使用&也可以不使用&:
char pwd[20] = {0};
scanf("%s", pwd);
// 经过实验也可以这么写:
scanf("%s", &pwd);
C语言如何定义变量
c语言定义变量时必须将变量定义到块中的最前面!
比如:
int main(){
int a1 = 0;
int a2 = 0;
scanf("%d%d", &a1, &a2);
int sum = a1 + a2;
printf("sum = %d\n", sum);
return 0;
}
这样是不行的,因为sum定义到scanf下面去了,正确的做法是将sum定义到前面:
int main(){
int a1 = 0;
int a2 = 0;
int sum = 0;
scanf("%d%d", &a1, &a2);
sum = a1 + a2;
printf("sum = %d\n", sum);
return 0;
}
关键字
extern
情景:
-
在一个文件中声明一个全局变量,在另一个文件中如何使用这个外部定义的全局变量呢?需要这么声明一下:
extern int xxx;
-
如果是要引入外部的函数呢?
t1.c文件中有函数:
int add(int x, int y){ return x + y; }
我们希望在main.c中引入该函数:
// 在main函数上方声明外部函数add extern int add(int, int); int main(){ int a = 0; int b = 0; int sum = add(a, b); printf("%d\n", sum); return 0; }
sizeof
比方说有变量 int a
则可以这么写:
表达式 | 是否合法 | 值 |
---|---|---|
sizeof(a) | 1 | 4 |
sizeof(int) | 1 | 4 |
sizeof a | 1 | 4 |
sizeof int | 0 |
再比如有数组 int arr[10] = {0},则:
sizeof(arr) = 40
sizeof(int [10]) = 40
sizeof(int [5]) = 20
则可计算数组长度:
sizeof(arr) / sizeof(arr[0]) = 10
sizeof中的表达式只是一个摆设,不直接运算
short s = 0;
int a = 10;
sizeof(s = a + 5); // 2,因为sizeof(s = a + 5)实际上是先算a + 5,再将a + 5的值赋到s上,我们知道a + 5的值肯定是一个整形,但是s是短整型,因此将a + 5的结果赋到s上之后它还是一个短整型,此时再算sizeof(s),而s是短整型,因此sizeof(s) 值为2
printf("%d\n", s); // 0,因为sizeof(s = a + 5)中s = a + 5相当于一个摆设,虽然它写在这里,但是实际上它是不直接计算的,因此s的值还是为0
★sizeof返回的是一个无符号数
因为sizeof是用于计算变量/类型所占内存的大小,因此它一定是大于等于0的,因此它返回的一定是一个无符号数
案例:
int i; // 全局变量不初始化默认是0
int main(){
i--;
if(i > sizeof(i)) printf("0");
else printf("1");
}
// 输出为0
// 解释:首先i是全局变量,没有初始化因此是0,然后i--,i变成-1,然后i和sizeof(i)比较,由于sizeof()返回的是无符号数,而i是有符号数,我们之前讲过无符号数和有符号数之间比较的时候有符号数会先转换成无符号数,那么这个时候i就会发生这样的转换,而i是-1,它的二进制最高位是1,如果转成无符号数那将会是一个非常大的数,一定大于sizeof(i),所以输出0
auto
局部变量出了作用域自动销毁,因此局部变量也叫自动变量,c语言中自动变量(局部变量)前面有一个关键字auto修饰,只不过一般情况下我们把它省略了:
int main() {
auto int a = 10; // 局部变量-自动变量
}
等价于:
int main() {
int a = 10; // 局部变量-自动变量
}
register
寄存器关键字
我们知道计算机存储数据有四种容器:
按照速度排名:
寄存器 > 高速缓存 > 内存 > 硬盘
寄存器和高速缓存存在的原因:
CPU速度越来越快导致内存读取速度跟不上CPU的处理速度,因此出现了高速缓存和寄存器
以后CPU都去寄存器读取数据,数据的流向变成了:从内存到高速缓存,再从高速缓存到寄存器,再从寄存器到CPU;CPU找数据的方式也变成从上到下:先去寄存器找,找不到再去高速缓存找,高速缓存再找不到再去内存找
使用方法
当我们频繁用到某个变量的时候就建议把它放到寄存器:
int main(){
register int a = 10; // 建议把a定义成寄存器变量
return 0;
}
那么为什么是建议呢?很简单,因为寄存器造价贵,在计算机中的个数比较少可能就几十个,所以要省着用
那最终到底它会不会被定义成寄存器变量呢?这个由编译器说了算
signed
有符号数,平时我们定义有符号数的时候是这么定义的:
int a = 10;
a = -2;
其实它省略了一个关键字signed
也就是说它等价于:
signed int a = 10;
a = -2;
static ★
-
用于修饰局部变量从而延长局部变量的生命周期
重点例子:
void test(){ static int a = 1; // 一个静态的局部变量 a++; printf("a = %d\n", a); } int main(){ int i = 0; while(i < 5){ test(); i++; } return 0; }
答案:2 3 4 5 6
解释:
由于使用static修饰了a变量,在第二次以及之后几次进入test的时候static int a = 1;这句代码是会直接跳过不执行的,而且a变量也不会因为出了作用域而被销毁,也就会出现输出2 3 4 5 6的结果
-
用于修饰全局变量
改变全局变量的作用域 - 让静态的全局变量只能在自己所在的源文件内部使用,出了源文件就没法再使用了
比方说有一个文件t1.c中定义了static修饰的全局变量,那这个时候就不能在另一个文件t2.c中访问这个全局变量了,只能在t1.c这个文件自身内部访问到这个static修饰的全局变量
-
用于修饰函数
比方说我在t1.c里面设置一个函数:
int Add(int a, int b){ return a + b; }
然后我们在main.c中引入这个函数:
extern int Add(int, int); int main(){ int sum = Add(2, 3); return 0; }
这个时候函数是正常可用的
但如果我们给函数加一个static:
static int Add(int a, int b){ return a + b; }
然后还是一样,在main.c中引入该函数Add
此时就会报错了:找不到外部符号Add
不准确的说static改变了函数的作用域
准确的说static改变了函数的链接属性(main.c中使用t1.c中的函数就是一种链接)
当外部函数没有static修饰的时候它具有外部链接属性,而当外部函数被static修饰之后它具有内部链接属性
const
const修饰的变量不能修改
存在这么一种非法写法:
int main(){
const int num = 10; // num被const修饰了,说明num不能更改
int* p = # // 指针没有被const限制,且拿到了num的地址,那这个指针它就是可以去修改num的值,显然这样是非法的
*p = 20;
printf("%d\n", num); // 20
return 0;
}
因此我们应该使用const关键字限制指针:
int main(){
const int num = 10;
const int* p = #
*p = 20; // 报错(左值指定为const对象),那么这里*p是不能改变的,*p是const对象
printf("%d\n", num);
return 0;
}
-
const放在指针变量的 * 的左边时,修饰的是*p,也就是说:不能通过p来改变*p(按照上面的代码来讲其实就是num)的值
-
const放在指针变量的 * 的右边时,修饰的是指针变量p本身,p不能被改变
const int num = 10; int* const p = # int n = 100; p = &n; // 报错(左值指定为const对象)
-
const同时放在指针变量的 * 的左边和右边时,修饰的是*p和指针变量p本身,此时既不能通过p来改变*p,p自身也不能被改变
案例:
// 手写strcpy函数
char* my_strcpy(char* dest, char* src){
char* ret = dest;
assert(dest != NULL);
assert(src != NULL);
// 把src指向的字符串拷贝到dest指向的空间,包含‘\0'字符
while(*dest++ = *src++){
;
}
return ret;
}
#define
不是c语言关键字,是预处理指令
定义标识符常量
#define X 10
定义宏
#define MAX(A, B) (A>B?A:B)
int main(){
int max = MAX(10, 20);
return 0;
}
VS下的一些问题
不安全的库函数
vs下scanf是不推荐使用的,vs编译器给我们提供了一个scanf_s来代替scanf,但是缺乏了跨平台的特性(比方说gcc他就不认识scanf_s,只认识标准C语言提供的scanf)
其他的还有例如strcpy、strlen、strcat等都是不安全的,那么VS编辑器都会为我们提供对应的安全的版本:strcpy_s、strlen_s、strcat_s等
同样,如果在VS编辑器下不使用这些_s的库函数而是使用标准C语言提供的函数,那VS编辑器都会报错(2013版本之后)
解决方法:
在头文件头部加一句话
如果是在VS编辑器下可以使用 _CRT_SECURE_NO_WARNINGS
#define _CRT_SECURE_NO_WARNINGS 1
那么有没有什么一劳永逸的方法呢?
可以在newc++file.cpp文件中加如这句话:
变量
局部变量不初始化默认是随机值
全局变量不初始化默认是0
常量
C语言的常量分以下几种:
-
字面常量
直接写出一个数字,这种的叫字面常量
-
const修饰的常变量
-
#define定义的标识符常量
-
枚举常量
enum Sex { MALE, FEMALE, SECRET }; int main() { enum Sex x = MALE; printf("%d\n", MALE); // 0 printf("%d\n", FEMALE); // 1 printf("%d\n", SECRET); // 2 return 0; }
字符串
int main() {
// "abc" -- 'a', 'b', 'c', '\0' -- '\0'是字符串的结束标志
char arr[] = "abc";
// 等价于:
char arr[] = { 'a', 'b', 'c', 0 };
// 因为 '\0' 的ascii码值就是0,因此还等价于:
char arr[] = { 'a', 'b', 'c', '\0' };
}
strlen
strlen和sizeof没有什么关联
strlen用来计算字符串长度 – 只能针对字符串求长度 – 求的其实是字符串中'\0’之前的字符的个数 – 库函数 – 使用得引入头文件
sizeof用来计算变量、数组、类型的大小 – 单位是字节 – 它是操作符
strcpy
用于赋值字符串到变量
char arr1[] = "bit"; // 'b','i','t','\0'
char arr2[10] = "######"; // '#','#','#','#','#','#','\0','\0','\0','\0'
strcpy(arr2, arr1); // 此时arr2会变成:'b','i','t','\0','#','#','\0','\0','\0','\0'
// 由于'\0'是字符串的结束标志,因此输出arr2的时候还是只会输出"bit"
printf("%s\n", arr2); // 输出bit
strcmp
‘==’ 不能用来比较两个字符串是否相等,应该使用库函数strcmp
#include <string.h>
strcmp(str1, str2) == 0 // 等于0说明两个字符串相等;如果str1 > str2则返回大于0的数字;如果str1 < str2则返回小于0的数字
字符串中的 \
转义符号
面试题:
int main()
{
// \t算一个字符
// 重点解释一下\32,\32的意思是32是一个八进制数,需要先转成10进制,也就是3 * 8^1 + 3 * 8^0 = 26,再将26转成ascii码对应的字符,因此这里\32表示一个字符
// 如果是 \32 那意思就是把32当成8进制数(所以\382这样的写法是错误的,错在中间这个8,八进制数最多到7),如果是 \x32 则表示将32当成16进制数
printf("%d\n", strlen("c:\test\32\test.c"));
// 答案是13
}
文件结束标志符EOF
EOF -> end of file -> -1,代表文件结束
可能在循环中使用到:
int ch = 0;
// 如何让下面的循环停下来呢? ctrl + z
while((ch=getchar()) != EOF){putchar(ch);}
二进制数的操作
负数
只要是整数,在内存中存储的都是二进制的补码,负数也不例外,而我们使用它的时候是使用它的原码
// 知识点
// 正数的原码、反码、补码三码统一(或者叫三码相同)
// 负数就不多说了
比方说:
int main{
int a = 0;
int b = ~a;
// 由于int有四字节,也就是8 * 4 = 32bit位:00000000000000000000000000000000
// 所以b就是a的反码:11111111111111111111111111111111(此时这个就是存在内存中的b的补码)
// 那么当我们取出b的时候是用它的原码,思考一下原码到补码是什么操作:符号位不动,其余位取反最后加1,那么反过来就是先减1,再符号位不动,其余位取反
// 那么我们可以得到b的原码:10000000000000000000000000000001(也就是说b此时就是-1)
printf("%d\n", b);
// 输出为-1
return 0;
}
指针
在计算机科学中,指针是编程语言中的一个对象,利用地址,它的值直接指向存在电脑存储器中另一个地方的值。由于通过地址能找到所需的变量单元,可以说,地址指向该变量单元。因此,将地址形象化的称为“指针”。意思是通过它能找到以它为地址的内存单元。
指针类型
既然指针在32位机器下大小是4字节,64位机器下大小是8字节,我们发现它的大小是固定的,那为什么还需要区分int型指针、double型指针、char型指针呢?反正不管是哪个类型的变量的地址我指针变量一定存的下,那我直接来个通用类型的指针变量不就行了吗?
如果有上述的想法说明对于指针的理解还不够深入
举个例子说明指针类型的重要性:
int a = 0x11223344; // a在内存中存的就是十六进制11 22 33 44
// 使用int型指针存放a的地址
int* pa = &a;
printf("%p\n", pa); // 00AFFEF4
*pa = 0; // 这里我们会发现a在内存中的值变成了十六进制00 00 00 00
// 下面我们换用char型指针存放a的地址
char* pc = (char*)&a;
printf("%p\n", pc); // 00AFFEF4 ,我们发现跟上面的pa的值是一样的,说明pa跟pc都能正常存放变量a的地址
*pc = 0; // 这里我们会发现a在内存中的值变成了十六进制11 22 33 00。解释一下为什么是11 22 33 00 而不是00 22 33 44,那是因为整型数在内存中有可能是按照字节倒着存进去的(小端模式):44 33 22 11,这个时候*pc = 0相当于就是把44变成了00,因此再读出来的时候也就变成了11 22 33 00
// 我们终于发现了两个不同类型的指针在做同一个解引用操作的时候结果不一样,这就是指针类型带来的区别
指针类型的意义:
任何类型的指针确实可以存放任何类型的变量的地址,但是!当进行解引用操作的时候,就不一样了
一句话来讲就是:指针类型决定了指针进行解引用操作的时候,能够访问空间的大小:
int* p; *p能够访问4个字节 // 解释:int型指针说明它指向的是int型变量,能访问4个字节
char* p; *p能够访问1个字节 // 解释:char型指针说明它指向的是char型变量,能访问1个字节
double* p; *p能够访问8个字节 // 解释:double型指针说明它指向的是double型变量,能访问8个字节
这就是不同类型指针的区别!
事实上,不同类型的指针还有另外一个不同点(计算地址加减的时候):
int a = 0x11223344;
int* pa = &a;
char* pc = &a;
printf("%p\n", pa); // 0095FB58
printf("%p\n", pa + 1); // 0095FB5C
printf("%p\n", pc); // 0095FB58
printf("%p\n", pc + 1); // 0095FB59
// pa和pc存放的都是变量a的地址,但是pa走一步,地址就加4,pc走一步,地址就加1,说明他们的步长不一样
指针类型的另外一个意义:
指针类型决定了:指针走一步走多远(指针的步长)
int* p; p + 1 –> 4 // 解释:int型指针加1,存放的地址就加4,说明int型指针的步长是4字节,对于它来说,加1就是跳过1个整型
char* p; p + 1 –> 1 // 解释:char型指针加1,存放的地址就加1,说明char型指针的步长是1字节,对于它来说,加1就是跳过1个字符型
double* p; p + 1 –> 8 // 解释:double型指针加1,存放的地址就加8,说明double型指针的步长是8字节,对于它来说,加1就是跳过1个双精度浮点型
这就是指针步长的意思!
指针步长的应用:
int arr[10] = {0};
int* p = arr; // 数组名-首元素的地址
int i = 0;
for(i = 0; i < 10; i++){
*(p + i) = 1; // 意思是数组中每个数的值都改成1
}
// 而当我们将上述代码的int* p = arr;改 成char* p = arr;此时for循环中*(p + i) = 1;的意义就变成了一个字节一个字节的改,将他们都改成1
这就很灵活了!你想怎么动内存,就把地址交给一个合理的指针。
野指针
野指针就是指指针指向的位置是不可知的(随机的,不正确的,没有明确限制的)
什么情况会导致野指针:
-
指针不初始化
int a; // 局部变量不初始化,默认是随机值 // 同理指针变量 int* p; // 局部的指针变量不初始化,就会被初始化随机值 // 这就很可怕了,比方说这个时候我们做*p = 20;它将会把内存中不知道哪一块的地方的值改成20
-
指针越界访问
int arr[10] = {0}; int* p = arr; int i = 0; for(i = 0; i <= 11; i++){ *(p++) = i; // 当指针指向的范围超出数组arr的范围的时候,p就是野指针 }
-
指针指向的空间释放
int* test(){ int a = 10; return &a; } int main(){ int* p = test(); *p = 20; return 0; } // 首先test()函数内部的a是个局部变量,当test()函数执行结束之后a会被销毁(a那块内存空间就还给系统了) // 此时main函数内部我们再去操作之前a的地址,通过p指针改变那一块内存的值,就会出问题了,因为那块内存现在根本不属于我们而是属于系统
同理下面这种非常容易出错的情况:
int* test(){ int arr[10] = {0}; return arr; // 产生野指针!返回了数组的起始地址,但是test()执行完毕后arr数组会被销毁 } int main() { int* p = test(); return 0; }
除非变量不销毁,比方说变量被static修饰:
int* test(){ // 被static修饰的变量在test()执行结束之后不销毁,因此不会产生野指针 static int a = 10; return &a; } int main(){ int* p = test(); return 0; }
如何避免野指针
- 指针初始化
- 小心指针越界
- 指针指向空间释放之后立马让指针置NULL
- 指针使用之前检查有效性
内存
内存中的地址编号怎么来的:
如果是32位,则会有32根地址线,每根地址线都有正负电,代表1和0,那么就会有如上图所示的2^32种可能,从上到下排下来其实就是0、1、2、3、…这样的编号,那么每一个编号其实就是内存中的一个地址编号
内存一个地址占多大空间
首先我们知道计算机中有空间单位:bit byte kb mb gb tb pb
假设一个地址占1bit,那么32位的机器能有多大呢?如上图32位能表示2^32个地址,也就是2^32bit,那么除以8就是byte,再除以1024就是kb,再除以1024就是mb,再除以1024就是gb,到最后gb单位的时候我们发现才0.5g,这导致就算我们给个1g内存也是没用的,因为它最大也就能表示0.5g
一个内存地址空间是以一个byte来划分的
例子:
int a = 10;
由于int是4字节的,所以会申请4字节内存空间:
取地址操作符&
int a = 10; // 4字节
// &a; // 取地址
printf("%p\n", &a);
注意打印地址用的是%p,显示的值是以16进制形式显示的
使用指针变量存放地址
int a = 10;
int* p = &a;
解读:p现在是一个指针变量,他的类型是int*,p变量里面存的是a的地址
printf("%p\n", &a);
printf("%p\n", p);
// 这两个东西打印出来是一样的
通过指针找到变量
那么我们存指针变量是为什么呢?
是因为有朝一日我们要用到这个变量,这个时候就需要这么用:
// 假设p是一个指针变量
*p // * -- 解引用操作符
// *p能找到指针p指向的变量
例如:
int a = 10;
int* p = &a;
*p = 20;
printf("a = %d\n", a); // 输出: a = 20
图解:
指针占用的空间
我们知道指针是用来存放内存地址的,所以32位机上指针需要表示的就是32个bit位,也就是4字节,这就是一个指针占用的内存大小,那如果是64位机,那就是8字节
指针和++操作的优先级
++操作优先级更高,因此*p++它就相当于*(p++),如何避免这样的情况呢?写成(*p)++即可
指针运算
-
指针+-数
这个就不多赘述了,上面“指针类型”小节有举例子
值得注意的是下面这个例子,以前不常写:
#define N_VALUES 10 int arr[N_VALUES] = {0}; int* p = NULL; for(p = &arr[0]; p < &arr[N_VALUES];){*p++ = 0;}
-
指针+-指针
int arr[10] = {0}; printf("%d\n", &arr[9] - &arr[0]) // 9 printf("%d\n", &arr[0] - &arr[9]) // -9
得出结论:指针减去指针得到的数的绝对值是指针之间元素的个数
案例:求字符串长度:
int my_strlen(char* str){ char* start = str; char* end = str; while(*end != '\0'){ end++; } return end - start; }
-
指针比较大小
看下面两段代码:
#define N_VALUES 10 int arr[N_VALUES] = {0}; int* p = NULL; for(p = &arr[N_VALUES]; p > &arr[0];){*--p = 0;}
for(p = &arr[N_VALUES-1]; p >= &arr[0]; p--){*p = 0;}
实际在大部分的编译器上是可以顺利完成任务的,然而我们还是应该避免第二种写法,因为标准不保证它可行。
标准规定:
允许指向数组元素的指针与指向数组最后一个元素后面的那个内存位置的指针比较,但是不允许与指向第一个元素之前的那个内存位置的指针进行比较
多级指针
int a = 10; // 类型:int
int* pa = &a; // 类型:int*
int** ppa = &pa; // 类型:int**,ppa就是二级指针
int*** pppa = &ppa; // 类型:int***,三级指针
如何理解int**:
我们知道int*中的*表示它是一个指针,前面的int表示它所指向的变量的类型是int
那么int**中最后一个*表示它是一个指针,前面的int*表示它所指向的变量类型是int型的指针
同理int***
那么上面的代码其实可以改写成:
int a = 10;
int * pa = &a;
int* * ppa = &pa;
int** * pppa = &ppa;
// 这样写也是合法的,也更容易理解
指针数组和数组指针
指针数组 – 数组 - 存放指针的数组
数组指针 – 指针
结构体
结构是一些值的集合,这些值称为成员变量。结构的每个成员可以是不同类型的变量。
结构是一种我们自己创造出来的类型,用于表达复杂对象
-
语法1:
struct tag{ member-list; // 结构体成员变量 }variable-list; // 全局的结构体变量,一般不用(因为全局的变量比较难维护)
比方说:
struct Stu{ char name[20]; // 成员变量 short age; // 成员变量 }s1, s2, s3; // s1, s2, s3是三个全局的结构体变量(一般不用,全局的变量难以维护) int main(){ // struct Stu是结构体类型(类似于int,double,long) struct Stu s; // s是局部的结构体变量 return 0; }
-
语法2:
typedef struct tag{ member-list; }alias; // typedef定义结构体别名
比方说:
typedef struct Stu{ char name[20]; short age; }Stu; // 别名Stu int main(){ struct Stu s; Stu s; // 使用别名创建结构体变量 return 0; }
使用方式:
struct Book{
char name[20];
short price;
}; // 因为是一个结构体声明,声明是一条语句,因此这里的;不能少
int main(){
struct Book b1 = {"C语言", 55};
printf("书名:%s\n", b1.name);
printf("价格:%d\n", b1.price);
b1.price = 15;
printf("价格:%d\n", b1.price);
return 0;
}
// 嵌套结构体初始化:
struct S{
int a;
char c;
};
struct T{
char ch[10];
struct S s;
};
int main(){
char ch[] = "hello";
struct T t = {ch, {1, 'c'}};
return 0;
}
结构体中字符串的赋值(strcpy)
还是上面那个结构体,我们修改price的时候一切正常,但是像修改price一样修改name字符串的时候就会有问题了,因为结构体中的字符串name本质上存的是一个地址,所以如果直接b1.name = “C++";这样改的话肯定不行,应该使用string.h头文件的strcpy函数:
// strcpy(修改目的地, 修改来源)
strcpy(b1.name, "C++");
结构体指针
比方说上面那个结构体Book,它这个类型的指针就是:
struct Book* p;
// 注意这里struct Book*是一起的,表示该变量是指针变量,是struct Book类型的指针变量
因此我们可以:
struct Book b1 = {"C语言", 55};
struct Book* p = &b1;
printf("%s\n", (*p).name);
箭头符号(->)
可以用一种更加方便的方式访问结构体指针指向的结构体中的成员变量:
// 刚才我们访问成员变量name的时候是这么访问的:(*p).name
// 其实还可以这么写:
p->name // 表示p所指向的那个结构体的成员变量name
结构体类型
// 比方说下面这段代码就是创建了一个结构体类型-struct Stu
// struct 结构体关键字, Stu 结构体标签, struct Stu 结构体类型
struct Stu{
char name[20];
int age;
};
// 那么怎么使用这个结构体类型呢?
int main(){
// 使用struct Stu这个结构体类型创建了一个学生对象s1,并初始化
struct Stu s1 = {"张三", 20, "2021101010"};
printf("%s\n", s1.name);
return 0;
}
悬空else
else跟离它最近的if匹配,所以以下代码:
if(2 == 1)
if(2 == 2)
printf("%d\n", 1);
else
printf("%d\n", 2);
输出是:啥也不输出
解释:最后一个else匹配到了第二个if
switch
注意case后面不一定需要写东西,也不一定一定要加上break,也不一定一定要加default:
int x = 2;
switch (x) {
case 0:
case 1:
printf("%d\n", 1);
case 2:
printf("%d\n", 2);
printf("%d\n", 3);
case 3:
printf("%d\n", 4);
break;
case 4:
printf("%d\n", 5);
case 5:
printf("%d\n", 6);
break; // 注意!最后一个case最好加上break,因为下次可能还要再加case,如果下次加了新case但是忘记给这个case加上break,则可能出现bug
}
输出:
2
3
4
switch语句如果不加break就会一直往下走,包括如果下面有default语句也会执行里面的代码,直到走完整个switch语句,例如:
int func(int a){
int b;
switch (a){
case 1: b = 10;
case 2: b = 20;
case 3: b = 16;
default: b = 0;
}
return b;
}
// 问func(1)的值是多少? 答案:0
for循环
问:下面这段代码循环几次?
int i = 0;
int k = 0;
for(i=0,k=0;k=0;i++,k++){
k++;
}
答:0次
解释:
因为for循环第一个分号后面(也就是第二个参数)其实是一个判断语句,我们知道判断语句0为假,1为真,那么k=0表示将k赋值为0,那么k等于0,导致该判断语句值为0,也就是说该判断语句为假,所以它一次都不会循环。当然,如果我们这里把k赋值为非0,比方说k=1,那么就会变死循环。
Sleep
让程序暂停多少秒再执行
#include <windows.h>
int main(){
Sleep(1000); // 表示睡1000ms
return 0;
}
system
用于执行系统函数
// 经过实验发现,windows系统有一个命令叫“cls”,执行这个命令会清空cmd窗口
#include <stdlib.h>
int main(){
system("cls"); // 执行这个代码的意思就是清空cmd窗口
return 0;
}
关机命令
shutdown -s -t 60 表示60秒钟之后关机
shutdown -a 表示取消关机命令
制作简单的关机病毒:
#include <stdlib.h>
int main(){
char input[20] = {0};
system("shutdown -s -t 60");
while(1){
printf("你的电脑将在60之后关机,如果输入:我是猪才可解除关机\n请输入:");
scanf("%s", input);
if(strcmp(input, "我是猪") == 0) {
system("shutdown -a");
break;
}
}
return 0;
}
将上述程序使用gcc编译成.exe文件,并添加到系统的service服务中,设置它的启动方式为自动启动,这样每次开机都会走一遍上面这个流程
暂停命令
#include <stdlib.h>
int main(){
system("pause"); // 可以让程序执行到这里的时候暂停
return 0;
}
goto和标签
goto语句最常用的用法就是终止程序在某些深度嵌套的结构的处理过程,例如一次跳出两层或多层循环,如下:
for(...)
for(...)
for(...)
if(disaster)
goto error;
error:
if(disaster)
// 处理错误情况
虽然goto在c语言中可以滥用,但是可能会出现一些破坏性的行为,因此能不用最好不要使用
int main(){
again:
printf("hello!");
goto again;
return 0;
}
上面的程序将会一直输出hello!
再比如:
int main(){
printf("nihao!");
goto again;
printf("skip");
again:
printf("hello!");
return 0;
}
上面的程序将输出:nihao!hello!
下面我们使用goto和标签来写一个关机游戏:用户需要输入“我是猪”才能取消关机,否则60秒之后将关机
int main(){
char input[20] = {0};
system("shutdown -s -t 60");
again:
printf("你的电脑60秒之后关机,如果输入:我是猪,就取消关机\n请输入:");
scanf("%s", input);
if(strcmp(input, "我是猪") == 0){
system("shutdown -a");
}else{
goto again;
}
return 0;
}
函数声明与定义
// 函数声明
int Add(int x, int y); // 或者这么写:int Add(int, int); 因为实际上我们不会使用到x和y,所以形参可以省略x和y,直接写int
int main(){
...
// 函数调用
Add(x, y);
...
}
// 函数定义
int Add(int x, int y){
return x + y;
}
上面这种写法就属于脱裤子放屁,因为一般情况下我们直接把Add函数写到main函数上面去就可以了,就不需要再提前声明了
那么什么时候函数声明才能真正发挥它的作用呢?
在正式写代码的时候,往往会把自定义函数写到一个新的模块当中去,比方说新建一个源文件add.c并将Add函数定义到这个文件中去,然后再新建一个头文件add.h并将Add函数的声明放到这个文件中去
最后如果我们要使用这个函数,直接引入头文件add.h即可:
#include "add.h"
int main(){...}
注意,如果是引入库文件,#include后面是使用尖括号,如果是引入自己的文件,#include后面使用双引号
#ifndef、#ifdef、#else、#endif
-
用于头文件
场景:避免多次引入同一个头文件
#ifndef __ADD_H__ // 命名规则:__自定义名称_H__ #define __ADD_H__ // 函数的声明 int Add(int, int); #endif
-
用于普通代码中
场景:判断这个宏是否被定义
#define MAX(x,y) (((x)>(y))?(x):(y)) int main(){ #ifdef MAX //判断这个宏是否被定义 printf("3 and 5 the max is:%d\n",MAX(3,5)); #endif return 0; }
数组
一维数组在内存中是连续存放的:
二维数组在内存中也是连续存放的:
初始化
指定数组大小:
char arr[5] = {'a', 'b'};
char arr[5] = "ab";
// 上面两种都是不完全初始化,剩下的元素默认初始化为0
// 需要注意的是第一种'b'后面那个0就是单纯意义上的0,而第二种'b'后面那个0其实是'\0'
不指定数组大小:
char arr[] = "abc"; // 必须初始化,因为他需要根据初始化的内容确定数组的大小,比方说这里的arr他的大小就是4(最后还有一个'\0')
二维数组初始化:
// 下面的几种初始化都是可以的
int arr[3][4] = {1, 2, 3, 4, 5}; // 不完全初始化,第一行放置1,2,3,4,第二行放置5,0,0,0,第三行放置0,0,0,0
int arr[3][4] = {{1, 2, 3}, {4, 5}}; // 不完全初始化,第一行放置1,2,3,0,第二行放置4,5,0,0,0,第三行放置0,0,0,0
// 数组的行和列只有行能省略,列一定不能省略
int arr[][] = {{1,2,3,4}, {5,6,7,8}}; ❌
int arr[2][] = {{1,2,3,4}, {5,6,7,8}}; ❌
int arr[][4] = {{1,2,3,4}, {5,6,7,8}}; ✔
int arr[2][4] = {{1,2,3,4}, {5,6,7,8}}; ✔
取地址
int arr[] = {1, 2, 3, 4, 5, 6, 7};
// 虽然此时arr、&arr[0]和&arr这三个值是一样的,但是只有arr和&arr[0]是等价的,都是指数组第一个元素的地址,其中&arr就不一样了,它代表整个数组的地址
// 如何验证上面的说法呢?
printf("%p\n", arr); // 00D3F900
printf("%p\n", arr + 1); // 00D3F904
printf("%p\n", &arr[0]); // 00D3F900
printf("%p\n", &arr[0] + 1); // 00D3F904
printf("%p\n", &arr); // 00D3F900
printf("%p\n", &arr + 1); // 00D3F91C
有这两种情况:
- sizeof(数组名) - 数组名表示整个数组,sizeof(数组名)计算的是整个数组的大小,单位是字节
- &数组名,数组名代表整个数组,&数组名,取出的是整个数组的地址
除了这两种情况之外,所有的数组名都表示数组首元素的地址
数组的类型
我们知道c语言中类型有:整形、长整型、浮点型等,那数组是否也有类型呢?答案是有的。
int arr[10] = {0};
比方说上面的代码,arr是一个数组,arr也是该数组的数组名,那么去掉arr这个数组名,剩下的就是该数组的类型。该数组类型是:int [10]
int arr[10] = {0};
sizeof(arr); // 40
sizeof(int [10]); // 40
sizeof(int [5]); // 20
数组越界访问可能导致死循环
案例:
// 上图代码:
int main(){
int i = 0;
int arr[10] = {1,2,3,4,5,6,7,8,9,10};
// VC6.0 环境下 <=10 就死循环了
// gcc编译器下 <= 11 就死循环了
// VS2013 环境下 <= 12 死循环
for(i = 0; i <= 12; i++){
arr[i] = 0;
}
system("pause");
return 0;
}
分析:
-
首先栈区的默认使用是先使用高地址处空间,再使用低地址处空间
-
且数组随着下标的增长地址是由低到高变化
-
★且for循环中的i变量是放到数组末尾元素地址后面的第三个地址那块空间的
所以当我们越界访问数组的时候,是可能访问到for循环中的变量i的,如果此时将变量i的值改掉,那就可能导致死循环,如上图代码,我们在最后将i的值改成了0,那么i将会一直小于等于12,程序也就进入了死循环
那么为什么变量i会在数组arr后面呢?(变量i在栈空间的高地址处)那是因为看上面的代码,我们将变量i声明在数组arr前面;如果变量i声明在arr后面,那在栈空间中变量i就会处于低地址处
左移与右移
左移很简单,符号位不动,右边补0
右移:
-
算术右移
右边丢弃,左边补原符号位
-
逻辑右移
右边丢弃,左边补0
逻辑右移就是我们想当然的那种右移
通常情况下计算机的右移都是算术右移
// 解释一下为什么-1右移之后还是-1
// 因为计算机的数都是以补码的形式存储的,-1的原码是:1000...0001,因此-1的补码是1111...1111,此时显然不管向右移多少位都还是1111...1111,而1111...1111的原码永远是1000...0001,也就是-1
// 同理-4右移1位是-2,-2右移一位是-1
逗号表达式
逗号表达式就是用逗号隔开的多个表达式。逗号表达式,从左向右依次执行。整个表达式的结果是最后一个表达式的结果
int a = 1;
int b = 2;
int c = (a > b, a = b + 10, a, b = a + 1); // 逗号表达式,a > b表达式不产生结果,a = b + 10表达式执行后a变12,a表达式虽然执行但是不产生结果,最后b = a + 1表达式执行后b变13并将b的值赋给c,因此c也为13
if(a = b + 1, c = a / 2, d > 0) // 逗号表达式,先执行a = b + 1再执行c = a / 2,最后执行d > 0,并将d > 0的结果作为if语句的判断结果
// 有代码:
a = get_val();
count_val(a);
while(a > 0){
// 业务处理
a = get_val();
count_val(a);
}
// 我们发现上述代码中a = get_val()和count_val(a)重复出现了两次,非常罗嗦
// 改写:
while(a = get_val(), count_val(a), a > 0){
// 业务处理
}
// 重复的语句被写成了一行,虽然比较难以理解,但是简洁了不少
习题:
// 有函数如下,请问实参的个数是多少?
exec((v1, v2), (v3, v4), v5, v6);
// 由于是逗号表达式,所以像(v1, v2)这种的都算一个,因此(v1, v2)相当于就是v2,(v3, v4)相当于就是v4。所以是4个实参
// 再比如:
int arr[] = {1, 2, (3, 4), 5}; // (3, 4)就相当于4,所以该数组初始化的其实就是1,2,4,5
逗号表达式优先级相对是比较低的
逗号表达式优先级低于赋值(=)
★案例:
int a, b, c;
a = 5; // 此时a=5
c = ++a; // 此时a=6 c=6
b = ++c, c++, ++a, a++, c++; // 由于逗号表达式优先级低于赋值,因此这行代码实际上是先执行了b = ++c,再执行后面的c++,++a,a++,c++。因此此时a=8 c=9 b=7
b += a++ + c; // +=优先级低于+,因此先执行a++ + c = 17,在执行+=,因此a=9 b=24 c=9
printf("a = %d b = %d c = %d\n", a, b, c); // a = 9 b = 24 c = 9
上面这个案例就一反常态了,原本b = ++c, c++, ++a, a++, c++; b按照常理思维来讲一定等于逗号表达式最后一个表达式的值,但是由于符号优先级的原因,逗号表达式优先级低于赋值,导致了b等于逗号表达式第一个表达式的值。
所以在注意逗号表达式特性的同时也一定要注意符号优先级的影响!
操作数
对于 1 + 2,这个1和2就是+的操作数
而对于[] ,它有两个操作数:
// 有数组:
int a[10] = {0};
a[4] = 10;
// 此时[]的操作数是a和4
// 因此[]的操作数一个是数组名,一个是下标值
对于函数调用的操作符(),它的操作数个数>=1
int get(int x, int y){ // 注意这里的()不是操作符,而是函数定义的语法规则
return x + y;
}
int main(){
int x = 1;
int y = 1;
int res = get(x, y); // 这里的()才是函数调用的操作符,且它的操作数是函数名get以及参数x和y这三个,同理如果函数get一个参数都不需要传入,那()的操作数就是函数名get这一个
}
隐式类型转换
整型提升
C的整型算术运算总是至少以缺省整型类型的精度来进行的。为了获得这个精度,表达式中的字符和短整型操作数在使用之前被转换为普通整型,这种转换称为整型提升。
char a, b, c;
a = b + c;
// 这里的b和c的值被提升为普通整型,然后再执行加法运算
// 加法运算完成之后,结果将被截断,然后再存储于a中
★让我们来详细解释一下:
int main(){
// 首先3是一个32位整型:00000000000000000000000000000011
// 它要放到char型变量a中,只能截断,那么我们截8位:00000011
char a = 3;
// 127是一个32位整型:00000000000000000000000001111111
// 它要放到char型变量b中,只能截断,那么我们截8位:01111111
char b = 127;
// 此时计算a + b,在计算之前需要先将a和b做整型提升,规则是先看该变量是有符号数还是无符号数,如果是无符号数那很简单直接高位补0,如果是有符号数,则看高位是1还是0如果是1则高位补1如果是0则高位补0,由于char的变量属于有符号数,因此如果最高位是1则是负数,如果是0则是正数,如果是负数则应该向前补1直到补满32位为止,如果是正数则应该向前补0直到补满32位为止,那么这里a和b都是正数,都补0
// 补完之后a变成00000000000000000000000000000011,b变成00000000000000000000000001111111
// 执行a + b,得到32位整型:00000000000000000000000010000010
// 要将这个32位整型放到char型变量c中,只能截断,那么我们截8位:10000010
char c = a + b;
// 按照%d输出,则c又需要做整型提升,又因为char型变量是有符号的,最高位表示符号,而这里c的最高位是1,因此需要向前补1直到补满32位为止:11111111111111111111111110000010
// 此时11111111111111111111111110000010这个数很明显是个负数,那么负数在计算机中都是以补码的形式存储的,在输出的时候需要先转成原码:10000000000000000000000001111110,该原码的值就是-126
// 因此输出-126
printf("%d\n", c);
return 0;
}
例子:
char c = 1;
printf("%u\n", sizeof(c)); // 1
printf("%u\n", sizeof(+c)); // 4 发生表达式运算,因此需要整型提升,因此变成了int型,因此变成了4字节。同理sizeof(-c) = 4
printf("%u\n", sizeof(!c)); // 1
无符号数做整型提升时:
我们知道有符号数做整型提升时是按照最高位符号位去补的,而无符号数就简单多了,因为它的最高位不是符号位,所以直接补0
例子1:
unsigned char a = -1; // 首先-1是整型,因此它在计算机中是这么存储的:11111111111111111111111111111111
// 由于变量a是char型,因此截取低位8位:11111111
printf("a=%d", a); // 输出按照%d来输出,因此需要做整型提升(由于是无符号数,因此高位用0填补):11111111 -> 00000000000000000000000011111111
// 最后输出255
例子2:
unsigned char a = 200; // 过程:首先200是个有符号整型:00000000 00000000 00000000 11001000,由于是unsigned char,所以截取低八位:11001000
unsigned char b = 100; // 同理a,得到b:01100100
unsigned char c = 0; // 同理a,得到c:00000000
c = a + b; // 发生整型提升,先将a和b补位,由于是unsigned char,所以前面直接补0,补完之后做加减法得结果:00000000 00000000 00000001 00101100,由于c是unsigned char,因此截取运算结果的低八位,得c:00101100
printf("%d %d", a + b, c); // 这里的a + b算完之后由于还没有将结果放入char型变量中,因此它就是:00000000 00000000 00000001 00101100,按照%d打印则打印结果为300;c需要按%d打印,先做整型提升,由于是unsigned char,因此整型提升后得:00000000 00000000 00000000 00101100,则打印结果为44
整型提升的意义
表达式的整型运算要在CPU的相应运算器件内执行,CPU内整形运算器(ALU)的操作数的字节长度一般就是int的字节长度,同时也是CPU的通用寄存器的长度
因此即使两个char类型的相加,在CPU执行时实际上也要先转换为CPU内整型操作数的标准长度
通用CPU(general-purpose CPU)是难以直接实现两个8比特字节直接相加运算(虽然机器指令中可能有这种字节相加指令)。所以,表达式中各种长度可能小于int长度的整型值,都必须先转换为int或unsigned int,然后才能送入CPU去执行运算
算术转换
寻常算术转换
如果某个操作符的各个操作数属于不同的类型,那么除非其中一个操作数的类型转换为另一个操作数的类型,否则操作就无法进行。下面的层次体系称为寻常算术转换
long double
double
float
unsigned long int
long int
unsigned int
int
如果某个操作数的类型在上面这个列表中排名较低,那么首先要转换为另外一个操作数的类型后执行运算
当然,算术转换需要合理,不然会出现精度丢失
案例:
int i; // 全局变量不初始化默认是0
int main(){
i--;
if(i > sizeof(i)) printf("0");
else printf("1");
}
// 输出为0
// 解释:首先i是全局变量,没有初始化因此是0,然后i--,i变成-1,然后i和sizeof(i)比较,由于sizeof()返回的是无符号数,而i是有符号数,我们之前讲过无符号数和有符号数之间比较的时候有符号数会先转换成无符号数,那么这个时候i就会发生这样的转换,而i是-1,它的二进制最高位是1,如果转成无符号数那将会是一个非常大的数,一定大于sizeof(i),所以输出0
程序调试
Debug和Release
Debug是调试版本,它包含调试信息,并且不做任何优化,便于程序员调试程序
Release是发布版本,往往进行了各种优化(包括功能上的优化,有些在Debug版本下的死循环在Release版本下可能就不会出现),使得程序在代码大小和运行速度上都是最优的,以便用户更好地使用
举个例子:
int main(){
int i = 0;
int arr[6] = {1, 2, 3, 4, 5, 6};
for(i = 0; i <= 8; i++){
arr[i] = 0;
}
return 0;
}
我们知道上面这段代码是会死循环的(具体请看数组章节的“数组越界访问导致死循环”)
那么我们知道如果使变量i存放在栈空间低位的话,就可以避免死循环,而Release版本给我们做了各种优化,其中就包括内存分布结构优化,那么它把上述代码原本在栈空间高位的变量i结构优化到栈空间低位,也正是因为这个原因,Release版本才不会进入死循环,而Debug版本由于没有经过优化,是会死循环的
如何来验证这一点呢?
我们分别用Debug版本和Release版本来执行下面的代码:
int main(){
int i = 0;
int arr[6] = {1,2,3,4,5,6};
printf("%p\n", arr); // Release:00B3FD04 Debug:00B3FD00
printf("%p\n", &i); // Release:00B3FD00 Debug:00B3FD04
return 0;
}
// 结果发现,Debug版本没有经过优化,变量i存放的栈空间的地址相较于arr来讲是在高位的(i的存放地址大于arr);而Release版本则在低位(i的存放地址小于arr)
而Release版本的优化并不都是好的,有时候优化导致代码发生一定程度的变化,导致了Debug版本没bug但是Release版本有bug
断点处输入条件值
打断点处右键点击断点即可输入条件,一般用于for循环输入条件让它停在第几层循环