Virtual destructor alters behavior of decltype
Asked Answered
H

1

7

I've created a header for optionally-lazy parameters (also visible in a GitHub repository). (This is not my first question based on the header.)

I have a base-class template and two derived-class templates. The base-class template has a protected constructor with a static_assert. This constructor is only called by a particular derived-class. Inside of the static_assert I'm using a decltype.

The really bizarre thing is that the type of a name inside the decltype is somehow affected by the whether or not there is a virtual destructor in my base-class template.

Here's my MCVE:

#include <type_traits>
#include <utility>

template <typename T>
class Base
{
  protected:
    template <typename U>
    Base(U&& callable)
    {
      static_assert(
          std::is_same<
              typename std::remove_reference<decltype(callable())>::type, T
            >::value,
          "Expression does not evaluate to correct type!");
    }

  public:
    virtual ~Base(void) =default; // Causes error 

    virtual operator T(void) =0;
};

template <typename T, typename U>
class Derived : public Base<T>
{
  public:
    Derived(U&& callable) : Base<T>{std::forward<U>(callable)} {}

    operator T(void) override final
    {
      return {};
    }
};

void TakesWrappedInt(Base<int>&&) {}

template <typename U>
auto MakeLazyInt(U&& callable)
{
  return Derived<
            typename std::remove_reference<decltype(callable())>::type, U>{
      std::forward<U>(callable)};
}

int main()
{
  TakesWrappedInt(MakeLazyInt([&](){return 3;}));
}

Note that if the destructor is commented out, this compiles without error.

The intent is for callable to be an expression of type U that, when called with the () operator, returns something of type T. Without the virtual destructor in Base, it appears that this is evaluated correctly; with the virtual destructor, it appears that callabele's type is Base<T> (which, as far as I can tell, makes no sense).

Here's G++ 5.1's error message:

recursive_lazy.cpp: In instantiation of ‘Base<T>::Base(U&&) [with U = Base<int>; T = int]’:
recursive_lazy.cpp:25:7:   required from ‘auto MakeLazyInt(U&&) [with U = main()::<lambda()>]’
recursive_lazy.cpp:48:47:   required from here
recursive_lazy.cpp:13:63: error: no match for call to ‘(Base<int>) ()’
               typename std::remove_reference<decltype(callable())>::type, T

Here's Clang++ 3.7's error message:

recursive_lazy.cpp:13:55: error: type 'Base<int>' does not provide a call operator
              typename std::remove_reference<decltype(callable())>::type, T
                                                      ^~~~~~~~
recursive_lazy.cpp:25:7: note: in instantiation of function template specialization
      'Base<int>::Base<Base<int> >' requested here
class Derived : public Base<T>
      ^
1 error generated.

Here is an online version.

EDIT: =delete-ing the copy-constructor also triggers this error.

Hurtle answered 5/7, 2016 at 23:0 Comment(10)
I couldn't tell you about the weird virtual destructor error, but I see that Derived(U&& callable) takes an r-value reference and not a universal reference. Is that intended?Lasandralasater
Could you add some ouput to the example showing what it's supposed to do. TakesWrappedInt seems to get a zero with your example, due to the final operator T();.Borneol
@GuyGreer No, it is not intended. Is that because once the template is specialized, U is no longer a template type?Hurtle
@JohanLundberg That's correct; this is just an MCVE, and the value of the integer isn't part of the problem. For a more complete context, click on the links in the first sentence.Hurtle
@GuyGreer ....though really, with the intended usage, callable probably should be an r-value reference.Hurtle
It's because once the class' templates are determined they are no longer deduced for the functions (which makes sense, you couldn't have different definitions of U in different parts of the same class).Lasandralasater
@GuyGreer Yes, sorry, by "no longer a template type" I meant "no longer deduced" (I now realize my previous comment wasn't using the right terminology).Hurtle
...in other words, this has nothing to do with virtual and everything to do with a greedy unconstrained constructor that accepts everything under the sun.Pisolite
@Pisolite Maybe there's some magic using enable_if I could use to constrain it? Oddly enough (to me, at least), making the constructors explicit does not help (I get the same error). Frankly, I feel like the "constructor from universal reference" feature of C++ is simply too hard to use correctly for my comfort.Hurtle
....I suppose if explicit fixed the issue in this case, then return-by-value would be be broken in many surprising ways....Hurtle
M
10

The problem is that when you declare destructor, implicit move constructor won't be declared, because

(N4594 12.8/9)

If the definition of a class X does not explicitly declare a move constructor, a non-explicit one will be implicitly declared as defaulted if and only if

...

  • X does not have a user-declared destructor

Base has user-declared destructor (it doesn't matter that it's defaulted).

When MakeLazyInt tries to return constructed Derived object, it calls Derived move constructor.

Derived implicitly-declared move constructor doesn't call Base move constructor (because that doesn't exist), but rather your templated Base(U&&) constructor.

And here's the problem, callable parameter doesn't contain callable object but Base object, which really doesn't contain operator ().

To solve the problem simply declare move constructor inside Base:

template <typename T>
class Base
{
  protected:
    template <typename U>
    Base(U&& callable)
    {
      static_assert(
          std::is_same<
              typename std::remove_reference<decltype(callable())>::type, T
            >::value,
          "Expression does not evaluate to correct type!");
    }

  public:
    virtual ~Base(void) =default; // When declared, no implicitly-declared move constructor is created

    Base(Base&&){} //so we defined it ourselves

    virtual operator T(void) =0;
};
Matroclinous answered 5/7, 2016 at 23:44 Comment(4)
Ahhhhh. I know that rule but didn't see how it applied, based on the error message. Thank you. Do you know if C++17's guaranteed copy-ellision would have avoided this problem?Hurtle
@KyleStrand Well based on second rule In a function call, if the operand of a return statement is a prvalue and the return type of the function is the same as the type of that prvalue., I think compiler will elide move constructor, but I'm not sure why compiler didn't elided it in this case.Matroclinous
@Matroclinous Copy-ellision is an optimization, so the compiler may consider it an error even if the copy will be elided to have a construct that would fail if the copy were not elided. This is why I suspect that guaranteed copy-elision may resolve the issue.Hurtle
@KyleStrand You're right. I've tested it, and it seems that compiler optimized it away. I think guaranteed copy-elision will resolve the issue: In those cases where copy-elision is not guaranteed, if it takes place and the copy-/move-constructor is not called, it must be present and accessible (as if no optimization happened at all), otherwise the program is ill-formed. sourceMatroclinous

© 2022 - 2024 — McMap. All rights reserved.