operator->返回指针的有效性

Jon*_*ann 7 c++ containers iterator unique-ptr

我正在实现一个二维数组容器(比如boost::multi_array<T,2>,主要用于练习).为了使用双索引表示法(a[i][j]),我引入了一个代理类row_view(const_row_view但我不关心这里的constness),它保留了指向行的开头和结尾的指针.

我还希望能够分别遍历行和行内的元素:

matrix<double> m;
// fill m
for (row_view row : m) {
    for (double& elem : row) {
        // do something with elem
    }
}
Run Code Online (Sandbox Code Playgroud)

现在,matrix<T>::iterator类(用于遍历行)在row_view rv;内部保持私有以跟踪迭代器指向的行.当然,iterator也实现了dereferenciation功能:

  • 因为operator*(),人们通常想要返回一个参考.相反,这里正确的做法似乎返回一个row_view值(即返回私有的副本row_view).这确保了当迭代器前进时,row_view静止指向前一行.(在某种程度上,row_view行为就像参考一样).
  • 因为operator->(),我不太确定.我看到两个选择:

    1. 返回指向row_view迭代器私有的指针:

      row_view* operator->() const { return &rv; }
      
      Run Code Online (Sandbox Code Playgroud)
    2. 返回指向新的指针row_view(私有指针的副本).由于存储生命周期,必须在堆上分配.为了确保清理,我将其包装成unique_ptr:

      std::unique_ptr<row_view> operator->() const {
          return std::unique_ptr<row_view>(new row_view(rv));
      }
      
      Run Code Online (Sandbox Code Playgroud)

显然,2更正确.如果迭代器 operator->调用后被row_view提升,那么1中指向的迭代器将会改变.但是,我能想到这个问题的唯一方法是,如果operator->它的全名被调用并且返回的指针被绑定:

matrix<double>::iterator it = m.begin();
row_view* row_ptr = it.operator->();
// row_ptr points to view to first row
++it;
// in version 1: row_ptr points to second row (unintended)
// in version 2: row_ptr still points to first row (intended)
Run Code Online (Sandbox Code Playgroud)

但是,这不是您通常使用的方式operator->.在这种用例中,您可能会调用operator*并保留对第一行的引用.通常,人们会立即使用指针调用成员的成员函数row_view或访问成员,例如it->sum().

我现在的问题是:鉴于->语法建议立即使用,被operator->认为仅限于该情况返回的指针的有效性,还是安全实现会导致上述"滥用"?

显然,解决方案2更昂贵,因为它需要堆分配.这当然是非常不受欢迎的,因为dereferenciation是一个非常常见的任务,并没有真正需要它:使用operator*而不是避免这些问题,因为它返回堆栈分配的副本row_view.

Sto*_*ica 3

如您所知,operator->递归地应用于函数返回类型,直到遇到原始指针。唯一的例外是当它像代码示例中那样按名称调用时。

您可以利用这一点并返回自定义代理对象。为了避免上一个代码片段中的情况,该对象需要满足几个要求:

  1. 它的类型名称应该是私有的matrix<>::iterator,因此外部代码无法引用它。

  2. 它的构建/复制/分配应该是私有的。matrix<>::iterator将能够通过成为朋友的方式接触到这些人。

一个实现看起来有点像这样:

template <...>
class matrix<...>::iterator {
private:
  class row_proxy {
    row_view *rv_;
    friend class iterator;
    row_proxy(row_view *rv) : rv_(rv) {}
    row_proxy(row_proxy const&) = default;
    row_proxy& operator=(row_proxy const&) = default;
  public:
    row_view* operator->() { return rv_; }
  };
public:
  row_proxy operator->() {
    row_proxy ret(/*some row view*/);
    return ret;
  }
};
Run Code Online (Sandbox Code Playgroud)

的实现operator->返回一个命名对象,以避免由于 C++17 中保证复制省略而导致的任何漏洞。使用内联运算符 ( it->mem) 的代码将像以前一样工作。但是,任何尝试按operator->()名称调用而不丢弃返回值的尝试都不会编译。

Live Example

struct data {
    int a;
    int b;
} stat;

class iterator {
    private:
      class proxy {
        data *d_;
        friend class iterator;
        proxy(data *d) : d_(d) {}
        proxy(proxy const&) = default;
        proxy& operator=(proxy const&) = default;
      public:
        data* operator->() { return d_; }
      };
    public:
      proxy operator->() {
        proxy ret(&stat);
        return ret;
      }
};


int main()
{
  iterator i;
  i->a = 3;

  // All the following will not compile
  // iterator::proxy p = i.operator->();
  // auto p = i.operator->();
  // auto p{i.operator->()};
}
Run Code Online (Sandbox Code Playgroud)

在进一步审查我建议的解决方案后,我意识到它并不像我想象的那么万无一失。无法在 的范围之外创建代理类的对象iterator,但仍然可以绑定对其的引用:

auto &&r = i.operator->();
auto *d  = r.operator->();
Run Code Online (Sandbox Code Playgroud)

从而允许再次申请operator->()

直接的解决方案是限定代理对象的运算符,并使其仅适用于右值。就像我的例子一样:

data* operator->() && { return d_; }
Run Code Online (Sandbox Code Playgroud)

这将导致上面的两行再次发出错误,而正确使用迭代器仍然有效。不幸的是,由于转换的可用性,这仍然不能保护 API 免受滥用,主要是:

auto &&r = i.operator->();
auto *d  = std::move(r).operator->();
Run Code Online (Sandbox Code Playgroud)

这对整个努力来说是致命的打击。这是无法阻止的。

operator->因此总而言之,对迭代器对象的方向调用没有任何保护。我们最多只能让 API 很难被错误地使用,而正确的使用仍然很容易。

如果row_view副本的创建范围很广,这可能就足够了。但这是你要考虑的。

另一个需要考虑的点(我在这个答案中没有提到)是代理可以用来实现写时复制。但是,除非非常小心并使用相当保守的设计,否则该类可能与我的答案中的代理一样容易受到攻击。