C语言笔记(四)

三十一、作用域和链接属性

31.1、作用域

上一讲我们是从变量的作用域角度将变量划分为局部变量和全局变量,这是从空间的角度来分析的。我们发现当变量被定义在程序的不同位置时,它的作用范围是不一样的。这个作用范围就是我们所说的作用域。

那么 C 语言编译器可以确认 4 种不同类型的作用域:代码块作用域、文件作用域、原型作用域和函数作用域。

31.1.1、代码块作用域(block scope)

最常见的就是代码块作用域。所谓代码块,就是位于一对花括号之间的所有语句。

31.1.2、文件作用域(file scope)

任何在代码块之外声明的标识符都具有文件作用域,作用范围是从它们的声明位置开始,到文件的结尾处都是可以访问的。另外,函数名也具有文件作用域,因为函数名本身也是在代码块之外。

31.1.3、原型作用域(prototype scope)

原型作用域只适用于那些在函数原型中声明的参数名。我们知道函数在声明的时候可以不写参数的名字(但参数类型是必须要写上的),其实多尝试你还可以发现,函数原型的参数名还可以随便写一个名字,不必与形式参数相匹配(当然,这样做毫无意义)。允许你这么做,只是因为原型作用域起了作用。

31.1.4、函数作用域(function scope)

函数作用域只适用于 goto 语句的标签,作用将 goto 语句的标签限制在同一个函数内部,以及防止出现重名标签。

31.2、定义和声明

当一个变量被定义的时候,编译器为变量申请内存空间并填充一些值。当一个变量被声明的时候,编译器就知道该变量被定义在其他地方。声明通知编译器该变量名及相关的类型已存在,不需要再为此申请内存空间。
局部变量既是定义又是声明。
定义只能来一次,否则就叫做重复定义某个同名变量;而声明可以有很多次。

31.3、链接属性

简单的来说,编译器将你的源文件变成可执行程序需要经过两个步骤:编译和链接。编译过程主要是将你写的源代码生成机器码格式的目标文件,而链接过程则是将相关的库文件添加进来(比如你在源文件中调用了 stdio 库的 printf 函数,那么在这个过程中,就把 printf 函数的代码添加进来),然后整合成一个可执行程序。
在这里插入图片描述
链接属性是个什么东西呢?
我们知道大型的程序都有好些个源文件构成,那么在不同文件中的同名标识符,编译器是如何处理的呢?这就要看链接属性了。
在 C 语言中,链接属性一共有三种:

  • external(外部的)-- 多个文件中声明的同名标识符表示同一个实体
  • internal(内部的)-- 单个文件中声明的同名标识符表示同一个实体
  • none(无)-- 声明的同名标识符被当作独立不同的实体(比如函数的局部变量,因为它们被当作独立不同的实体,所以不同函数间同名的局部变量并不会发生冲突)
    只有具备文件作用域的标识符才能拥有external或internal的链接属性,其他作用域的标识符都是none属性。
    默认情况下,具备文件作用域的标识符拥有 external 属性。也就是说该标识符允许跨文件访问。对于 external 属性的标识符,无论在不同文件中声明多少次,表示的都是同一个实体。

使用 static 关键字可以使得原先拥有 external 属性的标识符变为 internal 属性。这里有两点需要注意:

1、使用 static 关键字修改链接属性,只对具有文件作用域的标识符生效(对于拥有其他作用域的标识符是另一种功能)
2、链接属性只能修改一次,也就是说一旦将标识符的链接属性变为 internal,就无法变回 external 了

三十二、生存期和存储类型

32.1、生存期

C 语言的变量拥有两种生存期,分别是静态存储期(static storage duration)和自动存储期(automatic storage duration)。
具有文件作用域的变量具有静态存储期(比如全局变量),函数名也拥有静态存储期。具有静态存储期的变量在程序执行期间将一直占据存储空间,直到程序关闭才释放;具有代码块作用域的变量一般情况下具有自动存储期(比如局部变量和形式参数),具有自动存储期的变量在代码块结束时将自动释放存储空间。

32.2、存储类型

前边我们分别介绍了 C 语言变量的作用域、链接属性和生存期,总得来说,这些都是由变量的存储类型来定义的。变量的存储类型其实是指存储变量值的内存类型,C 语言提供了 5 种不同的存储类型,分别是:auto、register、static、extern 还有 typedef。

32.2.1、自动变量(auto)

在代码块中声明的变量默认的存储类型就是自动变量,使用关键字 auto 来描述。所以函数中的形参、局部变量,包括复合语句的中定义的局部变量都具有自动变量。自动变量拥有代码块作用域,自动存储期和空连接属性。

32.2.2、寄存器变量(register)

寄存器是存在于 CPU 的内部的,CPU 对寄存器的读取和存储可以说是几乎没有任何延迟。

将一个变量声明为寄存器变量,那么该变量就有可能被存放于CPU的寄存器中。为什么我这里说有可能呢?因为CPU的寄存器空间是十分有限,所以编译器并不会让你将所有声明为register的变量都放到寄存器中。事实上,有可能所有的register关键字都被忽略,因为编译器有自己的一套优化方法,会权衡哪些才是最常用的变量。在编译器看来,它觉得它比你更了解程序。而那些被忽略的register变量,它们会变成普通的自动变量。

所以寄存器变量和自动变量在很多方面的是一样的,它们都拥有代码块作用域,自动存储期和空链接属性。

32.2.3、静态局部变量(static)

static 用于描述具有文件作用域的变量或函数时,表示将其链接属性从 external 修改为 internal,它的作用范围就变成了仅当前源文件可以访问。

但如果将 static 用于描述局部变量,那么效果又会不一样了。默认情况下,局部变量是 auto 的,具有自动存储期的变量。如果使用 static 来声明局部变量,那么就可以将局部变量指定为静态局部变量。static 使得局部变量具有静态存储期,所以它的生存期与全局变量一样,直到程序结束才释放。

32.2.4、static 和 extern

作用于文件作用域的 static 和 extern,我们上一节已经讲过了,static 关键字使得默认具有 external 链接属性的标识符变成 internal 链接属性,而 extern 关键字是用于告诉编译器这个变量或函数在别的地方已经定义过了,先去别的地方找找,不要急着报错。

三十三、递归

33.1、递归的实质

递归从原理上来说就是函数调用自身这么一个行为。

33.2、编写递归程序需要注意的地方

递归程序需要正确设置结束条件,否则递归程序会一直走下去,直到崩溃。

33.3、递归的优势和劣势

优势:递归的思考角度跟通常的迭代(你可以理解为 for 循环之类的)迥然不同,所以有时候使用迭代思维解决不了的问题,使用递归思维则一下子迎刃而解。

劣势:递归的执行效率通常比迭代低很多,所以递归程序要更消耗时间;由于递归函数是不断调用函数本身,在最底层的函数开始返回之前,程序都是一致在消耗栈空间的,所以递归程序要“吃”更多的内存空间;递归的结束条件设置非常重要,因为一旦设置错误,就容易导致程序万劫不复(崩溃)。

三十四、汉诺塔

原始状态下的汉诺塔
在这里插入图片描述
Step1 X->Z:
在这里插入图片描述
Step2 X->Y:
在这里插入图片描述
Step3 Z->Y:
在这里插入图片描述
Step4 X->Z:

在这里插入图片描述
Step5 Y->X:
在这里插入图片描述
Step6 Y->Z:
在这里插入图片描述
Step7 X->Z:
在这里插入图片描述
对于上述游戏的玩法,可以简单分解为三个步骤:

1、将前 63 个盘子从 X 移动到 Y上,确保大盘在小盘下。
2、将最底下的第 64 个盘子从 X 移动到 Z 上。
3、将 Y 上的 63 个盘子移动到 Z 上。

在游戏中,由于每次只能移动一个圆盘,所以在移动的过程中显然要借助另外一根针才可以实施。也就是说,步骤 1 将 1~63 个盘子移到 Y 上,需要借助 Z;步骤 3 将 Y 针上的 63 个盘子移到 Z 针上,需要借助 X。

所以我们把新的思路聚集为以下两个问题:

  • 问题一:如何将 X 上的 63 个盘子借助 Z 移到 Y 上?
  • 问题二:如何将 Y 上的 63 个盘子借助 X 移到 Z 上?

解决这两个问题的方法跟解决“如何将 X 上的 64 个盘子借助 Y 移动到 Z 上?”这个问题是一样的,都是可以拆解成 1、2、3 三个步骤来实现。

问题一(“如何将 X 上的 63 个盘子借助 Z 移到 Y 上?”)拆解为:

1、将前 62 个盘子从 X 移动到Z上,确保大盘在小盘下。
2、将最底下的第 63 个盘子移动到 Y 上。
3、将 Z 上的 62 个盘子移动到 Y 上。

问题二(“如何将 Y 上的 63 个盘子借助 X 移到 Z 上?”)拆解为:
1、将前 62 个盘子从 Y 移动到 X 上,确保大盘在小盘下。
2、将最底下的第 63 个盘子移动到 Z 上。
3、将 X 上的 62 个盘子移动到 Y 上。

没错,汉诺塔的拆解过程刚好满足递归算法的定义,因此,对于如此难题,使用递归来解决,问题就变得相当简单了!

#include <stdio.h>

void hanoi(int n, char x, char y, char z);

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("%c --> %c\n", x, z);
                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;
}

三十五、快速排序

所谓排序就是将一对零零散散的数据重新整理成从大到小或从小到大的序列。
排序的算法有很多,比如大家耳熟能详的冒泡排序、插入排序、希尔排序、选择排序等等。其中名声最大的当属快速排序,你看这名字起得这么嚣张,恐怕效率也不会差到哪里去。没错,它可是二十世纪十大算法之一,是由图灵奖得主东尼·霍尔提出的排序算法。
快速排序算法的基本思想是:通过一趟排序将待排序数据分割成独立的两部分,其中一部分的所有元素均比另一部分的元素小,然后分别对这两部分继续进行排序,重复上述步骤直到排序完成。
在这里插入图片描述

三十六、动态内存管理(上)

36.1、动态内存管理的几个函数

  • malloc – 申请动态内存空间
  • free – 释放动态内存空间
  • calloc – 申请并初始化一系列内存空间
  • realloc – 重新分配内存空间

36.2、内存泄漏

导致内存泄漏主要有两种情况:

  • 隐式内存泄漏(即用完内存块没有及时使用free函数释放)
  • 丢失内存块地址

三十七、动态内存管理(下)

37.1、malloc 可以申请一块任意尺寸的内存空间

malloc 不仅可以申请存储基本数据类型的空间,事实上它还可以申请一块任意尺寸的内存空间。对于后者,由于申请得到的空间是连续的,所以我们经常用数组来进行索引即可:

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
        int *ptr = NULL;
        int num, i;

        printf("请输入待录入整数的个数:");
        scanf("%d", &num);

        ptr = (int *)malloc(num * sizeof(int));

        for (i = 0; i < num; i++)
        {
                printf("请录入第%d个整数:", i+1);
                scanf("%d", &ptr[i]);
        }

        printf("你录入的整数是:");
        for (i = 0; i < num; i++)
        {
                printf("%d ", ptr[i]);
        }

        putchar('\n');
        free(ptr);

        return 0;
}

在这里插入图片描述

37.2、初始化内存空间

由于malloc并不会帮你初始化申请的内存空间,所以你需要自己进行初始化。

当然你可以写一个循环来做这件事儿,但我不建议你这么做,因为标准库提供了更加高效的函数:memset

37.3、calloc

calloc函数在内存中动态地申请 nmemb 个长度为 size 的连续内存空间(即申请的总空间尺寸为 nmemb * size),这些内存空间全部被初始化为 0。

calloc 函数与 malloc 函数的一个重要区别是:

  • calloc 函数在申请完内存后,自动初始化该内存空间为零
  • malloc 函数不进行初始化操作,里边数据是随机的
    calloc 函数详细文档 ->calloc

37.4、realloc

以下几点是需要注意的:

  • realloc函数修改 ptr 指向的内存空间大小为 size 字节
  • 如果新分配的内存空间比原来的大,则旧内存块的数据不会发生改变;如果新的内存空间大小小于旧的内存空间,可能会导致数据丢失,慎用!
  • 该函数将移动内存空间的数据并返回新的指针
  • 如果 ptr 参数为 NULL,那么调用该函数就相当于调用 malloc(size)
  • 如果 size 参数为 0,并且 ptr 参数不为 NULL,那么调用该函数就相当于调用 free(ptr)
  • 除非 ptr 参数为 NULL,否则 ptr 的值必须由先前调用 malloc、calloc 或 realloc 函数返回
    realloc 函数详细文档 ->realloc

三十八、C语言的内存布局

38.1、C 语言的内存布局规律

在这里插入图片描述
可以看到局部变量的地址是占据高地,接着是 malloc 函数申请的动态内存空间,然后是全局变量和静态局部变量。

不过这两者都需要区分是否已经初始化,已经初始化的放一块,未初始化的放一块,并且未初始化的地址要比已经初始化的要更高一些。接着下来是字符串常量,最后是函数的地址。

38.2、典型 C 语言程序的内存空间划分

在这里插入图片描述
根据内存地址从低到高分别划分为:

  • 代码段(Text segment)
  • 数据段(Initialized data segment)
  • BSS段(Bss segment/Uninitialized data segment)
  • 栈(Stack)
  • 堆(Heap)

38.3、代码段(Text segment)

代码段通常是指用来存放程序执行代码的一块内存区域。

这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读。

在代码段中,也有可能包含一些只读的常数变量,例如字符串常量等。

38.4、数据段(Initialized data segment)

数据段通常用来存放已经初始化的全局变量和局部静态变量。

38.5、BSS段(Bss segment/Uninitialized data segment)

BSS 段通常是指用来存放程序中未初始化的全局变量的一块内存区域。

BSS 是英文 Block Started by Symbol 的简称,这个区段中的数据在程序运行前将被自动初始化为数字 0。

38.6、堆

前边我们学习了动态内存管理函数,使用它们申请的内存空间就是分配在这个堆里边。

所以,堆是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩展或缩小。

当进程调用 malloc 等函数分配内存时,新分配的内存就被动态添加到堆上;当利用 free 等函数释放内存时,被释放的内存从堆中被剔除。

38.7、栈

大家平时可能经常听到堆栈这个词,一般指的就是这个栈。

栈是函数执行的内存区域,通常和堆共享同一片区域。

38.8、堆和栈的对比

堆和栈则是 C 语言运行时最重要的元素,下面我们将两者进行对比。

申请方式:

  • 堆由程序员手动申请
  • 栈由系统自动分配

释放方式:

  • 堆由程序员手动释放
  • 栈由系统自动释放

生存周期:

  • 堆的生存周期由动态申请到程序员主动释放为止,不同函数之间均可自由访问
  • 栈的生存周期由函数调用开始到函数返回时结束,函数之间的局部变量不能互相访问

发展方向:

  • 堆和其它区段一样,都是从低地址向高地址发展
  • 栈则相反,是由高地址向低地址发展

三十九、高级宏定义

39.1、宏定义的实质

再牛逼的宏定义,说到底也是机械替换。

39.2、C 语言三大预处理功能

宏定义;文件包含;条件编译。

39.3、不带参数的宏定义

宏定义分为带参数和不带参数两种情况,不带参数的情况就是我们熟悉的直接替换操作。

#define PI 3.14

这个宏定义的作用是把程序中出现的 PI 在预处理阶段全部替换成 3.14。

  • 为了和普通的变量进行区分,宏的名字通常我们约定是全部由大写字母组成
  • 宏定义只是简单地进行替换,并且由于预处理是在编译之前进行,而编译工作的任务之一就是语法检查,所以编译器不会对宏定义进行语法检查
  • 宏定义不是说明或语句,在末尾不必加分号
  • 宏定义的作用域是从定义的位置开始到整个程序结束
  • 可以用#undef来终止宏定义的作用域
  • 宏定义允许嵌套

39.4、带参数的宏定义

C 语言允许宏定义带有参数,在宏定义中的参数称为形式参数,在宏调用中的参数称为实际参数,这点和函数有些类似。

#define MAX(x, y) (((x) > (y)) ? (x) : (y))

这个宏定义的作用是求出 x 和 y 两个参数中比较大的那一个。

宏定义最好带括号,因为宏定义只是机械的替换,所以不带括号可能导致一些错误。
在这里插入图片描述
例如:
int x=5
SQUARE(x+1)->x+1x+1->5+15+1=11

四十、内联函数和一些鲜为人知的技巧

40.1、内联函数

在这里插入图片描述
内联函数会直接在调用处展开。
普通函数调用是酱紫的:
在这里插入图片描述
内联函数调用是酱紫的:
在这里插入图片描述
不过内联函数也不是万能的,内联函数虽然节省了函数调用的时间消耗,但由于每一个函数出现的地方都要进行替换,因此增加了代码编译的时间。

另外,并不是所有的函数都能够变成内联函数。现在的编译器也很聪明,就算你不写 inline,它也会自动将一些函数优化成内联函数。

40.2、# 和

#和 ## 是两个预处理运算符。

在带参数的宏定义中,# 运算符后面应该跟一个参数,预处理器会把这个参数转换为一个字符串。

##运算符被称为记号连接运算符,可以使用它来连接多个参数。

40.3、可变参数

之前我们学习了如何让函数支持可变参数,带参数的宏定义也是使用可变参数的:

#define SHOWLIST() printf(#__VA_ARGS__)

其中 … 表示使用可变参数,VA_ARGS 在预处理中被实际的参数集所替换。

#include <stdio.h>

#define SHOWLIST(...) printf(# __VA_ARGS__)

int main(void)
{
        SHOWLIST(FishC, 520, 3.14\n);

        return 0;
}

在这里插入图片描述
可变参数是允许空参数的(如果可变参数是空参数,## 会将 format 后面的逗号“吃掉”,从而避免参数数量不一致的错误):

#include <stdio.h>

#define PRINT(format, ...) printf(#format, ##__VA_ARGS__)

int main(void)
{
        PRINT(num = %d\n, 520);
        PRINT(Hello FishC!\n);

        return 0;
}

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值