Using Clang-Tidy

Integrating Clang-Tidy to enforce good practices and find bugs before we even compile

Greg Filak
Published

Static analysis is the process of analyzing code for potential errors without executing it. Static analysis tools simply read our code files to see if they can find any potential issues.

A linter is a type of static analysis tool that checks for stylistic errors, common programming mistakes, and violations of best practices.

Clang-Tidy is a highly configurable linter for C++, C, and Objective-C. It can detect everything from simple formatting issues to complex bugs like potential memory leaks or incorrect API usage.

Integrating Clang-Tidy

Clang-Tidy is part of the LLVM Project. It's likely you already have it installed in your environment, particularly if you followed along with the earlier section on using .

You can verify Clang-Tidy is available in your environment in the usual way:

clang-tidy --version
LLVM (http://llvm.org/):
  LLVM version 20.1.8
  Optimized build.

From our build scripts, we can find clang-tidy in the usual way, via find_program():

Files

cmake
CMakeLists.txt
Select a file to view its content
cmake --preset default
...
-- clang-tidy found: D:/msys64/clang64/bin/clang-tidy.exe
...

We can now use this program using the same techniques we covered previously, by invoking it within a or encapsulating it within a .

However, CMake is aware of ClangTidy by default, and we can use it within our build simply by setting the CMAKE_CXX_CLANG_TIDY variable. This tells CMake to run clang-tidy alongside the compiler for every source file:

cmake/Tidy.cmake

cmake_minimum_required(VERSION 3.23)
find_program(CLANG_TIDY_EXE "clang-tidy")

if(CLANG_TIDY_EXE)
    message(STATUS "clang-tidy found: ${CLANG_TIDY_EXE}")
    set(CMAKE_CXX_CLANG_TIDY ${CLANG_TIDY_EXE})
else()
    message(WARNING "clang-tidy not found!")
endif()

If we configure and build now, then we may already see some clang-tidy warnings, depending on the state of our code base:

cmake --preset default
cmake --build --preset default
warning: Value stored to '_' during its initialization is never read [clang-analyzer-deadcode.DeadStores]
   10 |   for (auto _ : state) {
      |             ^ ~

Suppressing Warnings

In large projects, linters will often output warnings or errors for sections of code that we're happy with, or don't want to refactor. To handle this, we can suppress the linter by adding specifically formated comments at or near the offending lines.

Adding a NOLINT comment will ask the linter to ignore that line:

benchmarks/bench_main.cpp

#include <benchmark/benchmark.h>
#include <greeter/Greeter.h>
#include <string>

static void BM_Greeter_Greet(benchmark::State& state) {
  std::string name(state.range(0), 'x');
  Greeter g(name);

  for (auto _ : state) {// NOLINT
    std::string result = g.greet();
    benchmark::DoNotOptimize(result);
  }
}

BENCHMARK(BM_Greeter_Greet)->Range(8, 32768);

BENCHMARK_MAIN();

We can also ask the linter to ignore the next line using NOLINTNEXTLINE:

// NOLINTNEXTLINE 
for (auto _ : state) {
  std::string result = g.greet();
  benchmark::DoNotOptimize(result);
}

We can ignore an entire block by wrapping it between NOLINTBEGIN and NOLINTEND comments:

// NOLINTBEGIN 
for (auto _ : state) {
  std::string result = g.greet();
  benchmark::DoNotOptimize(result);
}
// NOLINTEND

All of these NOLINT variations allow us to specify the rule we want to suppress within brackets. The name of the rull that was violated will be included in the warning or the error that was emitted:

// NOLINTNEXTLINE(clang-analyzer-deadcode.DeadStores)
for (auto _ : state) {
  std::string result = g.greet();
  benchmark::DoNotOptimize(result);
}

This is generally recommended, as it clarifies to readers what the comment is actually suppressing.

Additionally, if a new violation is added to the area, that new violation will emit a warning to attract our attention, rather than also get suppressed.

We can suppress multiple rules by comma-seperating them:

// NOLINTNEXTLINE(rule-1,rule-2)

Configuring Clang-Tidy

Clang-Tidy is highly configurable. The primary way of doing this is through a .clang-tidy file - note the initial . in the file name. This file is stored in your project root and shared among the team.

The most notable thing we configure is which rules we want Clang-Tidy to check for. Clang-Tidy can scan for around 300 issue at the time of writing. You can find them all on the official documentation.

Let's enable a check that suggests using nullptr instead of the old NULL macro:

.clang-tidy

Checks: '-*,modernize-use-nullptr'

The -* syntax tells Clang-Tidy to disable all checks. The following modernize-use-nullptr then tells it to renable checking for just that rule.

We can add further rules using comma-separation. The following configuration also discourages the use of std::endl:

Checks: '-*,modernize-use-nullptr,performance-avoid-endl'

Let's introduce some code that violates the modernize-use-nullptr. Historically, the NULL keyword was often used to represent null pointers. NULL is a macro typically defined as the integer 0.

Setting a pointer to 0 technically works as a way to represent a null pointer, but it causes issues in overload resolution:

void f(int); 
void f(char*);

f(NULL); // calls f(int), not f(char*)

C++11 introduced the explicit nullptr keyword, which is now the best practice:

void f(int); 
void f(char*);

f(nullptr); // calls f(char*)

We'll violate this rule in Greeter.cpp so we can confirm that our configuration is working:

greeter/src/Greeter.cpp

#include <greeter/Greeter.h>

Greeter::Greeter(std::string name)
  : name_(std::move(name)) {}

std::string Greeter::greet() const {
  int* SomePtr{NULL};
  return "Hello, " + name_ + "!";
}

Let's rebuild our project:

cmake --build --preset default

During compilation, Clang-Tidy should run and emit a warning directly in our build log, pointing out the violation and often suggesting a fix:

.../greeter/src/Greeter.cpp:20:13: warning: use nullptr [modernize-use-nullptr]
  if (ptr == NULL) {
             ^
             nullptr

Emitting Errors

Our previous rule emits a warning when violations are detected. If we care a lot about ensuring a specific rule is followed, we can upgrade that to an error instead. We can do this using the WarningAsErrors option in our .clang-tidy:

.clang-tidy

Checks: '-*,modernize-use-nullptr'

# Treat specific warnings as errors:
WarningsAsErrors: 'modernize-use-nullptr'

# Alternatively, treat all warnings as errors:
WarningsAsErrors: '*'

If we rebuild the project, our use of NULL over nullptr should now generate an error.

Our build system may skip rebuilding our targets if the only thing that has changed is a .clang-tidy file. We can delete our outputs and force them to be recompiled by passing the --clean-first argument:

cmake --build --preset default --clean-first
D:/Projects/cmake/greeter/src/Greeter.cpp:7:16: error: use nullptr [modernize-use-nullptr,-warnings-as-errors]
    7 |   int* SomePtr{NULL};
      |                ^~~~
      |                nullptr

1 warning treated as error

Configuring Clang-Tidy using Variables

We can configure Clang-Tidy by extending our use of the CMAKE_CXX_CLANG_TIDY variable. This approach would override any rules we had set in our .clang-tidy file, if we had one.

For example, to override the warnings-as-errors configuration we set in our .clang-tidy, we could set that argument to -*, removing the errors for all rules:

cmake/Tidy.cmake

cmake_minimum_required(VERSION 3.23)
find_program(CLANG_TIDY_EXE "clang-tidy")

if(CLANG_TIDY_EXE)
  message(STATUS "clang-tidy found: ${CLANG_TIDY_EXE}")
  set(CMAKE_CXX_CLANG_TIDY ${CLANG_TIDY_EXE}
    --warnings-as-errors=-*
  )
else()
  message(WARNING "clang-tidy not found!")
endif()

As with any variable, this could also be set from a preset:

CMakePresets.json

{
  "version": 3,
  "configurePresets": [
    // ... other presets
    {
      "name": "no-lint-errors",
      "inherits": "default",
      "cacheVariables": {
        "CMAKE_CXX_CLANG_TIDY":
          "clang-tidy;--warnings-as-errors=-*"
      }
    }
  ]
  // ... 
}

Practical Example: Performance Checks

Finally, let's cover a slightly more advanced configuration. It's a common mistake in C++ to pass a large object, like a std::string, to a function by value when it could be passed by const reference. Passing by value creates an unnecessary and potentially expensive copy.

First, let's update our .clang-tidy with some further checks. Our previous configuration was checking for violations of the modernize-use-nullptr rule. The preference for using nullptr relates to code modernization, so it is prefixed with the modernize- tag. All other rules are namespaced in similar ways. For example:

  • Checks that relate to performance have a name starting with performance-
  • Checks relating to general code reability start with readability-
  • Checks that scan for syntax that is a common source of bugs are prefixed with bugprone-

We can use this to configure large categories of checks at once. The following config file enables all checks relating to modernization and performance. It also specifically upgrades violations of the unnecessary passing-by-value rule to be errors:

.clang-tidy

Checks: '-*,modernize-use-nullptr,performance-*'
WarningsAsErrors: 'performance-unnecessary-value-param'

Let's update Greeter.cpp to make this mistake:

greeter/src/Greeter.cpp

#include <greeter/Greeter.h>

Greeter::Greeter(std::string name)
  : name_(std::move(name)) {}

namespace {
bool should_ignore(std::string name) {
  return name == "Bob";
}
}

std::string Greeter::greet() const {
  if (should_ignore(name_)) return "";
  return "Hello, " + name_ + "!";
}

Now, when we build, Clang-Tidy will analyze Greeter.cpp. Because we set WarningsAsErrors for the rule we just violated, the check will fail the build and provide a detailed message explaining the problem and suggesting the fix.

Greeter.cpp:7:32: warning: the parameter 'name' is copied for each invocation but only used as a const reference; consider making it a const reference [performance-unnecessary-value-param]
    7 | bool should_ignore(std::string name) {
      |                                ^
      |                    const      &

Remember, we now also have benchmarks to quantify this problem. Previously, the BM_Greeter_Greet/32768 benchmark was reporting around 1,500 ns on my machine.

With our latest changes, the run time of our greet() function has increased by around 60%:

cmake --build --preset release
./build/benchmarks/GreeterBenchmarks
------------------------------------------------------
Benchmark                   Time      CPU   Iterations
------------------------------------------------------
...
BM_Greeter_Greet/32768   2440 ns  2382 ns       295153

If we do what Clang-Tidy suggests and update it to use a const reference, our new feature only adds around 15%:

greeter/src/Greeter.cpp

#include <greeter/Greeter.h>

Greeter::Greeter(std::string name)
  : name_(std::move(name)) {}

namespace {
bool should_ignore(const std::string& name) {
  return name == "Bob";
}
}

std::string Greeter::greet() const {
  if (should_ignore(name_)) return "";
  return "Hello, " + name_ + "!";
}
cmake --build --preset release
./build/benchmarks/GreeterBenchmarks
-------------------------------------------------------
Benchmark                   Time       CPU   Iterations
-------------------------------------------------------
...
BM_Greeter_Greet/32768   1722 ns   1713 ns       401408

Clang-Tidy has hundreds of checks covering performance, readability, modern C++ idioms, and more. You can find a full list and detailed explanations on the official Clang-Tidy documentation page.

Other Analysis Tools

The tools we've covered are just the beginning. The C++ ecosystem has a huge set of analysis tools, and most can be integrated into CMake using the patterns we've learned. Here are a few more to be aware of:

Dependency Analysis

Over time, #include directives in C++ files tend to become a mess. You might include headers you don't need, or forget to include headers you do need (relying on them being included transitively).

include-what-you-use (IWYU) is a tool that analyzes your code and tells you exactly which headers you should add or remove, helping to keep your codebase clean and improve compilation times.

Security Analysis

Many static analysis tools, including commercial ones like SonarQube and advanced configurations of Clang-Tidy, can be used to scan for common security vulnerabilities.

Summary

In this lesson, we explored static analysis as the final pillar of our automated code quality toolkit.

  • Static Analysis: Tools that analyze code for bugs and style issues without executing it.
  • Clang-Tidy: A powerful, configurable linter for C++. We integrated it into our build by setting the CMAKE_CXX_CLANG_TIDY variable.
  • Configuration as Code: The behavior of Clang-Tidy is controlled by a .clang-tidy file in the project root, allowing you to define and share a consistent set of checks for the whole team.
  • Enforcing Quality: We saw how Clang-Tidy can automatically find performance pitfalls and suggest modern C++ idioms like nullptr.
  • Advanced Clang-Tidy Capabilities: This is only the tip of the iceberg of what Clang-Tidy can help with. The official documentation page has much more.
Have a question about this lesson?
Answers are generated by AI models and may not be accurate