1. 为什么我们需要自己写一个String类?

说实话,C++标准库里的std::string已经很好用了。但我在带团队的时候发现,很多新人其实并不清楚它背后到底干了什么。你想想看,一个简单的字符串赋值,编译器究竟帮你处理了多少事情?

我个人习惯是,在讲深拷贝、浅拷贝、移动语义这些概念时,先让大家亲手实现一个String类。这就像学开车前先拆一遍发动机——虽然你以后不会天天拆,但懂了原理,开起来心里踏实。

好,那我们就从零开始,设计一个完整的String类。它会包含:

  • 构造函数(默认、带参、拷贝)
  • 析构函数
  • 移动构造函数和移动赋值运算符
  • 拷贝赋值运算符
  • 几个常用的运算符重载(+、==、[]、<<)

嗯,这基本上就是一个迷你版std::string了。

2. 类的骨架设计

先搭个架子。我们的String类内部用动态分配的char数组来存储字符串。为什么不用固定数组?因为字符串长度是不确定的,动态分配更灵活。

class String {
private:
    char* data_;      // 指向动态分配的字符数组
    size_t size_;     // 字符串长度(不含'\0')

public:
    // 构造函数
    String();                         // 默认构造
    String(const char* str);          // 从C风格字符串构造
    String(const String& other);      // 拷贝构造
    String(String&& other) noexcept; // 移动构造

    // 析构函数
    ~String();

    // 赋值运算符
    String& operator=(const String& other);  // 拷贝赋值
    String& operator=(String&& other) noexcept; // 移动赋值

    // 运算符重载
    String operator+(const String& other) const;
    bool operator==(const String& other) const;
    char& operator[](size_t index);
    const char& operator[](size_t index) const;

    // 工具函数
    size_t size() const;
    const char* c_str() const;

    // 友元函数用于输出
    friend std::ostream& operator<<(std::ostream& os, const String& str);
};

这个结构很清晰。你可能会问:为什么移动构造函数和移动赋值要加noexcept

我在项目中踩过这个坑。如果不加noexcept,标准库容器(比如std::vector)在扩容时,宁愿选择拷贝也不会用移动。因为移动如果抛异常,容器无法回滚。加了noexcept,编译器才敢放心用移动语义,性能能提升不少。

3. 构造与析构:内存管理的基石

3.1 默认构造函数

默认构造一个空字符串。注意,即使是空串,我们也要分配一个字节存放'\0'。这是C风格字符串的惯例。

String::String() : data_(new char[1]), size_(0) {
    data_[0] = '\0';
}

3.2 带参构造函数

从const char*构造。这里要处理nullptr的情况——我曾经见过线上崩溃就是因为传入了空指针。

String::String(const char* str) {
    if (str == nullptr) {
        data_ = new char[1];
        data_[0] = '\0';
        size_ = 0;
        return;
    }
    size_ = strlen(str);
    data_ = new char[size_ + 1];
    strcpy(data_, str);
}
注意: 这里用了strcpy,但前提是str必须以'\0'结尾。如果传入的字符串没有终止符,程序会崩溃。实际项目中建议用strncpy或自己实现安全拷贝。

3.3 拷贝构造函数

深拷贝。这是C++新手最容易犯错的地方。如果不自己实现拷贝构造,编译器会生成一个浅拷贝——两个对象指向同一块内存,析构时double free,直接崩溃。

String::String(const String& other) 
    : data_(new char[other.size_ + 1]), size_(other.size_) {
    strcpy(data_, other.data_);
}

说白了,深拷贝就是「你有一份,我也有一份,互不干扰」。

3.4 析构函数

释放动态分配的内存。这个很简单,但别忘了。

String::~String() {
    delete[] data_;
}
小提示: 析构函数中记得用delete[]而不是delete。前者会调用每个元素的析构函数,后者只释放第一个元素。对于char数组来说,虽然结果一样,但语义上必须用delete[]

4. 移动语义:现代C++的性能利器

移动构造和移动赋值是C++11引入的。它们的核心思想是:与其费力拷贝一份数据,不如把对方的资源「偷」过来,再把对方置为空。

String::String(String&& other) noexcept
    : data_(other.data_), size_(other.size_) {
    other.data_ = nullptr;
    other.size_ = 0;
}

String& String::operator=(String&& other) noexcept {
    if (this != &other) {
        delete[] data_;          // 释放当前资源
        data_ = other.data_;     // 接管对方资源
        size_ = other.size_;
        other.data_ = nullptr;   // 对方置空
        other.size_ = 0;
    }
    return *this;
}

为什么移动后要把原对象置空?因为原对象很快会被析构。如果不置空,析构时会把我们刚偷来的内存也释放掉——那就白忙活了。

我记得有一次优化一个字符串处理密集的服务,把拷贝全部改成移动后,性能提升了将近30%。移动语义在返回临时对象时特别有用。

5. 赋值运算符:拷贝与移动

5.1 拷贝赋值运算符

拷贝赋值要处理自赋值的情况。虽然自赋值不常见,但万一有人写了a = a呢?

String& String::operator=(const String& other) {
    if (this == &other) {
        return *this;  // 自赋值,直接返回
    }
    // 先分配新内存,再释放旧内存
    // 这样做的好处是:如果分配失败,当前对象状态不变(强异常安全)
    char* new_data = new char[other.size_ + 1];
    strcpy(new_data, other.data_);
    delete[] data_;
    data_ = new_data;
    size_ = other.size_;
    return *this;
}
关键点: 先分配再释放,而不是先释放再分配。如果先释放了data_,然后new抛出异常,你的对象就处于一个无效状态——data_指向已释放的内存。这就是异常安全中的「强保证」。

6. 运算符重载:让String用起来像原生类型

6.1 下标运算符

char& String::operator[](size_t index) {
    return data_[index];
}

const char& String::operator[](size_t index) const {
    return data_[index];
}

这里提供了两个版本:一个用于修改,一个用于只读。const对象只能调用const版本。

6.2 相等运算符

bool String::operator==(const String& other) const {
    return strcmp(data_, other.data_) == 0;
}

6.3 拼接运算符

String String::operator+(const String& other) const {
    String result;
    result.size_ = size_ + other.size_;
    result.data_ = new char[result.size_ + 1];
    strcpy(result.data_, data_);
    strcat(result.data_, other.data_);
    return result;
}

这里返回的是临时对象,编译器会优先使用移动语义(如果开启了C++11及以上标准)。所以不用担心性能问题。

6.4 输出运算符

std::ostream& operator<<(std::ostream& os, const String& str) {
    os << str.data_;
    return os;
}

7. 知识体系总览

下面这张图总结了String类的核心设计思路和各个函数之间的关系:

String类设计总览 核心数据 char* data_ | size_t size_ 构造与析构 默认构造 带参构造 (const char*) 拷贝构造 (深拷贝) 析构函数 (delete[]) 移动语义 移动构造 (noexcept) 移动赋值 (noexcept) 资源转移 + 原对象置空 性能提升约30% 运算符重载 operator+ (拼接) operator== (比较) operator[] (下标) operator<< (输出) 设计原则 • 深拷贝保证独立性,避免double free • 移动语义提升性能,noexcept保证容器兼容 • 运算符重载让String用起来像内置类型 • 异常安全:先分配再释放,保证强保证

8. 完整代码示例

最后,把上面所有代码拼在一起,就是一个可用的String类了。你可以把它放在一个头文件中,直接用于项目。

#include <iostream>
#include <cstring>

class String {
private:
    char* data_;
    size_t size_;

public:
    // 默认构造
    String() : data_(new char[1]{'\0'}), size_(0) {}

    // 带参构造
    String(const char* str) {
        if (str == nullptr) {
            data_ = new char[1]{'\0'};
            size_ = 0;
            return;
        }
        size_ = strlen(str);
        data_ = new char[size_ + 1];
        strcpy(data_, str);
    }

    // 拷贝构造
    String(const String& other) 
        : data_(new char[other.size_ + 1]), size_(other.size_) {
        strcpy(data_, other.data_);
    }

    // 移动构造
    String(String&& other) noexcept 
        : data_(other.data_), size_(other.size_) {
        other.data_ = nullptr;
        other.size_ = 0;
    }

    // 析构
    ~String() {
        delete[] data_;
    }

    // 拷贝赋值
    String& operator=(const String& other) {
        if (this == &other) return *this;
        char* new_data = new char[other.size_ + 1];
        strcpy(new_data, other.data_);
        delete[] data_;
        data_ = new_data;
        size_ = other.size_;
        return *this;
    }

    // 移动赋值
    String& operator=(String&& other) noexcept {
        if (this != &other) {
            delete[] data_;
            data_ = other.data_;
            size_ = other.size_;
            other.data_ = nullptr;
            other.size_ = 0;
        }
        return *this;
    }

    // 运算符重载
    String operator+(const String& other) const {
        String result;
        result.size_ = size_ + other.size_;
        result.data_ = new char[result.size_ + 1];
        strcpy(result.data_, data_);
        strcat(result.data_, other.data_);
        return result;
    }

    bool operator==(const String& other) const {
        return strcmp(data_, other.data_) == 0;
    }

    char& operator[](size_t index) { return data_[index]; }
    const char& operator[](size_t index) const { return data_[index]; }

    size_t size() const { return size_; }
    const char* c_str() const { return data_; }

    friend std::ostream& operator<<(std::ostream& os, const String& str) {
        os << str.data_;
        return os;
    }
};

写到这里,这个String类已经具备了现代C++中一个资源管理类应有的所有要素。你可以在自己的项目里直接使用它,或者基于它扩展更多功能,比如substr、find、replace等。

我个人建议,初学者把这个类手敲一遍,然后写几个测试用例跑一跑。你会发现,很多以前模糊的概念——深拷贝、移动语义、异常安全——一下子就清晰了。


公众号:蓝海资料掘金营,微信deep3321