【C 语言】C 语言 函数 详解 ( 函数本质 | 顺序点 | 可变参数 | 函数调用 | 函数活动记录 | 函数设计 ) [ C语言核心概念 ]

相关文章链接 :
1.【嵌入式开发】C语言 指针数组 多维数组
2.【嵌入式开发】C语言 命令行参数 函数指针 gdb调试
3.【嵌入式开发】C语言 结构体相关 的 函数 指针 数组
4.【嵌入式开发】gcc 学习笔记(一) - 编译C程序 及 编译过程
5.【C语言】 C 语言 关键字分析 ( 属性关键字 | 常量关键字 | 结构体关键字 | 联合体关键字 | 枚举关键字 | 命名关键字 | 杂项关键字)
6.【C 语言】编译过程 分析 ( 预处理 | 编译 | 汇编 | 链接 | 宏定义 | 条件编译 | 编译器指示字 )
7.【C 语言】指针 与 数组 ( 指针 | 数组 | 指针运算 | 数组访问方式 | 字符串 | 指针数组 | 数组指针 | 多维数组 | 多维指针 | 数组参数 | 函数指针 | 复杂指针解读)








一. 函数本质




1. 函数意义


(1) 函数来源


C 程序结构 由 数据 和 函数 组成;

函数是由汇编跳转发展而来的 :

  • 1.汇编操作 : 汇编语言中由一系列的指令组成, 这些指令从上到下顺序执行,
  • 2.跳转操作 : 汇编中需要做分支循环操作的时候, 就是使用跳转指令;
  • 3.指令代码模块 : 在汇编中有一组指令代码, 总是需要执行这一组代码, 需要时跳转到该代码处执行, 执行完毕后在跳转回去, 这就是一个函数的雏形;
  • 4.发展 : 跳转过来 和 跳转回去 相当于函数的 入栈 和 出栈;


扫描二维码关注公众号,回复: 22289 查看本文章

(2) 模块化程序设计


模块化程序设计 :

  • 1.思想 : 复杂问题拆解, 将一个复杂问题拆解成一个个的简单问题, 这些简单问题就可以作为一个个的函数来编写;
  • 2.C语言程序 : 将一个复杂的程序拆解成一个个模块 和 库函数;

一个复杂的 C 语言程序有几十上百万行代码, 这些代码可以分解成若干模块来实现, 即分解成一个个的函数来实现.





2. 面向过程的程序设计


(1) 程序结构


面向过程程序设计思想 :

  • 1.中心 : 整体的设计 以 过程 为中心;
  • 2.问题分解 : 将复杂问题分解为若干容易解决的问题;
  • 3.函数体现 : 面向过程 的思想在 C 语言 中的核心就是 函数;
  • 4.分解函数 : 复杂问题 分解后的过程可以分为一个个函数一步步实现;




3. 函数的声明和定义


(1) 声明 和 定义 的区别


声明和定义的区别 :

  • 1.声明 : 程序中 声明 只是告诉编译器 某个 实体 存在, 这个实体可以是 变量 或者 函数 等;
  • 2.定义 : 程序中定义 指的就是 某个实体 ( 函数 或 变量 ) 的实际意义;

在 test_1.c 中定义变量 int i = 10; 这是定义了 int 类型的变量, 需要为该变量分配内存空间;
在 test_2.c 中声明变量 extern int i; 这是声明了 int 类型的变量, 变量定义在了别的文件中, 不必为该变量分配内存空间;



(2) 代码示例 ( 函数 声明 和 定义区别 )


代码示例 :

  • 1.代码 test_1.c :
#include <stdio.h>

//声明 : 声明外部变量, 该值是在其它文件中定义的
extern int global_int;

//声明 : 声明函数 plus, 该函数定义在下面
int plus(int i, int j);

int main()
{
    //声明 : 声明函数 square, 如果不声明编译时会报错, 该声明只在 main 函数中有效果, 在main函数之外使用该方法就会报错
    int square(int i);

    //使用函数 square, 如果没有声明, 编译会报错
    global_int = 3;
    printf("%d\n", square(global_int));

    //使用函数 plus, 如果没有声明编译会报错
    printf("%d\n", plus(1, 2));

    return 0;
}

//定义 : 定义函数 plus 
int plus(int i, int j)
{
    return i + j;
}

//定义 : 定义函数 square
int square(int i)
{
    return i * i;
}
  • 2.代码test_2.c :
//定义 : 定义变量, 在这里需要为变量分配内存空间
int global_int;
  • 3.编译运行结果 :
    这里写图片描述






二. 参数 可变参数 顺序点 类型缺省认定




1. 函数参数


(1) 参数分析


函数参数分析 :

  • 1.本质 : 函数参数的本质 与 局部变量 基本相同, 这两种数据都存放在栈空间中 ( 中间隔着 返回地址 寄存器 EBP 数据 ) 详情参考上一篇博客内存管理 ;
  • 2.参数值 : 函数调用的 初始值 是 函数调用时的实参值 ;

函数参数的求值顺序 (盲点) :

  • 1.实现 : 函数参数的求值顺序 依赖 编译器的实现;
  • 2.操作数顺序没有在规范中 : C 语言规范中没有规定函数参数必须从左到右进行计算赋值;
  • 3.运算符编程注意点 : C语言中大多数的运算符的操作数求值顺序也是不固定的, 依赖于编译器的实现;
  • 4.示例 : 如 int ret = fun1() * fun2(); fun1 和 fun2 函数哪个先执行, 哪个后执行 不一定;

编程时尽量不要编写的代码依赖于操作数的实现顺序;



(2) 代码示例 ( 函数参数 求值顺序 )


代码示例 :

  • 1.代码 :
#include <stdio.h>

int fun(int i, int j)
{
    printf("%d, %d\n", i, j);
}

int main()
{
    int m = 1;

    fun(m, m ++);
    printf("%d\n", i);

    /*
        打印出来的结果是 2, 1 \n 2
        分析 : 函数的参数的求值顺序 不是 从左到右的, 是不固定的
               这个顺序是编译器制定的, 不同编译器该顺序不同
    */

    return 0;
}
  • 2.编译运行结果 :
    这里写图片描述

分析 :
函数参数计算说明 : fun(m, m ++); 进入函数体之前先计算 m 和 m++ 的值, m 和 m++ 是实参, 在计算完成之后才赋值给 i 和 j 形参;
顺序点 : 在进入函数体前是一个顺序点, 需要将计算完毕的实参 赋值给形参;
实参 m 赋值 : 赋值给 形参 i, 此处已经到达顺序点, m 自增操作已经反映到内存中, 因此 从 内存中获取的 i 的值是 2;
实参 m++ 赋值 : 赋值给 形参 j, m++ 表达式的计算结果是 1, 因此 j 的值是1;





2. 程序中的顺序点


(1) 顺序点简介


顺序点介绍 :

  • 1.顺序点位置 : 顺序点存在于程序之中;
  • 2.顺序点定义 : 顺序点是 代码 执行过程中, 修改变量值 的 最晚时刻 ;
  • 3.顺序点操作 : 程序运行到顺序点时, 之前的代码操作 都要反映到后续访问中 ;

顺序点列举 :

  • 1.表达式结束 : 每个表达式结束都是顺序点, 以分号 “;” 结尾, 每个分号的位置都是顺序点;
  • 2.某些表达式的运算对象计算 : &&, || (逻辑运算), ? :(三目运算符), 逗号 表达式 中每个 运算对象计算后 是顺序点;
  • 3.函数运行前 : 函数调用并且在执行函数体之前, 所有实际参数求值完之后是一个顺序点, 如参数是表达式, 需要将表达式计算出结果;

顺序点代码示例 :

#include <stdio.h>

int fun(int i, int j)
{
    printf("%d, %d\n", i, j);
}

//注意 : 这个知识点可能过时, k = k++ + k++; 在 Ubuntu 中执行结果是 5

int main()
{
    //顺序点 : 在 k = 2; 表达式以分号结束, 这是一个顺序点
    int k = 2;
    int a = 1;

    /*
        顺序点 : 分号结尾处是顺序点, 该顺序点

        第 1 个 k++, 计算时 k 先是 2, 自增操作到顺序点时执行; 
        第 2 个 k++, 计算时 k 还是 2, 自增操作到顺序点时执行;
        加法计算完毕后 k 变成 4, 两次自增后变为 6
    */
    k = k++ + k++;

    printf("k = %d\n", k);

    /*
        a-- && a 进行逻辑运算, 
            其中 && 是顺序点, a-- 在 && 时执行 自减操作, 
            然后 a-- 结果变成了 0, a 的值也变成了 0, 进行逻辑与操作结果为 0 
    */
    printf("a--&&a = %d\n",a--&&a);


    return 0;
}




3. C 语言 函数 的 缺省认定


(n) 标题3


函数缺省认定简介 :

  • 1.描述 : C 语言中 默认 没有类型的 参数 和 返回值 为 int 类型;
  • 2.举例 :
fun(i)
{
    return i
}

等价于

int fun(int i)
{
    return i;
}
  • 3.代码示例 :
#include <stdio.h>

//函数缺省认定 : 没有类型的 参数 和 返回值 为 int 类型
fun(i, j)
{
    return i + j;
}

int main()
{
    printf("fun(i, j) = %d\n",fun(3, 3));

    return 0;
}

这里写图片描述





4.可变参数 的 定义 和 使用


(1) 简介


可变参数简介 :

  • 1.描述 : 函数可以接收的参数个数是不定的, 根据调用的需求决定有几个参数;
  • 2.依赖头文件 : 如果要使用可变参数, 需要导入 stdarg.h 头文件;
  • 3.核心用法 : va_list, va_start, va_end, va_arg 配合使用, 访问可变参数值;

可变参数示例 :

  • 1.函数名相同, 参数个数不同 : open 函数, 有两种用法, 一个有 2 个参数 int open(const char *pathname, int flags) , 一个有三个参数 int open(const char *pathname, int flags, mode_t mode) , C 语言中明显没有重载, 这里是用可变参数来实现的 ; 使用 man 2 open 命令查看 open 函数的文档;
    这里写图片描述

可变参数的注意点 :

  • 1.取值必须顺序进行 : 读取可变参数的值时, 必须从头到尾按照前后顺序读取, 不能跳过任何一个参数;
  • 2.必须确定1个参数 : 参数列表中必须有一个命名确定的参数;
  • 3.可变参数数量无法确定 : 使用 va_arg 获取 va_list 中的值时, 无法判断实际有多少个参数;
  • 4.可变参数类型无法确定 : 使用 va_arg 获取 va_list 中的值时, 无法判断某个参数是什么类型的;

依次读取可变参数时, 注意 可变参数 的 数量 和 类型, 每个位置的参数 是 什么类型, 一定不要读取错误, 否则会产生不可预测的后果;



(2) 代码示例 ( 定义 使用 可变参数 )


代码示例 :

  • 1.代码 :
#include <stdio.h>
#include <stdarg.h>

/*
    定义可变参数 : 
        ① 列出第一个参数 int a
        ② 使用 ... 表明后面有 个数不定 并且 类型不定 的 参数
*/
double avg(int arg_count, ...)
{
    va_list args;
    int i = 0;      //循环控制变量
    double sum = 0; //统计参数之和

    //初始化列表, 让列表准备取值
    va_start(args, arg_count);

    for(i = 0; i < arg_count; i ++)
    {
        //从可变参数列表中获取数据, 数据类型是 int 类型
        sum = sum + va_arg(args, int);
    }

    //结束使用可变参数列表
    va_end(args);


    return sum / arg_count;
}

int main()
{
    //使用定义了可变参数的函数, 传入 11 个参数
    printf("%f\n", avg(10, 1, 2, 3, 4, 5 , 6, 7, 8, 9, 10));
    //使用定义了可变参数的函数, 传入 4 个参数
    printf("%f\n", avg(3, 444, 555, 666));

    return 0;
}
  • 2.编译运行结果 :
    这里写图片描述






三. 函数 与 宏




1. 函数 与 宏 对比案例


(1) 函数 和 宏 的案例


代码示例 : 分别使用 函数 和 宏 将数组数据清零;

  • 1.代码 :
#include <stdio.h>

/*
    定义宏 : 这个宏的作用是将 p 目前是 void* 类型, 转为 char* 类型, 
                将后将每个字节的内容都设置为 0
*/
#define RESET(p, len) while(len > 0) ((char*)p)[--len] = 0;

/*
    定义函数 : 也是将 p 指向的 len 字节的内存置空
*/
void reset(void* p, int len)
{
    while(len > 0)
    {
        ((char*)p)[--len] = 0;
    }
}

int main()
{
    //1. 定义两个数组, 用函数 和 宏 不同的方式重置数据
    int array1[] = {1, 2, 3};
    int array2[] = {4, 5, 6, 7};

    //2. 获取两个数组大小
    int len1 = sizeof(array1);
    int len2 = sizeof(array2);

    //3. 定义循环控制变量
    int i = 0;

    //4. 打印两个数组处理前的数据
    printf("打印array1 : \n");
    for( i = 0; i < 3; i ++)
    {
        printf("array1[%d] = %d \n", i, array1[i]);
    }

    printf("打印array2 : \n");
    for( i = 0; i < 4; i ++)
    {
        printf("array2[%d] = %d \n", i, array2[i]);
    }

    //5. 使用宏的方式处理数组1
    RESET(array1, len1);

    //6. 使用函数的方式处理数组2
    reset(array2, len2);

    //7. 打印处理后的数组
    printf("打印处理后的array1 : \n");
    for( i = 0; i < 3; i ++)
    {
        printf("array1[%d] = %d \n", i, array1[i]);
    }

    printf("打印处理后的array2 : \n");
    for( i = 0; i < 4; i ++)
    {
        printf("array2[%d] = %d \n", i, array2[i]);
    }


    return 0;
}
  • 2.编译运行结果 :
    这里写图片描述

虽然看起来 函数 和 宏实现了相同的功能, 但是它们有很大的区别;





2. 函数 和 宏 的分析


(1) 函数 和 宏 分析


函数 和 宏 分析 :

  • 1.宏处理 : 宏定义是在预处理阶段直接进行宏替换, 代码直接复制到宏调用的位置, 由于宏在预处理阶段就被处理了, 编译器是不知道宏的存在的;
  • 2.函数处理 : 函数是需要编译器进行编译的, 编译器有决定函数调用行为的义务;
  • 3.宏的弊端 ( 代码量 ) : 每调用一次宏, 在预处理阶段都要进行一次宏替换, 会造成代码量的增加;
  • 4.函数优势 ( 代码量 ) : 函数执行是通过跳转来实现的, 代码量不会增加;
  • 5.宏的优势 ( 效率 ) : 宏 的执行效率 高于 函数, 宏定义是在预编译阶段直接进行代码替换, 没有调用开销;
  • 6.函数的弊端 ( 效率 ) : 函数执行的时候需要跳转, 以及创建对应的活动记录( 栈 ), 效率要低于宏;




3. 函数 与 宏 的 利弊


(1) 宏 优势 和 弊端


宏的优势和弊端 : 宏的执行效率要高于函数, 但是使用宏会有很大的副作用, 非常容易出错, 下面的例子说明这种弊端;

代码示例 :

  • 1.代码 :
#include <stdio.h>

#define ADD(a, b) a + b
#define MUL(a, b) a * b
#define _MIN_(a, b) ((a) < (b) ? (a) : b)

int main()
{
    int a = 1, b = 10;

    //宏替换的结果是 : 2 + 3 * 4 + 5, 最终打印结果是 19
    printf("%d\n", MUL(ADD(2, 3), ADD(4, 5)));

    //宏替换的结果是 ((a++) < (b) ? (a++) : b), 打印结果是 2
    printf("%d\n", _MIN_(a++, b));


    return 0;
}
  • 2.编译运行结果 :
    这里写图片描述
  • 3.查看预编译文件 : 使用 gcc -E test_1.c -o test_1.i 指令, 将预编译文件输出到 test_1.i 目录中; 下面是预编译文件的一部分 ;
int main()
{
 int a = 1, b = 10;

 printf("%d\n", 2 + 3 * 4 + 5);
 printf("%d\n", ((a++) < (b) ? (a++) : b));


 return 0;
}


(2) 函数 的 优势 和 弊端


函数的优缺点 :

  • 1.函数优势 : 函数调用需要将实参传递给形参, 没有宏替换这样的副作用;
  • 2.弊端 ( 效率低 ) : 函数执行需要跳转, 同时也需要建立活动对象对象 ( 如 函数栈 ) 来存储相关的信息, 需要牺牲一些性能;


(3) 宏的无可替代性


宏 定义 优势 :

  • 1.宏参数不限定类型 : 宏参数 可以是 任何 C 语言 的实体类型, 如 int, float, char, double 等;
  • 2.宏参数可以使类型名称 : 类型的名称也可以作为宏的参数;
//宏定义 : 实现分配 n 个 type 类型空间, 并返回 type 类型指针
#define MALLOC(type, n) (type*)malloc(n * sizeof(type))

//分配 5 个 float 大小的动态空间, 并将首地址存放在 指针 p 中;
float *p = MALLOC(int, 5);




4. 总结


(1) 宏 定义 和 函数 总结


宏定义 和 函数 小结 :

  • 1.宏定义 : 宏 的 参数 可以 是 C 语言中 的 任何类型的 ( 优势 ) , 宏的执行效率 高 ( 优势 ), 但是容易出错 ( 弊端 );
  • 2.函数 : 函数 参数 的 类型是固定的, 其 执行效率低于宏, 但是不容易出错;
  • 3.宏定义 和 函数之间的关系 : 这两者不是竞争对手, 宏定义可以实现一些函数无法实现的功能;






四. 函数的调用约定




1. 函数的活动记录 分析


(1) 函数的活动记录


活动记录概述 : 函数调用时 将 下面一系列的信息 记录在 活动记录中 ;

  • 1.临时变量域 : 存放一些运算的临时变量的值, 如自增运算, 在到顺序点之前的数值是存在临时变量域中的;

    后置操作 自增 原理 : i++ 自增运算 进行的操作 :
    ( 1 ) 生成临时变量 : 在内存中生成临时变量 tmp ;
    ( 2 ) 临时变量赋值 : 将 i 的值赋值给临时变量, tmp = i ;
    ( 3 ) 进行加 1 操作 : 将 i + 1 并赋值给 i;

    示例 : 定义函数 fun(int a, int b), 传入 fun(i, i++), 传入后 获取的实参值分别是 2 和 1;
    在函数传入参数达到顺序点之后开始取值, 函数到达顺序点之后, 上面的三个步骤就执行完毕, 形参 a 从内存中取值, i 的值是2, 形参 b 从临时变量域中取值, 即 tmp 的值, 取值是 1;

  • 2.局部变量域 : 用于存放 函数 中定义 的局部变量, 该变量的生命周期是局部变量执行完毕;

  • 3.机器状态域 : 保存 函数调用 之前 机器状态 相关信息, 包括 寄存器值 和 返回地址, 如 esp 指针, ebp 指针;
  • 4.实参数域 : 保存 函数的实参信息 ;
  • 5.返回值域 : 存放 函数的返回值 ;




2. 函数的调用约定概述


(1) 参数入栈 问题描述


参数入栈问题 : 函数参数的计算次序是不固定的, 严重依赖于编译器的实现, 编译器中函数参数入栈次序;

  • 1.参数传递顺序 : 函数的参数 实参传递给形参 是从左到右传递 还是 从右到左传递;
  • 2.堆栈清理 : 是函数的调用者清理 还是 由 函数本身清理 ;

这里写图片描述


参数入栈 栈维护 问题示例 :

  • 1.多参数函数定义 : 定义一个函数 fun(int a, int b, int c) , 其中有 3 个参数;
  • 2.函数调用 : 当发生函数调用时 fun(1, 2, 3), 传入三个 int 类型的参数, 这三个参数肯定有一个传递顺序, 这个传递顺序可以约定;
    • ( 1 ) 从左向右入栈 : 将 1, 2, 3 依次 传入 函数中 ;
    • ( 2 ) 从右向左入栈 : 将 3, 2, 1 依次 传入 函数中 ;
  • 3.栈维护 : 在 fun1() 函数中 调用 fun2() 函数, 会创建 fun2() 函数的 活动记录 (栈), 当 fun2() 函数执行完毕 返回的时候, 该 fun2 函数的栈空间是由谁 ( fun1 或 fun2 函数 ) 负责释放的;

函数参数计算次序依赖于编辑器实现, 函数参数入栈的顺序可以自己设置;



(2) 参数传递顺序的调用约定


函数参数调用约定 :

  • 1.函数调用行为 : 函数调用时 参数 传递给 被调用的 函数, 返回值被返回给 调用函数 ;
  • 2.调用约定作用 : 调用约定 是 用来规定 ① 参数 是通过什么方式 传递到 栈空间 ( 活动记录 ) 中, ② 栈 由谁来 清理 ;
  • 3.参数传递顺序 ( 右到左 ) : 从右到左入栈使用 __stdcall, __cdecl, __thiscall 关键字, 放在 函数返回值之前;
  • 4.参数传递顺序 ( 左到右 ) : 从左到右入栈使用 __pascal, __fastcall 关键字, 放在 函数返回值之前;
  • 5.调用堆栈的清理工作 : ① 调用者负责清理调用堆栈; ② 被调用的函数返回之前清理堆栈;






五. 函数设计技巧





函数设计技巧 :

  • 1.避免使用全局变量 : 在函数中尽量避免使用全局变量, 让函数形成一个独立功能模块;
  • 2.参数传递全局变量 : 如果必须使用到全局变量, 那么多设计一个参数, 用于传入全局变量;
  • 3.参数名称可读性 : 尽量不要使用无意义的字符串作为参数变量名;
  • 4.参数常量 : 如果参数是一个指针, 该指针仅用于输入作用, 尽量使用 const 修饰该指针参数, 防止该指针在函数体内被修改;
//这里第二个参数仅用于输入, 不需要修改该指针, 那么就将该参数设置成常量参数
void fun(char *dst, const char* src); 
  • 5.返回类型不能省略 : 函数的返回类型不能省略, 如果省略了返回值, 那么返回值默认 int;
  • 6.参数检测 : 在函数开始位置, 需要检测函数参数的合法性, 避免不必要的错误, 尤其是指针类型的参数;
  • 7.栈内存指针 : 返回值 绝对不能是 局部变量指针, 即 指针指向的位置是 栈内存位置, 栈内存在返回时会销毁, 不能再函数运行结束后使用 ;
  • 8.代码量 : 函数的代码量尽量控制在一定数目, 50 ~ 80 行, 符合模块化设计规则;
  • 9.输入输出固定 : 函数在输入相同的参数, 其输出也要相同, 尽量不要在函数体内使用 static 局部变量, 这样函数带记忆功能, 增加函数的复杂度;
  • 10.参数控制 : 编写函数的时候, 函数的参数尽量控制在 4 个以内, 方便使用;
  • 11.函数返回值设计 : 有时候函数不需要返回值, 或者返回值使用指针参数设置, 但是为了增加灵活性, 可以附加返回值; 如 支持 链式表达式 功能;

猜你喜欢

转载自blog.csdn.net/han1202012/article/details/79902593