C++ string 类详解:从常用接口到深拷贝,彻底掌握字符串处理

🎬 个人主页 :道尔柯南
❄专栏传送门 :《C语言》《C++》《Linux操作系统》

昙花一现,却等待了整个白昼;蝉鸣一夏,却蛰伏了好几个四季。

文章目录

    • 一、前言
    • [二、为什么需要 string 类?](#二、为什么需要 string 类?)
      • [2.1 C 语言字符串的特点](#2.1 C 语言字符串的特点)
      • [2.2 C++ string 类的优势](#2.2 C++ string 类的优势)
    • [三、string 类的基本使用](#三、string 类的基本使用)
      • [3.1 头文件](#3.1 头文件)
      • [3.2 string 对象的构造](#3.2 string 对象的构造)
    • [四、C++11 新特性:auto 与范围 for](#四、C++11 新特性:auto 与范围 for)
      • [4.1 auto:让编译器推导类型](#4.1 auto:让编译器推导类型)
      • [4.2 auto 与引用](#4.2 auto 与引用)
      • [4.3 范围 for:简化遍历](#4.3 范围 for:简化遍历)
    • [五、string 的容量操作](#五、string 的容量操作)
      • [5.1 size() 与 length()](#5.1 size() 与 length())
      • [5.2 capacity():查看容量](#5.2 capacity():查看容量)
      • [5.3 empty():判断字符串是否为空](#5.3 empty():判断字符串是否为空)
      • [5.4 clear():清空字符串](#5.4 clear():清空字符串)
      • [5.5 resize():调整有效字符数量](#5.5 resize():调整有效字符数量)
      • [5.6 reserve():预留容量](#5.6 reserve():预留容量)
    • [六、string 的访问与遍历](#六、string 的访问与遍历)
      • [6.1 使用下标访问字符](#6.1 使用下标访问字符)
      • [6.2 使用迭代器遍历](#6.2 使用迭代器遍历)
      • [6.3 使用范围 for 遍历](#6.3 使用范围 for 遍历)
    • [七、string 的修改、查找与截取](#七、string 的修改、查找与截取)
      • [7.1 push_back():尾部追加字符](#7.1 push_back():尾部追加字符)
      • [7.2 append():追加字符串](#7.2 append():追加字符串)
      • [7.3 operator+=:推荐掌握的拼接方式](#7.3 operator+=:推荐掌握的拼接方式)
      • [7.4 c_str():获取 C 风格字符串](#7.4 c_str():获取 C 风格字符串)
      • [7.5 find():从前向后查找](#7.5 find():从前向后查找)
      • [7.6 rfind():从后向前查找](#7.6 rfind():从后向前查找)
      • [7.7 substr():截取子字符串](#7.7 substr():截取子字符串)
    • [八、string 的非成员函数与比较操作](#八、string 的非成员函数与比较操作)
      • [8.1 使用 + 拼接字符串](#8.1 使用 + 拼接字符串)
      • [8.2 使用 cin 和 cout 输入输出](#8.2 使用 cin 和 cout 输入输出)
      • [8.3 使用 getline() 读取整行](#8.3 使用 getline() 读取整行)
      • [8.4 使用比较运算符](#8.4 使用比较运算符)
    • [九、string 结构与内存管理](#九、string 结构与内存管理)
      • [9.1 为什么 string 需要管理内存?](#9.1 为什么 string 需要管理内存?)
      • [9.2 小字符串优化](#9.2 小字符串优化)
      • [9.3 如何理解容量和有效长度?](#9.3 如何理解容量和有效长度?)
    • 十、经典面试问题:浅拷贝与深拷贝
      • [10.1 一个存在问题的 String 类](#10.1 一个存在问题的 String 类)
      • [10.2 什么是浅拷贝?](#10.2 什么是浅拷贝?)
      • [10.3 什么是深拷贝?](#10.3 什么是深拷贝?)
      • [10.4 深拷贝的 String 实现](#10.4 深拷贝的 String 实现)
      • [10.5 为什么赋值运算符要返回引用?](#10.5 为什么赋值运算符要返回引用?)
      • [10.6 现代写法:拷贝与交换](#10.6 现代写法:拷贝与交换)
    • 十一、写时拷贝是什么?
    • [十二、经典 string 算法练习](#十二、经典 string 算法练习)
      • [12.1 仅反转字母](#12.1 仅反转字母)
      • [12.2 找出字符串中第一个只出现一次的字符](#12.2 找出字符串中第一个只出现一次的字符)
      • [12.3 判断回文字符串](#12.3 判断回文字符串)
      • [12.4 字符串相加](#12.4 字符串相加)
    • [十三、string 学习路线总结](#十三、string 学习路线总结)
    • 十四、总结
  • 结尾

一、前言

在 C++ 编程中,字符串是最常见的数据类型之一。无论是处理用户输入、读取文件、解析数据,还是参加算法竞赛,我们都离不开字符串。

在 C 语言中,我们通常使用字符数组来表示字符串,例如:

复制代码
char str[] = "hello";

虽然这种方式简单,但在实际使用中,需要注意字符串结束标志 '\0'、数组容量以及内存管理等问题。

为了解决这些问题,C++ 标准库提供了 string 类。通过 string,我们可以更方便地创建、修改、查找和比较字符串,而不必手动管理每一个字符数组的细节。

本文将结合 C++ 初学者的学习路线,系统介绍 string 类的常用操作,并进一步分析字符串算法与自定义字符串类中的深拷贝问题。

本文学习目标:

  • 理解为什么需要 string 类。
  • 掌握 string 的构造、容量、访问和修改操作。
  • 学会使用 auto 和范围 for 简化代码。
  • 掌握字符串查找、截取、比较等常见操作。
  • 通过算法练习理解双指针和字符统计思想。
  • 理解浅拷贝、深拷贝以及资源管理的基本原理。

二、为什么需要 string 类?

2.1 C 语言字符串的特点

在 C 语言中,字符串通常是以 '\0' 结尾的字符序列。

例如:

复制代码
char str[] = "hello";

它在内存中的逻辑结构可以表示为:

'h' 'e' 'l' 'l' 'o' '\0'

字符串中的有效字符有 5 个,但数组还需要额外的空间存储结束标志 '\0'。

C 语言提供了一系列字符串处理函数,例如:

  • strlen():计算字符串长度。
  • strcpy():复制字符串。
  • strcat():拼接字符串。
  • strcmp():比较字符串。

使用这些函数时,程序员需要自行保证目标空间足够、字符串正确结束,以及内存访问合法。

例如:

复制代码
#include <iostream>
#include <cstring>

using namespace std;

int main()
{
    char str1[] = "hello";
    char str2[20];

    strcpy(str2, str1);

    cout << str2 << endl;

    return 0;
}

这段代码可以正常工作,但如果目标数组空间不足,复制操作就可能引发越界访问。

此外,C 语言字符串函数与字符数组是分开管理的,代码写得不够谨慎时容易出现内存安全问题。

2.2 C++ string 类的优势

C++ 提供了 std::string,它能够封装字符串数据,并提供一系列方便的成员函数。

例如:

复制代码
#include <iostream>
#include <string>

using namespace std;

int main()
{
    string s = "hello";

    s += " world";

    cout << s << endl;
    cout << s.size() << endl;

    return 0;
}

输出:

复制代码
hello world
11

与传统字符数组相比,string 具有以下优点:

  1. 使用方便: 可以直接通过 + 或 += 拼接字符串。
  2. 管理长度: 可以使用 size() 获取有效字符的数量。
  3. 方便修改: 可以通过下标、成员函数等方式修改内容。
  4. 支持比较: 可以直接使用比较运算符比较字符串。
  5. 便于配合 STL: 可以使用迭代器、算法以及范围 for 等功能。

因此,在日常 C++ 开发和算法题中,通常优先使用 string 处理字符串。

三、string 类的基本使用

3.1 头文件

使用 string 类,需要包含对应的头文件:

复制代码
#include <string>

如果程序还需要输入输出,则通常还要包含:

复制代码
#include <iostream>

完整示例:

复制代码
#include <iostream>
#include <string>

using namespace std;

int main()
{
    string s = "hello";

    cout << s << endl;

    return 0;
}

需要注意,string 的完整名称是 std::string。如果没有使用 using namespace std;,就应写成 std::string。

3.2 string 对象的构造

介绍几种常用构造方式。

写法 含义
string s1; 创建空字符串
string s2("hello"); 使用 C 风格字符串构造
string s3(5, 'a'); 创建由 5 个字符 a 组成的字符串
string s4(s2); 通过已有字符串复制构造

示例:

复制代码
#include <iostream>
#include <string>

using namespace std;

int main()
{
    string s1;
    string s2("hello");
    string s3(5, 'a');
    string s4(s2);

    cout << "s1: " << s1 << endl;
    cout << "s2: " << s2 << endl;
    cout << "s3: " << s3 << endl;
    cout << "s4: " << s4 << endl;

    return 0;
}

输出:

复制代码
s1:
s2: hello
s3: aaaaa
s4: hello

初学者需要重点掌握空字符串构造、C 风格字符串构造以及拷贝构造。

四、C++11 新特性:auto 与范围 for

为了更方便地遍历字符串和容器,C++11 引入了 auto 类型推导和范围 for 循环。

这两个特性在现代 C++ 代码中非常常见。

4.1 auto:让编译器推导类型

auto 可以让编译器根据变量的初始化表达式推导变量类型。

例如:

复制代码
int a = 10;
auto b = a;

char c = 'A';
auto d = c;

这里,编译器会推导出:

  • b 的类型为 int。
  • d 的类型为 char。

使用 auto 时,变量必须能够从初始化表达式中推导出类型。

错误示例:

复制代码
auto x;  // 错误:没有初始化表达式,无法推导类型

auto 还可以用于迭代器。

例如:

复制代码
#include <iostream>
#include <map>
#include <string>

using namespace std;

int main()
{
    map<string, string> dict = {
        {"apple", "苹果"},
        {"orange", "橙子"},
        {"pear", "梨"}
    };

    auto it = dict.begin();

    while (it != dict.end())
    {
        cout << it->first << ":"
             << it->second << endl;

        ++it;
    }

    return 0;
}

如果不使用 auto,就需要写出较长的迭代器类型。

因此,auto 可以让代码更加简洁,特别适合类型名称复杂的场景。

4.2 auto 与引用

当我们需要修改原始变量时,需要注意 auto 与 auto& 的区别。

复制代码
int x = 10;

auto a = x;
auto& b = x;

a = 20;
b = 30;

最终:

复制代码
x = 30
a = 20
b 引用 x

原因在于:

  • auto a = x; 创建了一个独立变量。
  • auto& b = x; 创建了一个引用,b 与 x 指向同一个变量。

在字符串遍历中,如果需要修改原字符串,就要特别注意是否使用引用。

4.3 范围 for:简化遍历

传统遍历字符串通常需要下标:

复制代码
string s = "hello";

for (size_t i = 0; i < s.size(); ++i)
{
    cout << s[i] << " ";
}

使用范围 for 后,可以写成:

复制代码
string s = "hello";

for (auto ch : s)
{
    cout << ch << " ";
}

输出:

复制代码
h e l l o

范围 for 会自动遍历集合中的元素,不需要我们手动维护下标。

如果想修改字符串中的字符,则应使用引用:

复制代码
string s = "hello";

for (auto& ch : s)
{
    if (ch >= 'a' && ch <= 'z')
    {
        ch = ch - 'a' + 'A';
    }
}

cout << s << endl;

输出:

复制代码
HELLO

这里的 auto& ch 让 ch 引用字符串中的实际字符,因此修改 ch 就会改变原字符串。

如果写成 auto ch,循环变量只是字符副本,修改它不会改变原字符串。

五、string 的容量操作

字符串不仅有内容,还有长度和容量等概念。

这是学习 string 时非常重要的一部分。

5.1 size() 与 length()

这两个成员函数都用于获取字符串的有效字符数量。

复制代码
string s = "hello";

cout << s.size() << endl;
cout << s.length() << endl;

输出:

复制代码
5
5

在标准 std::string 中,size() 和 length() 的含义相同。

一般情况下,建议优先使用 size(),因为它与 STL 中许多容器的接口风格保持一致。

5.2 capacity():查看容量

capacity() 用于获取当前字符串在不重新分配存储空间的情况下,能够容纳的字符数量。

复制代码
string s = "hello";

cout << "size: " << s.size() << endl;
cout << "capacity: " << s.capacity() << endl;

这里的 size() 通常为 5,但 capacity() 的具体值取决于标准库实现,不能假定一定等于 5。

可以这样理解:

  • size():当前实际保存了多少个有效字符。
  • capacity():当前分配的存储空间最多能够容纳多少个字符。

5.3 empty():判断字符串是否为空

empty() 用于判断字符串当前是否不包含任何字符。

复制代码
string s;

if (s.empty())
{
    cout << "字符串为空" << endl;
}

与直接判断 s.size() == 0 相比,empty() 的表达更加直观。

5.4 clear():清空字符串

clear() 用于清除字符串的有效内容。

复制代码
string s = "hello";

s.clear();

cout << s.size() << endl;
cout << s.empty() << endl;

输出:

复制代码
0
1

需要注意:

clear() 清除的是字符串的有效字符,不保证释放已经分配的容量。

因此,清空字符串后,size() 会变为 0,但 capacity() 不一定变为 0。

5.5 resize():调整有效字符数量

resize() 用于调整字符串的有效字符数量。

如果新长度小于原长度,多余的字符会被删除;如果新长度大于原长度,则会在末尾补充字符。

示例:

复制代码
string s = "hello";

s.resize(3);
cout << s << endl;

s.resize(5, 'x');
cout << s << endl;

输出:

复制代码
hel
helxx

resize(n, c) 在需要增加字符时,会使用指定字符 c 进行填充。

而 resize(n) 在增加有效字符数量时,会使用值为 char{} 的字符填充,通常表现为 '\0'。

需要注意,resize() 可能引起容量变化,尤其是在字符串需要容纳更多字符时。

5.6 reserve():预留容量

reserve() 用于请求字符串预留一定的容量,但不会直接改变字符串的有效字符数量。

例如:

复制代码
string s;

s.reserve(100);

cout << s.size() << endl;
cout << s.capacity() << endl;

此时 size() 仍然为 0,而容量至少能够满足所请求的大小;具体容量可能更大。

如果我们预先知道字符串需要保存大量字符,可以考虑使用 reserve(),减少追加字符时可能发生的重复内存分配。

需要注意,reserve() 是容量管理接口,不是修改字符串有效长度的接口。

六、string 的访问与遍历

6.1 使用下标访问字符

operator[] 可以根据位置访问字符串中的字符。

复制代码
string s = "hello";

cout << s[0] << endl;
cout << s[1] << endl;

输出:

复制代码
h
e

字符串下标从 0 开始,因此:

  • s[0] 是第一个字符。
  • s[1] 是第二个字符。
  • s[s.size() - 1] 是最后一个字符,前提是字符串非空。

通过非 const 字符串对象的下标访问,还可以修改字符:

复制代码
string s = "hello";

s[0] = 'H';

cout << s << endl;

输出:

复制代码
Hello

访问字符时必须保证下标合法,否则可能出现未定义行为。

6.2 使用迭代器遍历

string 支持迭代器。

复制代码
string s = "hello";

for (auto it = s.begin(); it != s.end(); ++it)
{
    cout << *it << " ";
}

输出:

复制代码
h e l l o

其中:

  • begin() 返回指向第一个字符的迭代器。
  • end() 返回指向最后一个字符之后位置的迭代器。
  • *it 用于访问迭代器指向的字符。

需要记住,end() 并不指向最后一个字符本身。

6.3 使用范围 for 遍历

对于初学者,范围 for 往往是最简单的遍历方式。

复制代码
string s = "hello";

for (auto ch : s)
{
    cout << ch << endl;
}

如果只需要读取字符,使用 auto ch 就可以。

如果需要修改字符,使用 auto& ch。

七、string 的修改、查找与截取

7.1 push_back():尾部追加字符

复制代码
string s = "hello";

s.push_back('!');

cout << s << endl;

输出:

复制代码
hello!

push_back() 用于在字符串末尾追加一个字符。

7.2 append():追加字符串

复制代码
string s = "hello";

s.append(" world");

cout << s << endl;

输出:

复制代码
hello world

append() 可以将指定字符串追加到当前字符串末尾。

7.3 operator+=:推荐掌握的拼接方式

+= 可以追加单个字符,也可以追加另一个字符串。

复制代码
string s = "hello";

s += ' ';
s += "world";

cout << s << endl;

输出:

复制代码
hello world

在日常编程中,+= 通常是非常方便的字符串追加方式。

当需要连续构造字符串时,可以使用它来表达拼接意图。

7.4 c_str():获取 C 风格字符串

有些 C 语言函数或旧接口要求传入以 '\0' 结尾的字符指针,此时可以使用 c_str()。

复制代码
#include <iostream>
#include <string>
#include <cstring>

using namespace std;

int main()
{
    string s = "hello";

    cout << strlen(s.c_str()) << endl;

    return 0;
}

输出:

复制代码
5

c_str() 返回指向以空字符结尾的字符序列的指针。

这个指针由 string 对象管理,通常不应该自行释放。也不要在字符串被修改或销毁后继续依赖之前获取的指针。

7.5 find():从前向后查找

find() 用于查找指定字符或子字符串。

复制代码
string s = "hello world";

size_t pos = s.find("world");

if (pos != string::npos)
{
    cout << "找到位置:" << pos << endl;
}

输出:

复制代码
找到位置:6

find() 找到目标时返回其起始位置;找不到时返回 string::npos。

需要特别注意:

不要直接把位置 0 当成查找失败。

因为目标完全可能出现在字符串的第一个位置。

正确写法是:

复制代码
if (pos != string::npos)
{
    // 找到了
}

7.6 rfind():从后向前查找

rfind() 用于从后向前搜索指定字符或子字符串。

例如,获取一句话最后一个单词的长度:

复制代码
#include <iostream>
#include <string>

using namespace std;

int main()
{
    string s = "hello world";

    size_t pos = s.rfind(' ');

    size_t len = (pos == string::npos)
                 ? s.size()
                 : s.size() - pos - 1;

    cout << len << endl;

    return 0;
}

输出:

复制代码
5

这里需要考虑两种情况:

  1. 如果字符串包含空格,最后一个空格之后的部分就是最后一个单词。
  2. 如果字符串中没有空格,那么整段字符串就是一个单词。

这也是使用 rfind() 时常见的边界条件。

7.7 substr():截取子字符串

substr() 可以从指定位置开始截取一段字符串。

复制代码
string s = "hello world";

string sub = s.substr(6, 5);

cout << sub << endl;

输出:

复制代码
world

substr(pos, n) 中:

  • pos 表示起始位置。
  • n 表示希望截取的字符数量。

如果没有指定第二个参数,通常会截取从起始位置到字符串末尾的内容。

使用时需要保证起始位置合法;如果起始位置超过允许范围,可能抛出 std::out_of_range。

八、string 的非成员函数与比较操作

除了成员函数,string 还支持一些常见的非成员操作。

8.1 使用 + 拼接字符串

复制代码
string s1 = "hello";
string s2 = " world";

string s3 = s1 + s2;

cout << s3 << endl;

输出:

复制代码
hello world

operator+ 会生成拼接后的结果。

如果在循环中反复使用 + 创建新的字符串,可能带来额外的内存分配和复制成本。对于需要持续追加内容的场景,可以考虑使用 +=,必要时提前预留容量。

8.2 使用 cin 和 cout 输入输出

string 支持直接使用输入输出运算符。

复制代码
string s;

cin >> s;
cout << s << endl;

但是,cin >> s 遇到空白字符时就会停止读取。

如果输入的是:

复制代码
hello world

那么通常只会读入 hello。

8.3 使用 getline() 读取整行

如果需要读取包含空格的一整行,可以使用 getline()。

复制代码
#include <iostream>
#include <string>

using namespace std;

int main()
{
    string line;

    getline(cin, line);

    cout << line << endl;

    return 0;
}

输入:

复制代码
hello world

输出:

复制代码
hello world

在处理多组输入时,如果前面使用了 cin >>,后面紧接着调用 getline(),需要注意输入缓冲区中可能残留换行符。此时应根据输入格式正确处理换行。

8.4 使用比较运算符

string 支持常见的比较操作:

复制代码
string s1 = "apple";
string s2 = "banana";

cout << (s1 < s2) << endl;
cout << (s1 == s2) << endl;

对于普通字符串,这些比较按照字符串的字典序规则进行。

需要注意,字典序比较并不等同于自然语言中的所有排序规则,也不一定等同于忽略大小写后的比较。

九、string 结构与内存管理

下面介绍不同编译器实现下 string 对象的内部结构。

这部分内容可以帮助我们理解字符串为什么能够自动管理内存,但也需要注意:std::string 的内部布局不是由 C++ 标准固定规定的,不同标准库版本和平台可能有明显区别。

9.1 为什么 string 需要管理内存?

字符串长度不是固定的。

例如:

复制代码
string s = "hello";

s += " world";

当字符串追加内容时,如果现有容量不足,标准库可能需要申请新的存储空间,并将原有内容迁移过去。

因此,string 内部需要记录字符串的有效长度,并管理实际使用的存储空间。

在不同实现中,这些信息的组织方式可能不同。

9.2 小字符串优化

一些现代 string 实现会使用小字符串优化(Small String Optimization,SSO)。

其基本思想是:对于较短的字符串,直接使用对象内部的存储空间保存内容,从而减少动态内存分配。

对于较长的字符串,则可能使用动态分配的存储空间。

这种优化能够减少某些短字符串的内存分配开销。

特定 Visual C++ 实现,就是通过内部缓冲区与其他存储方式配合来管理字符串。

但是,内部缓冲区的大小、对象占用的字节数等具体细节取决于实现,不应该把某一版本的结构当作所有编译器的固定规则。

9.3 如何理解容量和有效长度?

可以把字符串想象成一个有空位的储物盒:

  • size() 表示目前已经放入的有效字符数量。
  • capacity() 表示当前容量能够容纳的字符数量。
  • reserve() 用于请求准备更多空间。
  • resize() 用于改变有效字符数量。

理解这几个概念后,就能更容易分析字符串追加、扩容和内存管理等问题。

十、经典面试问题:浅拷贝与深拷贝

在掌握标准 string 的使用方法后,进一步讨论如何模拟实现一个简化版字符串类。

这部分知识涉及 C++ 类与对象、动态内存管理、拷贝构造函数、赋值运算符重载以及析构函数,是面试中常见的考查方向。

10.1 一个存在问题的 String 类

假设我们自己实现一个字符串类:

复制代码
#include <cstring>

class String
{
public:
    String(const char* str = "")
    {
        _str = new char[strlen(str) + 1];
        strcpy(_str, str);
    }

    ~String()
    {
        delete[] _str;
    }

private:
    char* _str;
};

构造函数申请一块动态内存,并把字符串内容复制进去。

析构函数负责释放这块内存。

乍一看,这个类似乎没有问题。

但是,如果执行下面的代码:

复制代码
String s1("hello");
String s2(s1);

问题就出现了。

我们没有显式定义拷贝构造函数,因此编译器生成的默认拷贝构造会逐成员复制对象中的内容。

由于 _str 是指针,复制指针只会复制地址,不会自动复制它指向的字符串内容。

结果就是:s1 和 s2 指向同一块内存。

当两个对象先后析构时,它们可能尝试重复释放同一块内存,导致程序出现未定义行为。

这种只复制对象中成员值的行为,在资源管理类中通常称为浅拷贝问题。

10.2 什么是浅拷贝?

浅拷贝可以理解为:复制对象中的成员值,但不为其管理的资源创建独立副本。

假设对象内部有一个指针:

复制代码
s1._str ─────┐
             ├──> 同一块动态内存
s2._str ─────┘

两个对象共享同一个地址。

如果一个对象释放了这块内存,另一个对象中的指针就可能变成悬空指针。

继续使用它,或者再次释放它,都可能导致严重问题。

因此,对于直接管理动态资源的类,不能不加思考地依赖默认的浅拷贝行为。

10.3 什么是深拷贝?

深拷贝的基本思想是:复制对象时,为新对象分配独立资源,再复制原对象的内容。

示意图如下:

复制代码
s1._str ───────> 内存区域 A:"hello"

s2._str ───────> 内存区域 B:"hello"

两个对象保存的内容相同,但它们管理的内存互相独立。

这样,一个对象析构时释放自己的内存,不会影响另一个对象。

10.4 深拷贝的 String 实现

下面给出一个简化示例:

复制代码
#include <cassert>
#include <cstring>

class String
{
public:
    String(const char* str = "")
    {
        assert(str != nullptr);

        size_t len = strlen(str);

        _str = new char[len + 1];
        strcpy(_str, str);
    }

    String(const String& other)
    {
        size_t len = strlen(other._str);

        _str = new char[len + 1];
        strcpy(_str, other._str);
    }

    String& operator=(const String& other)
    {
        if (this != &other)
        {
            size_t len = strlen(other._str);

            char* newStr = new char[len + 1];
            strcpy(newStr, other._str);

            delete[] _str;
            _str = newStr;
        }

        return *this;
    }

    ~String()
    {
        delete[] _str;
    }

private:
    char* _str;
};

这段代码实现了:

  • 构造函数:申请空间并复制初始内容。
  • 拷贝构造函数:为新对象分配独立空间。
  • 赋值运算符:先复制内容,再替换原有资源。
  • 析构函数:释放对象管理的空间。

这里的 assert() 用于表达参数必须非空的前提。需要注意,断言在关闭断言检查的构建配置下可能不执行;如果类需要在所有构建配置下都可靠地处理空指针,还应明确设计相应的错误处理策略。

此外,实际工程中的资源管理代码还需要考虑异常安全等问题。上面的示例主要用于解释深拷贝的核心思路。

10.5 为什么赋值运算符要返回引用?

注意以下声明:

复制代码
String& operator=(const String& other);

它返回 String&,也就是当前对象的引用。

在赋值完成后:

复制代码
return *this;

这样可以支持连续赋值等常见语法,同时避免为了返回结果而额外复制对象。

例如:

复制代码
a = b = c;

赋值运算符通常会先完成 b = c,然后再完成 a = b。

10.6 现代写法:拷贝与交换

介绍一种更简洁的思路:拷贝与交换(Copy-and-Swap)。

其核心是:

  1. 通过值传递参数获得一个副本。
  2. 使用 swap() 交换当前对象和副本管理的资源。
  3. 函数结束时,临时副本析构并释放原先的资源。

示意代码:

复制代码
#include <cstring>
#include <utility>

class String
{
public:
    String(const char* str = "")
    {
        str = (str == nullptr) ? "" : str;

        size_t len = std::strlen(str);

        _str = new char[len + 1];
        std::strcpy(_str, str);
    }

    String(const String& other)
    {
        size_t len = std::strlen(other._str);

        _str = new char[len + 1];
        std::strcpy(_str, other._str);
    }

    String& operator=(String other)
    {
        std::swap(_str, other._str);
        return *this;
    }

    ~String()
    {
        delete[] _str;
    }

private:
    char* _str;
};

这里的赋值运算符参数不是引用,而是按值传递。

当传入另一个 String 对象时,会先构造参数副本。随后交换指针,原对象获得副本的资源,而参数副本接管原对象的旧资源,并在函数结束时自动释放它。

这种写法有助于简化赋值过程,并改善异常安全性。

需要注意,这个示例用于说明原理,并不是完整的生产级字符串类。实际设计还应考虑移动语义、异常处理、接口完整性等因素。

十一、写时拷贝是什么?

介绍写时拷贝(Copy-on-Write,COW)。

写时拷贝的基本思想是:多个对象在没有修改内容时,可以共享某份资源;当其中一个对象需要修改内容时,再为其创建独立副本。

例如:

复制代码
对象 A ───┐
          ├──> 共享字符串资源
对象 B ───┘

当对象 B 需要修改字符串时,再为它创建独立资源:

复制代码
对象 A ───────> 原字符串资源

对象 B ───────> 新字符串资源

实现思路中,还会使用引用计数记录共享资源的使用者数量。

当对象创建或销毁时,引用计数随之变化;只有不再有对象使用该资源时,才释放对应内存。

不过,写时拷贝是一种历史上使用过的实现策略,并不是现代 std::string 必须采用的机制。具体实现取决于标准库及其版本,不能把某种历史实现直接当作所有现代 C++ 环境的行为。

对于初学者,先理解浅拷贝与深拷贝的区别,再了解写时拷贝的设计思路即可。

十二、经典 string 算法练习

掌握常见接口之后,就可以通过算法练习巩固字符串处理能力。

12.1 仅反转字母

题目要求:给定一个字符串,只反转其中的英文字母,其他字符的位置保持不变。

例如:

复制代码
输入:a-bC-dEf
输出:f-Ed-Cba

可以使用双指针解决。

思路:

  1. 左指针从字符串开头开始。
  2. 右指针从字符串末尾开始。
  3. 左指针跳过非字母字符。
  4. 右指针跳过非字母字符。
  5. 如果两个指针都指向字母,就交换它们。
  6. 重复以上过程,直到两个指针相遇。

示例代码:

复制代码
#include <string>
#include <utility>

using namespace std;

bool isLetter(char ch)
{
    return (ch >= 'a' && ch <= 'z') ||
           (ch >= 'A' && ch <= 'Z');
}

string reverseOnlyLetters(string s)
{
    if (s.empty())
    {
        return s;
    }

    size_t left = 0;
    size_t right = s.size() - 1;

    while (left < right)
    {
        while (left < right && !isLetter(s[left]))
        {
            ++left;
        }

        while (left < right && !isLetter(s[right]))
        {
            --right;
        }

        if (left < right)
        {
            swap(s[left], s[right]);
            ++left;
            --right;
        }
    }

    return s;
}

该算法的时间复杂度为 (O(n)),其中 (n) 为字符串长度;除输入字符串外,算法只使用常数个额外变量。

这里的 isLetter() 只识别基本拉丁字母 A--Z 和 a--z,不包含其他语言的字母。

12.2 找出字符串中第一个只出现一次的字符

题目要求:找到字符串中第一个只出现一次的字符,并返回它的位置;如果不存在,则返回 -1。

例如:

复制代码
输入:leetcode
输出:0

其中字符 l 只出现一次,而且是最早出现的此类字符。

解题思路:

第一遍遍历统计字符出现次数,第二遍遍历寻找第一个计数为 1 的字符。

示例代码:

复制代码
#include <string>

using namespace std;

int firstUniqChar(const string& s)
{
    int count[256] = {0};

    for (unsigned char ch : s)
    {
        ++count[ch];
    }

    for (size_t i = 0; i < s.size(); ++i)
    {
        unsigned char ch = static_cast<unsigned char>(s[i]);

        if (count[ch] == 1)
        {
            return static_cast<int>(i);
        }
    }

    return -1;
}

时间复杂度为 (O(n)),额外空间为 (O(1)),因为统计数组的大小固定为 256。

这里的实现适合统计单字节字符。对于 UTF-8 编码的中文等多字节文本,不能简单地把每个字节当作一个完整字符,需要使用适当的 Unicode 文本处理方案。

12.3 判断回文字符串

回文字符串指正着读和反着读都相同的字符串。

例如:

复制代码
level

就是一个回文字符串。

如果题目要求忽略大小写,并且忽略非字母数字字符,可以采用双指针方法。

思路:

  1. 左右指针分别指向字符串两端。
  2. 跳过非字母数字字符。
  3. 将需要比较的字符统一转换为相同大小写。
  4. 如果左右字符不同,说明不是回文。
  5. 如果所有字符都能匹配,则说明是回文。

示例代码:

复制代码
#include <cctype>
#include <string>

using namespace std;

bool isLetterOrNumber(char ch)
{
    return (ch >= '0' && ch <= '9') ||
           (ch >= 'a' && ch <= 'z') ||
           (ch >= 'A' && ch <= 'Z');
}

bool isPalindrome(string s)
{
    size_t left = 0;
    size_t right = s.size();

    while (left < right)
    {
        while (left < right && !isLetterOrNumber(s[left]))
        {
            ++left;
        }

        while (left < right && !isLetterOrNumber(s[right - 1]))
        {
            --right;
        }

        if (left < right)
        {
            unsigned char a = static_cast<unsigned char>(s[left]);
            unsigned char b = static_cast<unsigned char>(s[right - 1]);

            if (tolower(a) != tolower(b))
            {
                return false;
            }

            ++left;
            --right;
        }
    }

    return true;
}

该实现按 ASCII 字母和数字的规则处理字符,适用于常见的英文回文题目。对于 Unicode 文本,需要额外考虑字符编码与大小写转换规则。

12.4 字符串相加

题目要求:给定两个非负整数的字符串形式,返回它们相加的结果。

例如:

复制代码
输入:
num1 = "123"
num2 = "789"

输出:
"912"

如果数字非常大,可能超过内置整数类型的表示范围。这时可以直接按照字符串逐位计算。

思路:

  1. 从两个字符串的末尾开始处理。
  2. 取出当前位的数字。
  3. 加上进位。
  4. 计算当前位的结果和新的进位。
  5. 将结果从低位到高位追加到字符串中。
  6. 最后将结果反转。

示例代码:

复制代码
#include <algorithm>
#include <string>

using namespace std;

string addStrings(const string& num1, const string& num2)
{
    int i = static_cast<int>(num1.size()) - 1;
    int j = static_cast<int>(num2.size()) - 1;

    int carry = 0;
    string result;

    while (i >= 0 || j >= 0 || carry != 0)
    {
        int a = (i >= 0) ? num1[i--] - '0' : 0;
        int b = (j >= 0) ? num2[j--] - '0' : 0;

        int sum = a + b + carry;

        result += static_cast<char>('0' + sum % 10);
        carry = sum / 10;
    }

    reverse(result.begin(), result.end());

    return result;
}

这道题的关键是理解十进制竖式加法中的进位规则。

如果两个数字字符串的总长度为 (n),该算法的时间复杂度为 (O(n))。

十三、string 学习路线总结

学习 string 时,建议按照由浅入深的顺序进行。

第一阶段:掌握常用接口

重点学习:

  • 构造字符串。
  • 使用 size()、empty() 和 clear()。
  • 使用 resize() 和 reserve()。
  • 使用下标、迭代器和范围 for。
  • 使用 +=、find()、rfind()、substr()。
  • 使用 cin、cout 和 getline()。

第二阶段:练习字符串算法

重点练习:

  • 双指针。
  • 字符统计。
  • 回文判断。
  • 字符串反转。
  • 字符串加法。
  • 单词处理与子字符串查找。

第三阶段:理解底层实现

进一步学习:

  • 字符串的长度与容量。
  • 动态内存分配。
  • 拷贝构造函数。
  • 赋值运算符重载。
  • 浅拷贝与深拷贝。
  • RAII、异常安全与现代 C++ 资源管理。

这三个阶段可以帮助我们从会使用 string,逐渐走向理解它的设计原理。

十四、总结

string 是 C++ 标准库中非常重要的字符串处理工具。

通过本文,我们了解了它相较于 C 风格字符串的优势,掌握了常用的构造、容量、访问、修改和查找操作,也学习了 auto、范围 for 等现代 C++ 特性。

在算法部分,我们进一步练习了双指针、字符统计和逐位运算等经典思想。

最后,通过自定义字符串类的示例,我们理解了浅拷贝可能带来的资源管理问题,以及深拷贝和拷贝与交换的基本思路。

学习 string 不应该只停留在背诵接口。真正重要的是能够根据问题选择合适的方法,并理解程序背后的数据与资源管理机制。

会用 string,可以提高字符串处理效率;理解深拷贝和内存管理,则能帮助我们进一步掌握 C++ 面向对象编程的核心思想。

为代码补充可编译示例删减重复接口讲解内容统一初学者学习主线


结尾

uu们,本文的内容到这里就全部结束了,道尔在这里再次感谢您的阅读!

|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ### 道尔柯南 C/C++ & Linux 底层探索者 | 一个正在努力学习的技术博主 *** ** * ** *** 👀 【关注】 跟随我一起深耕技术领域,见证每一次成长。 ❤️ 【点赞】 让优质内容被更多人看见,让知识传递更有力量。 ⭐ 【收藏】 把核心知识点存好,在需要时随时查、随时用。 💬 【评论】 分享你的经验或疑问,评论区一起交流避坑! 不要忘记给博主"一键四连"哦! "今日目标达成!" |

结语:希望对学习C++相关内容的uu有所帮助,不要忘记给博主"一键四连"哦!

相关推荐
唐诺1 小时前
5、Android JNI 动态注册中的“签名“详解
android·jni
kingx31 小时前
Bugku 3122 谷歌小恐龙题解:纯离线逆向 Vault 的 AES-GCM 加密资源(附 Python 解密器)
java·逆向·ctf·bugku·aes-gcm
DevOpenClub3 小时前
职教高考控制线 API:招生类别枚举、筛选与空分数含义
java·数据库·高考
十二4 小时前
【洛谷题解】P1083 [NOIP 2012 提高组] 借教室(二分答案 + 差分)
数据结构·c++·算法·蓝桥杯
露天赏雪4 小时前
第3篇:Sentinel熔断降级实战:大模型接口抖动时的服务兜底方案
java·开发语言·人工智能·spring boot·spring·servlet·sentinel
zhaoshuzhaoshu4 小时前
Android 系统启动与调试中的日志抓取方式全解析
android
天天被压力5 小时前
【A股分钟级高频数据完全手册 #06】北交所分钟级数据接口:京市股票Pro版最新分时
java·人工智能·python
天天被压力5 小时前
【A股分钟级高频数据完全手册 #05】指数历史分钟数据接口:Pro版沪深指数分时回放
java·人工智能·python
captain3765 小时前
▲统一功能——DispatcherServlet源码分析
java·spring·java-ee·log4j·maven