指针函数与函数指针:经典笔试题‘两次打印helloworld‘解析
之前在辅导 C/C 笔试时经常遇到一道流传很广的题目一个“指针函数”连续执行两次两次打印的结果都是helloworld吗有的同学脱口而出“是”结果在面试官追问下才发现题目的隐藏条件是“返回局部数组”还是“返回字符串字面量”答案完全不同。这篇文章就围绕这道经典笔试题展开先把“指针函数”和“函数指针”的边界理清楚再逐步拆解字符串返回、局部变量生命周期、函数指针调用等知识点最后给出可以直接编译运行的示例代码、常见编译警告的排查思路以及面试笔试时的表达建议。无论你是刚开始学 C 指针还是准备校招笔试这篇文章都能作为一份系统的查漏补缺笔记。1. 指针函数与函数指针先分清两个名字很多初学者第一次看到“指针函数”和“函数指针”时会把它们当成同一个概念。实际上这两个名词的顺序完全不同含义也完全相反。本节先统一术语后面的所有代码和讲解都建立在这组定义上。1.1 什么是指针函数“指针函数”的完整说法是“返回指针的函数”。它的本质是函数函数的返回值类型是一个指针。换句话说调用这种函数后你拿到的是一个地址而不是一个int、char或结构体。声明形式如下返回类型 *函数名(参数列表) { // 函数体 }例如char *getString();这里的getString是一个函数返回值类型是char *也就是字符指针。它内部通常会返回某个字符串的首地址。定义时要注意*是贴在函数返回值那一侧的需要与“函数指针”的写法区分。后面 1.2 节会专门对比。这里容易产生一个误解指针函数返回的地址到底指向哪里答案是它指向的地址由函数内部决定可能指向全局变量或静态局部变量。字符串字面量。malloc动态分配的内存。调用者传入的缓冲区。换句话说指针函数本身不负责“保证地址永远有效”。这是理解本章节标题那道笔试题的关键。1.2 什么是函数指针“函数指针”的完整说法是“指向函数的指针”。它的本质是指针这个指针变量保存的是某个函数的入口地址。声明形式如下返回类型 (*指针变量名)(参数列表);注意这里的括号非常关键。如果去掉括号含义就会发生本质变化。例如char *func(char *p); // 这是一个指针函数 char *(*fp)(char *p); // fp 是一个函数指针指向返回值是 char* 的函数数组名在表达式中可以退化为地址函数名在表达式中同样可以退化为函数地址。因此函数指针最常见的用途是把函数作为参数传递或者把多个函数放进数组统一调用。回调函数、命令分发表、状态机、信号处理等场景都离不开函数指针。1.3 为什么笔试题总爱同时考它们指针函数和函数指针在面试笔试中出现频率很高主要是因为两者名称只差一个字但声明语法完全不同稍不注意就会写错。它们都涉及“地址”的传递隐藏着 C 语言中数组、字符串、局部变量生命周期等问题。笔试题可以把“返回局部数组”这类运行时问题与“函数指针调用”写在同一个题目里综合考察底层理解。了解了这一点再看“两次打印的结果都是 helloworld 吗”这道题就会发现它并不是单纯考语法而是在考“指针函数返回的地址是否仍然有效”。2. 环境准备与代码阅读工具本文的示例代码以 C 语言为主部分示例也可以改成 C 编译但为了避免string、new等 C 特性干扰指针语义建议用纯 C 环境编译运行。2.1 编译器与运行环境示例采用 GCC 编译。版本不需要特意安装最新版常见的 GCC 8、GCC 9、GCC 11 等都可以。重点不是版本而是开启编译警告。推荐编译命令gcc -stdc11 -Wall -Werror main.c -o main-stdc11表示按 C11 标准编译-Wall表示开启常见警告-Werror表示把警告当作错误处理。对于本文要讲的“返回局部数组”问题编译器能给出非常明确的警告-Werror能强制你正视它。如果你使用的是 Windows可以用 Visual Studio 的开发者命令行工具或者安装 MinGW-w64。也可以使用在线编译器比如一些支持 C11 的在线练习平台但要注意在线编译器的警告信息可能没有本地 GCC 完整。2.2 示例代码的组织方式建议新建一个目录例如pointer-demo目录下分别建立多个.c文件。这样每一类问题都可以独立编译不受互相干扰。pointer-demo/ ├── func_ptr.c ├── pointer_func.c ├── local_var_return.c └── main.c本文正文中的代码为了阅读方便会把多个小函数放在同一个文件里展示实际练习时可以按自己的习惯拆分。代码量不大任何一个文件都可以单独编译运行。3. 指针函数深入返回值是指针的函数在拆解笔试题之前先系统地看几个指针函数示例。只有理解了返回指针背后隐藏的内存分布才能准确回答标题中的问题。3.1 指针函数的基本写法下面是一个最简单的指针函数它接收一个字符指针并把它原样返回char *echoString(char *str) { return str; } int main(void) { char *result echoString(helloworld); printf(%s\n, result); return 0; }运行结果helloworld这个函数很安全因为str指向的是传入的字符串字面量helloworld而字符串字面量具有静态存储期在程序运行期间始终存在。函数返回str本质是返回字符串字面量的首地址。这里引出一个关键概念字符串字面量的内存位置不属于任何局部变量它存储在只读的数据区域生命周期是整个程序。所以返回它的地址是安全的。3.2 返回字符串字面量的指针函数再看一个稍微常见的写法char *getHelloWorld1(void) { return helloworld; } int main(void) { printf(%s\n, getHelloWorld1()); printf(%s\n, getHelloWorld1()); return 0; }两次调用都会输出helloworld helloworld因为getHelloWorld1返回的是字符串字面量helloworld的地址这个地址在程序启动时就已经确定不会随着函数调用结束而失效。因此两次打印结果相同都是helloworld。这个例子看似简单却是后续所有对比的基础。记住字符串字面量驻留在静态存储区。3.3 返回局部数组的陷阱如果把字符串放在局部数组中然后返回数组名会怎么样来看一个典型错误#include stdio.h char *getHelloWorld2(void) { char str[] helloworld; return str; } int main(void) { printf(%s\n, getHelloWorld2()); printf(%s\n, getHelloWorld2()); return 0; }用 GCC 编译时会看到类似警告warning: function returns address of local variable [-Wreturn-local-addr]这个警告的意思是函数返回了一个局部变量的地址。局部变量str在函数返回后生命周期结束原本属于它的栈内存已经被释放。虽然从地址数值上看这个地址仍然指向一块内存但内容已经不再受控。这种行为的正式名称叫未定义行为Undefined Behavior, UB。结果是可能两次都打印helloworld。可能第二次打印乱码。可能第一次打印就崩溃。可能在不同的编译器、不同的优化级别下表现完全不同。因此标题问题的严谨答案是不能简单地说“是”或“不是”要看函数的实现细节。如果题目描述的是“局部数组”版本那么不能得到保证。3.4 返回静态数组和 malloc 内存的注意事项既然局部数组不安全常见的改进方案有两种。第一种是使用static修饰静态局部数组char *getHelloWorld3(void) { static char str[] helloworld; return str; } int main(void) { printf(%s\n, getHelloWorld3()); printf(%s\n, getHelloWorld3()); return 0; }静态局部变量的生命周期也是整个程序所以两次打印都能得到helloworld。但要注意如果多次调用这个函数并修改它返回的内容那么后来者会覆盖前面的内容。比如char *getHelloWorld3(void) { static char str[] helloworld; return str; } int main(void) { char *a getHelloWorld3(); char *b getHelloWorld3(); printf(%s\n, a); // 仍然取决于是否有人修改过 a printf(%s\n, b); return 0; }由于a和b其实指向同一个静态数组一旦后续代码修改了数组内容之前的指针也会跟着变。这种“共享同一份数据”的特性在并发和多线程场景下尤其危险。第二种方案是使用malloc#include stdio.h #include stdlib.h #include string.h char *getHelloWorld4(void) { char *str (char *)malloc(11); if (str NULL) { return NULL; } strcpy(str, helloworld); return str; } int main(void) { char *s getHelloWorld4(); if (s ! NULL) { printf(%s\n, s); free(s); } return 0; }malloc分配的内存在堆上必须由调用者负责释放。如果只调用而不free会产生内存泄漏。实际项目中决定“谁分配、谁释放、何时释放”是接口设计的一部分这一点在第七章会继续讨论。4. 函数指针深入指向函数的指针函数指针和指针函数是容易混淆的另一组概念。本节单独把函数指针的声明、赋值、调用和典型用途讲清楚为第五章的完整笔试题解析做准备。4.1 函数指针的声明语法先定义一个普通函数char *showMessage(char *msg) { printf(%s\n, msg); return msg; }要让一个指针指向这个函数声明方式如下char *(*fp)(char *msg);拆开来看fp是一个指针变量。*fp替换成函数名后整体形式是char *函数名(char *msg)。所以fp指向一个函数该函数的参数是char *返回值是char *。为什么括号不能省如果写char *fp(char *msg);编译器会认为fp是一个函数函数参数是char *msg返回值是char *。这就变成“指针函数”的声明了。所以函数指针声明中的(*fp)括号是语法核心。4.2 函数指针的赋值与调用函数名可以直接赋值给函数指针。这是因为在表达式中函数名会隐式转换为函数地址。#include stdio.h char *showMessage(char *msg) { printf(%s\n, msg); return msg; } int main(void) { char *(*fp)(char *msg); fp showMessage; char *r1 fp(helloworld); char *r2 (*fp)(helloworld); return 0; }这里fp(helloworld)和(*fp)(helloworld)是等价调用。前者是直接使用函数指针调用后者是先解引用再调用。两者最终都会调用showMessage函数因此两次打印都是helloworld。运行结果helloworld helloworld这个结果与第五章的笔试题很相似但注意区别这里的两次打印是通过同一个函数指针调用同一个函数且函数本身没有返回局部数组所以结果确定。而第五章的题目则要结合函数实现来讨论。4.3 函数指针数组与回调函数函数指针在实际项目中最重要的用途之一是“分发”。比如命令解析器#include stdio.h #include string.h int cmdStart(void) { printf(start command\n); return 0; } int cmdStop(void) { printf(stop command\n); return 0; } int cmdHelp(void) { printf(help command\n); return 0; } int main(void) { char cmd[32] {0}; int (*handlers[3])(void) {cmdStart, cmdStop, cmdHelp}; printf(enter command: ); scanf(%31s, cmd); if (strcmp(cmd, start) 0) { handlers[0](); } else if (strcmp(cmp, stop) 0) { handlers[1](); } else { handlers[2](); } return 0; }函数指针数组可以避免大量switch分支。当命令数量增加时只需要扩展数组即可。但要注意数组中所有函数必须具有相同的函数签名否则需要额外封装。回调函数也是同理。标准库的qsort就接收一个函数指针作为比较器#include stdio.h #include stdlib.h int compareInt(const void *a, const void *b) { int x *(const int *)a; int y *(const int *)b; return (x y) - (x y); } int main(void) { int arr[] {5, 2, 8, 1, 9}; int n sizeof(arr) / sizeof(arr[0]); qsort(arr, n, sizeof(int), compareInt); for (int i 0; i n; i) { printf(%d , arr[i]); } printf(\n); return 0; }运行结果1 2 5 8 9理解回调函数的关键是意识到compareInt的地址被传给了qsort由qsort在内部合适时机调用它。这比在函数内部硬编码“大于/小于”逻辑要灵活得多。5. 经典笔试题两次打印的结果都是“helloworld”吗现在回到文章标题。这道经典题在网上流传了很多版本不同版本对应完全不同的答案。本节先给出常见题目原型再逐版本分析。5.1 题目原题与常见变形我见到的题目通常是这样描述的请阅读下面的 C 代码判断两次打印的结果是否都是helloworld。版本 Achar *getHi(void) { char str[] helloworld; return str; } int main(void) { printf(%s\n, getHi()); printf(%s\n, getHi()); return 0; }版本 Bchar *getHi(void) { return helloworld; } int main(void) { printf(%s\n, getHi()); printf(%s\n, getHi()); return 0; }版本 Cchar *getHi(void) { static char str[] helloworld; return str; } int main(void) { printf(%s\n, getHi()); printf(%s\n, getHi()); return 0; }很多同学只记住了“两次打印结果相同”这个结论却没注意题目给出的函数体是哪一个。实际上A、B、C 三个版本的结论完全不同。5.2 解析返回局部数组版本版本 A 是名副其实的“陷阱题”。char str[] helloworld;定义了一个局部字符数组。函数执行时这个数组被分配在栈上。函数返回时return str;返回的是数组名转换成指针后的首地址但数组本身的生命周期已经结束。再次调用getHi()时同一段栈空间可能被再次使用也可能被其他函数调用破坏。因此第一次调用后打印的结果取决于那段时间内栈内存是否被改写第二次调用后打印的结果又取决于第二次进入函数时栈数据发生了什么变化。这个版本的正确回答是不确定属于未定义行为。不能保证两次打印结果都是 helloworld。面试时如果只回答“都是”通常会被认为没有理解栈帧和生命周期如果回答“未定义行为”并解释为什么会这样通常能加分。5.3 解析返回字符串字面量版本版本 B 返回的是字符串字面量helloworld的地址。字符串字面量存放在静态存储区程序启动时就已经存在直到程序结束才会消失。因此无论函数被调用多少次返回的地址都指向同一个只读字符序列。这个版本的正确回答是两次打印的结果都是 helloworld。同时可以补充一个细节不要试图修改这个字符串。字符串字面量本质上是只读的尝试写入会导致未定义行为。例如char *p getHi(); p[0] H; // 未定义行为警惕5.4 解析返回 static 数组版本版本 C 使用static char str[] helloworld;。static修饰的局部变量存放在静态存储区函数返回后不会销毁。因此两次打印的结果也都是helloworld。但面试中可以进一步分析由于两次调用返回的是同一个数组地址如果第一次返回后修改了数组内容第二次打印就会受到影响。例如char *getHi(void) { static char str[] helloworld; return str; } int main(void) { char *a getHi(); a[0] H; // 修改的是静态数组 printf(%s\n, a); // Helloworld printf(%s\n, getHi()); // Helloworld因为第二次调用返回的还是同一个数组 return 0; }这个例子说明static虽然延长了变量的生命周期但同时也引入了“共享状态”。5.5 参考答案与面试表达如果把三个版本放在一起对比结果如下版本函数内部写法两次打印结果是否都是 helloworld原因Achar str[] helloworld;不能保证返回局部数组地址未定义行为Breturn helloworld;是字符串字面量静态存储生命周期为整个程序Cstatic char str[] helloworld;是静态局部变量生命周期为整个程序面试官问“两次打印的结果都是 helloworld 吗”时不要立刻回答“是”。更稳妥的作答方式是先反问或确认函数的实现细节。指出关键点是否返回局部变量地址。分别说明确定性答案和未定义行为。这样的回答既展示了基本功也体现了严谨性。另外如果题目还结合了函数指针调用代码可能长这样char *getHi(void) { char str[] helloworld; return str; } int main(void) { char *(*fp)(void) getHi; printf(%s\n, fp()); printf(%s\n, (*fp)()); return 0; }这里fp()和(*fp)()等价但这不改变getHi返回局部数组地址的事实。函数指针调用方式不是问题核心问题的核心仍然是被调用函数返回的地址是否有效。6. 常见问题与排查思路本节整理 C 语言指针函数与函数指针相关的常见错误和排查方法同时回应一个网上经常出现的关联热词java.lang.ClassNotFoundException: helloworld。6.1 编译器警告function returns address of local variable错误现象warning: function returns address of local variable [-Wreturn-local-addr]可能原因函数内定义了局部数组或局部变量然后直接返回其地址。排查步骤检查返回语句后面跟的是不是局部变量名。判断该变量的存储类别是auto还是static。判断该变量是局部数组还是全局数组。如果必须返回局部数据地址改用static或malloc或者改成由调用者传入缓冲区。解决方案示例void getHi(char *buf, int bufSize) { snprintf(buf, bufSize, helloworld); }调用者提供缓冲区函数只负责写入更符合“谁分配谁释放”的原则。6.2 函数指针声明总是写错错误现象char *fp(char *p); // 想定义函数指针但实际声明了一个指针函数可能原因忘记了(*fp)的括号。排查方法看变量名后面是否紧跟(。如果是那它就是函数而不是函数指针。使用typedef简化复杂声明。推荐写法typedef char *(*StringFunc)(char *p); StringFunc fp getString;typedef可以显著降低多级指针声明的阅读难度特别是函数指针数组、返回函数指针的声明等场景。常见函数指针声明对照声明含义char *f(char *p);指针函数返回值是char *的函数char *(*fp)(char *p);函数指针fp指向一个返回char *的函数char *(*fpa[3])(char *p);函数指针数组数组内有 3 个函数指针char *(*getF(void))(char *p);返回函数指针的函数6.3 Java 的 ClassNotFoundException: helloworld 是同一类问题吗在搜索“helloworld”相关内容时会看到java.lang.ClassNotFoundException: helloworld这样的报错。这里需要明确它不是 C 语言指针问题而是 Java 运行时类加载异常。这个异常的常见场景是代码中写了Class.forName(helloworld)或者 JVM 在启动时查找一个名为helloworld的类但在 classpath 中找不到对应的.class文件。排查步骤确认类名是否写错包括包名和大小写。确认编译后的.class文件是否存在于 classpath。确认是否缺少依赖 jar 包。检查类加载器是否使用了正确的上下文。本文的helloworld是 C 字符串内容与 Java 的ClassNotFoundException完全无关。搜索资料时要注意区分语言和问题类型避免把不同领域的异常混在一起。6.4 使用 malloc 返回内存的释放问题错误现象char *getHi(void) { char *p (char *)malloc(11); strcpy(p, helloworld); return p; }这段代码能运行但如果调用者忘记free会造成内存泄漏。排查思路明确函数文档里是否注明“返回堆内存调用者负责释放”。使用内存检测工具例如 Valgrind 或 AddressSanitizer。在大型项目中尽量优先考虑调用者传入缓冲区的设计减少隐式内存所有权转移。7. 最佳实践与工程建议前面几节已经覆盖了语法、题目解析和排错。这一节把经验沉淀成可落地的工程规范帮助你在项目中减少指针相关的坑。7.1 返回指针时的生命周期管理无论是普通函数还是指针函数只要返回值是指针就要明确回答三个问题指针指向的数据在哪里谁负责释放数据在什么条件下失效推荐优先级从高到低优先使用调用者传入的缓冲区例如snprintf风格。其次返回static或全局只读数据。再次返回malloc分配的数据但必须用注释或命名显式说明“调用者负责 free”。尽量避免返回局部变量地址。如果是在团队协作中建议在函数头注释里写清楚/* * getConfig * 返回值内部静态配置字符串调用者不得修改、不得 free。 */ const char *getConfig(void);这样的注释能让接口使用者一目了然。7.2 函数指针的 typedef 与可读性复杂函数指针声明不宜裸写。推荐使用typedef给函数签名起一个可读的名字typedef int (*CompareFunc)(const void *, const void *); typedef void (*CommandHandler)(void);然后用这些类型去定义变量、数组、结构体成员。结构体中包含函数指针是状态机、插件系统的常见设计。例如typedef struct { const char *name; CommandHandler handler; } CommandEntry; CommandEntry commands[] { {start, cmdStart}, {stop, cmdStop}, {help, cmdHelp}, };这样做的好处是命令列表增删时不需要改动调用逻辑只需要修改结构体数组。7.3 编译选项与静态检查针对 C 语言建议至少开启以下编译选项gcc -stdc11 -Wall -Wextra -Werror -pedantic main.c -o main注释-Wall开启常规警告。-Wextra开启更多警告。-Werror把警告视为错误适合 CI 环境。-pedantic提醒不符合标准 C 的写法。对于更严格的项目可以使用clang-tidy、cppcheck、AddressSanitizer 等工具。AddressSanitizer 对栈内存越界和释放后使用非常敏感gcc -stdc11 -fsanitizeaddress -g main.c -o main在面试笔试题中“返回局部数组”这类错误如果开启-Wall编译器会直接给出警告开启-Werror后根本编译不过等于提前发现问题了。7.4 面试笔试中的表达方式面对“两次打印的结果都是 helloworld 吗”这类问题推荐分三步回答第一步定位关键语法这里是一个指针函数它返回了一个 char *。第二步分析返回值指向的内存区域需要看函数内部返回的是局部数组、字符串字面量还是静态数组。第三步给出确定性结论如果是局部数组结果是未定义行为如果是字符串字面量或 static 数组结果为 helloworld。在纸上答题时如果时间允许可以画出栈上数据在函数返回前后如何失效。面试官通常更看重这种“把问题归因到存储期”的分析能力。8. 总结与延伸练习这篇文章主要围绕“两次打印的结果都是 helloworld 吗”这一经典指针函数笔试题展开核心知识点可以浓缩为三句话指针函数是返回指针的函数函数指针是指向函数的指针两者声明语法不同含义完全不同。指针函数返回的地址必须指向生命周期足够长的数据区域返回局部数组地址属于未定义行为不能得到确定性结论。字符串字面量和static局部变量具有静态存储期返回它们的地址是安全的但要注意共享数据和只读约束。如果想进一步验证掌握程度可以尝试做下面几个延伸练习练习一char *test(void) { char buf[] hello; return buf; } int main(void) { char *p test(); printf(%s\n, p); return 0; }请问这段代码有什么问题如何修改练习二void func(char *msg) { printf(%s\n, msg); } int main(void) { void (*fp)(char *); fp func; (*fp)(hello); fp(hello); return 0; }请说明(*fp)(hello)和fp(hello)是否等价并解释原因。练习三const char *getMessage(void) { static const char msg[] helloworld; return msg; }为什么这个函数可以多次安全调用如果去掉static会怎样建议把每个练习都实际编译运行一遍并使用-Wall -Werror观察编译器提示。工程经验往往来自这种“写错、看到警告、分析根因、修正”的循环。如果本文对你有帮助可以收藏备用也欢迎在评论区分享你遇到过的类似指针面试题。

相关新闻

最新新闻

日新闻

周新闻

月新闻