C++ 字符串:始于 char*,终于深拷贝

很多人写 C++,一到字符串就开始乱:char* 怕越界,string 会用但不敢说懂,更别提拷贝构造、深拷贝、写时拷贝这些词。这篇文章从最基础的 C 风格串讲到标准库 string,再到 OJ 实战和手写 String 类,按使用顺序一点点铺开,让你把"字符串这一块"变成真正的强项。

目录

[1. 为什么要学习 string 类?](#1. 为什么要学习 string 类?)

[1.1 C 语言中的字符串](#1.1 C 语言中的字符串)

[1.2 面试高频题](#1.2 面试高频题)

[2. 标准库中的 string 类](#2. 标准库中的 string 类)

[2.1 string 类的基本使用](#2.1 string 类的基本使用)

[2.2 auto](#2.2 auto)

[2.2.1 auto 在 C++11 之前和之后的区别](#2.2.1 auto 在 C++11 之前和之后的区别)

[2.2.2 auto 的规则](#2.2.2 auto 的规则)

[2.2.3 完整示例](#2.2.3 完整示例)

[2.2.4 auto 使用场景](#2.2.4 auto 使用场景)

[2.3 范围for](#2.3 范围for)

[2.3.1 为什么要有"范围 for"](#2.3.1 为什么要有“范围 for”)

[2.3.2 例子](#2.3.2 例子)

[2.4 string 类的常用接口说明](#2.4 string 类的常用接口说明)

[2.4.1 string类对象的容量操作](#2.4.1 string类对象的容量操作)

[2.4.2 访问与遍历](#2.4.2 访问与遍历)

[2.4.3 修改与查询](#2.4.3 修改与查询)

[2.4.4 string类非成员函数](#2.4.4 string类非成员函数)

[3. VS 和 g++ 下string的内部结构](#3. VS 和 g++ 下string的内部结构)

[3.1 VS 下string的结构](#3.1 VS 下string的结构)

[3.2 g++下的string的结构](#3.2 g++下的string的结构)

[4. 例题练习](#4. 例题练习)

[4.1 仅仅反转字母(Reverse Only Letters)](#4.1 仅仅反转字母(Reverse Only Letters))

[4.2 找字符串中第一个只出现一次的字符](#4.2 找字符串中第一个只出现一次的字符)

[4.3 字符串里面最后一个单词的长度](#4.3 字符串里面最后一个单词的长度)

[4.4 验证一个字符串是否是回文](#4.4 验证一个字符串是否是回文)

[4.5 字符串相加](#4.5 字符串相加)

[5. string类的模拟实现](#5. string类的模拟实现)

[5.1 错误版本:只有构造 + 析构](#5.1 错误版本:只有构造 + 析构)

[5.2 浅拷贝](#5.2 浅拷贝)

[5.3 深拷贝](#5.3 深拷贝)

[5.3.1 传统版写法的String类](#5.3.1 传统版写法的String类)

[5.3.2 现代版写法的String类](#5.3.2 现代版写法的String类)

[6. 写时拷贝](#6. 写时拷贝)

[6.1 写时拷贝的核心思路](#6.1 写时拷贝的核心思路)

[7. string 模拟实现](#7. string 模拟实现)

[7.1 String类模拟的完整代码:](#7.1 String类模拟的完整代码:)


1. 为什么要学习 string 类?

1.1 C 语言中的字符串

C 语言中,字符串是以 '\0' 结尾的一些字符的集合,为了操作方便, C 标准库中提供了一些 str 系列的库函数,但是这些库函数与字符串是分离开的,不太符合OOP 的思想,而且底层空间需要用户自己管理,稍不留神可能还会越界访问。

典型写法:

cpp 复制代码
char str1[] = "hello";   //实际字符是 h e l l o \0
char str2[100];          //需要自己保证写入时别越界,最后手动补 '\0'

C 标准库给了我们一大堆 strXXX 函数来操作这些字符串,比如:

  • strlen 统计长度;

  • strcpy 拷贝;

  • strcat 追加;

  • strcmp 比较大小;

  • ...

注意:

  1. 字符串和操作函数是分离的

    字符串只是 char*,所有操作都靠一堆散落的库函数,不符合"数据 + 行为封装在一起"的 OOP 思想。

  2. 底层空间需要手动管理

    • 需要自己决定数组大小;

    • 需要自己确保不越界;

    • 需要自己记得加 '\0'

    • 经常会写出各种"看起来没问题,其实随时会炸"的代码。

  3. 越界、内存错误隐蔽

    一旦访问越界,有时候程序当场崩;有时候静静错半天,最难排查。

所以,当 C++ 提供了 std::string 之后,大多数情况下我们都会优先选择 string 来写业务逻辑,把"字符数组 + '\0' + 手动管理长度"这套交给库去做。


1.2 面试高频题

先暂时了解会用到什么功能,后续进行讲解:

  1. 字符串转整型数字

    题目大意:"12345"12345,要自己实现,不准用 atoi / stoi

  2. 两个"数字字符串"相加

    题目大意 "123" + "7890""8013",不能直接转 int 再加,而是按字符串逐位相加(支持特别长的数字)。

在 OJ 和日常工作里,字符串相关的题目基本都默认你用 std::string 来写,不会再拿 char 数组从头造轮子。


2. 标准库中的 string

2.1 string 类的基本使用

想用 std::string,至少要做两件事:

cpp 复制代码
#include<iostream>
#include<string>
using namespace std;

int main()
{
    string s1;             //默认构造:空字符串
    string s2("hello");    //C风格字符串
    cout<<s2<<endl;
    return 0;
}

2.2 auto

2.2.1 auto 在 C++11 之前和之后的区别

一开始,auto 的含义是:

"自动存储器"的局部变量。

后来这个含义不重要了,C++11 直接"变废为宝",给了 auto 一个全新的定位:

auto 是一个"让编译器帮你推导类型"的关键字。

也就是说:

  • 变量必须有初始化;

  • 编译器根据初始值的类型,推导出变量的实际类型。

2.2.2 auto 的规则

要点:

  1. 指针类型:autoauto* 相同

    cpp 复制代码
    int x = 10;
    auto y = &x;   //推导为 int*
    auto* z = &x;  //也是 int*
  2. 引用类型:auto&

    cpp 复制代码
    int x = 10;
    auto& r = x;   //r 是 int& 引用
  3. 一行里声明多个变量,类型必须一致

    cpp 复制代码
    auto a = 1, b = 2;     //正确, 都是 int
    //auto c = 3, d = 4.0; //编译报错: auto 在同一行必须推导为同一类型
  4. 不能作为函数参数类型

    cpp 复制代码
    //不能做参数
    
    void func2(auto a) //错误!!!
    {}
  5. 可以作为返回值类型

    cpp 复制代码
    //可以做返回值,但一般不用
    auto func3()
    {
        return 3;  //推导为 int
    }
  6. 不能直接声明数组类型

    cpp 复制代码
    auto array[] = {4,5,6}; //错误:auto 不能直接声明数组
2.2.3 完整示例
cpp 复制代码
#include<iostream>
#include<typeinfo>
using namespace std;

int func1()
{
    return 10;
}

int main()
{
    int a = 10;

    auto b = a;       //推导为 int
    auto c = 'a';     //推导为 char
    auto d = func1(); //推导为 int

    //auto e; //编译报错:包含 auto 的变量必须有初始值

    cout<<typeid(b).name()<<endl;
    cout<<typeid(c).name()<<endl;
    cout<<typeid(d).name()<<endl;

    int x = 10;
    auto y = &x;   //int*
    auto* z = &x;  //int*
    auto& m = x;   //int&

    cout<<typeid(x).name()<<endl;
    cout<<typeid(y).name()<<endl;
    cout<<typeid(z).name()<<endl;
    cout<<typeid(m).name()<<endl;

    auto aa = 1, bb = 2;
    //auto cc = 3, dd = 4.0; //编译报错:类型不一致

    //auto array[] = {4,5,6}; //编译报错:不能直接声明数组

    return 0;
}
2.2.4 auto 使用场景

假设需要遍历一个 map<string,string>,迭代器类型要写一长串:

cpp 复制代码
#include<iostream>
#include<string>
#include<map>
using namespace std;

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

    //auto的使用场景:不再写一长串迭代器类型
    //std::map<std::string,std::string>::iterator it = dict.begin();
    auto it = dict.begin();

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

在实际项目中,只要类型比较长、比较复杂(比如嵌套容器、模板模板参数),auto 会极大减轻负担。


2.3 范围for

2.3.1 为什么要有"范围 for"

传统 for 循环有三个问题:

  1. 需要自己写下标、边界;

  2. 一旦下标写错,就容易越界或者漏元素;

  3. 对于容器来说,下标访问并不总是直观(比如 list)。

C++11 引入了"基于范围的 for"来解决这个问题:

cpp 复制代码
for(元素声明 : 被遍历的范围)
{
    //使用元素
}
  • 范围可以是数组,也可以是标准容器(如 vectorstringmap 等);

  • 底层其实还是用迭代器,只不过语法帮你省掉了那一堆 begin()/end() 的写法。

2.3.2 例子
cpp 复制代码
#include<iostream>
#include<string>
using namespace std;

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

    //C++98 的遍历方式
    for(int i = 0; i < (int)(sizeof(array)/sizeof(array[0])); ++i)
    {
        array[i] *= 2;
    }
    for(int i = 0; i < (int)(sizeof(array)/sizeof(array[0])); ++i)
    {
        cout<<array[i]<<endl;
    }

    //C++11 的基于范围 for 遍历方式
    for(auto& e : array)
        e *= 2;           //e 是 int&, 可以修改数组元素
    for(auto e : array)
        cout<<e<<" "<<endl; //e 是按值拷贝

    string str("hello world");
    for(auto ch : str)
    {
        cout<<ch<<" ";
    }
    cout<<endl;

    return 0;
}

2.4 string 类的常用接口说明


2.4.1 string类对象的容量操作
函数名 功能说明
size(重点) 返回字符串有效字符长度
length 返回字符串有效字符长度
capacity 返回空间总大小
empty(重点) 检测字符串释放为空串,是返回 true ,否则返回 false
clear(重点) 清空有效字符size 变为 0,但容量不变)
reserve(重点) 为字符串预留空间只改容量,不改 size
**resize(**重点) 将有效字符的个数该成 n 个,多出的空间用字符 c 填充

注意:

  1. size()length() 底层实现几乎一样,只是为了跟其他容器(比如 vector::size)接口统一,一般我们只用 size()

  2. clear() 只是把有效字符数设为 0,不会主动释放底层内存,capacity() 不变。

  3. resize(size_t n) / resize(size_t n,char c)

    • 统一是把"有效字符个数 "改成 n

    • 如果 n 小于当前 size(),就把多余的字符"砍掉";

    • 如果 n 大于当前 size()

      • resize(n) 用字符 '\0' 填充新增部分;

      • resize(n,c) 用字符 c 填充新增部分;

      • 如有需要,底层会扩容(capacity 变大)。

  4. reserve(size_t res_arg = 0)

    • 用来提前预留空间,减少扩容次数(减少拷贝、提升性能);

    • 不会改变 size()

    • 如果 res_arg 小于当前的底层容量,通常不会缩小容量。

示例代码:

cpp 复制代码
#include<iostream>
#include<string>
using namespace std;

void TestStringCapacity()
{
    string s("hello");

    cout << "size=" << s.size() << endl;
    cout << "length=" << s.length() << endl;
    cout << "capacity=" << s.capacity() << endl;
    cout << "empty=" << s.empty() << endl;

    s.reserve(100);      //预留空间
    cout << "after reserve(100), capacity=" << s.capacity() << endl;

    s.resize(10, 'x');   //扩大有效长度,补 'x'
    cout << "after resize(10,'x'): " << s << endl;
    cout << "size=" << s.size() << ", capacity=" << s.capacity() << endl;

    s.clear();     //清空有效字符
    cout << "after clear, size=" << s.size() << ", capacity=" << s.capacity() << endl;
}

2.4.2 访问与遍历

相关接口:

函数名 功能说明
operator[](重点) 返回 pos 位置的字符, const string 类对象调用
begin() + end() begin 获取一个字符的迭代器 + end 获取最后一个字符下一个位 置的迭代器
rbegin() + rend() begin 获取一个字符的迭代器 + end 获取最后一个字符下一个位 置的迭代器
范围 for C++11 支持更简洁的范围 for 的新遍历方式

简单示例:

cpp 复制代码
void TestStringAccess()
{
    string s("hello");

    //下标访问
    for(size_t i = 0; i < s.size(); ++i)
    {
        cout<<s[i]<<' ';
    }
    cout<<endl;

    //正向迭代器访问
    for(auto it = s.begin(); it != s.end(); ++it)
    {
        cout<<*it<<' ';
    }
    cout<<endl;

    //反向迭代器访问
    for(auto rit = s.rbegin(); rit != s.rend(); ++rit)
    {
        cout<<*rit<<' ';
    }
    cout<<endl;

    //范围for
    for(auto ch : s)
    {
        cout<<ch<<' ';
    }
    cout<<endl;
}

2.4.3 修改与查询

讲义中列出的接口:

函数名 功能说明
push_back(char c) 在字符串后尾插字符 c
append(const string& str) 在字符串后追加一个字符串
operator+=(...)(重点) 在字符串后追加字符串 str
c_str()(重点) 返回 C 格式字符串结尾带 '\0'
find(char c,pos) + npos(重点) 从字符串 pos 位置开始往后找字符 c ,返回该字符在字符串中的 位置 ,找不到返回 string::npos
rfind(char c,pos) 从字符串 pos 位置开始往前找字符 c ,返回该字符在字符串中的 位置
substr(pos,n) str 中从 pos 位置开始,截取 n 个字符,然后将其返回

注意:

  1. 在字符串尾部追加字符时,有多种写法:

    cpp 复制代码
    s.push_back('c');
    s.append(1,'c');
    s += 'c';

    这三种底层实现差不多,只是写法上略有差异。
    实际开发里,+= 用得最多:既可以拼接单个字符,也可以拼接字符串。

  2. 如果大概能预估最终字符串大概有多长,可以先 reserve 一下,减少扩容。

例子:

cpp 复制代码
void TestStringModify()
{
    string s("hello");

    s.push_back(' ');
    s += "world";

    cout << "s=" << s << endl;  //"hello world"
    cout << "c_str=" << s.c_str() << endl;

    size_t pos = s.find('w');
    if (pos != string::npos)
    {
        cout << "find 'w' at " << pos << endl;
    }

    string sub = s.substr(6, 5); //"world"
    cout << "substr=" << sub << endl;
}

2.4.4 string类非成员函数

讲义列了几类:

函数名 功能说明
operator+ 字符串拼接;尽量少用,大量拼接时效率偏低
operator>>(重点) 输入运算符,读入一个单词(遇空白结束)
operator<<(重点) 输出运算符,打印整个字符串
getline(重点) 从输入流中读取一整行,包括空格
relational operators(重点) <,<=,>,>=,==,!=,按字典序比较

示例:

cpp 复制代码
#include<iostream>
#include<string>
using namespace std;

void TestStringIO()
{
    string s;

    cout << "请输入一个单词:" << endl;
    cin >> s;                   //读到空格/换行结束
    cout << "cin 读取:" << s << endl;

    cin.ignore(1024, '\n');    //把当前行剩余内容丢掉

    cout << "请输入一整行:" << endl;
    string line;
    getline(cin, line);        //读完整一行,包括空格
    cout << "getline 读取:" << line << endl;

    string a = "abc";
    string b = "abd";
    if (a < b)
    {
        cout << "\"abc\" 小于 \"abd\"" << endl;
    }
}

3. VS 和 g++ 下string的内部结构

注意:下述结构是在 32 位平台 下测试的结构(指针 4 字节),在 64 位下大小会变,但整体思路类似。


3.1 VS 下string的结构

在 VS 的实现里,一个 std::string 对象大概占 28 个字节,内部结构大致有三部分:

  1. 一个 联合体 _Bx,用来存储字符串内容:

    • 如果字符串长度 < 16,直接放在对象内部的一个固定数组里;

    • 如果长度 >= 16,则在堆上开空间存储。

  2. 一个 size_t 字段,用来记录当前字符串的有效长度(size)。

  3. 另一个 size_t 字段,记录底层堆空间的容量(capacity)。

  4. 最后还有一个指针,配合调试/内部实现使用。

示意代码:

cpp 复制代码
union _Bxty
{
    //小缓冲区:存放短字符串
    value_type _Buf[_BUF_SIZE];
    //长字符串:指向堆上空间
    pointer _Ptr;
    //别名数组,用于内部实现
    char _Alias[_BUF_SIZE];
} _Bx;

struct StringLike
{
    _Bxty _Bx;          //小字符串缓冲/指针
    size_t _Size;       //有效长度
    size_t _Capacity;   //容量
    void*  _Other;      //其他用途的指针
};

绝大部分字符串都比较短(比如路径、key、单词等),如果每次都到堆上去申请空间,开销会很大。

所以 VS 采用"小字符串优化"(SSO):短字符串直接"塞进对象内部",省掉一次堆分配,效率更高。


3.2 g++下的string的结构

g++下,std::string 采用了一种叫做"写时拷贝"(COW, Copy-On-Write)的实现方式:

  • string 对象本身只占 4 个字节(32 位平台),里面只放了一个指针;

  • 这个指针指向堆上的一个"共享数据块",里面包含:

    • 字符串的有效长度;

    • 空间总大小(容量);

    • 一个引用计数(有多少个 string 对象在"共享"这块内存);

    • 以及真正存放字符的缓冲区指针。

讲义中给出的结构片段:

cpp 复制代码
struct _Rep_base
{
    size_type _M_length;    //当前字符串长度
    size_type _M_capacity;  //容量
    _Atomic_word _M_refcount; //引用计数
};

写时拷贝的大致思想:

  • 拷贝构造或赋值时,不立刻深拷贝字符数组,而是让多个 string 对象共享同一块内存;

  • 只要字符串是"只读"的,不修改内容,就都用这一份数据,节省内存和拷贝开销;

  • 一旦某个对象要修改字符串(写操作),发现 引用计数 > 1,就先"偷偷"深拷贝一份,再在自己的那份上修改,这就是"写的时候才拷贝"。


4. 例题练习

4.1 仅仅反转字母(Reverse Only Letters)

题意大意:

给你一个字符串,其中有字母、数字、符号等。

要求只"翻转字母的相对顺序",非字母字符保持在原来的位置不动。

例子:

  • 输入:"a-bC-dEf-ghIj"

  • 输出:"j-Ih-gfE-dCba"

思路:双指针 + 跳过非字母

  1. 定义两个下标 beginend,分别指向字符串首尾;

  2. begin 向右走,跳过非字母;end 向左走,跳过非字母;

  3. 当两边都指向字母时,交换这两个字母;

  4. 继续向中间靠近,直到相遇。

代码:

cpp 复制代码
class Solution {
public:
    bool isLetter(char ch)
    {
        if(ch >= 'a' && ch <= 'z')
            return true;
        if(ch >= 'A' && ch <= 'Z')
            return true;
        return false;
    }

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

        size_t begin = 0;
        size_t end = S.size() - 1;

        while(begin < end)
        {
            while(begin < end && !isLetter(S[begin]))
                ++begin;
            while(begin < end && !isLetter(S[end]))
                --end;

            swap(S[begin],S[end]);
            ++begin;
            --end;
        }

        return S;
    }
};

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

题意大意:

给定一个字符串 s,找到第一个只出现一次的字符的下标;如果不存在,返回 -1

思路:计数 + 再扫描一遍

  1. 准备一个长度为 256 的整型数组 count,初始化为 0;

  2. 第一次遍历字符串,count[s[i]]++,统计每个字符出现的次数;

  3. 第二次从前往后一遍扫描,找到第一 个 count[s[i]] == 1 的位置返回即可;

  4. 如果没找到,返回 -1

代码:

cpp 复制代码
class Solution {
public:
    int firstUniqChar(string s)
    {
        int count[256] = {0};
        int size = (int)s.size();

        //统计每个字符出现的次数
        for(int i = 0; i < size; ++i)
        {
            unsigned char ch = (unsigned char)s[i];
            count[ch] += 1;
        }

        //按字符顺序从前往后找只出现一次的字符
        for(int i = 0; i < size; ++i)
        {
            unsigned char ch = (unsigned char)s[i];
            if(count[ch] == 1)
                return i;
        }

        return -1;
    }
};

时间复杂度 O(n),空间复杂度 O(1)。


4.3 字符串里面最后一个单词的长度

题意大意:

输入若干行字符串,每行可能包含空格。

要求输出"这一行里最后一个单词"的长度。

代码:

cpp 复制代码
#include<iostream>
#include<string>
using namespace std;

int main()
{
    string line;
    //不要使用 cin>>line,因为它遇到空格就结束了
    //while(cin>>line)
    while(getline(cin,line))
    {
        size_t pos = line.rfind(' ');
        cout<<line.size() - pos - 1<<endl;
    }
    return 0;
}

思路很直接:

  • getline 读取一整行(包含空格);

  • rfind(' ') 找到最后一个空格的位置;

  • "最后一个单词"的长度 = 整行长度 - 最后一个空格位置 - 1。

如果整行没有空格(只有一个单词),rfind 会返回 string::npos,上面这段代码就会算出一个很大的数。实战中可以加一个判断:如果 pos == string::npos,那答案就是整行长度 line.size()


4.4 验证一个字符串是否是回文

题意大意:

给一个字符串 s,只考虑字母和数字,忽略大小写,判断它是否是回文串。

例子:

  • "A man, a plan, a canal: Panama"true

  • "race a car"false

思路:预处理 + 双指针

  1. 先把所有小写字母转成大写(或反之),方便比较;

  2. 用两个下标 beginend

    • 从左往右找第一个"字母或数字";

    • 从右往左找第一个"字母或数字";

  3. 比较它们是否相等:

    • 如果不等,直接返回 false

    • 如果相等,两边向中间继续靠近;

  4. 直到 begin >= end,说明是回文。

代码:

cpp 复制代码
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; //利用 ASCII 码差值
        }

        int begin = 0;
        int end = (int)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;
    }
};

4.5 字符串相加

题意大意:

两个"非负整数"的字符串 num1num2,要求返回它们的"和"的字符串表示,不允许直接转成内置整型相加(因为可能非常长)。

例子:

  • "11" + "123" = "134"

  • "456" + "77" = "533"

思路:模拟竖式加法

  1. 准备三个变量:

    • end1 指向 num1 的末尾;

    • end2 指向 num2 的末尾;

    • next 表示进位(0/1)。

  2. 从后往前逐位相加:

    • 对应位的字符减 '0' 得到数字;

    • 三者相加:value1 + value2 + next

    • 处理进位,得到当前位结果;

    • 把这一位结果先"加在结果字符串尾部"。

  3. 循环结束后,如果还有进位 next == 1,再补一个 '1'

  4. 最后整个结果字符串反转一下。

代码:

cpp 复制代码
class Solution {
public:
    string addStrings(string num1,string num2)
    {
        int end1 = (int)num1.size() - 1;
        int end2 = (int)num2.size() - 1;
        int value1 = 0;
        int value2 = 0;
        int next = 0;
        string addret;

        //从后往前相加
        while(end1 >= 0 || end2 >= 0)
        {
            if(end1 >= 0)
                value1 = num1[end1--] - '0';
            else
                value1 = 0;

            if(end2 >= 0)
                value2 = num2[end2--] - '0';
            else
                value2 = 0;

            int valueret = value1 + value2 + next;
            if(valueret > 9)
            {
                next = 1;
                valueret -= 10;
            }
            else
            {
                next = 0;
            }

            //addret.insert(addret.begin(),valueret+'0');
            addret += char(valueret + '0');
        }

        if(next == 1)
        {
            //addret.insert(addret.begin(),'1');
            addret += '1';
        }

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

5. string类的模拟实现

5.1 错误版本:只有构造 + 析构

cpp 复制代码
//为了和标准库区分,这里使用自定义 String
class String
{
public:
    /*
    String()
        :_str(new char[1])
    {
        *_str = '\0';
    }
    */

    //String(const char* str = "\0") 错误示范
    //String(const char* str = nullptr) 错误示范
    String(const char* str = "")
    {
        //构造 String 对象时,如果传递 nullptr,说明调用方式就有问题
        if(str == nullptr)
        {
            assert(false);
            return;
        }

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

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

private:
    char* _str;
};

测试代码:

cpp 复制代码
void TestString()
{
    String s1("hello bit!!!");
    String s2(s1);  //用 s1 构造 s2
}

上述代码:程序会崩

原因是:我们没写拷贝构造,编译器会"合成一个默认拷贝构造",做的是浅拷贝 ------也就是把 s1._str 那个指针的值"原封不动"拷给 s2._str,两个对象共享同一块内存。

s1s2 依次析构时,同一块堆内存会被 delete[] 两次,内存管理器直接当场崩给你看。


5.2 浅拷贝

浅拷贝(shallow copy) 又叫"位拷贝":

  • 编译器只是简单地"按字节"拷贝对象内部的数据成员;

  • 对于普通类型(比如 intdouble),这没问题;

  • 但如果对象里有"指针、句柄、文件描述符"等资源句柄,这种拷贝会导致多个对象共享同一份资源

在上一节的例子里:

  • s1s2 共享 "_str" 这块堆上的字符串;

  • s1 析构时,把这块内存释放;

  • s2 以为这块内存还在,析构时再释放一次,崩溃。

就像两个小孩抢玩具:

  • 如果父母只买了一份玩具,两个小孩共用,一旦其中一个弄坏了,另一个就没得玩;

  • 对应到程序里,就是"共享资源 + 不知道何时被别人释放"。


5.3 深拷贝

深拷贝(deep copy) 的思想很简单:

既然共享会出问题,那就"各玩各的":每个对象都持有一份独立的资源。

String 来说就是:

  • 无论是拷贝构造还是赋值运算,都要 新申请一块堆内存并把内容拷贝过去

  • 析构函数只释放自己这份资源,不会影响其他对象。

结论:

只要一个类"涉及资源管理",那它的 拷贝构造、赋值运算符重载、析构函数 一般都要自己显式写出来,避免默认的浅拷贝。


5.3.1 传统版写法的String类
cpp 复制代码
class String
{
public:
    String(const char* str = "")
    {
        if (str == nullptr)
        {
            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;
};

注意:

  1. 拷贝构造用初始化列表直接申请新空间 ,然后 strcpy

  2. 赋值运算符重载:

    • 先检查是否自赋值(this == &s);

    • 先开新空间并拷贝,再 delete 掉旧的,最后更新指针;

    • 这样即使中途异常,也不会把旧数据提前删掉;

  3. 析构函数一如既往:释放 _str


5.3.2 现代版写法的String类
cpp 复制代码
class String
{
public:
    String(const char* str = "")
    {
        if (str == nullptr)
        {
            assert(false);
            return;
        }

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

    //拷贝构造:先构造一个临时对象,再和当前对象交换资源
    String(const String& s)
        :_str(nullptr)
    {
        String tmp(s._str);   //复用已有构造函数
        swap(_str, tmp._str);
    }

    //赋值运算符:按值传参,内部直接 swap
    String& operator=(String s)
    {
        swap(_str, s._str);
        return *this;
    }

    /*
    //也可以写成下面这个等价形式
    String& operator=(const String& s)
    {
        if(this != &s)
        {
            String tmp(s);    //调用拷贝构造
            swap(_str,tmp._str);
        }
        return *this;
    }
    */

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

private:
    char* _str;
};

这个写法的好处:

  1. 构造 + 赋值都用到了统一的"资源交换"策略,代码更简洁;

  2. 赋值时按值传参,会先调用拷贝构造生成一个副本 s

    • 如果拷贝构造失败(比如内存不足),根对象完全没被改动;

    • swap 完成后,原来的资源在副本 s 中,s 析构时自动释放;

  3. 异常安全性更好,语义也更清晰。


6. 写时拷贝

6.1 写时拷贝的核心思路

  1. 引用计数

    • 在构造时把计数初始化为 1;

    • 每有一个新的对象共享这块资源,计数 +1

    • 某个对象销毁时,计数 -1,如果减到 0,就释放资源。

  2. "写时"二字

    • 从别的对象"拷贝"过来时,只是共享(浅拷贝 + 引用计数 +1),不真正复制数据;

    • 当你要"写"这块数据时,如果引用计数大于 1,说明还有其他对象在用:

      • 先深拷贝出一份自己的副本;

      • 调整自己的指针和引用计数;

      • 然后在自己的那份副本上修改。

这样一来:

  • 只读场景下,多次拷贝几乎是"零成本"的;

  • 写操作才会真正付出"深拷贝"的代价。


7. string 模拟实现

7.1 String类模拟的完整代码:

String.h:

cpp 复制代码
#define _CRT_SECURE_NO_WARNINGS 1
#include<iostream>
#include<string>
#include<assert.h>
using namespace std;
class String
{
public:
	typedef char* iterator;

	iterator begin()
	{
		return _str;
	}
	iterator end()
	{
		return _str + _size;
	}
	//String()
	//	: _str(new char[1] {'\0'})
	//	, _size(0)
	//	, _capacity(0)
	//{}
	String(const char* str = "")
	{
		_size = strlen(str);
		//capacity不包含'\0'
		_capacity = _size;
		_str = new char[_capacity + 1];
		strcpy(_str, str);
	}
	String(const String& s)
		:_str(nullptr)
	{
		String tmp(s._str);
		swap(_str, tmp._str);
	}
	~String()
	{
		delete[] _str;
		_str = nullptr;
		_size = _capacity = 0;
	}
	size_t size() const
	{
		return _size;
	}
	size_t capacity() const
	{
		return _capacity;
	}
	char& operator[](size_t pos)
	{
		assert(pos < _size);
		return _str[pos];
	}
	const char& operator[](size_t pos) const
	{
		assert(pos < _size);
		return _str[pos];
	}
	String& operator=(String s)
	{
		swap(_str, s._str);
		return *this;
	}
	const char* c_str() const
	{
		return _str;
	}
	void reserve(size_t n);
	void push_back(char ch);
	void append(const char* str);
	String& operator+=(char ch);
	String& operator+=(const char* str);

	void insert(size_t pos, char ch);
	void insert(size_t pos, const char* str);
	void erase(size_t pos, size_t len = npos);

	size_t find(char ch, size_t pos = 0);
	size_t find(const char* str, size_t pos = 0);

	String substr(size_t pos = 0, size_t len = npos);
private:
	char* _str;
	size_t _size;
	size_t _capacity;
	const static size_t npos;
};
bool operator<(const String& s1, const String& s2);
bool operator<=(const String& s1, const String& s2);
bool operator>(const String& s1, const String& s2);
bool operator>=(const String& s1, const String& s2);
bool operator==(const String& s1, const String& s2);
bool operator!=(const String& s1, const String& s2);

String.cpp

cpp 复制代码
#include"String.h"
const size_t String::npos = -1;
void String::reserve(size_t n)
{
	if (n > _capacity)
	{
		char* tmp = new char[n + 1];
		strcpy(tmp, _str);
		delete[] _str;
		_str = tmp;
		_capacity = n;
	}
}
void String::push_back(char ch)
{
	if (_size == _capacity)
		reserve(_capacity == 0 ? 4 : _capacity * 2);
	_str[_size++] = ch;
	_str[_size] = '\0';
}
void String::append(const char* str)
{
	size_t len = strlen(str);
	if (_size + len >= _capacity)
		//大于2倍 要多少开多少,小于2倍按2倍扩
		reserve(_size + len > _capacity * 2 ? _size + len : _capacity * 2);
	strcpy(_str + _size, str);
	_size += len;
}
String& String::operator+=(char ch)
{
	push_back(ch);
	return *this;
}
String& String::operator+=(const char* str)
{
	append(str);
	return *this;
}
void String::insert(size_t pos, char ch)
{
	assert(pos <= _size);
	if (_size == _capacity)
		reserve(_capacity == 0 ? 4 : _capacity * 2);
	size_t end = _size + 1;
	while (end > pos)
	{
		_str[end] = _str[end - 1];
		--end;
	}
	_str[pos] = ch;
	++_size;
}
void String::insert(size_t pos, const char* str)
{
	assert(pos <= _size);
	size_t len = strlen(str);
	if (_size + len >= _capacity)
		//大于2倍 要多少开多少,小于2倍按2倍扩
		reserve(_size + len > _capacity * 2 ? _size + len : _capacity * 2);
	size_t end = _size;
	while (end > pos - len - 1)
	{
		_str[end + len] = _str[end];
		--end;
	}
	for (size_t i = 0; i < len; ++i)
		_str[pos + i] = str[i];
	_size += len;
}
void String::erase(size_t pos, size_t len)
{
	if (len >= _size - pos)
	{
		_str[pos] = '\0';
		_size = pos;
	}
	else
	{
		for (size_t i = pos + len; i <= _size; ++i)
			_str[i - len] = _str[i];
		_size -= len;
	}
}
size_t String::find(char ch, size_t pos)
{
	assert(pos <= _size);
	for (size_t i = pos; i < _size; ++i)
	{
		if (_str[i] == ch)
			return i;
	}
	return npos;
}
size_t String::find(const char* str, size_t pos)
{
	assert(pos <= _size);
	const char* ptr = strstr(_str + pos, str);
	if (ptr == nullptr)
		return npos;
	return ptr - _str;
}

String String::substr(size_t pos, size_t len)
{
	assert(pos < _size);
	//len大于剩余字符长度,更新len
	if (len > _size - pos)
		len = _size - pos;
	String sub;
	sub.reserve(len);
	for (size_t i = 0; i < len; ++i)
		sub += _str[pos + i];
	return sub;
}

bool operator<(const String& s1, const String& s2)
{
    return strcmp(s1.c_str(), s2.c_str()) < 0;
}
bool operator<=(const String& s1, const String& s2)
{
	return s2 < s1;
}
bool operator>(const String& s1, const String& s2)
{
	return s2 < s1;
}
bool operator>=(const String& s1, const String& s2)
{
	return !(s1 < s2);
}
bool operator==(const String& s1, const String& s2)
{
	return strcmp(s1.c_str(), s2.c_str()) == 0;
}
bool operator!=(const String& s1, const String& s2)
{
	return !(s1 == s2);
}

void test01()
{
	String s1;
	String s2("hello world");
	cout << s1.c_str() << endl;
	cout << s2.c_str() << endl;
}
void test02()
{
	String s1("hello world");
	s1 += '#';
	cout << s1.c_str() << endl;
	s1 += "&&&&&&";
	cout << s1.c_str() << endl;


}
void test03()
{
	String s1("hello world");
	s1.insert(0, 'x');
	cout << s1.c_str() << endl;
	s1.insert(7, "&&");
	cout << s1.c_str() << endl;
	s1.erase(7);
	cout << s1.c_str() << endl;
}
void test04()
{
	String s1("hello world");
	String s2 = s1.substr(0, 5);
	cout << s2.c_str() << endl;
	s1 = s1;
	String s3("test.cpp.txt");
	int pos = s3.find('.');
	cout << pos << endl;
}
int main()
{
	//test01();
	//test02();
	//test03();
	test04();
	return 0;
}

相关推荐
小尧嵌入式3 小时前
QT软件开发知识点流程及记事本开发
服务器·开发语言·数据库·c++·qt
ByNotD0g3 小时前
Golang Green Tea GC 原理初探
java·开发语言·golang
qingyun9893 小时前
使用递归算法深度收集数据结构中的点位信息
开发语言·javascript·ecmascript
冷崖3 小时前
单例模式-创建型
c++·单例模式
努力学习的小廉3 小时前
【QT(三)】—— 信号和槽
开发语言·qt
盼哥PyAI实验室3 小时前
Python自定义HTTP客户端:12306抢票项目的网络请求管理
开发语言·python·http
这儿有一堆花3 小时前
Python优化内存占用的技巧
开发语言·python
明洞日记3 小时前
【VTK手册024】高效等值面提取:vtkFlyingEdges3D 详解与实战
c++·图像处理·vtk·图形渲染
NaturalHarmonia3 小时前
【Go】sync package官方示例代码学习
开发语言·学习·golang