如何让 strcpy() 在目标为 const 时产生编译错误
结论
标准 C 没有通用机制,可以只把 strcpy() 的某个诊断提升为编译错误。向 char * 参数传入 const char * 违反了类型约束,标准要求编译器给出诊断,但诊断是 warning 还是 error,由编译器自行决定。
对于同时包含 C 和 C++ 的旧代码库,建议使用独立的检查宏,不要重新定义标准库名称 strcpy。可以把目标对象的可修改性测试放进 sizeof,让编译器检查表达式,又不在运行时写入目标对象:
#include <string.h>
#define checked_strcpy(dest, src) \
((void)sizeof((dest)[0] = '\0'), strcpy((dest), (src)))
这个版本保留了原方案的编译期检查,同时避免了额外写入和重复求值。
为什么普通调用在 C 中只是警告
strcpy() 的声明是:
char *strcpy(char *dest, const char *src);
把 const char * 传给 char * 参数,会丢弃所指对象的 const 限定。该调用违反了 C 的约束,编译器必须诊断,但 C 标准没有要求编译器在诊断后停止编译。
MSVC 在 C 模式下会把这种情况报告为 C4090,在 C++ 模式下通常会直接拒绝转换。因此,仅靠函数原型无法保证 C 编译器产生 fatal error。
显式强制转换也能绕过这项检查:
const char *p = "text";
strcpy((char *)p, "new"); /* 类型检查已被显式转换绕过 */
代码主动去掉 const 后,普通的编译期类型检查无法判断程序员的真实意图,只能依靠代码审查或 static analyzer。
推荐的检查宏
#include <string.h>
#define checked_strcpy(dest, src) \
((void)sizeof((dest)[0] = '\0'), strcpy((dest), (src)))
使用示例:
char name1[12] = "jubjub";
const char name2[12] = "bujbuj";
char *p1 = name1;
const char *p2 = name1;
char *const p3 = name1;
const char *const p4 = name1;
checked_strcpy(name1, "one"); /* 可以编译 */
checked_strcpy(p1, "two"); /* 可以编译 */
checked_strcpy(p3, "three"); /* 可以编译 */
checked_strcpy(name2, "x"); /* 编译错误 */
checked_strcpy(p2, "y"); /* 编译错误 */
checked_strcpy(p4, "z"); /* 编译错误 */
这里不会执行 sizeof 的操作数,但编译器仍然需要检查其中的表达式:
(dest)[0] = '\0'
如果 dest 指向 const char,左侧就不是可修改的 lvalue,MSVC 会报错。对于 char *const,不能修改的是指针本身的指向,它所指向的字符仍然可以修改,所以能够通过检查。
宏展开后,运行时实际执行的仍然只有:
strcpy((dest), (src))
运行时只会分别对 dest 和 src 求值一次,与函数是否 inline 无关。
原有 xstrcpy 宏的问题
原宏是:
#define xstrcpy(dest,src) strcpy((((dest)[0]=0),(dest)), (src))
这个宏会在运行时先执行:
(dest)[0] = 0;
然后再调用 strcpy(),因此它不只是进行类型检查,还会改变程序的运行时行为。
会额外修改目标对象
即使后面的 strcpy() 没有按预期执行,目标对象的第一个字符也已经被清零。对于 volatile 对象,这还是一次额外的可观察写操作。
dest 会被求值两次
下面的调用会产生两次自增,而且两次得到的指针可能不同:
xstrcpy(p++, "text");
宏大致会展开成:
strcpy((((p++)[0] = 0), (p++)), "text");
这改变了原调用的语义。
额外写入不是“纯检查”
优化器或 inline 不会改变 C 语言规定的可观察语义。编译器是否内联 strcpy() 与此无关。额外赋值确实存在于抽象机语义中,只有当编译器能证明它不影响可观察结果时,才可能将其消除。
不建议继续使用这个版本。把赋值放进 sizeof,既能保留类型检查,也能避免运行时副作用。
使用 _Generic 的标准 C11 方案
如果代码只使用 C11 或更高版本编译,也可以采用 _Generic。需要对控制表达式执行 + 0,使数组转换为指针,同时去掉指针对象本身的顶层限定:
#include <string.h>
#define checked_strcpy(dest, src) \
_Generic((dest) + 0, \
char *: (strcpy) \
)((dest), (src))
具体行为如下:
char[N]转换为char *,可以匹配。const char[N]转换为const char *,没有匹配项。char *const经过 lvalue conversion 后变为char *,可以匹配。const char *和const char *const仍然指向const char,没有匹配项。
没有匹配项时,编译器必须给出诊断。MSVC 的错误编号和具体措辞属于实现细节。
(strcpy) 后面没有紧跟 (,所以不会触发同名的 function-like macro,这里引用的是实际的库函数。
_Generic 只适用于 C。混合 C/C++ 代码库需要使用条件编译,或者采用前面的 sizeof 方案。
不要用宏覆盖 strcpy
不建议使用下面这种写法:
#define strcpy(d, s) /* ... */
它可能影响后续包含的头文件,所以宏至少要放在 <string.h> 之后。它还会替换第三方头文件和旧代码中的相关 token,而 (strcpy)(dest, src)、函数指针调用等写法又可以绕过这个宏。使用这种方式后,调试信息和错误位置也更难理解。
标准库函数名属于实现和标准库约定的命名空间,不适合作为项目级宏接口重新定义。使用 checked_strcpy、project_strcpy 等独立名称更便于控制迁移范围,也方便通过搜索或脚本逐步替换调用。
MSVC 的选择性警告升级
如果可以接受把所有 C4090 都提升为错误,而不限于 strcpy(),MSVC 支持按警告编号处理:
/we4090
该选项只把 C4090 视为错误,不等同于全局启用 /WX。
也可以在源代码中限制生效范围:
#pragma warning(push)
#pragma warning(error : 4090)
strcpy(dest, src);
#pragma warning(pop)
这种方式会处理指定代码区域内的所有 C4090,无法判断警告是否来自 strcpy()。在大型旧代码库中,可以按文件或模块逐步收紧检查。如果只想检查一组复制调用,独立的 checked_strcpy 接口会更准确。
这些方案只能检查目标是否可修改,不能检查缓冲区容量,也无法阻止显式强制转换。strcpy() 的越界风险仍需通过长度明确的接口、static analyzer 或额外的项目级封装处理。
备注:内容仅供参考。
