C语言函数全解

一.函数的概念

函数可以理解为一个子程序,在计算机科学中,子程序(英语:Subroutine, procedure, function, routine, method, subprogram, callable unit),是一个大型程序中的某部分代码, 由一个或多个语句块组成。它负责完成某项特定任务,而且相较于其他代 码,具备相对的独立性。
或者说函数就像是一个工具,比如修车时用到的扳手,扳手你只要买一次就够了,以后每次用的时候就拿这个扳手用。函数也是这样,你先把一个特定的功能写好并封装起来,以后想用这个功能就可以直接去用,而不是每次想用一种功能都要重新写一遍。

每个函数都由几个模块组成:

返回类型 函数名(函数参数)
{
	函数体
}

其中函数参数可有可无。返回值其实在一些情况下也可以不写。但是推荐不管有没有返回值都写上,有返回值的话就把返回的类型(int,float,char)这些写上,没有返回值就写一个void表示没有返回值。注意这个返回值只能有一种。
函数名就是名字和变量名那些差不多是你自己随便取得,函数体就是实现这个功能的逻辑。

二.C语言中函数的分类

2.1库函数

在平时写代码的时候,可能会用到一些基础的功能,像把键盘输入的信息放到电脑中,从电脑中读取信息,一些字符串的拷贝…这都是一些基础的功能,不是业务性的代码,可能所有程序员都或多或少会用到。
所以为了支持可移植性和提高程序的效率,所以C语言的基础库中提供了一系列类似的库函数,方便程序员进行软件开发。比如常用的scanf,printf,strcmp…这些都是别人为我们写好并封装起来的函数,包含好头文件之后,直接拿来用就可以了。

那问题来了,什么是头文件呢?因为C语言软件的这些开发商为了满足我们,为我们这些程序员写了非常多的库函数,并且为了方便管理和查询,把不同的库函数放到不同的文件中,比如上面提到的scanf,printf这两个函数就放到stdio.h这个文件中,strcmp是放在string.h的文件中。

因为这些头文件不是C语言本身自带的,是别人为我们写好的,所以在你直接用的时候,你的编译器可能不认识这些东西,所以在用的时候提前声明一下这个函数,就是给编译器说一下你要用到这个函数,这样就不会出错了,而声明的方法,就是把这些函数所在的头文件添加进来:

#include <stdio.h>
#include <string.h>

那问题又来了,直接把所有函数都放在一起,这样用的时候只包含一个头文件,岂不是很简洁。确实,这样做你确实舒服了,但是编译器可能不会这么想。通过上面那些文件的名字可以看出来,它们都有一些含义的,stdio中std是标准的意思,而io是input/output的意思,所以说stdio里面的函数都和标准输入输出有关,string就是字符串的意思,说明这个文件里的函数都和字符串有关。而#include的意思就是说把文件在编译期间,整个都放到你的代码里来,而且每个头文件里面的内容是非常多的:
在这里插入图片描述

一个stdio.h的文件有两千多行。
假如说你现在写的代码大多数都是scanf,printf这些函数,而你包含了一个含有所有文件的大文件。这样的话有很多函数你没用你却把它加进来了,那本来一个很简单的程序却需要花费很多的时间。

C语言库函数的大致分类

  1. IO函数
  2. 字符串操作函数
  3. 字符操作函数
  4. 内存操作函数
  5. 时间/日期函数
  6. 数学函数
  7. 其他库函数

C语言学习/查找途径

  1. (推荐)一个网站cplusplus.好像新版的没有查找,切换到旧版就可以用了:
    在这里插入图片描述

在这里插入图片描述

像这样直接输入想查询函数的名称就OK了。

  1. C语言官网(英文)
  2. C语言官网中文

第2,3个网站可能进去的时间会很长,用起来不方便,所以我推荐用第一个cplusplus。

2.2自定义函数

上面这些库函数虽然很多,而且很好用,但是总有一些功能需要你自己去写,如果全由别人来写,那你还有什么用嘞?

自定义和库函数一样有函数名,函数返回类型,函数体,函数参数。

随便举一个例子,我要找输入的两个数的最大值是多少:

//get_max函数的设计
int get_max(int x, int y)
{
	return (x > y) ? (x) : (y);
}
int main()
{
	int num1 = 10;
	int num2 = 20;
	int max = get_max(num1, num2);
	printf("max = %d\n", max);
	return 0;
}

get_max就是我们自己造的一个函数,因为我们要求两个数的最大值是什么,所以要把这个最大值的值给返回出来,而这个值的类型是int类型,所以函数名前面的类型是int,这样返回回来的值就可以用max来接收了。

函数的实参

在上面写的这个例子:

//get_max函数的设计
int get_max(int x, int y)
{
	return (x > y) ? (x) : (y);
}
int main()
{
	int num1 = 10;
	int num2 = 20;
	int max = get_max(num1, num2);
	printf("max = %d\n", max);
	return 0;
}

num1,num2就是实参,实际参数,也就是真实传给函数的参数,这些参数可以是常量,变量,表达式,甚至是一个函数。只要你这个参数能求出来一个确定的值,你都可以当这个函数的参数。

函数的形参

刚才的这个例子中x,y就是形参。形参(形式参数)是指函数名后括号中的变量,因为形式参数只有在函数被调用的过程中才实例化分配内
存单元(只有函数调用的时候,内存才会为形参开辟一块空间),所以叫形式参数。形式参数当函数调用完成之后就自动销毁了。因此形式参数只在函数中有效

我们可以简单的认为:形参实例化之后其实相当于实参的一份临时拷贝

函数的传值调用

还是刚才那个例子:

//get_max函数的设计
int get_max(int x, int y)
{
	return (x > y) ? (x) : (y);
}
int main()
{
	int num1 = 10;
	int num2 = 20;
	int max = get_max(num1, num2);
	printf("max = %d\n", max);
	return 0;
}

在这里我们是把num1,num2这两个变量的值传到函数中的,所以x,y两个形参才能拿到这两个值并做相应的计算。因为传的是参数的值,所以这里调用的方法叫传值调用

函数的传址调用

现在举一个船新的例子:将输入的两个数交换:

//交换函数
void Swap1(int x, int y)
{
	int tmp = x;
	x = y;
	y = tmp;
}
int main()
{
	int num1 = 1;
	int num2 = 2;
	Swap1(num1, num2);
	printf("Swap1::num1 = %d num2 = %d\n", num1, num2);
	return 0;
}

这应该很简单:
在这里插入图片描述

很轻松,但是我们看代码结果:
在这里插入图片描述

发现结果根本没有交换,这是为什么呢?因为现在调用函数的方法仍然是传值调用,而且形参只是实参的一份临时拷贝:
在这里插入图片描述

函数能用吗?肯定能用,但是你叫唤的只是a,b这两个值,你的x,y压根就没有变化。所以你的结果肯定也不会有变化,那怎么办呢?这里就要用到传址调用,你只要把x,y的地址传过去就行,函数里面在解引用的时候直接找到的就是你这两个变量x,y:

//正确的版本
void Swap2(int* px, int* py)
{
	int tmp = 0;
	tmp = *px;
	*px = *py;
	*py = tmp;
}
int main()
{
	int num1 = 1;
	int num2 = 2;
	Swap2(&num1, &num2);
	printf("Swap2::num1 = %d num2 = %d\n", num1, num2);
	return 0;
}

你把这两个变量的地址传过去之后,函数内部的 * px, * py这两个值其实就是num1,num2这两个变量,所以交换 * px, * py就可以理解为交换num1,num2。

小补充

初学者可能不怎么理解地址,解引用这一块,在这里我先做个大致的说明,之后想学习可以看我关于指针的博客。
在内存中每个变量都有自己的地址,相当于你家的那个门牌号,上面的&num1,&num2就是把num1,num2这两个变量的地址传给函数,&就是取地址操作符。函数拿到的就不是1,2这个值而是两个十六进制表示的地址,拿到之后对其解引用(*是解引用操作符),解引用就相当于拿着门牌号去找你,解引用之后直接拿到的就是num1,num2这两个变量,对其修改就会影响num1,num2本身的值。

什么时候用传值调用,什么时候用传址调用呢?这要看实际情况,向第一次写的代码,只是判断两个变量那个大那个小,根本不需要改变这两个数,所以这时候用传值调用就行。
但是像刚才那个交换两个变量,就是在函数里面更改函数外面的变量值的时候就该用传址调用。

三.函数的返回值

函数的返回值用return来返回。

int Add(int x, int y)
{
	return x + y;
	printf("hello");
}

int main()
{
	Add(10, 20);
	return 0;
}

像这里只要遇到return,函数就会立马返回return后面的内容,然后回到主函数中继续向下运行,所以这里return后面的printf是用不到不会执行的。

return返回的内容只能是一个,你不能一次返回两种值,原因上面也说过程序遇到return就会直接返回,不会运行return后面的内容。

如果你的函数不需要任何返回:

void print()
{
	printf("hello\n");
}

int main()
{
	print();
	return 0;
}

此时函数的返回类型写void代表没有返回值。但此时你非要返回一个值:

void print()
{
	printf("hello\n");
	return 1;
}

int main()
{
	print();
	return 0;
}

这样程序就会报警告:
在这里插入图片描述

当然也有可能你返回值是确定的,但是你什么都没返回:

int print1()
{
	int a = 10;
	int b = 20;
	int c = 10 + 20;
}

如果什么都不写,会默认返回一个值,有些编译器返回的是最后一条指令产品的结果,比如上面那个函数可能返回的就是c的值。

因为是个随机值,我们再写主函数的时候,末尾最好带上一个return 0;

四.函数的链式访问

在最开始我就讲过,函数的参数可以是变量,常量,表达式,或者是其它函数。因为函数支持链式访问,所以你可以直接把一个函数的返回值当成另一个函数的参数:

int main()
{
	//这里把strlen的返回值当成printf的参数
	printf("%d\n", strlen("abcde"));
	return 0;
}

或者像这样:

int main()
{
	printf("%d", printf("%d", printf("%d", 43)));
}

注:printf的返回值是正常打印字符的个数。

上面这个代码也是函数的链式访问,最开始先打印第一个printf,需要打印的值是printf(“%d”, printf(“%d”, 43))的返回值。而printf(“%d”, printf(“%d”, 43))的返回值是printf(“%d”, 43)成功打印的字符的个数,而printf(“%d”, 43)就会先打印43然后返回2被第二个printf接收,因为只接收了一个2,只是一个字符,所以返回1,而最开始的printf就打印一个1.

在这里插入图片描述

五.函数的嵌套调用

每个函数在使用的时候还可以调用其它的函数:

void new_line()
{
	printf("hehe\n");
}
void three_line()
{
	int i = 0;
	for(i=0; i<3; i++)
{
	new_line();
}
}
int main()
{
	three_line();
	return 0;
}

这里three_line函数就调用了new_line函数

注意函数只能嵌套调用,但是不能嵌套定义,像这样:

//err
int Add(int a, int b)
{
	int printf()
	{
		...
	}
}

六.函数的定义和声明

有些时候可能会这样写代码:

int main()
{
	printf("%d", add(10, 20));
	return 0;
}

//函数的定义
int add(int x, int y)
{
	return x + y;
}

函数写在主函数后面,因为程序在运行时是从上向下运行的。如果你把add函数放在最后面,程序在执行主函数中的add函数时会出错,因为编译器它不知道add是什么:
在这里插入图片描述

所以函数在使用时要满足一个条件:先声明后使用。你得给编译器说这个函数我将要用,你要注意一点。
声明的方法:告诉编译器有一个函数叫什么,参数是什么,返回类型是什么。但是具体是不是存在,函数声明决定不了。

//函数的声明
int add(int,int);

当然也可以这样写:

//函数的声明
int add(int x,int, y);

如果你在使用之前直接定义了话,就不需要声明了。

七.函数的递归

函数调用自身的编程技巧称为递归( recursion)。递归可以分成两个部分来看,递是递推,归是回归

递归做为一种算法在程序设计语言中广泛应用。一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解

递归策略:
只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。
递归的主要思考方式在于:把大事化小

讲一个简单的例子来帮助理解:将1234这个数字输出成1 2 3 4.
大致思路是先打印1234的1然后2,3最后是4.

而1234要怎么做才能分别得到1,2,3,4四个数字呢?递归怎么把这个事情大事化小呢?试着这样理解:

  1. 打印1 2 3 4相当于
  2. 打印数字1,和234这个数的三个数字
    (打印234这三个数字和打印1234这四个数字都用一个函数来写,只不过参数一个是234一个是1234罢了。)
  3. 而打印234是不是可以理解为打印2和打印34的三个数字。
  4. 打印34可以看成打印3和打印4这个数字

在这里插入图片描述

而递归的另一种解释就是自己调用自己。那我们就可以这样写:

void print(int n)
{
	if (n > 10)
	{
		print(n / 10);
		printf("%d ", n % 10);
	}
	else
		printf("%d ", n % 10);
}

接下来,我在画一个流程图来带大家深入理解。
首先,先看函数递推的过程:
在这里插入图片描述

然后走到最后一层的时候发现n<10此时就走else里面的代码,把1 打印出来。

然后再看回归的流程:
在这里插入图片描述

函数总会执行完,下一层函数执行完之后就可以返回到上一层函数了。这样不断返回不断返回,最终完成递归的过程。

小总结

其实递归是不难的,就是把问题划分成一个个子问题就行,比如刚才这一题要把1234这个数字变成四个字符输出1,2,3,4,我们就可以把这题变成先打印1在打印234这个数字变成三个字符输出。像这样慢慢的往下分。但是如何根据这个写代码呢?首先要清楚的是每次%10拿到的都是最后一位,但是我们要求第一次打印的是第一位。既然这样我们就倒着打印不就好了吗?想象着函数一直向下递推,知道此时只剩一位数的时候开始第一次打印,然后每次回归的时候在打印第2个,第3个字符…既然这样,就是说只有函数在回归的时候才开始打印,那些代码的时候把printf这个函数写在print函数后面就行了。因为在执行print函数时会跑到一个新的函数里面执行,根本不会去执行printf函数,只有在回来时也就是上一层print函数执行结束才开始执行printf函数。
而且每个递归函数必须要有一个限定范围,否则函数就会一直递归下去,最终造成栈溢出。而这个限定范围也很容易想到,1234这个数字每次递归少去一位数,是不是只有当数字只有一位数的时候就可以不用递归了?
这些方面都想清楚了,那递归的代码自然而然就写出来了。

7.1例题.递归求字符串长度

对于一个字符串:
在这里插入图片描述

要计算其长度,找的就是\0之前字符出现的个数。现在可以把这个问题慢慢分割成一个小问题:
abcde这个字符串的长度= = 1+bcde这个字符串的长度 = = 1+1+cde这个字符串的长度…
在这里插入图片描述

也就是说每次往下递归的时候计算的步骤都是1+子字符串的长度,最后当字符串为\0直接返回0,因为\0是不算在字符个数中的,所以代码可以这样写:

int my_strlen(char* str)
{
	//每次进来之前判断是否为\0是的话就直接返回\0
	if (*str == '\0')
	{
		return 0;
	}
	else
	{
		//每次返回的都是1+子字符串的个数
		//子字符串的个数就可以用函数自身来实现
		return 1 + my_strlen(str + 1);
	}
}

下面我画一下递归流程图来方便理解:
在这里插入图片描述

上面这题可能会把str+1写成str++。这种写法会造成死递归,因为这是后置++,str会先进到新的my_strlen函数,在返回的时候在进行++。但是由于每次进入新的函数是str都是不变的,所以会造成死递归。当然这题用++str是可以成功的,但是这里不推荐,因为我们最后不要动str这个指针的值。

7.2例题.求n的阶乘

n的阶乘是1234…*n

同样可以把这个问题划分成子问题来看待:
在这里插入图片描述

也就是说n的阶乘可以看作(n-1)的阶乘 * n。而(n-1)的阶乘又可以看作(n-2)的阶乘 * n-1。但是阶乘有个规定:0,1的阶乘都是1.所以为了防止造成死递归,给我们的函数增加一个结束条件,当n为1,0时直接返回1:

int Fac(int n)
{
	//限制条件,当函数一直递归下去直到n为1的时候停止
	//如果一开始n是1或0也可以直接计算并返回
	if (n < 2)
		return 1;
	
	//当n大于2的时候
	return n * Fac(n - 1);
}

7.3例题.斐波那契数列

斐波那契数列是这样子的:
1,1,2,3,5,8,13,21,34,55…

这个规律应该很好推,当n为3时,也就是从第三个数字开始,每个数字都是前两个数字的和,既然知道规律,那代码也很轻松能写出来:

int Fib(int n)
{
	if (n < 3)
		return 1;

	return Fib(n - 1) + Fib(n - 2);
}

虽然用递归很方便,但是斐波那契数列用递归是一个非常不明智的选择。因为如果你要算第50个是什么数字,那首先要知道第48,49位的数字,而48位的数字要知道47,46位上的数字…:
在这里插入图片描述

通过上图可以发现,很多数字都会重复计算。而且总次数是一个等差数列。像这种算法,计算一个第50位上的数字都要花非常多的时间,那60,70这种数只会成倍的增加。

既然递归不行,那有什么方法呢?通过上面递归的分析,主要问题是一个数字会重复计算很多次,所以我们需要每次计算都能保留下来结果来供下一次计算使用,这里可以定义三个变量a,b,c,c是我们需要得到的结果,a,b两个数的和是c。
在这里插入图片描述

当计算第三个数的时候只用计算一次a+b就行,如果要计算第四个数的时候,先把b的值赋给a,然后把上次结果得到的c赋值给b,最后重新计算a+b的值并赋值给c:
在这里插入图片描述

这样循环两次后c的值就是我们想要的值,那计算第5个数就循环3次,第6个数循环4次…

int Fib(int n)
{
	int a = 1;
	int b = 1;
	int c = 1;
	while (n > 2)
	{
		c = a + b;
		b = a;
		a = c;
		n--;
	}
	return c;
}

因为不管n是什么值,最后返回的都是c的值。如果此时n<=2的话想要的结果是1,所以这里把c的值初始化为1,就跳过循环,然后直接返回。

7.4例题.汉诺塔问题

汉诺塔的规则是这样的:有三个柱子a,b,c,a上放了n个圆盘,从上到下依次增大,要求把这些圆盘移动到c上去,并且每次移动都要保证小的圆盘在上面,大的圆盘在下面:
在这里插入图片描述

虽然看上去很发杂,但是划分成子问题后依然不是事。最后是把所有圆盘移动到c上,那可以认为先把前n-1个圆盘移动到b上,然后把最后一个圆盘也就是第n的圆盘移动到c上(先别管怎么把n-1的圆盘从a移动到b):
在这里插入图片描述

最后再把b上的这些圆盘移动到c上:
在这里插入图片描述

这样大的框架就写好了,好接下来考虑的问题就是如何将前n-1个圆盘从a移动到b然后在由b移动到c呢?其实这和上面的步骤是一样的,先挪动前n-2个然后在挪动第n-1个就行了。然后挪动前n-2个可以写成先挪动前n-3个在挪动第n-2个…。

所以说将这一个复杂的问题慢慢的转化成一个个子问题就非常好解决了。

为了将移动的过程全部展示出来,要再写递归函数之前再写一个打印移动过程的函数:

//将第n个圆盘从A移动到B
void move(int n, char A, char B)
{
	printf("把%d从%c移动到%c\n", n, A, B);
}

接下来就是递归函数:

这里先定义三个变量a,b,c。表示三个柱子,注意啊,这里a,b,c对应的不是图中的a,b,c三个柱子,它们三个代表的含义是n个圆盘从a这个柱子依靠b这个柱子到达c这个柱子。

//将n个圆盘从a开始借助b移动到c
void hanoi(int n, char a, char b, char c)
{
	//一直递归下去直到最后一个圆盘的时候,就将它从a柱子移动到c柱子
	if (n == 1)
	{
		move(n, a, c);
	}
	else
	{
		//先将前n-1个柱子从a柱子开始借助c柱子移动到b柱子
		hanoi(n - 1, a, c, b);
		//然后将第n个柱子从a移动到c
		move(n, a, c);
		//最后将前n-1个柱子从b开始借助a移动到c
		hanoi(n - 1, b, a, c);
	}
}
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值