Diagnostics for Template Meta-Programming in C++

At C++Now 2012 Marshall Clow presented Generic Programming in C++: A Real World Example which addressed the addition of a hex/unhex pair of functions to Boost.Utility. A future post may address why I think the design for this specific feature took a wrong turn right at the start, but as a pedagogical example of intermediate C++ generic programming it’s worth viewing.

The design includes an algorithm which expects a template parameter to provide certain capabilities. The original solution used std::enable_if to disable the definition when those requirements were not met. Around 00:45:00, Stephan T. Lavavej pointed out that disabling unacceptable overloads with std::enable_if produces obscure errors uninterpretable by mortal users because the compiler won’t find a match, and that a cleaner solution is an outer function with a static_assert that invokes an inner function that implements the algorithm. After a very inconvenient interruption, comment from somebody I didn’t recognize at 00:47:40 pointed out that not all compilers terminate template expansion on the static_assert failure, so using this approach you get the static assert diagnostic followed by the no-matching-function diagnostics. The commenter went on to propose a workaround where the inner function takes a bool argument, constructed in the outer function from the std::enable_if calculation, which bypasses the body if the expansion is not valid. Unfortunately the audio is unintelligible and I can’t figure out what technique was being recommended (did he say “mpl:bool_“, “template bool“; is the flag a template parameter or a function parameter; …).

All that’s the topic of this post. You can get the full source for the examples at this github gist.

So let’s start with a simple example. Here’s a generic algorithm that assigns one value to another:

template <typename T1, typename T2>
void useit (T1& t1, T2 t2)
{
  t1 = t2;
}

Here’s code that invokes it, but with types that don’t satisfy the expectations of the algorithm:

int main ()
{
  std::wstring s1{L"wide"};
  std::string t1{"narrow"};
  useit(t1, s1);
}

And here’s the noise that GCC 4.9.0 produces in response:

no-check.cc: In instantiation of ‘void useit(T1&, T2) [with T1 = std::basic_string<char>; T2 = std::basic_string<wchar_t>]’:
no-check.cc:13:15:   required from here
no-check.cc:6:6: error: no match for ‘operator=’ (operand types are ‘std::basic_string<char>’ and ‘std::basic_string<wchar_t>’)
   t1 = t2;
      ^
no-check.cc:6:6: note: candidates are:
In file included from /usr/local/gcc-20140124/include/c++/4.9.0/string:52:0,
                 from no-check.cc:1:
/usr/local/gcc-20140124/include/c++/4.9.0/bits/basic_string.h:554:7: note: std::basic_string<_CharT, _Traits, _Alloc>& std::basic_string<_CharT, _Traits, _Alloc>::operator=(const std::basic_string<_CharT, _Traits, _Alloc>&) [with _CharT = char; _Traits = std::char_traits<char>; _Alloc = std::allocator<char>]
       operator=(const basic_string& __str) 
       ^
/usr/local/gcc-20140124/include/c++/4.9.0/bits/basic_string.h:554:7: note:   no known conversion for argument 1 from ‘std::basic_string<wchar_t>’ to ‘const std::basic_string<char>&’
/usr/local/gcc-20140124/include/c++/4.9.0/bits/basic_string.h:562:7: note: std::basic_string<_CharT, _Traits, _Alloc>& std::basic_string<_CharT, _Traits, _Alloc>::operator=(const _CharT*) [with _CharT = char; _Traits = std::char_traits<char>; _Alloc = std::allocator<char>]
       operator=(const _CharT* __s) 
       ^
/usr/local/gcc-20140124/include/c++/4.9.0/bits/basic_string.h:562:7: note:   no known conversion for argument 1 from ‘std::basic_string<wchar_t>’ to ‘const char*’
/usr/local/gcc-20140124/include/c++/4.9.0/bits/basic_string.h:573:7: note: std::basic_string<_CharT, _Traits, _Alloc>& std::basic_string<_CharT, _Traits, _Alloc>::operator=(_CharT) [with _CharT = char; _Traits = std::char_traits<char>; _Alloc = std::allocator<char>]
       operator=(_CharT __c) 
       ^
/usr/local/gcc-20140124/include/c++/4.9.0/bits/basic_string.h:573:7: note:   no known conversion for argument 1 from ‘std::basic_string<wchar_t>’ to ‘char’
/usr/local/gcc-20140124/include/c++/4.9.0/bits/basic_string.h:589:7: note: std::basic_string<_CharT, _Traits, _Alloc>& std::basic_string<_CharT, _Traits, _Alloc>::operator=(std::basic_string<_CharT, _Traits, _Alloc>&&) [with _CharT = char; _Traits = std::char_traits<char>; _Alloc = std::allocator<char>]
       operator=(basic_string&& __str)
       ^
/usr/local/gcc-20140124/include/c++/4.9.0/bits/basic_string.h:589:7: note:   no known conversion for argument 1 from ‘std::basic_string<wchar_t>’ to ‘std::basic_string<char>&&’
/usr/local/gcc-20140124/include/c++/4.9.0/bits/basic_string.h:601:7: note: std::basic_string<_CharT, _Traits, _Alloc>& std::basic_string<_CharT, _Traits, _Alloc>::operator=(std::initializer_list<_Tp>) [with _CharT = char; _Traits = std::char_traits<char>; _Alloc = std::allocator<char>]
       operator=(initializer_list<_CharT> __l)
       ^
/usr/local/gcc-20140124/include/c++/4.9.0/bits/basic_string.h:601:7: note:   no known conversion for argument 1 from ‘std::basic_string<wchar_t>’ to ‘std::initializer_list<char>’

That’s not something I want my users to have to cope with. Sure, it says what the problem is, but there’s a lot of detail that’s just distracting, and it’d be a lot worse with more complex types in a more complex algorithm.

So: Assume we take the original approach from the talk and disable the generic algorithm when the types are not assignable:

/* Provide the algorithm only if the expectations are met */
template <typename T1, typename T2,
          typename = typename std::enable_if<std::is_assignable<T1, T2>::value>::type>
void useit (T1& t1, T2 t2)
{
  t1 = t2;
}

What that produces is not really better:

ei-check.cc: In function ‘int main()’:
ei-check.cc:16:15: error: no matching function for call to ‘useit(std::string&, std::wstring&)’
   useit(t1, s1);
               ^
ei-check.cc:16:15: note: candidate is:
ei-check.cc:7:6: note: template<class T1, class T2, class> void useit(T1&, T2)
 void useit (T1& t1, T2 t2)
      ^
ei-check.cc:7:6: note:   template argument deduction/substitution failed:
ei-check.cc:6:11: error: no type named ‘type’ in ‘struct std::enable_if<false, void>’
           typename = typename std::enable_if<std::is_assignable<T1, T2>::value>::type>
           ^

The diagnostic is shorter, and somewhat helpful because the conditional is so simple, but still obscure and indirect.

What STL appeared to propose was to add a static assert which verifies the expectations of the parameter and emits a diagnostic when they aren’t satisfied, then delegates to the original version:

template <typename T1, typename T2>
void useit_ (T1& t1, T2 t2)
{
  t1 = t2;
}

/* Generate a diagnostic if the expectations aren't met, but defer the
 * mis-use to another function */
template <typename T1, typename T2>
void useit (T1& t1, T2 t2)
{
  static_assert(template_types_ok::value, "cannot assign T2 to T1");
  useit_(t1, t2);
}

This is the same technique addressed in this blog post. And, just as the anonymous commenter in the video warned, the static assert failure didn’t prevent gcc from going on to produce the non-helpful cascading SFINAE errors:

sa-check.cc: In function ‘void useit(T1&, T2)’:
sa-check.cc:15:17: error: ‘template_types_ok’ has not been declared
   static_assert(template_types_ok::value, "cannot assign T2 to T1");
                 ^
sa-check.cc: In instantiation of ‘void useit_(T1&, T2) [with T1 = std::basic_string<char>; T2 = std::basic_string<wchar_t>]’:
sa-check.cc:16:16:   required from ‘void useit(T1&, T2) [with T1 = std::basic_string<char>; T2 = std::basic_string<wchar_t>]’
sa-check.cc:23:15:   required from here
sa-check.cc:7:6: error: no match for ‘operator=’ (operand types are ‘std::basic_string<char>’ and ‘std::basic_string<wchar_t>’)
   t1 = t2;
      ^
sa-check.cc:7:6: note: candidates are:
In file included from /usr/local/gcc-20140124/include/c++/4.9.0/string:52:0,
                 from sa-check.cc:1:
/usr/local/gcc-20140124/include/c++/4.9.0/bits/basic_string.h:554:7: note: std::basic_string<_CharT, _Traits, _Alloc>& std::basic_string<_CharT, _Traits, _Alloc>::operator=(const std::basic_string<_CharT, _Traits, _Alloc>&) [with _CharT = char; _Traits = std::char_traits<char>; _Alloc = std::allocator<char>]
       operator=(const basic_string& __str) 
       ^
/usr/local/gcc-20140124/include/c++/4.9.0/bits/basic_string.h:554:7: note:   no known conversion for argument 1 from ‘std::basic_string<wchar_t>’ to ‘const std::basic_string<char>&’
/usr/local/gcc-20140124/include/c++/4.9.0/bits/basic_string.h:562:7: note: std::basic_string<_CharT, _Traits, _Alloc>& std::basic_string<_CharT, _Traits, _Alloc>::operator=(const _CharT*) [with _CharT = char; _Traits = std::char_traits<char>; _Alloc = std::allocator<char>]
       operator=(const _CharT* __s) 
       ^
/usr/local/gcc-20140124/include/c++/4.9.0/bits/basic_string.h:562:7: note:   no known conversion for argument 1 from ‘std::basic_string<wchar_t>’ to ‘const char*’
/usr/local/gcc-20140124/include/c++/4.9.0/bits/basic_string.h:573:7: note: std::basic_string<_CharT, _Traits, _Alloc>& std::basic_string<_CharT, _Traits, _Alloc>::operator=(_CharT) [with _CharT = char; _Traits = std::char_traits<char>; _Alloc = std::allocator<char>]
       operator=(_CharT __c) 
       ^
/usr/local/gcc-20140124/include/c++/4.9.0/bits/basic_string.h:573:7: note:   no known conversion for argument 1 from ‘std::basic_string<wchar_t>’ to ‘char’
/usr/local/gcc-20140124/include/c++/4.9.0/bits/basic_string.h:589:7: note: std::basic_string<_CharT, _Traits, _Alloc>& std::basic_string<_CharT, _Traits, _Alloc>::operator=(std::basic_string<_CharT, _Traits, _Alloc>&&) [with _CharT = char; _Traits = std::char_traits<char>; _Alloc = std::allocator<char>]
       operator=(basic_string&& __str)
       ^
/usr/local/gcc-20140124/include/c++/4.9.0/bits/basic_string.h:589:7: note:   no known conversion for argument 1 from ‘std::basic_string<wchar_t>’ to ‘std::basic_string<char>&&’
/usr/local/gcc-20140124/include/c++/4.9.0/bits/basic_string.h:601:7: note: std::basic_string<_CharT, _Traits, _Alloc>& std::basic_string<_CharT, _Traits, _Alloc>::operator=(std::initializer_list<_Tp>) [with _CharT = char; _Traits = std::char_traits<char>; _Alloc = std::allocator<char>]
       operator=(initializer_list<_CharT> __l)
       ^
/usr/local/gcc-20140124/include/c++/4.9.0/bits/basic_string.h:601:7: note:   no known conversion for argument 1 from ‘std::basic_string<wchar_t>’ to ‘std::initializer_list<char>’

Not better.

I don’t know what the unrecognized commenter intended as the solution, but my reconstruction is the following: put the static assert in the user-called function, then delegate to a hidden overloaded implementation that provides the working algorithm only when the constraints are met, and provides a stub with no errors when they aren’t:

/* Provide the algorithm only if the expectations are met */
template <typename T1, typename T2,
          typename = typename std::enable_if<std::is_assignable<T1, T2>::value>::type>
void useit_ (T1& t1, T2 t2, std::true_type template_types_ok)
{
  t1 = t2;
}

/* Provide a no-op that doesn't produce errors when the expectations
 * are not met. */
template <typename T1, typename T2>
void useit_ (T1&, T2, std::false_type template_types_ok)
{ }

/* Bleat in distress when the template types don't satisfy
 * expectations, but unconditionally delegate to an implementation
 * that won't produce compiler errors in either case. */
template <typename T1, typename T2>
void useit (T1& t1, T2 t2)
{
  using template_types_ok = std::is_assignable<T1, T2>;
  static_assert(template_types_ok::value, "cannot assign T2 to T1");
  useit_(t1, t2, typename template_types_ok::type());
}

Walking through, in line 21 we alias template_types_ok to a type that’s equivalent to either std::true_type or std::false_type depending on whether or not the algorithm requirements are satisfied by the type parameters. In line 22 we check the satisfiability at compile-time and provide a user-level description of any failed expectation. Then line 23 we use the type that represents the satisfiability to select an implementation that won’t have compile-time errors. That one of the implementations wouldn’t work at runtime is irrelevant because it’s selected only when the static assert prevents compilation from succeeding.

Here’s what this tells the user:

sa-helper-check.cc: In instantiation of ‘void useit(T1&, T2) [with T1 = std::basic_string<char>; T2 = std::basic_string<wchar_t>]’:
sa-helper-check.cc:33:15:   required from here
sa-helper-check.cc:25:3: error: static assertion failed: cannot assign T2 to T1
   static_assert(template_types_ok::value, "cannot assign T2 to T1");
   ^

Now that’s what I want my users to see if they misuse my algorithms: a clear description of what they did wrong so they can fix things.

Leave a Reply

Your email address will not be published.