初次实现一个类

六个编译器为你生成的函数

手写一个类,我们需要知道创建类后,如果你没有显式定义构造/析构/拷贝/移动等函数,C++编译器会隐式地为你生成以下六个默认成员函数
(1)默认构造函数--->用来初始化类对象,创建对象时会自动调用,用于初始化成员变量。如果你没有定义任何构造函数,编译器会自动生成这个函数。

ClassName();//没有参数

(2)默认拷贝构造函数--->使用现有对象创建一个新对象时调用。默认行为是按成员变量逐个复制。需要给这个函数传入const属性修饰的类对象引用。

ClassName(const ClassName& other);//唯一参数

(3)默认拷贝赋值运算符--->将一个已有对象赋值给另一个对象的时候调用,默认行为也是逐个成员变量复制,并返回 *this。用于实现深拷贝。

ClassName& operator=(const ClassName& other);

(4)默认移动构造函数--->将一个临时对象或即将被销毁的对象的资源“搬走”到新对象中,避免深拷贝开销。

ClassName(ClassName&& other);

(5)默认移动赋值运算符--->将一个右值对象的资源“搬”到另一个已有对象中。类似拷贝赋值,但资源转移而非复制。

ClassName& operator=(ClassName&& other);

(6)默认析构函数--->对象销毁时自动调用,用于释放资源。默认析构函数是空实现,但如果你的类中包含了指针或资源管理,就需要自定义析构函数。

~ClassName();//没有参数

通过 = default= delete 控制是否使用这些默认函数。

✅ 显式定义(函数 = default)
告诉编译器:“这个函数我不自己写,仍然用你默认生成的就好”

class MyClass {
public:
    MyClass() = default;                          // 默认构造函数
    MyClass(const MyClass&) = default;            // 拷贝构造
    MyClass& operator=(const MyClass&) = default; // 拷贝赋值
    MyClass(MyClass&&) = default;                 // 移动构造
    MyClass& operator=(MyClass&&) = default;      // 移动赋值
    ~MyClass() = default;                         // 析构函数
};

❌ 禁用默认函数(函数 = delete)
告诉编译器:“这个函数我不允许使用,谁调用谁编译错误”(用于单例模式、或者资源句柄)

class MyClass {
public:
    MyClass() = default;
    MyClass(const MyClass&) = delete;             // 禁止拷贝构造
    MyClass& operator=(const MyClass&) = delete;  // 禁止拷贝赋值
    MyClass(MyClass&&) = delete;                  // 禁止移动构造
    MyClass& operator=(MyClass&&) = delete;       // 禁止移动赋值
};

综合上面的描述,实现一个创建类的简单框架例子

class A{
  //构造函数
  A();

  //拷贝构造函数
  A(const A& other)
  
  //拷贝赋值运算符
  A& operator=(const A& other);

  //移动构造函数
  A(A&& other);

  //移动赋值运算符
  A& operator=(A&& other);

  //析构函数
  ~A();
};

可以看出来规律:
拷贝相关的参数使用的是const 和 A&;移动相关的参数使用的是A&&
构造相关的名字都是A,没有返回值;赋值运算符相关是返回值类型是A&另加一个operator运算符

字符串底层结构

接下来进入今天讨论的主题:我们自己简单实现一个C++的字符串类String。
首先我们需要一个底层的数据结构,然后我们对这个数据结构进行操作管理,很容易想到C语言中的字符这个结构char。没错!要实现C++的String类来练习底层控制,可以使用封装C风格的字符串的方式,对C风格的字符串char*做一个管理来实现C++的字符串Sring。

C语言中char和C++中的String有什么本质区别呢
(1)C++的String类是C++ STL的一个模板类,本质上是一个基于动态数组管理字符序列的容器。底层组成如下:

template<
   class CharT,
   class Traits = std::char_traits<CharT>,//字符特性(比较/复制等)操作类
   class Allocator = std::allocator<CharT>//内存分配器
> class basic_string;

内部一般用 capacity() 来记录分配空间,size() 是当前字符数量。扩容方式:当字符数超出容量时,自动扩容(可能为 1.5x 或 2x);所有操作(比如拼接)都是自动管理内存,用户无需 new/delete;

(2)C风格字符串其实就是一段以 '\0'(空字符)结尾的连续char数组,不属于任何类或对象,靠一系列标准库函数(如strcpy, strlen)操作,以下总结了常用库函数

函数名 作用
strlen(s) 返回字符串长度(不含 \0
strcpy(dst, src) 拷贝字符串
strcmp(a, b) 字符串比较
strcat(dst, src) 拼接字符串
strchr(s, ch) 查找字符

由于String类实现起来比较复杂,我们今天就只要实现一个轻量化的字符串类,了解一下底层原理即可。

String类需要哪些功能?

使用的数据结构

C风格字符串类型包括char或者char[],考虑到char[]是固定大小的静态数组不能在运行的时候动态调整大小,所以我们可以采用char,它的本质就是一个字符指针,这种形式可以由程序员进行手动管理,使用new char[n]来分配,管理起来更加灵活。

char* c;

构造函数

使用一个有参的构造函数,构造一个字符串,需要分配空间,并且初始化。接收一个char*类型的字符串,默认初始化为0,也就是如果构造函数没有传入参数,默认就是nullptr。
🔍核心逻辑:判断,如果传入非空字符串,需要为其分配内存空间,否则就只需要为'\0'分配一个字符空间。

String(const char* cstr = 0){
  if(cstr){
    c = new char[strlrn(cstr)+1];  //动态分配一块堆内存,存储字符串内容;返回指针赋值给成员变量 c;
    c = strcpy(c, cstr);  //传入的字符串cstr拷贝到刚才分配的内存c中
  } else {
    c = new char[1];
    char[0] = '\0';
  }
}

拷贝构造函数

传入唯一参数就是String类型的对象引用,从拷贝构造函数的功能来看,也就是通过其他的字符串来创建新的字符串。
🔍核心逻辑:可以先分配这个传入字符串大小的内存空间(other.c就表示其他通过String类创建出来的字符串对象),然后将它的字符串复制到新的字符串。

String(const String& other){
  c = new char[strlen(other.c) + 1];
  c = strcpy(s, other.c);
}

拷贝赋值运算符

它的作用是在两个String对象之间执行赋值操作时可以正确地管理内存,避免内存泄漏和浅拷贝错误。

❗注意拷贝赋值运算符和拷贝构造函数是不一样的。因为拷贝构造函数只有一个已有的对象,需要创建新的对象,分配一下内存然后把原有对象的内容复制过来。而拷贝赋值运算符是已经有了两个对象,现在要把一个对象复制到另一个对象。

🔍 核心逻辑:如果两个对象不一样,可以先释放原先分配的空间,然后再和拷贝构造函数一样分配空间复制一下,那如果这两个对象一模一样比如a = a,这个时候直接返回不会出错,【进一步思考】如果不加一步判断可以吗?答案是不行。这样做可能会出现将自己的字符串删掉了,释放了自己的内存后赋值给自己,此时复制的是空的。最后拷贝赋值运算符需要返回值通常是自身的引用,所以我们返回一个this指针。(返回自身的引用可以防止多)

String& operator=(const String& other){
  if(this != &other){  //this是指向当前对象的指针,&other是赋值给当前对象的另一个对象的地址
    delete[] c;
    c = new char[strlen(other.c) + 1];
    c = strcpy(c, other.c);
  }
  return *this;  //返回 *this,使得支持 a = b = c链式赋值
}

接下来我们来看一下,C++中一个核心概念:指针、引用、对象本身*this
①首先需要知道类中的非静态成员函数它们都有一个隐藏的this指针,这个指针会指向具体对象,帮助我们识别这个方法是由哪个对象调用的。这也是实现C++多态的关键,正是因为有了this指针,我们在调用同名函数的时候,编译器才知道具体调用的哪个实现。这个this的类型是ClassName,在这里就是String

this是对this这个指针的解引用,就表示‘当前对象本身’,this的类型是“当前对象本身”,是String&,也就是当前对象的引用。不过return *this可以返回String&,也可以返回String。

String c = "hello";
String* cptr = &c;  //&c表示取c这个变量的地址
//以上就表示 *cptr == c == *(&c)
符号 含义 举例
&x 取地址,得到指针(地址值) int* p = &x;
*p 解引用,得到指针所指的值 int val = *p;

②我们可以看到上面表达式返回的是String&也就是字符串引用类型,而不是String类型。为什么指针解引用 *ptr 得到的是 T&(引用类型)而不是 T(值类型)?因为解引用某个指针,是指访问指针所指向的变量,直接操作原值,而不是复制一份它的值。
而如果返回的是String类型,也就是和变量同类型本质上是重新创建了一个变量,并且将原来变量的值复制过来。这就有了两个变量,这样你操作的时候不会操作到原来的变量。语法上可以,不过这里不要这样用。

返回类型 含义 后果
String& 返回当前对象的引用 ✅ 支持链式赋值,✅ 无额外拷贝
String 返回当前对象的副本(拷贝) ❌ 不能链式赋值,⚠️ 多一次拷贝开销

③再来说一下为什么返回String&可以实现链式赋值?
a = b = c; 是从右往左计算的,b = c 先执行完后,返回 b 的引用,再赋值给 a,这就要求赋值操作返回自身的引用。如果operator=返回的是引用,下一次赋值就可以继续使用返回的对象。可以理解为:“我已经赋值完了,现在把我自己(引用)交给下一个赋值语句继续用。”
如果返回值类型是String,本质上是进行了一步复制操作,将原来的值复制到新变量中,这不仅会浪费性能,并且由于这样做不能链接两个变量,也就是b = c(c的副本),a = b(b的副本),三者并不关联在一起。

移动构造函数

拷贝构造函数是深拷贝,性能较低;如果我们只需要“偷走”资源,移动构造就更高效。
🔍 核心逻辑:将其他对象的值先复制过来,然后将其他对象的指针置为空。⚠️不能释放其他对象的内存。移动语义的目标是“偷走资源”而不是“销毁资源”,不能销毁其他对象的变量;

String(String&& other) noexcept{
  c = other.c;
  other.c = nullptr;
}

noexcept关键字的作用是:“我保证这个函数调用过程中不会抛出异常。如果抛了异常,程序直接终止(std::terminate())。”能够提高性能、保障容器行为、减少异常风险。如果不加入这个关键字,编译器会担心抛出异常,从而选择安全的拷贝构造,降低性能。

移动赋值运算符

返回偷过来的资源给当前对象的变量。注意和移动构造不一样在于,移动构造是偷资源用来构造,原本就没有资源。而移动赋值运算符是已经有了资源,然后如果和别人不一样的话还要去偷资源

String& operator=(String&& other){
  if(this != &other){
    delete[] c;
    c = other.c;
    other.c = nullptr;
  }
  return *this;
}

析构函数

~String(){
  delete[] c;
}

重载<<运算符函数

这个函数的作用是:自定义类如何实现 cout << 对象 这样的行为:

String s("hello");
std::cout << s << std::endl;

🔍 核心逻辑:传入的参数是一个输出流,ostream&类型的输出流os(类似cout),以及一个具体的要输出的对象,然后将具体对象的变量传给os。最后返回输出流本身,这种方式和前面的移动赋值运算符一样,支持链式调用:std::cout << s1 << s2 << std::endl;

friend ostream& operator<<(ostream& os, const String& s){
  os << s.c;
  return os;
}

由于运算符左边的操作数不是String类的对象,而是std::ostream类型的对象(比如std::cout),所以这个运算符重载函数不能算是String的成员函数(因为成员函数的隐式左操作数是this,必须是String类型);
使用friend关键字,就可以使这个友元函数可以访问String类的私有变量c。

重载 operator== 和 operator!=

🔍 核心逻辑:直接用strcmp比较,或者使用*this与other比较。

bool operator==(const String& other) const {
  return strcmp(c, other.c) == 0;  //返回值是0=字符串相等
}
bool operator!=(const String& other) const {
  return !(*this == other);  //完全相等==>假(也就是不是不相等的字符串)
}

利用关键字strcmp来比较两个字符串,如果strcmp(s1, s2)返回值为0表示两个字符串完全相等,如果小于0,就表示s1字典序小于s2。

添加 size() 方法

🔍 核心逻辑:直接返回strlen()

size_t size() const{
  return strlen(c);
}

添加 operator[] 访问字符

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

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

添加 append()、+ 运算符

String operator+(const String& other){
  char* newC = new char[strlen(c) + strlen(other.c) + 1];
  strcpy(newC, c);
  strcat(newC, other.c);  //以上都只是普通C字符串的操作
  String result(newC);  //利用普通的C字符串,构造出String类对象result
  delete[] newC;
  return result;  //返回String类对象
}

字符串转为int类型

int StrToInt(bool* ok = nullptr) const{  //先传入一个bool类型的空指针
  if(ok) *ok = false;  //先要设置ok指针为false
  if(!c || *c == '\0') return -1;  //如果字符串为空指针或者空字符串-->返回-1

  char* end = nullptr;  //用来存储不能转换部分的字符串
  int i = strtol(c, &end, 10);  //字符串的起始位置c,&end转换结束,指向不能转换的位置
  if(ok) *ok = (*end == '\0');  //如果end最后指向的不是'\0'说明有不是数字的字符在后面
  return i;
}

strtol关键字的用法-->long strtol(const char* str, char** endptr, int base);可以看出来,需要传入end指针的地址才可以让strtol函数修改end的值。
假设 c = "123abc":
strtol 从 c 开始读取,发现能合法读取出 123
它会让 end 指向 'a' 的位置

c    ---> '1' '2' '3' 'a' 'b' 'c' '\0'
           ↑
        strtol 从这里开始
end ---> 'a' 'b' 'c' '\0'   ← strtol 填充的值

代码示例

#include <iostream>
#include <cstring>
#include <cstdlib>
using namespace std;


class String
{
private:
    char* c;
    //采用char*的形式,而不是char[],对C风格字符串的封装来实现string类
public:
    String(const char* cstr = 0) {
        if (cstr) {
            c = new char[strlen(cstr) + 1];
            strcpy(c, cstr);
        } else {
            c = new char[1];
            c[0] = '\0';
        }
    }
    ~String() {
        delete[] c;
    }

    String(const String& other) {
        c = new char[strlen(other.c) + 1];
        strcpy(c, other.c);
    }

    String& operator=(const String& other) {
        if (this != &other) {
            delete[] c;
            c = new char[strlen(other.c) + 1];
            strcpy(c, other.c);
        }
        return *this;
    }

    String(String&& other) noexcept {
        c = other.c;
        other.c = nullptr;
        //不能释放其他对象的资源,目标是偷走,不是销毁对象的变量,delete[] other.c;
    }

    String& operator=(String &&other) noexcept {
        if (this != &other)
        {
            delete[] c;
            c = other.c;
            other.c = nullptr;
        }
        return *this;
    }

    friend ostream& operator<<(ostream& os, const String& s) {
        os << s.c;
        return os;
    }

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

    bool operator!=(const String &other) const {
        return !(*this == other);
    }

    size_t size() const {
        return strlen(c);
    }

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

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

    String operator+(const String &other) const {
        char *newC = new char[strlen(c) + strlen(other.c) + 1];
        strcpy(newC, c);
        strcat(newC, other.c);
        String result(newC);
        delete[] newC;
        return result;
    }

    int toInt(bool* ok = nullptr) const {
        if (ok) *ok = false;
        if (!c || *c == '\0') return 0;
        char* end = nullptr;
        int i = strtol(c, &end, 10);
        if (ok) *ok = (*end == '\0');
        return i;
    }
};
int main() {
    // 构造函数测试
    String s1("Hello");
    String s2("World");
    cout << "s1: " << s1 << endl;
    cout << "s2: " << s2 << endl;

    // 拷贝构造函数测试
    String s3 = s1;
    cout << "【拷贝构造】用s1拷贝构造出s3: " << s3 << endl;

    // 移动构造函数测试
    String s4 = String("Temp");
    cout << "【移动构造】用Temp移动构造出s4 : " << s4 << endl;

    // 拷贝赋值测试
    s3 = s2;
    cout << "【拷贝赋值】s3拷贝赋值s2的数据: " << s3 << endl;

    // 移动赋值测试
    s4 = String("NewTemp");
    cout << "【移动赋值】s4原本有数据,现在移动赋值NewTemp: " << s4 << endl;

    // 比较运算符测试
    String s5("Hello");
    cout << "s1与s5是否相等:" << (s1 == s5) << endl;   // true
    cout << "s1与s2是否不相等:" << (s1 != s2) << endl;   // true

    // 索引运算符测试
    cout << "s1[1]: " << s1[1] << endl;           // 'e'
    s1[1] = 'a';
    cout << "修改s1的下标1元素后:" << s1 << endl;    // Hallo

    // 字符串拼接测试
    String s6 = s1 + s2;
    cout << "s6 = s1 + s2: " << s6 << endl;       // HalloWorld

    // size() 测试
    cout << "s6 size: " << s6.size() << endl;     // 10

    // toInt() 测试
    String s7("12345");
    bool ok;
    int val = s7.toInt(&ok);
    cout << "s7 toInt: " << val << ", 是否转化成功:" << ok << endl;  // 12345, true

    String s8("123abc");
    val = s8.toInt(&ok);
    cout << "s8 toInt: " << val << ", 是否转化成功:" << ok << endl;  // 123, false

    String s9("abc");
    val = s9.toInt(&ok);
    cout << "s9 toInt: " << val << ", 是否转化成功:" << ok << endl;  // 0, false

    String s10("");
    val = s10.toInt(&ok);
    cout << "s10 toInt: " << val << ", 是否转化成功:" << ok << endl; // 0, false

    return 0;
}

测试结果:
image