文章目录
1. 实现一个简单的string
1.1 简单的string类
我们循序渐进,不考虑string的增删查改,只考虑string的深浅拷贝问题。
//string.h
#pragma once
#include <string.h>
namespace Yuucho
{
class string
{
public:
//迭代器(内嵌类型),命名为begin、end(语法规定)
//只要有迭代器就可以使用范围for
//因为底层就是把范围for替换成迭代器
typedef char* iterator;
typedef const char* const_iterator;
//以有效数据的第一个为begin
iterator begin()
{
return _str;
}
//以有效数据的最后一个的后一个为end
iterator end()
{
return _str + _size;
}
//提供const版本,返回值不能修改
//const对象不能调用非const的迭代器
const_iterator begin() const
{
return _str;
}
const_iterator end() const
{
return _str + _size;
}
//缺省值,空字符串只有'\0'
string(const char* str = "")
: _size(strlen(str))
, _capacity(_size)
{
//永远为'\0'多开一个
_str = new char[_capacity + 1];
strcpy(_str,str);
}
~string()
{
if(_str)
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
}
//没有重载流提取、流插入,用c_str来打印字符串
//普通对象、const对象皆可调用
const char* c_str() const
{
return _str;
}
//引用返回方便修改,减少拷贝
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
//为const对象准备,返回const的引用,不能修改
const char& operator[](size_t pos) const
{
assert(pos < _size);
return _str[pos];
}
size_t size() const
{
return _size;
}
size_t capacity() const
{
return _capacity;
}
void clear()
{
_str[0] = '\0';
_size = 0;
}
private:
char* _str;
size_t _size; //有效字符个数
size_t _capacity; //实际存储有效字符的空间
const static size_t npos;
//const static size_t npos = -1;(编译器的特殊处理)
};
const size_t string::npos = -1;
}
测试:
//test.h
#include <iostream>
using namespace std;
#include "string.h"
void test_string1()
{
Yuucho::string s1("hello world");
//s1.operator[](0) = 'x';
s1[0] = 'x';
cout << s1.c_str() << endl;
for(size_t i = 0; i < s1.size(); ++i)
{
cout << s1[i] << " ";
}
cout << endl;
}
int main()
{
test_string1();
return 0;
}
'x’赋值给了这个函数调用表达式的返回值:
1.2 浅拷贝的问题
我们当前所写的string类还面临着一个重大的问题。如果不主动编写拷贝构造函数和拷贝赋值函数,编译器将以“按成员拷贝(浅拷贝)”的方式自动生成相应的默认函数。倘若类中含有指针成员或引用成员,那么这两个默认函数可能隐含错误。
void test_string2()
{
Yuucho::string s1("hello world");
Yuucho::string s2(s1);
cout << s1.c_str() << endl;
cout << s2.c_str() << endl;
}
以两个对象s1、s2为例。假设s1._str的内容为"hello world",现将s1拷贝构造给s2,默认拷贝构造函数的"按成员拷贝"将造成2个错误:
(1)s1、s2的两个指针成员指向同一块内存(堆上的空间),s1和s2任何一方变动都会影响另一方。
(2)在对象被析构时,_str被析构了两次。不能析构两次的原因是:
一块内存还给操作系统后,系统有可能把这块内存分配给其他地方,如果此时再析构就释放了其他地方的内存,这是不允许的。拷贝赋值也是一样的道理。这个过程如下图所示:
注:拷贝构造函数和拷贝赋值函数非常容易混淆,常导致错写、错用。拷贝构造函数是在对象被创建并用另一个已经存在的对象来初始化它时调用的,而赋值函数只能把一个对象赋值给另一个已经存在的对象,使得已经存在的对象具有和源对象相同的状态。
string a("hello");
string b("world");
string c = a; //调用拷贝构造函数,最好写成c(a);
c = b; //调用赋值函数
本例中第3条语句的风格较差,宜改写成string c(a),以区别于第4条语句。
2.string的深拷贝
2.1 拷贝构造函数
string类的拷贝构造函数的一种简单实现:
//拷贝构造必须传引用,不然将引发无穷递归
//s2(s1)
string(const string& s)
:_size(strlen(s._str))
, _capacity(_size)
{
_str = new char[_capacity + 1];
strcpy(_str,s._str);
}
测试test_string2:
先析构s2,再析构s1:
注:string类拷贝构造函数与默认构造函数的区别是:在函数入口处无须与nullptr进行比较,这是因为“引用”不可能是nullptr,而“指针”可以为nullptr。
2.2 拷贝赋值函数
string类的赋值函数的一种简单实现:
string& operator=(const string& s)
{
//(1)检查自赋值
if(this != &s)//这里&是取地址
{
//(2)分配新的内存资源,并复制内容
char *temp = new char[s._capacity + 1];
strcpy(temp,s._str);//'\0'也拷贝了
//(3)释放原有的内存资源
delete[] _str;
_str = temp;
_size = s._size;
_capacity = s._capacity;
}
//(4)返回本对象的引用
return *this;
}
注:
如果第(2)步先释放原有的内存资源,那么若后来的内存重分配操作失败了,就惨了!相反,如果先分配内存给一个临时指针保存,万一分配失败(抛出异常)也不会改变this对象,这是为实现异常安全!
如果第(3)步不释放内存,以后就没有机会了,因为将造成内存泄漏。
(4)返回本对象的引用,目的是为了实现如a = b = c;这样的链式表达式。
测试:
void test_string3()
{
Yuucho::string s1("hello world");
Yuucho::string s2(s1);
Yuucho::string s3("111111111");
s1 = s3;
cout << s1.c_str() << endl;
cout << s2.c_str() << endl;
cout << s3.c_str() << endl;
}
3. 增删查改及关系运算符重载
3.1 reserve
reserve是用来扩容的,它会请求将字符串容量根据计划的大小更改为最长不超过n个字符的长度。reserve还可以把空间提前开好,防止push_back重复扩容。reserve是不会缩容的,因为你给的n比_capacity小,它什么也不会做。
void reserve(size_t n)
{
if(n > _capacity)
{
char* tmp = new char[n + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
3.2 resize
resize是不仅会改变空间,还会改变_size以及字符串的内容。它经常用来扩空间+初始化或者删除部分数据,如果n<小于size,则保留前n个数据。
void resize(size_t n, char ch = '\0')
{
if (n < _size)
{
_size = n;
_str[_size] = '\0';
}
else
{
if (n > _capacity)
{
reserve(n);
}
for (size_t i = _size; i < n; ++i)
{
_str[i] = ch;
}
_size = n;
_str[_size] = '\0';
}
}
3.3 push_back和operator+=
push_back是尾插一个字符,因此我们暴力一点,如果满了我们直接扩2倍。然后把数据拷贝过来。再释放旧空间。注意空字符串(_capacity给4)。
void push_back(char ch)
{
if(_size == _capacity)
{
reserve(_capacity == 0 ? 4 : _capacity * 2);
}
_str[_size] = ch;
++_size;
_str[_size] = '\0';
}
//复用之后实现的insert
void push_back(char ch)
{
insert(_size,ch);
}
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
3.4 append和operator+=
append是用来尾插字符串的,扩容2倍不一定够。
void append(const char* str)
{
size_t len = _size + strlen(str);
if(len > _capacity)
{
reserve(len);
}
strcpy(_str + _size, str);
_size = len;
}
string& operator+=(const char*str)
{
append(str);
return *this;
}
复用insert:
void append(const char* str)
{
insert(_size,str);
}
3.5 insert(插入字符)
任意位置插入一个字符,和我们学习数据结构的实现是一样的。库里面还会返回*this,我们也这样实现。插入也是对原来数据的修改。
和学习顺序表时一样,下面这种写法会导致头插时出现问题。因为头插时end会走到-1,size_t end = -1;是一个很大的数。
所以我们以’\0’后一位作为end,确保end在循环结束后,停在下标为0的位置。
string& insert(size_t pos, char ch)
{
assert(pos <= _size);
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : _capacity * 2);
}
size_t end = _size+1;
while (end > pos)
{
_str[end] = _str[end-1];
--end;
}
_str[pos] = ch;
_size++;
return *this;
}
提示:大家看了insert的底层实现之后,就能很好地理解为什么insert效率不高了。所以频繁的头插不如尾插以后再逆置。
3.6 insert(插入字符串)
string& insert(size_t pos, const char* str)
{
assert(pos <= _size);
size_t len = strlen(str);
if (_size + len > _capacity)
{
reserve(_size + len);
}
// 往后挪动len个位置
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;
}
注意:如果while语句的判断条件写成end >= pos+len,在极端条件(空串且头插)下会有问题。
原因是end走到-1又变成一个很大的数,程序死循环。当然想处理也很简单,用if语句判断一下即可。
if(len == 0)
{
return *this;
}
3.7 erase
string& earse(size_t pos, size_t len = npos)
{
assert(pos < _size);
if (len == npos || pos + len >= _size)
{
_str[pos] = '\0';
_size = pos;
}
else
{
size_t begin = pos + len;
while (begin <= _size)
{
_str[begin - len] = _str[begin];
++begin;
}
_size -= len;
}
return *this;
}
3.8 find
size_t find(char ch, size_t pos = 0)
{
for (; pos < _size; ++pos)
{
if (_str[pos] == ch)
{
return pos;
}
}
return npos;
}
size_t find(const char* str, size_t pos = 0)
{
const char* p = strstr(_str + pos, str);
if (p == nullptr)
{
return npos;
}
else
{
return p - _str;
}
}
3.9 运算符重载
此类函数不能写成成员函数,原因是成员函数隐含有this指针。
//流提取、流插入不访问私有就不需要写成友元
ostream& operator<<(ostream& out, const string& s)
{
for (auto ch : s)
{
out << ch;
}
//支持连续的流插入
return out;
}
//利用buff减少连续的扩容,进行优化
istream& operator>>(istream& in, string& s)
{
s.clear();
char ch;
ch = in.get();
char buff[128] = {'\0'};
size_t i = 0;
while (ch != ' ' && ch != '\n')
{
buff[i++] = ch;
if (i == 127)
{
s += buff;
memset(buff, '\0', 128);
i = 0;
}
ch = in.get();
}
s += buff;
//支持连续的流提取
return in;
}
bool operator<(const string& s1, const string& s2)
{
return strcmp(s1.c_str(), s2.c_str()) < 0;
}
bool operator==(const string& s1, const string& s2)
{
return strcmp(s1.c_str(), s2.c_str()) == 0;
}
bool operator<=(const string& s1, const string& s2)
{
return s1 < s2 || s1 == s2;
}
bool operator>(const string& s1, const string& s2)
{
return !(s1 <= s2);
}
bool operator>=(const string& s1, const string& s2)
{
return !(s1 < s2);
}
bool operator!=(const string& s1, const string& s2)
{
return !(s1 == s2);
}
4. string类的现代写法
拷贝构造函数和拷贝赋值函数除了前文给出的最基本的实现方法外,string类的拷贝构造函数和拷贝赋值函数还可以这样来实现。
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
//_str不进行初始化就是随机值
//交换给tmp后,tmp出了作用域要析构就会出错
//所以要先对s2进行初始化
//s2(s1)
string(const string& s)
:_str(nullptr)
, _size(0)
, _capacity(0)
{
string tmp(s._str);
swap(tmp);
}
string& operator=(const string& s)
{
if (this != &s)
{
string tmp(s._str); //调用拷贝构造函数
swap(tmp); //当前对象与临时对象交换,异常安全的!
}
return *this;
}
string& operator=(string s) //值传递将调用拷贝构造函数
{
swap(s); //直接与临时对象交换,异常安全的!
return *this;
}
通过调用拷贝构造函数来实现拷贝赋值函数,可以确保在拷贝构造函数抛出异常时立即终止赋值操作,因此不会修改左值对象。