Catch bugs with static analysis

Wednesday, April 2, 2025

In a previous blog post a few examples were discussed demonstrating how the default behavior of an implicitly-defined copy constructor, copy assignment operator, and destructor can lead to bugs. While these bugs are simple enough to inspect manually, it raises the question, how can these bugs be caught on larger projects?

clang-tidy is a linter which is part of the LLVM ecosystem that catches bugs, among other things, via static analysis. This post describes how to use the tool to lint the code examples in the previous blog post. The tool can be installed via Homebrew and invoked using the script below.

$ brew install llvm
$ $(brew --prefix llvm)/bin/clang-tidy main.cpp -checks='modernize-*' -- -std=c++23

Consider the code shown below which has a memory leak:

class Int {
    int* value;

public:
    explicit Int(const int& value): value(new int(value)) {}
};

auto main(int argc, char** argv) -> int {
    Int i(1);
    Int j(i);
}

If it is not immediately clear that there is a memory leak in this code due to the implicitly defined constructor, then please observe the output of clang-tidy shown below:

$(brew --prefix llvm)/bin/clang-tidy main.cpp -checks="clang-analyzer-cplusplus.*" -- -std=c++23
1 warning generated.
main.cpp:11:1: warning: Potential leak of memory pointed to by 'i.value' [clang-analyzer-cplusplus.NewDeleteLeaks]
   11 | }
      | ^
main.cpp:9:9: note: Calling constructor for 'Int'
    9 |     Int i(1);
      |         ^~~~
main.cpp:5:43: note: Memory is allocated
    5 |     explicit Int(const int& value): value(new int(value)) {}
      |                                           ^~~~~~~~~~~~~~
main.cpp:9:9: note: Returning from constructor for 'Int'
    9 |     Int i(1);
      |         ^~~~
main.cpp:11:1: note: Potential leak of memory pointed to by 'i.value'
   11 | }
      | ^

Not only does this clearly warn us of the memory leak, but also that the memory leak is pointed at by i.value, that the memory is allocated in the constructor of Int, and that the memory becomes orphaned the variable i goes out of scope. This should be everything necessary to understand that ~Int needs to be explicitly defined to deallocate the allocated memory.

Now consider the code shown below which has a double free:

class Int {
    int* value;

public:
    explicit Int(const int& value): value(new int(value)) {}

    ~Int() {
        delete value;
    }
};

auto main(int argc, char** argv) -> int {
    Int i(1);
    Int j(i);
}

Again, we can see based on the output of clang-tidy below exactly where the bug is:

$(brew --prefix llvm)/bin/clang-tidy main.cpp -checks="clang-analyzer-cplusplus.*" -- -std=c++23
1 warning generated.
main.cpp:8:9: warning: Attempt to free released memory [clang-analyzer-cplusplus.NewDelete]
    8 |         delete value;
      |         ^
main.cpp:13:9: note: Calling constructor for 'Int'
   13 |     Int i(1);
      |         ^~~~
main.cpp:5:43: note: Memory is allocated
    5 |     explicit Int(const int& value): value(new int(value)) {}
      |                                           ^~~~~~~~~~~~~~
main.cpp:13:9: note: Returning from constructor for 'Int'
   13 |     Int i(1);
      |         ^~~~
main.cpp:15:1: note: Calling '~Int'
   15 | }
      | ^
main.cpp:8:9: note: Memory is released
    8 |         delete value;
      |         ^~~~~~~~~~~~
main.cpp:15:1: note: Returning from '~Int'
   15 | }
      | ^
main.cpp:15:1: note: Calling '~Int'
main.cpp:8:9: note: Attempt to free released memory
    8 |         delete value;
      |         ^~~~~~~~~~~~

Specifically, the destructor which is deallocating the memory is being called twice on line 15. First for the variable j and second for the variable i because variables are popped off of the stack in the reverse order in which they are pushed on. In either case, it can be seen that the memory is allocated only once in the constructor of Int for the variable i.

It is left as an exercise to the reader to run the final "Rule of zero" example through clang-tidy, which still finds several things to complain about when enabling all checks, that is $(brew --prefix llvm)/bin/clang-tidy main.cpp -checks="*" -- -std=c++23. It is worth mentioning that clang-tidy can be noisy, so it is important to be judicious about which checks are enabled.