复习C语言基本知识点,重新巩固基本知识

从今天开始记录我的C语言学习历程,以前零零散散的学习但是没有系统的总结过学习笔记,希望以此记录以及激励自己继续学习的激情。
作为一个差不多半个新手,如果写错了,还请大家指出。

一:C语言基本知识

本质上来说,C语言大概分为两个部分,头文件以及主函数
在这里插入图片描述

  1. 其中,#include <stdio.h>就是一条预处理命令,
    包含这个<stdio.h>这个库,它的作用是通知C语言编译系统在对C程序进行正式编译之前需做一些预处理工作。
  2. 函数就是实现代码逻辑的一个小的单元。
  3. main 函数称为主函数,一个函数有且仅有一个主函数,主函数作为程序的入口同时也是出口。
  4. printf是输出函数,作用是输出结果,比如上述程序输出结果 Hello World,而/n是换行符,输出结束后自动切换到下一行。
  5. return是函数的返回值,根据函数类型的不同,返回的值也是不同的。上述主函数返回为int 整形,则return 0。

1:C语言标识符

C语言规定,标识符可以是字母(A~Z,a~z)、数字(0~9)、下划线_组成的字符串,并且第一个字符必须是字母或下划线。在使用标识符时还有注意以下几点:

  1. 标识符的长度最好不要超过8位,因为在某些版本的C中规定标识符前8位有效,当两个标识符前8位相同时,则被认为是同一个标识符。
  2. 标识符是严格区分大小写的。例如Imooc和imooc 是两个不同的标识符。
  3. 标识符最好选择有意义的英文单词组成做到"见名知意",不要使用中文。 标识符不能是C语言的关键字。

2:变量以及赋值

变量就是可以变化的量,而每个变量都会有一个名字(标识符)。变量占据内存中一定的存储单元。使用变量之前必须先定义变量,要区分变量名和变量值是两个不同的概念。

变量定义

变量定义的一般形式为:数据类型 变量名;(int a) //整型变量A
多个类型相同的变量:数据类型 变量名, 变量名, 变量名…;(int a,b,c) //整形变量A,B,C

变量赋值

变量的赋值分为两种方式:
(1)先声明再赋值 int a;a=2;
(2)声明的同时赋值 int b=2;
其中,C语言不允许连续赋值,比如int a=b=2;不合法

3:基本数据类型

在C语言中,基本的数据类型大概可以分为以下几类:基本数据类型、构造数据类型、指针类型以及空指针四类。
在这里插入图片描述
其中基本类型以及所占字节如下:
数据类型 大小(字节)
char(1) signed char (1) unsigned char(1)
short(2) unsigned short(2) int(4)
unsigned int(4)long(8)unsigned long(8)
long long(8) unsigned long long(8)
float(4) double(8) long double(16) Bool(1)
值得注意的是,位,字节,字三者的关系是:1字(word)=2字节(byte),1字节=8位,1字=16位(bit)。
位(bit),数据存储是以“字节”(Byte)为单位,数据传输是以大多是以“位”(bit,又名“比特”)为单位,一个位就代表一个0或1(即二进制),每8个位(bit,简写为b)组成一个字节(Byte,简写为B),是最小一级的信息单位,是计算机信息技术用于计量存储容量的一种计量单位。

4:格式化输出语句

格式化输出语句,也可以说是占位输出,是将各种类型的数据按照格式化后的类型及指定的位置从计算机上显示。
其格式为:printf(“输出格式符”,输出项),其中格式输出符又叫占位符,旨在提前声明输出结果的类型,常见的有

%d:用于输出整数类型(包括short、int、long、long long等);
%u:用于输出无符号整数类型(包括unsigned short、unsigned int、unsigned long、unsigned long long等);
%f:用于输出浮点数类型(包括float、double、long double等);
%c:用于输出字符类型(包括char);
%s:用于输出字符串类型(即指向字符数组或字符串字面量的char *类型);
%p:用于输出指针类型(即指向任意类型的指针);
%%:用于输出百分号。
例如:
在这里插入图片描述

5:常量

在程序执行过程中,值不发生改变的量称为常量。
mtianyan: C语言的常量可以分为直接常量和符号常量。
直接常量也称为字面量,是可以直接拿来使用,无需说明的量,比如:
整型常量:13、0、-13;
实型常量:13.33、-24.4;
字符常量:‘a’、‘M’
字符串常量:”Hello world!”

printf (" %d\n",100); //输出100整形常量
printf (" %f\n",2.86);//浮点型常量
printf (" %c\n",‘A’); //字符常量
printf (" Hello world”);//字符串常量

在C语言中,可以用一个标识符来表示一个常量,称之为符号常量。符号常量在使用之前必须先定义,其一般形式为

#define 标识符 常量值
#include <stdio.h>
#define Dollar 10    //定义常量及常量值
int main()
{
    // Dollar = 6;  //小明私自增加零花钱对吗?
    printf("今天又发工资%d美刀\n", Dollar);
    return 0;  
}

6:数据类型转换

自动类型转换

数据类型存在自动转换的情况.
自动转换发生在不同数据类型运算时,在编译的时候自动完成。
在这里插入图片描述
char类型数据转换为int类型数据遵循ASCII码中的对应值.
注意:
字节小的可以向字节大的自动转换,但字节大的不能向字节小的自动转换
char可以转换为int,int可以转换为double,char可以转换为double。但是不可以反向。

强制类型转换

强制类型转换是通过定义类型转换运算来实现的。其一般形式为:

(数据类型) (表达式)
其作用是把表达式的运算结果强制转换成类型说明符所表示的类型

在使用强制转换时应注意以下问题:
数据类型和表达式都必须加括号, 如把(int)(x/2+y)写成(int)x/2+y则成了把x转换成int型之后再除2再与y相加了。
转换后不会改变原数据的类型及变量值,只在本次运算中临时性转换。
强制转换后的运算结果不遵循四舍五入原则。

7:运算符号

1.算术运算符

c语言基本运算符:
在这里插入图片描述

2.自增与自减运算符

自增运算符为++,其功能是使变量的值自增1
自减运算符为–,其功能是使变量值自减1。
它们经常使用在循环中。自增自减运算符有以下几种形式:
在这里插入图片描述

3.赋值运算符

C语言中赋值运算符分为简单赋值运算符和复合赋值运算符

简单赋值运算符=号了,下面讲一下复合赋值运算符:

复合赋值运算符就是在简单赋值符=之前加上其它运算符构成.

注意:复合运算符中运算符和等号之间是不存在空格的。

4.关系运算符

C语言中的关系运算符:
在这里插入图片描述
关系表达式的值是真和假,在C程序用整数1和0表示。

注意:>=, <=, ==, !=这种符号之间不能存在空格。

5.逻辑运算符

C语言中的逻辑运算符
在这里插入图片描述

6.三目运算符

C语言中的三目运算符:?:,其格式为:
表达式1 ? 表达式2 : 表达式3;
执行过程是:
先判断表达式1的值是否为真,如果是真的话执行表达式2;如果是假的话执行表达式3。

 a>b? a:b;

7.运算符以及优先级顺序

在这里插入图片描述

优先级别为1的优先级最高,优先级别为10的优先级别最低

二:分支与循环

1.简单if语句

C语言中的分支结构语句中的if条件语句,基本语句结构如下:

if(条件表达式)
{
	执行语句
}

当加了{},不需要分号,但是单独执行语句只有一行时需要,比如:

if (condition)
    statement;

2.if-else语句

简单的if-else语句的基本结构:

if(表达式)
{
 	执行语句1
}
else
{
	执行语句2
}

语义是: 如果表达式的值为真,则执行代码块1,否则执行代码块2。
同时多重条件判断,中间加上else if语句

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

语义是:依次判断表达式的值,当出现某个值为真时,则执行对应代码块,否则执行代码块n。
注意:当某一条件为真的时候,则不会向下执行该分支结构的其他语句。
然后还有嵌套分支语句,不再赘述。

3.循环语句

1.while循环

反复不停的执行某个动作就是江湖人称的循环 。

C语言中有三种循环结构,先看一下C语言while循环的结构

while(表达式)
{
	执行语句
}

其中表达式表示循环条件,执行代码块为循环体。
while语句的语义是:计算表达式的值,当值为真(非0)时, 执行循环体代码块。
while语句中的表达式一般是关系表达或逻辑表达式,当表达式的值为假时不执行循环体,反之则循环体一直执行。
一定要记着在循环体中改变循环变量的值,否则会出现死循环(无休止的执行)。
循环体如果包括有一个以上的语句,则必须用{}括起来,组成复合语句。
2.do-while循环
C语言中的do-while循环,一般形式如下:

do
{
	执行表达式
}while(表达式);

do-while循环语句的语义是:

它先执行循环中的执行代码块,然后再判断while中表达式是否为真,如果为真则继续循环;如果为假,则终止循环。因此,do-while循环至少要执行一次循环语句。

注意:使用do-while结构语句时,while括号后必须有分号。

2.for循环

c语言中for循环一般形式:

for(表达式1;表达式2;表达式3{
	执行语句
}

它的执行过程如下:
执行表达式1,对循环变量做初始化;
判断表达式2,若其值为真(非0),则执行for循环体中执行代码块,然后向下执行;若其值为假(0),则结束循环;
执行表达式3,(i++)等对于循环变量进行操作的语句;
执行for循环中执行代码块后执行第二步;第一步初始化只会执行一次。
循环结束,程序继续向下执行。

使用for语句应该注意:

for循环中的“表达式1、2、3”均可不写为空,但两个分号(;;)不能缺省。
省略“表达式1(循环变量赋初值)”,表示不对循环变量赋初始值。
省略“表达式2(循环条件)”,不做其它处理,循环一直执行(死循环)。
省略“表达式3(循环变量增减量)”,不做其他处理,循环一直执行(死循环)。
表达式1可以是设置循环变量的初值的赋值表达式,也可以是其他表达式。
表达式1和表达式3可以是一个简单表达式也可以是多个表达式以逗号分割。
表达式2一般是关系表达式或逻辑表达式,但也可是数值表达式或字符表达式,只要其值非零,就执行循环体。
各表达式中的变量一定要在for循环之前定义。

3.三种循环比较

while, do-while和for三种循环在具体的使用场合上是有区别的,如下:

在知道循环次数的情况下更适合使用for循环;
在不知道循环次数的情况下适合使用while或者do-while循环:
如果有可能一次都不循环应考虑使用while循环
如果至少循环一次应考虑使用do-while循环。
但是从本质上讲,while,do-while和for循环之间是可以相互转换的。

4.结束语句

1.break语句

那么循环5次的时候,需要中断不继续训练。在C语言中,可以使用break语句进行该操作.
使用break语句时注意以下几点:
在没有循环结构的情况下,break不能用在单独的if-else语句中。
在多层循环中,一个break语句只跳出当前循环。

2.continue语句

那么循环5次的时候,需要中断后继续训练。在C语言中,可以使用continue语句进行该操作
continue语句的作用是结束本次循环开始执行下一次循环。
break语句与continue语句的区别是:
break是跳出当前整个循环,continue是结束本次循环开始下一次循环。

三:变量与函数

1.局部与全局

局部与全局
C语言中的变量,按作用域范围可分为两种,即局部变量和全局变量。局部变量也称为内部变量。局部变量是在函数内作定义说明的。其作用域仅限于函数内, 离开该函数后再使用这种变量是非法的。在复合语句中也可定义变量,其作用域只在复合语句范围内。
全局变量也称为外部变量,它是在函数外部定义的变量。它不属于哪一个函数,它属于一个源程序文件。其作用域是整个源程序。

2.变量存储类别

C语言根据变量的生存周期来划分,可以分为静态存储方式和动态存储方式。

静态存储方式:是指在程序运行期间分配固定的存储空间的方式。静态存储区中存放了在整个程序执行过程中都存在的变量,如全局变量。
动态存储方式:是指在程序运行期间根据需要进行动态的分配存储空间的方式。动态存储区中存放的变量是根据程序运行的需要而建立和释放的,通常包括:函数形式参数;自动变量;函数调用时的现场保护和返回地址等。
C语言中存储类别又分为四类:
自动(auto)、
静态(static)、
寄存器的(register)
外部的(extern)。

3.内部函数与外部函数

在C语言中不能被其他源文件调用的函数称谓内部函数 ,内部函数由static关键字来定义,因此又被称谓静态函数,形式为:
static [数据类型] 函数名([参数])
这里的static是对函数的作用范围的一个限定,限定该函数只能在其所处的源文件中使用,因此在不同文件中出现相同的函数名称的内部函数是没有问题的。
在C语言中能被其他源文件调用的函数称谓外部函数 ,外部函数由extern关键字来定义,形式为:
extern [数据类型] 函数名([参数])
C语言规定,在没有指定函数的作用范围时,系统会默认认为是外部函数,因此当需要定义外部函数时extern也可以省略。
静态变量只赋值一次

四:数组

数组:程序中一块连续的,大小固定并且里面的数据类型一致的内存空间。

1.数组声明以及初始化

数据类型 数组名称[长度];

数组只声明也不行啊,看一下数组是如何初始化的。说到初始化,C语言中的数组初始化是有三种形式的,分别是:

数据类型 数组名称[长度n] = {元素1,元素2…元素n};
数据类型 数组名称[] = {元素1,元素2…元素n};
数据类型 数组名称[长度n]; 数组名称[0] = 元素1; 数组名称[1] = 元素2; 数组名称[n-1] = 元素n;
我们将数据放到数组中之后又如何获取数组中的元素呢?

获取数组元素时: 数组名称[元素所对应下标];
注意:
数组的下标均以0开始;
如:初始化一个数组 int arr[3] = {1,2,3}; 那么arr[0]就是元素1。
数组在初始化的时候,数组内元素的个数不能大于声明的数组长度;
如果采用第一种初始化方式,元素个数小于数组的长度时,多余的数组元素初始化为0;
在声明数组后没有进行初始化的时候,静态(static)和外部(extern)类型的数组元素初始化元素为0,自动(auto)类型的数组的元素初始化值不确定。

2.数组遍历

数组就可以采用循环的方式将每个元素遍历出来,而不用人为的每次获取指定某个位置上的元素,例如我们用for循环遍历一个数组:

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

最好避免出现数组越界访问,循环变量最好不要超出数组的长度.
C语言的数组长度一经声明,长度就是固定,无法改变,并且C语言并不提供计算数组长度的方法。

其中数组可以作为参数传值或者变量
数组作为函数参数时注意以下事项:
数组名作为函数实参传递时,函数定义处作为接收参数的数组类型形参既可以指定长度也可以不指定长度。
数组元素作为函数实参传递时,数组元素类型必须与形参数据类型一致。

3.字符串与数组

常用的字符串函数如下(strlen,strcmp,strcpy,strcat,atoi)
在这里插入图片描述
strlen()获取字符串的长度,在字符串长度中是不包括‘\0’而且汉字和字母的长度是不一样的
strcmp()在比较的时候会把字符串先转换成ASCII码再进行比较,返回的结果为0表示s1和s2的ASCII码相等,返回结果为1表示s1比s2的ASCII码大,返回结果为-1表示s1比s2的ASCII码小
strcpy()拷贝之后会覆盖原来字符串且不能对字符串常量进行拷贝
strcat在使用时s1与s2指的内存空间不能重叠,且s1要有足够的空间来容纳要复制的字符串

遇到这个问题时又想到了C++中两个函数sizeof() 和Strlen()

char *str1 = "asdfgh";
char str2[] = "asdfgh";
char str3[8] = {'a', 's', 'd'};
char str4[] = "as\0df";

结果:

sizeof(str1) = 4;  strlen(str1) = 6;
sizeof(str2) = 7;  strlen(str2) = 6;
sizeof(str3) = 8;  strlen(str3) = 3;
sizeof(str4) = 6;  strlen(str4) = 2;

str1是字符指针变量,sizeof获得的是该指针所占的地址空间,32位操作系统对应4字节,所以结果是4;strlen返回的是该字符串的长度,遇到\0结束,\0本身不计算在内,故结果是6。
str2是字符数组,大小由字符串常量"asdfgh"确定,sizeof获得该数组所占内存空间大小,包括字符串结尾的\0,所以结果为7;strlen同理返回6。
str3也是字符数组,但大小确定为8,故sizeof得到的结果是8;strlen统计\0之前所有字符的个数,即为3。
str4是常量字符数组,sizeof得到字符总数即6;strlen计算至\0结束,因此返回2。
总结一句就是sizeof计算的是变量的大小,strlen计算的是字符串长度,前者不受字符\0影响,后者以\0作为长度判断依据。
字符数组的元素个数 == strlen( arr ) 头文件 : string

数字类型数组的元素个数 == sizeof(arr) / sizeof(arr [ 0 ] ) 总数组长度除以一个数组的长度

4.多维数组

多维数组的定义格式是:
数据类型 数组名称[常量表达式1][常量表达式2]…[常量表达式n];

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

定义了一个名称为num,数据类型为int的二维数组。其中第一个[3]表示第一维下标的长度,就像购物时分类存放的购物;第二个[3]表示第二维下标的长度,就像每个购物袋中的元素。
在这里插入图片描述
多维数组的初始化与一维数组的初始化类似也是分两种:

数据类型 数组名称[常量表达式1][常量表达式2]…[常量表达式n] = {{值1,…,值n},{值1,…,值n},…,{值1,…,值n}};
数据类型 数组名称[常量表达式1][常量表达式2]…[常量表达式n]; 数组名称[下标1][下标2]…[下标n] = 值;
多维数组初始化要注意以下事项:

采用第一种始化时数组声明必须指定列的维数。因为系统会根据数组中元素的总个数来分配空间,当知道元素总个数以及列的维数后,会直接计算出行的维数;
采用第二种初始化时数组声明必须同时指定行和列的维数。
二维数组定义的时候,可以不指定行的数量,但是必须指定列的数量

五:指针

1:内存

(1)内存编址

计算机的内存是一块用于存储数据的空间,由一系列连续的存储单元组成,就像下面这样,
在这里插入图片描述
每一个单元格都表示 1 个 Bit,由于 1 个 bit 只能表示两个状态,所以大佬们规定 8个 bit 为一组,命名为 byte。
并且将 byte 作为内存寻址的最小单元,也就是给每个 byte 一个编号,这个编号就叫内存的地址。
在这里插入图片描述
这就相当于,我们给小区里的每个单元、每个住户都分配一个门牌号,在生活中,我们需要保证门牌号唯一,这样就能通过门牌号很精准的定位到一家人。

同样,在计算机中,我们也要保证给每一个 byte 的编号都是唯一的,这样才能够保证每个编号都能访问到唯一确定的 byte。

(2)内存地址空间
上面我们说给内存中每个 byte 唯一的编号,那么这个编号的范围就决定了计算机可寻址内存的范围。
所有编号连起来就叫做内存的地址空间,这和大家平时常说的电脑是 32 位还是 64 位有关。
早期 Intel 8086、8088 的 CPU 就是只支持 16 位地址空间,寄存器和地址总线都是 16 位,这意味着最多对 2^16 = 64 Kb 的内存编号寻址。
这点内存空间显然不够用,后来,80286 在 8086 的基础上将地址总线和地址寄存器扩展到了20 位,也被叫做 A20 地址总线。
当时在写 mini os 的时候,还需要通过 BIOS 中断去启动 A20 地址总线的开关。
但是,现在的计算机一般都是 32 位起步了,32 位意味着可寻址的内存范围是 2^32 byte = 4GB。
所以,如果你的电脑是 32 位的,那么你装超过 4G 的内存条也是无法充分利用起来的。
好了,这就是内存和内存编址

3)变量的本质

有了内存,接下来我们需要考虑,int、double 这些变量是如何存储在 0、1 单元格的。

在 C 语言中我们会这样定义变量:

int a = 999;
char c = 'c';

当你写下一个变量定义的时候,实际上是向内存申请了一块空间来存放你的变量。

我们都知道 int 类型占 4 个字节,并且在计算机中数字都是用补码(不了解补码的记得去百度)表示的。

999 换算成补码就是:0000 0011 1110 0111

这里有 4 个byte,所以需要四个单元格来存储:
在这里插入图片描述
其中数据由左到右,表示数据从高位到低位。而内存地址相反,从左到右是低位到高位。注意到上述把数据的高位存在了低地址位。

当然,这就引出了大端和小端。

像上面这种将高位字节放在内存低地址的方式叫做大端,反之,将低位字节放在内存低地址的方式就叫做小端。

上面只说明了 int 型的变量如何存储在内存,而 float、char 等类型实际上也是一样的,都需要先转换为补码。

对于多字节的变量类型,还需要按照大端或者小端的格式,依次将字节写入到内存单元。

记住上面这两张图,这就是编程语言中所有变量的在内存中的样子,不管是 int、char、指针、数组、结构体、对象… 都是这样放在内存的。

2:指针是什么啥?

变量放在哪?上面我说,定义一个变量实际就是向计算机申请了一块内存来存放。

那如果我们要想知道变量到底放在哪了呢?可以通过运算符&来取得变量实际的地址,这个值就是变量所占内存块的起始地址。

PS: 实际上这个地址是虚拟地址,并不是真正物理内存上的地址

我们可以把这个地址打印出来

printf("%x", &a);

大概会是像这样的一串数字:0x7ffcad3b8f3c

上面说,我们可以通过&符号获取变量的内存地址,那获取之后如何来表示这是一个地址,而不是一个普通的值呢?

也就是在 C 语言中如何表示地址这个概念呢?

对,就是指针,你可以这样

int *pa = &a; 

pa 中存储的就是变量 a 的地址,也叫做指向 a 的指针
你可以认为,编译器会自动维护一个映射,将我们程序中的变量名转换为变量所对应的地址,然后再对这个地址去进行读写。

也就是有这样一个映射表存在,将变量名自动转化为地址:

a  | 0x7ffcad3b8f3c
c  | 0x7ffcad3b8f2c
h  | 0x7ffcad3b8f4c
....

说的好!

可是我还是不知道指针存在的必要性,那么问题来了,看下面代码:

int func(...) {
  ... 
};
 
int main() {
 int a;
 func(...);
};

解引用

上面的问题,就是为了引出指针解引用的。

pa中存储的是a变量的内存地址,那如何通过地址去获取a的值呢?

这个操作就叫做解引用,在 C 语言中通过运算符 *就可以拿到一个指针所指地址的内容了。

比如*pa就能获得a的值。

我们说指针存储的是变量内存的首地址,那编译器怎么知道该从首地址开始取多少个字节呢?

这就是指针类型发挥作用的时候,编译器会根据指针的所指元素的类型去判断应该取多少个字节。

如果是 int 型的指针,那么编译器就会产生提取四个字节的指令,char 则只提取一个字节,以此类推。

下面是指针内存示意图:
在这里插入图片描述
pa 指针首先是一个变量,它本身也占据一块内存,这块内存里存放的就是 a 变量的首地址。

当解引用的时候,就会从这个首地址连续划出 4 个 byte,然后按照 int 类型的编码方式解释。

别看这个地方很简单,但却是深刻理解指针的关键。
举两个例子来详细说明:

比如:

float f = 1.0;
short c = *(short*)&f; 

实际上,从内存层面来说,f 什么都没变。
在这里插入图片描述

假设这是f 在内存中的位模式,这个过程实际上就是把 f 的前两个 byte 取出来然后按照 short 的方式解释,然后赋值给 c。

详细过程如下:

&f取得f 的首地址
(short*)&f

上面第二步什么都没做,这个表达式只是说 :

“噢,我认为f这个地址放的是一个 short 类型的变量”

最后当去解引用的时候*(short*)&f时,编译器会取出前面两个字节,并且按照 short 的编码方式去解释,并将解释出的值赋给 c 变量。

这个过程 f的位模式没有发生任何改变,变的只是解释这些位的方式。
那反过来,这样呢?

short c = 1;
float f = *(float*)&c;

在这里插入图片描述
具体过程和上述一样,但上面肯定不会报错,这里却不一定。

为什么?

(float*)&c会让我们从c 的首地址开始取四个字节,然后按照 float 的编码方式去解释。

但是c是 short 类型只占两个字节,那肯定会访问到相邻后面两个字节,这时候就发生了内存访问越界。

当然,如果只是读,大概率是没问题的。

但是,有时候需要向这个区域写入新的值,比如:

*(float*)&c = 1.0;

那么就可能发生 coredump,也就是访存失败。

另外,就算是不会 coredump,这种也会破坏这块内存原有的值,因为很可能这是是其它变量的内存空间,而我们去覆盖了人家的内容,肯定会导致隐藏的 bug。

3:结构体和指针

结构体内包含多个成员,这些成员之间在内存中是如何存放的呢?

struct fraction {
 int num; // 整数部分
 int denom; // 小数部分
};
 
struct fraction fp; //结构体变量
fp.num = 10;
fp.denom = 2;

这是一个定点小数结构体,它在内存占 8 个字节(这里不考虑内存对齐),两个成员域是这样存储的:
在这里插入图片描述
我们把 10 放在了结构体中基地址偏移为 0 的域,2 放在了偏移为 4 的域。

接下来我们做一个这样的操作:

((fraction*)(&fp.denom))->num = 5; 
((fraction*)(&fp.denom))->denom = 12; 
printf("%d\n", fp.denom); // 输出多少?

接下来我分析下这个过程发生了什么:
在这里插入图片描述
首先,&fp.denom表示取结构体 fp 中 denom 域的首地址,然后以这个地址为起始地址取 8 个字节,并且将它们看做一个 fraction 结构体。

在这个新结构体中,最上面四个字节变成了 denom 域,而 fp 的 denom 域相当于新结构体的 num 域。

因此:

((fraction*)(&fp.denom))->num = 5

实际上改变的是 fp.denom,而

((fraction*)(&fp.denom))->denom = 12

则是将最上面四个字节赋值为 12。

当然,往那四字节内存写入值,结果是无法预测的,可能会造成程序崩溃,因为也许那里恰好存储着函数调用栈帧的关键信息,也可能那里没有写入权限。

大家初学 C 语言的很多 coredump 错误都是类似原因造成的。

所以最后输出的是 5。

4、多级指针

说起多级指针这个东西,我以前上学的时候最多理解到 2 级,再多真的会把我绕晕,经常也会写错代码。

你要是给我写个这个:int ******p 能把我搞崩溃,我估计很多同学现在就是这种情况🤣

其实,多级指针也没那么复杂,就是指针的指针的指针的指针…非常简单。

今天就带大家认识一下多级指针的本质。

首先,我要说一句话,没有多级指针这种东西,指针就是指针,多级指针只是为了我们方便表达而取的逻辑概念。

首先看下生活中的快递柜:
在这里插入图片描述
这种大家都用过吧,每个格子都有一个编号,我们只需要拿到编号,然后就能找到对应的格子,取出里面的东西。

这里的格子就是内存单元,编号就是地址,格子里放的东西就对应存储在内存中的内容。

假设我把一本书,放在了 03 号格子,然后把 03 这个编号告诉你,你就可以根据 03 去取到里面的书。

那如果我把书放在 05 号格子,然后在 03 号格子只放一个小纸条,上面写着:「书放在 05 号」。

你会怎么做?

当然是打开 03 号格子,然后取出了纸条,根据上面内容去打开 05 号格子得到书。

这里的 03 号格子就叫指针,因为它里面放的是指向其它格子的小纸条(地址)而不是具体的书。

明白了吗?

那我如果把书放在 07 号格子,然后在 05 号格子 放一个纸条:「书放在 07号」,同时在03号格子放一个纸条「书放在 05号」
在这里插入图片描述
这里的 03 号格子就叫二级指针,05 号格子就叫指针,而 07 号就是我们平常用的变量。

依次,可类推出 N 级指针。

所以你明白了吗?同样的一块内存,如果存放的是别的变量的地址,那么就叫指针,存放的是实际内容,就叫变量。

int a;
int *pa = &a;
int **ppa = &pa;
int ***pppa = &ppa;

上面这段代码,pa就叫一级指针,也就是平时常说的指针,ppa 就是二级指针。
在这里插入图片描述
不管几级指针有两个最核心的东西:

指针本身也是一个变量,需要内存去存储,指针也有自己的地址

指针内存存储的是它所指向变量的地址

这就是我为什么多级指针是逻辑上的概念,实际上一块内存要么放实际内容,要么放其它变量地址,就这么简单。

怎么去解读int **a这种表达呢?

int ** a 可以把它分为两部分看,即int* 和 a,后面 a 中的表示 a 是一个指针变量,前面的 int 表示指针变量a

只能存放 int* 型变量的地址。

对于二级指针甚至多级指针,我们都可以把它拆成两部分。

首先不管是多少级的指针变量,它首先是一个指针变量,指针变量就是一个*,其余的*表示的是这个指针变量只能存放什么类型变量的地址。

比如int*a表示指针变量 a 只能存放int 型变量的地址。

概念:用来存放一级指针地址的指针 ,也就是指向指针的指针;
理解:指针一般指一级指针,一级指针变量用来存放普通变量的地址;每个变量都有自己本身所对应的地址,一级指针也有自己的地址,我们便用二级指针变量来存储一级指针的地址;

5、指针与数组

(1)一维数组

数组是 C 自带的基本数据结构,彻底理解数组及其用法是开发高效应用程序的基础。

数组和指针表示法紧密关联,在合适的上下文中可以互换。
比如:

int array[10] = {10, 9, 8, 7};
printf("%d\n", *array);  //  输出 10,指针指向数组首地址
printf("%d\n", array[0]);  // 输出 10,等同于指针
 
printf("%d\n", array[1]);  // 输出 9
printf("%d\n", *(array+1)); // 输出 9,数组名+1表示指向下一个元素。
 
int *pa = array;
printf("%d\n", *pa);  //  输出 10
printf("%d\n", pa[0]);  // 输出 10
 
printf("%d\n", pa[1]);  // 输出 9
printf("%d\n", *(pa+1)); // 输出 9

具体来说,当数组名与指针运算结合时,C语言会将数组名解释为指向数组第一个元素的指针,而不是整个数组。因此,数组名+1 实际上是将指针偏移一个元素的大小。

在内存中,数组是一块连续的内存空间:
在这里插入图片描述
第 0 个元素的地址称为数组的首地址,数组名实际就是指向数组首地址,当我们通过array[1]或者*(array + 1) 去访问数组元素的时候。

实际上可以看做 address[offset],address 为起始地址,offset 为偏移量,但是注意这里的偏移量offset 不是直接和 address相加,而是要乘以数组类型所占字节数,也就是: address + sizeof(int) * offset。

学过汇编的同学,一定对这种方式不陌生,这是汇编中寻址方式的一种:基址变址寻址。

看完上面的代码,很多同学可能会认为指针和数组完全一致,可以互换,这是完全错误的。

尽管数组名字有时候可以当做指针来用,但数组的名字不是指针。
最典型的地方就是在 sizeof:

printf("%u", sizeof(array));
printf("%u", sizeof(pa));

第一个将会输出 40,因为 array包含有 10 个int类型的元素,而第二个在 32 位机器上将会输出 4,也就是指针的长度。
为什么会这样呢?

站在编译器的角度讲,变量名、数组名都是一种符号,它们都是有类型的,它们最终都要和数据绑定起来。

变量名用来指代一份数据,数组名用来指代一组数据(数据集合),它们都是有类型的,以便推断出所指代的数据的长度。

对,数组也有类型,我们可以将 int、float、char 等理解为基本类型,将数组理解为由基本类型派生得到的稍微复杂一些的类型,

数组的类型由元素的类型和数组的长度共同构成。而 sizeof 就是根据变量的类型来计算长度的,并且计算的过程是在编译期,而不会在程序运行时。

编译器在编译过程中会创建一张专门的表格用来保存变量名及其对应的数据类型、地址、作用域等信息。

sizeof 是一个操作符,不是函数,使用 sizeof 时可以从这张表格中查询到符号的长度。

所以,这里对数组名使用sizeof可以查询到数组实际的长度。

pa 仅仅是一个指向 int 类型的指针,编译器根本不知道它指向的是一个整数,还是一堆整数。

虽然在这里它指向的是一个数组,但数组也只是一块连续的内存,没有开始和结束标志,也没有额外的信息来记录数组到底多长。

所以对 pa 使用 sizeof 只能求得的是指针变量本身的长度。

也就是说,编译器并没有把 pa 和数组关联起来,pa 仅仅是一个指针变量,不管它指向哪里,sizeof求得的永远是它本身所占用的字节数。
(2)二维数组

大家不要认为二维数组在内存中就是按行、列这样二维存储的,实际上,不管二维、三维数组… 都是编译器的语法糖。

存储上和一维数组没有本质区别,举个例子:

int array[3][3] = {{1, 23}, {4, 56}{7, 8, 9}};
array[1][1] = 5;

或许你以为在内存中 array 数组会像一个二维矩阵:

1  2  3
4  5  6
7  8  9

可实际上它是这样的:

1  2  3  4  5  6  7  8  9

和一维数组没有什么区别,都是一维线性排列。

当我们像 array[1][1]这样去访问的时候,编译器会怎么去计算我们真正所访问元素的地址呢?

为了更加通用化,假设数组定义是这样的:

int array[n][m]
访问: array[a][b]

那么被访问元素地址的计算方式就是: array + (m * a + b)

这个就是二维数组在内存中的本质,其实和一维数组是一样的,只是语法糖包装成一个二维的样子。
6、 void 指针
想必大家一定看到过 void 的这些用法:

void func();
int func1(void);

在这些情况下,void 表达的意思就是没有返回值或者参数为空。

但是对于 void 型指针却表示通用指针,可以用来存放任何数据类型的引用。

下面的例子就 是一个 void 指针:

void *ptr;

void 指针最大的用处就是在 C 语言中实现泛型编程,因为任何指针都可以被赋给 void 指针,void 指针也可以被转换回原来的指针类型, 并且这个过程指针实际所指向的地址并不会发生变化。
比如:

int num;
int *pi = &num; 
printf("address of pi: %p\n", pi);
void* pv = pi;
pi = (int*) pv; 
printf("address of pi: %p\n", pi);

这两次输出的值都会是一样:
在这里插入图片描述
平常可能很少会这样去转换,但是当你用 C 写大型软件或者写一些通用库的时候,一定离不开 void 指针,这是 C 泛型的基石,比如 std 库里的 sort 函数申明是这样的:

void qsort(void *base,int nelem,int width,int (*fcmp)(const void *,const void *));

所有关于具体元素类型的地方全部用 void 代替。

void 还可以用来实现 C 语言中的多态,这是一个挺好玩的东西。
不过也有需要注意的,不能对 void 指针解引用

比如:

int num;
void *pv = (void*)&num;
*pv = 4; // 错误

为什么?

因为解引用的本质就是编译器根据指针所指的类型,然后从指针所指向的内存连续取 N 个字节,然后将这 N 个字节按照指针的类型去解释。

比如 int *型指针,那么这里 N 就是 4,然后按照 int 的编码方式去解释数字。

但是 void,编译器是不知道它到底指向的是 int、double、或者是一个结构体,所以编译器没法对 void 型指针解引用。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值