第11章 定义一个抽象类型——向量类型

accelerated-cpp学习笔记,参考电力出版社的《Accelerated C++》翻译,这里是第11章内容。本章的内容主要在于引导实现一个标准库类型,为了实现多种数据类型的兼容,使用了模板类型,定义了一个抽象类型。介绍了如何初始化一个抽象类型、如何管理对象的复制和删除、如何实现向量类的插入操作和内存管理,并定义了向量的索引操作。


第11章 定义一个抽象类型——向量类型

1 初步实现Vec类

为了加深印象,仿写标准库中的vector类,设计一个vec类,实现向量的功能。设计一个类的时候,首先要确定在类中要实现什么接口,以正确引导具体类的实现,以向量类为例

vector<StudentInfo> vs;	// 一个空的vector
vector<double> v(100);	// 一个有100个元素的vector
vector<StudentInfo>::const_iterator b, e;
vector<StudentInfo>::size_type i = 0;
vs.size();	// size成员函数
vs[i].name;	// 索引功能
vs.begin(), vs.end();	// 迭代器

这里使用模板类进行实现,使得Vec类可以存储不同类型的数据,模板类的定义可以用公共部分定义接口,私有部分实现类。接着,考虑vector的数据结构是动态创建的数组,这里也会用动态数组实现。接着,因为要实现beginendsize等函数的功能,所以Vec要保存首元素地址,末尾元素后面的地址以及元素个数等,这里决定元素个数通过首元素和末尾元素的间距计算出来。于是,Vec类的初步结构如下

template <class T> class Vec {
public:
    // 接口
private:
    T* data;	// 首元素
    T* limit;	// 末尾元素
    // 实现
}

接着,要给数组动态分配内存,由于可能是不同类型的数据,可能该类型没有构造函数,如果使用new T[n]将导致该类型不能被正确初始化,这不符合Vec类的要求。为此,在类的实现中需要创建一个函数来管理内存,这将在私有部分实现,现在假定这个函数已经被实现了,为create函数。

1.1 构造函数

Vec类中,需要定义三个构造函数

Vec<StudentInfo> vs;	// 默认构造函数
Vec<double> vs(100);	// 第一个参数为向量的大小
Vec<int> vs(100, 1);	// 第二个参数为默认初始化的值

对于Vec中的对象,需要对datalimit进行初始化,包括给Vec中的元素分配内存空间和置一个初始值。在默认构造函数中,创建了一个空的Vec,所以不需要分配任何空间,对于带一个参数的构造函数,就要为给定数目的对象分配内存空间,如果在给定数目之外还给定了初始值,就需要用这个初始值对分配了空间的元素进行初始化。

Vec() { create(); }
explicit Vec(size_type n, const T& val=T()) { create(n, val); }

默认构造函数不带任何参数,它首先清空了Vec类的对象,通过调用create成员函数实现的,它将datalimit的值都置为了零。第二个构造函数中,使用了一个默认的变量作为第二个参数,并且调用了两个参数的create函数为n个T类型的参数分配内存空间,并用val设置了初始值。这个初始值可以被用户显式地提供,默认用T类型的构造函数生成。

explicit关键字只在定义带一个参数的构造函数的时候才有意义,如果声明一个构造函数是explicit的,那么编译器只有在用户显式地使用构造函数时如Vec<int> vi(100);才会调用它,隐式的调用如Vec<int> vi = 100;将会失败。

1.2 类型定义

还需要提供用户能使用的数据类型,以隐藏该类的具体细节,尤其是为常量和非常量迭代器类型以及用来表示Vec的大小的类型提供类型定义typedef。在标准库容器中还定义了一个value_type的类型,这是容器中存储的对象的类型的别名。Vec类需要实现可以通过push_back函数,动态地增加容器中的元素,还可以通过back_inserter来生成一个使Vec大小增加的输出迭代器。定义这些类型的难点在于选择要定义些什么类型,要知道的是,迭代器是用来定位容器中的不同对象,并提供一种检验对象的值的途径,它本身也是一种自定义类型。

因为是使用的数组封装Vec的元素,所以可以使用普通指针作为Vec迭代器类型,所有的指针都指向内部的data数组,指针支持所有的随机存取迭代器操作,这与向量的使用是一致的。value_type的类型显然是Tsize_t类型足够装下任何一个数组能容纳的元素个数,所以可以将它作为size_type的基础类型,现在类的新的定义如下

template <class T> class Vec {
public:
    typedef T* iterator;
    typedef const T* const_iterator;
    typedef size_t size_type;
    typedef T value_type;
    Vec() { create(); }
    explicit Vec(size_type n, const T& val=T()) { create(n, val); }
private:
    iterator data;
    iterator limit;
}

除了定义了新的类型,还在类中使用了它,这使得程序代码更具可读性,同时,如果今后改变了某个地方的类型,其他地方也能同步发生变化。

1.3 索引与大小

size函数可以用来获取容器的大小,使用索引可以访问Vec中的任何一个元素,显然,这里要对[]运算符进行重载。运算符的种类一定程度上决定了该函数要带多少个参数,如果运算符是一个函数而不是一个成员,那么函数的参数个数与运算符的操作数一样多,第一个参数一定是左操作数,第二个参数一定是右操作数。如果运算符定义成一个成员函数,那么它的左操作数必须是调用该运算符的对象。可见,成员运算符函数比普通的运算符函数要少带一个参数,显然,只带一个参数的索引运算符函数必须是成员函数。

这里,操作数是一个整型值,而且必须能表示任何大小Vec类型的最后一个元素,size_type正好合适。同时,索引运算符要返回的类型应当是指向Vec储存的元素引用,这使得用户可以直接进行写操作,于是,新增成员函数

// 因为size函数并不能改变Vec类型对象,所以声明为const
size_type size() const { return limit-data; }
T& operator[](size_type i) { return data[i]; }
const T& operator[](size_type i) { return data[i]; }

const版本返回一个指向const的引用,这样是为了确保用户只能进行读操作,而不能改变它的值,而使用引用的真正原因是为了避免容器中对象的复制造成的不必要的开销。

1.4 返回迭代器操作

实现beginend操作,分别返回一个定位在Vec首元素和末尾元素后一个元素的迭代器。

iterator begin() { return data; }
const_iterator begin() const { return data; }
iterator end() { return limit; }
const_iterator end() const { return limit; }

在此,定义了beginend的两个重载版本,根据Vec是否是常量分别调用不同的版本。至此,Vec类的基本要素已经齐全了,剩下一些重要特性。

2 拷贝与删除

类的作者应当可以控制对象在被创建、复制以及销毁时的行为,如果没有定义这些操作,那么编译器将会默认合成,有时默认的操作会导致不可预计的后果。对象的复制可以通过拷贝构造函数或者重载复制运算符进行,删除操作通过析构函数进行。

2.1 拷贝构造函数

把一个对象的值作为参数传递给函数,或者从函数中通过传值返回一个对象,就是在对对象进行隐式的复制操作,而通过使用一个对象来初始化另一个对象,就是显式地复制对象。

vector<int> vi;
d = median(vi);		// 将对象作为参数传递给函数

string line;
vector<string> words = split(line);	// 把函数的返回值赋给words

vector<int> vs = vi;	// 将vi复制到vs

无论是显式地复制还是隐式的复制,都通过拷贝构造函数copy cnstructor进行。它和其他构造函数一样,和类同名,同时,因为是用来复制一个已存在的同类型对象,以此初始化一个新的对象,所以拷贝构造函数只带一个参数,该参数和类本身具有相同的类型。此外,由于复制对象不会改变原对象的值,所以拷贝构造函数的参数使用了一个常量引用类型

Vec(const Vec& v);

一般来说,拷贝构造函数会从一个已存在的对象中复制每个数据元素到一个新的对象中,注意,有些时候,赋值操作并不复制数据元素的内容,他可能同时进行了一些其他的操作。例如,在Vec类中有两个指针类型的数据元素,如果复制这两个指针的值,那么被复制的对象和复制后的对象都指向内存中的同一个数据,这显然不是想要的效果。需要达到的是复制原对象中的数据元素而不仅仅是复制指针的值,那么就要为新的对象的元素开辟新的内存空间,并把原对象的内存复制进去。

Vec(const Vec& v) { create(v.begin(), v.end()); }

不同的是,这里的create包含了两个迭代器,并对这两个指针之间的元素进行了初始化。

2.2 赋值运算符

一个类在复制时必须要定义进行什么样的操作,通过赋值操作进行类的复制时,可以通过重载赋值运算符进行,虽然一个类中可以定义不同版本的赋值运算符重载函数,但可以通过不同的参数进行重载。通过重载赋值运算符,可以将运算符右操作数对象的每一个元素的值都复制到左操作数对象,这个拷贝构造函数是一致的。

Vec& openrator=(const Vec&);

在此之前,还需要考虑自我赋值的问题,有时候用户会将一个对象赋给它本身,对这种操作的处理十分重要

template <class T>
    Vec<T>& Vec<T>::operator=(const Vec& rhs) {
        if (&rhs != this) {
            // 删除运算符左侧的数组
            uncrete();
            create(rhs.begin(), rhs.end());
        }
        return *this;
    }

需要注意的是,在定义模板类的类外定义一个模板类成员函数,返回类型用的是Vec<T>&,这是因为在类中,C++允许忽略具体类型名称,模板参数是隐式的,所以不需要重复写<T>。而在类外面,必须声明返回类型,所以要在必要的地方显式地写出模板参数。不过,一旦在前面指定了定义是一个Vec<T>类型的成员函数,后面就不需要重复使用这个定语了,所以在参数列表中就可以写成Vec&而不必是Vec<T>&

this关键字只能在成员函数内才有效,代表指向函数操作的对象的指针,在Vec::operator=函数中,this的类型是Vec*,代表指向一个Vec类型对象的指针。对于一个二元操作,例如赋值操作,this总是指向左操作数。一般来说,常在需要指向对象本身时用this关键字。

这里,&rhs是一个指向rhs的指针,this指针指向左操作数,如果赋值操作的左边和右边指向同一个对象,那么程序不做任何改变,如果指向的是不同的对象,那么就需要先释放掉右操作数对象占用的内存空间,然后把新值分别赋给对象中的各个元素,把右操作数的内容复制到新分配的对象空间里。假如没有自我赋值的判断,每次赋值时都将右操作数对象的元素删除并释放掉其占用的内存,如果左边和右边都指向同一个对象,那么左操作数对象也将为空。

最后,在函数结束时返回的是this的间接引用,这使得,在函数返回后,引用指向的对象仍然存在。如果将一个引用返回给一个局部对象,被引用参照的对象在函数返回时会被删除,这会导致错误的引用。

2.3 赋值不是初始化

等号=既可以用来初始化也可以用来赋值,这也是C++学习的一个难点。在使用=为一个变量赋初始值时,程序自动调用拷贝构造函数,而在赋值表达式中,程序调用operator=赋值操作函数。它们之间的主要区别在于:赋值总是会删除一个旧值,而初始化则没有这样的操作,它是创建一个新的对象并给一个初始值,会发生初始化的地方:

  • 声明一个变量时
  • 在一个函数的入口处用到函数参数的时候
  • 函数返回中使用函数返回值的时候
  • 在构造初始化的时候

而赋值只有在表达式中使用=运算符的时候会被调用,如下所示

string url = "www.baidu.com";	 // 隐式地调用构造函数,用一个字符串常量来构造string类型变量
							   // 也可以用字符串厂里创建一个没有名字的临时变量,再用copy创建临时变量的副本
string spaces(url.size, ' ');	 // 调用重载的构造函数生成string类型变量
string y;						// 由默认构造函数生成一个空的string类型变量
y = url;						// 在表达式中使用=进行赋值

vector<string> split(const string&);
vector<stirng> v;
v = split(words);				// 在函数入口处初始化words
							   // 在函数返回中对返回值进行初始化,又将返回值赋给变量v

初始化操作与赋值操作的区别是很重要的,因为其中一个操作的执行可能导致另一个操作的执行,二者是紧密相关的,但要注意:

  • 构造函数始终只控制初始化操作。
  • operator=成员函数只控制赋值操作。
2.4 析构函数

一个在局部范围内被创建的对象在它的生存周期外就会被自动删除,而一个动态分配内存的对象则只有在使用delete时才会被删除,有一个析构函数(destructor)就是为了进行删除操作,将其占用的内存空间释放。

~Vec() { uncreate(); }
2.5 默认操作

对于一个类,如果没有显式地定义一个拷贝构造函数、赋值运算符函数或析构函数,那么在创建这个类的对象时,进行赋值或删除操作时,编译器将自动为类生成相应的默认版的函数。这些默认函数被定义成一系列的递归操作——对每个成员按照他们相应的类型规则进行赋值、赋值或删除。如果成员变量是内置类型,在删除时不需要做任何额外的工作,包括指针,需要注意的是,在通过默认析构函数删除一个指针变量时不会释放该指针指向的对象占用的内存空间。

值得注意的是,只要在类中显式地定义了任何一个构造函数,包括拷贝构造函数,编译器将不会为类自动生成默认构造函数。默认的构造函数在有些情况下是必须的,其中一种情况就是生成默认构造函数本身。一个好的习惯是,为每一个类定义一个默认构造函数。

在创建一个管理资源(如内存资源)的类时应该特别注意对复制函数的控制,一般来说,默认的操作对这种类时不够用的。假如在类中没有定义任何拷贝构造函数、赋值操作或析构函数,由编译器生成的默认构造函数仅仅删除对象的指针,而不会释放掉指针所指向对象占用的内存空间,导致内存泄漏:Vec类型对象使用的内存空间再也不能被收回利用。

又或者,只定义了一个析构函数用于回收内存,如果由两个Vec类型对象共享同一块内存空间,当其中一个对象被删除时,析构函数将释放那片共享的内存空间,导致另一个对象将无法使用。

在构造函数中进行了动态资源分配的类都要求该类的每个对象要正确地处理这些资源,这些类几乎都需要一个析构函数来释放资源。如果类需要一个析构函数,那么它就几乎一定需要 一个复制构造函数和一个赋值运算符成员函数,它们之间构成了一个“三位一体”规则。对一个动态分配资源的类型对象进行复制或者赋值操作一般都要重新分配内存,就像创建一个对象时做的那样。

T::T();					// 一个或几个构造函数
T::~T();				// 析构函数
T::T(const T&);			// 拷贝构造函数
T::operator=(const T&);	// 赋值运算符函数

3 动态类型和内存管理

在定义了创建、复制和删除操作后,某些高级特性仍需完善,它还需要可以动态地管理对象的函数push_back,以及内存管理函数,这里不使用内置的newdelete运算符进行内存管理,而是通过标准库中allocator类实现。

3.1 动态的Vec类型对象

在为Vec提供了内存管理函数后,还需要可以对Vec插入元素的函数,标准库中提供了push_back函数,现在,为了实现它,可以为Vec类的对象分配新的内存,它能够比当前对象所占的空间多容纳一个元素,随后将当前对象的内存复制到新的内存空间中,并插入新的元素。显然,这样做的开销非常大,为了解决这个问题,一个经典的方法是,为程序分配比实际需要更多的内存,只有在使用完了所有预分配内存时,才可以申请分配更多的内存。做法是,只要push_back函数要求得到更多的内存空间,就为程序分配当前内存空间的两倍大小空间。这样的话,就需要改变获得数组中某个元素的方式了,这里将多出一个末尾指针,该指针指向新分配内存空间的末尾元素后面的地址。

template <class T> class Vec {
public:
    void push_back(const T& val) {
        if (avail == limit)		// 获得所需的空间
            grow;
        unchecked_append(val);	// 将新增元素加入到对象的末尾
    }
private:
    iterator data;
    iterator avail;	// 指向已经构造的元素空间中的末尾元素后面的一个元素
    iterator limit;	// 现在指向新分配内存空间的末尾元素后面的地址
}
3.2 灵活的内存管理

在创建Vec类时,就不打算使用newdelete运算符来管理内存,因为它们在创建时需要被初始化,这将带来不必要的资源开销,如果用户想用自己提供的数据进行初始化,实现上需要进行两次初始化,第一次是new自动进行的。就如在push_back函数中所采用的方法一样,没必要为Vec类型对象多分配的两倍空间进行初始化。有关内存的性质复杂多变,因而内存分配管理的工作没必要被固定在语言当中,最好把它们留给函数库去做,标准函数库并不需要支持所有的内存。C++的标准库中提供了管理内存的功能, 但是只是为内存管理者们提供了一个统一的内存管理接口而已。

<memory>中提供了一个命为allocator<T>的类,它可以分配一块预备用来储存T类型对象但是尚未被初始化的内存块,并返回一个指向这块内存的首元素的指针。可以通过T*类型的指针获得它的地址。

template <class T> class allocator {
public:
    T* allocate(size_t);
    void deallocate(T*, size_t);	// 释放size_t大小的内存,首元素为T*
    void construct(T*, T);			// 用T对T*这块内存进行初始化,生成单个对象
    void destroy(T*);				// 调用析构函数,删除这个对象
};
// 将前两个参数指针指向的内存区间中的元素都被初始化第三个参数所指对象的内容
void uninitialized_fill(T*, T*, const T&);
// 将前两个参数所指向的内存区间中的值复制到第三个参数指针所指向的目标内存块中
T* uninitialized_copy(T*, T*, T*);

4 最终的Vec类

最终完成的类中包含内存管理函数的声明,但还没有定义,如下所示

template <class T> class Vec {
public:
    typedef T* iterator;
    typedef const T* const_iterator;
    typedef size_t size_type;
    typedef T value_type;
    
    Vec() { create(); }
    explicit Vec(size_type n, const T& t=T()) { create(n, t); }
    
    Vec(const Vec& v) { create(v.begin(), v.end()); }
    Vec& operator=(const Vec&);
    ~Vec() { uncreate(); }
    T& operator[](size_type i) { return data[i]; }
    const T& operator[](size_type i) const { return data[i]; }
    
    void push_back(const T& t) {
        if (avail == limit)
            grow();
        unchecked_append(t);
    }
    
    size_type size() const { return avail-data; }
    
    iterator begin() { return data; }
    const_iterator begin() const { return data; }
    iterator end() { return avail; }
    const_iterator end() const { return avail; }
    
private:
    iterator data;
    iterator avail;
    iterator limit;
    
    allocator<T> alloc;	// 内存分配对象
    
    // 内存分配底层函数,为数组分配空间并初始化
    void create();
    void create(size_type, const T&);
    void create(const_iterator, const_iterator);
    void uncreate();
    
    // push_back的成员函数
    void grow();
    void unchecked_append(const T&);
}

现在剩下的是如何实现用来进行内存分配的私有成员函数,需要注意的是,只要我们有一个有效的Vec类型对象,那它必须始终满足以下条件:

  1. 如果对象中有数据元素的话,data指向对象数组的首元素,否则为零
  2. data<=avail<=limit
  3. [data,avail)区间内的元素被初始化
  4. [avail,limit)区间内的元素不会被初始化

这四个条件叫做类不变式(class invariant),类中的所有公有成员函数都不能打破这一不变式。

create函数用于分配内存空间,并对这片内存进行初始化,以及正确地设置指针。在初始化时,limit指针和avail指针始终是相等的,因为最后一个被初始化的元素也就是最后一个被分配内存的元素,根据create函数就可以验证类不等式。

// 生成一个空的Vec类型对象
template <class T> void Vec<T>::create() {
    data = avail = limit = 0;
}
// 通过调用alloc对象的allocate成员函数分配内存空间以储存指定个数的T类型对象
// 再通过uninitialized_fill函数对这段内存用值val进行初始化
template <class T> void Vec<T>::create(size_type n, const T& val) {
    data = alloc.allocate(n);
    limit = avail = data+n;
    uninitialized_fill(data, limit, val);
}
// uninitialized_copy函数将区间内的值复制到data所指向的内存空间中
template <class T> void Vec<T>::create(const_iterator i, const_iterator j) {
    data = alloc.allocate(j-i);
    limit = avail = uninitialized_copy(i, j, data);
}

uncreate成员函数正好相反,它运行析构函数,删除该对象,并释放其占用的内存。

template <class T> void Vec<T>::uncreate() {
    if (data) {
        // 从末尾开始删除构造函数生成的元素
        iterator it = avail;
        while (it != data)
            alloc.destroy(--it);
        // 释放所占用的内存
        alloc.deallocate(data, limit-data);
    }
    // 重置指针
    data = limit = avail = 0;
}

delete不同的是,deallocate成员函数需要一个非零指针作为参数,因此,必须检查data是否为零。这里使用it迭代器从后往前遍历Vec对象中的元素,所以是以相反的顺序删除各元素,再调用deallocate函数释放元素占用的空间。

现在,就可以开始实现push_back函数的成员函数:

template <class T> void Vec<T>::grow() {
    // 为对象分配实际大小两倍的内存空间
    size_type new_size = max(2*(limit-data), ptrdiff_t(1));
    // 分配新的内存空间并将已存在的对象元素内容复制到新内存中
    iterator new_data = alloc.allocate(new_size);
    iterator new_avail = uninitialized_copy(data, avail, new_data);
    // 释放当前对象所占用的内存,也就是旧的对象
    uncreate();
    // 重置指针
    data = new_data;
    avail = new_avail;
    limit = data+new_size;
}
// 用val初始化avail这块新分配的内存,假定avail指向的内存是新分配但尚未初始化的
// 在调用grow函数之后再使用是非常安全的
template <class T> void Vec<T>::unchecked_append(const T& val) {
    alloc.construct(avail++, val);
}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值