-
-
[原创]从底层视角看面向对象
-
发表于: 2024-9-21 13:43 9454
-
编程语言发展
假如让计算机运算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,所以上述步骤又可以表示为:
1 2 3 | mov ax, 1 ; 将数值1传送到寄存器ax mov bx, 2 ; 将数值2传送到寄存器bx add ax, bx ; 将ax与bx的数据相加并将结果保存到ax |
这就是汇编语言,汇编就是机器语言的符号化表示。
那么有了这种便于人类理解的形式,我们可以编写一些复杂的指令,比如:
1 2 3 4 5 6 7 8 | cmp ax, 2 ; 用ax中的值和2做比较 jle @1 ; 如果小于等于则跳转到标号@1处,执行bx递减操作 add ax, bx ; 否则执行ax+bx jmp @2 ; 执行完ax+bx跳转到@2处 @1: dec bx ; 执行bx递减操作 @2: ... |
上述指令演示了一个分支结构,如果ax <= 2 则执行ab+bx,否则执行bx-1。
再比如:
1 2 3 4 5 6 7 8 | xor ax, ax; 异或运算使ax清0 @lp: inc ax ; 递增ax cmp ax, 100 ; 将ax与100做比较 jae @1 ; 如果大于等于则跳转到@1处 jmp @lp ; 否则跳回@lp处重新执行 @1: ... |
这组指令表示了一个循环结构,ax的值从0开始递增,直到100跳出循环。
可以看出,汇编语言虽然转化为了人类易于理解的形式,但想要编写复杂的程序,还需要写大量的指令,并不是很方便,能否用一种更简洁的形式来代表这些指令?比如分支用if else表示,循环用while, do while, for表示,于是C语言诞生了,C语言表示分支结构是这样:
1 2 3 4 5 6 7 8 9 | // 伪代码 if (ax <= 2) { bx--; } else { ax = ax + bx; } |
循环结构是这样:
1 2 3 4 5 | // 伪代码 for (ax = 0; ax < 100; ax++) { //... } |
用起来舒服多了,C语言的出现极大提高了编程效率,并且一定程度上解决了跨平台的问题,过去用汇编写程序时,在x86平台上编写的指令是无法拿到arm平台上执行的,但是有了C语言,在不改动源代码的情况下,只要有目标平台的编译环境,就可以将代码编译为该平台的汇编指令。
我们常说C语言是面向过程的语言,我倒是认为面向过程更符合人类在编程时的直觉,只要明确了要实现的功能,只需按照步骤
step1;
step2;
step3;
...
往下写就行了,举个例子,假设要实现一个时钟的功能,包含时分秒三个数据,能够设置这三个数据,并且格式化显示出来,按照面向过程的思维就这么写:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | #include <stdio.h> int main() { // 声明三个变量分别表示时、分、秒 int nHour; int nMin; int nSec; // 设置时钟时间 nHour = 12; nMin = 35; nSec = 34; // 格式化显示时间 printf ( "%d:%d:%d\n" , nHour, nMin, nSec); return 0; }</stdio.h> |
很直观,但是也存在一些问题,数据是零散的,能否抽象出一个实体表示时钟,将时分秒集成到时钟内部,那么我们可以定义一个结构体:
1 2 3 4 5 6 | typedef struct _stgClock { int m_nHour; int m_nMin; int m_nSec; } stgClock; |
还有一个问题,如果买来一个钟表想要调它的时间,难道要把表拆开亲自去拨动里面的齿轮吗?当然不是,通常钟表后面有两个可以旋转的按钮,通过旋转它们即可调整到正确的时间,那么我们的程序也不应该直接修改stgClock里的变量,而是提供修改时分秒的接口,通过调用接口来设置时间,所以我们提供一些函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | /* * 初始化时间 */ void Init(stgClock* psClock, int nHour, int nMin, int nSec) { psClock->m_nHour = nHour; psClock->m_nMin = nMin; psClock->m_nSec = nSec; } /* * 格式化时间 */ void FormatTime(stgClock* psClock, char * pszBuf, int nBufSize) { // 为了调试方便按16进制输出 sprintf_s(pszBuf, nBufSize, "%x:%x:%x" , psClock->m_nHour, psClock->m_nMin, psClock->m_nSec); } |
这就叫做”封装“。
有了封装,于是main函数改为这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | #include <stdio.h> #include "Clock.h" #define BUF_SIZE 1024 int main() { // 定义一个时钟对象 stgClock clock ; // 初始化时间 InitClock(& clock , 0x2, 0x14, 0x36); // 格式化时间 char szBuf[BUF_SIZE] = { 0 }; FormatTime(& clock , szBuf, BUF_SIZE); // 显示时间 printf ( "%s\n" , szBuf); return 0; }</stdio.h> |
数据都被集成到结构体内部,对外通过调用接口来操作时钟,而并非直接修改里面的变量。
可以通过调试InitClock函数,观察stgClock的内存结构:
当三条赋值语句执行完后,内存中psClock指向的3个连续的4字节被分别初始化为0x02,0x14,0x36。
现在假设推出一款精度更高的时钟,除了拥有原先stgClock的功能外,还要求精确到毫秒,那么我们再定义一个结构体:
1 2 3 4 5 | typedef struct _stgClockPlus { stgClock m_clock; int m_nMs; // 毫秒 } stgClockPlus; |
包含了原stgClock的同时,新增了一个成员m_nMs表示毫秒,同时也提供其对应的接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | void InitClockPlus(stgClockPlus* psClockPlus, int nHour, int nMin, int nSec, int nMs) { // 复用stgClock的函数初始化时分秒 InitClock(&psClockPlus->m_clock, nHour, nMin, nSec); // 初始化毫秒 psClockPlus->m_nMs = nMs; } void FormatTimePlus(stgClockPlus* psClockPlus, char * pszBuf, int nBufSize) { // 复用stgClock的函数格式化时分秒 FormatTime(&psClockPlus->m_clock, pszBuf, nBufSize); // 追加毫秒 sprintf_s(pszBuf + strlen (pszBuf), nBufSize - strlen (pszBuf), ".%x" , psClockPlus->m_nMs); } |
这两个函数都复用了stgClock的接口,并且扩展了自己的功能,这叫”继承“。
在main函数中增加stgClockPlus的调用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | #include <stdio.h> #include "Clock.h" #include "ClockPlus.h" #define BUF_SIZE 1024 int main() { // 定义一个时钟对象 stgClock clock ; // 初始化时间 InitClock(& clock , 0x2, 0x14, 0x36); // 格式化时间 char szBuf[BUF_SIZE] = { 0 }; FormatTime(& clock , szBuf, BUF_SIZE); // 显示时间 printf ( "%s\n" , szBuf); stgClockPlus clockPlus; InitClockPlus(&clockPlus, 0x3, 0x12, 0x45, 0x666); FormatTimePlus(&clockPlus, szBuf, BUF_SIZE); printf ( "%s\n" , szBuf); return 0; }</stdio.h> |
可以调试一下InitClockPlus函数,观察继承的内存结构:
InitClock执行完后,在内存窗口中,psClockPlus指向的3个连续的4字节已初始化为0x3, 0x12, 0x45,
当初始化毫秒执行完后,紧随其后的4个字节被初始化为0x666:
从内存结构的角度来看,继承就是在原先的内存结构后面追加新数据。
接下来,我们对FormatTimePlus函数做一点修改:
1 2 3 4 5 6 | void FormatTimePlus(stgClock* psClock, char * pszBuf, int nBufSize) { // 复用stgClock的函数 FormatTime(psClock, pszBuf, nBufSize); sprintf_s(pszBuf + strlen (pszBuf), nBufSize - strlen (pszBuf), ".%x" , ((stgClockPlus*)psClock)->m_nMs); } |
第一个参数由原来的 stgClockPlus* 改为 stgClock* 类型,在格式化输出毫秒的时候再强转为 stgClockPlus* 类型,这样FormatTimePlus和FormatTime函数的返回值、参数列表、调用约定都相同,我们可以认为是同一种类型的函数。
然后在stgClock结构前面插入一个该类型的函数指针__vfptr,姑且称之为”虚函数指针“:
1 2 3 4 5 6 7 | typedef struct _stgClock { void (* __vfptr) ( struct _stgClock*, char *, int ); // 虚函数指针 int m_nHour; int m_nMin; int m_nSec; } stgClock; |
在stgClock和stgClockPlus的初始化函数中,分别对虚函数指针赋值:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | void InitClock(stgClock* psClock, int nHour, int nMin, int nSec) { psClock->__vfptr = FormatTime; // 设置clock的虚函数指针 psClock->m_nHour = nHour; psClock->m_nMin = nMin; psClock->m_nSec = nSec; } void InitClockPlus(stgClockPlus* psClockPlus, int nHour, int nMin, int nSec, int nMs) { InitClock(&psClockPlus->m_clock, nHour, nMin, nSec); ((stgClock*)psClockPlus)->__vfptr = FormatTimePlus; // 将虚函数指针替换为FormatTimePlus psClockPlus->m_nMs = nMs; } |
另外再封装一个新函数ShowTime:
1 2 3 4 5 6 | void ShowTime(stgClock* psClock) { char szBuf[BUF_SIZE] = { 0 }; psClock->__vfptr(psClock, szBuf, BUF_SIZE); printf ( "%s\n" , szBuf); } |
该函数传入一个stgClock类型的指针,并且调用其__vfptr所代表的函数。
最后将main函数改为这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 | int main() { stgClock clock ; InitClock(& clock , 0x2, 0x14, 0x36); stgClockPlus clockPlus; InitClockPlus(&clockPlus, 0x3, 0x12, 0x45, 0x666); ShowTime(& clock ); ShowTime((stgClock*)&clockPlus); return 0; } |
接下来进行调试,在执行完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函数里写这么一个语句:
1 | clock .m_nHour = -1; |
从语法的角度讲没有任何问题,但破坏了对象的封装性,也就是说用C写面向对象,代码的合理性完全取决于使用者的素质,为了更自然的使用面向对象,C++诞生了。
C++对面向对象的支持
在C++里,有一个与结构体对标的概念叫类(class),我们想要解决类的内部成员不能被外部随意修改的问题,可以给它声明权限为私有(private),于是我们定义出一个类:
1 2 3 4 5 6 7 | class CClock { private : int m_nHour; int m_nMin; int m_nSec; }; |
在写C代码时,函数与结构体是分离的,但是在C++里,类可以包括函数(也叫方法),并且函数可以对外开放,所以权限声明为共有(public):
1 2 3 4 5 6 7 8 9 10 11 | class CClock { public : void InitClock( int nHour, int nMin, int nSec); void FormatTime( char * pszBuf, int nBufSize); private : int m_nHour; int m_nMin; int m_nSec; }; |
函数的定义可以与声明分离开:
1 2 3 4 5 6 7 8 9 10 11 12 | void CClock::InitClock( int nHour, int nMin, int nSec) { m_nHour = nHour; m_nMin = nMin; m_nSec = nSec; } void CClock::FormatTime( char * pszBuf, int nBufSize) { // 为了调试方便按16进制输出 sprintf_s(pszBuf, nBufSize, "%02x:%02x:%02x" , m_nHour, m_nMin, m_nSec); } |
可以发现,类的成员是从结构体原模原样照搬过来的,但是函数都少了一个入参,就是stgClock*指针,并且函数的调用方式也发生了变化:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | #include "CClock.h" #include <iostream> using namespace std; #define BUF_SIZE 1024 int main() { CClock clock ; clock .InitClock(0x20, 0x49, 0x11); char szBuf[BUF_SIZE] = { 0 }; clock .FormatTime(szBuf, BUF_SIZE); cout << szBuf << endl; return 0; }</iostream> |
现在是通过 对象.函数 的形式调用的,似乎原先C语言调用需要传入的指针消失了,是真的不需要对象指针了吗?我们可以调试观察一下:
在调用InitClock之前,现在监视窗口观察对象clock的地址为0x0019FED0,然后按F11步入函数内部,然后打开寄存器窗口:
发现ecx的值变为了0x0019FED0,正是对象clock的地址,也就是说原先C语言函数调用显式传入的stgClock*指针现在通过寄存器ecx隐式的帮我们传了,这种调用约定称为__thiscall。
也可以在函数调用前,打开反汇编视图观察对象指针的传入:
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课
赞赏
- [原创]【Golang】interpolateParams参数导致的宽字节注入 2402
- [原创]从底层视角看面向对象 9455
- [原创]C语言的文件与缓冲区 8724
- [原创]CC1利用链分析 8848
- [原创]VC++6调试状态下的堆结构 7520