C++ string 类:从使用到模拟实现的全面指南

        在 C++ 开发中,字符串是最常用的数据类型之一。C 语言中以\0结尾的字符数组不仅使用繁琐,还需手动管理内存,容易引发越界或泄漏问题。而 C++ 标准库提供的string类,通过封装字符串操作、自动管理内存,彻底解决了这些痛点。本文将从string类的核心价值出发,详解其常用接口、底层实现差异,并手把手教你模拟实现string类,同时结合 OJ 例题巩固实战能力,帮你彻底掌握这一高频使用类。

一、为什么要学习 string 类?C 语言字符串的痛点与解决方案

在学习string类之前,我们先明确其存在的意义 —— 解决 C 语言字符串的固有缺陷。

1.1 C 语言字符串的三大痛点

C 语言中,字符串本质是 “以\0结尾的字符数组”,配合strlenstrcpystrcat等库函数使用,但存在明显问题:

  1. 内存管理繁琐:需手动malloc/free动态内存,稍不注意就会导致内存泄漏(如忘记free)或野指针(如free后未置空)。
  2. 接口与数据分离:库函数与字符串是分离的(如strcpy(dst, src)需显式传入两个参数),不符合面向对象(OOP)思想,代码可读性低。
  3. 安全性差strcpystrcat等函数不检查目标空间大小,容易导致缓冲区溢出(越界访问),引发程序崩溃。

1.2 string 类的核心优势

C++ 的string类本质是 “封装了字符数组的类”,通过成员函数封装字符串操作,核心优势包括:

        自动内存管理:无需手动申请 / 释放内存,构造函数、析构函数自动处理,避免泄漏。

        丰富的接口:提供增删查改、容量调整、遍历等一站式接口(如push_backfindsubstr),无需依赖库函数。

        安全性高:接口内部会检查边界(如operator[]配合at函数),减少越界风险。

        兼容 C 语言:提供c_str()接口,可将string转为 C 风格字符串(const char*),适配旧代码。

无论是 OJ 刷题(如字符串反转、相加)还是日常开发(如配置文件读取、日志输出),string类都是首选工具,掌握它是 C++ 开发者的必备技能。

二、string 类的基础:C++11 语法糖(auto 与范围 for)

在使用string类前,先补充两个 C++11 的实用语法 ——auto和范围 for,它们能大幅简化string的遍历与操作代码。

2.1 auto:让编译器自动推导类型

auto在 C++11 中不再是 “自动存储类型指示符”,而是 “类型推导关键字”—— 编译器会根据变量的初始化值,自动推导其类型。

2.1.1 auto 的核心规则

        指针与引用:推导指针时,autoauto*等价;推导引用时,必须加&(如auto& ref = var)。

        多变量声明:同一行声明的多个变量必须是同一类型(编译器仅推导第一个变量的类型)。

        限制场景:不能作为函数参数(如void func(auto a)错误)、不能直接声明数组(如auto arr[] = {1,2}错误)。

2.1.2 auto 的实用场景:简化迭代器声明

stringmap等容器的迭代器类型冗长(如std::string::iterator),auto可大幅简化代码:

#include <string>
#include <map>
using namespace std;

int main() {
    map<string, string> dict = {{"apple", "苹果"}, {"orange", "橙子"}};
    
    // 传统写法:迭代器类型冗长
    map<string, string>::iterator it1 = dict.begin();
    
    // auto写法:编译器自动推导为map<string, string>::iterator
    auto it2 = dict.begin();
    
    // 遍历字典
    while (it2 != dict.end()) {
        cout << it2->first << ":" << it2->second << endl;
        ++it2;
    }
    return 0;
}

2.2 范围 for:简化容器遍历

C++11 引入的 “范围 for” 循环,适用于 “有明确范围的集合”(如数组、string、STL 容器),无需手动控制索引或迭代器,自动遍历所有元素。

2.2.1 范围 for 的语法

for (auto 迭代变量 : 被遍历的范围) {
    // 循环体
}

        迭代变量:每次循环取范围中的一个元素(值传递,若需修改元素需加&,如auto& e)。
        被遍历的范围:需支持begin()和end()接口(string和 STL 容器均支持)。

2.2.2 范围 for 遍历 string 示例

#include <string>
#include <iostream>
using namespace std;

int main() {
    string str = "hello world";
    
    // 遍历并打印每个字符(值传递,不修改原字符串)
    for (auto ch : str) {
        cout << ch << " "; // 输出:h e l l o   w o r l d
    }
    cout << endl;
    
    // 遍历并修改字符(引用传递,原字符串会被修改)
    for (auto& ch : str) {
        ch -= 32; // 小写转大写(ASCII码差值)
    }
    cout << str << endl; // 输出:HELLO WORLD
    return 0;
}

底层原理:范围 for 本质是 “迭代器的语法糖”,编译器会自动将其替换为begin()/end()迭代器遍历,因此支持所有提供迭代器的容器。

三、string 类的常用接口:从构造到修改的核心操作

string类提供了大量接口,本节聚焦最常用的核心接口,按 “构造→容量→遍历→修改” 的逻辑分类讲解,配合代码示例帮助理解。

3.1 字符串构造(constructor)

string类的构造函数支持多种初始化方式,重点掌握以下 4 种:

构造函数功能说明示例代码
string()构造空字符串(默认构造)string s1;(s1 为空串)
string(const char* s)用 C 风格字符串(const char*)构造string s2("hello");(s2="hello")
string(size_t n, char c)构造包含 n 个字符 c 的字符串string s3(5, 'a');(s3="aaaaa")
string(const string& s)拷贝构造(用已有 string 对象构造新对象)string s4(s2);(s4="hello")

代码示例

void TestStringConstruct() {
    string s1;          // 空串
    string s2("hello"); // 用C字符串构造
    string s3(5, 'a');  // 5个'a'
    string s4(s2);      // 拷贝s2
    
    cout << "s1: " << s1 << "(空串)" << endl;
    cout << "s2: " << s2 << endl; // 输出hello
    cout << "s3: " << s3 << endl; // 输出aaaaa
    cout << "s4: " << s4 << endl; // 输出hello
}

3.2 容量操作:size、capacity、reserve 与 resize

容量操作用于管理string的内存空间,避免频繁扩容导致的性能损耗,重点掌握以下接口:

接口名功能说明
size()返回字符串有效字符长度(不包含\0),与length()功能完全一致(推荐用size()
capacity()返回字符串底层空间总大小(可存储的最大字符数,不包含\0
empty()判断字符串是否为空(有效字符长度为 0),返回bool
clear()清空有效字符(将size()置 0),不释放底层空间capacity()不变)
reserve(n)为字符串预留 n 个字符的空间(仅扩容,不缩容;不改变size()
resize(n, c)将有效字符数调整为 n:- 若 n > 当前size():用字符 c 填充新增空间(默认填\0)- 若 n < 当前size():截断字符串(capacity()不变)
关键注意点:
  1. size vs capacitysize()是 “已使用的字符数”,capacity()是 “底层分配的总空间”(如string s("hello")size()=5,capacity()可能为 15(VS 下)或 5(GCC 下))。
  2. reserve 的作用:若提前知道字符串的大致长度(如读取 1000 个字符),先用reserve(1000)预留空间,可避免频繁扩容(每次扩容需申请新内存、拷贝旧数据、释放旧内存,性能损耗大)。
  3. resize 与 reserve 的区别resize()改变size()(有效字符数),reserve()仅改变capacity()(空间大小)。

代码示例

void TestStringCapacity() {
    string s("hello");
    cout << "size: " << s.size() << ", capacity: " << s.capacity() << endl; // 5, 15(VS下)
    
    s.reserve(20); // 预留20个字符空间
    cout << "size: " << s.size() << ", capacity: " << s.capacity() << endl; // 5, 20
    
    s.resize(8, 'x'); // 有效字符数改为8,新增3个'x'
    cout << "s: " << s << ", size: " << s.size() << endl; // helloxxx, 8
    
    s.clear(); // 清空有效字符
    cout << "size: " << s.size() << ", capacity: " << s.capacity() << endl; // 0, 20(capacity不变)
}

3.3 访问与遍历:operator []、迭代器与范围 for

string提供三种遍历方式,可根据场景选择:

3.3.1 operator [](最常用)

operator[]重载了 “下标访问”,支持通过索引获取或修改字符,用法与数组一致。

        普通对象:char& operator[](size_t pos)(可修改字符)。

        const 对象:const char& operator[](size_t pos) const(仅读取,不可修改)。

代码示例

void TestStringAccess() {
    string s("hello");
    // 修改第3个字符(索引从0开始)
    s[2] = 'L'; 
    cout << s << endl; // 输出heLlo
    
    // 遍历所有字符
    for (size_t i = 0; i < s.size(); ++i) {
        cout << s[i] << " "; // 输出h e L l o
    }
}
3.3.2 迭代器(iterator)

迭代器是 “连接容器与算法的桥梁”,string的迭代器支持正向、反向遍历,常用接口:

  begin():返回指向第一个字符的迭代器。

  end():返回指向最后一个字符的下一个位置的迭代器(不指向有效字符)。

  rbegin():返回指向最后一个字符的反向迭代器。

  rend():返回指向第一个字符的前一个位置的反向迭代器。

代码示例

void TestStringIterator() {
    string s("hello");
    
    // 正向迭代器:遍历并修改
    string::iterator it = s.begin();
    while (it != s.end()) {
        *it -= 32; // 小写转大写
        ++it;
    }
    cout << s << endl; // 输出HELLO
    
    // 反向迭代器:从后往前遍历
    string::reverse_iterator rit = s.rbegin();
    while (rit != s.rend()) {
        cout << *rit << " "; // 输出O L L E H
        ++rit;
    }
}
3.3.3 范围 for(最简洁)

如 2.2 节所示,范围 for 是遍历string的最简方式,无需关心索引或迭代器,适合 “遍历所有元素” 的场景。

3.4 修改操作:+=、push_back、find 与 substr

修改操作是string的核心功能,包括尾插、追加、查找、截取等,重点掌握以下接口:

接口名功能说明
push_back(char c)在字符串末尾插入单个字符 c
operator+=(const string& str)在末尾追加字符串(支持stringconst char*,最常用)
c_str()返回 C 风格字符串(const char*),用于适配 C 语言接口
find(char c, size_t pos=0)从 pos 位置开始向后找字符 c,返回其索引;找不到返回string::npos(一个很大的数,代表 “不存在”)
rfind(char c, size_t pos=npos)从 pos 位置开始向前找字符 c,返回其索引;找不到返回string::npos
substr(size_t pos=0, size_t n=npos)从 pos 位置开始截取 n 个字符,返回新string;n 默认取到字符串末尾
关键注意点:
  1. 尾插效率push_back(c)s += cappend(1, c)效率相近,但+=支持追加字符串(如s += "world"),功能更全面,推荐优先使用。
  2. find 的返回值find找不到字符时返回string::npos(本质是static const size_t npos = -1),判断时需用if (s.find(c) == string::npos)
  3. substr 的应用:常用于截取子串(如从文件路径中提取文件名、从 URL 中提取参数)。

代码示例

void TestStringModify() {
    string s("hello");
    
    // 尾插与追加
    s.push_back(' ');   // 插入空格:hello 
    s += "world";       // 追加字符串:hello world
    cout << s << endl;  // 输出hello world
    
    // 查找字符
    size_t pos = s.find(' '); // 找空格的位置,返回5
    if (pos != string::npos) {
        // 截取空格后的子串(world)
        string sub = s.substr(pos + 1);
        cout << sub << endl; // 输出world
    }
    
    // 适配C语言接口(如printf)
    const char* cstr = s.c_str();
    printf("C style: %s\n", cstr); // 输出hello world
}

3.5 非成员函数:输入输出与比较

string的非成员函数主要用于输入、输出和字符串比较,常用接口:

        输入输出operator>>(输入,遇空格 / 换行结束)、operator<<(输出)、getline(cin, s)(读取一行,包括空格)。

        比较operator==operator!=operator<等(按 ASCII 码比较,支持stringconst char*比较)。

代码示例

void TestStringIO() {
    string s1, s2;
    
    // 用cin>>输入:遇空格结束
    cin >> s1; // 若输入"hello world",s1仅为"hello"
    cout << "s1: " << s1 << endl;
    
    // 用getline读取一行(需先处理cin>>留下的换行符)
    cin.ignore(); // 忽略缓冲区中的换行符
    getline(cin, s2); // 读取整行,包括空格
    cout << "s2: " << s2 << endl;
    
    // 字符串比较
    string s3("apple"), s4("banana");
    if (s3 < s4) {
        cout << s3 << " < " << s4 << endl; // 输出apple < banana(ASCII码a < b)
    }
}

四、string 类的底层实现:VS 与 GCC 的差异

不同编译器对string的底层实现不同,核心差异在于 “内存分配策略”,了解这些差异有助于排查跨平台问题。

4.1 VS 下的 string 结构(32 位平台)

VS 的string采用 “小字符串优化(SSO)” 策略,即:

        当字符串长度小于 16时,使用内部固定的 16 字节数组存储(无需申请堆内存,效率高)。

        当字符串长度大于等于 16时,从堆上申请内存存储。

VS 的string对象共占28 字节,结构如下:

union _Bxty { // 联合体:要么用固定数组,要么用堆指针
    char _Buf[16]; // 小字符串存储(16字节)
    char* _Ptr;    // 大字符串堆指针
};

class string {
private:
    _Bxty _Bx;          // 16字节(联合体)
    size_t _Mysize;     // 4字节(有效字符长度)
    size_t _Myres;      // 4字节(底层空间大小,不包含\0)
    void* _Ptr;         // 4字节(其他用途,如调试信息)
};
// 总大小:16 + 4 + 4 + 4 = 28字节

4.2 GCC 下的 string 结构(32 位平台)

GCC 的string采用 “写时拷贝(Copy-On-Write, COW)” 策略,即:

        多个string对象共享同一块堆内存,通过 “引用计数” 记录使用者个数。

        当某个对象修改字符串时,才会拷贝一份新内存(避免无修改时的冗余拷贝)。

GCC 的string对象仅占4 字节,结构如下:

// 堆内存中的数据结构
struct _Rep_base {
    size_t _M_length;  // 有效字符长度
    size_t _M_capacity; // 底层空间大小
    _Atomic_word _M_refcount; // 引用计数(使用者个数)
};

class string {
private:
    char* _M_data; // 4字节指针,指向堆内存(_Rep_base + 字符数组)
};

写时拷贝的逻辑

  1. 构造时:申请堆内存,引用计数设为 1。
  2. 拷贝构造时:仅拷贝指针,引用计数 + 1(浅拷贝)。
  3. 修改时:若引用计数 > 1,先拷贝一份新内存(深拷贝),引用计数调整为 1,再修改新内存。
  4. 析构时:引用计数 - 1,若计数为 0,释放堆内存。

五、string 类的模拟实现:从浅拷贝到深拷贝

面试中,string的模拟实现是高频考点,核心是实现 “构造、拷贝构造、赋值运算符重载、析构函数”,解决浅拷贝问题。

5.1 问题引入:浅拷贝的缺陷

若仅简单实现string(仅封装char*,未处理拷贝),会导致 “浅拷贝” 问题 —— 多个对象共享同一块内存,析构时重复释放,程序崩溃。

错误示例(浅拷贝)

class String {
public:
    // 构造:用C字符串初始化
    String(const char* str = "") {
        _str = new char[strlen(str) + 1];
        strcpy(_str, str);
    }
    
    // 析构:释放内存
    ~String() {
        delete[] _str; // 若多个对象共享_str,会重复释放
        _str = nullptr;
    }

private:
    char* _str;
};

void Test() {
    String s1("hello");
    String s2(s1); // 浅拷贝:s2._str = s1._str(共享内存)
} // 析构时:先析构s2(释放_str),再析构s1(释放已释放的内存,崩溃)

5.2 解决方案:深拷贝

深拷贝的核心是 “每个对象拥有独立的内存”,拷贝时不仅拷贝指针,还拷贝指针指向的内容。以下是两种主流的深拷贝实现方式:

5.2.1 传统版实现(手动申请内存 + 拷贝)

传统版通过 “申请新内存→拷贝内容→释放旧内存” 实现深拷贝,逻辑直观:

class String {
public:
    // 构造
    String(const char* str = "") {
        if (str == nullptr) str = ""; // 处理nullptr
        _str = new char[strlen(str) + 1];
        strcpy(_str, str);
    }
    
    // 拷贝构造(深拷贝)
    String(const String& s) {
        // 申请与s相同大小的内存
        _str = new char[strlen(s._str) + 1];
        strcpy(_str, s._str); // 拷贝内容
    }
    
    // 赋值运算符重载(深拷贝)
    String& operator=(const String& s) {
        if (this != &s) { // 避免自赋值(s = s)
            // 1. 申请新内存
            char* tmp = new char[strlen(s._str) + 1];
            strcpy(tmp, s._str);
            // 2. 释放旧内存
            delete[] _str;
            // 3. 指向新内存
            _str = tmp;
        }
        return *this; // 支持连续赋值(s1 = s2 = s3)
    }
    
    // 析构
    ~String() {
        delete[] _str;
        _str = nullptr;
    }

private:
    char* _str;
};
5.2.2 现代版实现(利用构造函数 + swap)

现代版通过 “临时对象构造 + swap 交换” 简化代码,避免手动申请 / 释放内存,逻辑更优雅:

class String {
public:
    // 构造(同传统版)
    String(const char* str = "") {
        if (str == nullptr) str = "";
        _str = new char[strlen(str) + 1];
        strcpy(_str, str);
    }
    
    // 拷贝构造(现代版)
    String(const String& s) : _str(nullptr) {
        String tmp(s._str); // 用s的字符串构造临时对象tmp(tmp拥有独立内存)
        swap(_str, tmp._str); // 交换this->_str和tmp._str:this指向tmp的内存,tmp指向原this->_str(nullptr)
    }
    
    // 赋值运算符重载(现代版1:参数传值,自动拷贝构造临时对象)
    String& operator=(String s) { // s是实参的拷贝(深拷贝)
        swap(_str, s._str); // 交换后,s指向原this->_str(析构时释放)
        return *this;
    }
    
    // 赋值运算符重载(现代版2:参数传const引用,手动构造临时对象)
    /*
    String& operator=(const String& s) {
        if (this != &s) {
            String tmp(s); // 构造临时对象
            swap(_str, tmp._str);
        }
        return *this;
    }
    */
    
    // 析构(同传统版)
    ~String() {
        delete[] _str;
        _str = nullptr;
    }

private:
    char* _str;
};

现代版优势:无需手动申请 / 释放内存,借助临时对象的构造 / 析构自动完成深拷贝,代码更简洁,且避免了自赋值判断(现代版 1)。

六、string 类实战:OJ 例题巩固

掌握string的接口后,通过 OJ 例题实战是巩固知识的最佳方式。以下是 4 道经典例题,覆盖字符串反转、查找、相加等核心场景。

6.1 例题 1:仅反转字母(LeetCode 917)

题目:给定一个字符串,反转其中的字母(非字母字符位置不变)。思路:双指针(左指针从左找字母,右指针从右找字母,交换后移动指针)。代码

#include <string>
using namespace std;

class Solution {
public:
    // 判断是否为字母
    bool isLetter(char ch) {
        return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z');
    }
    
    string reverseOnlyLetters(string S) {
        size_t left = 0, 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;
    }
};

6.2 例题 2:字符串相加(LeetCode 415)

题目:给定两个非负整数的字符串(如 "123"+"45"),返回它们的和(字符串形式)。思路:双指针从后往前加,处理进位,结果尾插后反转(避免头插效率低)。代码

#include <string>
#include <algorithm>
using namespace std;

class Solution {
public:
    string addStrings(string num1, string num2) {
        int i = num1.size() - 1, j = num2.size() - 1;
        int carry = 0; // 进位
        string res;
        
        while (i >= 0 || j >= 0 || carry > 0) {
            // 取当前位的值(越界则为0)
            int val1 = (i >= 0) ? (num1[i--] - '0') : 0;
            int val2 = (j >= 0) ? (num2[j--] - '0') : 0;
            int sum = val1 + val2 + carry;
            
            carry = sum / 10; // 更新进位
            res += (sum % 10) + '0'; // 尾插当前位(字符形式)
        }
        
        reverse(res.begin(), res.end()); // 反转结果(尾插的是逆序)
        return res;
    }
};

6.3 例题 3:验证回文串(LeetCode 125)

题目:给定一个字符串,验证它是否是回文串(只考虑字母和数字,忽略大小写)。思路:双指针跳过非字母数字,统一大小写后比较。代码

#include <string>
using namespace std;

class Solution {
public:
    // 判断是否为字母或数字
    bool isAlphaNum(char ch) {
        return (ch >= '0' && ch <= '9') 
            || (ch >= 'a' && ch <= 'z') 
            || (ch >= 'A' && ch <= 'Z');
    }
    
    bool isPalindrome(string s) {
        // 统一为小写(或大写)
        for (auto& ch : s) {
            if (ch >= 'A' && ch <= 'Z') {
                ch += 32; // 大写转小写(ASCII差值)
            }
        }
        
        int left = 0, right = s.size() - 1;
        while (left < right) {
            // 跳过非字母数字
            while (left < right && !isAlphaNum(s[left])) ++left;
            while (left < right && !isAlphaNum(s[right])) --right;
            
            if (s[left] != s[right]) {
                return false;
            }
            ++left;
            --right;
        }
        return true;
    }
};

6.4 例题 4:最后一个单词的长度(LeetCode 58)

题目:给定一个字符串,返回最后一个单词的长度(单词以空格分隔)。思路:从后往前找第一个非空格字符,再找前一个空格,计算长度。代码

#include <string>
using namespace std;

class Solution {
public:
    int lengthOfLastWord(string s) {
        // 从后往前跳过空格
        int right = s.size() - 1;
        while (right >= 0 && s[right] == ' ') {
            --right;
        }
        if (right < 0) return 0; // 全是空格
        
        // 找最后一个单词的左边界
        int left = right;
        while (left >= 0 && s[left] != ' ') {
            --left;
        }
        
        return right - left; // 长度 = 右边界 - 左边界
    }
};

七、总结:string 类的核心要点与学习建议

string类是 C++ 中最基础、最常用的类之一,其核心价值在于 “封装字符串操作、自动管理内存”。掌握string的关键在于:

1. 核心要点回顾

        接口使用:熟练掌握构造、容量(reserve/resize)、修改(+=/find/substr)接口,理解sizecapacity的区别。

        底层差异:了解 VS 的 “小字符串优化” 和 GCC 的 “写时拷贝”,避免跨平台问题。

        模拟实现:掌握深拷贝的两种实现方式(传统版、现代版),理解浅拷贝的缺陷。

        实战能力:通过 OJ 例题巩固接口使用,培养字符串处理的思维(如双指针、反转、进位处理)。

2. 学习建议

  1. 多查文档string的接口众多,无需死记硬背,使用时参考 C++ 标准文档(如cppreference)。
  2. 重视内存:虽然string自动管理内存,但需注意reserve的使用(避免频繁扩容),以及c_str()的生命周期(不要用已释放stringc_str())。
  3. 多练实战:字符串是 OJ 高频考点,通过 LeetCode 字符串专题(简单→中等)逐步提升,培养代码熟练度。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值