4

I'm writing a wrapper class for C++ types, which allows me to instrument when a wrapped object is constructed, accessed, modified, and destroyed. To make this transparent for the original code, I include implicit conversion functions back to the underlying type, but this fails when a wrapped object is passed directly to a template since implicit conversions aren't evaluated. Here's some code that demonstrates this problem:

#include <utility>

// simplified wrapper class
template <typename T>
class wrap {
    T t;
public:
    wrap() : t() {}
    wrap(const T& _t) : t(_t) {}
    wrap(T&& _t) : t(std::move(_t)) {}

    constexpr operator T&() { return t; }
    constexpr operator const T&() const { return t; }
};

// an example templated function
template <typename T>
bool is_same(const T& t1, const T& t2) { return t1 == t2;}

// second invocation fails due to template substitution failure
bool problem() {
    wrap<int> w(5);

    return is_same(static_cast<int>(w), 5) && is_same<>(w, 5);
}

I can resolve this manually by performing a static_cast on the wrapped variable at each template call site (as shown in the first invocation), but this doesn't scale well since I'm working with a large code base. Similar questions suggest inlining each template function as a friend function, but this also requires identifying and copying each template, which doesn't scale.

I'd appreciate any advice on how to (1) workaround this conversion problem with templated functions, or (2) otherwise instrument a variable at source-level without this problem.

xskxzr
  • 12,442
  • 12
  • 37
  • 77
ddcc
  • 539
  • 3
  • 10

2 Answers2

2

With a pointer wrapper function it can work, if you treat the "inner" guy as a pointer.
This is not a complete solution, but should be a good starting point for you (for instance, you need to carefully write the copy and move ctors).
You can play with this code in here.

Disclaimer: I took the idea from Andrei Alexandrescu from this presentation.

#include <iostream>

using namespace std;


template <typename T>
class WrapperPtr
{
   T * ptr;
public:
  WrapperPtr(const WrapperPtr&){
    // ???
  }
  
  WrapperPtr(WrapperPtr&&) {
    // ???
  }
  
  WrapperPtr(const T & other)
     : ptr(new T(other)) {}
     
  WrapperPtr(T * other)
     : ptr(other) {}
     
  ~WrapperPtr()
  {
      // ????
      delete ptr;
  }

  T * operator -> () { return ptr; } 
  
  T & operator * () { return *ptr; }
  const T & operator * () const { return *ptr; }
  
  bool operator == (T other) const {  other == *ptr; }
  
   operator T () { return *ptr; }
};

// an example templated function
template <typename T>
bool my_is_same(const T& t1, const T& t2) { return t1 == t2;}

bool problem() {
    WrapperPtr<int> w(5);
 
    return my_is_same(static_cast<int>(w), 5) && my_is_same(*w, 5);
}
SimonFarron
  • 242
  • 1
  • 4
  • Appreciate the suggestion, but the inner type isn't necessarily a pointer. Sometimes it's just an integral type, so I do include some conditionally-enabled `operator*` and `operator->` functions, which were omitted here for simplicity. – ddcc Feb 13 '21 at 21:38
2

The fault in this example lies with is_same. It declares that it requires two arguments of the same type, which is a requirement it does not need, and fails to require that type to have an ==, which it does need.

Granted, it is common to find C++ that poorly constrains template functions because it is difficult and verbose to do otherwise. Authors take a practical shortcut. That said, isn't the approach to fix the interface of is_same?

// C++17 version. Close to std::equal_to<>::operator().
template <typename T, typename U>
constexpr auto is_same(T&& t, U&& u)
    noexcept(noexcept(std::forward<T>(t) == std::forward<U>(u)))
    -> decltype(std::forward<T>(t) == std::forward<U>(u))
{
    return std::forward<T>(t) == std::forward<U>(u);
}

With a corrected is_same, the code just works.

There are other examples one can imagine which may require two arguments to have the same type. For example, if the return type depends on the argument type and the return value can come from either:

template <typename T>
T& choose(bool choose_left, T& left, T& right) {
    return choose_left ? left : right;
}

This is much rarer. But it might actually require thought to decide whether to use the underlying or wrapper type. If you have this enhanced behavior in the wrapper type, and conditionally use a wrapped value or an underlying value, should the underlying value be wrapped to continue to get the enhanced behavior, or do we drop the enhancement? Even if you could make this silently choose one of those two behaviors, would you want to?

However, you can still make it easier to get the value than to say static_cast<T>(...), for example by providing an accessor:

// given wrap<int> w and int i
is_same(w.value(), 5);
choose_left(true, w.value(), i);

I have a few other important comments:

wrap() : t() {}

This requires T be default constructible. = default does the right thing.

wrap(const T& _t) : t(_t) {}
wrap(T&& _t) : t(std::move(_t)) {}

These are not explicit. A T is implicitly convertible to a wrap<T> and vice versa. This does not work well in C++. For example, true ? w : i is not well-formed. This causes std::equality_comparable_with<int, wrap<int>> to be false, which would be a reasonable requirement for is_same. Wrapper types should probably be explicitly constructed, and can be implicitly converted to the underlying type.

constexpr operator T&() { return t; }
constexpr operator const T&() const { return t; }

These are not ref-qualified, so they return lvalue references even if the wrapper is an rvalue. That seems ill-advised.

Finally, construction and conversion only take into account the exact type T. But any place T is used, it might be implicitly converted from some other type. And two conversions are disallowed in C++. So for a wrapper type, one has a decision to make, and that often means allowing construction from anything a T is constructible from.

Jeff Garrett
  • 5,863
  • 1
  • 13
  • 12
  • Thanks for the suggestions! Adjusting the template parameters makes sense, but unfortunately I don't think it's feasible. To instrument classes with heap allocations, like STL containers, I've made instrumented versions where internal variables are also wrapped, but in just `libcxx` alone, this gives multiple compile-time errors about substitution failures in `std::__to_raw_pointer()`, `std::swap()`, the internal `__map_value_compare::operator()` that implements comparison for `std::map`, etc. – ddcc Feb 13 '21 at 21:34
  • Your ternary operator example is a good one; I've encountered this in the codebase and have manually added casts to resolve them. – ddcc Feb 13 '21 at 21:34
  • I thought about adding a member function previously, but wanted to preserve the flexiblity of disabling instrumentation with `template using wrap = T`. However, it's a smaller concern, which I now think is probably outweighed by this template type conversion issue. – ddcc Feb 13 '21 at 21:35
  • I'm using an explicit user-defined constructor here, because otherwise the default constructor can be omitted inside the compiler, which loses instrumentation. I only use this base wrapper class for primitive types though, so default construction isn't an issue. But it sounds like perhaps I should provide a perfect-forwarding constructor instead of the lvalue reference and xvalue constructors? – ddcc Feb 13 '21 at 21:35
  • Oh, I didn't know about ref-qualifiers on member functions, but it definitely looks like I should be using them here. – ddcc Feb 13 '21 at 21:35