从本文开始,我们就要正式来学习C++中的类和对象了,本文我将带你一步步从C语言的结构体
struct
到C++的类class
,真正搞懂有关C++的面向对象的三大特征之一 —— 封装
现在,我提出以下这几个问题,看看你是否都了解
接下去,就让我们带着疑惑,再度出发,好好地探一探这些知识,可能内容会比较多,但我会尽量用生动的语言和图文并茂的方式,结合一些生活中的实际场景,让你更好地理解每个知识点
对于C语言而言,它完全是一门【面向过程】的语言。关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题
对于C++是基于【面向对象】的,关注的是对象,将一件事情拆分成不同的对象,靠对象之间的交互完成。
可是面向过程和面向对象它们之间的区别到底是怎样的呢?可以通过一个在我们生活中最普遍的例子来说明一下
struct ListNode {
int val;
struct ListNode* next;
};
struct
了struct ListNode {
int val;
ListNode* next;
};
struct
这个关键字了,直接使用定义出来的结构体即可;但是在C语言中没有这样的规定,所以是一定要写的知道了上面这些,其实就可以回忆我们之前在数据结构中写过的很多代码,在结构体中只是定义了一些成员变量,具体的函数都是写在结构体外,那现在知道了C++可以这么去做的话,是否可以将这些函数都写到结构体内来呢?我们来试试看
下面我要使用C++去实现一个栈,如果有忘记的小伙伴可以再去回顾一下栈的一些知识
typedef int DataType;
struct Stack
{
void Init(size_t capacity)
{
_array = (DataType*)malloc(sizeof(DataType) * capacity);
if (nullptr == _array)
{
perror("malloc申请空间失败");
return;
}
_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;
}
}
DataType* _array;
size_t _capacity;
size_t _size;
};
定义出来这么一个栈的结构体之后,我们就可以去使用了
Stack
,但是在C++这一块,我却一个都没有加,这就是因为它们一定是属于【栈】的接口算法,而不是其他数据结构:队列、链表、二叉树.
这个操作符,然后传入对应的参数即可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语言写的【栈】时,发现完全没问题,这下你应该可以进一步了解为何C++兼容C了,不过呢在C++中,这样变量和函数存放在一起的结构我们不叫做结构体,而叫做【类】,可是对于类来说,在C++中也不常使用struct
这个关键字来定义,而是使用[class]
语法格式:
class className
{
// 类体:由成员函数和成员变量组成
}; // 一定要注意后面的分号
【注】:class
为定义类的关键字,ClassName
为类的名字,{}
中为类的主体,注意类定义结束时后面分号不能省略
知道了一个类长什么样,接下去我们来聊聊一个类该如何去进行规范的定义
struct
换成class
即可,这种类的定义方式简单粗暴,也是我们平常用得最多的,自己练习代码可以直接这样使用,但其实在日常项目的开发中,不建议大家这样使用❌::
stack.h
#pragma once
#include
#include
using namespace std;
typedef int DataType;
struct Stack
{
void Init(size_t capacity);
void Push(const DataType& data);
DataType Top();
void Destroy();
DataType* _array;
size_t _capacity;
size_t _size;
};
stack.cpp
#include "stack.h"
void Init(size_t capacity)
{
_array = (DataType*)malloc(sizeof(DataType) * capacity);
if (nullptr == _array)
{
perror("malloc申请空间失败");
return;
}
_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;
}
}
test.cpp
#include "stack.h"
int main()
{
Stack s;
s.Init(10);
s.Push(1);
s.Push(2);
s.Push(3);
cout << s.Top() << endl;
s.Destroy();
return 0;
}
cpp
的文件中访问这个类中定义的成员变量的话也就是访问Stack作用域中的内容,就要加上【域作用限定符::
】,就像下面这样最后再来普及一点,你可以自己观察我上面在写【栈】的时候对成员变量的命名形式,前面都加上了
_
,可能你看起来会很别扭,但这却是比较规范的一种定义形式
Init()
这个函数对类中的成员变量去进行一个初始化,观察【成员变量】和【形参】可以发现我故意将它们写成了一样的,此时调用函数进行初始化操作的时候会发生什么呢?class Date {
public:
void Init(int year, int month, int day)
{
year = year;
month = month;
day = day;
}
int year;
int month;
int day;
};
通过观察可以发现,若是【成员变量】和【形参】的名字一样的话,其实这个时候就会造成歧义,初始化的就不是当前这个对象的成员变量了,如果你自己观察就可以发现,命名一样的话,在VS中二者的字体都会变淡,这其实就是VS在提示你这样的做法其实是无效的❌
那要如何命名才是最规范的呢?
_变量名
或者是m_变量名
,但如果你在公司里面的话,内部是如何规定的你怎么做就行了,这个没有强制,只要别造成相同即可this->year = year
这种写法,确实这也可以,这里面就用到了C++类和对象中很重要的一块叫做【this指针】,这里先不做详解,见最后一个模块哦this->year = year;
this->month = month;
this->day = day;
学习了上面的这些,你只是初步了解了什么是类,但是C++中的类远远不止将
struct
换成class
这简单,如果你自己观察的话,可以发现我在上面的Date类中加了【public:】和【private:】这两个东西,它们就叫做类的访问限定符
public
来说只的是共有,表示从当前public到收括号};
为止的所有成员变量或者是成员函数均为公有的,什么是公有呢?就是类内类外都可以随意调用访问,不受限制private
指的就是私有,这很直观,对于共有来说就是所有东西都是公开的,无论是谁都可以访问;那对于私有来说便是无法访问,谁无法访问呢?这里指的是外界无法访问,但类内还是可以访问的,例如就是类内的成员函数访问这些成员变量是不受限制的protected
指的是保护,代表它会将类内的这些东西保护起来,外界无法访问到。但对于这个限定来说暂时可以把它当成和private
是类同的,到了C++中的【多态】才会出现差异接下去再来看看有关访问限定符的一些特性指南
private
去掉的话,还是会存在【不可访问】的现象,原因就是在于类内的定义的内容默认访问权限都是private
,外界是无法访问到的但一定会有同学有这么一个疑问,那在加上
[private]
关键字后,这个成员变量也是私有的呀,为什么可以对他们去进行一个初始化呢?那不是访问到了这些成员变量了
对于上面这一点来说,其实就又一些C++中类的封装思想了,接下去我们就正式来谈谈面向对象的三大特性之一 —— 【封装】
【封装思想】:用类将对象的属性(数据)与操作数据的方法结合在一块,让对象更加完善,通过访问权限选择性的将其接口提供给外部的用户使用
这里先初步地讲一下有关【类的封装】思想,文章的后半部分会不断地加强读者对这块的理解
当我们写好一个类之后,就要去把它给定义出来,就好比在C语言中,我们要使用一个变量的话,也是要把它定义出来才行,才可以使用,例如:结构体声明好了之后就要将其定义出来,否则是不用的
可能就这么说太好理解,我们通过一个形象一点的案例来说明
支付宝到账5万元
】的声音,那么这笔钱就真正地到你手里的,这是实实在在的,已经存在了的事,指的就是【定义】
那怎样才算定义呢?又是何时开出空间,让我们来瞧瞧【类对象的声明与定义】
int main(void)
{
Date d; //类对象的实例化
return 0;
}
用类类型创建对象的过程,称为类的实例化
类是对对象进行描述的,是一个模型一样的东西,限定了类有哪些成员,定义出一个类并没有分配实际的内存空间来存储它;
一个类可以实例化出多个对象,实例化出的对象占用实际的物理空间,存储类成员变量
Date d;
d.year = 2023;
d.month = 3;
d.day = 18;
static
进行修饰,是可以的直接使用类名来进行访问的Init()
和Print()
函数的时候也不会有什么问题,这是为何呢?难道这个【成员函数】和类没什么关系吗?它存在于类中吗?让我们带着这个疑问开始本模块的学习
要想知道这个类中存在多少东西,其实我们去计算一个它的大小即可
struct
和class
都可以去定义一个类,那么结构体内存对齐的规则也一样适用。不过我们只会计算成员变量的大小,那就来先计算一下这个【year】、【month】、【day】的大小在看了上面惊人的一幕后,我们就来思考一下,对于这个类对象究竟是如何进行存储的。在下面,我给出了类对象存储方式的三种设计方式,你认为哪一种设计方式是正确的呢?
Init()
函数,外界被定义出来的对象只需要调用一下这个函数去初始它自己的成员变量即可,不需要将其放在自己的类内。但是这么说一定是比较抽象了,我们再通过一个生活小案例来理解一下
// 类中既有成员变量,又有成员函数
class A1 {
void f1() {}
private:
int a;
};
// 类中仅有成员函数
class A2 {
void f1(){}
};
// 类中什么都没有---空类
class A3 {};
A1
,有一个成员变量,那经过上面的学习可以得知成员函数是不存在于类中,又因为整型占4个字节,所以很容易可以得知A3的大小为4A2
,只有一个成员函数f1()
,没有成员变量,那【sizeof(A2)】的结果会是多少呢?一会看运行结果后再来分析A3
,对于这个类来说既没有成员函数也没有成员变量,那它的大小会是多少呢?0吗?我们来看一下运行结果
上面的这个概念在笔试面试中都有可能会涉及,准备校招的同学要重视
class Date {
public:
//定义
void Init(int year, int month, int day)
{
_year = year;
_year = month;
_year = day;
}
void Print()
{
cout << "year:" << _year << endl;
cout << "month:" << _year << endl;
cout << "day:" << _year << endl;
}
private:
int _year; //仅仅是声明,并没有开出实际的空间
int _month;
int _day;
};
d1
里面的【year】【month】【day】,然后在内部Init()函数中使用_year = year
这样的方式来进行初始化,此时右边的[year]
是外部传递进来的2023,[_year]
是内部的成员变量,但是仔细回忆一下,刚才我们有说到这个[_year]
只是类内部声明的,并没有被定义出来呀,那要怎么赋值初始化呢?Date d1;
d1.Init(2023, 3, 18);
_year = year
中的[_year]
要怎么区分这是d1还是d2的成员变量呢?若有又定义了一个d3呢?如何做到精准赋值无误?好,就让我们带着这些问题,进入下一小节的学习
Date d1;
Date d2;
d1.Init(2023, 3, 18);
d2.Init(2024, 3, 18);
面对上面情况,其实就可以使用到C++中的
this指针
了,这个我在上面有提过一嘴,还有印象吗
this指针
//void Init(int year, int month, int day)
void Init(Date* this, int year, int month, int day)
//d1.Init(2023, 3, 18);
d1.Init(&d1, 2023, 3, 18);
year
、month
和day
进行初始化。随着每次的传入的对象地址不同,this指针就会通过不同的地址去找到内存中对应的那块地址中的成员变量,进行精准赋值不过,虽然我们不能传递观察,但可以通过这个隐藏的this指针来看看是否真的传递了当前对象的地址进去
了解了this指针的基本原理后,我们来聊聊它的相关特性
this指针的类型:类类型* const(Date* const
),即成员函数中,不能给this指针赋值
const
常所修饰的,为【指针常量】,对于指针本身的指向是不可修改的,但是指针所指向的内容可以通过解引用的方式来修改。如果不是很清楚这一块可以看看常量指针与指针常量的感性理解this指针本质上是“成员函数”的形参,当对象调用成员函数时,将对象地址作为实参传递给this形参。所以对象中不存储this指针
this指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过ecx寄存器自动传递,不需要用户传递
Init()
的参数都会被压入栈中,不过可以观察到,由于栈【先进后出】的性质,是从第4个参数开始压栈的,若是按照原本的三个参数来说应该会压三次,但是看到2023被Push
进去之后还有一个[d1]需要被lea(load effective address)
进去,不过并不是直接加载,而是放到一个寄存器ecx中再加载,这个d1指的其实就是对象d1的地址说到了这么多有关this指针的特性,有些特性对大家来说可能还是比较难以理解,接下去我会通过三个生活中的小场景带你好好理解一下
通过上面的三个生活小案例,相信你对this指针一定有有了自己的理解
本小节的内容可能会让你感到非常枯燥,如果没有校招需求的读者可以选择跳过,当然也可以浏览一下喽
先来看看第一位同学的回答:
this指针是存放对象地址的,和对象存在关系。应该是存放在对象中的把
ecx
进行一个临时保存,刚才我们也有通过汇编指令进行一个查看再来听听第二位同学的回答:
刚才不是说这个成员函数是存放在公共代码区的吗,那隐藏形参this是属于这个函数的,为何没有存放在公共代码区呢?
call
指令的地址不在对象中找,而去【公共代码区】中找,为什么要去这个公共区找呢?因为成员函数被编译出来的这些指令(刚才看的指令)存放在这里面, 而成员函数内部的一些形参、临时变量
则不在这里面,它们都是存放在【栈区】中的。所以this指针不在【公共代码区】,而在【栈区】好,接下去我们再来看第二个面试题
请问下面程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行
class Date {
public:
//定义
//void Init(Date* this, int year, int month, int day)
void Init(int year, int month, int day)
{
cout << "this:" << this << endl;
this->_year = year;
this->_month = month;
this->_day = day;
}
void Print()
{
cout << "Print()" << endl;
}
private:
int _year; //仅仅是声明,并没有开出实际的空间
int _month;
int _day;
};
int main()
{
Date* p = nullptr;
p->Print();
}
运行结果:
p->Init(2023, 3, 19);
运行结果:
Init()
初始化函数的话就会发生空指针异常的问题,这是为什么呢?有同学说:一看就是这个this->
的问题,很明显的解引用嘛,去掉不就好了
this
指针的时候说到, 在成员函数内部调用成员变量时,可以在前面加上this->
去指向,当然也可以不加,因为是默认带有的,所以在这里无论是显式地加上还是隐式让他保留,都会产生一个【指针指向】,那我们在C语言的指针章节有谈到,对于指针指向而言其实就是解引用,所以这才造成了出错通过上面这两个函数的调用,相信你对this指针传参机制有了一些基本认识。但还是没有清楚这其中的原理,接下来我便带你分析一下里面的原理
Print()
函数而言,并没有涉及到访问成员变量,那你可以把它理解为在别人家的小区里的公共蓝球场打了个篮球,那这其实是属于公共的区域,而没有闯入别人的私人领地00000000
。但是并没有再做任何的事了,所以不会有问题Init()
函数时,也不是去【栈区】里找,而是去【公共代码区】里找,也是一样首先打印了一下传入的空对象地址,不过接下来的操作就不对了!只要是要访问当前类的成员变量时,都需要使用到this
指针来进行指向,只是因为它是一个隐式形参罢了那有同学又说:那我在调用Init()
的时候不传空对象不就好了,直接调用
Init(2023, 3, 19);
this
接受传递过来的对象地址,以此去调用不同对象的成员变量。但是就上面这个调用,连执行的对象都没有,你觉得this
指针会接收到什么东西呢?好,看完了上面这一些,相信你对this指针的了解一定更加深刻了,我们再来看最后一个
还是调用刚才的Print()
函数 A、编译报错 B、运行崩溃 C、正常运行
(*ptr).Print();
->
或者是解引用*
就一定是在对指针进行解引用嘛”->“
。因为对于编译器来说,是将代码的这些语法转换为指令,我们要去看的是这些汇编指令->
和*
解析成了同一种方式,至于内部的逻辑是怎么样的,上面已经讲过了,此处不再赘述最后,有位同学又提出了这样的写法,蛮不错的,给读者分享一下
::
】的方式去访问可行吗?Date::Print();
运行结果如下:
为什么没有让她罚站呢,怎么能让女生罚站呢,是吧
好,接下去就对上面的两道面试题所引申出来的知识点做一个总结与回顾,我们就进入下一模块
【总结一下】:
this->
,都会造成解引用从而导致空指针异常⚠的问题。->
或者*
,而是要去观察底层的汇编指令如何执行,这才是计算机内部的真正执行逻辑好,讲了这么多,相信读者对C++的类这一块一定有了自己的理解,本模块,我将通过C语言和C++分别去实现一个【栈】,通过观察来让读者看出C++到底是如何实现封装的
C语言的相关代码可以看这篇文章 链接,这里就不贴代码了
主要来展示一下C++的代码,下面是比较规范的声明与定义分离的形式,可以先看看
stack.h
typedef int DataType;
class Stack
{
public:
void Init(size_t capacity = 4);
void Check_Capacity();
void Push(const DataType& data);
void Pop();
bool Empty();
DataType Top();
void Destroy();
private:
DataType* _array;
size_t _capacity;
size_t _top;
};
stack.cpp
#include "stack.h"
void Stack::Init(size_t capacity)
{
_array = (DataType*)malloc(sizeof(DataType) * capacity);
if (nullptr == _array)
{
perror("malloc申请空间失败");
return;
}
_capacity = capacity;
_top = 0;
}
void Stack::Check_Capacity()
{
if (_top == _capacity)
{
DataType* tmp = (DataType*)realloc(_array, sizeof(DataType) * _capacity * 2);
if (nullptr == tmp)
{
perror("fail realloc");
exit(-1);
}
_array = tmp;
_capacity = _capacity * 2;
}
}
void Stack::Push(const DataType& data)
{
// 扩容
Check_Capacity();
_array[_top] = data;
++_top;
}
bool Stack::Empty()
{
return _top == 0;
}
void Stack::Pop()
{
assert(_top > 0);
assert(!Empty());
_top--;
}
DataType Stack::Top()
{
return _array[_top - 1];
}
void Stack::Destroy()
{
if (_array)
{
free(_array);
_array = nullptr;
_capacity = 0;
_top = 0;
}
}
test.cpp
int main(void)
{
Stack st;
st.Init();
st.Push(1);
st.Push(2);
st.Push(3);
size_t top = st.Top();
cout << top << endl;
st.Pop();
top = st.Top();
cout << top << endl;
st.Destroy();
return 0;
}
运行结果:
stack
,里面就有上面所学的全部内容数据访问控制是自由的,不受限制
StackTop()
时,产生了分歧,有的同学说直接像下面这样取就可以了int top1 = st->a[st->top - 1];
int top2 = StackTop(&st);
top
指针初始化的值是多少,是-1呢?还是0呢?asssert(top > 0);
assert()
进行检查,此时若是这个栈顶指针 <= 0
的话也就表明栈里面没有元素了,再去通过数组访问的话就会造成有越界的风险【总结一下】;
虽说使用C语言这样去进行访问确实存在一些缺陷,那为什么标准委员会没有改进这一点呢?还是允许这样的访问。
但是推荐这个东西管用吗?
”红灯停,绿灯行,黄灯等一等”
真的起到了什么作用吗?所以可以看出来C语言存在一定的不严谨性,而且C语言还比较乱,尤其体现在学校的教科书和一些相关书籍中
代码
让人看起来确实有点难受上面的一些种种案例其实都可以说明C语言在语法设计这一块确实是有些松散了,导致缺乏经验的初学者会遇到很多难题
数据和方法都封装到类里面
“xxx到此一游”
、“xxx爱xxx”
,或者缺胳膊少腿控制访问方式。【愿意给你访问的共有,不愿意给你访问的私有】
public
,不想给外界访问的就设置为私有private
Stack st;
st.Init();
Top()
成员函数,不需要去管内部的实现细节是怎样的,只需要调用就可以了int top = StackTop(&st); //✔
int top = st->a[st->top - 1]; //❌
讲得通俗一点,还是用我们上面讲到过的红绿灯例子
调用函数比C语言要轻松一点,不用传入当前对象的地址
//C语言实现
void PushStack(ST* st, STDataType x)
PushStack(&st, 1);
//C++实现
void Stack::Push(const DataType& data)
st.Push(1);
--> this指针
。还记得this指针的原理吗 ?它是成员函数的一个隐藏形参,在当前对象调用成员函数时,当前对象的地址会连同其他参数一起压入栈中,VS中则是使用寄存器ecx
来进行临时存放,最后再由成员函数中的this指针接受当前正在调用函数对象的地址,以此来访问不同对象的不同成员变量到这里,就讲完了类的封装思想,我们来总结回顾一下
struct
和class
这两个关键字,知道了原来在C++中结构体可以这么玩,并且二者都可以用在来定义一个类puiblc
、protected
和 private
,若是加上了访问限定符后,类内的成员对外部来说就存在不同的限制。初次讲到了类的封装思想,将对象的属性(数据)与操作数据的方法结合在一块,起到了更好地管理this指针
,随着每次的传入的对象地址不同,隐式形参this指针就会通过不同的地址去找到内存中对应的那块地址中的成员变量,进行精准赋值以上就是本文要介绍的所有内容,
Thanks for reading, see you next article