笨鸟先飞学编程系列-C++的封装性
C++的阶段,我想根据 C++的一些特有的特性分别写一些专题,每个专题我都捎带讲一些语法,当然不会很多,
我还是会像 C 语言那样,内存结构贯穿始终,有汇编就有真相……
本专题,我们讲述封装性。封装性是 C++的入门特性,要想学习 C++语言,封装性是首先要掌握的。下面我们进
入正题:
一、 类与对象
早在本系列第一节课(理解程序中的数据)的时候讲述过数据类型与数据的区别和联系当时得出的结论如下:
数据类型规定了数据的大小和表现形式
数据就是电脑中存放的数。
每个数据都有自己的地址,而这些地址可以有变量来代替
因此,数据通常存放于变量(或常量)中
这个结论在 C++中仍然同样适用,类就是我们自己定义的复杂的数据类型,而对象则就是由类声明的变量。
下面我们进入纯语法层面。
1、 类的定义方法
我相信,大家都还记得我在第一节课的时候讲述的结构体的课程,也相信大家没有忘记怎么定义一个结
构体。下面我给出类的定义方法:
// 是不是很像定义一个结构体
class CExample
{
private: // 权限控制,相关内容在下面的小节中详细讲述
// 定义成员变量。也叫属性
int m_nFirstNum;
int m_nSecNum;
public:
int
bool SetNum(int nFirst, int nSec)
{
GetSum() const {return m_nFirstNum}
// 成员函数
m_nFirstNum = nFirst;
m_nSecNum = nSec ;
return true;
}
CExample(){m_nFirstNum = 0; m_nSecNum = 0;} //构造函数
~CExample(){}
// 空析构
};
当然,上面这个类的定义是不是很像定义一个结构体?只不过多了个 private 和 public 还有一些函数。是
的,C++里面,将结构体升级了,结构体里面可以有函数成员了,为了兼容,换了个关键字,当然,上面的这
个 class 完全可以改成 struct,一点问题都没有。
好奇的朋友会问:如果函数体的语句太多,逻辑复杂了,函数很大,那这个类岂不是很难看,太臃肿了吧。
是的,为了方便类的组织,也为了协调项目工程中文件的组织。上面的类还可以写成如下的形式:
// .h 文件中写如下的声明部分
class CExample
{
private: // 权限控制,防止外面直接操作这些变量,相关内容在下面的小节中详细讲述
// 是不是很像定义一个结构体
int m_nFirstNum;
int m_nSecNum;
// 定义成员变量。也叫属性
GetSum() const;
public:
int
bool SetNum(int nFirst, int nSec);
CExample();
~CExample();
// 成员函数
//构造函数
// 空析构
};
// .cpp 文件中写如下的定义及实现部分
CExample::CExample()
{
}
CExample::~CExample()
{
}
int CExample::GetFirstNum() const
{
return m_nFirstNum;
}
int CExample::GetSecNum() const
{
return m_nSecNum;
}
bool CExample::SetNum(int nFirst, int nSec)
{
m_nFirstNum = nFirst;
m_nSecNum = nSec ;
return true;
}
int CExample::GetSum() const
{
return m_nFirstNum+m_nSecNum;
}
上面两种写法也是有区别的,第一种方法写的函数具有 Inline 函数的特性。后一种则没有。
2、 属性和方法的使用
C++中定义一个对象跟定义一个函数没有什么区别。
#include
#include "Example.h"
int main(int argc, char* argv[])
{
CExample obj_Exp;
// 定义一个对象
obj_Exp.SetNum(10, 20); // 调用一个方法
printf("%d+%d = %d\r\n", obj_Exp.GetFirstNum(),
obj_Exp.GetSecNum(),
obj_Exp.GetSum());
return 0;
}
由此,我们就可以通过一个函数间接的来操作我们的变量,用户在给我们的变量赋值时,我们可以通过
Set 函数来对输入的内容作检测,在获取一个变量的内容时,我们可以通过一个函数来取得,这样都提高了程
序安全性。
也许,有的朋友会说,如果我绕过你提供的函数,直接对_nFirstNum;和 m_nSecNum;进行操作不是一样
不安全么,是的,这就是为什么我在程序中加上了 private 的原因。下面我们详细说明下这些关键字的含义。
private、public、protected 三个关键字,是 C++提供给并实现类封装的关键,它们用来说明类外的代码可
以直接访问类内的什么哪些成员,哪些成员不可能被外面访问。
private:说明,它后面所有的变量和函数,都不可能被类外访问,只能在类内被使用。
public:说明,它后面的所有变量和函数可以被类外的代码所访问,没有任何限制。
protected:说明,它后面的所有变量和函数,只能被自己或自己派生的类所使用。不能被类外的代码使用。
这个我们将在 C++继承性中详细讨论。
从程序设计的角度来讲,如果我们以类为单位编码的话,每个模块都是独立的我们只要关注与本类相关
操作,比如人这个类,它一般情况下有两个眼睛、一个嘴巴等之类的属性,人可以使用工具,可以行走,可
以跳跃等方法。我们编写的所有的函数都是针对这些展开的。而不用去关心谁要使用这个类。因此,类/对象
概念的加入,不单单是给编码方式做了改变,主要是设计思路的改变,程序模块化的改变等等。
3、 关于常成员函数
相信我们讲过的 const 与 inline 相关的知识,大家一定都没有忘记,是的,你猜对了,现在我们要说的就
是 const 的一个扩展用法。
当然,不用担心,这只是一个小小的扩展,不用担心混淆杂乱:当我们的成员函数不允许修改我们类中
的成员内容时,可以在函数的参数列表后加上一个 const 关键字。以免以后不小心更改了我们的类中成员属性。
二、 解析对象的内存结构
现在,我相信,如果习惯了我这种学习方式的朋友一定会很好奇,类定义对象的内存格式是怎样的,它
是不是像一个普通变量那样,或者是不是像一个结构体变量那样在内存里连续的将各个成员组织到一起,它
又是怎样将各个成员变量还有函数绑定到一起的?变量和函数在一起它是怎么组织的?本小节让我们来解决
这些问题。
为节省篇幅,我们仍旧使用上面的代码。我们用 VC 的调试器,调试这个代码:
注意看我们的变量监视区,我们定义的对象的内容跟结构体成员的内容格式差不多,(是按照定义的顺序
连续存放的,这点跟普通的局部变量不一样,普通的局部变量在内存中的顺序与定义顺序相反)内存中只存
放了成员变量,它并没有标出 SetNum 的位置,那它是怎么找到 SetNum 这个函数的呢?
根据我们先前调试 C 函数的经验,我们知道,函数的代码是被放在可执行文件的代码区段中的。在这个
CExample obj_Exp;
dword ptr [ebp-4],0
ecx,[ebp-14h]
@ILT+15(CExample::CExample) (00401014)
代码中,也有调用 SetNum 的代码,我们详细的跟一下它的汇编代码:
10:
lea
004011ED
004011F0
call
004011F5 mov
11:
004011FC
004011FE
00401200
00401203
14h
0Ah
ecx,[ebp-14h]
@ILT+0(CExample::SetNum) (00401005)
obj_Exp.SetNum(10, 20);
push
push
lea
call
这段代码又给我们带来了新的问题,我们只用类定义了一个对象(变量),它自动的调用了一个函数,根
CExample::CExample()
{
据注释我们知道它调用的是构造函数。我们跟进去看下:
11:
12:
push
00401050
00401051 mov
sub
00401053
00401056
push
push
00401057
push
00401058
00401059
push
0040105A
lea
0040105D mov
ebp
ebp,esp
esp,44h
ebx
esi
edi
ecx
edi,[ebp-44h]
ecx,11h
; 保存寄存器环境
}
00401062 mov
rep stos
00401067
00401069
pop
0040106A mov
13:
0040106D mov
pop
00401070
00401071
pop
00401072
pop
00401073 mov
00401075
pop
ret
00401076
eax,0CCCCCCCCh
dword ptr [edi]
; 将栈空间清为 CC(Release 编译就没有这部分代码了。)
; 将 ECX 中的内容给局部变量
; 将 ECX 的内容返回
ecx
dword ptr [ebp-4],ecx
eax,dword ptr [ebp-4]
edi
esi
ebx
esp,ebp
ebp
这段代码,首次看还真看不出个所以然来,源码的构造函数中,我们什么都没写,是个空函数,而这里
做的是返回 ECX 的值,可是这个函数也没有对 ECX 做什么特别的操作,而是直接使用进函数时 ECX 的值。
那只能说明在调用这个函数前,ECX 发生了变化。我们再回头看下调用构造函数的代码:
10:
004011ED
lea
004011F0
call
004011F5 mov
ecx,[ebp-14h]
@ILT+15(CExample::CExample) (00401014)
CExample obj_Exp;
dword ptr [ebp-4],0
obj_Exp.SetNum(10, 20);
; 传递参数
哈哈,它是把我们 obj_Exp 对象的地址给了 ECX,然后调用构造返回的,也就是说,构造的返回值是我
们对象的首地址。哎,迷糊了,真搞不懂这是在干什么。先不管他,我们继续看怎么调用的 SetNum 这个函数
吧:
11:
004011FC
004011FE
00401200
00401203
29:
30:
14h
0Ah
ecx,[ebp-14h]
@ILT+0(CExample::SetNum) (00401005)
bool CExample::SetNum(int nFirst, int nSec)
{
; 也有这句,还是把我们的对象首地址给 ECX
push
push
lea
call
push
00401130
00401131 mov
sub
00401133
push
00401136
00401137
push
push
00401138
ebp
ebp,esp
esp,44h
ebx
esi
edi
00401139
push
0040113A
lea
0040113D mov
00401142 mov
rep stos
00401147
00401149
pop
0040114A mov
31:
0040114D mov
00401150 mov
00401153 mov
32:
00401155 mov
00401158 mov
0040115B mov
m_nFirstNum = nFirst;
m_nSecNum = nSec ;
ecx
edi,[ebp-44h]
ecx,11h
eax,0CCCCCCCCh
dword ptr [edi]
ecx
dword ptr [ebp-4],ecx
; 备份一下我们的对象首地址
eax,dword ptr [ebp-4]
ecx,dword ptr [ebp+8]
dword ptr [eax],ecx
; 取出对象首地址
; 取出 nFirst 参数
; 给对象首地址指向的内容赋值为 nFirst 的内容
eax,dword ptr [ebp-4]
ecx,dword ptr [ebp+0Ch]; 取出 nSec 参数
dword ptr [eax+4],ecx
; 取出对象首地址
; 给对象首地址+4 指向的你内容赋值
return true;
0040115E mov
al,1
; 返回 1
}
34:
pop
00401160
pop
00401161
pop
00401162
00401163 mov
pop
00401165
00401166
ret
edi
esi
ebx
esp,ebp
ebp
8
我简要的注释下来一下上面的代码。通过分析上面的代码,我们可以得出这样的结论:
A、 函数通过 ecx 传递了我们对象的首地址。
B、函数通过 ecx 传递的对象首地址定位对象的每个成员变量。
这样,很明显,ECX 起到了传递参数的作用,这时 ecx 中的地址有个专业术语,叫做 this 指针。
OK,这就是一个新的知识点,我们成员函数的调用方式。
1、 成员函数的调用方式: __thiscall
记得在前面章节介绍函数时,讲过一些调用方式,但是没有提到过这种调用方式。下面我做一个简要的
总结:
A、 参数也通过栈传递。
B、它用一个寄存器来传递 this 指针。
C、本条特性摘自《加密与解密》(第三版)非原文:
a) 对于 VC++中传参规则:
i.
ii.
最左边两个不大于 4 字节的参数分别用 ECX 和 EDX 传参数.
对于浮点数、远指针、__int64 类型总是通过栈来传递的。
b) 对于 BC++|DELPHI 中的传递规则:
i.
最左边三个不大于 DWORD 的参数,依次使用 EAX,ECX,EDX 传递,其它多的参数依次通
过 PASCAL 方式传递。
这样,函数的地址还是在代码区域,对象的内存中只存放数据成员,当我们要调用成员函数时,就通过
一个寄存器将函数操作的对象的首地址(也就是 this 指针)传递过去就可以了,传递不同的对象指针,就操
作不同的数据。哈哈,太巧妙了。
printf("%d+%d = %d\r\n", obj_Exp.GetFirstNum(),
obj_Exp.GetSecNum(),
obj_Exp.GetSum());
; 调用 GetSum 函数
lea
call
push
lea
call
push
lea
call
push
push
call
add
ecx,[ebp-14h]
@ILT+30(CExample::GetSum) (00401023)
eax
ecx,[ebp-14h]
@ILT+5(CExample::GetSecNum) (0040100a)
eax
ecx,[ebp-14h]
@ILT+10(CExample::GetFirstNum) (0040100f)
eax
offset string "%d+%d = %d\r\n" (0042501c)
printf (00401290)
2、 浅谈构造与析构函数
OK,继续调试代码:
13:
14:
15:
00401208
0040120B
00401210
00401211
00401214
00401219
0040121A
0040121D
00401222
00401223
00401228
0040122D
16:
17:
00401230 mov
00401237 mov
0040123E
lea
00401241
call
00401246 mov
我们至始至终都没有调用过构造和析构函数。但是,通过这次调我们知道,在创建一个对象(变量)的
dword ptr [ebp-18h],0
dword ptr [ebp-4],0FFFFFFFFh
ecx,[ebp-14h]
@ILT+20(CExample::~CExample) (00401019)
eax,dword ptr [ebp-18h]
esp,10h
return 0;
; 调用析构函数
时候,我们的程序会自动的调用我们的构造函数,在要出对象作用域的时候,会自动的调用析构函数。
这样,我们很容易就能想象出,构造和析构的用途:构造就做初始化对象的各个成员,申请空间等初始
化工作。析构就做一些释放申请的空间啊之类的清理工作。
就这样,C++将数据跟函数封装到了一起,这样我们每个类产生的对象都是一个独立的个体,它有一个自己的
运作方式,几乎完全独立。在我们使用它的时候,根本不需要它是怎么实现了,只要知道怎么使用即可。
三、 浅谈类的静态成员
通过前面几节的学习,我们大概的能理解类的封装性及其运作过程,但是,如果我们继续深入的学习 C++,
我们很快就能发现一个问题:我们上面说的所有的成员都是属于对象的,也就是说,我们必须先通过类来定义一
个对象才可以操作。但是有的时候,我们需要一些属于类的成员,比如:人都有一个脑袋,这一个脑袋属于人类共
有的特性。不需要具体到哪一个人,我们都可以确定人只有一个脑袋。
放到类中也一样,比如我们需要知道当前这个类创建了几个对象的时候,我们不必在创建一个新的对象只需
要使用类的相关函数或者直接访问类的某些属性就可以了,而这些函数或者变量,它肯定不可能属于某个对象,
它应该属于这个类本身。
OK,下面就来体验一下静态带给我们的一些好处。同样,我们将前面的代码添加点儿东西(见 Exp02):
// .h 文件中
public:
static int m_nCount; // 统计产生对象的
static int print(const char *szFormat, ...);
// 让我们的类有自己的输出函数
// .cpp 文件中
int CExample::m_nCount = 0; // 初始化静态成员变量
CExample::CExample()
{
m_nCount++;
// 当创建一个对象的时候,这个变量加 1
}
CExample::~CExample()
{
if (m_nCount > 0)
{
m_nCount--;
// 当对象销毁时,这个变量减 1
}
}
/************************************************************************/
/*让我们的 CExample 可以打印自己的信息
/*支持多参,同 printf 用法相同
/************************************************************************/
int CExample::print(const char *szFormat, ...)
{
if (!szFormat)
{
return 0;
}
pArgs;
szBuffer[256 * 15] = {0};
va_list
char
va_start(pArgs, szFormat);
vsprintf(szBuffer, szFormat, pArgs);
va_end(pArgs);
printf(szBuffer);
return strlen(szFormat);
}
好,有了这些,我们可以编写如下的测试代码:
#include "stdafx.h"
#include
#include "Example.h"
int main(int argc, char* argv[])
{
CExample obj_Exp1;
CExample::print("当前对象的数量为:%d\r\n", CExample::m_nCount);
if (1)