目录
3.1 size()、length() 和 operator[ ]
string的底层就是一个动态增长字符数组。首先在自己创建的mystring的命名空间中创建一个头文件string.h,再定义一个string类,同时创建一个test.cpp文件来调用测试。
1. string的私有成员
namespace mystring {
class string
{
public:
//成员函数
private:
//string的底层就是动态增长的字符数组
char* _str; //指向一个动态分配的字符数组
size_t _size; //字符串的实际长度(不包括'\0')
size_t _capacity; //分配的内存容量
};
//测试函数
void test_string1()
{
}
}
其中size_t是无符号整型。
2. 构造函数和析构函数
2.1 构造函数
使用构造函数来进行初始化,同时使用初始化列表
class string
{
public:
//成员函数
//2.1 构造函数
//无参构造函数
string()
:_str(nullptr)
,_size(0)
,_capacity(0)
{}
//带参构造函数
string(const char* str)
:_str(str)
,_size(strlen(str))
,_capacity(strlen(str))
{}
private:
//string的底层就是动态增长的字符数组
//1 私有成员
char* _str; //指向一个动态分配的字符数组
size_t _size; //字符串的实际长度(不包括'\0')
size_t _capacity; //分配的内存容量
};
这时会出现一个问题 如图:
- const char* 不能改成 char* 因为这样可能会导致参数字符串被改变,得不到原来想要的参数。
- 也不能把 私有成员 char* 改成 const char* ,因为我们想去修改字符串的时候,发现无法修改。
所以应该去开辟一个新空间,拷贝数据。正确方法:
//带参构造函数
string(const char* str)
:_str(new char[strlen(str)+1])//加1原因是为了存储'\0',与C语言风格字符串匹配
,_size(strlen(str))
,_capacity(strlen(str))
{
strcpy(_str,str); //拷贝数据
}
这里发现调用了三次strlen,优化版本
错误示例:
//错误示例:
string(const char* str)
:_size(strlen(str))
,_str(new char[_size+1])
, _capacity(strlen(str))
{
strcpy(_str, str); //拷贝数据
}
所以 在构造函数上的初始化列表上的初始化顺序 最好和私有成员定义的顺序一致。
正确写法:
string(const char* str)
:_size(strlen(str))
{
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str,str);
}
2.1.1 无参构造函数 和 带参构造函数
//无参构造函数
string()
:_str(nullptr)
,_size(0)
,_capacity(0)
{}
string(const char* str)
:_size(strlen(str))
{
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str,str);
}
但是,当定义无参对象的时候仍有问题
string s1;
这里借助获取字符串的c_str()成员函数来检测
#include<iostream>
namespace mystring {
class string
{
public:
//成员函数
//2.1 构造函数
//无参构造函数
string()
:_str(nullptr)
,_size(0)
,_capacity(0)
{}
string(const char* str)
:_size(strlen(str))
{
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str,str);
}
const char* c_str()
{
return _str;
}
private:
//string的底层就是动态增长的字符数组
//1 私有成员
char* _str; //指向一个动态分配的字符数组
size_t _size; //字符串的实际长度(不包括'\0')
size_t _capacity; //分配的内存容量
};
//测试函数
void test_string1()
{
string s1;
std::cout << s1.c_str() << std::endl;
}
}
可以将无参构造函数和带参构造函数合并为一个全缺省构造函数(即所有参数都有默认值的构造函数)
2.1.2 全缺省构造函数
在写全缺省构造函数的时候也要注意:
string(const char* str = nullptr)
:_size(strlen(str))
{
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str,str);
}
strlen(nullptr) 这会导致程序崩溃,strlen的参数必须指向合法的C字符串(以 \0
结尾)
正确写法:
string(const char* str = "") //也可以"\0"
:_size(strlen(str))
{
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str,str);
}
2.2 析构函数
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
3. string遍历
3.1 size()、length() 和 operator[ ]
在成员函数中进行实现
//遍历
size_t size() const
{
return _size;
}
size_t length() const
{
return _size;
}
//返回pos位置的字符 并且要可读可写
char& operator[](size_t pos) //char& 返回引用 说明,
{
//断言检查有没有越界
assert(pos < _size); //开销不大,因为会变成内联函数
return _str[pos];
//_str[pos]访问的是堆上面的空间,出了作用域还在,就说明可以使用引用返回
// 返回值类型是引用,返回的不是拷贝,返回的是别名,就说明可读可写
}
传统的c语言的越界检查是一种给抽查(结束位置进行,看变量有没有被修改) 而C++函数封装的时候,可以加上越界判断,使用assert(#include<assert.h>) |
3.2 迭代器遍历
迭代器是像指针一样,有可能是指针,也有可能不是指针,这里借助指针。
//成员函数
//指针版本的迭代器
typedef char* iterator;
typedef const char* const_iterator;
//普通对象版本
iterator begin()
{
return _str;
}
iterator end()
{
return _str + _size;
}
//const对象版本
const_iterator begin() const //第二个const是防止修改_str
{
return _str;
}
const_iterator end() const
{
return _str + _size;
}
3.3 遍历测试
//测试访问
void test_string1()
{
//string s1;
//cout << s1.c_str() << endl;
//测试遍历
string s1 = "hello world";
for (size_t i = 0; i < s1.size(); i++)
{
cout << s1[i] << " ";
}
cout << "\n";
for (size_t i = 0; i < s1.size(); i++)
{
cout << ++s1[i] << " ";
}
cout << "\n";
//普通对象迭代器遍历+修改
string::iterator it1 = s1.begin();
while (it1 != s1.end())
{
cout << *it1 << " ";
it1++;
}
cout << endl;
it1 = s1.begin();
while (it1 != s1.end())
{
cout << ++(*it1) << " ";
it1++;
}
cout << endl;
//const对象迭代器遍历
const string s2("xxxx");
string::const_iterator cit2 = s2.begin();
while (cit2 != s2.end())
{
cout <<*cit2<< " ";
++cit2;
}
cout << "\n";
//范围for,底层是替换成迭代器,从编译器角度只增加一个替换规则
for (auto e : s1)
{
cout << e << " ";
}
cout << '\n';
//范围for根据对象类型 对应
//在这里发现 当s是普通对象的时候会替换成普通迭代器,是const对象的时候就会替换成const迭代器
const string s("xxxxx");
for (auto e : s)
{
cout << e << endl;
}
}
测试结果;
4. string的增删改查
在实现下方函数时,因为增加数据要开空间,所以先实现reserve。
//预分配好内存
void reserve(size_t n)
{
//当n > _capacity 需要单独开空间
if (n > _capacity)
{
//开新空间
char* tmp = new char[n + 1];
//拷贝数据
strcpy(tmp,_str);
//释放旧空间
delete[] _str;
//修改指针指向
_str = tmp;
//修改容量
_capacity = n;
}
}
4.1 insert
这里模拟实现在pos位置插入字符串。
//在pos位置插入单个字符
void insert(size_t pos , char ch)
{
//首先判断位置是否合法
assert(pos <= _size); //这里pos == _size 相当于尾插
//判断是否需要扩容
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 :2* _capacity);
}
//移动数据
//先移动_size位置的'\0',之后在向前移动,直到移动完pos位置
for (size_t i = 0; i != _size - pos + 1; i++)
{
_str[_size + 1 - i] = _str[_size - i];
}
//插入数据
_str[pos] = ch;
//调整大小
++_size;
}
//在pos位置插入字符串
void insert(size_t pos , const char* str)
{
//先判断位置是否合法
assert(pos <= _size);
//判断是否需要进行扩容
size_t len = strlen(str);
if (len + _size >_capacity)
{
reserve(len + _size);
}
//移动数据
// 错误例子
//size_t end = _size;
//while (end >= pos)
//{
// _str[end+len] = _str[end];
// --end;
//}
//正确写法
size_t end = _size + 1;
while (end > pos)
{
_str[end-1 + len] = _str[end-1];
--end;
}
//拷贝数据
strncpy(_str+pos,str,len);
_size += len;
}
上述在移动插入单个字符的实现移动数据实现上 通过移动的数据个数来控制,不会出现头插的bug,而在 插入在字符串移动数据使用end指针来控制的时候,需要格外关注头插问题。
4.2 push_back 和 append
push_back 和 append都是在字符串的末尾进行追加。
//追加单个字符
void push_back(char ch)
{
//尾插,首先要判断是否需要扩容
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : 2*_capacity); //这样写是为了避免_capacity == 0的情况
}
//追加数据
_str[_size] = ch;
++_size;
_str[_size] = '\0';
}
//追加字符串
void append(const char* str)
{
size_t len = strlen(str);
if (len + _size > _capacity)
{
reserve(len+_size);
}
//拷贝数据
strcpy(_str+_size,str);
_size += len;
}
优化:当然也可以借助insert函数来实现
void push_back(char ch)
{
insert(_size,ch);
}
void append(const char* str)
{
insert(_size,str);
}
4.3 operator+=
对于增加字符串,operator+=重载是经常用的。
string& operator+=(char ch)
{
//这里可以直接调用push_back
push_back(ch);
return *this;
}
string& operator+=(const char* str)
{
//直接调用append
append(str);
return *this;
}
4.4 erase
这里简单实现一下erase常用的函数,在pos位置删除len个字符。
这里的nops是一个共有的静态const成员变量,属于整个类,属于每个对象 ,类内声明,类外初始化。
注意在实现erase函数的时候要注意参数
如果 len = npos 或者 pos+ len >= _size的时候,直接在pos位置加 \0,否则直接删除部分串即可。
但是 pos + len 会有溢出风险,len = npos的时候,就会出现的。
所以最好改写成 _size - pos <= len 。
//在pos位置删除len个字符
void erase(size_t pos , size_t len = npos)
{
//判读删除位置是否合法
assert(pos < _size);
//pos位置后面全部删除
if (len == npos || _size - pos <= len )
{
_str[pos] = '\0';
_size = pos;
}
else
{
//部分删除
strcpy(_str+pos,_str+pos+len);
_size -= len;
}
}
4.5 resize
调整字符长度,需要注意的就是调整小空间和调大的情况
- 调小:直接在字符串 n 的位置给 \0 表示该串结束
- 调大:先扩容,再进行填充字符ch,注意最后n位置要加\0表示结束
void resize(size_t n,char ch = '\0')
{
//调小的情况
if (n < _size)
{
_str[n] = '\0';
}
else
{
//调大的情况
//先扩容,再在原串的末尾填充ch
reserve(n);
for (size_t i = _size; i < n;i++)
{
_str[i] = ch;
}
_str[n] = '\0';
}
_size = n; //注意修改
}
4.6 swap
swap具体有三种,算法库里面的swap,手动模拟实现的swap,string里面重载的swap。
标准库里面的swap:
//库里面的swap对象交换的底层是
/*
template<class T> void swap(T&a,T&b)
{
T c(a);a = b; b = c;
}
这里的代价就是:三次拷贝+一次析构
*/
这使用整个模板有一定的代价,三次拷贝+一次析构。
手动实现的swap:
为了避免上述的代价,最简单的方式就是把对象里面的成员进行交换。
void swap(string & s)
{
//命名空间展开
//using namespace std; 先去局部找,在类中和函数找,没有找到就去全局找
//一般不会直接去命名空间找(除非 命名空间展开)
//编译器只会向上找 , 先局部-> 全局,如果命名空间展开就是命名空间找
std::swap(_str,s._str);
std::swap(_size,s._size);
std::swap(_capacity,s._capacity);
}
有什么方式避免误用swap?
我们发现string里面 重载了一个 void swap(string& x,string&y){x.swap(y)}
有现成就用现成,没有就去调用模板的。
4.7 find
实现 查找在pos位置出现的字符或者字符串
- 查找字符:先判断pos位置是否合法,然后从pos位置开始查找,找到返回下标,找不到返回npos。
- 查找字符串:先判断pos位置是否合法,然后从pos位置开始查找( 这里使用strstr()函数进行查找。strstr( ) 找不到就会返回空,找到了就会返回指向原字符串中第一次出现的子串的指针找到。)找到返回下标(这里返回的下标通过 两个指针来实现),找不到返回npos。
//find ch
size_t find(char ch,size_t pos = 0) const
{
//判断位置是否合法
assert(pos < _size);
//查找字符,从pos位置开始查找
for (size_t i = pos; i < _size; i++)
{
if (_str[i] == ch)
{
return i;
}
}
return npos;
}
//find substr
size_t find(const char* substr, size_t pos = 0) const
{
//判断位置是否合法
assert(pos < _size);
//查找字串
const char* p = strstr(_str+pos,substr);
if (p)
{
//不为空就返回下标
//指针-指针返回的就是两指针之间的距离,也就是下标
return p - _str;
}
else
{
return npos;
}
}
4.8 substr
获取部分字串,根据标准库来模拟实现。也就是获取从pos位置后且长度为len的字串。
先判断pos位置是否合法。
当 len 为npos的时候或者 pos+len >=_size的时候,获取从pos位置开始所有字符。
否则获取 pos开始长度为len的字符串。
//获取部分字串
string substr(size_t pos = 0,size_t len = npos) const
{
string sub;
assert(pos < _size);
if (len == npos || _size - len <= pos)
{
获取pos位置后的全部
for (size_t i = pos; i < _size; i++)
{
sub += _str[i];
}
}
else
{
//获取从pos位置开始的len个
for (size_t i = pos; i < pos + len;i++)
{
sub += _str[i];
}
}
return sub;
}
优化的地方:可以去复用append函数
5. 深浅拷贝问题
拷贝构造函数
在没有手动实现拷贝构造函数的情况下,进行拷贝构造,发现程序出现问题,从调试可以发现问题:
- 在没有写拷贝构造的情况下,编译器会自动默认生成的拷贝构造是浅拷贝。
- 浅拷贝会指向相同的空间
- 而且会析构同一空间两次。
- 当s1修改的时候,发现s也被修改了。
所以编译器生成的拷贝构造函数不合适,所以需要手动生成一个拷贝构造函数。
采用深拷贝的方式
//拷贝构造函数
//s1(s)
string(const string& s)
{
//深拷贝
//另开一个空间,再把数据拷贝过来
_str = new char[s._capacity + 1];
strcpy(_str,s._str);
_size = s._size;
_capacity = s._capacity;
}
再次调试验证一下:
拷贝的对象不再指向相同空间了。
赋值运算符重载
当我们按照上图的方式赋值的时候,发现也会出现浅拷贝的问题。
所以需要手动实现赋值运算重载。
传统写法版本
//赋值运算符重载
//如果不手动实现就会出现浅拷贝的问题
//传统写法
string& operator=(const string & s)
{
//开新空间
char* tmp = new char[s._capacity + 1];
//拷贝数据
strcpy(tmp,s._str);
//释放旧空间
delete[] _str;
//指向tmp
_str = tmp;
_size = s._size;
_capacity = s._capacity;
return *this;
}
现代写法
//拷贝构造函数的现代写法
string(const string& s)
{
string tmp(s._str);
swap(tmp);
}
6. 比较关系运算符重载
6.1 operator==
一般会直接在类中来实现该成员函数
bool operator==(const string & s)
{
int ret = strcmp(_str,s._str);//这里借助strcmp函数来实现
return ret == 0;
}
但是在比较的时候会出现问题:成员函数的左端必须是对象才可以。
解决方法:可以重载为 全局的关系运算符。
bool operator==(const string& s1,const string& s2)
{
int ret = strcmp(s1.c_str(), s2.c_str());
return ret == 0;
}
6.2 operator<
这里借助strcmp函数来实现,strcmp(str1,str2) 当str1 < str2 的时候,会返回一个小于0的值。
bool operator<(const string& s1,const string& s2)
{
int ret = strcmp(s1.c_str(),s2.c_str());
return ret < 0;
}
6.3 其他关系运算符重载
可以复用上述的关系运算符,来实现其他关系运算符。
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,string& s2)
{
return !(s1 == s2);
}
7. 流插入、流提取和getline
7.1 流插入
当直接给对象输入字符串的时候和直接用对象输出字符串的时候,发现程序报错。需要我们手动来模拟实现。这里使用标准输出流类来实现。
//流插入必须是全局的
ostream& operator<<(ostream& out , const string& s)
{
//使用范围for拿到字符,在使用out来输出
for (auto ch : s)
{
out << ch;
}
return out; //这里返回希望连续cout<< s << s1...
}
发现这里没有使用友元,因为这里只是打印字符,不需要去访问私有成员,打印字符可以使用operator[ ]、范围for等来访问 。
所以,运算符流插入的重载 不一定 必须为是全局函数且为类的友元函数。
7.2 流提取
这里对一个对象输入一个新的字符串。
需要注意的是先提前清空缓冲区,或者提前清空原来的串,也就是新的字符串覆盖原来的串,这样才可以保证打印出来的是新的串,这里通过复用模拟实现的clear来实现。
void clear()
{
//内容清空,空间不变
_size = 0;
_str[_size] = '\0';
}
标准库中的cin提取不到空格和换行,但是这里string中的流提取需要读到。这里使用istream类中的重载函数get。
//流提取
istream& operator>>(istream& in , string& s)
{
s.clear();
//把提取的字符放到s里面去
char ch;
ch = in.get();
while (ch != ' ' && ch != '\n')
{
s += ch;
ch = in.get();
}
return in;
}
问题:
当输入比较长的字符串的时候,上述代码会频繁的进行扩容,字符换越长,扩容次数就越多,就会导致一系列的消耗。
如果使用reserve,虽然可以减少扩容,但是不确定要开多少的内存
上述如果给了128,但是提取字符串较少的时候,内存空间就开大了,有些浪费。
优化:
s.clear()
//为避免频发库容 和 reserve的不合适
char ch;
ch = in.get();
char buff[128];
size_t i = 0;
while (ch != ' ' && ch != '\n')
{
buff[i++] = ch;
if (i == 127)
{
buff[127] = '\0';
s += buff;
i = 0;
}
ch = in.get();
}
if (i > 0)
{
buff[i] = '\0';
s += buff;
}
return in;
所以这里没有把提取的数据直接用string += 而是先放到一个buff数组里面,如果字符串比较短,例如3个字符遇到空格或者换行就结束,string对象直接+=buff 就可以。
如果提取的字符串大 _capacity就大,值得注意的是,字符拆较大的时候,借助buff一段一段加。
7.3 getline
getline是获取一行的字符串,包括中间的空格。这里只需要把流提取的 ch != ‘ ’ 去掉就可以。
istream& getline(istream& in,string& s)
{
s.clear();
char ch;
ch = in.get();
while (ch != '\n')
{
s += ch;
ch = in.get();
}
return in;
}
getline同样也可以优化
istream& getline(istream& in,string& s)
{
//优化
//避免频繁扩容 和 reserve的不合适
s.clear();
char ch;
char buff[128];
ch = in.get();
size_t i = 0;
while (ch != '\n')
{
buff[i++] = ch;
if (i == 127)
{
s += buff;
i = 0;
}
ch = in.get();
}
if (i > 0)
{
buff[i] = '\0';
s += buff;
}
return in;
}
8. 源码
详细源码 请猛戳 此处