【C语言航路】第三站:函数(二)

简介: 【C语言航路】第三站:函数(二)

五、函数返回类型,参数未写导致产生的一些后果

我们在写函数的时候,总有一些初学者,会将函数写的乱七八糟。比如返回类型不写,无返回类型的函数却强行让他返回,导致产生了各种各样的后果,那么今天我们就故意来试错,看看到底会产生什么结果。

1.函数未写返回类型,则默认返回int类型

有些人在些函数的时候,就不写返回类型,因为他们认为不写返回类型就是void返回类型,这其实是一个经典的错误,标准的零分。函数不写返回类型,则默认返回int类型。如下代码所示,test返回int类型。这是c语言所规定的。

test()
{
  printf("hehe");
}
int main()
{
  test();
  return 0;
}

2.函数不写形参,但传了实参。

如下代码所示,函数中不写形参,但是非要传一个参数,这样的操作是不会报错的,无非就是给传一个参数,但是这个函数不接收罢了,也就是说,传不传这个100对代码毫无影响。但是不推荐这样写,容易误导人。降低可读性。同时这也是c语言不严谨的一个小地方。

#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
int test()
{
  printf("hehe");
}
int main()
{
  test(100);
  return 0;
}

3.函数形参写了void,但非要传实参

如下代码所示,函数已经明确规定不要传参了,但是非要传100,那编译器就会报警告。

#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
int test(void)
{
  printf("hehe");
}
int main()
{
  test(100);
  return 0;
}

运行结果如下所示

4.函数内没有return,但是却强行让他返回一个值,则大多数编译器返回最后一条语句的返回值

如下代码所示,test内没有return,反而强行让他返回,那大多数编译器就会返回最后一条语句的结果。

#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
int test(void)
{
  printf("hehe\n");
}
int main()
{
  int ret = test();
  printf("%d", ret);
  return 0;
}

运行结果为,因为printf的返回值为他打印出来的字符数。显然打印了五个字符(hehe\n)。具体printf这个库函数如何学习,请大家看这篇文章,详细讲解了库函数如何学习,因为授之以鱼,不如授之以渔。一定要自己学会如何学习库函数。第三站:函数(第一幕),如何学习库函数

当然,最好不要写出这种摸棱两可的函数,因为这个只是一般情况,不代表全部编译器都是这样的,这是一种很危险的行为。

5.声明函数时省略函数形参,但不省略类型。

如下代码所示

int Add(int ,int);

这样的代码是没有问题的, 在声明函数时候,可以省略函数的形参的变量,但是不可以省略类型,但是定义的时候不可以省略。同样也不建议这样操作。因为很多人定义和声明极其混淆。不过在下文我们会详细讲解声明和定义的区别。

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

1.嵌套调用

我们通过一个例子来详细解读一下,如下所示代码,three_line中调用了new_line这个函数,一个函数调用另外一个函数,这就叫做嵌套调用。但是有一点需要注意,函数可以嵌套调用,但是绝对不可以嵌套定义。

#include <stdio.h>
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;
}

2.链式访问

我们通过一段代码来了解一下链式访问

#include<stdio.h>
int main()
{
  printf("%d", strlen("abcdef"));
  return 0;
}

顾名思义,就是一个像一根链子一样串起来,把strlen的返回值作为另一个函数的参数,就叫做链式访问

我们在看一段代码,这是一个经典的例子。

#include<stdio.h>
int main()
{
  printf("%d", printf("%d", printf("43")));
  return 0;
}

运行结果为

因为printf的返回值为它打印出的字符个数,具体了解printf这个函数在上文中已经提到。第三个printf先打印出43,43是两个字符,返回2,打印出2,2又是一个字符,又返回1,1在打印出来。所以最终结果为4321

七、函数的声明和定义

当我们在代码中这样写函数时候,将函数放在了调用它的后面,那么就会产生报错,这是因为我们的计算机编译时候是从上往下按照顺序的。

#include<stdio.h>
int main()
{
  int a = 0;
  int b = 0;
  scanf("%d %d", &a, &b);
  int ret = Add(a, b);
  printf("%d", ret);
  return 0;
}
int Add(int x,int y)
{
  return x + y;
}

这时候,我们为了防止报错,在前面加上一个函数的声明,就可以防止报错了。此时上面的Add称作声明,下面的Add称作定义。

#include<stdio.h>
int Add(int x,int  y);
int main()
{
  int a = 0;
  int b = 0;
  scanf("%d %d", &a, &b);
  int ret = Add(a, b);
  printf("%d", ret);
  return 0;
}
int Add(int x,int  y)
{
  return x + y;
}

当然我们也可以按照我们之前的方式直接将Add放在调用它之前,此时Add就是定义,而此时定义本身就是一种特殊的声明。

当然,大家肯定觉得这样很麻烦,为什么要这样做呢,为什么就不能把这个永远定义在最前面呢?其实,这种定义方式在工程中不是这样使用的,而是通过分文件使用的。我们将这个内容放在后面的一篇外传中,为大家详细讲解,为什么要这样定义,多文件使用。有什么好处,一个完整的程序是如何不让别人看到源码的使用的?这些都将放在后面的一篇文章中详细讲解,后续也会在该文章中做出一定的修改,在这里附上链接。

当然在我们之前提到变量的声明和定义时候,提到了函数的声明和定义,在这里我们在回顾一下变量的声明和定义,其实和函数是基本相同的

如下代码所示,当我们只在下面具有一个g_val时候,毫无疑问编译器会报错误,因为定义在使用之后了。

#include<stdio.h>
int main()
{
  printf("%d", g_val);
  return 0;
}
int g_val = 2022;//定义

因此我们在前面补上一个声明,此时上面的这个就叫做声明了,下面那个是定义。

#include<stdio.h>
int g_val;//声明
int main()
{
  printf("%d", g_val);
  return 0;
}
int g_val = 2022;//定义

而且不能在声明的时候赋值,然后下面还继续定义。此时上面的叫做定义,下面的也叫做定义,此时会报一个错误,叫做重定义

#include<stdio.h>
int g_val = 2022;//定义
int main()
{
  printf("%d", g_val);
  return 0;
}
int g_val = 2022;//定义

所以此时我们就应该去掉下面的定义,这样就不会叫做重定义了,并且上面的定义也是一种特殊的声明。

#include<stdio.h>
int g_val = 2022;//定义
int main()
{
  printf("%d", g_val);
  return 0;
}

上面的这些例子这也同时告诉着我们,函数的声明是是不需要赋值的,定义的时候在赋值就可以了。但是定义一定要赋值吗?我们说,是不一定的。请看下面的代码

#include<stdio.h>
int g_val;//定义
int main()
{
  printf("%d", g_val);
  return 0;
}

这段代码中,g_val就属于定义,虽然它没有赋值,但他仍然属于定义。总的来说就是有定义的时候,它就是声明,没有定义的时候,它就是定义。

并且我们运行后,结果是0.

为什么是0呢?我们说这是因为全局变量不初始化默认是0

八、函数递归

1.什么是递归?以及基本定义,基本概念

程序调用自身的编程技巧称为递归(recursion)。

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

递归策略

只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。

递归的主要思考方式在于:把大事化小

我们来看一个史上最简单的递归

#include<stdio.h>
int main()
{
  printf("hehe\n");
  main();
  return 0;
}

这个递归下去效果是这样的:最终程序挂了

我们调试,会发现报错信息:stack overflow 意思为栈溢出。

这是什么意思呢?这又是为什么呢?我们得好好研究一下,我们在前面的文章中提到过内存的分区使用,如有忘记可以查看该链接去跳转前文:第一站:初识C语言(第四幕)。内存我们会粗略的分为栈区,堆区和静态区。如下图所示

其中栈区存放着函数的局部变量、函数参数、临时的变量。每一次调用递归,都会为本次函数,在内存的栈区上开辟一块空间。而我们上面的调用递归,会使得main函数不断占用栈区,这个递归相当于一个无限递归,而我们的栈区是有限的。所以最终就会使得栈区内存被完全占用,也就是所谓的栈溢出了。

也即是说,栈被耗干了,程序最终就会崩溃。上面的递归虽然是一个最简单的递归,但是却是一个错误的递归。

当然提起栈溢出,这里也为大家分享一个网站:https://stackoverflow.com/,可以看到这个网站的名字就叫做栈溢出。这相当于知乎一样的问答社区,只不过上面的都是程序员在交流,所以他是一个程序员的问答社区。也被人调侃为:同性交友网站。

当然我们国内也有类似的网站:它的名字是思否,网站是:https://segmentfault.com/

2.递归的两个必要条件

1.存在限制条件,当满足这个限制条件时,递归不在继续。

2.每次递归调用之后越来越接近这个限制条件。

我们看两个例子来详细解释一下这两条:

(1)接受一个无符号整型值,按照顺序打印它的每一位。

例如:输入1234  输出 1 2 3 4

这道题大家第一眼看过去,当然觉得简单,我们只需要利用好/和%这两个操作符就可以很轻松的打印出来。唯一需要注意的就是无符号整型用%u来输入。代码实现如下.

#include<stdio.h>
void Print(unsigned int num)
{
  while(num != 0)
  {
    printf("%d ", num % 10);
    num = num / 10;
  }
}
int main()
{
  unsigned int num = 0;
  scanf("%u",&num);
  Print(num);
  return 0;
}

可是需求却有一点不一样啊,这个打印出来的是逆序。而我们需要的是顺序,那还有其他办法吗?我们说,是有的。

我们可以试一下递归的思想,因为递归的思想就是大事化小。

我们想要1234按顺序打印出来.

那是不是可以分为先打印将123给按顺序打印出来,然后在打印出4.

想把123按顺序打印出来可以分为先把12给按顺序打印出来,然后打印出3

想把12按顺序打印出来可以分为先把1打印出来,然后在打印出来2

也就是说

Print(1234)可分为

Print(123)   4   可分为

Print(12)   3    4   可分为

Print(1) 2    3     4    

然后就打印出来1   2    3    4了

所以我们的递归函数表达式应该是这样写的

也即是,如果num大于9,则两条都执行,如果num不大于9,则只执行后一条语句

我们按照上面的思路实现一下代码

#include<stdio.h>
void Print(unsigned int num)
{
  if (num > 9)
  {
    Print(num / 10);
  }
  printf("%d ", num % 10);
}
int main()
{
  unsigned int num;
  scanf("%d", &num);
  Print(num);
  return 0;
}

当然呢,肯定还是有些读者看到这个递归代码是很懵逼的,但是没关系,我们详细解剖一下这个代码的运行过程。

递归=递推+回归,先递推,在回归。

我们把主函数和Print函数先分开

然后当我主函数传入1234后,我就进入Print函数,如下图所示,1234进入函数以后,满足if语句,继续往下走,又遇到Print函数,此时这个Print函数传入一个123

如下图所示,我们第二个继续传入123,然后我们继续进入下一个Print函数,和之前一样完全满足之前的情况

同样的,我们继续深入递归

注意看,这里我们的递归就要开始回归了,此时num为1了,不满足递归条件,所以先打印出1%10,也就是1

打印出1以后,就返回到上一层递归中,执行上一层递归剩余的语句,也就是num%10,而在这一层中num为12,所以打印出2

然后继续退回上一层递归,打印出3

继续退回第一层递归,打印出4

最后回到主函数,程序结束

我们回想一下之前的两个必要条件。

1.存在限制条件,当满足这个限制条件时,递归不在继续。

2.每次递归调用之后越来越接近这个限制条件。

我们会发现这个题中的函数完全符合这两个条件。

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

我们已经知道,我们库函数本身就有一个strlen函数可以求字符串长度,我们先演示一遍库函数求字符串长度的代码

#include<stdio.h>
#include<string.h>
int main()
{
  char a[] = "abc";
  int len = strlen(a);
  printf("%d", len);
  return 0;
}

而这道题的意思其实翻译一下也很简单,就是自己模拟实现一个strlen函数

那我们先把这个函数的类型,参数都给定义好。如下代码所示,值得注意的是,数组名就是首元素的地址,也就是a的地址,所以我们传参的时候需要使用一个指针来传参。

#include<stdio.h>
#include<string.h>
int my_strlen(char* str)
{
}
int main()
{
  char a[] = "abc";
  int len = my_strlen(a);
  printf("%d", len);
  return 0;
}

如何实现呢?我们可以这样想,这是我们的数组中的元素,我们想要求他的长度,那不就是只要*str不等于\0的时候,弄一个计数器,进行++一下,然后str这个指针也往后走一步,利用一个循环就可以完美的解决问题吗?

我们代码实践一下,注意str++,字符指针+1,往后跳一个字符,举一反三,整型指针+1,向后跳一个整型,也就是4个字节。

#include<stdio.h>
#include<string.h>
int my_strlen(char* str)
{
  int count = 0;
  while (*str != '\0')
  {
    count++;
    str++;
  }
  return count;
}
int main()
{
  char a[] = "abc";
  int len = my_strlen(a);
  printf("%d", len);
  return 0;
}

可是这个好像创建了一个新变量count啊,我们题目的要求是不创建新变量啊,这违背了我们题目的要求,那么还有什么好的方法可以避免吗?我们说,是有的。

我们刚刚学习了递归,那我们就可以使用分而治之的思想啊。那我们分析一下,我们想要求my_strlen("abc")的长度,那是不是就是1+my_strlen("bc")的长度啊,那不就是1+1+my_strlen("c")的长度啊,进而就是1+1+1+my_strlen("\0")的长度啊(因为字符串的结尾是\0,即便不写也是一个默认的,这在之前的文章中提到过),最终就是1+1+1+0,最后返回一个3就结束了。所以说我们的递推函数应该怎么写呢?应该这样写。

有了这个递归函数,那么写出这个递归就轻而易举了

#include<stdio.h>
#include<string.h>
int my_strlen(char* str)
{
  if (*str != '\0')
  {
    return 1 + my_strlen(str+1);
  }
  else
  {
    return 0;
  }
}
int main()
{
  char a[] = "abc";
  int len = my_strlen(a);
  printf("%d", len);
  return 0;
}

但是呢,注意一点,在这里很多人都会犯一个经典的错误,标准的零分,它把my_strlen(str+1)改成了my_strlen(str++)。这个错误很不容易看出来,要知道str++是先引用str,在加加的,可是我们这个递归中先引用了str后,这个str就进入了下一层递归,然后悲剧来了,你会发现这个str一直不会被改变,因为str一直处于递推状态,它只有在回归的时候才会++,这就造成了递归层数太深,栈溢出,程序崩溃了。但同时修改方法很简单,把str++改为++str就可以了。

而且我们也可以看到,这个递归中也同样满足两个必要条件。

这个递归也没有创建临时变量,也完美的实现了我们的需求。

3.递归和迭代

所谓迭代就是指用非递归的方式解决问题

我们看两个题

(1)求n的阶乘

非递归的方式,也就是迭代的方式相信大家都很容易能够写出来,这里就不再赘述了,直接给出代码

#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
int Factorial(int n)
{
  int i;
  int s=1;
  for (i = 1; i <= n; i++)
  {
    s = s * i;
  }
  return s;
}
int main()
{
  int n;
  scanf("%d", &n);
  int ret = Factorial(n);
  printf("%d", ret);
  return 0;
}

那么对于递归的思想其实也不难,因为n的阶乘等于n乘以n-1的阶乘

有了递归公式,写出递归函数就轻而易举了,代码如下

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

我们画一下该递归的图解

迭代与递归的方法各有优缺,因为递归可能会造成栈溢出,程序崩溃,所以此题其实迭代的方法更好一些。

(2)求第n个斐波那契数

这是斐波那契数列的递推式

有了递推式写出代码那简直轻而易举

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

可是这代码,我们可以试一下,会发现效率特别低,如果输入50,可能得跑很长时间,里面具有大量的重复计算。而且还有栈溢出,程序崩溃的风险。

我们可以试一下,使用一个全局变量count,计算一下光Fib(3)被重复计算了多少次,

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

运行结果如下

可见单单是Fib(3)就重复计算了将近4千多万次

我们还可以画个图可以更加直观的看出这计算次数之大

数目之大难以想象。可见递归并不是解决这道题的最优方案。

那么还有什么方法能好一点呢?其实就是循环,循环也是迭代的一种

如图所示,我们其实可以用循环的思想,按照顺序进行计算第n个斐波那契数

我们的思考是这样的,第一个和第二个是已知的,都是1,第三个数是前两个相加,那我就把后两个数给放到前两个数的位置上,然后就循环下去。判断条件也很简单,就是当n>2时候才需要执行,n小于2直接返回即可,另外要给c初始化为1,因为我们返回的是c。

代码实现如下

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

在调试 factorial 函数的时候,如果你的参数比较大,那就会报错: stack overflow(栈溢出) 这样的信息。系统分配给程序的栈空间是有限的,但是如果出现了死循环,或者(死递归),这样有可能导致一直开辟栈空间,最终产生栈空间耗尽的情况,这样的现象我们称为栈溢出

如何解决呢?

1. 将递归改写成非递归。

2. 使用static对象替代 nonstatic 局部对象。在递归函数设计中,可以使用 static 对象替代

nonstatic 局部对象(即栈对象),这不仅可以减少每次递归调用和返回时产生和释放 nonstatic 对象的开销,而且 static 对象还可以保存递归调用的中间状态,并且可为各个调用层所访问

1. 许多问题是以递归的形式进行解释的,这只是因为它比非递归的形式更为清晰。

2. 但是这些问题的迭代实现往往比递归实现效率更高,虽然代码的可读性稍微差些。

3. 当一个问题相当复杂,难以用迭代实现时,此时递归实现的简洁性便可以补偿它所带来的运行时开销。

4.函数栈帧的创建与销毁.

在上面这么多的递归的例子以后,大家有没有思考过一个问题,就是为什么递归在回归的时候,他原先的n值还在呢?这个n不会发生变化呢?这个阶乘递归的图解,我们以这个为例子进行讲解

函数栈帧

每一次函数调用都会为本次函数调用分配内存空间(是在内存的栈区),为本次函数调用分配的内存空间叫被成为这次函数调用的栈帧空间,这也就是我们将要说的的函数栈帧的创建与销毁

我们在之前已经多次提到过,函数的内存大致分为三个区域,注意是大致,实际上的分区要比这个还要复杂,也就是下图中的栈区,堆区,静态区。

由于都是在内存的栈区上进行创建的。所以我们将栈区单独拿出来

而我们第一次调用主函数,就会为主函数分配一块内存空间,然后在主函数中定义n,这个n是在主函数栈帧空间内的,如下图所示

紧接着,我们进入第一层递归,就要调用函数,调用函数继续占用栈区空间。

然后继续进入第二层,第三层。

每个空间的具体详情如下,在这里你也能感受到,当递归层次太深的话,栈区就会爆满了,也就是所谓的栈溢出。程序就会崩溃

然后当我们一层递归使用完毕之后就会销毁这个空间,从内层到直到程序结束释放了main函数

当然呢,这里只是简单了谈了一下栈帧的创建与销毁,或许你听了之后还是摸棱两可,但是不要紧,我们后续会专门拿出一节内容来好好探讨一下函数栈帧的创建与销毁,从汇编代码的角度一步一步看懂函数栈帧的创建与销毁


总结

本节内容较多,需要大家好好消化理解一下,主要讲解了一些函数返回类型乱写导致的现象,函数嵌套调用与链式访问,声明和定义的区别,函数递归的思想,如何理解递归,递归的必要条件以及迭代,并且简单了解了函数栈帧的创建与销毁。

本站未完,欲知后事,请看下节

相关文章
|
10天前
|
程序员 C语言
C语言库函数 — 内存函数(含模拟实现内存函数)
C语言库函数 — 内存函数(含模拟实现内存函数)
22 0
|
10天前
|
程序员 C语言 开发者
C语言库函数 — 字符串函数(含模拟实现字符串函数)
C语言库函数 — 字符串函数(含模拟实现字符串函数)
35 0
|
17天前
|
存储 C语言
【我爱C语言】详解字符函数isdigit和字符串转换函数(atoi和snprintf实现互相转换字符串)&&三种strlen模拟实现1
【我爱C语言】详解字符函数isdigit和字符串转换函数(atoi和snprintf实现互相转换字符串)&&三种strlen模拟实现
|
17天前
|
机器学习/深度学习 C语言
【C语言】函数的系统化精讲(三)1
【C语言】函数的系统化精讲(三)
|
17天前
|
编译器 C语言
【C语言】函数的系统化精讲(一)2
【C语言】函数的系统化精讲(一)2
|
17天前
|
编译器 Serverless C语言
【C语言】函数的系统化精讲(一)1
【C语言】函数的系统化精讲(一)
|
17天前
|
存储 程序员 编译器
【C语言第二回】main、printf和库函数
【C语言第二回】main、printf和库函数
|
存储 程序员 C语言
手写C语言之函数概念-函数分类-实参与形参-传值调用与传址调用介绍(13)
手写C语言之函数概念-函数分类-实参与形参-传值调用与传址调用介绍
132 0
手写C语言之函数概念-函数分类-实参与形参-传值调用与传址调用介绍(13)
|
C语言
【C语言深度剖析】重点详解函数的形参和实参、传值和传址
【C语言深度剖析】重点详解函数的形参和实参、传值和传址
144 0
【C语言深度剖析】重点详解函数的形参和实参、传值和传址
|
C语言
C语言中不同函数之间怎么传值?
#include int change(); int change(int j) { j=0; return(j); } void main() { int b = change (b); printf("%d",b); } ...
665 0