Chapter 2 封装
2.1 封装的目的和意义
广义的说,封装是为了使这个世界以更加简单的方式呈现在我们的面前。买一台电冰箱,我不必要知道里面压缩机的运作过程,不必了解详细的制冷过程,我所要所的是,只是给它通电,通过调整一些旋钮来设置温度,剩下的事情,就不归我管了。
就面向对象程序设计来说,封装是为了隐藏实现的细节,使类的用户通过公开的接口来使用类的功能。在C++中,声明为public的成员函数,就是这样的接口,类的用户可以通过借口来安全的修改类的内部数据(设想,如果冰箱厂商让用户直接调节压缩机参数,那将是非常危险的)。对类内部方法的封装,可以增强代码的可复用性,用户是通过接口在访问类的功能的,类成员函数的实现细节发生变化,但只要结构不变,所有的使用类的客户代码都不需要修改。
很多人会认为C++中的class的封装效果,用C中的struct也可以实现。其实不然,看代码:
/////////////////////////////////////////////
//Source Code 2.1
#include<iostream.h>
struct s_Test
{
int (*p_add)(s_Test*);
int i;
};
int add(s_Test* p){return ++(p->i);}
class c_Test
{
public:
c_Test(){i=0;}
int add(){return ++i;}
void show(){cout<<i<<endl;}
private:
int i;
};
void main(void)
{
s_Test s;
s.i=0;
s.p_add=add;
s.p_add(&s);
cout<<s.i<<endl;
c_Test c;
c.add();
c.show();
}
//定义结构体 s_Test
//其中包含两个成员
//p_add为指向函数的指针
//i 为整形数据
//定义参数为指向结构体s_Test的指针的函数
//定义类c_Test
//构造函数对内部数据成员初始化
//成员函数add()完成对内部数据成员i的+1操作
//成员函数show()显示内部数据成员i的值
//内部数据成员
//创建结构体 s
//给s的数据成员i赋值
//初始化p_add指针,使其指向int add(s_Test* p)
//通过指向函数的指针来调用add(s_Test* p)
//输出i的值
//创建类c_Test的对象 c
//调用add()成员函数
//使用show()输出i的值
通过观察上述的代码,我们可以发现,struct可以将数据和操作“包装”在一起,但这种包装是松散和不安全的。首先,struct没有访问控制机制,任何代码都可以操作其内部数据i,其次,结构体内指针指向的函数与结构体本身没有必然的联系,任何代码都可以调用add(s_Test* p)函数。Struct只是一个框架,结构体中所有的成员对外来说都是可见和可访问的。再看Class,通过使用public和private关键字,类很好的对借口和内部数据进行的分离。对内部数据的访问,都要通过成员函数来实现,例如int i的初始化,改变i的值,先是输出等,都实现了封装。
我们可以得出这样的结论
a.封装的作用是提供接口
b.封装可以保护内部数据
c.封装可以代码的可复用性
2.2. 封装的实现机制
2.2.1 类成员函数的调用方法
在文章的第一部分中,我们探讨了C++ Class的内存格局,那么,类的封装和其内存格局之间,有着什么样的联系呢?成员函数对内部变量的访问,又是怎么实现的呢?
通过观察图1-2,我们知道,类的实例中只包括数据成员,类的成员函数是被排除在对象封装之外的。编译器通过“类名::函数名”的方式,区分每一个成员函数。这样的内存布局,使人认为class对成员函数的处理方法,跟上节程序中struct的函数指针像类似。我们先看看类对其成员函数的调用方法与struct中使用的函数指针在形式上的区别,看代码片断:
void main(void)
{
s_Test s;
s.i=0;
s.p_add=add;
s.p_add(&s);
cout<<s.i<<endl;
c_Test c;
c.add();
c.show();
}
//完整代码见上节Source Code 2.1
//通过函数指针调用add()时,我们把结构体指针传给了函数,//通过这个指针使add()函数访问并操作结构体中的数据
//调用类的成员函数,不需要传递指针
我们知道c_Test可以被创建很多份实例,但其成员函数在内存中只有一份,那么,这一份成员函数在没有参数传入的情况下,如何分辨这若干个c_Test的实例呢?调用类成员函数的时候,真的没有参数传入吗?让我们看看编译器针对c.add();语句生成的二进制代码,这样,任何隐藏的机制,都将一目了然。
Main函数中调用c.add()时,系统生成如下的代码,其中
00401050 lea ecx,[ebp-4]
00401053 call @ILT+5(c_Test::add) (0040100a)
call @ILT+5(c_Test::add) (0040100a) 将控制转向地址0040100a,这里是一条跳转指令
0040100A jmp c_Test::add (004010c0)
地址004010c0为函数c_Test::add的真实入口地址,我们再看一下004010c0处的代码
004010C0
004010C1
004010C3
004010C6
004010C7
004010C8
004010C9
004010CA
004010CD
004010D2
004010D7
004010D9
004010DA
004010DD
004010E0
004010E2
004010E5
004010E8
004010EA
004010ED
004010EF
004010F0
004010F1
004010F2
004010F4
004010F5
push
mov
sub
push
push
push
push
lea
mov
mov
rep stos
pop
mov
mov
mov
add
mov
mov
mov
mov
pop
pop
pop
mov
pop
ret
ebp
ebp,esp
esp,44h
ebx
esi
edi
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 [eax]
ecx,1
edx,dword ptr [ebp-4]
dword ptr [edx],ecx
eax,dword ptr [ebp-4]
eax,dword ptr [eax]
edi
esi
ebx
esp,ebp
ebp
上述代码为c_Test::adde的具体实现代码。
通过上述汇编代码的分析,我们可以发现add()函数有一个隐藏的参数,这是一个指向调用add()函数的那个类实例(即c这个对象的地址)的参数,因此,实际上编译器会将c.add();的调用转化为如下代码:
Test::add((Test*)&c);
int add(){return ++i;}函数实际上是
int add((Test*)&this)
{return ++((Test*)&this).i;}
这里,隐藏的参数(Test*)&this就是我们常说的this指针。我们会发现,这样的做法与使用结构体中指向函数的指针的方法相似(s.p_add(&s);),但C++中这一步骤是由编译器来实现的,通常用户不需要对this指针进行操作。这便是封装的好处,把复杂而且容易出错的部分交给编译器来实现。
2.2.2 封装的性能问题
封装就是将事物的内容和行为都隐藏在实现里,用户不需要知道其内部实现。但有得必有失,有时候我们无法保证封装的高效性。C++对封装的实现到底有没有性能上的损失呢?要是有的话,有多少呢?
请看下面的测试程序和相应的代码注释
//////////////////////////////////
//Source Code 2.2
//C++ class封装的性能
const double COUNT=1000000;
#include <iostream.h>
#include <time.h>
#include <sys/timeb.h>
void showtime()
class Test{
public:
Test(){i=0;}
int foo(){return 0;}
double i;
};
int foo(){return 0;}
double i=0;
void main(void){
double j=0;
Test t;
showtime();
for(j=0;j<COUNT;j++)i++;
showtime();
for(j=0;j<COUNT;j++)t.i++;
showtime();
for(j=0;j<COUNT;j++)foo();
showtime();
for(j=0;j<COUNT;j++)t.foo();
showtime();
i++;
t.i++;
foo();
t.foo();
}
//测试在英特尔赛扬800MHz CPU
//VC++ 6.0 ,Windows 2000环境下完成
//定义循环次数
//输出毫秒级精度的时间函数,实现代码略
//循环控制变量
//Test类的实例
//以下代码为性能测试,输出循环运行时间
//时间精确到毫秒级,i++和t.i++运行时间相同
//时间精确到毫秒级,foo()和t.foo()调用运行时间相同
//代码检验,输出对变量和函数访问的实际汇编代码
//0040128E fld qword ptr [i (0042f220)]
//00401294 fadd qword ptr [__real@8@3fff8000000000000000 (0042b050)]
//0040129A fstp qword ptr [i (0042f220)]
//004012A0 fld qword ptr [ebp-10h]
//004012A3 fadd qword ptr [__real@8@3fff8000000000000000 (0042b050)]
//004012A9 fstp qword ptr [ebp-10h]
//004012AC call @ILT+35(foo) (00401028)
//地址 00401028 处为一跳转指令,指向foo()函数的入口
//004012B1 lea ecx,[ebp-10h]
//004012B4 call @ILT+40(Test::foo) (0040102d)
//地址 0040102d 处为一跳转指令,指向Test::foo()函数的入口
从上面的结果我们可以知道,对类中封装了的数据和函数进行访问的时间与访问未封装的数据与函数相同。从上述反汇编出来的代码中我们可以发现,程序对封装数据和未封装数据的访问方式,在汇编代码的级别上是完全一样的。下面来看对函数的访问。
调用foo()生成的汇编代码为
004012AC call @ILT+35(foo) (00401028)
而调用类成员函数生成的代码为
004012B1 lea ecx,[ebp-10h]
004012B4 call @ILT+40(Test::foo) (0040102d)
这条指令引起了性能问题吗?前面进行的时间测试告诉我们这两个函数运行的时间是相同的,而且,针对一个函数调用过程,编译器会产生至少上百条汇编代码,因此,这里多出来的一条指令,在考虑性能问题的时候,完全可以忽略。那么,这条指令的作用是什么呢?在2.2.1 类成员函数的调用方法这一节中我分析了this指针的作用和实现,这条指令,便是起这个作用,具体的细节,请参考2.2.1节。
通过上面的分析,我们知道封装在普通成员变量和普通成员函数方面没有引起性能上的额外开销,参考C++ Class的内存格局一节,我们可以很容易的发现封装对静态成员也没有任何的性能开销,那么,虚函数呢?这的确是一个问题。
系统访问虚函数,实现要通过指针索引虚拟函数表,并且考虑到派生类对虚函数的改写,虚拟函数表实际上是在运行期才进行绑定的。关于虚函数的详细分析,将在多态一部分中展开。
根据权威测试,C++由封装而引起的性能损失相比同类的C语言,大概在5%以内。5%的性能损失,换来面向对象的编程,我想,任何程序员都是会做出明智的选择的。