C++ | 类与对象(上)

目录

前言

一、面向过程与面向对象 

二、类的引入 

三、类的定义

1、声明都放在结构体内

2、声明与定义分开放 

四、类的访问限定符与封装

1、访问限定符

2、封装

五、类的作用域

六、类的实例化

1、类的实例化

2、类的大小计算

七、this指针

1、this指针的引出 

2、this指针的特性


前言

        本章话题是C++的类与对象,主要分为三部分来解释C++中类与对象中的种种细节,我们都知道,市面上的一些主流语言大部分都采用了面向对象的编程方式,以前我们学过的C语言主要是一种面向过程的编程方式。而我们的C++采取的则是面向过程与面向对象相结合的方式进行混编(主要是因为C++向下兼容C),面向对象首先必须了解类的相关知识。

一、面向过程与面向对象 

        这个概念可能大家已经不陌生了,大家可能早已听闻关于面向对象和面向过程的编程方式,可能大家第一次接触相关概念有有些蒙,随着大家写代码的过程中,会对此处概念有了更加深入的了解,正如孔夫子所言,“温故而知新”,下面是我对面向过程与面向对象的了解;

 正如大家所知道的段子,我们把大象放进冰箱一共需要几步??

答:

(1)面向对象的方式思考   ----  三步

  • 打开冰箱
  • 把大象塞进去
  • 关上冰箱

(2)面向过程的方式思考

首先,我们先要定做一个能放得下的大象的冰箱,冰箱还得使用能承受得了大象体重得坚固材料,其次,我们得考虑如何把大象塞进冰箱,是用食物诱惑进去,还是用什么机器将大象运进去,等等等等

        我们可以体会到面向对象得过程并不考虑实现步骤的种种细节,而面向过程则需要将每一个细节考虑到位,那么问题又来了,假如我们又想把长颈鹿放进冰箱,又需要几步呢?? --- 4步

  • 打开冰箱
  • 把大象取出来
  • 把长颈鹿放进去
  • 关上冰箱

 理解到这里,是否又对面向对象又有更深刻的理解了呢???

二、类的引入 

        在C语言的结构体中,我们只能定义变量,而不能定义函数,而在C++的struct中,我们可以定义函数,如下所示 

struct Stack
{
	// 成员函数
	void Init(int capacity = 4)
	{
		_arr = (int*)malloc(sizeof(int) * capacity);
		if (nullptr == _arr)
		{
			perror("malloc申请空间失败");
			return;
		}
		_capacity = capacity;
		_top = 0;
	}
	void Push(int elem)
	{
		// ....
	}

	// 成员变量
	int* _arr;
	int _top;
	int _capacity;
};

        以上我们变定义了一个叫stack的类,实际上我们在C++定义类更喜欢用class来定义,其区别我们会在后面介绍,以下先介绍上述代码的一些细节;

补充细节一:有一些成员变量声明在成员函数的后面,为什么不需要前置声明? 

        细心的小伙伴或许已经看出来了,比如在初始化函数中,我们使用_arr变量,而这个变量声明在初始化函数后面,这不也就是违背了我们之前学的一个知识,在使用一个变量前,前面必须不是要有其声明或定义吗?在C++的类中,我们在类内的对象访问另外一个对象时,会在整个类内搜索,故无需前置声明!!

 补充细节二:类内部的成员变量,我们一般以下划线(_)开头,以示区分

三、类的定义

 class classname

{

        // 类内成员

}; 

// 后面有个分号

// classname 为类名

struct ListNode
{
	int _data;
	ListNode* _next;
};

        我们会经常看见这种代码,以上代码为对链表结点的声明,可以理解为我们对链表节点进行封装,将而在定义_next指针时,我们发现,与C语言结构体不同的是,在定义next指针时,我们并没有写关键字struct,在C++中,可以用类名声明一个对象或对象的指针;

 类定义的两种方式:

1、声明都放在结构体内

struct Stack
{
	// 成员函数
	void Init(int capacity = 4)
	{
		_arr = (int*)malloc(sizeof(int) * capacity);
		if (nullptr == _arr)
		{
			perror("malloc申请空间失败");
			return;
		}
		_capacity = capacity;
		_top = 0;
	}

	void Push(int elem)
	{
		// ....
	}
	// 成员变量
	int* _arr;
	int _top;
	int _capacity;
};

        通常这种类的声明和定义都放在一起的时候,我们有时候会将这个命名为 xxx.hpp,表示其中既有类的声明,也有定义

2、声明与定义分开放 

// Stack.h文件

#pragma once
#include <stdlib.h>

struct Stack
{
	// 成员函数
	void Init(int capacity = 4);

	void Push(int elem);
	// 成员变量
	int* _arr;
	int _top;
	int _capacity;
};
// Stack.cpp文件
#include "Stack.h"

// 注意此处要声明该函数来自于哪个类
void Stack::Init(int capacity)
{
	_arr = (int*)malloc(sizeof(int) * capacity);
	if (nullptr == _arr)
	{
		perror("malloc申请空间失败");
		return;
	}
	_capacity = capacity;
	_top = 0;
}

void Stack::Push(int elem)
{
	// ....
}

        分开放时,一定要注意在函数定义的前面要声明该函数来自于哪个类中,不然编译器会将该函数当作全局作用域中的函数;

补充细节:我们将函数定义在类内部时,编译器可能会将该函数视为内联函数,无论是有又inline关键字

 

四、类的访问限定符与封装

1、访问限定符

访问限定符有什么作用呢?

        访问限定符对类内数据进行分类,在类外访问类内数据时,我们通过访问限定符对访问权限进行分类,使数据类内数据更加安全。 

目前C++有三种访问限定符,我们暂时将其分为两类:

 我们可暂时将public划为一类,将protected与private划为一类;

public:

        public修饰的成员在类外可进行访问

private与protected:

        其修饰的成员仅仅只能在类内进行访问,类外无法进行访问,其具体区别将在后续继承时做介绍

使用:访问限定符左右于其声明行开始,直至下一个访问限定符或类的结尾

class A
{
public:
	void func1();  // 公有
	void func2();
private:
	int _a; // 私有
	int _b;
public:
	int _c; // 公有
	int _d;
};

        在上述代码中,函数func1和函数func2为公有成员,可以在类内访问,可以在类外访问,而_a 与_b为私有成员,只能在类内进行访问,_c与_d为公有成员,同样可以在类外访问,也可以在类内访问;

 补充细节:struct默认的访问权限符为public,class默认的访问权限符为private;

2、封装

面试题:类的三大特性是什么?

封装、继承、多态

此处我们不谈继承和多态,后面会专门介绍

 那么什么是封装呢?

封装:封装是将对象的数据与操作对象的方法结合到一起,隐藏对象的属性以及实现细节,通过对外提供管理数据的接口,而不直接让用户访问或修改操作对象的数据

五、类的作用域

        在声明一个类的时候,同时,该类生成了一个类的作用域,我们在类外定义类成员函数时,我们必须指定其所属的类域;

// 声明类
class A
{
public:
	int ADD();
private:
	int _a;
	int _b;
};

// 定义类内函数
int A::ADD()
{
	return _a + _b;
}

        在定义ADD函数时,我们必须指定ADD函数是来自于A类域的,不然会找不到对应的类内成员;

六、类的实例化

1、类的实例化

        当我们创建了一个类的模型,我们只是对该类的声明,只有我们用该类创建一个实例对象(变量)的时候,我们才算将该类的实例化,一个类可以实例化出多个对象,而一个对象只属于一种类;

// 类的声明
class A
{
public:
	int ADD();
private:
	int _a;
	int _b;
};
int A::ADD()
{
	return _a + _b;
}


int main()
{
    // 类的实例化
	A a1;
	A a2;
	return 0;
}

        上述代码中,我们将类进行了实例化,生成了两个类的对象a1与a2;在理解上述类与对象的关系时,我们可以将我们声明的类比作房子设计图,而我们用该类定义对象,也就是意味着我们用房子设计图建造出的一栋又一栋的房子;

补充细节: 在实例化类中,OS会为该类的对象分配内存空间,而我们在声明该类的时候,并不会为其分配内存空间;

2、类的大小计算

        回想C语言的结构体,我们在创建一个结构体对象时,我们也会为其分配内存空间,而在C++的类与对象中,当我们实例化一个类后,该对象也有其内存空间;

class Student
{
public:
	int getAge()
	{
		return _age;
	}
private:
	char _name[16];
	char _gender[4];
	int _age;
};

// 以下代码的输出结果是什么???
int main()
{
	Student stu1;
	cout << sizeof(stu1) << endl;
	return 0;
}

 在计算类创建的对象大小时,规则同结构体计算规则一致,如我们所想结果为24;

 结构体对齐规则:

  1. 第一个成员在与结构体偏移量为0的地址处。
  2. 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。注意:对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。VS中默认的对齐数为8。
  3. 结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。
  4. 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。

了解了对齐规则后,我们再来看以下三个类的大小分别为多少? 


// 类中既有成员变量,又有成员函数
class A1 {
public:
	void f1() {}
private:
	int _a;
};

// 类中仅有成员函数
class A2 {
public:
	void f2() {}
};

// 类中什么都没有---空类
class A3
{};

细节的小伙伴可能早就发现了,在我们之前的计算中,我们并没有计算函数的大小,同样上面三种类我们也不计算其函数大小,那么大小应分别为:4  0  0,结果是不是如此呢?

如下图;

        我们发现第二个与第三个类并非我们想想的0,而是1,这到底是为什么呢?难道类中的成员函数会占用内存??那第三个类也没有成员函数呀,也不对劲呀!下面我来解释

 1、关于占用一个字节的原因

        当我们对以上空类实例化的对象进行取地址时,我们可以取到其地址,说明该对象一定会被分配空间,实际上,1字节是为了占位,当我们实例化后,一定要给这个对象分配空间,而分配空间则一定会有地址,证明其存在过,不管类内是否有成员对象,我们都至少为其分配1字节的空间;

 2、关于成员函数到底储存在哪里的问题

        前面我们在计算类的大小时,我们也发现成员函数并不会占用内存空间,也就是说成员函数并不会储存在类的内部,那么成员函数究竟储存在哪里呢?? -- 答案是代码段中

        实际上我们仔细想想,放在代码段实际上也比较合理,一个类可以实例化多个对象,而每个对象的数据实际上是独立的,需要每个对象拥有一份自己的数据,而函数方法,所有对象只需要共用一份就行了,因此以节约内存空间的角度来看,确实只需要一份就行了

七、this指针

1、this指针的引出 

我们首先定义了一个日期类;如下

class Data
{
public:
	void init(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Data d1;
	Data d2;
	d1.init(2023, 5, 27);
	d1.init(2023, 5, 28);
	return 0;
}

         在以上代码中,我们实例化了两个日期类对象d1与d2,当我们调用初始化函数时,我们都知道函数只有一份,且存在了代码段,那么我们从函数的视角来看,我们收到了传过来的年月日,我们也都知道,每个对象都有自己的数据,也即是有自己的年月日,可函数只收到了年月日的参数,那么函数怎么知道应该给哪个对象初始化呢??

        实际上,在上述案例中,我们看着好像只传了三个参数,实际上,编译器会帮我们多传一个参数,这个参数实际上是这个对象的地址,所以以上代码是如下所示;

        因为这个this指针,我们也就知道了我们应该给哪个对象进行初始化,以及对哪个对象的数据进行操作。我们可以在函数体中显示的使用这个this指针,比如下面对 this 指针的打印;

2、this指针的特性

 this指针的类型为  类类型* const , 也就是说,this指针无法改变指向

        this指针只能在成员函数内部使用,这个就比较好理解了,我们知道,只有在调用成员函数时,我们才传this指针,因此只能在成员内部使用;

        this指针本质上是 “成员函数” 的形参,当对象调用成员函数时,才将this指针传过去,因此对象中也不需要储存this指针;

了解了以上特性后,我们来尝试解答下面的两个面试题;

1、this指针存在哪里? 

答案:栈上,因为this指针本质上还是形参,而形参储存在栈上,只有在对象调用形参的时候,我们才传入这个参数,当调用结束后,同样也会对该参数进行销毁;

2、this指针可以为空吗?观察以下题目,猜测运行结果是什么 

// 1.下面程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行
class A
{
public:
	void Print()
	{
		cout << "Print()" << endl;
	}
private:
	int _a;
};
int main()
{
	A* p = nullptr;
	p->Print();
	return 0;
}

        结果为正常运行,有很多小伙伴们就不解了,这不是空指针的解引用吗?怎么没有运行崩溃。结合我们上面的知识,我们知道,类内的成员函数并不会储存在类内部,而是储存在代码段中,而代码会转化为汇编指令来依次执行,编译器很聪明,看到我们准备调用类内函数时,并不会解引用去类内寻找函数的地址,而是直接去代码段寻找该函数的地址,因此不会崩溃,具体转换为汇编的指令如下;

// 2.下面程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行
class A
{
public:
	void PrintA()
	{
		cout << _a << endl;
	}
private:
	int _a;
};
int main()
{
	A* p = nullptr;
	p->PrintA();
	return 0;
}

        以上代码会运行崩溃,同理,我们可以推测,我们可以正常进入成员函数,而在成员函数内部,我们在访问_a时,实际上会转化为 this->_a,此时我们对空指针进行了解引用,因此会运行崩溃;

// 3.下面程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行
class A
{
public:
	void Print()
	{
		cout << "Print()" << endl;
	}
private:
	int _a;
};
int main()
{
	A* p = nullptr;
	(*p).Print();
	return 0;
}

        以上代码会正常运行,虽然看着像我们对空指针解引用,实际上,编译器识别到你要访问成员函数,就会将该代码转换为访问代码段函数的汇编指令,与第一题同理,并不会出现空指针解引用的情形;

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值