自学内容网 自学内容网

《类和对象:基础原理全解析(中篇)》

一、 类成员函数的 this 指针

  在类中,通过类对象可以调用类的成员函数,在类的成员函数中可以访问该类对象的私有成员。但是,每个函数都有属于自己的作用域,除了在其中创建的变量之外,也就这有传递的参数可以访问。那么被调用的类方法如何访问该类对象?

  在 C++ 中通过类对象访问类的成员函数时隐式传递了该对象的指针,该指针也就是 this 指针。如通过日期类 Date 的类对象 d1 调用其打印函数 Print(),d1.print() 等价于 d1.print(&d1)。而该地址被赋值给 this 指针,而在类的成员函数中使用类对象的成员时,默认前面加上了 this->,所以实际上是通过 this 指针来访问类的成员变量和调用类的成员函数。

1. this 指针的使用

  下面的代码展示了日期类的 this 指针使用。

// 头文件
#include <iostream>

// using 声明
using std::cout;
using std::endl;

// Date 类声明
class Date
{
private:
size_t _year;
size_t _month;
size_t _day;

public:
Date(size_t year = 1949, size_t month = 10, size_t day = 1);
void Print();

};

// Date 类函数定义
Date::Date(size_t year, size_t month, size_t day)
{
_year = year;
_month = month;
_day = day;
}

void Date::Print()
{
cout << this->_year << "-" << this->_month << "-" << this->_day << endl;
}

int main()
{
Date d1;  // 默认构造函数
Date d2(2024, 12, 23);
d1.Print();
d2.Print();

return 0;
}

  可以看到上述代码中,全缺省构造函数 Date(size_t year = 1949, size_t month = 10, size_t day = 1); 的定义中没有显式使用 this 指针,但是编译器在识别类的成员变量的时候会加上 this-> 前缀,代表这个成员变量是调用该函数的类对象的。

  而在打印函数 Print() 的定义中,显式使用了 this 指针。

2. this 指针的注意事项

1)在类函数声明和定义的参数列表中,不显式写出 this 指针,而是编译器隐式加上;
2)在调用类成员函数时,也不需要传递该类对象的地址,编译器隐式传递;
3)一般不再类的成员函数定义中显式使用 this 指针;
4)this 指针一般用来返回该类对象的引用或者指针。
5)使用 this 指针可以区分类对象的成员变量和

二、const 成员函数

  在我们使用类的成员函数时,有的类成员函数是不需要改变类的成员变量的,如 Date 类的 Print() 函数。在我们使用常规的函数时,如果传入的参数时指针或者引用且不改变参数的值时,都是用 const 传参。而在类中对象的地址是编译器隐式传递的,我们无法像平时一样在函数声明或者定义时加上 const 关键字。

1. const 关键字的添加位置

  C++ 规定,如果需要对调用函数的类对象使用 const 关键字,那么const 关键字需要添加在类的成员函数的圆括号的右边。如下代码所示:
在这里插入图片描述

2. const 成员函数的使用建议

  如果类的成员函数不需要修改类的成员变量,最好都加上 const 关键字修饰,这样即使不小心修改了成员变量,编译器也会进行报错提示,有助于提升编写代码的健壮性。

三、析构函数

  析构函数和构造函数时一对,构造函数是类对象被创建的时候调用用来初始化类对象的;而析构函数则是类对象即将要被销毁时用来清理该对象的动态内存空间的。

  当没有给类定义析构函数时,编译器会默认创建一个默认析构函数,该析构函数对内置类型不做处理,而对自定义类型调用其默认析构函数。

  像 Date 类这种类可以不定义析构函数,而使用编译器创建的默认析构函数,因为它没有动态内存开辟。而像 Stack 这种类就需要定义析构函数,因为其在堆上开辟了空间。

  下面的代码中,在析构函数中添加了打印语句,让读者在程序运行结果中可以看见析构函数被自动调用。

1. 拷贝构造函数的使用

Stack.h 头文件

#pragma once

// 头文件
#include <iostream>

// using 声明
using std::cout;
using std::endl;

// 类型声明
using DataType = int;

// Stack 类声明
class Stack
{
private:
DataType* _pdata;
size_t _top;
size_t _capacity;

public:
Stack(size_t capacity = 4);  // 默认构造函数
~Stack();  // 析构函数
void push(DataType data);
void pop();
DataType top() const;
size_t size() const;
bool isempty() const;
size_t capacity() const;

};

Stack.cpp 方法定义文件

// 头文件
#include "Stack.h"
#include <stdlib.h>

// Stack 类声明
Stack::Stack(size_t capacity)  // 默认构造函数
{
// 申请数据空间
_pdata = (DataType*)malloc(sizeof(DataType) * capacity);
if (nullptr == _pdata)
{
perror("Stack::Stack: ");
return;
}
// 初始化成员变量
_capacity = capacity;
_top = 0;
}

Stack::~Stack()  // 析构函数
{
// 释放数据空间
free(_pdata);
_pdata = nullptr;
// 成员变量归零
_capacity = _top = 0;
// 展示自动调用
cout << "Destory Stack\n";
}

void Stack::push(DataType data)
{
// 检查增容
if (_top == _capacity)
{
DataType* tmp = (int*)realloc(_pdata, sizeof(DataType) * _capacity * 2);
if (nullptr == tmp)
{
perror("Stack::push: ");
return;
}
// 更新成员变量
_pdata = tmp;
_capacity *= 2;
}
// 入栈
_pdata[_top++] = data;
}

void Stack::pop()
{
// 空栈检查
if (0 == _top)
{
cout << "Empty Stack!\n";
return;
}
// 出栈
--_top;
}

DataType Stack::top() const
{
return _pdata[_top - 1];
}

size_t Stack::size() const
{
return _top;
}

bool Stack::isempty() const
{
return (0 == _top);
}

size_t Stack::capacity() const
{
return _capacity;
}

test.cpp 测试文件

// 头文件
#include "Stack.h"

int main()
{
Stack sk;
// 入栈
for (int i = 0; i < 10; ++i)
sk.push(i);
// 出栈
while (!sk.isempty())
{
cout << sk.top() << " ";
sk.pop();
}

return 0;
}

  程序运行结果如下:
在这里插入图片描述
  可以看到析构函数在类对象被销毁时自动调用,清理其中的动态内存。

2. 使用析构函数的注意事项

1)如果该类没有涉及动态内存,且该类的自定义类型都具有默认析构函数,或者该类没有自定义类型,那么该类可以不显式创建默认析构函数,使用编译器创建的默认析构函数;
2)如果该类涉及动态内存开辟,一定要显式创建析构函数,且要在析构函数中释放动态开辟的内存空间。

四、拷贝构造函数

  C++ 规定当使用一个类对象去初始化另一个类对象的时候,需要调用拷贝构造函数。拷贝构造函数时构造函数的一种,只不过是使用类对象来初始化。

  为什么使用一个类对象去初始化另一个类对象时需要调用拷贝构造函数?而内置类型只需要赋值即可。因为内置类型不涉及动态内存开辟,而自定义类型可能涉及动态内存开辟。就拿 Stack 类来说,在使用 sk1 去初始化 sk2 时只是单纯的赋值,那么两个类对象的 _pdata 指针将指向同一块内存空间,这不符合我们的预期,且在两个对象销毁时,析构函数自动调用,会对这块动态开辟的内存空间释放两次。

1. 使用默认拷贝构造函数的 Stack 类

  不显式创建拷贝构造函数,编译器默认生成一个拷贝构造函数,该拷贝构造函数是浅拷贝,把一个类对象的每个成员变量的值赋值给另一个类对象。

(1)头文件 Stack.h

#pragma once

// 头文件
#include <iostream>

// using 声明
using std::cout;
using std::endl;

// 类型声明
using DataType = int;

// Stack 类声明
class Stack
{
private:
DataType* _pdata;
size_t _top;
size_t _capacity;

public:
Stack(size_t capacity = 4);
~Stack();
bool push(const DataType& data);
bool pop();
bool empty() const;
bool full() const;
DataType top() const;
size_t size() const;
size_t capacity() const;
};

(2)方法定义文件 Stack.cpp

// 头文件
#include "Stack.h"
#include <stdlib.h>

Stack::Stack(size_t capacity)
{
// 申请数据空间
_pdata = (DataType*)malloc(sizeof(DataType) * capacity);
if (nullptr == _pdata)
{
perror("Stack::Stack::malloc: ");
return;
}
// 初始化成员变量
_capacity = capacity;
_top = 0;
}

Stack::~Stack()
{
// 释放数据空间
free(_pdata);
_pdata = nullptr;
// 重置成员变量
_capacity = _top = 0;
}

bool Stack::push(const DataType& data)
{
// 增容判断
if (_top == _capacity)
{
DataType* tmp = (DataType*)realloc(_pdata, sizeof(DataType) * _capacity * 2);
if (nullptr == tmp)
{
perror("Stack::push::malloc:");
return false;
}
// 更新成员变量
_pdata = tmp;
_capacity *= 2;
}
// 入栈
_pdata[_top++] = data;
return true;
}

bool Stack::pop()
{
// 空栈判断
if (0 == _top)
{
cout << "Emtpy Stack!\n";
return false;
}
// 出栈
--_top;
return true;
}

bool Stack::empty() const
{
return (0 == _top);
}

bool Stack::full() const
{
return (_top == _capacity);
}

DataType Stack::top() const
{
return _pdata[_top - 1];
}

size_t Stack::size() const
{
return _top;
}

size_t Stack::capacity() const
{
return _capacity;
}

(3)测试文件 test.cpp

// 头文件
#include "Stack.h"

int main()
{
Stack sk1;
// 默认拷贝构造函数
Stack sk2(sk1);
// 对 sk1 进行操作,sk2 不进行任何操作
// 入栈f
for (int i = 0; i < 10; ++i)
sk1.push(i);
// 出栈
while (!sk1.empty())
{
cout << sk1.top() << " ";
sk1.pop();
}
cout << endl;


return 0;
}

(4)程序的运行结果如下:
在这里插入图片描述
在这里插入图片描述

  可以看到上述程序的输出是没有问题的,但是问题出在析构函数的 free() 函数中。

  我们可以在 sk2 创建的语句的下一句给一个断点,然后可以在程序的末尾的 cout << endl; 语句出打一个断点,然后调试运行程序,观察两个 Stack 类对象的成员。

在这里插入图片描述
  可以看到在第一个断点处,两个 Stack 类对象的 _pdata 指针指向同一块空间,那么在最后调用析构函数的时候这块空间一定会被释放两次,然后错。

在这里插入图片描述
  上图是第二个断点处两个 Stack 类对象的成员变量,可以看到 sk1 的 _pdata 已经改变,因为中途增容把之前的空间释放了,拿到了新的空间。但是 sk2 的 _pdata 还是在刚开始的那块空间,在程序结束时,析构函数会对这块已经被销毁的空间进行释放。所以最终程序报错,并且报错位置在析构函数的 free() 函数处,因为对同一块动态开辟的空间释放了两次。

2. 显示创建拷贝构造函数的 Stack 类

  显示创建的拷贝构造函数需要重新创建一个数据空间,然后把另一个对象的数据拷到新创建的数据空间。其他非动态内存的数据直接拷贝即可。

 &esmp;下面的代码只给出拷贝构造函数的定义,其余和上面的 Stack 类相同。

(1)拷贝构造函数

// 拷贝构造函数
Stack::Stack(const Stack& sk)
{
// 开辟同样大小的空间
_pdata = (DataType*)malloc(sizeof(DataType) * sk._capacity);
if (nullptr == _pdata)
{
perror("Stack::Stack::malloc: ");
return;
}
// 拷贝数据
memcpy(_pdata, sk._pdata, sizeof(DataType) * sk._top);
// 拷贝其他成员
_top = sk._top;
_capacity = sk._capacity;
}

(2)程序运行结果如下:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

  通过第一张截图可以知道,sk2 的 _pdata 和 sk1 的 _pdata 指向不同的内存空间,这也证明了我们自定义的拷贝构造函数实现了深拷贝。而在第二张截图中 sk1 依旧进行了增容,指向了增容后的新空间。最后程序输出的结果正常,也没有报错。

3. 拷贝构造函数的注意事项

(1)如果该类涉及动态内存开辟,一定要显式创建拷贝构造函数;
(2)如果该类不涉及动态内存开辟,且其成员变量均为内置类型,或者其中的自定义类型有正确的拷贝构造函数,才可以使用默认拷贝构造函数。

五、赋值运算符重载

  赋值运算符相信大家都不陌生,它的作用是改变变量原来的值,给变量赋予新值。而为什么需要重载赋值运算符?因为 C++ 中只对内置类型定义了赋值运算符,没有为自定义类型创建赋值运算符。且对内置类型的赋值运算符只是浅拷贝,而自定义类型可能涉及动态内存开辟,所以需要深拷贝。

  当没有显式定义赋值运算符重载时,编译器会创建一个默认的赋值运算符重载,其对内置类型进行浅拷贝,而对自定义类型调用其自身的赋值运算符重载。

  而赋值运算符如何重载?以 Stack 类为例,其声明为:
    Stack& operator=(const Stack & sk);
这里返回调用该方法对象的引用,方便连续赋值。

1. 使用默认赋值运算符重载的 Stack 类

  下面只给出测试文件 test.cpp 和程序运行的结果,头文件和方法定义文件上面都有。
在这里插入图片描述
在这里插入图片描述

  可以看到,sk2 的值和 sk1 的值一模一样,但是这并不是我们想要的(浅拷贝),我们想要的拷贝(深拷贝)需要把数据空间拷贝一份新的出来。

  最终报错的地方和上面的拷贝构造函数一样,都是出在析构函数的 free() 函数处,也就是对同一块动态开辟的空间释放了两次。

2. 显示创建赋值运算符重载的 Stack 类

  下面就给出赋值运算符重载的定义,和测试文件的代码还有运行结果。

(1)赋值运算符重载

Stack& Stack::operator=(const Stack& sk)
{
// 自我赋值判断
if (this == &sk)
return *this;
// 增容判断
if (_capacity < sk._capacity)
{
DataType* tmp = (DataType*)realloc(_pdata, sizeof(DataType) * sk._capacity);
if (nullptr == tmp)
{
perror("Stack::operator=::realloc: ");
return *this;
}
// 更新成员变量
_pdata = tmp;
_capacity = sk._capacity;
_top = sk._top;
}
// 拷贝数据
memcpy(_pdata, sk._pdata, sk._top * sizeof(DataType));
return *this;
}

对于所有类类型的显式赋值运算符重载,都需要做到以下几点:
(1)检查是否自己给自己赋值;
(2)检查当前容量知否足够拷贝数据;
(3)拷贝数据和成员变量。

(2)测试文件 test.c
在这里插入图片描述
  可以看到在用 sk1 给 sk2 赋值之后,sk2 的内容和 sk1 一模一样。(当然两个的数据均存储在自身的动态内存空间中)、

(3)程序的运行结果如下:

在这里插入图片描述
在这里插入图片描述
  这里要注意几个问题,在使用 memcpy() 函数时,第三个函数是需要拷贝的字节数,不要传递成了元素个数。(因为作者这里错了,找了半天错误,然后发给AI看了一下,指出来了)

3. 赋值运算符重载的注意事项

(1)对自己给自己赋值进行检查;
(2)检查当前的容量是否足够容纳需要拷贝的数据;
(3)memcpy() 函数的第三个参数时拷贝数据的字节数。

六、取地址及 const 取地址操作符重载

  类的六大默认成员函数分别为:构造函数、析构函数、拷贝构造函数、赋值运算符重载、取地址操作符和 const 取地址操作符重载。

  一般情况下,并不需要去显式创建取地址操作符和 const 取地址操作符的重载。编译器默认生成的取地址操作符和 const 取地址操作符重载在大部分情况下已经够用了。

  一般情况下,编译器默认生成的取地址和 const 取地址操作符重载如下代码所示:
在这里插入图片描述
  普通类对象取地址返回普通地址,而 const 对象取地址返回 const 地址。


原文地址:https://blog.csdn.net/weixin_70742989/article/details/144595723

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!