一、为什么我们一定要用 string 类?

1.1 C 语言字符串的痛点

先回忆一下 C 语言中我们是如何操作字符串的:

char str[20];
strcpy(str, "hello");
strcat(str, " world");
printf("%s\n", str);

C 标准库提供了 strlenstrcpystrcatstrcmp 等函数,但它们存在几个致命问题:

  • 与数据分离:字符串数据存储在字符数组中,操作函数是独立的,不符合 OOP 的封装思想。

  • 手动管理内存:开发者必须自己保证数组大小足够,否则就会发生缓冲区溢出,造成严重安全隐患。

  • 边界检查缺失:很多 str 函数不检查越界,稍不留神就可能踩踏内存。

1.2 C++ string 的优势

C++ 的 std::string 类完美解决了上述问题:

  • 自动内存管理:字符串长度改变时,自动分配或释放内存。

  • 安全边界:通过 size() 和 [] 运算符(带检查)避免越界。

  • 面向对象接口:使用成员函数如 findsubstrappend,语义清晰。

  • 支持运算符重载++===< 等,使用起来非常自然。

  • 高效实现:现代标准库采用小字符串优化(SSO)、写时拷贝(COW)等技巧,兼顾性能和灵活性。

在实际开发、笔试面试中,string 几乎无处不在。接下来,我们就从零开始学习 string 的标准用法。

二、string 基础:构造、容量与遍历

使用 string 需要包含头文件 <string>,并引入 std 命名空间。

2.1 常见构造方式

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

int main() {
    string s1;                  // 空字符串
    string s2("hello");         // 从 C 字符串构造
    string s3(s2);              // 拷贝构造
    string s4(5, 'A');          // 5 个 'A': "AAAAA"
    
    cout << s2 << endl;         // hello
    cout << s4 << endl;         // AAAAA
    return 0;
}

2.2 容量相关操作

函数 作用
size() 返回字符串有效字符个数(不含 '\0')
length() 同 size(),历史原因保留
capacity() 返回当前分配的内存大小(可容纳字符数)
empty() 判断是否为空
clear() 清空字符串,但一般不释放内存
resize(n) 将有效字符数改为 n,多出的用 '\0' 填充
reserve(n) 预留至少 n 个字符的空间(不改变 size)

重要区别resize 会改变 size(),而 reserve 只影响 capacity()。合理使用 reserve 可以减少多次扩容带来的性能开销。

示例:

string s = "hello";
cout << s.size() << " " << s.capacity() << endl;  // 5 15(具体值依编译器而定)

s.reserve(100);
cout << s.capacity() << endl;  // 至少 100

s.resize(10, '!');
cout << s << endl;              // "hello!!!!!"  size 变为 10

2.3 访问与遍历

有三种主流方式遍历 string

① 使用 [] 运算符(或 at()
string s = "world";
for (size_t i = 0; i < s.size(); ++i) {
    cout << s[i] << " ";
}

at() 会做边界检查,越界时抛出异常,但效率稍低。

② 使用迭代器
for (string::iterator it = s.begin(); it != s.end(); ++it) {
    cout << *it << " ";
}
③ C++11 范围 for
for (char ch : s) {
    cout << ch << " ";
}

范围 for 底层就是迭代器,书写最简洁,推荐使用。

三、string 的修改操作:拼接、查找、截取

3.1 追加字符串

string s = "hello";
s.push_back(' ');         // 追加单个字符
s.append("world");        // 追加字符串
s += "!";                 // 最常用,支持字符和字符串
cout << s << endl;        // "hello world!"

3.2 查找与替换

find 函数用于查找子串或字符,返回第一次出现的位置(size_t 类型),若找不到则返回 string::npos(通常为 -1)。

string s = "C++ is powerful, C++ is fun.";
size_t pos = s.find("C++");
while (pos != string::npos) {
    cout << "found at " << pos << endl;
    pos = s.find("C++", pos + 1);
}

rfind 从右向左查找。substr(pos, len) 用于截取子串:

string sub = s.substr(0, 3);   // "C++"

3.3 获取 C 风格字符串

c_str() 返回一个 const char*,用于与旧 C 函数交互:

string s = "hello";
printf("%s\n", s.c_str());

四、牛刀小试:几个经典 string 算法题

4.1 仅仅反转字母(LeetCode 917)

只反转字母字符,保持非字母的位置不变。思路:双指针向中间靠拢,遇到字母才交换。

class Solution {
public:
    bool isLetter(char ch) {
        return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z');
    }
    string reverseOnlyLetters(string S) {
        int 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--]);
        }
        return S;
    }
};

4.2 字符串相加(LeetCode 415)

模拟竖式加法,从末尾逐位相加,注意进位。

string addStrings(string num1, string num2) {
    int i = num1.size() - 1, j = num2.size() - 1, carry = 0;
    string result;
    while (i >= 0 || j >= 0 || carry) {
        int sum = carry;
        if (i >= 0) sum += num1[i--] - '0';
        if (j >= 0) sum += num2[j--] - '0';
        result.push_back(sum % 10 + '0');
        carry = sum / 10;
    }
    reverse(result.begin(), result.end());
    return result;
}

五、深入底层:string 的内存布局(vs 和 g++)

了解不同标准库实现下的 string 内存结构,有助于写出高效代码。

5.1 VS(微软编译器)下的 string

在 32 位平台下,sizeof(string) = 28 字节。其内部设计采用了 小字符串优化(SSO)

  • 当字符串长度小于 16 时,存储在对象内部的固定数组 _Buf[16] 中,不进行堆分配。

  • 当长度 >= 16 时,在堆上分配内存,_Ptr 指向堆空间。

联合体 _Bxty 同时容纳 _Buf 和 _Ptr,配合 size 和 capacity 字段,实现了短字符串零开销。

5.2 g++ 下的 string

g++ 采用 写时拷贝(Copy-On-Write) 技术(早期版本,较新的 g++ 已改为 SSO)。其对象只占 4 字节(一个指针),指向堆上一个 _Rep 结构,该结构包含:

  • _M_length:字符串长度

  • _M_capacity:容量

  • _M_refcount:引用计数

多个 string 对象可以共享同一块堆内存,当需要修改时才真正复制。这种设计减少了不必要的拷贝,但在多线程环境下引用计数的维护有额外开销。

注意:C++11 标准之后,COW 实现被禁止,目前主流编译器(libstdc++ 高版本、libc++)均已转向 SSO。不同平台 SSO 的阈值略有差异。

六、手撕 string:模拟实现及深拷贝剖析

面试官最爱问:“请手写一个简单的 String 类,实现构造、析构、拷贝构造和赋值运算符。” 这背后考察的是对浅拷贝深拷贝的理解。

6.1 浅拷贝的灾难

如果不显式定义拷贝构造和赋值,编译器生成的默认版本只是逐字节复制成员变量(位拷贝)。对于含有指针成员的类,这会导致多个对象指向同一块堆内存。析构时该内存被多次释放,程序崩溃。

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;   // 浅拷贝!s2._str 和 s1._str 指向同一块内存
// 程序结束时会 double free

6.2 深拷贝的传统写法

深拷贝的核心:为每个对象独立分配资源,拷贝时复制内容。

class String {
public:
    String(const char* 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) {
            delete[] _str;
            _str = new char[strlen(s._str) + 1];
            strcpy(_str, s._str);
        }
        return *this;
    }
    ~String() { delete[] _str; }
private:
    char* _str;
};

6.3 现代写法(拷贝交换)

利用 swap 和临时对象,使代码更简洁且异常安全。

class String {
public:
    String(const char* str = "") {
        _str = new char[strlen(str) + 1];
        strcpy(_str, str);
    }
    // 拷贝构造:传值?不对,应该是 const& 配合临时对象
    String(const String& s) : _str(nullptr) {
        String tmp(s._str);   // 调用构造创建临时对象
        swap(_str, tmp._str);
    }
    // 赋值:使用传值,直接复用拷贝构造
    String& operator=(String s) {
        swap(_str, s._str);
        return *this;
    }
    ~String() { delete[] _str; }
private:
    char* _str;
};

赋值运算符的参数 String s 是传值,会调用拷贝构造生成一份副本,然后与当前对象的 _str 交换,临时对象销毁时释放旧内存。这种方式代码简洁且自动处理了自我赋值。

6.4 写时拷贝(COW)简介

写时拷贝是一种优化策略:在浅拷贝的基础上添加引用计数。多个对象共享同一块内存,只有当某个对象试图修改内容时才真正执行深拷贝。这样可以节省大量拷贝开销,尤其适合频繁复制但很少修改的场景。

// 伪代码示意
struct StringRep {
    size_t refCount;
    size_t size;
    char data[1];
};

当 String 对象拷贝时,只是增加引用计数;修改前若发现引用计数 > 1,则复制一份新内存并减少原计数。不过现代 C++ 中由于移动语义和 SSO 的普及,COW 已不再主流。

七、C++11 的两个小语法:auto 和范围 for

在学习 string 过程中,我们经常看到 auto 和范围 for 的身影,这里简单提一下。

7.1 auto 类型推导

auto 让编译器根据初始值自动推导类型,极大地简化了冗长的迭代器声明:

map<string, string> dict;
// 原本:map<string, string>::iterator it = dict.begin();
auto it = dict.begin();   // 简洁多了

注意:auto 不能用于函数参数,不能声明数组,且同一行定义的变量类型必须一致。

7.2 范围 for

本质上是一种语法糖,编译器会将其展开为基于迭代器的循环。它可以遍历任何拥有 begin/end 成员函数的容器以及原生数组。

int arr[] = {1,2,3,4};
for (int& x : arr) x *= 2;   // 修改原数组
for (int x : arr) cout << x << " ";

八、总结与建议

  1. 优先使用 std::string:不要再用 char* 手动管理字符串内存,string 安全且高效。

  2. 熟悉常用接口:构造、size/capacityreserveresizec_strfindsubstrappend+=[] 等。

  3. 合理使用 reserve:预分配空间避免反复扩容。

  4. 理解深浅拷贝:面试常考,写自己的 String 类时务必实现深拷贝或使用现代写法。

  5. 掌握范围 for 和 auto:提升代码简洁性,但也要清楚其底层原理。

string 类虽小,却是 C++ 标准库中最常用的组件之一。希望这篇文章能帮你打通从使用到原理的任督二脉,在开发与面试中更加游刃有余。

练习推荐:LeetCode 上字符串分类的题目,如“反转字符串中的单词”、“字符串相乘”、“最长公共前缀”等,都是巩固 string 操作的好机会。

下一篇预告:我们将深入 vector 容器,讲解动态数组的实现与迭代器失效问题。敬请期待!

更多推荐