代理对象/引用的getter和setter有什么区别?

8

当我设计一个通用类时,我经常陷入以下设计选择的两难境地:

template<class T>
class ClassWithSetter {
 public:
  T x() const; // getter/accessor for x
  void set_x(const T& x);
  ...
};
// vs
template<class T>
class ClassWithProxy {
  struct Proxy {
    Proxy(ClassWithProxy& c /*, (more args) */);
    Proxy& operator=(const T& x);  // allow conversion from T
    operator T() const;  // allow conversion to T
    // we disallow taking the address of the reference/proxy (see reasons below)
    T* operator&() = delete;
    T* operator&() const = delete;
    // more operators to delegate to T?
   private:
    ClassWithProxy& c_;
  };
 public:
  T x() const; // getter
  Proxy x();  // this is a generalization of: T& x();
  // no setter, since x() returns a reference through which x can be changed
  ...
}; 

注意:

  • x()operator T()中返回T而不是const T&的原因是,如果x仅以隐式方式存储(例如,假设T = std::set<int>,但类型为Tx_存储为std::vector<int>),则可能无法从类内部获得对x的引用。
  • 假设禁止缓存代理对象和/或x

我想知道在哪些情况下会更倾向于一种方法而不是另一种方法,特别是在以下方面:

  • 可扩展性/通用性
  • 效率
  • 开发人员投入的努力
  • 用户投入的努力

您可以假设编译器足够智能,能够应用NRVO并完全内联所有方法。

当前个人观察:

(此部分与回答问题无关;它只作为动机,并说明有时一种方法比另一种方法更好。)

一个特定的场景,其中setter方法存在问题如下。假设您正在实现以下语义的容器类:

  • MyContainer<T>&(可变,读写)- 允许在容器和其数据上进行修改
  • MyContainer<const T>&(可变,只读) - 允许对容器进行修改但不允许对其数据进行修改
  • const MyContainer<T>(不可变,读写)- 允许修改数据但不允许修改容器
  • const MyContainer<const T>(不可变,只读)- 不允许对容器/数据进行修改

其中“容器修改”是指添加/删除元素等操作。如果我使用setter方法来天真地实现它:

template<class T>
class MyContainer {
 public:
   void set(const T& value, size_t index) const {  // allow on const MyContainer&
     v_[index] = value;  // ooops,
     // what if the container is read-only (i.e., MyContainer<const T>)?
   }
   void add(const T& value);  // disallow on const MyContainer&
   ...
 private:
  mutable std::vector<T> v_;
};

问题可以通过引入大量依赖于SFINAE的样板代码来缓解(例如,从实现set()的两个版本的特殊化模板帮助类中派生)。然而,更大的问题在于,这种方法"破坏了常见接口",因为我们需要做到以下两点之一:
  • 确保在只读容器上调用set()是一个编译错误
  • 为只读容器提供不同的set()方法语义
另一方面,基于代理的方法工作很好:
template<class T>
class MyContainer {
   typedef T& Proxy;
 public:
   Proxy get(const T& value, size_t index) const {  // allow on const MyContainer&
     return v_[index];  // here we don't even need a const_cast, thanks to overloading
   }
   ...
};

而且常见的接口和语义不会被破坏。

我发现代理方法的一个困难是如何支持Proxy::operator&(),因为可能没有类型 T 的对象存储/可用的引用(参见上面的注释)。例如,请考虑以下情况:

T* ptr = &x();

除非x_实际存储在某个地方(可以是类本身中或通过调用成员变量的(链式)方法访问),否则无法支持该操作,例如:

template<class T>
T& ClassWithProxy::Proxy::operator&() {
  return &c_.get_ref_to_x();
}

这是否意味着当 T& 可用时(即 x_ 显式地存储),代理对象引用实际上更为优越,因为它允许:
  • 批处理/延迟更新(例如,假设更改是从代理类析构函数传播的)
  • 更好地控制缓存?
(在这种情况下,两者之间的困境在于 void set_x(const T& value)T& x()。)
编辑:我更正了设置器/访问器中 constness 的拼写错误。

我已经读了几遍,但仍然不明白你在说什么或者在问什么。从高层次上看,似乎你想要一个具有固定内部类型但其接口由模板类型定义的类?除了固定的内部表示之外,这个类将是“通用”的? - qeadz
假设该类是一些非平凡的包装器/容器,应该能够存储任何类型T。通过接口,我只是指“它应该有一种设置存储的T类型对象的方法”。内部类型不固定(它取决于类型T),并且存储机制/从您使用的任何内容中获取T的方式在编译时已知。例如,假设T对应于某些图像类型,并且实际存储它的方式不同(可能已压缩?),因此您需要一个代理,每当您使用setter/proxy时,它会解压缩、修改并重新压缩图像。 - eold
3
在C++中,std::vector<T>意味着容器上的const适用于其中的元素,因为容器"拥有"其元素。因此,如果您想要这些语义,将std::vector<T>存储而不使用mutable是错误的。此外,我认为您的语义存在错误:不应该以这种方式使用constconst_container_classcontainer_classconst container_class更好。其次,如果您无论如何都不想破坏封装,代理(proxy)应该通过设置/获取方法转发。 - Yakk - Adam Nevraumont
@Yakk:我将向量设置为可变的,以使意图更加清晰。@Straw1239:我想要一种类似于unique_ptr / smart_ptr的语义;请注意,即使您无法通过const引用重置(重新分配)这样的指针,您仍然可以修改所指向的值(除非它是<const T>)。顺便说一句,问题不在于这些具体示例,而在于选择使用void set_x(const T& x)T& get()(或Proxy作为返回类型)来封装对x的修改的方式。 - eold
1
@leden 你提供的两个选项在constness方面的语义完全不同,因此没有什么有用的比较可以进行。 - D Drmmr
显示剩余7条评论
3个回答

1
大多数设计问题都是情况而定的,我认为这取决于具体情况。总体而言,我更喜欢使用getter和setter模式,因为它更简单易懂(无需为每个字段创建代理类),在某些情况下更加明确。但是,在某些情况下,代理类可以简化用户体验并隐藏实现细节。以下是一些例子:
如果您的容器是某种关联数组,您可能会重载operator[]以获取和设置特定键的值。然而,如果没有定义键,您可能需要一个特殊操作来添加它。在这种情况下,代理类可能是最方便的解决方案,因为它可以根据需要以不同方式处理=赋值。但是,这可能会误导用户:如果此特定数据结构有不同的添加和设置时间,则使用代理会使其难以看到,而使用set和put方法则可以清楚地说明每个操作使用的不同时间。
如果容器对T执行某种压缩并存储压缩形式怎么办?虽然您可以使用代理在必要时执行压缩/解压缩,但它会将与解压缩相关的成本隐藏在用户之后,他们可能会将其用作简单的赋值而不进行重计算。通过创建适当命名的getter/setter方法,可以更明显地表明它们需要大量计算工作。
获取器和设置器似乎更具可扩展性。为新字段创建一个getter和setter很容易,而为每个属性转发操作的代理将是一个容易出错的烦恼。如果您以后需要扩展您的容器类呢?使用getter和setter,只需使它们虚拟并在子类中重写它们即可。对于代理,您可能需要在每个子类中制作一个新的代理结构体。为避免破坏封装,您可能应该使您的代理结构体使用超类的代理结构体来完成一些工作,这可能会变得非常混乱。使用getter/setter,只需调用超级getter/setter即可。
总的来说,getter和setter更容易编程、理解和更改,并且它们可以使操作相关的成本可见。因此,在大多数情况下,我更喜欢它们。

你实际上不需要为每个字段创建一个代理类 - 你只需创建一个模板化的代理类即可,它可以适用于所有字段类型,而且代理类本身并不隶属于某个具体的类。 - einpoklum

0

我认为你的set实现可能存在问题的部分是,你对于const MyContainer<T>&的行为方式的理解与标准容器的行为方式不一致,因此可能会让未来的代码维护者感到困惑。"常量容器,可变元素"的正常容器类型是const MyContainer<T*>&,在这里你需要添加一个间接层以清楚地向用户表明你的意图。

这就是标准容器的工作方式,如果你利用这种机制,你就不需要底层容器是可变的,也不需要set函数是const的。

尽管如此,我还是稍微更喜欢set/get的方法,因为如果某个属性只需要一个get,你根本不需要编写set

然而,我更喜欢不直接访问成员(如get/set或代理),而是通过提供一个有意义的命名接口来让客户端访问类的功能。在一个简单的例子中,为了展示我的意思,我更喜欢使用直接的接口,像这样:generate_report(1, 2);,而避免直接操作类属性。

0

我认为你的ClassWithProxy接口混合了包装器/代理和容器。对于容器,通常使用访问器,例如

T& x();
const T& x() const;

就像标准容器一样,例如std::vector::at()。但通常通过引用访问成员会破坏封装性。对于容器来说,这是一种方便和设计的一部分。

但您注意到并非总是有对T的引用,因此这将减少您的ClassWithSetter接口的选项,它应该是T包装器,处理您存储类型的方式(而容器则处理对象的存储方式)。我建议更改命名,以明确表明它可能不如简单的get/set高效。

T load() const;
void save(const T&);

或者更多的上下文。现在很明显,通过使用代理修改T,再次破坏了封装性。

顺便说一句,在容器内部使用包装器是没有理由不这样做的。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接