1. 那个让整个组加班到十点的未命名花括号

先说个真事儿。前年我刚接手一个同事离职留下的模块,打开他的代码文件,肉眼扫过去第一屏就有三种缩进风格混着用:函数体是 4 空格,循环体是 Tab,结构体成员又莫名变成 2 空格。更要命的是他喜欢在 if 后面不换行直接写语句,而团队规范里明确要求必须加花括号。我给这段代码补一个日志埋点,改完一提交,review 页面上 diff 显示足有八百多行变动——因为 Git 把整段缩进差异全算进去了。

那天的活本来二十分钟能干完,最后我花了一个多钟头给同事的代码逐段调整对齐,又花了四十多分钟给 Git 记录里的"假 diff"做解释。组里其他人就站在洗手间门口等着用那台 Windows 机器跑编译,因为只有那台上装着旧版库。

这不是手气差,这是代码规范化工具箱里啥都没有的必然结果。C++ 这门语言从 1983 年出生到现在,风格问题就一直没断过:Bjarne 本人的写法、Google 的规范、LLVM 的规范、Qt 的规范、各家公司的内部规范,随便列出来就是五六套。更麻烦的是 C++ 的灵活性给了每个人创造"个人风格"的空间——同一件事能写出二十种不同写法,而每一种都能通过编译。

所以当团队里有人说"我们要做代码规范化"时,他真正的问题是:定哪套规则?怎么让规则不被当作摆设?怎么在 code review 之外、提交进仓库之前就把问题拦住?怎么让新手来了以后不用背规范文档也能写出符合要求的代码?

这篇文章要聊的就是这几个问题的落地答案。先说工具组合怎么选、规则文件怎么配、怎么塞进 CMake 和 git 流程里,再说那些跑在真实项目里才会炸出来的坑。都是我自己在几个 Windows 和 Linux 混合的项目里踩过的,可以直接抄作业。

单纯讲"规范很重要"没什么意义,真正有意义的是把它变成流水线上自动跑的那一环。

2. clang-format 与 clang-tidy 各管哪一段:别把工具用反了

不少刚接触规范化的 C++ 开发者,第一反应是找一个工具把代码"格式化"一遍就算完事。这个想法对了一半。格式化确实解决缩进、换行、空格这类 视觉层面 的问题,但代码规范化里还有一整个地盘是格式化工具管不了的:未使用变量、危险的隐式转换、违反命名规则的函数、可能越界的循环、拷贝构造写得有问题的类。这些是 静态检查 的领域,得交给另一个工具。

我们项目里实际用到的是一对组合: clang-format 负责排版, clang-tidy 负责质量与风格检查。这里有个常见误区得先说清楚。

2.1 clang-format 不负责检查你的代码有没有问题

clang-format 本质是一个纯文本级的排版器。它把源码当作一串 token(标记流),根据你提供的风格规则(缩进宽度、对齐方式、是否在运算符两侧加空格、花括号在行尾还是独立成行等)重新生成代码文本。它完全不去理解你的代码逻辑,所以它不会告诉你"这个循环可能有越界"或者"你忘了解释一个指针的所有权"。

它的价值体现在两个地方:

第一,给全组统一基线。只要大家共用同一个 .clang-format 文件,不管本人习惯是 Tab 还是空格,提交前跑一遍格式化,所有人交出来的代码长一个样。第二,消灭因为格式差异引起的无效 diff。这一点在多人改同一文件时尤其重要——否则你改了第 30 行,因为格式问题整个文件 diff 几百行,review 的人根本不知道你实际动了什么逻辑。

注意:clang-format 会把你手写的换行布局按规则重排。如果你特意把一行代码断成几行为了可读性,而规则里列宽是 80,那它会在 80 列位置强制打断。这是预期的行为,不是 bug。

2.2 clang-tidy 才是那个"抓问题"的工具

clang-tidy 是不同的物种。它基于 clang 的抽象语法树运行,真正理解你的代码结构。它检查的东西包括:

  • bug 类 : clang-analyzer-* 系列,比如空指针解引用、资源泄漏、除零。
  • 可读性与风格类 : readability-* 系列,比如变量名太长或太短、多余的 else 、不必要的 const 。
  • 现代 C++ 用法 : modernize-* 系列,比如把 NULL 换成 nullptr ,把手写循环换成 std::find_if 。
  • 性能类 : performance-* 系列,比如不必要的拷贝、在循环里反复构造临时 std::string 。
  • 并发类 : concurrency-* 系列。
  • Google/LLVM 等风格的专属检查集 : google-* 、 llvm-* 。

所以你可以把这条链路理解为:格式化管"好看",静态检查管"不炸"。两者必须配合使用,不能互相替代。

我们在项目里把 clang-format 和 clang-tidy 都接入了 CMake,确保任何人在本地 cmake --build . 时都会触发检查,而不是靠自觉去命令行手动敲一下。

3. 一套能直接抄的 .clang-format 配置:关键选项逐一解读

网上能搜到一堆 clang-format 风格模板,Google 风格、LLVM 风格、Chromium 风格。直接拿着用不是不行,但建议还是基于自家项目实际调一遍。下面这份是我目前在一个跨平台库项目里用的配置,注释里都写了"为什么这么设"。

3.1 基础配置示例

# .clang-format
BasedOnStyle: Google
IndentWidth: 4
ColumnLimit: 100
TabWidth: 4
UseTab: Never
BreakBeforeBraces: Attach
AllowShortIfStatementsOnASingleLine: false
AllowShortLoopsOnASingleLine: false
AllowShortFunctionsOnASingleLine: Empty
SortIncludes: CaseSensitive
PointerAlignment: Left
DerivePointerAlignment: false
IncludeBlocks: Regroup

逐条说下这些选项背后的逻辑:

  • BasedOnStyle: Google :Google 风格在 C++ 社区里认知度高,花括号换行的约定比较主流。但它默认缩进是 2 空格,许多 C++ 老项目习惯 4 空格,所以我覆盖了它。
  • IndentWidth: 4 / TabWidth: 4 :4 空格缩进是我带的几个项目里的共识。更宽更清晰,嵌套深了也能看出层级。
  • ColumnLimit: 100 :Google 默认是 80,太窄了,现在屏幕普遍够宽,100 列在 split diff 视图下也基本不会折行。如果你组里有几个 1080p 显示器竖屏党,100 比 80 舒服不少。
  • UseTab: Never :这个真的一票否决。我见过 Tab 和空格混用然后编译警告的工具链,见过因为 Tab 宽度不同对齐全乱的 diff。宁可强制不用 Tab。
  • BreakBeforeBraces: Attach :意思是花括号跟在 if 、 for 、函数名后面同行(K&R 风格)。Google 默认是这样,符合大多数人的阅读习惯。你如果偏好 Allman(花括号另起一行),把值改成 Allman 即可。
  • AllowShortIfStatementsOnASingleLine: false :禁止把 if (x) return; 写在一行。C++ 里单行 if 是很多隐蔽 bug 的来源——往里面加第二行时如果忘了加花括号,整个逻辑就变了。
  • AllowShortFunctionsOnASingleLine: Empty :空函数体(比如虚析构函数只写了 {} )可以留在同一行,其他情况强制展开。
  • SortIncludes: CaseSensitive :让 clang-format 自动按字典序排列 #include ,省得手工整理。这个选项对减少头文件重复包含、方便查找头文件都有帮助。
  • PointerAlignment: Left : int* p 而不是 int *p 。这个纯粹是风格习惯,但必须统一,因为两种写法混在一起特别扎眼。
  • IncludeBlocks: Regroup :让 clang-format 把 include 分块:本项目的头文件、系统库头文件、第三方库头文件,块之间加空行。这个对 CMake 项目很友好。

3.2 配置文件的生效方式与优先级

.clang-format 文件放在仓库根目录下,clang-format 会从被格式化文件所在目录开始向上查找,找到最近的配置文件。如果你只想对某个子目录用不同规则,在那个子目录单独放一份 .clang-format 即可,这份会覆盖根目录的配置。

这一点在"第三方代码目录不想动"的场景下非常好用。我们项目里有 third_party/ 文件夹,里面是外部仓库的代码,改动格式会和我们维护的分支产生大量冲突。解决方案就是在 third_party/ 下放一个自己的 .clang-format ,里面什么都没写,只加了一行:

DisableFormat: true
SortIncludes: false

这样 clang-format 对这个目录下的代码一律不处理。

4. 把规范化嵌进 CMake 流程:不只是格式化,还要做编译期检查

配置文件有了,接下来关键的是怎么让团队真正跑起来。很多人下载了 clang-format,格式化了一次,觉得"挺好",然后就再也没碰过——因为手动跑工具这件事本身就不可持续。规范化的价值必须靠流程自动化才能发挥出来。

我把 clang-format 和 clang-tidy 都接到了 CMake 里,让 cmake --build 和 ctest 阶段就能响应检查,并且可以随时切换到"只提示"或者"报错"两种模式。

4.1 添加 clang-format 检查目标

# 在 CMakeLists.txt 末尾追加
find_program(CLANG_FORMAT_BIN clang-format)
if(CLANG_FORMAT_BIN)
  file(GLOB_RECURSE ALL_SOURCE_FILES
    src/*.cpp
    include/*.h
    tests/*.cpp
  )
  add_custom_target(format-check
    COMMAND ${CLANG_FORMAT_BIN}
      --style=file
      --dry-run
      --Werror
      ${ALL_SOURCE_FILES}
    COMMENT "Checking code formatting with clang-format"
  )
  add_custom_target(format
    COMMAND ${CLANG_FORMAT_BIN}
      --style=file
      -i
      ${ALL_SOURCE_FILES}
    COMMENT "Formatting source files with clang-format"
  )
endif()

解释一下这里几个参数的含义:

  • --style=file :告诉 clang-format 用项目根目录下 .clang-format 文件里的规则,而不是内置默认规则。
  • --dry-run :只检查而不实际修改文件,配合 --Werror 可以在"发现问题"时返回非零退出码,这样构建系统会报错。
  • -i :原地修改。是实际格式化时用的模式。

这里面有个隐患: file(GLOB_RECURSE ...) 在 CMake 里是"配置期"执行的,新增/删除源文件后不会自动更新。解决办法是每次重新运行 cmake,或者改用 CONFIGURE_DEPENDS 选项。从 CMake 3.12 开始支持:

file(GLOB_RECURSE ALL_SOURCE_FILES CONFIGURE_DEPENDS
  src/*.cpp
  include/*.h
  tests/*.cpp
)

加上 CONFIGURE_DEPENDS 后,重新构建时会重新扫描目录,新文件自动纳入检查范围。实测在 Visual Studio 生成器和 Ninja 生成器下都有效。

4.2 让 clang-tidy 在编译时检查

clang-tidy 与 clang-format 不太一样。它需要知道编译选项(头文件路径、宏定义、C++ 标准版本),否则解析一些代码会报错或者漏检。接入 CMake 有两条路:

路线一:通过 CMAKE_CXX_CLANG_TIDY

set(CMAKE_CXX_CLANG_TIDY
  "clang-tidy;-checks=-*,clang-analyzer-*,readability-*,modernize-*"
)

在 CMakeLists.txt 里设置了 CMAKE_CXX_CLANG_TIDY 后,每次编译 C++ 目标时,CMake 会把每个源文件的实际编译命令自动传给 clang-tidy,clang-tidy 就能完美理解代码上下文。这个方案最省事,不需要单独的 target。

但它的缺点是:每次修改一个 .cpp 文件,重新编译一次就检查一次,哪怕你只改了一行注释。大型项目里这会拖慢编译速度。另外 clang-tidy 某些检查(例如 misc-throw-by-value-catch-by-reference )耗时较长,全量开会导致构建时间明显上升。

路线二:独立 check 目标(推荐)

我在实际项目里更推荐单独建一个 tidy target,需要时手动跑,而不是每次编译都扫描一遍:

find_program(CLANG_TIDY_BIN clang-tidy)
if(CLANG_TIDY_BIN)
  add_custom_target(tidy
    COMMAND ${CMAKE_COMMAND} -P ${CMAKE_SOURCE_DIR}/cmake/run-clang-tidy.cmake
    WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
    COMMENT "Running clang-tidy static analysis"
  )
endif()

上面的 run-clang-tidy.cmake 脚本需要自己写一点逻辑,核心是遍历 compile_commands.json (编译命令数据库),对每个源文件执行 clang-tidy。想省事的话,也可以用 LLVM 项目自带的 run-clang-tidy.py 脚本,在 /usr/local/opt/llvm/share/clang/run-clang-tidy.py 等路径能找到,直接复用:

python3 run-clang-tidy.py -p build -checks='-*,readability-*,modernize-*' -header-filter='include/'

其中 -p build 是编译数据库目录, -header-filter 限制只检查项目自己的头文件,不检查第三方与标准库头文件。

提示:无论走哪条路线,都要开启 CMake 的 CMAKE_EXPORT_COMPILE_COMMANDS 。这个开关会在构建目录生成 compile_commands.json ,clang-tidy 和很多 IDE 扩展都依赖它。在命令行加 -DCMAKE_EXPORT_COMPILE_COMMANDS=ON 或者放在 CMakePresets.json 里都行。

4.3 编译期 vs 提交期:检查时机的选择

这里分享一个经验法则: 格式化检查放在提交前(pre-commit)或者持续集成(CI)里;clang-tidy 放在手动执行的独立阶段,或者只对关键目标开启 。

原因不复杂:clang-format 极快,多几百个文件也就几秒钟,放在每次构建里完全可以接受。clang-tidy 慢,尤其当你开启 clang-analyzer-* 这种深度分析时,单文件可能要几十秒甚至几分钟。你要是把它绑死在每次编译流程里,组员的耐心会在两周内耗尽,最后大家一起把检查注释掉。

所以我们的实际流程是:

阶段 执行内容 失败时的处理
本地提交前(pre-commit 钩子) clang-format 全量检查 拦截提交,提示运行 cmake --build . --target format
开发者手动执行 clang-tidy 标准检查 按警告逐条修复,一般要求清零
CI 的 MR/PR 分支 clang-format + clang-tidy 全量检查 门禁拦截,不允许合并

5. 把规范化嵌入 Git 工作机制:pre-commit 钩子与 CI 双重保障

光在 CMake 里加了 target 还不够,因为人总是会忘记跑 make format 、忘记跑 clang-tidy。我在项目里又加了两个保险:Git pre-commit 钩子强制检查,以及 CI 流水线里的全职检查任务。

5.1 pre-commit 钩子脚本示例

在仓库 .git/hooks/pre-commit 里放一个脚本,每次 git commit 之前自动跑检查。为了让大家不用手动复制脚本到各自的 .git/hooks ,我选择把钩子脚本放到仓库里的 scripts/pre-commit.sh ,然后在 README 或 setup 脚本里手动执行一次 git config core.hooksPath scripts 。

# .git/hooks 或 scripts/pre-commit.sh(根据 core.hooksPath 而定)
#!/bin/sh

echo "Running clang-format check..."
if ! clang-format --style=file --dry-run --Werror \
    $(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(cpp|h|hpp|cc)$'); then
    echo "❌ 代码格式不符合项目规范。"
    echo "请先运行: cmake --build build --target format"
    exit 1
fi

echo "Commit 前格式化检查通过。"
exit 0

有几个点需要解释:

  • git diff --cached --name-only --diff-filter=ACM 只处理暂存区里的文件,且只处理新增(A)、修改(M)、复制(C)状态的文件。删除的文件不需要格式化。
  • 管道给 grep -E 是为了过滤出 C++ 相关扩展名。如果你的项目头上还有 .cc 、 .hpp 、 .inl ,记得都加上。
  • 这个钩子只检查不修改。为什么不用 -i ?因为 pre-commit 里保持"只读"更安全——如果你偷懒没先格式化,提交会被拦下来,之后你可以手动格式化再 add 再 commit。要是钩子直接帮你改了代码,那 git diff 里的缓存状态会变得混乱,容易把未暂存的改动一起提交上去。

5.2 在 CI 里用两个 Job 卡住入口

我们的 CI 用的是 GitHub Actions,但思路通用于 GitLab CI、Jenkins 或任何平台: 格式检查要快、要独立于编译阶段;clang-tidy 也不依赖测试跑的编译产物,单独一份编译翻译单元即可。

一个最小的 GitHub Actions 工作流长这样:

# .github/workflows/code-quality.yml
name: code-quality

on:
  pull_request:
  push:
    branches: [ main ]

jobs:
  clang-format:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install clang-format
        run: sudo apt-get update && sudo apt-get install -y clang-format
      - name: Run clang-format check
        run: |
          find src include tests -name '*.cpp' -o -name '*.h' | xargs clang-format --style=file --dry-run --Werror

  clang-tidy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install clang-tidy
        run: sudo apt-get update && sudo apt-get install -y clang-tidy
      - name: Configure CMake with compile commands
        run: cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
      - name: Run clang-tidy
        run: |
          python3 run-clang-tidy.py -p build -checks='-*,readability-*,modernize-*' -header-filter='include/'

这个工作流里有几个可以直接抄的设计决策:

  1. 格式检查和 tidy 分成两个 Job。如果一个挂了,另一个还能跑,log 里能直接看到失败原因,不用从头翻。
  2. sudo apt-get install clang-format 不一定和你本地的 clang 版本一致,有可能出现"本地格式化通过、CI 失败"的情况。解决办法是锁定版本,或者用 Docker 镜像。我见过很多团队在这上面栽跟头,后面第 5 节专门讲。
  3. CI 里只跑 cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON ,不实际编译整个项目。因为 clang-tidy 需要的是编译命令,不是编译产物,这一步能省一两分钟。

5.3 给"老代码"一条活路:渐进式接手

直接对全仓库强制执行 clang-format 有个后果:一次提交全部文件都会被格式化,git history 里每个文件的 blame 全都变形,后续 review 会很难受。如果你的项目已经跑了两三年,里面有不少历史遗留代码,不建议上来就一刀切全量格式化。

我建议做法是折中:在根目录 .clang-format 里保留全量规则,但新增一个 legacy/ 子目录(如果历史代码散落各处,就先建一个分支,把所有旧代码挪进去),在 legacy/ 里放一份禁用格式化的 .clang-format 。新代码放进 include/ 、 src/ 、 tests/ ,走全量检查;旧代码暂时不做处理,等后续重构时一块块翻出来格式化。

这也是为什么 pre-commit 脚本和 CI 检查要显式列目录( find src include tests ),而不是全仓库扫描。 规范化工具落地效果好不好,一半取决于规则本身,另一半取决于你让它管多大范围。

6. 真实跑起来时最常炸的七个坑:版本不一致、宏定义误判、第三方头文件遭殃

这节谈谈我从实际项目里总结的血泪教训。理论配置讲完了,工具进到真实环境一定会遇到各种意外。每一条都是我亲身踩过、并且找到了解决方案的。

6.1 坑一:本地和 CI 的 clang-format 版本不同

这是最隐蔽的一个坑。clang-format 的规则文件格式在版本间有演进。比如 DerivePointerAlignment 、 InsertBraces 这些选项是后来才加入的,老版本不认。你本地用的是 Ubuntu 22.04 自带的 14.0 版,CI 上用的可能是 apt 源里的 10.0 版,两边格式化同一段代码,结果可能不同——最常见的差异在于 AllowShortIfStatementsOnASingleLine 的解析规则,以及二进制运算符是否换行的差异。

解决方式 :在 CI 和本地统一版本。我们仓库里放了 .clang-format-version 文件,和 .clang-format 放一起,写的是 14.0.0 。同时在开发环境里用 Docker 镜像,把 clang-format、clang-tidy、CMake、甚至 GCC 都固定到对应版本。Dockerfile 大概是这样:

FROM ubuntu:22.04
RUN apt-get update && apt-get install -y \
    clang-format-14 \
    clang-tidy-14 \
    clang-14 \
    lldb-14 \
    cmake \
    ninja-build \
    git

如果你不想上 Docker,最次也要在 CMake 里加一行版本检查:

execute_process(
  COMMAND clang-format --version
  OUTPUT_VARIABLE CLANG_FORMAT_VERSION
  ERROR_QUIET
)
if(NOT CLANG_FORMAT_VERSION MATCHES "14\\.")
  message(FATAL_ERROR "需要 clang-format 14.x,当前为 ${CLANG_FORMAT_VERSION}")
endif()

6.2 坑二:clang-tidy 误报——宏定义或者平台条件编译导致解析失败

我们的代码里有一些 Windows 专属分支,用了 #ifdef _WIN32 、 #ifdef __GNUC__ 。clang-tidy 在 Linux 上解析这些文件时,默认只走 Linux 分支,Windows 分支的代码可能是未经推导的宏、平台上没有的头文件,于是报出一堆错误的 "use of undeclared identifier"。

解决方式 :clang-tidy 有一个 --extra-arg 选项,可以给编译命令额外追加定义。我们在 CI 脚本里加了一行:

clang-tidy -p build --extra-arg='-D_WIN32' src/windows_only.cpp

或者更好的是在 CMake 里给不同平台分别设置 COMPILE_DEFINITIONS ,这样 clang-tidy 拿到 compile_commands.json 里就已经带上了对应平台的宏定义。如果交叉编译场景复杂,建议直接给 clang-tidy 指定是分析哪个平台,别让它自己猜。

6.3 坑三:第三方头文件被拖下水

如果你的 CMake 工程包含了 /usr/include/opencv4/ 或者 vcpkg 安装的库头文件,clang-tidy 默认会去解析它们,并输出大量来自这些头文件的警告。比如 opencv2/core/mat.hpp 内部的一些写法不符合我们的 readability 规则,警告刷屏后,项目本身的警告被淹没。

解决方式 :用 -header-filter 强制限制。我们用的是:

clang-tidy -p build -header-filter='^(include|src)/'

这样 clang-tidy 只会报告项目自身头文件( include/ 、 src/ 目录)里的问题,系统头文件、第三方库头文件即使有问题也一概忽略。同时检查规则里也建议排除 misc-include-cleaner 这类对系统头文件敏感度高的检查。

6.4 坑四:clang-format 把对齐后的注释块搞乱了

如果代码里有手工对齐的多行注释块(比如函数参数说明表),clang-format 的默认 AlignTrailingComments 行为可能不满意——它往往会打乱原始对齐,让注释紧贴上一个代码元素。看起来不整齐,而且因为没有语法错误,很难被注意到。

解决方式 :在 .clang-format 里关闭对齐,或者干脆接受 release 版 clang-format 的默认行为。部分项目用 15.x 版以后, AlignTrailingComments 的表现趋于稳定,可以把值设为 true 并在注释块前加 // clang-format off 来豁免。例:

// clang-format off
// 参数说明:
//   a : 输入参数,表示 xxx
//   b : 输入参数,表示 yyy
//   c : 输出参数,表示 zzz
// clang-format on
void func(int a, int b, int* c);

6.5 坑五:clang-tidy 对项目代码里的 goto 、异常、C 风格 cast 误报

有些检查项与项目实际风格冲突。比如 readability-identifier-length 会抱怨 i 、 j 这类循环变量太短,但我们代码里的 for (int i = 0; i < n; ++i) 是传统写法,完全合理。如果直接全开 readability-*,会得到大量噪音。

解决方式 :不要直接套整组检查,而是先白名单少量、明确价值的检查。我推荐初始配置是:

Checks: >
  -*
  clang-analyzer-*
  bugprone-*
  readability-identifier-length
  readability-braces-around-statements
  readability-container-size-empty
  modernize-use-nullptr
  modernize-use-override
  performance-unnecessary-copy-initialization
  performance-unnecessary-value-param

这里的 -* 代表"关闭所有检查",然后逐个开启指定的检查项。项目稳定后,再逐步添加新的检查,别一步到位,否则告警多到没人看。

6.6 坑六:Windows 上换行符 CRLF 导致 clang-format 反复横跳

Windows 上 Git 默认把文本文件换成 CRLF 换行,Linux 上则是 LF。clang-format 格式化完成后,如果跨平台提交,可能出现"格式化的 diff 里全是换行差异"的情况。

解决方式 :在仓库根目录加一个 .gitattributes :

* text=auto eol=lf
*.cpp text eol=lf
*.h text eol=lf
*.cmake text eol=lf
CMakeLists.txt text eol=lf

同时要求本地 Windows 开发者的 Git 配置 core.autocrlf 设为 false 或 input 。这样不管开发者在 Windows 还是 Linux 上,存入仓库的都是 LF,从仓库检出到工作区时再按平台转 CRLF。我们在 CI 中也验证一次: git ls-files --eol | grep '^i/lf' 确保没有 CRLF 混入。

6.7 坑七:把 clang-format 和 clang-tidy 版本绑定在 IDE 插件里

用 VSCode 做 C++ 开发时, C/C++ 插件自带的格式化器是可选的,但它默认可能用的是插件的内置引擎,而不是你的 clang-format。如果两边引擎版本不一致,就会出现"在编辑器里看着是规范的,一提交 CI 全红"的尴尬局面。

解决方式 :在项目的 .vscode/settings.json 里固定使用系统命令行 clang-format:

{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "llvm-vs-code-extensions.vscode-clangd",
  "clangd.arguments": [
    "--clang-tidy",
    "--header-insertion=never"
  ]
}

用 clangd 替换微软的 C/C++ 插件作为主力开发插件(它的补全和诊断都基于 clang,和 clang-tidy 是一家人),并在团队文档里写明:本地必须按第 3.1 节方式安装 clang-format 14.x。如果真有人漏装,pre-commit 钩子会在提交前拦下来。

7. 从"能用"到"好维护":让规范化工具在项目里长期运转的个人体会

讲完工具与坑,最后聊点组织层面的经验,这也是代码规范化工具能不能在一个项目里长期活下去的关键。

我见过不少团队的失败案例:一开始轰轰烈烈引入 clang-format 和 clang-tidy,写了一大堆规则,把检查塞进 CI,结果三个月后告警列表里躺了几百条没人修的 warning,CI 门禁形同虚设;再后来有人干脆在 merge request 里绕过了检查直接 push,整个流程就烂掉了。规范化这件事,最大的敌人从来不是工具,而是"失去耐心"。

我自己的经验是三条原则。

第一条, 从小范围开始,让规则先在一个目录里被完全遵守 。我们刚上线规范时只对 include/ 和 src/core/ 开了检查,其他模块不动。第一个月攒的改动不多,但每条都干净;当新加入的同事看到"规范区域"与"历史区域"的对比,他会天然地想把新代码写进干净的区域。这比任何惩罚机制都有效。

第二条, 告警数量盯趋势,不盯绝对数 。clang-tidy 刚开的时候,我们仓库里有三百多条 warning,根本修不完。但我们在 CI 里记录上次的警告数,如果当前分支比上次多了哪怕一条,就拦住。这样持续三周,警告数量一路下降到了八十条左右,剩余的都是历史遗留,排期在重构任务里慢慢处理。

第三条, 定一个"官方格式化走查日" ,我习惯放在每周四下午。全组花二十分钟,各自对自己这个星期写的代码跑一遍 format-check ,有异常直接提交。这个仪式感很轻,但作用很大——它把"规范化"从一个冷冰冰的门禁变成了团队协作的一个节奏点。

另外还有一个容易被忽略的细节: .clang-format 和 .clang-tidy 这些文件本身也需要被 code review。它们不是写一次就再也不动的,随着项目引入新的库、新的编译器版本,规则也要跟着迭代。至少每个季度过一遍配置,看看有没有新的 clang-tidy 检查项可以点亮。

这套流程跑下来一年后,我们仓库里的 diff 干净了很多,code review 的注意力真正回到了逻辑本身。有一次新同事在 review 里给一段循环优化方案提了个性能建议,跟格式一点关系都没有——我当时的感受是,这一年的坚持,值了。

最后分享一个小技巧作为收尾:如果你项目里还有不少历史代码不敢动,又想让 clang-tidy 先帮你把最危险的 bug 类问题扫一遍,可以先只打开 clang-analyzer-* 这个检查集,它对代码风格零要求,纯找空指针、内存泄漏、除零这类问题,而且极少误报。我在这套配置下只跑了半天,就从一个没人维护的老模块里揪出了两个隐藏的资源泄漏点。规范化不一定非要等大重构,从查隐患开始,也是一个很顺的切入点。

更多推荐