欢迎光临
我们一直在努力

《告别手写 char*!string 类让你代码效率提升 50%!》

1.为什么学习string类?

1.c语言中的字符串

我们知道c语言中的字符串是以‘\\0’结尾的一串字符,并且提供一系列的库函数,可以对字符串操作,但库函数和字符串分离,不符合面向对象的思想,并且字符串空间是由用户自已控制,可能有内存泄漏,越界访问的问题

2.标准库中的string类

1.了解string

基于这一系列的问题,c++标准库中引入了string类,标准库中的string类将方法与成员变量封装到一起,便于统一管理,且用访问限定符进行限定,这样用户就只能通过成员函数来访问成员变量,大大降低了出错的可能性

初学者可以翻阅string类文档学习

2.string结构剖析

1.构造函数

无参构造 string()
拷贝构造 string(const sttring& str)
指定区间拷贝构造 string(const string& str,size_t pos,size_t npos)
用字符串构造 string(const char* s)
用字符串前n个构造 string(const char* s,size_t n)

用n个字符构造

string(size_t n,char c)
迭代器区间构造 string(iterator first,iterator last)

#include <iostream>
#include <string>
using namespace std;
int main() {
string str1;
string str2(str1);
string str3(str1,0,1);
string str4("hello world");
string str5("hello world", 5);
string str6(5, 'h');
string str7(str4.begin(), str4.end());
return 0;
}

2.迭代器(iterator)

迭代器其实并不那么“高大上”,本质上就如同指针一般,只不过封装性更强,更加方便而已

它是一个“指向容器元素的工具”,能够帮助我们遍历、读取、修改容器中的元素

代码示例:

int main() {
string str1("hello world");
string::iterator it = str1.begin();
while (it != str1.end()) {
cout << *it;
it++;
}
cout << endl;
return 0;
}

由上图可以看出我为什么会说迭代器与指针类似了,it是一个迭代器,它指向str1的开始,也就是h的地址,当它不等于有效数据的下一个时,就一直遍历

与指针的用法上相似,但是有些时候指针可完不成遍历,例如我们在数据结构中学的链表,由于空间的不连续,指针能够通过++找到下一个数据吗?指针解引用之后能够获取到数据吗?答案都是不能,所以指针的局限性还是太大了,所以我们封装了迭代器,不需要关心底层结构,只需要关注用法


2.1.auto

我们继续看上面的代码,可以看到it的类型是string::iterator,这似乎也太长了,所以为了方便,引入了auto关键字

  • 用auto声明的变量,可由编译器根据右边表达式类型自动推导
  • 当用auto声明指针类型的时候,用auto和auto*均可,但用auto声明引用类型时则必须加&
  • 当在同一行声明多个变量时,变量的类型必须相同,原因是编译器只会对第一个变量的类型进行推导,当后面的类型与第一个类型匹配不上的时候编译器就会报错
  • auto不能作为函数的参数传递(函数参数需要 “先定义类型,再接收值”,而auto依赖 “先有值,再推导类型”,逻辑上矛盾),可以做函数的返回值
  • auto不能直接用来声明数组,因为我们知道,数组的类型是元素类型加上数组个数,如int arr[3]的类型就是int [3],但是auto只能推导元素类型,却不能知道元素个数,所以不能完成对数组类型的推导

有了以上的理论知识,我们就可以完成更为简单的范围for遍历

范围for循环的本质是自动遍历一个“可迭代的范围”,无需手动管理索引或循环终止条件,编译器就会自动帮你处理这些细节

(其底层依旧是迭代器,只不过自动完成了解引用操作和++移动操作)

string str1("hello world");
//加上引用,减少自定义类型的拷贝
for (auto& str : str1) {
cout << str;
}

2.2.遍历的三种方式

看到这里我们就已经掌握了三种遍历string的方式了

int main() {
//下标法
string str1("hello world");
for (int i = 0; i < str1.size(); i++) {
cout << str1[i];
}
cout << endl;
//迭代器法
auto it = str1.begin();
while (it != str1.end()) {
cout << *it;
it++;
}
//范围for法
cout << endl;
for (auto& str : str1) {
cout << str;
}
return 0;
}

毫无疑问,我们肯定在实践中更倾向于选择范围for了,但是我们也不要被它的方便外表所迷惑,需要清楚它的底层依旧是迭代器

2.3.string中的迭代器

注:c指的是const,r指的的是reverse(逆置)

begin、cbegin 指向开头
end、cend 指向有效数据的下一个位置(类似于C语言中的'\\0')
rbegin、crbegin 从结尾开始
rend、crend 到开头结束

用const修饰的迭代器代表着指向的内容不可修改

auto it = str1.cbegin();
while (it != str1.cend()) {
*it++;
cout << *it;
it++;
}

若是我们尝试修改


在某些情况,我们可能需要反向遍历,这时就需要用到反向迭代器

int main() {
string str1("hello world");
auto it = str1.rbegin();
while (it != str1.rend()) {
cout << *it;
it++;
}
return 0;
}

你可能会有疑惑,rbegin不是指向最后一个位置吗,要反向遍历的话,不应该把it往前移,也就是it–吗?

这其实也再次证明了封装的意义,如果向以前的指针那般,确实只能通过it–了,但是这破坏了统一的接口,增加了记忆成本与学习成本,而封装之后,我们可以把it–实现在底层,而表现出来的依旧是it++,保证了接口的统一性

3.容量(Capacity)

size(length) 返回对象的长度
resize(n)

调整数据至n

capacity 返回容量大小
clear 用于清除数据
empty 用于判空
reserve 用于扩容
1、resize:

分为三种情况:

1、原数据个数<n<容量大小,会将数据个数增加至n,若不给数据,会按照缺省值进行增加数据

2、n>容量大小,先扩容,然后增加数据

3、n<数据,会减少数据至n

2、reserve

同样分为三种情况

1、处于上面第一种情况,c++标准没有明确规定,不同的编译器处理不同,如在vs下,依旧保持原有的容量,没有缩容,而在linus下,会缩容至n

2、会扩容至n

3、c++标准明确规定不会缩容

4.获取数据(Element access)

operator[](at) 都可用于访问数据
front、back 返回头尾的数据

int main() {
string str1("hello world");
cout<<str1.at(1)<<endl;
cout << str1[1]<<endl;
//返回的都是e
return 0;
}

需要注意的是在使用operator[]时不会检查边界,越界的时候程序会崩溃

在使用at是会检查边界情况,越界时会抛异常,我们可以通过try-catch捕获(后面会讲到)

5.修改数据(Modifiers)

1.operator+=/push_back/append
operator+=

可加字符、字符串、string类

push_back 尾插一个字符
append 追加字符串/string


int main() {
string str1("hello world");
str1.push_back('/');
str1 += "你好";
str1.append("世界");
cout << str1 << endl;
return 0;
}

一般+=使用得比较多,因为很方便

2.insert/erase
insert 可在任意位置插入数据
erase 可删除任意位置数据
1.insert
string& insert (size_t pos, const string& str) 在pos位置之前插入string类
string& insert (size_t pos, const string& str, size_t subpos, size_t sublen) 在pos位置之前插入一段区间
string& insert (size_t pos, const char* s) 在pos位置之前插入字符串
string& insert (size_t pos, const char* s, size_t n) 在pos位置之前插入字符串的前n个
string& insert (size_t pos, size_t n, char c) 在pos位置之前插入n个c字符
iterator insert (iterator p, char c) 在迭代器位置插入字符c
template <class InputIterator> void insert (iterator p, InputIterator first, InputIterator last) 在迭代器p位置插入一段迭代器的数据

int main() {
string str1("hello world");
string str2("你好啊");
//1.
str1.insert(0, str2);
//2.
//注意中文会占2个空间,区间默认都是左闭右开
str1.insert(0, str2, 0, 6);
//3.
str1.insert(0, "love the world");
//4.
str1.insert(0, "love you, ", 9);
//5.
str1.insert(0, 1, ',');
//6.
auto it = str1.begin();
str1.insert(it, 'h');
//7.
auto it1 = str2.begin();
auto it2 = str2.end();
str1.insert(it, it1, it2);
cout << str1<<endl;
}

注:上面的代码是有问题的,新手同学想必也可能会犯同样的错误,相信细心的你已经发现错误了

这里的问题就是迭代器失效,后面的erase也存在迭代器失效的问题

我们在使用第6个插入时(迭代器插入):在插入之前it指向开头,但在插入之后

  • 可能涉及到扩容,这样原有空间销毁,原有数据拷贝到新空间,那么这时it就指向了无效数据
  • 就算不扩容,插入数据之后,数据挪动,it也不再指向原有数据

以上两种情况都被称为迭代器失效

解决办法就是更新迭代器,注意到c++标准库中第6个插入方式的返回值为iterator类型,返回的iterator已经是更新后的迭代器

int main() {
string str1("hello world");
string str2("你好啊");
//1.
str1.insert(0, str2);
//2.
//注意中文会占2个空间,区间默认都是左闭右开
str1.insert(0, str2, 0, 6);
//3.
str1.insert(0, "love the world");
//4.
str1.insert(0, "love you, ", 9);
//5.
str1.insert(0, 1, ',');
//6.
auto it = str1.begin();
//重新更新it的指向
it = str1.insert(it, 'h');
//7.
auto it1 = str2.begin();
auto it2 = str2.end();
str1.insert(it, it1, it2);
cout << str1<<endl;
}


2.erase

string& erase (size_t pos = 0, size_t len = npos) 删除从pos开始,长度为len的字符
iterator erase (iterator p) 删除迭代器p指向的字符
iterator erase (iterator first, iterator last) 删除迭代器区间内的字符

这里的len是有缺省值的,不传的话就会用这个缺省值,npos是最大的无符号数

static const size_t npos = -1

为什么是-1呢?原因是负数是以补码的形式存在的,-1的补码就是全1,而size_t是取无符号整数,所以就会把全1当作正数,全1肯定j就是最大的数了,具体大小跟操作系统位数相关

所以这里的含义就是从pos位置开始删完,因为默认认为没有那么长的string类型

int main() {
string str1("hello world");
auto it = str1.begin();
auto it1 = str1.end();
str1.erase(0,6);
cout << str1 << endl;
str1.erase(it);
str1.erase(it, it1);
cout << str1<<endl;
}

注:以上代码虽然能够运行,但仍存在未定义行为,即迭代器没有更新

正确做法:

int main() {
string str1("hello world");
str1.erase(0,6);
cout << str1 << endl;
auto it = str1.begin();
auto it1 = str1.end();
it = str1.erase(it);
str1.erase(it, it1);
cout << str1<<endl;
}

3.replace

从名字很容易看出,这是用来替换成新内容的

string& replace(size_t pos, size_t len, const string& str) 从pos位置开始后面的len长度被替换成str
string& replace(iterator first, iterator last, const string& str) 迭代器区间替换成str

int main() {
string s = "Hello World!";
// 从下标 6 开始,替换长度为 5 的字符("World")为 "C++"
s.replace(6, 5, "C++");
cout << s << endl; // 输出:Hello C++!
s.replace(s.begin()+6, s.end()-1, "C++");
return 0;
}

4.swap
void swap (string& str) 交换两个string类

int main ()
{
std::string buyer ("money");
std::string seller ("goods");

std::cout << "Before the swap, buyer has " << buyer;
std::cout << " and seller has " << seller << '\\n';

seller.swap (buyer);

std::cout << " After the swap, buyer has " << buyer;
std::cout << " and seller has " << seller << '\\n';

return 0;
}

6.String operations

1.find系列

函数 方向 作用
find 正向查找 指定位置开始查找字符或字符串,返回第一次出现的位置
rfind 反向查找 指定位置开始查找字符或字符串,返回最后一次出现的位置
find_first_of 正向查找 指定位置开始查找给定的集合,只要存在就返回第一次出现的位置
find_last_of 反向查找 指定位置开始查找给定的集合,只要存在就返回最后一次出现的位置
find_first_not_of 正向查找 指定位置开始查找不属于给定集合的元素,返回第一次的位置
find_last_not_of 反向查找 指定位置开始查找不属于给定集合的元素,返回最后一次的位置

int main() {
string str1("hello world");
//要求字符串必须是连续的
size_t ret = str1.find("l", 0);
size_t ret1 = str1.rfind("l");
//可以查找任意字符,只要有就返回
size_t ret2 = str1.find_first_of("mwd", 0);
size_t ret3 = str1.find_last_of("mwd", 0);
//返回除了给定字符串的位置
size_t ret4 = str1.find_first_not_of("mwd", 0);
size_t ret5 = str1.find_last_not_of("mwd", 0);
cout << ret<<endl;
cout << ret1<<endl;
}

2.substr

用于截取字串,返回新的string对象

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

int main() {
string str1("hello world");
string str2;
str2 = str1.substr(0);
cout << str1<<endl;
cout << str2;
}

3.c_str

为了兼容c语言,将string类型转换成char*类型

int main() {
string str1("hello world");
cout << typeid(str1).name() << endl;
cout << typeid(str1.c_str()).name()<<endl;
}

可以看出类型已经将类类型转换成了char*类型

7.非成员函数(Non-member function overloads)

1.operator+

字符串拼接函数,可用于string类之间的拼接,也可以用于string类和字符串之间的拼接,与前面的+=类似

int main() {
string str1("hello world");
string str2("hello c++");
string str3;
string str4;
str3 = str1 + str2;
str4 = str1 + "hello c++";
cout << str3 << endl;
cout << str4 << endl;
}

2.getline

传统的输入是默认将空格作为分隔符,而不会读取空格,而getline能够自行设置停止符(默认是以'\\n'停止),能够读取空格

传统写法

int main() {
char ch[100] = {0};
cin >> ch;
string str(ch);
cout << str << endl;
}

可以看到空格之后就不会被读取

用了getline之后

int main() {
string str;
getline(cin,str);
cout << str << endl;
}

3.模拟实现string

相信你看完了string的基本结构早已摩拳擦掌,想要自已手撕一个string出来,别急,接下来我一步步带你实现简单的string

1.底层结构

底层我们用char*来实现,实际上跟前面数据结构的顺序表差不多,只不过存储的数据类型为char

private:
//给默认值
char* _str = nullptr;
size_t _size = 0;
size_t _capacity = 0;
static const size_t npos;

2.构造函数

1.普通构造

需要注意的是开空间的时候要多开一个,用于存储\\0,但这个空间并不算在实际容量中

string(const char* str = "\\0")
{
_size = strlen(str);
_str = new char[_size + 1] ;//实际空间要多加一个,存储\\0
strcpy(_str, str);
_capacity = _size;
}

2.拷贝构造

string类是需要我们写拷贝构造的,因为底层有一个指针,指向了资源,若调用编译器自动生成的默认构造(浅拷贝),就会析构两次,运行崩溃

这是传统写法:一般来说我们都是自已申请空间,然后拷贝数据从而实现拷贝构造

//传统写法
string(const string& str) {
size_t size = str.size();
char* tmp = new char[size + 1];
strcpy(tmp, str._str);
_str = tmp;
_size = size;
_capacity = size;
}

这是现代写法:不用我们自已申请空间,而是调用构造函数构造一份新的string类出来,然后与tmp的成员变量交换就间接完成了拷贝

//为了方便我们直接调用标准库中的swap,也可以自已实现
void swap(string& str) {
std::swap(_str, str._str);
std::swap(_size, str._size);
std::swap(_capacity, str._capacity);
}
string(const string& str) {
//先构造一个string出来
string tmp(str._str);
swap(tmp);
}

3.赋值重载

可能前面还感受不到现代写法的用处,那么现在你将会感受到现代现代写法的简洁性与优雅

传统写法:清除原有数据,申请空间,拷贝数据,释放原有空间,再将原有指针更改指向。

注意这里有个坑:需要检查一下,不能给自已赋值,否则我们在delete的时候,会将其中一个自已释放掉

//传统写法
string& operator=(const string& str) {
//先将原有数据清除
clear();
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;
}

现代写法:注意这里是传值传参,会拷贝形成形参(形参的改变不会影响实参,且出了函数栈帧自动销毁),然后直接与本身交换,这样将自身无用的数据销毁掉,同时还获取到了tmp中的值,是不是一举两得

string& operator=(const string tmp) {
swap(tmp);
return *this;
}

4.析构函数

比较简单,释放指针指向的内容即可

~string() {
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}

5.迭代器

前面提到迭代器与指针类似,那么这里能不能用指针实现迭代器呢?答案是可以,因为底层是顺序表,内存连续,可以通过++移动到下一数据,也可以通过解引用获取到数据

//为了保证接口的统一性,将char*封装成iterator
typedef char* iterator;
//指向第一个数据
iterator begin() const {
return _str;
}
//指向最后一个有效数据的下一个
iterator end() const {
return _str+_size;
}

6.简单的接口

由于这些接口的实现比较简单,我不做具体讲解,详情请看注释

//返回数据个数
size_t size() const{
return _size;
}
//返回此时的容量大小
size_t capacity() const{
return _capacity;
}
//扩容
void string::reserve() {
if (_size == _capacity) {
//注意+1
char* tmp = new char[_capacity == 0 ? 4 :2 * _capacity + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
//注意更新_capacity
_capacity = 2*_capacity;
return;
}
return;
}
//这是为了兼容c语言,返回string底层的指针,方便调用c语言库中的函数
const char* string::c_str() const {
return _str;
}
//用于遍历和修改
const char& string::operator[](size_t pos) const {
assert(pos < _size);
return _str[pos];
}

7.插入数据

1.push_back

在尾部插入一个字符

为了兼容c语言,我们同样以'\\0'作为字符串的结尾,我们在插入一个字符的时候,若不手动添加'\\0'的话,编译器就不知道什么时候结束,会造成越界问题

void string::push_back(char c) {
reserve();
_str[_size++] = c;
_str[_size] = '\\0';//要手动添加一个\\0
}

2.append

追加函数,可以追加string类,字符串类,只实现了部分

string& string::append(const string& str) {
//计算追加后的长度,若容量不够就需要扩容
size_t len = _size + str.size();
if ( len > _capacity) {
size_t newcapacity = len > 2 * _capacity ? len : 2 * _capacity;
char* tmp = new char[newcapacity+1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = newcapacity;
}
//strcpy拷贝的是指针指向的内容,这时候就需要用到c.str()返回原生指针
strcpy(_str + _size, str.c_str());
_size = len;
return *this;
}
string& string::append(const string& str, size_t pos, size_t len) {
//如果len超出范围,就更新len,只从pos位置追加到最后
if (len > str._size – pos) {
len = str._size – pos;
}
size_t size = len + _size;
if (size > _capacity) {
size_t newcapacity = size > 2 * _capacity ? size : 2 * _capacity;
char* tmp = new char[newcapacity+1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = newcapacity;
}
//这里拷贝的是一段区间,无法用strcpy,所以一字符一字符地加
size_t index = _size;
for (size_t i = pos; i <= pos + len; i++) {
_str[index++] = str._str[i];
}
return *this;
}
//与上面追加string类似
string& string::append(const char* s) {
size_t len = _size + strlen(s);
if (len > _capacity) {
size_t newcapacity = len > 2 * _capacity ? len : 2 * _capacity;
char* tmp = new char[newcapacity + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = newcapacity;
}
strcpy(_str + _size, s);
_size = len;
return *this;
}

3.+=

从实际运用来说,+=使用得最多,因为最方便,可以+=字符和字符串和string类,它的底层实质上是复用了append和push_back

string& string::operator+=(const char* s) {
append(s);
return *this;
}
string& string::operator+=(const string& str) {
append(str);
return *this;
}
string& string::operator+=(const char ch) {
push_back(ch);
return *this;
}

4.insert

可以在任意位置插入数据,平时不建议使用,因为涉及到数据的挪动,效率不高

string& string::insert(size_t pos, string& str) {
//保证pos的正确性
assert(pos <= _size);
//依旧先判断是否需要扩容
size_t size = str.size();
size_t len = size + _size;
if (len > _capacity) {
char* tmp = new char[len + 1];
strcpy(tmp, _str);
delete[] _str;
_str = tmp;
_capacity = len;
}
//挪动数据,注意是从后往前挪,防止覆盖
for (size_t i = len – 1; i > pos+size-1; i–) {
_str[i] = _str[i – size];//结束条件是:_str[pos+1] = _str[pos]
}
_str[len] = '\\0';
//不能用strcpy,因为会默认拷贝\\0,而编译器遇到\\0就会停止,可能后面还有数据
for (int i = 0; i < size; i++) {
_str[i + pos] = str._str[i];
}
_size = len;
return *this;
}

8.删除数据

1.erase

string& string::erase(size_t pos,size_t len) {
assert(pos < _size);
if (len > _size – pos) {
len = _size – pos;
}
//移动数据
for (size_t i = pos + len; i <=_size; i++) {
_str[i – len] = _str[i];
}
return *this;
}

2.clear

不用真的删除数据,只需要让编译器读不到就行,所以只需要在第一个位置放\\0即可

string& string::clear() {
_str[0] = '\\0';
_size = 0;
return *this;
}

9.比较数据

有6大类,实际上只需要手动实现< 和 = 即可,其余类均可复用这两类

bool operator==(const string& str1, const string& str2) {
if (strcmp(str1.c_str(), str2.c_str()) == 0)
return true;
return false;
}
bool operator!=(const string& str1, const string& str2) {
return !(str1 == str2);
}
//调用c语言库中的函数,比较的其实是ascll码,ascll码小的返回小于0的数
bool operator<(const string& str1, const string& str2) {
if (strcmp(str1.c_str(), str2.c_str()) < 0)
return true;
return false;
}
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);
}

10.流插入流输出

这两个函数要实现成非成员函数,因为如果实现成成员函数的话,函数的第一个参数默认就是this指针,这就代表着我们在使用的时候就只能写成str>>cout,显然与我们平时习惯不同。

但是在我们实现成非成员函数又有一个问题,我们在输出的时候肯定是输出底层的指针啊,而非成员函数无法访问成员变量,这个时候我们需要与类成为"朋友",需要用到友元函数

详情请看类和对象(下)

//输出函数
ostream& operator<<(ostream& out, const string& str) {
out << str.c_str();
return out;
}
//输入函数
istream& operator>>(istream& in, string& str) {
//覆盖之前的数据,所以要先将之前的数据清除掉
str.clear();
char ch;
//创建一个缓冲区,否则一有数据就插入str中,这样会扩容多次,效率不高
const size_t N = 256;
char buff[N] = { '\\0' };
int index = 0;
//用c++标准库中的get读取数据,get可以读取任意字符,包括空格和换行符
in.get(ch);
while (ch != ' ' && ch != '\\n') {
buff[index++] = ch;
//注意最后一个位置要加上\\0
if (index == N – 1) {
buff[index] = '\\0';
str += buff;
index = 0;//重新置为0,反复使用
}
in.get(ch);
}
//当buff里面还有值的时候要加上
if (index > 0) {
str += buff;
}
return in;
}

4.string完整代码

5.结语

学习 string 的意义,从来不止于会用它的接口,更在于透过这层封装,读懂背后的设计巧思 —— 如何用抽象屏蔽底层复杂度,如何用规范保证代码的健壮性。而这些思考,也会成为我们后续学习 STL 其他容器、理解泛型编程的重要基础。

愿我们在敲下每一个 string 的背后,都能看见底层的内存与逻辑,让每一次调用都知其然,更知其所以然。

赞(0)
未经允许不得转载:171主机测评 » 《告别手写 char*!string 类让你代码效率提升 50%!》
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址