经过诸多等待,我们也是终于来到了string类的模拟实现环节,接下来就跟着我一起学习,了解string的底层实现吧!
由于我们水平并未那么高,所以我们并不会去实现basic_string,那样的话会有很多问题无法理解,毕竟我们只是了解,而不是创建一个更加好的
string.h
基本构造
//创建命名空间bit是为了隔绝std,避免命名冲突,并且封装性更好
namespace bit {
class string
{
public :
string()//无参构造函数
:_str(nullptr)
,_size(0)
,_capacity(0)
{}
string(const char* str)//带参构造函数
:_str(new char[strlen(str) + 1])
, _size(strlen(str))
, _capacity(strlen(str))
{
strcpy(_str, str);
}
//拷贝构造(深拷贝)
//传统写法,s1和s2的空间不在同一个地方
//就相当于我们自己做一碗红烧牛肉面
string(const string& s) {
_str = new char[s._capacity + 1];
strcpy(_str, s._str);
_size = s._size;
_capacity = s._capacity;
}
private:
char* _str;//字符串首字符地址
size_t _size;//有效字符个数(不包含\0)
size_t _capacity;//空间大小,需要比size至少多开一个,因为要容纳\0,但是capacity理论上与size相等
};
}
以上的带参构造有很大的问题,大家知道为什么吗?
因为中间strlen调用了三次,而要用strlen获得字符串长度,每次都需要遍历字符串,这是极其浪费效率的;有些同学会耍小聪明,把capacity的strlen改成_size,但是这里是因为我们的定义顺序和初始化顺序刚好一样,假如我们把size和capacity的位置互换,因为初始化顺序就是类中成员变量定义的顺序,那么就会出现这个时候明明size还没有初始化,把一个随机数给了capacity初始化,这就也会出现错误,所以,为了避免这些问题,我们如下所示:
//string()
// :_str(nullptr)
// ,_size(0)
// ,_capacity(0)
//{}
string(const char* str = "")
:_size(strlen(str))
{
_capacity = _size;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
我们一般建议全缺省,这样的话无论不放参数还是放参数都可以使用,比我们先前再创建一个无参数构造好;而且我们会发现,我们注释掉的无参数和全缺省参数是不一样的,一个_str=nullptr,一个则是"“,这又是为什么呢?nullptr不仅会让strlen无用,在后续返回c_str的时候也会出现问题(因为c_str返回的就是_str,是指向字符串的指针,而nullptr是无法使用的),而且我们这里为什么给的是个空串,而不是”\0"呢,这是因为const char* str常量字符串本来就是有"\0"在最后面的,如果我们给的不是空串,就重复填入了一个\0,这样的话是无意义的,所以给一个空串是刚刚好的
如果不是全缺省可以这么写:
但这只是一个参考,我们一般的官方写法都是全缺省
析构
~string() {
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
c_str()、size()、capacity()
const char* c_str() {
return _str;
}
size_t size() const{
return _size;
}
//因为其不会改变成员变量,所以后面加上const
size_t capacity() const{
return _capacity;
}
operator[]
//大家可能会觉得我们一直调用这个函数会造成负担,但实际上我们知道类里面调用的函数就是内联函数
//而内联函数可以提升函数使用的效率(inline)
char& operator[](size_t pos) {
assert(pos < _size);//因为pos本来就大于0,所以只需要判断其小于size
//这里用assert断言可以避免数组越界的问题
//因为C语言的数组查越界只是抽查
//就像我们查酒驾,你不一定会查到,但是如果车上设置一个程序,直接探测坐驾驶位的人有没有喝酒,就可以完全避免
return _str[pos];//这里可以用_str[pos]的值是因为其是在堆上开辟的空间,不会在这里被销毁
//返回引用的话可以提高效率,不怕多次创建对象
}
//可读不可写(重载)
const char& operator[](size_t pos) const{
assert(pos < _size);
return _str[pos];
}
迭代器(iterator)和范围for
typedef char* iterator;//这里我们直接用指针版本
iterator begin() {
return _str;
}
iterator end() {
return _str + _size;
}
我们会发现写了iterator之后,我们直接按照规范写范围for便可以直接运行,这是因为范围for的底层相当于就是迭代器,只是进行了一个替换,但是需要注意的是:迭代器的名字一定要叫iterator,不然的话,范围for就无法成功替换
这里补充一些东西:我们知道每个容器都有各自的迭代器,它们都有统一的名字叫做iterator,那么为什么各种iterator不会冲突呢?一个是因为typedef将所有的迭代器都命名为iterator,还有一个则是因为我们并未定义在全局域中,而是定义在每个类域中,在使用时在前面包含类域名即可。
范围for是从python拿过来的(以前C++有个for_each是想达成这种目的,但是没范围for好用)
typedef const char* const_iterator;//也需要有const版本的iterator
const_iterator begin() const{
return _str;
}
const_iterator end() const{
return _str + _size;
}
这里也别忘了还有const类型的iterator
增删查改
push_back与append
void push_back(char ch) {
//扩容2倍
if (_size == _capacity) {
reserve(_capacity == 0 ? 4 : 2 * _capacity);//防止一开始capacity是0的情况
}
_str[_size] = ch;
_size++;
_str[_size] = '\0';
}
void append(const char* str) {
//append是不能扩容2倍的,不知道字符串多长
size_t len = strlen(str);
if (_size + len > _capacity) {
reserve(_size + len);
}
strcpy(_str + _size, str);//_str+_size就是末尾的地方,把str塞到后面
_size += len;//有效个数要加上
}
operator+=
//这个才是用的最多的
string& operator+=(char ch) {
push_back(ch);
return *this;
}
//重载一下
string& operator+=(const char* str) {
append(str);
return *this;
}
insert和erase
void insert(size_t pos,char ch) {
assert(pos <= _size);//==就是尾插
if (_size == _capacity) {
reserve(_capacity == 0 ? 4 : 2 * _capacity);//防止一开始capacity是0的情况
}
int end = _size;
while (end >= (int)pos) {
_str[end + 1] = _str[end];
end--;
}
_str[pos] = ch;
++_size;
}
//其实以上写法是经过改良之后的写法,我们一开始会把int都改成size_t
//而这样的话,当end==-1(头插)的时候就会因为size_t是>0的数从而导致end的值反而变为最大的正数
//而如果我们只把end改为int也不可以,因为那样的话会发生整型提升
//两边操作数类型不同会发生类型提升,一般是范围小的向范围大的提升
//这里则是有符号向无符号提升
//另一种更简单的方法
void insert(size_t pos, char ch) {
assert(pos <= _size);//==就是尾插
if (_size == _capacity) {
reserve(_capacity == 0 ? 4 : 2 * _capacity);//防止一开始capacity是0的情况
}
size_t end = _size;
while (end > pos) {
_str[end] = _str[end - 1];
end--;
}
_str[pos] = ch;
++_size;
}
void insert(size_t pos, const char* str) {
assert(pos <= _size);
size_t len = strlen(str);
if (_size + len > _capacity) {
reserve(_size + len);
}
size_t end = _size + len;
while (end >= pos + len)
{
_str[end] = _str[end - len];
end--;
}
//这里用strncpy的原因在于,如果我们直接用strcpy(_str+pos,str)
//就会出现我们用范围for输出字符串和c_str()的结果不一样
//因为strcpy会拷贝源字符串直到遇到空字符,并且包括空字符在内。
//这自然导致了我们c_str()遇到\0就停止输出,于是原本后面还有字符串的内容,却无法被输出了
//且一个字符串中含有多个\0也是一个问题
strncpy(_str + pos, str, len);
_size += len;
}
void erase(size_t pos, size_t len = npos) {
assert(pos < _size);//不可能删除\0
//如果是pos+len>=_size就会有溢出风险,左边数字容易过大
if (len == npos || len >= _size - pos) {
_str[pos] = '\0';
_size = pos;
}
else {
strcpy(_str + pos, _str + pos + len);
_size -= len;
}
}
写完insert之后,我们还可以更改append和push_back使其更简单
其他成员函数
resize
//直接把官方的合二为一,变为半缺省
void resize(size_t n, char ch = '\0') {
if (n <= _size) {
_str[n] = '\0';
_size = n;
}
else {
reserve(n);//不管大还是小,最好都reserve一下
for (size_t i = _size; i < n; i++) {
_str[i] = ch;
}
_str[n] = '\0';
_size = n;
}
}
swap
由于库里的swap使用代价太大,所以我们建议自己造一个
并且库里面的swap的话,原来的旧空间甚至都还会变化,并不只是两个交换(深拷贝的原因,要创建新空间)
void swap(string& s) {
//加上std是因为我们指定了std里面找,如果这里不进std的话,就会直接进bit找,会说参数类型不匹配
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
find
//找一个字符
size_t find(char ch, size_t pos = 0) const {
assert(pos < _size);
for (size_t i = pos; i < _size; i++) {
if (_str[i] == ch) {
return i;
}
}
return npos;
}
//找一个子串
size_t find(const char* sub, size_t pos = 0) const {
assert(pos < _size);
const char* p = strstr(_str, sub);//每次默认从头开始搜索,暴力匹配
if (p) {
return p - _str;
}
else {
return npos;
}
}
substr
string substr(size_t pos = 0, size_t len = npos) const{
string sub;
if (len == npos || len >= _size - pos) {
for (size_t i = pos; i <= _size; i++) {
sub += _str[i];
}
}
else {
for (size_t i = pos; i < pos + len; i++) {
sub += _str[i];
}
}
return sub;//sub的拷贝返回后sub本体即被销毁,所以返回string
}
relational operators
//重载成全局的原因主要还是因为要让常量字符串和string作比较(不管两个的传入参数顺序如何)
//我们不用像string里面写三个函数,因为我们传入的常量字符串会发生隐式类型转换
//就比如string s1="hello world",就是编译器会创建一个 string 对象,并使用字符串字面值的内容初始化该对象(拷贝构造的感觉)
bool operator==(const string& str1, const string& str2) {
int ret = strcmp(str1.c_str(), str2.c_str());
return ret == 0;
}
bool operator<(const string& str1, const string& str2) {
int ret = strcmp(str1.c_str(), str2.c_str());
return ret < 0;
}
bool operator>=(const string& str1, const string& str2) {
return !(str1 < str2);
}
bool operator<=(const string& str1, const string& str2) {
return str1 == str2 || str1 < str2;
}
bool operator>(const string& str1, const string& str2) {
return !(str1 <= str2);
}
bool operator!=(const string& str1, const string& str2) {
return !(str1 == str2);//前面的代码可以复用的
}
赋值
//相当于我自己买东西做红烧牛肉面,做完了还要自己洗锅
string& operator=(const string& s) {
char* tmp = new char[s._capacity + 1];
strcpy(tmp, s._str);
delete[] _str;
_str = tmp;
_size = s._size;
_capacity = s._capacity;
return *this;
}
流插入和流提取
提供流插入和流提取的原因很简单,我们在C++的库里已经为内置类型提供了所有的输出函数,但是自定义类型是无法输出的,C语言的printf输出时需要有%d这样的格式控制字符,但是不可能每个自定义类型都有一个格式控制字符,所以创造了C++的流插入,可以自己定义
//流插入
//为了支持连续输出,所以必须返回osteam&对象类型
//为什么现在我们不需要像日期类一样使用友元,因为我们不再直接使用私密成员变量
ostream& operator<<(ostream& out, const string& s) {
for (auto ch : s) {
out << ch;
}
return out;
}
//流提取
istream& operator>>(istream& in, string& s){
s.clear();//这里是为了清除原本字符串里的内容,因为cin是覆盖内容
char ch;
//in >> ch;
//这里我们不能用in>>ch,不然的话会死循环
//因为拿不到空格和回车,只会认为是值的分割
//C语言我们会用getchar(),而C++用in.get()方法
ch = in.get();
s.reserve(128);//这个很重要,不然运行会说堆出错
while (ch != '\n' && ch != ' ')
{
s += ch;
ch = in.get();
}
return in;
}
但其实我们这个也还没有完全正确,我们直接用reverse(128),如果我们输入的少,那么就会浪费一大块堆上的空间,而且是无法被释放的;但是如果我们用下列的方法,你就会发现十分巧妙
std::istream& operator>>(std::istream& in, string& s)
{
s.clear();
char ch;
//in >> ch;
ch = in.get();
char buff[128];
size_t i = 0;
while (ch != '\n' && ch != ' ')
{
buff[i++] = ch;
if (i == 127) {
buff[127] = '\0';
s += buff;
i = 0;
}
ch = in.get();
//如果没有等于127,就会只有以下代码
//buff[i++]=ch;
//ch = in.get();
//就只是往数组里面塞输入的内容,然后继续输入
//如果i超过127,那么又会重新计数,并把先前的内容塞进字符串
//扩容不频繁,很好
}
if (i > 0) {
buff[i] = '\0';
s += buff;
}
//如果i没有超过128,那么到后面就是在数组的最后一个位置塞\0
//然后最后把所有的数组内容插入进去字符串
return in;
}
getline(与>>类似)
std::istream& getline(std::istream& in, string& s)
{
s.clear();
char ch;
//in >> ch;
ch = in.get();
char buff[128];
size_t i = 0;
while (ch != '\n')
{
buff[i++] = ch;
// [0,126]
if (i == 127)
{
buff[127] = '\0';
s += buff;
i = 0;
}
ch = in.get();
}
if (i > 0)
{
buff[i] = '\0';
s += buff;
}
return in;
}
赋值和拷贝构造现代写法
//赋值现代写法
//跟拷贝构造差不多
//这里就相当于我让你给我做红烧牛肉面,做完了之后厨房还留给你收
//因为tmp是局部对象
string& operator=(const string& s) {
string tmp(s);
swap(tmp);
return *this;
}
//拷贝构造(现代写法)
//我们还要把this指针指向的字符串初始化一下
//不然会是随机数(_str=nullptr;_size=0;_capacity=0)
//相当于找个别人做红烧牛肉面(跟上面传统方式相比)
//假他人之手
string(const string& s) {
string tmp(s._str);//用的是上面带有缺省值的构造(只能调用构造)
swap(tmp);
}
//拷贝构造函数通过创建一个临时字符串对象,并将其内容与原始字符串对象的内容进行交换
//从而实现了拷贝构造的目的。通过使用 swap,可以避免不必要的内存分配和释放,提高代码性能和效率。
这里可以用图看一下:
(拷贝构造)上面是传统,下面是现代
我们一开始初始化s2的原因在于,如果在析构函数中访问已经释放的或未初始化的内存,就可能导致出现问题,可能表现为读取随机值、崩溃或其他不可预测的行为。
需要注意的是,这里没有效率高低的说法,传统写法开空间拷贝数据,现代写法也开空间拷贝数据,两者只是方法不同,结果都一样
更简单的赋值写法
//本质跟上面没什么区别
//去掉const,变成传值传参,调用拷贝构造
//要记住拷贝构造不传值的原因是因为拷贝构造调拷贝构造会死循环
string& operator=(string s) {
swap(s);
return *this;
}
其实这里我们总结一下我们为什么要写现代写法,究其原因在于其是可以复用的,如果是传统写法,那么每次写的方法都是不一样的,并且需要我们琢磨其过程,是没有现代写法方便效率的
这里还做一个补充,我们发现用sizeof(string)的变量的时候,其大小是28,但明明我们按照成员变量来算的话,是只有12的,这又是为什么呢?我们观察底层,发现其实string的底层是这样的
这个buff数组,占据了16个字节的空间,其作用是什么呢?是我们的字符串如果小于16,就会存进buff里面;如果超过16,就会存进_str里面,但是buff存入的数据不会销毁,是一种以空间换时间的方法。因为我们的string类其实一般存入的字符都比较小,如果在堆上开空间的话,效率是没有栈上好的,所以在字符串较小的情况下,我们可以选择存入buff,提高效率;而超过16,也不会有任何影响