07.string类
在 C++ 中,std::string
是一个非常重要的类,位于标准库的 <string>
头文件中,专门用于处理字符串。它提供了功能丰富的接口和动态的内存管理,是 C 风格字符串(字符数组 char[]
)的更高效、更安全的替代品。
目录
1. 标准库中的string类
1.1 特点
1.2 string类对象的常见构造
1.3 string类对象的容量操作
1. size() /length()
2. capacity()
3. empty()
4.clear()
5.reserve(size_type n)
6.max_size()
7.resize(n,c)
1.4 string类对象的访问与遍历操作
1. 通过索引访问字符operator[]
2. 使用范围循环遍历字符(范围for)
3. 使用迭代器遍历字符
4. 使用索引遍历字符
5. 常量字符串的访问
1.5 string类对象的修改操作
1. 追加(Append)
2. 插入(Insert)
3. 删除(Erase)
4. 查找(find)
5.截取(substr)
2. 迭代器
2.1常用的迭代器操作
2.2 const迭代器
3. string类的模拟实现
3.1 构造函数
3.2 拷贝构造函数与析构函数
3.2.1 遍历string方式
3.2.2 拷贝构造
3.2.3 析构函数
3.3 迭代器
3.4 增
reserve、push_back、append、+=
insert
resize
3.5 删(erase)
3.6 查
find
substr
3.7 流插入、流提取
3.8 比大小
3.9 赋值运算符重载
1. 标准库中的string类
1.1 特点
- 动态内存管理:
std::string
自动管理内存,动态调整大小,无需手动分配或释放。 - 支持 STL 接口:
std::string
是标准模板库的一部分,支持与其他 STL 容器类似的接口,例如迭代器、比较和算法。 - 多样化的操作:可以方便地进行字符串拼接、子串提取、查找、替换等操作。
- 类型安全:
std::string
提供了一些安全的接口,防止数组越界等问题。
1.2 string类对象的常见构造
constructor函数名称 | 功能说明 |
string() | 构造空string类对象,即空字符串 |
string(const char* s) | 用C-string构造string类对象 |
string(size_t n, char c) | string类对象中包含n个c |
string(const string& s) | 拷贝构造函数 |
#include <iostream>
#include <string> // 必须包含头文件
int main()
{
std::string str1 = "Hello"; // 使用 C 风格字符串初始化
std::string str2("World"); // 构造函数初始化
std::string str3(str1); // 拷贝构造str3
std::string str4(5, 'A'); // 重复字符初始化,生成 "AAAAA"
std::string str5(str1, 0, 3); // 从 str1 的第 0 个位置起,截取 3 个字符
std::string str6 = {"C++"}; // 列表初始化
return 0;
}
1.3 string类对象的容量操作
1. size() /length()
- 功能: 返回当前字符串中的字符个数(字符串长度)。
std::string str = "hello";
std::cout << str.size() << std::endl; // 输出: 5
std::cout << str.length() << std::endl; // 输出: 5
两者虽然是等价的,但是建议使用size,因为string类比较特殊,是与STL不同时期的产物,size是STL出现后新加入string的,原来只有length。引入size的原因是为了与其他容器的接口保持一致。
2. capacity()
- 功能: 返回字符串当前分配的内存容量(即可以存储的字符数,不包括
\0
终止符)。
3. empty()
- 功能: 检查字符串是否为空。
std::string str = "";
if (str.empty()) {
std::cout << "String is empty!" << std::endl;
}
是空返回true,否则返回false
4.clear()
- 功能:清空有效字符
size清为0,capcity不变。只是将string中的有效字符清空,不改变底层空间的大小。
5.reserve(size_type n)
- 功能: 将字符串的容量调整为至少能存储
n
个字符。如果n
小于当前容量,则容量保持不变。
std::string str = "hello";
str.reserve(50); // 将容量至少扩展到 50
std::cout << str.capacity() << std::endl; // 输出: 50 或更大
6.max_size()
- 功能: 返回字符串对象能够存储的最大字符数。这取决于系统和实现。
std::string str;
std::cout << str.max_size() << std::endl; // 输出一个非常大的值
7.resize(n,c)
- 功能:将有效字符的个数改成n个,多出的空间用字符c填充
- 如果
n
小于当前长度,会截断内容。 - 如果
n
大于当前长度,会填充额外字符(通常为'\0'
)。
扩容+开空间+初始化。注意:resize在改变元素个数时,如果是将元素个数增多,可能会改变底层容量的大小,如果是将元素个数减少,发生截断,size()会变小,但是底层空间大小(capcity)不变。
1.4 string类对象的访问与遍历操作
1. 通过索引访问字符operator[]
- 功能: 通过
operator[]
方法访问字符串中的单个字符。 - 特点:
operator[]
不进行边界检查。
std::string str = "hello";
// 使用索引访问字符
char ch1 = str[1]; // 'e'
std::cout << "str[1]: " << ch1 << std::endl;
有两个版本,一个普通版,一个const版
2. 使用范围循环遍历字符(范围for)
- 功能: 使用现代 C++ 的范围
for
循环来遍历字符串中的每个字符。 - 特点: 简洁直观,适用于只读或简单操作。
std::string str = "hello";
for (char ch : str) {
std::cout << ch << " ";
}
// 输出: h e l l o
3. 使用迭代器遍历字符
- 功能: 通过迭代器遍历字符串。
- 种类:
- 正向迭代器 (
begin()
/end()
): 从头到尾。 - 反向迭代器 (
rbegin()
/rend()
): 从尾到头。
- 正向迭代器 (
std::string str = "hello";
// 正向迭代器
for (std::string::iterator it = str.begin(); it != str.end(); ++it)
{
std::cout << *it << " ";
}
// 输出: h e l l o
// 反向迭代器
for (std::string::reverse_iterator rit = str.rbegin(); rit != str.rend(); ++rit)
{
std::cout << *rit << " ";
}
// 输出: o l l e h
4. 使用索引遍历字符
- 功能: 利用索引和
size()
方法遍历字符串。
std::string str = "hello";
for (size_t i = 0; i < str.size(); ++i) {
std::cout << str[i] << " ";
}
// 输出: h e l l o
5. 常量字符串的访问
- 功能: 如果字符串是
const
的,可以使用cbegin()
/cend()
或范围循环来访问字符。
const std::string str = "hello";
// 使用范围循环
for (char ch : str)
{
std::cout << ch << " ";
}
// 使用常量迭代器
for (std::string::const_iterator it = str.cbegin(); it != str.cend(); ++it)
{
std::cout << *it << " ";
}
1.5 string类对象的修改操作
1. 追加(Append)
方法:
append
方法:将字符串或字符追加到当前字符串的末尾。- 使用
operator+=
运算符。(最好使用它)+=不仅可以连接单个字符,还可以连接字符串。
#include <iostream>
#include <string>
int main()
{
std::string str = "Hello";
str.append(" World"); // 追加字符串
str += '!'; // 使用 += 运算符追加字符
std::cout << str << std::endl; // 输出: Hello World!
return 0;
}
2. 插入(Insert)
方法:
insert
方法:在指定位置插入字符串或字符。
int main()
{
std::string str = "Hello!";
str.insert(5, " World"); // 在索引 5 位置插入字符串
std::cout << str << std::endl; // 输出: Hello World!
return 0;
}
3. 删除(Erase)
方法:
erase
方法:删除指定范围内的字符。
int main()
{
std::string str = "Hello World!";
str.erase(5, 6); // 从索引 5 开始删除 6 个字符
std::cout << str << std::endl; // 输出: Hello
return 0;
}
但是上面的insert和erase效率很低,很少用。
4. 查找(find)
方法:
find
用于在字符串中查找 子字符串 或 字符 的第一次出现的位置。
返回值:
- 如果找到了目标字符串或字符,返回其在原字符串中的索引。
- 如果没有找到,则返回
std::string::npos
。npos
是一个非常大的size_t
值(通常是-1
转换为无符号整数后的值),它被用作一个特殊标记值。
rfind则是从字符串pos位置向前找字符,返回该字符在字符串中的位置。
5.截取(substr)
std::string substr(size_t pos = 0, size_t len = npos) const;
参数:
pos
(起始位置)- 表示要提取的子字符串的起始位置(索引),默认为
0
。
- 表示要提取的子字符串的起始位置(索引),默认为
len
(长度)- 表示从起始位置
pos
开始提取的字符数,默认为std::string::npos
,即提取到字符串末尾。
- 表示从起始位置
int main()
{
std::string str = "Hello, World!";
std::string sub = str.substr(7, 5); // 从索引 7 开始提取 5 个字符
std::cout << "Sub: " << sub << std::endl; // 输出: World
return 0;
}
#include <iostream>
#include <string>
int main() {
std::string url = "https://www.example.com/path";
size_t start = url.find("://") + 3; // 查找 "://" 的位置并跳过
size_t end = url.find('/', start); // 查找第一个 '/' 的位置
std::string domain = url.substr(start, end - start); // 提取域名部分
std::cout << "Domain: " << domain << std::endl; // 输出: www.example.com
return 0;
}
输出:Domain: www.example.com
除了上面的函数之外,还有一些非成员函数,不一一举例,下面一些OJ题会体现他们的使用。
2. 迭代器
在 C++ 中,迭代器是一种用于遍历容器(如数组、向量、链表、集合等)的对象或工具。C++ 的迭代器是一种抽象化的指针,它不仅可以访问容器中的元素,还支持很多灵活的操作(如增量操作、比较操作等)。
C++ 的迭代器通过 STL(标准模板库) 提供,包含了迭代器类型和接口的标准定义。
string s1("hello world");
string::iterator it = s1.begin();
while (it != s1.end())
{
cout << *it << endl;
++it;
}
cout << endl;
这个string::iterator只是像指针一样的类型,string::iterator可以直接写成auto。相比于下标+[],string平时不太使用迭代器。要注意上面提到的范围for在底层就会替换为迭代器,而且范围for只能正着遍历,不能反着。
链表就用不了下标加[],因为空间不是连续的。但是任何容器都支持迭代器,并且用法类似。
#include <iostream>
#include <vector>
int main()
{
std::vector<int> vec = {1, 2, 3, 4, 5};
// 使用普通迭代器
std::cout << "Using normal iterator:" << std::endl;
for (std::vector<int>::iterator it = vec.begin(); it != vec.end(); ++it)
{
std::cout << *it << " ";
}
std::cout << std::endl;
// 使用逆序迭代器
std::cout << "Using reverse iterator:" << std::endl;
for (std::vector<int>::reverse_iterator it = vec.rbegin(); it != vec.rend(); ++it)
{
std::cout << *it << " ";
}
std::cout << std::endl;
return 0;
}
Using normal iterator:
1 2 3 4 5
Using reverse iterator:
5 4 3 2 1
当想反向遍历时,有反向迭代器。
2.1常用的迭代器操作
基本操作
begin()
:返回指向容器第一个元素的迭代器。end()
:返回指向容器最后一个元素之后的迭代器。rbegin()
:返回一个指向容器末尾的逆序迭代器。rend()
:返回一个指向容器开头的逆序迭代器之前的位置。
关键运算
*it
:获取迭代器指向的元素。++it
或it++
:将迭代器移动到下一个元素。--it
或it--
:将迭代器移动到前一个元素(仅适用于双向迭代器)。it + n
或it - n
:随机访问迭代器可以偏移位置。it1 == it2
、it1 != it2
:比较两个迭代器是否相等。
2.2 const迭代器
在 C++ 中,const
迭代器(const_iterator
)是一种特殊类型的迭代器,它允许读取容器中的元素,但不允许修改它们。与普通迭代器不同,const_iterator
确保容器中的元素是不可修改的,从而增强了代码的安全性和可读性。
int main()
{
std::vector<int> vec = {1, 2, 3, 4, 5};
// 定义 const_iterator
std::vector<int>::const_iterator it;
for (it = vec.cbegin(); it != vec.cend(); ++it)
{
std::cout << *it << " ";
// *it = 10; // 错误!const_iterator 不允许修改元素
}
return 0;
}
如果容器本身是 const
的,那么只能使用 const_iterator
遍历:
void Func(const string& s)
{
auto it = s.begin(); // string::const_iterator it = s.begin();
while(it != s.end())
{
cout << *it << " ";
++it;
}
cout << endl;
}
const对象是不可改变的,但是如果用普通迭代器的意思就变成了,在迭代器中可以修改,这是不合理的,编译不通过。
3. string类的模拟实现
3.1 构造函数
namespace zzy
{
class string
{
public:
string(const char*str)
:_size(strlen(str))
,_capacity(_size)
,_str(new char[_capacity+1])
{}
private:
char* _str;
size_t _capacity;
size_t _size;
};
}
注意:初始化顺序是声明顺序,这里我先声明了_str,那么会先初始化它,但是在初始化str时用到了还未初始化的capcity,会出错,所以要保持声明与初始化顺序的一致
namespace zzy
{
class string
{
public:
explicit string(const char*str)
:_size(strlen(str))
,_capacity(_size)
,_str(new char[_capacity+1])
{
strcpy(_str, str);
}
private:
size_t _size;
size_t _capacity;
char* _str;
};
}
初始化列表中对_str仅为开空间,下面的strcpy才是初始化。
有时我们会使用无参的构造函数,如s2:
void test_string1()
{
zzy::string s1("hello world!");
cout << s1.c_str() << endl;
zzy::string s2;
cout << s2.c_str() << endl;
}
那无参的构造函数和带参的构造函数怎么合并成一个呢?
正确写法:
explicit string(const char* str = "")
{
_size = strlen(str);
_capcity = _size;
_str = new char[_capacity+1];
strcpy(_str, str);
}
strlen() 遇到 \0 停止,所以 _size = 0,_capcity = 0,_str 就会开一个空间,正好把 str 中的一个 \0 拷贝进 _str。
其他一些写法:
string(const char* str = '\0')
string(const char* str = nullptr)
string(const char* str = "\0")
前两种是错误的,第一种:str应该是指针,\0是字符,类型不匹配。第二种虽然是指针,但是strlen()会使程序崩溃。第三种:可以但是没必要,因为字符串结尾默认有\0,这里再写一个会有两个\0。
最后,注意 strcpy() 函数遇 \0 会终止,若某一字符串中间有 \0 ,strcpy就不适合了,所以需要使用memcpy
explicit string(const char* str = "")
{
_size = strlen(str);
_capcity = _size;
_str = new char[_capacity+1];
memcpy(_str, str,_size+1);
}
3.2 拷贝构造函数与析构函数
3.2.1 遍历string方式
const char* c_str() const
{
return _str;
}
size_t size() const
{
return _size;
}
char& operator[](size_t pos)
{
assert(pos < _size);
return _str[pos];
}
const char& operator[](size_t pos) const
{
assert(pos < _size);
return _str[pos];
}
关于下面两个operator:
- 为什么传引用返回:因为_str[pos]出了作用域还存在,所以用引用,可读可写
- 为什么要写两个,一个普通的,一个const的:[]有两个版本,一个读写版本,一个只读版本,如果我们有一个const对象s3,而没有const实现函数会出错
const zzy::string s3("hello world!");
3.2.2 拷贝构造
string(const string&s)
{
_str = new char[s._capacity+1];
memcpy(_str, s._str,s._size+1);
_size = s.size();
_capacity = s._capacity;
_str = new char[_capacity+1];
}
3.2.3 析构函数
~string()
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
}
3.3 迭代器
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;
}
同理,const对象无法调用普通成员函数,所以要实现两个版本。
注意在测试时,如果你直接写 iterator
而没有指定它属于哪个类,编译器会尝试在全局作用域中查找名字 iterator
,而不是自动进入 string
类的作用域。
void test_string1()
{
zzy::string s1("hello world!");
cout << s1.c_str() << endl;
zzy::string::iterator it = s1.begin();
//iterator it = s1.begin();
while(it != s1.end())
{
cout << *it << endl;
++it;
}
}
如果我们使用范围for,可以发现我们只要写了迭代器,没写for,但是可以正常用,在编译器看来,两者是一个东西,在底层是替换的。
for (auto ch : s1)
{
cout << ch << " ";
}
cout << endl;
3.4 增
reserve、push_back、append、+=
void reserve(size_t n)
{
if(n > _capacity)
{
char* tmp = new char[n+1];
memcpy(tmp,_str,_size+1);
delete[] _str;
_str = tmp;
_capacity = n;
}
}
void push_back(char ch)
{
if(_size == _capacity)
{
reserve(_capacity == 0 ? 4: _capacity*2);
}
_str[_size] = ch;
++_size;
_str[_size] = '\0';
}
void append(const char* str)
{
size_t len = strlen(str);
//如果插入后的大小大于容量,二倍的容量不一定有size+len大
if(_size + len > _capacity)
{
reserve(_size+len);
}
memcpy(_str+_size, str, len+1);
_size += len;
}
string& operator+=(char ch)
{
push_back(ch);
return *this;
}
string operator+=(const char* str)
{
append(str);
return *this;
}
测试:
void test_string2()
{
zzy::string s1("hello world");
cout << s1.c_str() << endl;
s1.push_back(' ');
s1.append("balabala");
cout << s1.c_str() << endl;
}
insert
void insert(size_t pos, size_t n, char ch)
{
assert(pos<=_size);
if(_size + n > _capacity)
{
reserve(_size+n);
}
size_t end = _size;
while (end>=pos && end!=npos)
{
_str[end+n] = _str[end];
--end;
}
for (size_t i=0; i<n; i++)
{
_str[pos+i] = ch;
}
_size += n;
}
其中在向后挪动数据时,写了end>=pos && end!=npos,下面来分析一下它是什么,为什么要写:
如果我们这样写:
size_t end = _size;
while (end>=pos)
{
_str[end+n] = _str[end];
--end;
}
如果 pos=0 呢?当 end<pos 时循环结束,但 pos=0,需要 end<0 才可以结束循环,但是size_t不可能 <0,所以程序会崩溃。若我们将 end 定义为 int,还是跑不通,调试发现 end=-1,pos=0时还会进入循环,明明已经小于可,为什么没结束呢?因为 end>=pos 的类型是 int>=size_t,有符号向无符号提升,-1会被看作最大值,是>0的一个数,所以还会进入循环。
解决办法:
private:
size_t _size;
size_t _capacity;
char* _str;
static size_t npos;
};
size_t string::npos = -1;
}
在代码中,npos
是常见的表示“无效位置”(not position)的值,通常在 C++ 标准库的std::string
和 std::basic_string
中使用。它通常被定义为一个很大的无符号整数(通常是 -1
转换为无符号类型后的值)。
但是为什么要这样定义npos呢?
-1
是一个有符号整数值,将其赋值给无符号的size_t
时,会通过类型转换变成size_t
类型的最大值。在 C++ 标准库中,std::string::npos
被定义为static const size_t npos = -1;
,其用途是完全一致的。- 静态成员变量不能在声明时给缺省值=-1,因为这个缺省值是给初始化列表用的,但是静态成员变量不在初始化列表中,因为它不属于某一个对象而属于全局的,静态成员变量必须在类的外部显式定义并初始化,类中只是声明。
resize
void resize(size_t n, char ch = '\0')
{
if (n<_size)
{
_size = n;
_str[_size] = '\0';
}
else
{
reserve(n);
for (size_t i = 0; i<n; i++)
{
_str[i] = ch;
}
_size = n;
_str[_size] = '\0';
}
}
3.5 删(erase)
void erase(size_t pos, size_t len = npos)
{
assert(pos <= _size);
if(len==npos || pos + len >= _size)
{
_str[pos] = '\0';//将字符串从 pos 处截断。
//字符串的有效长度现在是从索引 0到pos-1,索引 pos 以及之后的内容被认为是无效的。
_size = pos;
_str[_size] = '\0';//再次确保字符串有结束符
}
else
{
size_t end = pos + len;
while (end<=_size)
{
_str[pos++] = _str[end++];
}
_size -= len;
}
}
3.6 查
find
size_t find(char ch, size_t pos = 0)
{
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* ptr = strstr(_str + pos, str);
if(ptr)
{
return ptr-_str;//字符串索引是通过整数值表示的,而 ptr - _str 正好返回了这个整数值
}
else
{
return npos;
}
}
substr
string substr(size_t pos = 0, size_t len = npos)
{
assert(pos < _size);
size_t n = len;
if(len == npos || pos + len > _size)
{
n = _size - pos;
}
string tmp;
tmp.reserve(n);
for (size_t i=pos; i<n; i++)
{
tmp += _str[i];
}
return tmp;
}
3.7 流插入、流提取
由于这两个函数的左操作数不能为*this,所以不能实现为成员函数,要实现在命名空间中。
流插入:
ostream& operator<<(ostream& out, const string& s)
{
for (auto ch : s)
{
out << ch;
}
return out;
}
c_str 与 直接流插入不同,c_str在碰到 '\0' 会停止,流插入就会打印它:
void test_string2()
{
zzy::string s1("hello world");
cout << s1.c_str() << endl;
s1.push_back(' ');
s1.append("balabala");
cout << s1.c_str() << endl;
s1 += '\0';
s1 += "!!!!";
cout << s1.c_str() << endl;
cout << s1 << endl;
}
结果为:
hello world
hello world balabala
hello world balabala
hello world balabala !!!!
这里也体现了前面拷贝构造中需要使用memcpy而不用strcpy的问题。即如果我们的字符串中有 \0,在进行拷贝构造时如果用strcpy则只会拷贝 \0 之前的字符。
流提取:
istream& operator>>(istream& in, string& s)
{
s.clear();//清空旧缓冲区
char ch = in.get();
//处理缓冲区前面的空格或换行
while (ch == ' ' || ch == '\n')
{
ch = in.get();
}
char buff[128];
int i = 0;
while (ch != ' ' && ch != '\n')
{
buff[i++] = ch;
if (i == 127)
{
buff[i] = '\0';//buff装满了,把buff的存入s,将buff清空
s += buff;
i = 0;
}
ch = in.get();
}
if (i != 0)
{
buff[i] = '\0';
s += buff;
}
return in;
}
void clear()
{
_str[0] = '\0';
_size = 0;
}
3.8 比大小
bool operator<(const string& s) const
{
size_t i1 = 0;
size_t i2 = 0;
while (i1 < _size && i2 < _size)
{
if(_str[i1] < s._str[i2])
{
return true;
}
else if(_str[i1] > s._str[i2])
{
return false;
}
else
{
++i1;
++i2;
}
}
return i1 == _size && i2 != s._size;
}
bool operator==(const string& s) const
{
return _size == s._size
&& memcmp(_str, s._str, _size) == 0;
}
bool operator<=(const string& s) const
{
return *this < s || *this == s;
}
bool operator>(const string& s) const
{
return !(*this <= s);
}
bool operator>=(const string& s) const
{
return !(*this < s);
}
bool operator!=(const string& s) const
{
return !(*this == s);
}
3.9 赋值运算符重载
传统写法:
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;
}
有一种现代写法:
string& operator=(const string& s)
{
if(this != &s)
{
string tmp(s);
std::swap(_str, tmp._str);
std::swap(_size, tmp._size);
std::swap(_capacity, tmp._capacity);
}
return *this;
}
tmp是临时变量,结束后销毁,所以直接跟他交换,换完后原来的就销毁了。注意交换的是成员,不能写成swap(tmp, *this)直接交换对象,因为在swap中整个对象会通过赋值操作交换,而赋值操作符会依赖于自己(我们正在实现的这个赋值操作符)。这可能导致递归调用(即赋值操作符调用自身,进入死循环)。
于是衍生出了一种真正现代的写法:
string& operator=(string tmp)
{
swap(tmp);
return *this;
}
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
调用 s1 = s3 一开始tmp就是s3的拷贝,而且是深拷贝。按值传递会在调用 operator=
时,为参数创建一个独立的副本(通过拷贝构造函数)。按值传递实际上会创建一个副本(具体是浅拷贝还是深拷贝,取决于数据类型和语言实现)。
拷贝构造函数虽然也有类似的现代写法,但是并不推荐。