指针基础
在 C 语言中,指针和指针变量通常被视为相同的概念,但在严格意义上,它们有细微的区别:
- 指针 指的是内存中的一个地址,它是虚拟内存空间中某字节的唯一标识。
- 指针变量 则是专门用来存储这个地址的变量。指针变量本身也具有自己的内存地址,并且内部存储的数据是它所指向的内存地址。
指针的初始化
- 直接地址赋值:最常见的初始化方法是将一个变量的地址赋值给指针变量。这允许指针直接指向该变量的内存位置。
- 使用 NULL 进行初始化:指针变量可以被初始化为 NULL,这是一个特殊的字面值常量,表示指针不指向任何有效的内存地址。在大多数平台上,NULL 实际上等同于地址值 0。尝试操作 NULL 指针将导致未定义行为,通常会引发空指针异常,可能导致程序崩溃。
- 指针之间的赋值:一个指针变量可以被初始化为另一个指针变量的值。这样,两个指针变量将存储相同的地址,并且都指向相同的内存对象。
- 使用数组名初始化:数组名在 C 语言中代表数组首元素的地址。因此,当用数组名初始化指针变量时,该指针将指向数组的第一个元素。
示例
- 打印变量的地址,以 16 进制显示
int a; scanf("%d", &a); // 20 int *p = &a; printf("%#x",p); // 0x1bdffc04
- 打印出指针类型的空间大小
printf("%d", sizeof(int *)); // MSVC 64 位编译器是 8 个字节大小,32 位编译器是 4 个字节大小
- 多层次的指针,通常称为“指针套娃”,需要仔细管理。
例如,当你有一个整型变量
a
和一个指向它的指针p
,随后创建一个指向p
的指针pp
,这就形成了一个双层指针结构。在这个结构中,pp
存储的是p
的地址。int a = 1; int *p = &a; int **pp = &p; // 指针变量的地址存入到另一个指针变量中
逻辑流程如下:
- 声明并初始化一个整型变量 a,然后获取它的地址:
int a = 1; int *p = &a;
-
声明一个二级指针
pp
,并将其初始化为p
的地址:int **pp = &p;
-
通过解引用
pp
,我们可以访问p
,然后再通过p
访问a
的值。这个过程可以表示为:int value = *((*pp));int value = *((*pp));
这里
*(*pp)
首先解引用pp
得到p
的值,即a
的地址,然后再次解引用得到a
的值。 -
&pp
表示pp
本身的地址,这与通过pp
访问的p
的地址是不同的。在实际编程中,通常不需要使用&pp
,除非在某些特定的上下文中需要获取一个指针变量本身的地址。
在 C 语言中,指针变量的声明应该遵循明确的语法规则,以确保代码的清晰性和正确性:
- 声明并初始化一个整型变量 a,然后获取它的地址:
- 使用
数据类型 *指针名;
的格式来声明指针变量。 - 确保指针声明的数据类型与其指向的目标类型一致,避免未定义行为。
- 在命名指针变量时,推荐使用
p
或ptr
等前缀,以明确表示其为指针类型。
只读指针变量和(指向)只读变量(的)指针
当 const
用于指针时,它的位置决定了它修饰的数据类型。const
位于星号 * 的左侧时,修饰的是指针指向的数据;位于星号右侧时,修饰的是指针本身。
理解 const
修饰指针的规则,可以采用 “从后向前看” 的方法。即从变量名开始,向前找到最近的 const
关键字,它就修饰紧挨着它的数据类型。
int *const
:const
修饰int *
,即指针本身是常量。const int *
:const
修饰int
,即指针指向的数据是常量。
int *const p = &a;
// 此时 const 修饰的是指针,所以指针变量不可更改,但指针指向的变量值可以修改
// *p = 3; OK
// p = &b; ERROR
int const *p = &a;
// 此时 const 修饰的是指针变量指向的值,所以指针变量可以更改,但指针指向的变量值不可以修改
// *p = 3; ERROR
// p = &b; OK
int const *const pp = &p;
// 这里第一个 const 修饰的是指针,所以指针变量的值不可改变;第二个 const 修饰的是指针变量指向的值,也不可改变
// *pp = 3; ERROR
// pp = &b; ERROR
特殊指针
指针指向自己
在 C 语言中,一个指针可以被赋值为自己的地址,这种指针被称为自引用指针。这种情况下,指针的值就是它自己的内存地址。例如,如果我们有一个指针 p
,并且执行 p = &p
,那么 p
就指向了自己的内存地址。
逻辑如下:
- 首先获取指针
p
的地址,使用&p
。 -
然后将这个地址赋值给
p
本身,即p = &p
。
在这种情况下,p
的值是它自己的地址,所以 p
和 &p
实际上是相等的。但是,*p
应该是 p
所指向的地址的值,而不是 p
本身的地址。如果我们声明了一个指针 p
并让它指向自己,那么 *p
的值应该是一个未定义的或者是一个随机的值,因为 p
的地址可能没有被初始化为一个有效的内存地址。
因此,表达式 p == &p
是正确的,因为 p
被赋值为它自己的地址。但是,*p == p
通常是不正确的,因为 *p
应该是 p
所指向地址的值,而不是地址本身。
另外,如果 p
指向一个变量 a
,即 p = &a
,那么 *p
将等于 a
的值,这是指针解引用的常规用法。
// int *p = (int *)0x98f2fc28; 直接硬编码指针变量的值
int *p; // 定义一个指针,指针的值
p = &p;
printf("%#x", &p); // 0x98f2fc28
printf("\n%#x",p); // 0x98f2fc28
printf("\n%#x",*p); // 0x98f2fc28
野指针
在 C 语言中,一个 “野指针” 是指一个指针变量,它可能没有被正确初始化,或者它指向的内存地址已经不再有效。这种情况可能发生在多种场景下,比如一个全局指针变量被赋值为一个局部变量的地址。当局部变量的生命周期结束,它所占用的内存可能被系统回收或重新分配给其他变量,此时全局指针变量就变成了 “野指针”,因为它指向的内存内容是不确定的。
int *dangerous_pointer;
void DangeousPointer() {
int *a;
dangerous_pointer = &a;
}
空指针
在 C 语言中,NULL
是一个特殊的宏定义,它被用来表示一个空指针。空指针不指向任何有效的内存地址,常用于初始化指针变量,表示它们尚未指向一个具体的数据对象。
NULL
通常被定义为 (void *)0
,这意味着它是一个指向 void
类型的指针,并且其值为 0。它可以用来初始化任何类型的指针变量,因为 C 语言允许指针类型之间的隐式转换。
当一个指针变量被赋值为 NULL
,它的值就是 0。在内存中,这表示它没有指向任何有效的内存地址。
由于 NULL
指针没有指向任何内存地址,所以它指向的值是未定义的,尝试解引用 NULL
指针(即访问它所指向的内存位置)将导致未定义行为,通常是程序崩溃。
NULL
本身不是一个变量,它没有地址。NULL
是一个常量,它代表了一个指针的值,即 0。当你尝试获取 NULL
的地址,即 &NULL
,这是不合法的,因为 NULL
不是一个对象,没有存储在内存中的地址。
在 3.2 的例子中,可以在函数 DangeousPointer()
中加一句 dangerous_pointer = NULL;
避免产生野指针。
悬空指针
悬空指针是一种特殊类型的野指针,它最初指向一个有效的、已定义的内存区域。然而,在程序执行过程中,如果该内存区域被释放或重用,而指针未被适当更新,它就会变成指向一个随机、未定义区域的指针。
悬空指针通常在内存管理不当的情况下产生。例如,当动态分配的内存被释放后,如果指针没有被设置为 NULL
或更新为指向其他有效的内存区域,它就会变成悬空指针。
使用悬空指针是非常危险的,因为它可能导致程序访问无效的内存区域,从而引发程序崩溃或不可预测的行为。
一个典型的悬空指针例子是函数返回其局部变量的指针。如果函数的局部变量位于栈上,当函数返回后,这些局部变量的生命周期结束,栈帧被销毁,此时返回的指针就变成了悬空指针。
通用指针
通用指针类型 void*
在 C 语言中具有独特的地位和用途
void *
类型的特点:
- 类型无关性:
void *
类型是一个通用指针类型,可以存储任意类型的指针,包括任意地址。 - 解引用限制:由于
void *
是一个不明确的指针类型,直接解引用它会导致编译错误。 - 类型转换:在解引用
void *
类型之前,需要先将其转换为具体的指针类型。类型转换必须正确,否则可能导致未定义行为。
注意事项:
- 类型转换:在 C 语言中,
void *
到其他指针类型的转换可以隐式进行,而在 C++中则需要显式转换。 - NULL 宏定义:在 C 语言中,
NULL
通常被定义为(void *)0
,表示空指针。 - 通用性:
void *
类型的灵活性使其成为函数返回值和参数的良好选择,尤其是在处理不同类型的数据时。
优点:
- 函数返回值:当函数需要返回不同类型的指针时,可以使用
void *
类型作为返回值,然后由调用者将其转换为适当的类型。 - 函数参数:使用
void *
作为函数参数可以增加函数的通用性,例如qsort
函数可以对任何类型的数组进行排序。
扩展:带有 void *
类型参数的函数通常会有一个额外的参数,如 size
或 len
,表示数据的长度或大小。
指针的运算
指针的加法
在 C 语言中,指针加法是一种基本操作,它允许我们将指针向前或向后移动特定的字节数。这种移动的步长取决于指针指向的数据类型的大小。例如,如果我们有一个指向 int
的指针并对其执行加法操作(如 int *ptr; ptr++;
),指针 ptr
将移动到下一个 int
变量的地址,这通常意味着向前移动了四个字节(在 32 位系统中)。同样,如果指针是指向 double
的(如 double *dp; dp++;
),执行加法操作将使指针向前移动八个字节,因为 double
类型通常占用八个字节。
指针减法同理,当两个指针相减时,它们必须指向同一个数组或连续的内存块。如果指针不是指向同一内存块,那么结果将是未定义的。
int a = 2;
int *p = &a;
printf("%d\n", p); // 250607300
printf("%d\n", p + 1); // 250607304
printf("%d\n", sizeof(int)); // 4
printf("\n");
double b = 3.0;
double *po = &b;
double **poi = &po;
printf("%d\n", poi); // 250607368
printf("%d\n", poi + 1); // 250607376
printf("%d\n", sizeof(double *)); // 8
指针变量比大小
在 C 语言中,指针变量之间的比较操作是基于它们所指向的内存地址进行的。当我们比较两个指针变量时,实际上是在比较它们各自存储的内存地址值。这意味着,如果指针 p1
和 p2
分别存储了不同的地址,p1 > p2
的比较结果将取决于 p1
的地址值是否大于 p2
的地址值。
只有当指针指向相同的数组或内存区域时,它们的比较才有意义。比较指向不同内存区域的指针(如不同的数组或动态分配的内存块)可能会导致未定义的结果或错误。
int result = (p + 3) > (p + 1) ? 1 : 0;
int result2 = (p + 3) == (p + 3) ? 1 : 0;
printf("\n%d", result); // 1
printf("\n%d", result2); // 1
数组和指针
数组名和指针的关系
在 C 语言中,数组名是一个在内存中代表数组结构起始位置(基地址,也就是首元素的地址)的标识符。在大多数上下文中,数组名可以视为数组首元素的指针。
- 功能上:数组名的行为类似于一个固定指向数组首元素的指针,其指向不可改变。数组名不能被重新赋值,但是数组中的元素值是可以修改的。
- 语法和语义上:数组名始终代表整个数组,不应在所有场景中直接视为首元素指针。
数组名表示整个数组的场景:
&
运算符:使用&
运算符时,得到的是指向整个数组的指针,也就是数组指针。sizeof
运算符:当使用sizeof
运算符时,得到的是整个数组变量所占内存空间的大小。注意,这适用于数组定义时的数组名,而不是作为参数传递的数组名。
数组名视为首元素指针的场景:
- 指针初始化:可以用数组名给指针变量初始化,此时数组名被视为数组首元素的指针。
- 函数参数传递:将数组名作为实参传递给函数时,函数得到的是数组的首元素指针。
- 指针算术运算:直接用数组名进行指针算术运算(如
++
、--
、+
、-
)时,数组名也被视为首元素指针。
数组在传递给函数时退化为指针,这一设计选择带来了以下好处:
- 传递效率:传递指针给函数比复制整个数组更加高效。无论数组的大小如何,传递的始终是一个固定大小的指针值。
- 空间效率:避免了复制整个数组到函数中,节省了大量内存空间。指针传递仅需要一个较小的存储空间。
- 修改原始数据:通过指针传递,函数能够直接修改原始数组的数据,而不是仅操作其副本,这为数组操作提供了更大的灵活性。
- 灵活性:数组类型包含其长度信息,不同长度的数组类型是不同的。退化为指针后,函数可以接受任何大小的数组,增强了函数的通用性。
然而,这种设计也带来了一些挑战,例如 在函数内部无法直接获取数组的长度,需要额外传递长度信息。此外,指针操作需要谨慎,以避免潜在的风险。
数组作为参数传递
- 数组参数传递: 在 C 语言中,将数组作为参数传递给函数时,数组名会“退化”为指向数组首元素的指针。这意味着函数接收到的只是数组首元素的地址。
-
数组长度信息的丢失: 由于数组退化为指针,原始数组的长度信息在传递过程中丢失。因此,在函数声明中包含数组长度是不必要的,即使包含,也不会有任何效果。
-
获取数组长度: 在函数内部,不能通过
sizeof(arr)
获取数组的长度,因为sizeof
将返回指针的大小,而不是数组的大小。正确的做法是将数组的长度作为额外的参数传递给函数。 -
函数声明示例: 在 C 语言中,操作数组的函数通常需要声明为:
void test(int arr[], int len);
或者,当数组名退化为指针时:
void test(int *arr, int len);
这两种声明方式是等价的,都表示函数接收一个指向整数的指针和一个表示数组长度的整数。
-
函数内部对数组的操作: 在函数内部,可以自由地操作数组名,因为它是指针的副本。由于这个副本指向原始数组的首元素,所以对数组元素的修改会影响到原始数组。
指针数组和数组指针
- 指针数组(Array of Pointers):
- 指针数组本质上是一个数组,其特点是数组中的每个元素都是指针。
- 这意味着数组可以存储多个指针,每个指针可以指向不同的数据或内存地址。
- 指针数组在数据结构中非常常见,尤其是在处理字符串数组时。例如,在 C 语言中,字符串通常表示为字符数组,而字符串数组可以表示为字符指针数组,即
char *strArray[]
。每个指针指向一个字符串的起始位置。
- 数组指针(Pointer to an Array):
- 数组指针本质上是一个指针,它指向一个数组的首元素或整个数组。
- 这个指针允许我们通过指针操作来访问数组的元素,但它本身并不存储多个指针。
- 数组指针是一种不常见的语法,通常只在特定情况下使用,例如在处理二维数组或多维数组时。当你需要传递一个二维数组给函数时,由于数组在传递时会退化为指向其首元素的指针,所以如果函数需要操作整个二维数组,使用数组指针可以清晰地表达这一需求。
在中文表达中,“指针数组”强调的是“数组”,即一个由指针组成的集合;而“数组指针”强调的是“指针”,即一个指向数组的单一指针。
int *arr[10]; // 这里 arr 是一个数组,这个数组的长度是 10,每个元素都是指向 int 的指针。这意味着我们可以存储 10 个整数指针
int (*arr)[10]; // arr 是一个指针,它指向一个数组,而这个数组的长度是 10,类型是 int。
// 这种声明通常用于函数指针参数,其中函数需要操作一个固定大小的数组
arr
和 &arr
的区别:
arr
:当用作首元素指针时,arr
的值是数组首元素的地址,其类型是int*
。&arr
:当取arr
的地址时,我们得到的是一个指向整个数组的指针,类型是数组指针类型,如果我们声明的是int arr[3]
,则&arr
的类型是int(*)[3]
。
数组的 7 种写法
在 C 语言中,数组名 arr
在代码中被视为指向数组首元素的指针。当使用 arr[2]
时,可以直接访问数组的第三个元素,即下标为 2 的元素。实际上,数组名在很多语境下会被编译器视为一个指向数组第一个元素的常量指针,这意味着一旦定义,它就不能再被指向其他地址。然而,这并不影响我们通过数组名访问数组元素或进行遍历。
表达式 *(arr + 2)
通过将数组名与下标值相加,然后解引用结果指针来访问数组元素。这里 arr + 2
表示将数组的首元素指针向前移动两个元素的位置,*
运算符用于获取该指针指向的元素的值。
[]
运算符是 C 语言提供的语法糖,它简化了程序员的操作。实际上,arr[2]
等价于 *(arr + 2)
,这种写法更加直观和易于理解。
值得注意的是,尽管语法 array[3]
和 3[array]
在 C 语言中都是合法的,它们都访问数组的第四个元素(假设数组索引从 0 开始),但后者的可读性较差,因为它违反了常规的数组访问习惯。通常,我们使用方括号 []
来包围索引,以明确表示我们正在访问数组中的特定位置。
示例一:
int array[] = {0, 1, 2, 3, 4};
int *p = array;
printf("%d, ", *(p + 3)); // 3,推荐写法
printf("%d, ", *(3 + p)); // 3
printf("%d, ", p[3]); // 3
printf("%d, ", 3[p]); // 3
printf("%d, ", *(array + 3)); // 3
printf("%d, ", 3[array]); // 3
printf("%d, ", array[3]); // 3,推荐写法
示例二:
对于 *(*(matrix + 2) + 3)
,有:
- 二维数组和指针: 假设
matrix
是一个二维数组,可以表示为int matrix [行数][列数]
。在这种情况下,matrix
被视为指向其首元素的指针,其类型可以表示为int (*p) [列数]
。 - 指针算术:
matrix+2
表示将matrix
指针向前移动两个一维数组的位置。如果每个一维数组的长度是 5(即列数),并且每个int
占用 4 个字节(这取决于平台,这里假设为 20 字节),那么matrix + 2
实际上移动了2 * 20
字节。 - 解引用和进一步的指针运算:
*(matrix + 2)
解引用matrix + 2
指针,得到二维数组的第三个一维数组,此时类型变为int *
,指向该一维数组的首元素(假设为元素11
)。 - 再次指针运算:
*(matrix + 2) + 3
再次进行指针运算,将int *
类型的指针向前移动三个int
的大小,即3 * 4
字节,此时指针指向元素14
。 - 最终解引用:
*(*(matrix + 2) + 3)
进行第二次解引用,得到指针指向的元素值,即14
,其类型为int
。 - 等价表达式: 这个运算等价于直接使用下标访问
matrix [2][3]
,得到的也是元素14
。
左值和右值
在 C 语言的赋值语句中,等号左边通常是代表一个内存位置的变量,而等号右边则是一个表达式,其计算结果将被赋值给左侧的变量。这意味着左侧必须是一个可以存储值的有效内存地址,如一个变量或一个数组元素。右侧可以是任何能够产生值的表达式,包括常量、变量、函数调用或其他算术或逻辑操作的结果。
int main() {
int a;
a = 2; // a 是左值,2 是右值
int *p = &a; // p 是左值,&a 是右值
int b = *p; // b 是左值,*p 是右值
// 左值是内存空间,右值是内存空间里的值
int array[] = {0};
int *pp = array;
*pp++ = 5; // Ctrl + W 的妙用,IDE 自动展开寻找优先级,此处光标展开先是 pp++,然后是 *pp++
return 0;
}
指针参数作为返回值
指针替代数组作为返回值
在 C 语言中,数组不能直接作为函数的返回值。这是因为数组的返回涉及到复制整个数组,而 C 语言不支持这种复制操作。
尽管数组不能直接返回,但可以通过返回指向数组的指针来间接实现。数组名在 C 语言中被视为数组首元素的指针,因此返回数组名实际上是返回了数组的首元素指针。
将指针作为返回值时需要格外小心,尤其是当指针指向函数的局部变量时。如果函数返回指向其局部变量的指针,一旦函数执行完毕,其栈帧将被销毁,局部变量的生命周期结束,此时返回的指针将成为野指针,指向未定义的内存区域。
避免野指针的最佳实践:
- 避免返回指向局部变量的指针。
- 使用动态内存分配(如
malloc
)时,确保内存在使用完毕后被正确释放。 - 在函数外部分配数组内存,或使用静态或全局数组,这些内存的生命周期不受限于函数的局部作用域。
跨函数通过指针修改局部变量
在跨函数操作中,通过指针修改局部变量时,必须确保该局部变量在被访问和修改期间是有效的。如果局部变量超出其生命周期,即它所占用的内存已经被释放或重用,那么通过指针访问它将导致未定义行为。
当 A 函数调用 B 函数时,B 函数可以通过指针访问和修改 A 函数中的局部变量。这是因为在 B 函数执行期间,A 函数的栈帧仍然存在于调用栈中,因此 A 中的局部变量仍然处于其生命周期内。为了安全地在 B 函数中操作 A 函数的局部变量,需要确保 B 函数在 A 函数结束前完成执行。这意味着 A 函数必须等待 B 函数的返回,才能继续执行或结束。
在设计程序时,应该考虑局部变量的存储位置和生命周期。如果需要在多个函数间共享数据,可能需要考虑使用全局变量、静态变量或通过参数传递的方式。未定义行为可能导致程序崩溃、数据损坏或不可预测的结果。因此,在编写涉及指针和跨函数操作的代码时,应该小心谨慎,确保所有操作都在变量的有效生命周期内进行。
// int* 指针类型作为形参,表示调用函数需要传入指针变量,也就是需要传入地址
void swap_poniter(int* a, int* b) {
int temp = *a;
*a = *b;
*b = temp;
}
int main(void) {
int a = 1;
int b = 2;
int *p_a = &a, *p_b = &b;
printf("调用交换函数之前,实参a = %d , b = %d\n", a, b);
swap_poniter(p_a, p_b);
// 等价于
//swap_poniter(&a, &b);
printf("调用交换函数之前,实参a = %d , b = %d\n", a, b);
return 0;
}
指针传递和修改的变量
当基本数据类型的实参直接作为参数传递给函数时,函数内部无法修改实参的值,因为传递的是值的副本。为了在函数内部修改实参的值,可以将实参的地址作为参数传递。这样,函数接收到的是指向实参的指针,从而能够修改实参的值。
如果将基本数据类型的实参的指针作为参数传递,函数内部可以修改实参的值,但不能修改指针本身的指向,因为传递的是指针值的拷贝。
如果需要在函数内部修改指针变量的指向,可以传递一个二级指针(即指向指针的指针)。这样,函数不仅可以修改原始变量的值,还可以修改指针变量的指向。
示例:
– int a = 10;
直接传递 a
作为参数,函数内部不能修改 a
。
– int a = 10; int *p = &a;
将 p
作为参数传递,函数内部可以修改 a
的值,但不能修改 p
的指向。
– int a = 10; int *p = &a; int **pp = &p;
将 pp
作为参数传递,函数内部可以修改 a
的值,也可以修改 p
的指向。
函数形参列表中的规律
void find_max_min(const int* arr, int len, int* pmax, int* pmin);
在这个示例中,我们可以看到两种类型的形参:
- 基本数据类型形参:
int len
,这种类型的参数是按值传递的,因此在函数内部对它的修改不会影响到函数外部的变量。 - 指针类型形参:可以分为两类:
- 传入参数:
const int* arr
,使用const
修饰的指针意味着函数不会修改它指向的数据。这种参数仅用于读取或访问传入的数据。 - 传入传出参数:
int* pmax
和int* pmin
,这些指针类型的参数在函数内部可以被用来修改外部数据,它们既用于读取数据,也用于输出数据。
- 传入参数:
- 当学习标准库函数时,如果看到
const
指针类型的基本数据类型形参,这表明函数内部不会修改原始数据。例如,文件操作函数通常需要const
修饰的字符串参数来表示文件路径。 - 对于指针类型的形参,如果没有
const
修饰,这表明函数内部可能会修改指针指向的内容。例如,scanf
函数需要指针来初始化指向的内存块。 - 在编写自己的函数时,如果指针形参不应在函数内部被修改,应该使用
const
修饰。如果确定会修改,就不使用const
。
当分析一个函数的声明,特别是其返回值类型为指针时,确实需要特别注意以下几点:
- 指针与栈区: 返回的指针绝不应该指向当前栈区的内存。这是因为函数的局部变量在函数调用结束后会释放,返回指向这些局部变量的指针会导致悬空指针问题。
-
指针与数据段: 返回的指针可能是指向数据段中的内存块。数据段中的数据具有静态存储期限,它们在程序的整个运行期间都是有效的。因此,返回指向数据段的指针是安全的,程序员不需要考虑这些内存的释放问题。
-
指针与堆区: 返回的指针可能是指向堆区的内存块。堆内存需要程序员手动管理,具有动态存储期限。如果函数返回指向堆内存的指针,程序员必须考虑内存的分配和释放,以避免内存泄漏。
-
示例:
int *test(int *p) { // 利用 p 指针的传参,对 p 指向的内存块做各种各样的处理 return p; }
这个函数接受一个指向
int
类型的指针p
,处理指向的内存块,并返回这个指针。这种设计在 C 语言中很常见,p
参数作为传入传出参数,由函数调用者提供并确保其正确性。 -
内存管理责任: 如果函数返回的是指向堆内存的指针,调用者需要负责管理这块内存,包括在适当的时候释放它。如果函数返回的是指向数据段的指针,调用者则不需要管理内存,因为数据段中的数据具有静态存储期限。
使用函数返回值的流程
在 C 语言中,调用函数并处理其返回值的过程遵循特定的调用约定。当一个函数被调用时,其参数通常首先被推入调用栈中,这是通过将参数复制到函数栈帧的特定位置来实现的。然后,函数开始执行。
对于返回值,大多数 C 编译器使用寄存器来存储小的数据类型的返回值,如整数或指针。当函数执行完毕并准备返回时,它的返回值会被放置在特定的寄存器中。如果返回值是一个复合数据类型或者大小超过了寄存器能够存储的范围,它可能会被存储在堆栈上,或者通过引用参数来传递。
当控制权返回到调用者时,调用者可以通过读取寄存器中的值或从堆栈中获取返回值来使用函数的返回结果。对于小数据类型,通常只需要从寄存器中读取返回值,而对于大数据类型,则可能需要进行一次内存拷贝操作。
并非所有情况下都会发生两次拷贝。实际上,返回值的传递机制取决于编译器的优化和函数返回类型的具体情况。
#include <stdio.h>
int SumIntArray(int array[], int length) {
int sum = 0;
for (int i = 0; i < length; ++i) {
sum += array[i];
}
// 第一次拷贝,函数栈中的函数返回值拷贝到寄存器中
// mov eax, DWORD PTR [rbp-4] # _10, sum
return sum;
}
int main() {
int array[5] = {0, 1, 2, 3, 4};
// 第二次拷贝,函数返回值从寄存器拷贝至函数栈中
// mov DWORD PTR [rbp-4], eax # b, tmp85
int b = SumIntArray(array, 5);
printf("%d", b);
return 0;
}
这个 demo 中,SumIntArray(array, 5)
的结果 sum
的值,就是先从函数栈拷贝到 eax
寄存器中,然后再从 eax
寄存器拷贝到变量 b
所在的函数栈地址中。
GCC 编译器下,通过 GDB 可以查看寄存器的值:
# 第一次拷贝
(gdb) info registers rbp
rbp 0x5ffe10 0x5ffe10
# 第二次拷贝(gdb) info registers rbp
rbp 0x5ffe60 0x5ffe60
大数返回值的处理
// TestBigValue:
// push rbp
// mov rbp, rsp
__int128 TestBigValue() {
// mov eax, 0
// mov edx, 0
return 0;
// pop rbp
// ret
}
// main:
// push rbp
// mov rbp, rsp
// sub rsp, 16
int main() {
// mov eax, 0
// call TestBigValue
// mov QWORD PTR [rbp-16], rax
// mov QWORD PTR [rbp-8], rdx
__int128 big_int = TestBigValue();
// mov eax, 0
return 0;
// leave
// ret
}
这里使用了两个寄存器进行存储。
#include <stdio.h>
typedef struct {
char *name_;
int gender_;
int age_;
char *school_name_;
} Student;
// TestStruct:
// push rbp
// mov rbp, rsp
// mov QWORD PTR [rbp-40], rdi
Student TestStruct() {
// mov QWORD PTR [rbp-32], OFFSET FLAT:.LC0 # student.names
// mov DWORD PTR [rbp-24], 1 # student.gender
// mov DWORD PTR [rbp-20], 17 # student.age
// mov QWORD PTR [rbp-16], OFFSET FLAT:.LC1 # student.school_name
Student student = {"Sakurakouji Runa", 1, 18, "Firia joshi daigaku"};
// mov rcx, QWORD PTR [rbp-40]
// mov rax, QWORD PTR [rbp-32]
// mov rdx, QWORD PTR [rbp-24]
// mov QWORD PTR [rcx], rax
// mov QWORD PTR [rcx+8], rdx
// mov rax, QWORD PTR [rbp-16]
// mov QWORD PTR [rcx+16], rax
return student;
// mov rax, QWORD PTR [rbp-40]
// pop rbp
// ret
}
// main:
// push rbp
// mov rbp, rsp
// sub rsp, 32
int main() {
// lea rax, [rbp-32]
// mov rdi, rax
// mov eax, 0
// call TestStruct
Student student = TestStruct();
// mov eax, 0
return 0;
// leave
// ret
}
这里使用了多个寄存器进行存储。
为了减少在函数调用过程中的数据拷贝,特别是在返回大型数据结构时,可以采用一种优化技术:将用于接收返回值的指针作为参数传递给函数。这样,函数可以直接在调用者提供的内存地址上构建或修改数据,从而避免了数据的两次拷贝。
具体来说,第一次拷贝发生在将参数传递到函数的栈帧时。然后,函数执行过程中,计算结果直接存储到由指针参数指定的内存位置,通常是调用者栈帧中的一个变量。这种方法只涉及到一次数据拷贝 —— 从函数的参数栈到寄存器,然后直接写入目标内存地址,省略了将返回值从寄存器拷贝回调用者栈帧的步骤。
// SumIntArray:
// push rbp
// mov rbp, rsp
// mov QWORD PTR [rbp-24], rdi
// mov DWORD PTR [rbp-28], esi
int SumIntArray(int array[], int length) {
// mov DWORD PTR [rbp-4], 0
int sum = 0;
// mov DWORD PTR [rbp-8], 0
for (int i = 0; i < length; ++i) {
// jmp .L2
// .L3
// mov eax, DWORD PTR [rbp-8]
// cdqe
// lea rdx, [0+rax*4]
// mov rax, QWORD PTR [rbp-24]
// add rax, rdx
// mov eax, DWORD PTR [rax]
// add DWORD PTR [rbp-4], eax
sum += array[i];
// add DWORD PTR [rbp-8], 1
// .L2:
// mov eax, DWORD PTR [rbp-8]
// cmp eax, DWORD PTR [rbp-28]
// jl .L3
}
// mov eax, DWORD PTR [rbp-4]
return sum;
}
// pop rbp
// ret
// SumIntArray:
// push rbp
// mov rbp, rsp
// mov QWORD PTR [rbp-24], rdi
// mov DWORD PTR [rbp-28], esi
// mov QWORD PTR [rbp-40], rdx
void SumIntArray2(int array[], int length, int *sum) {
// mov rax, QWORD PTR [rbp-40]
// mov DWORD PTR [rax], 0
*sum = 0;
// mov DWORD PTR [rbp-4], 0
// jmp .L2
for (int i = 0; i < length; ++i) {
// .L3:
// mov rax, QWORD PTR [rbp-40]
// mov edx, DWORD PTR [rax]
// mov eax, DWORD PTR [rbp-4]
// cdqe
// lea rcx, [0+rax*4]
// mov rax, QWORD PTR [rbp-24]
// add rax, rcx
// mov eax, DWORD PTR [rax]
// add edx, eax
// mov rax, QWORD PTR [rbp-40]
// mov DWORD PTR [rax], edx
*sum += array[i];
// add DWORD PTR [rbp-4], 1
// .L2:
// mov eax, DWORD PTR [rbp-4]
// cmp eax, DWORD PTR [rbp-28]
// jl .L3
}
// nop
// nop
// pop rbp
// ret
}
使用指针参数作为返回值的好处:
- 避免函数返回值带来的开销
- 实现函数多个返回值的目的
内存管理
存储期限
存储期限(Storage Duration)是指变量在内存中存在的时间。以下是三种主要的存储期限类型及其特点:
- 自动存储期限(Automatic Storage Duration):
- 位置:通常位于栈(Stack)上。
- 生命周期:与函数的调用周期相关。当函数被调用时,自动存储期限的变量被创建;当函数返回时,这些变量被销毁。
- 示例:函数的局部变量(非静态)。
- 特点:生命周期短,由系统自动管理。
- 静态存储期限(Static Storage Duration):
- 位置:通常位于数据段(Data Segment)。
- 生命周期:与整个程序的生命周期相同,从程序开始运行直到程序结束。
- 示例:全局变量、静态局部变量、字符串字面值。
- 特点:生命周期长,存储在程序的全局数据区,通常用于存储程序运行期间需要一直访问的数据。
- 动态存储期限(Dynamic Storage Duration):
- 位置:通常位于堆(Heap)上。
- 生命周期:由程序员通过动态内存分配函数(如
malloc
、calloc
、realloc
)和释放函数(如free
)来控制。 - 示例:通过
malloc
分配的内存。 - 特点:灵活性高,程序员需要手动管理内存的分配和释放,不当管理可能导致内存泄漏。
栈内存管理
栈内存管理的特点:
- 基于栈顶指针寄存器管理:栈内存管理依赖于一个栈顶指针寄存器(通常是 ESP 或 RSP),该寄存器的移动用来管理栈的内存空间。
- 自动内存管理:栈内存的分配和释放是自动的,当函数调用结束时,其栈帧会自动被销毁,内存被回收。
优点:
- 简单高效:由于栈的后进先出(LIFO)特性,栈内存管理非常高效,访问速度很快。
- 自动管理:程序员不需要手动管理栈内存,减少了内存泄漏的风险。
- 线程安全:每个线程有自己的栈,因此栈内存是线程安全的。
缺点:
- 大小限制:栈的大小通常较小,且在编译时确定,不适合存储大的数据结构。
- 编译时确定:函数的栈帧大小在编译时就确定,无法动态调整。
- 线程隔离:栈内存是线程隔离的,无法实现线程间数据共享。
不适合存储在栈上的数据:
- 大数组或大数据结构:由于栈空间有限,大数组或大数据结构不适合存储在栈上。
- 动态数据:需要在运行时确定大小的数据不适合存储在栈上,因为栈空间在编译时就固定了。
- 需要线程共享的数据:由于栈内存是线程隔离的,需要线程共享的数据无法存储在栈上。
堆内存管理
堆内存管理的特点:手动管理:堆内存的分配和释放需要程序员手动管理,这与自动管理的栈内存不同。
优点:
- 大容量:堆区域的大小虽然受限于计算机实际可用内存的大小,但通常远大于栈,可以存储很大的数据。
- 动态内存分配:堆内存可以在程序运行时动态分配和释放,适用于存储需要动态确定大小的数据。
- 线程共享:在多线程环境中,所有线程共享同一个堆空间,便于实现线程间的数据共享。
- 灵活的生命周期:程序员可以手动决定堆中数据的存活时间,堆中数据具有动态存储期限。
缺点:
- 性能问题:动态内存分配和回收的性能开销比栈操作要大,可能影响程序性能。堆内存动态分配过程可能涉及系统调用,对程序的性能影响较大。
- 管理复杂且风险高:内存管理是程序中的一个重要方面,将这一责任交给程序员可能会导致内存泄漏、野指针等安全问题,尤其是对于初学者来说。
- 线程安全问题:多个线程共享同一个堆,必须考虑线程同步和加锁等问题,这增加了复杂性,并可能导致性能下降。
内存分配使用的优先级
- 栈(Stack):
- 优先使用栈内存,因为它提供了自动内存管理和高效的访问速度。
- 适用于生命周期有限的局部变量。
- 堆(Heap):
- 当栈内存不足以满足需求时,考虑使用堆内存。
- 适用于大数据结构、大数组、大字符串或需要动态确定长度的数据。
- 数据段(Data Segment):
- 一般不推荐使用数据段进行内存分配,因为它可能导致内存碎片和程序性能下降。
注意事项:
- 合理使用堆内存:
- 程序员在编写程序时,应首先考虑使用栈内存。只有在栈内存不可用时,才考虑使用堆内存。
- 动态内存分配的场景:
- 当需要存储的数据大小在编译时无法确定,或者数据需要在程序运行期间动态变化时,适合使用堆内存。
- 堆内存分配的类型:
- 在堆上分配数组或结构体是常见的做法,因为它们的大小可能是可变的。对于基本数据类型,通常不需要在堆上分配,除非有特殊需求。
动态内存分配
动态内存分配的注意事项:
- 检查返回值类型:使用
malloc
、calloc
或realloc
函数时,应确保理解这些函数返回的指针类型,并正确地使用变量接收返回值。不应使用数组声明来接收这些函数的返回值。 - 验证内存分配是否成功:分配内存后,首要任务是检查分配是否成功。这通常通过检查返回的指针是否为
NULL
来完成。如果指针为NULL
,则表示内存分配失败。 - 错误处理:如果内存分配失败,必须进行错误处理。在 C 语言中,由于缺乏异常机制,错误处理通常依赖于函数返回值。
总结规律:
- 对于返回指针类型的函数,如动态内存分配函数,错误标志通常是返回
NULL
指针。 - 对于返回
int
类型的函数,如printf
家族函数,错误标志通常是返回-1
。
malloc
在 C 语言编程中,使用 malloc
函数可以从堆区分配内存,这与在栈上自动分配的数组不同。栈上分配的数组在函数调用结束时会自动释放,而通过 malloc
分配的内存则位于堆区,其生命周期不会随着函数调用的结束而结束。这意味着,程序员必须负责管理这部分内存的生命周期,包括在不再需要时使用 free ()
函数来显式释放内存。
malloc
函数的使用注意事项:
- 参数:
malloc
函数只有一个参数size
,表示在堆上分配的内存大小,单位为字节。 - 返回类型:
malloc
返回一个通用指针类型void*
。在使用分配的内存之前,需要将其转换为适当的指针类型。 - 内存初始化:分配的内存块未初始化,包含随机值。在使用前,应手动初始化内存,以避免未定义行为。
- 错误处理:
- 如果内存分配成功,
malloc
返回指向分配内存块首字节的地址。 - 如果分配失败,返回
NULL
。此时,应检查返回值并进行适当的错误处理。
- 如果内存分配成功,
#include <stdio.h>
#include <stdlib.h>
#define PLAYER_COUNT 10
int main() {
int *players = malloc(PLAYER_COUNT * sizeof(int));
for (int i = 0; i < PLAYER_COUNT; ++i) {
players[i] = i;
}
for (int i = 0; i < PLAYER_COUNT; ++i) {
printf("%d ", players[i]);
}
free(players); // 释放内存
return 0;
}
内存泄露与内存溢出
内存泄漏(Memory Leak):
- 定义: 内存泄漏发生在程序未能在适当的时机释放不再使用的内存区域,导致这部分内存空间始终被占用,无法重新利用。
- 影响: 内存泄漏会导致系统的可用内存逐渐减少。虽然短期内可能不会对程序造成严重影响,但长期累积可能导致严重后果。
- 长期风险: 在长时间运行或频繁执行的程序中,内存泄漏可能导致程序运行缓慢甚至崩溃,特别是在内存资源有限的系统中。
- 与内存溢出的关系: 内存泄漏的累积有时可能导致内存溢出,但两者之间并没有必然的联系。
内存溢出(Memory Overflow):
- 定义: 内存溢出发生在程序分配了过大的内存空间,超出了系统可用内存的容量,导致内存空间“生长”到不属于它的内存区域,引发越界访问和修改。
- 后果: 内存溢出可能导致程序崩溃或数据损坏,对系统的稳定性构成威胁。
calloc
calloc
函数是 C 语言中用于动态内存分配的另一种工具,它在功能上与 malloc
相似,但有一个关键的区别:calloc
在分配内存后会自动将所有字节初始化为零。这意味着当你使用 calloc
来分配内存时,得到的内存块中的每个字节都被预设为 0 值,省去了手动初始化的步骤。
calloc
函数的行为:
- 内存分配:与
malloc
类似,calloc
函数也会在堆上分配一段连续的内存空间,并返回一个指向该内存块的指针。 - 内存初始化:
calloc
的一个显著特点是它会将分配的内存块初始化为零值。这是calloc
与malloc
最大的区别。 - 函数声明:
calloc
的函数声明如下:void *calloc(size_t num, size_t size)
其中num
是内存块元素的个数,size
是每个元素的大小(以字节为单位)。 - 适用场景:
calloc
函数特别适合用于分配数组的内存空间,因为它会自动将数组初始化为零。 - 性能差异:由于初始化内存的额外步骤,
calloc
可能比malloc
稍慢。如果性能是关键考虑因素,malloc
可能是更好的选择。 - 安全性差异:
calloc
分配的内存块中没有随机值,都是默认的零值,这使得它在某些情况下更安全。
players = calloc(PLAYER_COUNT, sizeof(int));
realloc
realloc
是一个动态内存管理函数,它允许程序员在不丢失原有数据的前提下,对已分配的内存块进行大小调整。使用 realloc
,可以扩展或缩减内存块,这在需要根据程序运行时的需要调整内存使用量时非常有用。
当使用 realloc
来增加内存块大小时,新的内存块将包含原始内存块的内容,并且可能包含额外的未初始化的内存。这意味着,新增的内存部分可能包含任何值,包括垃圾数据,因此不能假定它们是零或其他特定值。
realloc
函数是一个动态内存重新分配函数,它可以调整已经分配的内存块的大小。
- 函数声明:
void *realloc(void *ptr, size_t newsize)
- 参数:
ptr
:指向原来已分配内存的内存块的指针。newsize
:新的内存块大小。
- 特殊行为:
- 当
ptr
是NULL
时,realloc
函数的行为类似于malloc
,分配一个新的内存块。 - 当
newsize
为0
时,realloc
函数的行为类似于free
,释放传入的内存块。
- 当
- 内存调整:
- 如果
newsize
小于原始内存块的大小,realloc
会截断内存块,保留从低地址到newsize
的部分。 - 如果
newsize
大于原始内存块的大小,realloc
会扩容内存块,可能移动内存块到新的地址。
- 如果
- 内存截断:在内存截断过程中,
realloc
会自动处理被截断的内存部分,程序员不需要手动释放这部分内存。
int *players;
InitPointer(&players, PLAYER_COUNT, 2);
// 函数 realloc 的用法
players = realloc(players, 4 * sizeof(int)); // 重新分配四块内存
for (int i = 0; i < PLAYER_COUNT; ++i) {
printf("%d ", players[i]); // 2 2 2 2 -842150451 ... ,只能确定前四个值,后面的值无法确定,因为是在原有空间的基础上重新分配了四块内存
}
printf("\n");
players = realloc(players, PLAYER_COUNT * 2 * sizeof(int)); // 重新分配20块内存
for (int i = 0; i < PLAYER_COUNT * 2; ++i) {
printf("%d ", players[i]); // 2 2 2 2 -842150451 -842150451 -842150451 ... 后面一直都是 -842150451
}
free(players);
realloc
函数用于调整已分配内存块的大小,与 malloc
和 calloc
相比,它需要特别的错误处理。realloc
可能会失败,例如当没有足够的内存可用时。因此,不能直接使用原始指针接收 realloc
返回的内存块,惯用写法是创建一个临时指针来接收 realloc
返回的内存块,并进行错误处理。
int *tmp = realloc(p, 5 * sizeof(int));
if (tmp == NULL) {
printf("error: realloc failed in main.\n");
/**
* realloc 失败了
* 那么原本 malloc 分配的内存块怎么办呢? 看需求
* 可以 free 掉
* 也可以做其他的处理
*/
free(p);
exit(-1);
}
p = tmp;
print_arr(p, 5);
free
free
函数用于释放之前通过 malloc
、calloc
或 realloc
函数分配的堆内存块。
- 函数声明:
void free(void *ptr)
- 参数要求:
free
函数需要传入一个指向已分配堆内存块的指针。 - 内存释放:
free
函数释放内存块,但不会修改内存块中的数据。它仅通知系统该内存可以被重新分配。 - 指针不变性:由于 C 语言使用值传递,
free
函数不会修改传入的指针。因此,原始指针在free
调用后仍然是一个悬空指针(野指针)。 - 后续使用:如果指针需要继续使用,建议将其置为
NULL
,以避免悬空指针带来的风险。 - 避免移动原始指针:分配内存时获得的原始指针不应移动。如果需要移动指针,应创建一个临时指针进行操作,并保留原始指针。
- 避免重复释放:避免
double free
,即不要重复调用free
释放同一块内存。 - 单一职责原则:在多函数管理同一块内存时,应遵循单一职责原则。明确哪个函数负责分配内存,哪个函数负责释放内存。
尽管 free
函数释放了内存,但是内存块中的数据实际上并没有被修改。在某些平台上,例如 MSVC,可能会看到释放后的内存被标记为 0xDD
或其他模式,这是一种调试时的可视化手段,用于帮助程序员识别内存状态。并非所有平台都具备此特性。因此,最佳实践是在使用完动态分配的内存后,将其指针设置为 NULL
,以避免悬空指针的风险。
分配内存之后
在 C 语言编程中,动态内存分配是一个常见的操作,但这个过程并不总是成功的。当调用 malloc
、calloc
或 realloc
函数时,可能会由于多种原因(如内存不足)导致分配失败。因此,在这些函数调用之后,应该立即检查返回的指针是否为 NULL
,这是一个良好的编程实践。
如果指针是 NULL
,这意味着内存分配未能成功,程序应该采取适当的错误处理措施,例如释放已分配的内存(如果调用的是 realloc
),并可能需要记录错误或终止程序。
if (players) {
... // 指针指向的地方有内存才有意义
} else {
...
}
注意事项
常见指针使用错误:
- 忘记释放内存:使用
malloc
、calloc
或realloc
分配的内存,如果不再需要,应该使用free()
函数及时释放,以避免内存泄漏。 - 使用已释放的内存:释放内存后,如果没有将指针设置为
NULL
,再次使用该指针可能导致野指针错误。 - 内存越界:访问数组或内存块的边界之外,可能导致未定义行为或程序崩溃。
- 指针丢失:改变指针的值,使其指向其他内存地址,可能导致原始内存无法释放,因为没有任何指针指向它。
改进建议:
- 保持指针稳定:一旦分配了内存,避免修改指针的值,直到内存被释放。
- 释放后归零:释放内存后,应将指针显式设置为
NULL
,以避免悬垂指针问题。 - 避免多指针管理:尽量不要让多个指针指向同一块动态分配的内存,这可能导致释放内存时的混乱。
- 遵守释放原则:坚持“谁分配,谁释放”的原则,确保内存管理的清晰和一致性。
二级指针
在 C 语言中,若需在函数间传递二维数组,应使用二维指针作为参数。例如,声明一个函数参数为 int **array
,允许函数访问和修改多维数组的内容。此外,当使用 free ()
函数释放内存后,内存块中原有的数据并不会被清除,它们依然保留在内存中。这意味着,如果再次使用 malloc
为相同大小的数组分配内存,新内存块中将包含之前释放的内存块的数据。因此,在使用 malloc
分配新内存后,通常需要对内存块进行初始化,以确保数据的一致性和预期行为。
#include <stdio.h>
#include <stdlib.h>
#define PLAYER_COUNT 10
void InitPointer(int **ptr, int length, int default_value){
*ptr = malloc(length * sizeof(int));
for (int i = 0; i < length; ++i) {
(*ptr)[i] = default_value;
}
}
int main(){
// 和向函数传递想要修改的参数值的情况相同,必须将变量的地址传入函数才能进行修改,此处就是指针变量的地址传入 InitPointer 函数,进而修改指针变量的值
int *players;
InitPointer(&players, PLAYER_COUNT, 2);
for (int i = 0; i < PLAYER_COUNT * 2; ++i) {
printf("%d ", players[i]);
}
free(players);
return 0;
}
一级指针和二级指针的使用
- 基本数据类型和结构体对象:
- 当函数参数传递基本数据类型变量或结构体对象时,这些参数是通过值传递的。因此,函数内部对这些参数的修改不会影响到原始变量。
- 一级指针:
- 当函数参数传递的是基本数据类型变量或结构体对象的指针时,函数可以通过指针修改原始指针指向的内容,但无法修改指针本身的指向。
- 二级指针:
- 二级指针是指向指针的指针。如果函数参数传递的是二级指针,那么函数可以:
- 解引用一次,修改原本一级指针的指向。
- 解引用两次,修改原本一级指针指向的内容。
- 二级指针是指向指针的指针。如果函数参数传递的是二级指针,那么函数可以:
- 一级指针允许函数修改指针指向的内容,但不允许修改指针本身的指向。
- 二级指针提供了更多的灵活性,允许函数修改指针的指向和指针指向的内容。
- 在实际应用中,二级指针的使用已经足够满足大多数需求,更高级别的指针较为少见。
函数指针与 typedef
函数指针
函数指针的概念:
- 定义: 函数指针是一种特殊的指针类型,它存储的是函数的地址。函数指针允许将函数作为参数传递给其他函数,或者在运行时动态地调用函数。
- 函数地址:
- 每个函数在编译后都会转换为一系列机器指令,这些指令存储在程序的代码段(只读)中。
- 函数的地址是指向这些指令序列起始点的内存地址,即函数的入口点。
函数指针的用途:
- 提高编程灵活性:函数指针的主要作用是将函数作为参数传递给其他函数,这使得编程更加灵活。
- 函数回调:
- 将函数作为参数传递的语法被称为“函数回调”。被传参的函数称为回调函数。
- 通过函数回调,可以在运行时自由地决定调用哪个函数,这为程序提供了更大的灵活性和可扩展性。
- 应用示例:
- 排序函数
qsort
使用函数指针作为参数,允许用户传递自定义的比较函数,实现任意规则的排序。 - 映射函数
map
使用函数指针作为参数,允许用户传递自定义的映射规则,实现任意规则的映射。
- 排序函数
函数指针变量的基本使用:
- 声明函数指针变量:
- 函数指针变量的声明需要指定函数的返回值类型、指针变量名以及函数的参数列表。
- 语法示例:
返回值类型(∗指针变量名)(参数列表);
- 为了简化使用,可以为函数指针类型起别名:
typedef 返回值类型(∗函数指针别名)(参数列表);
- 初始化函数指针变量:
- 在 C 语言中,函数名代表该函数的地址,因此可以直接使用函数名来初始化函数指针变量。
- 函数指针变量的初始化类似于数组名的使用,函数名不能被改变指向。
- 函数指针的等价性:函数名和取地址操作
&函数名
是完全等价的,编译器会自动判断它们是同等概念。 - 调用函数指针:
- 有多种方式可以通过函数指针调用函数:
- 直接使用函数指针变量调用函数。
- 使用函数指针变量解引用后调用函数(不推荐,编译器会自动优化)。
- 有多种方式可以通过函数指针调用函数:
// 给void返回值类型和void形参的函数指针类型起一个别名
typedef void (*VoidPtr)(void);
// 给int返回值类型和int形参的函数指针类型起一个别名
typedef int (*IntPtr)(int);
void test(void) {
printf("有一种爱叫做放手!\n");
}
int test2(int a) {
printf("传参的数字是: %d\n", a);
}
// 函数指针类型作为形参, 这种方式会麻烦一些,不推荐
void test3(void (*ptr)(void)) {
}
// 推荐使用类型别名
void test4(VoidPtr ptr) {
}
int main(void) {
// 声明一个函数指针类型变量ptr, 可以指向函数test
void (*ptr)(void) = test;
// 声明一个函数指针类型变量ptr2, 可以指向函数test2
int (*ptr2)(int) = test2;
// 利用别名,可以更简单的声明函数指针类型的变量
VoidPtr ptr3 = test;
IntPtr ptr4 = test2;
test();
(*test)(); // 和上面等价的调用方式
// 以下四种方式都是等价的
int ret = ptr4(100); // 推荐
int ret2 = (*ptr4)(100); // 不推荐
int ret3 = test2(100); // 推荐
int ret4 = (*test2)(100); // 不推荐
return 0;
}
打印现有的函数地址
#include <stdio.h>
#include <malloc.h>
void InitPointer(int **ptr, int length, int default_value) {
*ptr = malloc(length * sizeof(int));
for (int i = 0; i < length; ++i) {
(*ptr)[i] = default_value;
}
}
int main() {
printf("main: %#x\n", main); // main: 0x1ffa10e6
printf("&main: %#x\n", &main); // &main: 0x1ffa10e6
printf("pointer: %#x\n", InitPointer); // pointer: 0x1ffa118b
printf("&pointer: %#x\n", &InitPointer); // &pointer: 0x1ffa118b
return 0;
}
函数地址存入指针变量
在 C 语言中,函数名本身就表示了函数的入口地址,这意味着我们可以将函数的地址赋给一个指针变量,这种指针称为函数指针。为了正确地声明一个函数指针,我们需要确保指针的类型与它所指向的函数的签名(返回类型和参数列表)一致。
int a;
int *p; // 观察两者的区别,数据类型不变,(变量名)改换为(* + 变量名)
// void InitPointer(int **ptr, int length, int default_value)
// 对比上面进行改写,数据类型 void + * + 指针变量名 + 函数的参数
使用函数指针
函数指针在 C 语言中允许我们将函数的执行能力 “存储” 在一个变量中。使用函数指针就像使用普通函数一样,但首先需要声明一个正确类型的指针变量,使其指向特定的函数。
一旦函数指针被正确初始化,就可以通过以下几种方式调用它指向的函数:
func(&p, 5, 1); // 等同于 InitPointer(&p, 5, 1);
InitPointer(&p, 5, 1);
(*func)(&p, 5, 1);
(*InitPointer)(&p, 5, 1); // 注意,这样也能调用
for (int i = 0; i < 5; ++i) {
printf("%d ", p[i]); // 1 1 1 1 1
}
free(p); // p 是动态分配的内存,别忘记要 free()/ &pointer: 0x1ffa118b
- 直接使用函数指针调用:
func (&p, 5, 1);
这里func
是指向函数的指针。 -
使用函数名调用:
InitPointer (&p, 5, 1);
这里InitPointer
是函数名。 -
通过解引用函数指针调用:
(*func)(&p, 5, 1);
这里通过解引用func
来调用函数。 -
通过解引用函数名调用:
(*InitPointer)(&p, 5, 1);
这种方式较少使用,但语法上是正确的。这种用法与数组名类似,数组名是指向数组第一个元素的指针常量。相应地,函数名在大多数表达式中表现得就像指向函数的指针常量。当我们对函数名进行解引用时,我们实际上是在获取函数的入口地址,并直接调用该地址处的代码。
在上述示例中,p
是动态分配的内存,因此在不再需要时,应使用 free (p);
来释放内存。
函数指针与函数名的关系在于,函数名在大多数情况下被编译器视为指向该函数的指针常量。因此,当我们将函数名用于调用时,编译器实际上在背后使用了一个隐式的函数指针。函数指针提供了这种隐式行为的显式形式,允许程序员在运行时动态地改变函数的行为。
int a[] = {1,2,3,4};
int *p = a;
printf("a: %#x\n", a); // a: 0x606ffc68
printf("&a: %#x\n", &a); // &a: 0x606ffc68
printf("p: %#x\n", p); // p: 0x606ffc68
// 1
int *f1(int, double); // == int *(f1(int, double)), f1 函数, 返回值是指针
// 2
int (*f2)(int, double); // 函数指针,返回值是 int
// 3
int *(*f3)(int, double); // 函数指针,返回值是 int*
// 4 这里实质上是一个函数指针,不过返回值是数组,但 C 语言不支持返回值是数组,所以会报错
// 写成刚才那样看不出返回值,如果写成这样呢:int[] (*f4)(int, double); 类似 java 的方式,是不是清晰多了
// 之所以把括号放在后面,是因为 C 定义数组的时候是 int a[] 而不是 int[] a,所以这里函数返回数组的时候,括号也要放在后面
// int (*f4)(int, double)[]; // 如果将 (*f4)(int, double) 看作一个整体,当成 a,是不是 int a[];
// 5
// int (*f5)[](int, double); // 把 (*f5) 看作 f,这里就变换为 int f[](int, double),其实是个函数数组,但这在 C 语言中是不允许的
// 6 注意,[] 优先级比 * 优先级要高
int (*f6[])(int, double); // == int (*(f6[]))(int, double); 注意结合顺序,f6 先和 [] 结合
// 首先这是一个 f6 数组,然后是这是一个什么类型的数组呢?其实是一个指针类型的 f6 数组;然后这个指针是什么类型呢?其实是一个函数类型的指针;所以合起来就是函数指针的数组,每一个元素都是一个函数指针
typedef
类型别名的优势:
- 提升代码可读性:通过为数据类型定义一个描述性的别名,可以增强代码的可读性和直观性。例如,在实现链表时,可以将链表的节点定义为一个结构体类型,并为其提供一个直观的别名,如
Node
,而不是使用通用的struct
关键字。 - 增强代码的扩展性:使用类型别名可以简化代码的修改和扩展。例如,如果链表中存储的数据类型需要改变,只需修改类型别名的定义,而无需修改链表实现中的每个数据类型声明。
- 提高代码的跨平台性:类型别名允许程序员为不同的平台定义适当的数据类型。例如,在 32 位平台上,可以使用
int
作为大整数的类型,而在 16 位平台上,可以使用long long
。通过修改类型别名的定义,可以确保代码在不同平台上的一致性和正确性。
typedef int *IntPtr; // 将 int* 取别名为 IntPtr
int *p1;
IntPtr p2;
typedef int IntArray[]; // typedef int[] IntArray; 这样写虽然不合法,但是不是清楚了许多,为创建数组取个别名,叫 IntArray
int array1[] = {1,2,3,4};
IntArray array2 = {1,2,3,4};
qsort
函数声明与参数
qsort
函数是基于快速排序算法实现的,它不是稳定的排序算法,这意味着相同元素的相对顺序可能会在排序过程中改变。
- 函数声明:
void qsort(void* ptr, size_t count, size_t size, int (*comp)(const void*, const void*));
- 参数解释:
base
:通用指针类型,指向需要排序的数组的起始位置。由于是通用指针,它可以指向任意类型的数组。num
:无符号整数,表示数组base
中元素的个数,即数组的长度。size
:无符号整数,表示数组base
中每个元素的大小(以字节为单位)。compare
:函数指针,指向一个比较函数,用于比较两个元素。该函数应返回一个整数,如果第一个参数小于第二个参数则返回负数,如果相等则返回 0,如果大于则返回正数。
- 比较函数示例:
int compare(const void *a, const void *b);
- 这个函数应该根据数组元素的类型进行相应的比较逻辑实现。
- 比较函数的返回值:
- 如果返回值大于 0,则认为
a
大于b
,在排序中a
应该在b
的后面。 - 如果返回值小于 0,则认为
a
小于b
,在排序中a
应该在b
的前面。 - 如果返回值等于 0,则认为
a
和b
相等,在排序中a
和b
的位置可以相邻。
- 如果返回值大于 0,则认为
示例代码
int
数组从大到小排序
在 qsort
函数的比较函数中,参数 a
和 b
是通用指针类型,它们代表待排序数组中两个待比较元素的指针。
- 参数
a
和b
的含义:当待排序的数组是int
类型时,a
和b
实际上就是指向int
类型的指针。 - 排序规则:如果排序规则是从大到小,即按照
int
数值的降序排序,比较函数需要相应地调整。 - 比较逻辑:站在元素
a
的立场上,如果a
的值越大,它应该在排序后的数组中排在前面。这意味着在比较函数中,a
应该被认为小于b
。 - 比较函数的返回值:为了让值较大的元素排在前面,比较函数应该在
a
值较大时返回负数。
int my_cmp(const void *a, const void *b) {
int *num1 = (int *)a;
int *num2 = (int *)b;
return *num2 - *num1;
}
int main(void) {
int arr[] = {1, 21, 3, 123, 12, 3, 123, 4, -245, 346, 4, 5, 34, -23, 42, 4, 3245, 4, 6, 456, 45, 6, -4, 2343};
int len = ARR_SIZE(arr);
qsort(arr, len, sizeof(int), my_cmp);
return 0;
}
字符串数组按照字典序排序
当使用 qsort
函数对字符串数组进行排序时,需要提供一个自定义的比较函数,该函数根据 strcmp
的返回值来确定字符串的相对顺序。
- 比较函数
my_cmp2
的实现:- 该函数接受两个
const void*
类型的参数,它们是指向待比较字符串的指针。 - 由于数组元素是
char*
类型,参数a
和b
实际上是指向char*
的指针,即二级指针char**
。
- 该函数接受两个
- 正确地解引用指针:不能直接将二级指针转换为一级指针,需要先解引用。例如,
*(char **)a
正确地获取了指向字符串的指针。 - 比较函数的返回值:使用
strcmp
函数比较两个字符串。strcmp
返回 0 如果两个字符串相等,返回小于 0 的值如果第一个字符串在字典序上在前,返回大于 0 的值如果第一个字符串在字典序上在后。
int my_cmp2(const void *a, const void *b)
{
//(char*)a; // 典型错误, 二级指针不能直接强转成一级指针, 需要解引用才行
char *str1 = *(char **)a; // () 不能省略, 省略就错了
char *str2 = *(char **)b; // () 不能省略, 省略就错了
return strcmp(str1, str2);
}
int main(void) {
char *strs[] = {"a", "b", "aaa", "aa", "bbb", "ccc", "nba", "bca"};
int strs_len = ARR_SIZE(strs);
qsort(strs, strs_len, sizeof(char *), my_cmp2);
return 0;
}
结构体数组排序
结构体定义:
typedef struct
{
int stu_id;
char name[25];
int age;
int total_socre;
} Student;
将学生数组按照成绩由高到低排序:为了实现降序排序,比较函数计算 s2
的总成绩与 s1
的总成绩之差。
int my_ruler1(const void *a, const void *b)
{
Student *s1 = a;
Student *s2 = b;
return s2->total_socre - s1->total_socre;
}
将学生数组按照学号从小到大排序:为了实现升序排序,比较函数计算 s1
的学号与 s2
的学号之差
int my_ruler2(const void *a, const void *b)
{
Student *s1 = a;
Student *s2 = b;
return s1->stu_id - s2->stu_id;
}
综合排序:先按总分从高到低进行排序,若总分相同则按照年龄从低到高排序,若仍然都相同,则按照名字的逆字典顺序排序。
int my_ruler3(const void *a, const void *b)
{
Student *s1 = a;
Student *s2 = b;
if (s1->total_socre != s2->total_socre)
{
return s2->total_socre - s1->total_socre;
}
// 代码运行到这里, 说明总分是一样的, 于是按照年龄排序 (但前提是年龄不相同)
if (s1->age != s2->age)
{
return s1->age - s2->age;
}
// 代码运行到这里, 说明总分和年龄都是一样的, 于是按照名字的逆字典顺序排列
return strcmp(s2->name, s1->name);
}