一、类和对象
类和对象是面向对象编程(OOP)的两个核心概念,它们之间的关系是抽象与具体的关系。
1.封装
封装是面向对象编程的核心概念之一,它指的是将数据和操作这些数据的函数捆绑在一起,形成一个类。封装的目的是保护对象的数据,防止外部代码直接访问对象内部的数据结构,减少错误并简化复杂性。
封装的优点:
- 封装可以隐藏类的实现细节,只暴露必要的接口给外部使用,这样可以提高代码的安全性和可维护性。
- 通过封装将相关的数据和操作封装在一起,提供简洁的接口。外部只需要关注接口,而不需要关心实现细节,这样可以简化代码的使用方式。
- 封装将数据和操作组织成类,提高了代码的可重用性。
2.类
类是对象的抽象,它定义了一组具有相同属性和行为的对象的模板。
类包括数据成员和成员函数,数据成员描述对象的属性,而成员函数则定义了对象的行为。
类的声明形式为:
class 类名{
public:
公有数据和函数
protected:
保护数据和函数
private:
私有数据和函数
}
类的成员访问限定符包括public(公有)、private(私有)和protected(保护),它们决定了类的成员函数和数据成员的访问权限。
- public:公有成员,可以被类的外部访问
- private:私有成员,只能被类的内部访问
- protected:保护成员,可以被类的派生类访问
class Person {
private:
string p_name;
int p_age;
public:
void setName(string name) {
p_name = name;
}
string getName() {
return p_name;
}
void setAge(int age) {
p_age = age;
}
int getAge() {
return p_age;
}
};
3.对象
对象是类的实例。当我们根据类创建一个实体时,这个实体就是对象。每个对象都有其自身的状态和行为,这些都由类定义。
对象一般通过成员函数来操作自己的数据成员,从而完成特定的功能。
一个类可以创建多个对象,每个对象都有自己的数据成员,但它们共享相同的成员函数。
Person p1;
p1.setName("Tom");
p1.setAge(20);
cout << p1.getName() << " " << p1.getAge() << endl;
//输出Tom 20
Person p2;
p2.setName("Mike");
p2.setAge(18);
cout << p2.getName() << " " << p2.getAge() << endl;
//输出Mike 18
二、构造函数和析构函数
构造函数和析构函数是类中特殊的成员函数,它们在对象的创建和销毁过程中发挥重要的作用。
1.构造函数
构造函数用于创建对象时初始化类成员,它的名称与类名相同,并且没有返回类型。
特点:
- 构造函数可以重载,即一个类中可以定义多个参数或参数类型不同的构造函数。
- 如果在类中没有定义构造函数,编译器一般会自动生成一个无参数的默认形式的构造函数。
- 为类定义了非默认构造函数后,一般需要提供默认构造函数。
- 在设计类时,通常应提供对所有类成员函数做隐式初始化的默认构造函数。
2.构造函数的分类
- 无参构造函数:当不提供任何参数时被调用,通常用于初始化对象的默认状态。
- 带参数的构造函数:当创建对象时可以传入参数,用于初始化对象的特定状态。
- 拷贝构造函数:用于从一个已经存在的对象初始化一个新对象。
- 移动构造函数:用于初始化一个新对象,从一个右值引用对象移动资源。
3.析构函数
析构函数用于在对象销毁前执行必要的清理工作,如释放分配给对象的内存资源。它的名称是在类名前面加上~,并且没有返回类型。
特点:
- 析构函数没有返回值,并且不能有参数,因此它不能被重载。
- 一般情况下,析构函数会被自动调用,例如在对象作用域结束时或使用delete运算符时。
- 如果类中的数据成员进行了内存的申请,通常需要手动编写析构函数来释放这些内存。
- 如果在类中没有定义析构函数,编译器会自动生成一个默认的析构函数。
4.析构函数调用时机
- 如果对象是动态变量,则当执行完定义该对象的程序块时,将调用该对象的析构函数。
- 如果对象是静态变量(外部、静态、静态外部或来自名称空间),则在程序结束时调用对象的析构函数。
- 如果对象是用new创建的,则仅当用户显式地使用delete删除对象时,其析构函数才会被调用。
class Person {
public:
Person() {
cout << "构造函数调用" << endl;
}
~Person() {
cout << "析构函数调用" << endl;
}
};
5.构造函数的调用方式
即对象初始化的方式:
- 括号法:直接使用类名和括号来调用构造函数。
- 显式法:通过将构造函数的调用表达式赋给一个对象来调用构造函数。
- 隐式法:在某些情况下,C++允许将构造函数的调用隐式转换为另一种形式。
class Person {
public:
Person(string name,int age) {
p_name = name;
p_age = age;
}
Person() {
p_name = " ";
p_age = 0;
}
//拷贝构造函数
Person(const Person& p) {
p_name = p.p_name;
p_age = p.p_age;
}
private:
string p_name;
int p_age;
};
Person p1; //不要加括号
//括号法
Person p2("Tom", 10);
Person p3(p1);
//显式法
Person p4 = Person("Tom", 10);
Person p5 = Person(p1);
//隐式法
Person p6 = { "tom", 10 };
Person p7 = p1;
Person("tom",10); //匿名对象:当前执行结束后,系统立即回收
//Person(p1); //不要利用拷贝构造函数初始化匿名对象
③调用规则:
- 如果用户定义有参构造函数,编译器不会再提供默认无参构造,但是会提供默认拷贝构造。
- 如果用户定义拷贝构造函数,编译器不会再提供其它构造函数。
6.拷贝构造函数
当使用已存在的对象来初始化新创建的对象时,编译器会自动调用拷贝构造函数。
特点:
- 拷贝构造函数的名称与类的名称相同,没有返回值。
- 拷贝构造函数必须使用引用作为参数,以防止在函数中修改原对象。
- 拷贝构造函数可以被显式调用,也可以由编译器在需要时隐式调用。
- 如果类中没有显式声明拷贝构造函数,编译器会自动生成一个默认的拷贝构造函数。
拷贝构造函数调用时机:
- 使用一个已创建的对象来初始化一个新对象时。
- 以值传递的方式给函数参数传值时。
- 函数以值的方式返回局部对象时。
- 生成临时对象或临时副本时。
三、动态内存分配
在C++中,类可以包含指针作为其成员变量。指针成员变量通常用于动态内存管理、实现多态性以及资源共享等。
1.内存分配注意事项
如果类使用new来分配类成员指向的内存,在设计时应采取一些预防措施:
- 对于指向的内存是由new分配的所有类成员,都应在类的析构函数中对其使用delete,用来释放分配的内存。
- 如果析构函数通过对指针类成员使用delete来释放内存,则每个构造函数都应当使用new来初始化指针,或将它设置为空指针。
- 如果构造函数使用的是new,则析构函数应使用delete,如果构造函数使用的是new[],则析构函数应使用delete[]。
- 应定义一个分配内存(而不是将指针指向已有内存)的拷贝构造函数。这样程序将能够将类对象初始化为另一个类对象。
- 应定义一个重载赋值运算符的类成员函数,以确保赋值的安全性。
动态分配数组举例:
class Array {
public:
Array(int size) {
data = new int[size];
len = size;
for (int i = 0; i < size; i++) {
data[i] = i;
}
}
~Array() {
delete[] data;
}
void print() {
for (int i = 0; i<len; i++) {
cout << data[i] << " ";
}
cout << endl;
}
private:
int* data;
int len;
};
Array arr(5);
arr.print();
//输出0 1 2 3 4
2.浅拷贝与深拷贝
①浅拷贝:
- 浅拷贝通常指的是在对象复制时,只对对象的数据成员进行简单的赋值操作。
- 当对象中包含指针成员时,浅拷贝只会复制指针本身,而不会复制指针所指向的内存区域。这意味着原始对象和拷贝对象将共享同一块内存,当其中一个对象释放内存时,可能会导致另一个对象中的指针变成悬空指针,从而引发程序错误。
- 如果类中没有定义拷贝构造函数,编译器会自动生成一个默认的拷贝构造函数,这个构造函数可能会进行浅拷贝。
- 浅拷贝后的两个对象中的指针成员将指向相同的内存地址,如果其中一个对象修改了通过指针访问的数据,另一个对象的数据也会随之改变。
- 浅拷贝适用于对象中不含有动态分配内存或其他资源的情况,或者当拷贝的目的是为了快速创建一个结构相似的对象时。
②深拷贝:
- 深拷贝则是在复制对象时,不仅仅是简单的复制对象的数据成员,还会为指针成员分配新的内存空间,并复制指针所指向的内容。
- 深拷贝可以避免浅拷贝带来的问题,确保原始对象和拷贝对象各自拥有独立的内存空间,互不影响。
- 实现深拷贝通常需要自定义拷贝构造函数或重载赋值运算符,以确保指针成员指向的内存区域被正确复制。
- 深拷贝适用于对象包含动态分配内存或其他需要独立管理的资源时,以避免潜在的资源冲突和悬挂指针问题。
举例:
class Copy {
public:
int* data;
Copy(int value) {
data = new int(value);
}
// 浅拷贝,默认拷贝构造函数实现方式
// Copy(const Copy& c) {
// data = c.data;
// }
// 深拷贝
Copy(const Copy& c) {
data = new int(*c.data);
}
~Copy() {
if (data != nullptr) {
delete data;
data = nullptr;
}
}
};
Copy c1(10);
Copy c2(c1);
cout << *c1.data << endl; //10
cout << *c2.data << endl; //10
*c1.data = 20;
cout << *c1.data << endl; //20
cout << *c2.data << endl; //10
3.定位new运算符
C++中的定位new运算符是一种特殊的new运算符,它允许用户指定动态内存分配的具体位置。
①定位new运算符的语法:
ptr = new (ptr) TypeName;
其中,ptr是一个指向已分配内存的指针,TypeName是要构造的对象类型。这个运算符会调用对象的构造函数来初始化新创建的对象,并在完成后返回指向该对象的指针。
②定位new运算符与普通new运算符的区别:
- 普通new运算符会在堆中找到一个足以满足要求的内存块,并返回指向该内存块的指针。
- 而定位new运算符则允许用户直接指定内存地址,从而可以在特定的位置创建对象。这种灵活性使得定位new运算符在处理内存时更加精确和高效。
③特点:
- delete可与常规new运算符配合使用,但不能与定位new运算符配合使用。
- 定位new运算符不会自动调用构造函数,因此必须显式调用对象的析构函数来清理内存。
- 如果在未正确分配的内存上使用定位new运算符,或者忘记调用析构函数释放内存,可能会导致未定义行为或内存泄漏。
- 定位new运算符允许用户实现更精细的内存控制,以便更好地优化内存的使用。
举例:
class A {
public:
A() {
cout << "A()" << endl;
}
~A() {
cout << "~A()" << endl;
}
};
char* buffer = new char[512];
A* p1 = new (buffer) A;
A* p2 = new (buffer+sizeof(A)) A;
//delete p2; //no
//delete p1; //no
p2->~A();
p1->~A();
delete[] buffer;
上述代码为p1和p2提供两个位于缓冲区的不同地址,确保这两个内存单元不重叠,并显式地为使用定位new运算符创建的对象调用析构函数来清理内存。
对于使用定位new运算符创建的对象,应以与创建顺序相反的顺序进行删除。原因在于,晚创建的对象可能依赖于早创建的对象。另外,仅当所有对象都被销毁后,才能释放用于储存这些对象的缓冲区。