因为 string 类的函数接口组合在一起才好玩,所以我们模拟实现 string 类就一组一组函数来模拟实现。为了避免我们模拟实现的 string 类和库里的 string 类冲突,所以我们将自己实现的 string 类封装在命名空间里。
首先,string 类是一个可以动态增长的字符数组,其实就相当于是存储字符的顺序表。那么 string 类的成员变量如下方代码:
namespace Joy
{
class string
{
public:
// 成员函数
private:
char* _str; // 指向动态开辟的数组
size_t _size; // 数组的有效字符个数
size_t _capacity; // 数组的容量
};
}
那现在我们就来模拟实现 string 类了。string 类最重要的就是构造函数了,所以我们先实现构造函数。
string 类的构造函数
string(const char* str = "")
{
_size = strlen(str);
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
注:string类的构造函数最好提供空串缺省参数,然后还有多开一个空间来存储\0
标识字符,标识字符不算是 string 类的有效字符,给 _size 和 _capacity 都赋好初值后,就借助 strcpy 函数来拷贝字符串了。
string 类的析构函数
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
为了 string 类能用起来像数组一样,我们需要提供以下函数接口size
、c_str
和[]
运算符重载。
const char* c_str() const
{
return _str; // 返回数组首元素的地址
}
size_t size() const
{
return _size;
}
// 普通对象:可读可写
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
// const对象:只读
const char& operator[](size_t pos) const
{
assert(pos < _size);
return _str[pos];
}
以上实现的函数接口,足以帮我构造一个 string 类对象和访问 string 类对象了,那么现在我们就来测试一下。
这样,访问和遍历 string 类对象的第一种方式就搞定了。那我们现在来实现一下遍历 string 类对象的第二种方式:迭代器iterator
。
迭代器可能是指针,也有可能不是指针,所以我们实现 string 类迭代器的方式是指针。
typedef char* iterator;
typedef const char* const_iterator;
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
const_iterator begin() const
{
return _str;
}
const_iterator end() const
{
return _str + _size;
}
那现在我们来测试一下功能。
注:以上的 string 类迭代器是使用指针实现的,但是库里的 string 类的迭代器就有可能不是用指针实现了,比如 string 类的反向迭代器就不是使用指针来实现的了。因为reverse_iterator rit = s.rbegin()
rit++
会走到s.rend()
,而如果使用指针实现,应该it--
才能走到s.end()
,使用 string 类的反向迭代器不是使用指针来实现的。
现在我们已经实现了 string 类的正向迭代器,其实范围 for 也实现好了。因为范围 for 的底层原理就是迭代器,编译器会自动将范围 for 替换成迭代器。
如果我们将正向迭代器的代码屏蔽掉,那范围 for 的代码就无法编译通过了。
size_t capacity() const
{
return _capacity;
}
void reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
void resize(size_t n, char ch = '\0')
{
if (n > _size)
{
reserve(n);
for (size_t i = _size; i < n; i++)
{
_str[i] = ch;
}
_size = n;
_str[_size] = '\0';
}
else
{
_str[n] = '\0';
_size = n;
}
}
reserve 函数接口说明
n > _capacity
,那么就先申请一块 n + 1的空间tmp
,然后将数据拷贝到这块空间,_str = tmp
,更新容量的大小_capacity = n
。n <= _capacity
,那么就什么都不用做。如果缩容的话,就会造成效率低下。resize 函数接口说明
n > _size
时,调用 reserve 函数调整容量的大小,然后插入字符 ch。n <= _size
时,直接_str[_size] = '\0'
_size = n
,注意:这个过程也不要缩容。我们主要模拟实现最常用的几个插入的接口,目的不是造更好的轮子。那我们现在开干!
string& push_back(char ch)
{
if (_size == _capacity)
{
int newCapacity = _capacity == 0 ? 4 : _capacity * 2;
reserve(newCapacity);
}
_str[_size] = ch;
++_size;
_str[_size] = '\0';
return *this;
}
string& append(const char* str)
{
size_t len = strlen(str);
if (_size + len > _capacity)
{
reserve(_size + len);
}
strcpy(_str + _size, str);
_size += len;
return *this;
}
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
string& operator+=(const char* str)
{
append(str);
return *this;
}
注:不管是 push_back 函数,还是 append 函数,都要考虑是否需要扩容(reverse 函数会多开一个空间存储标识字符'\0'
),然后再插入数据,最后更新_size
和加上标识字符'\0'
。对于operator +=
运算符重载就可以赋用 push_back 函数和 append 函数了。
除了以上在尾部的插入,还有在任意位置的插入。
string& insert(size_t pos, char ch)
{
assert(pos <= _size);
if (_size == _capacity)
{
int newCapacity = _capacity == 0 ? 4 : _capacity * 2;
reserve(newCapacity);
}
// 挪动数据
/*
int end = _size;
while (end >= (int)pos)
{
_str[end + 1] = _str[end];
--end;
}
_str[pos] = ch;
++_size;
return *this;
*/
size_t end = _size + 1;
while (end > pos)
{
_str[end] = _str[end - 1];
--end;
}
_str[pos] = ch;
++_size;
return *this;
}
string& insert(size_t pos, const char* str)
{
assert(pos <= _size);
int len = strlen(str);
if (_size + len > _capacity)
{
reserve(_size + len);
}
// 挪动数据
/*
int end = _size;
while (end >= (int)pos)
{
_str[end + len] = _str[end];
--end;
}
strncpy(_str + pos, str, len);
_size += len;
return *this;
*/
size_t end = _size + len;
while (end > pos + len - 1)
{
_str[end] = _str[end - len];
--end;
}
strncpy(_str + pos, str, len);
_size += len;
return *this;
}
注:挪动数据就要注意对边界条件的控制,如果控制不会就会造成死循环和越界访问的问题。还有拷贝数据,不能使用 strcpy 函数,strcpy 函数会将\0'
拷贝过去。如果是 >=,要将 pos 强转为 int。
bool empty() const
{
return _size == 0;
}
string& erase(size_t pos, size_t len = npos)
{
assert(!empty());
assert(pos < _size);
if (len == npos || pos + len >= _size)
{
_str[pos] = '\0';
_size = pos;
}
else
{
strcpy(_str + pos, _str + pos + len);
_size -= len;
}
return *this;
}
注:const static size_t npos = -1
,npos
是 string 类的静态成员变量,其值为 -1。
erase 函数接口说明
len == nps || pos + len >= _size
时,就相当于将pos
位置及其之后的字符都删掉,所以此时只需要将pos
位置的字符改为'\0'
并更新_size
的值为pos
。_size -= len
就行了。size_t find(char ch, size_t pos) const
{
assert(pos < _size);
while (pos < _size)
{
if (_str[pos] == ch)
{
return pos;
}
++pos;
}
return npos;
}
size_t find(const char* str, size_t pos) const
{
assert(pos < _size);
const char* ptr = strstr(_str + pos, str);
if (ptr == nullptr)
{
return npos;
}
else
{
return ptr - _str;
}
}
注:strstr 函数是暴力匹配的查找算法,除了这种算法外,还有 KMP 算法。如果想要了解 KMP 算法的话,可以看一下这篇文章。需要注意的是,strstr 函数的返回值是指针,而 find 函数的返回值为下标,所以我们要将指针进行相减转换成下标。
<< 运算符重载
ostream& operator<<(ostream& out, const string& s)
{
for (size_t i = 0; i < s.size(); i++)
{
out << s[i];
}
return cout;
}
<< 运算符是将类对象里的字符一个个打印出来的,所以它跟 c.str() 有一点区别。
clear 函数清空类对象的数据
void clear()
{
_size = 0;
_str[0] = '\0';
}
>> 运算符重载
istream& operator>>(istream& in, string& s)
{
s.clear();
// 第一种方式
/*char ch = in.get();
while (ch != ' ' && ch != '\n')
{
s += ch;
//in >> ch;
ch = in.get();
}
return in;*/
// 第二种方式
char buff[128] = { '\0' };
size_t i = 0;
char ch = in.get();
while (ch != ' ' && ch != '\n')
{
if (i == 127)
{
s += buff;
i = 0;
}
buff[i++] = ch;
ch = in.get();
}
if (i > 0)
{
buff[i] = '\0';
s += buff;
}
return in;
}
以上两种方式,都能实现流插入。但是第二种方式相较于第一种方式不需要频繁地扩容。注:流提取是以空格或者换行结束的。
注:以上的流插入和流提取重载可以用友元实现,友元的话就可以直接访问类对象的数据了,而我实现的方式是通过函数接口来访问类对象的数据。
//传统拷贝构造写法 s2(s1)
string(const string& s)
{
_str = new char[s._capacity + 1];
_capacity = s._capacity;
_size = s._size;
strcpy(_str, s._str);
}
// 传统赋值运算符重载 s2 = s1
string& operator=(const string& s)
{
if (this != &s)
{
char* tmp = new char[s._capacity + 1];
strcpy(tmp, s._str);
delete[] _str;
_str = tmp;
_size = s._size;
_capacity = s._capacity;
}
return *this;
}
注:拷贝构造和赋值运算符重载都是先申请或释放空间,然后再把数据拷贝到对象中去。
交换类对象的数据
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
库里面也有交换的函数模板,但是这个函数接口会涉及三次深拷贝,效率不高。所以我们就自己实现一个交换类对象数据的函数接口,string 类中的交换函数也是这样实现的,效率较高。
// 现代拷贝构造写法 s2(s1)
string(const string& s)
: _str(nullptr)
, _size(0)
, _capacity(0)
{
string tmp(s._str); // 构造函数
//this->swap(tmp);
swap(tmp);
}
拷贝构造现代写法说明
string tmp(s._str)
,构造一个对象出来delete
掉,程序会崩溃。未写初始化列表
交换类对象的数据
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
// 现代赋值运算符重载 s2 = s1
/*string& operator=(const string& s)
{
if (this != &s)
{
//string tmp(s._str);
string tmp(s);
swap(tmp);
}
return *this;
}*/
// s2 = s1
string& operator=(string s)
{
swap(s);
return *this;
}
赋值运算符重载现代写法有两种:第一种现代赋值运算符重载的参数是类对象的引用,可以减少拷贝构造。当 s2 != s1 时,才进行调用拷贝构造函数构造对象 tmp,再将 tmp 的数据和 s2 的数据进行交换。第二种现代赋值运算符重载的参数类对象,传参需要调用拷贝构造函数构造 s,然后将 s 的数据和 s2 的数据进行交换。
注:拷贝构造和赋值运算符重载的现代写法并不是追求效率,而是追求简洁。深拷贝不要求 _capacity 相同。
有了模板之后,内置类型也需要有构造函数和析构函数。见下图代码:
为什么要支持内置类型的构造函数和析构函数呢?因为有了模板,要支持泛型编程。比如下图:如果 T1 和 T2 为内置类型,就要去调用构造函数和析构函数了。
浅拷贝:也称位拷贝,编译器只是将对象中的值拷贝过来。如果对象中管理资源,最后就会导致多个对象共享同一份资源,当一个对象销毁时就会将该资源释放掉,而此时另一些对象不知道该资源已经被释放,以为还有效,所以当继续对资源进项操作时,就会发生发生了访问违规。
就像一个家庭中有两个孩子,但父母只买了一份玩具,两个孩子愿意一块玩,则万事大吉,万一不想分享就 你争我夺,玩具损坏。
可以采用深拷贝解决浅拷贝问题,即:每个对象都有一份独立的资源,不要和其他对象共享。父母给每个孩子都买一份玩具,各自玩各自的就不会有问题了。
如果一个类中涉及到资源的管理,其拷贝构造函数、赋值运算符重载以及析构函数必须要显式给出。一般情况都是按照深拷贝方式提供。