粗略说函数

函数

本章讲解的是:

  1. 函数是什么
  2. 库函数
  3. 自定义函数
  4. 函数参数
  5. 函数调用
  6. 函数的嵌套调用和链式访问
  7. 函数的声明和定义
  8. 函数递归

函数是什么

数学中我们就学过函数,数学中的函数是什么样的呢?f(x)=2*x+1 or f(x)=2*x^2+x+1。那C语言中的函数是怎么样的呢?官方是这样解释的:维基百科中对函数的定义:子程序 。它认为函数就是子程序,而子程序:

  • 在计算机科学中,子程序(英语:Subroutine, procedure, function, routine, method,subprogram, callable unit),是一个大型程序中的某部分代码, 由一个或多个语句块组成。它负责完成某项特定任务,而且相较于其他代 码,具备相对的独立性。
  • 一般会有输入参数并有返回值,提供对过程的封装和细节的隐藏。这些代码通常被集成为软件库 。

函数也就是子程序,它是一个或多个语句组成的完成某项特定功能并且会有输入参数和返回值。函数就用用来完成某种特定功能的。

C语言函数分类:

  1. 库函数
  2. 自定义函数

库函数

为什么会有库函数?

  1. 我们知道在我们学习C语言编程的时候,总是在一个代码编写完成之后迫不及待的想知道结果,想
    把这个结果打印到我们的屏幕上看看。这个时候我们会频繁的使用一个功能:将信息按照一定的格
    式打印到屏幕上(printf)。

  2. 在编程的过程中我们会频繁的做一些字符串的拷贝工作(strcpy)。

  3. 在编程是我们也计算,总是会计算n的k次方这样的运算(pow)。像上面我们描述的基础功能,它们不是业务性的代码。我们在开发的过程中每个程序员都可能用的到,为了支持可移植性和提高程序的效率,所以C语言的基础库中提供了一系列类似的库函数,方便程序员进行软件开发 。

那怎么学习库函数呢?
这里我们简单的看看:www.cplusplus.com

image-20211215180340307

我们可以照着这个网站学习库函数。或者可以使用MSDN,我用的是VS2022编译器,我在VS2022中添加了MSDN。给大伙看看效果:

image-20211215213611879

简单的总结,C语言常用的库函数有:

  • IO函数 如:printf、scanf getchar putchar
  • 字符串操作函数 如:strcmp strlen strcpy
  • 字符操作函数 如:toupper
  • 内存操作函数 如:memcpy memcmp memset
  • 时间/日期函数 如:time
  • 数学函数 如:sqrt
  • 其他库函数

上面提到的函数有些我们已经使用过了,还有些后期会讲到哈。

自定义函数

既然库函数可以干这么多事,那能否干所有的事情呢?也不是不行,但是呢如果库函数能干所有事,那要程序员干嘛?我们学这个干啥?使用引进更为重要的是自定义函数。

自定义函数和库函数一样,函数名,返回值类型和函数参数。但不一样的是这些都是由我们自己来设计,我们可以自定义让它实现某种功能。

函数的组成:

return_type fun_name(para1,...)
{
    语句项;
}
return_type 返回类型
fun_name 	函数名
para1		函数参数

我们举个例子吧:

写一个函数可以算出两个整数的和。

#include <stdio.h>
//加法Add函数设计
//返回值类型 函数名 函数参数
int Add(int x,int y)
{
    return x + y;//返回两个整数的和
}
int main()
{
    int num1 = 10;
    int num2 = 20;
    int sum = Add(num1,num2);
    printf("num1 + num2 = %d\n",sum);
    return 0;
}

写一个函数求两个整数最大值

#include <stdio.h>
int Max(int x,int y)
{
    return (x>y)?(x):(y);
}
int main()
{
    int a = 16;
    int b = 20;
    printf("%d",Max(a,b));
    return 0;
}

这些示例运行下来都没问题的,我们看下面这个例子:

写一个函数交换两个整型变量

#include <stdio.h>
//void是空类型  意思就是函数没有返回值
//2
void Swap2(int* x,int* y)
{
    int tmp = *y;
    *y = *x;
    *x = tmp;
}
//1
void Swap(int x,int y)
{
    int tmp = x;
    x = y;
    y = tmp;
}
int main()
{
    int a = 5;
    int b = 9;
    Swap(a,b);
    printf("a = %d  b = %d\n",a,b);
    Swap2(&a,&b);
    printf("a = %d  b = %d\n",a,b);
    return 0;
}

image-20211216094741141

可以看到并没有交换,为什么呢?我不是把a和b都传进去了吗?我们再看第二个函数的执行效果:

image-20211216095045594

这种情况下就可以交换了,为什么我们传进去的是a和b的地址就可以交换,而传进去a和b的值就不能呢?

这就跟我们下面要讲的函数的参数有关了。

函数的参数

函数的参数分为:

实际参数(实参):

真实传给函数的参数,叫实参。实参可以是:常量、变量、表达式、函数等。无论实参是何种类型的量,在进行函数调用时,它们都必须有确定的值,以便把这些值传送给形参 。

形式参数(形参):

形式参数是指函数名后括号中的变量,因为形式参数只有在函数被调用的过程中才实例化(分配内存单元),所以叫形式参数。形式参数当函数调用完成之后就自动销毁了。因此形式参数只在函数中有效 。

简单来说实参就是我们传进去的值,而形参就是函数名括号内的参数。上述Swap和Swap2函数中的x,y都是形式参数。在mian函数中传给Swap和Swap2的a,b,&a,&b都是实际参数。我们调试一下看看内存中它们是怎么样的:

image-20211216102244716

image-20211216102533773

我们可以看出,在调用Swap函数时,x,y拥有自己的空间,同时也有了和实参一模一样的内容。所以我们可以简单地认为:形参实例化之后相当于实参的一份临时拷贝。即使改变了临时拷贝的内容,原来的实参也不会被改变。但是如果我们传地址过去的话,相当于px,py指向了a,b,所以对px,py进行解引用就间接访问了a,b,这样实参也就会被改变。

函数的调用

函数调用分为两类:

传值调用

函数的形参和实参分别 占不同内存区域,对形参的修改不会影响实参。

传址调用

传址调用是把函数外部创建变量的内存地址传递给函数参数的一种调用函数的方式。

这种传参方式可以让函数和函数外边的变量建立起真正的联系,也就是函数内部可以直接操作函数外部的变量,改变形参会影响实参。

我们上面交换两个整型变量函数,Swap2就是用传址调用方式,这样就可通过改变形参而改变实参。这也就是为什么Swap改变不了而Swap2函数可以改变的原因之一。

练习题:

  1. 写一个函数可以判断一个数是不是素数。
  2. 写一个函数判断一年是不是闰年。
  3. 写一个函数,实现一个整型有序数组的二分查找。
  4. 写一个函数,每调用一次函数,就会讲count值增加1。

1.判断是不是素数。

素数的判断方式:2~ n-1暴力求解法,2~开平方求解。

#include <stdio.h>
#include <math.h>
int is_prime(int n)
{
    int i = 0;
    for(i=2;i<sqrt(n);i++)
    {
        if(n%i==0)
        {
            return -1;
        }
    }
    return 1;
}
int is_prime(int n)
{
    int i = 0;
    for(i=2;i<n-1;i++)
    {
        if(n%i==0)
        {
            return -1;
        }
    }
    return 1;
}
int main()
{
    int n = 0;
    scanf("%d",&n);
    int ret = is_prime(n);
    if(1 == ret)
    {
        printf("%d是素数\n",n);
    }
    else
    {
        printf("%d不是素数\n",n);
    }
    return 0;
}

image-202112161117157102.判断闰年

闰年判断的方式:

  1. 该年份能被 4 整除同时不能被 100 整除;
  2. 该年份能被400整除
#include <stdio.h>
int is_leap(int n)
{
    if((n%4==0&&n%100!=0)||(n%400==0))
        return 1;
    else
        return 0;
}
int main()
{
    int n = 0;
    scanf("%d",&n);
    int ret = is_leap(n);
    if(1 == ret)
        printf("%d是闰年\n",n);
    else
        printf("%d不是闰年\n",n);
    return 0;
}

image-20211216112401235

3.二分查找

二分查找法的思想:这里假设数组元素呈升序排列)将n个元素分成个数大致相同的两半,取a[n/2]与欲查找的x作比较,如果x=a[n/2]则找到x,算法终止;如 果x<a[n/2],则我们只要在数组a的左半部继续搜索x;如果x>a[n/2],则我们只要在数组a的右 半部继续搜索x。

#include <stdio.h>
int search(int arr[],int sz,int k)
{
    int left = 0;
    int right = sz - 1;
    while(left<=right)
    {
        int mid = (left+right)/2;
        if(arr[mid]>k)
        {
            right=mid-1;
        }
        else if(arr[mid]<k)
        {
            left=mid+1;
        }
        else
        {
            return mid;
        }
    }
    if(left>right)
    {
        return -1;
    }
}
int main()
{
    int arr[]={21,22,23,24,25,26,27,28,29,30};
    int sz = sizeof(arr)/sizeof(arr[0]);
    int k = 27;
    //数组传参传的是数组首元素地址,数组名是首元素地址
    int ret = search(arr,sz,k);
    if(ret > 0)
    {
        printf("找到了,下标是:%d",ret);
    }
    else
    {
        printf("找不到\n");
    }
    return 0;
}

image-20211216134401562

4.函数调用一次就增加1

函数调用一次,num增加1。函数的调用会改变外部变量的值,那我们就得用传址的形式来做:

#include <stdio.h>
void test(int* n)
{
    (*n)++;//通过对n解引用访问到num然后再自增1
}
int main()
{
    int num = 0;
    //函数调用一次就增加1
    test(&num);//传址调用
    printf("%d",num);
    test(&num);
    printf("%d",num);
    test(&num);
    printf("%d",num);
    return 0;
}

当我们需要改变外部变量的时候,我们就采用传址调用,反之使用传值调用

函数的嵌套调用和链式访问

嵌套调用,其实就和之前学过循环的嵌套是类似的。函数和函数之间可以有机的组合的。函数调用是可以嵌套的,但是函数嵌套定义就不允许的。

嵌套调用

#include <stdio.h>
void test2()
{
    printf("Hello World!\n");
}
void test1()
{
    test2();
}
int main()
{
    test1();
    return 0;
}

我们分析一下:程序从主函数开始,首先调用了test1函数,进入test1函数后,接着调用了test2函数,进入test2函数后,在屏幕上打印了Hello World!。这就是函数的嵌套调用。

链式访问

把一个函数的返回值作为另外一个函数的参数。

举个例子:如果我们要求字符串长度,使用求字符串长度函数strlen时,它会返回一个size_t类型的值。

image-20211216141311415

无符号整型的数字,我们既可以用定义一个整型接收,也可以直接用链式访问的形式将它的返回值打印出来:

#include <stdio.h>
#include <string.h>
int main()
{
    int ret = strlen("abcd");
    printf("%d\n",ret);
    printf("%d\n",strlen("abcd"));
    return 0;
}

两者的效果是一样的。

image-20211216141629094

我们看一道有趣的代码:

#inlcude <stdio.h>
int main()
{
    printf("%d",printf("%d",printf("%d",43)));
    //结果是啥?
    return 0;
}

image-20211216142159930

如果不清楚printf函数的返回值可以看一下文档,它是这样说的返回值是 返回打印的字符数。首先我们看函数的访问,首先第一个printf函数要打印的是第二个printf函数的返回值。而第二个printf函数要打印的是第三个printf函数的返回值,而第三个printf函数首先会在屏幕上打印43然后接着返回打印字符数,43是2个字符所以第二个printf函数打印的是2,2是1个字符,所以第二个printf函数返回值是1,第一个printf函数就会打印1,得到最终答案:4321。我们看效果:

image-20211216142900631

函数的声明和定义

函数的声明为什么要有这个呢?因为编译器都是从上至下执行的,如果我函数的声明和定义在调用之后,会造成函数未定义。所以这个时候我们提前声明这个函数。然后编译器会从后面的代码去寻找这个函数。

函数的声明

  1. 告诉编译器有一个函数叫什么,参数是什么,返回类型是什么。但是具体是不是存在,无关紧要。
  2. 函数的声明一般出现在函数的使用之前。要满足先声明后使用
  3. 函数的声明一般要放在头文件中的。

函数的定义

函数的定义是指函数的具体实现,交待函数的功能实现。

定义是一种更强有力的声明。

#include <stdio.h>
int Max(int x, int y);//函数声明
int main()
{
	int a = 10;
	int b = 20;
	int sum = 0;
	sum = Max(a, b);//函数调用
	printf("%d\n", sum);
	return 0;
}
int Max(int x, int y)//函数定义
{
	return (x>y)?(x):(y);
}

因此我们在使用函数时,最好将函数的定义和函数的声明放在函数调用之前。

其实我们在以后工作中写代码是分模块来写的,比如说一个计算器功能,由4个程序员来写,加法我们交给甲程序员,减法交给乙程序员,乘法交给丙程序员,除法交给丁程序员来完成,最后再拼接起来就可以了。我们在C语言中拆分模块用不同头文件与相对应的源文件中声明和定义函数即可。在其他人写代码是引用头文件就可以了。举个例子:

image-20211216154410189

这样举例子大家会了解更清楚一点。

函数的声明为什么要放到头文件.h里面?

头文件的包含,实际上相当于将头文件的内容拷贝到该条语句处。

函数声明和定义的小应用

假设珂程序员写了一个判断闰年的函数文件,有人需要购买这个功能。而珂程序员又不想将之间的源码给它,那该怎么办呢?我使用的环境是VS2022,用这样编译器来举例子:

珂程序员在自己的判断闰年项目工程中:

第一步、把项目工程的属性改为静态库。

image-20211216160849730

image-20211216160930266

点击应用后再确定。改为静态库后,Ctrl+F7编译一下,再Ctrl+F5运行一下:

image-20211216161213452

然后我们进入到工程中的Debug文件夹下,找到后缀名为 .lib的文件,那就是我们的静态库了

image-20211216161418635
这个.lib文件就是头文件和源文件编译产生的静态库。我们打开试试看能否看得懂:

image-20211216161656602

我们发现这些都是些十六进制的数字,看不懂的文件,所以卖出了也不用担心。这时珂程序员就可以安心的将自己写的程序卖给买家了,但是光有这个是不够的,因为别人就算有了这个文件他看不懂,自己也看不懂,所以还需要把一个文件卖给买家,那就是头文件。而且头文件要详细说明函数的作用和用法。

那买家买到了该如何使用呢?

买家应该把这两个文件放在要使用的工程中,和源文件是相同目录的文件夹中:

image-20211216162317397

然后再在工程中添加该头文件

此时在引入头文件,光引头文件是没用的,还需要导入静态库:

image-20211216163009765

导入静态库后再运行:

image-20211216163158965

这样珂程序员的源码也没有泄漏,买家也得到了自己想要的功能。是不是很有趣?

我们去看其他C语言原有的头文件,如:stdio.h或者string.h之类等等的头文件,看那些头文件可以看到一下的代码:

#ifndef xxxxx
#define xxxxx
//内部其他代码
...
#endif xxxxx

为什么会有这些符号呢?

防止同一个头文件被多次使用

头文件在使用的时候,实际上是将整个头文件的内容复制到这条语句处,如果同一个头文件多次使用,就会产生大量重复且无效的代码,降低代码的效率和增加代码所在内存。

函数递归

什么是递归

函数递归,先解释一下什么是递归:

程序调用自身的编程技巧称为递归( recursion)。 递归做为一种算法在程序设计语言中广泛应用。 一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解,递归策略只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。
递归的主要思考方式在于:把大事化小

函数自己调用自己,这就是递归,当然是有条件的。

我们看一个简单的递归:

#include <stdio.h>
int main()
{
    printf("hello world\n");
    main();//主函数调用主函数
    return 0;
}

我们看看会怎么样:

image-20211216170427047

死循环了,然后再看

image-20211216170505577

程序退出了,这是为什么呢?image-20211216170533125

我们看到它说栈溢出了。所以这个程序是先死循环然后接着程序挂掉。因为栈溢出了。那为什么会出现栈溢出呢?栈又是什么呢?想知道这些我们首先得知道内存的划分:

每一次函数的调用,都需要在栈区分配一定的空间,函数调用会在栈区上开辟空间,因为调用的次数太多,导致栈空间被榨干,所以会栈溢出。

看一个练习:

接收一个整型值(无符号),按照顺序打印它的每一位。例如:输入1234,输出1 2 3 4 。

按顺序打印出每一位,那要怎样做呢?首先我们肯定得要得到它的每一位,而得到这些数字,我们知道1234最容易得到的一位就是4了,那其他的呢?

1234 %10 = 4

1234 / 10 = 123 % 10 = 3

123 / 10 = 12 %10 = 2

12 / 10 = 1 %10 = 1

1 /10 = 0;

#include <stdio.h>
void print(unsigned int n)
{
    if(n>9)
    {
        print(n/10);
    }
    printf("%d ",n%10);
}
int main()
{
    unsigned int num = 0;
    scanf("%u",&num);//1234
    //递归  函数自己调用自己
    print(num);//打印参数部分的每一位
    //print(1234)
    //print(123) + 4
    //print(12)+ 3 + 4
    //print(1) + 2 + 3 + 4   直到print里面的值不能被拆解
    return 0;
}

代码是否看的有点懵?解题的思路:

想打印数字的每一位,那就把每一位都取出来,而最容易得到的是个位,那么我们先把个位取出来,再把十位取出来,以此类推,这样就能把每一位都取出来,这样取出来的是倒序的,我们想要正序的该怎么办,首先我们先把个位舍掉,就变成了求其他3位再加上打印的个位,然后以这种方式求到最后只剩1位数了,那就是1位加上之前丢弃的每一位,再打印出来,这样就可以正序打印了。

我们看程序的执行过程:

递归的中心思想:大事化小。

递归的两个必要条件

  • 存在限制条件,当满足这个限制条件的时候,递归便不再继续。
  • 每次递归调用之后越来越接近这个限制条件 。

注意:这两个虽然是必要条件,但是也不是说满足这两个条件就一定能实现。我们看个例子:

#include <stdio.h>
void test(int n)
{
    if(n<10000)
    {
        test(n+1);
    }
}
int main()
{
    test(1);
    return 0;
}

image-20211216191423731

这个程序到最后也挂了,因为栈溢出了

image-20211216191740658

image-20211216192145261

每一次函数的调用,都需要在栈区分配一定的空间,函数调用会在栈区上开辟空间,因为调用的次数太多,导致栈空间被榨干,所以会栈溢出。

所以在写递归代码时:

  1. 不能死递归,要有跳出条件,每一递归要逼近跳出条件
  2. 递归层次不能太深。

练习2:

编写函数不允许创建临时变量,求字符串长度。

解析:求字符串长度就是找‘\0’,常见的方法有计算器加上循环的方式可以求出来,但是又不能创建临时变量,那就用指针减指针的方式可以求出中间元素的个数,也就是字符串长度。但是用递归的方式怎么做呢?

my_strlen("hello");
my_strlen("ello") + 1;
my_strlen("llo")  + 1  + 1;
my_strlen("lo")   + 1  + 1  + 1
my_strlen("o")    + 1  + 1  + 1  + 1
my_strlen("\0")   + 1  + 1  + 1  + 1 + 1

递归思想:大事化小,我们可以判断第一个字符如果不是‘\0’那就+1然后指针向后走一步指向第二个字符,第二个字符不是‘\0’那就再+1接着指针向后走一步....一次类推知道找到‘\0’

写成代码:

#include <stdio.h>
//			这里使用const修饰是因为这里只是求字符串长度不会对字符串本身造成影响
int my_strlen(const char* str)
{
    if(*str == '\0')
    {
        return 0;
    }
    else
    {
        return 1 + my_strlen(str+1);
    }
}
int main()
{
    char arr[]="hello";
    int len = my_strlen(arr);
    printf("%d",len);
    return 0;
}

运行效果:image-20211217140615469

效果确实是出来了,但是运行是怎样的呢?

有人会有疑问这个str+1为啥不写作str++,因为后置加加是先使用再自增,而我们需要的是加了之后再传进去,在递归中还是减少++的使用。大概是这个样子画的不好还请勿怪。其实刚开始学我也想不出来的,多做点题目就会了,递归是函数调用自己,但是中心思想还是:大事化小。

递归与迭代

大家有木有发现其实递归跟循环也是很相似的,一直重复做某件事情,那递归是否能写成循环、迭代的方式呢?迭代就是是重复反馈过程的活动,其目的通常是为了逼近所需目标或结果。

我们来做一个练习题

求n的阶乘。(不考虑溢出)

阶乘不就是n*n-1*n-2*...*1,之前学循环时就做个这个题, 从1乘到n即可。循环是一种迭代方式。递归方式就是这样的:

Fac(n)
     n == 1       n!  == 1
     n >  1       n * Fac(n - 1)

代码如下:

#include <stdio.h>
int Fac(int n)
{
 	if(n <= 1)
    {
        return 1;
    }
    else
    {
        return  n * Fac(n-1);
    }
}
int main()
{
    int n = 0;
    scanf("%d",&n);
    int ret = Fac(n);
    printf("%d ",ret);
    return 0;
}

效果如图所示

image-20211218202524188

再来看一道题

求第n个斐波那契数。(不考虑溢出)

在这里有必要提一下斐波那契数列,就是前两个数之和等于第三个数。

1 1 2 3 5 8 13 21 34 55 89 ......

求第n个斐波那契数

我们要求第n个,那有几种可能性呢?

Fib(n)
  n  <= 2    1
  n  > 2     Fib(n-1)+Fib(n-2);

有了这样的公式我们能更容易写出代码:

#include <stdio.h>
int Fib(int n)
{
    if(n<=2)
        return 1;
    else
        return  Fib(n-1)+Fib(n-2);
}
int main()
{
    int n = 0;
    scanf("%d",&n);
    printf("%d",Fib(n));
    return 0;
}

我们看运行效果。

image-20211218203350116

我在输入语句上加了循环是用来多次输入的,我们可以看到数字不大的时候可以算出来,但是数字一旦大了一点就要等很久,上面程序还在运行,有些都是重复的计算。我们可以推断一下程序的运行状态:

image-20211218204132437

我们可以看到有许多重复的计算,所以导致了计算效率太低,而程序运算时间需要很久。我们可以看看第三个斐波那契数被计算了多少次

image-20211218204600205

可以看见我只是计算第40个斐波那契数,结果第三个斐波那契数被计算了3千多万次,可想而知做了多少次无用功。这题可以用递归方式求解,那是否也可以用循环来求解呢?
前两个数之和等于第三个数。

1 1 2 3 5 8 13 21 34 55 ...
m n x
​ m n x

x = m + n;
//我们算出第三个数字的值 那之后的第三个数字的值是不是都是上面的表达式? 到了第二次循环的时候
//是不是就是下一个 m + n  那这两个值怎么得来呢
//可以把 m = n  n = x

那用循环的方式怎样写代码呢?

#include <stdio.h>
int Fib(int n)
{
    int m = 1;
    int n = 1;
    int x = 1;
    while(n>2)
    {
        x = m + n;
        m = n;
        n = x;
        n--;
    }
    return x;
}
int main()
{
    int n = 0;
    scanf("%d",&n);
    printf("%d",Fib(n));
    return 0;
}

效果:

image-20211218210056787

可以看到效率比递归要快很多,为啥还有负数呢是因为整型溢出了,超出了最大值。

这里得出总结:

代码可以用递归或非递归来写,但是有时递归会出现栈溢出或效率低下的问题。所以我们需要想一个方法用迭代来求解,两种方式都写出来,再择优选择。

提示:

  1. 许多问题是以递归的形式进行解释的,这只是因为它比非递归的形式更为清晰。
  2. 但是这些问题的迭代实现往往比递归实现效率更高,虽然代码的可读性稍微差些。
  3. 当一个问题相当复杂,难以用迭代实现时,此时递归实现的简洁性便可以补偿它所带来的运行时开销。

​ 关于函数的部分知识,鄙人大概的总结一下,如若有错,还请各位兄台指点指点。谢谢大家的阅览!!!

posted @ 2022-03-08 16:01  L-wk  阅读(73)  评论(0)    收藏  举报