在 C++ 中,std::string
是一个非常重要的类,位于标准库的 <string>
头文件中,专门用于处理字符串。它提供了功能丰富的接口和动态的内存管理,是 C 风格字符串(字符数组 char[]
)的更高效、更安全的替代品。
目录
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=
时,为参数创建一个独立的副本(通过拷贝构造函数)。按值传递实际上会创建一个副本(具体是浅拷贝还是深拷贝,取决于数据类型和语言实现)。
拷贝构造函数虽然也有类似的现代写法,但是并不推荐。