当我们进一步研究类与对象的时候,难免的就要考虑到类本身的一些特点以及类与其它类之间的关系。在本专题开始之前,我们已经接触到像一个类对象作为另一个类成员的嵌套关系了。本专题,我们就专心的研究一下类与类之间的继承关系和其类本身的特点。
我们知道,类与对象的概念是来自于对现实事物的模拟,就像孩子用于其父母的一些特征,不论是木桌还是石桌都有桌子的特点。同样,类与类之间自然的也应该拥有这些特点的。而拥有这些特点就使得我们代码更加结构化,条理化,最大的好处则是:简化我们的代码,提高代码的重用性。
好,不多废话,先让我们看看,这个专题大概要讲些什么:
1、 体验类的静态多态性之重载
2、 构建类与类之间的父子关系及其访问限制
3、 体验类的动态多态性之虚函数
4、 浅析类的多继承
5、 学习小结
从这个目录可以看出这个专题内容非常的关键而且非常的庞杂。本来我是想将它们分成两个专题,分别讲述的。可是鉴于它们之间好多的知识点相互参杂,没有办法很好的分离,为了不给各位读者遗留困惑,我决定将他们合到一起,希望各位能慢慢体会其中的奥秘,从根本上掌握它们。
好废话不多说,我们进入正题。
重载,当时我理解了半天没弄明白是什么意思,现在才知道,就是用模样相同的东西实现不同的功能,下面我们分别看一下它们的用法。
1、 函数重载与缺省参数
A、函数重载的实现原理
假设,我们现在想要写一个函数(如Exp01),它即可以计算整型数据又可以计算浮点数,那样我们就得写两个求和函数,对于更复杂的情况,我们可能需要写更多的函数,但是这个函数名该怎么起呢?它们本身实现的功能都差不多,只是针对不同的参数:
int sum_int(int nNum1, int nNum2)
{
return nNum1 + nNum2;
}
double sum_float(float nNum1, float nNum2)
{
return nNum1 + nNum2;
}
C++中为了简化,就引入了函数重载的概念,大致要求如下:
1、 重载的函数必须有相同的函数名
2、 重载的函数不可以拥有相同的参数
这样,我们的函数就可以写成:
int sum (int nNum1, int nNum2)
{
return nNum1 + nNum2;
}
double sum (float nNum1, float nNum2)
{
return nNum1 + nNum2;
}
到现在,我们可以考虑一下,它们既然拥有相同的函数名,那他们怎么区分各个函数的呢?相信聪明的你一定根据上面的要求推测出来了,是的,名称粉碎。很简单,我们来验证一下我的说法,继续打开Exp01工程,点击菜单栏的”project”à”settings”:
勾选“Generate mapfile”选项,然后重新编译程序,到Debug目录下找到Exp01.map文件,用记事本打开它:
Address |
Publics by Value |
Rva+Base |
Lib:Obj |
|
0001:00000050 |
?sum_int@@YAHHH@Z |
00401050 |
f |
Exp01.obj |
0001:00000080 |
?sum_float@@YAMMM@Z |
00401080 |
f |
Exp01.obj |
0001:000000b0 |
?TestCFun@@YAXXZ |
004010b0 |
f |
Exp01.obj |
0001:00000130 |
?sum@@YAHHH@Z |
00401130 |
f |
Exp01.obj |
0001:00000160 |
?sum@@YAMMM@Z |
00401160 |
f |
Exp01.obj |
0001:00000190 |
?TestCplusFun@@YAXXZ |
00401190 |
f |
Exp01.obj |
0001:00000210 |
_main |
00401210 |
f |
Exp01.obj |
哈哈,将参数信息与函数名粉碎了并整合成了一个新的函数名,今后,我们在编写C++程序的时候,调试、排错都难免与这些粉碎后的函数名打交道,好多的朋友为了解决这个问题想出了各种方法,记得在看雪坛子上有一篇名叫《史上最牛资料助你解惑c++调试》的文章,大致上把粉碎后的函数名各部分的含义都解释出来了,其实没有这个必要的,我们用的VC开发环境中已经提供了一个工具(UNDNAME.EXE),它可以解析这些粉碎后的函数,当然如果我们逆向分析它,很容易就可以知道,其实就是调用一个API函数:
UnDecorateSymbolName(m_szFuncName, m_szResultInfo.GetBuffer(0), MAX_PATH,\
UNDNAME_32_BIT_DECODE|UNDNAME_NO_RETURN_UDT_MODEL|\
UNDNAME_NO_MEMBER_TYPE|UNDNAME_NO_THROW_SIGNATURES|\
UNDNAME_NO_THISTYPE|UNDNAME_NO_CV_THISTYPE);
OK,我们随便输入粉碎的一个函数名看看效果:
B、缺省参数
如果用Win32API写过程序的朋友一定知道,好多的函数存在许多参数,而且大部分都是NULL,倘若我们有个函数大部分的时候,某个参数都是固定值,仅有的时候需要改变一下,而我们每次调用它时都要很费劲的输入参数岂不是很痛苦?C++提供了一个给参数加默认参数的功能,例如:
double sum (float nNum1, float nNum2 = 10);
我们调用时,默认情况下,我们只需要给它第一个参数传递参数即可,但是使用这个功能时需要注意一些事项,以免出现莫名其妙的错误,下面我简单的列举一下大家了解就好。
A、 默认参数只要写在函数声明中即可。
B、 默认参数应尽量靠近函数参数列表的最右边,以防止二义性。比如
double sum (float nNum2 = 10,float nNum1);
这样的函数声明,我们调用时:sum(15);程序就有可能无法匹配正确的函数而出现编译错误。
2、 浅析运算符重载
运算符重载也是C++多态性的基本体现,在我们日常的编码过程中,我们经常进行+、—、*、/等操作。在C++中,要想让我们定义的类对象也支持这些操作,以简化我们的代码。这就用到了运算符重载。
比如,我们要让一个日期对象减去另一个日期对象以便得到他们之间的时间差。再如:我们要让一个字符串通过“+”来连接另一个字符串……
要想实现运算符重载,我们一般用到operator关键字,具体用法如下:
返回值 operator 运算符(参数列表)
{
// code
}
例如:
CMyString Operator +(CMyString & csStr)
{
int nTmpLen = strlen(msString.GetData());
if (m_nSpace <= m_nLen+nTmpLen)
{
char *tmpp = new char[m_nLen+nTmpLen+sizeof(char)*2];
strcpy(tmpp, m_szBuffer);
strcat(tmpp, msString.GetData());
delete[] m_szBuffer;
m_szBuffer = tmpp;
}
}
当然,运算符重载也存在一些限制,在我们编码的过程中需要注意一下:
A、 不能使用不存在的运算符(如:@、**等等)
B、 “::、. 、.*”运算符不可以被重载。
C、 不能改变运算符原有的优先级和结核性。
D、不能改变操作数的数量。
E、 只能针对自定义的类型做重载。
F、 保留运算符原本的含义
1、 继承代码的定义方法及其内存布局
看代码:
// 基类
class BaseCls
{
private:
int m_na;
int m_nb;
public:
int GetAValue() const
{
return m_na;
}
int GetBValue() const
{
return m_nb;
}
void SetAValue(int nA);
void SetBValue(int nB);
BaseCls();
~BaseCls();
};
相信大家看明白上面的代码应该是非常容易的。OK,我们继续:
class SubCls : public BaseCls // 以共有的方式继承BaseCls
{
private:
int m_nc;
public:
int GetCValue() const
{
return m_nc;
}
void SetCValue(int nC);
SubCls();
~SubCls();
};
OK,倘若我们要给SubCls增加一个成员函数,来计算m_nA+m_nB+m_nC的和:
int SubCls::GetSum()
{
//return m_na+m_nb+m_nc; // 没有权限访问m_na、m_nb
return GetAValue()+ GetBValue() + m_nc;
}
现在我们来看下它的内存结构:
由此可见,对于简单的继承关系,其子类内存布局,是先有基类数据成员,然后再是子类的数据成员,当然后面讲的复杂情况,本规律不一定成立。
2、 关于继承权限的说明及其改变
什么时候SubCls类才能直接使用父类中的变量呢,前人已经为我们总结了一个张表:
基类访问属性 继承权限 |
public |
protected |
private |
public |
public |
protected |
不可访问 |
protected |
protected |
protected |
不可访问 |
private |
private |
private |
不可访问 |
对于基类的私有成员,它的子类都无法直接访问,只有通过相应的Get/Set方法来操作它们(get/set方法必须是public/protected权限)。换句话说:基类的private成员虽然不能直接被访问,但是他们在子类对象的内存中仍然存在,可以通过指针来读取它们。
当一个类中的成员变量为publi权限,但它子类继承它时采用了priva方式继承,这样它就变成了private权限,我们要让它继续变为public权限,可以对它重新调整。比如:
class BaseCls
{
public:
int m_nPub;
};
class SubCls : private BaseCls
{
public:
using BaseCls::b3; // 调整变量权限为publi
}
注意,调整成员访问权限的前提是:基类成员在子类中是可见的,没有被隔离。
之前我们讲述的多态性,像函数重载,运算符重载,拷贝构造等都是在编译器完成的多态性,我们称之为静态多太性,现在我们讨论下运行期间才体现出来的多态性——动态多态性。我将分成几个不同的小结,逐步深入的描述我对虚函数的理解。
1、 什么是虚函数,用在什么场合
之所以称为虚函数,是因为此类函数在被调用之前谁都不确定它会被谁调用。换句话说就是:调用虚函数的方式不同于以往的立即数直接寻址,而是采用了寄存器间接寻址的方式,因此它调用的地址并不固定。所以,虚函数可以通过相同的函数实现不同的功能。这便是虚函数的特点。
我可以举个例子来说明虚函数的用途:
假如,我们有一个家具类,他有一个成员函数来获取家具的价格,如果家具类派生出了桌子、椅子、床、沙发……各种对象不计其数。这时,如果我们要实现一个函数来输出用户指定“家具”的价格,我想最常规的做法应该是用一个很深的if……else if ……else结构,当然,如果你看过我写的switch的学习笔记的话,你或许会用一个很大的switch结构来判断用户选择了那个家具,然后创建相应的对象,调用其获取价格的方法,打印输出……
当然,如果有虚函数,我们就不需要这样费事的写程序了,我们大可以创建一个家具类的对象指针,然后让它直接指向用户选择的家具对象,当用户选择“家具”时,我们不需要确定用户选择的是哪个“家具”,只需要简单的调用基类的虚函数即可。程序在运行时,会自动的根据用户的不同选择调用不同子类的虚函数……
2、 怎样使用虚函数
说了一堆的废话,或许你真的知道虚函数是怎么回事了,或许你一定很好奇虚函数是怎么实现的,也或许,你可能更加疑惑:到底什么是虚函数了。
都没关系,我们先带着这些问题,一步步的来,先看看如何定义和使用一个虚函数以简化我们的程序。
要使用虚函数,异常的简单,你只要在要制定为虚函数的声明前加上Virtual关键字,当然,在使用的过程中需要遵循如下规则:
a) 虚函数必须在继承的情况下使用。
b) 若基类中有一个虚函数,那它所派生的所有子类中所有函数名、参数、返回值都相同的成员方法都是虚函数,不论它们的声明前是否有Virtual关键字。
c) 只有类的成员方法才能声明为虚函数,但是:静态、内联、构造除外。
d) 析构函数建议声明为虚函数。
e) 调用虚函数时,必须要通过基类对象的指针或者引用来完成调用,否则无法发挥虚函数的特性。
如:下来程序(见Exp03的代码)
class CBaseCls
{
public:
int m_nBaseData;
virtual void fun(int x)
{
printf("%d in BaseCls...\r\n", x);
}
};
class CSubCls : public CBaseCls
{
public:
int m_nSubData;
// 由于它的参数返回值还有函数名等都与父类的虚函数相同,所以它也是虚函数。
void fun(int x)
{
printf("%d in subclass...\r\n", x);
}
};
相信上面的代码,你应该能看明白吧,我们按照上面列出的规范,使用一下这两个类:
int main(int argc, char* argv[])
{
//CSubCls *pSub = (CSubCls*)new CBaseCls;
//pSub->fun(5);
CBaseCls objBase;
CSubCls objSub;
CBaseCls *pBase = new CSubCls;
pBase->fun(5);
return 0;
}
运行结果:
3、 虚函数的运行机制
OK,我们调试一下这段代码,看看虚函数的这钟特性到底是怎么实现的:
32: CBaseCls objBase;
0040EDDD lea ecx,[ebp-14h] ; CBaseCls的this指针
0040EDE0 call @ILT+15(CBaseCls::CBaseCls) (00401014)
33: CSubCls objSub;
0040EDE5 lea ecx,[ebp-20h]
0040EDE8 call @ILT+0(CSubCls::CSubCls) (00401005)
34:
35: CBaseCls *pBase = new CSubCls;
0040EDED push 0Ch
0040EDEF call operator new (00401350) ; new一个空间
0040EDF4 add esp,4
0040EDF7 mov dword ptr [ebp-2Ch],eax
0040EDFA mov dword ptr [ebp-4],0
0040EE01 cmp dword ptr [ebp-2Ch],0
0040EE05 je main+64h (0040ee14)
0040EE07 mov ecx,dword ptr [ebp-2Ch] ; 子类的this
0040EE0A call @ILT+0(CSubCls::CSubCls) (00401005) ; 调用子类构造创建子类的临时对象
0040EE0F mov dword ptr [ebp-70h],eax ; 保存子类的this指针
0040EE12 jmp main+6Bh (0040ee1b)
0040EE14 mov dword ptr [ebp-70h],0
0040EE1B mov eax,dword ptr [ebp-70h] ; 子类的this
0040EE1E mov dword ptr [ebp-28h],eax ; 子类的this
0040EE21 mov dword ptr [ebp-4],0FFFFFFFFh
0040EE28 mov ecx,dword ptr [ebp-28h] ; 子类的this
0040EE2B mov dword ptr [ebp-24h],ecx ; 可见,[ebp-24h]中是子类的this
36: pBase->fun(5);
0040EE2E mov esi,esp
0040EE30 push 5
0040EE32 mov eax,dword ptr [ebp-24h]
0040EE35 mov edx,dword ptr [eax] ; this指针去内容(也就是虚函数指针表简称虚表)
0040EE37 mov ecx,dword ptr [ebp-24h] ; 传递this指针
0040EE3A call dword ptr [edx] ; 调用虚表的第0项函数
0040EE3C cmp esi,esp
这时,我们应该发现,我们对象的内存结构应该是这样的(比以前的内存结构多了个虚函数表的指针):
由此可知,this指针指向一个函数表的首地址,这个表的每一项都是一个函数地址(函数指针),换句话说,虚函数的指针都被存放在this指向的这个虚函数表中。
现在,我们再回头看上述的程序,猜测一下他的编译过程。
A、 编译父类时,先编译代码,最后把虚函数的首地址加入到虚函数表中。并将虚表的首地址作为本类的第一个数据成员
B、 编译子类时,先编译子类的代码,再把父类的虚函数表拷贝过来,检查子类中重新实现了那些虚函数,一次在子类的虚表中将重新实现的虚函数表项覆盖掉并增加子类中心实现的虚函数地址。
那它的执行过程已经是可以明白的说明了:
A、 我们先分析下上述代码的执行:
CBaseCls *pBase = new CSubCls; // 让一个父类指针指向子类的实例
pBase->fun(5); //调用虚函数时,传递的是子类的this指针,也就是子类的虚表
再根据我们上面的分析,很自然的,代码将调用子类的函数,输出的结果也自然的是子类虚函数的结果。
B、 倘若,我们将调用的代码换一下:用子类的指针指向父类的实例。
CSubCls *pSub = new CBaseCls; // 编译出错,需要强转
pSub ->fun(5); //输出的是父类的虚函数的结果
编译器不支持父类对象向子类类型的转换,我们想一下它们的内存结构就可以知道,一般子类的数据成员比父类的多,父类想子类转换以后,其指针取成员会存在安全隐患。
C、 由此,我们可以得出结论:
a) 调用虚函数时,传递不同类实例的this指针,就调用传递this对象的虚函数。
b) 虚函数的调用方式:
用基类的指针或引用指向子类的实例,通过基类的指针调用子类的虚函数。
说明:一定要用指针或引用去调用虚函数,否则可能会失去虚函数的特性。
4、 模拟实现虚函数机制
到这里,我想你一定对虚函数有一定的了解了,为了加深印象,我们不妨手工用C语言类模拟一个虚函数出来(代码见Exp04):
#include
// 定义函数指针
class CPerson;
typedef void (*PFUN_TYPE)();
typedef void (CPerson::*PBASEFUN_TYPE)();
// 基类的成员。
class CPerson
{
public:
PBASEFUN_TYPE *m_pFunPoint;//定义函数指针
CPerson()
{
m_pFunPoint = (PBASEFUN_TYPE*)new PFUN_TYPE[2]; // 保存虚函数指针表
m_pFunPoint[0] = (PBASEFUN_TYPE)vsayHello; // 填充虚表项
m_pFunPoint[1] = (PBASEFUN_TYPE)vsayGoodbye;
}
~CPerson()
{
// 释放资源,防止内存泄露
delete [] m_pFunPoint;
}
void sayHello()
{
printf("person::Hello\r\n");
}
void sayGoodbye()
{
printf("person::Goodbye\r\n");
}
void vsayHello()
{
sayHello();
}
void vsayGoodbye()
{
sayGoodbye();
}
};
class CStudent:public CPerson
{
public:
CStudent()
{
// 填充虚表项,覆盖父类的成员地址
m_pFunPoint[0] = (PBASEFUN_TYPE)vsayHello;
m_pFunPoint[1] = (PBASEFUN_TYPE)vsayGoodbye;
}
void sayHello()
{
printf("CStudent::Hello\r\n");
}
void sayGoodbye()
{
printf("CStudent::Goodbye\r\n");
}
void vsayHello()
{
sayHello();
}
void vsayGoodbye()
{
sayGoodbye();
}
};
int main()
{
CStudent objStu;
CPerson objPer;
CPerson *pobjPer = &objStu; // 用基类指针指向子类对象
objPer.vsayHello(); // 用基类对象直接调用
objPer.vsayGoodbye();
(pobjPer->*pobjPer->m_pFunPoint[0])(); // 用基类指针调用
(pobjPer->*pobjPer->m_pFunPoint[1])();
return 0;
}
运行结果:
一个类可以从多个基类中派生,也就是说:一个类可以同时拥有多个类的特性,是的,他有多个基类。这样的继承结构叫作“多继承”,最典型的例子就是沙发-床了:
1、 基本概念
相信上图描述的结构大家应该都可以看明白的,SleepSofa类继承自Bed和Sofa两个类,因此,SleepSofa类拥有这两个类的特性,但在实际编码中会存在如下几个问题。
a) SleepSofa类该如何定义?
Class SleepSofa : public Bed, public Sofa
{
….
}
构造顺序为:Bed à sofa à sleepsofa (也就是书写的顺序)
b) Bed和Sofa类中都有Weight属性页都有GetWeight和SetWeight方法,在SleepSofa类中使用这些属性和方法时,如何确定调用的是哪个类的成员?
可以使用完全限定名的方式,比如:
Sleepsofa objsofa;
Objsofa.Bed::SetWeight(); // 给方法加上一个作用域,问题就解决了。
2、 虚继承
上节对多继承作了大概的描述,相信大家对SleepSofa类有了大概的认识,我们回头仔细看下Furniture类:
倘若,我们定义一个SleepSofa对象,让我们分析一下它的构造过程:它会构造Bed类和Sofa类,但Bed类和Sofa类都有一个父类,因此Furniture类被构造了两次,这是不合理的,因此,我们引入了虚继承的概念。
class Furniture{……};
class Bed : virtual public Furniture{……}; // 这里我们使用虚继承
class Sofa : virtual public Furniture{……};// 这里我们使用虚继承
class sleepSofa : public Bed, public Sofa {……};
这样,Furniture类就之构造一次了……
3、 总结下继承情况中子类对象的内存结构
A. 单继承情况下子类实例的内存结构
// 描述单继承情况下子类实例的内存结构
#include "stdafx.h"
class A
{
public:
A(){m_A = 0;}
virtual fun1(){};
int m_A;
};
class B:public A
{
public:
B(){m_B = 1;}
virtual fun1(){};
virtual fun2(){};
int m_B;
};
int main(int argc, char* argv[])
{
B* pB = new B;
return 0;
}
B. 多继承情况下子类实例的内存结构
// 描述多继承情况下子类实例的内存结构
#include "stdafx.h"
#include <stdio.h>
class A
{
public:
A(){m_A = 1;};
~A(){};
virtual int funA(){printf("in funA\r\n"); return 0;};
int m_A;
};
class B
{
public:
B(){m_B = 2;};
~B(){};
virtual int funB(){printf("in funB\r\n"); return 0;};
int m_B;
};
class C
{
public:
C(){m_C = 3;};
~C(){};
virtual int funC(){printf("in funC\r\n"); return 0;};
int m_C;
};
class D:public A,public B,public C
{
public:
D(){m_D = 4;};
~D(){};
virtual int funD(){printf("in funD\r\n"); return 0;};
int m_D;
};
C. 部分虚继承的情况下子类实例的内存结构
// 描述部分虚继承的情况下,子类实例的内存结构
#include "stdafx.h"
class A
{
public:
A(){m_A = 0;};
virtual funA(){};
int m_A;
};
class B
{
public:
B(){m_B = 1;};
virtual funB(){};
int m_B;
};
class C
{
public:
C(){m_C = 2;};
virtual funC(){};
int m_C;
};
class D:virtual public A,public B,public C
{
public:
D(){m_D = 3;};
virtual funD(){};
int m_D;
};
int main(int argc, char* argv[])
{
D* pD = new D;
return 0;
}
D. 全部虚继承的情况下,子类实例的内存结构
// 描述全部虚继承的情况下,子类实例的内存结构
#include "stdafx.h"
class A
{
public:
A(){m_A = 0;}
virtual funA(){};
int m_A;
};
class B
{
public:
B(){m_B = 1;}
virtual funB(){};
int m_B;
};
class C:virtual public A,virtual public B
{
public:
C(){m_C = 2;}
virtual funC(){};
int m_C;
};
int main(int argc, char* argv[])
{
C* pC = new C;
return 0;
}
E. 菱形结构继承关系下子类实例的内存结构
// 描述菱形结构继承关系下子类实例的内存结构
#include "stdafx.h"
class A
{
public:
A(){m_A = 0;}
virtual funA(){};
int m_A;
};
class B :virtual public A
{
public:
B(){m_B = 1;}
virtual funB(){};
int m_B;
};
class C :virtual public A
{
public:
C(){m_C = 2;}
virtual funC(){};
int m_C;
};
class D: public B, public C
{
public:
D(){m_D = 3;}
virtual funD(){};
int m_D;
};
int main(int argc, char* argv[])
{
D* pD = new D;
return 0;
}
上图中,多出两个未知的地址,它们并不是类成员的地址,如果我们跟踪它,得到的结果如下图:
如果留意观察这两个地址,就会发现,它们都紧跟着虚继承的两个子类:
A、00425024 à 是B类的虚表指针,B类虚继承与A类
B、00425020 à 是C类的虚表指针,C类虚继承与A类
知道了这两点,我们先跟踪0x00425030这个地址,我们得到一个-4和0x0C两个数,这两个数字很难不让我们想到这个是偏移。
0x00425030 所在的位置减去4,就是C类的虚表指针。
0x00425030 所在的位置加上C,就是A类的虚表指针。
同理,我们在看3C这个位置:
0x0042503C 所在的位置减去4,就是B类的虚表指针。
0x0042503C 所在的位置加上0x18,就是A类的虚表指针
由此,我们可以大胆的猜测,这个偏移表是用来关联虚继承的基类和子类的,比如:
0x00425030这个偏移表,将A类和C类的虚继承关系联系到了一起,0x0042503C这个偏移表则是把A类和B类联系到了一起。
4、 总结下多继承情况下对象的构造顺序
A. 虚继承的构造函数按照被继承的顺序先构造。
B. 非虚继承的构造函数按照被继承的顺序再构造。
C. 成员对象的构造函数按照声明顺序构造。
D. 类自己的构造函数最后构造。
本专题本来是想分成两个专题分别讲述类的继承和多态性的。可是由于这两个特性联系的实在是太紧密,这样穿插在一起,我着实没想到更好的分类方法。索性将这两个特性放在一个专题中一并讲述。
但是我的言语表达能力实在是有限,总是不能把文章中的知识点讲述到我想象中的那样简单、清晰,这个专题,不求能教给大家点什么,只希望在大家的学习过程中能起到垫脚石的作用。我就心满意足了。
本专题的内容可以说是C++语言的精华所在,讲述的知识虽然重要,但也仅限于我现在这个知识层面上的理解,或许过些日子又会发现更多更重要的知识我没有讲到或者讲的不对……
如果你在阅读本文章个过程中,如果发现我哪里讲的不对,麻烦通知我,以便我及时改正,以免误人子弟……
—— besterChen
2010年5月20日星期四