본문으로 건너뛰기
Previous
Next
Static Analysis in C++: Enforce Quality with Clang-Tidy &

Static Analysis in C++: Enforce Quality with Clang-Tidy &

Static Analysis in C++: Enforce Quality with Clang-Tidy &

이 글의 핵심

Integrate clang-tidy and Cppcheck into your C++ workflow. Configure .clang-tidy, generate compile_commands.json, run both tools in CI, and adopt incrementally on legacy codebases.

Why Static Analysis?

Tests verify behavior for inputs you thought of. Static analysis verifies structural properties of the code itself — without executing it. It catches bugs that exist on rarely-executed code paths, in unusual configurations, or in code that was never tested at all.

Common bugs caught before runtime:

  • Use-after-move: accessing a moved-from object on the happy path looks fine, but calling it twice crashes
  • Null pointer dereference: pointer checked on one branch but dereferenced on another
  • Uninitialized variable: reads garbage in optimized builds even when debug builds zero-initialize
  • Range-for copy: for (auto item : container) copies every element when const auto& was intended
  • Resource leak: allocated but never freed on an error path

Finding these at CI time instead of in production is a significant reliability improvement.


The Two Tools and Why Both

Clang-Tidy is LLVM’s linter. It plugs into the Clang frontend and uses the same AST the compiler builds. This gives it deep understanding of C++ semantics — it can reason about move semantics, template instantiations, and type conversions. It has 400+ checks across categories: bugprone-*, modernize-*, performance-*, readability-*, cppcoreguidelines-*.

Cppcheck is a standalone analyzer that does its own parsing. It does not need a compilation database and runs on projects without Clang in the toolchain. It excels at flow-sensitive analysis: detecting null dereferences, out-of-bounds access, and integer overflow.

They catch different things. Running both increases coverage. The combination is the standard in serious C++ projects.


Setting Up Clang-Tidy

Generate compile_commands.json

Clang-Tidy needs to know your build flags (include paths, preprocessor definitions, language standard) to analyze each file correctly. CMake generates this with one flag:

cmake -B build -S . \
    -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
    -DCMAKE_BUILD_TYPE=Debug

The result is build/compile_commands.json. Some projects symlink it to the project root so editors find it automatically:

ln -sf build/compile_commands.json compile_commands.json

For non-CMake builds, bear can generate it by intercepting compiler invocations:

# Debian/Ubuntu
sudo apt-get install bear
bear -- make -j$(nproc)

The .clang-tidy Configuration File

Place .clang-tidy at the project root. Clang-Tidy searches upward from each file’s directory to find it:

# .clang-tidy
Checks: >
  bugprone-*,
  -bugprone-easily-swappable-parameters,
  modernize-use-nullptr,
  modernize-use-override,
  modernize-avoid-bind,
  performance-for-range-copy,
  performance-unnecessary-copy-initialization,
  readability-identifier-naming,
  readability-avoid-const-params-in-decls

# Treat these checks as errors (block CI)
WarningsAsErrors: 'bugprone-*,performance-for-range-copy'

# Analyze headers in your project, not system/third-party headers
HeaderFilterRegex: '^(?!.*third_party).*\.h(pp)?$'

CheckOptions:
  - key:   readability-identifier-naming.ClassCase
    value: CamelCase
  - key:   readability-identifier-naming.FunctionCase
    value: camelCase
  - key:   readability-identifier-naming.MemberCase
    value: lower_case
  - key:   readability-identifier-naming.MemberSuffix
    value: '_'

Running Clang-Tidy

# Check a single file
clang-tidy -p build src/main.cpp

# Check all files in src/
clang-tidy -p build src/*.cpp

# Parallel (faster on multi-core)
run-clang-tidy -p build -j$(nproc)   # from llvm-dev package

# Auto-fix: apply safe fixes (review the diff!)
clang-tidy -p build --fix src/*.cpp
git diff    # review what was changed

What Each Check Category Finds

// bugprone-use-after-move
std::string s = "hello";
auto s2 = std::move(s);
std::cout << s.size() << '\n';  // WARNING: use-after-move — s is in valid but unspecified state

// performance-for-range-copy
std::vector<std::string> names = {"Alice", "Bob", "Charlie"};
for (auto name : names)           // WARNING: copies each string
    std::cout << name << '\n';
// Fix:
for (const auto& name : names)    // no copy
    std::cout << name << '\n';

// modernize-use-nullptr
int* ptr = NULL;                  // WARNING: use nullptr
int* ptr2 = nullptr;              // OK

// modernize-use-override
class Base {
    virtual void foo() {}
};
class Derived : public Base {
    virtual void foo() {}         // WARNING: use override
    void foo() override {}        // OK
};

// readability-identifier-naming (if configured)
class my_class { };               // WARNING: should be MyClass (CamelCase)

Setting Up Cppcheck

Cppcheck works without a compilation database and is simpler to run:

# Basic: check src/ directory with all checks enabled
cppcheck \
    --enable=all \
    --suppress=missingIncludeSystem \
    --error-exitcode=1 \
    -I include/ \
    -j $(nproc) \
    src/

# More targeted: specific enable categories
cppcheck \
    --enable=warning,performance,portability \
    --suppress=missingIncludeSystem \
    --error-exitcode=1 \
    src/

Enable categories:

  • warning — suspicious code that might be a bug
  • style — code style violations (unused variables, etc.)
  • performance — inefficient code patterns
  • portability — code that may not work on all platforms
  • information — informational messages
  • all — all of the above

What Cppcheck Finds

// Null pointer dereference
void process(int* data, int size) {
    if (!data) {
        printf("Error\n");
        // Forgot to return!
    }
    for (int i = 0; i < size; ++i)
        data[i] *= 2;   // Cppcheck: dereference of null pointer 'data'
}

// Memory leak
void leak() {
    int* p = new int[100];
    if (someCondition())
        return;       // Cppcheck: memory leak: p
    delete[] p;
}

// Out-of-bounds access
void oob() {
    int arr[5];
    for (int i = 0; i <= 5; ++i)   // <= instead of <
        arr[i] = i;                  // Cppcheck: out of bounds access
}

// Assignment in condition (common typo)
int x = 5;
if (x = 0) {           // Cppcheck: found assignment in condition
    printf("zero\n");
}

Suppressing False Positives

// Inline suppression — suppress for the next line
// cppcheck-suppress nullPointer
data->field = value;

// Suppress with comment justification (better)
// cppcheck-suppress useInitializationList  // not applicable in this case
MyClass::MyClass() { }

Or a suppressions file for project-wide rules:

# suppressions.txt
missingIncludeSystem
# Suppress specific check in a specific file
uninitvar:src/legacy/old_code.cpp
cppcheck --suppressions-list=suppressions.txt src/

CI Integration with GitHub Actions

# .github/workflows/static-analysis.yml
name: Static Analysis

on:
  pull_request:
    paths:
      - 'src/**'
      - 'include/**'
      - '.clang-tidy'

jobs:
  clang-tidy:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4

      - name: Install clang-tidy
        run: sudo apt-get install -y clang-tidy cmake

      - name: Configure CMake
        run: cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DCMAKE_BUILD_TYPE=Debug

      - name: Run clang-tidy (changed files only)
        run: |
          # Only check files changed in this PR — much faster than full scan
          CHANGED=$(git diff --name-only origin/main...HEAD | grep '\.cpp$' || true)
          if [ -z "$CHANGED" ]; then
            echo "No C++ files changed"
            exit 0
          fi
          clang-tidy -p build --warnings-as-errors='bugprone-*' $CHANGED

  cppcheck:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4

      - name: Install Cppcheck
        run: sudo apt-get install -y cppcheck

      - name: Run Cppcheck
        run: |
          cppcheck \
            --enable=warning,performance \
            --suppress=missingIncludeSystem \
            --error-exitcode=1 \
            -I include/ \
            -j 2 \
            src/

Analyzing Only Changed Files

Running the full analysis on a large codebase can take minutes. For PRs, analyze only the changed files:

# Get files changed vs main branch
CHANGED_CPP=$(git diff --name-only origin/main...HEAD | grep '\.cpp$')

if [ -n "$CHANGED_CPP" ]; then
    clang-tidy -p build $CHANGED_CPP
fi

This reduces CI time from minutes to seconds for typical PRs.


Adopting Incrementally in Legacy Codebases

Turning on all checks at once on a legacy codebase generates thousands of warnings. This is overwhelming and makes CI fail immediately. The incremental approach:

Week 1: Enable bugprone-* only. Fix the real bugs (these are high-value). Suppress false positives with NOLINT(bugprone-easily-swappable-parameters).

Week 2-3: Add performance-for-range-copy and performance-unnecessary-copy-initialization. These are easy wins — the fix is usually adding const&.

Month 2: Add modernize-use-nullptr, modernize-use-override. Use --fix to apply automatically and review the diff.

Month 3+: Add readability-identifier-naming if your team agrees on naming conventions.

# Start with just the most impactful checks
clang-tidy -p build \
    -checks='-*,bugprone-*,performance-for-range-copy' \
    src/*.cpp

Excluding Third-Party Code

Never run static analysis on third-party headers or vendored code — the warnings are not yours to fix and they overwhelm real signal:

# In .clang-tidy
HeaderFilterRegex: '^(?!.*(third_party|vendor|external)).*\.(h|hpp)$'
# For Cppcheck: exclude directories
cppcheck --suppress=missingIncludeSystem \
    -i third_party/ \
    -i vendor/ \
    src/

Editor Integration

Both tools integrate with major C++ editors:

VS Code (with C/C++ Extension):

  • clangd language server reads .clang-tidy and shows warnings inline as you type
  • Install: clangd extension + clangd binary (sudo apt-get install clangd)

CLion:

  • Built-in clang-tidy support reads your .clang-tidy file
  • Settings → Editor → Inspections → C/C++ → clang-tidy

Neovim (with LSP):

-- Using clangd as language server
require('lspconfig').clangd.setup({
    cmd = {'clangd', '--clang-tidy'},
})

With editor integration, developers see warnings while writing code — not just in CI. This catches issues earlier and makes the CI gate feel less surprising.


Key Takeaways

  • Clang-Tidy uses the Clang AST — deep C++ understanding, 400+ checks, auto-fix for many — requires compile_commands.json
  • Cppcheck does its own parsing — no compilation database needed, strong on flow-sensitive bugs (null deref, OOB, leaks)
  • compile_commands.json: generate with cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON; symlink to project root for editor support
  • .clang-tidy: place at project root; use HeaderFilterRegex to exclude third-party headers
  • CI: check only changed files on PRs for fast feedback; full scan on main branch merges
  • Incremental adoption: start with bugprone-*, fix real bugs, then add performance-* and modernize-*
  • Suppress sparingly: // NOLINT(check-name) or // cppcheck-suppress for genuine false positives — document why
  • Editor integration: clangd with .clang-tidy shows warnings as you type — the fastest feedback loop

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. Integrate clang-tidy (.clang-tidy, compile_commands) and Cppcheck into editors and CI. Fix use-after-move, leaks, and st… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

C++, Static Analysis, Clang-Tidy, Cppcheck, Code Quality, CI 등으로 검색하시면 이 글이 도움이 됩니다.