简体   繁体   中英

How do I distinguish reads from writes when using the Proxy Pattern without breaking interaction with primitive types & member access for other types?

Preface

I'm asking this question after a week of investigating and reviewing dozens and dozens of proxy pattern implementations.

Please, do not incorrectly flag this question as a duplicate unless the answer does not break (1) member access for structs & class types and (2) interaction with primitive types.

Code

For my Minimal, Reproducible Example I'm using code from @Pixelchemist as the base.

#include <vector>
#include <type_traits>
#include <iostream>

template <class T, class U = T, bool Constant = std::is_const<T>::value>
class myproxy
{
protected:
  U& m_val;
  myproxy& operator=(myproxy const&) = delete;
public:
  myproxy(U & value) : m_val(value) { }
  operator T & ()
  {
    std::cout << "Reading." << std::endl;
    return m_val;
  }
};

template <class T>
struct myproxy < T, T, false > : public myproxy<T const, T>
{
  typedef  myproxy<T const, T> base_t;
public:
  myproxy(T & value) : base_t(value) { }
  myproxy& operator= (T const &rhs)
  {
    std::cout << "Writing." << std::endl;
    this->m_val = rhs;
    return *this;
  }
};

template<class T>
struct mycontainer
{
  std::vector<T> my_v;
  myproxy<T> operator[] (typename std::vector<T>::size_type const i)
  {
    return myproxy<T>(my_v[i]);
  }
  myproxy<T const> operator[] (typename std::vector<T>::size_type const i) const
  {
    return myproxy<T const>(my_v[i]);
  }
};

int main()
{
  mycontainer<double> test;
  mycontainer<double> const & test2(test);
  test.my_v.push_back(1.0);
  test.my_v.push_back(2.0);
  // possible, handled by "operator=" of proxy
  test[0] = 2.0;
  // possible, handled by "operator T const& ()" of proxy
  double x = test2[0];
  // Possible, handled by "operator=" of proxy
  test[0] = test2[1];
}

Compile Command

g++ -std=c++17 proxy.cpp -o proxy

Execution Command

./proxy

Output A

Writing.
Reading.
Reading.
Writing.

Comment A

Now add this class:

class myclass
{
public:
  void xyzzy()
  {
    std::cout << "Xyzzy." << std::endl;
  }
};

and change the main function accordingly while calling xyzzy to test member access:

int main()
{
  mycontainer<myclass> test;
  mycontainer<myclass> const & test2(test);
  test.my_v.push_back(myclass());
  test.my_v.push_back(myclass());
  // possible, handled by "operator=" of proxy
  test[0] = myclass();
  // possible, handled by "operator T const& ()" of proxy
  myclass x = test2[0];
  // Possible, handled by "operator=" of proxy
  test[0] = test2[1];
  // Test member access
  test[0].xyzzy();
}

Output B

proxy.cpp: In function ‘int main()’:
proxy.cpp:70:11: error: ‘class myproxy<myclass, myclass, false>’ has no member named ‘xyzzy’
   70 |   test[0].xyzzy();
      |           ^~~~~

Comment B

One way to resolve this is to unconditionally inherit T .

struct myproxy < T, T, false > : public myproxy<T const, T>, T
                                                           ^^^

Output C

Writing.
Reading.
Reading.
Writing.
Xyzzy.

Comment C

However, unconditionally inheriting T causes a different compile failure when we switch back to primitive types.

Output D

proxy.cpp: In instantiation of ‘class myproxy<double, double, false>’:
proxy.cpp:64:9:   required from here
proxy.cpp:21:8: error: base type ‘double’ fails to be a struct or class type
   21 | struct myproxy < T, T, false > : public myproxy<T const, T>, T
      |        ^~~~~~~~~~~~~~~~~~~~~~~

Comment D

We can probably conditionally inherit T for structs and class types using std::enable_if but I'm not proficient enough with C++ to know if this causes different underlying issues.

After a week of investigating and reviewing dozens and dozens of proxy pattern implementations I have discovered that almost every proxy pattern implementation is broken because of how the primary operator method(s) are written.

Case in point:

myproxy<T> operator[] (typename std::vector<T>::size_type const i)
^^^^^^^
  1. This should be T . Obviously, T<T> doesn't work here but T does.

  2. In fact this should specifically be T& (to avoid subtle breakage, especially if we are using a map or map-like container as the underlying) but that doesn't work here either without rewriting the implementation.

But regardless of whether we use T or T& we'll get:

Output E

Reading.
Reading.
Reading.
Reading.
Reading.
Xyzzy.

Comment E

As you can see, we lost the ability to distinguish reads from writes.

Additionally, this method causes a different compile failure when we switch back to primitive types:

Output F

proxy.cpp: In function ‘int main()’:
proxy.cpp:64:13: error: lvalue required as left operand of assignment
   64 |   test[0] = 2.0;
      |             ^~~
proxy.cpp:68:20: error: lvalue required as left operand of assignment
   68 |   test[0] = test2[1];
      |

Comment F

We can probably resolve this by adding another class to access the components as lvalues but I'm also not proficient enough with C++ to know if this causes different underlying issues.

Question

How do we distinguish reads from writes when using the proxy pattern without breaking (1) interaction with primitive types, and (2) member access for structs & class types?

There's no short answer to this one so if you don't understand the problem then start from the beginning otherwise start with Answer for Trivial Use Cases which addresses the original question.

Premise

You create a wrapper around two or more containers and want to support the std::map or map-like subscript operator [] .

Problem

You realize that when you insert values using the subscript operator [] that every underlying container must also receive this value. However, you discover that the subscript operator [] doesn't know if it's reading or writing a value until after the function has returned.

Without knowing the value you can't populate every underlying container so you search for ways to obtain the value.

"Solution"

You discover the proxy pattern and realize it's necessary since there is no other way to directly obtain the value.

You may even encounter some words by @KenBloom which emphasize the need for the proxy pattern, "C++ doesn't define a []= operator like Ruby, a magic update function like Scala, or parameterized properties like Visual Basic."

However, you realize that use of the proxy pattern will break either (1) interaction with primitive types, or (2) member access for structs & class types.

Answer for Trivial Use Cases

Which brings us here.

@NicolBolas said, "C++ doesn't allow you to do the kind of thing you want to do. Any kind of proxy type is going to, at some point, not behave like the thing it is proxying. A C++ proxy can only ever be an approximation, not a replacement."

Only the first sentence is not true since all you have to do is conditionally inherit T .

#include <type_traits>  // conditional, is_class
#include <variant>      // monostate

struct myproxy < T, T, false > : public myproxy<T const, T>, <
                                 public std::conditional<std::is_class<T>::value, T, std::monostate>::type
                                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

This makes mycontainer output correctly for trivial use cases (and addresses the original question).

Trival meaning (1) as long as mycontainer is not used recursively, or (2) if mycontainer is used recursively then as long as mycontainer is only used for the innermost node.

If you use the proxy pattern and your output is mostly empty then your use case is non-trivial and the only way to get the expected output is to return T& but if you return T& you cannot use the proxy pattern.

Answer for non-Trivial Use Cases

As previously mentioned, the subscript operator [] doesn't know if it's reading or writing a value until after the function has returned.

You can try to figure out a way to execute code after the function has returned (which would probably be undefined behavior if you actually succeeded) or you can try to understand what returning T& means.

T& returns a reference backed by a memory address.

A memory address is assigned when a variable is created.

This means we don't need the value. We only need a reference that will remain valid after the function returns.

Once the function returns the value will be assigned to the reference and therefore every underlying container that received the reference will have the value.

All you have to do is use a container that does not invalidate iterators or references as the base container.

For instance, std::list .

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM