C++面向对象—类和对象那些你不知道的细节原理


课程总目录



一、面向对象编程和this指针

在这里插入图片描述

OOP语言的四大特征:抽象、封装、继承、多态

封装也称为隐藏,是通过类里面的访问限定符(public、private、protected) 体现出来的

程序示例:

#include <iostream>
using namespace std;

const int NAME_LEN = 10;

class CGoods	// 命名规则:C开头代表是类,后面的单词均大写开头
{
public:	// 给外部提供公有的成员方法,来访问私有的属性
	// 做商品数据的初始化用的
	void init(const char* name, double price, int amount);
	// 打印商品信息
	void show();

	// 给成员变量提供一个getXxx或setXxx的方法
	// 类体内实现的方法,会自动处理成inline内联函数
	void setName(char* name) { strcpy(_name, name); }
	void setPrice(double price) { _price = price; }
	void setAmount(int amount) { _amount = amount; }

	const char* getName() { return _name; }
	double getPrice() { return _price; }
	int getAmount() { return _amount; }

private:	// 类属性一般都是私有的成员变量,下划线开头用以区分
	char _name[NAME_LEN];
	double _price;
	int _amount;
};

void CGoods::init(const char* name, double price, int amount)
{
	strcpy(_name, name);
	_price = price;
	_amount = amount;
}

void CGoods::show()
{
	cout << "name: " << _name << endl;
	cout << "price: " << _price << endl;
	cout << "amount: " << _amount << endl;
}


int main()
{
	CGoods good1;
	good1.init("面包", 10.0, 200);
	good1.show();
	// name: 面包
	// price: 10
	// amount: 200

	good1.setPrice(20.5);
	good1.setAmount(100);
	good1.show();
	// name: 面包
	// price: 20.5
	// amount: 100

	CGoods good2;
	good2.init("空调", 10000.0, 50);
	good2.show();
	// name: 空调
	// price: 10000
	// amount: 50

	return 0;
}

注:

  • 类本身不占空间,定义出来的对象才占用空间,对象的内存在栈上
  • 对象占用内存的大小,只和成员变量有关(不考虑static成员变量),与成员方法无关。占用大小的计算方式是先找占用内存最长的成员变量,以他为内存对齐的方式,然后计算出总的对象的大小
  • 类方法的实现可以有两种
    1. 类内:会被处理成inline内联函数
    2. 类外:要加类的作用域,加在方法名的前面。类外的方法是普通方法,要实现内联,要在最前面加inline

this指针:

CGoods可以定义无数的对象,每一个对象都有自己的成员变量,但是它们共享一套成员方法

那么,不同成员变量调用同一成员方法的时候,方法是怎么知道处理哪个对象的信息呢?这就是this指针的作用

从底层来说,调用init的时候为init(&good1, "面包", 10.0, 200);,在汇编上之后函数调用,没有什么面向对象,编译的时候会被转化

同时,类成员方法一经编译,方法的参数都会添加一个this指针,用来接收调用该方法的对象的地址,如void init(CGoods* this, const char* name, double price, int amount);

使用成员变量的时候可以写上this指针,如this->_price = price;,我们不写的话编译器会自动写

二、构造函数和析构函数

构造函数和析构函数是用于管理对象生命周期的两种特殊函数。构造函数在对象创建时自动调用,用于初始化对象;析构函数在对象销毁前自动调用,用于清理资源。

构造函数 :在创建对象时自动调用的特殊成员函数。它的主要作用是初始化对象的状态,即设置对象的数据成员的初始值。构造函数的名字与类名相同

构造函数特点

  1. 与类名相同
  2. 没有返回类型:构造函数没有返回类型,即使是void也不行。
  3. 自动调用:在创建对象时,构造函数会被自动调用。

析构函数:在对象销毁前自动调用的特殊成员函数。它的主要作用是执行清理工作,例如释放资源(内存、文件句柄等)。析构函数的名字通常在类名前加一个波浪号

析构函数特点:

  1. 与类名相同,但前面加一个波浪号
  2. 没有返回类型和参数:析构函数没有返回类型,也不能带参数。
  3. 自动调用:在对象的生命周期结束时,析构函数会被自动调用。

程序示例:

#include <iostream>
using namespace std;


class SeqStack
{
public:
	// 构造函数
	SeqStack(int size = 10)	// 是可以带参数的,因此可以提供多个构造函数,叫做构造函数的重载
	{
		cout << this << " SeqStack()" << endl;
		_pstack = new int[size];
		_top = -1;
		_size = size;
	}

	//析构函数
	~SeqStack()//是不带参数的,所有析构函数只能有一个
	{
		cout << this << " ~SeqStack()" << endl;
		delete[] _pstack;
		_pstack = nullptr;
	}

	void push(int val)
	{
		if (full())
			resize();
		_pstack[++_top] = val;
	}

	void pop()
	{
		if (empty())
			return;
		--_top;

	}

	int top()
	{
		return _pstack[_top];
	}

	bool empty() { return _top == -1; }
	bool full() { return _top == _size - 1; }

private:
	int* _pstack;	// 动态开辟数组,存储顺序栈的元素
	int _top;		// 指向栈顶元素的位置
	int _size;		// 栈的总大小
	void resize()
	{
		int* ptmp = new int[_size * 2];
		for (int i = 0; i < _size; i++)
		{
			ptmp[i] = _pstack[i];
		} // memcpy(ptmp,_pstack,sizeof(int)*_size);
		// 在这块用memcpy没有问题,因为这里面数组栈里面存的都是整型,可以直接做内存的拷贝
		// 但是有时在扩容的时候,有可能是对象,会产生问题。
		// memcpy和realloc都是内存拷贝,不适合用在类里,容易产生问题
		// 具体的看下一节的深拷贝和浅拷贝

		delete[]_pstack;
		_pstack = ptmp;
		_size *= 2;
	}
};

SeqStack gs;//全局对象,程序结束后析构

int main()
{
	SeqStack* ps = new SeqStack(60);// malloc堆内存开辟+SeqStack对象构造
	ps->push(70);
	ps->push(80);
	ps->pop();
	cout << ps->top() << endl;
	delete ps;// ps->~SeqStack()+free(ps)
	// 这也就是delete和free的区别
	// delete会调用对象的析构函数,然后释放内存,分为两步
	// free 只释放内存,不会调用对象的析构函数

	// 定义一个对象的步骤:1.开辟内存;2.调用构造函数
	SeqStack s;
	for (int i = 0; i < 15; i++)
		s.push(i);

	while (!s.empty())
	{
		cout << s.top() << " ";
		s.pop();
	}
	cout << endl;

	// SeqStack s1(50);
	// s1.~SeqStack();	// 析构函数调用后对象不存在了,就不要再调用它的方法了
	// s1.push(30);		// 堆内存的非法访问,因为上面已经调用析构函数了!

	return 0;
}

运行结果:

00E5F460 SeqStack()
00AF32B8 SeqStack()
70
00AF32B8 ~SeqStack()
008FFD3C SeqStack()
14 13 12 11 10 9 8 7 6 5 4 3 2 1 0
008FFD1C SeqStack()
008FFD1C ~SeqStack()
008FFD3C ~SeqStack()
00E5F460 ~SeqStack()

注:

  • .data上定义的对象在程序启动时构造,程序结束被析构。上例gs
  • heap堆上的对象new的时候构造,delete的时候析构。上例ps
  • stack栈上定义的对象在进入函数进行到它定义的地方才构造,出函数就会被析构。上例s

三、对象的浅拷贝和深拷贝

浅拷贝(Shallow Copy):指的是复制对象时,仅复制对象的基本数据类型成员,而对于指针或引用等复杂数据类型,仅复制其地址。换句话说,浅拷贝后的新对象和原对象的指针指向同一块内存区域。如果其中一个对象修改了指针指向的内容,另一个对象也会受到影响。
在这里插入图片描述

深拷贝(Deep Copy):指的是复制对象时,不仅复制对象的基本数据类型成员,还为指针或引用等复杂数据类型分配新的内存,并复制其内容。这样,深拷贝后的新对象和原对象独立存在,互不影响。
在这里插入图片描述

关键区别

  1. 内存共享
    • 浅拷贝:新旧对象共享同一块内存区域,修改其中一个对象会影响另一个对象。
    • 深拷贝:新旧对象拥有各自独立的内存区域,互不影响。
  2. 实现复杂度
    • 浅拷贝:实现简单,通常由编译器默认生成的拷贝构造函数完成。
    • 深拷贝:实现复杂,需要自定义拷贝构造函数,确保所有指针和动态分配的资源都被正确复制。
  3. 适用场景
    • 浅拷贝:适用于不涉及动态内存分配或共享资源的对象。
    • 深拷贝:适用于涉及动态内存分配或需要独立资源管理的对象。

拷贝构造:拷贝构造函数是在创建对象时使用另一个同类型的对象初始化新对象的一种构造函数

程序示例:

class SeqStack
{
public:
	...

	// 对象的浅拷贝有问题 -> 自定义拷贝构造
	SeqStack(const SeqStack& src)
	{
		cout << "拷贝构造" << endl;
		_pstack = new int[src._size];
		for (int i = 0; i <= src._top; i++)
			_pstack[i] = src._pstack[i];
		_top = src._top;
		_size = src._size;
	}
	
	...
	
private:
	int* _pstack;
	int _top;
	int _size;
	void resize(){...}
};

int main()
{
	SeqStack s1(10);
	SeqStack s2 = s1;
	// SeqStack s2(s1); 同上,是一个语句的不同写法
}

上例扩容的时候用的是for循环,为什么不用memcpyrealloc呢?

在这里插入图片描述
赋值函数:赋值运算符重载用于将一个对象的内容赋值给另一个已存在的同类型对象

默认的赋值函数也是做直接的内存拷贝(浅拷贝),想要进行深拷贝需要我们对赋值函数进行重载

一般步骤:1. 防止自赋值;2. 释放当前对象占用的外部资源;3.做拷贝构造做的事情

程序示例:

class SeqStack
{
public:
	...

	// 对象的浅拷贝有问题 -> 赋值函数重载
	void operator=(const SeqStack& src)
	{
		cout << "赋值重载函数" << endl;
		// 1.防止自赋值(如:s1 = s1)
		if (this == &src)
			return;
		// 2. 释放当前对象占用的外部资源
		delete[] _pstack;
	
		// 3.做拷贝构造做的事情
		_pstack = new int[src._size];
		for (int i = 0; i <= src._top; i++)
			_pstack[i] = src._pstack[i];
		_top = src._top;
		_size = src._size;
	}

	...
	
private:
	int* _pstack;
	int _top;
	int _size;
	void resize(){...}
};

int main()
{
	SeqStack s1(10);
	SeqStack s2 = s1;

	s2 = s1; // s2.operator=(s1);
	// 若是浅拷贝,那么此时s2和s1指向的是同一块堆内存
	// 同时会把拷贝构造给s2开辟的新的堆内存的地址丢失,连释放的机会都没了
	// 所以我们要重载赋值函数
}

对象默认的拷贝构造和赋值函数都是做内存的数据拷贝(浅拷贝)。对象如果占用外部资源,那么浅拷贝就出现问题了!析构时会对同一资源进行多次释放,后析构的那一个就会出错!所以我们要自定义拷贝构造和赋值函数来进行深拷贝,让每一个对象拥有自己的外部资源!

注意区分对象的初始化和赋值:

  • 初始化SeqStack s2 = s1;,会调用拷贝构造
  • SeqStack s2; s2 = s1;,这是赋值,因为s2已经存在了,会调用赋值函数

四、拷贝构造和赋值重载函数应用代码实践

1、编写类String的构造函数、析构函数和赋值函数

题目:已知类String的原型为

class String
{
public:
	String(const char* str = nullptr);		// 普通构造函数
	String(const String& other);			// 拷贝构造函数
	~String(void);							// 析构函数
	String& operator=(const String& other);	// 赋值重载函数
private:
	char* m_data;							// 用于保存字符串
};

请编写String的上述4个函数

答案

class String
{
public:
	String(const char* str = nullptr)	// 构造函数
	{
		if (str != nullptr)
		{
			m_data = new char[strlen(str) + 1];
			strcpy(m_data, str);
		}
		else
		{
			// m_data = nullptr
			// 不给字符串置空,不然其他方法总是需要判断字符串是否为空,比较麻烦
			m_data = new char[1];
			*m_data = '\0';
		}
	}

	String(const String& src)	// 拷贝构造
	{
		m_data = new char[strlen(src.m_data) + 1];
		strcpy(m_data, src.m_data);
	}

	~String()	// 析构函数
	{
		delete[] m_data;
		m_data = nullptr;
	}

	// 返回String&是为了支持连续的operator=赋值操作
	String& operator=(const String& src)
	{
		// 防止自赋值->删除当前占用外部资源->拷贝
		if (this == &src)
			return *this;
		delete[] m_data;
		m_data = new char[strlen(src.m_data) + 1];
		strcpy(m_data, src.m_data);
		return *this;
	}

private:
	char* m_data;	// 用于保存字符串
};

注意到上面的赋值重载函数返回的是String&而不是void,这是为了支持连续的operator=赋值操作,也即str3 = str1 = str2;
分步解析str1 = str2; str1.operator=(str2);->String& str3 = str1(返回的String&)
如果写void,那么在第二步str1.operator=(str2);会返回void,再将其赋值给str3就会报错

2、实现一个循环队列

class Queue
{
public:
	Queue(int size = 10)	// 构造函数
	{
		_pQue = new int[size];
		_front = _rear = 0;
		_size = size;
	}

	Queue(const Queue& src)	// 拷贝构造
	{
		_front = src._front;
		_rear = src._rear;
		_size = src._size;
		_pQue = new int[_size];
		for (int i = _front; i != _rear; i = (i + 1) % _size)
			_pQue[i] = src._pQue[i];
	}

	Queue& operator=(const Queue& src)	// 赋值重载函数
	{
		if (this == &src)
			return *this;
		delete[]_pQue;
		_front = src._front;
		_rear = src._rear;
		_size = src._size;
		_pQue = new int[_size];
		for (int i = _front; i != _rear; i = (i + 1) % _size)
			_pQue[i] = src._pQue[i];
		return *this;
	}

	~Queue()	// 析构函数
	{
		delete[] _pQue;
		_pQue = nullptr;
	}

	void enQueue(int val)	//入队操作
	{
		if (full())
			resize();
		_pQue[_rear] = val;
		_rear = (_rear + 1) % _size;
	}
	void deQueue()	//出队操作
	{
		if (empty())
			return;
		_front = (_front + 1) % _size;
	}

	int front()	// 获取队头元素
	{
		return _pQue[_front];
	}

	bool full() { return (_rear + 1) % _size == _front; }
	bool empty() { return _front == _rear; }
private:
	int* _pQue;	// 申请队列的数组空间
	int _front;	// 指示队头的位置
	int _rear;	// 指示队尾的位置
	int _size;	// 队列总大小

	void resize()
	{
		int* ptmp = new int[2 * _size];
		int index = 0;
		for (int i = _front; i != _rear; i = (i + 1) % _size)
		{
			ptmp[index] = _pQue[i];
			index++;
		}
		delete[]_pQue;
		_pQue = ptmp;
		_front = 0;
		_rear = index;
		_size *= 2;
	}
};

五、构造函数初始化列表

程序示例:

class CDate
{
public:
	CDate(int y, int m, int d)	// 自定义了一个构造函数,编译器就不会再产生默认构造函数了
	{
		_year = y;
		_month = m;
		_day = d;
	}

	void show()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}

private:
	int _year;
	int _month;
	int _day;
};


// 构造函数的初始化列表:可以指定当前对象成员变量的初始化形式
// CDate信息是CGoods信息的一部分,是组合关系(a part of)
class CGoods
{
public:
	CGoods(const char* n, int a, double p, int y, int m, int d)
		// 构造函数的初始化列表
		: _date(y, m, d)
		, _amount(a)//相当于int _amount = a
		, _price(p)
	{
		// 当前类类型构造函数体
		strcpy(_name, n);
		// _amount = a;相当于int _amount;_amount=a;
	}

	void show()
	{
		cout << "name:" << _name << endl;
		cout << "amount:" << _amount << endl;
		cout << "price:" << _price << endl;
		_date.show();
	}

private:
	char _name[20];
	int  _amount;
	double _price;
	CDate _date; // 对象成员 1.分配内存 2.调用构造函数
};


int main()
{
	CGoods goods("商品1", 100, 35.0, 2019, 5, 21);
	goods.show();
	return 0;
}

输出:

name:商品1
amount:100
price:35
2019/5/21

那么,在构造函数的函数体里初始化和在初始化列表中初始化有什么区别?

比如_amount=a在初始化列表中相当于int _amount=a;,在构造函数函数体中相当于int _amount; amount=a;,即在定义变量的时候直接初始化

在初始化列表中_date(y,m,d)相当于CDate _date(y,m,d)指定了日期对象的构造方式;但是在构造函数体中写成_date=CDate(y,m,d);的前提是把对象构造起来,即CDate _date;,那我们重写了CDate的构造函数之后就没有默认构造了,这句话本身就会报错,所以对象成员的初始化必须写在当前构造函数的初始化列表

同时要注意!成员变量的初始化和它们定义的顺序有关,和构造函数初始化列表中出现的先后顺序无关!不要被迷惑!

class Test
{
public:
	Test(int data = 10) : mb(data), ma(mb) {}
	void show() { cout << "ma:" << ma << " mb:" << mb << endl; }
private:
	int ma;
	int mb;
};
int main()
{
	Test t;		// 栈开辟默认初始化是:0xCCCCCCCC -858993460
	t.show();	// ma:-858993460 mb:10
	return 0;
}

六、详解类的各种成员方法

1、普通的成员方法 → \to 编译器会添加一个this指针形参变量

  • 属于类的作用域
  • 调用该方法时,需要依赖一个对象 (常对象无法调用,因为this指针传入实参为const CGoods* this,形参为CGoods* this,类型转换会出错)
  • 可以任意访问对象的私有成员

2、static静态成员方法 → \to 不会生成this指针形参变量

  • 属于类的作用域
  • 用类名作用域来调用方法
  • 可以访问对象的私有成员,但仅限于不依赖对象的成员,即只能调用static静态成员
  • static成员变量在类内属于声明,一定要在类外进行定义并且初始化
  • 如果方法访问的是所有对象共享的信息的,最好把这个方法写成static方法
  • 静态成员变量的内存在数据段.data/.bss中,其占用的空间在计算对象占用空间大小的时候不会被纳入计算

3、const常成员方法 → \to const CGoods* this

  • 属于类的作用域
  • 调用依赖于一个对象,普通对象或者常对象都可以
  • 可以任意访问对象的私有成员,但是只能读,不能写
class CDate {...};

class CGoods
{
public:
	CGoods(const char* n, int a, double p, int y, int m, int d)
		:_date(y, m, d)
		, _amount(a)
		, _price(p)
	{
		strcpy(_name, n);
		_count++; // 记录所有产生的新对象的数量
	}

	// 普通成员方法
	void show()// 打印商品的私有信息CGoods* this
	{
		cout << "name:" << _name << endl;
		cout << "amount:" << _amount << endl;
		cout << "price:" << _price << endl;
		_date.show();
	}

	// 常成员方法
	// 如果不加const,常对象调用的时候就是const CGoods* this->CGoods* this,类型转换出错
	// 所以一般只要是只读操作的成员方法,一律实现成const常成员方法,这样普通对象和常对象都能调用
	void show() const // const CGoods *this
	{
		cout << "name:" << _name << endl;
		cout << "amount:" << _amount << endl;
		cout << "price:" << _price << endl;
		_date.show();
	}

	// 静态成员方法,没有this指针
	static void showCGoodsCount() // 打印的是所有商品共享的信息
	{
		cout << "所有商品的种类数量是:" << _count << endl;
	}
private:
	char _name[20];
	int  _amount;
	double _price;
	CDate _date;
	static int _count; // 声明,用来记录商品对象的总数量
	// static声明的变量不属于对象,而是属于类级别的 
};

// static成员变量一定要在类外进行定义并且初始化
int CGoods::_count = 0;


int main()
{
	CGoods good1("商品1", 100, 35.0, 2019, 5, 21);
	good1.show();

	CGoods good2("商品2", 100, 35.0, 2019, 5, 21);
	good2.show();

	// 统计所有商品的总数量
	CGoods::showCGoodsCount();	// 2

	// 常对象
	const CGoods good5("非卖品商品", 100, 35.0, 2019, 5, 21);
	good5.show();	// 常对象调用方法会传入const CGoods* this
	return 0;
}
  1. 重点是注意this指针,普通成员方法接收普通的xxx* this,常成员方法接收const xxx* this,静态方法没有this指针
  2. 构造函数不需要也不应该被声明为const,因为它们负责初始化对象,而对象在初始化完成之前还不是const。常对象的const 性质在对象构造完成后才会生效,确保对象的成员变量不会在初始化之后被修改

七、指向类成员(成员变量和成员方法)的指针

在 C++ 中,指向成员变量(或成员方法)的指针和普通指针不太一样。普通指针直接指向一个内存地址,而指向成员变量的指针则表示一个类的成员变量的偏移量。这样的指针需要和一个具体的对象结合起来使用,才能访问该对象的成员变量。

class Test
{
public:
	void func() { cout << "call Test::func" << endl; }
	static void static_func() { cout << "call Test::static_func" << endl; }
	int ma;
	static int mb;
};

int Test::mb;

定义指向成员变量的指针:

int main()
{
	Test t1;
	Test* t2 = new Test();

	// "int Test::*" 类型的值不能用于初始化 "int *" 类型的实体
	// int* p = &Test::ma;
	int Test::* p = &Test::ma;
	t1.*p = 20;
	cout << t1.ma << " " << t1.*p << endl;		// 20 20
	t2->*p = 30;
	cout << t2->ma << " " << t2->*p << endl;	//30 30

	// mb不依赖对象,因此p1不用加Test::
	int* p1 = &Test::mb;
	*p1 = 40;
	cout << *p1 << endl;	//40

	delete t2;
	return 0;
}

定义指向成员方法的指针:

int main()
{
	Test t1;
	Test* t2 = new Test();

	void(Test:: * pfunc)() = &Test::func;
	(t1.*pfunc)();	// call Test::func
	(t2->*pfunc)();	// call Test::func

	// static成员方法不依赖对象,不需要加Test::
	void(*static_pfunc)() = &Test::static_func;
	static_pfunc();	// call Test::static_func

	delete t2;
	return 0;
}
  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

GeniusAng丶

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值