
最近刚把一个攒了很久的项目收尾趁着周末坐下来把这一年的C代码翻了一遍整理出一版属于自己的编码规范。说是“规范”其实更像一份踩坑记录——这些年写C遇到的问题十有八九不是因为语法不会而是因为一开始就没想清楚约束该立在哪里。这篇文章是《莫寒C成长之路》系列的近期小结聊的是我给自己定的那套个人向规则不想看长篇大论的朋友可以把它当成一份参考答案。东西不复杂但每一条都是真金白银换来的。这套规范适合谁看刚把C语法学完、准备上手写项目的初学者写了两三年还觉得代码乱得没法看的人以及想给团队立规矩但不知道从哪下手的同学都有参考价值。我不打算讲“标准答案”这里也没有所谓权威只有我自己用下来确实能减少返工的约定。1. 为什么要立一套自己的编码规范1.1 C给自由度但不给兜底C是一门自由度极高的语言多继承、运算符重载、指针运算、模板元编程几乎所有特性都在诱惑你“这里可以用一下”。但这种自由是有代价的编译器不会拦着你写出能跑但极难维护的代码。我见过一个模块三千行全放在一个.cpp里函数起名从doThing1一直到doThing9头文件里居然藏着全局变量的定义所有人改完都提心吊胆。这种情况不是某个人的水平问题而是缺少一套明确的取舍标准。团队规范当然能解决一部分问题但个人规范的价值在于它是你在写每一行之前心里已经过了一遍的“默认答案”。比如我现在的默认答案是能用const的地方全部用const能不用裸指针就不用裸指针函数超过三十行就自觉拆。这些听起来不起眼落到每天几百行的代码量上效果非常明显。1.2 规范的核心目的不是好看是降低返工很多人误解编码规范是“让代码风格统一、看起来舒服”。统一确实重要但这是结果不是目的。真正的目的是降低未来的维护成本。三个月前的代码你自己回头看都需要重新理解一遍如果当时连命名和结构都没有约束那基本等于看别人写的代码成本翻倍。我给自己立规范的标准只有一条能不能减少“下次改动时踩坑”的概率。举一个简单例子——成员变量统一加m_前缀。这看起来只是风格问题但它立刻解决了构造函数参数和成员变量重名的歧义也不用费劲想this-要不要写。再比如禁止在头文件里定义非内联的全局变量直接消灭了一类“链接阶段莫名报错”的排查时间。这些规则不浪漫但非常实惠。1.3 个人向规范的三条基本原则整理下来我的个人规范围绕三个原则展开一致性同一类东西永远只有一种写法不做“看心情”的选择。可读性代码是给人看的机器只是顺便能编译过。安全性默认选择安全的写法哪怕它稍微啰嗦一点。后面所有细则都是这三条原则的具体化。记住这三句话看规范就不会觉得条目冗长。2. 命名与文件组织先把代码的门面立住2.1 命名风格自洽远比选哪派重要C社区最有名的两套命名风格一套是STL/Boost那套小写下划线snake_case另一套是Java/C#风格的驼峰CamelCase加上微软系那套PascalCase互联网上吵得快比语言本身还热闹。我自己的选择是类名用PascalCase函数和变量用camelCase宏全大写。理由很简单这个组合在阅读时区分度最高——看到大驼峰知道是类型看到小驼峰知道是函数或变量看到全大写知道是宏扫一眼就能建立分类。这里想强调一点选哪套不是重点重点是自洽。最糟糕的代码是头文件用驼峰源文件用下划线结构体成员用大驼峰枚举值又全大写读起来像三种语言混在一起。我自己曾把一份旧项目改成新风格光改命名就花了两个晚上后来再也不敢混搭。2.2 各类标识符的命名约定速查以下是我现在固定使用的约定可以直接抄作业对象风格示例类、结构体PascalCaseclass HttpConnection函数、方法camelCasevoid parseRequest()局部变量camelCaseint retryCount成员变量m_ camelCaseint m_connectionId常量k PascalCaseconstexpr int kMaxRetry 3宏全大写 下划线#define LOG_DEBUG(fmt, ...)模板参数大写T/U或语义化PascalCasetemplate typename T枚举值全大写或PascalCase选一种enum class ErrorCode { TIMEOUT }这里面有一个容易忽略的细节枚举一定要用enum class而不是裸enum这能避免枚举值污染外部命名空间也算一种命名规范。k前缀的常量命名借鉴自Google风格配合编辑器补全写的时候很舒服读的时候一眼知道这是个编译期常量不会试图去修改它。2.3 头文件与目录布局的硬规则文件组织上我有几条“红线”都是踩过坑才定的头文件必须有 include guard统一用#pragma once。它不支持的人会说可移植性问题但主流平台编译器全认写起来还少一个宏我用了几年没遇到问题。头文件里不定义非内联的全局变量。之前在某模块里写过int g_flag 0;放在头文件里结果多个cpp包含后链接报重复符号查了一个下午才反应过来。每个头文件对应一个源文件并且头文件里只放声明定义尽量放源文件。这不只是为了降低编译依赖也是让阅读者清楚知道“接口与实现的分界线在哪”。目录结构按模块拆不按类型拆。不要建一个utils/把所有人的工具函数都堆进去按业务模块建目录工具函数归属于它唯一服务的模块。文件命名上我直接用类名或者模块名一个类一个文件文件名与类名保持一致比如http_connection.h对应HttpConnection。这样在IDE里按文件名找类、按类去找文件永远不会有错位。3. 内存与生命周期现代C避坑的基本功3.1 new/delete 真的不能随便写早期写C的时候最喜欢在构造函数里new一个数组在析构函数里delete[]。这个写法看起来对称但只要中间抛出异常、提前return或者有人复制了这个对象立刻就会出问题——要么泄漏要么双重释放。现代C最核心的理念就是让资源跟着对象走对象销毁资源自动释放。我的规范第一条就是业务代码里禁止直接写new/delete。分配动态内存一律用std::make_unique或std::make_shared。这条规则执行起来很简单后果却很严重——有一次我在一个循环里裸写了一个new忘记在分支里delete线上跑了三天内存涨了十几个GB最后靠排查工具定位到那一行改掉之后再没犯过。如果你还在纠结“指针性能好、智能指针有开销”那先问自己一个问题你的代码真的需要微秒级的内存分配优化吗绝大多数场景不需要而泄漏和崩溃的代价远比这点性能高。性能瓶颈真到了那一步再局部绕开智能指针也不迟。3.2 智能指针选型unique_ptr 优先unique_ptr和shared_ptr的选型是很多人头疼的点。我给自己定的规则是默认用unique_ptr实在需要共享所有权才用shared_ptrweak_ptr只在必须打破循环引用时出现。为什么默认unique_ptr因为独占所有权意味着心智负担最小没有引用计数没有循环引用风险拷贝被禁止只能移动编译器还能帮你检查是不是误复制。而shared_ptr的引用计数生命周期是全局的一旦两个对象互相持有对方的shared_ptr就会形成循环引用谁也释放不了。打个比方unique_ptr像你租了一间房钥匙只有一把退租时房东自动收房shared_ptr像几个朋友合租每个人手里都有钥匙必须所有人都退钥匙房东才能收房只要中间有人忘了这房子就永远被占着。能用一把钥匙解决的问题没必要拉上其他人。3.3 RAII 不只是析构函数的事RAIIResource Acquisition Is Initialization是C最优雅的机制但很多人把它窄化成了“析构函数里释放内存”。实际上任何资源都可以用RAII管起来文件句柄、锁、数据库连接、图形上下文。C标准库的std::lock_guard、std::fstream、std::unique_ptr全是RAII的范例。我自己在项目里写过一个小工具类专门用来做“作用域结束时自动执行某段逻辑”类似ScopeGuard。这种东西用起来体验极好函数中间任何分支return资源都会自动清理完全不用担心漏掉。写代码的时候我要求自己形成条件反射——凡是“打开/获取”了什么立刻想到“如何保证它一定被关闭/释放”如果没有现成的RAII类就要么加一个要么改用已经有的封装而不是靠人肉记账。这里面有个小技巧析构函数里不要抛异常。如果析构函数抛异常在栈展开过程中会导致std::terminate直接终止程序。我之前被坑过一次析构函数里写了可能抛异常的清理逻辑结果程序在正常退出时莫名崩溃日志上只有一行terminate called after throwing an instance of ...。所以规范里明确要求析构函数默认noexcept有清理异常自己吞掉并写日志。4. 错误处理与const正确性把隐患掐死在编译期4.1 异常和错误码不是对立的C的错误处理有两大流派异常和错误码。很多人喜欢站队其实两者完全可以搭配使用。我的约定是预期内、可恢复的错误用错误码或std::optional预期外、不可恢复的错误用异常。比如读取配置文件时字段缺失这是预期内的返回错误码内存分配失败、逻辑走到不该走的默认分支这是预期外的抛异常。std::optional是个好东西。它语义清晰返回值可能“没有”调用方必须显式处理。很多情况下用它代替魔法数字或哨兵值能减少一大类边界错误。我用一个简单的函数说明std::optionalint tryParsePort(const std::string text) { if (text.empty()) { return std::nullopt; } try { return std::stoi(text); } catch (const std::exception) { return std::nullopt; } }调用方拿到optional就必须面对“可能为空”这个事实要么用value_or给默认值要么用has_value分支处理不会再出现“解析失败返回-1结果端口号真的能是-1”这种尴尬。4.2 noexcept 与移动语义的配合现代C里noexcept不只是性能优化还是一种接口契约。当move构造和move赋值被声明为noexcept时std::vector扩容可以放心地移动元素而不是拷贝反之如果有异常可能标准库会退回拷贝性能差距巨大。我的规范是在自定义类型的移动构造、移动赋值、析构函数、swap上都标注noexcept。实现的时候保证这些操作不会抛异常比如内部只有指针交接、小对象拷贝。这个约束虽然看着死板但能让容器用起来非常舒服。同时普通函数如果不会抛异常也可以标注noexcept但这里我不强制。因为漏标最多只是错过优化错标却会导致异常被吞掉、程序直接终止。所以我的建议是先保证移动操作和析构达标其他函数等编译器提示再补不要凭感觉乱标。4.3 能加 const 就加 constconst正确性应该是C规范里性价比最高的一条。参数加const告诉调用者“我没打算改你的东西”成员函数加const告诉调用者“这个方法不会改变对象状态”。这些信息一旦写成代码编译器就能帮你检查误修改等于把一部分运行时错误提前到编译期发现。我对自己的强制要求是不加const就默认可修改的引用参数必须是故意要修改它。成员函数只要能加const就全部加除非内部确实需要修改成员变量。局部变量如果初始化后不再改变也加const。实战中的感受是每次加一个const都是在给未来的自己打一剂预防针。有一次我改一个渲染模块一个成员函数忘记加const结果别的模块拿到的是非常量引用想调用一个const接口就得先加const_cast那味道想想都难受。const还有一个容易被忽视的场景全局常量和局部常量尽量用constexpr而不是const两者不只是语义不同——constexpr保证编译期求值而且可以用于模板参数、数组大小这些需要编译期常量的地方。能用constexpr的都不为过。5. 可读性维护与工具链规范靠人盯不如靠机器5.1 注释写什么才不算废话注释这块我有自己的执念注释只写“为什么”不写“是什么”。“是什么”代码自己会说话// 把 timeout 加上去 m_timeout 10;这种注释删掉没有任何影响留着反而占屏幕。真正有价值的是这种// 这里不能用 memcpy因为源和目标可能重叠要改用 memmove memmove(dst, src, len);它记录了一个具体的坑告诉后来者“你也许想过用更快的写法但那是错的”。我在代码里写函数注释时基本模板是这个函数是干什么的、什么情况下会出错、调用方需要保证什么前置条件。这样半年后再回头看自己写的接口不用重新推演逻辑。还要注意一点注释的风格要克制不要给每一行都加注释。变量命名清楚了调用逻辑自然清楚了评论只是填补“命名无法表达的那些内容”。5.2 函数长度与复杂度的自我约束我给自己定的硬限制单个函数尽量不超过30行超过就拆。这不是说30行是什么魔法数字而是超过这个长度函数名很难再准确概括它的行为变量开始重复使用状态开始累积阅读者必须上下滚动才能看全。复杂度控制上我尽量避免嵌套超过三层。写过这样一段垃圾代码for循环里套whilewhile里套if-elseif-else里又调了一个回调回调里还有一层循环。当时看着逻辑很顺但五天之后再改整个人都懵了。后来重构成几个小函数每个只做一件事测试也更好写了。拆函数的技巧是先找“缩进最深”的块把它独立出去参数用语义化的名字传进来返回值用一个清晰的结构带出去。拆的时候不用追求完美抽象先“物理拆分”让每个函数都能一句话说明白它在干嘛就已经赢过八成代码。5.3 用 clang-format 和 clang-tidy 管住自己手写的格式化总有漏网之鱼而且跟同事的代码一合并格式冲突能逼死人。我用clang-format做了两件事第一写一份.clang-format配置文件放在项目根目录设置好缩进、换行、指针星号靠左还是靠右、分号后是否空格这些细节第二提交前强制跑一遍格式检查不通过不让提交。clang-tidy更狠它直接做静态分析能检查出不安全的写法。比如它会对裸指针的使用给出提示对C风格的类型转换报warning对拷贝构造和析构存在但不满足Rule of Five的类给出告警。我最近在旧项目上跑了一遍clang-tidy列出来三百多个问题把其中“内存类”“并发类”“类型安全类”挑出来优先修很多代码看着能跑其实已经埋了雷。我还配置了一个提交前脚本自动跑clang-format和clang-tidy不过关就报错。这样做的好处是不靠自律靠工具。人总有偷懒的时候脚本不会。6. 近期踩坑复盘规范真的是从教训里长出来的6.1 一个 shared_ptr 循环引用把自己锁死的案例先说一个最近踩的坑。两个类互相需要持有对方一个是管理类一个是业务对象。我当时图省事双方都用shared_ptr保存对方结果析构永远不触发每次创建销毁一批就会少释放一批内存泄漏不明显但累计起来很要命。定位之后发现是循环引用。之后我在规范里专门加了一条共享所有权的关系中如果已经能确定“谁依赖谁”依赖方保存被依赖方用shared_ptr被依赖方回指依赖方一律用weak_ptr。weak_ptr不影响所有权用的时候用lock()临时获得一个shared_ptr用完自动释放。这相当于把“合租钥匙”变成了“临时访客卡”房东该收房时照样收。这也解释了为什么我前面说的“默认unique_ptr”有多重要如果用unique_ptr从根上避免了共享所有权循环引用就根本没机会发生。6.2 虚析构缺失引发的内存泄漏谜案另一个印象深刻的教训一个基类没有虚析构派生类持有了std::vector。通过基类指针删除派生类对象时只调用了基类析构派生类成员完全没有被释放。最气人的是代码跑的时候从表面看一切正常只有用内存检测工具才能看到泄漏量在涨。这类问题用眼睛很难发现所以我给自己定了一条规则只要一个类被当作多态基类使用有虚函数就一定要写虚析构函数哪怕是空实现。作为对比如果这个类明确不会被继承就把它标成final让编译器帮你阻止子类化。顺带说一个相关细节基类的析构函数建议声明为noexcept。多态删除时如果在析构里抛异常程序直接崩溃连排查的机会都没有。6.3 全局单例与隐藏依赖某次我在一个老模块里看到这样的调用ServiceLocator::instance().GetConfig().GetLogLevel()随手就在好几个文件里用了。后来需要给每个请求设置不同的日志级别才发现这个全局单例把整个系统状态绑死在进程级——想改成请求级必须动到所有调用方改到怀疑人生。全局单例本质上是隐藏的全局变量共享可变状态是最容易摧毁可维护性的东西之一。我的规范是默认不使用单例确实只有一个实例的需求优先考虑通过构造函数依赖注入显式传递。如果一个模块实在绕不开单例那就在规范里要求它必须是“初始化后只读”的状态绝不允许运行时随意变。依赖注入一开始写起来比较绕多传几个参数而已但它让依赖关系显式可见测试时可以替换实现这让后续的维护省太多事。6.4 模板滥用导致的编译时间膨胀最后一个是关于模板的。模板是C的神器但容易上瘾。我曾经写过一个“万能”的容器包装模板类型层层套编译时展开导致编译时间从几十秒膨胀到几分钟。最崩溃的是报错信息能打出几百行里面全是像_M_impl之类的内部符号看到整个人都麻了。模板的使用规范我现在放在心里模板的复杂度不超过两层任何复杂的模板都尽量用概念C20 Concepts约束类型把报错信息变得可读。能用普通派生类解决的就不要模板模板只在“对不同类型提供完全相同接口”时才用。写模板的同事但凡看到编译报错有几百行我第一反应就是问能不能拆掉一层编译时间不只是体验问题——它直接影响开发效率。CI流水线每次多跑两分钟一天几十次提交下来就是几个小时。抽象得很爽的模板如果换不来极高水平的生产力就该让位于简单直白的代码。最后分享一点个人体会整理这套规范的过程比我预想的要花时间但收获也远超预期。最大的感触是规范不是拿来约束别人的是拿来约束未来的自己。每写下一个“禁止”背后都有一个我真正踩过的坑所以执行起来没有“要不要遵守”的犹豫只有“这次真的不能再踩”的自觉。如果你刚开始给自己的项目立规矩不要贪多。先挑五条最痛的比如禁止裸new/delete、成员变量加前缀、能const就const、函数别超过30行、头文件不定义全局变量。坚持一个月你会发现代码的“事故率”显著下降再慢慢把它丰富成一份只属于你自己的操作手册。毕竟写代码这件事最可靠的文档从来不是网上抄来的模板而是你自己血泪换来的习惯。