欢迎光临
我们一直在努力

C++ string 类详解:常用接口、OJ 场景与模拟实现中的深浅拷贝

C++ string 类详解:常用接口、OJ 场景与模拟实现中的深浅拷贝

头像

🔥 星恒随风:
个人主页

❄️ 个人专栏:
《指针合集》

《C语言基础》

《数据结构》

《机器学习导论》

《前端基础》

《python基础》

✨ 数据即知识,压缩即智能


C++ string 类详解:常用接口、OJ 场景与模拟实现中的深浅拷贝

目录

  • C++ string 类详解:常用接口、OJ 场景与模拟实现中的深浅拷贝
  • C++ string 类详解:常用接口、OJ 场景与模拟实现中的深浅拷贝
    • 前言
  • 一、string 常见构造方式
    • 1.1 构造空字符串
    • 1.2 用 C 字符串构造
    • 1.3 拷贝构造
    • 1.4 构造 n 个相同字符
  • 二、string 的容量相关接口
    • 2.1 size 和 length
    • 2.2 capacity
    • 2.3 empty
    • 2.4 clear
    • 2.5 reserve
    • 2.6 resize
    • 2.7 reserve 和 resize 的核心区别
  • 三、string 的访问和遍历
    • 3.1 operator[]
    • 3.2 迭代器遍历
    • 3.3 反向迭代器
    • 3.4 范围 for 遍历
  • 四、string 的修改操作
    • 4.1 push_back
    • 4.2 append
    • 4.3 operator+=
    • 4.4 operator+ 为什么要少用?
  • 五、string 的查找和截取
    • 5.1 find
    • 5.2 rfind
    • 5.3 substr
    • 5.4 c_str
  • 六、输入输出:cin、getline 和空格问题
    • 6.1 operator>>
    • 6.2 getline
    • 6.3 cin 和 getline 混用时的坑
  • 七、几个典型 OJ 字符串场景
    • 7.1 仅反转字母
    • 7.2 找第一个只出现一次的字符
    • 7.3 最后一个单词的长度
    • 7.4 验证回文串
    • 7.5 字符串相加
  • 八、不同编译器下 string 的底层结构
    • 8.1 string 不是简单的 char*
    • 8.2 小字符串优化
    • 8.3 写时拷贝
  • 九、为什么要模拟实现 String?
  • 十、一个有问题的 String 类
  • 十一、浅拷贝的问题
    • 11.1 什么是浅拷贝?
    • 11.2 浅拷贝为什么危险?
  • 十二、深拷贝解决问题
    • 12.1 什么是深拷贝?
    • 12.2 传统写法:拷贝构造
    • 12.3 传统写法:赋值运算符重载
  • 十三、现代写法:拷贝交换
    • 13.1 拷贝构造中的交换思想
    • 13.2 赋值运算符的现代写法
  • 十四、String 模拟实现的基本版本
  • 十五、本文总结

前言

上一篇我们从 STL 的整体结构出发,介绍了 string 在 C++ 标准库中的位置。

这一篇继续深入:

string 到底怎么用?


一、string 常见构造方式

1.1 构造空字符串

string s1;

这会构造一个空字符串。

此时:

s1.size() == 0
s1.empty() == true


1.2 用 C 字符串构造

string s2("hello bit");

或者:

string s2 = "hello bit";

这是最常用的构造方式。

相比 C 字符数组,string 会自己管理内部空间。


1.3 拷贝构造

string s3(s2);

或者:

string s3 = s2;

这会用已有的 string 对象构造新对象。

从使用层面看,就是复制一份字符串内容。

从实现层面看,现代 string 通常需要保证两个对象的内容独立管理,避免浅拷贝导致的重复释放问题。


1.4 构造 n 个相同字符

string s4(5, 'x');

结果是:

"xxxxx"

这种写法在刷题中比较常用。

比如要构造一个长度为 n 的初始字符串:

string ans(n, '0');


二、string 的容量相关接口

容量相关接口是 string 中非常重要的一组。

常见函数有:

size()
length()
capacity()
empty()
clear()
reserve()
resize()


2.1 size 和 length

s.size()
s.length()

这两个函数都返回字符串中有效字符的个数。

例如:

string s = "hello";
cout << s.size() << endl; // 5
cout << s.length() << endl; // 5

从使用习惯上来说,C++ 容器普遍使用 size()。

所以日常更推荐使用:

s.size()

length() 更多是为了字符串语义保留的接口。


2.2 capacity

s.capacity()

返回的是当前 string 底层已经分配的容量。

注意:

size != capacity

size() 表示有效字符个数。

capacity() 表示当前底层空间最多能容纳多少有效字符。

比如:

string s = "hello";

size() 是 5。

capacity() 可能大于等于 5。

因为底层通常会预留一些空间,避免每次追加字符都重新申请内存。 在这里插入图片描述


2.3 empty

s.empty()

用于判断字符串是否为空。

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

它等价于判断:

s.size() == 0

但语义更清晰。


2.4 clear

s.clear();

clear() 会清空字符串中的有效字符。

例如:

string s = "hello";
s.clear();

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

注意:

clear 通常不会改变底层容量。

也就是说,它只是让字符串逻辑上变空,不一定释放已经申请好的空间。

这样做是为了后续继续使用时减少重新分配的开销。


2.5 reserve

s.reserve(100);

reserve 的作用是预留容量。

它不会改变有效字符个数。

例如:

string s = "hello";
s.reserve(100);

cout << s.size() << endl; // 5
cout << s.capacity() << endl; // 至少能容纳 100 左右

它适合在你大致知道字符串会增长到多长时提前使用。

比如频繁拼接字符串:

string ret;
ret.reserve(10000);

for (...)
{
ret += part;
}

这样可以减少扩容次数,提高效率。


2.6 resize

s.resize(n);
s.resize(n, ch);

resize 会改变有效字符个数。

如果 n 小于当前 size(),字符串会被截断。

如果 n 大于当前 size(),字符串会扩展。

扩展出来的位置:

resize(n)

通常用 '\\0' 填充。

resize(n, ch)

用指定字符 ch 填充。

例如:

string s = "hello";
s.resize(8, 'x');

cout << s << endl; // helloxxx


2.7 reserve 和 resize 的核心区别

这两个函数非常容易混。

reserve

只管容量,不改变有效字符个数。

resize

改变有效字符个数,必要时也可能改变容量。

例子:

string s = "hello";

s.reserve(100);
cout << s.size() << endl; // 5

s.resize(100, 'x');
cout << s.size() << endl; // 100

在这里插入图片描述


三、string 的访问和遍历

3.1 operator[]

最常用的访问方式是下标:

string s = "hello";

cout << s[0] << endl; // h
s[0] = 'H';

operator[] 使用起来很方便,但要注意下标不能越界。

合法下标范围是:

0 ~ s.size() 1


3.2 迭代器遍历

string 支持迭代器:

string s = "hello";

string::iterator it = s.begin();
while (it != s.end())
{
cout << *it << " ";
++it;
}

也可以用 auto 简化:

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

begin() 指向第一个字符。

end() 指向最后一个字符的下一个位置。

注意:

end() 不是最后一个字符,而是最后一个字符后面的位置。

在这里插入图片描述


3.3 反向迭代器

string 也支持反向遍历:

auto rit = s.rbegin();
while (rit != s.rend())
{
cout << *rit << " ";
++rit;
}

这会从后往前访问字符串。


3.4 范围 for 遍历

最简单的遍历方式是范围 for:

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

如果要修改字符,需要引用:

for (auto& ch : s)
{
ch = toupper(ch);
}

这里一定要注意:

auto ch

是副本。

auto& ch

才是原字符引用。


四、string 的修改操作

4.1 push_back

push_back 用于在字符串尾部追加一个字符:

string s = "hello";
s.push_back('!');

cout << s << endl; // hello!


4.2 append

append 用于追加字符串:

string s = "hello";
s.append(" world");

cout << s << endl; // hello world

也可以追加若干个字符:

s.append(3, 'x');


4.3 operator+=

日常最常用的是 +=。

它既可以追加字符:

s += '!';

也可以追加字符串:

s += " world";

还可以追加另一个 string:

string t = " C++";
s += t;

一般情况下,尾部追加更推荐使用 +=。

写法自然,表达清楚。


4.4 operator+ 为什么要少用?

比如:

string s1 = "hello";
string s2 = "world";

string s3 = s1 + " " + s2;

这当然可以用。

但如果在循环中大量使用 + 拼接字符串,可能会产生较多临时对象。

例如:

string ret;

for (int i = 0; i < n; ++i)
{
ret = ret + part;
}

这种写法可能效率较低。

更推荐:

string ret;
ret.reserve(预估长度);

for (int i = 0; i < n; ++i)
{
ret += part;
}

核心思想是:

能预估长度时先 reserve,频繁拼接时优先 +=。


五、string 的查找和截取

5.1 find

find 用于从字符串中查找字符或子串。

string s = "hello world";

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

如果找到了,返回第一次出现的位置。

如果没找到,返回:

string::npos

所以使用时一定要判断:

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

不要直接拿 npos 当正常下标使用。

在这里插入图片描述


5.2 rfind

rfind 从后往前查找。

比如求最后一个单词长度时,经常会用:

size_t pos = line.rfind(' ');

如果字符串是:

"hello world"

最后一个空格的位置就是 5。

最后一个单词长度可以写成:

line.size() pos 1

不过实际写题时要注意没有空格的情况。

如果 rfind 返回 npos,说明整行就是一个单词。


5.3 substr

substr 用于截取子串。

string s = "hello world";

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

结果是:

"world"

第一个参数表示起始位置。

第二个参数表示截取长度。

如果第二个参数省略,默认截取到字符串末尾:

string sub = s.substr(6);


5.4 c_str

c_str() 用于返回 C 风格字符串。

string s = "hello";
const char* p = s.c_str();

这个接口在和 C 库函数、某些系统接口交互时很有用。

但要注意:

返回的指针指向 string 内部数据。

当 string 被修改或销毁后,这个指针可能失效。

所以不要长期保存这个指针后继续使用。


六、输入输出:cin、getline 和空格问题

6.1 operator>>

可以直接输入字符串:

string s;
cin >> s;

但它有一个特点:

遇到空白字符就停止。

比如输入:

hello world

cin >> s 只能读到:

hello


6.2 getline

如果要读取一整行,应该使用:

getline(cin, line);

例如:

string line;
getline(cin, line);

它会读取一整行,包括中间的空格,直到换行结束。

所以处理整行文本、句子、带空格输入时,getline 更合适。

在这里插入图片描述


6.3 cin 和 getline 混用时的坑

如果前面用了:

cin >> n;

然后立刻:

getline(cin, line);

可能会读到一个空行。

原因是 cin >> n 读取数字后,换行符还留在输入缓冲区中。

getline 会把这个残留换行符当作一行结束。

常见处理方式是先消耗掉这个换行符:

cin.ignore();
getline(cin, line);

更严谨时可以写:

cin.ignore(numeric_limits<streamsize>::max(), '\\n');


七、几个典型 OJ 字符串场景

7.1 仅反转字母

题意:

字符串中只有字母需要反转,其他字符位置不动。

思路:

用双指针。

左指针从前往后找字母。

右指针从后往前找字母。

找到后交换。

核心逻辑:

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;

swap(s[left], s[right]);
++left;
right;
}

return s;
}

这个题适合练习:

  • string 下标访问
  • 双指针
  • 字符判断
  • swap

7.2 找第一个只出现一次的字符

思路:

先统计每个字符出现次数。

再从前往后扫描字符串。

第一个出现次数为 1 的字符,就是答案。

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

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

for (int i = 0; i < s.size(); ++i)
{
if (count[(unsigned char)s[i]] == 1)
return i;
}

return 1;
}

这个题适合练习:

  • 计数数组
  • 字符作为下标
  • 按原字符串顺序查找答案

7.3 最后一个单词的长度

输入一整行,求最后一个单词长度。

不能用:

cin >> line;

因为它遇到空格就停止。

应该用:

getline(cin, line);

一种写法:

int LastWordLength(const string& line)
{
size_t pos = line.rfind(' ');

if (pos == string::npos)
return line.size();

return line.size() pos 1;
}

不过如果字符串末尾可能有空格,需要先处理尾部空格。


7.4 验证回文串

常见题意:

只考虑字母和数字,忽略大小写。

思路:

  • 双指针
  • 跳过非字母数字字符
  • 比较时统一大小写

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

char ToLower(char ch)
{
if (ch >= 'A' && ch <= 'Z')
return ch + 32;

return ch;
}

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

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

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

if (ToLower(s[left]) != ToLower(s[right]))
return false;

++left;
right;
}

return true;
}


7.5 字符串相加

题意:

两个非负整数字符串相加,返回字符串形式的结果。

不能直接转成整数,因为数字可能非常大。

思路:

从后往前逐位相加,维护进位。

string addStrings(string num1, string num2)
{
int i = num1.size() 1;
int j = num2.size() 1;
int carry = 0;
string ret;

while (i >= 0 || j >= 0 || carry)
{
int x = i >= 0 ? num1[i] '0' : 0;
int y = j >= 0 ? num2[j] '0' : 0;

int sum = x + y + carry;
ret += char(sum % 10 + '0');
carry = sum / 10;
}

reverse(ret.begin(), ret.end());
return ret;
}

这个题很好地体现了 string 的使用价值:

  • 下标访问
  • 尾插字符
  • reverse
  • 字符和数字之间的转换

八、不同编译器下 string 的底层结构

8.1 string 不是简单的 char*

很多人刚开始会把 string 理解成:

char*

这不准确。

string 是一个类对象,内部通常会管理:

  • 字符串数据
  • 有效长度
  • 当前容量
  • 分配器或其他辅助信息

不同编译器和不同标准库实现,内部结构可能不同。

所以不要在代码中依赖某个实现细节。


8.2 小字符串优化

很多现代 string 实现会使用一种优化:

小字符串优化。

大致意思是:

当字符串比较短时,不一定去堆上申请空间,而是直接把字符存放在 string 对象内部的小缓冲区里。

这样可以减少小字符串频繁动态分配的成本。

比如很多名字、短单词、路径片段都比较短,直接存在对象内部效率更高。

当字符串超过内部缓冲区容量时,再转到堆上分配空间。


8.3 写时拷贝

早期某些实现中,string 可能使用写时拷贝。

写时拷贝的思想是:

拷贝时先共享资源,真正要修改时再拷贝一份。

它通常配合引用计数使用。

优点是减少不必要的拷贝。

缺点是实现复杂,在多线程和字符访问语义上会带来一些问题。

现代 C++ 标准库实现中,主流 string 已经不再把写时拷贝作为推荐实现方式。

入门阶段只需要了解:

写时拷贝是一种历史上出现过的优化思想,不是我们模拟实现 string 时的第一选择。


九、为什么要模拟实现 String?

标准库里的 string 很强大。

那为什么面试和学习中还要模拟实现?

因为模拟实现 String 能训练几个非常关键的 C++ 能力:

  • 构造函数
  • 析构函数
  • 拷贝构造
  • 赋值运算符重载
  • 深拷贝
  • 资源管理
  • 自赋值处理
  • 异常安全意识

这不是为了替代标准库 string。

而是为了理解:

一个管理动态资源的类,应该怎么写默认成员函数。


十、一个有问题的 String 类

先看一个简化版本:

class String
{
public:
String(const char* str = "")
{
if (str == nullptr)
{
assert(false);
return;
}

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

~String()
{
delete[] _str;
_str = nullptr;
}

private:
char* _str;
};

构造函数申请空间。

析构函数释放空间。

看起来没问题。

但是如果写:

String s1("hello");
String s2(s1);

这里会调用编译器默认生成的拷贝构造函数。

默认拷贝构造会把 _str 的值直接复制给 s2._str。

也就是说:

s1._str == s2._str

两个对象指向同一块堆空间。

这就是浅拷贝。


十一、浅拷贝的问题

11.1 什么是浅拷贝?

浅拷贝也叫位拷贝。

意思是:

只把对象中的成员变量值原样复制一份。

如果成员变量是普通 int,没问题。

但如果成员变量是指针,就会把地址复制过去。

结果就是多个对象共享同一块资源。

在这里插入图片描述


11.2 浅拷贝为什么危险?

继续看:

String s1("hello");
String s2(s1);

浅拷贝后:

s1._str
s2._str

都指向同一块字符数组。

当程序结束时,两个对象都会析构:

delete[] s1._str;
delete[] s2._str;

同一块空间被释放两次,程序很可能崩溃。

此外,一个对象修改字符串内容,另一个对象也会受到影响。

所以对管理资源的类来说,浅拷贝通常是不够的。


十二、深拷贝解决问题

12.1 什么是深拷贝?

深拷贝的核心是:

每个对象都拥有自己独立的资源。

对于 String 来说,拷贝时不能只复制 _str 指针,而是要重新申请一块空间,再把字符串内容复制过去。 在这里插入图片描述


12.2 传统写法:拷贝构造

String(const String& s)
{
_str = new char[strlen(s._str) + 1];
strcpy(_str, s._str);
}

这样:

String s2(s1);

会让 s2 自己拥有一块新的空间。

内容和 s1 一样,但地址不同。

s1._str != s2._str

析构时各自释放自己的空间,不会重复释放。


12.3 传统写法:赋值运算符重载

赋值运算符用于两个已经存在的对象之间赋值。

String& operator=(const String& s)
{
if (this != &s)
{
char* tmp = new char[strlen(s._str) + 1];
strcpy(tmp, s._str);

delete[] _str;
_str = tmp;
}

return *this;
}

这里有几个细节:

第一,检查自赋值:

if (this != &s)

处理:

s1 = s1;

第二,先申请新空间,再释放旧空间。

这样如果申请失败,原对象还没有被破坏。

第三,返回 *this。

为了支持连续赋值:

s1 = s2 = s3;


十三、现代写法:拷贝交换

13.1 拷贝构造中的交换思想

可以写成:

String(const String& s)
: _str(nullptr)
{
String tmp(s._str);
swap(_str, tmp._str);
}

这段代码的思路是:

先用 s._str 构造一个临时对象 tmp。

然后交换当前对象和 tmp 的资源。

tmp 析构时,会释放当前对象原来的资源。

这种写法简洁,并且复用了构造函数。


13.2 赋值运算符的现代写法

一种非常经典的写法是传值:

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

这里参数 s 是按值传参。

调用时会先生成一份拷贝。

然后当前对象和这份拷贝交换资源。

函数结束时,形参 s 析构,释放当前对象原来的旧资源。

这就是 copy-and-swap 思想。

它的优点是:

  • 代码短
  • 自赋值也能安全处理
  • 异常安全性更好
  • 复用拷贝构造和析构逻辑 在这里插入图片描述

十四、String 模拟实现的基本版本

#include <iostream>
#include <cstring>
#include <cassert>
using namespace std;

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

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

String(const String& s)
{
_str = new char[strlen(s._str) + 1];
strcpy(_str, s._str);
}

String& operator=(const String& s)
{
if (this != &s)
{
char* tmp = new char[strlen(s._str) + 1];
strcpy(tmp, s._str);

delete[] _str;
_str = tmp;
}

return *this;
}

~String()
{
delete[] _str;
_str = nullptr;
}

const char* c_str() const
{
return _str;
}

private:
char* _str;
};

这个版本不是为了替代标准库。

它的重点是展示:

只要类里管理动态资源,就必须认真处理拷贝构造、赋值运算符和析构函数。


十五、本文总结

这一篇主要围绕 string 的使用和模拟实现展开。

string 的容量接口中:

size() 和 length() 都表示有效字符个数。

capacity() 表示底层容量。

clear() 清空有效字符,但通常不释放容量。

reserve() 只预留空间,不改变有效长度。

resize() 会改变有效字符个数。

string 的访问方式包括:

  • 下标访问
  • 迭代器
  • 反向迭代器
  • 范围 for

string 的修改方式中:

  • push_back 追加单个字符
  • append 追加字符串
  • += 更常用、更自然
  • 大量拼接时少用 +,优先 reserve + +=

string 的查找和截取中:

  • find 找不到返回 string::npos
  • rfind 从后往前找
  • substr 用于截取子串
  • c_str 用于获得 C 风格字符串

输入时:

  • cin >> s 遇到空格停止
  • getline(cin, s) 读取整行

模拟实现 String 时:

  • 默认拷贝会导致浅拷贝
  • 浅拷贝会导致资源共享和重复释放
  • 深拷贝让每个对象拥有独立资源
  • 管理资源的类必须认真写拷贝构造、赋值运算符和析构函数

最后用一句话总结:

string 的接口是用来解决字符串问题的,而 string 的模拟实现是用来理解 C++ 资源管理本质的。


赞(0)
未经允许不得转载:171主机测评 » C++ string 类详解:常用接口、OJ 场景与模拟实现中的深浅拷贝
分享到: 更多 (0)

评论 抢沙发

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