Duck-typing API changes in C++

May 19, 2020

You may have run across this situation before: your project is dependent on a third-party library. Someone modifies the library with a breaking Application Programming Interface (API) change but the library is not strict about semantic versioning. It seems there is no way for you to conditionally compile code that depends on the library API. Your project can work with one API the library presents, but not both.

But there is a way, with C++ template metaprogramming! The Substitution Failure Is Not An Error (SFINAE) paradigm for templated functions allows you to force the compiler to choose a version of your project’s code that will compile with the matching API presented by the library, while not causing errors when methods are missing. You can use template argument deduction rules[1] and partial ordering of overloaded function templates[2] to prefer the new API when it is available; and `decltype()` and `std::is_same` to detect which API the library provides at compile time.

This approach of ignoring the type of objects in favor of using their behavior to determine their proper use is called duck typing. The name comes from the archetypal example: rather than asking whether you’ve been given a duck, simply observe whether it behaves like a duck (i.e., quacks and walks with a waddle).

Our solution will wrap two or more versions of your project’s code (the pieces that must vary, anyway) inside a structure templated on the third-party’s API-breaking class(es). Whichever version the compiler prefers is how we know what’s a duck!

Of course, just because this post shows you how to work without access to proper versioning macros doesn’t mean you should give up versioning macros! Kitware is working on automating our own versioning macros, so even large projects like VTK that do not use semantic versioning will at least provide some way to discern what API a set of header files provide.

Types of API changes

Let’s see what a breaking API change looks like. There are two principal ways that things change:

  • Method Variations: A method is removed, the method name changes, a method is broken into several calls that must now be made in series (or vice versa, where several calls are now replaced with one); and
  • Signature Variations: A method accepts parameters of different types, or a different number of parameters, or different parameter values than it did before.

Changes within each category above have similar solutions, while the two categories sometimes require different approaches. For our purposes, let’s consider a single class that has two breaking API changes: one in each category above.

// Here is the "before" version of the class:
class Example
{
public:
  void foo()      { std::cout << "foo\n"; }
  void baz(int i) { std::cout << "baz " << i << "\n"; }
};

// ... and here  is the "after" version of the class:
class Example
{
public:
  [[deprecated("Please use bar instead of foo.")]]
  void foo()         { std::cout << "foo\n"; }
  void bar()         { std::cout << "bar\n"; }

  [[deprecated("Please pass the exact floating-point value.")]]
  void baz(int i) { std::cout << "baz " << i << "\n"; }
  void baz(double d) { std::cout << "baz " << d << "\n"; }
};

Let’s suppose that we had been using the library’s Example class before it changed like so:

Example ex;
ex.foo();
ex.baz(static_cast<int>(std::floor(2.5)));

We can’t just switch to

Example ex;
ex.bar();
ex.baz(2.5);

because that would break previous versions. And we don’t want to keep using deprecated (or non-existent) API; even if the old methods exist they may contain bugs that justified the API change in the first place. Instead, we’ll create a templated API “adaptor” structure that will call the proper methods for us:

  Example ex;
  APIMethodNameAdaptor<Example>{ex}; // Calls ex.foo() or ex.bar().
  APISignatureAdaptor<Example>{ex, 2.5}; // Calls ex.baz(2) or ex.baz(2.5).

The next sections look at what the APIAdaptor implementations must look like to work for both Example API changes above.

Method variations

If the name of a method or the number/order of method invocations changes in your third-party library, you can use SFINAE to force some templated methods on the adaptor to fail to compile. However, when these methods do compile, they are written so as to be preferred over the version that works with the old API.

template<typename Class>
struct APIMethodNameAdaptor
{
  // Provide a fallback to the old API if the
  // new method(s) are unavailable.
  // The substitution of Example as the template
  // Test argument will fail when the foo() method
  // is no longer provided but succeed otherwise.
  template <typename Test>
  static void test(Test& obj, ...)
  {
    obj.foo();
  }

  // Implement calls to the new API in a templated
  // method that cannot compile with the old class
  // (in this case, because it requires an argument
  // whose type is undefined when the old class is
  // substituted as the Test parameter).
  template <typename Test>
  static void test(Test& obj, decltype(&Test::bar))
  {
    obj.bar();
  }

  // Make construction of the adaptor invoke one of
  // the methods above.
  APIMethodNameAdaptor(Class& obj)
  {
    // Note that nullptr can serve as the address
    // of a pointer to a method (the second function)
    // as well as a function parameter pack value
    // (the first function above). However, the method
    // pointer is a more exact match and thus is
    // preferred if both methods can be compiled.
    APIMethodNameAdaptor::test<Class>(obj, nullptr);
  }
};

// Use this as follows:
Example ex;
APIMethodNameAdaptor<Example>{ex};
// ... instead of invoking ex.foo() or ex.bar().

Signature variations

When the signature of a method changes in the library’s API — rather than its name — we cannot always use the same pattern above because the decltype(&Test::bar) specification of our templated function’s parameter type exists in both the old and new API — it just has a different signature. While SFINAE still applies, it is possible that implicit type conversion could make the overloading ambiguous. In these cases, we can use some newer compiler and library features:

template<typename Class>
struct APISignatureAdaptor
{
  // Provide a fallback to the old API.
  // The second template argument has a default value
  // computed from the first template argument. Since
  // we don't specify the template arguments explicitly
  // below, the inferred default must compile. It will
  // only compile when the old API signature is present.
  //
  // Recall that std::is_same<A,B>::value evaluates to
  // std::true_type if A and B are the same type.
  // So, this template will only compile if R evaluates
  // to std::true_type because the implementation's
  // return statement will otherwise not be convertible
  // into the type specified for template argument R.
  //
  // Also, note that this function accepts an integer to
  // match the old Example::baz() signature. It is up to
  // you to evaluate whether this will work in your case
  // or whether the APISignatureAdaptor object needs to
  // have member variables that implementations may access,
  // depending on what implicit type conversions are available.
  template <
    typename Test,
    typename R = typename std::is_same<
      decltype(&Test::baz),
      void(Test::*)(int)
    >::value
  >
  static R test(Test& obj, int i)
  {
    obj.baz(i);
    return std::true_type();
  }

  // Provide an implementation using the new API.
  // The second template argument has a default value
  // computed from the first template argument. Since
  // we don't specify the template arguments explicitly
  // below, the inferred default must compile.
  //
  // As above, std::is_same<>::value only matches our
  // return type when the Test class has a baz method whose
  // signature is a pointer to a method of Test that takes
  // parameters matching the types specified (double in
  // this case).
  template <
    typename Test,
    typename R = typename std::is_same<
      decltype(&Test::baz),
      void(Test::*)(double)
    >::value
  >
  static R test(Test& obj, double d)
  {
    obj.baz(d);
    return std::true_type();
  }

  // Finally, we invoke whichever of the above methods
  // can be compiled within the adaptor's constructor.
  // Note that we explicitly provide the second template
  // parameter, forcing the compiler to choose only
  // methods where the is_same<> test result is true.
  APISignatureAdaptor(Class& obj, double d)
  {
    APISignatureAdaptor::test<Class, std::true_type>(obj, d);
  }
};


// Use this as follows:
Example ex;
APISignatureAdaptor<Example>{ex, 2.5};
// ... instead of invoking ex.baz(2) or ex.baz(2.5).

Conclusions

Now you have a couple different ways to adapt to API changes in third-party libraries. Although the adaptors above might seem like a lot of code, once you remove the comments they are pretty concise.

The full example source code is below. You can compile and run it against the “before” version of the Example class like so:

c++ -std=c++11 -DBEFORE -o examples examples.cxx ; ./examples
# You should see:
# foo
# baz 2

and against the “after” version of the Example class like so:

c++ -std=c++11 -DAFTER -o examples examples.cxx ; ./examples
# You should see:
# bar
# baz 2.5

References

[1]: Template argument deduction rules are discussed in Effective Modern C++ by Scott Meyers, chapter 1 and on cppreference.com.

[2]: Some discussion of partial template ordering is on cppreference.com. However, the language specification is the only place we’ve found that thoroughly goes over the rules. It’s just hard to read. If you are really dedicated, take a look at the c++11 specification, section 14.8.2.4 Deducing template arguments during partial ordering [temp.deduct.partial] available in draft form here.

Example Code

#include <iostream>
#include <utility>
#include <type_traits>

#if defined(BEFORE) && BEFORE != 0

// Here is the "before" version of the class:
class Example
{
public:
  void foo()      { std::cout << "foo\n"; }
  void baz(int i) { std::cout << "baz " << i << "\n"; }
};

#else

// ... and here  is the "after" version of the class:
class Example
{
public:
  [[deprecated("Please use bar instead of foo.")]]
  void foo()         { std::cout << "foo\n"; }
  void bar()         { std::cout << "bar\n"; }

  [[deprecated("Please pass the exact floating-point value.")]]
  void baz(int i) { std::cout << "baz " << i << "\n"; }
  void baz(double d) { std::cout << "baz " << d << "\n"; }
};

#endif

template<typename Class>
struct APIMethodNameAdaptor
{
  template <typename Test>
  static void test(Test& obj, ...)
  { obj.foo(); }

  template <typename Test>
  static void test(Test& obj, decltype(&Test::bar))
  { obj.bar(); }

  APIMethodNameAdaptor(Class& obj)
  { APIMethodNameAdaptor::test<Class>(obj, nullptr); }
};

template<typename Class>
struct APISignatureAdaptor
{
  template <
    typename Test,
    typename R = typename std::is_same<
      decltype(&Test::baz),
      void(Test::*)(int)
    >::value
  >
  static R test(Test& obj, int i)
  { obj.baz(i); return std::true_type(); }

  template <
    typename Test,
    typename R = typename std::is_same<
      decltype(&Test::baz),
      void(Test::*)(double)
    >::value
  >
  static R test(Test& obj, double d)
  { obj.baz(d); return std::true_type(); }

  APISignatureAdaptor(Class& obj, double d)
  { APISignatureAdaptor::test<Class, std::true_type>(obj, d); }
};

int main()
{
  Example ex;
  APIMethodNameAdaptor<Example>{ex}; // Calls ex.foo() or ex.bar().
  APISignatureAdaptor<Example>{ex, 2.5}; // Calls ex.baz(2) or ex.baz(2.5).
  return 0;
}

2 comments to Duck-typing API changes in C++

Leave a Reply to Bastien JACQUET (Kitware)Cancel reply