容器及其内容的const和non-const之间的区别

15

如果使用诸如std::vector这样的容器类,就会有两个不同的常量性概念:容器本身的(即其大小)和元素的。看起来std::vector混淆了这两者,因此以下简单代码将无法编译:

struct A {
  A(size_t n) : X(n) {}
  int&x(int i) const { return X[i]; }    // error: X[i] is non-const.
private:
  std::vector<int> X;
};
请注意,尽管std::vector数据成员(指向数据开头和结尾以及分配缓冲区结尾的三个指针)在调用其operator[]时没有被改变,但是该成员不是const——这难道不是一个奇怪的设计吗?
还要注意,对于原始指针,这两个常量的概念是清晰分开的,因此相应的原始指针代码...
struct B {
  B(size_t n) : X(new int[n]) {}
  ~B() { delete[] X; }
  void resize(size_t n);                 // non-const
  int&x(int i) const { return X[i]; }    // fine
private:
  int*X;
};

运行良好。

那么在使用 std::vector(不使用 mutable)时,正确/推荐的处理方式是什么?

像这样使用 const_cast<> 是否可行:

int&A::x(int i) const { return const_cast<std::vector<int>&>(X)[i]; }

认为是可以接受的(X已知不是const,因此这里没有未定义行为)?

编辑 仅为了防止进一步混淆:我确实想要修改元素,即容器的内容但不包括容器本身(大小和/或内存位置)。


std::vector的数据可以通过调用operator[]进行更改。由于您已经编写了A::X,因此a.x(1)++;是完全合法的,并且会修改向量的内容。 - David Schwartz
2
@DavidSchwartz 向量的内容并不是它实际的数据(尽管您可以逻辑上将它们视为这样)。如果您检查std::vector,它只有3个指针作为数据(数据的开始和结束以及缓冲区的结束)。这些保持不变。 - Walter
4个回答

23

C++仅支持一级const。就编译器而言,它是按位const:对象中实际的“位”(即sizeof中计数的位)不能在不进行某些操作(如const_cast等)的情况下被修改,但任何其他东西都是公平的。在C++早期(上世纪80年代末,90年代初),人们对按位const和逻辑const设计优劣的讨论很多(逻辑const也称为Humpty-Dumpty const,因为正如Andy Koenig曾经告诉我的那样,当程序员使用const时,它的含义确确实实是程序员想要它的含义)。最终达成了以逻辑const为核心的共识。

这意味着容器类的作者必须做出选择。容器的元素是否是容器的一部分?如果他们是容器的一部分,则在容器为const时无法修改它们。无法提供选择;容器的作者必须选择一个或另一个。在这里,也似乎存在共识:元素是容器的一部分,如果容器是const,则无法修改它们。(也许与C风格数组的相似之处发挥了作用;如果C风格数组是const,则无法修改其任何元素。)

就像你一样,我遇到过想要禁止修改向量的大小(可能是为了保护迭代器),但不想禁止修改其元素。没有真正令人满意的解决方案;我能想到的最好办法是创建一个新类型,其中包含一个mutable std::vector,并提供相应于我在这种特定情况下需要的const含义的转发函数。如果您想区分三个级别(完全const、部分const和非const),则需要派生。基类只公开完全const和部分const函数(例如,const int operator[]( size_t index ) const;int operator[]( size_t index );),子类可以添加非const函数。

<p>该类使用了保护继承,使得只有派生类可以调用插入和删除元素的函数(例如void insert( int value, size_t index );,但不包括void push_back( int );); 对于不应插入或删除元素的客户端,仅传递给基类一个非const引用。</p>

+1 很好的讨论。然而,我并不认同一个容器类的设计者没有选择的事实。可以实现一个容器类,将其元素的const性作为模板参数,并允许在const和非const容器之间使用移动转换(使用智能指针)。尽管你的基础派生设计看起来更简单。 - Walter
@Walter 一个容器类的设计者有各种选择。在这种情况下,std::vector 的设计者做出了似乎存在普遍共识的选择——我所知道的大多数预标准容器都做出了相同的选择。全球范围内似乎没有太大的需求,需要提供三个级别的 const(无、部分或完全),除非是特殊情况(例如 std::array)。 (在预标准时代,我的一个数组类将大小作为构造函数参数,并且不提供以后更改大小的可能性。) - James Kanze
2
@JamesKanze:解释非常好,但是顶部的前两个开头句子给人的印象是位运算const是C++支持的那一个。*(我希望没有太多C++程序员是TL;DR)* 这个开头句子在编译器后端是正确的,但前端只认为const是一种语法辅助 - 它仅用于类型检查。 (顺便说一下,C++11引入了编译时constexpr。)逻辑上的const只存在于程序员的大脑中。当一个人开始多线程编程时,C++程序员会意识到其中的差异。 - rwong
1
@rwong,该编程语言(及其编译器)正式仅支持位常量。但是const是类型系统的一部分,当您在其上进行重载时,可以将其定义为任何您想要的含义。今天的共识是程序员应该实现逻辑const(因为语言或编译器无法做到这一点,因为它不知道在任何给定上下文中什么是“逻辑”的)。 - James Kanze

4

不幸的是,与指针不同,你不能像这样做:

std::vector<int> i;
std::vector<const int>& ref = i;

这就是为什么std::vector无法区分两种可能应用的const类型,因此必须保守处理。个人而言,我会选择采取以下措施:
const_cast<int&>(X[i]);

编辑:正如另一位评论者准确指出的,迭代器确实模拟了这种二分法。如果您在一个const方法中存储了一个指向vector<int>::iterator的开头,然后对其进行反引用,您可以得到一个非const的int&。我想是这样的。但您必须小心无效化。


1
迭代器的使用提出了一个有趣的解决方案:一个“视图”类,它仅包含开始和结束迭代器,并提供他想要的有限功能。(同样,一个带有指向向量的指针的视图类也可以。) - James Kanze

3

这不是一种奇怪的设计,而是一个非常明智的选择,在我看来是正确的。

你的B示例并不是std::vector的好比喻,一个更好的类比是:

struct C {
   int& get(int i) const { return X[i]; }
   int X[N];
};

但是,这里有一个非常有用的区别,那就是可以调整数组大小。与您最初的代码一样,上述代码无效,因为数组(或vector)元素在概念上是包含类型的“成员”(技术上是子对象),所以您不应该能够通过const成员函数修改它们。
我认为const_cast是不可接受的,使用mutable也不可取,除非作为最后的手段。你应该问问自己为什么要更改一个const对象的数据,并考虑将成员函数设置为非const。

这是一个解释问题。如果您将vector视为可重新调整大小的C数组,那么我同意。但是,那么resize()等函数在这个类比中应该放在哪里?它们必须不仅仅是常量:您希望能够对元素进行非常量访问,但不能访问大小。这在std::vector中是不可能的。我认为通常解决这个问题的方法是提供迭代器,通常允许更改内容,但不允许更改容器。 - Walter
这只是一种类比,不要太过于字面化解读。无论如何,resize不是常量,因此可以更改对象。关于迭代器我不确定你的意思,但是标准容器都不会给你提供一个非常量迭代器到一个常量容器中。 - Jonathan Wakely
我所说的迭代器是指,如果你有一个(非const)迭代器,你不能修改容器。因此,容器保持不变,而元素可以被修改。 - Walter

-3
我建议使用std::vector::at()方法,而不是使用const_cast

2
vector::at()const 重载返回一个 const 引用,所以这并没有帮助。 - Jonathan Wakely

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