6

I am in the process of refactoring a large C++ code (~2300 files, ~600K lines, mostly older C/C++98 style code) and there are definitely memory leaks that could be shored up using C++ smart pointers. Is there an incremental path towards migrating towards smart pointers or is this an "all or nothing" proposition?

For example, all the "factory classes" should return std::unique_ptr's, but this will (appropriately) force all of the caller's to save the result as a std::unique_ptr. But local code could just get the raw ptr (treated as a local weak ptr) to process it. It seems I could also follow a similar path where std::shared_ptr should be used -- e.g., use std::shared_ptr (and std::weak_ptr for back references) when storing pointers within (multiple) data structures, but use raw pointers for local weak pointers.

1 Answer 1

11

You can migrate gradually, though cleaning up the boundaries later can be tricky.

Please look at the C++ Core Guidelines before rewriting old code, because they provide lots of very sensible guidance. For example:

Quite often, using raw pointers is still appropriate, in particular to indicate borrowed/temporary ownership, except in function arguments where you'd use a reference.

I'd therefore suggest that you migrate one function and file at a time, modernizing the internals and the interfaces. Fix up the call sites to those functions as you go to translate to and from smart pointers. For example, if you update a function T* create() to unique_ptr<T> create(), you'd update the call site from create() to create().release() until you're modernizing that part as well.

Unfortunately this won't work very well for shared ownership because a shared_ptr would be responsible for deletion, but ideally that kind of tangled ownership would be rare anyway. You will have to update all co-owners in one go. If in doubt, leave those complicated parts for later and first attack those functions where smart pointers bring valuable clarity without larger risks.

Of course general refactoring best practices apply, e.g.:

  • have some tests before you change the code
    • even a very superficial test helps a lot, don't even make detailed assertions, just aim for >80% line coverage
  • make small changes, commit early and often, keep the entire project in a working state
    • I have messed up way too many refactoring efforts by not respecting this rule. Doubling the effort of a refactoring is cheap when it means you're not stalling other important work.
  • use temporary compatibility shims to keep the code runnable.
    • E.g. instead of changing T* create() to unique_ptr<T> create() directly:
      1. start by creating a compatibility layer like unique_ptr<T> create2(); T* create() { return create2().release(); },
      2. then gradually move all call sites over to create2(),
      3. then mass-rename create2create.

A large “risk” in this effort will be that you will encounter memory safety issues, e.g. use-after-frees. If so, it will likely be easiest to fix it immediately. This ties in with an incremental refactoring approach, because that will probably allow you to make maintenance releases before you've completed going through all 2k files.

Given the large scope of your refactoring, it may not be sensible to move to smart pointers entirely. Only do this where it brings substantial value. The value of smart pointers is that they make it easier to write correct code. Thus, focus your efforts on components that are likely to be modified in the future. In contrast, you should leave code unchanged (for now) if it has been barely touched in a decade.

4
  • 1
    Very thorough answer and abundantly useful. I have already begin other refactoring following the "compatibility" layer using the [deprecated] tag to make it more obvious that new code should not use that API anymore.
    – wcochran
    Commented May 4, 2020 at 20:11
  • 1
    I would add a few things. Create and maintain a mental map of object ownership graph, and remember to validate and update whenever discrepancies are found. Also, use destructor instrumentation when creating your test suite. A crude way is to log a message whenever any destructor is entered. In other words, compare the timeline of destructor execution before, during, and after the code change. This will be needed to catch memory leaks (object leaks) and unintended consequences from altering the destruction sequence of multiple related objects. (...)
    – rwong
    Commented May 4, 2020 at 22:59
  • 1
    (...) Some commercial software testing frameworks may be able to perform runtime instrumentation (injecting binary instrumentation code into the application at runtime), and help detect memory leaks. Valgrind and Clang (ASan, UBSan) may help catch some use-after-free bugs. You may also improvise some creative ways of catching this type of bugs for some of your classes (e.g. putting magic numbers in fields or in an external table).
    – rwong
    Commented May 4, 2020 at 23:02
  • @rwong The code largely involves intense (multi-threaded) processing of large amounts of data and is not robust against speed reduction so unfortunately tools like Valgrind are not helpful since it requires too much overhead. I am not sure how much overhead is required for Asan and UBsan, but it is important that whatever is used doesn't slow things down. The idea of magic numbers sounds interesting -- e.g., put a magic value in a object on destruction and check for it upon destruction?
    – wcochran
    Commented May 5, 2020 at 16:07

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.