GitHub Copilot for C++
Every other statically typed language in this cluster gives you the same rough deal: if it compiles, a large class of mistakes has been excluded.
C++ does not offer that deal. C++ has a type system that is strict about types and says nothing whatsoever about whether a pointer still points at anything. Code that compiles without a warning can read freed memory, index past the end of a vector, or invoke behaviour the standard leaves undefined — which means the compiler is permitted to assume it never happens and optimise accordingly.
So the central claim of this lesson is narrow and important: in C++, “it compiles” is close to meaningless as a review signal, and “it ran and produced the right answer” is only slightly better. Undefined behaviour frequently produces the right answer, right up until a compiler upgrade or a different input.
C++ quick referenceVerified August 21, 2026
- Type system
- Statically typed with no runtime safety net: the type system says nothing about whether a pointer is still valid.
- Package management
- vcpkg or Conan; often none
- Manifest Copilot should see
- CMakeLists.txt
- Testing
- GoogleTest, Catch2, doctest
- Formatting
- clang-format
- Linting
- clang-tidy
- Static analysis
- clang-tidy, compiler warnings, ASan and UBSan
- Common frameworks
- Qt, Boost, Abseil
- Typical Copilot work
- Container manipulation, algorithm calls, CMake targets, test fixtures, RAII wrappers
- First thing to review
- Ownership and lifetime — code that compiles cleanly and is still undefined behaviour
Fastest honest checkcompile with -Wall -Wextra -fsanitize=address,undefined, then run the tests
Key takeaways
- Compiling is not evidence. Turn on
-Wall -Wextra -Wpedanticand build the test binary with AddressSanitizer and UndefinedBehaviorSanitizer; that combination is the actual acceptance test for generated C++. - The review question is always ownership: who owns this object, how long does it live, and does anything hold a reference past that point?
- Iterator and reference invalidation is the most common concrete failure. A
push_backinside a loop over the same vector is undefined behaviour that usually works. - C++‘s public corpus spans forty years. Expect suggestions containing raw
new, C-style casts and manualdeleteunless you say otherwise. - The standard changes what is idiomatic. Say “C++20” or “C++17” in the prompt; the difference is not cosmetic.
Why the corpus is a bigger problem here
C++ has the longest and most heterogeneous public history of any language in this cluster. Code written for C++98, C++11, C++17 and C++20 all compiles, all appears in public repositories, and all looks superficially similar.
The consequence is that generated C++ tends toward an average that is considerably older than what you want. Concretely, expect to see:
- Raw
newanddeletewherestd::make_uniquebelongs. - C-style casts where
static_castwould be checked. char*and manual length arithmetic wherestd::string_vieworstd::spanbelongs.- Output parameters where a return value, a
std::optionalor structured bindings would do. typedefrather thanusing,NULLrather thannullptr,0for a pointer.- Hand-written loops where a standard algorithm exists.
None of these is automatically wrong. Collectively they are a strong signal that the suggestion was drawn from older code, and the memory-management decisions in it deserve reading with that in mind.
Ownership is the review question
Everything else in this lesson is a special case of one question: for every object in this suggestion, who owns it, and what is its lifetime?
The vocabulary that makes the answer explicit:
std::unique_ptr— exactly one owner, transferred by move. The default answer for a heap allocation.std::make_uniquerather thannew.std::shared_ptr— shared ownership with a reference count. Correct occasionally, over-used constantly, and the source of reference cycles that leak. If a suggestion usesshared_ptreverywhere, ask whetherunique_ptrwould do.std::weak_ptr— breaks the cycle when shared ownership is genuinely needed.- A raw pointer or reference — a non-owning observer, valid only as long as something else keeps the object alive. This is where lifetime bugs live.
- A value — owned by its scope. Usually the right answer, and the one generated code reaches for least often.
RAII is the mechanism underneath all of it: a resource acquired in a constructor and released in a destructor is released on every exit path, including the exception path. Generated code that acquires a lock, opens a file or allocates a buffer and then releases it manually at the end of the function is correct only if nothing throws in between — and something usually can.
Iterator and reference invalidation
The single most common concrete defect in generated C++, because the pattern is natural, readable and wrong.
// Undefined behaviour. Frequently "works".
for (const auto& item : items) {
if (needsCopy(item)) {
items.push_back(transform(item)); // may reallocate the buffer
}
}push_back may reallocate, which invalidates every iterator, pointer and
reference into the vector — including the one the range-based for loop is
holding. The loop then walks freed memory. On a small vector with spare capacity
it will not reallocate and the code will appear to work perfectly, which is the
worst possible outcome because it means the test passes.
The same class of problem appears with:
- Erasing from a container while iterating it.
std::erase_ifis the C++20 answer; the erase-remove idiom is the older one, and a suggestion that callsremovewithouterasesilently does nothing to the container’s size. - Holding a reference to a
std::vectorelement across an operation that grows it. std::string_vieworstd::spanoutliving the buffer it views — the particularly nasty case being astring_viewbound to a temporarystd::string, which dangles immediately.- A lambda capturing by reference (
[&]) that is stored and invoked later. The captured references are dangling by then. Generated callbacks and async continuations do this routinely.
AddressSanitizer catches all of these at runtime — but only if the code path runs, which is why the sanitizer belongs in your test build rather than in an occasional manual check.
Practical project: a small data processor
Practical example
A command-line log summariser, with the checks that make it reviewable
A small, self-contained program showing modern ownership, a CMake build with warnings enabled, and a sanitizer-instrumented test target.
- Status
- Example implementation
- Not executed here
- This code was written against the documentation cited at the end of the lesson but was not run while writing it. Treat the commands as the ones to run, not as output that has been observed.
- Requires
- A C++20 compiler (GCC or Clang), CMake 3.20 or later, and GoogleTest
Files
copilot-cpp-demo/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ └── summary.cpp ├── include/ │ └── summary.hpp └── tests/ └── summary_test.cpp
The prompt
C++20. Standard library only.
Write a function that takes a std::span of std::string_view lines and returns a struct with: line count, the count of lines containing “ERROR”, and the longest line’s length.
Do not allocate. Do not use raw owning pointers. Do not return a reference or a string_view into the input.
Handle the empty span. Use std::ranges algorithms where they are clearer than a loop.
Then write GoogleTest cases for: empty input, no matches, all matches, a line containing “ERROR” as a substring of a longer word, and a line of length zero.
Two clauses there are doing specifically C++ work. “Do not return a reference or
a string_view into the input” pre-empts the dangling-view failure. “Handle the
empty span” pre-empts the *std::ranges::max_element on an empty range, which is
undefined behaviour rather than an exception.
Implementation
#include "summary.hpp"
#include <algorithm>
#include <ranges>
Summary summarise(std::span<const std::string_view> lines) {
Summary result{}; // value-initialised: no garbage
result.lineCount = lines.size();
result.errorCount = static_cast<std::size_t>(
std::ranges::count_if(lines, [](std::string_view line) {
return line.find("ERROR") != std::string_view::npos;
}));
if (!lines.empty()) { // max_element on empty is UB
const auto longest = std::ranges::max_element(
lines, {}, [](std::string_view line) { return line.size(); });
result.longestLine = longest->size();
}
return result; // by value; owns nothing
}The if (!lines.empty()) guard is the line most likely to be missing from a
first suggestion, and the failure it prevents is not an exception — it is
dereferencing an end iterator.
The build: where the real checking lives
cmake_minimum_required(VERSION 3.20)
project(copilot_cpp_demo CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_library(summary src/summary.cpp)
target_include_directories(summary PUBLIC include)
target_compile_options(summary PRIVATE
-Wall -Wextra -Wpedantic -Wshadow -Wconversion)
enable_testing()
add_executable(summary_test tests/summary_test.cpp)
target_link_libraries(summary_test PRIVATE summary GTest::gtest_main)
# The test binary is where the sanitizers go. They cost runtime performance,
# which is exactly the trade you want in a test build and not in a release one.
target_compile_options(summary_test PRIVATE -fsanitize=address,undefined -g)
target_link_options(summary_test PRIVATE -fsanitize=address,undefined)
add_test(NAME summary_test COMMAND summary_test)-Wconversion and -Wshadow are the two flags worth adding beyond the usual
trio. Generated code mixes signed and unsigned integer types constantly —
.size() returns an unsigned type and loop counters are usually int — and
-Wconversion is what turns that into a warning rather than a silent wraparound.
Run
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build
ctest --test-dir build --output-on-failureWhat the sanitizers would tell you
If the empty-span guard were missing, ASan reports a heap-buffer-overflow or a
container-overflow with a stack trace pointing at the dereference. If a
string_view outlived its std::string, ASan reports a use-after-scope. Neither
is visible from the compiler output, and neither is visible from a passing test
run without instrumentation.
That is the whole argument for instrumenting the test build: it converts C++‘s worst review category — “compiles, runs, is undefined” — into a failing test with a stack trace.
The build system is part of the review
C++ is the one language in this cluster with no dominant package manager, and that changes what a suggestion can safely assume.
There is no manifest to read. Where a Python suggestion can be anchored by
pyproject.toml and a Rust one by Cargo.toml, a C++ suggestion has only
CMakeLists.txt — and CMake describes a build, not a dependency set. If your
project uses vcpkg or Conan, the manifest is a separate file the model may not
have in context; if it uses neither, dependencies arrive as system packages,
submodules or vendored source, none of which are declarative.
The practical consequence: a suggested #include is a much weaker signal here
than a suggested import in any other language. It may reference a header you do
not have, from a library you have never installed, in a version whose API differs.
The failure is a compile error rather than a runtime surprise, which is a mercy,
but the error message (“no such file or directory”) does not tell you which
package provides it.
CMake itself is a frequent source of plausible-but-wrong suggestions. The
older directory-scoped commands — include_directories, link_libraries,
add_definitions — apply to everything below them and are still abundant in
public code. The target-scoped forms — target_include_directories,
target_link_libraries, target_compile_options — are the modern practice, and
they are what the worked example above uses. A generated CMakeLists.txt mixing
the two styles will work and will make the next change harder.
Two smaller things worth checking in any generated CMake:
PRIVATE,PUBLICandINTERFACEon a target property. Getting these wrong does not break your build; it breaks your consumers’ builds, later.- Compiler flags hard-coded for one compiler.
-Wallis GCC and Clang; MSVC wants/W4. A suggestion that assumes one toolchain fails on the other, and CI is where you find out.
Testing C++
Test tooling is where C++ asks more of you than the other languages here, and it is worth setting up properly before you start accepting suggestions in volume.
Name the framework. GoogleTest, Catch2 and doctest all appear heavily in public code and have incompatible macros. A suggestion written for one will not compile against another, and the error is a wall of preprocessor noise.
Ask for the boundary cases explicitly. The C++-specific ones worth naming in the prompt: empty containers, a single element, maximum size, and — because so much C++ code computes sizes and offsets — arithmetic at the limits of the integer type involved.
Make the tests run under the sanitizers. This is the point of the whole exercise. A test suite that passes in a plain build has verified behaviour; a test suite that passes under ASan and UBSan has verified behaviour and the absence of the memory errors that behaviour testing cannot see. The cost is runtime performance in a build where performance does not matter.
Consider property-based tests for anything that parses. RapidCheck and similar libraries generate inputs you would not have thought of, and parsing untrusted input is where C++ failures are most expensive.
One caution specific to generated C++ tests: a test that constructs an object, calls a method and asserts on the result verifies the happy path only. In C++ the interesting question is usually what happens on the destruction path, or on the exception path, and neither is exercised by an assertion on a return value. Ask for tests that check a resource was released, or that an exception propagated without leaking.
The standard library is usually the safest suggestion
A useful heuristic for reviewing generated C++: the more of the standard library a suggestion uses, the less of it you have to check.
std::vector handles its own memory. std::string handles its own length.
std::unique_ptr handles its own release, on every path including the exception
path. std::lock_guard unlocks whatever happens. Standard algorithms handle their
own iteration bounds. Each of these replaces a place where hand-written code could
be off by one with a place where it cannot be.
So when a suggestion hand-rolls something the library provides — a manual buffer with a separate length, a hand-written search loop, a bespoke reference-counted pointer — that is worth pushing back on, not for style reasons but because you have just been handed more surface to review.
Rewrite this using the standard library where it applies.
Replace manual buffers with std::vector or std::array, manual loops with the appropriate std::ranges algorithm, and any manual resource release with RAII.
If a hand-written version is genuinely necessary — for performance or because no standard facility fits — say which one and why, and leave a comment.
The second half of that prompt matters as much as the first. There are real reasons to hand-roll in C++, and a model that is told to justify the exception will either produce a good reason or produce the library version. Both outcomes are better than an unexamined hand-rolled buffer.
Templates and generic code
Copilot writes basic templates competently and gets progressively less reliable as the metaprogramming deepens.
Constraints beat SFINAE. In C++20, requires clauses and concepts produce
comprehensible errors; std::enable_if produces pages of them. If a suggestion
reaches for enable_if in a C++20 project, ask for a concept instead — it is
shorter, and when a caller misuses it the error message names the constraint that
failed.
Check where instantiation happens. A template defined in a .cpp file and
used from another translation unit produces a linker error rather than a compile
error, which is a confusing failure the first time.
Forwarding references are easy to get subtly wrong. T&& in a deduced context
is a forwarding reference; std::vector<T>&& is an rvalue reference. A suggestion
that uses std::forward on something that is not a forwarding reference, or
std::move on a forwarding reference, silently changes ownership semantics.
Watch for a moved-from object used afterwards. Moved-from standard-library
types are in a valid but unspecified state. Reading one is not undefined
behaviour, but it is a bug, and clang-tidy’s bugprone-use-after-move finds it.
Concurrency
C++ gives you no help here beyond what you build. A data race is undefined behaviour, not a race condition with a wrong answer.
Points to check in generated concurrent C++:
- Every shared mutable variable is either atomic or guarded by a mutex. A
bool stopflag written by one thread and read by another is a data race even though it “obviously works.” - Locks are RAII.
std::lock_guardorstd::scoped_lock, never a manuallock()/unlock()pair with anything that can throw between them. - Lock ordering is consistent. Two mutexes acquired in different orders in
two functions is a deadlock waiting for the interleaving.
std::scoped_lockwith both mutexes avoids it. - A captured reference in a thread’s lambda outlives the thread. A
std::threadcapturing[&]and detaching is a use-after-free the moment the enclosing scope exits.
ThreadSanitizer catches races that actually execute; it is worth a separate build configuration on any code with real concurrency.
C++-specific risks
Use-after-free and dangling references. The headline risk. Ownership review plus ASan.
Buffer issues. Off-by-one on array indexing, memcpy with the wrong size,
strcpy at all. Prefer std::span, std::array and .at() where a bounds check
is affordable.
Ownership mistakes. shared_ptr cycles, double delete, a unique_ptr
copied where it should have been moved (which does not compile, so the model
works around it — check how).
Undefined behaviour that compiles. Signed integer overflow, reading an
uninitialised variable, strict-aliasing violations from reinterpret_cast,
out-of-bounds indexing. UBSan catches most of these at runtime.
Iterator invalidation. Covered above.
Incorrect templates. Covered above.
Outdated C-style patterns. Raw arrays with separate length parameters,
printf with mismatched format specifiers, manual resource management.
Debugging and refactoring
For debugging, C++‘s most valuable Copilot use is explanation rather than generation. Template error messages run to hundreds of lines; pasting one into chat and asking which constraint failed is reliably faster than reading it. The same applies to a sanitizer report — ASan’s output is precise and structured, and a model reading it will name the offending line quickly.
Refactoring is safer than in the dynamic languages and less safe than in Rust, because the compiler verifies types but not lifetimes. A mechanical rename is safe; a change that moves ownership around is exactly the change the compiler will not check. Re-run the sanitizer build after any refactor that touches who owns what.
Review workflow
- Compile with -Wall -Wextra -Wpedantic -WconversionWarnings, not errors, are where C++ tells you what it noticed.
- Run clang-tidybugprone-* and cppcoreguidelines-* catch use-after-move, dangling handles and owning raw pointers.
- Answer the ownership question for every objectHuman judgementWho owns it, how long does it live, does anything outlive it?
- Look for invalidationHuman judgementAny mutation of a container while something holds an iterator, pointer or reference into it.
- Run the tests under ASan and UBSanThis is the step that catches what compiling and reading both miss.
- For concurrent code, run under ThreadSanitizer too
Steps marked Human judgement are the ones that do not get delegated — they are where you decide whether what Copilot produced is actually right.
Best practices
- State the standard (C++17, C++20) in every prompt. It changes what is idiomatic and what is available.
- Ask explicitly for no raw owning pointers, and for RAII on every resource.
- Build tests with
-fsanitize=address,undefinedpermanently, not occasionally. - Turn on
-Wconversion; generated code mixes signed and unsigned constantly. - Prefer
std::spanandstd::string_viewin parameters — and check that neither outlives what it views. - Ask for concepts rather than
enable_ifwhen constraining templates.
Common mistakes
- Treating a clean compile as a passing review.
- Accepting a loop that mutates the container it is iterating.
- Letting a
shared_ptrbe the default answer to every allocation. - Storing a lambda that captured by reference.
- Running the sanitizers once, manually, instead of wiring them into the test build.
Where to go next
GitHub Copilot for Rust is the deliberate counterpoint: the same problem domain, with a compiler that checks the ownership questions this lesson asks you to check by hand. GitHub Copilot with Visual Studio covers the IDE side for Windows C++ work.
Sources
Every version-sensitive claim on this page was checked against first-party documentation. Only sources actually used are listed.
Your progress
Saved in this browser only. No account, no server, and nothing leaves your device. Clearing site data resets it.
Was this lesson helpful?
Your answer is stored in this browser and is not sent anywhere.