今天带大家来手搓简单的 string 库了,顺便一起了解它的底层逻辑,有利于后面STL的学习
目录
[2.string类的 默认成员函数](#2.string类的 默认成员函数)
[2.4赋值运算符重载 结合 swap 函数 (现代写法)](#2.4赋值运算符重载 结合 swap 函数 (现代写法))
[3.1 C风格字符串 c_str](#3.1 C风格字符串 c_str)
[3.2 清空数据 clear](#3.2 清空数据 clear)
[3.3 访问_size和_capacity的接口](#3.3 访问_size和_capacity的接口)
[3.4operator[ ]](#3.4operator[ ])
[3.5判空 empty](#3.5判空 empty)
[3.6迭代器 iterator](#3.6迭代器 iterator)
[4.1 operator==](#4.1 operator==)
[4.2 operator<](#4.2 operator<)
[5.扩容 reserve](#5.扩容 reserve)
[6.push_back和append ,operator+= 和 operator+](#6.push_back和append ,operator+= 和 operator+)
[6.3 operator+=和operator+](#6.3 operator+=和operator+)
[11.getline 和 operator>>,operator<<](#11.getline 和 operator>>,operator<<)
[11.3 getline](#11.3 getline)
1.简单实现string的头文件
我们用 .h 和 .cpp 文件分离的方式书写,我先给出它的类:
#pragma once
#include<string.h>
#include<assert.h>
#include<iostream>
#include<algorithm>
using namespace std;
namespace bit
{
class string
{
public:
//static不能类内定义,但是static const 可以。
static const size_t npos = -1;
private:
char* _str;
size_t _capacity;
size_t _size;
};
}
string的字符串和C语言的类似,底层也有用指针实现 ,并且调试观察标准库的string可以发现,它还有size和capacity,表示长度和空间 。我们的目的是实现简单功能,以了解它的底层逻辑,那就先用这三个作为私有成员。还有一个npos,用于很多string函数中,提前声明定义一下。
留意一下注释。
命名空间 bit 是为了和标准库的string作区分,也可以给我们的string类改个名字,避免冲突。
2.string类的 默认成员函数
2.1构造函数
我们来实现string的构造函数.
string(const char* str = "");
思路:获取字符串str的长度(strlen获取,但是这样不包括'\0'的长度),然后给_str开辟它长度+1,(为了包含'\0') ,同样给_capacity初始化它的长度,最后用strcpy把str的内容拷贝到_str内
string::string(const char* str)
:_size(strlen(str))
{
_str = new char[_size + 1];
_capacity = _size;
strcpy(_str, str);
}
初始化列表只初始化_size的原因与私有成员声明的顺序有关,不方便全在初始化列表**。会先初始化_str,这就会导致strlen函数会重复调用多次(因为strlen不能先初始化给_size,导致_str只能先自己调用strlen),效率较低。也不方便修改私有成员,随意修改可能导致初始化顺序出错。**
2.2拷贝构造函数
string(const string& s);
思路:和构造函数一样,只不过有一点改变:这次是string对象的别名,需要调用他底层的私有成员来拷贝,那么需要调整一下strcpy部分,并且不需要strlen,只需要调用底层的_size,_capacity彻底修改底层三个私有成员就行
string::string(const string& s)
:_size(s._size)
{
_str = new char[_size + 1];
strcpy(_str, s._str);
_capacity = s._capacity;
}
2.2.1现代写法
swap函数,2.4有介绍。
string::string(const string& s)
{
string tmp(s._str);
swap(tmp);
}
调用构造函数构造一个和s_str构造一样大的空间,一样的值,而这个刚好就是 *this 指针的需求,于是直接用swap调换*this和tmp的资源,这样就完成了拷贝构造。本质上效率没有提升,只是提供了一个新颖的思路,代码更简洁。本质是代码复用。
2.3析构函数
~string();
思路:析构函数,用delete销毁_str,并且置空就行,_size 和_capacity不需要修改,因为对象销毁了它们没意义了,也没申请资源。 注意:_str本质上是指向一片连续空间(字符串),所以要用delete[ ]
string::~string()
{
delete[] _str;
_str = nullptr;
//_size = 0; //可有可无,写了不会错,更直观。
//_capacity = 0;
}
2.4赋值运算符重载 结合 swap 函数 (现代写法)
string& operator=(const string& s);
思路:和拷贝构造函数的区别就是,赋值运算符重载针对的是已存在的对象,那思路基本一致,
但需要注意:赋值不是初始化,两边都申请了资源,资源有大小之分,大的赋值给小的,就需要小的开空间,不然就会越界写内存
string& string::operator=(const string& s)
{
//排除自己赋值自己的清空,少做无用功
if (this != &s)
{
string tmp(s);
swap(tmp);
}
return *this;
}
所以,std : : swap函数就很好的解决了这一点:
void string::swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
我们的swap函数通过调用了标准库的swap函数,实现了资源的交换,不需要我们自己麻烦地比较资源大小和交换。 其实这个函数,以前效率十分低下,需要进行三次深拷贝,代价极大。但有了移动构造和右值引用,并且编译器各种超前优化后,效率还很可观,以后再介绍
2.4.1更简洁的现代写法
string& string::operator=(const string tmp)
{
swap(tmp);
return *this;
}

s1 = s3; tmp在传参的过程中就调用拷贝构造函数,获取了和s3一样的资源 ,这招太狠了。现代写法更注重简洁,直接去除了自我赋值的判断,毕竟很少出现。
3.各种逻辑简单的小函数
3.1 C风格字符串 c_str
//不复杂,不分离
const char* c_str() const
{
return _str;
}
C风格字符串,而string底层就是C类型字符串**(一片连续数组空间,并且以'\0'结尾)**
直接返回_str
3.2 清空数据 clear
void clear()
{
_str[0] = '\0';
_size = 0;
}
清空数据,直接在0位置给终止符,并且长度设置为0就行,但请注意不需要清理空间!
3.3 访问_size和_capacity的接口
size_t size() const
{
return _size;
}
size_t capacity() const
{
return _capacity;
}
直接返回就行。
3.4operator[ ]
char& operator[](size_t index)
{
assert(index < _size);
return _str[index];
}
const char& operator[](size_t index) const
{
assert(index < _size);
return _str[index];
}
库中重载了两个版本,无非就是 const修饰this指针 的区别 ,逻辑很简单,返回_str对应下标的值就行。但需要注意断言检查,避免越界读写
3.5判空 empty
bool empty() const
{
return _str == "";
}
直接判断是否为空。
3.6迭代器 iterator
public:
typedef char* iterator;
typedef const char* const_iterator;
前面几篇提及过,迭代器是行为逻辑很像指针的东西,那我们直接用让指针 typedef 为 iterator
iterator begin()
{
// 返回首地址
return _str;
}
iterator end()
{
//返回last+1地址
return _str + _size;
}
const_iterator begin() const
{
// 返回首地址
return _str;
}
const_iterator end() const
{
//返回last+1地址
return _str + _size;
}
然后如图所示,直接返回。
4.常见比较类的运算符重载(<,=)
标准库中,这些是定义为全局函数的,我们就定义为成员函数,因为主要是为了了解底层
4.1 operator==
bool operator==(const string& s) const;
思路:比较是否相等,那就有两种情况,第一种,长度不同,直接false。第二种,长度相同,那就循环比较每一个字符,相同就true,否则false
bool string::operator==(const string& s)
{
//长度不同直接排除
if (_size != s._size)
{
return false;
}
//长度相同,比较内容,一旦不同,false,循环结束
size_t i = 0;
while (i < _size)
{
if (_str[i] != s._str[i])
{
return false;
}
i++;
}
return true;
}
也可以偷懒,直接用strcmp实现:
bool operator==(const string& s) const
{
int res = strcmp(_str, s._str);
if(res == 0)
return true;
return false;
}
4.2 operator<
bool operator<(const string& s) const;
思路:循环遍历两串字符(判断条件是两者的_size),比较每个数据的ASCII值,不相等就返回
_str[ i ]<s._str[ i ],循环结束有一种可能:其中一个字符串较短,那就返回_size<s._size
bool string::operator<(const string& s) const
{
size_t i = 0;
while (i < _size && i < s._size)
{
if (_str[i] != s._str[i])
{
return _str[i] < s._str[i];
}
i++;
}
//有一个较短,出了循环
return _size < s._size;
}
4.3其他的重载都可以用这两个复现
bool operator>(const string& s) const
{
return !(*this <= s);
}
bool operator>=(const string& s) const
{
return !(*this < s);
}
bool operator!=(const string& s) const
{
return !(*this == s);
}
bool operator<=(const string& s) const
{
return *this < s || *this == s;
}
5.扩容 reserve
void reserve(size_t n);
思路:扩容,首先 n 大于需要扩容的对象才扩容,那就先开一个 n+1(预留 '\0' )的空间,把原字符串数据拷贝过去,然后释放旧资源。这时候,新空间就已经开好,只需让 _str 指向这片空间,再把_capacity修改成 n 就完成了扩容
void string::reserve(size_t n)
{
if (n > _capacity) //大才需要扩容,小于不需要扩容
{
char* tmp = new char[n + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
tmp 是局部指针,栈区,出作用域就销毁了,不需要释放。
6.push_back和append ,operator+= 和 operator+
6.1push_back
void push_back(char c);
思路:尾插,那就判断空间是否满了,_size==_capacity,满了就需要开空间,那我们就二倍扩容,扩容完把字符 c 插入(此时字符 c 占据了 \0 的位置,没有 \0 了),最后补上 \0
void string::push_back(char c)
{
//在字符串末尾+1字符c,需要检查空间是否足够,适当扩容
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4:_capacity * 2);
}
_str[_size] = c;
_size++;
_str[_size] = '\0';
}
6.2append
void append(const char* str);
思路:尾插字符串,那二倍扩容可能就不适用了,因为插入的可能远不止原字符串的二倍 ,那就换个扩容方案,选二倍扩容或者 _size+len 的长度之中较大的扩。然后strcpy拷贝字符串,最后_size+=len
void string::append(const char* str)
{
size_t len = strlen(str);
if (_size + len > _capacity)
{
//更好的扩容:如果我原来的两倍够插入就行,两倍不够你插入,就加多少扩多少
reserve(max(_size + len, _capacity * 2));
}
//从最后一个字符后开始插入字符串,strcpy会自动拷贝 \0 ,所以不用考虑这个
strcpy(_str + _size, str);
_size += len;
}
6.3 operator+=和operator+
这两个直接复用push_back 和 append 就行. 这两个的区别就是 operator+ 不能改变对象,所以需要返回局部变量tmp。
string& operator+=(const char* str)
{
append(str);
return *this;
}
string& operator+=(char c)
{
push_back(c);
return *this;
}
string string::operator+(const string& s)
{
// +的逻辑,复用+=就行,注意不要返回*this,只需要返回结果
string tmp(*this);
tmp += s._str;
return tmp;
}
7.查找find
// 返回c在string中第一次出现的位置
size_t find(char c, size_t pos = 0) const;
// 返回子串s在string中第一次出现的位置
size_t find(const char* s, size_t pos = 0) const;
find重载了两个版本,查找字符,查找子串。
7.1查找字符
查找字符:如果pos位置不在_size范围内,直接返回npos。然后从pos位置开始遍历字符串,如果找到相等,就返回位置,遍历结束没找到返回npos。
size_t string::find(char c, size_t pos) const
{
if (pos >= _size)
{
return npos;
}
for (; pos < _size; pos++)
{
if (_str[pos] == c)
{
return pos;
}
}
return npos;
}
7.2查找子串
查找子串:先断言检查pos位置是否合法。然后利用strstr函数,查找子串,找到了就返回位置,没找到返回npos。
size_t find(const char* s, size_t pos = 0) const
{
assert(pos < _size);
const char* ptr = strstr(_str+pos,s); //找子串函数strstr
if (ptr) //不为空,就是找到了
{
return ptr - _str; //找到了返回索引
}
else
{
return npos;
}
}
8.指定位置插入insert
//在pos位置上插入字符c/字符串str,并返回引用
string& insert(size_t pos, char c);
string& insert(size_t pos, const char* str);
一样,重载了两个版本,插入字符,插入字符串
8.1插入字符
插入字符:先检查pos位置的合法性,注意,_size+1处也能插入,所以是pos<=_size. 然后检查空间是否足够,不够就二倍扩容。
然后循环,从最后一个元素开始,到pos位置结束,把元素一一向后挪(最后一个元素会覆盖\0 ),然后插入字符,最后补上 \0
string& string::insert(size_t pos, char c)
{
assert(pos <= _size);
//检查空间是否足够插入,不足够二倍增容
if (_size == _capacity)
{
reserve(_capacity == 0 ? 4 : _capacity * 2);
}
int end = _size;
while (end >= (int)pos)
{
//一步一步实现整体往右挪动一位
_str[end + 1] = _str[end];
end--;
}
_str[pos] = c;
_size++;
_str[_size] = '\0';
return *this;
}
8.2插入字符串
插入字符串:和插入字符思路一样,整体向后挪动len个字符,也就是最后一个字符挪到_size+len位置,然后依次类推,一个一个往后挪,空出len长度 ,以便插入字符串。注意,不是二倍扩容了,因为字符串可能极长,因此扩容逻辑和append一样。
这两个函数,循环中都用到了强制类型转换 ,因为end>=0的逻辑,会使得end最终变-1.pos原本是size_t类型,表达式进行比较时,会将end转换为size_t类型,此时end为-1,size_t的- 1 就变成INT_MAX了,这时候循环就不会停止,无限循环直到内存溢出
9.截取子串substr
string substr(size_t pos = 0, size_t len = npos) const;
思路:同样,先断言检查pos位置合法性。注意到len的缺省值为npos,说明不传参数就是直接取完。同时,len长度过长(从pos位置开始的len已经大于字符串剩余长度),那就等同于直接取完,即len = npos。接下来,建造子串,将pos到len的每个数据,循环赋入子串,最后传值返回子串。
string string::substr(size_t pos, size_t len) const
{
assert(pos < _size);
if (len == npos || len > _size - pos)
{
//若pos + len 超过了后面的长度,或者len极大,那就去剩下的字符就行
len = _size - pos;
}
//异常len被修正,接下来正常操作,取出子串:1.建造子串,将pos到len的每个数据,循环赋入子串
string subStr;
subStr.reserve(len);
for (int i = 0; i < len; i++)
{
subStr += _str[pos + i];
}
return subStr;
}
10.erase
string& erase(size_t pos = 0, size_t len = npos);
思路:先断言检查pos位置是否合法,\0位置不能删,所以范围是(0,_size)。然后,判断pos开始剩余长度是否够删,若不够,则直接在pos位置加终止符\0,_size = pos; (相当于删完了) ,若够删,则利用strcpy把pos后len的位置覆盖到pos位置。并_size-=len
//删除pos位置上的元素,并返回该元素的下一个位置
string& erase(size_t pos = 0, size_t len = npos)
{
assert(pos < _size);
//在pos要删除的len大于或者等于_size,直接删除到结尾
if (pos + len >= _size)
{
_str[pos] = '\0';
_size=pos;
}
else
{
strcpy(_str+pos,_str+pos+len);
_size -= len;
}
return *this;
}
11.getline 和 operator>>,operator<<
三个全局函数。
11.1operator<<
friend ostream& operator<<(ostream& _cout, const bit::string& s);
遍历打印就行,很简单。
ostream& operator<<(ostream& _cout, const bit::string& s)
{
for (auto ch : s)
{
_cout << ch;
}
return _cout;
}
这里利用了范围for,也可以不用,怎么喜欢怎么来。
11.2operator>>
friend istream& operator>>(istream& _cin, bit::string& s);
思路:先clear清空字符串,避免了新输入追加到旧字符串末尾的问题。然后利用get()读取输入的下一个缓冲区字符,再开一个char数组,存放字符。然后while循环插入字符进数组,数组满了就插入进 s ,直到遇到换行符 或者 空格 (cin遇到换行符空格会停止读取)
istream& operator>>(istream& _cin, bit::string& s)
{
//为什么要清空?因为s本身可能还有字符串,比如"xxx",不清空,cin就会在他之后继续读取
//这就导致原本需要读取"hello",结果输出后变成了"xxxhello"
s.clear();
char ch;
char buff[256];
int i = 0;
ch = _cin.get();
//不能用_cin>>ch!
//因为cin的特性是会自动跳过空白和换行,只读取有效字符,所以进入循环的都是有效,无法出循环
//需要使用get,什么都会读取,让我们在循环中自己判断有效无效,这也刚好符合我们写这循环的意图。
while (ch != '\n' && ch != ' ')
{
buff[i] = ch;
i++;
if(i==255) //buff满了
{
buff[i] = '\0';
s += buff;
i = 0;
}
//s += ch; //用上面的逻辑更好,不用反复开空间,也不会浪费空间,因为是栈帧的,局部空间。
ch = _cin.get();
}
//i>0 ,说明buff数组没满就读取空格、换行出循环了,此时直接+=剩下的数,但注意先给buff终止符
if (i > 0)
{
buff[i] = '\0';
s += buff;
}
return _cin;
}
具体细节,注释都有解释。char数组的意义是避免+=频繁扩容。
11.3 getline
istream& getline(istream& _cin, string& s, char delim = '\n')
思路:区别就是,getline的终止符默认是换行符,遇到空格不会停止读取,并且可以自己设置终止符,所以我们直接复用operator>>
istream& getline(istream& _cin, string& s, char delim = '\n')
{
s.clear();
char ch;
char buff[256];
int i = 0;
ch = _cin.get();
while (ch != delim)
{
buff[i] = ch;
i++;
if(i==255)
{
buff[i] = '\0';
s += buff;
i = 0;
}
ch = _cin.get();
}
if (i > 0)
{
buff[i] = '\0';
s += buff;
}
return _cin;
}
没封装,所以代码重复率高了点,可以封装内部代码,再将while判断条件单独封装一个bool函数即可。
12.简单string的bug
我们实现的字符串,很多都是有bug的,字符串中可能有\0,比如 "hello\0world" ,C语言的函数遇到\0就会停止,所有会出问题。
解决办法就是比如:
//strcpy(_str,s._str)
memcpy(_str,s.str,s._size+1);
换成memcpy函数。为什么是s._size+1 ? 因为memcpy不像strcpy一样会自动拷贝\0 ,所以要多一位。
我们实现的string,是为了了解底层,能实现大部分功能就行了,毕竟上述情况还是极少出现的。