1. 类的定义
1.1 类定义格式
在C++中,class
关键字是定义类的核心标识。以一个简单的Stack
类为例:
#include<iostream>
using namespace std;
class Stack
{
public:// 成员函数void Init(int n = 4){array = (int*)malloc(sizeof(int) * n);if (nullptr == array){perror("malloc申请空间失败");return;}capacity = n;top = 0;}void Push(int x){// ...扩容array[top++] = x;}int Top(){assert(top > 0);return array[top - 1];}void Destroy(){free(array);array = nullptr;top = capacity = 0;}
private:// 成员变量int* array;size_t capacity;size_t top;
}; // 分号不能省略
int main()
{Stack st;st.Init();st.Push(1);st.Push(2);cout << st.Top() << endl;st.Destroy();return 0;
}
上述代码中,Stack
类包含了public
和private
两个访问限定区域。public
区域定义的成员函数是类对外暴露的接口,外部代码可以通过这些接口对Stack
对象进行操作,如初始化、入栈、获取栈顶元素和销毁栈等操作。而private
区域定义的成员变量array
、capacity
和top
,则是类内部实现细节,外部代码无法直接访问,这体现了C++类的封装特性。
为了增强代码的可读性和可维护性,在命名成员变量时,业界通常会采用一些约定俗成的方式。例如,在成员变量前添加_
、m_
前缀,或者在变量名后添加_
后缀,像Date
类中的成员变量:
class Date
{
public:void Init(int year, int month, int day){_year = year;_month = month;_day = day;}
private:// 为了区分成员变量,⼀般习惯上成员变量// 会加⼀个特殊标识,如_ 或者 m开头int _year; int _month;int _day;
};
int main()
{Date d;d.Init(2024, 3, 31);return 0;
}
虽然C++并没有强制要求使用这种命名方式,但在团队协作开发中,统一的命名规范能让代码更易于理解和维护。
此外,C++中的struct
也具备定义类的能力。在C语言中,struct
主要用于封装数据,而在C++中,struct
得到了功能扩展,不仅可以定义数据成员,还能定义成员函数,其用法与class
非常相似。二者的主要区别在于成员的默认访问权限:class
中成员默认访问权限为private
,而struct
中成员默认访问权限为public
。
#include<iostream>
using namespace std;
// C++升级struct升级成了类
// 1、类⾥⾯可以定义函数
// 2、struct名称就可以代表类型
// C++兼容C中struct的⽤法
typedef struct ListNodeC
{struct ListNodeC* next;int val;
}LTNode;
// 不再需要typedef,ListNodeCPP就可以代表类型
struct ListNodeCPP
{void Init(int x){next = nullptr;val = x;}ListNodeCPP* next;int val;
};
int main()
{return 0;
}
在实际开发中,通常使用class
来定义类,因为class
更能明确体现面向对象编程的特性,并且默认的private
访问权限有助于更好地实现数据封装。而struct
则更多地用于一些轻量级的数据结构定义,或者在需要默认public
访问权限的场景下使用。
值得一提的是,定义在类内部的成员函数默认为inline
函数。inline
函数的特点是在编译时,编译器会尝试将函数体代码直接嵌入到函数调用处,减少函数调用的开销,从而提高程序的执行效率。不过,编译器是否真正将函数作为inline
函数处理,还会受到函数体复杂度等因素的影响。
1.2 访问限定符
访问限定符是C++实现封装的重要手段,通过public
、protected
和private
三个关键字,将类的成员进行访问权限划分,选择性地将类的接口提供给外部使用,有效保护类的内部实现细节。
public
:用public
修饰的成员在类外可以直接被访问。它们是类对外提供的公共接口,外部代码可以通过这些接口对类的对象进行操作。例如,Stack
类中的Init
、Push
、Top
和Destroy
函数,外部代码可以直接调用这些函数来使用栈的功能 。protected
和private
:protected
和private
修饰的成员在类外不能直接被访问。在类的当前定义阶段,二者功能相同,但在继承体系中会体现出差异(这部分内容将在后续继承章节详细讲解)。一般情况下,会将类的成员变量设置为protected
或private
,防止外部代码随意修改,保证数据的安全性和一致性 。- 作用域:访问权限的作用域从该访问限定符出现的位置开始,直到下一个访问限定符出现时结束。如果后面没有访问限定符,作用域就到类定义结束的
}
为止 。例如:
class Example
{
private:int privateData;
public:void publicFunction();
protected:int protectedData;
};
在上述代码中,privateData
的访问权限作用域从private:
开始,到public:
出现结束;publicFunction
的访问权限作用域从public:
开始,到protected:
出现结束;protectedData
的访问权限作用域从protected:
开始,到类定义结束。
- 默认访问权限:使用
class
关键字定义类时,若成员没有被访问限定符修饰,默认为private
;而使用struct
关键字定义类时,成员默认为public
。
1.3 类域
类在C++中定义了一个独特的作用域,类的所有成员(包括成员变量和成员函数)都处于这个作用域内。当在类体外定义成员函数时,需要使用::
作用域操作符明确指定该成员函数属于哪个类域。例如:
#include<iostream>
using namespace std;
class Stack
{
public:// 成员函数声明void Init(int n = 4);
private:// 成员变量int* array;size_t capacity;size_t top;
};
// 声明和定义分离,需要指定类域
void Stack::Init(int n)
{array = (int*)malloc(sizeof(int) * n);if (nullptr == array){perror("malloc申请空间失败");return;}capacity = n;top = 0;
}
int main()
{Stack st;st.Init();return 0;
}
在上述代码中,Init
函数的声明在类Stack
内部,而定义在类外。通过Stack::Init
这种形式,编译器能够明确知道Init
函数是Stack
类的成员函数,从而在编译过程中正确地查找和解析函数中使用的成员变量(如array
、capacity
和top
)。如果不使用::
作用域操作符指定类域,编译器会将Init
视为全局函数,此时在函数内部访问类的成员变量就会因为找不到其声明和定义而报错。
类域对编译查找规则有着重要影响。编译器在编译代码时,会先在当前作用域内查找变量和函数的定义,如果找不到,再到外层作用域进行查找。对于类成员函数,当在函数体中访问成员变量时,编译器会先在函数的局部作用域查找,如果未找到,就会到类域中查找对应的成员变量。这种查找规则确保了类成员函数能够正确访问和操作类的成员变量,同时也避免了不同作用域中同名变量或函数的冲突。
2. 实例化
2.1 实例化概念
类是对对象的抽象描述,它定义了对象的属性和行为,但类本身并不占用实际的物理内存,其中的成员变量仅仅是声明,未分配存储空间。而实例化则是使用类类型在物理内存中创建对象的过程 。打个形象的比方,类就如同建筑设计图纸,它规划了房屋的布局、房间数量、大小以及功能等,但设计图纸本身并不能居住,只有依据设计图纸建造出实际的房屋,才能供人居住。同样,类只是一个抽象的模型,只有通过实例化创建出对象,对象才会在内存中分配空间,用于存储类的成员变量数据。
#include<iostream>
using namespace std;
class Date
{
public:void Init(int year, int month, int day){_year = year;_month = month;_day = day;}void Print(){cout << _year << "/" << _month << "/" << _day << endl;}
private:// 这⾥只是声明,没有开空间int _year;int _month;int _day;
};
int main()
{// Date类实例化出对象d1和d2Date d1;Date d2;d1.Init(2024, 3, 31);d1.Print();d2.Init(2024, 7, 5);d2.Print();return 0;
}
在上述代码中,Date
类定义了年、月、日相关的成员变量和操作函数。通过Date d1;
和Date d2;
语句,从Date
类实例化出了两个对象d1
和d2
,这两个对象在内存中分别拥有独立的存储空间,用于存储各自的年、月、日数据。每个对象都可以调用类的成员函数Init
和Print
,对自身的数据进行初始化和输出操作。
一个类可以实例化出多个对象,这些对象在内存中各自独立,拥有自己的成员变量副本。它们可以通过调用类的成员函数来操作自身的数据,同时不同对象之间的数据互不干扰 。
2.2 对象大小
在分析类对象大小时,需要明确对象中包含哪些成员。类实例化出的每个对象都有独立的数据空间,因此对象中必然包含成员变量。而对于成员函数,由于函数被编译后是存储在代码段的指令,对象无法直接存储这些指令,若要存储只能是成员函数的指针。但进一步分析会发现,即使是同一个类实例化出的多个对象,它们的成员函数指针是相同的,将其存储在每个对象中会造成内存浪费。例如,Date
类实例化出的d1
和d2
对象,它们各自拥有独立的_year
、_month
和_day
成员变量来存储数据,但d1
和d2
调用的Init
和Print
函数的地址是相同的,没必要在每个对象中都存储该指针。实际上,在非动态多态的情况下,函数调用在编译链接阶段就已经确定了函数地址,不需要在运行时通过对象存储的指针查找,只有在动态多态场景下才需要在对象中存储函数地址(这部分内容将在后续多态章节详细介绍) 。
由此可知,C++类实例化的对象中只存储成员变量。并且,C++规定类实例化的对象大小计算也要遵循内存对齐规则 。内存对齐规则具体如下:
- 第一个成员在与结构体偏移量为0的地址处。
- 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。其中,对齐数是编译器默认的一个对齐数与该成员大小的较小值。在VS编译器中,默认对齐数为8 。
- 结构体(类对象)总大小为:所有成员变量中最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。
- 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
下面通过具体示例来计算类对象的大小:
#include<iostream>
using namespace std;
// 计算⼀下A/B/C实例化的对象是多⼤?
class A
{
public:void Print(){cout << _ch << endl;}
private:char _ch;int _i;
};
class B
{
public:void Print(){//...}
};
class C
{};
int main()
{A a;B b;C c;cout << sizeof(a) << endl;cout << sizeof(b) << endl;cout << sizeof(c) << endl;return 0;
}
对于类A
,其成员变量_ch
是char
类型,大小为1字节,_i
是int
类型,大小为4字节。根据内存对齐规则,_ch
从偏移量0处开始存储;_i
的对齐数为4(int
类型大小4字节与默认对齐数8的较小值),所以_i
要对齐到4的整数倍地址处,即偏移量4处开始存储。此时,类A
对象占用的内存空间为8字节(最大对齐数4的整数倍)。
对于类B
和类C
,它们没有成员变量。但为了标识对象的存在,C++规定即使没有成员变量,对象也会占用1字节的空间 。
3. this指针
在 C++ 的类体系中,当我们定义一个类并实例化多个对象时,类的成员函数需要一种机制来明确当前操作的是哪个对象的数据。以 Date
类为例:
#include<iostream>
using namespace std;
class Date
{
public:// void Init(Date* const this, int year, int month, int day)void Init(int year, int month, int day){// 编译报错:error C2106: “=”: 左操作数必须为左值// this = nullptr;// this->_year = year;_year = year;this->_month = month;this->_day = day;}void Print(){cout << _year << "/" << _month << "/" << _day << endl;}
private:// 这⾥只是声明,没有开空间int _year;int _month;int _day;
};
int main()
{// Date类实例化出对象d1和d2Date d1;Date d2;// d1.Init(&d1, 2024, 3, 31);d1.Init(2024, 3, 31);d1.Print();d2.Init(2024, 7, 5);d2.Print();return 0;
}
Date
类中有 Init
与 Print
两个成员函数,函数体中并没有直接体现出对不同对象的区分。当 d1
调用 Init
和 Print
函数时,this
指针就发挥了关键作用。
实际上,编译器在编译类的成员函数时,会默认在形参的第一个位置增加一个当前类类型的指针,即 this
指针。例如,Date
类的 Init
函数的真实原型为 void Init(Date* const this, int year, int month, int day)
。这意味着在成员函数内部,对成员变量的访问本质上都是通过 this
指针来实现的。比如在 Init
函数中给 _year
赋值,实际执行的是 this->_year = year
。
C++ 规定不能在实参和形参的位置显式地写 this
指针(编译时编译器会自动处理),但在函数体内是可以显式使用 this
指针的。例如在上述 Date
类的 Init
函数中,this->_month = month;
和 this->_day = day;
就是显式使用 this
指针的情况。
需要注意的是,this
指针的类型是 Date* const
,这表示 this
指针本身的值不能被修改(即不能指向其他对象),但它所指向的对象的数据是可以修改的。如果尝试在函数体内对 this
指针进行赋值操作,如 this = nullptr;
,会导致编译错误,因为这违反了 this
指针的常量性。
下面通过两个选择题来测试对前面知识的掌握程度:
- 下面程序编译运行结果是()
#include<iostream>
using namespace std;
class A
{
public:void Print(){cout << "A::Print()" << endl;}
private:int _a;
};int main()
{A* p = nullptr;p->Print();return 0;
}
A、编译报错
B、运行崩溃
C、正常运行
答案:C。在这个程序中,虽然 p
是一个空指针,但调用 Print
函数时,由于 Print
函数中没有访问对象的成员变量,仅仅是输出一个字符串,this
指针在这个函数中并没有实际用于访问对象的成员数据,所以不会引发运行时错误,程序可以正常运行。
- 下面程序编译运行结果是()
#include<iostream>
using namespace std;
class A
{
public:void Print(){cout << "A::Print()" << endl;cout << _a << endl;}
private:int _a;
};
int main()
{A* p = nullptr;p->Print();return 0;
}
A、编译报错
B、运行崩溃
C、正常运行
答案:B。在这个程序中,p
是一个空指针,当调用 Print
函数时,函数中试图访问 _a
成员变量,而 this
指针此时为空,通过空指针去访问成员变量会导致运行时错误,程序会崩溃。
this
指针存在内存哪个区域的 ()
A. 栈
B.堆
C.静态区
D.常量区
E.对象⾥⾯
答案:A。this
指针是作为类成员函数的隐含参数存在的,当成员函数被调用时,this
指针会被压入栈中,和其他函数参数一样存储在栈区。所以 this
指针存在于栈内存区域。
通过对 this
指针的深入理解以及这些测试题的分析,我们能够更加准确地把握 this
指针在 C++ 类与对象体系中的作用和使用方式,避免在编程中出现因对 this
指针理解不当而导致的错误。
4. C++ 和 C 语言实现栈的对比
4.1 C 语言实现栈
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>// 定义栈结构体
typedef struct Stack {int* array;int capacity;int top;
} Stack;// 初始化栈
void StackInit(Stack* ps) {assert(ps);ps->array = (int*)malloc(sizeof(int) * 4);if (ps->array == NULL) {perror("malloc failed");return;}ps->capacity = 4;ps->top = 0;
}// 入栈操作
void StackPush(Stack* ps, int x) {assert(ps);// 检查是否需要扩容if (ps->top == ps->capacity) {int* tmp = (int*)realloc(ps->array, ps->capacity * 2 * sizeof(int));if (tmp == NULL) {perror("realloc failed");return;}ps->array = tmp;ps->capacity *= 2;}ps->array[ps->top++] = x;
}// 出栈操作
void StackPop(Stack* ps) {assert(ps);assert(ps->top > 0);ps->top--;
}// 获取栈顶元素
int StackTop(Stack* ps) {assert(ps);assert(ps->top > 0);return ps->array[ps->top - 1];
}// 销毁栈
void StackDestroy(Stack* ps) {assert(ps);free(ps->array);ps->array = NULL;ps->capacity = 0;ps->top = 0;
}// 检查栈是否为空
int StackEmpty(Stack* ps) {assert(ps);return ps->top == 0;
}int main() {Stack st;StackInit(&st);StackPush(&st, 1);StackPush(&st, 2);StackPush(&st, 3);while (!StackEmpty(&st)) {printf("%d ", StackTop(&st));StackPop(&st);}StackDestroy(&st);return 0;
}
特点分析
- 面向过程:C 语言的实现是面向过程的,将栈的各种操作(初始化、入栈、出栈等)封装成独立的函数。在使用栈时,需要手动传递栈结构体的指针作为参数,以明确操作的对象。
- 数据和操作分离:栈的数据(
array
、capacity
、top
)和对栈的操作(StackInit
、StackPush
等)是分离的。这种分离使得代码的逻辑较为清晰,但也增加了使用的复杂度,需要程序员手动管理栈的生命周期和操作顺序。 - 内存管理复杂:在 C 语言中,需要手动进行内存的分配(
malloc
、realloc
)和释放(free
)。如果忘记释放内存,会导致内存泄漏;如果在不恰当的时候释放内存,会导致程序崩溃。
4.2 C++ 实现栈
#include <iostream>
#include <cassert>
using namespace std;class Stack {
public:// 初始化栈void Init(int n = 4) {array = (int*)malloc(sizeof(int) * n);if (array == nullptr) {perror("malloc申请空间失败");return;}capacity = n;top = 0;}// 入栈操作void Push(int x) {// 检查是否需要扩容if (top == capacity) {int* tmp = (int*)realloc(array, capacity * 2 * sizeof(int));if (tmp == nullptr) {perror("realloc failed");return;}array = tmp;capacity *= 2;}array[top++] = x;}// 出栈操作void Pop() {assert(top > 0);top--;}// 获取栈顶元素int Top() {assert(top > 0);return array[top - 1];}// 销毁栈void Destroy() {free(array);array = nullptr;top = capacity = 0;}// 检查栈是否为空bool Empty() {return top == 0;}private:int* array;size_t capacity;size_t top;
};int main() {Stack st;st.Init();st.Push(1);st.Push(2);st.Push(3);while (!st.Empty()) {cout << st.Top() << " ";st.Pop();}st.Destroy();return 0;
}
特点分析
- 面向对象:C++ 的实现是面向对象的,将栈的数据(
array
、capacity
、top
)和对栈的操作(Init
、Push
等)封装在一个类中。通过类的实例化对象来调用相应的成员函数,操作更加直观和方便。 - 数据封装:使用
private
访问限定符将栈的内部数据成员封装起来,外部代码只能通过类提供的公共成员函数来访问和操作栈。这样可以隐藏栈的实现细节,提高代码的安全性和可维护性。 this
指针的使用:在 C++ 的成员函数中,隐含了一个this
指针,它指向调用该成员函数的对象。通过this
指针,成员函数可以访问和操作对象的成员变量。例如,在Push
函数中,array
和top
实际上是通过this
指针访问的,即this->array
和this->top
。
4.3 对比总结
代码组织和可读性
- C 语言的实现将数据和操作分离,代码的逻辑结构相对松散,需要手动管理栈结构体指针的传递,代码的可读性和可维护性较差。
- C++ 的实现将数据和操作封装在类中,代码的组织更加清晰,通过对象调用成员函数的方式使得代码的可读性和可维护性更高。
安全性
- C 语言的实现中,数据是公开的,外部代码可以直接访问和修改栈的内部数据,容易导致数据的不一致性和错误。
- C++ 的实现通过访问限定符对数据进行封装,只有类的成员函数可以访问和修改内部数据,提高了代码的安全性。
可扩展性
- C 语言的实现如果需要添加新的功能,需要修改独立的函数,并手动调整函数调用的顺序,扩展性较差。
- C++ 的实现可以通过继承和多态等面向对象的特性,方便地扩展栈的功能,例如可以创建一个新的类继承自
Stack
类,并添加新的成员函数。
综上所述,C++ 的面向对象特性使得栈的实现更加简洁、安全和易于扩展,而 C 语言的面向过程实现则更加底层,需要程序员手动管理更多的细节。在实际开发中,可以根据具体的需求和场景选择合适的实现方式。
5. 类的默认成员函数
在C++编程中,类的默认成员函数是非常重要的基础概念。即使我们没有显式地为类定义某些成员函数,编译器也会自动为类生成一些默认的成员函数。这些默认成员函数在对象的创建、初始化、销毁以及对象之间的交互过程中发挥着关键作用。下面,我们将详细介绍构造函数、析构函数、拷贝构造函数等默认成员函数。
C++中,编译器会为类自动生成6个默认成员函数(如果用户没有定义的话),它们分别是:
- 默认构造函数
- 析构函数
- 拷贝构造函数
- 拷贝赋值运算符
- 移动构造函数(C++11引入)
- 移动赋值运算符(C++11引入)
这些函数的存在,使得我们在创建和使用类对象时更加便捷。不过,在一些特定情况下,我们需要显式地定义这些函数,以满足程序的实际需求。接下来,我们将对前4个函数进行详细讲解,移动构造函数和移动赋值运算符将在后续章节介绍。
6. 构造函数
6.1 什么是构造函数
构造函数是一种特殊的成员函数,它的主要任务是在对象创建时对对象进行初始化。构造函数的名称必须与类名完全相同,并且没有返回类型(连void
也不能写)。
6.2 构造函数的特性
- 自动调用:当创建类的对象时,构造函数会自动被调用。例如:
class Student {
public:Student() {std::cout << "Student object is being created." << std::endl;}
};int main() {Student stu; // 这里会自动调用Student类的构造函数return 0;
}
在上述代码中,当执行Student stu;
时,Student
类的构造函数会自动执行,输出相应的提示信息。
- 可重载:我们可以定义多个不同参数列表的构造函数,这就是构造函数的重载。通过重载构造函数,我们可以用不同的方式初始化对象。例如:
class Rectangle {
public:// 无参构造函数Rectangle() {width = 0;height = 0;}// 带参数的构造函数Rectangle(int w, int h) {width = w;height = h;}
private:int width;int height;
};int main() {Rectangle rect1; // 调用无参构造函数Rectangle rect2(5, 3); // 调用带参数的构造函数return 0;
}
在这个例子中,Rectangle
类有两个构造函数,一个是无参构造函数,用于将矩形的宽和高初始化为0;另一个是带参数的构造函数,可以根据传入的参数初始化矩形的宽和高。
- 编译器自动生成:如果我们在类中没有显式定义任何构造函数,编译器会自动生成一个默认构造函数。这个默认构造函数是一个空函数,它不会对成员变量进行任何初始化操作。例如:
class Point {// 没有定义构造函数int x;int y;
};int main() {Point p; // 编译器自动生成的默认构造函数被调用return 0;
}
不过,一旦我们在类中显式定义了任何一个构造函数,编译器就不会再自动生成默认构造函数。此时,如果我们还需要使用默认构造函数,就必须自己显式定义。
6.3 构造函数的初始化列表
在构造函数中,我们可以使用初始化列表来初始化成员变量。初始化列表的语法是在构造函数的参数列表后面加上一个冒号:
,然后列出需要初始化的成员变量及其初始值。例如:
class Circle {
public:Circle(double r) : radius(r) {// 这里可以添加其他初始化代码}
private:double radius;
};
使用初始化列表有以下几个优点:
- 效率更高:对于一些自定义类型的成员变量,使用初始化列表可以直接调用其构造函数进行初始化,避免了先调用默认构造函数再进行赋值的过程。
- 必须使用场景:对于常量成员变量和引用成员变量,必须使用初始化列表进行初始化,因为它们在对象创建后不能被赋值。例如:
class Person {
public:Person(const std::string& n) : name(n) {}
private:const std::string name; // 常量成员变量
};
7. 析构函数
7.1 什么是析构函数
析构函数也是一种特殊的成员函数,它的作用与构造函数相反,主要用于在对象销毁时释放对象所占用的资源,例如动态分配的内存、打开的文件句柄等。析构函数的名称是在类名前面加上波浪号~
,并且没有参数和返回类型。
7.2 析构函数的特性
- 自动调用:当对象的生命周期结束时,析构函数会自动被调用。对象生命周期结束的情况包括:
- 局部对象离开其作用域时。例如:
void func() {class Temp {public:~Temp() {std::cout << "Temp object is being destroyed." << std::endl;}};Temp t; // 在func函数结束时,t的析构函数会自动调用
}int main() {func();return 0;
}
- 使用`delete`运算符释放通过`new`运算符动态分配的对象时。例如:
class DynamicObject {
public:~DynamicObject() {std::cout << "DynamicObject is being destroyed." << std::endl;}
};int main() {DynamicObject* ptr = new DynamicObject();delete ptr; // 这里会调用DynamicObject的析构函数return 0;
}
- 无参数和返回值:析构函数不能有参数,也不能有返回值。这是因为析构函数的主要任务是进行资源清理,不需要外部传入参数,也不需要返回任何结果。
- 编译器自动生成:如果我们在类中没有显式定义析构函数,编译器会自动生成一个默认析构函数。默认析构函数是一个空函数,对于大多数简单类来说,默认析构函数已经足够。但是,当类中有动态分配的资源时,我们必须显式定义析构函数来释放这些资源,否则会导致内存泄漏。例如:
class MyArray {
public:MyArray(int size) {data = new int[size];this->size = size;}~MyArray() {delete[] data; // 释放动态分配的内存}
private:int* data;int size;
};
在这个例子中,MyArray
类在构造函数中动态分配了一块内存用于存储数组。在析构函数中,我们使用delete[]
运算符释放了这块内存,以确保内存资源得到正确管理。
8. 拷贝构造函数
8.1 什么是拷贝构造函数
拷贝构造函数是一种特殊的构造函数,它的作用是使用一个已存在的对象来初始化一个新对象。也就是说,当我们用一个对象去创建另一个对象时,拷贝构造函数会被调用。拷贝构造函数的参数通常是一个常量引用,引用的类型为当前类的类型。
8.2 拷贝构造函数的特性
- 参数为常量引用:拷贝构造函数的参数一般为
const ClassName&
的形式。这样做有两个好处:一是避免了在调用拷贝构造函数时对实参对象进行不必要的拷贝,提高了效率;二是保证了在拷贝构造函数内部不会意外修改原对象。例如:
class Book {
public:Book(const Book& other) {title = other.title;author = other.author;std::cout << "Copy constructor is called." << std::endl;}
private:std::string title;std::string author;
};
- 编译器自动生成:如果我们在类中没有显式定义拷贝构造函数,编译器会自动生成一个默认拷贝构造函数。默认拷贝构造函数会逐个复制对象的成员变量,这种复制方式被称为浅拷贝。对于一些简单的类,浅拷贝通常是可以满足需求的。例如:
class SimplePoint {
public:int x;int y;
};int main() {SimplePoint p1;p1.x = 1;p1.y = 2;SimplePoint p2(p1); // 调用编译器自动生成的默认拷贝构造函数return 0;
}
然而,当类中包含动态分配的资源时,浅拷贝会引发严重的问题。因为浅拷贝只是简单地复制指针,而不是复制指针所指向的内存空间。这就导致两个对象的指针指向同一块内存,当其中一个对象销毁时,另一个对象的指针就会变成野指针,再次访问时会导致程序崩溃。例如:
class BadString {
public:BadString(const char* str) {data = new char[strlen(str) + 1];strcpy(data, str);}// 没有定义拷贝构造函数,使用默认的浅拷贝
private:char* data;
};int main() {BadString s1("Hello");BadString s2(s1); // 这里会出现问题,s1和s2的data指针指向同一块内存return 0;
}
为了解决这个问题,当类中有动态分配的资源时,我们必须显式定义拷贝构造函数,实现深拷贝。深拷贝是指在拷贝对象时,不仅复制指针,还复制指针所指向的内存空间,使得两个对象拥有独立的资源。例如:
class GoodString {
public:GoodString(const char* str) {data = new char[strlen(str) + 1];strcpy(data, str);}GoodString(const GoodString& other) {data = new char[strlen(other.data) + 1];strcpy(data, other.data);std::cout << "Deep copy constructor is called." << std::endl;}~GoodString() {delete[] data;}
private:char* data;
};
在这个例子中,我们显式定义了GoodString
类的拷贝构造函数,实现了深拷贝。这样,当用一个GoodString
对象去初始化另一个GoodString
对象时,两个对象会拥有独立的内存空间,避免了因浅拷贝带来的问题。
通过对构造函数、析构函数和拷贝构造函数的详细学习,我们对C++类的默认成员函数有了更深入的理解。这些知识对于正确创建、使用和管理类对象至关重要,也是我们进一步学习C++面向对象编程的基础。
看到这里我相信你已经吃的饱饱的了吧哈哈哈,再讲下去的话我也看不下去了,毕竟第一次讲到这么多,也该消化消化一下在继续看,那咱们下期在讲其他的咯~~~回见各位
祝大家五一快乐,天天开心!!!