-
-
[原创]从底层视角看面向对象
-
发表于: 2024-9-21 13:43 6941
-
假如让计算机运算1+2,包含如下几步操作:
将数值1写入寄存器A
将数值2写入寄存器B
将寄存器A和寄存器B的数据相加并将结果保存到寄存器A
我们知道计算机是用二进制来表示数据或指令的,假设向寄存器传送数据的指令用0001表示,加法指令用0010表示,寄存器A用0000表示,寄存器B用0001表示,那么上述步骤可表示为:
0001 0000 0001
传送数据 到寄存器A 传送数值1
0001 0001 0010
传送数据 到寄存器B 传送数值2
0010 0000 0001
加法运算 寄存器A 寄存器B
那么将上述指令连起来就是:0001 0000 0001 0001 0001 0010 0010 0000 0001。这就是机器语言,早期纸带打孔编程,就是用的这种形式,很明显机器语言有个缺陷:计算器看得懂,但是人看不懂。
人类能看懂什么?文字,符号,所以能否将机器指令表示为人类易于理解的形式?比如用mov表示向寄存器传送数据,用add表示加法运算,用ax表示寄存器A,bx表示寄存器B,所以上述步骤又可以表示为:
这就是汇编语言,汇编就是机器语言的符号化表示。
那么有了这种便于人类理解的形式,我们可以编写一些复杂的指令,比如:
上述指令演示了一个分支结构,如果ax <= 2 则执行ab+bx,否则执行bx-1。
再比如:
这组指令表示了一个循环结构,ax的值从0开始递增,直到100跳出循环。
可以看出,汇编语言虽然转化为了人类易于理解的形式,但想要编写复杂的程序,还需要写大量的指令,并不是很方便,能否用一种更简洁的形式来代表这些指令?比如分支用if else表示,循环用while, do while, for表示,于是C语言诞生了,C语言表示分支结构是这样:
循环结构是这样:
用起来舒服多了,C语言的出现极大提高了编程效率,并且一定程度上解决了跨平台的问题,过去用汇编写程序时,在x86平台上编写的指令是无法拿到arm平台上执行的,但是有了C语言,在不改动源代码的情况下,只要有目标平台的编译环境,就可以将代码编译为该平台的汇编指令。
我们常说C语言是面向过程的语言,我倒是认为面向过程更符合人类在编程时的直觉,只要明确了要实现的功能,只需按照步骤
step1;
step2;
step3;
...
往下写就行了,举个例子,假设要实现一个时钟的功能,包含时分秒三个数据,能够设置这三个数据,并且格式化显示出来,按照面向过程的思维就这么写:
很直观,但是也存在一些问题,数据是零散的,能否抽象出一个实体表示时钟,将时分秒集成到时钟内部,那么我们可以定义一个结构体:
还有一个问题,如果买来一个钟表想要调它的时间,难道要把表拆开亲自去拨动里面的齿轮吗?当然不是,通常钟表后面有两个可以旋转的按钮,通过旋转它们即可调整到正确的时间,那么我们的程序也不应该直接修改stgClock里的变量,而是提供修改时分秒的接口,通过调用接口来设置时间,所以我们提供一些函数:
这就叫做”封装“。
有了封装,于是main函数改为这样:
数据都被集成到结构体内部,对外通过调用接口来操作时钟,而并非直接修改里面的变量。
可以通过调试InitClock函数,观察stgClock的内存结构:
当三条赋值语句执行完后,内存中psClock指向的3个连续的4字节被分别初始化为0x02,0x14,0x36。
现在假设推出一款精度更高的时钟,除了拥有原先stgClock的功能外,还要求精确到毫秒,那么我们再定义一个结构体:
包含了原stgClock的同时,新增了一个成员m_nMs表示毫秒,同时也提供其对应的接口:
这两个函数都复用了stgClock的接口,并且扩展了自己的功能,这叫”继承“。
在main函数中增加stgClockPlus的调用:
可以调试一下InitClockPlus函数,观察继承的内存结构:
InitClock执行完后,在内存窗口中,psClockPlus指向的3个连续的4字节已初始化为0x3, 0x12, 0x45,
当初始化毫秒执行完后,紧随其后的4个字节被初始化为0x666:
从内存结构的角度来看,继承就是在原先的内存结构后面追加新数据。
接下来,我们对FormatTimePlus函数做一点修改:
第一个参数由原来的 stgClockPlus* 改为 stgClock* 类型,在格式化输出毫秒的时候再强转为 stgClockPlus* 类型,这样FormatTimePlus和FormatTime函数的返回值、参数列表、调用约定都相同,我们可以认为是同一种类型的函数。
然后在stgClock结构前面插入一个该类型的函数指针__vfptr,姑且称之为”虚函数指针“:
在stgClock和stgClockPlus的初始化函数中,分别对虚函数指针赋值:
另外再封装一个新函数ShowTime:
该函数传入一个stgClock类型的指针,并且调用其__vfptr所代表的函数。
最后将main函数改为这样:
接下来进行调试,在执行完InitClock后,clock的__vfptr被赋值为0x00411046,这是函数FormatTime地址;
然后步入InitClockPlus函数内部,InitClockPlus要复用InitClock,所以在执行完InitClock后__vfptr同样被赋值为0x00411046:
但是下面的语句又将__vfptr替换为函数FormatTimePlus的地址0x0041118b:
接下来跟进ShowTime函数,第一次传入&clock,在执行到调用__vfptr所指向的函数时,按F11步入发现跳转到了FormatTime函数内部:
第二次传入&clockPlus,在执行到调用__vfptr所指向的函数时,按F11步入发现跳转到了FormatTimePlus函数内部:
也就是说,函数ShowTime的入参接收一个stgClock类型的指针,但无论是传入 stgClock* 类型还是扩展的 stgClockPlus* 类型,都能调用到其对应的格式化函数,我姑且称之为”多态“。
现在,封装,继承,多态,面向对象三大特性都集齐了,所以C语言是可以进行面向对象编程的,但是C并不适合写面向对象,比如我非要在main函数里写这么一个语句:
从语法的角度讲没有任何问题,但破坏了对象的封装性,也就是说用C写面向对象,代码的合理性完全取决于使用者的素质,为了更自然的使用面向对象,C++诞生了。
在C++里,有一个与结构体对标的概念叫类(class),我们想要解决类的内部成员不能被外部随意修改的问题,可以给它声明权限为私有(private),于是我们定义出一个类:
在写C代码时,函数与结构体是分离的,但是在C++里,类可以包括函数(也叫方法),并且函数可以对外开放,所以权限声明为共有(public):
函数的定义可以与声明分离开:
可以发现,类的成员是从结构体原模原样照搬过来的,但是函数都少了一个入参,就是stgClock*指针,并且函数的调用方式也发生了变化:
现在是通过 对象.函数 的形式调用的,似乎原先C语言调用需要传入的指针消失了,是真的不需要对象指针了吗?我们可以调试观察一下:
在调用InitClock之前,现在监视窗口观察对象clock的地址为0x0019FED0,然后按F11步入函数内部,然后打开寄存器窗口:
发现ecx的值变为了0x0019FED0,正是对象clock的地址,也就是说原先C语言函数调用显式传入的stgClock*指针现在通过寄存器ecx隐式的帮我们传了,这种调用约定称为__thiscall。
也可以在函数调用前,打开反汇编视图观察对象指针的传入:
用一条lea指令将clock的内存地址加载到ecx中。
当然我们也可以通过栈传递对象指针,那就改一下函数调用约定,比如使用__stdcall:
此时再调用函数,使用了push指令将对象指针压入栈中:
进入函数内部后,在内存窗口定位到&nHour的位置,可见从右向左依次压入了nSec(0x11), nMin(0x49), nHour(0x20),然后是对象指针0x0019fed0:
现在进入函数内部,我们知道对象指针已经隐式传入了,那该如何使用它呢?那就是关键字this:
可以在监视窗口中输入this,可见得出的值也是0x0019fed0。
如果想要明确表示使用类内部的成员,可以使用this->的形式:
接下来观察类的内存结构:
在三条赋值语句都执行完毕后,内存窗口中this指向的3个连续的4字节分别被初始化为0x20,0x49,0x11,发现类的内存结构与结构体是一样的。
我们现在声明一个对象后,是手动调用它的初始化方法,但如果一个对象在每次使用前还需要手动初始化,很有可能会忘掉,就好比公司规定上班要先打卡,但你可能经常忘,但如果公司门口有个闸机,每次进出就相当于打了卡,你就永远不会忘了,所以C++也需要有一种自动初始化对象的机制,那就是构造函数,没有返回值,函数名与类名相同:
当然,有构造就有解构,或者更常见的叫法是析构,析构就是对构造取反:~CClock()
于是现在使用对象就变成了这样:
在定义对象时调用了构造函数,何时调用析构?我们的对象现在是在栈上分配的,当离开对象作用域时会调用析构,在我们的代码里,main函数返回时clock对象的作用域也就结束了,可以在return 0处打个断点,代码执行到此处时按F11可步入析构函数,也可以查看其反汇编指令:
对象除了可以在栈上分配,也可以在堆上分配,C语言通常使用malloc函数动态申请堆内存,使用free函数释放内存:
不过使用malloc并不会调用对象的构造函数,所以在调试状态下你得到的对象将是一个由0xcdcd填充的内存块:
如果是这样,那构造函数岂不是失去了意义?所以C++又发明了新的关键字new,对标C语言的malloc,区别是new会调用构造函数:
对象已被初始化:
相对应的delete关键字,对标free,会在释放内存时调用析构函数。
在用C语言实现继承时,我们是用包含结构体的方式,C++天然支持继承语法,我们用C++的姿势派生一个CClockPlus类:
调试跟踪一下子类的构造过程,在执行到new CClockPlus时,打开反汇编视图:
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课
赞赏
- [原创]【Golang】interpolateParams参数导致的宽字节注入 1162
- [原创]从底层视角看面向对象 6942
- [原创]C语言的文件与缓冲区 5227
- [原创]CC1利用链分析 5472
- [原创]VC++6调试状态下的堆结构 5292