C++ string类详解:从使用到模拟实现

一、前言

在C语言里,操作字符串是一件让人头疼的事。你得手动管理内存,小心翼翼地调用strcpystrcatstrlen这些函数,稍不留神就可能越界访问或者内存泄漏。更别提这些函数和字符串本身是分离的,用起来一点也不面向对象。

C++引入了string类,把字符串的存储、操作、内存管理全部封装好了。你只需要#include <string>,就能用一套简洁优雅的接口来处理字符串。

这篇文章会带你全面了解string类,从基本用法到模拟实现,再到经典OJ题目。学完之后,你对string的理解会上一个台阶。

二、为什么需要string类?

2.1 C语言字符串的痛点

C语言中,字符串是以'\0'结尾的字符数组。标准库提供了一些str系列的函数来操作字符串:

char str1[20] = "hello";
char str2[20];

strcpy(str2, str1);     // 拷贝
strcat(str1, " world"); // 追加
int len = strlen(str1); // 求长度

看起来还行,但实际用起来有几个大问题:

  • 底层空间需要用户自己管理。你得确保目标数组足够大,否则strcpystrcat都会越界。忘了分配或者分配不够,程序直接崩。
  • 函数和字符串分离strlenstrcpy这些函数不是字符串"自己的方法",而是外部函数,不符合面向对象的思想。
  • 容易出错。C字符串没有边界检查,越界访问是家常便饭。

2.2 string类的优势

string类解决了上面所有问题:

  • 自动管理内存,不需要手动分配和释放
  • 提供了丰富的成员函数,操作字符串就像操作普通变量一样自然
  • 有边界检查,安全性更高
  • 支持+==<<>>等运算符,使用极其方便

在OJ刷题和实际开发中,string类几乎是处理字符串的首选。C库的字符串函数基本可以放一边了。

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

在正式讲string之前,先补两个C++11的语法糖,后面会频繁用到。

3.1 auto关键字

C++11给auto赋予了全新的含义:它不再表示"自动存储期",而是作为一个类型指示符,告诉编译器"根据初始化表达式自动推导变量的类型"。

int a = 10;
auto b = a;       // b的类型推导为int
auto c = 'a';     // c的类型推导为char
auto d = 3.14;    // d的类型推导为double

使用auto的注意事项:

  1. auto定义的变量必须有初始值,否则编译器无法推导类型:
auto e;  // 编译错误!无法推导e的类型
  1. auto声明指针时,autoauto*没有区别:
int x = 10;
auto y = &x;    // y是int*
auto* z = &x;   // z也是int*,和上面等价
  1. 声明引用时必须加&:
auto m = x;     // m是int,是x的一份拷贝
auto& n = x;    // n是int&,是x的引用
  1. 同一行声明多个变量时,类型必须一致:
auto aa = 1, bb = 2;     // OK,都是int
auto cc = 3, dd = 4.0;   // 编译错误!int和double不一致
  1. auto不能做函数参数,不能声明数组:
void func(auto a) {}         // 编译错误
auto arr[] = {1, 2, 3};     // 编译错误

auto真正好用的场景是类型特别长的时候:

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

map<string, string> dict = {{"apple", "苹果"}, {"orange", "橙子"}};

// 不用auto,类型名长得离谱
map<string, string>::iterator it = dict.begin();

// 用auto,简洁多了
auto it2 = dict.begin();

3.2 范围for循环

C++11引入了范围for(也叫for-each),用来遍历有范围的集合:

int array[] = {1, 2, 3, 4, 5};

// C++98的写法
for (int i = 0; i < sizeof(array) / sizeof(array[0]); ++i)
{
    cout << array[i] << " ";
}

// C++11范围for,简洁得多
for (auto e : array)
{
    cout << e << " ";
}

范围for的语法是for (变量 : 集合),编译器会自动迭代、自动取数据、自动判断结束。

如果要修改元素,用引用:

// 修改数组中每个元素,乘以2
for (auto& e : array)
{
    e *= 2;
}

// 只读遍历,用值即可
for (auto e : array)
{
    cout << e << " ";
}

范围for可以作用于数组和所有STL容器。底层其实就是用迭代器实现的,只是写法更简洁。

string str("hello world");

// 用范围for遍历string的每个字符
for (auto ch : str)
{
    cout << ch << " ";  // 输出: h e l l o   w o r l d
}

四、string类的常用接口

string类的接口非常多,这里只讲最常用的那些。用到了再查文档就行。

4.1 构造函数

构造函数 功能说明
string() 构造空字符串
string(const char* s) 用C字符串构造
string(size_t n, char c) 构造包含n个字符c的字符串
string(const string& s) 拷贝构造
string s1;              // 空字符串
string s2("hello bit");  // 用C字符串构造
string s3(10, 'a');      // "aaaaaaaaaa"
string s4(s2);           // 拷贝构造,s4和s2一样

4.2 容量操作

函数 功能说明
size() 返回有效字符长度
length() 返回有效字符长度(和size一样)
capacity() 返回底层空间总大小
empty() 检测是否为空串
clear() 清空有效字符
reserve(n) 预留空间(不改变有效字符个数)
resize(n) 改变有效字符个数为n

几个需要注意的点:

  1. size()length()底层实现完全相同。引入size()是为了和其他STL容器接口保持一致。实际开发中基本都用size()

  2. clear()只清空有效字符,不改变底层空间大小:

string s("hello world");
cout << s.size() << endl;     // 11
cout << s.capacity() << endl; // 15(或更大,取决于实现)

s.clear();
cout << s.size() << endl;     // 0
cout << s.capacity() << endl; // 15,容量没变
  1. resize改变元素个数时,如果增大可能会改变底层容量:
string s("hello");
s.resize(10, 'x');  // 扩大到10个字符,多出的用'x'填充
// s = "helloxxxxx"

s.resize(3);  // 缩小到3个字符
// s = "hel",底层容量不变
  1. reserve只预留空间,不改变有效字符个数,且当参数小于当前容量时不会缩容:
string s;
s.reserve(100);  // 预留100个字符的空间
cout << s.size() << endl;     // 0,有效字符没变
cout << s.capacity() << endl; // >=100

s.reserve(50);  // 50 < 100,什么也不做
cout << s.capacity() << endl; // 还是>=100

实用建议:如果你能预估字符串大概要多长,先reserve预留空间,可以减少多次扩容带来的开销。

4.3 元素访问和遍历

有三种方式访问string中的字符:

方式1:operator[] (最常用)

string s("hello");
cout << s[0] << endl;  // 'h'
cout << s[4] << endl;  // 'o'

// 修改字符
s[0] = 'H';
cout << s << endl;  // "Hello"

operator[]有两种重载:普通版本返回char&(可读可写),const版本返回const char&(只读)。

方式2:迭代器

string s("hello");

// 正向迭代器
string::iterator it = s.begin();
while (it != s.end())
{
    cout << *it << " ";
    ++it;
}

// 反向迭代器
string::reverse_iterator rit = s.rbegin();
while (rit != s.rend())
{
    cout << *rit << " ";
    ++rit;
}

迭代器是STL的通用遍历方式,所有容器都支持。begin()指向第一个元素,end()指向最后一个元素的下一个位置。

方式3:范围for (最简洁)

string s("hello");

// 只读遍历
for (auto ch : s)
{
    cout << ch << " ";
}

// 修改每个字符
for (auto& ch : s)
{
    ch = toupper(ch);  // 转大写
}
cout << s << endl;  // "HELLO"

4.4 修改操作

函数 功能说明
push_back(c) 尾插一个字符
append(str) 尾插一个字符串
operator+=(str) 尾插字符串或字符
c_str() 返回C格式的字符串
find(str, pos) 从pos位置向后查找,返回位置
rfind(str) 从后向前查找,返回位置
substr(pos, n) 从pos位置截取n个字符

追加字符串的三种方式:

string s("hello");

s.push_back('!');       // "hello!"
s.append(" world");    // "hello! world"
s += " C++";           // "hello! world C++"

这三种方式效果差不多,但实际开发中+=用得最多,因为它既能追加字符,也能追加字符串,写法最简洁。

查找和截取:

string s("hello world hello");

// find:从前往后找,返回第一个匹配的位置
size_t pos = s.find("world");
if (pos != string::npos)  // npos是string的一个静态常量,表示"没找到"
{
    cout << "找到world,位置:" << pos << endl;  // 位置:6
}

// rfind:从后往前找
size_t rpos = s.rfind("hello");
cout << "最后一个hello的位置:" << rpos << endl;  // 位置:12

// substr:截取子串
string sub = s.substr(6, 5);  // 从位置6开始截取5个字符
cout << sub << endl;  // "world"

find找不到时返回string::npos,这是一个size_t类型的最大值,通常用!= string::npos来判断是否找到。

c_str的使用场景:

有些C语言的接口只接受const char*参数,这时候需要用c_str():

string filename("test.txt");

// C语言的文件操作接口需要const char*
FILE* fp = fopen(filename.c_str(), "r");

4.5 非成员函数

函数 说明
operator+ 拼接字符串(尽量少用,效率低)
operator>> 输入运算符(遇空格结束)
operator<< 输出运算符
getline 读取一整行(包括空格)
关系运算符 ==!=<>

输入的区别:

string s1, s2;

cin >> s1;       // 遇到空格就停止,"hello world"只读到"hello"
getline(cin, s2); // 读取整行,包括空格,"hello world"完整读取

cin >>遇到空格、制表符、换行都会停止。如果你想读取包含空格的一整行内容,用getline

operator+为什么尽量少用?

因为+返回的是一个新的string对象,涉及深拷贝,效率较低。能用+=就用+=:

string s = "hello";
s = s + " world";  // 创建临时对象,深拷贝,效率低
s += " world";     // 直接在原对象上追加,效率高

五、string的底层结构

了解string的底层结构有助于理解它的行为。不同编译器的实现方式不同,这里介绍两种主流实现。

5.1 VS下的string结构

VS的string总共占28个字节(32位平台),内部包含:

  • 一个16字节的联合体(小字符串存储区 或 堆指针)
  • 一个size_t字段保存字符串长度(4字节)
  • 一个size_t字段保存堆空间总容量(4字节)
  • 一个指针做其他事情(4字节)

关键在于那个联合体:

union _Bxty
{
    char _Buf[16];  // 小字符串直接存在这里
    char* _Ptr;      // 大字符串用指针指向堆空间
} _Bx;

当字符串长度小于16时,直接存在内部的_Buf数组里,不需要去堆上分配内存,效率很高。当长度大于等于16时,才在堆上分配空间,_Ptr指向那块内存。

这种设计很聪明——大多数字符串都比较短,直接用内部缓冲区就搞定了,省去了堆分配的开销。

5.2 g++下的string结构

g++的实现完全不同,string对象只占4个字节,内部只有一个指针,指向堆上的一块空间。那块空间里存放了:

struct _Rep_base
{
    size_t _M_length;    // 字符串有效长度
    size_t _M_capacity;  // 空间总大小
    int _M_refcount;     // 引用计数
};

g++采用了写时拷贝(Copy-on-Write)技术。拷贝string时不会立即复制数据,而是共享同一块内存,引用计数加1。只有当某个对象试图修改字符串内容时,才会真正复制一份独立的数据。

这种方式的优点是拷贝开销小,缺点是修改时可能触发额外的拷贝,而且在多线程环境下引用计数的维护比较复杂。现代g++版本已经逐渐放弃了写时拷贝。

六、string类的模拟实现

面试中经常被要求手写string类。这里从浅拷贝的问题讲起,逐步实现一个完整的string

6.1 浅拷贝的陷阱

先看一个"有问题"的String类:

class String
{
public:
    String(const char* str = "")
    {
        if (nullptr == str)
        {
            assert(false);
            return;
        }
        _str = new char[strlen(str) + 1];  // +1留给'\0'
        strcpy(_str, str);
    }

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

private:
    char* _str;
};

void TestString()
{
    String s1("hello");
    String s2(s1);  // 拷贝构造
}

问题出在哪?这个类没有自己定义拷贝构造函数,编译器会生成一个默认的——浅拷贝。浅拷贝只是把s1._str的值(指针)复制给s2._str,结果两个对象指向同一块堆内存。

TestString结束时,s2先析构,释放了那块内存。然后s1析构,又试图释放同一块内存——同一块空间被释放两次,程序崩溃

打个比方:两个孩子共用一个玩具,一个孩子把玩具摔坏了,另一个孩子就没得玩了。

6.2 深拷贝:传统写法

解决办法就是深拷贝——每个对象都有自己独立的一份资源:

class String
{
public:
    // 构造函数
    String(const char* str = "")
    {
        if (nullptr == str)
        {
            assert(false);
            return;
        }
        _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* pStr = new char[strlen(s._str) + 1];
            strcpy(pStr, s._str);
            delete[] _str;
            _str = pStr;
        }
        return *this;
    }

    // 析构函数
    ~String()
    {
        if (_str)
        {
            delete[] _str;
            _str = nullptr;
        }
    }

private:
    char* _str;
};

赋值运算符的实现中,先开辟新空间再释放旧空间,这是为了防止new失败时原来的数据也丢了。这种写法安全但稍显啰嗦。

6.3 深拷贝:现代写法

现代C++有一种更简洁的写法,利用了拷贝构造和swap:

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

    // 拷贝构造:先构造一个临时对象,再交换
    String(const String& s)
        : _str(nullptr)  // 重要!初始化为nullptr
    {
        String strTmp(s._str);  // 用s的字符串构造临时对象
        swap(_str, strTmp._str); // 交换指针,临时对象析构时释放旧资源
    }

    // 赋值运算符:参数传值,自动调用拷贝构造
    String& operator=(String s)  // 注意:参数是值,不是引用
    {
        swap(_str, s._str);  // 交换this和s的资源
        return *this;         // s析构时自动释放this原来的资源
    }

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

private:
    char* _str;
};

现代写法的精髓在于:

  • 拷贝构造:先构造一个临时对象(临时对象有自己的独立空间),然后用swap把临时对象的指针和当前对象的指针交换。临时对象析构时,自动释放当前对象原来的资源。
  • 赋值运算符:参数按值传递,调用拷贝构造创建了一个局部副本 s,然后swap交换指针。函数结束时 s析构,自动释放this原来的资源。

代码更简洁,而且天然处理了自赋值的情况(自己给自己赋值时,swap之后还是自己)。

6.4 写时拷贝(了解)

写时拷贝是g++早期采用的策略。核心思想是:拷贝时不立即复制数据,而是共享同一块内存,用一个引用计数记录有多少对象共享这块资源。

  • 构造时,引用计数为1
  • 拷贝时,引用计数+1,不复制数据
  • 析构时,引用计数-1;如果减到0,才真正释放资源
  • 修改时,如果引用计数>1,先复制一份独立的数据,再修改

这种方式的优点是拷贝开销极低(只复制指针和计数),缺点是每次修改都要检查引用计数,多线程下还要加锁,反而可能降低性能。现代STL实现已经很少用写时拷贝了。

七、经典OJ题目实战

7.1 仅仅反转字母

给定一个字符串,只反转其中的字母,其他字符保持原位。

思路:双指针,一个从头往后找字母,一个从后往前找字母,找到后交换。

class Solution {
public:
    // 判断是否是字母
    bool isLetter(char ch)
    {
        return (ch >= 'a' && ch <= 'z')
            || (ch >= 'A' && ch <= 'Z');
    }

    string reverseOnlyLetters(string S)
    {
        if (S.empty())
            return S;

        int begin = 0, end = S.size() - 1;
        while (begin < end)
        {
            // begin跳过非字母字符
            while (begin < end && !isLetter(S[begin]))
                ++begin;
            // end跳过非字母字符
            while (begin < end && !isLetter(S[end]))
                --end;
            // 交换两个字母
            swap(S[begin], S[end]);
            ++begin;
            --end;
        }
        return S;
    }
};

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

思路:用数组统计每个字符出现的次数,然后遍历字符串找第一个次数为1的字符。

class Solution {
public:
    int firstUniqChar(string s)
    {
        // 用数组统计每个字符出现次数,ASCII字符共256个
        int count[256] = {0};
        int size = s.size();

        // 第一遍:统计次数
        for (int i = 0; i < size; ++i)
            count[s[i]] += 1;

        // 第二遍:找第一个只出现一次的字符
        for (int i = 0; i < size; ++i)
            if (1 == count[s[i]])
                return i;

        return -1;  // 没找到
    }
};

时间复杂度O(n),空间复杂度O(1)(固定大小的数组)。这里用数组下标对应字符的ASCII值,是一种常见的哈希思想。

7.3 字符串最后一个单词的长度

从标准输入读取一行,输出最后一个单词的长度。

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

int main()
{
    string line;
    // 用getline读取整行,不能用cin>>(遇空格就停了)
    while (getline(cin, line))
    {
        // rfind从后往前找空格
        size_t pos = line.rfind(' ');
        // 最后一个单词的长度 = 总长度 - 空格位置 - 1
        cout << line.size() - pos - 1 << endl;
    }
    return 0;
}

rfind(' ')返回最后一个空格的位置。如果字符串是"hello world",rfind返回5,长度就是11 - 5 - 1 = 5,正好是"world"的长度。

7.4 验证回文串

只考虑字母和数字,忽略大小写,验证字符串是否是回文。

class Solution {
public:
    // 判断字符是否是字母或数字
    bool isLetterOrNumber(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;  // 小写转大写
        }

        int begin = 0, end = s.size() - 1;
        while (begin < end)
        {
            // 跳过非字母数字字符
            while (begin < end && !isLetterOrNumber(s[begin]))
                ++begin;
            while (begin < end && !isLetterOrNumber(s[end]))
                --end;

            if (s[begin] != s[end])
                return false;

            ++begin;
            --end;
        }
        return true;
    }
};

还是双指针的套路,一个从前往后,一个从后往前,跳过无关字符后比较。

7.5 字符串相加

给定两个用字符串表示的非负整数,计算它们的和。

思路:从后往前逐位相加,处理进位,最后反转结果。

class Solution {
public:
    string addStrings(string num1, string num2)
    {
        int end1 = num1.size() - 1;
        int end2 = num2.size() - 1;
        int next = 0;  // 进位
        string addret;

        while (end1 >= 0 || end2 >= 0)
        {
            // 取当前位的数字,如果已经遍历完就取0
            int value1 = (end1 >= 0) ? num1[end1--] - '0' : 0;
            int value2 = (end2 >= 0) ? num2[end2--] - '0' : 0;

            int valueRet = value1 + value2 + next;

            // 处理进位
            if (valueRet > 9)
            {
                next = 1;
                valueRet -= 10;
            }
            else
            {
                next = 0;
            }

            // 把当前位的结果追加到字符串
            addret += (valueRet + '0');
        }

        // 处理最后的进位
        if (next == 1)
            addret += '1';

        // 因为我们是从低位到高位追加的,需要反转
        reverse(addret.begin(), addret.end());
        return addret;
    }
};

这里用+=尾插再reverse的方式,比用insert头插效率高得多。insert在头部插入需要移动所有后续元素,时间复杂度O(n),而+=尾插是均摊O(1)。

八、总结

这篇文章从使用到原理,系统地讲解了C++的string类:

核心知识点回顾:

  • C语言字符串的缺陷催生了string类,它封装了内存管理,提供了丰富的操作接口
  • auto和范围for是C++11的语法糖,能让代码更简洁
  • string的常用接口包括构造、容量管理、元素访问、修改操作、查找截取等
  • 不同编译器下string的底层结构不同,VS用了小缓冲区优化,g++早期用写时拷贝
  • 模拟实现string的关键是正确处理深拷贝,现代写法利用swap更简洁
  • OJ题目中双指针、哈希统计是处理字符串的常用技巧

使用建议:

  • 优先使用+=而不是+来拼接字符串
  • 能预估长度时先用reserve预留空间
  • 读取含空格的整行用getline,不要用cin >>
  • 查找时注意判断npos

string是C++中最常用的类之一,熟练掌握它的用法对日常开发和刷题都很有帮助。多写多练,遇到不会的接口随时查文档,慢慢就熟了。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐