前言
在C语言中虽然没有字符串这个类型,但可以通过'\0'结尾的字符集合标识,也提供了操作字符串的函数,但这并不符合c++面向对象的思想,而且字符串越界的问题是由程序员自己控制的,这没有充分发挥编译器校验的功能。为此c++在设计字符串时为了更加便捷快速的使用,引入了string类来维护字符串。今天我们就来简单的介绍一下string类,具体string类的描述可以参考string - C++ Reference (cplusplus.com)
在不同的编译器下string类的实现各有差异,这里我们使用的是Microsoft Visual Studio Community 2022 (64 位) - Current 版本 17.8.5
string类
构造函数
了解一个类我们首先需要了解这个类的构造函数
其中我们最常使用到的就是 1,2,4 其他不常用的就不在这里展开了
string() 表示构造一个空字符串 string(const string& str)表示用字符串str构造出一个一样内容的字符串 string(const char* s)表示用C语言中的字符串构造出一个string类
我们可以打开调试窗口看看标准库是如何实现的
示例代码:
int main()
{
std::string s1("zzzyh");;
std::string s2("zzzyhzzzyhzzzyhzzzyhzzzyhzzzyhzzzyh");
return 0;
}
我们可以在调试窗口监视中打开原始视图进行观察
我们不难发现,如果字符串的长度小于16会先在栈空间上开辟一个数组(_Buf)进行维护,长度大于等于16时数据才会被转移到堆(Ptr)上维护。但这并不是每个编译器都会这样实现,具体实现的细节还是要看编译器,甚至同一个编译器的不同版本在具体实现时也有区别 。
在这里我们发现string类在维护这段字符串时还维护了一些别的内容,比如 size 和 capacity 其实这是字符串的附带属性,size指这段字符串的长度, capacity指这个对象目前容纳的最大长度,这两个都不包含'\0','\0'需要单独维护。容量可以动态扩容
现在我们可以实现一个简单的字符串
namespace zzzyh
{
class string
{
public:
string(const char * s="")
{
_size = strlen(s);
_capacity = _size;
_str = new char[_capacity+1];
/*for (size_t i = 0; i <= size; i++)
{
str[i] = s[i];
}*/
strcpy(_str, s);
}
private:
char* _str=nullptr;
size_t _size=0;
size_t _capacity=0;
};
现在我们可以边学习标准库的string边实现我们自己的string
析构函数
此时,我们发现_str是动态开辟的空间,如果使用默认的析构函数会造成内存泄露,我们需要实现符合我们需求的析构函数
~string() {
delete[] _str;
}
拷贝构造和赋值重载
拷贝构造
此时我们还需要看看 拷贝构造和赋值重载 是否能符合我们的预期
这里会涉及到深浅拷贝的问题,我们简单的讲一下。如果我们使用默认的构造函数,这会导致两者字符串对象指向同一块_str这就是浅拷贝,如何解决这个问题?显然是我们需要实现一个深拷贝符合我们预期的构造函数,官方库也是这样实现的
string(const string& s) {
this->_capacity = s._capacity;
this->_size = s._size;
this->_str = new char[s._capacity+1];
memcpy(this->_str, s._str, s._capacity + 1);
}
赋值重载
同理我们也需要实现符合我们要求的赋值重载
string& operator=(const string& s) {
if (this == &s)
return*this;
delete[] this->_str;
this->_capacity = s._capacity;
this->_size = s._size;
this->_str = new char[s._capacity + 1];
memcpy(this->_str, s._str, s._capacity + 1);
return *this;
}
拷贝构造和赋值重载的现代写法
其实这里还有一种现代写法,我们也简单介绍一些。上面这种写法其实开辟空间交换都是由我们程序员自己实现的,我们也可以先构造出一个符合要求的字符串,交换自动实现深拷贝
void swap(string& s) {
std::swap(this->_str, s._str);
std::swap(this->_size, s._size);
std::swap(this->_capacity, s._capacity);
}
string(const string& s) {
string tmp(s.c_str());
this->swap(tmp);
}
string& operator=(const string& s) {
if (this == &s)
return*this;
string tmp(s.c_str());
this->swap(tmp);
return *this;
}
string& operator=(string s) {
this->swap(s);
return *this;
}
容量操作
现在我们再模仿官方库实现一些关于容量的操作
这里我们还是只介绍常用的方法
size 返回字符串的长度 和length的功能相同,但更为通用的方法还是size
resize是调整字符串长度,如果字符串长度大于调整的长度,会将字符串截断至调整的长度;如果等于可以视为没有任何操作;如果小于调整的长度,会将字符串填充至调整的长度,填充的字符可以传参指定默认是0
capacity 返回此对象的容量,可以动态扩容 和size/length 一样不考虑'\0'
reserve是调整字符串容量,在本文的环境下,如果容量小于等于size不做任何处理,如果容量大于size会将字符串容量调整至不小于size,至于调整为多大还是取决于具体的实现
clear是清除有效的字符,size重新变为0
emptr是判断字符串是否为空字符串,是返回true否则返回false
我们在一起介绍一个 c_str 这里是c++兼容c语言的体现,返回c的字符串
我们可以模拟实现一下
size_t size() const
{
return _size;
}
size_t capacity() const
{
return _capacity;
}
void clear() {
_str[0] = '\0';
_size = 0;
}
void reserve(size_t n) {
cout << "reserve:" << n << endl;
if (n <= _capacity) {
return;
}
char* tmp = new char[n + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = n;
}
const char* c_str() const
{
return _str;
}
访问及遍历
下面我们再来看看string类对象的访问及遍历操作
访问
[] 重载 和 at 是得到指定位置的字符,back得到最后一个字符,front得到第一个字符
最常用的还是[] 重载我们来简单实现一下
const char& operator[](size_t pos) const
{
assert(pos < _size);
return _str[pos];
}
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
遍历
遍历最常用的还是迭代器和范围for,其中范围for的底层实现还是依赖于迭代器
begin是得到第一个字符的位置,end是得到最后一个字符的下一个位置 ,正向遍历
rbegin和rend和上面同理,不过是反向遍历
前面加c之后描述的就是const修饰的字符串,只能访问不能修改
下面我们利用指针简单的模仿实现一下
typedef char* iterator;
typedef const char* const_iterator;
iterator begin()
{
return _str;
}
iterator end()
{
return _str+_size;
}
const_iterator begin() const
{
return _str;
}
const_iterator end() const
{
return _str + _size;
}
范围for
下面我们再来简单的介绍一下范围for
在这之前我们先简单的介绍一个前置的知识点 auto 。这是c++11引入的语法,英文直译过来就是自动,可以理解为我们可以用 auto 修饰变量的类型,由编译器在编译阶段自动推导,如果推导不出来或者有歧义会自动报错。
用auto声明指针类型时,用auto和auto*没有任何区别,但用auto声明引用类型时则必须加&
当在同一行声明多个变量时,这些变量必须是相同的类型,否则编译器将会报错,因为编译器实际
只对第一个类型进行推导,然后用推导出来的类型定义其他变量
auto不能作为函数的参数,可以做返回值,但是建议谨慎使用
auto不能直接用来声明数组
因此auto可以在范围for中简化书写,这是因为范围for的语法格式导致的
对于一个有范围的集合而言,由程序员来说明循环的范围是多余的,有时候还会容易犯错误。因此
C++11中引入了基于范围的for循环。for循环后的括号由冒号“ :”分为两部分:第一部分是范围
内用于迭代的变量,第二部分则表示被迭代的范围,自动迭代,自动取数据,自动判断结束。
范围for可以作用到数组和容器对象上进行遍历
范围for的底层很简单,容器遍历实际就是替换为迭代器,这需要在汇编层面发现
string str("hello zzzyh");
for (auto ch : str)
{
cout << ch << " ";
}
cout << endl;
int array[] = { 1, 2, 3, 4, 5 };
for (auto e : array)
cout << e << " " << endl;
auto可以替换成具体的类型,这里可以简化书写
修改操作
string类的修改也是很关键的操作
+=运算符重载可以在字符串后面追加字符串
append在字符串后面追加一个字符
push_buck的功能和append类似,push_bush在字符串后面尾插一个字符
assign截取指定字符串中部分或者全部的数据覆盖原先的字符串
insert在指定位置插入字符或者字符串
erase清除指定范围的字符串,注意和clean区分
replace替换指定范围的字符串为新字符串
swap交换两个字符串,注意std::swap也可以实现交换的功能,但效率没有这个高,同样这个方法也实现有全局的方法
pop_back弹出最后一个字符并返回
下面我们来简单的实现几个功能
void insert(size_t pos, char c) {
assert(pos <= _size);
if (pos == _size) {
push_buck(c);
return;
}
if (_size + 1 > _capacity) {
reserve(1 + _size > 2 * _capacity ? 1 + _size : 2 * _capacity);
}
memmove(_str + pos, _str + pos-1, strlen(_str + pos-1)+1);
_str[pos-1] = c;
}
void insert(size_t pos, const char* c){
assert(pos <= _size);
if (pos == _size) {
append(c);
return;
}
size_t len = strlen(c);
if (_size + len > _capacity) {
reserve(len + _size > 2 * _capacity ? len + _size : 2 * _capacity);
}
memmove(_str + (pos + len-1), _str + pos-1, strlen(_str + pos-1)+1);
memmove(_str + (pos - 1), c, len);
_size += len;
/*for (size_t i = 0; i < len; i++)
{
_str[pos - 1 + i] = c[i];
}*/
}
void erase(size_t pos, size_t len)
{
assert(pos < _size);
int leni = strlen(_str + pos);
if (len >= leni) {
_size -= leni;
_str[pos] = '\0';
return;
}
memmove(_str + pos, _str + pos + len, strlen(_str + pos + len) + 1);
_size -= len;
}
void append(const char* c)
{
size_t len = strlen(c);
if (len+_size > _capacity)
{
//reserve(len + _size);
reserve(len + _size>2*_capacity?len+_size:2*_capacity);
}
/*const char* tmp = c;
while (*tmp != '\0') {
push_buck(*tmp);
tmp++;
}*/
strcpy(_str + _size, c);
_size += len;
}
void push_buck(char c)
{
if (_size == _capacity)
{
reserve(_capacity ==0?4:_capacity * 2);
}
char* e = end();
char* ed = e + 1;
*ed = '\0';
*e = c;
_size++;
}
string& operator+=(char c) {
push_buck(c);
return *this;
}
void swap(string& s) {
std::swap(this->_str, s._str);
std::swap(this->_size, s._size);
std::swap(this->_capacity, s._capacity);
}
查询操作
find指从头开始寻找符合要求的字符或者字符串,rfind从尾部向前寻找,返回的是下标。没找到返回npos(size_t -1)
find_first_of指从头开始找第一个符合任意一个字符的下标,find_last_of从尾部向前找,返回值和上面的相同
_not_是在上面的基础上寻找不符合的任意一个
substr截取字串并返回
size_t find(const char c,int pos) {
assert(pos < _size);
for (size_t i = pos; i < _size; i++)
{
if (_str[i] == c) {
return i;
}
}
return -1;
}
size_t find(const char* c, int pos) {
assert(pos < _size);
const char * tmp = strstr(_str+pos, c);
if (tmp == nullptr) {
return -1;
}
return tmp - _str;
}
string substr(size_t pos, size_t len)
{
assert(pos < _size);
size_t i = len;
int y = pos;
string ret;
while (y<_size && i > 0)
{
ret.push_buck(_str[y]);
y++;
i--;
}
return ret;
}
非成员函数
重载+会将原先的字符串上追加字符/字符串并返回新的字符串
relational重载各种大小比较的运算符
swap我们前面已经实现了,交换功能,这里在全局位置是避免程序员误用std::swap降低效率
流插入和流提取,这是两个比较常见的重载
getline是解决流插入以空格分割会忽略空格的问题,可以指定结束符 ,不传默认换行符
cin.get 可以得到一个一个的字符,不会忽略空格
static bool operator<(const string& s1, const string& s2)
{
return strcmp(s1.c_str(), s2.c_str()) > 0;
}
static bool operator>(const string& s1, const string& s2) {
return strcmp(s1.c_str(), s2.c_str()) < 0;
}
static bool operator==(const string& s1, const string& s2)
{
return strcmp(s1.c_str(), s2.c_str()) == 0;
}
static bool operator<=(const string& s1, const string& s2)
{
return s1 == s2 || s1 < s2;
}
static bool operator>=(const string& s1, const string& s2) {
return s1 == s2 || s1 > s2;
}
static bool operator!=(const string& s1, const string& s2) {
return strcmp(s1.c_str(), s2.c_str()) != 0;
}
static ostream& operator<<(ostream& out, const string& s) {
out << s.c_str();
return out;
}
static istream& operator>>(istream& in, string& s) {
s.clear();
char ch;
ch=in.get();
const int n = 256;
char buf[256] = { 0 };
int i = 0;
while(ch != ' ' && ch != '\n') {
//s += ch;
buf[i++] = ch;
if (i == n - 1) {
buf[i++] = '\0';
s.append(buf);
i = 0;
}
ch = in.get();
}
if (i != 0) {
buf[i++] = '\0';
s.append(buf);
}
return in;
}
/*static istream& operator>>(istream& in, string& s) {
s.clear();
char ch;
ch = in.get();
while (ch != ' ' && ch != '\n') {
s += ch;
ch = in.get();
}
return in;
}*/
写时拷贝和引用计数
我们在这里再介绍一种解决深拷贝的思路——写时拷贝
再浅拷贝时我们遇到的问题是修改了就全修改和多次析构的问题,但如果只读并且按正确的时机析构就可以认为再逻辑上实现了深拷贝。引用计数就是实现写时拷贝的基础
我们先用引用计数标识对象的次数,如果所有对象只读浅拷贝,但凡一个对象需要修改,这个对象单独进行深拷贝,不影响其他只读的字符串。析构时计数--,构造时计数++,当计数!= 1析构不处理,==1时才析构释放空间
结语
以上便是今天的全部内容。如果有帮助到你,请给我一个免费的赞。
因为这对我很重要。
编程世界的小比特,希望与大家一起无限进步。
感谢阅读!