C语言性能提升的3个技巧(新手必看)
由于 C 语言是最接近汇编语言的编程语言,相比其他更高级的编程语言,通常使用 C语言编写的程序可以获得最好的运行速度。
但是,也正因为 C 语言有优越的性能表现,程序员在使用 C 语言的过程中,往往会无视一些低效的代码。这些低效的代码在开发环境中很难察觉,但是当它们成为调用热点时,可能就会对程序的整体性能产生明显的影响。
这里的调用热点是指在程序运行过程中,某个(或某些)函数会被频繁调用;也就是说,在给定统计周期内,这个(或这些)函数被调用的次数远远超过其他函数。如果构成调用热点的函数恰好未经充分优化,则大概率会形成程序的性能瓶颈。
为避免这种情况,在日常编码中,C 程序员应该时刻牢记以下 3 个技巧。
比如下面的代码:
本质上,上述两个操作是重复的,只需要保留一个即可。从最终生成的汇编代码角度看,使用编程语言提供的赋值语句进行初始化的执行效率要更高些。
从这段代码的最终执行结果看,以上两个操作都是多余的,因为 strcpy() 函数会将字符串常量"foo"最后的空终止字符(\0)一并复制到 buf 中。故而,以上代码段只需要保留如下两行:
更进一步地,我们可以将这段代码简化为一条赋值语句:
读到这里,读者可能会有疑问,如此低效的代码在实际项目中应该不多见吧?现实情况是,这类代码极可能出自“有丰富经验”的老手。究其原因,他们被可能的内存使用错误搞怕了,于是不问青红皂白,只要程序中用到数组,就调用 memset() 重置一下。
另一种常见的无用功,便是执行一些不必要的额外检查。下面的代码实现了一个 bar() 函数:
这段代码的无用功在于,当我们测试到 length 为零时,调用了 strlen(str) 函数来计算字符串的长度。实际上,计算字符串的长度要循环查找空终止字符,而其后的 while 循环也要检测空终止字符,因此便出现了多余的测试。
要优化这一实现,只需要在 length 为零时,将 SIZE_MAX 宏的值赋给 length 即可——反正 while 循环始终会判断是否到达字符串尾部,那就当其长度为最大的可能值好了。这里的 SIZE_MAX 宏定义了 size_t 的最大值,SIZE_MAX 宏定义在 stdint.h 头文件中。优化后的实现如下:
在内存中执行格式化输入输出的 STDIO 接口,如 sprintf() 和 sscanf() 函数,首先会调用 fmemopen() 函数构造一个基于内存的 FILE 对象,然后调用 fprintf()、fscanf() 等函数完成最终的格式化输入输出功能,最后销毁临时构建的 FILE 对象。
因此,这些接口的开销较大:不论从空间复杂度看还是从时间复杂度看,都远大于 strcat()、strcpy()、atoi() 等函数。如果只是想完成字符串的串接或者单个字符串转整数的功能,大可不必调用 STDIO 接口,而应该调用其他的标准库函数,如下所示:
如果对性能仍不满足,则可以进一步优化以上字符串串接代码。strcat() 首先会找到 a_buffer 的尾部,然后复制 another_string 的内容直到字符串末尾。但实际上,strcpy() 函数在其内部实现中一定已经循环到了 a_string 的尾部,故而也知道 a_buffer 中字符串的尾部地址。然而,strcpy() 函数并没有返回 a_buffer 中指向字符串尾部的指针,返回的却是 a_buffer 本身。
幸好,为满足这一需求,标准库特意增加了一个接口 stpcpy(),该接口复制字符串并返回指向其尾部的指针:
故而,我们可以进一步优化以上用来串接两个字符串的代码:
以常见的串接给定的路径和文件名,并读取文件内容的函数为例,函数原型如下:
单看该函数中用于串接 path 和 fname 生成完整路径名的代码,一个简单的实现是调用 asprintf() 函数:
为此,我们可以做一些调整:
进一步调整后的实现如下:
因此,对此类缓冲区的分配,实践中更为有效的办法是根据所要分配的缓冲区大小灵活使用栈空间或者自行分配合适的栈空间大小:
但是,也正因为 C 语言有优越的性能表现,程序员在使用 C 语言的过程中,往往会无视一些低效的代码。这些低效的代码在开发环境中很难察觉,但是当它们成为调用热点时,可能就会对程序的整体性能产生明显的影响。
这里的调用热点是指在程序运行过程中,某个(或某些)函数会被频繁调用;也就是说,在给定统计周期内,这个(或这些)函数被调用的次数远远超过其他函数。如果构成调用热点的函数恰好未经充分优化,则大概率会形成程序的性能瓶颈。
为避免这种情况,在日常编码中,C 程序员应该时刻牢记以下 3 个技巧。
避免做无用功
常见的无用功出现在如下两个场景中:局部变量尤其是数组的初始化以及多余的函数调用。比如下面的代码:
void foo(void) { char buf[64] = {}; memset(buf, 0, sizeof(buf)); strcpy(buf, "foo"); ... }以上代码最终的执行结果是将"foo"字符串复制到 buf 中。然而以上代码执行了多次不必要的初始化工作:
- 第一,在声明 buf 时,使用 {} 执行赋值操作,这会置 buf 中的所有成员为 0;
- 第二,调用 memset() 函数重置 buf 中的各字节为 0。
本质上,上述两个操作是重复的,只需要保留一个即可。从最终生成的汇编代码角度看,使用编程语言提供的赋值语句进行初始化的执行效率要更高些。
从这段代码的最终执行结果看,以上两个操作都是多余的,因为 strcpy() 函数会将字符串常量"foo"最后的空终止字符(\0)一并复制到 buf 中。故而,以上代码段只需要保留如下两行:
void foo(void) { char buf[64]; strcpy(buf, "foo"); ... }
更进一步地,我们可以将这段代码简化为一条赋值语句:
void foo(void) { char buf[64] = "foo"; ... }根据 C语言语法,以上语句会将字符串常量 "foo" 连同最后的空终止字符一并复制到 buf 中,而无须调用 strcpy() 函数,这样便可省去调用函数的开销。
读到这里,读者可能会有疑问,如此低效的代码在实际项目中应该不多见吧?现实情况是,这类代码极可能出自“有丰富经验”的老手。究其原因,他们被可能的内存使用错误搞怕了,于是不问青红皂白,只要程序中用到数组,就调用 memset() 重置一下。
另一种常见的无用功,便是执行一些不必要的额外检查。下面的代码实现了一个 bar() 函数:
#include <string.h> static int bar(const char* str, size_t length) { if (str == NULL) return 0; if (length == 0) length = strlen(str); while (*s && length) { ... length--; } ... }该函数接收两个参数,一个参数是字符串指针,另一个参数是长度。从代码已有的实现看,length 限定了该函数需要处理的最大字符个数;若给定的 length 为零,则意味着处理到字符串尾部。故而在 while 循环中既检查了 *s 的值,也检查了 length 的值,并在循环的末尾执行了 length--。
这段代码的无用功在于,当我们测试到 length 为零时,调用了 strlen(str) 函数来计算字符串的长度。实际上,计算字符串的长度要循环查找空终止字符,而其后的 while 循环也要检测空终止字符,因此便出现了多余的测试。
要优化这一实现,只需要在 length 为零时,将 SIZE_MAX 宏的值赋给 length 即可——反正 while 循环始终会判断是否到达字符串尾部,那就当其长度为最大的可能值好了。这里的 SIZE_MAX 宏定义了 size_t 的最大值,SIZE_MAX 宏定义在 stdint.h 头文件中。优化后的实现如下:
#include <stdint.h> static int bar(const char* str, size_t length) { if (str == NULL) return 0; if (length == 0) length = SIZE_MAX; while (*s && length) { ... length--; } ... }如此便可免去一次多余的 strlen() 函数调用。
避免滥用接口
滥用标准 C 库接口或者某些第三方函数库的接口。最为常见的便是滥用 STDIO 接口。比如下面的两个函数调用:sprintf(a_buffer, "%s%s", a_string, another_string); sscanf(a_string, "%d", &i);这两个函数调用分别完成了串接两个字符串以及将字符串转换为一个整型值的功能。
在内存中执行格式化输入输出的 STDIO 接口,如 sprintf() 和 sscanf() 函数,首先会调用 fmemopen() 函数构造一个基于内存的 FILE 对象,然后调用 fprintf()、fscanf() 等函数完成最终的格式化输入输出功能,最后销毁临时构建的 FILE 对象。
因此,这些接口的开销较大:不论从空间复杂度看还是从时间复杂度看,都远大于 strcat()、strcpy()、atoi() 等函数。如果只是想完成字符串的串接或者单个字符串转整数的功能,大可不必调用 STDIO 接口,而应该调用其他的标准库函数,如下所示:
// sprintf(a_buffer, "%s%s", a_string, another_string); strcpy(a_buffer, a_string); strcat(a_buffer, another_string); // sscanf(a_string, "%d", &i); i = atoi(a_string);
如果对性能仍不满足,则可以进一步优化以上字符串串接代码。strcat() 首先会找到 a_buffer 的尾部,然后复制 another_string 的内容直到字符串末尾。但实际上,strcpy() 函数在其内部实现中一定已经循环到了 a_string 的尾部,故而也知道 a_buffer 中字符串的尾部地址。然而,strcpy() 函数并没有返回 a_buffer 中指向字符串尾部的指针,返回的却是 a_buffer 本身。
幸好,为满足这一需求,标准库特意增加了一个接口 stpcpy(),该接口复制字符串并返回指向其尾部的指针:
#include <string.h> char *stpcpy(char *dest, const char *src);
故而,我们可以进一步优化以上用来串接两个字符串的代码:
// sprintf(a_buffer, "%s%s", a_string, another_string); char *p = stpcpy(a_buffer, a_string); strcpy(p, another_string);
避免滥用内存分配
滥用内存分配是导致 C 程序性能低下的常见原因,且常常表现为两个截然相反的倾向:- 第一种倾向:不论是否有必要动态分配内存,也不管要分配的内存是大是小,统统调用 malloc() 函数从堆中动态分配内存。
- 第二种倾向:对于函数的临时空间需求,不管三七二十一,始终按所需空间的最大上限定义局部缓冲区变量。
以常见的串接给定的路径和文件名,并读取文件内容的函数为例,函数原型如下:
char *get_file_contents_under_dir(const char *path, const char *fname);其中,参数 path 用于指定路径,fname 用于指定文件名。该函数需要将 path 和 fname 串接为一个完整路径名,然后根据文件长度分配一个缓冲区并读取文件的内容到该缓冲区,最后返回缓冲区的地址。
单看该函数中用于串接 path 和 fname 生成完整路径名的代码,一个简单的实现是调用 asprintf() 函数:
#define _GNU_SOURCE #include <stdio.h> char *get_file_contents_under_dir(const char *path, const char *fname) { char *full_path; if (asprintf(&full_path, "%s/%s", path, fname) > 0) { assert(full_path); } else goto failed; char *buff = NULL; /* 略去打开文件并读取其内容的代码。 */ ... free(full_path); return buff; failed: return NULL; }仅仅因为要串接两个字符串就调用 asprintf() 函数,显然是“杀鸡用牛刀”。另外,asprintf() 并不是标准接口,而是 GNU 扩展接口,存在一定的平台兼容性问题。
为此,我们可以做一些调整:
#include <stdlib.h> char *get_file_contents_under_dir(const char *path, const char *fname) { char *full_path; full_path = malloc(strlen(path) + strlen(fname) + 2); if (full_path == NULL) { goto failed; } strcpy(full_path, path); strcat(full_path, "/"); strcat(full_path, fname); char *buff; ... free(full_path); return buff; failed: return NULL; }上述调整避免了“杀鸡用牛刀”,但考虑到绝大多数情况下,一个文件的完整路径名也就几百字节,定义一个足够长的局部变量作为缓冲区就可以了,完全不必调用 malloc() 等函数从堆中动态分配对应的缓冲区。另外,C99 支持变长数组(Variable Length Array,VLA),故而我们可以利用变长数组来定义这个缓冲区。
进一步调整后的实现如下:
char *get_file_contents_under_dir(const char *path, const char *fname) { char full_path[strlen(path) + strlen(fname) + 2]; strcpy(full_path, path); strcat(full_path, "/"); strcat(full_path, fname); char *contents; ... free(full_path); return contents; failed: return NULL; }然而,针对程序中使用变长数组的方法,在 path 和 fname 两个字符串的长度之和大于一个内存页(page)的长度(通常为4 KB)时,系统需要分配一个完整的物理内存页来容纳该缓冲区,从而会在某种程度上降低程序的执行效率。另外,仍然有少数编译器不支持变长数组,或者其内部实现并不是从栈中分配空间,而仍然从堆中分配空间,只是在函数返回前自动完成了缓冲区的释放而已。
因此,对此类缓冲区的分配,实践中更为有效的办法是根据所要分配的缓冲区大小灵活使用栈空间或者自行分配合适的栈空间大小:
char *get_file_contents_under_dir(const char *path, const char *fname) { /* 从栈中分配一个覆盖绝大多数路径长度的缓冲区。*/ char stack_buff[1024]; char *full_path; size_t full_path_len = strlen(path) + strlen(fname) + 2; if (full_path_len > sizeof(stack_buff)) { /* 如果用于完整路径长度的缓冲区之大小超过预定义的栈缓冲区, 则执行动态分配。 */ if ((full_path = malloc(full_path_len)) == NULL) goto failed; } else full_path = stack_buff; strcpy(full_path, path); strcat(full_path, "/"); strcat(full_path, fname); char *contents; ... /* 如果实际使用的缓冲区不是预定义的栈缓冲区,则释放该缓冲区。 */ if (full_path != stack_buff) free(full_path); return contents; failed: return NULL; }程序中,我们并没有使用 PATH_MAX 宏来定义栈缓冲区的大小。这是因为 PATH_MAX 宏的值通常被定义为 4096,恰好是一个物理内存页的大小。使用 PATH_MAX 宏会导致额外物理内存页的分配,甚至为了容纳最后的终止字符串用的空字符,我们通常会如下定义栈缓冲区:
char stack_buff[PATH_MAX + 1];但因为缓冲区超过了 4096 字节,stack_buff 需要两个物理内存页来容纳——这显然很不经济。但绝大多数情况下,需要处理的完整路径名之长度不会超过 1024 字节,故而我们只定义了 1024 字节大小的栈缓冲区。