目录
前言
在编写C代码之前,我们需要了解一些基本的C知识与语法
一、第一个C程序
这里将使用一个标准输出函数 printf 来初步认识C代码
//第一个C程序
#include<stdio.h>
int main()
{
int x = 0;
printf("%d\n", x);
return 0;
}
头文件
<stdio.h>是一个头文件。由于 printf 是一个库函数,所以在调用这个库函数之前需要引用<stdio.h>这个头文件以声明这个库函数(' i '即 input,' o '即 output,所有关于输入输出的库函数都需要引用这个头文件以声明)
主函数
这个程序中 main() 是一个主函数,它是程序的入口,一个工程中有且只有一个主函数,前面的int表示这个函数在执行完成后会返回一个整形(关于数据类型马上就会提到)。用{ }包括的代码块是这个函数的函数体。
定义变量
int x = 0; 表示定义了一个整型变量,变量名是x,并且给这个变量赋予一个初始值0。
printf函数
printf()是一个库函数,用于标准输出,可在屏幕上打印一些东西。上述代码表示打印一个整形x的值,即打印一个0(用%d表示打印一个整形)
\n
\n 是一个转义字符表示换行
语句
每一条语句后必须加上一个 ";" ,表示这个语句的结束。这也意味着在if、while 等语句的判断部分之后不能加";",因为此时这个语句还没有结束。(有关分支与循环语句后面的部分会讲)
return 0
return 0; 表示返回一个0 ,同时意味着主函数已成功运行。
注释
用 /* */ 包括的内容,以及 // 之后的整行内容都不会被编译器识别。但是 /* 只会识别与其最近的*/并注释掉其中的代码,所以当想要注释掉的内容中有已经注释的部分,就可能会出现一些bug。所以建议大家用 // 来注释。
二、数据类型
基本数据类型
在C语言中有许多的基本数据类型(当然还有自定义类型这个后面再提)分别是
char //字符整形
short //短整型
int //整形
long //长整型
long long //更长的整形
float //单精度浮点数
double //双精度浮点数
那为什么会需要好几种数据类型用来存储数据呢?
因为每一个数据类型所占内存的大小不同,将不同大小的数据存入合适的数据类型中有利于提高内存的使用效率。这里,我们可以通过代码看一下每个数据类型所占的内存大小:
可以很明显的看出每一个数据类型大小(这段代码中sizeof()是一个关键字,可以计算一个数据的大小,单位是字节)。
类型的使用
数据类型在C代码中被广泛使用,这里先介绍其在定义变量时的作用
char ch = 'a';
int i = 0;
float f = 0;
这里定义了一个char类型的变量,变量名为ch,初始值为'a';一个int型的变量,变量名为i,初始值为0;同理,这个float型的变量变量名为f,初始值为0。当然,也可以说是0.0。
本质上是向内存申请了不同字节的空间用于存储一个数据。当然存储于内存中的都是二进制数,所以char类型的数据存储时,实际存的是其ASCII码值。float与double类型则有其特殊的存入方式。在输出时会通过不同的格式代码来区分要打印什么。(这个其实并不是很重要,可以了解一下:%d是十进制整形,%c是字符,%f是浮点数,%s是字符串)
计算机中的单位
这里浅提一下计算机中的内存单位bit(比特)是计算机中的最小单位,只可存放一个二进制位。然后是byte(字节)大小等于8个比特位。之后,便是大家耳熟能详的KB、MB、GB、TB、PB。1KB=1024B,之后的进制均为1024。
三、常量与变量
1、变量
变量,就是可以被改变的量。如何定义变量在上一部分中已经介绍过,这里就不赘述了这里主要介绍一下变量的分类与其性质。
变量可以分为局部变量与全局变量:
局部变量
局部变量就是定义在代码块里的变量(即{}内定义的变量)
* 作用域:变量所在的代码块内;
* 生命周期:进入作用域变量被创建,出作用域时销毁(局部变量是存放在栈区中的,方便于存储与销毁)
全局变量
全局变量就是没有定义在代码块里的变量
* 作用域:整个工程(具有外部链接性,跨文件使用时需要用 extern 声明)
* 生命周期:等于整个程序的生命周期(局部变量是存放于静态区中的)
另外
当一个局部变量于全局变量的变量名相同,使用这个变量时局部变量优先。如图示代码,定义了一个全局变量x赋值为10,又定义了一个局部变量x赋值为20,此时打印x变量时,以局部变量优先。可能有人会质疑是否是定义先后导致的,这里希望大家自行试验。
* 如果全局变量未初始化,会使这个变量被赋予0。如果局部变量未初始化,会使这个变量别赋予任意值。所以,在定义一个变量时最好给它赋予一个初始值,否则将会导致一些意料之外的结果。
2、常量
常量就是不能被改变的值,分为
字面常量、const修饰的常变量、#define定义的标识符常量、枚举常量
字面常量
直接写出的一个数字、值、字符、字符串就是字面常量。比如 3、'a'、"abcdef" 这样,是不能被改变的。就像我们不能写出像 3=4 ,这样的赋值语句一样。
const修饰的常变量
const修饰的变量会使之具有常属性,但这个变量本质上还是变量。这里我们通过代码来验证一下:
很明显,这段代码是漏洞百出的。
第一,const修饰的变量具有了常属性,使之不能被改变。
第二,在定义数组时,数组的大小必须是一个常量。而不论是用n定义数组大小,还是用m定义数组大小都是不被允许的。所以即便是const修饰,本质上依然是个变量。
#define定义的标识符常量
#define可以定义常量和宏,这里暂且只谈其定义标识符常量。这里用一段代码来说明:
这段代码在修改MAX的时报错,在用标识符常量MAX去定义数组大小时是没有问题的,说明#define定义的标识符常量,本质上也是个常量。
枚举常量
枚举是一种自定义类型,即一一列举。这里用了枚举关键字 enum 后面的部分会提到,只需知道这里的Red、Blue、Green都是常量,不能被改变即可。
#include<stdio.h>
enum Color
{
//Red,Green,Blue是枚举类型Color的可能取值,同时也是常量,不能被改变
Red,
Green,
Blue
};
int main()
{
printf("%d\n", Red);
printf("%d\n", Green);
printf("%d\n", Blue);
//枚举常量是默认从0开始,依次往下递增1的
return 0;
}
四、字符串与转义字符
1、字符串
"hello world"
像这样由双引号引起来的一串字符称为字符串面值,简称字符串。字符串的结束标志是'\0'它是一个转义字符(这个马上会讲到),在计算字符串长度时不算做字符串内容。代码如下:
#include<stdio.h>
int main()
{
char s1[] = "hfy";
char s2[] = { 'h', 'f', 'y' };
char s3[] = { 'h', 'f', 'y', '\0' };
printf("s1=%s\n", s1);
printf("s2=%s\n", s2);
printf("s3=%s\n", s3);
return 0;
}
在C语法中没有定义字符串类型,只有字符数组用于存放字符串,所以这两种方式初始化字符串都是正确的。
在前文中讲过%s用于字符串的标准输入与输出。
我们可以很容易的看出,当有'\0'在字符串的结尾作为结束标志时,标准输出在遇到'\0'时停止打印(用"hfy"初始化字符串会自带'\0'在其结尾)。但当没有'\0'在结尾时,在打印玩hfy之后会继续向后打印,所以显示出一行乱码。
2、转义字符
转义,就是转变意思,使这个字符不再是原来的意思。这里只做简单的列举与解释。
列举转义字符
\? 在书写连续多个问号时使用,防止其被解释为三字母词
\' 用于表示字符常量'
\" 用于表示一个字符串内部的双引号
\\ 用于表示一个反斜杠,防止其别解释为一个转义序列符
\a 警告字符,蜂鸣
\b 退格符
\f 进纸符
\n 换行
\r 回车
\t 水平制表符
\v 垂直制表符
\ddd 表示三个八进制数字
\xdd 表示两个十六进制数字
举例
#include<stdio.h>
int main()
{
printf("打印1=hfy\n");
printf("打印2=hfy\\n ");
printf("打印3=\" ");
printf("打印4=c:\test\628\test.c\n");
printf("打印5=%d\n", sizeof("c:\test\628\test.c\n"));
return 0;
}
这段代码已经可以说明许多问题了:
打印1中有一个转义字符 \n 所以没有打印 n 而是进行了换行
打印2中转义字符 \\ 使 \n 中的 \ 不再使换行符的一部分,所以这里打印了字符 n,并且没有换行
打印3中转义字符 \" 使这个双引号不再与其他的双引号组合,而是成为了一个字符,然后被打印
打印4中有两个水平制表符 \t 。有 \62 被解析为由两个八进制数所表示的十进制数所对应的字符,也就是字符 '2' ,但因为八进制数中最大数为7,所以这里的8只是被解析为字符 '8' 。在字符串的最后有一个换行符 \n 、使打印5进行了换行。
打印5中用sizeof计算了字符串的大小。需要注意的是:*不论转义字符由几个字符组成,其大小都是一个字节;*sizeof在计算字符串大小(不是计算字符串长度)时会将其结束标志 \0 也计入。所以这16个字节分别是:
c : \t e s t \62 8 \t e s t . c \n \0
五、分支与循环
1、分支语句
分支语句,实际上就是分类讨论。当满足某一条件时就会导致某一结果。
这里我们介绍两种: if 语句和 switch 语句
if
这里先来浅举一个例子:假设有一个分段函数 f(x) ,当 x<0 时 f(x) = 1 ;当 x=0 时 f(x) = 0 ;当 x>0 时 f(x) = -1 。对于这个函数,我们就可以用 if 语句来刻画它,代码如下:
这是一段多分支的代码。这里运用了一个库函数scanf用于标准输入一个值,并将其赋给 x(后面的部分会详细讲)。这里先讲一下 if 语句的格式:它由 if 、判断部分(小括号里的内容)、执行部分(大括号里的内容)组成,当判断部分的结果为真时,才会去执行后面 if 下面 {} 里的内容;若为假会继续判断 else if 后的小括号里的判断部分是否为真,为真才会去执行else if 下面 {} 里的内容;若为假,就会执行 else 下面 {} 里的内容。
当删去其中的 else if 时它就成为了一个双分支语句。若 if 判断为假,则执行 else 下的代码块。例如:(这里可能会有一些暂时不认识的符号,如 !=(判断不相等操作符)、==(判断相等操作符) 、&(取地址操作符)这些在后面的部分都会讲到)
这里,当 x 不等于0时,if 为真,执行 y=1 ;当 x 等于0时,if 为假,执行 y=0 。
另外,当 if 判断为真时,就不会进入 else if 的判断部分。这里需要特别注意使用时是否有逻辑问题。
当然,else if 和 else 都是可以删去的,这样的话 if 语句就只是单纯的判断语句了(也就是单分支),判断为真就执行 ,为假就不执行。例如:
在这段代码中:输入-5,判断为真,执行 y=1 ;输入10,判断为假,不执行 y=1 ,打印出的就是 y 的初始值0。
另外,需要注意的是 if 语句在没有 {} 划定用于执行的代码块的情况下,只默认 if 语句下的第一条语句为其执行的部分。为了避免不必要的麻烦,建议在书写 if 语句时都加上 {} 书写用来执行的代码块。
switch
switch语句同样也是分支语句的一种,这里先简单的介绍一下它的格式:
switch(1)//整形表达式
{
case 1://常量表达式
;//语句
case 2:
;//语句
default:
;//语句
}
以上就是 switch 语句的基本格式,switch 语句常常用于多分支。它会判断 switch 后小括号里的整形表达式等于哪一种情况,然后进入该分支,依次执行接下来 switch 中的所以语句项。如果小括号里的整形表达式不等于任何一个分支,就会从 default 子句开始执行代码,所以在一个 switch 语句中至多只能有一个 default 子句( default 是可以放在任何 case 可以出现的位置的,但其本身不具有 break 的作用,将其置于前面仅相当于一个 case 。就和 case 分支在设置时不存在顺序一样)。下面我们来举一个输入数字打印对应星期的例子:
这段代码可以向我们展示出一些需要注意的点:
* 在 switch 语句中,break 起着非常重要的作用。它使 switch 语句可以实现真正的分支。在遇到break 时 switch 语句才会结束,在这之前 switch 语句会从起始的 case 分支依次执行每条语句(如图示:输入1时连续打印了周一周二周三然后遇到 break 停止)。当然,执行完最后一条语句后 switch 语句也会结束。
* 在 switch 后的判断部分必须填入一个整形表达式(字符整形也包含在内),如果不加限制的填入,可能会导致一些问题。在VS2013环境下输入一个浮点数,编译器会对其取整(图中并没有举例)。case 后必须是一个常量表达式,若错放了一个变量,编译器将会报错。
2、循环语句
在认识具体的循环语句之前,我们要知道循环的基本逻辑:反复的执行一件事情(循环体),产生一种效果(调整),直到达到某一个条件为止(判断),跳出循环。循环语句在C中运用十分广泛。这里介绍三种循环语句:while、for、do_while。对于这些语句,我们需要知道的是它们的格式(即判断部分、循环体、调整部分分别在什么地方),以及它们的一些特性(主要是 continue 与 break 在这些语句中的异同)。这样,你就可以用C实现一些有趣的东西了。
(注:这里先简单介绍一下 continue 与 break :它们都是C语言中的关键字,用途与字面意思相同:continue 用于结束循环体中其后语句的执行;break 用于跳出当前的循环代码块或switch 。只是在不同的语句中会有一些细节的差别。)
while
#include<stdio.h>
int main()
{
int n = 0;
scanf("%d", &n);
while (n != 0)//判断部分(为真执行)
{
//循环体以及调整部分
n--;
printf("%d\n", n);
}
return 0;
}
如上就是 while 语句的基本格式 ,while 后的()内就是判断部分,当判断为真的时候进入循环体执行并调整,当执行完一遍后再回到判断部分再次判断。如此循环,直到再次判断时判断为假,跳出循环。(这里的 n-- 表示将 n-1 的值再赋给 n,这里先 n-- 再打印所以没有打印 5~1 而是 4~0)
要特别注意的是:我们需要时刻关注判断部分的有效性。如果再循环体中没有调整部分,或者虽然有调整部分,但是判断永远都不可能为假。这就是我们常说的死循环,我们需要避免这种情况的出现。(在其他两种循环语句中同样需要注意!)
continue 与 break:break 用于跳出循环体;continue 用于跳过本次循环,直接进行下一次的判断。如下两段代码可以说明其用途:
第一段代码中输入5,判断 n!=0 进入循环:打印n(n=5)-> 判断为假,if 不执行 -> n--(n=4)->再次判断 -> 进入循环。。。直到 n=2 时 if 语句判断为真,执行 break 跳出循环。
第二段代码中输入5,判断 n!=0 进入循环:n--(n=4) -> 判断为假,if 不执行 -> 打印n (n=4) -> 再次判断 -> 进入循环。。。直到 n=2 时 if 语句判断为真,执行 continue 跳出本次循环,不打印n -> 再次判断 n!=0 进入循环 -> n--(n=1)-> 判断为假,if 不执行 。。。直到 n==0 时,判断为假,循环结束。
for
for 循环的使用,较 while 循环而言是更为广泛的,因为其将初始化,判断部分,调整部分都放在了for后面的小括号内。方便进行调整(while 循环是没有规定初始化的)。格式如下:
for (n = 0/*初始化*/; n < 10/*判断(为真执行)*/;n++/*调整部分*/)
{
//循环体
printf("%d ", n);
}
for 循环的运行逻辑与 while 循环的逻辑略有不同:在 while 循环中你可以自由的定义调整部分与循环部分的顺序,虽然这样的自由在一些时候可能会导致一些问题;但 for 循环已经规定了循环执行的顺序,即初始化(只有在进入 for 循环时会初始化一次) -> 判断 -> 循环主题 -> 调整。这样的限制使 for 循环变得更可控。
也正是这样的顺序,使得 continue 在 for 循环中的意义发生了一些变化:在用 continue 跳过本次循环后,会跳至调整部分调整后再判断而不是跳至判断部分直接判断。break 并没有被赋予不同的用法。
do_while
do_while 循环与以上的两种又有不同的逻辑:进入时不判断,先执行一次,然后在循环体的后面判断。实例代码如下:
#include<stdio.h>
int main()
{
int n = 0;
scanf("%d", &n);
do
{
//循环体
printf("%d ", n);
n--;
} while (n!=0);//判断(为真执行)
return 0;
}
这段代码的运行结果是5 4 3 2 1。为什么没有打印出0呢?
因为 do_while 循环判断部分在循环体的后面。这样,当打印出n(n=1)后,n--(n=0)这时再进行判断 n!=0 结果为假,循环结束。
当 do_while 循环中存在 continue 时,会跳过本次循环直接到达判断部分进行判断;do_while 中的break 依旧是跳出整个循环。(但其实 do_while 的可读性不高,使用频率也不是很高)
3、goto
goto 语句其实并没有特别常用,而且会使代码的可读性大大降低。但是我们任然需要了解它:
#include<stdio.h>
int main()
{
int n = 0;
scanf("%d", &n);
while (n != 0)
{
printf("%d ", n);
n--;
if (n == 3)
{
goto again;
}
}
again:
return 0;
}
这段代码就不做解释了,goto 语句的格式就是这样。但这里只是简单的举个例子,要只是这样用goto语句就有些许过分了。另外,goto语句不能跨函数使用。
六、指针
由于指针在C中可以说是极其重要的,所以我们应该在函数与数组之前就了解一些指针的基本知识。
1、指针
在了解指针之前,我们需要了解内存大概是个什么样子的。首先,内存中的最小单元是一个字节(由8个比特位组成),以32位机器为例,CPU一次性可以处理字长为32位的二进制数据(如10011111000010100011100011000110),所以在32位机器中就有从00000000000000000000000000000000 到 1111111111111111111111111111111 的2^32个内存编号。每一个编号都对应有一个字节的空间。这里我们一般使用8个十六进制的数字来表示这个编号,即为0x 00 00 00 00到 0x ff ff ff ff 。我们把这个编号称为这个内存单元的指针。也就是地址。
然后就可以引出指针的定义:指针是内存中一个最小单元的编号。这里来举一个例子,比如说好樊鸭住在西邮宾馆的1006房间,这里的西邮宾馆1006就是好樊鸭的地址,我们就可以通过这个地址去找到这个地址里面住的好樊鸭。同理,如果我们定义了一个变量 int n = 10; 计算机是需要开辟出4个字节的空间去存放这个int型的变量 n 的。而这4块空间在计算机中都是有一个唯一的编号的。这时,我们就把存放 n 的这块空间的四个字节的编号中的第一个编号称为 n 的地址。此时,我们可以吧这个地址称为指向 n 的指针。对于只有一个字节的 char,8个字节的 double 同理。
2、指针变量与指针类型
指针变量
那么,我们如何能获取一个变量的指针?我们应该用什么来存放指针?先来看一段代码:
#include<stdio.h>
int main()
{
int n = 0;
int* p = &n;
printf("%d\n", n);
printf("%p\n", p);
printf("%p\n", &n);
*p = 20;
printf("%d\n", *p);
printf("%d\n", n);
return 0;
}
(%p是打印地址)在这段代码中,定义了一个 int 型的变量 n,初始值为0。然后又定义了一个变量 p ,初始值是&n(&是取地址操作符,它的作用是取出变量的地址),它的类型是 int*(这就是指针变量,马上会提到),当我们去用%p 打印 p 的时候,发现结果是一个由8个十六进制数组成的编号,这与用 %p 打印 &n 的结果相同,说明 & 取出了 n 的地址,然后将这个地址赋给了 int* 型的 p。这里的 p 就是一个指针变量。也就是存放指针的变量。
前面说过,可以通过西邮宾馆1006这个地址,找到里面住的好樊鸭。那我们能不能通过指针去改变其中存的数据呢?答案是肯定的,我们用 *(解引用操作符)就可以实现通过指针变量改变其中存的数据。继续看这段代码:
*p = 20;
printf("%d\n", *p);
printf("%d\n", n);
*p =20; 就是把 p 这个指针变量解引用,取出里面存的数据。可以说 *p 就相当于 n 。 在后面分别用 %d 打印 *p 与 n 的时候,结果也都是20。那么,为什么会有如此多此一举的操作呢?因为我们在进行传址调用的时候就必须用到 * 了,这个函数的部分会提到。
另外,由于指针变量是存放指针的变量,我们也习惯性的将指针变量称作指针。
指针类型
数据有许多的类型(上面已经介绍过):
char short int long long long float double
相应的就会有与之一一对应的指针类型:
char* pc = NULL; //字符指针
short* ps = NULL; //短整型指针
int* pi = NULL; //整形指针
long* pl = NULL; //长整型指针
long long* pll = NULL; //更长整形指针
float* pf = NULL; //单精度浮点型指针
double* pd = NULL; //双精度浮点型指针
如上:表示了指针的几种类型以及如何定义一个指针变量,即在数据类型后加上 * 作为指针变量的类型,然后是变量名,最后给这个指针变量一个值(NULL表示空指针,表示该指针不指向任何内存空间)。
我们自然能够想到 char* 类型的指针是为了存放 char 类型的数据;int* 类型的指针是为了存放 int 类型的数据...... 但是,上面提到过:在32位的机器上每一个指针(最小单元的编号)都是由32个二进制位组成,而不同大小的数据都只是取了其多个字节地址的第一个字节的地址作为该数据的指针。所以,不论是哪种指针类型,他的内存大小都是相同的(在32位机器上就是 32/8=4 个字节)。这里,我们可以通过一段代码来验证一下(上面提到过 sizeof() 可以计算一个数据所占内存的大小,单位是字节):
可以很清楚的看出来,这些指针变量的大小都是4。这是否意味着 char* 中也可以存 int 型的数据?答案是肯定的。可以。那设置不同的指针类型有什么意义呢?
3、指针类型的意义
决定了指针进行解引用操作时能访问空间的大小
上面提到了,使用解引用操作符可以通过指针变量访问存储的数据,但是当指针变量解引用时能访问的内存大小是由指针类型决定的。一个 char* 类型的指针变量解引用时只能访问1字节,int* 的能访问4个字节,其他的指针类型同理。我们可以通过调试来观察一下:
可以看出:在这段代码中用 char* 型的指针 pc 解引用改变 int 型的 n 的值(0x11223344表示这是一个8位的十六进制数)时,只访问了一个字节并将其赋值为00。
但是当我们用 int* 型的指针 pi 改变 n 的值时,能够访问4个字节并把它们都改为0。
决定了指针的步长
在了解这个问题之前我们需要知道:在数组中,指针加或减一个整数,指针就会向前或向后移动多少个元素。来看代码:
这里需要一些数组的知识:数组是一些相同类型的元素的集合。int nums[4] = { 1, 2, 3, 4 }; 是数组的定义,它表示定义了数组,数组名是 nums ,数组内有4个元素,每个元素都是 int 型的。另外,数组名是数组首元素的地址,在这段代码中就是1的地址。
这段代码中,我们通过解引用 nums(数组首元素地址)打印出了首元素1,然后又给 nums+2 后再解引用,发现打印的是第三个元素 3。这说明 nums+2 是数组第三个元素的指针,加 2 向后移动了两个元素。(这里建议大家跳至数组部分先浅看一二)
但是,这种说法不是完全正确的!当指针加或减的时候向前或向后移动的字节数是由该指针的步长决定的。指针的步长就是指针在 + 或 - 1时所向前或向后移动的字节数(数组中指针加减整数后恰好移动几个元素是因为指针的字节数刚好等于数组中元素的字节数)。调试窗口观察一下:
可以看出用 for 循环改变 nums 后加的整数来移动指针。nums 是数组首元素的地址,而数组nums 中的元素是 int 型的,所以这里的 nums 的类型是 int*。int* 的步长是4,所以在循环4次后将数组中的元素全部改为了0。那么,如果我们将 nums 的类型从 int* 转化为 char* 会出现什么效果呢?
(类型) 是强制类型转换操作符,将变量强转为括号中的类型。在这段代码中:同样的 for 循环移动指针,但是当 nums 是 char* 的指针时,步长是1,每次加一后移只能移动一个字节。四次循环后也仅仅将四个字节的内容改为0。
4、二级指针
二级指针就是存放一级指针地址的变量。
那么二级指针的类型是什么呢?我们来分析一下:
char* pc = NULL; //字符指针
short* ps = NULL; //短整型指针
int* pi = NULL; //整形指针
long* pl = NULL; //长整型指针
long long* pll = NULL; //更长整形指针
float* pf = NULL; //单精度浮点型指针
double* pd = NULL; //双精度浮点型指针
不难看出,指针类型去掉 * 就是这个指针类型解引用时可以访问的空间大小的类型。比如:int* 型的指针变量解引用后可以访问的空间大小就是一个整形变量的大小,也就是四个字节。
说得草率一点:指针类型去掉 * 就是这个指针类型解引用时指向的数据的类型。就不难联想到,一个二级整形指针的类型就是 int** 。代表这个指针变量指向的是 int* 型的指针,这个指针指向的是一个整型。
#include<stdio.h>
int main()
{
int a = 0;
int* pa = &a;
int** ppa = &pa;
printf("a %d\n", a);
printf("&a %p\n", &a);
printf("pa %p\n", pa);
printf("&pa %p\n", &pa);
printf("ppa %p\n", ppa);
return 0;
}
二级指针也是一种指针,所以它的大小与一级指针相同:32位机器上为4个字节;64位机器上为8个字节。
七、操作符与关键字
前面的内容中曾很多次的提到操作符与关键字,这部分中就系统的介绍一下:
1、操作符
C语言中有许多的操作符,先来看一下操作符的分类:
1、算数操作符
2、移位操作符
3、位操作符
4、赋值操作符
5、单目操作符
6、关系操作符
7、逻辑操作符
8、条件操作符
9、逗号表达式
10、下标引用、函数调用和结构成员
算术操作符
+(加) -(减) *(乘) /(除) %(取模)
算数操作符看字面就可以理解,但是需要注意的是:
% 的操作数必须为两个整数,其余的算术操作符操作数可以为整数也可以为浮点数;
对于 / 若两个操作数均为整数时执行整数除法,若有一个操作数为浮点数则执行浮点数除法。
移位操作符
<< 左移操作符
>> 右移操作符
(注:位移操作符的操作数只能是整数)
这里需要补充一下整数在内存中的存储:我们都知道,数据在内存中是以二进制的形式存储的。就整数而言分为有符号数与无符号数。有符号数的第一个二进制位是符号位,0 表示正数 1表示负数;无符号数的二进制首位都是0。例如:放在 short 型中的 -5 用二进制表示就是 1000000000000101;5 用二进制表示就是 0000000000000101 。我们把这样的二进制数叫做原码。但是直接用这个二进制数进行计算时会导致许多问题。-5+5 应该等于0,但是这两个原码直接相加的结果是 1000000000001010 再转为十进制数是 -10 ,显然出现了错误。所以就有了反码与补码以解决这种问题。
对于正数与无符号数而言原码、反码、补码相等;对于负数而言,反码是原码符号位不变其他位按位取反得来的(-5 的反码就是 1111111111111010),补码是反码加一(-5 的补码就是 1111111111111011)。
整数在内存中存储的就是补码,整数的运算也是用补码进行的。-5+5 就是 1111111111111011 与 0000000000000101 相加后再变为原码对应的十进制数,也就是 0。
左移操作符:
整数补码的左边抛弃,右边补0。
对于 n :5 是个正数,原反补相同,均为 00000000000000000000000000000101 ,左移一位后就是 00000000000000000000000000001010 按十进制打印就是 10 。
对于 m:-5 是负数,原码为 10000000000000000000000000000101 ,反码为 11111111111111111111111111111010 ,补码为 11111111111111111111111111111011,左移一位就是11111111111111111111111111110110 ,然后再将其化为反码再化为原码就是 10000000000000000000000000001010 按十进制打印就是 -10 。
右移操作符:
右移运算分为两种:逻辑位移与算术位移。
在逻辑位移中:补码右边丢弃,左边补0。
在算术位移中:补码右边丢弃,左边用原值的符号位填充。
采取哪种右移运算取决于你所用的编译器,思维方式与左移类似,这里就不用代码说明了。
位操作符
& 按位与
| 按位或
^ 按位异或
(注:位操作符的操作数只能是整数)
先来看一段代码:
& 按位与
二进制补码同为1是1。
n、m 的原反补码相同。n 的补码为 00000000000000000000000000000101 ;m 的补码为 00000000000000000000000000000011。
n & m 为 00000000000000000000000000000001,化为十进制打印就是1。
| 按位或
二进制补码同为0是0。
n | m 为 00000000000000000000000000000111,化为十进制打印就是7。
^ 按位异或
二进制补码相同为0,不同为1。
n ^ m 为 00000000000000000000000000000110,化为十进制打印就是6。
当然,负数的位运算就要先换成补码运算后再换回原码,再化为十进制打印。
赋值操作符
通常意义上的赋值操作符就是 = ,它可以把一个值赋给一个变量,像这段代码中表示的这样:
现在来介绍一些复合赋值符:
+= -= *= /= %= >>= <<= &= |= ^=
这些操作符的含义十分简单就是将一个变量执行等号前面操作符的运算后再赋值给它本身。这里选择几个举例说明一下:
+=
这里就是给 n+2 后再赋值给 n 。-= 、*= 、/= 、%= 与之类似 。
<<=
这里就是将 n 的补码左移两位,右边补0后,再化为十进制打印(n 是正整数,原反补相同)。>>= 与之类似。
&=
可以看到打印出的 n 是 5 ,与 n 初始化的值相同,难道是出错了吗?我们来分析一下:
n 的原反补相同,为 00000000000000000000000000000101;m 是负数,原码为 10000000000000000000000000000011,原码符号位不变其它位按位取反得到反码:11111111111111111111111111111100,反码加一得到补码:11111111111111111111111111111101 。n & m 的二进制补码值为 00000000000000000000000000000101 ,将这个补码赋给 n 。而这个补码的符号位是 0 所以这是一个正数,原反补相同,所以赋值之后 n 的原码仍然是 00000000000000000000000000000101,化为十进制打印就是5 。
所以并没有出错,确实实现了将 n & m 的值赋给 n 。^= 与 |= 同理。
单目操作符
在前面的内容中,我们曾多次使用了一些单目操作符,这里将它们整合一下:
! //逻辑反操作
- //负值
+ //正值
& //取地址
sizeof() //操作数的大小(单位是字节)
~ //按位取反
-- //减一后赋值
++ //加一后赋值
* //间接引用(解引用)
(类型) //强制类型转换
!
对于逻辑反操作,就是条件不满足的时候为真,条件满足的时候为假。例如:
在这段代码中:for 循环,当 n 不等于5时打印 n。
& 与 *
取地址操作符与解引用操作符主要在指针的部分使用,用于取出变量的地址和通过指针访问变量。
在这段代码中:pn 是一个 int* 型的指针变量,其中存放的是变量 n 的指针,所指向的变量是 n ,n中存放的是0。然后对 pn 解引用访问 n 并将其赋值为 20 。最后打印 n 。
++ 与 --
表示将变量加一或减一后再赋给该变量。
n++ 的作用等同于 n=n+1 等同于 n+=1 。
需要说明的是:n++ 与 ++n 是有很大区别的。++ 前置是先加一再使用加一后的值;++ 后置是先使用原值后加一。例如:
这段代码中,n 与 m 同样是 0,但是 ++n 表示在使用之前就加一,所以打印出的 n 值为1;而 m++ 表示使用之后再加一,所以打印出的 m 值为 0 。
sizeof()
前面提到 sizeof 用于计算操作数的大小,单位为字节。这里就不赘述。
一些需要注意的关于数组与函数相关的知识在后面再提。
(类型)
强制类型转换,将操作数的类型强转为 () 中的类型。举例见指针的步长。
关系操作符
< <= > >= != ==
大于、大于等于、小于、小于等于的含义与我们平时理解的并没有区别。!= 是判断不相等,相等为假,不等为真。
这里需要注意的是 == 是判断相等,而 = 是赋值。需要特别注意不要用错,否则会导致一些意料之外的结果。
逻辑操作符
&& 逻辑与
|| 逻辑或
&&
全为真才为真。
需要特别注意的是:&&连接两条及以上的语句时,当一条语句为假后,后面的所有语句不会执行,此时语句为假。
int main()
{
int a = 0;
int b = 2;
int c = 3;
if (a++ && ++b && c++)
{
printf("1\n");
printf("%d %d %d", a, b, c);
}
else
{
printf("2\n");
printf("%d %d %d", a, b, c);
}
return 0;
}
在这段代码中:&& 连接了三条语句,第一条是 a++ ,先使用原值后加一 ,所以第一条语句等于0,为假,后面的语句不再执行,表达式结果为假。所以进入 else ,打印 a 的值(1), b 、 c 的初始值(2)(3)。
||
全为假才为假。
需要特别注意的是:||连接多两条及以上的语句时,当一条语句为真后,后面的所有语句不会执行,此时语句为真。
int main()
{
int a = 0;
int b = 2;
int c = 3;
if (a++ || ++b || c++)
{
printf("1\n");
printf("%d %d %d", a, b, c);
}
else
{
printf("2\n");
printf("%d %d %d", a, b, c);
}
return 0;
}
在这段代码中:|| 连接了三条语句,第一条先使用 a 的原值 0 ,为假,之后 a 自加一变成 1 ;第二条语句 ++b 先自加一变成 3,再使用,为真。后面的语句不再执行,表达式结果为真。所以进入 if ,打印 a(1)、b(3)、c 的初始值(3)。
条件操作符
exp1 ? exp2 : exp3
这里就先简单介绍:判断表达式一(exp1),若为真则执行表达式二(exp2),否则执行表达式三(exp3)。 可以尝试用这个操作符求一下两个数中的较大值,这里就不举例说明。
逗号表达式
exp1,exp2,exp3,...,expn
逗号表达式就是用逗号隔开的一串表达式,最后一个表达式的结果就是整个表达式的结果。
下标引用、函数调用和结构成员
[] 下标引用操作符
() 函数调用操作符
数组名+[ ]
表示由下标访问数组元素,这一点在后面的数组部分会谈到。
函数名+()
表示调用一个函数,这一点在后面的函数部分会谈到。
访问结构体成员操作符
. 由结构体变量名访问结构体成员(结构体.成员名)
-> 由结构体指针访问结构体成员(结构体指针->成员名)
表示访问一个结构体变量中的成员,这一点在后面的结构体部分会谈到。
2、关键字
C语言提供了许多关键字,这些关键字都是设定好的,不能自己创建关键字。下面一一列举这些关键字:
// auto break case char const continue default do
// double else enum extern float for goto if
// int long register return short signed sizeof static
// struct switch typedef unsigned union void volatile while
有许多关键字是已经被我们所熟知的,除已经认识的,这里只介绍几种关键字:
extern
用于声明外部符号。
当我们使用一个变量或者使用一个函数时是必须要声明的,当我们将变量或函数定义在使用的前面时就相当于已经声明过。但是如果将变量或函数定义在其他地方时,使用前的声明就必不可少了。
我们在前面谈过,全局变量是具有外部连接性的。但是在跨文件使用时,在要使用的文件中是没有声明这个变量的,所以需要用 extern 来声明。函数跨文件使用时也是同样的道理(函数在跨文件使用时也可以用包含头文件的方式声明),之后就可以直接调用在别的文件中定义的函数了。
register
建议将变量定义为寄存器变量。
计算机将数据存储在硬盘与内存(空间递减,速度递增)中。CPU 处理数据时要从内存中读取数据,但随着科技的发展 CPU 读取数据的速度越来越快,从内存中取出的速度已经跟不上了,就限制了处理数据的速度。所以,就研究出了更快的存储模块:高速缓存,先将数据从内存中转移到高速缓存,再由 CPU 处理。后来,高速缓存的速度依旧太慢,于是就有了寄存器。
但是,由于高速缓存与寄存器的造价高昂,所以大小有限。而这个关键字可以在创建变量时通过以下形式建议系统将其创建于寄存器上以提高效率。
register int a = 10;
typedef
类型重定义(重命名)
这个关键字是很好理解的,可以将类型重命名。格式如下:
typedef unsigned int uint;
将 unsigned int 这个类型重命名为 uint ,在后面的代码中就可以用 uint 作为无符号整形的类型
uint a = 5;
unsigned int b = 5;
比如定义的 a、b 两个变量的类型是一样的,都是无符号整形。
在给结构体类型重命名时还需要注意一些点,会在后面的结构体部分提到。
static
1、修饰局部变量称为静态局部变量
2、修饰全局变量称为静态全局变量
3、修饰函数称为静态函数
static 修饰局部变量
前面提到过,局部变量是存放在栈区中的,在出作用域时销毁。这会使局部变量在每次进入作用域时被重新定义。比如这段代码:
#include<stdio.h>
void test()
{
int i = 0;
i++;
printf("%d ", i);
}
int main()
{
int n = 0;
for (n = 0; n < 10; n++)
{
test();
}
return 0;
}
在这段代码中,变量 n 用于控制循环次数,每次进入 test 函数时都会定义一个局部变量 i ,初始化为0,然后自加一并打印。函数结束时这块空间被释放,也就是变量 i 被销毁,再次进入 test 函数时重新创建。所以这段代码运行的结果是打印十个 1 。但是,当变量 i 被 static 修饰时,情况如下:
#include<stdio.h>
void test()
{
static int i = 0;
i++;
printf("%d ", i);
}
int main()
{
int n = 0;
for (n = 0; n < 10; n++)
{
test();
}
return 0;
}
在这段代码中,与上面不同的是,局部变量被 static 修饰,这使得局部变量 i 被创建在了静态区上,出作用域时不会被销毁,到程序结束时生命周期才结束(改变了局部变量的生命周期)。当函数结束时 i 不会被销毁,for 循环每次进入函数时 i 也不会重新创建,而是保留上次的值自加一并打印,所以这段代码的结果是打印 1 到 10。
static 修饰全局变量
前面提到,全局变量具有外部连接性,可以经 extern 声明后跨文件使用:
但是当全局变量 i 被 static 修饰后,就会丧失其外部连接性而不能跨文件使用(改变了全局变量的作用域)。
static 修饰函数
static 修饰函数的效果和其修饰全局变量类似,都是会使其丧失外部连接性而不能跨文件使用。
通过对比可以看出:static 修饰函数会使其不能被跨文件调用。
八、数组
这里先举一个例子:如果我们要把一些学生的数学成绩存储起来,可以将它们放在一个集合中,这个集合中放的都是整型(int)的数据。在C语言中就把这样的集合叫数组。
数组是一组相同类型元素的集合。
char arr[10]; //字符数组
short arr[10]; //短整型数组
int arr[10]; //整型数组
long arr[10]; //长整型数组
long long arr[10]; //更长的整形数组
float arr[10]; //单精度浮点型数组
double arr[10]; //双精度浮点型数组
1、一维数组的创建和使用
一维数组的创建和初始化
创建
要创建一个数组自然需要明确一些条件:数组名、数组中元素个数、数组中元素类型。创建时遵循这样的格式:
type_t arr_name [const_n];
//type_t 数组中元素类型
//arr_name 数组名
//const_n 元素个数
结合上述的例子,假设需要把10个学生的成绩存储在 stu 数组中,创建如下:
int stu[20] = { 10, 11, 11, 11, 11, 11, 11, 11, 11, 11 };
在数组中的元素是在 { } 中的,每个元素之间需要用 , 隔开。
初始化
当然,在创建数组时可以初始化,也可以不初始化。当数组被初始化时可以不写出元素个数,编译器会以初始化时元素的个数规定数组的长度;当数组没有初始化时必须规定数组的长度,这时,如果数组在局部创建则数组中的元素被初始化为任意值,如果在全局创建则全部被初始化为0;当数组初始化的数据小于数组大小,未初始化的部分会被初始化为0。比如以下的各种创建方式均是正确的:
int arr1[] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
int arr2[10];
int arr3[10] = { 1, 2, 3 };
在 arr1 中数组长度就是 10;在 arr2 中10个元素均被初始化为0;在 arr3 中前三个元素为1、2、3,剩余的7个元素都被初始化为0。
另提一点:在 C99 标准之前,是不允许创建变长数组的,也就是数组长度必须为常量表达式;但在 C99 标准之后数组的长度可以是变量,这样的数组称为变长数组。需要注意的是:创建变长数组时不能初始化。
一维数组的使用
数组的下标
元素在存入数组后会被编号,我们可通过这些编号访问元素。这些编号就被称为下标。下标从0开始,之后递增1。比如一个长度为10的数组,第一个元素的下标为0,最后一个元素的下标为9。
通过下标访问数组
在前面我们介绍过下标引用操作符 [ ] 。可以用 数组名 [下标] 的形式访问数组元素。
数组 arr 中下标为4的元素是5,arr[4] 就可以找到下标为4的元素也就是5。
2、一维数组在内存中的存储
在了解了数组的概念之后,我们会自然地想问:数组在内存中是怎么存储的?
为了研究这个问题,我们可以直接把数组中每一个元素的地址打印出来来观察一下数组在内存中的存储:
#include<stdio.h>
int main()
{
int i = 0;
int arr[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
for (i = 0; i < 10; i++)
{
printf("%p\n", &arr[i]);
}
return 0;
}
%p用来打印地址,这些编号都是十六进制的数字。不难看出从第一个元素的地址开始,后面每个元素的地址都递增4。这里的数组中存储的元素类型是 int型的,所以每个元素占四个字节。也就是说, 数组中的元素在内存中是连续存放的。
3、通过指针访问数组
既然知道了数组在内存中是怎样存放的,那我们当然可以通过指针去访问数组中的元素,当然我们也可以通过指针访问数组。
数组名是首元素地址
先来看一段代码:
#include<stdio.h>
int main()
{
int arr[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
printf("%p\n", arr);
printf("%p\n", &arr[0]);
printf("%d\n", *arr);
printf("%d\n", arr[0]);
return 0;
}
不难看出,取地址arr[0] 与 arr 打印出的是同一个地址,而解引用arr 与 arr[0] 都打印的是数组的首元素。所以,数组名就是首元素的地址。 而因为这里的 arr 数组中存储的元素是 int型的,所以这里的 arr 与&arr[0] 一样,都是 int* 型的指针。
此时,我们就可以通过 解引用(指针+整数) 的形式去访问数组中的任何一个元素(前面提过:指针+/-整数 时移动的长度由指针类型决定,如果将 arr 强转为 char* 型时,就不能正常使用了):
#include<stdio.h>
int main()
{
int i = 0;
int arr[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
for (i = 0; i < 10; i++)
{
printf("%d ", *(arr+i));
}
printf("\n");
for (i = 0; i < 10; i++)
{
printf("%d ", arr[i]);
}
return 0;
}
数组名是首元素地址这一点在数组传参时会导致一些问题,这会在后面谈到。
例外:数组名是整个数组
当然,任何事情都有例外:有两种情况,数组名是代表整个数组的:
&arr :这种情况下,取地址去取出的是整个数组的地址。
#include<stdio.h>
int main()
{
int i = 0;
int arr[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
printf("%p\n", &arr);
printf("%p\n", arr);
printf("%p\n", (&arr)+1);
printf("%p\n", arr+1);
return 0;
}
整个数组的地址与数组首元素的地址在数值上是相同的,但是它们的类型不同会导致加一后移动的位置不同。&arr 是一个数组指针(马上就会提到),指向的是整个数组,所以加一后移动了40个字节;arr 的类型是整形指针,指向的是数组的一个元素,所以加一后移动了4个字节。
sizeof(arr):对于这一点,会在后面的部分详细讨论。
数组指针
前面提到过:指针类型去掉 * 就是这个指针类型解引用时可以访问的空间大小的类型。比如:int* 型的指针变量解引用后可以访问的空间大小就是一个整形变量的大小,也就是四个字节。
说得草率一点:指针类型去掉 * 就是这个指针类型解引用时指向的数据的类型。就不难联想到,数组指针的指针类型就是数组的类型加上 * 。
那么,数组的类型是什么呢?(需要注意的是:这里说的是数组的类型,不是数组中元素的类型)这里直接告诉大家:数组的类型就是数组的定义除去数组名以外的部分。上述数组 arr 的类型就是int [10] 。那么指向这个数组的指针,也就是 &arr 的指针类型就是 int(* )[10] 。
(注:因为 [ ] 的优先级大于 * 所以这里的 * 与数组指针的变量名要括起来,表示这是一个指针)
当然,我们可以再定义一个数组指针变量去存放这个数组指针:
#include<stdio.h>
int main()
{
int arr[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
int(*parr)[10] = &arr;
printf("%p\n", &arr);
printf("%p\n", parr);
return 0;
}
这个 parr 指针所指向的空间就是存有10个 int 型的数据的数组,也就是40个字节的空间。
4、二维数数组的创建和使用
我们已经能运用一维数组存储几个学生的高数成绩。但是当我们需要存储一个矩阵时,一维数组很明显不能满足我们的需求。这时,我们就可以通过创建二维数组来存储矩阵。、
二维数组的创建与初始化
创建
二维数组的创建与一维数组相似,都需要数组名、数组中存储的元素类型。但是,二维数组创建时还需要明确二维数组有几行几列。并遵循下面的格式:
type_t arr_name [row][col];
//type_t 数组中元素类型
//arr_name 数组名
//row 行
//col 列
初始化
在创建二维数组时,同样的,可以初始化,也可以不初始化。不论是初始化还是不初始化,必须写出二维数组的列数。先看代码:
#include<stdio.h>
int main()
{
int sarr1[][4] = { 1, 2, 3, 4, 5, 6, 7, 8 };
int sarr2[][4] = { { 1, 2 }, { 3, 4 }, { 5, 6 }, { 7, 8 } };
int sarr3[][4] = { { 1, 2 }, { 3 }, 4, 5, 6, 7, 8 };
int sarr4[][4] = { 0 };
int sarr5[3][4] = { 1, 2, 3, 4, 5, 6, 7, 8 };
int sarr6[3][4] = { { 1, 2 }, 3, 4, 5, 6, 7, 8 };
int sarr7[3][4] = { 0 };
int sarr8[3][4];
return 0;
}
有初始化:当不写出行数时,可以用 {} 分隔每一行的内容(每一个 {} 之间要用 , 隔开),如果分隔的元素小于行数,会用0补齐(sarr2、sarr3)。没有分隔时,编译器会根据列数与初始化的值来自动确定行数,不够一行时,会用 0 补齐一行(sarr1、sarr3、sarr4);当写出行数时,同理,还是先看有没有分隔,如果分隔的数量大于行数就会报错,总的初始化个数大于行数乘列数也会报错。剩余的未初始化的元素均会被初始化为0(sarr5、sarr6、sarr7)。
没有初始化:必须写出行数,在全局创建则全部初始化为0;在局部创建则全部初始化为任意值。
二维数组的使用
在使用二维数组时,依旧用数组的下标来访问二维数组中的元素,每一行的下标从0开始递增一,每一列的下标也是从0开始递增一 。下面代码使用了下标遍历二维数组:
#include<stdio.h>
int main()
{
int i = 0;
int j = 0;
int sarr[3][4] = { 1, 2, 3, 4, 5, 6, 7, 8 };
for (i = 0; i < 3; i++)
{
for (j = 0; j < 4; j++)
{
printf("%d ", sarr[i][j]);
}
printf("\n");
}
return 0;
}
5、二维数组在内存中的存储
虽然为了容易理解,我们把二维数组想象为具有行和列的矩阵。但其实,二位数组在内存中也是连续存放的。
#include<stdio.h>
int main()
{
int i = 0;
int j = 0;
int sarr[3][4] = { 1, 2, 3, 4, 5, 6, 7, 8 };
for (i = 0; i < 3; i++)
{
for (j = 0; j < 4; j++)
{
printf("%p\n", &sarr[i][j]);
}
}
return 0;
}
不难看出,每一个地址差了4个字节,因为这个二维数组中存放的是 int 型的,所以,二位数组在内存中也是连续排列的。
6、指针访问二维数组
要想通过指针访问二维数组中的元素,首先就要搞清楚一些指针的类型。
前面提过,数组名是首元素地址。对于二维数组而言,首元素就是一个数组。所以,二维数组的数组名代表的是一个数组指针,指向的是二维数组中的第一行元素。对这个二维数组名解引用,得到的就是这个二维数组的“首元素”,也就是一个一维数组名(这个一位数组的数组名叫 *sarr ),而一维数组的数组名是首元素地址,指向的是一个整形元素。对这个指针再解引用,得到的就是访问到的数组的元素。
#include<stdio.h>
int main()
{
int sarr[2][4] = { 1, 2, 3, 4, 5, 6, 7, 8 };
printf("sarr[0][0] = %d\n", **sarr);
printf("sarr[1][0] = %d\n", **(sarr+1));
printf("sarr[0][1] = %d\n", *((*sarr)+1));
printf("sarr[1][1] = %d\n", *((*(sarr+1))+1));
return 0;
}
第一行打印的是 sarr[0][0]: sarr 是第一个一维数组的数组指针,*sarr 是这个一维数组的数组名,也是 sarr[0][0] 的地址,再解引用就得到 sarr[0][0]。
第二行打印的是 sarr[1][0]: sarr+1 是第二个一维数组的数组指针,*(sarr+1) 是这个一维数组的数组名,也是 sarr[1][0] 的地址,再解引用就得到 sarr[1][0]。
第三行打印的是 sarr[0][1]: sarr 是第一个一维数组的数组指针,*sarr 是这个一维数组的数组名,也是 sarr[0][0] 的地址,给这个地址加一就是 sarr[0][1] 的地址,也就是 (*sarr)+1,再解引用就得到 sarr[0][1]。
第四行打印的是 sarr[1][1]: sarr+1 是第一个一维数组的数组指针,*(sarr+1) 是这个一维数组的数组名,也是 sarr[1][0] 的地址,给这个地址加一就是 sarr[1][1] 的地址,也就是 ((*sarr)+1)+1,再解引用就得到 sarr[1][1]。
由此我们就可以写出用一般式表示的通过指针访问二维数组的元素。下面代码用指针遍历了二维数组。
#include<stdio.h>
int main()
{
int i = 0;
int j = 0;
int sarr[3][4] = { 1, 2, 3, 4, 5, 6, 7, 8 };
for (i = 0; i < 3; i++)
{
for (j = 0; j < 4; j++)
{
printf("%d ", *((*(sarr + i)) + j));
}
printf("\n");
}
return 0;
}
7、指针数组与数组指针数组
指针数组
让我们再进一步思考:二维数组的首元素是一个一维数组。那么,我们能不能将一个一维数组的首元素地址作为元素存在一个数组中,然后通过这个指针去访问我们创造出来的这个“二维数组”的元素呢?当然是可以的。
要实现这个目的,需要解决的就是如何将一个指针变量存储在数组中。
前面提过数组是一组相同类型元素的集合。前面说的比较模糊,这里的相同类型元素在哪里体现?我们从数组的定义中找一找规律:
char arr[10]; //字符数组
short arr[10]; //短整型数组
int arr[10]; //整型数组
long arr[10]; //长整型数组
long long arr[10]; //更长的整形数组
float arr[10]; //单精度浮点型数组
double arr[10]; //双精度浮点型数组
不难发现,一个数组的定义去掉数组名与数组中存储的元素个数后, 剩下的就是这个数组中存储的数据的类型。
如果一个数组中存的是指针类型那么自然就可以写出这样的数组定义:int* arr[10]。这就代表着 arr 是一个数组,数组中有十个元素,每个元素都是 int* 型的。(注意:数组指针 int (*sarr)[10] 与指针数组 int *sarr[10] 的区别。含义不同的原因在于变量名先于谁结合:先与 * 结合表明这是个指针;先与 [10] 结合表明这是个数组)
在了解了指针数组之后,我们就可以伪造一个二维数组出来:
#include<stdio.h>
int main()
{
int i = 0;
int j = 0;
int arr1[4] = { 1, 2, 3, 4 };
int arr2[4] = { 5, 6, 7, 8 };
int arr3[4] = { 0 };
int*sarr[3] = { arr1, arr2, arr3 };
for (i = 0; i < 3; i++)
{
for (j = 0; j < 4; j++)
{
printf("%d ", *((*(sarr + i)) + j));
}
printf("\n");
}
return 0;
}
这样造假出来的二维数组,用指针遍历时会更好理解:二维数组名是一维数组首元素地址的地址,解引用两次自然可以访问为二维数组中的元素。效果与刚才的用指针遍历二维数组是一样的。
数组指针数组
再进一步思考,如果一个数组里存储的元素类型是数组指针。那么我们应该如何定义这个数组呢?
上面我们得出的结论是,数组的定义去掉数组名与数组中存储的元素个数后, 剩下的就是这个数组中存储的数据的类型。那么当这个数组中存储的元素是数组指针时:首先这是一个数组,所以数组名应该先与 [ ] 结合,其次这个数组中的元素类型是数组指针类型,(先假设是整形数组指针)也就是 int (*)[] ,把它们结合起来就是:int (* [ ])[ ] 。
int main()
{
int arr1[4] = { 1, 2, 3, 4 };
int arr2[4] = { 5, 6, 7, 8 };
int arr3[4] = { 0 };
int(*parr[3])[4] = { &arr1, &arr2, &arr3 };
return 0;
}
这里的数组 parr 就是一个整形数组指针数组,他表示一个数组,数组有3个元素,数组中存储的元素类型是整形数组指针,这个整型数组指针指向的数组有4个元素,其中存储的元素类型是整形。
在了解了这部分内容之后,大家可以尝试写一下 “指向指向指向数组指针数组的指针数组的指针数组的指针数组” ,多有意思。
8、数组作为函数参数
我们经常需要对数组其进行一些操作,我们会选择将这个操作分装一个函数,这就需要我们将数组作为参数进行传参(关于函数在后面会详细的讲,这里只需要知道我们需要将数组传递给函数即可)。这里将从函数定义的数组、函数调用时的数组、函数中访问的数组这三个方面来说明。
一维数组传参
一维数组传参就是将一维数组作为函数参数传过去。先来看一段代码:
#include<stdio.h>
void testFunction1(int* arr)
{
printf("%d\n", *(arr+1));
printf("%d\n", arr[1]);
}
void testFunction2(int arr[])
{
printf("%d\n", arr[1]);
printf("%d\n", *(arr + 1));
}
int main()
{
int arr[4] = { 1, 2, 3, 4 };
testFunction1(arr);
testFunction2(arr);
return 0;
}
在这段代码中,我们将数组 arr 传给了函数 testFunction1 与 testFunction2 。
在主函数中调用的部分,直接以数组名作为实参传给函数即可。数组名是首元素地址,所以我们实际上传了一个指针给函数。
所以在函数的定义部分用 int* 型的指针变量来接收是合适的(testFunction1)。当然,传过去一个数组,用一个数组(前面说过,数组的类型是数组的定义除去数组名的部分,这里参数的类型就是 int [ ] )来接收也是没有问题的(testFunction2)。因为传参的时候传的是数组名,也就是首元素地址,所以无论用哪种方式来接收,这个参数本质上就是个指针。
在访问这个数组时,按照正常的访问一维数组的方式即可。
二维数组传参
二维数组传参有三种情况:数组名传参、一级指针接收、当作一维数组访问;数组名传参、数组指接收、正常二维数组访问;二级指针传参、二级指针接受、强转访问。先看代码:
#include<stdio.h>
void testFunction1(int* sarr)
{
printf("%d\n", sarr[4 * 1 + 1]);
printf("%d\n", *(sarr + (4 * 1 + 1)));
}
void testFunction2(int (*sarr)[4])
{
printf("%d\n", sarr[1][1]);
printf("%d\n", *((*(sarr + 1)) + 1));
}
void testFunction3(int** sarr)
{
//强转为一级指针
printf("%d\n", (int*)sarr[4 * 1 + 1]);
printf("%d\n", *((int*)sarr + (4 * 1 + 1)));
//强转为数组指针
printf("%d\n", ((int(*)[4])sarr)[1][1]);
printf("%d\n", *((*(((int(*)[4])sarr) + 1)) + 1));
}
int main()
{
int sarr[3][4] = { 1, 2, 3, 4, 5, 6, 7, 8 };
testFunction1(sarr);
testFunction2(sarr);
testFunction3((int**)sarr);
return 0;
}
数组名传参、一级指针接收、当作一维数组访问
数组名是首元素地址,用一个一级指针接收这个地址是可以的。但是,在访问这个二维数组时就会比较别扭,因为,用一级指针接收就相当于这是个一维数组,是按照一维数组的形式去访问的(指针或下标访问)。为了更加直观,我们当然可以把数组的下标部分写成(列数*i + j)的形式。
数组名传参、数组指针接收、正常二维数组访问
数组名是首元素地址,二维数组的首元素是一个一维数组,所以二维数组的数组名代表一个数组指针,用一个数组指针去接收当然也是合适的。这种情况就可以按照正常的访问二维数组的方法去访问这个数组(指针或下标访问)。
二级指针传参、二级指针接收、强转访问
这种情况是比较麻烦的,数组名是不能被当作一个二级指针的,所以在函数调用时数组名需要被强数值类型转换为 int** 型,接收时自然就可以使用二级指针接收。但是在访问时二级指针是不能直接用来访问数组元素的,所以需要再将其强转为数组指针或一级指针的形式访问。
9、数组与sizeof
前面提到过,sizeof 关键字可以计算数据所占内存的大小。
#include<stdio.h>
int main()
{
int i = 0;
int arr1[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
int arr2[10] = { 1, 2, 3, 4, 5, 6, 7, 8 };
printf("%d\n", sizeof(i));
printf("%d\n", sizeof(arr1));
printf("%d\n", sizeof(arr2));
return 0;
}
在这段代码中,整型变量 i 所占内存大小是4个字节, 所以第一行打印的结果是4;但是第二、三行的打印出来的结果却都是40。前面提到数组名是首元素的地址,但别忘了有两个特例:&+数组名 、sizeof(数组名) 这两种情况下数组名代表的是整个数组。所以打印出的就是 arr1 与 arr2 整个数组所占内存的大小(在 arr2 中虽然只初始化了八个元素,但是在创建数组时已经定义数组的元素有十个,其他没有初始化的部分被初始化为 0 .所以 arr2 所占内存的大小也是 40 字节)。
但是当我们在函数中这样使用的时候又会出现不一样的情况:
#include<stdio.h>
void testFunction1(int* arr)
{
printf("%d\n", sizeof(arr));
}
void testFunction2(int arr[])
{
printf("%d\n", sizeof(arr));
}
int main()
{
int arr[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
testFunction1(arr);
testFunction2(arr);
return 0;
}
这里的结果都变成了4。应为这里的 arr 就不是一个数组名,而单纯的是一个指针。所以我们在用 sizeof 计算一个指针所占内存大小的时候,结果当然是4(前面说过传参的时候传的就是地址,所以无论用哪种方式来接收,这个参数本质上都是指针)。
这也决定了,当函数需要用到数组元素的个数,不能在函数内部计算,而应该在主函数中计算好之后,将元素个数作为参数传给函数:
#include<stdio.h>
void testFunction1(int* arr)
{
int n = sizeof(arr) / sizeof(arr[0]);
int i = 0;
for (i = 0; i < n; i++)
{
printf("%d ", arr[i]);
}
}
void testFunction2(int* arr,int n)
{
int i = 0;
for (i = 0; i < n; i++)
{
printf("%d ", arr[i]);
}
}
int main()
{
int arr[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
int n = sizeof(arr) / sizeof(arr[0]);
testFunction1(arr);
printf("\n");
testFunction2(arr, n);
return 0;
}
这段代码的目的是遍历并打印这个 arr 数组,显然在 testFunction1 中出现了问题。因为它在函数内部计算了数组元素的数量 4/4=1 ,所以只打印了第一个元素就停止了;而 testFunction2 的数组元素个数是在主函数内计算再传给函数的,所以打印了整个数组。
这里的这种情况,数组的创建和计算之间有传参的过程,所以不能再函数中计算。如果在函数中创建数组,当然是可以在函数中计算长度的。
九、函数(子程序)
“C程序由一个或多个函数组成”,可见函数的重要。这一部分先简单的了解函数。
函数,也就是子程序(除 main() 也就是主程序之外所有的程序都称为子程序)。维基百科中给出了对函数的定义:在计算机科学中中,子程序(英语:Subroutine, procedure, function, routine, method, subprogram, callable unit),是一个大型程序中的某部份代码,由一个或多个语句块组成。它负责完成某项特定任务,而且相较于其他代码,具备相对的独立性。
其实用数学对函数的定义可以帮助我们理解:对每个 x 按对应法则 f ,总有唯一确定的值 y 与之对应,这个值称为函数值。就是输入一个值有唯一的输出值。而 C 语言中的函数在调用时需要给函数传递一个参数,函数在执行完函数体的内容后会返回一个值也可以不返回值(空返回值)。
函数可以让我们的代码段重复使用(需要用到某操作的时候调用某个函数即可),从而实现代码的简化。
C语言函数分为库函数与自定义函数:
1、库函数
定义
库函数就是C语言自带的函数,例如我们前面经常使用的 printf(标准输出函数)、scanf(标准输入函数)都属于库函数。再例如求字符串长度(strlen)、字符串拷贝(strcpy)、字符串比较(strcmp)、求阶乘(pow)像这些我们经常使用的基础功能,都有其库函数。在开发的过程中每个程序员都可能用的到, 为了支持可移植性和提高程序的效率,所以C语言的基础库中提供了一系列类似的库函数方便程序员进行软件开发。
使用
库函数使用的时候必须要包含 #include<> 对应的头文件,这一点前面提到过。是为了声明这个函数。
库函数使用时需要输入参数,比如 printf 函数需要输出形式与输出的变量名两个参数,也可以只有输出内容一个参数;strlen 函数需要字符数组名一个参数。
使用资料库
学习库函数,不需要背下所有的库函数,但是我们需要掌握库函数的使用方法并且熟练使用常用的几种库函数。其他的库函数我们可以在资料库中查询:https://cplusplus.com
用 strlen(计算字符串长度)举一个例子:
funtion 表示这是一个函数,strlen 是函数名。接下来是函数的定义,表示参数是一个 C字符串,返回值是无符号整形。接下来的粗体字表示函数作用的概括:获得字符串的长度。接下来是描述返回值:返回 C字符串 的长度。接下来是对函数的详细描述以及示例。
当然,C 库函数的使用还有许多需要注意的地方,在这里就不赘述,后续会有一些博客去专门说明这些库函数的用法。
2、自定义函数
当然,只有库函数是不能满足实际的生产需求的。这就需要我们自己把一些操作封装成函数去使用。也就是自定义函数。自定义函数与库函数一样,都有参数、返回值、函数体这样的内容。
先来举一个例子:假如需要写一个自定义函数,以实现两个数的和
#include<stdio.h>
int add(int m, int n)
{
int c = m + n;
return c;
}
int main()
{
int a = 10;
int b = 20;
int ret = add(a, b);
printf("%d\n", ret);
return 0;
}
首先,我们要给这个函数起一个名字,一般与函数的作用联系。在这个函数中要实现的是两数求和所以我们叫它 add。
接下来会详细介绍这段代码的含义。
3、函数的参数与返回值
参数分为两种:实际参数与形式参数
实际参数
实际参数就是真实传给函数的参数。
实际参数可以是常量、变量、表达式、函数等。但是无论实参是哪种类型的,在进行函数的调用时,它们都必须有确定的值,以便把这些值传给形参。、
比如上面这个这段代码中定义的变量 a、b 就是函数 add 的两个实参。调用 add 函数时,就是把 a、b 这两个变量的值传给了函数。
形式参数
形式参数就是函数定义时写在函数名后面的括号中的参数。
因为形式参数只有在函数被调用的时候才会被创建,函数调用结束时被销毁,之在函数中有效,所以被称为形式参数。形参的类型应该与它对应的实参保持一致。
比如上面这段代码中的变量 m、n 都是形式参数,且与它们所对应的实参都是 int型的。
另外,实参与形参的变量名可以相同。因为形参使用时是开辟新的内存空间的,所以变量名相同是没有影响的。
当然,函数也可以没有参数,也就是空参数。调用函数与定义函数时括号内可以空着。
返回值
我们在实现完两个数的相加后,需要把这个结果返回给主函数,这个值就是函数的返回值。
返回值也可以是常量、变量、表达式、函数等。这个类型需要写到函数名的前面。函数的返回值需要在 return 之后返回给调用这个函数的函数,对于上面的 add 函数的返回值就是返回给主函数。在执行 return 后整个函数调用结束。
返回值之后,原函数中需要有一个变量来接受收这个返回值。当然这个变量的类型要与函数的返回值相同。
#include<stdio.h>
int add(int a, int b)
{
int c = a + b;
return c;
c = b - a;
}
int main()
{
int a = 10;
int b = 20;
int ret = add(a, b);
printf("%d\n", ret);
return 0;
}
在上面一段代码中,返回了 c 的值是 a b之和,函数调用结束,return 之后的语句不再执行 。
当然,函数也可以没有返回值,也就是空返回值。但是这种情况下,函数名的前面需要写上 void,表示这个函数是空返回值。这时,也不需要 return 来返回一个值回去。
void funtion()
在了解了这些内容后,就能够解释主函数书写的含义了:有一个函数调用了主函数,这个函数的函数名是 main,返回值是 int ,没有参数,返回值是 0 。
4、函数的调用
add(a, b);
这样的语句就是函数调用。
但是我们发现,这样的调用是不能在函数中改变参数的内容的。我们用一个交换两数的值的函数来观察一下:
#include<stdio.h>
void exchange(int a, int b)
{
int temp = a;
a = b;
b = temp;
}
int main()
{
int a = 10;
int b = 20;
exchange(a, b);
printf("a = %d\nb = %d", a, b);
return 0;
}
很明显,这样的函数并不能实现在函数中改变参数的值。
传值调用
像上面这样,只是把变量的值传给函数,并不能改变参数的值的传参方式叫做传值调用。
我们来分析一下不能改变值的原因:传值调用时,传过去的是实参的值。而当这个函数被调用时,会开辟新的内存空间去存放传过来的值。当我们在函数中改变形参的值时,相当于在新的内存空间上改变了这个变量的值而实参对应的内存空间中的值并没有被改变。函数调用结束后,为形参开辟的空间被释放,在原函数中打印时依旧打印的是实参的值。所以说:传值调用时形参是实参的一份临时拷贝。我们可以通过调试的方式来验证一下:
不难看出,形参 m、n 的内容实现了交换,但实参 a、b 的内容没有发生改变。
传址调用
想到由于调用函数时会给形参开辟新的内存空间,只要我们把变量的地址作为实参传给函数,需要在函数内部改变变量内容时直接由形参访问实参所指的元素即可改变变量。
这样把函数外部创建变量的内存地址传递给函数的一种调用函数的方式方式,称为传址调用。
#include<stdio.h>
void exchange(int* pa, int* pb)
{
int temp = *pa;
*pa = *pb;
*pb = temp;
}
int main()
{
int a = 10;
int b = 20;
exchange(&a, &b);
printf("a = %d\nb = %d", a, b);
return 0;
}
这样的调用方式,就可以实现在函数内部改变函数外部定义的变量的值。
5、函数的声明与定义
函数的声明
前面多次提到了函数的声明,和变量的声明类似:
函数的声明是为了告诉编译器有一个函数的函数名是什么、参数是什么类型、返回值是什么类型。但是,声明的函数是否存在是未知的。拿上面的交换函数举例,我们可以这样声明这个函数:
void exchange(int*, int*);
函数的声明必须在函数的调用之前。必须要满足先声明后使用。这就是为什么在使用库函数之前必须包含相应的头文件。
函数的声明一般是写在头文件中的,在调用这个函数之前调用头文件即可。但是,包含我们自己创建的头文件时要用 #include" " 的形式。
函数的定义
函数的定义即函数的实现。
当函数的定义在函数的调用之前时,我们可以不进行函数的声明。
6、函数的嵌套调用与链式访问
嵌套调用
函数的嵌套调用就是在函数的定义中调用另一个函数。
比如我们需要打印一个字符串的所有内容:
#include<stdio.h>
void printStr(char* s)
{
int i = 0;
int len = strlen(s);
for (i = 0; i < len; i++)
{
printf("%c ", s[i]);
}
}
int main()
{
char s[] = "hellokitty";
printStr(s);
return 0;
}
在这个 printStr 函数中就嵌套调用了库函数 strlen(求字符串长度) 。当然,这段代码只是为了展示函数的嵌套调用,要打印字符串的话,用 %s 打印即可。
需要注意的是:函数可以嵌套调用,但是不能嵌套定义(在一个函数的定义中去定义另一个函数)。
链式访问
函数的链式访问就是把一个函数的返回值作为另一个函数的参数。
比如这段代码:
#include<stdio.h>
int main()
{
printf("%d ", printf("%d ", printf("%d ", 1)));
return 0;
}
这段代码是打印一个数,这个数是 printf("%d ", printf("%d ", 1)) 这个函数的返回值,而这个函数也是打印一个数,这个数是 printf("%d ", 1) 这个函数的返回值,而这个函数还是打印一个数,这个数是1。
要读懂这段代码,首先要知道:printf 函数的返回值是在屏幕上成功打印数的位数。
再倒过来分析:先打印 1 与一个空格,所以这个函数的返回值是 2;在打印 2 与空格,所以返回值依旧是 2;在打印这个 2 与一个空格。就是汝上图的结果。
7、函数递归
递归的定义
程序调用自身的编程技巧称为递归( recursion)。
递归做为一种算法在程序设计语言中广泛应用。 一个过程或函数在其定义或说明中有直接或间接
调用自身的一种方法,它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解,只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。
用递归解决问题的本质就是:大事化小。
函数的递归就是自己调用自己。
递归的必要条件
正确的使用递归 ,必须要满足两点条件:存在限制条件;每次递归后越来越接近这个限制条件。
要满足这样条件的原因就是防止死递归,不满足任何一种条件都会导致死递归。
递归实现斐波那契数列
斐波那契数列想必大家都知道,是指:1 1 2 3 5 8 13 21 34 55...... 这样的第三个数是前两个数之和的数列。
我们要实现的是输入一个整数,输出这个整数对应位置上的斐波那契数。
#include<stdio.h>
int Fib(int n)
{
if ((n == 1) || (n == 2))
{
return 1;
}
else
{
return Fib(n - 1) + Fib(n - 2);
}
}
int main()
{
int n = 0;
scanf("%d",&n);
int ret = Fib(n);
printf("%d\n",ret);
return 0;
}
对于这段递归代码,我们一步一步的分析:
首先 scanf 输入想要求得的第 n 个斐波那契数;接下来以 n 为实参传给函数 Fib,这个函数的返回值是 int 型的。
在 Fib 函数中,首先判断这个 n 是否等于 1 或者 2 ,如果是,返回 1,如果不是,返回 Fib(n-1) 与 Fib(n-2) 的和。直到递归的参数等于 1 或者 2 时返回1。然后逐级加回来得到最初的 Fib 函数的返回值(在这一步中,满足了存在限制条件,即当 n 为 1 或 0 时结束递归;满足了每次递归靠近限制条件,即 n-1 与 n-2)。
之后用一个 int型的变量 ret 接受并打印即可。
其实,递归与循环的思想是相似的。循环时也需要限制条件与一步步靠近限制条件,否则就会导致死循环。能用递归解决的问题用循环一般也能解决。这样想可以帮助我们更加容易的理解递归i算法。
总结
在了解了以上的所有内容之后,我们就对C语言有了初步的了解。
在后面的数组部分与函数部分,有些内容讲的不是很有条理,后续我会将这些小的、有点难度的内容另行发布。包括一些没有足够详细说明的点都会有新的博客去讨论。
在这篇博客中一定会有讨论地不全面,描述错误的问题。希望大家多多指正。
最后,希望能与大家共同进步!!!