【C++篇】手撕 C++ string 类:从零实现到深入剖析的模拟之路

2024-10-09 20:03:42 浏览数 (1)

C string 类的模拟实现:从构造到高级操作

前言

在 C 标准库中,string 类是用于字符串操作的一个非常常见和重要的类,它极大地简化了开发者处理字符串的过程。然而,为了深入理解 C 的核心机制,特别是内存管理、深拷贝与浅拷贝的差异、运算符重载等底层细节,自己实现一个简易的 string 类是一个很好的练习。

通过本篇博客,我们将一步步实现一个简单的 string 类,并且深入探讨与之相关的现代 C 特性,包括内存管理、深拷贝与浅拷贝、移动语义等。我们会从最基础的构造函数开始,逐步扩展功能。

第一章:为什么要手写 C string 类?

1.1 理由与价值

在面试或者一些学习场景中,手写 string 类不仅仅是对字符串操作的考察,更多的是考察程序员对 C 内存管理的理解。例如,深拷贝与浅拷贝的实现,如何正确重载赋值运算符,如何避免内存泄漏,这些都是需要掌握的核心技能。

实现一个简易的 string 类可以帮助我们更好地理解:

  1. C 中动态内存管理:如何正确地分配与释放内存。
  2. 深拷贝与浅拷贝的区别:当对象之间共享资源时,如何避免潜在问题。
  3. 运算符重载的实现:尤其是赋值运算符和输出运算符的重载。
  4. 现代 C 特性:包括移动语义、右值引用等。

接下来,我们会从一个简单的 string 类开始,逐步扩展。


第二章:实现一个简单的 string

2.1 基本构造与析构

我们先实现 string 类的基础部分,包括构造函数、析构函数、字符串存储、内存管理等基础操作。在最初的实现中,我们将模拟 C 标准库 string 类的基本行为,让其能够存储字符串,并在析构时正确释放内存。

2.1.1 示例代码:基础的 string 类实现
代码语言:javascript复制
#include <iostream>
#include <cstring>   // 包含 strlen 和 strcpy 函数
#include <cassert>   // 包含 assert 函数

namespace W
{
    class string
    {
    public:
        // 默认构造函数
        string(const char* str = "") {
            _size = strlen(str);  // 计算字符串长度
            _capacity = _size;
            _str = new char[_capacity   1];  // 动态分配内存
            strcpy(_str, str);  // 复制字符串内容
        }

        // 析构函数
        ~string() {
            if (_str) {
                delete[] _str;  // 释放动态分配的内存
                _str = nullptr;
            }
        }

    private:
        char* _str;  // 存储字符串的字符数组
        size_t _capacity;  // 分配的内存容量
        size_t _size;  // 当前字符串的有效长度
    };
}

int main() {
    W::string s("Hello, World!");
    return 0;  // 程序结束时,析构函数自动释放内存
}
2.1.2 解读代码

在这个简单的 string 类中,我们实现了两个重要的函数:

  • 构造函数:为字符串动态分配内存,并将传入的字符串内容复制到新分配的空间中。
  • 析构函数:使用 delete[] 释放动态分配的内存,以避免内存泄漏。

接下来,我们将讨论拷贝构造函数以及浅拷贝带来的潜在问题。


2.2 浅拷贝与其缺陷

当前版本的 string 类只支持基本的构造和析构操作。如果我们通过另一个 string 对象来构造新的对象,默认情况下会发生浅拷贝,即对象共享同一块内存。这会带来潜在的内存管理问题,特别是当对象被销毁时,会导致多个对象同时试图释放同一块内存,进而导致程序崩溃。

2.2.1 示例代码:浅拷贝问题
代码语言:javascript复制
void TestString() {
    W::string s1("Hello C  ");
    W::string s2(s1);  // 浅拷贝,s1 和 s2 共享同一块内存
    // 当程序结束时,析构函数会尝试两次释放同一块内存,导致程序崩溃
}

问题分析:浅拷贝的默认行为只复制指针的值,即 s1s2 都指向同一个内存区域。因此,当程序执行析构函数时,会尝试两次释放同一块内存,导致程序崩溃。


2.3 深拷贝的解决方案

为了避免浅拷贝带来的问题,我们需要在拷贝构造函数中实现深拷贝。深拷贝确保每个对象都有自己独立的内存空间,不会与其他对象共享内存。

2.3.1 示例代码:实现深拷贝
代码语言:javascript复制
namespace W
{
    class string
    {
    public:
        // 构造函数
        string(const char* str = "") {
            _size = strlen(str);
            _capacity = _size;
            _str = new char[_capacity   1];
            strcpy(_str, str);
        }

        // 深拷贝构造函数
        string(const string& s) {
            _size = s._size;
            _capacity = s._capacity;
            _str = new char[_capacity   1];  // 分配新的内存
            strcpy(_str, s._str);  // 复制字符串内容
        }

        // 析构函数
        ~string() {
            delete[] _str;
        }

    private:
        char* _str;
        size_t _capacity;
        size_t _size;
    };
}

void TestString() {
    W::string s1("Hello C  ");
    W::string s2(s1);  // 深拷贝,s1 和 s2 拥有独立的内存
}

第三章:赋值运算符重载与深拷贝

3.1 为什么需要重载赋值运算符?

在C 中,当我们将一个对象赋值给另一个对象时,默认情况下,编译器会为我们生成一个浅拷贝的赋值运算符。这意味着赋值后的对象和原对象会共享同一个内存空间,这会导致和浅拷贝相同的潜在问题,特别是在一个对象被销毁时,另一个对象继续使用该内存区域会引发错误。

为了解决这个问题,我们需要手动重载赋值运算符,确保每个对象都拥有自己独立的内存空间。

3.2 实现赋值运算符重载

在赋值运算符重载中,我们需要考虑以下几点:

  1. 自我赋值:对象是否会被赋值给自己,避免不必要的内存释放和分配。
  2. 释放原有资源:在赋值前,我们需要释放被赋值对象原有的内存资源,避免内存泄漏。
  3. 深拷贝:为目标对象分配新的内存,并复制内容。
3.2.1 示例代码:赋值运算符重载
代码语言:javascript复制
namespace W
{
    class string
    {
    public:
        // 构造函数
        string(const char* str = "") {
            _size = strlen(str);
            _capacity = _size;
            _str = new char[_capacity   1];
            strcpy(_str, str);
        }

        // 深拷贝构造函数
        string(const string& s) {
            _size = s._size;
            _capacity = s._capacity;
            _str = new char[_capacity   1];
            strcpy(_str, s._str);
        }

        // 赋值运算符重载
        string& operator=(const string& s) {
            if (this != &s) {  // 避免自我赋值
                delete[] _str;  // 释放原有内存
                _size = s._size;
                _capacity = s._capacity;
                _str = new char[_capacity   1];  // 分配新内存
                strcpy(_str, s._str);  // 复制内容
            }
            return *this;
        }

        // 析构函数
        ~string() {
            delete[] _str;
        }

    private:
        char* _str;
        size_t _capacity;
        size_t _size;
    };
}

void TestString() {
    W::string s1("Hello");
    W::string s2("World");
    s2 = s1;  // 调用赋值运算符重载
}
3.2.2 解读代码
  1. 自我赋值检查:自我赋值是指对象在赋值时被赋值给自己,例如 s1 = s1。在这种情况下,如果我们没有进行检查,就会先删除对象的内存,然后再试图复制同一个对象的内容,这样会导致程序崩溃。因此,重载赋值运算符时,自我赋值检查是非常必要的。
  2. 释放原有内存:在分配新内存之前,我们必须先释放旧的内存,以防止内存泄漏。
  3. 深拷贝:通过分配新的内存,确保目标对象不会与源对象共享内存,避免浅拷贝带来的问题。

第四章:迭代器与字符串操作

4.1 迭代器的实现

迭代器是一种用于遍历容器(如数组、string 等)的工具,它允许我们在不直接访问容器内部数据结构的情况下遍历容器。通过迭代器,可以使用范围 for 循环等简便的方式遍历 string 对象中的字符。

在我们的 string 类中,迭代器一般会被实现为指向字符数组的指针

4.1.1 示例代码:实现 string 类的迭代器
代码语言:javascript复制
namespace W
{
    class string
    {
    public:
        // 非const迭代器
        typedef char* iterator;
        // const迭代器
        typedef const char* const_iterator;

        // 构造函数与析构函数等...
        
        // 非const迭代器接口
        iterator begin() { return _str; }
        iterator end() { return _str   _size; }

        // const迭代器接口(针对const对象)
        const_iterator begin() const { return _str; }
        const_iterator end() const { return _str   _size; }

    private:
        char* _str;
        size_t _capacity;
        size_t _size;
    };

}

void TestIterator() {
    W::string s("Hello World!");

    // 非const对象使用迭代器
    for (W::string::iterator it = s.begin(); it != s.end();   it) {
        *it = toupper(*it);  // 转换为大写
    }
    std::cout << s << std::endl;  // 输出:HELLO WORLD!

    // const对象使用const迭代器
    const W::string cs("Const String!");
    for (W::string::const_iterator it = cs.begin(); it != cs.end();   it) {
        std::cout << *it;  // 只能读取,不能修改
    }
    std::cout << std::endl;

	for (auto& ch : s) {
        ch = tolower(ch);  // 转换为小写
    }
    std::cout << s << std::endl;  // 输出:hello world!

    // 范围for循环遍历const对象
    for (const auto& ch : cs) {
        std::cout << ch;  // 只能读取,不能修改
    }
    std::cout << std::endl;
}

第五章:字符串的常见操作

在 C 标准库 string 类中,提供了很多方便的字符串操作接口,如查找字符或子字符串、插入字符、删除字符等。我们也需要在自定义的 string 类中实现这些操作。接下来,我们将逐步实现这些功能,并进行测试。


5.1 查找操作

C 中 string 类的 find() 函数用于查找字符串或字符在当前字符串中的位置。如果找到了字符或子字符串,find() 会返回其位置;如果找不到,则返回 string::npos

我们将在自定义的 string 类中实现类似的功能。

5.1.1 示例代码:实现字符和子字符串查找
代码语言:javascript复制
namespace W
{
    class string
    {
    public:
        // 构造函数与析构函数等...

        // 查找字符在字符串中的第一次出现位置
        size_t find(char c, size_t pos = 0) const {
            assert(pos < _size);
            for (size_t i = pos; i < _size;   i) {
                if (_str[i] == c) {
                    return i;
                }
            }
            return npos;  // 如果没有找到,返回 npos
        }

        // 查找子字符串在字符串中的第一次出现位置
        size_t find(const char* str, size_t pos = 0) const {
            assert(pos < _size);
            const char* p = strstr(_str   pos, str);
            if (p) {
                return p - _str;  // 计算子字符串的位置
            }
            return npos;  // 如果没有找到,返回 npos
        }

    public:
        static const size_t npos = -1;  // 定义 npos 为 -1,表示未找到

    private:
        char* _str;
        size_t _capacity;
        size_t _size;
    };
}

void TestFind() {
    W::string s("Hello, World!");

    // 查找字符
    size_t pos = s.find('W');
    if (pos != W::string::npos) {
        std::cout << "'W' found at position: " << pos << std::endl;
    } else {
        std::cout << "'W' not found." << std::endl;
    }

    // 查找子字符串
    size_t subPos = s.find("World");
    if (subPos != W::string::npos) {
        std::cout << "'World' found at position: " << subPos << std::endl;
    } else {
        std::cout << "'World' not found." << std::endl;
    }
}

看到这里细心的小伙伴可能发现了,我们在声明npos的时候直接给了初始值,但是之前我们在【C 篇】C 类与对象深度解析(四):初始化列表、类型转换与static成员详解里明确说过静态成员变量只能在类外初始化,以及const修饰的变量只能在初始化列表初始化,但这里却可以

这是为什么呢?不得不承认这是一看到就令人困惑的语法

0 人点赞