Skip to content

🔥更新:2024-12-09📝字数: 0 字⏱时长: 0 分钟

第一章:函数的基本概念

1.1 为什么需要函数?

  • 我们小时候,可能玩过类似《街霸》《拳皇》这样的游戏,如下所示:
  • 在这些游戏中,假设角色的出拳出脚跳跃等动作都需要编写 50 - 80 行的代码。

提醒

  • ① 或许,你会感觉每种角色出拳出脚跳跃等动作是不一样的,其背后的代码逻辑也应该是不一样的。
  • ② 其实,出拳出脚跳跃等动作只是 UI 方面的效果,这些动作背后对应的代码逻辑都是一样的(无非就是增减血量、增减蓝而已),不同的只是接收的参数!!!
  • 如果我们在角色每次出拳出脚跳跃的地方都重复的编写这 50 - 80 的代码,这样程序就会编写很臃肿可读性也非常,如下所示:
  • 为了解决代码重复编写的问题,我们可以将角色出拳出脚跳跃等动作的代码提取出来,放到一个 {}(代码块) 中,并为这个代码块起个名字。这样就可以在角色进行出拳出脚跳跃等动作的地方,通过这个名字来调用这个 {} 中的代码,如下所示:
  • 没有使用函数使用函数的对比,如下所示:

重要

  • ① 提取出来的代码可以看做是程序中定义的一个函数,程序在需要出拳出脚跳跃等地方调用该函数即可。
  • ② 函数只有调用的时候才会执行其内部的逻辑,不调用不执行!!!
  • ③ 将特定功能的代码封装为函数的好处: 实现代码重用减少冗余简化代码

1.2 什么是函数?

  • 函数定义
    • 函数是一种可以重复使用代码块,用于执行特定的任务或操作。
    • 函数允许我们将代码逻辑组织成独立单元,从而提高了代码的可读性可维护性以及重用性
  • 在 C 语言中,一个 C 程序可以由一个或多个源文件构成(C 文件扩展名是“.c”),一个源文件是一个编译单位。一个源文件可以由若干个函数构成,函数之间可以相互调用。也就是说,函数是 C 程序基本的组成单位,如下所示:

1.3 函数的作用

  • 封装功能:将一个完整的功能封装成函数,提高代码的结构化和复用性。

  • 代码模块化:将程序按照功能拆分成若干模块单元,有助于降低复杂度。

  • 增强可维护性:如果需要修改某项功能,只需要调整对应的函数代码。

  • 隔离细节:通过函数调用可以隐藏实现细节,只关心输入输出。

1.4 函数的分类

1.4.1 概述

  • 不同的角度看函数,有不同的分类方法。

1.4.2 从程序执行角度看

  • 从程序的执行角度,函数可以分为主函数子函数
    • 主函数:main() 函数。
    • 子函数:非 main() 函数。

提醒

  • ① 每个 C 语言应用有且仅有一个 main() 函数。
  • ② 无论主函数写在什么位置,C 程序总是从 main() 函数开始执行。
  • ③ main() 函数可以调用其它的子函数,子函数之间可以相互调用任意多次。

1.4.3 是否允许函数被外部文件调用角度看

  • 从函数是否可以被外部文件的函数调用角度看,函数可以分为内部函数外部函数
    • 内部函数:只能被当前源文件中的其他函数调用。
    • 外部函数:不仅可以被当前源文件中的其他函数调用,还可以被其他源文件中的函数调用。

1.4.4 从用户使用角度看

  • 用户使用角度看,函数可以分为库函数(标准函数)自定义函数
    • 库函数(标准函数):它是由 C 系统提供的,用户不必自己定义,可直接使用它们。使用库函数,必须包含 #include 对应的头文件。
    • 自定义函数:解决具体需求而自己定义的函数,需先定义再使用。

提醒

  • ① C 语言标准仅仅是一个规范文件而言,不同的 C 语言编译系统,提供的库函数的数量和功能可能不一样。但是,基本的库函数是相同的。

    点我查看 常见的 C 语言标准库函数
    • 字符串操作函数。
    • 字符操作函数。
    • 时间/日期函数。
    • 数学函数。
    • IO函数。
    • 内存操作函数。
    • 其它库函数。
  • ② 正是由于上述的特点,导致程序员在编写跨平台程序的时候,感觉非常麻烦。也倒逼了程序员开发了跨平台的工具,如:CMake 等。

第二章:函数的基本语法(⭐)

2.1 函数定义语法

  • 语法:
c
返回值类型 函数名(数据类型1 参数1,数据类型2 参数2,...){
    // 函数体
    return 返回值; 
}
  • 示例:
c
#include <stdio.h>

/**
 * 定义一个函数,计算两个整数的和,并返回
 * @param a 整数
 * @param b 整数
 * @return
 */
int add(int a, int b) {
    return a + b;
}

int main() {

    return 0;
}
  • 示例:
c
#include <stdio.h>

/**
 * 定义一个函数,获取两个整数的最大值,并返回
 * @param a 整数
 * @param b 整数
 * @return 最大值
 */
int max(int a, int b) {
    return a > b ? a : b;
}

int main() {

    return 0;
}
  • 示例:
c
#include <stdio.h>

/**
 * 打印两个整数的最大值
 * @param a 整数
 * @param b 整数
 */
void printMax(int a, int b) {
    int max = a > b ? a : b;
    printf("max = %d\n", max);
}

int main() {

    return 0;
}

2.2 定义函数语法的组成部分

2.2.1 概述

  • 函数的定义语法分为:返回值类型函数名参数列表函数体return 语句
c
返回值类型 函数名(数据类型1 参数1,数据类型2 参数2,...){
    // 函数体
    return 返回值; 
}
  • 其对应的图示,如下所示:

2.2.2 函数名

  • 函数名就是函数被调用时使用的名字,函数名就是标识符,需要遵循标识符的命名规则,并尽量做到见名知意,以增强程序的可读性。
  • 在 C 语言中,函数名是函数的唯一标识,同一个文件中不允许两个同名的函数定义。

2.2.3 函数体

  • 函数体要写在大括号 {} 里面,是函数被调用后要执行的代码。
  • 对于调用者来说,如果不了解函数体是如何实现的,也并不影响函数的使用。

2.2.4 返回值类型

  • 函数调用后,是否需要在主函数(main 函数)中得到一个确定的、返回的值。针对这个返回值的描述,就是返回值类型

提醒

  • ① 返回值常常是一个计算的结果,或是用来作为判断函数执行状态的标记。
  • ② 函数按照是否有返回值来进行划分,函数可以分为无返回值的类型的函数有返回值类型的函数
点我查看 无返回值的类型的函数有返回值类型的函数
  • 无返回值的类型
    • 针对函数无返回值或明确不需返回值的情况,使用 void 表示。
    • 例如:输出函数 void printf(const char *format, ...)
  • 有返回值的类型
    • 指明具体的类型,如:int、float、char 等。如果省略,默认为 int 类型。
    • 例如:int rand(),调用后返回一个随机整数。

注意

  • ① 如果返回值类型不是 void,并且函数中没有 return 语句,则函数会返回一个不确定的值。但从 C99 标准开始,这种做法已不再被支持。
  • ② C 语言不允许函数返回数组类型,是因为 C 语言设计时没有提供直接按值传递整个数组的机制(主要是出于效率和历史设计原因),而是采用指针来间接操作数组内容。
  • 示例:无返回值类型的函数
c
#include <stdio.h>

/**
 * 定义一个没有返回值的函数
 * 其返回值类型是 void
 */
void printHelloWorld() {
    printf("Hello World");
}

int main() {

    // 禁用 stdout 缓冲区
    setbuf(stdout, nullptr);

    // 调用无返回值的函数
    printHelloWorld();

    return 0;
}
  • 示例:有返回值类型的函数
c
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <unistd.h>

/**
 * 生成指定范围的随机数的函数
 * @param min
 * @param max
 * @return
 */
int randomInRange(int min, int max) {
    return rand() % (max - min + 1) + min;
}

int main() {

    // 禁用 stdout 缓冲区
    setbuf(stdout, nullptr);

    unsigned int seed = time(nullptr) ^ getpid(); // 使用时间和进程ID的组合

    srand(seed);

    // 定义范围
    int min = 1;
    int max = 100;

    // 生成并打印随机数
    for (int i = 0; i < 10; ++i) {`
        int random = randomInRange(min, max);
        printf("%d \n", random);
    }

    return 0;
}

2.2.5 参数列表

  • 在定义函数时,函数名后面括号()中声明的变量称为形式参数,简称形参

提醒

根据是否有参数来进行划分,函数可以分为无参函数有参函数

点我查看 无参函数有参函数
  • 无参函数
    • 在调用无参函数的时候,调用方不需要向被调用函数传递任何数据。
    • 例如:立即终止函数 abort(),就不需要传递任何数据。
  • 有参函数
    • 在调用有参函数的时候,调用方需要向被调用函数传递数据,并且传递的数据称为实参(实际参数)。
    • 例如:字符串比较函数 strcmp(const char *str1, const char *str2) 就需要传递两个字符串。
  • 示例:无参函数
c
#include <stdio.h>

/**
 * 定义无参函数
 */
void printGraph() {
    for (int i = 0; i < 5; ++i) {
        for (int j = 0; j < 6; ++j) {
            printf("* ");
        }
        printf("\n");
    }
}

int main() {

    // 禁用 stdout 缓冲区
    setbuf(stdout, nullptr);

    // 调用无参函数
    printGraph();

    return 0;
}
  • 示例:有参函数
c
#include <stdio.h>

/**
 * 定义有参函数
 * @param rows
 * @param cols
 */
void printGraph(int rows, int cols) {
    for (int i = 0; i < rows; ++i) {
        for (int j = 0; j < cols; ++j) {
            printf("* ");
        }
        printf("\n");
    }
}

int main() {

    // 禁用 stdout 缓冲区
    setbuf(stdout, nullptr);

    // 调用无参函数
    printGraph(5, 6);

    return 0;
}

2.2.6 return 语句

  • return 语句的作用:
    • ① 结束函数的执行。
    • ② 将函数的结果返回给调用处。

提醒

  • ① return 语句后面不要再写其它代码,否则会报错!!!
  • ② 如果返回值类型不是 void ,则函数体中必须保证有 return 返回值;语句,并且要求该返回值结果的类型和函数声明的返回值类型保持一致或兼容。
  • ③ 如果返回值类型是 void,函数体中可以没有 return 语句。如果要用 return 语句提前结束函数的执行,那么 return 后面不能加返回值,直接写 return; 即可。
  • 示例:
c
#include <stdio.h>

/**
 * 定义一个函数,计算两个整数的和,并返回
 * @param a 整数
 * @param b 整数
 * @return
 */
int add(int a, int b) {
    return a + b;
}

/**
 * 定义一个函数,获取两个整数的最大值,并返回
 * @param a 整数
 * @param b 整数
 * @return 最大值
 */
int max(int a, int b) {
    return a > b ? a : b;
}

/**
 * 打印指定范围的数据。
 * 如果到了索引 5 ,则结束该函数的执行
 * @param start 起始索引
 * @param limit 结束索引
 */
void printNum(int start, int limit) {
    for (int i = start; i <= limit; i++) {
        if (i % 5 == 0) {
            return; // 用于提前结束函数的执行
        }
        printf("i = %d\n", i);
    }
}

int main() {

    int a = 10;

    int b = 20;

    printf("a + b = %d\n", add(a, b));

    printf("a 和 b 的最大值 = %d\n", max(a, b));

    printNum(1, 10);

    return 0;
}

2.2.7 定义函数的注意事项

  • ① C 语言中所有的函数都是独立执行的。一个函数并不从属于另一个函数,即:函数不能嵌套定义。

提醒

点我查看 类 C 的编程语言函数式编程语言函数嵌套的支持
  • ① 类 C 的编程语言(C、C++、Java 等)通常不支持函数的嵌套,即:在一个函数内部定义另一个函数。这种设计简化了编译过程,但也限制了某些编程风格。如果需要类似的功能,可以通过使用回调函数或将函数指针作为参数来实现。
  • ② 函数式编程语言(Lisp、JavaScript 等)通常支持函数嵌套,即:在一个函数内部定义另一个函数。嵌套函数可以访问其外部函数的作用域中的变量,增强了封装性和可读性。这种特性有助于实现更复杂的逻辑而不污染全局命名空间。
c
#include <stdio.h>

// 错误演示
void fun1(int a,int b){
    ...
    void fun2(int c,int d){
       ...
    }
}

int main() {


    return 0;
}
  • ② 在 C 语言中,不允许同名函数有不同参数列表,函数名用来唯一标识一个函数,即:在 C 语言中,函数不能重载。

提醒

点我查看 不同编程语言对函数重载的支持
  • ① C 语言不支持函数重载。
  • ② C++、Java 等编程语言是支持函数重载的。
c
#include <stdio.h>

// 错误演示
int add(int a, int b) {
    return a + b;
}

// 错误演示
int add(int a, int b, int c) {
    return a + b + c;
}

int main() {

    return 0;
}

2.3 调用函数

  • 语法:
c
函数名(实参1,实参2,...); // 调用没有返回值的函数
c
数据类型 变量名 = 函数名(实参1,实参2,...); // 调用有返回值的函数

提醒

  • ① 调用函数的时候,实际参数的个数必须和函数的形式参数个数一致,不能多也不能少;否则,将会报错!!!
  • ② 子函数可以相互调用,但是不能调用 main() 函数,因为 main() 函数是被操作系统调用的,作为程序的启动入口。反之,main() 函数可以调用其它函数。
  • ③ 函数的参数和返回值类型,会根据需要进行自动类型转换。
  • 示例:
c
#include <stdio.h>

void fun1() {
    printf("呵呵哒\n");
}

int main() {

    // 调用无返回值函数
    fun1();
    
    return 0;
}
  • 示例:
c
#include <stdio.h>

int add(int a, int b) {
    return a + b;
}

int main() {

    int a = 1;
    int b = 2;

    // 调用有返回值函数
    int result = add(a, b);

    printf("a +b = %d\n", result);

    return 0;
}

2.4 应用示例

  • 需求:编写程序,声明一个函数,在函数中获取两个整数的最大值。

  • 示例:

c
#include <stdio.h>

/**
 * 获取两个整数的最大值
 * @param a 整数
 * @param b 整数
 * @return 最大值
 */
int max(int a, int b) {
    return a > b ? a : b;
}

int main() {

    int a = 1;
    int b = 2;

    int maxValue = max(a, b);

    printf("a 和 b 的最大值是:%d\n", maxValue);

    return 0;
}

2.5 应用示例

  • 需求:编写程序,声明两个函数,在函数中获取两个整数或三个整数的最大值。

  • 示例:

c
#include <stdio.h>

/**
 * 获取两个整数的最大值
 * @param a 整数
 * @param b 整数
 * @return 最大值
 */
int max(int a, int b) {
    return a > b ? a : b;
}
/**
 * 获取三个整数的最大值
 * @param a 整数
 * @param b 整数
 * @param c 整数
 * @return 最大值
 */
int triMax(int a, int b, int c) {
    return max(max(a, b), c);
}

int main() {

    int a = 1;
    int b = 2;

    printf("a 和 b 的最大值是:%d\n", max(a, b));

    int c = 10;

    printf("a, b 和 c 的最大值是:%d\n", triMax(a, b, c));

    return 0;
}

2.6 应用示例

  • 需求:编写程序,声明一个函数,在函数中获取 1 + 2 ... + n 的结果。

  • 示例:

c
#include <stdio.h>

/**
 * 获取 1+2+...+n 的和
 * @param n
 * @return
 */
int getSum(int n) {
    int sum = 0;
    for (int i = 1; i <= n; i++) {
        sum += i;
    }
    return sum;
}

int main() {

    int result = getSum(10);
    printf("%d\n", result);

    return 0;
}

2.7 应用示例

  • 需求:编写程序,验证哥德巴赫猜想。

提醒

  • ① 哥德巴赫猜想:任一大于 2 的偶数都可写成两个素数之和。
  • ② 质数:只能被 1 或其本身整除的自然数,如:2、3、5...
  • 示例:
c
#include <math.h>
#include <stdio.h>

/**
 * 判断某个数是否是质数(只能被 1 或其本身整除的自然数,如:2、3、5...)
 * 判断方法:[2,num-1] 范围内没有其它约数
 * @param num
 * @return
 */
bool prime(int num) {
    if (num <= 1) {
        return false;
    }

    // 判断 [2,num-1] 范围内没有其它约数
    // for (int i = 2; i < num; i++) {
    for (int i = 2; i <= sqrt(num); i++) { // 优化
        // 如果 num 能被 i 整除,说明 num 不是质数
        if (num % i == 0) {
            return false;
        }
    }

    return true;
}

/**
 * 哥德巴赫猜想:任何一个大于 2 的偶数都可以写成两个质数之和
 * @param num
 * @return
 */
bool guess(int num) {
    bool flag = false;

    for (int i = 2; i < num; ++i) {
        if (prime(i) && prime(num - i)) {
            flag = true;
            break;
        }
    }

    return flag;
}

#define BOOL_TO_STRING(x) ((x) ? "true" : "false")

int main() {

    // 禁用 stdout 缓冲区
    setbuf(stdout, nullptr);

    int num = 0;

    while (true) {
        if (num > 2 && num % 2 == 0) {
            break;
        }
        printf("请输入一个大于 2 的偶数:");
        scanf("%d", &num);
    }

    printf("哥德巴赫猜想正确? %s\n", BOOL_TO_STRING(guess(num)));

    return 0;
}

第三章:进一步认识函数

3.1 main() 函数(主函数)

3.1.1 主函数的作用

  • ① 主函数是程序的入口函数,即:所有的程序一定要包含一个主函数,程序总是从这个函数开始执行,如果没有该函数,程序就无法启动。
  • ② 主函数中可以调用其它函数,但其它函数不能反过来调用主函数,主函数也不能调用自己。

3.1.2 主函数的返回值

  • 主函数的一般格式:
c
int main(){
    
    ...
        
    return 0;
}
  • C 语言约定,主函数返回值 0 ,表示运行成功;如果返回其它非零整数,就表示运行失败

建议

  • ① 默认情况下,如果主函数里面省略 return 0 这一行,编译器会自动加上,即 main() 的默认返回值为 0。
  • ② 建议书写时保留 return 语句,以便形成统一的代码风格。

3.1.3 主函数的其它写法

3.1.3.1 概述

  • 主函数的声明中是可以携带两个参数,格式如下所示:
c
/**
* @param argc(argument count)参数的个数
* @param argv(argument value)字符串的指针数组
*/
int main(int argc, char *argv[]) {
   //函数体
}

提醒

  • ① 通过这种方式,我们可以在命令行中将指定的字符串参数传递到 C 程序中,以便进行下一步处理。
  • ② 在实际开发中,很少使用,了解即可!!!
  • 示例:
c
#include <stdio.h>

int main(int argv, char *argc[]) {

    for (int i = 0; i < argv; ++i) {
        char *args = argc[i];
        printf("%s\n", args);
    }

    return 0;
}

3.1.3.2 CLion 调试

  • 如果是 CLion 进行调试,在 编辑配置 --> 程序实参 处,填写想要的实际参数即可。

3.1.3.3 命令行调试

  • 如果是命令行进行调试,通过 gcc 命令编译之后,只需要在程序启动时加上参数。
shell
gcc demo.c -o demo
shell
./demo a b c d

3.2 exit() 函数(了解)

  • exit() 函数的声明,如下所示:
c
void exit (int status);

提醒

  • exit() 函数是用于终止程序执行的标准库函数。一旦执行到该函数,程序就会立即结束。
  • exit() 函数的原型(函数原型是什么?下文见!!!)定义在头文件 stdlib.h 中,主要用于在程序执行过程中提前退出。
  • exit() 函数本身不会直接向程序返回值,但它会将提供的状态码传递给操作系统:
    • 如果 status0(EXIT_SUCCESS,常量) ,则表示程序运行成功,正常结束。
    • 如果 status非 0 (EXIT_FAILURE,常量),则表示程序异常中止。
  • 这个状态码可以在命令行或父进程中被访问,以指示程序的退出状态(在 shell 中,可以使用 $? 来获取上一个执行命令的退出状态)。

提醒

点我查看 exit() 函数的执行过程
  • 清理操作:在退出程序之前,exit 会先调用所有注册的 atexit 函数。这些函数通常用于释放资源或进行清理操作。
  • 关闭文件流:如果有打开的文件流,exit 会确保它们被正确关闭。
  • 返回控制:程序的控制权返回给操作系统。

提醒

  • ① 当 main 函数返回时,编译器会自动调用 exit,传递 main 函数的返回值作为 exit 的参数。
  • ② 换言之,如果 main 函数返回 0,相当于调用了 exit(0)
  • ③ 这意味着在 main 函数结束后,系统会执行与直接调用 exit 相同的清理操作,包括:清理操作关闭文件流返回控制

注意

  • ① 使用 exit 会立即终止程序,而不会执行 main 函数中的剩余代码。
  • ② 应谨慎使用 exit,尤其是在大型程序中,因为它可能会影响程序的可维护性和调试。
  • ③ 在多线程程序中,调用 exit 会终止整个程序,而不仅仅是调用 exit 的线程。
  • 示例:
c
#include <stdio.h>
#include <stdlib.h>

int main() {
    printf("This is a simple program.\n");

    // 进行某种检查
    if (1) { // 假设条件为真
        printf("Exiting the program.\n");
        exit(0); // 正常退出
    }

    printf("This line will not be executed.\n");
    return 0;
}

3.3 atexit() 函数(了解)

  • atexit() 函数的声明,如下所示:
c
int atexit (void (*func) (void))

提醒

  • ① 当程序正常终止时,所有通过 atexit 注册的函数都会按照注册的相反顺序(即 LIFO,后进先出)依次执行。
  • ① func 参数是一个指向无参数、无返回值的函数的指针。也就是说,func 是一个不接受任何参数并且返回 void 的函数。
  • atexit 函数返回 0 表示注册成功,返回非零值表示注册失败。

注意

  • ① 通过 atexit 注册的函数只会在程序正常终止时被调用。如果程序异常终止(通过 abort 函数终止,或者遇到未处理的严重错误如分段错误),则这些函数不会被调用。
  • atexit 函数最多可以注册 32 个清理函数(具体数量依赖于实现)。
  • atexit 机制非常有用,尤其是在需要确保某些资源(文件句柄或动态内存)在程序结束时被正确释放的情况下。
  • 示例:
c
#include <stdio.h>
#include <stdlib.h>

void cleanup1(void) {
    printf("清理1....\n");
}

void cleanup2(void) {
    printf("清理2....\n");
}

int main() {

    printf(".... main ....\n");

    atexit(cleanup1);
    atexit(cleanup2);

    printf(".... main ....\n");

    return 0;
}

3.4 函数原型(声明)(⭐)

3.4.1 概述

  • 默认情况下,函数必须先定义,再使用。由于程序总是先运行 main() 函数,导致其它的函数都必须在 main() 函数之前定义。
c
#include <stdio.h>

// 定义函数
void fun1() {
    // ...
}

// 定义函数
void fun2() {
    // ...
}

int main() {

    // 调用函数
    fun1();
    
    // 调用函数
    fun2();
    
    return 0;
}
  • 如果将 fun1() 的函数定义和 fun2() 的函数定义放在 main() 函数的后面,就会产生编译错误,即:找不到 fun1()fun2() 的声明。
c
#include <stdio.h>

int main() {

    // 调用函数
    fun1();

    // 调用函数
    fun2();

    return 0;
}

// 定义函数
void fun1() {
    // ...
}

// 定义函数
void fun2() {
    // ...
}
  • 在 CLion 中,会直接报错,如下所示:

提醒

点我查看 CLion 中的使用技巧

本人是在 CLion 中安装了 Inspection Lens 插件,所以警告、报错信息,会直接在代码旁边显示,非常实用!!!

  • 对于函数较多的程序,保证每个函数的正确顺序,会变得极其麻烦。C 语言提供的解决方案是:只要在程序开头处给出函数原型(声明),函数就可以先使用,再定义。

提醒

函数原型(声明)到底是什么?随后讲解!!!

c
#include <stdio.h>
// 函数原型
void fun1();

// 函数原型
void fun2();

int main() {

    // 调用函数
    fun1();

    // 调用函数
    fun2();

    return 0;
}

// 声明函数
void fun1() {
    // ...
}

// 声明函数
void fun2() {
    // ...
}
  • 到此为止,CLion 中将不会报错,如下所示:

3.4.2 函数原型

  • 所谓的函数原型(Function Prototype)就是函数在调用前告诉编译器每个函数的基本信息,包括:返回值类型函数名函数个数参数类型参数顺序。并不需要包括函数体和参数名称。在程序编译的时候,只要检查函数原型函数定义是否一致,如果一致就可以正确的编译。

提醒

  • ① 尽管函数原型中包含参数名是多余的,但是对阅读代码是有帮助的,建议在定义函数原型的时候,加上参数名。
  • ② 如果学过 Java 语言,可以将函数原型理解为 JDK8 之前的接口
c
#include <stdio.h>

// 等价于 int add(int num1,int num2);
int add(int, int); // 函数原型

int main() {

    int a = 10;
    int b = 20;

    printf("a + b = %d\n", add(a, b));

    return 0;
}

// 函数实现
int add(int a, int b) {
    return a + b;
}
  • 如果你还不理解,那么让我们回顾一下,C 程序的编译过程:
  • 我们在入门程序中,是这么写的,如下所示:
c
#include <stdio.h>

int main() {

    printf("你好,世界");

    return 0;
}
  • printf 函数并不是我们写的,它是一个标准库函数,它的头文件是 stdio.h,内容如下:
c
// ... 其余内容省略
extern int printf (const char *__restrict __format, ...);
// ... 其余内容省略
  • 我们可以看到 stdio.h 中,并没有 printf 函数的实现,即:printf 只有函数原型,而没有具体的函数实现(函数体)。在 C 语言进行预处理的时候,会将 stdio.h 中的所有内容复制到源文件(还有宏展开条件编译删除注释等过程),再经过编译汇编,并进行链接

提醒

如果以 printf 函数为例,所谓的链接就是通过链接器将 printf 函数的实现(glibc 提供的)编译到最终的二进制文件中。

第四章:C 语言面向接口编程(⭐)

4.1 多文件编译

4.1.1 头文件的作用

  • 我们不难发现,标准库就是将函数原型定义在头文件中的,那么我们也应该将自定义函数函数原型定义在头文件中,这样方便管理,而将自定义函数实现放在 .c 文件中。

注意

  • ① 在实际开发中,我们是使用#include <xxx.h>标准库函数的头文件引入到工程中。
  • ② 在实际开发中,我们是使用#include "xxx.h"自定义函数的头文件引入到工程中。
  • 如果你用的是我之前给出的 CMakeLists.txt,那么项目的工程目录是这样的,如下所示:
txt
├─📁 include/---- # 头文件目录
│ └─📄 add.h
├─📁 module/----- # 函数实现目录
│ └─📄 add.c
└─📄 main.c------ # 主函数
  • 在 CLion 中,是这样的,如下所示:
  • 对于 include/add.h 文件,其内容如下:
include/add.h
c
#ifndef ADD_H
#define ADD_H

// 函数原型
int add(int a, int b);

#endif // ADD_H
  • 对于 module/add.c 文件,其内容如下:
module/add.c
c
#include "./include/add.h" // 导入自定义函数的头文件

// 函数声明或函数实现
int add(int a,int b) {
  return a + b;
}
  • 对于 main.c 文件,其内容如下:
main.c
c
#include <stdio.h> // 导入标准库函数的头文件
#include "./include/add.h" // 导入自定义函数的头文件

int main() {

    int a = 5;
    int b = 10;

    int result = add(a, b);
    printf("%d + %d = %d\n", a, b, result);

    return 0;
}
  • 可以在 CLion 中试着运行,看是否能正确运行:

4.1.2 头文件重复

4.1.2.1 概述

  • 我们也知道,对于 C 语言的预处理而言,只会将对应的头文件的内容都插入到源文件中。这样会导致一个问题:有可能头文件会在很多 .c 文件中都导入过,这样有可能会导入两遍甚至更多。
  • 我们可以使用一个案例来验证,目录结构如下所示:
txt
├─📁 include/---- # 头文件目录
│ ├─📄 add.h
│ └─📄 sub.h
├─📁 module/----- # 函数实现目录
│ ├─📄 add.c
│ └─📄 sub.c
└─📄 main.c------ # 主函数
  • 其对应的图示,如下所示:
  • 对于 include/add.h 文件,其内容如下:
include/add.h
c
int add(int a, int b);
  • 对于 include/sub.h 文件,其内容如下:
include/sub.h
c
int sub(int a, int b);
  • 对于 module/add.c 文件,其内容如下:
module/add.c
c
#include "../include/add.h"

int add(int a, int b) { 
    return a + b; 
}
  • 对于 module/sub.c 文件,其内容如下:
module/sub.c
c
#include "../include/sub.h"

int sub(int a, int b) { 
    return a - b; 
}
  • 对于 main.c 文件,其内容如下:
main.c
c
#include <stdio.h>

#include "./include/add.h"
#include "./include/add.h"
#include "./include/sub.h"

int main() {
  int a = 5;
  int b = 10;

  int result = add(a, b);
  printf("%d + %d = %d\n", a, b, result);

  result = sub(a, b);
  printf("%d - %d = %d\n", a, b, result);

  return 0;
}
  • 使用 gcc 命令来进行预处理,如下所示:
shell
gcc -E main.c -o main.i
  • 其工程对应的逻辑结构,如下所示:

4.1.2.2 解决头文件重复

  • 头文件重复并不会对程序的执行造成什么影响。但是,可能会增加编译时间。
  • 那么,如何防止头文件重复包含,C 语言给出了两种解决方案:
    • 方案①:在头文件最顶行加上 #pragma once
    • 方案②:在头文件中使用 #ifndef #define #endif

注意

  • 方案① 并非 C 语言标准,是一个非标准但是被广泛支持的前置处理符号,会让所在的文件在一个单独的编译中只被包含一次。并且,#pragma once提供类似方案②的目的,拥有较少的代码且能避免名称的碰撞。
  • 方案②相比,方案①可以加快编译速度:编译器会自动比对文件名称inode而不需要在头文件去判断#ifndef#endif
  • 使用方案① 解决头文件重复包含,那么 include/add.hinclude/sub.h 的内容如下:
include/add.h
c
#pragma once

int add(int a, int b);
include/sub.h
c
#pragma once

int sub(int a, int b);
  • 使用 gcc 命令来进行预处理,如下所示:
shell
gcc -E main.c -o  main.i
  • 使用方案② 解决头文件重复包含,那么 include/add.hinclude/sub.h 的内容如下:
include/add.h
c
#ifndef ADD_H
#define ADD_H

int add(int a, int b);

#endif // ADD_H
include/sub.h
c
#ifndef SUB_H
#define SUB_H

int sub(int a, int b);

#endif // SUB_H
  • 使用 gcc 命令来进行预处理,如下所示:
shell
gcc -E main.c -o  main.i
  • CLion 的快捷方式是采用方案①来解决头文件包含问题,如下所示:

提醒

CLion 支持方案①方案②,只不过方案①有对应的模板文件而已!!!

4.2 面向接口编程

4.2.1 概述

  • 在实际开发中,我们需要做到面向接口编程,即:通过依赖抽象(接口)而不是具体实现,以增强代码的灵活性和可扩展性。

提醒

点我查看 生活中的举例
  • 在生活中,最为常见的就是笔记本电脑上的 USB 接口了,其是一种规范,即:不同的版本有不同的要求,如:USB 2.1 、USB 3 等)。
  • 如果某种设备(键盘、U 盘、鼠标等)实现了 USB 接口(USB 2.1),那么该设备就可以插入到电脑上的 USB 接口(USB 2.1)上使用。

4.2.2 Java 语言面向接口编程

  • 面向接口编程(Programming to Interface) 是 Java 编程中的一种设计原则,强调使用接口来定义系统中的行为规范,而不是依赖具体实现类。其核心思想是“面向抽象,而非面向具体编程”。
  • 面向接口编程的核心原则:
    • 依赖抽象:高层模块(业务逻辑)和低层模块(具体实现)都应该依赖接口,而不是直接依赖具体实现。
    • 接口隔离:使用接口定义行为,使得代码具有更好的可扩展性和可维护性。
    • 松耦合:通过接口实现类之间的松耦合,便于模块之间的解耦和替换。
    • 多态性:利用接口可以实现运行时的动态行为替换。

提醒

点我查看 JDK8 之前的接口的要求

JDK 8 之前的接口是一个完全抽象的蓝图,用于定义实现类必须提供的方法行为,但它自身不包含任何实现细节。

  • 只能定义抽象方法:接口中的方法默认是 public abstract 的,不能包含具体实现(即方法体)。
java
public interface PaymentProcessor {
    void method1();
    void method2();
}
  • 只能定义常量:接口中只能定义 public static final 修饰的常量(隐式修饰,不需要显式声明)。
java
public interface PaymentProcessor {
    // 等同于 public static final int CONSTANT = 100;
    int CONSTANT = 100; 
}
  • 不能包含构造器:接口无法实例化,因此接口不能包含构造器。
  • 接口之间支持多继承: 一个接口可以继承多个接口,用 extends 关键字。
java
public interface A {
    void methodA();
}

public interface B {
    void methodB();
}

public interface C extends A, B {
    void methodC();
}
  • 实现类的要求: 一个类实现接口后,必须实现接口中的所有抽象方法。
java
public class CreditCardPaymentProcessor implements PaymentProcessor {
    @Override
    public void method1() {
        System.out.println("Method1 implemented");
    }

    @Override
    public void method2() {
        System.out.println("Method2 implemented");
    }
}

提醒

点我查看 模拟 USB 代码的 UML 类图
  • 示例:
USB.java
java
// 定义 USB 接口
public interface USB {
    void connect();  // 连接 USB 设备
    void disconnect(); // 断开 USB 设备
}
Keyboard.java
java
// 实现 USB 接口:键盘
public class Keyboard implements USB {
    @Override
    public void connect() {
        System.out.println("Keyboard connected.");
    }

    @Override
    public void disconnect() {
        System.out.println("Keyboard disconnected.");
    }
}
FlashDrive.java
java
// 实现 USB 接口:U盘
public class FlashDrive implements USB {
    @Override
    public void connect() {
        System.out.println("FlashDrive connected.");
    }

    @Override
    public void disconnect() {
        System.out.println("FlashDrive disconnected.");
    }
}
Computer.java
java
// 模拟电脑类
public class Computer {
    // 模拟插入 USB 设备
    public void plugInUSB(USB device) {
        device.connect();  // 调用设备的 connect 方法
    }

    // 模拟拔出 USB 设备
    public void unplugUSB(USB device) {
        device.disconnect(); // 调用设备的 disconnect 方法
    }
}
Test.java
java
// 测试类
public class Test {
    public static void main(String[] args) {
        Computer computer = new Computer();

        // 创建键盘设备
        USB keyboard = new Keyboard();
        computer.plugInUSB(keyboard);  // 连接键盘
        computer.unplugUSB(keyboard); // 断开键盘

        System.out.println();

        // 创建 U盘设备
        USB flashDrive = new FlashDrive();
        computer.plugInUSB(flashDrive);  // 连接 U盘
        computer.unplugUSB(flashDrive); // 断开 U盘
    }
}

4.2.3 C 语言面向接口编程

  • 在 C 语言中,并没有 interface 等关键字;但是,在 C 语言中,头文件就是一个接口
  • 之前的多文件编译,如下所示:
txt
├─📁 include/---- # 头文件目录
│ └─📄 add.h
├─📁 module/----- # 函数实现目录
│ └─📄 add.c
└─📄 main.c------ # 主函数
  • 其对应的逻辑,如下所示:
  • 示例:
include/add.h
c
#ifndef ADD_H
#define ADD_H

// 函数原型
int add(int a, int b);

#endif // ADD_H
module/add.c
c
#include "./include/add.h" // 导入自定义函数的头文件

// 函数声明或函数实现
int add(int a,int b) {
  return a + b;
}
main.c
c
#include <stdio.h> // 导入标准库函数的头文件
#include "./include/add.h" // 导入自定义函数的头文件

int main() {

    int a = 5;
    int b = 10;

    int result = add(a, b);
    printf("%d + %d = %d\n", a, b, result);

    return 0;
}

Released under the MIT License.