在不中断与原始类型的交互和其他类型的成员访问的情况下,如何在使用代理模式时区分读取和写入?

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

前言

在调查和审查了数十种代理模式实施一周之后,我提出了这个问题。

请不要错误地将此问题标记为重复问题,除非答案不会破坏 (1) 结构和 class 类型的成员访问以及 (2) 与原始类型的交互。

代码

对于我最小的、可重现的例子,我使用来自@Pixelchemist 的code作为基础。

#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];
}

编译命令

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

执行命令

./proxy

输出A

Writing.
Reading.
Reading.
Writing.

评论一

现在添加这个 class:

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

并在调用 xyzzy 测试成员访问时相应地更改主函数:

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();
}

输出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();
      |           ^~~~~

评论 B

解决这个问题的一种方法是无条件继承 T.

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

输出C

Writing.
Reading.
Reading.
Writing.
Xyzzy.

评论 C

然而,当我们切换回原始类型时,无条件继承 T 会导致不同的编译失败。

输出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
      |        ^~~~~~~~~~~~~~~~~~~~~~~

评论 D

我们可能可以使用 std::enable_if 有条件地继承 T 结构和 class 类型,但我对 C++ 不够熟练,不知道这是否会导致不同的潜在问题。

经过一周的调查和审查数十个代理模式实现后,我发现几乎每个代理模式实现都被破坏,因为主要运算符方法的编写方式。

例证:

myproxy<T> operator[] (typename std::vector<T>::size_type const i)
^^^^^^^
  1. 这应该是T。显然,T<T> 在这里不起作用,但 T 可以。

  2. 实际上这应该是 T&(以避免细微的破损,尤其是当我们使用地图或类似地图的容器作为底层时)但这在这里不起作用要么不重写实现。

但是无论我们使用 T 还是 T& 我们都会得到:

输出E

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

评论E

如您所见,我们失去了区分读取和写入的能力。

此外,当我们切换回基本类型时,此方法会导致不同的编译失败:

输出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];
      |

评论F

我们或许可以通过添加另一个 class 来将组件作为左值访问来解决这个问题,但我对 C++ 的熟练程度也不够高,不知道这是否会导致不同的潜在问题。

问题

在不破坏 (1) 与原始类型的交互,以及 (2) 结构和 class 类型的成员访问的情况下,我们如何在使用代理模式时区分读取和写入?

这个问题没有简短的答案,所以如果您不理解这个问题,那么请从头开始,否则请从 简单用例的答案 开始,它解决了原始问题。

前提

您围绕两个或多个容器创建了一个包装器并希望支持 std::map 或 map-like 下标运算符 [].

问题

您意识到,当您使用下标运算符 [] 插入值时,每个底层容器也必须接收该值。但是,您发现下标运算符 [] 直到函数 returned.

后才知道它是在读取还是写入值

在不知道该值的情况下,您无法填充每个底层容器,因此您需要寻找获取该值的方法。

“解决方案”

您发现代理模式并意识到它是必要的,因为没有其他方法可以直接获取该值。

你甚至可能会遇到@KenBloom 的 some words,它强调代理模式的必要性,“C++ 没有定义像 Ruby 这样的 []= 运算符,一个神奇的 update 像 Scala 那样的函数,或者像 Visual Basic 这样的参数化属性。"

但是,您意识到使用代理模式会破坏 (1) 与基本类型的交互,或 (2) 结构和 class 类型的成员访问。

简单用例的答案

这让我们来到这里。

@NicolBolas 说,“C++ 不允许你做你想做的事情。任何一种代理类型在某些时候都不会表现得像它正在代理的东西。A C++ 代理只能是近似值,不能替代。"

只有第一句是不正确的,因为你所要做的就是有条件地继承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
                                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

这使得 mycontainer 对于普通用例正确输出(并解决了原始问题)。

琐碎的含义 (1) 只要 mycontainer 不被递归使用,或 (2) 如果 mycontainer 被递归使用,那么只要 mycontainer 仅用于最里面的节点。

如果您使用代理模式并且您的输出大部分为空,那么您的用例是 non-trivial 并且获得预期输出的唯一方法是 return T& 但是如果你 return T& 你不能使用代理模式。

non-Trivial 个用例的答案

如前所述,下标运算符 [] 直到函数 returned 后才知道它是在读取还是写入值。

您可以尝试找出一种在函数 returned 后执行代码的方法(如果您真的成功了,这可能是未定义的行为)或者您可以尝试理解什么 return ing T& 表示.

T& return 由内存地址支持的引用。

创建变量时分配内存地址。

这意味着我们不需要这个值。我们只需要一个在函数 returns.

之后仍然有效的引用

一旦函数 returns 值将被分配给引用,因此接收引用的每个底层容器都将具有该值。

您所要做的就是使用不会使迭代器或引用失效的容器作为基础容器。

例如,std::list.