Search code examples
c++c++11tostringargument-dependent-lookupinfinite-recursion

Compile-time detection of missing user-defined to_string()


I want to provide a to_string(obj) function for every object type I create. I found this question, applied the accepted answer, and it works. So far so good.

Then I created a new type, but forgot to write a to_string() for it (or better: I accidentally made it unreachable by ADL). The problem is: my program still compiles fine, and at runtime I get an obscure stack overflow(TM).

Is there a way to obtain a reasonable error message, instead?

Here is a small program to demonstrate the problem: an infinite recursion between notstd::to_string() and notstd::adl_helper::as_string().

#include <iostream>
#include <string>

namespace notstd {
  namespace adl_helper {
    using std::to_string;

    template<class T>
    std::string as_string( T&& t ) {
      return to_string( std::forward<T>(t) );
    }
  }
  template<class T>
  std::string to_string( T&& t ) {
    std::cout << "called" << std::endl; // <-- this is to show what's going on
    return adl_helper::as_string(std::forward<T>(t));
  }

  class A {
    /* both versions are needed, or the perfect forwarding candidate will
     * always be chosen by the compiler in case of a non-perfect match */
    //friend std::string to_string(A &a) { return std::string("a"); }
    //friend std::string to_string(const A &a) { return std::string("a"); }
  };
}


int main(int argc, char** argv) {

  notstd::A a;

  std::cout << to_string(a) << std::endl;
}

I tried creating a wrapper function that accepts one more parameter, to be used to perform the an anti-recursion check, like this:

#include <iostream>
#include <string>
#include <cassert>

namespace notstd {
  namespace wrap_std {
    std::string to_string(double v, bool) { return std::to_string(v); }
    /* .... etc.....  */
  }

  namespace adl_helper {
    using wrap_std::to_string;

    template<class T>
    std::string as_string( T&& t ) {
      return to_string( std::forward<T>(t), true );
    }
  }
  template<class T>
  std::string to_string( T&& t, bool recurring = false ) {
    std::cout << "called" << std::endl;
    assert(!recurring);
    return adl_helper::as_string(std::forward<T>(t));
  }

  class A {
    /* both versions are needed, or the perfect forwarding candidate will
     * always be chosen by the compiler in case of a non-perfect match */
    //friend std::string to_string(A &a) { return std::string("A"); }
    //friend std::string to_string(const A &a) { return std::string("A"); }
  };
}


int main(int argc, char** argv) {

  notstd::A a;

  std::cout << to_string(a) << std::endl;
}

The problems here are:

  • I'd have to wrap all std::to_string() overloads
  • I'll only get a runtime error, but I feel the problem could and should be detected ad compile time
  • I'm probably adding some overhead, for something useful only during development: maybe I could add some macros to deactivate all this in release mode, but it would add even more work

Maybe I could use a template to wrap std::to_string() and create specializations for my types... this would be a quite different beast, but at least it would provide a compile time error if a suitable specialization is not available. I would have, again, to wrap all std::to_string() overloads, and I'd probably have to (almost) forget about ADL, at least until c++20 is supported by all compilers, If I understand well.

Does anyone have a better solution?

Thanks!


Solution

  • The idea of that accepted answer is different: you put A outside notstd namespace and then use qualified notstd::to_string instead of unqualified to_string. That is:

    namespace notstd {
        // ...
    }
    
    class A {
        friend std::string to_string(const A&);
    };
    
    A a;
    std::cout << notstd::to_string(a);
    

    Now your code won't compile if there is no friend function. Moreover, you need only one friend function (taking const A&), because notstd::to_string(T&&) won't be present in the overload set inside adl_helper::as_string(T&&).

    Putting A inside notstd screws everything up. You have infinite recursion problem and you need two friends to handle both A and const A cases in the presence of notstd::to_string(T&&) candidate: if only one friend is defined, that candidate is a better match in one of the cases because const qualifier should be added/dropped to invoke the friend function.