首页
社区
课程
招聘
5
[原创]从底层视角看面向对象
发表于: 2024-9-21 13:43 9454

[原创]从底层视角看面向对象

2024-9-21 13:43
9454

编程语言发展

假如让计算机运算1+2,包含如下几步操作

  1. 将数值1写入寄存器A

  2. 数值2写入寄存器B

  3. 将寄存器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直播授课

最后于 2024-10-10 14:52 被米龙·0xFFFE编辑 ,原因:
收藏
免费 5
支持
分享
赞赏记录
参与人
雪币
留言
时间
sinker_
感谢你的积极参与,期待更多精彩内容!
2025-2-18 07:55
PLEBFE
为你点赞!
2025-2-7 06:56
hipwang
感谢你的贡献,论坛因你而更加精彩!
2024-10-11 17:03
mb_omefuxtv
+1
非常支持你的观点!
2024-10-3 22:38
mb_wpitiize
+1
谢谢你的细致分析,受益匪浅!
2024-9-23 10:16
最新回复 (0)
游客
登录 | 注册 方可回帖
返回

账号登录
验证码登录

忘记密码?
没有账号?立即免费注册