Core Guidelines, clang-tidy & clang-format
In a company, your code is read many more times than it is written: by reviewers, by teammates fixing bugs, and by you six months later. Teams therefore agree on rules for how C++ should be written, and they let tools check those rules automatically so that code reviews can focus on design and logic instead of spaces and missing const.
This lesson introduces the C++ Core Guidelines, the most widely used set of modern C++ rules, and the two tools that enforce rules in practice: clang-tidy, which finds bug-prone and outdated code, and clang-format, which fixes layout.
The C++ Core Guidelines
The C++ Core Guidelines are a free, open document led by Bjarne Stroustrup (the creator of C++) and Herb Sutter, published at isocpp.github.io/CppCoreGuidelines. Each rule has an ID made of a section letter and a number, a reason, examples and, where possible, a note on how a tool can check it. The sections include P (philosophy), I (interfaces), F (functions), C (classes), R (resource management), ES (expressions and statements), E (error handling), Con (constants), CP (concurrency), Per (performance) and SF (source files).
You do not need to memorise hundreds of rules. These are the ones you will hear about most in code reviews:
| Rule | In short |
|---|---|
| R.1 | Manage resources with RAII objects, not by hand |
| R.11 | Avoid calling new and delete yourself |
| R.3 | A raw pointer T* never owns what it points to |
| I.11 | Never pass ownership through a raw pointer or reference |
| C.21 | If you write or delete one of the copy, move or destructor functions, handle all of them (rule of five) |
| C.35 | A base class destructor is either public and virtual, or protected and non-virtual |
| ES.20 | Always initialise an object |
| ES.49 | If you must cast, use a named cast such as static_cast |
| Con.1 | Make objects const unless they need to change |
| F.16 | Pass cheap types by value and others by reference to const |
| CP.20 | Lock with RAII (std::lock_guard, std::scoped_lock), never plain lock()/unlock() |
| SF.7 | Never write using namespace at global scope in a header |
The guidelines come with a small helper library, the Guidelines Support Library (GSL), which provides types such as gsl::not_null and gsl::owner. Many teams use the rules without the library.
The rules applied
Here is code typical of an older project. Count the problems before reading on.
#include <vector>
class Order {
public:
virtual ~Order() {}
virtual double total() { return 0; }
};
class TrainTicket : public Order {
public:
double fare;
double total() { return fare; }
};
double sum(std::vector<Order*> orders) {
double s;
for (int i = 0; i < (int)orders.size(); i++) s += orders[i]->total();
return s;
}The problems: total() changes nothing but is not const (Con.1); the override has no override keyword, so a typo would silently create a new function; fare is public data that anyone can break; the vector is copied on every call (F.16); s is never initialised (ES.20), which is undefined behaviour; there is a C-style cast (ES.49); and nobody can tell who owns the Order* pointers (R.3, I.11). Here is the same code following the guidelines:
#include <memory>
#include <print>
#include <span>
#include <vector>
class Order {
public:
virtual ~Order() = default;
virtual double total() const = 0;
};
class TrainTicket final : public Order {
public:
explicit TrainTicket(double fare) : fare_{fare} {}
double total() const override { return fare_; }
private:
double fare_;
};
double sum(std::span<const std::unique_ptr<Order>> orders) {
double s = 0.0;
for (const auto& order : orders) {
s += order->total();
}
return s;
}
int main() {
std::vector<std::unique_ptr<Order>> orders;
orders.push_back(std::make_unique<TrainTicket>(1299.0));
orders.push_back(std::make_unique<TrainTicket>(455.5));
std::println("Total: {:.2f}", sum(orders));
}Total: 1754.50
Ownership is now visible in the types (std::unique_ptr owns, std::span only looks), and the compiler checks the override.
clang-tidy: automatic code review
clang-tidy is a free tool from the LLVM project that reads your code like a compiler and reports problems from a list of several hundred checks. Many checks can also fix the code for you. Checks are grouped by prefix:
| Group | Finds |
|---|---|
bugprone- | Code that is legal but very likely a bug, such as bugprone-use-after-move |
cppcoreguidelines- | Core Guidelines rules, such as cppcoreguidelines-init-variables |
modernize- | Old style that has a modern replacement, such as modernize-use-nullptr |
performance- | Needless copies, such as performance-unnecessary-value-param |
readability- | Confusing names, magic numbers, deeply nested code |
clang-tidy needs to know how each file is compiled. CMake can write that information into compile_commands.json when you configure with -DCMAKE_EXPORT_COMPILE_COMMANDS=ON. Then run clang-tidy and point it at the build folder with -p:
cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
clang-tidy -p build src/orders.cppsrc/orders.cpp:15:32: warning: the parameter 'orders' is copied for each invocation but only used as a const reference; consider making it a const reference [performance-unnecessary-value-param] src/orders.cpp:16:12: warning: variable 's' is not initialized [cppcoreguidelines-init-variables] ...
The output is shortened, and the exact checks reported depend on your configuration. Each warning ends with the name of the check in square brackets, so you can look it up. Add --fix to let clang-tidy apply the fixes it knows, then review the changes with git diff.
Choose your checks in a .clang-tidy file at the top of the project; it is written in YAML:
Checks: >
-*,
bugprone-*,
cppcoreguidelines-*,
modernize-*,
performance-*,
-modernize-use-trailing-return-type,
-cppcoreguidelines-avoid-magic-numbers
WarningsAsErrors: 'bugprone-*'
HeaderFilterRegex: 'include/.*'-* first turns everything off, then each line turns a group on; a leading - turns one check off again. Some checks, such as the one that wants auto f() -> int everywhere, are a matter of taste, and most teams disable them. To run clang-tidy on every build, set CMAKE_CXX_CLANG_TIDY in CMake, for example set(CMAKE_CXX_CLANG_TIDY clang-tidy).
clang-format: no more arguments about spaces
clang-format rewrites the layout of your code (indentation, spaces, line breaks, brace positions, include order) to match a style. Nobody has to discuss formatting in reviews again, and diffs show only real changes. Put a .clang-format file at the top of the project:
BasedOnStyle: Google
IndentWidth: 4
ColumnLimit: 100BasedOnStyle starts from a well-known style (others include LLVM, Chromium, Mozilla, WebKit and Microsoft), and the following lines adjust it. Format files in place with:
clang-format -i src/*.cpp include/school/*.hppMost editors, including VS Code with the C/C++ extension, can run clang-format every time you save. Teams usually also check formatting automatically before code is merged.
Common mistakes
- Turning on every check at once. Thousands of warnings get ignored. Start small and keep the count at zero.
- Blindly applying
--fix. Automatic fixes are usually right but not always; review and test them. - Mixing formatting and logic changes in one commit. Reviewers cannot see the real change.
- Treating the guidelines as laws. Each rule has reasons and exceptions; when you break one, write a comment that explains why.
- Running clang-tidy without
compile_commands.json. It then guesses flags and include paths and reports false errors.
Key points
- The C++ Core Guidelines are a free set of modern C++ rules with IDs such as R.11 and ES.20 that teams cite in reviews.
- Key ideas: RAII, no owning raw pointers, initialise everything,
constby default, named casts,override. - clang-tidy checks code against hundreds of rules; configure it with
.clang-tidyand give itcompile_commands.json. - clang-format fixes layout automatically from a
.clang-formatfile and ends style debates. - Introduce tools step by step and keep warnings at zero.
Exercise
Review this function using the Core Guidelines. Write down each rule it breaks (an ID or a short description is enough), then rewrite it.
int* make_scores(int n) { int* p = new int[n]; for (int i = 0; i < n; i++) p[i] = 0; return p; }
Show solution
#include <cstddef>
#include <print>
#include <vector>
std::vector<int> make_scores(int n) {
return std::vector<int>(static_cast<std::size_t>(n), 0);
}
int main() {
const auto scores = make_scores(4);
std::println("{} scores, first is {}", scores.size(), scores.front());
}4 scores, first is 0
The original calls new directly (R.11), returns an owning raw pointer so the caller must remember delete[] (R.3, I.11), and a leak or the wrong delete form is easy. A std::vector owns its memory, knows its size and is returned efficiently. The result is const because it does not change (Con.1).
Quick Quiz
1. Which Core Guidelines idea does std::unique_ptr support most directly?
A unique_ptr owns and frees its object automatically, so raw pointers can stay non-owning.
2. What does clang-tidy need to know how each file is compiled?
CMAKE_EXPORT_COMPILE_COMMANDS=ON writes the exact compile command for every file.
3. What does clang-format change?
clang-format never changes what the code does, only how it looks.
4. In a .clang-tidy file, what does -* at the start of Checks do?
Starting from nothing makes the enabled checks explicit.
Learn C++ with a mentor
Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
