编程语言发展
假如让计算机运算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,所以上述步骤又可以表示为:
mov ax, 1 ; 将数值1传送到寄存器ax mov bx, 2 ; 将数值2传送到寄存器bx add ax, bx ; 将ax与bx的数据相加并将结果保存到ax
这就是汇编语言,汇编就是机器语言的符号化表示。
那么有了这种便于人类理解的形式,我们可以编写一些复杂的指令,比如:
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。
再比如:
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语言表示分支结构是这样:
// 伪代码
if (ax <= 2)
{
bx--;
}
else
{
ax = ax + bx;
}循环结构是这样:
// 伪代码
for (ax = 0; ax < 100; ax++)
{
//...
}用起来舒服多了,C语言的出现极大提高了编程效率,并且一定程度上解决了跨平台的问题,过去用汇编写程序时,在x86平台上编写的指令是无法拿到arm平台上执行的,但是有了C语言,在不改动源代码的情况下,只要有目标平台的编译环境,就可以将代码编译为该平台的汇编指令。
我们常说C语言是面向过程的语言,我倒是认为面向过程更符合人类在编程时的直觉,只要明确了要实现的功能,只需按照步骤
step1;
step2;
step3;
...
往下写就行了,举个例子,假设要实现一个时钟的功能,包含时分秒三个数据,能够设置这三个数据,并且格式化显示出来,按照面向过程的思维就这么写:
#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;
}很直观,但是也存在一些问题,数据是零散的,能否抽象出一个实体表示时钟,将时分秒集成到时钟内部,那么我们可以定义一个结构体:
typedef struct _stgClock
{
int m_nHour;
int m_nMin;
int m_nSec;
} stgClock;还有一个问题,如果买来一个钟表想要调它的时间,难道要把表拆开亲自去拨动里面的齿轮吗?当然不是,通常钟表后面有两个可以旋转的按钮,通过旋转它们即可调整到正确的时间,那么我们的程序也不应该直接修改stgClock里的变量,而是提供修改时分秒的接口,通过调用接口来设置时间,所以我们提供一些函数:
/*
* 初始化时间
*/
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函数改为这样:
#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;
}数据都被集成到结构体内部,对外通过调用接口来操作时钟,而并非直接修改里面的变量。
可以通过调试InitClock函数,观察stgClock的内存结构:

当三条赋值语句执行完后,内存中psClock指向的3个连续的4字节被分别初始化为0x02,0x14,0x36。
现在假设推出一款精度更高的时钟,除了拥有原先stgClock的功能外,还要求精确到毫秒,那么我们再定义一个结构体:
typedef struct _stgClockPlus
{
stgClock m_clock;
int m_nMs; // 毫秒
} stgClockPlus;包含了原stgClock的同时,新增了一个成员m_nMs表示毫秒,同时也提供其对应的接口:
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的调用:
#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;
}可以调试一下InitClockPlus函数,观察继承的内存结构:

InitClock执行完后,在内存窗口中,psClockPlus指向的3个连续的4字节已初始化为0x3, 0x12, 0x45,
当初始化毫秒执行完后,紧随其后的4个字节被初始化为0x666:

从内存结构的角度来看,继承就是在原先的内存结构后面追加新数据。
接下来,我们对FormatTimePlus函数做一点修改:
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,姑且称之为”虚函数指针“:
typedef struct _stgClock
{
void (* __vfptr) (struct _stgClock*, char*, int); // 虚函数指针
int m_nHour;
int m_nMin;
int m_nSec;
} stgClock;在stgClock和stgClockPlus的初始化函数中,分别对虚函数指针赋值:
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:
void ShowTime(stgClock* psClock)
{
char szBuf[BUF_SIZE] = { 0 };
psClock->__vfptr(psClock, szBuf, BUF_SIZE);
printf("%s\n", szBuf);
}该函数传入一个stgClock类型的指针,并且调用其__vfptr所代表的函数。
最后将main函数改为这样:
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函数里写这么一个语句:
clock.m_nHour = -1;
从语法的角度讲没有任何问题,但破坏了对象的封装性,也就是说用C写面向对象,代码的合理性完全取决于使用者的素质,为了更自然的使用面向对象,C++诞生了。
C++对面向对象的支持
在C++里,有一个与结构体对标的概念叫类(class),我们想要解决类的内部成员不能被外部随意修改的问题,可以给它声明权限为私有(private),于是我们定义出一个类:
class CClock
{
private:
int m_nHour;
int m_nMin;
int m_nSec;
};在写C代码时,函数与结构体是分离的,但是在C++里,类可以包括函数(也叫方法),并且函数可以对外开放,所以权限声明为共有(public):
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;
};函数的定义可以与声明分离开:
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*指针,并且函数的调用方式也发生了变化:
#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;
}现在是通过 对象.函数 的形式调用的,似乎原先C语言调用需要传入的指针消失了,是真的不需要对象指针了吗?我们可以调试观察一下:

在调用InitClock之前,现在监视窗口观察对象clock的地址为0x0019FED0,然后按F11步入函数内部,然后打开寄存器窗口:

发现ecx的值变为了0x0019FED0,正是对象clock的地址,也就是说原先C语言函数调用显式传入的stgClock*指针现在通过寄存器ecx隐式的帮我们传了,这种调用约定称为__thiscall。
也可以在函数调用前,打开反汇编视图观察对象指针的传入:

用一条lea指令将clock的内存地址加载到ecx中。
当然我们也可以通过栈传递对象指针,那就改一下函数调用约定,比如使用__stdcall:
void __stdcall InitClock(int nHour, int nMin, int nSec);
此时再调用函数,使用了push指令将对象指针压入栈中:

进入函数内部后,在内存窗口定位到&nHour的位置,可见从右向左依次压入了nSec(0x11), nMin(0x49), nHour(0x20),然后是对象指针0x0019fed0:

现在进入函数内部,我们知道对象指针已经隐式传入了,那该如何使用它呢?那就是关键字this:

可以在监视窗口中输入this,可见得出的值也是0x0019fed0。
如果想要明确表示使用类内部的成员,可以使用this->的形式:
void CClock::InitClock(int nHour, int nMin, int nSec)
{
this->m_nHour = nHour;
this->m_nMin = nMin;
this->m_nSec = nSec;
}接下来观察类的内存结构:

在三条赋值语句都执行完毕后,内存窗口中this指向的3个连续的4字节分别被初始化为0x20,0x49,0x11,发现类的内存结构与结构体是一样的。
我们现在声明一个对象后,是手动调用它的初始化方法,但如果一个对象在每次使用前还需要手动初始化,很有可能会忘掉,就好比公司规定上班要先打卡,但你可能经常忘,但如果公司门口有个闸机,每次进出就相当于打了卡,你就永远不会忘了,所以C++也需要有一种自动初始化对象的机制,那就是构造函数,没有返回值,函数名与类名相同:
class CClock
{
public:
CClock(int nHour, int nMin, int nSec); //构造函数
void FormatTime(char* pszBuf, int nBufSize);
private:
int m_nHour;
int m_nMin;
int m_nSec;
};// 实现与InitClock一样
CClock::CClock(int nHour, int nMin, int nSec)
{
this->m_nHour = nHour;
this->m_nMin = nMin;
this->m_nSec = nSec;
}当然,有构造就有解构,或者更常见的叫法是析构,析构就是对构造取反:~CClock()
class CClock
{
public:
CClock(int nHour, int nMin, int nSec); //构造函数
~CClock(); //析构函数
void FormatTime(char* pszBuf, int nBufSize);
private:
int m_nHour;
int m_nMin;
int m_nSec;
};CClock::~CClock()
{
// 不涉及资源释放,象征性的写点代码
this->m_nHour = 0;
this->m_nMin = 0;
this->m_nSec = 0;
}于是现在使用对象就变成了这样:
int main()
{
CClock clock(0x20, 0x49, 0x11);
char szBuf[BUF_SIZE] = { 0 };
clock.FormatTime(szBuf, BUF_SIZE);
cout << szBuf << endl;
return 0;
}在定义对象时调用了构造函数,何时调用析构?我们的对象现在是在栈上分配的,当离开对象作用域时会调用析构,在我们的代码里,main函数返回时clock对象的作用域也就结束了,可以在return 0处打个断点,代码执行到此处时按F11可步入析构函数,也可以查看其反汇编指令:

对象除了可以在栈上分配,也可以在堆上分配,C语言通常使用malloc函数动态申请堆内存,使用free函数释放内存:
int main()
{
CClock* pClock = (CClock*)malloc(sizeof(CClock));
char szBuf[BUF_SIZE] = { 0 };
pClock->FormatTime(szBuf, BUF_SIZE);
cout << szBuf << endl;
free(pClock);
return 0;
}不过使用malloc并不会调用对象的构造函数,所以在调试状态下你得到的对象将是一个由0xcdcd填充的内存块:

如果是这样,那构造函数岂不是失去了意义?所以C++又发明了新的关键字new,对标C语言的malloc,区别是new会调用构造函数:
int main()
{
CClock* pClock = new CClock(0x20, 0x49, 0x11);
char szBuf[BUF_SIZE] = { 0 };
pClock->FormatTime(szBuf, BUF_SIZE);
cout << szBuf << endl;
delete pClock;
return 0;
}对象已被初始化:

相对应的delete关键字,对标free,会在释放内存时调用析构函数。
在用C语言实现继承时,我们是用包含结构体的方式,C++天然支持继承语法,我们用C++的姿势派生一个CClockPlus类:
// CClockPlus继承于CClock,
// CClock称为父类(基类),
// CClockPlus称为子类(派生类)
class CClockPlus : public CClock
{
public:
CClockPlus(int nHour, int nMin, int nSec, int nMs);
~CClockPlus();
void FormatTime(char* pszBuf, int nBufSize);
private:
int m_nMs; // 毫秒
};CClockPlus::CClockPlus(int nHour, int nMin, int nSec, int nMs)
:CClock::CClock(nHour, nMin, nSec) // 在初始化列表中构造父类
{
this->m_nMs = nMs;
}
CClockPlus::~CClockPlus()
{
// 不涉及资源释放,象征性的写点代码
this->m_nMs = 0;
}
void CClockPlus::FormatTime(char* pszBuf, int nBufSize)
{
CClock::FormatTime(pszBuf, nBufSize); // 复用父类的FormatTime函数
sprintf_s(pszBuf + strlen(pszBuf), nBufSize - strlen(pszBuf), ".%x", this->m_nMs);
}int main()
{
CClockPlus* pClockPlus = new CClockPlus(0x20, 0x49, 0x11, 0x666);
char szBuf[BUF_SIZE] = { 0 };
pClockPlus->FormatTime(szBuf, BUF_SIZE);
cout << szBuf << endl;
delete pClockPlus;
return 0;
}调试跟踪一下子类的构造过程,在执行到new CClockPlus时,打开反汇编视图:

先执行了一个operator new的调用,这是一个全局运算符重载,申请了16字节堆内存,调试状态下会用0xcdcd填充。
PS:由于在写这里时反复调试了好几次,每次申请到的堆内存地址不同,所以后文的地址与这里不一致,见谅。
跳转到operator new的源码,可见其也是调用了malloc:

之后会调用CClockPlus的构造函数:

步入到CClockPlus构造函数内部:

发现这里是先调用父类的构造函数,然后才会执行子类的构造代码。
执行完父类的构造代码之后,this指向的连续3个4字节已被初始化为0x20,0x49,0x11:

然后执行子类的构造代码,将紧随其后的4字节赋值为0x666:

可见,C++继承的内存结构与包含结构体是一样的。
在调用delete pClockPlus时会调用CClockPlus的析构函数,步入到CClockPlus的析构中,打开反汇编视图:

可见,在执行了子类的析构代码后,还要调用父类的析构。
当父类的析构代码执行完毕后,this指向的4个连续4字节已置0:

调用完子类和父类的析构之后,还要执行一个operator delete的调用,在执行完这步后,堆内存就被释放,在调试状态下会用0xdddd填充:

跟进operator delete的源码可见其内部调用了free:

总结一下刚才的执行步骤:
申请堆内存
构造父类
构造子类
析构子类
析构父类
释放堆内存
现在还是封装一个ShowTime函数,传入一个CClock* 指针:
void ShowTime(CClock* pClock)
{
char szBuf[BUF_SIZE] = { 0 };
pClock->FormatTime(szBuf, BUF_SIZE);
cout << szBuf << endl;
}int main()
{
CClock* pClock1 = new CClock(0x20, 0x49, 0x11);
CClock* pClock2 = new CClockPlus(0x6, 0x9, 0x22, 0x666); // 父类指针指向子类对象
ShowTime(pClock1);
ShowTime(pClock2);
delete pClock1;
delete pClock2;
return 0;
}但是这样写,在传入pClock2时依旧调用的是父类的FormatTime,没有实现”多态“,为了解决这个问题,C++又发明了新的关键字virtual:
class CClock
{
public:
CClock(int nHour, int nMin, int nSec); //构造函数
~CClock(); //析构函数
virtual void FormatTime(char* pszBuf, int nBufSize); // virtual表示虚函数
private:
int m_nHour;
int m_nMin;
int m_nSec;
};class CClockPlus : public CClock
{
public:
CClockPlus(int nHour, int nMin, int nSec, int nMs);
~CClockPlus();
virtual void FormatTime(char* pszBuf, int nBufSize) override; // override明确表示覆盖了父类的虚函数
private:
int m_nMs; // 毫秒
};加上virtual,即可在传入pClock1时调用父类的FormatTime,传入pClock2 时调用子类的FormatTime。
不过现在产生了另外一个问题,在delete pClock2 时,调用的是父类的析构,而不是子类的析构:

为了解决这个问题,只好给析构也加上virtual关键字:
class CClock
{
public:
CClock(int nHour, int nMin, int nSec); //构造函数
virtual ~CClock(); // 虚析构函数
virtual void FormatTime(char* pszBuf, int nBufSize);
private:
int m_nHour;
int m_nMin;
int m_nSec;
};class CClockPlus : public CClock
{
public:
CClockPlus(int nHour, int nMin, int nSec, int nMs);
virtual ~CClockPlus(); // 虚析构函数
virtual void FormatTime(char* pszBuf, int nBufSize) override;
private:
int m_nMs; // 毫秒
};之前在用C语言实现多态时,我们是手动在结构体头部插入了一个虚函数指针,C++的原理也类似,
在new CClockPlus时,步入到父类的构造函数里:

发现this指向的首地址处被插入了一个4字节,值为0x0041ab34,在内存窗口里跳转到这个地址:

发现这里又保存了两个地址,0x004114d3和0x004113bb,在反汇编视图中跳转到这两个地址可见:
一个是虚析构函数的地址:

一个是虚函数FormatTime的地址:

也就是说C++实现多态,也是在对象头部插入了一个指针,称之为虚函数指针__vfptr,该指针不是直接指向某个函数,而是指向一个表,称之为虚函数表(vftable),记录了类中所有虚函数的地址。
__vfptr是何时被插入的呢?在执行父类的构造代码之前,有这么一条汇编指令,执行完该指令之后,对象头部就被插入了vftable的地址:

在使用C语言初始化stgClockPlus时,会手动将__vfptr替换为自己的函数地址,C++在执行完父类的构造,执行子类构造代码之前,也会有这么一个操作:

此时,对象头部的__vfptr已被替换成了0x0041ab58,这里同样保存了两个函数地址,

分别是子类的析构:

和子类的FormatTime:

在执行delete pClock2时,由于当前虚函数表是子类的,所以会调用到CClockPlus的析构,执行完子类的析构代码,进而调用父类的析构,在执行父类析构之前,发现它又重新把__vfptr替换为父类的虚表了:

我们常说多态是运行时多态,这个”运行时“就体现在这里,多态的实现依赖于虚函数表,而虚函数表的地址是在执行构造函数时写入的。