容器应该保存什么?
假设存在容器Container
另外,当释放对象的时候也会存在问题,例如:
void f() {
Container c;
{
T obj;
c.insert(obj);
}
// 这个时候c中保存的元素还是有效的吗?
}
通过上面的两个问题,我们可以得到结论,容器应当保存对象的副本而部署对象本身。如果想通过改变obj同时影响容器中的元素,我们应当使用指针处理。但是不能c.insert(&obj);
因为obj是局部变量,c中保存了局部变量的地址会导致内存错误。
容器的复制和赋值应该做什么?
首先我们先来看一下c内置的容器:结构体和数组。
针对结构体进行复制和赋值的时候,其实是创建了对应的副本,修改一个变量不会影响其他变量。例如:
struct C {
int i;
double d;
}
struct C c1, c2;
c1 = c2; // 使用c2的i,d赋值给c1的i,d
c1.i = 10; // 此时c2.i的值保持不变
针对数组而言,数组不允许赋值操作,也不能通过一个数组初始化另外一个数组,但是当数组作为函数参数的时候会被“复制”(这里的意思是类似于其他类型的参数调用了拷贝构造函数,只是数组不支持。)
void f(int [N]);
int x[N];
void g() {
f(x);
}
这里的f(int [N])
在调用时会转化为f(int*)
,对f的传参就变成了传递x数组的第一个元素的地址,所以f中对元素的修改会反应在x中,即:这里使用的是引用语义,复制完成后,两个对象指向同一个底层对象。
大部分针对容器的复制操作都是因为传参,所以我们为了避免针对容器的复制可以使用void f(const Container
的声明方式。如果用的目的是修改容器中的元素则void f(Container
和void f(Container
是可行的。
一般而言针对容器的复制我们采用复制容器内容的值语义,而不是引用语义。但是这不是绝对的正确的,有时候一些元素是不可被复制的,那么就只能采用引用语义。依赖于类型T的拷贝构造函数的实现。
怎样获取容器中的元素
当我们取出一个元素的时候应该返回这个对象的一个副本还是这个元素的引用?即:operator[]()
的返回类型应该是啥?
从效率上看,一般取出元素的操作要比写入的次数要多,如果每次都创建一个副本返回,则会导致大量的复制动作。
另外,如果每次读取都是返回一个副本,我们还需要另外一个方法来帮助用户更新容器中的元素。
由此看来读取操作返回一个引用似乎是正确的。
但是返回引用带来的后果是用户可以通过引用来获取元素的地址,并保存下来以便后续使用。如果容器因为存储空间不足而进行resize的时候,一般会重新创建一块存储空间并将旧数据复制过去,然后清除掉旧的数据,此时用户获取的地址已经失效了!
针对这种情况需要我们提供详尽文档来告诉用户怎样使用。
区分读写
根据上面的讨论我们还没办法完全确定到底应该返回T,还是T&。如果我们将读和写区分开,使用operator[]
仅用来读取,提供update
方法来写入。
但是这样构建的容器可能毫无用处,例如:
template
class Container
{
// ...
public:
T operator[](Index) const; // 复制元素
void update(Index, const T&); // 按位置更新
};
这样定义的容器没有办法处理:
Container< Container > c;
Index i,j;
int k = c[i][j];
// 如何更新元素c[i][j]?
因为update只能指定一个Index参数,如果我们通过重载定义了支持两个Index参数的update就行了吗?但是如果出现更高维的情况我们还是无法处理。
c[i].update(j, new_value);
这样写呢?
这样做也不行,因为operator[]
返回的是一个副本,我们只是修改了副本而已。
所以至此我们应该只能通过operator[]
返回引用了,但是要提醒用户只有创建了以后才能使用他们。
容器需要支持哪些操作
- 容器需要包含默认构造函数,否则将不能定义容器数组。
- 容器包含的元素要支持复制,因为容器保存的是副本。
- 容器应该支持“顺序”的遍历,这里采用迭代器的方案,后续介绍。
设想容器的元素的类型
- 假设容器的元素的类型支持复制所以要保证元素使用了
T::T(const T&)
的拷贝构造函数。 - 元素类型应该包含默认构造函数/
- 容器需要提供判断两个元素是否相等的方法,所以需要操作符
operator==(const T&, const T&)
,但是由于该操作符的性能比较差,一般强调顺序的容器还需要支持operator<(const T&, const T&)
。 - 提供一种遍历的方案。
容器和继承
数组是不能和继承一起使用的,因为数组是通过计算保存的对象大小顺序存储的。如果元素类型是基类,则赋值一个子类对象也仅能获取到基类的内容。
同样的,我们的容器也不应该跟继承糅合在一起。
实现一个类似数组的容器
考虑c数组,当我们使用下标和指针分别访问的时候有什么区别呢?
区别:
- 下标值本身就是有意义的(顺序存储的第几个元素)。
- 指针访问时没有必要知道容器的信息,指针包含了这部分信息。
也就是说当我们使用指针访问数组的时候没有必要知道是访问的哪个数组,但是使用下标则必须知道。另外,当释放数组的时候,指针将全部失效,但是下标仍然有意义。
所以我们的容器要实现的功能有:
- 复制和赋值是被禁止的。
- Array创建的时候就给元素分配了内存,Array拥有元素对象,并依赖默认构造函数进行初始化。后续可以修改这个元素的值,但是元素的值是对象的副本还是引用取决于进行赋值时的
T::operator(const T&)
的实现。 - 使用
operator[]
进行读取和写入。 - 支持Array到T*的转换。
template
class Array
{
public:
Array():size(0),data(nullptr) {}
Array(unsigned int n): size(n), data(new T[n]) {} // 使用T的默认构造函数初始化
~Array() { delete [] data; } // 注意这里是数组
const T& operator[](unsigned int i) const { // 返回只读引用,为了重载添加了后面的const
if (data == nullptr || i >= size) {
throw "array out of range!";
}
return data[i];
}
T& operator[](unsigned int i) { // 返回用来修改的引用
if (data == nullptr || i >= size) {
throw "array out of range!";
}
return data[i];
}
operator T*() { // 隐式转换为T*
return data;
}
operator const T*() const { // 隐式转换为const T*
return data;
}
Array(const Array&) = delete; // 禁止复制构造即:Array b(a);
Array& operator=(const Array&) = delete; // 禁止赋值操作即:Array b = a;
private:
T* data;
unsigned int size;
};
这个实现类存在两个缺陷,也是内建数组存在的缺陷:
- Array对象销毁后,元素的地址还存在。
void f() {
int * p;
{
Array a(20);
p = &a[10];
}
cout << *p; // 此时a已经被清理了,但是p还持有元素a[10]的地址,此时访问会有问题。
}
- 因为Array类允许了用户获取元素的地址,也就导致了出现上面的缺陷。即:Array类向外暴漏了太多内部运行的信息。
针对第二点,我们可以认为a[5]应该是a[4]的后继,但是不能假设a[5]保存在a[4]后面。顺序结构和顺序存储是不一样的。
更进一步,如果我们的容器是支持动态扩展的,我们还需要一个resize
的方法,如果执行了resize方法就会导致所有获取到的元素的指针失效。
改进这些问题,我们将在下一章看到解决方案。