ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C/C++ const关键字全面解析:从指针到MySQL保留字避坑指南

C/C++ const关键字全面解析:从指针到MySQL保留字避坑指南 前阵子帮人做代码评审一条SQL把我惊住了SELECT order FROM user_tableMySQL直接报语法错误紧接着又看到一个C类里const成员函数内部调用了一个非const的成员函数编译直接挂掉。两件事撞在一起我就想专门把const这个关键字彻底捋一遍。const在C/C里看似简单真正用起来牵扯到指针修饰、顶层底层语义、成员函数重载、编译期常量、类型限定符丢失甚至跑到数据库里还会和字段命名撞车。这篇文章我会把这几个场景全部拆开从C语言的基础指针规则一直讲C的六种典型用法再顺手分析scanf(const char *format, ...)这类API设计背后为什么要用const最后聊MySQL字段名撞保留字怎么解决。适合刚学指针被各种const写法搞晕的人、写C类遇到编译错误的老手以及建表命名不规范挨过数据库顿悟的开发者。1. 先把const的家底摸清楚C语言里的多种指针修饰1.1 从int const *p说起指针、常量到底谁被锁死很多人刚学C语言时最绕的就是const int *p和int *const p这两兄弟。我先说结论const int *p和int const *p完全等价它们都表示“p指向的那个int值不能通过p去修改”但是p本身可以指向别的地方int *const p表示“p这个指针变量本身被锁死不能再指向别的地址”但p指向的int值可以随便改。可以这样记忆const出现在*的左边锁定的是“指向的对象”const出现在*的右边锁定的是“指针自己”。我用一个生活化的类比指针就是门牌号内存区域就是房间。const int *p相当于你手里拿着一个门牌号你可以换房间门牌号但不管你走到哪个房间都不能通过这个门牌号去改房间里的东西。int *const p则像门牌号被胶水粘死在某个房间上你只能进这个房间进去了想搬东西、改装修都随便。下面这段代码能说明问题int a 10; int b 20; const int *p1 a; p1 b; // 合法指针本身可以变 (*p1); // 非法不能通过p1修改指向的int值 int *const p2 a; p2 b; // 非法指针本身被锁死 (*p2); // 合法a的值可以被修改如果想把指针本身和指向的对象都锁死就用const int *const p。这种写法常见于固件开发、嵌入式寄存器映射描述以及那些一旦配置就不允许再动的全局配置指针。我在实际工作中建议一个原则读代码时先找*以*为圆心看const落在哪一侧比死记“常量指针”“指针常量”这些术语可靠得多。1.2 顶层const与底层const名字不重要语义才重要在C里有个更规范的分法叫顶层consttop-level const和底层constlow-level const。虽然这个叫法主要出现在C的语法讨论里但理解它对C语言同样有帮助。顶层const指的是指针本身是常量比如int *const p底层const指的是指针指向的对象是常量比如const int *p。为什么要区分这两者因为C在拷贝、赋值和类型匹配的时候规则完全是两套。当你写const int *p; int *q p;时这是非法的因为从底层const到非底层const属于丢弃类型限定符但如果你写const int *p; const int *q p;两边都是底层const拷贝安全。反过来int *const p这个顶层const在拷贝时反而无所谓因为拷贝的是指针值不改变指向对象的可变性。这个规则在实际工程里最重要的体现就是函数参数。比如void foo(const int *p)它承诺的是“我不会通过这个指针修改你传进来的数据”。而void foo(int *const p)这写法在参数列表里基本没有意义因为参数是按值传递的函数拿到的p本来就是一个副本你限制副本本身不能改对调用方没有任何保护作用。我见过不少人把void foo(int *const p)当成一种很严格的写法实际上纯属心理安慰。从这个角度你也能明白为什么真正优秀的接口会写成const int *p或者const char *str而不是int *const p。1.3 数组参数退化和const的真正边界const修饰数组参数时有个特别容易忽略的语法规则数组作为函数参数时会退化成指针所以void foo(const int arr[])其实就是void foo(const int *arr)。这条规则带来的实际效果是你可以在函数内部随意修改arr这个指针变量本身比如arr但不能通过arr去修改数组元素。void print_array(const int arr[], int n) { arr; // 合法指针变量本身可以移动 for (int i 0; i n - 1; i) { printf(%d , arr[i]); } }很多人第一次看到arr会吓一跳觉得const修饰了数组怎么还能改地址实际上const锁定的语义是“无法通过这个指针修改内存内容”而不是“指针不能移动”。这件事想明白以后const的边界就清晰了它约束的是访问路径不是内存本身。哪怕后台内存里的数据不是常量只要我手里拿的是一个指向常量的指针我就只能读取不能写入。反过来如果内存本身是只读的比如放在只读段你再用非const指针去写那就是未定义行为。C语言里还有个经典陷阱char *p hello;。要知道C标准把字符串字面量当作char数组处理修改它却是未定义行为。C标准就严格得多字符串字面量是const char数组所以char *p hello在C里直接编译失败。很多从C转C的人在这里踩坑正确写法是const char *p hello;。这也是为什么接下来讲C时我会反复强调const不只是“不能修改”的提示它还是类型系统的一部分写错了就是编译错误。2. C里const的完全体六大使用场景逐个过2.1 const成员函数给this套上紧箍咒C的const比C多了一个极其重要的场景const成员函数。在成员函数声明的末尾加上const就相当于告诉编译器“这个函数不会修改对象的状态”。编译器会把这个函数的this指针当成const T *来处理所以在函数内部访问成员变量时只读操作没问题写操作直接报错。class Counter { public: int getValue() const { return value_; } // 合法只读 void add(int n) { value_ n; } // 非const成员函数 void broken() const { value_; // 编译错误在const成员函数里修改成员 add(1); // 编译错误调用非const成员函数 } private: int value_ 0; };这个约束还有一个连带效果const成员函数可以被重载。你可以同时写int getValue()和int getValue() const普通对象调用非const版本const对象只能调用const版本。这个特性在实际工程里非常常用比如STL容器的begin()和cbegin()就是这种思路普通迭代器可以改元素const对象拿到的是只读迭代器。需要提醒的是const成员函数这个“不改对象”的承诺是可以被绕过的方法就是给成员变量加mutable关键字。mutable专门用来标注“即使在const环境里也可以修改”的成员常见于计数器、缓存、互斥锁这些和逻辑状态无关的辅助字段。比如一个线程安全的类里const成员函数可能也要加锁那个锁就需要是mutable的否则没法在const函数里lock()。2.2 const引用临时对象的生命周期延长器C里const T这种引用和普通引用最大的不同是它能绑定到临时对象并且让这个临时对象的生命周期延长到引用变量离开作用域为止。这个概念在C11之前特别重要因为那时候没有移动语义很多函数按值返回一个大对象你直接用T ref func()是编译不过的但写const T ref func()就能安全地延长临时对象生命。std::string getMessage(); const std::string msg getMessage(); // 临时对象生命周期被延长 std::cout msg std::endl;在C11及以后虽然移动语义和返回值优化让很多场景不再需要这样写但const引用仍然是“只读借阅”的首选。它表达的是外部可以传一个很大的对象进来我不拷贝它我只保证自己不修改它。比如一个函数void analyze(const std::vectorint data)如果你不写const调用方就得被迫接受“这个函数可能会修改我的数据”的风险写了const语义立刻清楚纯只读分析。这里有个很多人问到的问题为什么const T能绑定临时对象而T不行简单说临时对象是右值普通左值引用不允许绑定右值但const左值引用为了兼容历史上的写法被允许绑定右值。C11之后有了右值引用T专门用来绑临时对象并做移动操作但只读访问临时对象时const T依然是合法且非常清晰的写法。2.3 constexpr跨过“只读”到“编译期常量”的坎const和constexpr是两回事。const只是声明“这个名字在作用域里不可修改”它不保证值在编译期可知。举个例子const int n compute(); // 合法n运行时才确定 int arr[n]; // 大多数编译器会报错n不是编译期常量constexpr才是真编译期常量。它要求变量必须能在编译期求值所以constexpr int n 5;可以放心用来定义数组大小、模板参数、std::array的大小等等。constexpr int kMaxSize 1024; std::arrayint, kMaxSize buffer;constexpr也可以用于函数constexpr int square(int x) { return x * x; }如果实参是编译期常量调用结果在编译期就算出来了如果实参是运行时变量它就退化成普通函数调用不会报错。这正是constexpr函数和模板元编程相比更亲民的地方。我在实际编码中的习惯是只要一个值在逻辑上“天生固定”比如协议版本号、最大重试次数、缓冲区默认大小一律用constexpr而不是const。这样既能让编译器尽早发现问题也能让代码读者一眼看出“这个值不需要运行时计算”。2.4 mutable和const_cast两个打破const约束的后门mutable和const_cast都能在const环境里修改数据但两者性质完全不同。mutable是语法层面的正规后门适合修改“非逻辑状态”的辅助数据const_cast则是类型层面的强行转换适合处理历史遗留接口用的时候必须格外小心。先说mutable的典型场景class Server { public: int getRequestCount() const { std::lock_guardstd::mutex lock(mutex_); return request_count_; // request_count_是mutable } private: mutable std::mutex mutex_; mutable int request_count_ 0; };如果没有mutableconst成员函数每次调用都要拷一份数据或者牺牲线程安全。用mutable标记锁或计数器既保证了对外只读接口又让内部状态管理变得合理。const_cast则是用来去掉指针或引用的const限定。它最常见的合法用途是一个对象本身不是常量只是你手里拿的是const引用但你需要调用一个老旧的、没加const的接口去处理它。只要原始对象不是真正的常量const_cast之后修改是安全的但如果你对一个真正const的对象做const_cast然后修改那是未定义行为程序可能崩溃也可能看起来正常然后随机出诡异bug。这个坑我后面在常见问题部分会展开讲。2.5 const对象与成员变量的初始化列表还有一个使用场景经常被新手忽略const成员变量。类的const成员变量必须在构造函数初始化列表里赋值不能先声明再在构造函数体内用赋值语句初始化。原因很简单const变量在进入构造函数体之前就必须有确定的值。class Config { public: Config(int max) : max_count_(max) {} private: const int max_count_; };如果你写成Config::Config(int max) { max_count_ max; // 编译错误 }编译器会报错因为此时max_count_已经是未初始化的const对象赋值操作被禁止。这个规则在实际工程中常被误用特别是在需要根据配置计算多个常量时。我自己的经验是如果确实需要在构造函数里“绕个弯”计算const成员可以用委托构造函数或者用编译期函数表达式实在绕不开就考虑把成员改成非const并用const接口约束访问。3. scanf函数原型里的const接口设计暗藏的学问3.1 参数为什么要加const给调用方一张只读承诺书很多初学者第一次看到int scanf(const char *format, ...)时心里会嘀咕scanf不是要读取用户输入吗怎么它的format参数是const char *关键点在于scanf确实会从标准输入读取数据但它写入的是后面那些可变参数指针所指的内存不是format本身。format只是解析规则从头到尾只读不修改。把只读参数声明成const char *本质上是在接口层面写下一句承诺“这个函数保证不修改你传进来的字符串。”这种设计叫“const正确性”const-correctness是C/C库设计里非常重要的一个约定。比如你去看标准库的printf原型也是int printf(const char *format, ...)理由完全一样。如果你自己写一个函数接收一个字符串参数但只读取它却没有加const会带来两个麻烦第一调用方不知道你的函数是否会改这个字符串于是不敢把const char *字符串传给你第二如果你内部某处误写了字符串编译器不会提醒你直到运行时产生破坏。我写工程代码的习惯是所有函数参数只要不修改它一律加const。这不是吹毛求疵而是用最小的语法成本换来最大限度的安全可读性。3.2 如果去掉const会发生什么我们做个假设如果标准库把scanf的第一个参数设计成char *format而不是const char *会有什么后果最大的问题是字符串字面量的兼容性。C中字符串字面量是const char[]类型把一个const char *实参传给char *形参在C里是编译错误在C里至少也是警告具体取决于编译选项。也就是说最常见的调用方式scanf(%d, n)将无法通过编译因为%d本身是一个const char[]字面量。就算编译器放你过去函数内部也无法知道自己拿到的是一个可写缓冲区还是只读字面量。一旦scanf内部因为解析逻辑bug试图修改格式字符串程序就会在只读段崩溃而且极难排查。把参数声明为const char *从类型系统层面就堵死了这条路编译器保证这个字符串既不能被误改也不能被传给其他修改它的接口。这一点对普通开发者很有借鉴意义在设计API时如果某个字符串参数只是用来读取的务必写const char *。反过来如果函数需要写入字符串那参数就必须是char *。我见过不少代码把业务函数的所有字符串参数一律写成char *导致调用方为了传一个const char *进去被迫用const_cast强转简直是自找麻烦。3.3 字符串字面量与const的实际交互理解了scanf的const char *format再回头看传参的匹配规则就更清楚了。你可以写const char *fmt %d %s; scanf(fmt, n, buf);这完全合法因为fmt本身就是const char *。但你一定要分清楚scanf里的参数除了format之外后面的参数才决定数据写到哪。比如scanf(%s, buf)中buf必须是可写的char *不能是const char *。有些新手会把缓冲区声明成const char buf[100]然后往scanf里传结果出现“丢弃限定符”的编译警告运行起来更是大概率崩溃——因为const char数组可能被放在只读存储区scanf写入直接触发段错误。正确姿势是char buf[100]; scanf(%s, buf);如果只是要给scanf传格式说明你可以放心使用字符串字面量或const char *变量。这个例子很好地说明了const在形参和实参之间的匹配逻辑形参声明为const char *是为了接受所有“只读字符串”的传入但函数内部分明写出的写入目标参数就必须是可写缓冲区。4. MySQL字段名踩到关键字SQL写不出来的三种解决办法4.1 现场还原为什么order这个字段名会让SQL直接报错写业务代码时最郁闷的SQL错误大概就是建表建好了、数据也进去了结果一条查询因为字段名撞上关键字而报错。比如有人为了图省事直接把字段命名为order、key、select、desc、group、from这些单词。这些词在MySQL里属于保留字MySQL的词法分析器遇到它们时会优先按关键字解析而不是标识符所以SELECT order FROM t中的order会被当成ORDER BY的一部分直接导致语法混乱。在我排查过的真实案例里最常见的还有用desc做备注、用group做分组名、用key做外键字段名。报错信息五花八门有的是“You have an error in your SQL syntax”有的是莫名其妙的解析异常。如果项目用的是MyBatisXML文件里的SQL可能在启动时不报错到真正执行时才炸出来定位成本更高。4.2 反引号MySQL标识符的“保护罩”MySQL的标准做法是用反引号把保留字标识符包起来。反引号在MySQL里是标识符引号符用它包起来的字符串会被强制当成标识符解析而不是语法关键字。CREATE TABLE user_order ( order INT NOT NULL, desc VARCHAR(50), key VARCHAR(50) ); SELECT order, desc, key FROM user_order;注意一个细节**不要把反引号和双引号混用。**在默认的SQL模式下反引号专门包标识符双引号则更多被当作字符串字面量分隔符。你用双引号包order很可能会被解析成字符串常量一样报错。当然在ANSI_QUOTES模式下双引号也可以包标识符但多数项目的SQL模式并不会这么配所以实践中就统一用反引号别搞特殊。在MyBatis的XML里同样能直接用反引号select idgetOrder resultTypemap SELECT order, desc FROM user_order WHERE id #{id} /selectJDBC直接拼SQL也没问题。但这里要提醒一句如果动态SQL里用了${}去拼接字段名反引号并不能防止SQL注入因为它只解决关键字解析问题。标识符本身不能被参数化你只能尽量用白名单校验后再拼接。这是一个很多新手容易误解的点误以为加个反引号就万事大吉实际上安全性和名称解析是两码事。4.3 改名派vs反引号派存量表怎么选如果不幸已经建好表、上线了字段名撞了保留字你面前有两条路一是把所有SQL都加上反引号二是直接改字段名。我个人的经验是**如果是新建项目优先改命名如果是存量表且SQL数量多第一反应加反引号救急但长期还是要规划改名。**因为反引号虽然能解决问题但是让SQL变丑而且如果项目里不同成员的写法不统一有的人加引号有的人不加后续维护就是噩梦。改字段名的SQL在MySQL 8.0里可以这样写ALTER TABLE user_order RENAME COLUMN order TO order_no; ALTER TABLE user_order RENAME COLUMN desc TO description;改名后所有涉及这个字段的SQL、实体类映射、SQL语句都要跟着改。如果代码里搜索一下全是order引用工作量其实不小。所以务实做法往往是先反引号顶上同时排期做一次字段重命名。等以后做数据库迁移或大版本升级时顺手把字段名规范掉。4.4 新建表时如何从源头避开关键字与其救火不如从设计上就不让字段名撞上保留字。MySQL官方文档维护着一份保留字列表order、select、group、desc、key、from、where、table这些都赫然在列。建表之前先对字段名做一个“感冒测试”**如果字段名能被识别成常见SQL单词果断换。**更稳妥的做法是给字段加业务前缀或业务后缀。比如不叫order叫order_no、order_status不叫desc叫description或remark不叫group叫group_name。这样既保留业务语义又不和保留字冲突。还有一点值得注意不同数据库的关键字保留策略不一样。SQL Server用方括号[]包标识符PostgreSQL和Oracle用双引号包标识符MySQL用反引号。如果你的项目以后可能要兼容多种数据库最好一开始就不要用保留字命名否则换数据库时反引号脚本迁移又是一堆坑。5. 我实际踩过的const相关大坑排查与避雷清单5.1 踩坑实录const成员函数里调了非const成员函数有一次我封装一个监控上报组件类里有个const成员函数用于读取统计值逻辑写到一半发现需要复用另一个非const成员函数来做格式化。当时想都没想就调用编译器直接报错。我盯着错误看了半天才意识到const成员函数的this是const T *而调用非const成员函数需要T *类型不匹配。这个问题的解决思路有两个如果被调函数本身不改数据只是忘了加const那就给它补上const如果被调函数确实需要修改内部状态比如更新缓存那当前函数就不应该声明成const或者被改的成员标记为mutable。关键是先想清楚语义再决定是加const还是去掉const不要用const_cast糊弄编译器否则后面绝对出幺蛾子。5.2 const_cast修改真常量运行时才爆炸的教训const_cast是最容易写出“表面能编译、运行时崩”的代码。我见过一个处理跨语言字符串的程序模块A返回const char*指向一个常量字符串字面量模块B的老接口需要char*同事直接const_castchar*然后往里面写内容。测试一小段数据时没事因为字符串字面量所在的内存页恰好可写压测到大流量时直接段错误。这里有一个我必须强调的原则**只有在原始对象本身就非常量时才允许用const_cast修改。**怎么判断你回看这个指针最初是从哪里来的如果来源于const char* p abc这种常量存储区那原始对象就是常量绝对不能用const_cast修改如果来源于一个普通的非const char buf[]只是某处用const引用把它挡住那const_cast后修改是合法的。在实际工程里如果你发现自己正在用const_cast处理数据第一反应应该是怀疑设计有误而不是庆幸找到了捷径。5.3 C/C传参中的const丢失问题还有一个高频恶心场景C代码里定义了一个const char *s想传给一个形参为char *的C库函数。C语言编译器对这种“丢弃限定符”的转换通常只给警告所以很多人在C项目里没感觉但到C里这就是硬性的编译错误。比如老旧的C库函数写着void parse(char *str)你手里只有const char *s编译直接挂。正确办法是检查这个C库函数是否真的会修改传入字符串。如果它只读就尽量找有没有const版本的重载如果没有只能在调用点保证原对象不是常量再用const_cast。我建议在项目里统一把这些历史遗留调用封装起来封装层里做一次const_cast上层永远不直接触碰这样风险点被隔离排查时也有明确方向。5.4 常见const问题速查表问题现象根本原因解决方案const int *p和int *const p分不清const相对*的位置不同以*为参照左定对象右定指针C里char* p hello编译失败C字符串字面量为const char[]改用const char *pconst成员函数内修改成员报错this退化成const T *逻辑上不需要改就加const需要改就考虑mutableconst对象调不到非const成员函数常对象只能访问const接口给接口加const或重新设计访问方式const_cast后修改崩溃原始对象本身是真正的常量避免修改真常量检查指针来源MySQL字段名order导致SQL报错字段名是保留字用反引号包起来或改字段名MyBatis中保留字字段动态拼接出问题标识符不能参数化使用反引号白名单校验不用${}直接接受外部值最后再分享一个小经验const这个关键字本质上不是拿来限制别人的而是拿来保护自己的。你写const char *format保护的是调用方传入的数据不被你误改你写const成员函数保护的是对象的状态不被意外破坏你建表时避开保留字保护的是未来半年后接手的人不被你挖的坑绊倒。我这些年看代码有个感受一个函数或一张表设计得规不规范不用看复杂的架构文档只看它对待const和关键字的细节就大致能判断出作者的工程素养。如果这篇文章能让你在读代码、写代码时不那么怕const我对这个关键字就没白讲。
返回列表