上一章 类与对象(1)-优快云博客
大致介绍了类的定义、实例化、this指针和c++对比c语言方便之处的小部分展示
1.类的默认成员函数
默认成员函数是指当用户没有显式实现时,编译器会自动生成的成员函数。如果用户自定义了这些成员函数,编译器将不会自动生成对应的默认成员函数。
一个类在用户没有显式实现时,编译器会默认生成以下 6 个默认成员函数。需要注意的是,这 6 个函数中最重要的是前 4 个,最后 2 个函数(取地址重载)相对不重要,我们稍微了解即可。此外,自 C++11 以后,编译器还可能生成两个新增的默认成员函数:移动构造函数和移动赋值运算符。这些新增函数的具体细节将在后续讲解中展开。
学习默认成员函数需要从两个方面着手:
- 编译器默认生成的函数的行为是什么?是否满足我们的需求?
- 如果默认生成的函数不满足需求,我们需要自己实现这些函数,那么应该如何正确实现?
2.构造函数
构造函数是类中特殊的成员函数。尽管名称中有“构造”,但构造函数的主要任务并不是为对象分配内存空间(通常情况下,局部对象的空间在栈帧创建时就已经分配好了)。它的核心职责是在对象实例化时对对象进行初始化。
构造函数的本质是取代我们在之前的 Stack
和 Date
类中手动编写的 Init
函数的功能。而且,构造函数具有自动调用的特点,这种机制可以完美替代手动调用 Init
函数的过程,提高了代码的简洁性和安全性。
构造函数的特点
构造函数有以下特点和规则:
- 函数名与类名相同:这是构造函数的基本特征。
- 无返回值:构造函数没有返回值类型,既不需要指定,也不能写
void
,这是 C++ 的明确规定。 - 自动调用:对象实例化时,系统会自动调用对应的构造函数。
- 支持重载:构造函数可以重载,因此可以定义多个构造函数,为对象提供多种初始化方法。
默认构造函数的相关规则:
- 编译器生成默认构造函数:如果类中没有显式定义构造函数,C++ 编译器会自动生成一个无参的默认构造函数。如果用户显式定义了构造函数,编译器就不再生成。
- 默认构造函数的分类:
- 无参构造函数。
- 全缺省构造函数(所有参数都有默认值的构造函数)。
- 编译器生成的构造函数。
这三种情况都称为默认构造函数,并且只能存在一个,不能同时出现。虽然无参构造函数和全缺省构造函数构成重载,但调用时可能会产生歧义,因此只能保留一个。
总结:只要构造函数在不传递实参时可以调用,就称为默认构造函数。
默认构造函数的行为:
- 内置类型成员变量:编译器生成的默认构造函数对内置类型成员变量没有强制初始化要求,初始化行为依赖于编译器,因此成员变量可能是未定义的随机值。
- 自定义类型成员变量:要求调用该成员变量的默认构造函数。如果自定义类型成员变量没有默认构造函数,编译器会报错。
初始化自定义类型的规则:
- 如果类中包含没有默认构造函数的自定义类型成员变量,则需要在构造函数中通过初始化列表对该成员变量进行显式初始化。
自定义类型和默认构造函数的注意事项:
- 如果自定义类型中没有显式定义默认构造函数,而使用的是编译器生成的默认构造函数,且类的成员变量是内置类型,那么这些成员变量不会被初始化,可能会是随机值。
这些规则总结为:默认构造函数负责对象初始化,但编译器生成的默认构造函数对内置类型不保证初始化,对自定义类型则要求调用其默认构造函数。如果初始化需求特殊,需要显式实现构造函数或使用初始化列表(将在后面中讲解)。
构造函数的分类
1.默认构造函数
- 定义:没有参数,或者所有参数都有默认值的构造函数。
- 特点:当用户没有显式定义构造函数时,编译器会默认生成一个默认构造函数。
class Example {
public:
Example() {
std::cout << "Default Constructor Called" << std::endl;
}
};
Example obj; // 调用默认构造函数
2.带参数的构造函数
- 定义:接受参数的构造函数,用于根据提供的值初始化对象。
- 特点:需要通过传参调用。
class Example {
public:
int x;
Example(int value) : x(value) {
std::cout << "Parameterized Constructor Called" << std::endl;
}
};
Example obj(10); // 调用带参数的构造函数
3.拷贝构造函数
- 定义:用于通过已有对象初始化新对象。
- 形式:
ClassName(const ClassName& obj);
- 特点:编译器会自动生成一个按成员逐一复制的默认拷贝构造函数。
class Example {
public:
int x;
Example(int value) : x(value) {}
Example(const Example& obj) {
x = obj.x;
std::cout << "Copy Constructor Called" << std::endl;
}
};
Example obj1(10);
Example obj2 = obj1; // 调用拷贝构造函数
4.移动构造函数(C++11 引入)
- 定义:通过移动语义将临时对象的资源转移到新对象中。
- 形式:
ClassName(ClassName&& obj);
- 特点:在对象的资源可以安全转移时,提升效率,避免不必要的复制。
class Example {
public:
int* data;
Example(int value) : data(new int(value)) {}
Example(Example&& obj) noexcept : data(obj.data) {
obj.data = nullptr; // 释放临时对象的资源
std::cout << "Move Constructor Called" << std::endl;
}
~Example() {
delete data;
}
};
Example obj1(10);
Example obj2 = std::move(obj1); // 调用移动构造函数
5.委托构造函数(C++11 引入)
- 定义:一个构造函数可以调用另一个构造函数,以减少代码重复。
class Example {
public:
int x, y;
Example(int value) : x(value), y(0) {}
Example() : Example(10) {} // 委托构造函数
};
Example obj; // 调用无参构造函数,进而委托到带参数构造函数
6.显式构造函数
- 定义:通过
explicit
关键字修饰的构造函数,禁止隐式转换。 - 特点:避免不必要的类型转换。
class Example {
public:
explicit Example(int value) {
std::cout << "Explicit Constructor Called" << std::endl;
}
};
Example obj1(10); // 正常
// Example obj2 = 10; // 错误:隐式转换被禁止
5和6大概了解有这个东西就好, 还有要注意下,如果类中涉及动态资源分配,建议显式定义拷贝构造函数和析构函数,避免浅拷贝导致的问题(如多次释放同一资源)。
额外说明下,在 C++ 中,类型可以分为两大类:内置类型和自定义类型。
- 内置类型(基本类型):由 C++ 语言本身提供的原生数据类型,例如
int
、char
、double
、指针等。- 自定义类型:由用户通过
class
、struct
等关键字定义的类型,是用户根据需求创建的自定义数据类型。
#include <iostream>
using namespace std;
class Date{
public:
// 1.⽆参构造函数
Date(){
_year = 1;
_month = 1;
_day = 1;
}
// 2.带参构造函数
Date(int year, int month, int day){
_year = year;
_month = month;
_day = day;
}
// 3.全缺省构造函数
/*Date(int year = 1, int month = 1, int day = 1){
_year = year;
_month = month;
_day = day;
}*/
void Print(){
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main(){
// 如果留下三个构造中的第⼆个带参构造,第⼀个和第三个注释掉
// 编译报错:error C2512: “Date”: 没有合适的默认构造函数可⽤
Date d1; // 调⽤默认构造函数
Date d2(2025, 1, 1); // 调⽤带参的构造函数
// 注意:如果通过⽆参构造函数创建对象时,对象后⾯不⽤跟括号,否则编译器⽆法
// 区分这⾥是函数声明还是实例化对象
// warning C4930: “Date d3(void)”: 未调⽤原型函数(是否是有意⽤变量定义的?)
Date d3();
d1.Print();
d2.Print();
return 0;
}
#include<iostream>
using namespace std;
// 定义STDataType为int类型的别名
typedef int STDataType;
// 定义Stack类,模拟栈结构
class Stack {
public:
// 构造函数,默认容量为4
Stack(int n = 4) {
// 动态分配数组空间
_a = (STDataType*)malloc(sizeof(STDataType) * n);
if (nullptr == _a) {
perror("malloc申请空间失败");
return;
}
// 初始化容量和栈顶指针
_capacity = n;
_top = 0;
}
// 析构函数,释放分配的内存
~Stack() {
free(_a);
}
private:
STDataType* _a; // 存储栈数据的数组
size_t _capacity; // 栈的容量
size_t _top; // 栈顶指针
};
// 定义MyQueue类,使用两个栈实现队列
class MyQueue {
public:
// 默认构造函数,编译器自动调用Stack的构造函数初始化两个栈
MyQueue() : pushst(4), popst(4) {} // 初始化两个栈的容量为4
private:
Stack pushst; // 用于入栈操作的栈
Stack popst; // 用于出栈操作的栈
};
int main() {
MyQueue mq; // 创建MyQueue对象,自动调用MyQueue构造函数
return 0;
}
3.析构函数
析构函数与构造函数的功能相反。析构函数并不是用来销毁对象本身的,比如局部对象的生命周期与栈帧的销毁有关,函数结束时栈帧被销毁,对象的内存也随之释放,程序不需要手动干预。C++ 规定,当对象销毁时,会自动调用析构函数,完成对象资源的清理和释放工作。
析构函数的功能类似于我们之前在 Stack
类中实现的 Destroy
函数。而像 Date
类,如果没有需要释放的资源,实际上是可以不需要析构函数的。严格来说,Date
类不需要析构函数。
析构函数的特点:
1.名称与类名相反:
- 析构函数的名称是在类名之前加上
~
字符。例如,如果类名是MyClass
,那么析构函数的名称就是~MyClass()
。
2.无参数无返回值:
- 析构函数没有返回值类型,也不需要显式地写
void
,并且不能有参数。构造函数与析构函数的一个共同点就是不需要返回类型。
3.一个类只能有一个析构函数:
- 在一个类中,只能定义一个析构函数。如果没有显式定义,C++ 编译器会自动生成一个默认的析构函数。
4.对象生命周期结束时自动调用:
- 当对象的生命周期结束时(如对象超出作用域或者使用
delete
删除动态分配的对象时),编译器会自动调用析构函数。这个过程是自动的,无需程序员干预。
5.处理成员变量的析构:
- 对于内置类型的成员变量(例如
int
、char
、double
等),默认的析构函数不会做任何处理。 - 对于自定义类型的成员变量(即类类型成员变量),析构函数会自动调用这些成员变量的析构函数,进行清理工作。
6.显式定义析构函数:
- 如果类中没有显式定义析构函数,编译器会自动生成一个默认析构函数。对于没有申请资源的类,默认析构函数通常是足够的。
- 如果类中有动态分配的资源(如通过
new
或malloc
分配的内存),则必须显式地编写析构函数以释放这些资源,防止资源泄漏。
7.当没有资源需要清理时,可以不写析构函数:
- 如果类中没有需要清理的资源(例如,类成员是内置类型或没有动态分配内存),则可以不显式定义析构函数,直接使用编译器生成的默认析构函数。
8.局部对象析构顺序:
- 在局部变量的情况下,C++ 定义了“后定义先析构”的规则。也就是说,后创建的对象会先被析构。
析构函数的使用场景:
- 资源释放:析构函数主要用于释放对象在生命周期内分配的资源,例如:
- 释放动态分配的内存(
delete
或free
)。- 关闭打开的文件句柄。
- 清理网络连接等资源。
- 防止资源泄漏:如果类中有动态资源(如堆内存),需要在析构函数中进行清理,确保不发生资源泄漏。若没有显式定义析构函数,编译器提供的默认析构函数只会对成员进行逐一销毁,但不会处理动态资源。
通过下面这段代码来讲解下
#include <iostream>
class MyClass {
public:
// 构造函数
MyClass() {
std::cout << "Constructor: Allocating resource." << std::endl;
// 假设分配了动态内存
data = new int(42);
}
// 析构函数
~MyClass() {
std::cout << "Destructor: Releasing resource." << std::endl;
delete data; // 释放动态分配的内存
}
private:
int* data;
};
int main() {
MyClass obj; // 创建对象,会调用构造函数
// 对象生命周期结束,自动调用析构函数
return 0;
}
MyClass
的构造函数动态分配了一块内存,并将其指针保存在data
成员中。- 析构函数释放了这块内存,防止内存泄漏。
主要注意下面四点
- 析构函数在对象销毁时自动调用,负责清理和释放资源。
- 类中可以有一个析构函数,如果没有显式定义,编译器会生成一个默认析构函数。
- 自定义类型的成员变量会调用它们的析构函数,内置类型的成员变量则没有特别的处理。
- 当类中涉及动态资源时,必须显式定义析构函数来进行资源释放,以避免资源泄漏。
4.拷贝构造函数
虽然在构造函数那边就大概讲了一下,但这还是需要详细讲解的
拷贝构造函数是类中一种特殊的构造函数,用于创建一个新对象并用另一个相同类型的对象进行初始化。它的主要作用是在对象拷贝时,确保正确的资源管理(例如内存管理、文件句柄等)。在 C++ 中,拷贝构造函数会在以下几种情况下被调用:
- 通过另一个同类型对象初始化新对象。
- 将对象作为值传递给函数。
- 将对象作为值返回。
拷贝构造函数的语法:
ClassName(const ClassName& other);
other
是被拷贝对象的引用,类型必须与当前类一致。- 拷贝构造函数不需要返回值,因为它是用来初始化对象的。
拷贝构造函数的特点:
1.拷贝构造函数是构造函数的重载:
- 作为构造函数的一个重载,拷贝构造函数允许用现有对象来初始化一个新对象。
2.拷贝构造函数的参数:
- 拷贝构造函数的参数是类类型对象的引用,且该引用必须是常量引用(
const ClassName&
),这样可以防止对原对象进行修改。 - 不能使用传值方式作为参数,因为这样会导致无限递归调用拷贝构造函数。
3.自动生成的拷贝构造函数:
- 如果我们没有显式定义拷贝构造函数,C++ 编译器会为类自动生成一个默认的拷贝构造函数。这个自动生成的拷贝构造函数执行的是浅拷贝:
- 对于内置类型的成员变量,编译器进行简单的字节复制。
- 对于自定义类型的成员变量,编译器会调用自定义类型的拷贝构造函数进行拷贝。
4.自定义拷贝构造函数的需求:
- 如果类中的成员变量需要深拷贝(例如,动态分配的内存或其他资源),则需要显式定义拷贝构造函数来执行深拷贝,避免浅拷贝导致资源泄漏或悬空指针等问题。
5.显式实现拷贝构造函数的情景:
- 如果类没有动态分配资源,编译器生成的默认拷贝构造函数通常足够,但如果类中有动态内存或其他资源分配(如打开文件、网络连接等),则需要显式定义拷贝构造函数来确保正确的资源管理。
- 如果类定义了析构函数并且管理了资源(例如释放内存),则需要显式定义拷贝构造函数,以确保在对象拷贝时,资源能被正确管理。
6.拷贝构造函数与赋值操作:
- 拷贝构造函数与赋值操作符(
operator=
)是两个不同的概念。拷贝构造函数在对象创建时调用,而赋值操作符用于将已存在的对象赋值给另一个对象。
一样通过函数来讲解
#include <iostream>
class MyClass {
public:
MyClass(int val) : value(new int(val)) {
std::cout << "Constructor: Allocating memory" << std::endl;
}
// 拷贝构造函数:深拷贝
MyClass(const MyClass& other) {
std::cout << "Copy Constructor: Allocating memory for copy" << std::endl;
value = new int(*other.value); // 深拷贝:为新对象分配内存并拷贝数据
}
~MyClass() {
std::cout << "Destructor: Releasing memory" << std::endl;
delete value; // 释放内存
}
void show() const {
std::cout << "Value: " << *value << std::endl;
}
private:
int* value; // 动态分配的内存
};
int main() {
MyClass obj1(10); // 创建对象
obj1.show();
MyClass obj2 = obj1; // 调用拷贝构造函数
obj2.show();
return 0;
}
obj1
创建时分配了一块内存,存储了值10
。obj2
是通过obj1
调用拷贝构造函数创建的。在拷贝构造函数中,obj2
通过深拷贝分配了自己的内存,并将obj1
中的值拷贝过来。obj1
和obj2
现在有各自独立的内存,所以它们的析构函数会分别释放各自的内存。
拷贝构造函数常见场景:
函数参数传递时:
当一个对象作为函数参数传递时,如果使用传值方式,会调用拷贝构造函数。
void foo(MyClass obj) {
obj.show();
}
int main() {
MyClass obj1(10);
foo(obj1); // 调用拷贝构造函数
return 0;
}
函数返回时:
当一个对象作为函数返回值时,编译器会调用拷贝构造函数(尽管 C++11 以后可能优化为返回值优化(RVO)来避免不必要的拷贝)。
MyClass createObject() {
MyClass obj(20);
return obj; // 调用拷贝构造函数,或者在某些情况下返回值优化
}
一样注意4点
- 拷贝构造函数用于通过已有对象初始化一个新对象。
- 如果没有显式定义,编译器会自动生成一个浅拷贝构造函数。
- 自定义类型成员需要特别注意深拷贝,避免浅拷贝导致资源泄漏。
- 对于管理动态资源的类,必须显式定义拷贝构造函数以确保资源正确管理。
#include <iostream>
using namespace std;
class Date {
public:
// 构造函数,设置默认日期为 1-1-1
Date(int year = 1, int month = 1, int day = 1) {
_year = year;
_month = month;
_day = day;
}
// 拷贝构造函数,按值拷贝
Date(const Date& d) {
_year = d._year;
_month = d._month;
_day = d._day;
}
// 错误的构造函数:接受指针类型,不是拷贝构造函数
// Date(Date* d) { _year = d->_year; _month = d->_month; _day = d->_day; }
// 打印日期
void Print() {
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
// 函数接收 Date 类型对象,并进行拷贝构造
void Func1(Date d) {
cout << &d << endl; // 打印局部对象 d 的地址
d.Print();
}
// 函数返回一个局部对象的引用,可能会造成返回值优化
Date& Func2() {
Date tmp(2024, 7, 5); // 创建局部对象 tmp
tmp.Print();
return tmp; // 返回局部对象的引用,但会在函数结束后销毁
}
int main() {
Date d1(2024, 7, 5); // 创建 Date 对象 d1
// 传值调用 Func1,会触发拷贝构造函数
Func1(d1);
cout << &d1 << endl; // 打印 d1 的地址
// 传递指针给构造函数(不是拷贝构造),只是创建了一个新的对象 d2
Date d2(&d1);
d1.Print();
d2.Print();
// 使用拷贝构造函数,通过同类型对象初始化
Date d3(d1); // 调用拷贝构造函数
d3.Print();
// 另一种拷贝构造的写法,等价于 d3 = Date(d1);
Date d4 = d1; // 调用拷贝构造函数
d4.Print();
// 这里会调用 Func2 函数,它返回了一个局部对象的引用
// 但是返回的 tmp 对象会在函数结束时销毁,相当于“野引用”。
Date ret = Func2();
ret.Print();
return 0;
}
#include <iostream>
#include <cstring> // 包含memcpy需要的头文件
using namespace std;
typedef int STDataType;
class Stack {
public:
// 构造函数:分配内存
Stack(int n = 4) {
_a = (STDataType*)malloc(sizeof(STDataType) * n);
if (nullptr == _a) {
perror("malloc申请空间失败");
return;
}
_capacity = n;
_top = 0;
}
// 拷贝构造函数:深拷贝
Stack(const Stack& st) {
// 需要对_a指向的资源重新分配空间并拷贝数据
_a = (STDataType*)malloc(sizeof(STDataType) * st._capacity);
if (nullptr == _a) {
perror("malloc申请空间失败!!!");
return;
}
memcpy(_a, st._a, sizeof(STDataType) * st._top); // 深拷贝数据
_top = st._top;
_capacity = st._capacity;
}
// 入栈操作
void Push(STDataType x) {
if (_top == _capacity) {
int newcapacity = _capacity * 2;
STDataType* tmp = (STDataType*)realloc(_a, newcapacity * sizeof(STDataType));
if (tmp == NULL) {
perror("realloc fail");
return;
}
_a = tmp;
_capacity = newcapacity;
}
_a[_top++] = x;
}
// 析构函数:释放内存
~Stack() {
cout << "~Stack()" << endl;
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
private:
STDataType* _a; // 存储数据的数组
size_t _capacity; // 数组容量
size_t _top; // 当前栈顶
};
// 两个Stack实现队列
class MyQueue {
public:
// 默认构造函数,会自动调用Stack的拷贝构造函数
MyQueue() {}
// 拷贝构造函数:自动调用Stack的拷贝构造函数
MyQueue(const MyQueue& mq) : pushst(mq.pushst), popst(mq.popst) {}
private:
Stack pushst; // 用于入队的栈
Stack popst; // 用于出队的栈
};
int main() {
Stack st1;
st1.Push(1);
st1.Push(2);
// 使用自动生成的浅拷贝构造函数,浅拷贝会导致st1和st2指向相同的资源
// 这将导致栈的析构函数在销毁时尝试释放同一内存两次,导致程序崩溃
Stack st2 = st1;
MyQueue mq1;
// MyQueue自动生成的拷贝构造函数,会自动调用Stack的拷贝构造函数
// 只要Stack拷贝构造函数实现了深拷贝,就没有问题
MyQueue mq2 = mq1;
return 0;
}
- 如果一个类有指向动态分配内存的指针,必须实现深拷贝,否则会在拷贝时出现指针共享的问题,导致析构时内存被释放两次,发生崩溃。
- 在使用
MyQueue
这样的类时,拷贝构造函数会递归地调用Stack
的拷贝构造函数,保证了资源的正确管理。
第二章大概就这样?下一章讲赋值运算符重载、取地址运算符重载和对经典的日期类进行讲解