An Attempt to Bring C++ Closer to Rust's Guarantees


For the past year, I had to switch to Rust temporarily. That period is over now, and I am happy to be returning to my beloved C++.

While working with Rust, a lot of things annoyed me, and I was looking forward to going back to C++. But after returning, I found that I missed some things from Rust. Most of all, its strong memory safety model and strict compiler checks, which Rust provides practically out of the box. I am not sure that C++ can fully reach the same level of guarantees, but I wanted to get noticeably closer to it.

So I set up a strict build for the project and several layers of checks directly in CI: compile-time diagnostics, dynamic analysis with sanitizers during tests, and static analysis of the source code.

Current stack

  • OS: Ubuntu 24.04
  • Compiler: Clang 23
  • Build system: CMake + Ninja
  • CI: GitLab CI, builds and runs inside Docker

Pipeline structure

GitLab-CI

  • check_commit_format — commit message format check;
  • check_linear_history — linear history check;
  • debug — debug build with a test run;
  • release — release build with a test run;
  • asan — a run with AddressSanitizer, LeakSanitizer, and UndefinedBehaviorSanitizer enabled in a single configuration;
  • tsan — a separate run with ThreadSanitizer;
  • coverage — a build with code coverage measurement;
  • lifetime — object lifetime analysis from LLVM;
  • tidy — static analysis;
  • format — formatting check.

Sanitizers

Two sanitizer runs have been added to CI:

  • asan — combines three tools in a single configuration:
    • AddressSanitizer — detects out-of-bounds memory accesses, use-after-free, and double-free;
    • LeakSanitizer — detects memory leaks;
    • UndefinedBehaviorSanitizer — detects the classes of undefined behavior supported by the compiler;
  • tsan — ThreadSanitizer, detects data races in multithreaded code.

ASan, LSan, and UBSan combine well in a single build, but ThreadSanitizer is incompatible with AddressSanitizer, so TSan is kept in a separate configuration.

I also originally planned to add MemorySanitizer to detect reads of uninitialized memory. However, using it properly requires rebuilding not only the project itself but also all dependencies on the tested path. For the current project, the cost of preparing a consistent environment and triaging the resulting noise outweighs the benefit, so I have skipped it for now.

Compiler diagnostics

I also strengthened diagnostics at the compilation stage.

Baseline warnings

  • -Wall — the main set of warnings;
  • -Wextra — additional general warnings;
  • -Wpedantic — diagnostics for deviations from the C++ standard;
  • -Wshadow — name shadowing detection;
  • -Wnon-virtual-dtor — polymorphic classes without a virtual destructor;
  • -Woverloaded-virtual — hiding of virtual functions in a derived class;
  • -Wnull-dereference — potential null dereferences;
  • -Wformat=2 — extended format string checks;
  • -Wimplicit-fallthrough — implicit fallthrough between switch branches;
  • -Wcast-align — potentially unsafe alignment after a cast;
  • -Werror=return-type — a missing or invalid return value is treated as an error;
  • -Werror=unused-result — ignoring a significant result is treated as an error.

Strict profile

  • -Wconversion — potentially dangerous implicit conversions;
  • -Wsign-conversion — conversions between signed and unsigned types;
  • -Wold-style-cast — use of C-style casts instead of C++ casts;
  • -Wdouble-promotion — implicit promotion of float to double;
  • -Wfloat-conversion — potentially dangerous conversions between floating-point types;
  • -Wfloat-equal — direct comparison of floating-point numbers;
  • -Wundef — use of undefined macros in conditional compilation;
  • -Wswitch-enum — unhandled enumeration values in a switch;
  • -Wextra-semi — extra semicolons.

Additional Clang checks

  • -Wthread-safety — static checking of thread safety annotations;
  • -Wmismatched-tags — mismatched class, struct, and union tags in declarations.

Error mode

  • -Werror — turns warnings into build errors (I enable it in the CI configurations).

Lifetime analysis

I added object lifetime analysis separately:

  • -Wlifetime-safety — detection of potentially dangling references and pointers.

This is a relatively new Clang analysis, and its set of diagnostics depends on the compiler version, so it lives in a separate lifetime profile. It catches certain classes of bugs related to object lifetimes. The authors were inspired by Polonius, the Rust borrow checker, when designing this analysis (Clang documentation).

Static analysis and formatting

  • Clang-Tidy — static analysis and detection of potential defects;
  • Clang-Format — a unified code style. In CI it works as a check rather than an automatic fixer: if the code is not formatted according to the rules, the pipeline fails.

Coverage

The coverage job builds the project, runs the tests, and publishes reports (JUnit, Cobertura, HTML). The coverage percentage is shown in the job list, and in a merge request GitLab highlights covered and uncovered lines of the changed code.

Conclusion

The resulting setup does not turn C++ into Rust and does not provide the same memory safety guarantees. But it catches substantially more bugs at compile time, during tests, and in static analysis.