【C++】string的底层原理及实现

炸掉地球 2024-07-07 17:05:02 阅读 98

文章目录

string类的存储结构默认成员函数构造函数析构函数拷贝构造函数赋值重载

容量操作size()capacity()reserve()resize()clear()

遍历与访问operator[ ]迭代器范围与for

增删查改push_back()pop_back()append()operator+=insert()erase()c_str()find()substr()

非成员函数operator+关系运算符流插入<<和流提取>>getline()

本篇参考C++string类参考手册,实现一些string的常用接口,接口原型在该网站查阅。

string类的存储结构

string类的底层实际上是char类型的顺序表,所以结构上也比较相似。

<code>namespace lw

{

class string

{

public:

static const size_t npos;

private:

size_t _size;//有效数据个数

size_t _capacity;//可存储的容量

char* _str;//指向字符串起始位置的地址

};

const size_t string::npos = -1;

};

STL源码中,许多整型变量类型都是size_t无符号整型(没有负值);npos是string类常用到的一个值,有些函数的参数或者返回值是npos,并且这个值设为-1(表示232-1),参考标准库中的定义。

在这里插入图片描述

为什么string定义在一个命名空间中?

我们如果展开了标准库using namespace std; 那string默认使用的就是标准库中的。如果不展开标准库,那么所有的cin和cout都需要加上<code>std:: 比较繁琐。

用一个命名空间对我们自己实现的string类进行封装,可以方便我们随时测试代码,与库里的string类进行测试对比,只需要改::前面的作用域即可。

#include <iostream>

using namespace std;

int main()

{

string s("hello world");//默认标准库

std::string s("hello world");//标准库

lw::string s("hello world");//自己定义的类

cout << s << endl;

return 0;

}

string与C语言中的char类型字符串的一个较大的区别就是:char类型的字符串是以\0为结束符,遇到\0就停止;而string是以_size来判断是否结束,遇到\0并不会停止。 这点很重要,一定要弄清楚!


默认成员函数

常用的四个默认成员函数,一般情况我们不需要显示定义,使用编译成生成的即可;但string类涉及到资源申请,所以我们必须自己显示定义。

构造函数

官网的参考手册中给的string标准库有很多构造函数重载,我们只实现常用的两个构造。这两个我们可以合并成一个实现。

string();

string(const char* s);

缺省值不能给nullptr,一是因为strlen无法计算空指针,二是因为无参标准库默认给的就是空串。

这里strcpy和memcpy都可以,因为是对char类型字符串进行拷贝,拷贝str在\0之前的内容。用memcpy只是为了和后面的写法统一。

string(const char* str = "")

: _size(strlen(str))

, _capacity(strlen(str))

, _str(new char[strlen(str) + 1])//多一个空间给'\0'

{

//strcpy(_str, str);//strcpy也可以,只是为了与后面统一,换成memcpy

memcpy(_str, str, _size + 1);//'\0'也要拷贝

}

我们在new新空间时,每次多new一个空间给\0留位置。 memcpy在拷贝时,多拷贝一个字节把末尾\0也拷贝过去。


析构函数

~string()

{

delete[] _str;

_str = nullptr;

_size = _capacity = 0;

}


拷贝构造函数

深浅拷贝问题

编译器默认生成的是浅拷贝,就是只将s._str的指针地址拷贝给了新对象的_str,两个对象指向同一块地址! 那么就会出问题:

1.一块空间最后会析构两次,程序必然崩溃;

2.一个对象修改,另一个对象也会修改;

所以我们需要进行深拷贝,重新申请一块空间,把s._str指向地址的内容拷贝过来。

string(const string& s)

{

_str = new char[s.capacity() + 1];//多一个空间给'\0'

//strcpy(_str, s._str);//遇到'\0'终止,后面内容无法拷贝

memcpy(_str, s._str, s._size + 1);//末尾'\0'也拷贝过来

_size = s._size;

_capacity = s._capacity;

}

注意:这里不能用strcpy来拷贝数据,因为strcpy的拷贝结束条件是遇到\0终止,而string对象是可以存储\0的,strcpy不会将\0后面内容拷贝过来,所以要用memcpy或者memmove按照字节进行拷贝,多拷贝一个字节是将末尾的\0也拷贝过来。


赋值重载

与拷贝构造原理类似,但要先将申请的空间存放在临时变量里,防止空间开辟失败丢失原数据。原始空间别忘了释放!

string& operator=(const string& s)

{

if (this != &s)

{

char* tmp = new char[s._capacity + 1];

memcpy(tmp, s._str, s._size + 1);

delete[] _str;//释放原始空间

_str = tmp;

_size = s._size;

_capacity = s._capacity;

}

return *this;//支持连续赋值

}

第二种写法:我们可以利用库里的swap函数将两个对象_str指向的地址和数据个数、容量完全交换。顺便将swap接口也实现了。

void swap(string& s)

{

std::swap(_str, s._str);

std::swap(_size, s._size);

std::swap(_capacity, s._capacity);

}

//现代写法

string& operator=(string s)//不能传引用也不能加const

{

swap(s);

return *this;

}

注意:不能影响右操作数实参的值,所以右操作数传参只能以传值方式,且不能加const,否则不能交换改变。并且我们不需要进行自己给自己赋值的判断,因为s是实参的拷贝,一定与原对象地址不同。


容量操作

size()

size_t size() const

{

return _size;

}


capacity()

size_t capacity() const

{

return _capacity;

}


reserve()

只扩容不缩容,指定n大于原始容量则进行扩容,否则不进行操作。

拷贝时还是同样要注意\0问题,不能用strcpy。

void reserve(size_t n)

{

if (n > _capacity)

{

char* tmp = new char[n + 1];

//strcpy(tmp, _str);//错误,无法拷贝'\0'后面的内容

memcpy(tmp, _str, _size + 1);

delete[] _str;

_str = tmp;

_capacity = n;

}

}


resize()

resize()只改变有效数据个数,不会改变容量。

n > _size:缩小长度为n,多余内容删掉。

n < _szie:增加长度到n,剩下空间用给定的字符参数c填充,无参默认补充\0

void resize (size_t n);

void resize (size_t n, char c);

库里的resize()函数有两个版本,同样我们可以合成一个版本,第二个参数给缺省值\0即可。

void resize(size_t n, char ch = '\0')

{

assert(n >= 0);

if (n < _size)//缩小长度,后面删掉

{

_size = n;

_str[_size] = '\0';

}

else

{

reserve(n);//reserve会检查容量

for (size_t i = _size; i < n; i++)

{

_str[i] = ch;

}

_size = n;

_str[_size] = '\0';

}

}


clear()

void clear()

{

_size = 0;

_str[0] = '\0';

}


遍历与访问

operator[ ]

需要实现两个版本:普通对象调用[ ]可读可写,const对象调用[ ]只可读不可写。

char& operator[](size_t pos)//可读可写

{

assert(pos < _size);

return _str[pos];

}

const& char operator[](size_t pos) const//可写

{

assert(pos < _size);

return _str[pos];

}


迭代器范围与for

迭代器不一定是指针,而string的迭代器我们可以用指针来模拟实现。将指针重命名为迭代器。

typedef char* iterator;//普通迭代器 可读可写

iterator begin()

{

return _str;

}

iterator end()

{

return _str + _size;

}

typedef const char* const_iterator;//const迭代器 只可读

const_iterator begin() const

{

return _str;

}

const_iterator end() const

{

return _str + _size;

}

左闭右开区间,所以begin()返回字符串起始位置,end()返回末尾字符的下一个位置\0

范围for

实现迭代器后,我们就可以使用范围for了。范围for是C++11的新语法,它的底层是傻瓜式地替换成迭代器,支持迭代器就支持范围for。

int main()

{

lw::string s1("hello world");

lw::string::iterator it = s1.begin();

while (it != s1.end())

{

*it += 1;

cout << *it << ' ';

it++;

}

cout << endl;

for (auto ch : s1)

{

cout << ch << ' ';

}

cout << endl;

}


增删查改

push_back()

末尾要放\0

void push_back(char ch)

{

if (_size == _capacity)

{

reserve(_capacity == 0 ? 10 : _capacity * 2);

}

_str[_size++] = ch;

_str[_size] = '\0';

}

pop_back()

void pop_back()

{

assert(_size > 0);

_size--;

_str[_size] = '\0';

}


append()

append()在末尾追加字符串,空间不够则扩容,如果每次将_capacity扩2倍,一方面可能造成空间浪费,另一方面可能原始空间太小导致频繁扩容,所以我们最好提前计算好扩容的空间。

append的接口也有很多,下面给出最常用的两种。

string& append(const char* str)

{

size_t len = strlen(str);

if (_size + len > _capacity)

{

reserve(_size + len);

}

//strcpy(_str + _size, str);//strcat效率O(n+m) strcpy效率O(m)

memcpy(_str + _size, str, len + 1);//末尾的'\0'也要拷贝

_size += len;

return *this;

}

string& append(const string& s)

{

if (_size + s._size> _capacity)

{

reserve(_size + s._size);

}

memcpy(_str + _size, s._str, s._size + 1);//末尾的'\0'也要拷贝

_size += s._size;

return *this;

}


operator+=

+=是string经常用到的操作符,使用非常方便。

operator+=总共有三个重载版本,我们可以直接复用push_back()和append()

string& operator+=(char ch)

{

push_back(ch);

return *this;

}

string& operator+=(const char* str)

{

append(str);

return *this;

}

string& operator+=(const string& s)

{

append(s);

return *this;

}


insert()

任意位置插入字符ch

string& insert(size_t pos, size_t n, char ch)

{

assert(pos <= _size);

if (_size + n > _capacity)

{

reserve(_size + n);

}

for (size_t end = _size; end >= pos; end--)

{

//pos==0时end会减到-1

//无符号整型 end=-1时表示42亿多 不加判断会无限循环

if (end == npos)

{

break;

}

_str[end + n] = _str[end];

}

for (size_t i = 0; i < n; i++)

{

_str[i + pos] = ch;

}

_size += n;

return *this;

}

任意位置插入字符串str

string& insert(size_t pos, const char* str)

{

assert(pos <= _size);

size_t len = strlen(str);

if (_size + len > _capacity)

{

reserve(_size + len);

}

for (size_t end = _size; end >= pos; end--)

{

if (end == npos)

{

break;

}

_str[end + len] = _str[end];

}

memcpy(_str + pos, str, len);//不能多拷贝一个字节

_size += len;

return *this;

}

注意:这里的memcpy不能跟之前一样多拷贝一个字节,否则会将原对象pos+1的字符替换成\0,会出错。


erase()

string& erase(size_t pos, size_t len = npos)

{

assert(pos < _size);

//没给参数 或者 要删除的个数>=pos后面剩下的个数 则后面包括pos位置全部删掉

if (len == npos || pos + len >= _size)

{

//pos及pos后面所有字符都删掉

_size = pos;

_str[_size] = '\0';

}

else

{

memcpy(_str + pos, _str + pos + len, len);

_size -= len;

}

return *this;

}


c_str()

返回字符串首地址,以\0结束。这个接口主要是用来兼容C语言的。

const char* c_str() const

{

return _str;

}


find()

find查找失败会返回npos,找到则返回下标

//查找字符

size_t find(char ch, size_t pos = 0)

{

assert(pos < _size);

for (size_t i = pos; i < _size; i++)

{

if (_str[i] == ch)

{

return i;

}

}

return npos;

}

//查找字符串

size_t find(const char* str, size_t pos = 0)

{

assert(pos < _size);

const char* p = strstr(_str + pos, str);

if (p)

{

return p - _str;

}

else

{

return npos;

}

}


substr()

返回子串

string substr(size_t pos = 0, size_t len = npos) const

{

assert(pos < _size);

size_t n = len;//子串长度

if (len == npos || pos + len > _size)

{

n = _size - pos;

}

string tmp;

for (size_t i = 0; i < n; i++)

{

tmp += _str[i + pos];//复用+=

}

return tmp;

}


非成员函数

operator+

实际上+操作符很少用,直接用+=更方便省事;这里只实现了一个接口,重载为友元函数。

string operator+(const string& s1, const string& s2)

{

string tmp(s1);

tmp += s2;

return tmp;

}


关系运算符

C++官方手册中,每种运算符都有3种重载版本,这里每个只实现了一种,重要的是理解本质,加深对string的理解。

为什么用C语言的memcmp函数进行字节上的比较,不用strcmp?

因为strcmp遇到\0终止,而string不看\0,以_size为终止。

当然也可以不用memcmp,遍历每个字符进行比较。

bool operator==(const string& s1, const string& s2)

{

return s1._size == s2._size

&& memcmp(s1._str, s2._str, min(s1._size, s2._size)) == 0;

}

bool operator<(const string& s1, const string& s2)

{

int ret = memcmp(s1._str, s2._str, min(s1._size, s2._size));

return ret == 0 ? s1._size < s2._size : ret < 0;

}

//直接复用

bool operator!=(const string& s1, const string& s2)

{

return !(s1 == s2);

}

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);

}


流插入<<和流提取>>

流插入的实现可以借助范围for,本质是遍历整个字符串打印每个字符。

ostream& operator<<(ostream& out, const string& s)

{

//out << s._str;//'\0'后面的内容无法打印

for (auto ch : s)

{

out << ch;

}

return out;//带返回值 支持连续输出

}

流提取需要注意几种情况:

1.每次读取之前需要先清空string对象,否则会叠加之前的内容。

2.为什么用get()读取而不用>>?

流提取>>默认是跳过空格和换行的,所以>>永远无法读取空格和换行,程序会一直运行一直可以输入。

3.如果字符串前面有空格或者换行,标准库的>>会默认清理空格和换行。所以要预先处理前面的空格和换行。

istream& operator>>(istream& in, string& s)

{

s.clear();//清空之前的内容

char ch = in.get();//可以读取空格和换行

//处理掉缓冲区前面的空格或者换行

while (ch == ' ' || ch == '\n')

{

ch = in.get();

}

while (ch != ' ' && ch != '\n')

{

s += ch;

ch = in.get();

}

return in;

}

getline()

getline可以自定义读取的分隔符,遇到分隔符就不再读取,字符参数默认为\n,换行截断。

istream& getline(istream& in, string& s, char delim = '\n')

{

s.clear();//清空之前的内容

char ch = in.get();

while (ch != delim)

{

s += ch;

ch = in.get();

}

return in;

}


整体代码->:string模拟实现代码



声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。