【C++】类和对象【上篇】

前言

面向过程 (Procedure Oriented) 是一种 以过程为中心 的编程思想。面向对象是一种 对现实世界理解和抽象的方法 ,是计算机编程技术发展到一定阶段后的产物。 C++ 在 C 语言的基础上增加了面向对象编程,C++ 支持面向对象程序设计。类是 C++ 的核心特性,通常被称为用户定义的类型。

面向对象三大特性:

1.封装性:数据和代码捆绑在一起,避免外界干扰和不确定性访问。封装可以使得代码模块化

优点:

(1)确保用户代码不会无意间破坏封装对象的状态

(2)被封装的类的具体实现细节可以随时改变,而无须调整用户级别的代码

2.继承性:让某种类型对象获得另一个类型对象的属性和方法。继承可以扩展已存在的代码

3.多态性:同一事物表现出不同事物的能力,即向不同对象发送同一消息,不同的对象在接收时会产生不同的行为(重载实现编译时多态,虚函数实现运行时多态)。多态的目的则是为了接口重用

一、面向过程和面向对象初步认识

C语言是面向过程的,关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题。

在这里插入图片描述

C++是基于面向对象的,关注的是对象,将一件事情拆分成不同的对象,靠对象之间的交互完成。

在这里插入图片描述

C语言是面向过程的,C++是在c语言的基础上增加面向对象的思想,所以C++是基于面向对象的(既有面向过程,也有面向对象,因为C++要兼容C语言)

二、类的引入

C语言结构体中只能定义变量,在C++中,结构体内不仅可以定义变量,也可以定义函数。比如:在数据结构中,用C语言方式实现的栈,结构体中只能定义变量;现在以C++方式实现,会发现struct中也可以定义函数。

#include <iostream>
#include <assert.h>
using namespace std;

typedef int DataType;

struct Stack
{
    
    
private:
	DataType* _array;
	size_t _capacity;
	size_t _size;

public:

	//初始化
	void Init(size_t capacity)
	{
    
    
		_array = (DataType*)malloc(sizeof(DataType) * capacity);
		if (_array == nullptr)
		{
    
    
			perror("malloc fail");
			exit(-1);
		}
		_capacity = capacity;
		_size = 0;
	}

	//入栈
	void Push(const DataType& data)
	{
    
    
		_array[_size] = data;
		_size++;
	}

	//获取栈顶元素
	DataType Top()
	{
    
    
		return _array[_size - 1];
	}

	//栈的销毁
	void Destroy()
	{
    
    
		if (_array)
		{
    
    
			free(_array);
			_array = nullptr;
			_capacity = 0;
			_size = 0;
		}
	}
};

int main()
{
    
    
	Stack s;
	s.Init(10);
	s.Push(1);
	s.Push(2);
	s.Push(3);
	cout << s.Top() << endl;
	s.Destroy();

	return 0;
}

对于C语言来说,结构体只能定义一个类型,类型里面只有类型的属性,然后用这种类型来创建出具体的对象,但也仅仅只有对象的属性。一个对象除了具有自身的属性,还应该有自己的方法。所以在C++中对struct进行了升级,在strucu中不仅可以定义对象的属性,还可以定义对象所具有的方法;比如在上面的代码中,我们不仅定义的栈的top,capacity,a这些变量,还定义了栈的Init,Top ,Destroy等等,上面结构体的定义,在C++中更喜欢用class来代替。class是C++的一个关键字,用来定义一个类。因为C++兼容C语言,所以在C++中保留了C语言struct的所有用法,同时又可以用来定义一个类。

C++结构体可以直接使用structName代表类,也可以用struct + structName的方式定义变量。比如下面的代码。

typedef struct Stack
{
    
    
    int* a;
    int top;
    int capacity;
}ST;

int main()
{
    
    
    //C语言
    struct stack st1;
    ST* st2;
    
    //C++
    stack st3;
}

三、类的定义

class className
{
    
    
// 类体:由成员函数和成员变量组成
};  // 一定要注意后面的分号

class为定义类的关键字,ClassName为类的名字,{}中为类的主体,注意类定义结束时后面分号不能省略

类体中内容称为类的成员:类中的变量称为类的属性成员变量; 类中的函数称为类的方法或者成员函数

类的两种定义方式:

1.声明和定义全部放在类体中,需注意:成员函数如果在类中定义,编译器可能会将其当成内

联函数处理。

class Person
{
    
    

    //成员变量
private:
	char _name[10];
	int _age;
	int  _height;
	double _weight;

	//成员函数
public:
	void showInfo()
	{
    
    
		cout << _name << " " << _age << " " << _height << " " << _weight << endl;
	}
};

2.类声明放在.h文件中,成员函数定义放在.cpp文件中,注意:成员函数名前需要加类名::

.h文件代码:

class Person
{
    
    

	//成员变量
private:
	char _name[10];
	int _age;
	int  _height;
	double _weight;

	//成员函数
public:
	void showInfo();
};

.c文件代码:

//成员函数实现
void Person::showInfo()
{
    
    
	cout << _name << " " << _age << " " << _height << " " << _weight << endl;
}

成员变量命名规则的建议:

// 我们看看这个函数,是不是很僵硬?
class Date
{
    
    
public:
    void Init(int year)
    {
    
    
    // 这里的year到底是成员变量,还是函数形参?
        year = year;
        month = month;
        day = day;
    //或者采用以下的方式
        Date::year = year;
        Date::month = month;
        Date::day = day;
    //或者这个方式
        this->year = year;
        this->month = month;
        this->day = day;
    }
private:
    int year;
    int month;
    int day;
};

对于上面的几种方式,不是无法区分就是比较麻烦,所以在C++惯例中,在初始化成员时,成员变量和函数形参分不清和书写麻烦,所以我们建议采用以下的方式:

class Date
{
    
    
public:
    void Init(int year)
    {
    
    
        _year = year;
        _month = month;
        _day = day;
    }
private:
    int _year;
    int _month;
    int _day;
};
class Date
{
    
    
public:
    void Init(int year)
    {
    
    
        mYear = year;
        mmonth = month;
        mday = day;
    }
private:
    int mYear;
    int mmonth;
    int mday;
};

其他方式也可以的,主要看公司要求。一般都是加个前缀或者后缀标识区分就行。

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

1.访问限定符

C++实现封装的方式:用类将对象的属性与方法结合在一块,让对象更加完善,通过访问权限选

择性的将其接口提供给外部的用户使用

在这里插入图片描述

【访问限定符说明】

1.public修饰的成员在类外可以直接被访问

2.protected和private修饰的成员在类外不能直接被访问(此处protected和private是类似的)

3.访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现时为止

4.如果后面没有访问限定符,作用域就到 } 即类结束。

5.class的默认访问权限为private,struct为public(因为struct要兼容C)

访问限定符的存在使得用户不能直接修改类中的成员变量,而是只能使用我们提供的函数/接口进行访问,让类中的数据更加的安全,也让用户使用类的方式更加的规范。

【注意】:

1.访问限定符只在编译时有用,当数据映射到内存后,没有任何访问限定符上的区别

2.访问限定符限定的知识类外的访问权限,在类中可以随意访问。

3.C++中struct和class的区别是什么?

C++需要兼容C语言,所以C++中struct可以当成结构体使用。另外C++中struct还可以用来定义类。和class定义类是一样的,区别是struct定义的类默认访问权限是publicclass定义的类默认访问权限是private。在继承和模板参数列表位置,struct和class也有区别。

2.封装

面向对象的三大特性:封装、继承、多态

在类和对象阶段,主要是研究类的封装特性,那什么是封装呢?

封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来

和对象进行交互。

封装本质上是一种管理,让用户更方便使用类。比如:对于电脑这样一个复杂的设备,提供给用

户的就只有开关机键、通过键盘输入,显示器,USB插孔等,让用户和计算机进行交互,完成日

常事务。但实际上电脑真正工作的却是CPU、显卡、内存等一些硬件元件。

在这里插入图片描述

对于计算机使用者而言,不用关心内部核心部件,比如主板上线路是如何布局的,CPU内部是如

何设计的等,用户只需要知道,怎么开机、怎么通过键盘和鼠标与计算机进行交互即可。因此

算机厂商在出厂时,在外部套上壳子,将内部实现细节隐藏起来,仅仅对外提供开关机、鼠标以

及键盘插孔等,让用户可以与计算机进行交互即可

在C语言中就可能存在使用不规范的问题,比如我们在用C语言实现栈的时候,获取栈顶元素的函数特别简单,有些人用的时候不通过函数的方式来访问,而是直接访问结构体里的数据,这样就和栈top的初始值有关,top从-1开始时,需要返回top-1位置的数据,top从0开始时,返回top位置的数据,所以用的人不知道栈的实现,就可能出现越界访问,C++使用访问修饰符就可以解决这个问题。

在C++语言中实现封装,可以通过类将数据以及操作数据的方法进行有机结合,通过访问权限来隐藏对象内部实现细节,控制哪些方法可以在类外部直接被使用。

五、类的作用域

类定义了一个新的作用域,类的所有成员都在类的作用域中。在类体外定义成员时,需要使用 ::作用域操作符指明成员属于哪个类域。

例如:

class Person
{
    
    
public:
    void PrintPersonInfo();
private:
    char _name[20];
    char _gender[3];
    int  _age;
};
// 这里需要指定PrintPersonInfo是属于Person这个类域
void Person::PrintPersonInfo()
{
    
    
    cout << _name << " "<< _gender << " " << _age << endl;
}

【注意】类域和命名空间域是不同的,命名空间域中存放的是变量和函数的定义,而类域中我们虽然可以定义函数,但是对于变量来说,仅仅只是变量的声明,并没有为变量开辟空间,只有当我们用这个类实例化对象之后才会为变量开辟空间,这就是为什么结构体和类中的成员变量都不能直接初始化,而是仅仅声明

六、类的实例化

用类类型创建对象的过程,称为类的实例化

1.类是对对象进行描述的,是一个模型一样的东西,限定了类有哪些成员,定义出一个类并没有分配实际的内存空间来存储它;比如:入学时填写的学生信息表,表格就可以看成是一个类,来描述具体学生信息。类就像谜语一样,对谜底来进行描述,谜底就是谜语的一个实例。

谜语:“年纪不大,胡子一把,主人来了,就喊妈妈” 谜底:山羊

2.一个类可以实例化出多个对象实例化出的对象 占用实际的物理空间,存储类成员变量

int main()
{
    
    
    Person._age = 100;   // 编译失败:error C2059: 语法错误:“.”
    return 0;
}

Person类是没有空间的,只有Person类实例化出的对象才有具体的年龄。所以我们要先实例化出一个对象,在对年龄进行赋值。

3.类实例化出对象就像现实中使用建筑设计图建造出房子,类就像是设计图,只设计出需要什么东西,但是并没有实体的建筑存在,同样类也只是一个设计,实例化出的对象才能实际存储数据,占用物理空间

在这里插入图片描述

在这里插入图片描述

七、类的对象大小的计算

类中既可以有成员变量,又可以有成员函数,那么一个类的对象中包含了什么?如何计算一个类的大小?

类对象的存储方式猜测

1.对象中包含类的各个成员

在这里插入图片描述

缺陷:每个对象中成员变量是不同的,但是调用同一份函数,如果按照此种方式存储,当一

个类创建多个对象时,每个对象中都会保存一份代码,相同代码保存多次,浪费空间。

2.代码只保存一份,在对象中保存存放代码的地址

成员变量全部保存,另外在对象中保存一份存放代码的区域的地址,即我们不单独保存每一个函数的地址,而是保存类中所有函数所在的代码段的起始地址,我们通过这个地址去找类中的成员函数。

在这里插入图片描述

3.只保存成员变量,成员函数存放在公共的代码段

在这里插入图片描述

我们知道,函数经过编译后形成的指令是由编译器存放到代码段中保存,所以编译器在调用函数时能够很容易的找到指令所在的位置,并且编译器也不会将不同类的成员函数所形成的指令混淆。

总结

对于上面三种类对象的存储方式,我们举例说明,类就相当于一个小区,成员就是小区中的居民,每个人的特征不一样,所以成名变量不相同,需要分别储存,而成员函数就相当有小区中的健身器材,第一种方式就相当有每家都有一套健身器材,这样就特别浪费,第二种方式就相当于给了每个居民一把健身房的钥匙,第三种方式就相当于把健身器材放在一个公共的地方,大家都能够看见,并且能够使用,综上所述,第三种存储方式比较好。

举例说明:

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

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

// 类中什么都没有---空类
class A3
{
    
    };
int main()
{
    
    
	cout << sizeof(A1) << endl;
	cout << sizeof(A2) << endl;
	cout << sizeof(A3) << endl;
	return 0;
}

在这里插入图片描述

由上面的例子我们可以看出,一个类的大小,实际就是该类中”成员变量”之和,当然要注意内存对齐注意空类的大小,空类比较特殊,编译器给了空类一个字节来唯一标识这个类的对象。

我们看到类A2和A3的大小是1,为什么不是0呢?这是因为当我们一个空类实例化多个对象时,需要用不同的空间来标识区分他们,所以编译器给了空类一个字节大小空间来唯一标识这个类的某一具体对象,且这个空间不存储任何有效的数据。

结构体内存对齐规则

1.第一个成员在与结构体偏移量为0的地址处

2.其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。

注意:对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。VS中默认的对齐数为8

3.结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍

4.如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整

体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。

八、类成员函数的this指针

1.this指针的引出,

对于下面的代码:

#include <iostream>
using namespace std;
class Date
{
    
    
private:
	int _year;   //年
	int _month;  //月
	int _day;    //日

public:
	void Init(int year, int month, int day)
	{
    
    
		_year = year;
		_month = month;
		_day = day;
	}

	void Print()
	{
    
    
		cout << _year << "-" << _month << "-" << _day << endl;
	}
};

int main()
{
    
    
	Date d1, d2;
	d1.Init(2022,12,18);
	d2.Init(2022,12,19);
	d1.Print();
	d2.Print();
	return 0;
}

在这里插入图片描述

对于上述类,有这样的一个问题:

Date类中有 Init 与 Print 两个成员函数,函数体中没有关于不同对象的区分,那当d1调用 Init 函数时,该函数是如何知道应该设置d1对象,而不是设置d2对象呢?

C++中通过引入this指针解决该问题,即:C++编译器给每个“非静态的成员函数“增加了一个隐藏的指针参数,该指针指向当前对象**(函数运行时调用该函数的对象),在函数体中所有“成员变量”**的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成。
在这里插入图片描述

所以上面的代码,经过编译器处理之后会编成下面这样。

class Date
{
    
    
private:
	int _year;
	int _month;
	int _day;

public:
	void Init(Date* const* this, int year, int month, int day)
	{
    
    
		this->_year = year;
		this->_month = month;
		this->_day = day;
	}
	void Print(Date* const* this)
	{
    
    
		cout << this->_year << "-" << this->_month << "-" << this->_day << endl;
	}
};

int main()
{
    
    
	Date d1, d2;
	d1.Init(&d1, 2022, 12, 18);
	d2.Init(&d2, 2002, 12, 19);
	d1.Print(&d1);
	d2.Print(&d2);
	return 0;
}

但是this指针参数以及对象的地址都是由编译器去帮我们完成的,不需要我们去传递,当我们手动去传递时编译器就会报错;我们在成员函数类部我们是可以显示的去使用this指针。

在这里插入图片描述

对于上图红色部分这些工作都是由编译器自动完成的,我们不能代替编译器去做;对于上图蓝色部分,我们不添加编译器会帮我们自动添加,我们自己添加的话编译器则不会添加。

2.this指针的特性

1.this指针的类型:类类型*const,即成员函数中,不能给this指针赋值。const位于指针*的后面,即this指针本身不能被修改,但可以修改其指向的对象(我们可以通过this改变成员变量的值,但不能让this指向其他对象)

2.只能在“成员函数”的内部使用

3.this指针本质上是“成员函数”的形参,当对象调用成员函数时,将对象地址作为实参传递给this形参。所以对象中不存储this指针。

4.this指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过建立“成员函数"的函数栈帧创建时压栈时传递,不需要用户传递;this指针被频繁调用,所以在VS编译器中,编译器对其进行了优化,由编译器通过ecx寄存器自动传递。

【注意】

1.this指针存在哪里?

this指针是函数的形参,一般存在于函数的栈帧中(VS下面进行了优化,用ecx寄存器传递),而函数栈帧在栈区上开辟空间,所以this指针存在于栈区上。

2.this指针可以为空吗?

this指针作为函数参数传递时可以为空,但是如果成员函数中使用到了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;
}

【解析】虽然我们用空指针A访问了成员函数Print,但是成员函数的地址不在对象中,在公共代码区域(代码段),所以编译器并不会通过类对象p去访问成员还是函数,不发生解引用,所以程序正常运行。

// 1.下面程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行

class A
{
    
    
public:
	void PrintA()
	{
    
    
		cout << _a << endl;
	}
private:
	int _a;
};

int main()
{
    
    
	A* p = nullptr;
	p->PrintA();
	return 0;
}

【解析】当对象是指针类型时,编译器会直接把这个指针作为形参传递给Print函数的this指针,成员函数,第一个参数是隐藏的this指针,在cout<< _a << endl;语句中,this->_a发生了空指针的解引用,所以运行崩溃。

在这里插入图片描述

【注意】p是类的指针对象,成员函数隐含了this指针,这个函数是p调用的,那么p就是这个指针指向的对象,所以传参的参数是p。

九、C语言和C++实现Stack的对比

1.C语言实现

#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>

typedef int STDataType;
typedef struct Stack
{
    
    
	STDataType* a;
	int top;
	int capacity;
}ST;

//栈的初始化
void StackInit(ST* ps)
{
    
    
	assert(ps);
	ps->a = NULL;
	ps->top = ps->capacity = 0;
}

//入栈
void StackPush(ST* ps, STDataType x)
{
    
    
	assert(ps);
	//栈满或栈空的时候需要扩容
	if (ps->top == ps->capacity)
	{
    
    
		int newCapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
		STDataType* tmp = (STDataType*)realloc(ps->a, sizeof(STDataType) * newCapacity);
		if (tmp == NULL)
		{
    
    
			perror("realloc fail");
			exit(-1);
		}
			ps->a = tmp;
			ps->capacity = newCapacity;

	}
	//入栈
	ps->a[ps->top] = x;
	ps->top++;
}

//判断栈是否为空
bool StackEmpty(ST* ps)
{
    
    
	assert(ps);
	return ps->top == 0;
}

//出栈
void StackPop(ST* ps)
{
    
    
	assert(ps);
	assert(!StackEmpty(ps));

	ps->top--;
}

//返回栈顶元素
STDataType StackTop(ST* ps)
{
    
    
	assert(ps);
	assert(!StackEmpty(ps));

	return ps->a[ps->top];
}

//栈的销毁
void StackDestroy(ST* ps)
{
    
    
	assert(ps);
	free(ps->a);
	ps->a = NULL;
	ps->top = ps->capacity = 0;
}

//返回栈元素的个数
size_t StackSize(ST* ps)
{
    
    
	assert(ps);
	return ps->top + 1;
}

int main()
{
    
    
	ST st;
	StackInit(&st);
	StackPush(&st, 1);
	StackPush(&st, 2);
	StackPush(&st, 3);
	StackPop(&st);
	while (!StackEmpty(&st))
	{
    
    
		printf("%d ", StackTop(&st));
		StackPop(&st);
	}
}

可以看到,在用C语言实现时,Stack相关操作函数有以下共性:

每个函数的第一个参数都是ST*

函数中必须要对第一个参数检测,因为该参数可能会为NULL

函数中都是通过ST*参数操作栈的

调用时必须传递Stack结构体变量的地址

结构体中只能定义存放数据的结构,操作数据的方法不能放在结构体中,即数据和操作数据的方式是分离开的,而且实现上相当复杂一点,涉及到大量指针操作,稍不注意可能就会出错。

2.C++实现

typedef int STDataType;
class Stack
{
    
    
private:
	STDataType* _array;
	int _top;
	int _capacity;

public:
	//栈的初始化
	void Init()
	{
    
    
		_array = NULL;
		_top = _capacity = 0;
	}

	//入栈
	void Push(STDataType x)
	{
    
    
		//栈满或栈空的时候需要扩容
		if (_top == _capacity)
		{
    
    
			int newCapacity = _capacity == 0 ? 4 : _capacity * 2;
			STDataType* tmp = (STDataType*)realloc(_array, sizeof(STDataType) * newCapacity);
			if (tmp == NULL)
			{
    
    
				perror("realloc fail");
				exit(-1);
			}
			_array = tmp;
			_capacity = newCapacity;

		}
		//入栈
		_array[_top] = x;
		_top++;
	}

	//判断栈是否为空
	bool Empty()
	{
    
    
		return _top == 0;
	}

	//出栈
	void Pop()
	{
    
    
		if (Empty())
		{
    
    
			return;
		}

		_top--;
	}

	//返回栈顶元素
	STDataType Top()
	{
    
    
		return _array[_top - 1];
	}

	//栈的销毁
	void Destroy()
	{
    
    
		if (_array)
		{
    
    
			free(_array);
			_array = NULL;
			_top = _capacity = 0;
		}
	}

	//返回栈元素的个数
	size_t Size()
	{
    
    
		return _top;
	}
};

int main()
{
    
    
	Stack st;
	st.Init();

	st.Push(1);
	st.Push(2);
	st.Push(3);

	while (!st.Empty())
	{
    
    
		cout << st.Top() << " ";
		st.Pop();
	}
	cout << st.Size() << endl;
	return 0;
}

C++中通过类可以将数据以及操作数据的方法进行完美结合,通过访问权限可以控制那些方法在类外可以被调用,即封装,在使用时就像使用自己的成员一样,更符合人类对一件事物的认知。而且每个方法不需要传递Stack*的参数了,编译器编译之后该参数会自动还原,即C++中Stack *参数是编译器维护的,C语言中需用用户自己维护。

十、总结

1.通过对象名字访问成员使用点号 . ,通过对象指针访问成员使用箭头->,这和结构体非常类似。

2.this指针是形参,一般存在函数的栈帧中

3.一个类的大小,实际就是该类中”成员变量”之和,要注意内存对齐注意空类的大小,空类比较特殊,编译器给了空类一个字节来唯一标识这个类的对象。

猜你喜欢

转载自blog.csdn.net/qq_67582098/article/details/128364121