Search code examples
c++migrationbackwards-compatibility

How to add new pure virtual method to class in a migration-friendly way?


Situation: We have a C++ library "L" with a class X that has already some pure virtual methods. The library "L" provides all derived classes for X that are needed in production code - users do not have to create derived classes of X themselves. However, for testing, users of "L" have created derived classes of X to mock "L" in their unit-tests.

Problem: X shall be extended by adding a new pure virtual method. It is clear that this breaks binary compatibility of user code, but that is not our concern. We only strive for source code compatibility of user code. This source code compatibility of user code is granted for production code. However, the unit-tests of the users break, because their mock classes that are derived from the changed X can no longer be instantiated.

We are looking for a way to introduce the new method in a migration friendly way that does avoid breaking user code - be it production code or test code. In other scenarios where interfaces changed we used the common approach to have two steps: first deprecate some function, then remove it in a later release. That is, we are willing to take an approach that involves more than one step.

For example, we were thinking about adding the new method as non-pure at first (still allowing the derived mock classes to be instantiated), and make the new method pure later. However, those users who do not anyway want to use the new method would not get any indication that their test classes need adaptation: There does not seem to be a way to mark a method in a way that indicates "the use as a non-pure function is deprecated".

We are aware of some approaches to solve such problems:

  • A new class derived from Xcould be created to hold the pure new virtual method, such that users of the new method would derive from that (Extending a class and maintaining binary backward compatibility).
  • We could provide - for mocking purposes - some class XMockBase which is derived from X and has some default implementation for each method, such that implementers of mock classes would not derive from X but rather from XMockBase.

We may in the end take one of those approaches. But, before going in such a direction we would like to know:

Is there any migration friendly approach that can be used to introduce the new method into the existing class?


Solution

  • namespace TheLibrary {
    #if !defined(USE_OLD_BOB_API)
      inline
    #endif
      namespace v2 {
        struct Bob {
          virtual ~Bob() {}
          virtual int CountAlices() const = 0;
          virtual int CountCharlies() const = 0;
        };
      }
    #if defined(USE_OLD_BOB_API)
      inline
    #endif
      namespace v1 {
        struct
        [[deprecated( "Update Bob to v2" )]]
        Bob: TheLibrary::v2::Bob {
          virtual int CountAlices() const final {
            return 0;
          }
        };
      }
    }
    

    If you include this with USE_OLD_BOB_API defined, TheLibrary::Bob refers to a [[deprecated]] class with only CountAlices as a pure-virtual function, and CountCharlies has a final default implementation. So existing code has compile time compatibility.

    If you include this without USE_OLD_BOB_API defined, TheLibrary::Bob refers to a Bob with a pure virtual CountCharlies.

    So you'll get a [[deprecated]] warning in the code, but it won't otherwise break.

    Logic to define USE_OLD_BOB_API is pretty standard versioning macro stuff I think: you'll ask them to define a THELIBRARY_VERSION_NUMBER macro; if THAT is undefined, it will use the oldest version of your API.

    Code can still directly access the specific versions of Bob even after they have updated the library version, in case they are doing a partial update. So if there are 3 such classes, they can bump the version number up, get the new APIs, and manually put in TheLibrary::v1::Bob because they are going to get around to Bob last.

    So long as they are using old versions of Bob they'll be deprecated.

    Now, the real problem is that your customers will insist on compling with warnings as errors. And you cannot communicate a non-fatal error when they do so.


    Dirk has incidated he didn't understand how to map USE_OLD_BOB_API to a concrete case, so I'll do it.

    #ifndef THELIBRARY_VERSION_NUMBER
      #define THELIBRARY_VERSION_NUMBER 1 // if not provided, default to old version
    #endif
    
    #ifndef THELIBRARY_NOWARNINGS
      #define THELIBRARY_VERBOSE_CODE(...)
    #else
      #define THELIBRARY_VERBOSE_CODE(...) __VA_ARGS__
    #endif
    
    
    #if THELIBRARY_VERSION_NUMBER == 1
      #define THELIBRARY_BOB_VERSION 1
    #else
      #define THELIBRARY_BOB_VERSION 2
    #endif
    
    namespace TheLibrary {
    #if THELIBRARY_BOB_VERSION==2
      inline
    #endif
      namespace v2 {
        struct Bob {
          // current Bob interface
        };
      }
    #if THELIBRARY_BOB_VERSION==1
      inline
    #endif
      namespace v1 {
        struct
        THELIBRARY_VERBOSE_CODE([[deprecated( "Update Bob to v2" )]])
        Bob: TheLibrary::v2::Bob {
          // implementations of pure-virtual methods
          // in v2 but missing in v1
        };
      }
    }