C语言之函数(中)

局部变量和全局变量

局部变量

在我们学习函数之前,我们所理解的变量,只不过是在内存中开辟一个存储数据的位置,并取了个我们好懂的名字而已。因为我们之前写的程序只有一个主函数,因此我们觉得,定义了一个变量,就应该可以随时调用。但是学习了函数之后,我们发现,不同函数之间的变量是不能够相互调用的,这又是为什么呢?

比如:

//Example 01
#include <stdio.h>
int main(void)
{
    int i = 100;
    printf("Before i = %d\n", i);
    for (int i = 0; i <= 10; i++)//再定义一个局部变量i
    {
        printf("i = %d\n", i);
    }
    printf("After i = %d\n", i);
    return 0;
}

结果如下:

//Consequence 01
Before i = 100
i = 0
i = 1
i = 2
i = 3
i = 4
i = 5
i = 6
i = 7
i = 8
i = 9
i = 10
After i = 100

我们可以看到,我们在for函数里和main函数里都有一个i变量,但是我们在for函数里面定义的i却对外层函数不构成影响。

一般来说,变量名应该是不能够重复的。但是,由于我们定义的位置不一样(在不同的函数中),所以变量名重复又变得合法起来。这就是局部变量的特性:只能在自己的领域里面发挥作用

像我们刚刚定义的for一样,C语言允许随处定义变量。也就是说,变量在需要用到的时候再定义。这样也符合我们的思维方式。因为当程序很庞大的时候,没有人愿意翻到顶上去看一个变量的注释。

全局变量

既然有局部变量,那么全局变量也照样不可少。看下面的例子:

//Example 02
#include <stdio.h>
void f1(void);
void f2(void);
void f3(void);
int a = 0;//定义一个全局变量

void f1(void)
{
    a++;
}

void f2(void)
{
    a++;
}

void f3(void)
{
    a++;
}

int main(void)
{
    f1();
    f2();
    f3();
    printf("a = %d\n", ++a);
    return 0;
}

运行结果如下:

//Consequence 02
a = 4

我们发现,全局变量在每一个函数里面的更改都“有效”,也就是说,全局变量是贯穿整个程序始终的。

有的小伙伴可能会好奇,如果全局变量和局部变量重名了,会发生什么呢?

那我们就来试试:

//Example 03
#include <stdio.h>
void f(void);
int a, b = 100;
void f(void)
{
    int b;
    a = 50; b = 101;
    printf("func, a = %d, b = %d\n", a, b);
}

int main(void)
{
    printf("Main, a = %d, b = %d\n", a, b);
    f();
    printf("Main, a = %d, b = %d\n", a, b);
}

运行结果如下:

//Consequence 03
Main, a = 0, b = 100
func, a = 50, b = 101
Main, a = 50, b = 100

我们发现,名字叫a的变量只有全局变量,那么被赋值之后就等于在函数f()中赋的值。但是b就不一样了。在函数f()中,也有一个变量叫做b,那么此时编译器的做法是,在函数f()里先暂时屏蔽全局变量b,使用自己的局部变量。等走出了函数f(),局部变量被释放。

而我们还发现一个问题,我们可以直接输出没有被初始化的a!这也是全局变量的一个特点,不同于局部变量,全局变量在没有手动初始化的时候,会被系统自动初始化为0而不是像局部变量那样的很小的一个很奇怪的数。

另外,如果没有需要,尽量不要大量使用全局变量。因为全局变量的内存将会伴随着这个程序,直到程序执行完毕。如果大量使用全局变量的话,可能会造成内存占用过多等问题。这在嵌入式开发这种领域,内存空间寸土寸金,不当使用全局变量会导致资源的浪费。其次,全局变量会造成程序可读性变差。最后,全局变量会使得程序牵连性变强,牵一发而动全身的情况可能会再次出现。

因此全局变量虽然是个好东西,但也要谨慎使用。

作用域和链接属性

在上一节,我们简单地了解了不同的变量,它能够有不同的作用范围,那么这个范围,就是我们所说的作用域。C语言的编译器一共能够确认4种不同的作用域:代码块作用域 文件作用域 原型作用域 函数作用域

代码块作用域

//Example 04
#include <stdio.h>
int main(void)
{
    int i = 1;
    {
        int i = 2;
        printf("i = %d\n", i);
    }
    {
        printf("i = %d\n", i);
        int i = 3;
        printf("i = %d\n", i);
    }
    printf("i = %d\n", i);
    return 0;
}

运行结果如下:

//Consequence 04
i = 2
i = 1
i = 3
i = 1

我们通常管一个大括号里面的语句叫做代码块,那么看这个程序,一个代码块里面的变量只能作用于所在的代码块里,超出了的就无效了。若存在代码块嵌套,那么优先内层代码块的变量。这就是代码块作用域

当然,有一点需要说说,就是函数的形参也是代码块作用域,只能作用于函数定义代码块里面,即便它没有写在代码块里面。

文件作用域

在任何代码块之外定义的变量,都具有文件作用域。它们的作用域是从变量声明开始,一直到文件尾结束。另外,函数名也是文件作用域,因为函数名本身也在代码块之外。

原型作用域

原型作用域只适合那些在函数原型中声明的参数名。我们知道,在声明一个函数的时候,形参的名字是可以不用写的,只需要把类型写好就行了。但是,其实不妨可以试试写上名字,即便这个名字和正式定义的时候形参的名字不一样,也是没问题的(当然这样做毫无意义),这其实就是原型作用域在起作用。

函数作用域

函数作用域只适用于goto语句的标签,作用是将goto语句的标签限定在一个函数的内部,避免出现重名的标签。

链接属性

简单来说,编译器将源代码转换成机器码的时候需要有两个步骤:编译链接

所谓编译,就是将我们写的源代码转换为机器码,而链接,就是将相关的库文件添加进来。比如我们在写程序的时候加入的头文件,在最终生成的时候就是要链接进来的。

我们知道,大型程序是由很多源文件构成的,那么在不同的文件中的同名标识符,编译器是入场处理的呢?

在C语言中,链接属性一共有以下3种:

  • external(外部的):多个文件中声明的同名标识符表示同一个实体
  • internal(内部的):单个文件中声明的同名标识符表示同一个实体
  • none(无):声明的同名标识符被当作独立不同的实体。比如,函数的局部变量。

默认情况下,具备文件作用域的标识符拥有external属性,也就是说,这种标识符允许跨文件访问。使用static可以使原先拥有external属性的标识符变为internal属性。但是static只能修改具有文件作用域的标识符,并且是不可逆修改。

生存期和存储类型

生存期

上一节我们用空间的角度去解释了不同的变量,但其实,还可以从时间的角度来分析。

C语言的变量通常有两种生存期,静态存储期(static storage duration)和自动存储期(automatic storage duration)。

具有文件作用域的变量具有静态存储期(如全局变量),函数名也有静态存储期。静态存储期的变量在程序执行的期间内将一直占据存储空间。

具有代码块作用域的变量通常具有自动存储期(如局部变量和形参),具有自动存储期的变量将在代码块执行完毕的时候释放内存。

存储类型

变量的存储类型实际上是指存储变量值的内存类型。C语言提供了5种存储类型:auto register static extern typedef

1. 自动变量

在代码块中声明的变量默认就是自动变量(auto)。

//Example 05
#include <stdio.h>
int main(void)
{
    auto int a, b, c;
    return 0;
}

但是由于是默认存储类型,所以auto一般不写也完全没问题。函数中的形参、局部变量以及复合语句中定义的局部变量都具有自动变量。自动变量拥有代码块作用域、自动存储期和空链接属性。

2. 寄存器变量

如果你学过汇编语言,或者对计算机的原理比较了解的话,一定没少听说寄存器这个词。寄存及就集成在CPU内部,因此它和CPU之间的交流几乎可以说没有延迟。

如果你申请了寄存器变量,那么就有可能被存储到寄存器里面去。当然,编译器也有自己的优化方案,它会在程序运行的时候权衡哪些变量更应该被放到寄存器的位置,因此,你的申请只是做一个参考而已。那么那些没有被放入寄存器的变量就会成为自动变量,所以,寄存器变量和自动变量在很多地方是一样的,也拥有代码块作用域、自动存储期和空链接属性。

但是,如果是寄存器变量的话,那么理论上就没法通过**取地址运算符&**来获取地址了,因为我们知道这个是针对内存的。

//Example 06
#include <stdio.h>
int main(void)
{
    register int i = 100;
    printf("addr of i is %p\n", &i);
    return 0;
}

但是VS可能对这种代码有优化,将变量转换成自动变量了:

//Consequence 06 of Visual Studio 2019
addr of i is 00CFFE90

3. 静态局部变量

static用于描述具有文件作用域的变量或者函数时,表示将其链接属性从external修改成internal,它的作用范围就变成了仅当前文件可访问。如果static用于描述局部变量,那可就不太一样了。

默认情况下,局部变量是自动变量,具有自动存储期。如果使用了static来声明,那么就可以将局部变量指定为静态局部变量(static)。这使得局部变量具有静态存储期,所以它的生存期和全局变量一样。但是作用域依旧是局部变量,在别的函数中是无法访问这个局部变量的。

4. extern

extern关键字告诉编译器这个变量或函数在别的地方已经定义过了,先在别的地方找找,不要急着报错。

通常情况下,这个关键词可以不写,但是为了程序更加完善,更加易读。在多人协作的时候,可以避免很多重名的问题。

5. typedef

typedef与其他四个存储类型的语义不同,typedef与内存存储无关,用于为数据类型定义一个新名字。

递归

虽然递归隶属于算法的范畴,但是几乎所有的程序语言教程,都会讲到这个知识点。因为,递归是一个非常好的编程思路,有时候一个很难解决的问题,使用递归就可以巧妙地搞定。

什么是递归

递归说白了,就是函数在执行的时候,调用自身的行为。其实递归在生活中有很多实例,比如:

  1. 汉诺塔游戏

    汉诺塔游戏

    这个游戏要求将中间的柱子的圆盘全部移动到另外一个柱子上,要求每次只能移动一个圆盘,并且较大的圆盘始终在下方。

  2. 谢尔宾斯基三角形

    谢尔宾斯基三角形

    三角形里边填充三角形,只要空间够大,它可以撑满整个宇宙。

甚至还诞生了一门数学分支:分形几何。专门研究这种递归现象。

说了这么多,那么递归在程序里面该如何实现呢?

//Example 07
#include <stdio.h>
void r(void);
void r(void)
{
    printf("Hi!");
    r();
}
int main(void)
{
    r();
    return 0;
}

运行结果如下:

//Consequence 08
...
Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Hi!Segmentation fault

可以看到,满屏幕的Hi!这就是初学者经常会犯的错误,程序无休止地执行下去,直到消耗掉所有的内存。这就像我们讲过的“从前有座山,山里有座庙……”这个故事一样,没有中止的条件,讲多久都讲不完。

修改下代码:

//Example 07 V2
#include <stdio.h>
void r(void);
void r(void)
{
    static int count = 5;//设置计数变量
    printf("Hi!\n");
    if (--count)//设置跳出条件
    {
        r();
    }
}

int main(void)
{
    r();
    return 0;
}

运行结果如下:

//Consequence 07 V2
Hi!
Hi!
Hi!
Hi!
Hi!

这样,递归这头小猛兽,就这样被我们控制住了。

递归求斐波那契数列

斐波那契数列每一项等于前两项之和,正好符合递归的思路:

#include <stdio.h>
int fibo(int n)
{
    if (n <= 2)
    {
        return 1;
    }
    else
    {
        return fibo(n - 1) + fibo(n - 2);
    }
}
int main()
{
    int n;
    printf("请输入斐波那契数列长度:");
    scanf("%d", &n);
    for (int i = 1; i <= n; ++i)
    {
        printf("%d", fibo(i));
        if (i < n)
        {
            printf(", ");
        }
    }
    return 0;
}

程序执行如下:

请输入斐波那契数列长度:10
1, 1, 2, 3, 5, 8, 13, 21, 34, 55

递归求汉诺塔

其实,不论有多少层汉诺塔,都可以使用递归一层一层往下解包。

//Example 08
#include <stdio.h>
void hanoi(int, char, char, char);
void hanoi(int n, char x, char y, char z)
{
    if (n == 1)
    {
        printf("%c --> %c\n", x, z);//剩下底部的圆盘
    }
    else
    {
        hanoi(n - 1, x, z, y);//将n-1个圆盘从x移动到y
        printf("x --> z\n");
        hanoi(n - 1, y, x, z);//将n-1个圆盘从y移动到z
    }
}
int main(void)
{
    int n;
    printf("请输入汉诺塔的层数:");
    scanf("%d", &n);
    hanoi(n, 'X', 'Y', 'Z');
    return 0;
}

运行结果如下:

//Consequence 08
请输入汉诺塔的层数:4
X --> Y
x --> z
Y --> Z
x --> z
Z --> X
x --> z
X --> Y
x --> z
Y --> Z
x --> z
Z --> X
x --> z
X --> Y
x --> z
Y --> Z

分治法

所谓分治法,就是大事化小的思维。递归实际上就是一种分治。层层递归,然后从最简单的问题开始解决。

说到这里,就不得不提到一种排序算法,就是十分经典的——快速排序。

作为20世纪十大算法之一,快速排序的基本思想是:通过一项将待排序数据分割成独立的两部分,其中一部分元素均比另一部分小,然后分别对这两部分继续排序,重复步骤直到完成。

快速排序动画演示

代码如下:

//Example 09
#include <stdio.h>
void qs(int, int, int);
void qs(int array[], int left, int right)
{
    int i = left, j = right;
    int temp;
    int pivot;
    
    //基准点设置为中间元素,当然别的也可以
    pivot = array[(left + right) / 2];
    
    while (i <= j)
    {
        //找到左边大于等于基准点的元素
        while (array[i] < pivot)
        {
            ++i;
        }
        //找到右边小于等于基准点的元素
        while (array[j] > pivot)
        {
            --j;
        }
        //如果左边下标小于右边,则交换元素
        if (i <= j)
        {
            temp = array[i];
            array[i] = array[j];
            array[j] = temp;
            ++i;
            --j;
        }
    }
    
    //递归遍历左子
    if (left < j)
    {
        qs(array, left, j);
    }
    //递归遍历右子
    if (i < right)
    {
        qs(array, i, right);
    }
}

int main(void)
{
    int array[] = {135, 156, 120, 102, 130, 62, 410, 158, 173, 113, 124, 184, 131, 214};
    int length;
    length = sizeof(array) / sizeof(array[0]);
    qs(array, 0, length - 1);
    
    printf("排序后结果为:");
    for (int i = 0; i < length; i++)
    {
        printf("%d", array[i]);
        if (i < length - 1)
        {
            printf(", ");
        }
    }
    putchar('\n');
    return 0;
}

执行结果如下:

//Consequence 09
排序后结果为:62, 102, 113, 120, 124, 130, 131, 135, 156, 158, 173, 184, 214, 410

学了这么多,也该好好休息下了!下期再见!