器→工具, 编程语言

C语言学习之函数

钱魏Way · · 13 次浏览

函数的基本概念

在C语言中,函数是用于完成特定任务的独立代码块。函数可以带有参数(即输入),并且可以返回一个值(即输出)。C语言为我们提供了很多内建的函数,如 printf(),scanf() 等,同时也允许我们自定义函数。

函数的基本组成:

  • 函数返回类型:告诉编译器函数返回的值的类型,如果函数不返回任何值,则返回类型为 void。
  • 函数名:为函数起个名称,以便于在其他地方调用。
  • 函数参数:传递给函数的值。如果函数没有参数,可以使用 void 关键字。
  • 函数体:包含了一组定义函数任务的语句。

函数的定义格式如下:

返回类型 函数名(参数类型 参数名,...) {
   // 函数体
}

例如,一个求和的函数可以定义为:

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

在这个函数中,”int” 是返回类型,表示函数返回的结果是整型。”add” 是函数名,”(int a, int b)” 是函数的参数列表,这两个参数都是整型。函数体中,计算了 a 和 b 的和,并返回这个和。

函数的主要优点是它们允许模块化编程,使代码的复用性和可读性得到提高。

函数的定义和声明

在C语言中,函数的定义和声明是两个不同的概念。

函数定义:函数定义包含了函数的名称,返回类型,参数列表和函数体。函数体是实现函数功能的具体代码。一旦函数被定义,就可以在程序的其他地方被调用执行。例如,下面的代码就是一个函数的定义:

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

函数声明:又被称为函数原型,它告诉编译器函数的名称、返回类型和参数列表。函数的声明并不包含函数体,它只是告诉编译器,存在一个这样的函数,具体的实现可能在别的地方。函数的声明以分号结束。例如,下面的代码就是一个函数的声明:

int add(int a, int b);

如果函数的定义在调用它的代码之前,那么函数的声明可以省略。但是如果函数的定义在调用它的代码之后,那么就需要在调用之前进行函数声明,否则编译器会报错,因为它需要在编译时确保被调用的函数确实存在。

函数的调用

在C语言中,函数的调用是在程序中执行一个特定任务或者一段代码的方式。当你在程序中调用一个函数时,程序的控制权会转移到被调用的函数。函数在执行完毕后,会返回控制权,继续执行下一行代码。

函数的调用需要使用函数名,后面跟着圆括号,并在圆括号中填入函数参数(如果有的话)。例如,对于一个名为add的函数,它的调用可能看起来像这样:

int result = add(5, 10);

在这个例子中,函数add被调用,并传入了两个参数5和10。函数执行后,返回的结果被赋值给变量result。

注意,函数的参数在调用时必须与函数声明或定义时的参数类型和顺序相匹配。如果函数没有参数,那么在调用时也必须使用空的圆括号。例如,对于一个没有参数的函数,它的调用可能看起来像这样:

displayMessage();

在这个例子中,函数displayMessage被调用,因为这个函数没有参数,所以调用时圆括号内为空。

直接调用和间接调用

在C语言中,函数的调用主要有两种方式:直接调用和间接调用。

直接调用

直接调用是最常见的调用方式,就是在程序中直接使用函数名和参数列表来调用函数。例如,我们定义了一个求和函数add,我们可以通过以下方式直接调用它:

int result = add(3, 4);

这里直接使用了函数名add和参数列表(3, 4)来调用函数。

间接调用

间接调用是通过函数指针来调用函数。函数指针是一个指针变量,它指向一个函数。通过函数指针,我们可以间接地调用这个函数。例如,我们定义了一个求和函数add,我们可以通过以下方式间接调用它:

int (*p)(int, int); // 定义一个函数指针
p = add; // 让函数指针指向add函数
int result = (*p)(3, 4); // 使用函数指针调用函数

这里定义了一个函数指针p,然后让p指向函数add,最后通过函数指针p来调用函数。这就是间接调用。

嵌套调用

在C语言中,嵌套调用指的是在一个函数调用中又包含了又一个或多个函数的调用。这是一种常见的编程技术,可以使代码更加简洁和易读。

例如,假设我们有一个名为multiply的函数,它接受两个整数作为参数并返回它们的乘积,还有一个名为add的函数,它接受两个整数作为参数并返回它们的和。我们可以将multiply函数的调用嵌套在add函数的调用中,如下所示:

int result = add(5, multiply(2, 3));

在上述代码中,multiply(2, 3)函数首先被调用,返回6。然后add(5, 6)函数被调用,返回11。所以,result的值为11。

嵌套调用可以有任意多层,只要每个函数都能正确地接受参数并返回结果,就可以进行嵌套调用。例如:

int result = add(add(1, 2), multiply(add(3, 4), add(5, 6)));

在这个例子中,add和multiply函数被嵌套调用了多次。首先,所有的add函数被调用,然后multiply函数被调用,最后最外层的add函数被调用。

函数的参数

参数的类型

在C语言中,函数的参数是在调用函数时传递给函数的值,用于在函数内部执行特定操作。参数可以有多个,也可以没有。

函数参数分为两种类型:

形式参数

在函数定义时声明的参数,也被称为形参。形参只在函数内部有效,当函数调用结束后,形参就会被销毁。例如,在下面的函数定义中,a和b就是形参:

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

实际参数

在调用函数时传递给函数的具体值,也被称为实参。实参可以是常量、变量或者表达式,只要它们的类型和函数定义中的形参类型相匹配即可。例如,在下面的函数调用中,3和4就是实参:

int result = add(3, 4);

在C语言中,函数参数默认是通过值传递的方式进行传递的,也就是说,函数在调用时会创建形参的副本,对形参的任何修改都不会影响实参。但是,我们也可以通过传递指针的方式来实现引用传递,即对形参的修改会影响实参。

参数传递方式

在C语言中,函数的参数传递有两种方式:值传递和引用传递。

值传递

这是C语言中最常见的参数传递方式。值传递是将实际参数复制一份传给函数,函数接收的是原始数据的副本,函数内部对参数的修改不会影响到实际参数。例如:

void change(int a) {
    a = 10;
}

int main() {
    int a = 5;
    change(a);
    printf("%d\n", a);    // 输出 5,a的值并没有改变
    return 0;
}

在这个例子中,函数change接收的是变量a的副本,所以在函数内部修改a的值并不会影响到main函数中的a。

引用传递

虽然C语言本身并不直接支持引用传递,但我们可以通过指针参数来实现类似引用传递的效果。引用传递是将实际参数的地址传给函数,函数接收的是原始数据的内存地址,函数内部对参数的修改会直接影响到实际参数。例如:

void change(int *p) {
    *p = 10;
}

int main() {
    int a = 5;
    change(&a);
    printf("%d\n", a);    // 输出 10,a的值被改变
    return 0;
}

在这个例子中,函数change接收的是变量a的地址,所以在函数内部修改*p的值会直接影响到main函数中的a。

无参数函数

在C语言中,一个函数可以没有参数。这种函数被称为无参数函数。

无参数函数的定义不包含任何参数,其格式如下:

return_type function_name(void) {
   // 函数体
}

这里的return_type代表函数返回的类型,function_name是函数的名字。void表示函数没有参数。函数体中是实现函数功能的代码。

例如,下面这个函数是一个无参数函数,它的功能是打印一条消息:

void printMessage(void) {
   printf("Hello, World!");
}

这个函数没有参数,返回类型为void,说明它不返回任何值。

无参数函数的调用也不需要任何参数,只需要使用函数名即可。例如,调用上面定义的printMessage函数,可以这样写:

printMessage();

函数的返回值

在C语言中,函数的返回值是函数执行完毕后返回给调用者的值。函数可以返回任何类型的值,也可以不返回值。

函数的返回值是通过return语句来指定的。return后面跟着的表达式的值就是函数的返回值。例如,下面的函数返回两个数的和:

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

在这个函数中,return a + b;这一行代码表示函数的返回值是a和b的和。

如果函数的返回类型是void,那么这个函数就不返回值。在这种情况下,可以省略return语句,或者单独使用return;来表示函数的结束。例如,下面的函数就没有返回值:

void printMessage() {
   printf("Hello, World!");
   return;
}

在调用有返回值的函数时,可以用一个变量来接收返回值。例如:

int result = add(3, 4);

在这里,变量result接收了函数add的返回值。

返回指针

在C语言中,函数可以返回一个指针,即返回的是某种类型的内存地址。为了做到这一点,函数的返回类型必须是一个指针类型。

下面是一个简单的例子,该函数返回一个整数数组的指针:

int* getArray() {
  static int array[10];  // 静态整型数组,注意静态数组才能返回,否则会在函数结束后销毁导致指向的内存无效
  for (int i = 0; i < 10; i++) {
    array[i] = i;
  }
  return array; // 返回数组的首地址(即指向数组的指针)
}

在这个例子中,函数getArray返回一个整数数组的指针,我们可以用一个整数指针来接收这个返回值:

int *p = getArray();

然后我们就可以通过这个指针来访问数组中的元素:

for (int i = 0; i < 10; i++) {
  printf("%d ", p[i]);
}

需要注意的是,由于局部变量在函数返回后会被销毁,所以不能返回指向局部变量的指针。如果需要返回一个指针,一般可以返回静态变量的指针,或者使用动态内存分配函数(如malloc、calloc等)分配内存,并返回指向这块内存的指针。

返回数组

在C语言中,函数不能直接返回一个数组,但是可以返回一个指向数组的指针或者返回一个结构体,结构体中包含数组。

例如,如果我们想要返回一个int类型的数组,可以声明函数的返回类型为指向int的指针,并返回数组的首地址:

int* getArray() {
    static int arr[5] = {1, 2, 3, 4, 5}; 
    return arr;  
}

int main() {
    int* p;
    p = getArray();
    for (int i = 0; i<5; i++) {
        printf("%d ", p[i]);
    }
    return 0;
}

在上面的代码中,getArray函数返回一个指向静态int数组的指针。在main函数中,我们可以通过这个指针来访问数组的元素。

注意:如果我们试图返回一个局部数组的指针,那么会导致未定义的行为,因为在函数返回后,局部数组会被销毁,所返回的指针将指向无效的内存。为了避免这个问题,我们可以返回一个指向静态数组的指针,或者返回一个指向动态分配的数组的指针。

函数的作用域和生命周期

函数的作用域

在C语言中,函数的作用域主要指的是函数标识符的可见范围。C语言中的函数作用域是从函数定义的地方开始,一直到源代码文件的结束。这意味着,一旦在某个地方定义了一个函数,那么在定义它的地方之后的所有代码中,都可以通过这个函数名来调用这个函数。

例如,假设我们有以下的C代码:

#include <stdio.h>

void function1() {
    printf("Inside function1\n");
}

int main() {
    function1();
    return 0;
}

在这个例子中,function1函数的作用域是从它的定义开始,一直到文件结束。所以在main函数中我们可以直接使用function1这个标识符来调用这个函数。

但是,如果我们将代码改为以下形式:

#include <stdio.h>

int main() {
    function1();
    return 0;
}

void function1() {
    printf("Inside function1\n");
}

在这种情况下,当编译器在main函数中遇到function1这个标识符时,由于function1的定义在main函数之后,因此编译器无法识别function1这个标识符,会报错。

解决这个问题的一种方法是使用函数声明。如:

#include <stdio.h>

void function1();  // function declaration

int main() {
    function1();  // This is now valid due to the declaration above
    return 0;
}

void function1() {
    printf("Hello from function1\n");
}

在这个例子中,虽然function1()的定义在main()函数之后,但由于我们在main()函数之前提供了function1()的声明,所以在main()函数中调用function1()是有效的。

全局函数与静态函数

在C语言中,函数可以被定义为全局函数或者静态函数。

全局函数是默认的函数类型。全局函数可以在定义它的文件内,或者通过函数声明在其他文件中使用。全局函数的作用域是从它定义的地方开始,到整个程序结束。

例如:

// file1.c
#include <stdio.h>

void globalFunction() {
    printf("This is a global function.\n");
}

// file2.c
void globalFunction();  // function declaration

int main() {
    globalFunction();
    return 0;
}

静态函数与全局函数的最大区别在于其作用域。静态函数只能在定义它的文件内被使用,不能在其他文件中使用,即使有函数声明也不行。这是因为静态函数的作用域只在它定义的文件内。

例如:

// file1.c
#include <stdio.h>

static void staticFunction() {
    printf("This is a static function.\n");
}

int main() {
    staticFunction();
    return 0;
}

// file2.c
void staticFunction();  // function declaration, but it won't work

void someFunction() {
    staticFunction();  // error: staticFunction is not visible here
}

在这个例子中,尝试在file2.c中声明并使用file1.c中定义的静态函数staticFunction会导致编译错误。

静态函数可以被用来隐藏实现细节,只在一个文件中提供服务,而不被其他文件访问。

函数的生命周期

在 C 语言中,函数的生命周期并不同于变量的生命周期。

函数的“生命周期”可以被理解为从函数被定义开始,到程序运行结束。一旦函数被定义,它就可以在其作用域内的任何地方被调用。函数在程序运行期间始终存在,直到程序结束。也就是说,函数的生命周期是与程序的运行周期一致的。只要程序开始运行,所有的函数就可以被调用;当程序结束时,所有的函数就不能被调用了。

需要注意的是,这里的“生命周期”与函数中的变量的生命周期是不同的。例如,函数中的局部变量只在函数执行的过程中存在,当函数执行完毕后,这些局部变量就会被销毁。而全局变量和静态变量则在整个程序运行期间都存在。

递归函数

递归函数是在函数体内调用自身的函数。每个递归函数都有两个基本部分:基线条件(base case)和递归条件(recursive case)。

基线条件是递归函数的停止条件,也就是不再调用自己的条件。

递归条件是递归函数继续调用自己的条件。

以下是一个经典的递归函数例子:计算阶乘。

#include <stdio.h>

unsigned long long factorial(unsigned int i) {

   if(i <= 1) {
      return 1;
   }
   return i * factorial(i - 1);
}

int  main() {
   int i = 12;
   printf("Factorial of %d is %llu\n", i, factorial(i));
   return 0;
}

在上面的例子中,factorial函数是一个递归函数,用来计算整数的阶乘。当i为1或者0时,函数返回1,这是基线条件。当i大于1时,函数返回i乘以factorial(i – 1)的结果,这是递归条件。

需要注意的是,虽然递归在某些情况下非常有用,但是也要注意递归可能导致的问题,比如栈溢出。这是因为每次函数调用都会在内存中分配空间来保存参数和局部变量,如果递归调用的次数过多,可能会超出内存的容量,导致程序崩溃。

内联函数

内联函数(Inline Function)是C语言中的一种特殊函数,主要用于优化程序的运行效率。

对于常规函数,每次调用都需要进行一系列的操作,包括参数传递、栈帧分配、程序跳转等,这些操作会消耗一定的时间。而对于执行语句较少的小函数,这些额外的操作可能会使得函数调用的开销大于函数体的执行时间。

内联函数通过在编译时将函数体直接嵌入到每个调用点来解决这个问题。也就是说,编译器会将函数调用替换为函数体的代码,从而避免了函数调用的开销。但是,这也可能会使得最终生成的代码体积增大。

在C语言中,可以使用关键字inline来声明内联函数,如下所示:

inline int max(int a, int b) {
    return a > b ? a : b;
}

需要注意的是,inline只是向编译器提供一个建议,编译器可以选择忽略这个建议。例如,对于非常复杂的函数,编译器可能会选择不进行内联。

另外,由于内联函数可能在多个地方被展开,所以它们必须在每个使用它们的源文件中都有定义,最常见的做法是将内联函数的定义放在头文件中。

函数指针

函数指针是C语言中的一种特殊类型的指针,它指向一个函数,而不是一个变量或对象。函数指针可以被用来调用函数和传递指向特定函数的引用。在C语言中,每个函数都有一个地址,这个地址就是函数的入口点,通过这个地址,我们可以调用这个函数。函数指针就是保存这个地址的变量。

定义一个函数指针的一般语法如下:

return_type (*ptr_name) (function_arguments);

举个例子,假设我们有一个函数:

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

我们可以定义一个函数指针ptr,指向这个函数:

int (*ptr)(int, int);
ptr = &add;

然后我们就可以使用这个指针来调用函数:

int sum = (*ptr)(2, 3);  // sum now holds the value 5

函数指针的主要用途之一是实现函数作为参数的函数(也称为高阶函数)。例如,C标准库中的qsort函数就接受一个函数指针作为参数,这个函数用于定义排序的顺序。

回调函数

回调函数是一种在C语言或其他语言中常用的编程技术。简单来说,回调函数就是把一个函数的指针(引用)作为参数传递给另一个函数,当这个指针被用来调用其所指向的函数时,我们就说这个函数被回调了。

回调函数在很多情况下都非常有用。例如,你可以将特定的处理逻辑(以函数形式)传递给一个通用的算法函数,然后让算法函数在适当的时候调用你的处理逻辑。C标准库函数qsort就是这样一种典型的例子,它可以接受一个比较函数作为参数,然后用这个比较函数来决定排序的顺序。

下面是一个使用了回调函数的例子:

#include <stdio.h>

void printNumber(int value) {
    printf("%d ", value);
}

void forEach(int* arr, size_t size, void(*func)(int)) {
    for(size_t i = 0; i < size; ++i) {
        func(arr[i]);
    }
}

int main() {
    int arr[] = {1, 2, 3, 4, 5};
    forEach(arr, sizeof(arr) / sizeof(int), printNumber);
    return 0;
}

上面的代码中,函数forEach接受一个数组、一个大小和一个函数指针作为参数。对于数组中的每一个元素,forEach都会调用传入的函数。在main函数中,我们把打印数字的函数printNumber传给了forEach,结果就是把数组中的每一个数字都打印了出来。

函数的错误处理

在C语言中,函数的错误处理通常有以下几种方式:

  • 返回错误码:函数在执行过程中如果遇到错误,可以返回一个错误码表示不同的错误类型。例如,许多标准库函数在出错时会返回-1,同时设置全局变量errno以表示具体的错误原因。
  • 设定错误标志:函数可以设定一个全局的错误标志,在执行过程中遇到错误时设置这个标志。函数调用者可以在函数调用后检查这个标志来确定是否有错。
  • 返回错误对象:如果函数返回的是某种复杂的数据类型,可以在这个数据类型中设定一个字段来表示错误状态。
  • 错误回调:函数可以接受一个错误处理函数作为参数,当遇到错误时调用这个函数处理错误。
  • 异常处理:虽然C语言本身不支持异常处理,但是可以通过一些技巧(如使用setjmp和longjmp)来实现类似于异常的错误处理。

每种方式都有其适用情况,具体应选择哪种方式取决于函数的设计和使用环境。

errno

errno 是在C语言中用于表示错误的整型全局变量。当某些C标准库函数执行中发生错误时,系统会将该错误的错误代码赋值给 errno。每种错误都有一个唯一的错误代码,表示不同类型的错误。

以下是一些常见的 errno 错误代码:

  • EACCES:权限被拒绝。
  • EEXIST:文件已存在。
  • ENOENT:文件或目录不存在。
  • ENOMEM:没有足够的空间。

我们可以通过查看 errno 的值来确定最近的函数调用是否成功,以及出现了何种错误。例如,在调用 fopen 函数打开文件失败时,我们可以检查 errno 来确定失败的原因:

#include <stdio.h>
#include <errno.h>

FILE *fp = fopen("nonexistent.txt", "r");
if (fp == NULL) {
    printf("Failed to open file. Error code: %d\n", errno);
}

同时,为了在发生错误时能够显示出更加人性化的错误信息,我们可以使用 strerror 函数将 errno 的错误代码转换为对应的错误信息字符串:

if (fp == NULL) {
    printf("Failed to open file: %s\n", strerror(errno));
}

这样,当文件打开失败时,我们就可以打印出更加具体的错误信息了。

assert

assert是C语言中的一个宏,通常用于在代码中添加断言。断言是一种在程序运行时检查某种条件是否满足的方法。如果断言的条件不满足(即,条件为假),则程序会立即终止,并输出一条错误信息。

assert的使用方法如下:

#include <assert.h>

int main() {
    int x = 5;
    assert(x == 5);
    assert(x == 10);
    return 0;
}

上述代码中,第一个assert的条件满足(x确实等于5),所以程序继续执行。但是第二个assert的条件不满足(x不等于10),所以程序立即终止,并输出一条错误信息,该信息通常包含失败断言的条件和源文件的行号。

需要注意的是,assert只在调试版本的程序中有效。在编译发布版本的程序时,通常会定义NDEBUG宏来关闭所有的assert断言。可以通过在包含assert.h头文件之前定义NDEBUG宏来实现这一点,如下:

#define NDEBUG
#include <assert.h>

总的来说,assert是一个非常有用的工具,可以帮助我们检查程序的正确性,并找出可能的错误。

标准库函数

C语言标准库函数是一组预定义的、在C语言标准库中包含的函数,它们提供了许多基础和通用的功能,如输入输出处理、字符串操作、内存分配、数学计算等等。使用这些函数可以方便我们进行程序开发,而无需从头开始编写这些基础功能。

C语言标准库函数主要包含在以下几个头文件中:

  • <stdio.h>:包含输入输出相关的函数,如printf、scanf、getchar、putchar等。
  • <stdlib.h>:包含一些通用的函数,如内存分配函数malloc、free,随机数生成函数rand等。
  • <string.h>:包含字符串处理的函数,如strcpy、strlen、strcat等。
  • <math.h>:包含数学计算的函数,如sin、cos、sqrt等。
  • <time.h>:包含时间和日期处理的函数。

这些函数通常在任何C语言环境下都可用,也是C语言标准的一部分。当然,实际可用的函数可能还会根据不同的平台和编译器有所不同。

第三方函数

C语言的第三方函数主要指的是由第三方库提供的函数,这些库可能包括但不限于数学运算、数据处理、图形界面、网络编程等。以下是一些常用的C语言第三方库:

  • GNU科学计算库(GSL):这个库包含了大量用于数值计算的函数,如随机数生成、线性代数运算、数值微积分等。
  • OpenSSL:这是一个强大的安全通信库,提供了大量用于加密、解密、SSL/TLS协议等的函数。
  • libcurl:这是一个用于网络编程的库,提供了大量的函数用于HTTP、FTP、SMTP等协议的通信。
  • libpng/libjpeg:这两个库提供了处理PNG和JPEG格式的图像的函数。
  • GTK+:这是一个用于创建图形用户界面的库,提供了大量的函数用于创建窗口、按钮、文本框等界面元素。
  • SQLite:这是一个轻量级的数据库系统,提供了大量的函数用于数据库的创建、查询、更新和删除等操作。

以上只是C语言第三方库的一部分,实际上,C语言有着丰富的第三方库资源,几乎可以满足任何编程需求。GitHub是全球最大的开源软件社区,你可以在这里找到各种各样的C语言库。Awesome C是一个GitHub上的项目,收集了各种优秀的C语言库和资源。

系统函数

系统函数是操作系统提供的一些函数,它们通常用来执行一些底层或者系统级的操作,比如文件操作、进程管理、内存管理、设备控制等。

在C语言中,我们可以通过系统调用来使用这些系统函数。系统调用是操作系统为其他软件提供的服务接口,它是在用户模式和内核模式之间的一个桥梁。换句话说,系统调用可以让我们的程序请求操作系统来完成一些我们无法直接完成的任务。

以下是一些常见的系统函数(系统调用)的例子:

  • open:打开文件。
  • read:从文件中读取数据。
  • write:向文件中写入数据。
  • close:关闭文件。
  • fork:创建一个新的进程。
  • exec:执行一个新的程序。
  • wait:等待一个进程结束。
  • exit:结束当前进程。

这些函数通常在unistd.h(在POSIX系统,如Linux和Unix)或者windows.h(在Windows系统)头文件中声明。

由于这些函数直接与操作系统交互,因此它们的行为和具体的操作系统有很大关系。在编写跨平台程序时,我们需要特别注意这一点,因为不同的操作系统可能会提供不同的系统函数,或者相同的函数在不同的系统中的行为可能会有所不同。

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注