Skip to content

List of tricks on How to make API changes backwards compatible

License

Notifications You must be signed in to change notification settings

victor-mlai/Backwards-Comp-API-Cpp

Repository files navigation

Backwards Compatibility tricks for API changes in C++

This project contains "tricks" on how to make API changes backwards compatible (Eg. changing return types, renaming namespaces, changing enums to enum classes etc.)

It contains also tests (see /tests and /neg-tests) to prove that the tricks make them backwards compatible and don't break something else.

Some of them also have negative tests to show some edge cases for when the API change trick breaks code that previously compiled.

Notice

This project just got created.

Please feel free to create Issues and Pull Requests to improve this list.

These tricks are not focussed to not break ABI, forward declarations or function pointer aliases.

Project internals

The tests are run twice, with the macro BC_API_CHANGED OFF (before the API change) and ON (after).

/include represents an "unstable" library which exports both the old API and the new (changed) API based on the macro BC_API_CHANGED.

/tests represents the users of the library whose code must compile before and after the API change.

/neg-tests contains code that should not compile after the API change.

  • If an API change can lead to multiple kinds of compile errors then each of these errors should be isolated in separate files.

How to, in a backwards compatible way:

Rename a namespace

Initial code:

namespace path::to::v1 { ... }

Scenario: We maybe need to change the namespace name to fix a typo. We will change it from path::to::v1 to path::to::v2.

Solution: Rename the old namespace to the new one and add a namespace alias for the old one.

+ namespace path::to::v2 {}
+ namespace path::to {
+   namespace v1 = path::to::v2;
+ }
+ 
- namespace path::to::v1 { ... }
+ namespace path::to::v2 { ... }

Remarks:

  • [[deprecated]] attribute doesn't work on namespace aliases. You can try compiler specific directives (Eg. #pragma deprecated(keyword) for msvc)

  • The empty namespace namespace path::to::v2 {} was added at the top of the file for visibility purposes

Files:

Change method taking default parameters to taking struct

Initial code:

void SomeMethod(
    const int mandatory,
    const bool opt1 = false,
    const float opt2 = 1e-6
) { ... }

Scenario: This method receives too many default parameters, and it only becomes harder for users to call it with only 1 or 2 parameters changed. We need to change the method to receive a struct containing these parameters instead.

Solution: If you just add the new SomeMethod, users calling SomeMethod with just the mandatory parameters will have the compiler complain about ambiguity ( it won't know which of the 2 methods to choose from). To tell it to prefer the newer one we need to make the old one less specialized by making it a template.

+ template<int = 0>
void SomeMethod(
    const int mandatory,
    const bool opt1 = false,
    const float opt2 = 1e-6
+ ) {
+  // Call the new implementation now
+  SomeMethod(mandatory, SomeMethodOpts{opt1, opt2});
+ }
+ 
+ struct SomeMethodOpts { bool opt1 = false; float opt2 = 1e-6; };
+ void SomeMethod(
+     const int mandatory,
+     SomeMethodOpts opts = {}
) { ... }

Remarks: You can deprecate the old SomeMethod (now a template)

Files:

Rename a type

Initial code:

struct OldName { ... };

Scenario: We maybe need to update the struct name to fix a typo. We will change it to NewName.

Solution: We can use a type alias.

- struct OldName { ... };
+ struct NewName { ... };
+ using OldName = NewName;

Remarks:

  • You can deprecate the old OldName.
  • The users might learn the hard way that they shouldn't forward declare foreign types.

Files:

Move a header

Initial code:

// v1/OldName.hpp:
...

Scenario: We need to move/rename the header to v2/NewName.hpp.

Solution:

  1. Move/rename the header:
- // v1/OldName.hpp:
+ // v2/NewName.hpp: <- only moved/renamed
...
  1. Create a compatibility header file in the old location that includes the renamed/moved one.
// v1/OldName.hpp: <- created to only include the renamed header + deprecation notice
#include "v2/NewName.hpp"

// You can also deprecate it by inserting a compilation error/warning:
// #error/warning OldName.hpp is deprecated, include "v2/NewName.hpp".`

Remarks: Rename/move using the versioning tool (Git/SVN) so you don't lose blame history.

Change the return type (or "overloading" by return type)

Initial code:

// (1) change primitive `T` to `NewUserDefT`
bool CheckPassword(std::string);

// (2) change primitive `const T&` to primitive `T`
struct Strukt {
const float& GetMemF() const {
return m_memF; }
};

Scenario:

(1) CheckPassword method returns true if it succeeds, otherwise false. Make this method return some meaningful error message so the user knows why it failed (why it returned false).

(2) Strukt::GetMemF returns primitive types as const& which is bad for multiple reasons. We need to return by value.

However, we cannot just overload a function by return type and then deprecate it.

Solution:

For (1): Return a new type that can be implicitly casted to bool.

  • (1.1): If you don't want it to be implicitly casted to other primitive types like int, since C++20 you can make it conditionally explicit. (In the tests, int x = CheckPassword(""); doesn't compile after the API change, while bool x = CheckPassword(""); does)

For (2): Add a new class with an implicit cast operator to NewRetT and OldRetT.

  • (2.1) Additionally, if the compiler can't decide between the 2 cast operators at overload resolution, templating the old one makes it choose the new overload candidate since it's more specialized.
  • (2.2) Return GetterRetT by const& to avoid dangling references in user's Wrappers that only forward the old const float&
// (1) change primitive `T` to `NewUserDefT`
+ struct CheckPasswordResult { // mimics std::expected<void, std::string>
+     operator bool() const { return !m_errMsg.has_value(); }
+     const std::string& error() const { return m_errMsg.value(); }
+ private:
+     std::optional<std::string> m_errMsg;
+ };
- bool CheckPassword(std::string);
+ CheckPasswordResult CheckPassword(std::string);

// (2) change primitive `const T&` to primitive `T`
+ struct SomeMethodRetT {
+   template <int = 0> // (2.1)
+   operator OldRetT () const { ... }
+   operator NewRetT () const { ... }
+ };
struct Strukt {
-   const float& GetMemF() const { return m_memF; }
-   float m_memF = 3.f;
+   // (2.2)
+   const GetterRetT& GetMemF() const { return m_memF; }
+   GetterRetT m_memF = 3.f;
};

Remarks: The implicit cast may happen in unintended scenarios. Also, you might want to deprecate the OldRetT cast operator and the GetterRetT type.

Files:

Change old-style enum to enum class

Initial code:

enum Handler {
    StdOut,
    StdErr,
    File,
};

Scenario:

CTO: > Upgrading old-style enums to enum classes shouldn't be too hard ... Right?

Solution:

In order to not break unscoped uses of the enum, we should define static variables for each enum entry.

- enum Handler {
+ enum class Handler {
    StdOut,
    StdErr,
    File,
};

+ static constexpr Handler StdOut = Handler::StdOut;
+ static constexpr Handler StdErr = Handler::StdErr;
+ static constexpr Handler File = Handler::File;

Remarks:

If the enum was used as bit flags, define bitwise operators as well.

Note: Assuming you have a Log method with 2 overloads, for int and for Handler, and expect Log(StdOut | StdErr) to still call Log(int), then the return type for the bitwise operators should be int, otherwise Handler:

// Add `friend` if the enum lies inside a `struct`
[friend] constexpr int operator|(Handler lhs, Handler rhs) {
    return static_cast<int>(lhs) | static_cast<int>(rhs);
}

Files:

Other reasonably safe changes:

TODO: add unit tests for these:

  • removing the const when returning by value: const RetT some_func() (except when it is a virtual method, since it can be overriden by users)
  • changing a class that has only static methods to a namespace
  • add static to member function (obj.SomeMethod(..) still works)
  • changing the underlying type of an enum
  • adding [[nodiscard]] or explicit (since they prevent bugs)

Other TODOs

  • Add tests to ensure no breakings for dynamic libraries. (Hint: ODR + static members)
  • Change a base class by making the new one extend from the old one
  • ...

About

List of tricks on How to make API changes backwards compatible

Topics

Resources

License

Stars

Watchers

Forks