目录
上篇我们介绍了STL, STL(standard template libaray-标准模板库):是C++标准库的重要组成部分,在C++的学习中,STL的学习是必不可少的。对于STL的学习,我们采用模拟实现——造轮子的方法来更好的了解和学习STL。在学习STL时,由于各个容器的接口众多,所以学会查看文档是很重要的,这里我们推荐一个 cplusplus网站来查看容器的相关接口。
string类介绍
string类本质是一个顺序表,用来存放字符,字符串,有了string我们就可以方便地对字符进行相关操作了。
string类文档介绍可以帮助我们更好学习( string在底层实际是:basic_string模板类的别名)。string类作为第一个容器(但cplusplus网站没有将其放入Containers中,单独列了一个string),接口有点繁杂,函数功能有点重复,只需掌握常用的即可。
总结
- string是表示字符串的字符串类。
- 该类的接口与常规容器的接口基本相同,再添加了一些专门用来操作string的常规操作。
- string在底层实际是:basic_string模板类的别名,typedef basic_string<char, char_traits, allocator>
string;- 不能操作多字节或者变长字符的序列。
使用时包含头文件string
string类函数接口总览
#pragma once
#include<iostream>
#include<assert.h>
#include<string>
using std:: cout;
using std:: cin;
using std:: endl;
using std:: ostream;
using std:: istream;
namespace djs
{
class string
{
public:
typedef char* iterator;
typedef const char* const_iterator;
static const size_t npos = -1;
//默认成员函数
string(const char* str = "");//常用
string(const string& str);//拷贝构造
~string();
string& operator= (const string& str);//赋值重载
//迭代器相关函数
iterator begin();//
const_iterator begin() const;//const对象并且只可读(const 对象本就不可改;返回值也应该不能改)
iterator end();
const_iterator end() const;
//容积相关函数
size_t size() const;
size_t capacity() const;
bool empty() const;
void reserve(size_t n = 0);
void resize(size_t n, char c='\0');//将库的二合一
//内容修改(增删)相关函数
string& insert(size_t pos, const char* s);
string& insert(size_t pos, char ch);//插入一个字符,库里没有该函数
void push_back(char c);
string& operator+= (const char* s);
string& operator+= (char c);
string& append(const char* s);
string& erase(size_t pos = 0, size_t len = npos);//全缺省,不指定删除区间则全部删除
void pop_back();
//字符串相关操作
const char* c_str() const;
void swap(string& str);
size_t find(const char* s, size_t pos = 0) const;
size_t find(char c, size_t pos = 0) const;
//访问
char& operator[] (size_t pos);
const char& operator[] (size_t pos) const;
void clear();//不缩容
//比较关系运算符重载
bool operator>(const string& s)const;
bool operator>=(const string& s)const;
bool operator<(const string& s)const;
bool operator<=(const string& s)const;
bool operator==(const string& s)const;
bool operator!=(const string& s)const;
private:
char* _str;
size_t _capacity;
size_t _size;
};
//流出入和输出
ostream& operator<< (ostream& os, const string& str);
istream& operator>> (istream& is, string& str);
istream& getline(istream& is, string& str, char delim='\n');//改为默认以'\n'作为结束标志
}
为了不与库里的string冲突,我们将模拟实现的string放入自己的命名空间里。
结构介绍
string结构:
string类由于STL的版本不一样其结构(类成员)也有所不同。
VS下(PG版)的string的结构(了解):
注意:下述结构是在32位平台下进行验证,32位平台下指针占4个字节。
string总共占28个字节,内部结构稍微复杂一点,先是有一个联合体,联合体用来定义string中字符串的存储空间:
- 当字符串长度小于16时,使用内部固定的字符数组来存放。
- 当字符串长度大于等于16时,从堆上开辟空间。
union _Bxty
{ // storage for small buffer or pointer to larger one
value_type _Buf[_BUF_SIZE];
pointer _Ptr;
char _Alias[_BUF_SIZE]; // to permit aliasing
} _Bx;
这种设计也是有一定道理的,大多数情况下字符串的长度都小于16,那string对象创建好之后,内部已经有了16个字符数组的固定空间,不需要通过堆创建,效率高。
其次:还有一个size_t字段保存字符串长度,一个size_t字段保存从堆上开辟空间总的容量
最后:还有一个指针(不是_str)做一些其他事情。
故总共占16+4+4+4=28个字节。(x64平台下为40)
我们模拟实现的时候就不需要这么麻烦,因其本质为一个储存字符的舒徐容器,所以我们将其简化为一个维护字符串的指针_str,一个记录字符个数的_size,和一个记录容量大小的_capacity即可
private:
char* _str;//维护字符串
size_t _capacity;//容量大小
size_t _size;//字符个数
当我们使用string类的一些查找功能或一些长度值时,会经常看到一个值npos:npos是公共静态成员常量,此常量使用值 -1 定义,由于成员类型 size_type 是无符号整数类型,因此它是此类型的最大可能表示值。可作为返回值,它通常用于指示不匹配。
public:
static const size_t npos = -1;
设为static const变量,为string类共有且不可修改。
实现文件结构:
我们分为三个文件,将声明和定义分离实现:
string.h:string类成员变量和函数的声明。
string.cpp:string类的实现。
test.cpp:测试各接口。
默认成员函数
构造函数
构造函数我们设为全缺省,用户不传参则使用默认值,默认值为""——空串,因为字符串会自动追加’\0’;使用初始化列表先计算传入的字符串的大小,并初始化_size,再由此设置_capacity,最后使用strcpy将字符串拷进_str中。
string::string(const char* str)//const char* str = ""
:_size(strlen(str))//注意初始化列表的顺序
{
_capacity = 1 + _size;//当_size为0时,保证可以开空间
_str = new char[_capacity + 1];//预留'/0'的位置
strcpy(_str, str);
}
注意:
- 初始化列表是按声明顺序初始化的,_size,和_capacity都放在初始化列表初始化时要注意。
- 开空间时要确保_capacity不为0。
- 开空间时即使开一个字符大小的空间也要使用new char[1],这是为了析构时不用区分delete,delete[],也是为了更好的统一。
深浅拷贝
在实现拷贝构造前,再讲讲深浅拷贝的区别:
默认成员函数如果我们不写,编译器默认实现的都是浅拷贝,如下:
#define _CRT_SECURE_NO_WARNINGS 1
#include<assert.h>
#include<string.h>
class String
{
public:
String(const char* str = "")
{
// 构造String类对象时,如果传递nullptr指针,可以认为程序非
if (nullptr == str)
{
assert(false);
return;
}
_str = new char[strlen(str) + 1];
strcpy(_str, str);
}
~String()
{
if (_str)
{
delete[] _str;
_str = nullptr;
}
}
//没有实现拷贝构造,使用编译器默认生成的
private:
char* _str;
};
// 测试
void TestString()
{
String s1("hello csdn");
String s2(s1);
}
int main()
{
TestString();
return 0;
}
崩溃原因:
上述String类没有显式定义其拷贝构造函数与赋值运算符重载,此时编译器会合成默认的,当用s1构造s2时,编译器会调用默认的拷贝构造。最终导致的问题是,s1、s2共用同一块内存空间,在释放时同一块空间被释放多次而引起程序崩溃,这种拷贝方式,称为浅拷贝
所以string类的对象应该有一块独立的空间,管理自己的数据。这样的方式即称为深拷贝。
拷贝构造函数
有了以上对深浅拷贝的认识,所以拷贝构造函数里也应该自己开一块新的空间维护改对象的数据。实现方式与构造类似。
string::string(const string& str)
{
_str = new char[str._size + 1];
_size = str._size;
_capacity = str._capacity;
strcpy(_str, str._str);
}
以上是传统写法,借助string的swap函数可以更加简洁高效,等到swap函数再介绍。
赋值重载函数
赋值重载与拷贝构造是类似的,但是要区别出赋值重载是对已存在的对象而言;
string s5("HELLO CSDN");
string s6(s5);//拷贝构造
string s7 = s5;//拷贝构造
string s8;
s8 = s5;//赋值重载
先使用tmp来确保获取了新的数据,再删除原有的数据,最后让_str指向tmp,完成赋值操作。最后确保一下不会发生自己给自己赋值这种扯蛋操作。
string& string::operator=(const string& str)
{
if (this != &str)//防止自己给自己赋值
{
char* tmp = new char[str._capacity + 1];
strcpy(tmp, str._str);
delete[] _str;//删除原有空间
_str = tmp;
_size = str._size;
_capacity = str._capacity;
}
return *this;
}
赋值重载同样也可以使用string的swap写得更简洁的写法。
析构函数
析构函数主要负责对构造时在堆区开辟的空间进行释放。使用delete[] 进行空间的释放。
string::~string()
{
delete[] _str;
_str = nullptr;
_size = 0;
_capacity = 0;
}
迭代器
string类是顺序容器,所以string的迭代器本质上就是指针:char*,const char*,使用typedef重命名为iterator,const_iterator。
注意:
- 并不是所有的迭代器都是原生指针。
- 迭代器的区间范围是左闭右开[first,last)
typedef char* iterator;
typedef const char* const_iterator;
begin
迭代器的begin也就是返回字符串给首地址,即_str即可;这里我们重载两个版本的begin
string::iterator string::begin()
{
return _str;//返回字符串中第一个字符的地址
}
string::const_iterator string::begin() const
{
return _str;//返回字符串中第一个字符的const地址
}
end
迭代器的end为有效字符的下一个位置,即_str+_size的位置;这里我们也重载两个版本的end
string::iterator string:: end()
{
return _str + _size;
}
string::const_iterator string:: end() const
{
return _str + _size;
}
注意迭代器的区间[first,last),使用迭代器时需要声明是谁的迭代器;
string s9("HELLO CSDN");
//string::iterator it = s9.begin();
auto it = s9.begin();//当然你也可以使用auto自动识别
范围for本质
如今又学习了迭代器,那么我们总共有三种方式访问数据了
string s9("HELLO CSDN");
//下标访问
for (int i = 0;i<s3.size(); i++)
{
cout << s9[i];
}
cout << endl;
//迭代器访问
string::iterator it = s9.begin();
while (it != s9.end())
{
cout << *it;
it++;
}
cout << endl;
//范围for范文
for (auto e : s9)
{
cout << e;
}
cout << endl;
对于范围for,曾今的你觉得很神奇,但现在也将不值得一提了,因为其本质就是通过迭代器来访问数据,只不过是编译器将范围for替换成迭代器了。
我们将(自己模拟实现的string)迭代器一关,范围for也用不了了
通过反汇编也能看到范围for也确实是调用了迭代器,其本质是一样的。
容量相关函数
因为string类的成员变量是私有的,我们并不能直接对其进行访问,所以string类设置了size和capacity这两个成员函数,用于获取string对象的大小和容量。
size
返回有效字符的大小——_size
size_t string::size() const
{
return _size;
}
capacity
返回容量大小
size_t string::capacity() const
{
return _capacity;
}
reserve
功能:
- 如果 n 大于当前字符串容量,则该函数会导致容器将其容量增加到 n 个字符(或更大)。
- 在所有其他情况下(n小于当前容量),它被视为一个非约束性请求,不做响应,也就是说不进行缩容。
只对n大于_capacity的情况进行处理,逻辑与赋值重载类似,要先确保原有数据已经拷贝到tmp里再释放原有空间,再把_str指向tmp,记得更新容量_capacpity。
void string::reserve(size_t n)
{
//小了不处理
if (_capacity < n)
{
char* tmp = new char[n + 1];
_capacity = n;
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
}
}
resize
功能:
- 将字符串的大小调整为 n 个字符的长度。如果 n 小于当前字符串长度,则当前值将缩短为其前 n 个字符,并删除第 n个字符之外的字符。
- 如果 n 大于当前字符串长度,则通过在末尾插入所需数量的字符来扩展当前内容,以达到 n 的大小。如果指定了 c,则新元素将初始化为 c 的副本,否则,它们是值初始化的字符(‘\0’)。
我们这里使用半缺省参数(‘\0’)将库里的两个函数合二为一了;
首先要检测指定的个数n是否大于_capacity来检测是否需要扩容,接着使用一个循环,通过_size<n 的条件看看是否需要添加新字符,_size=n则是定位resize后字符串的结束位置。
void string::resize(size_t n, char c)//size_t n, char c='\0'
{
//大于size:是否需要扩容
if (n > _capacity)
{
reserve(n);
}
//大于我就重新设置内容,小于则不进循环但size就没有改变
while (_size < n)
{
_str[_size] = c;
_size++;
}
//
_size = n;//必须再次设置,小于时_size不会变,在此设置
_str[_size] = '\0';
}
empty
返回_size==0,判断是否为空
bool string::empty() const
{
return _size == 0;
}
clear
功能:将字符串清空
将_size改为0,再将此时的起始位置_size的值设为’\0’即可。
void string::clear()
{
_size = 0;
_str[_size] = '\0';
}
访问相关函数
operator[]
功能:
该函数是为了让string类对象能够像数组一样通过[]+下标访问字符串,并且支持修改对应的值;所以我们重载operator[]来实现这一功能。
由于能够直接修改字符串内容,所以对于位置需要严加小心,assert一下;我们的字符串是用_str维护的,而_str是我们在堆区开辟的连续空间,是可以通过下标访问的,所以直接_str[]的形式返回即可。由于是引用返回,所以能够修改对应的字符。对于只读不修改的operator[]我们再重载一个const版。
char& string::operator[](size_t pos)
{
assert(pos < _size);//size为'\0'
return _str[pos];
}
const char& string::operator[](size_t pos) const
{
assert(pos < _size);//size为'\0'
return _str[pos];
}
字符串修改相关函数
insert
先自己实现一个插入字符的insert函数(库里没有使用下标一个插入字符的insert);
对于insert,可以在任意位置插入一个字符:先检查插入位置是否合规,大于_size的pos才属于非法位置,接着检查是否需要扩容,需要的话使用reserve进行扩容,一般以二倍或一点五倍的速度扩容。要注意我们使用的类型为size_t——无符号整型。 将pos开始的数据向后移动一位,最后将字符插入pos位置,记得_size++
- 数据从后先前往后移。
- 以上移动方法是带’\0’的。
string& string::insert(size_t pos, char ch)
{
//1.检查pos是否合法
assert(pos <= _size);//'\0'也是该串可插入的范围
//2.检查是否需要扩容
if (_size + 1 > _capacity)
{
reserve(_capacity * 2);//;两倍速扩容
}
//开始插入
size_t end = _size + 1;//移动到的新位置
while (end > pos)//pos==end即可
{
_str[end] = _str[end - 1];//'\0'也移动
end--;
}
_str[end] = ch;//此时pos==end
_size++;
return *this;
}
对于插入字符串的insert,需要知道插入字符串的长度len,我们的方法是将end设为插入字符后的最后位置,间隔len个字符向后移,end–不断将前面的字符往后移,直到end-len==pos时即为最后一次移动,所以end > len + pos - 1可作为循环结束的条件。
string& string::insert(size_t pos, const char* s)
{
//1.检查pos是否合法
assert(pos <= _size);//'\0'也是该串可插入的范围
size_t len = strlen(s);
//2.检查是否需要扩容
if (_size + len > _capacity)
{
reserve(_capacity +len);//具体大小扩容
}
//插入
size_t end = _size + len;//移动到的新位置
while (end > len + pos - 1)//end==len+pos为最后一次移动 //对比上面插入一个字符,len==即插入一个字符
{ //end>=len+pos也行
_str[end] = _str[end - len];
end--;
}
strncpy(_str + pos, s, len);//指定个数
//strcpy(_str + pos, s);//连'\0'也拷,后面有内容的要使用strncpy
_size += len;
return *this;
}
push_back
功能:尾插一个字符。
有了insert,插入操作就比值得一谈了,直接使用insert尾插
void string::push_back(char c)
{
insert(_size, c);
}
+=
功能:尾插一个字符或字符串。
该运算符重载函数较为常用,同样复用insert实现
string& string::operator+=(const char* s)
{
insert(_size, s);
return *this;
}
string& string::operator+=(char c)
{
insert(_size, c);
return *this;
}
append
功能:尾插一个字符串
这里就能体现string类的接口过于冗余了。依旧复用insert
string& string::append(const char* s)
{
insert(_size, s);
return *this;
}
erase
功能:删除任意位置的指定个字符
有两种情况:
- 从pos开始全删.
- 从pos开始删除部分
为区分这两种状况,用一个range表示可串中删除的个数,如果指定删除的个数len大于等于可删除的个数range,说明从pos开始全删,只需要将_size(‘\0’下标)改为pos,并将该位置的字符改为结束标志’\0’即可;否则即为删除pos后的部分字符,使用strcpy将剩余部分即 _str + pos + len开始到’\0’部分,拷贝到pos处覆盖要删除的内容。
string& string::erase(size_t pos, size_t len)//size_t pos = 0, size_t len = npos,
{ //全缺省,不指定删除区间则全部删除
assert(pos <= _size);//可以在pso位置,(开区间),但该位置不删除
assert(!empty());
size_t range = _size - pos;//可删除的个数
//两种情况
//1.从pos开始全删
if (len >=range)//npos的情况只会在这里
{
_size = pos;
_str[_size] = '\0';
}
else//从pos开始删除部分
{
strcpy(_str + pos, _str + pos + len);
_size -= len;
}
return *this;
}
pop_back
功能:尾删一个字符
复用erase,删除的时有效字符,即_size的前一位。
void string::pop_back()
{
erase(_size - 1, 1);
}
string相关操作
c_str
功能:返回一个字符串指针。
C++兼容C语言,在部分场景中,需要获取指针字符串的指针,但此时 _str 为私有成员,所以需要通过函数间接获取指针 _str。
const char* string::c_str() const
{
return _str;
}
find
功能:从指定位置(默认从下标0开始找)所要查询的字符。
确保pos在有效位置,遍历字符串,找到则返回有效位置pos,否则返回无效位置npos。
size_t string::find(char c, size_t pos) const
{
assert(pos < _size);
while (pos < _size)
{
if (_str[pos] == c)
{
return pos;
}
pos++;
}
return npos;
}
功能:查找一个字符串。
使用库函数strstr,该函数找到则返回起始位置的指针,通过与_str(起始位置)相减,得到坐标。
size_t string::find(const char* s, size_t pos) const
{
assert(pos < _size);
const char* find = strstr(_str + pos, s);
if (find != NULL)
{
return find - _str;
}
//到此处说名find为NULL
return npos;
}
swap
功能:交换两个string对象
借助函数库的swap,交换string的成员变量。
void string::swap(string& str)
{
std::swap(_str, str._str);
std::swap(_size, str._size);
std::swap(_capacity, str._capacity);
}
前面说了string的拷贝构造和赋值重载借助string的swap可以写得更加简洁。
拷贝构造现代写法
拷贝构造就是拷贝一个一模一样的string对象,所以可以使用源字符串借助构造函数构造一个临时的tmp对象,该tmp对象与源string对象一模一样,这时再使用swap与tmp交换,就可以达到拷贝的效果了。
string::string(const string& str)
{
string tmp(str._str);
swap(tmp);
}
赋值重载现代写法
与上面类似,不过这里是借助拷贝构造函数生成一个临时的tmp对象,再使用swap完成交换,达到赋值的效果;记得先判断是否自己给自己赋值这种无效操作。
string& string::operator=(const string& str)
{
if (this != &str)//防止自己给自己赋值
{
string tmp(str);//构造一个临时对象,再将临时对象与对象使用swap交换
swap(tmp);//string的swap
}
return *this;
}
比较运算符重载
这部分较为简单,主要使用了库的strcmp函数,以及比较运算符重载的复用与逻辑关系。
>
bool string::operator>(const string& s) const
{
return strcmp(_str, s._str) > 0;
}
>=
bool string::operator>=(const string& s) const
{
return *this > s || *this == s;
}
<
bool string::operator<(const string& s) const
{
return strcmp(_str, s._str) < 0;
}
<=
bool string::operator<=(const string& s) const
{
return *this < s || *this == s;
}
==
bool string::operator==(const string& s) const
{
return strcmp(_str, s._str) == 0;
}
!=
bool string::operator!=(const string& s) const
{
return !(*this == s);
}
非成员函数
<<流提取
为达到cout<<的效果,我们在类外实现,由于有众多访问接口,我们不用将其设为友元函数。
string类的流提取是按_size打印的(字符串按’\0’打印),所以直接使用范围for获取打印。
ostream& djs::operator<<(ostream& os, const string& str)
{
//按size打印
for (auto e : str)
{
os << e;
}
return os;
}
流插入>>
向string类输入也是以空格’ ‘或’\n’为结束标志;为可接收空格或’\n’,使用istream类中的get()输入;为了更好的效率,先将输入的字符放入一个大小为128的字符数组buf,并用i记录存放的下标,当i==127时则说明数组可存放的有效字符满了,将下标为127处的字符设为’\0‘,使用+=尾插到_str中;结束输入时判断数组中是否还有字符,有则再次+=进_str。
//以空格或\n换行作为结束
istream& djs::operator>>(istream& is, string& str)
{
str.clear();
char buf[128];//最多存放127个字符
int i = 0;
char ch;
ch = is.get();//用get获取
while (ch != ' ' && ch != '\n')
{
buf[i++] = ch;
if (i == 127)//一次加进str里效率高
{
buf[i] = '\0';
str += buf;
i = 0;//重置
}
ch = is.get();
}
//结束输入
if (i != 0)//说明还有
{
buf[i] = '\0';
str += buf;
}
return is;
}
getline
string的流插入无法接收空格,所以还有一个getline函数来解决:可以按指定的字符作为输入结束的标志,其逻辑与流插入一致,只是判断结束的标志不同。
- 这里使用了半缺省参数delim默认为’\n’。
istream& djs::getline(istream& is, string& str, char delim)
{
str.clear();
char buf[128];//最多存放127个字符
int i = 0;
char ch;
ch = is.get();//用get获取
while (ch != delim)
{
buf[i++] = ch;
if (i == 127)//一次加进str里效率高
{
buf[i] = '\0';
str += buf;
i = 0;//重置
}
ch = is.get();
}
//结束输入
if (i != 0)//说明还有
{
buf[i] = '\0';
str += buf;
}
return is;
}
总结
string作为我第一个学习的容器,可以帮助熟悉STL的用法,也加深了对string的理解;本篇string的模拟实现难度不大。主要讲了深浅拷贝的问题,开始接触迭代器,剩余的也只是对之前C++内容学习的一个大杂烩吧。当然我们也只是浅浅的模拟了一下,也还有很多不足,如使用strcpy/strncpy导致模拟实现的string无法完全达到库中string的效果,有兴趣的可以自己再改改。完整代码之后传上gitee。篇幅过长,错误请在评论区提醒,Thanks~