C++ 类和对象

面向对象思想

过程论(Procedural Programming)

核心观点

  • 程序由一系列步骤或过程组成,这些步骤按照一定的顺序执行。
  • 数据和逻辑是分离的,数据是被处理的对象,而逻辑是处理数据的规则。

优点

  • 逻辑清晰:对于简单的、顺序的任务,过程论可以提供清晰的执行路径。
  • 易于理解和控制:在小型或结构化程度高的应用中,过程论易于理解和控制。

缺点

  • 可扩展性差:随着系统复杂度的增加,过程论可能导致代码难以管理和维护。
  • 难以适应变化:在需求变化时,过程论的程序可能需要大规模重构。

对象论(Object-Oriented Programming, OOP)

核心观点

  • 程序由一系列交互的对象组成,每个对象包含数据和处理这些数据的方法。
  • 对象是数据和逻辑的封装体,具有状态、行为和标识。

优点

  • 高度抽象:通过封装、继承和多态等机制,OOP 提供了高度的抽象能力。
  • 易于扩展和维护:对象的模块化特性使得系统易于扩展和维护。
  • 重用性:对象和类可以被重用,减少了代码的冗余。

缺点

  • 性能开销:对象的封装和多态等特性可能会带来额外的性能开销。
  • 设计复杂性:设计良好的对象模型需要深入的理解和经验。

过程论与对象论的辩证关系

  • 相互渗透:在实际应用中,过程论和对象论往往是相互渗透的。例如,一个面向对象的程序可能包含一些过程式的函数来处理特定的任务。
  • 相互转化:过程论可以通过封装和抽象转化为对象论,反之亦然。这种转化不是简单的替换,而是根据实际需求和上下文来决定使用哪种方法。

类的定义

在 C++中,类是面向对象编程的核心概念之一,用于模拟现实世界中的实体和概念。类的定义和使用涉及几个关键部分,包括数据成员、成员函数以及类的声明和定义。

类的定义和声明:

  1. 数据成员:这些是类的属性,用于存储对象的状态信息。数据成员可以是私有的或公有的,这取决于类的设计和封装的需求。

  2. 成员函数:这些是类的方法,用于定义可以对类的数据成员执行的操作。成员函数可以访问和修改数据成员。

  3. 类的声明:这通常在头文件中进行,允许其他类或函数知道类的存在,而无需了解其具体实现。

  4. 类的定义:这包括类的具体实现,通常在源文件中进行。

类的基本语法:

// 类的声明
class MyClass {
  public:
  // 公有成员函数
  void myFunc() {
    // 函数实现
  }

  // 公有数据成员
  int _a;

  private:
  // 私有数据成员
  int _b;
};

// 类的声明,仅声明,定义在后面
class MyClass2;

// 类的完整定义
class MyClass2 {
  public:
  void display() {
    std::cout << "Display function." << std::endl;
  }
};

访问修饰符

在 C++编程中,我们经常需要定义类来封装数据和行为。以代工厂的角度来看,我们可以定义一个 Computer 类,它包含品牌和价格这两个属性,以及设置这些属性的行为。以下是 Computer 类的基本定义:

class Computer {
public:
    void setBrand(const char* brand) {
        strcpy(_brand, brand);
    }

    void setPrice(float price) {
        _price = price;
    }

private:
    char _brand[20];
    float _price;
};

在这个类中,_brand_price 是私有成员,这意味着它们不能直接在类外部被访问。这是为了保护数据的完整性和封装性,确保对象的状态只能通过类提供的方法来改变。

当我们尝试在类外部直接访问这些私有成员时,比如:

Computer pc;
pc.setPrice(10000); // 正确
pc._price; // 错误,因为_price是私有的

我们会发现,直接访问 _price 会导致编译错误。这是因为私有成员只能在类的内部被访问,例如在成员函数中。

class 中的所有的成员都拥有自己的访问权限,分别可以用以下的三个访问修饰符进行修饰:

  • public: 公有的访问权限,在类外可以通过对象直接访问公有成员

  • protected: 保护的访问权限,在本类中和派生类中可以访问,在类外不能通过对象直接访问(后面学)

  • private: 私有的访问权限,在本类之外不能访问,比较敏感的数据设为 private,类定义中可以访问。

为了正确地使用 Computer 类,我们应该通过公共接口(即公共成员函数)来设置和获取数据。例如,设置品牌和价格应该使用 setBrandsetPrice 函数:

Computer pc;
pc.setBrand("Dell");
pc.setPrice(10000);

这样,我们就能够安全地操作对象的状态,同时保持类的封装性和数据的安全性。
public 的成员函数也可以称为接口,就是该类提供给外界使用的路径,在类外可以直接通过对象进行访问

class Computer {
public:
  void setBrand(const char * brand)
  {
    strcpy(_brand, brand);
  }
  void setPrice(float price)
  {
    _price = price;
  }
private:
  char _brand[20];
  float _price;
};

Computer pc;
pc.setPrice(10000); // 正确
pc._price; // 错误,因为 _price 是私有的

structclass 的对比

在 C++ 中,类(class)的概念与 C 语言中的结构体(struct)非常相似,但它们在功能和用途上有所不同。

在 C 语言中,struct 主要用于创建包含多个不同类型数据成员的复合数据类型。它仅仅是数据的集合,不包含方法(函数)。例如:

struct Student {
  int number;
  char name[25];
  int score;
};

void test0() {
  struct Student s1;
  struct Student s2 = {10, "Jack", 98};
}

这里,struct Student 定义了一个包含学号、姓名和分数的结构体。在 C 语言中,所有结构体成员默认是公开的,无法隐藏数据。

为了简化结构体类型的使用,C 语言提供了 typedef 关键字,允许我们为结构体类型创建别名:

typedef struct {
  int number;
  char name[25];
  int score;
} Student;

void test0() {
  Student s1;
  Student s2;
}

这种方式使得结构体的使用更加类似于 C++ 中的类。

在 C++ 中,structclass 都可以用来定义数据和行为的组合,但它们在默认访问权限上有所不同:

  • C++ 中的 struct:默认访问权限是 public,这意味着其成员在类外可以直接访问,除非显式指定为 privateprotected
  • C++ 中的 class:默认访问权限是 private,这意味着其成员在类外不能直接访问,需要通过公共接口(如公共成员函数)来访问。

这种设计使得 C++ 的 class 更适合实现面向对象编程中的封装特性,即隐藏对象的属性和实现细节,仅对外公开接口,严格控制属性的访问级别。

成员函数的定义

在 C++中,成员函数可以在类的定义内部直接实现,也可以仅在类定义中声明成员函数,然后在类定义外部实现。以下是两种方式的示例:

成员函数定义的形式:

class Computer {
  public:
  void setBrand(const char* brand) { strcpy(_brand, brand); } // 设置品牌
  void setPrice(float price) { _price = price; } // 设置价格
  void print() { /* 打印信息 */ }
  private:
  char _brand[20];
  float _price;
};

类外定义成员函数:

class Computer {
  public:
  void setBrand(const char* brand); // 设置品牌
  void setPrice(float price); // 设置价格
  void print(); // 打印信息
  private:
  char _brand[20];
  float _price;
};

// 类外定义成员函数
void Computer::setBrand(const char* brand) { 
  strcpy(_brand, brand); 
}
void Computer::setPrice(float price) { 
  _price = price;
}

为什么采用成员函数声明和实现分离的写法?

在实际开发中,如果一个类包含很多成员函数,直接在类定义中实现所有成员函数会使代码难以阅读和维护。将成员函数的声明放在类定义中,而将实现放在类定义外部,可以提高代码的可读性和可维护性。同时,这种方法也方便在类定义中通过注释对成员函数进行说明。

class Computer {
 public:
  void setBrand(const char *brand); // 设置品牌
  void setPrice(double price); // 设置价格
  void print(); // 打印信息
 private:
  char _brand[20];
  double _price;
};

// 如果将成员函数放在类外定义
// 需要在成员函数名称前面加上类作用域限定
void Computer::setBrand(const char *brand) {
  strcpy(_brand, brand);
}

void Computer::setPrice(double price) {
  _price = price;
}

void Computer::print() {
  cout << "brand:" << _brand << endl;
  cout << "price:" << _price << endl;
}

多文件联合编译时可能出现的错误

在头文件中定义函数时,如果多个源文件包含了该头文件,那么在联合编译时可能会出现重定义错误。这是因为头文件的内容在每个源文件中都会被复制一份,导致在链接阶段出现多个相同函数定义的情况。

解决方法:

  1. 使用 inline 关键字: 在成员函数的定义前加上 inline 关键字,这样即使在头文件中定义函数,也不会导致重定义错误。
    class Computer {
    public:
     void setBrand(const char *brand); // 设置品牌
     void setPrice(double price); // 设置价格
     void print(); // 打印信息
    private:
     char _brand[20];
     double _price;
    };
    
    inline void Computer::setBrand(const char *brand) {
     strcpy(_brand, brand);
    }
    
    inline void Computer::setPrice(double price) {
     _price = price;
    }
    
    inline void Computer::print() {
     cout << "brand:" << _brand << endl;
     cout << "price:" << _price << endl;
    }
    
  2. 类内部定义成员函数: 类内部定义的成员函数默认是 inline 函数,因此可以在头文件中定义。
    class Computer {
    public:
     void setBrand(const char *brand) {
    strcpy(_brand, brand);
     }
     void setPrice(double price) {
    _price = price;
     }
     void print() {
    cout << "brand:" << _brand << endl;
    cout << "price:" << _price << endl;
     }
    private:
     char _brand[20];
     double _price;
    };
    
  3. 分离声明和实现: 将成员函数的声明放在头文件中,而将定义放在源文件中。这样即使有多个源文件包含头文件,也不会出现重定义错误。

对象的创建

构造函数的主要目的是初始化数据成员。它在对象创建时自动调用,确保对象的状态在首次使用前是正确的。

构造函数具有以下特点:

  • 无返回值:构造函数不返回任何值,甚至不能有 void
  • 名称与类名相同:构造函数的名称必须与类名完全相同。
  • 参数列表:构造函数可以通过参数列表接收初始化数据。

对象的创建规则

默认构造函数:如果类中没有显式定义任何构造函数,编译器会生成一个默认的无参构造函数。但这个默认构造函数不会初始化数据成员,其值是不确定的。

class Point {
 public:
  void print() {
    cout << "(" << _ix 
      << "," << _iy
      << ")" << endl;
  }
 private:
  int _ix;
  int _iy;
};

void test0() {
  Point pt; // 调用了默认的无参构造
  pt.print(); // 输出不确定的值
}

显式定义构造函数:如果类中定义了至少一个构造函数,编译器将不会自动生成默认构造函数。

class Point {
 public:
  Point() {
    cout << "Point()" << endl;
    _ix = 0;
    _iy = 0;
  }
  void print() {
    cout << "(" << _ix 
      << "," << _iy
      << ")" << endl;
  }
 private:
  int _ix;
  int _iy;
};

void test0() {
  Point pt; // 调用了自定义的构造函数
  pt.print(); // 输出(0,0)
}

带参数的构造函数:构造函数可以接收参数,这提供了更大的灵活性。

class Point {
 public:
  Point(int ix, int iy) {
    cout << "Point(int,int)" << endl;
    _ix = ix;
    _iy = iy;
  }
  void print() {
    cout << "(" << _ix 
      << "," << _iy
      << ")" << endl;
  }
 private:
  int _ix;
  int _iy;
};

void test0() {
  Point pt2(10, 20); // 正确
  pt2.print(); // 输出(10,20)
}

提供无参构造函数:如果需要无参构造函数,必须手动定义。

class Point {
 public:
  Point() {}
  Point(int ix, int iy) {
    cout << "Point(int,int)" << endl;
    _ix = ix;
    _iy = iy;
  }
  void print() {
    cout << "(" << _ix 
      << "," << _iy
      << ")" << endl;
  }
 private:
  int _ix;
  int _iy;
};

void test0() {
  Point pt; // 正确
  Point pt2(10, 20);
  pt2.print(); // 输出(10,20)
}

构造函数重载:一个类可以有多个构造函数。

class Point {
 public:
  Point() {
    cout << "Point(int,int)" << endl;
    _ix = 0;
    _iy = 0;
  }
  Point(int ix, int iy) {
    cout << "Point(int,int)" << endl;
    _ix = ix;
    _iy = iy;
  }
  void print() {
    cout << "(" << _ix 
      << "," << _iy
      << ")" << endl;
  }
 private:
  int _ix;
  int _iy;
};

对象的数据成员初始化

在 C++中,构造函数不仅用于执行对象创建时的初始化操作,而且推荐使用初始化列表来初始化数据成员。

初始化列表位于构造函数的参数列表之后、函数体之前,用于初始化数据成员。它使用冒号开始,数据成员的初始值用逗号分隔,并放在一对小括号中。

class Point {
 public:
  Point(int ix = 0, int iy = 0)
      : _ix(ix), _iy(iy)  // 初始化列表
  {
    cout << "Point(int,int)" << endl;
  }
  void print() const {
    cout << "(" << _ix << "," << _iy << ")" << endl;
  }
 private:
  int _ix;
  int _iy;
};

如果在构造函数的初始化列表中没有显式地初始化某个成员,则该成员将在构造函数体执行之前进行默认初始化。这意味着,即使在构造函数体内对成员进行了赋值,成员也会先经历默认初始化,然后再被赋新值。

数据成员的初始化顺序与其在类中声明的顺序一致,与它们在初始化列表中的顺序无关。初始化列表通常按照数据成员声明的顺序排列,以保持代码的清晰和一致性。

构造函数的参数可以设置默认值,使得构造函数更加灵活。如果在声明和定义分开的情况下,建议在声明中设置参数的默认值。

class Point {
 public:
  Point(int ix, int iy = 0);  // 默认参数设置在声明时
  // ...
};

Point::Point(int ix, int iy)
    : _ix(ix), _iy(iy) {
  cout << "Point(int,int)" << endl;
}

void test0() {
  Point pt(10);  // 使用默认参数值
}

从 C++11 开始,普通的数据成员可以在声明时直接初始化,这为初始化提供了另一种选择。但是,对于某些特殊的数据成员(如引用或类类型),初始化只能在初始化列表中进行。

class Point {
 public:
  // ...
  int _ix = 0;  // C++11 直接初始化
  int _iy = 0;
};

对象所占空间大小

C++中,类的大小和对象的大小通常是相同的,因为类定义了对象的内存布局。类的大小由其数据成员的大小和内存对齐规则共同决定。

使用 sizeof 运算符可以查看类的大小和类对象的大小,结果通常是相同的。

class Point {
 public:
  Point(int ix, int iy) : _ix(ix), _iy(iy) {}
 private:
  int _ix;
  int _iy;
};

void test0() {
  Point pt(1, 2);
  cout << sizeof(Point) << endl; // 输出类的大小
  cout << sizeof(pt) << endl;    // 输出对象的大小
}

内存对齐是出于以下原因:

  1. 平台原因:某些硬件平台只能在特定的地址处访问特定类型的数据。

    image-20240223163737665.png

  2. 性能原因:内存对齐可以减少内存访问次数,提高性能。

内存对齐的规则

  1. 按照类中最大数据成员大小的倍数对齐
  2. 数据成员的声明顺序会影响类的大小
class A {
 public:
  int _num;
  double _price;
};
// sizeof(A) = 16 

class B {
 public:
  int _num;
  int _price;
};
// sizeof(B) = 8 

64 位系统默认以 8 个字节的块大小进行读取。

如果没有内存对齐机制,CPU 读取 _price 时,需要两次总线周期来访问内存,第一次读取_price 数据前四个字节的内容,第二次读取后四个字节的内容,还要经过计算,将它们合并成一个数据。

有了内存对齐机制后,以浪费 4 个字节的空间为代价,读取_price 时只需要一次访问,所以编译器会隐式地进行内存对齐。

数据成员的顺序会影响类的大小,因为编译器会根据内存对齐规则来安排数据成员的位置。

class C {
 public:
  int _c1;
  int _c2;
  double _c3;
};
// sizeof(C) = 16

class D {
 public:
  int _d1;
  double _d2;
  int _d3;
};
// sizeof(D) = 24

image-20240223163111420.png

如果数据成员中有数组类型,会按照除数组以外的其他数据成员中最大的那一个的倍数对齐。

class E {
 public:
  double _e;
  char _eArr[20];
  double _e1;
  int _e2;
};
// sizeof(E) = 48

class F {
 public:
  char _fArr[20];
};
// sizeof(F) = 20

即使是空类,其对象所占空间通常也为 1 个字节,这是编译器的占位机制。

class EmptyClass {};

EmptyClass ec;
// sizeof(EmptyClass) = 1

可以使用 #pragma pack(n) 指令来控制编译器的对齐方式,其中 n 可以是 1, 2, 4, 8, 16 等。

指针数据成员

在 C++中,如果一个类的成员变量是指针,那么通常意味着需要在堆上动态分配内存。为了确保申请的内存得到妥善管理,需要在类的析构函数中释放这些内存。

以下代码示例中,Computer 类的构造函数在初始化列表中分配了内存,但在对象销毁时没有相应的内存释放机制,导致了内存泄漏。

class Computer {
 public:
  Computer(const char *brand, double price)
      : _brand(new char[strlen(brand) + 1]()),
        _price(price) {
    strcpy(_brand, brand);
  }

 private:
  char *_brand;
  double _price;
};

void test0() {
  Computer pc("Apple", 12000);
}

在上述代码中,_brand 成员在构造函数中通过 new 分配了内存,但在对象 pc 被销毁时,并没有相应的 delete 操作来释放内存。这会导致内存泄漏。

对象的销毁

析构函数的作用

  1. 资源释放:析构函数负责清理对象的数据成员申请的资源,特别是动态分配的堆空间。这是防止内存泄漏和其他资源泄露的关键。
  2. 自动调用:当对象的作用域结束或被显式删除时,析构函数会被自动调用。

析构函数是一种特殊的成员函数,具有以下特点:

  • 无返回值:析构函数不返回任何值,甚至不能是 void
  • 无参数:析构函数不接受任何参数。
  • 特殊命名:析构函数的名称与类名相同,但在类名前加一个波浪号 ~

析构函数的唯一性:每个类只能有一个析构函数,它不能被重载。

默认析构函数:如果程序员没有显式定义析构函数,编译器会默认提供一个。默认析构函数不做任何操作,但当涉及到动态内存管理或其他资源管理时,显式定义析构函数是必要的。

析构函数的自动调用:当对象生命周期结束时,无论是因为超出作用域还是因为被显式删除,析构函数都会被自动调用。

自定义析构函数

在 C++中,析构函数是一个特殊的成员函数,它在对象生命周期结束时被自动调用,用于执行清理工作,尤其是释放动态分配的内存资源。

析构函数的主要作用是:

  1. 释放资源:清理对象的数据成员申请的堆空间,防止内存泄漏。
  2. 安全回收:将指针成员设置为 nullptr,确保不会再次释放同一内存。

以下是一个包含指针成员的 Computer 类,它演示了如何正确实现析构函数:

class Computer {
public:
  Computer(const char* brand, double price)
    : _brand(new char[strlen(brand) + 1]()),
  _price(price) {
    strcpy(_brand, brand);
  }

  ~Computer() {
    if (_brand) {
      delete[] _brand;
      _brand = nullptr; // 设为空指针,安全回收
    }
    std::cout << "~Computer()" << std::endl;
  }

  void print() const {
    std::cout << _brand << " " << _price << std::endl;
  }

private:
  char* _brand;
  double _price;
};

void test0() {
  Computer pc("Apple", 12000);
  pc.print();
  // pc.~Computer(); // 手动调用析构函数是不推荐的做法
}

析构函数的规范写法

  1. 检查指针是否为空:在释放内存之前,检查指针是否为空,以避免 delete 空指针的风险
  2. 设置为空指针:释放内存后,将指针设置为 nullptr,确保不会再次释放同一内存。

undefined202403071603877.png

析构函数的调用

  • 自动调用:对象被销毁时,析构函数会被自动调用。
  • 手动调用:虽然可以通过对象手动调用析构函数,但这通常不推荐,因为它可能导致各种问题,如重复释放内存、访问已销毁的对象等。

手动调用析构函数的问题

void test() {
  Computer pc("apple", 12000);
  pc.~Computer(); // 手动调用析构函数
  pc.print();     // 此时调用 print 函数会导致未定义行为
}
  • 问题 1:手动调用析构函数后,再次调用成员函数可能导致未定义行为,因为对象的内存可能已经被部分释放。
  • 问题 2:手动调用析构函数可能导致析构函数被调用两次,从而引发 double free 错误。

析构函数是可以通过对象来调用,而构造函数不同。

构造函数是最特殊的成员函数,不是由对象来调用构造函数。而是,编译器在看到创建对象的语句时,会自动生成一段代码,在这段代码中调用构造函数,利用传入的参数来初始化对象。

析构函数的调用时机

  1. 全局对象
    • 析构时机:全局对象的析构函数在程序结束时调用,通常是在 main 函数执行完毕后,操作系统开始卸载程序之前。
    • 特点:全局对象的生命周期贯穿整个程序,它们的析构函数确保在程序结束前释放资源。
  2. 局部对象
    • 析构时机:局部对象的析构函数在离开其定义的作用域时调用。例如,在一个函数中定义的局部对象,在函数返回前会被销毁。
    • 特点:局部对象的生命周期由其作用域决定,析构函数确保在对象离开作用域时资源被释放。
  3. 静态对象
    • 析构时机:静态对象(包括静态存储区的对象和 static 关键字定义的对象)的析构函数在程序结束时调用,与全局对象类似。
    • 特点:静态对象的生命周期也是整个程序期间,它们的析构函数确保在程序结束前进行必要的清理。
  4. 堆对象
    • 析构时机:堆对象的析构函数在使用 delete 操作符删除对象时调用。这是显式的内存管理操作,确保在不再需要对象时释放其占用的内存。
    • 特点:堆对象的生命周期由程序员控制,需要手动管理内存的分配和释放。
Computer pc1("Dell",5000);

void test0(){
  Computer pc2("Lenovo",6000);
  static Computer pc3("Asus",6500);

  int * p = new int(10);
  delete p;
  p = nullptr;

  Computer * pp = new Computer("apple",5500);
  /* pp->print(); */
  /* (*pp).print(); */
  delete pp;
  pp = nullptr;
}

image-20240517091425912.png

拷贝构造函数

对于内置类型而言,使用一个变量初始化另一个变量是很常见的操作

int x = 1;
int y = x;

那么对于自定义类型,我们也希望能有这样的效果,如

Point pt1(1,2);
Point pt2 = pt1;
pt2.print();

发现这种操作也是可以通过的。执行 Point pt2 = pt1; 语句时, pt1 对象已经存在,而 pt2 对象还不存在,所以也是这句创建了 pt2 对象,既然涉及到对象的创建,就必然需要调用构造函数,而这里会调用的就是拷贝构造函数(复制构造函数)。

拷贝构造函数的定义

在 C++中,拷贝构造函数用于创建一个对象的新实例,该实例是另一个同类型对象的副本。如果类中包含指针成员指向动态分配的内存,使用默认的拷贝构造函数会导致浅拷贝,即新对象和原对象的指针成员指向同一块内存区域。

拷贝构造函数用于创建一个对象的新实例,该实例是另一个同类型对象的副本。它主要用于以下场景:

  1. 对象的初始化:当一个对象需要通过另一个同类型的对象来初始化时。
  2. 函数参数传递:当函数的参数通过值传递对象时。
  3. 函数返回对象:当函数返回一个对象时。

拷贝构造函数具有以下特点:

  • 参数:它接受一个对类类型的常量引用作为唯一参数,用于传递要拷贝的对象。
  • 名称:它的名称与类名相同,并带有一个参数。
  • 默认行为:如果未显式定义拷贝构造函数,编译器会生成一个默认的拷贝构造函数,该构造函数会逐个成员地拷贝已有对象的值。

没有显式定义拷贝构造函数,这条复制语句依然可以通过,说明编译器自动提供了默认的拷贝构造函数。其形式是:

Point(const Point &rhs)
    : _ix(rhs._ix), _iy(rhs._iy) {}

拷贝构造函数看起来非常简单,那么我们尝试对 Computer 类的对象进行同样的复制操作。发现同样可以编译通过,但运行报错。

Computer pc("Acer",4500);
Computer pc2 = pc; //调用拷贝构造函数

Computer(const Computer &rhs)
  : _brand(rhs._brand), _price(rhs._price) {
    cout << "Computer(const Computer & rhs)" << endl;
  }

image-20240517101108391.png

浅拷贝意味着新对象和原对象共享相同的资源(如堆内存)。这会导致以下问题:

  • 内存泄漏:当两个对象都试图释放同一块内存时,可能会导致内存泄漏或程序崩溃。
  • double free 错误:当两个对象的析构函数都尝试释放同一块内存时,会导致 double free 错误。

为了避免这些问题,需要在自定义的拷贝构造函数中实现深拷贝。深拷贝意味着为新对象分配新的内存,并复制原对象的数据到这块新内存中。

#include <iostream>
#include <cstring>

class Computer {
 public:
  Computer(const char *brand, double price)
      : _brand(new char[strlen(brand) + 1]()), _price(price) {
    strcpy(_brand, brand);
  }

  // 深拷贝构造函数
  Computer(const Computer &rhs)
      : _price(rhs._price) {
    _brand = new char[strlen(rhs._brand) + 1];
    strcpy(_brand, rhs._brand);
  }

  ~Computer() {
    delete[] _brand;
    std::cout << "~Computer() called.\n";
  }

  void print() const {
    std::cout << _brand << " " << _price << std::endl;
  }

 private:
  char *_brand;
  double _price;
};

int main() {
  Computer pc("Acer", 4500);
  Computer pc2 = pc; // 调用深拷贝构造函数
  pc2.print();
  return 0;
}

image-20230831161420785.png

拷贝构造函数的调用时机

拷贝构造函数在以下情况下被调用:

  1. 对象初始化:当一个新对象通过已存在的对象来初始化时,调用拷贝构造函数。
    Point pt1(1, 2);
    // 利用一个已经存在的对象用复制的方式创建出新的对象
    // 调用拷贝构造,用 = 连接是为了跟内置类型保持一致
    Point pt2 = pt1;
    
  2. 函数参数传递:当函数的参数以值的方式传递对象时,实参初始化形参会调用拷贝构造函数。
    // 当函数的实参和形参都是对象时
    // 利用实参初始化形参,相当于是值传递
    // 会发生复制
    void printPoint(Point p) {
       // ...
    }
    
    Point pt;
    printPoint(pt); // 调用拷贝构造函数
    

    为了避免不必要的拷贝,可以按引用传递对象:

    // 为了避免这次不必要的拷贝,可以使用引用作为参数
    void printPoint(const Point& p) {
       // ...
    }
    
  3. 函数返回对象:当函数返回一个对象时,返回值需要通过拷贝构造函数初始化调用者的局部变量(编译器有优化)。
    // 函数的返回值是对象时
    // 函数体中执行 return 语句时会发生复制
    Point createPoint() {
       Point p(1, 2);
       return p; // 调用拷贝构造函数
    }
    

    为了避免多余的拷贝,可以按引用返回对象,但要确保返回对象的生命周期:

    // 为了避免这次多余的拷贝,可以使用引用作为返回值
    // 但一定要确保返回值的生命周期大于函数的生命周期
    Point& createPoint() {
       static Point p(1, 2);
       return p; // 返回引用
    }
    

    第三种情况直接编译并不会显示拷贝构造函数的调用,但是底层实际是调用了的,加上优化参数进行编译可以看到效果

    g++ CopyComputer.cc -fno-elide-constructors
    

拷贝构造函数的形式探究

拷贝构造函数是否可以去掉引用符号?

拷贝构造函数的标准形式是:

ClassName(const ClassName& other);

这里的 const ClassName& 是一个常量引用,允许我们通过引用传递对象,而不需要进行拷贝。

如果我们尝试将拷贝构造函数的参数改为值传递,即去掉引用符号,形式变为:

ClassName(ClassName other);

在这种情况下,当我们用一个对象来初始化另一个对象时,会发生以下情况:

  1. 递归调用:当我们调用拷贝构造函数时,编译器会创建一个新的对象(形参)来接收传入的对象(实参)。这将再次调用拷贝构造函数来初始化这个新的对象。
  2. 栈溢出:由于每次调用拷贝构造函数时都在创建新的对象,导致不断递归调用,最终会导致栈溢出,程序崩溃。

以下是一个示例,展示了去掉引用符号后会导致的错误:

#include <iostream>
using namespace std;

class Point {
 public:
  Point(int x, int y) : _x(x), _y(y) {
      cout << "Constructor called." << endl;
  }

  // 错误的拷贝构造函数

  Point(Point const other) { // 去掉引用符号
      cout << "Copy constructor called." << endl;
      _x = other._x;
      _y = other._y;
  }

 private:
  int _x, _y;
};

int main() {
  Point p1(1, 2);
  // 形参的初始化 Poinnt const other = p1
  // 触发拷贝构造的第二种调用时机
  // 会再次对 p1 进行复制
  // 这样下去会导致拷贝构造无线调用,直到栈溢出,程序崩溃
  Point p2 = p1;
  return 0;
}

image-20240422173226120.png

为了避免这种递归调用,拷贝构造函数的参数必须使用引用:

class Point {
  public:
  Point(int x, int y) : _x(x), _y(y) {
    cout << "Constructor called." << endl;
  }

  // 正确的拷贝构造函数
  Point(const Point &other) { // 使用引用
    cout << "Copy constructor called." << endl;
    _x = other._x;
    _y = other._y;
  }

  private:
  int _x, _y;
};

拷贝构造函数是否可以去掉 const

拷贝构造函数的标准形式是:

ClassName(const ClassName &other);

如果我们将拷贝构造函数的参数改为非 const 引用,即:

ClassName(ClassName &other);

在这种情况下,会出现以下问题:

  1. 修改原对象:去掉 const 后,拷贝构造函数可以修改传入的对象。这在拷贝构造的上下文中是不合理的,因为我们通常希望保留原对象的状态不变。
    class Point {
    public:
       Point(int x, int y) : _x(x), _y(y) {}
    
       // 错误的拷贝构造函数
       Point(Point& other) { // 去掉const
           _x = other._x;
           _y = other._y;
           // 这里可以意外地修改other对象
       }
    
    private:
       int _x, _y;
    };
    
  2. 无法绑定临时对象:如果拷贝构造函数的参数是非 const 引用,它将无法接受临时对象(右值)。例如:
    Computer& rhs = Computer("apple", 12000); // 错误
    

    这段代码会导致编译错误,因为临时对象不能绑定到非常量引用。

    void test() {
     // 可以取址的变量(对象)称为左值
     // 不能取值的变量(对象)称为右值
     // (匿名的或临时的变量、对象)
     int num = 1;
     &num;
     // &1; // 数是在指令系统中的,没有存在内存中
    
     // 非 const 引用只能绑定左值,不能绑定右值
     int &ref = num;
     // int &ref2 = 10;
    
     // const 引用既能绑定左值,又能绑定右值
     const int &ref3 = 10;
     const int &ref4 = num;
    }
    
    

赋值运算符函数

赋值运算同样是一种很常见的运算,比如:

int x = 1, y = 2;
x = y;

自定义类型当然也需要这种运算,比如:

Point pt1(1, 2), pt2(3, 4);
pt1 = pt2;//赋值操作

在执行 pt1 = pt2; 该语句时, pt1pt2 都存在,所以不存在对象的构造,这要与 Point pt2 = pt1; 语句区分开,这是不同的。

赋值运算符函数的形式

赋值运算符函数用于定义对象之间的赋值行为。其标准形式为:

ClassName& ClassName::operator=(const ClassName& other);

对 Point 类进行测试时,会发现我们不需要显式给出赋值运算符函数,就可以执行测试。这是因为如果类中没有显式定义赋值运算符函数时,编译器会自动提供一个赋值运算符函数。对于 Point 类而言,其实现如下:

Point & Point::operator=(const Point & rhs)
{
  _ix = rhs._ix;
  _iy = rhs._iy;
}

手动写出赋值运算符,再加上函数调用的提示语句。执行发现语句被输出,也就是说,当对象已经创建时,将另一个对象的内容复制给这个对象,会调用赋值运算符函数

虽然赋值运算符看起来是双目运算符,需要两个对象(一个左侧对象和一个右侧对象),但实际上,左侧对象是通过隐式的 this 指针提供的,不需要显式传递。this 指针是一个指向当前对象的指针,它在成员函数内部自动可用。

赋值运算符的返回类型通常是对当前对象的引用(ClassName&)。这样设计的原因包括:

  1. 链式赋值:允许像 a = b = c 这样的链式赋值操作。
  2. 避免返回值被销毁:返回对象的引用,而不是对象本身,可以避免返回值在函数结束时被销毁。

this 指针

this 指针的本质是一个常量指针,形式为 Type* const this。它存储了调用成员函数的对象的地址,并且不可被修改。

this 指针在成员函数中隐式存在,指向调用该函数的对象实例,使得成员函数能够访问和修改该对象的成员变量。

// 返回类型是 Point &
// 函数名是 operator=
// 参数是 const Point &
// this 不能显式地在参数列表中写出
// 因为编译器一定会加入一个 this 指针作为第一个参数
Point &operator=(const Point &rhs) {
  // 可以通过 this 指针修改其指向的对象的内容
  // 不能修改 this 指针的指向
  // 因为 this 指针是常量指针
  this->_is = rhs._ix;
  this->_iy = rhs._is;
// 想想返回值是什么?本对象
  return *this;
}
// 当对象调用 print 函数时,
// 编译器会自动地添加一个参数:this 指针
// this 指针指向当前对象(调用本成员函数的对象)
void print() {
  cout << "("
       << this->_ix << ","
       << this->_iy << ")"
  endl;
}

编译器在生成程序时会将对象的首地址存放在寄存器中,这就是 this 指针的存储位置。

生命周期

  • this 指针的生命周期始于成员函数的执行开始。当一个非静态成员函数被调用时,this 指针会自动设置为指向调用该函数的对象实例。
  • 在成员函数执行期间,this 指针一直有效,可以用来访问调用对象的成员变量和成员函数。
  • this 指针的生命周期结束于成员函数的执行结束。当成员函数返回时,this 指针的作用范围也随之结束。

悬挂指针:如果成员函数通过一个已经销毁或未初始化的对象调用,this 指针将是悬挂的,使用它将导致未定义行为。

对象与 this 指针的关系this 指针的生命周期与它所指向的对象的生命周期相关,但并不完全相同。对象可能在成员函数执行前就已存在,并在执行后继续存在。

  1. 对象调用函数时,如何找到自己本对象的数据成员?

    通过 this 指针,成员函数可以访问调用对象的成员变量。

  2. this 指针代表的是什么?

    this 指针指向当前对象,即调用成员函数的对象实例。

  3. this 指针在参数列表中的位置?

    this 指针是参数列表的第一位,编译器自动加入,不需要手动声明。

  4. this 指针的形式是什么?

    this 指针的形式为 ClassName* const this,表示它是一个指向当前对象的常量指针。

Point & operator=(const Point &rhs){
  this->_ix = rhs._ix;
  this->_iy = rhs._iy;
  cout << "Point & operator=(const Point &)" << endl;
  return *this;
}

成员函数中可以加上 this 指针,展示本对象通过 this 指针找到本对象的数据成员。但是不要在参数列表中显式加上 this 指针,因为编译器一定会在参数列表的第一位加上 this 指针,如果显式再给一个,参数数量就不对了。

赋值运算符函数的定义

当类的成员变量中包含指针时,需要特别小心处理赋值运算符,以避免浅拷贝带来的问题,如内存泄漏和悬挂指针。

以 Computer 类为例,默认的赋值运算符函数长这样

Computer &operator=(const Computer &rhs){
  this->_brand = rhs._brand;
  this->_price = rhs._price;
  return *this;
}

默认的赋值运算符进行的是浅拷贝,即直接复制指针的值,而不是指针指向的数据。这会导致多个对象尝试释放同一块内存,或一个对象的析构函数错误地释放了另一个对象正在使用的内存。

undefined202403081024442.png

为了避免这些问题,需要在赋值运算符中实现深拷贝。深拷贝意味着为新对象分配新的内存,并复制数据到这块新内存中。

undefined202403081029700.png

在实现深拷贝之前,首先要检查自赋值,即左侧对象是否正在尝试赋值给自己。如果是自赋值,应该直接返回。

  1. 检查自赋值:确保不是将对象赋值给自己。
  2. 释放原有资源:释放左侧对象已有的动态分配的资源。
  3. 深拷贝资源:为左侧对象分配新的资源,并从右侧对象复制数据。
  4. 返回当前对象的引用:完成赋值后返回当前对象的引用。

以下是 Computer 类赋值运算符的一个示例,展示了如何实现这四步:

#include <iostream>
#include <cstring>

class Computer {
public:
    Computer(const char* brand, double price)
        : _brand(new char[strlen(brand) + 1]()), _price(price) {
        strcpy(_brand, brand);
    }

    // 拷贝构造函数
    Computer(const Computer& other)
        : _price(other._price) {
        _brand = new char[strlen(other._brand) + 1];
        strcpy(_brand, other._brand);
    }

    // 赋值运算符
    Computer& operator=(const Computer& rhs) {
        if (this != &rhs) {  // 检查自赋值
            delete[] _brand;  // 释放原有资源
            _brand = new char[strlen(rhs._brand) + 1];
            strcpy(_brand, rhs._brand);  // 深拷贝
            _price = rhs._price;
        }
        return *this;  // 返回当前对象的引用
    }

    ~Computer() {
        delete[] _brand;
    }

    void print() const {
        std::cout << _brand << " " << _price << std::endl;
    }

private:
    char* _brand;
    double _price;
};

int main() {
    Computer pc1("Dell", 1200.0);
    Computer pc2("HP", 1500.0);
    pc1 = pc2;  // 调用赋值运算符
    pc1.print();  // 输出: HP 1500
    return 0;
}

赋值运算符函数的形式探究

  1. 赋值运算符函数的返回类型必须是引用吗?
    Computer operator=(const Computer &rhs) {
     ……
    return *this;
    }
    

    赋值运算符函数的返回类型通常应该是引用。这是因为:

    避免多余拷贝:如果返回类型不是引用,如 Computer operator=(const Computer& rhs),那么在赋值操作完成后,返回的 Computer 对象将被销毁,而其数据需要被拷贝到调用者期望的对象中,这增加了不必要的开销。

  2. 赋值运算符函数的返回类型可以是 void 吗?

    void operator=(const Computer &rhs) {
     ……
    }
    

    链式赋值问题:如果赋值运算符函数的返回类型是 void,那么将无法支持链式赋值,因为 void 函数不能返回一个对象用于进一步的赋值操作。

  3. 赋值运算符函数的参数必须是引用吗?

    Computer & operator=(const Computer rhs) {
     ……
     return *this;
    }
    

    避免多余拷贝:如果参数不是引用,如 Computer& operator=(Computer rhs),那么参数传递将触发拷贝构造函数,这增加了不必要的拷贝开销。更重要的是,这将导致无法正确处理通过值传递导致的潜在问题,如自赋值保护。

  4. 赋值运算符函数的参数必须是一个 const 引用吗?

    Computer &operator=(Computer &rhs) {
    ……
    return *this;
    }
    

    防止修改:使用 const 引用作为参数,如 Computer& operator=(const Computer& rhs),确保了在赋值过程中不会修改右侧对象。这是非常重要的,因为它允许将临时对象(右值)作为赋值源,同时保护了原对象不被修改。

    支持右值:如果去掉 const,如 Computer& operator=(Computer& rhs),那么将无法接受临时对象作为参数,因为临时对象不能绑定到非 const 引用上。

特殊的数据成员

在 C++ 的类中,有 4 种比较特殊的数据成员,分别是常量成员、引用成员、类对象成员和静态成员,它们的初始化与普通数据成员有所不同。

常量数据成员

在 C++ 类中,使用 const 关键字修饰的数据成员称为常量数据成员。一旦这些成员被初始化,它们的值就不能再被修改,保证了数据的不变性。

由于常量数据成员在对象的生命周期内不能被修改,因此它们必须在构造函数的初始化列表中进行初始化。在 C++11 及以后的版本中,也可以在成员变量声明时直接初始化。

与普通 const 常量的区别

  • 普通 const 常量:必须在声明时初始化,并且之后不能再被修改。
  • 常量数据成员:同样必须在声明时或在构造函数的初始化列表中初始化,之后也不能被修改。

以下是包含常量数据成员的 Point 类示例:

#include <iostream>

class Point {
 public:
  // 构造函数初始化列表中初始化常量数据成员
  Point(int ix, int iy) : _ix(ix), _iy(iy) {}

  // 打印点的坐标
  void print() const {
      std::cout << "(" << _ix << ", " << _iy << ")" << std::endl;
  }

 private:
  const int _ix; // 常量数据成员
  const int _iy; // 常量数据成员
};

int main() {
  Point pt(1, 2);
  pt.print(); // 输出: (1, 2)
  // pt._ix = 3; // 错误:不能修改常量数据成员
  // pt._iy = 4; // 错误:不能修改常量数据成员
  return 0;
}

注意事项:

  • 初始化时机:常量数据成员必须在对象构造时初始化,不能在构造函数体内通过赋值操作进行初始化。
  • 类模板:如果类是模板类,所有数据成员(包括非常量数据成员)都必须在类内初始化,因为模板类在定义时不会进行实例化。
  • 不变性:常量数据成员提供了对象状态的不变性保证,这在多线程环境或需要确保数据不被修改的场景中非常有用。

引用数据成员

引用数据成员必须在对象构造时初始化,因为引用一旦被初始化,就不能再重新绑定到另一个对象。C++11 之后,可以在声明引用数据成员时直接初始化(绑定)。

引用数据成员的初始化只能在构造函数的初始化列表中进行,不能在构造函数体内通过赋值操作进行初始化。

以下是包含引用数据成员的 Point 类示例:

#include <iostream>

class Point {
 public:
  Point(int x, int y, int z) : _ix(x), _iy(y), _iz(z) {
    // 构造函数初始化列表中初始化引用成员
  }

  void print() const {
      std::cout << "ix: " << _ix << ", iy: " << _iy << ", iz: " << _iz << std::endl;
  }

 private:
  int _ix;
  int _iy;
  int &_iz;
};

int main() {
  int z = 5;
  Point pt(1, 2, z);
  pt.print(); // 输出: ix: 1, iy: 2, iz: 5
  return 0;
}

思考:构造函数接收一个参数初始化引用成员

在构造函数中接收一个参数用于初始化引用成员是可行的,但这个参数必须是一个已经存在的对象的引用,且在引用成员的生命周期内始终有效。

class Point {
 public:
  Point(int x, int y)
      : _ix(x), _iy(y)
      /* , _ref(z) // z 的生命周期在构造函数执行完时结束 */
      , _ref(_ix) //_ref 和 _ix 的生命周期都是在对象销毁时结束
  /* , _ref(gNum) //ok */
  {
    cout << "Point(int,int)" << endl;
  }
  void print() {
    cout << "(" << this->_ix
         << "," << this->_iy
         << "," << this->_ref
         << ")" << endl;
  }

 private:
  int _ix;
  int _iy;
  // C++ 11 允许引用成员在声明时初始化
  int &_ref = _iy;
};

注意事项

  • 生命周期:引用成员必须绑定一个在其生命周期内始终有效的对象。
  • 不可重新绑定:引用成员一旦被初始化,就不能再绑定到另一个对象。
  • 悬挂引用:如果引用成员绑定的对象先于引用成员被销毁,那么引用成员将成为悬挂引用,访问它将导致未定义行为。

对象成员

当一个类的对象被用作另一个类的数据成员时,我们称之为对象成员或成员子对象。对象成员的初始化必须在初始化列表中进行。

对象成员的初始化遵循以下规则:

  1. 初始化列表:在初始化列表中,必须使用对象成员的名称进行初始化。
  2. 不能直接创建:不能在对象成员的声明中直接使用带参数的构造函数创建对象。

以下是 Line 类包含两个 Point 对象成员的示例:

#include <iostream>

class Point {
  public:
  Point(int x, int y) : _x(x), _y(y) {
    std::cout << "Point(int,int)" << std::endl;
  }
  ~Point() {
    std::cout << "~Point()" << std::endl;
  }
  // 其他成员函数和数据成员
  private:
  int _x, _y;
};

class Line {
  public:
  Line(int x1, int y1, int x2, int y2)
    // 如果没有在 Line 构造函数的初始化列表中
    // 显示调用 Point 的构造函数
    // 那么会自动调用 Point 的无参函数
    : _pt1(x1, y1), // 显示调用 Point 的构造函数
  _pt2(x2, y2) // 括号前依然是对象成员的名字
  {
    std::cout << "Line(int,int,int,int)" << std::endl;
  }
  ~Line() {
    std::cout << "~Line()" << std::endl;
  }
  // 其他成员函数和数据成员
  private:
  Point _pt1;
  Point _pt2;
};

int main() {
  // 创建 Line 对象会马上调用 Line 的构造函数
  // 在 Line 的构造函数执行过程中调用 Point 的构造函数
  // Line 对象要销毁,就会马上调用 Line 的析构函数
  // Line 的析构函数执行完之后,再根据对象成员声明的反序
  // 通过成员子对象调用 Point 的析构函数
  // 此处就是 _pt2 调用析构函数,执行完后,_ptl再调用析构函数
  Line line(1, 2, 3, 4);
  // 输出顺序:Point(int,int), Point(int,int), Line(int,int,int,int)
  // 析构顺序:~Line(), ~Point(), ~Point()
  return 0;
}

构造函数和析构函数的调用顺序

  • 构造顺序:当创建一个包含对象成员的类的对象时,构造函数的调用顺序是从基类到派生类,从对象成员到成员变量。
  • 析构顺序:当销毁一个包含对象成员的类的对象时,析构函数的调用顺序与构造函数的调用顺序相反,即从成员变量到对象成员。

注意事项

  • 默认构造函数:如果未在初始化列表中显式初始化对象成员,编译器会调用对象成员的默认无参构造函数。
  • 资源管理:如果对象成员申请了堆空间,需要确保在对象成员的析构函数中释放这些资源。

如果 Line 类中有数据成员申请堆空间,Point 类对象也有数据成员申请堆空间,堆空间资源的回收顺序如下

image-20240628162920344.png

静态数据成员

静态数据成员是类的一部分,但它不属于某个特定的对象实例。它在所有对象之外独立存在,并且所有对象都共享同一个静态成员。

特点:

  • 共享性:静态数据成员被类的所有对象共享。
  • 存储:静态数据成员存储在全局/静态存储区,不占用对象的存储空间。
  • 初始化:必须在类外进行初始化。

以下是包含静态数据成员的 Computer 类示例:

#include <iostream>
#include <string>

class Computer {
 public:
  Computer(const char* brand, double price) {
    // 为非静态成员变量分配内存
    _brand = new char[strlen(brand) + 1];
    strcpy(_brand, brand);
    _price = price;
    _totalPrice += price;  // 更新静态成员变量
  }

  ~Computer() {
    delete[] _brand;  // 释放内存
  }

  static void printTotalPrice() {
    std::cout << "Total price: " << _totalPrice << std::endl;
  }

 private:
  char* _brand;
  double _price;
  static double _totalPrice;  // 声明静态成员变量
};

// 初始化静态成员变量
double Computer::_totalPrice = 0;

int main() {
  Computer pc1("Dell", 1200.0);
  Computer pc2("HP", 1500.0);
  Computer::printTotalPrice();  // 直接通过类名访问静态成员函数
  return 0;
}

静态成员规则

  1. 私有静态成员:私有的静态数据成员无法在类外部直接访问,但可以在类的成员函数内部访问。
  2. 类外初始化:静态数据成员必须在类外初始化。
  3. 初始化格式:初始化时,应在成员名前加上类名和作用域限定符,不需要再加 static 关键字。
  4. 初始化顺序:静态成员的初始化顺序与它们在类中的声明顺序一致,与对象的创建无关。
  5. 访问方式:静态成员可以通过对象访问,也可以直接通过类名和作用域限定符访问。

特殊的成员函数

除了特殊的数据成员以外, C++ 类中还有两种特殊的成员函数:静态成员函数和 const 成员函数。

静态成员函数

静态成员函数是类的一部分,但它不依赖于类的具体对象实例。它可以在没有创建类的对象的情况下被调用。

特点:

  1. 不依赖于对象:静态成员函数不依赖于某个具体的对象实例。
  2. 调用方式:可以通过对象调用,但更常见的是通过类名加上作用域限定符调用。
  3. this 指针:静态成员函数没有 this 指针,因此不能访问非静态成员。
  4. 访问限制:只能访问静态数据成员或调用静态成员函数。

以下是 Computer 类包含静态成员函数的示例:

#include <iostream>
#include <cstring>

class Computer {
 public:
  Computer(const char* brand, double price) : _price(price) {
    _brand = new char[strlen(brand) + 1];
    strcpy(_brand, brand);
    _totalPrice += _price;  // 累计总价
  }

  ~Computer() {
    delete[] _brand;  // 释放品牌名占用的内存
  }

  // 静态成员函数
  static void printTotalPrice() {
    std::cout << "Total price: " << _totalPrice << std::endl;
    // std::cout << _price << std::endl; // 错误:无法访问非静态成员
  }

 private:
  char* _brand;
  double _price;
  static double _totalPrice;  // 静态数据成员
};

double Computer::_totalPrice = 0;  // 静态成员初始化

int main() {
  Computer pc1("Dell", 1200.0);
  Computer pc2("HP", 1500.0);
  Computer::printTotalPrice();  // 通过类名调用静态成员函数
  return 0;
}

注意事项:

  • 构造函数、析构函数、拷贝构造函数、赋值运算符:这些特殊的成员函数不能被声明为静态,因为它们需要访问和操作对象的非静态成员。
  • 访问非静态成员:静态成员函数无法直接访问非静态成员,因为它们没有 this 指针。
  • 访问静态成员:静态成员函数可以访问静态数据成员和调用静态成员函数。

const 成员函数

在 C++ 类中,成员函数可以通过在函数声明末尾添加 const 关键字来声明为 const 成员函数。这表明该成员函数不会修改类的任何成员变量(除了那些用 mutable 修饰的成员)。

class Computer {
 public:
  // ...
  void print() const {
    cout << "Brand: " << _brand << endl;
    cout << "Price: " << _price << endl;
  }
  // ...
 private:
  char* _brand;
  double _price;
};

特点:

  1. 不修改成员变量const 成员函数保证不会修改对象的非 mutable 成员变量。
  2. this 指针:在 const 成员函数中,this 指针被类型为 const ClassName* const,这意味着你不能通过 this 指针来修改对象的任何成员。
  3. 重载决策const 和非 const 成员函数可以作为重载的一部分,使得同一个函数名可以用于对象的 const 和非 const 上下文。
  4. 常量对象调用:只有 const 成员函数才能在常量对象上被调用。

以下是 const 成员函数的示例:

#include <iostream>
#include <cstring>

class Computer {
 public:
  Computer(const char* brand, double price) : _brand(new char[strlen(brand) + 1]), _price(price) {
    strcpy(_brand, brand);
  }

  ~Computer() {
    delete[] _brand;
  }

  // const 成员函数
  void print() const {
    std::cout << "Brand: " << _brand << std::endl;
    std::cout << "Price: " << _price << std::endl;
  }

 private:
  char* _brand;
  double _price;
};

int main() {
  const Computer pc("Dell", 1200.0);
  pc.print();  // 正确:可以在const对象上调用
  // pc.modify(); // 错误:不能在const对象上调用非const成员函数
  return 0;
}

注意事项:

  • mutable 关键字:虽然 const 成员函数不能修改非 mutable 成员变量,但可以通过 mutable 关键字修饰的成员变量来实现一些必要的修改。
  • const 对象const 成员函数可以在 const 对象上调用,这对于保证对象状态不被改变是有用的。

对象的组织

有了自己定义的类,或者使用别人定义好的类创建对象,其机制与使用内置类型创建普通变量几乎完全一致,同样可以创建 const 对象、创建指向对象的指针、创建对象数组,还可使用 new(delete) 来创建(回收)堆对象。

const 对象

在 C++中,const 对象是其状态(数据成员)不能被修改的对象。这意味着只有 const 成员函数才能在 const 对象上被调用。

class Point {
 public:
  Point(int x, int y) : _x(x), _y(y) {}

  void print() const {
    std::cout << "(" << _x << ", " << _y << ")" << std::endl;
  }

  // 非const成员函数
  void move(int x, int y) {
    _x = x;
    _y = y;
  }

 private:
  int _x, _y;
};

int main() {
  const Point pt(1, 2);
  pt.print();  // 正确:const 成员函数可以在 const 对象上调用
  // pt.move(); // 错误:非 const 成员函数不能在 const 对象上调用
  return 0;
}

const 对象与 const 成员函数的规则:

  1. 重载决策const 对象只能调用 const 成员函数,非 const 对象可以调用 const 或非 const 成员函数。
  2. 单一 const 函数:如果类中只有一个 const 成员函数,它既可以被 const 对象也可以被非 const 对象调用。
  3. 单一非 const 函数:如果类中只有一个非 const 成员函数,它不能被 const 对象调用。

一个类中可以有参数形式“完全相同”的两个成员函数(const 版本与非 const 版本),既然没有报错重定义,那么它们必然是构成了重载,为什么它们能构成重载呢?

即使两个成员函数的参数类型完全相同,它们也可以通过 this 指针的 const 性质来构成重载。const 成员函数的 this 指针是 const 的,而非 const 成员函数的 this 指针不是 const 的。

const 成员函数中不允许修改数据成员,const 数据成员初始化后不允许修改,其效果是否相同?

const 数据成员与 const 成员函数

  • const 数据成员:一旦初始化后就不能再被修改。

  • const 成员函数:不能修改对象的非 mutable 数据成员。

对于指针类型的数据成员,在 const 成员函数中的限制如下:

  • const int* p:不能通过 p 修改指向的值,也不能让 p 指向别的地址(即不能修改 p 的值)。

  • int* p:在 const 成员函数中,不能让 p 指向别的地址,但可以修改 p 指向的值。

class Point {
 public:
  Point(int x, int y) : _x(x), _y(y), _pX(&_x), _pY(&_y) {}

  // const 成员函数
  void print() const {
    std::cout << "(" << *_pX << ", " << *_pY << ")" << std::endl;
    // *_pX = 10; // 错误:不能修改指向的值
    // _pX = &_y;   // 错误:不能修改指针的指向
  }

  // 非 const 成员函数
  void move(int x, int y) {
    *_pX = x;  // 正确:可以修改指向的值
    _pX = &_y; // 正确:可以修改指针的指向
  }

 private:
  int _x, _y;
  int* _pX;
  int* _pY;
};

int main() {
  const Point pt(1, 2);
  pt.print();  // 正确:const成员函数可以在const对象上调用
  // pt.move(); // 错误:非const成员函数不能在const对象上调用
  return 0;
}

指向对象的指针

在 C++中,通过指向对象的指针来调用对象的成员函数是一个常见的操作。使用指针调用成员函数时,可以使用两种常见的语法:-> 操作符和 * 操作符。

#include <iostream>

class Point {
 public:
  Point(int x, int y) : _x(x), _y(y) {}

  void print() const {
    std::cout << "(" << _x << ", " << _y << ")" << std::endl;
  }

 private:
  int _x, _y;
};

int main() {
  Point pt(1, 2);
  Point *p1 = nullptr;
  Point *p2 = &pt;
  Point *p3 = new Point(3, 4);

  if (p1 == nullptr) {
    std::cout << "p1 is null\n";
  } else {
    p1->print();  // 尝试调用,但 p1 是 nullptr,这里会导致运行时错误
  }

  p2->print();  // 正确调用
  (*p2).print();  // 等价的调用方式

  delete p3;  // 清理动态分配的内存
  return 0;
}

指针调用成员函数的两种方式

  1. 使用 -> 操作符
    • 这是最常用的方式,直接使用 -> 操作符来访问指针指向的对象的成员。
    • 示例:p2->print();
  2. 使用解引用操作符 *
    • 先解引用指针,然后使用 . 操作符访问成员。
    • 示例:(*p2).print();

对象数组

对象数组的声明和初始化与普通数组类似,但每个数组元素都是类的对象。对象数组可以在声明时初始化,也可以稍后初始化。

声明对象数组:

Point pts[2];  // 声明一个包含两个Point对象的数组

在这种情况下,如果类有默认构造函数,将调用默认构造函数来初始化数组的每个元素。

初始化对象数组可以在声明数组时初始化数组元素:

Point pts[2] = {Point(1, 2), Point(3, 4)};  // 使用构造函数初始化数组元素

如果数组的初始化元素少于数组的大小,剩余的元素将使用默认构造函数进行初始化。

Point pts[5] = {Point(1, 2), Point(3, 4)};  // 剩余的元素使用默认构造函数初始化

使用对象数组可以通过下标操作符 [] 来访问数组中的元素,并调用其成员函数:

pts[0].print();  // 调用数组第一个元素的print函数
pts[1].print();  // 调用数组第二个元素的print函数

也可以使用指针运算来访问数组元素:

pts->print();  // 等同于 pts[0].print()
(pts + 1)->print();  // 等同于 pts[1].print()

以下是使用对象数组的完整示例:

#include <iostream>

class Point {
 public:
  Point(int x = 0, int y = 0) : _x(x), _y(y) {}

  void print() const {
    std::cout << "(" << _x << ", " << _y << ")" << std::endl;
  }

 private:
  int _x, _y;
};

int main() {
  Point pts[3] = {Point(1, 2), Point(3, 4)};  // 初始化前两个元素,第三个使用默认构造函数
  pts[0].print();  // 输出: (1, 2)
  pts[1].print();  // 输出: (3, 4)
  pts[2].print();  // 输出: (0, 0),因为第三个元素只进行了默认构造

  // 使用指针访问
  pts->print();  // 输出: (1, 2)
  (&pts[2])->print();  // 输出: (0, 0)

  return 0;
}

注意事项:

  • 默认构造函数:如果类没有默认构造函数,那么在声明数组时必须提供足够的构造参数来初始化每个元素。
  • 数组大小:在初始化数组时,如果初始化器的数量少于数组的大小,剩余的元素将使用默认构造函数进行初始化。
  • 访问方式:可以通过下标或指针运算来访问数组元素。

堆对象

在 C++中,使用 newdelete 操作符可以在堆(动态存储区)上分配和释放对象的内存。这种方式提供了更大的灵活性,特别是在对象大小未知或需要动态管理内存时。

单个对象的动态分配:

Point *pt1 = new Point(11, 12); // 动态分配一个Point对象
pt1->print();                   // 调用对象的成员函数
delete pt1;                     // 释放分配的内存
pt1 = nullptr;                  // 将指针置为nullptr,防止悬挂指针

对象数组的动态分配:

Point *pt2 = new Point[5]; // 动态分配一个包含5个Point对象的数组
pt2->print();              // 调用数组第一个对象的成员函数
(pt2 + 1)->print();        // 调用数组第二个对象的成员函数
delete [] pt2;             // 释放分配的内存
pt2 = nullptr;             // 将指针置为nullptr,防止悬挂指针

注意事项:

  1. 匹配 newdelete:使用 new 分配的单个对象应该用 delete 释放,使用 new[] 分配的对象数组应该用 delete[] 释放。
  2. 空指针检查:在释放内存后,应将指针置为 nullptr,以避免悬挂指针问题。
  3. 构造函数和析构函数:动态分配的对象在分配时会调用构造函数,在释放时会调用析构函数。

以下是使用堆对象的完整示例:

#include <iostream>

class Point {
 public:
  Point(int x = 0, int y = 0) : _x(x), _y(y) {}

  void print() const {
    std::cout << "(" << _x << ", " << _y << ")" << std::endl;
  }

 private:
  int _x, _y;
};

int main() {
  Point *pt1 = new Point(11, 12); // 动态分配一个Point对象
  pt1->print();                   // 输出: (11, 12)
  delete pt1;                     // 释放分配的内存
  pt1 = nullptr;                  // 将指针置为nullptr

  Point *pt2 = new Point[5];      // 动态分配一个包含5个Point对象的数组
  pt2->print();                   // 输出: (0, 0),使用默认构造函数
  (pt2 + 1)->print();             // 输出: (0, 0),使用默认构造函数
  delete[] pt2;                  // 释放分配的内存
  pt2 = nullptr;                  // 将指针置为nullptr

  return 0;
}

new/delete 表达式的工作步骤

new 表达式的三个工作步骤

当在 C++中使用 new 表达式来创建一个自定义类型的对象时,会发生以下三个步骤:

  1. 申请未类型化的空间
    • 首先,调用 operator new 函数来分配足够的未初始化内存以存储对象。这个函数是标准库的一部分,它返回一个指向分配的内存的指针,该内存的大小足以容纳对象,但不包含对象的构造。
    • operator new 通常接受所需内存的大小(以字节为单位)作为参数,并返回一个 void* 类型的指针。
  2. 调用构造函数初始化对象
    • 一旦内存被分配,就会在这块内存上调用对象的构造函数来初始化对象。构造函数会根据其参数(如果有的话)来初始化对象的成员变量。
    • 这个步骤实际上是对象的构造过程,它确保对象在被使用前处于正确和一致的状态。
  3. 返回指向对象的指针
    • 构造函数调用完成后,new 表达式会返回一个指向新创建并初始化的对象的指针。
    • 这个指针是类型安全的,即它的类型与被创建的对象的类型相匹配。

delete 表达式的两个工作步骤

当在 C++中使用 delete 表达式来释放一个自定义类型的对象时,会发生以下两个步骤:

  1. 调用析构函数
    • 首先,调用对象的析构函数来释放对象所占用的资源,包括动态分配的内存(堆空间)。
    • 析构函数负责执行任何必要的清理工作,以确保对象被销毁时不会留下未释放的资源。
  2. 回收对象所在的空间
    • 析构函数调用完成后,delete 表达式会调用 operator delete 来释放对象所在的内存空间。
    • operator delete 是一个标准库函数,它通常与 operator new 配对使用,用于释放之前通过 new 分配的内存。

以下是使用 delete 表达式释放对象的完整示例:

#include <iostream>
#include <cstring>

class Student {
  public:
  Student(int id, const char *name)
    : _id(id), _name(new char[strlen(name) + 1]()) {
      strcpy(_name, name);
      std::cout << "Student constructor called.\n";
    }

  ~Student() {
    std::cout << "~Student() called.\n";
    if (_name) {
      delete[] _name;
      _name = nullptr;
    }
  }

  void *operator new(size_t sz) {
    std::cout << "operator new called.\n";
    void *ret = malloc(sz);
    return ret;
  }

  void operator delete(void *pointer) {
    std::cout << "operator delete called.\n";
    free(pointer);
  }

  void print() const {
    std::cout << "ID: " << _id << "\n"
      << "Name: " << _name << std::endl;
  }

  private:
  int _id;
  char *_name;
};

int main() {
  Student *stu = new Student(100, "Jackie");
  stu->print();
  delete stu;
  return 0;
}

输出结果:

Student constructor called.
operator new called.
ID: 100
Name: Jackie
~Student() called.
operator delete called.

注意事项

  • 匹配 newdelete:使用 new 分配的对象应该使用 delete 来释放,使用 new[] 分配的对象数组应该使用 delete[] 来释放。
  • 自定义内存管理:可以为类自定义 operator newoperator delete,以实现特定的内存管理策略。
  • 析构函数:析构函数负责释放对象持有的资源,并执行清理工作。

image-20240517173347739.png

创建对象的条件

在 C++中,创建对象(无论是栈对象还是堆对象)需要满足特定的条件。

创建堆对象的条件

为了能够创建堆对象,类需要满足以下条件:

  1. 公有的 operator new:必须能够访问类的 operator new 函数来分配内存。如果这个函数是私有的,那么无法在类外部使用 new 表达式来创建对象。
  2. 公有的 operator delete:必须能够访问类的 operator delete 函数来释放内存。如果这个函数是私有的,那么无法在类外部使用 delete 表达式来释放对象。
  3. 可访问的构造函数:必须有一个可访问的构造函数来初始化对象。
  4. 析构函数:析构函数在销毁对象时被调用,无论它是公有的还是私有的。

创建栈对象的条件

为了能够创建栈对象,类需要满足以下条件:

  1. 可访问的构造函数:必须有一个可访问的构造函数来初始化对象。
  2. 可访问的析构函数:析构函数在对象生命周期结束时被调用,必须能够访问。

示例代码

#include <iostream>
#include <cstdlib>

class Student {
 public:
  Student(int id, const char* name) : _id(id) {
    _name = new char[strlen(name) + 1];
    strcpy(_name, name);
  }

  ~Student() {
    std::cout << "~Student() called.\n";
    delete[] _name;
  }

  // 禁止生成堆对象
  void* operator new(size_t) = delete;
  void operator delete(void*) = delete;

  // 禁止生成栈对象
  // ~Student() = delete; // 将析构函数设为删除,禁止生成栈对象

  void print() const {
    std::cout << "ID: " << _id << "\n"
      << "Name: " << _name << std::endl;
  }

 private:
  int _id;
  char* _name;
};

int main() {
  Student s1(1, "Jackie"); // 栈对象
  s1.print();

  // Student* s2 = new Student(2, "Tom"); // 错误:禁止生成堆对象
  // delete s2;

  return 0;
}

总结:

  • 创建堆对象:需要公有的 operator newoperator delete,以及可访问的构造函数。
  • 创建栈对象:需要可访问的构造函数和析构函数。
  • 限制条件:可以通过将 operator newoperator delete 或析构函数设为私有或删除来限制对象的创建方式。
  • 默认行为:通常情况下,不需要特别定义 operator newoperator delete,使用默认行为即可。

单例模式

单例模式是 23 种常用设计模式中最简单的设计模式之一,它提供了一种创建对象的方式,确保只有单个对象被创建。这个设计模式主要目的是想在整个系统中只能出现类的一个实例,即一个类只有一个对象。

将单例对象创建在静态区

单例模式确保一个类只有一个实例,并提供一个全局访问点。在 C++中,可以通过以下步骤实现单例模式:

  1. 私有构造函数:将构造函数声明为私有,防止外部通过构造函数创建对象。
  2. 静态成员函数:提供一个公有的静态成员函数,用于获取类的唯一实例。
  3. 局部静态对象:在静态成员函数中创建一个局部静态对象,利用静态局部变量的生命周期和初始化特性保证对象的唯一性和线程安全。
  4. 返回引用:静态成员函数返回类实例的引用,避免对象的不必要复制。

以下是使用静态存储区实现单例模式的示例:

#include <iostream>

class Point {
 public:
  // 禁止外部通过 new 创建对象
  static Point& getInstance() {
    // 当静态函数多次被调用
    // 静态的局部对象只会被初始化一次
    // 第一次调用时,静态对象会被初始化为一个对象实例
    // 后续的调用中,静态局部对象已经存在,不会再初始化
    // 而是直接返回已经初始化的对象实例
    static Point instance(1, 2); // 局部静态对象
    return instance;
  }

  void print() const {
    std::cout << "(" << _ix << ", " << _iy << ")" << std::endl;
  }

 private:
  // 私有构造函数
  Point(int x, int y) : _ix(x), _iy(y) {
    std::cout << "Point(int,int)" << std::endl;
  }

  int _ix;
  int _iy;
};

int main() {
  Point& pt = Point::getInstance();
  pt.print();

  Point& pt2 = Point::getInstance();
  pt2.print();

  std::cout << &pt << std::endl;
  std::cout << &pt2 << std::endl;

  return 0;
}

输出:

Point(int,int)
(1, 2)
(1, 2)
0x7ffeedf6e9c0
0x7ffeedf6e9c0

注意事项:

  • 内存压力:如果单例对象占用大量内存,可能会对静态存储区造成压力。在这种情况下,可以考虑使用其他方法,如动态内存分配。
  • 线程安全:示例中的实现在 C++11 及更高版本中是线程安全的,因为局部静态变量的初始化是线程安全的。
  • 销毁时机:静态存储区的对象在程序结束时销毁,可能需要手动管理资源释放。

将单例对象创建在堆区

  1. 私有构造函数:确保不能在类外部直接创建对象。
  2. 静态成员函数 getInstance:负责创建和管理单例对象的生命周期,返回指向单例对象的指针。
  3. 静态成员函数 releaseInstance:负责释放单例对象占用的内存。
  4. 私有析构函数:确保对象不能在类外部被销毁。
  5. 删除拷贝构造函数和赋值运算符:确保单例对象不能被复制。

示例代码:

#include <iostream>

class Point {
 public:
  // 获取单例对象的引用
  static Point *getInstance() {
    if (_instance == nullptr) {
      _instance = new Point(1, 2);
    }
    return _instance;
  }

  // 释放单例对象
  static void destroy() {
    delete _instance;
    _instance = nullptr;
  }
  void init(int x, int y) {
    _ix = x;
    _iy = y;
  }
  void print() const {
    std::cout << "(" << this->_ix << ", " << this->_iy << ")" << std::endl;
  }

 private:
  // 私有构造函数
  Point(int x, int y) : _ix(x), _iy(y) {
    std::cout << "Point(int,int)" << std::endl;
  }

  // 私有析构函数
  ~Point() {
    std::cout << "~Point()" << std::endl;
  }

  Point() = default;
  // 删除拷贝构造函数和赋值运算符
  Point(const Point &rhs) = delete;
  Point &operator=(const Point &rhs) = delete;

  int _ix;
  int _iy;

  // 单例对象指针
  static Point *_instance;
};

Point *Point::_instance = nullptr;

int main() {
  // 规范用法
  Point::getInstance()->init(21, 83);
  Point::getInstance()->print();
  Point::getInstance()->init(2, 8);
  Point::getInstance()->print();

  // 即使多次调用 destroy 函数也不会有 double free
  Point::destroy();
  Point::destroy();
  Point::destroy();
  Point::destroy();
  return 0;
}

输出:

Point(int,int)
(21, 83)
(2, 8)
~Point()

注意事项:

  • 内存管理:确保在程序适当的位置调用 releaseInstance 以释放单例对象占用的内存,避免内存泄漏。
  • 线程安全:在多线程环境中,需要添加额外的同步机制来确保 getInstance 的线程安全。
  • 对象复制:通过删除拷贝构造函数和赋值运算符,确保单例对象不能被复制。

undefined202403081817230.png

image-20240423175410103.png

单例对象的数据成员申请堆空间

  1. 私有构造函数和析构函数:防止外部构造和销毁对象。
  2. 静态实例指针:用于持有单例对象。
  3. getInstance 方法:用于获取单例对象的引用,如果对象不存在则创建它。
  4. destroy 方法:用于手动销毁单例对象,释放资源。

执行 destroy 函数的回收顺序

image-20240518113329654.png
要注意 init 函数中也需要考虑回收 _brand 原本管理的堆空间

image-20240518114114806.png

示例代码

#include <cstring>
#include <iostream>
using std::cout;
using std::endl;

class Computer {
 public:
  static Computer *getInstance() {
    if (nullptr == _pInstance) {
      _pInstance = new Computer("huawei", 8000);
    }
    return _pInstance;
  }

  static void destroy() {
    if (_pInstance) {
      delete _pInstance;
      _pInstance = nullptr;
      cout << ">> delete heap" << endl;
    }
  }

  void init(const char *brand, double price) {
    /* release(); */
    delete[] _brand;
    _brand = new char[strlen(brand) + 1]();
    strcpy(_brand, brand);
    _price = price;
  }

  void print() {
    cout << "brand:" << _brand << endl;
    cout << "price:" << _price << endl;
  }

 private:
  Computer(const char *brand, double price)
    : _brand(new char[strlen(brand) + 1]()), _price(price) {
      cout << "Computer(const char *,double)" << endl;
      strcpy(_brand, brand);
    }

  ~Computer() {
    cout << "~Computer()" << endl;
    release();
  }

  void release() {
    if (_brand) {
      delete[] _brand;
      _brand = nullptr;
    }
  }

  Computer(const Computer &rhs) = delete;
  Computer &operator=(const Computer &rhs) = delete;
  private:
  char *_brand;
  double _price;
  static Computer *_pInstance;
};
Computer *Computer::_pInstance = nullptr;

int main() {
  Computer::getInstance()->init("xiaomi", 6000);
  Computer::getInstance()->print();
  Computer::getInstance()->init("apple", 18000);
  Computer::getInstance()->print();
  Computer::destroy();
  return 0;
}

输出

Computer(const char *,double)
brand:xiaomi
price:6000
brand:apple
price:18000
~Computer()
>> delete heap

注意事项

  • 资源管理:在 init 方法中,确保在分配新的堆空间前释放已有的资源,避免内存泄漏。
  • 销毁顺序:在 destroy 方法中,确保先释放对象占用的资源,然后销毁对象本身。
  • 线程安全:在多线程环境中,需要添加锁来保证 getInstancedestroy 方法的线程安全性。

单例模式的应用场景

  1. 频繁实例化和销毁:当对象的创建和销毁成本较高,且在程序中需要多次使用时,单例模式可以避免重复创建和销毁的开销。
  2. 资源密集型对象:对于初始化需要大量资源的对象,如加载大型数据集或初始化复杂计算环境,单例模式可以确保这些资源只被加载一次。
  3. 全局资源管理:对于那些本质上就应该是唯一的资源,如配置文件、日志记录器、网络连接等,单例模式提供了一种简单有效的管理方式。
  4. 配置文件管理:配置文件通常包含程序运行所需的设置,使用单例模式可以确保配置的一致性和同步访问。
  5. 线程池管理:在多线程程序中,线程池用于管理和复用线程,减少创建和销毁线程的开销。单例模式确保整个程序中只有一个线程池实例。
  6. GUI 应用程序中的全局状态管理:GUI 程序中经常需要管理用户会话、应用设置等全局状态。单例模式可以确保这些状态的一致性和可访问性。

C++ 字符串

字符串处理在程序中应用广泛, C 风格字符串是以 '\0' (空字符)来结尾的字符数组,在 C++ 中通常用 const char * 表示,用 "" 包括的认为是 C 风格字符串。

对字符串进行操作的 C 函数定义在头文件 <string.h><cstring> 中。常用的函数如下:

//字符检查函数(非修改式操作)
size_t strlen(const char *str);//返回str的长度,不包括null结束符
//比较lhs和rhs是否相同。lhs等于rhs,返回0; lhs大于rhs,返回正数; lhs小于rhs,返回负数
int strcmp(const char *lhs, const char *rhs);
int strncmp(const char *lhs, const char *rhs, size_t count);
//在str中查找首次出现ch字符的位置;查找不到,返回空指针
char *strchr(const char *str, int ch);
//在str中查找首次出现子串substr的位置;查找不到,返回空指针
char *strstr(const char* str, const char* substr);
//字符控制函数(修改式操作)
char *strcpy(char *dest, const char *src);//将src复制给dest,返回dest
char *strncpy(char *dest, const char *src, size_t count);
char *strcat(char *dest, const char *src);//concatenates two strings
char *strncat(char *dest, const char *src, size_t count);

在使用时,程序员需要考虑字符数组大小的开辟,结尾空字符的处理,使用起来有诸多不便。

void test0()
{
    char str[] = "hello,";
    char * pstr = "world";
    //求取字符串长度
    printf("%d\n", strlen(str));

    //字符串拼接
    char * ptmp = (char*)malloc(strlen(str) + strlen(pstr) + 1);
    strcpy(ptmp, str);
    strcat(ptmp, pstr);
    printf("%s\n", ptmp);

    //查找子串
    char * p1 = strstr(ptmp, "world");
    free(ptmp);
}

std::string 是 C++标准库中的一个类,用于处理字符串。它定义在头文件 <string> 中,提供了一种安全、方便的字符串操作方式,与 C 风格的字符串相比,具有很多优点。

std::string 的特点:

  1. 自动内存管理std::string 自动管理内存,无需担心内存分配和释放。
  2. 异常安全std::string 的操作通常是异常安全的,即使在抛出异常的情况下,也不会导致内存泄漏。
  3. 功能丰富:提供了大量的成员函数,可以方便地进行字符串的各种操作,如连接、比较、查找、替换等。
  4. 类型安全:避免了 C 风格字符串的类型不安全问题。`

std::string 实际上是 std::basic_string<char> 的别名,后者是一个模板类,可以用于创建不同字符类型的字符串(如 wchar_t 用于宽字符字符串)。std::string 专门用于处理以 char 为元素的字符串。

std::string 的构造

basic_string 的常用构造

basic_string(); //无参构造

basic_string( size_type count,
              CharT ch,
              const Allocator& alloc = Allocator() );  //count + 字符

basic_string( const basic_string& other,
              size_type pos,
              size_type count,
              const Allocator& alloc = Allocator() ); //接收一个basic_string对象

basic_string( const CharT* s,
              size_type count,
              const Allocator& alloc = Allocator() ); //接收一个C风格字符串

basic_string 是一个模板类,它是 std::string 的基类。

在创建字符串对象时,我们可以直接使用 std::string 作为类名,如 std::string str = "hello". 这是因为 C++ 标准库已经为我们定义了 std:: string 这个类型的别名。

std::string 类提供了多种构造函数,使得在不同场景下创建字符串对象变得非常方便。

  1. 无参构造函数
    std::string();
    

    创建一个空的字符串。

  2. 重复字符构造函数

    std::string(size_t count, char ch);
    

    创建一个字符串,包含指定数量的重复字符。

  3. 从 C 风格字符串构造

    std::string(const char* s);
    std::string(const char* s, size_t count);
    

    从 C 风格字符串创建一个 std::string 对象。第二个版本只使用字符串的前 count 个字符。

  4. 拷贝构造函数

    std::string(const std::string& str);
    

    使用另一个 std::string 对象来初始化新对象。

  5. 子字符串构造

    std::string(const std::string& str, size_t pos, size_t count);
    

    从另一个 std::string 对象的指定位置开始,提取指定数量的字符创建新字符串。

  6. 从迭代器范围构造

    std::string(std::string::iterator first, std::string::iterator last);
    

    从给定的迭代器范围 [first, last) 创建字符串。

以下是使用 std::string 的构造函数的示例:

#include <iostream>
#include <string>

int main() {
  // 无参构造
  std::string str1;

  // 从 C 风格字符串构造
  std::string str2("Hello");

  // 从 C 风格字符串的子串构造
  std::string str3("Hello, World!", 7, 5); // "World"

  // 重复字符构造
  std::string str4(10, 'A'); // "AAAAAAAAAA"

  // 拷贝构造
  std::string str5 = str2;

  // 子字符串构造
  std::string str6 = str2.substr(0, 5); // "Hello"

  // 拼接构造
  std::string str7 = str5 + ", " + str3; // "Hello, World"

  // 迭代器构造
  char arr[] = "Hello";
  std::string str8(arr, arr + 5);
  // begin() 函数返回首迭代器
  // end() 函数返回尾迭代器
  std::string::iterator it1 = str8.begin();
  auto it2 = str8.end();
  std::string str9(it1, --it2);

  // 打印所有字符串
  std::cout << str1 << std::endl; // ""
  std::cout << str2 << std::endl; // "Hello"
  std::cout << str3 << std::endl; // "World"
  std::cout << str4 << std::endl; // "AAAAAAAAAA"
  std::cout << str5 << std::endl; // "Hello"
  std::cout << str6 << std::endl; // "Hello"
  std::cout << str7 << std::endl; // "Hello, World"
  std::cout << str8 << std::endl; // "Hello"
  std::cout << str9 << std::endl; // "Hell"

  return 0;
}

std::string 的常用操作

std::string 提供了丰富的成员函数,用于执行各种字符串操作。

  1. 获取字符串数据
    const char* data() const;
    const char* c_str() const; // 获取出 C++ 字符串保存的字符串内容,以 C 风格字符串作为返回值
    
    • data() 返回一个指向字符串数据的指针,不以空字符终止。
    • c_str() 返回一个以空字符终止的 C 风格字符串。
  2. 判断字符串是否为空
    bool empty() const; // 判空
    
  3. 获取字符串长度
    size_type size() const; // 获取字符数
    size_type length() const;
    
  4. 追加字符或字符串
    void push_back(char ch);
    basic_string &append(const basic_string &str);  // 添加字符串
    basic_string &append(const char* s); // 添加 C 风格字符串
    basic_string &append(size_type count, CharT ch); // 添加count 个字符
    basic_string &append(const basic_string &str, size_type pos, size_type count);  // 从原字符串末尾添加 str 从 pos 位置的count个字符
    
  5. 查找子串或字符
    size_type find(const basic_string& str, size_type pos = 0) const; // 从 C++ 字符串的 pos 位开始查找 C++ 字符串 str
    size_type find(char ch, size_type pos = 0) const; // 从 C++ 字符串的 pos 位开始查找字符 ch
    size_type find(const char* s, size_type pos, size_type count) const;  // 从 C++ 字符串的 pos 位开始,去查找 C 字符串的前 count 个字符
    

以下是使用 std::string 操作的示例:

#include <iostream>
#include <string>

int main() {
  std::string str1 = "Hello";
  std::string str2 = " World";
  std::string str3;

  // 追加字符串
  str3.append(str1);
  str3.append(", ");
  str3.append(str2);
  std::cout << "Concatenated: " << str3 << std::endl;

  // 判断字符串是否为空
  std::cout << "Is str3 empty? " << (str3.empty() ? "Yes" : "No") << std::endl;

  // 获取字符串长度
  std::cout << "Length of str3: " << str3.size() << std::endl;

  // 查找子串
  std::size_t pos = str3.find(str2);
  std::cout << "Found '" << str2 << "' at position: " << pos << std::endl;

  // 获取C风格字符串
  const char* cstr = str3.c_str();
  std::cout << "C-style string: " << cstr << std::endl;

  return 0;
}

输出

Concatenated: Hello, World
Is str3 empty? No
Length of str3: 13
Found ' World' at position: 6
C-style string: Hello, World

std::string 重载了比较运算符,可以直接使用 ==, !=, <, >, <=, >= 等运算符进行字符串比较。

std::string 的遍历方法

std::string 可以被视为一个字符容器,提供了多种方式来遍历其存储的字符。

  1. 通过下标遍历
    使用下标操作符 [] 可以访问字符串中的每个字符。这种方法简洁直观,但需要确保索引有效,否则可能导致未定义行为。

    std::string str("hello");
    for (size_t idx = 0; idx < str.size(); ++idx) {
     std::cout << str[idx] << " ";
    }
    std::cout << std::endl;
    
  2. 使用 at() 函数遍历
    at() 函数提供了边界检查,如果索引超出字符串范围,会抛出 std::out_of_range 异常。

    try {
     std::cout << str.at(4) << std::endl;  // 输出 'o'
     std::cout << str.at(5) << std::endl;  // 抛出异常
    } catch (const std::out_of_range& e) {
     std::cout << "Exception: " << e.what() << std::endl;
    }
    
  3. 增强 for 循环(范围 for 循环)
    C++11 引入了范围 for 循环,它使得遍历容器更加简洁。

    for (char &ch : str) {
     // 使用引用符号对容器本体进行操作
     // 如果没有引用符号,操作就是对元素副本进行操作
     std::cout << ch << " ";
    }
    std::cout << std::endl;
    
  4. 迭代器遍历
    使用迭代器遍历字符串是一种更通用的方法,适用于所有支持迭代器的容器。

    for (std::string::iterator it = str.begin(); it != str.end(); ++it) {
     std::cout << *it << " ";
    }
    std::cout << std::endl;
    

    在 C++11 之后,也可以使用 auto 关键字和基于范围的 for 循环来简化迭代器的使用:

    for (auto it = str.begin(); it != str.end(); ++it) {
     std::cout << *it << " ";
    }
    std::cout << std::endl;
    

如指针一样,迭代器也有其固定的形式。

// 某容器的迭代器形式为 容器名 ::iterator
// 此处 auto 推导出来 it 的类型为 string::iterator
auto it = str.begin();
while(it != str.end()){
  cout << *it << " ";
  ++it;
}
cout << endl;

C++ 动态数组

std::vector 是 C++ 标准模板库(STL)中的一个序列容器,它封装了动态大小数组的实现,提供了以下特性:

  1. 动态大小
    • std::vector 能够根据元素的添加或删除自动调整其容量。
    • 当超出当前分配的内存时,std::vector 会分配一块更大的内存区域,并将现有元素复制到新内存区域,然后添加新元素。
  2. 动态插入和删除
    • std::vector 支持在容器的任意位置插入或删除元素。
    • 插入操作可能涉及将后续元素向后移动以腾出空间,并可能触发容量重新分配。
    • 删除操作涉及将后续元素向前移动以填补被删除元素留下的空位。
  3. 随机访问
    • std::vector 提供了随机访问迭代器,允许通过下标直接访问元素,提供了快速的索引访问能力。

std::vector 的常用构造函数

std::vector 是 C++ 标准模板库中的一个序列容器,提供了多种构造函数来初始化容器。

  1. 无参构造函数
    • 创建一个空的 std::vector,不包含任何元素。
    std::vector<int> numbers;
    
  2. 指定大小的构造函数
    • 创建一个 std::vector,包含指定数量的元素,每个元素初始化为该类型的默认值(如 int 的默认值为 0)。
    std::vector<long> numbers2(10); // 存放 10 个 `0`
    
  3. 指定大小和默认值的构造函数
    • 创建一个 std::vector,包含指定数量的元素,每个元素初始化为指定的值。
    std::vector<long> numbers3(10, 20); // 存放 10 个 `20`
    
  4. 列表初始化
    • 使用花括号 {} 直接指定 std::vector 中的元素。
    std::vector<int> number3{1, 2, 3, 4, 5, 6, 7};
    
  5. 迭代器范围构造函数
    • 使用两个迭代器(开始和结束迭代器)来初始化 std::vector,复制指定范围内的元素。
    std::vector<int> number4(number3.begin(), number3.end() - 3);
    // number4 中存储了 number3 中从开始到第四个元素之前的元素:{1, 2, 3, 4}
    

以下是使用 std::vector 构造函数的示例:

#include <iostream>
#include <vector>

int main() {
    // 无参构造
    std::vector<int> numbers;

    // 指定大小的构造
    std::vector<long> numbers2(10); // 存放 10 个 `0`

    // 指定大小和默认值的构造
    std::vector<long> numbers3(10, 20); // 存放 10 个 `20`

    // 列表初始化
    std::vector<int> number3{1, 2, 3, 4, 5, 6, 7};

    // 迭代器范围构造
    std::vector<int> number4(number3.begin(), number3.end() - 3);

    // 输出验证
    std::cout << "number4 contains:";
    for (int num : number4) {
        std::cout << " " << num;
    }
    std::cout << std::endl;

    return 0;
}

输出

number4 contains: 1 2 3 4

std::vector 的常用操作

std::vector 提供了一系列成员函数,用于管理容器的内容和状态。

  1. 迭代器访问
    iterator begin();  // 返回指向第一个元素的迭代器
    iterator end();    // 返回指向尾后位置(不是最后一个元素)的迭代器
    
  2. 判断容器状态
    bool empty() const;            // 判断容器是否为空
    size_type size() const;        // 返回容器中元素的数量
    size_type capacity() const;     // 返回容器分配的内存可以容纳的元素数量
    
  3. 元素添加和删除
    void push_back(const T& value); // 在容器末尾添加一个新元素
    void pop_back();               // 删除容器的最后一个元素
    void clear();                  // 删除容器中的所有元素
    void shrink_to_fit();          // 减少容器的容量以适应其大小
    
  4. 内存管理
    void reserve(size_type new_cap); // 改变容器的容量,通常用于优化性能
    

以下是使用 std::vector 操作的示例:

#include <iostream>
#include <vector>

int main() {
  std::vector<int> v;

  // 添加元素
  v.push_back(10);
  v.push_back(20);

  // 遍历
  std::cout << "Initial vector: ";
  for (int i = 0; i < v.size(); ++i) {
    std::cout << v[i] << " ";
  }
  std::cout << "\n";

  // 删除最后一个元素
  v.pop_back();

  // 遍历
  std::cout << "Vector after pop_back: ";
  for (int i = 0; i < v.size(); ++i) {
    std::cout << v[i] << " ";
  }
  std::cout << "\n";

  // 清空容器
  v.clear();

  // 检查是否为空
  if (v.empty()) {
    std::cout << "Vector is empty.\n";
  }

  return 0;
}

输出

Initial vector: 10 20 
Vector after pop_back: 10 
Vector is empty.

std::vector 的遍历

std::vector 可以使用迭代器、下标操作符、at() 成员函数和基于范围的 for 循环进行遍历。

std::vector<int> v = {1, 2, 3, 4, 5};
// 下标遍历
for (size_t idx = 0; idx < v.size(); ++idx) {
    std::cout << nums[idx] << " ";
}

// 使用迭代器遍历
for (std::vector<int>::iterator it = v.begin(); it != v.end(); ++it) {
  std::cout << *it << " ";
}

// 使用基于范围的 for 循环遍历
for (int val : v) {
  std::cout << val << " ";
}

std::vector 不仅可以存储基本数据类型,还可以存储自定义类型的对象、指针、其他容器等。

std::vector 的动态扩容机制

std::vector 容器中的元素数量达到当前容量时,如果继续添加新元素,容器会自动进行扩容。扩容过程一般包括以下步骤:

  1. 开辟新空间std::vector 会分配一块新的内存空间,这块新空间的容量通常是当前容量的两倍(不同编译器的实现可能有所不同,有的可能是 1.5 倍)。
  2. 分配内存:通过分配器(Allocator)分配新的空间。分配器负责内存的分配和释放。
  3. 复制元素:将现有元素从旧空间复制到新空间。
  4. 添加新元素:在新空间中添加新元素。
  5. 回收旧空间:释放旧空间,以避免内存泄漏。

undefined202403111142666.png

以下是演示 std::vector 动态扩容的示例:

#include <iostream>
#include <vector>

int main() {
  std::vector<int> numbers;

  std::cout << "初始大小: " << numbers.size() << ", 容量: " << numbers.capacity() << std::endl;

  numbers.push_back(1);
  std::cout << "添加一个元素后, 大小: " << numbers.size() << ", 容量: " << numbers.capacity() << std::endl;

  numbers.push_back(1);
  std::cout << "再添加一个元素后, 大小: " << numbers.size() << ", 容量: " << numbers.capacity() << std::endl;

  numbers.push_back(1);
  std::cout << "再添加一个元素后, 大小: " << numbers.size() << ", 容量: " << numbers.capacity() << std::endl;

  // 继续添加元素,观察容量的变化
  for (int i = 0; i < 10; ++i) {
    numbers.push_back(i + 4);
  }

  std::cout << "最终大小: " << numbers.size() << ", 容量: " << numbers.capacity() << std::endl;

  return 0;
}

输出

初始大小: 0, 容量: 0
添加一个元素后, 大小: 1, 容量: 1
再添加一个元素后, 大小: 2, 容量: 2
再添加一个元素后, 大小: 3, 容量: 4
最终大小: 13, 容量: 16

std::vector 的底层实现

在 C++ 的标准库中,std::vector 是一个动态数组容器,其底层实现主要依赖于三个指针来管理存储的元素。这三个指针分别是:

  1. _start
    • 指向当前数组中第一个元素存放的位置。
    • std::vector 被创建时,这个指针指向分配的内存的起始位置。
  2. _finish
    • 指向当前数组中最后一个元素存放的下一个位置。
    • 这个指针在元素被添加或删除时会更新,表示当前有效元素的结束位置。
  3. _end_of_storage
    • 指向当前数组能够存放元素的最后一个空间的下一个位置。
    • 这个指针用于管理内存的容量,当需要扩容时,std::vector 会使用这个指针来决定是否需要分配更多的内存。

image-20231009161203935.png

可以推导出:

  • size()
    • 通过计算 _finish - _start 来获取当前存储的元素数量。
  • capacity()
    • 通过计算 _end_of_storage - _start 来获取当前分配的内存容量。
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇