📖 前言:本期我们将引入类和对象的概念,系统的介绍面向对象程序设计的魅力,它的出现开启了程序设计的新篇章。
目录
🕒 1. 面向过程和面向对象初步认识
C语言是面向过程的,关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题。
C++是基于面向对象的,关注的是对象,将一件事情拆分成不同的对象,靠对象之间的交互完成。 面向对象的三大特性:封装;继承
例子:
- 展开解释一下:面向过程的思想就是,我不管这事情主角是谁,我就按事情的发展过程叙述。这个形式里强调描述“过程"。就好比编年体里,我会把1000年里每一年有什么事情发生罗列在一起,让你看完之后,对事情的过程很清晰,但每个人物都干过啥,理不清了。
- 而面向对象思想是,我强调一件事情的“主角”是谁,所有和他相关的内容都封装在一起。就好比纪传体,一个人物单独封装成一个章节。这个封装里面,主角(对象)非常清晰,一切事情都是围绕它的。这个做法的好处是,你如果想查一查A是否干过B,你可以只查A那章内容就好了。
- 简而言之就是,如果思考事情都从一个特定主角出发(比如写纪传体),那就是面向对象的。如果没有清晰的主角,那就是面向过程的。
🕒 2. 类的引入
- 在C语言中其实我们已经了解过
类
的概念,本质就是C语言中的结构体
//使用结构体描述一个学生
struct Student
{
char name[20];//姓名
int age;//年龄
char id[12];//学号
};
- 但是上面这个“对象”,只有属性显得没有活力,一个完整的对象应该是具有行为(函数)的,而在C++中,因为面向对象的概念引入,C的结构体得以升级。所以创建了一种新的类型:
类
//使用结构体描述一个学生
struct Student
{
void Learn()
{
//如何学习……
}
void Eat()
{
//如何吃饭……
}
void Sleep()
{
//如何睡觉……
}
char name[20];//姓名
int age;//年龄
char id[12];//学号
};
本质: 类可以看作不同对象的集合【Eg:不同的学生,有着不同的姓名、身高、行为方式……但可以定义一个student类去表述出不同的学生】
类的构成:
- 类的属性:即类的
成员变量
- 类的方法(行为):即类的
成员函数
简单来说:
- C语言中,结构体中只能定义变量
- 在C++中,结构体内不仅可以定义变量,也可以定义函数
- 其中,我们更喜欢用
class
来代替struct
🕒 3. 类的定义
class className
{
// 类体:由成员函数和成员变量组成
}; // 一定要注意后面的分号
class
:为定义类的关键字className
:为类的名字
类的主体
:可由成员变量+成员函数
组成
类体中内容称为类的成员:类中的变量称为类的属性或成员变量; 类中的函数称为类的方法或者成员函数。
🕘 3.1 类的两种定义方式
- 声明和定义全部放在类体中,需要注意:成员函数如果
在类中定义
,编译器可能会将其当成内联函数
处理。
class Student
{
void ShowStu()
{
cout << name << " " << age << " " << tele << endl;
}
char name[20];
int age;
char tele[12];
};
- 类声明放在
.h
文件中,成员函数定义放在.cpp
文件中,注意:成员函数名前需要加类名::
(声明和定义分离)
class Student
{
void ShowStu(); //显示基本信息
char name[20];
int age;
char tele[12];
};
#include"student.h"
// 显示基本信息,实现:输出名字、年龄、电话
void Student::ShowStu()
{
cout << name << " " << age << " " << tele << endl;
}
一般情况下,更期望采用第二种方式。声明和定义分离的目的就是增加代码的观赏性,在大工程中如果不这样的话,想看类中的成员变量,可能会由于内部函数代码过多导致花费的时间成本高,看起来极其复杂。需要注意的是,这样进行分离定义之后,缺省值不能同时在.h
中定义,而是在.cpp
中使用。
🕘 3.2 类定义的两个惯例
- 类的成员变量使用修饰符修饰 :与C语言结构体不同,由于类中可以同时定义变量和函数,所以函数的形参与类成员变量就可能会发生冲突,这种情况在 Init 函数中十分常见,如下
class Date
{
public:
void Init(int year)
{
// 这里的year到底是成员变量,还是函数形参?
year = year;
}
private:
int year;
};
Init 函数的形参和类成员变量相同,这就导致我们初始化赋值的不确定性,当然我们也可以使用类名+域作用限定符
或者this指针(以后会提到)来解决这个问题:
void Init(int year)
{
Date::year = year;
//this->year = year;
}
但是这样显然比较麻烦,所以在C++中有一个惯例,成员变量使用某种修饰符来修饰,比如_member
,mMenber
等,具体看个人喜好与未来企业要求,这里采用_member
的写法
class Date
{
public:
void Init(int year)
{
_year = year;
}
private:
int _year;
};
- 成员函数定义在成员变量前面:C语言编译器寻找变量的规则是先到前面去找,然后再到全局去找,所以在C语言中变量必须定义在函数前面,才可以在函数中使用该变量;但是C++编译器不一样,C++编译器会把类看作一个整体,当我们使用一个变量时,它会到整个类中去寻找,然后再到全局去寻找;所以在C++中,我们是可以将成员变量定义成员函数后面的;
上面解释了成员函数定义在成员变量之前的可行性,下面我借用 《高质量C/C++编程》中的解释来阐述为什么要将成员函数定义在成员变量前面:
🕒 4. 类的访问限定符及封装
🕘 4.1 访问限定符
C++实现封装的方式:用类将对象的属性与方法结合在一块,让对象更加完善,通过访问权限选择性的将其接口提供给外部的用户使用。
访问限定符有:public(公有)、protected(保护)、private(私有)
【访问限定符说明】
public
修饰的成员在类外可以直接被访问protected
和private
修饰的成员在类外不能直接被访问(现阶段可以认为protected
和private
是一样的)- 访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现时为止
- 如果后面没有访问限定符,作用域就到
}
即类结束。 class
的默认访问权限为private
,struct
为public
(因为struct要兼容C)
注意:访问限定符只在编译时有用,当数据映射到内存后,没有任何访问限定符上的区别
【面试题】
问题:C++中struct
和class
的区别是什么?
解答:C++需要兼容C语言,所以C++中struct可以当成结构体使用。另外C++中struct还可以用来定义类。和class定义类是一样的,区别是struct
定义的类默认访问权限是public
,class
定义的类默认访问权限是private
。注意:在继承和模板参数列表位置,struct和class也有区别,后续给大家介绍。
🕤 4.1.1 限定域与类的区别
我们知道,对于命名空间来说,有一个限定域的概念,并且通过::
可以直接操作,但对于类,我们可不可以这样访问里面的成员变量呢?
class Student
{
public:
void ShowStu()
{
cout << _name << " " << _age << " " << _tele << endl;
}
char _name[20];
int _age;
char _tele[12];
};
int main()
{
Student::_age = 20; //error
}
很明显不能,我们回顾下C语言时学习的struct类型
struct Student2 //类型
{
char _name[20]; //声明
int _age;
char _tele[12];
};
声明是不会开辟空间的,定义才会开空间。声明就好比你的好朋友说在疯狂星期四要V你50,但是实际给你了吗?定义就是只有给你个承诺也就是拿到50块才实现了
正确的打开方式:new一个对象
int main()
{
Student Alice;
Alice._age = 20;
}
用类类型创建对象的过程,称为类的实例化
上面的代码中,Student类是没有空间的,只有Student类实例化出的对象才有具体的年龄。只有先定义一个对象,才能对其内部属性进行操作。
打个比方。类实例化出对象就像现实中使用建筑设计图建造出房子,类就像是设计图,只设计出需要什么东西,但是并没有实体的建筑存在,同样类也只是一个设计,实例化出的对象才能实际存储数据,占用物理空间
🕘 4.2 封装
面向对象的三大特性:封装、继承、多态。
【实际中不止三种特性,还有:抽象、反射(Java),但与上述三种相比较之下,上述的三种特性更为重要】
封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。
封装本质上是一种管理,让用户更方便使用类。比如:对于电脑这样一个复杂的设备,提供给用户的就只有开关机键、通过键盘输入,显示器,USB插孔等,让用户和计算机进行交互,完成日常事务。但实际上电脑真正工作的却是CPU、显卡、内存等一些硬件元件。
🕒 5. 类的作用域
类定义了一个新的作用域,类的所有成员都在类的作用域中。在类体外定义成员时,需要使用::
作用域操作符指明成员属于哪个类域。
class Student
{
public:
void ShowStu();
private:
char _name[20]; //声明
int _age;
char _tele[12];
};
// 这里需要指定ShowStu是属于Student这个类域
void Student::ShowStu()
{
cout << _name << " " << _age << " " << _tele << endl;
}
🕒 6. 类对象模型
🕘 6.1 类对象的大小
- 类对象的大小的计算是遵循🔎 内存对齐的规则
- 但现在有一个问题:类里包含了成员函数,成员函数的大小是否包含在类对象的大小中呢?
class A
{
public:
void PrintA()
{
cout << _a << endl;
}
private:
char _a;
};
int main()
{
A aa;
cout << sizeof(A) << endl; // 输出:1
cout << sizeof(aa) << endl; // 输出:1
return 0;
}
答案是否定的,一个类的大小只计算成员变量
的大小(遵循内存对齐
的规则),不计算成员函数的大小在内;对于"空类
"的空间大小,只有1
字节
⚡ 注意:成员函数
真正存储的地方,并不是在类里面,而是在公共代码区
;这样的好处是当多个对象调用成员函数
时,并不会产生多份重复的成员函数
,从而占用空间、浪费空间。
"空类"给1字节的原因是表示占位(即表示对象存在),如果都为0字节则会地址重合无法区分。
🕒 7. this 指针
🕘 7.1 this 指针的引出
我们先来定义一个日期类 Date
class Date
{
public:
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "_" << _month << "_" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1, d2;
d1.Init(2022, 10, 5);
d2.Init(2022, 11, 11);
d1.Print();
d2.Print();
return 0;
}
由上通过Date
类创建了两个对象:d1
、d2
,但当对象都调用成员函数的时候,函数体内并没关于不同对象的区分,那成员函数是如何知道该设置哪个对象呢?
C++中通过引入this指针解决该问题,C++编译器给每个 “非静态的成员函数” 增加了一个隐藏的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有成员变量的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成。
🕘 7.2 this 指针的特性
- this指针的类型:类类型
*const
,即成员函数中,不能给this指针赋值。
const Date* p1
Date const* p2
Date* const p3
快问快答:以上代码中const修饰的是谁
答:第一个和第二个分别修饰的是p1和p2,第三个是p3
- 只能在“成员函数”的内部使用
- this指针本质上是“成员函数的“的形参,当对象调用成员函数时,将对象地址作为实参传递给this形参。所以对象中不存储this指针。
- this指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过ecx寄存器自动传递,不需要用户传递,但如果强行加上this,编译器也就不会自动加了,这就是多此一举了。
【面试题】
this指针存在哪里?(栈、堆、静态区、常量区)
this指针可以为空吗?
答:一般是存放在栈帧中。我们通过前面的演示可以看见,this指针是存放在形参中的,因此,与函数的形参的保存位置一样。(vs下面进行了优化,使用ecx寄存器传递)
我们发现,在这里利用了ecx
直接通过this指针(这里的this指针事实上就是转化成了d1
)
对于第二个问题,我们来看看具体例子:
1.下面程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行
class A
{
public:
void Print()
{
cout << "Print()" << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->Print();
return 0;
}
由于p为空指针,但是这里的p->Print();
并不是解引用去访问,通过上面类对象的存储方式猜测我们知道,因为成员函数的地址不在对象中,而是在公共代码区域。通过this指针的知识可知,this指针为空指针,Print的参数就是一个空指针,但是Print函数的执行与this指针是否为空无关,因此,选项为C。
再看一个例子:
2.下面程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行
class A
{
public:
void PrintA()
{
cout<<_a<<endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->PrintA();
return 0;
}
这个与第一个例子的差别只是在"Print()"
和_a
的区别,对于第二个,this指针当然也为空,由上面知识可知,_a
是this->a
,而this又是p(nullptr),于是这里出现了空指针的解引用,因此会运行崩溃,选项为B。
所以对于第二个问题,this指针不可以为空。
注:空指针是运行错误,编译是检查不出来的。(因此上述的空指针错误是运行错误。)
🕒 8. C语言和C++实现Stack的对比
- C语言实现
typedef int DataType;
typedef struct Stack
{
DataType* array;
int capacity;
int size;
}Stack;
void StackInit(Stack* ps)
{
assert(ps);
ps->array = (DataType*)malloc(sizeof(DataType) * 3);
if (NULL == ps->array)
{
assert(0);
return;
}
ps->capacity = 3;
ps->size = 0;
}
void StackDestroy(Stack* ps)
{
assert(ps);
if (ps->array)
{
free(ps->array);
ps->array = NULL;
ps->capacity = 0;
ps->size = 0;
}
}
void CheckCapacity(Stack* ps)
{
if (ps->size == ps->capacity)
{
int newcapacity = ps->capacity * 2;
DataType* temp = (DataType*)realloc(ps->array,
newcapacity*sizeof(DataType));
if (temp == NULL)
{
perror("realloc申请空间失败!!!");
return;
}
ps->array = temp;
ps->capacity = newcapacity;
}
}
void StackPush(Stack* ps, DataType data)
{
assert(ps);
CheckCapacity(ps);
ps->array[ps->size] = data;
ps->size++;
}
int StackEmpty(Stack* ps)
{
assert(ps);
return 0 == ps->size;
}
void StackPop(Stack* ps)
{
if (StackEmpty(ps))
return;
ps->size--;
}
DataType StackTop(Stack* ps)
{
assert(!StackEmpty(ps));
return ps->array[ps->size - 1];
}
int StackSize(Stack* ps)
{
assert(ps);
return ps->size;
}
int main()
{
Stack s;
StackInit(&s);
StackPush(&s, 1);
StackPush(&s, 2);
StackPush(&s, 3);
StackPush(&s, 4);
printf("%d\n", StackTop(&s));
printf("%d\n", StackSize(&s));
StackPop(&s);
StackPop(&s);
printf("%d\n", StackTop(&s));
printf("%d\n", StackSize(&s));
StackDestroy(&s);
return 0;
}
可以看到,在用C语言实现时,Stack相关操作函数有以下共性:
- 每个函数的第一个参数都是
Stack*
- 函数中必须要对第一个参数检测,因为该参数可能会为
NULL
- 函数中都是通过
Stack*
参数操作栈的 - 调用时必须
传递
Stack结构体变量的地址
结构体中只能定义存放数据的结构,操作数据的方法不能放在结构体中,即数据和操作数据的方式是分离开的,而且实现上相当复杂一点,涉及到大量指针操作,稍不注意可能就会出错。
- C++实现
typedef int DataType;
class Stack
{
public:
void Init()
{
_array = (DataType*)malloc(sizeof(DataType) * 3);
if (NULL == _array)
{
perror("malloc申请空间失败!!!");
return;
}
_capacity = 3;
_size = 0;
}
void Push(DataType data)
{
CheckCapacity();
_array[_size] = data;
_size++;
}
void Pop()
{
if (Empty())
return;
_size--;
}
DataType Top(){ return _array[_size - 1];}
int Empty() { return 0 == _size;}
int Size(){ return _size;}
void Destroy()
{
if (_array)
{
free(_array);
_array = NULL;
_capacity = 0;
_size = 0;
}
}
private:
void CheckCapacity()
{
if (_size == _capacity)
{
int newcapacity = _capacity * 2;
DataType* temp = (DataType*)realloc(_array, newcapacity *
sizeof(DataType));
if (temp == NULL)
{
perror("realloc申请空间失败!!!");
return;
}
_array = temp;
_capacity = newcapacity;
}
}
private:
DataType* _array;
int _capacity;
int _size;
};
int main()
{
Stack s;
s.Init();
s.Push(1);
s.Push(2);
s.Push(3);
s.Push(4);
printf("%d\n", s.Top());
printf("%d\n", s.Size());
s.Pop();
s.Pop();
printf("%d\n", s.Top());
printf("%d\n", s.Size());
s.Destroy();
return 0;
}
C++中通过类可以将数据 以及 操作数据的方法进行完美结合,通过访问权限可以控制那些方法在类外可以被调用,即封装,在使用时就像使用自己的成员一样,更符合人类对一件事物的认知。而且每个方法不需要传递Stack*的参数了,编译器编译之后该参数会自动还原,即C++中 Stack *
参数是编译器维护的,C语言中需用用户自己维护。
OK,以上就是本期知识点“类和对象(上)”的知识啦~~ ,感谢友友们的阅读。后续还会继续更新,欢迎持续关注哟📌~
💫如果有错误❌,欢迎批评指正呀👀~让我们一起相互进步🚀
🎉如果觉得收获满满,可以点点赞👍支持一下哟~