GitHub Copilot for C++

GitHub Copilot Programming LanguagesAcademy lesson 32Cluster 3 · Lesson 7 of 13Advanced15 min readVersion-sensitive
Published
Updated
Last technically verified
GitHub Copilot for C++GitHub Copilot Programming Languages7Advanced/github-copilot/languages/cpp/

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.

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 new and delete where std::make_unique belongs.
  • C-style casts where static_cast would be checked.
  • char* and manual length arithmetic where std::string_view or std::span belongs.
  • Output parameters where a return value, a std::optional or structured bindings would do.
  • typedef rather than using, NULL rather than nullptr, 0 for 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_unique rather than new.
  • 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 uses shared_ptr everywhere, ask whether unique_ptr would 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_if is the C++20 answer; the erase-remove idiom is the older one, and a suggestion that calls remove without erase silently does nothing to the container’s size.
  • Holding a reference to a std::vector element across an operation that grows it.
  • std::string_view or std::span outliving the buffer it views — the particularly nasty case being a string_view bound to a temporary std::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

copilot-cpp-demo/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ └── summary.cpp ├── include/ │ └── summary.hpp └── tests/ └── summary_test.cpp

The prompt

Copilot promptGenerate the core functionCopilot Chat, with CMakeLists.txt open

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-failure

What 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, PUBLIC and INTERFACE on a target property. Getting these wrong does not break your build; it breaks your consumers’ builds, later.
  • Compiler flags hard-coded for one compiler. -Wall is 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.

Copilot promptPush the suggestion toward the libraryCopilot Chat

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 stop flag written by one thread and read by another is a data race even though it “obviously works.”
  • Locks are RAII. std::lock_guard or std::scoped_lock, never a manual lock()/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_lock with both mutexes avoids it.
  • A captured reference in a thread’s lambda outlives the thread. A std::thread capturing [&] 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

Accepting a C++ suggestion
  1. Compile with -Wall -Wextra -Wpedantic -WconversionWarnings, not errors, are where C++ tells you what it noticed.
  2. Run clang-tidybugprone-* and cppcoreguidelines-* catch use-after-move, dangling handles and owning raw pointers.
  3. Answer the ownership question for every objectHuman judgementWho owns it, how long does it live, does anything outlive it?
  4. Look for invalidationHuman judgementAny mutation of a container while something holds an iterator, pointer or reference into it.
  5. Run the tests under ASan and UBSanThis is the step that catches what compiling and reading both miss.
  6. 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,undefined permanently, not occasionally.
  • Turn on -Wconversion; generated code mixes signed and unsigned constantly.
  • Prefer std::span and std::string_view in parameters — and check that neither outlives what it views.
  • Ask for concepts rather than enable_if when 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_ptr be 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.

Primary sources