语义的需要
见过不少拙劣的设计,不少程序员都只为最终功能的实现,完全不管语义的需要,编写出低劣的代码,几乎没有语义这个概念,而我认为这是一个程序员的修养问题,对于一个程序员其具有深远的意义。
语义就是语言的意义,在电脑编程方面来说就是对内存操作及机器指令执行的解释。它表现出程序编写人逻辑思维的清晰与否,不仅仅应用在电脑编程方面,还可往社会上推,也就是所谓的权责明确。就系统设计员的角度来说,语义表示各个接口的意义,它指导设计者如何设计接口函数的参数类型及接口函数的名字(即意义),就一个管理层人士其表示下面的各功能部门存在的必要性,各功能机构的职责及其业务流程如何制订。不过本文仅讨论语义在电脑编程方面中最底层的写代码这个环节中的意义,其是不容忽视的。具有良好语义的代码调试容易,稳定性高,可读性好。在此仅讨论C++语言所提供的语义及使用。
类型
C++提供了char、int、long等多种基础变量类型,这些类型就已经提供了语义——unsigned long表示这个变量里面放的数字被认为是一个而非是一个无符号长整型数字。“认为是”和“是”是有严重区别的。任何类型的变量都“是”数字,而变量的类型除了说明其所占内存空间的大小外,还被附带了语义,表示其应该被代码阅读者和代码编写人共同“认为是”某种意义,而非一个数字。这就正如我们会说一个字节数组是一个位图而不是一个数组。
类型的运用在各个库中均有大量运用。Win32 API中,HWND、HANDLE等句柄其实都只是一个void*;LPARAM、WPARAM等也都只是一个四字节数字而已。但它们都被赋予了重要的意义,HWND表示一个窗口句柄而不是一个指针,表示程序员不应该将其视为一个指针,而应该是一个cookie,一个序号,通过将其传递给Win32 API来操作对应的窗口(即使它其实就是个指针)。当然,Win32 API中也还是有很多仅仅为了方便而作的类型定义,如:UINT、USHORT等。
编写代码时,应该选取正确的变量类型。如进行一个for( unsigned long i = 0; i < count; ++i)循环枚举一个数组的各元素进行操作,i就应该使用unsigned的类型,而count也应该使用unsigned类型。因为是枚举一个数组,i代表数组下标,在C++中数组的下标是≥0的,因此应该使用unsigned,而count代表数组个数,个数逻辑上最小只能为零,故也应该使用unsigned。这个例子看起来有点极端,但它表现出程序员清晰的逻辑,如果count和i分别是由两个程序员定义的(这更极端了),那么那两个程序员如果都按照语义编写将会得到配合默契的代码(都使用unsigned,在for循环的比较中就不需要类型转换了),而这正是修饰成员函数的const的主要应用。
除了变量类型,变量名字也是语义的体现,为此而出现如匈牙利命名规则等多种变量命名规则。最好能养成自己的变量命名规则并严格按其命名变量,将会使代码的可读性上一个台阶。对于函数的命名也是同样的。
修饰变量的const
const关键字用于表示其所修饰的变量的值是不能修改的。应该注意,不是“不能修改”,而是“不是用来写入的”,而“不能修改”是其附加效果(或实现方式),结果const修饰的变量就被称为常数,这正是“不是用来写入的”的体现。C语言中使用宏来定义常数的坏处就是宏无法让编译器加入语义检查的行列,因为没有类型信息。使用const变量就将类型信息告诉了编译器,编译器可以进行少许的语义检查——类型匹配检查,因此定义常数时应一律使用const变量,而宏应该用于代码的配置(如定义代码版本,像Unicode版本)和代码编写的简化(视为inline函数)。
修饰成员函数的const
const修饰成员函数时,表示所修饰的成员函数不能修改其对应类的成员变量的值。同样,不是“不能修改”,而是“不会修改”,“不能修改”是其附加效果(或实现方式)而已。见过一些老师讲课及某些教材上将const成员函数说成是帮助程序员发现逻辑错误用的,即恐怕程序员自己在不能修改成员变量的函数中修改了成员变量而要编译器协助检查!这是真正的本末倒置!如果一个程序员在const成员函数中修改了成员变量,那只能说明那个程序员的逻辑混乱,自己都不清楚在编些什么,根本还不够程序员的资格。他们一般发现警告了,就将那个const去掉,更有甚者则是从不使用const这个关键字修饰成员函数。
const是在写代码前就已经可以确定的,在编写类的定义时就可以确定这个成员函数是否应该是const的(通过这个成员函数的语义来确定)。最常见的就是一对Get/Set成员函数用以操作某个成员变量,如:
inline ULONG CAbcList::GetCount() const
{
ASSERT_VALID( this );
return m_Count;
}
BOOL CAbcList::SetCount( ULONG count )
{
ASSERT_VALID( this );
// 更多的代码
m_Count = count;
return TRUE;
}
其中的Get成员函数在编写其定义前就可以确定其应该是const——取类实例(类的一个实例)的状态而已,逻辑上是不会影响类实例的状态的。但Set成员函数就一定不能是const的——设置类实例的状态,当然影响类实例的状态。再如在链表类中查找某个元素,逻辑上仍是与类实例无关,不会影响其状态,故仍旧应该是const的。确定const成员函数的工作严格来说应该是由设计者完成,与程序员无关(特指写代码的人),但一些简单的小类还是应该由程序员自己完成的,故仍应需要理解const的语义。
使用const成员函数,如前面在类型中所提到的。有一个部门类,其有多个CAbcList的成员变量以分别存储人事、财务等信息,然后其有人事查找的成员函数,很明显地应该为const成员函数,在其中需要调用到CAbcList::GetCount。这里配合得非常恰当--在const成员函数中调用const成员函数,没有任何警告。如果这两个类分别由两个人编写,定义类时并不互相知道对方的定义,而大家都按照语义编写,则最后的代码就非常恰当地配合起来。
但是仍可能出现在逻辑上应该是const的成员函数中需要修改成员变量的情况,对于此可参考下面的mutable中的例子。
类的public、protected、private
类的public、protected以及private可以说只是为了语义而存在,对最终代码没有任何影响,但是它是如此的重要,因此导致面向对象的编程思想大行其道。已经见过不少说法——为了具有面向对象的思想,成员变量一律定为private的,全部通过上面提到的Get/Set成员函数(或类似函数)进行操作。下面解释这三个关键字的语义,不过应注意到前面那个说法的不正确性——至少MFC中的CWnd::m_hWnd就是public的。
public 表示其所修饰的成员函数或成员变量可以被外界(类实例的操作者,即使用类实例的代码)看见。同样,不是“可以被看见”,而是“被用来看的”。比如开一个杂货店,门面本来就是拿来给行人看的,而不是门面可以被行人看见,这只是门面的附加效果(也可以说是实现方式),其目的是让行人看从而吸引行人进入杂货店买东西。
所以一个成员函数是public的,表示它是专门用来被外界调用以操作类实例的,通常对这类成员函数有个很贴切的叫法——操作。如前面提到过的Get/Set成员函数就应该是public的。一个成员变量是public的,和成员函数一样,表示它是专门被外界操作的。如MFC中的CWnd::m_hWnd,其之所以是public的就是因为CWnd是一个包装类,其包装的就是对窗口句柄m_hWnd的操作,也就是说外界知道CWnd一定有个窗口句柄的成员变量,也就是m_hWnd。窗口句柄在这和类CWnd一起组成了一个逻辑——窗口句柄的包装类,故CWnd::m_hWnd是public的。而前面举的例子CAbcList::m_Count又为什么要使用Get/Set函数对来操作而不使用public成员变量?因为CAbcList是一个链表类,链表本身是没有元素个数这个概念的,只是可以得到其元素个数,而CAbcList使用了一个成员变量m_Count来记录元素个数是为了加快对元素个数的获得,即m_Count具有一些逻辑规则(保证其数值为链表个数),由CAbcList维护。所以m_Count应该是protected的,而不是public。
总的来说,类所代表的意义所具有的属性就应该是public的,但如果属性是关联的,则应该使用成员函数将对属性的操作包装起来,以实现属性之间的联系。如:位图类的长宽,多边形立体模型的顶点数、多边形数就不应该是public的,因为它们都和各自的实现有关系——位图使用一个字节数组,多边形模型则使用顶点数组和多边形数组。改变长宽或顶点数就需要相应的修改字节数组或顶点数组。而智能指针由于是指针的包装,而且其包装的指针并没有和类的其他什么成员变量有联系,故其包装的指针成员变量应该为public。
protected 表示其所修饰的成员函数或成员变量不能被外界看见,但可以被其派生类看见。同样,不是“可以被看见”,而是“被用来看的”。还是开杂货店,店里的帐本就不是拿给行人看,其对行人来说是保密的。不过后来杂货店要改建成小超市,帐本还是要被小超市使用的。在这里,帐本就是protected的成员。
一个成员函数是protected的,表示它是专门用来被成员函数和派生类的成员函数调用,以帮助操作类实例的,通常这类成员函数就是就是辅助函数。比如链表类CAbcList为了能加快元素的查找,其有一个成员函数专门用于对元素进行排序,这个辅助函数被用于元素的排序插入操作和查找操作,并且其派生类也有可能调用它进行排序操作,故这个函数应该为protected的。
一个成员变量是protected的,派生类的成员函数也可操作它,专用于可能会随派生类的不同而有不同用法。如果某个成员变量可以具有不同的操作方式,如前面的杂货店的帐本,在小超市时其可能由于支持批发业务而导致需要不同的记帐形式,即需要记录批发的相关信息,而原来的杂货店是不能记录批发相关的信息的。因此帐本这个杂货店类的成员变量应该是protected的。
当不好确定成员变量是否将会具有不同的操作方式时,就将其定为protected。成员变量定为protected表示这个基类对其派生类提供了更多的灵活性,但也更加具有风险——派生类可能导致原来制定的逻辑不复存在。
private 表示其所修饰的成员函数或成员变量只能被其自身看见(即使是其派生类也不能看见)。同样,不是“可以被看见”,而是“被用来看的”。杂货店有个租赁合同,表示店面的租用手续。对其的使用,必须十分小心,不能随便使用,故店长决定自己将其收藏好,即使后来改做小超市,请了另一个来替他打理超市的业务,也仍然由自己保管合同,打理业务的人也只能通过店长办理需要使用到租赁合同的事宜。这个租赁合同就是private的(这个比喻并不很恰当)。
private表示只能被自己使用,private成员函数是保密的行为(如制药的秘方),private成员变量是保密的物品(如前面的租赁合同)。
一个位图类,其可以将数字水印加到其所表示的位图中去,则生成水印的成员函数是辅助函数,而且是不打算让子类能够参与以进行改进的(虽然这不是一个好的设计),则水印的生成和抽取都应该是private的。再如多边形立体模型,多边形数组中存放的都是顶点的序号,通过顶点数组获取具体的顶点位置。在此不打算也不认为子类有改变这种关系的必要性,因此顶点数组和多边形数组都应该是private的,不过这非常明显地严重限制了子类的能动性,因此需要提供一些protected成员函数帮助处理顶点数组和多边形数组。这看起来真的比较极端,其增加的复杂性不是一般的,且又降低了效率,为此最好是将顶点数组和多边形数组单独提出来形成一个结构(结构只代表一个内存布局,但类不一样,虽然它们其实没有区别,除了缺省的限定符,一个public,一个private),然后从这个结构派生。这相当于将那两个数组定义成public,但不同。原来的基类中和这两个数组相关的成员函数又将被另外提出构成一个模板类,和那个结构一同被多重继承,这是状态和功能的分离,在ATL中被大量运用。
看起来很荒谬,上面只不过为了一个“应该是private的”而弄得整个体系的复杂性增加,虽然代码可能并没有增多。对于这点,我只能说:最后编写代码的人是自己。
mutable
前面提到过,在逻辑上应该是const的成员函数却仍然需要修改成员变量。为此,C++提供了mutable这个语义味浓厚的关键字。mutable用于修饰一个成员变量,以表示这个成员变量即使在const成员函数中仍可以被修改,下面给出一个具体例子。
我曾经写过一个软件,其需要能处理几种资源(文本、声音、视频、多边形模型),我像VC6那样提供一个资源浏览栏,里面是个列表控件框,显示每个资源的图标和名字以供拖到视中进行操作。其中,对于多边形模型,图标就是那个模型的缩小,以方便对模型的选用。由于Icon的颜色和大小限制,决定选择Bitmap作为图标的表现形式。因此提供一个基类,代表资源,其具有一个如下形式:
class CResource : public CObject
{
…
protected:
mutable CBitmap m_BigIcon; // 存放实例的图标
mutable CBitmap m_SmallIcon;
public:
virtual HBITMAP GetIcon( BOOL bBig = TRUE ) const; // 获得特定实例的图标
…
};
因为获得资源实例的图标,没有改变资源实例状态的必要性,因此GetIcon是const,但是由于图标通过存放在每个实例的成员变量中以缓冲图标的使用(不用每次都新建一个位图且降低图标的管理费用——不用使用一个容器专门记录各个图标),所以将有可能修改m_BigIcon或m_SmallIcon,但它们对于资源这个概念来说根本是我私自定的,与资源无关,并不应该因为这个原因而将GetIcon的const去掉。在此,mutable的作用得到发挥,从而保证了语义。
全局、静态和成员变量
局部变量都是临时变量,相当于草稿纸。而非临时的变量就是全局、静态和成员变量,它们的区别主要就在于作用域的不同,而从语义来看就相当于所处的名字空间不同。名字空间是一个很不错的东西,其严格说明了其包含的函数变量的意义,还可以分层嵌套以表示出更复杂的语义。
全局变量就表示这个变量是被整个工程中的所有模块文件(即源文件,.c/.cpp)拿来用的,如MFC文档/视图结构程序中的全局应用程序对象,每个模块文件都可以使用它提取资源或执行其他操作。
静态全局变量则表示这个变量是其所在模块文件(其定义所在的模块文件)中定义的函数(包括成员函数)公用的,不是整个工程中的函数公用的,如编写COM客户端时,某个函数创建一个组件,那个组件的CLSID就应该是静态全局兼只读(const)的变量,其不具有让工程中的其他模块文件都看见的意义(而MFC应用的应用程序对象就具有整个工程可见的意义)。
静态成员变量则表示其是专属于其所在的类,对那个类有特殊意义,如某个MFC应用有一些配置信息,如远程服务器的名字(配置信息应该存放在注册表中,这里假设使用静态成员变量做缓冲),则对于MFC应用总有个应用程序对象,而那个远程服务器的名字应该作为这个MFC应用中的应用程序类的静态成员变量——即使多个应用的实例仍使用同一个服务器名字(实际中光是静态成员变量是达不到这个要求的)。从这个角度看,MFC应用中,所有的全局变量都应该作为应用程序类的成员变量。
断言
断言准确的说应该算是一门语言无关的技术,不过其在代码的编写中占有重要地位,不能不提。断言就是在代码的调试版时会由于一表达式的值而弹出警告对话框,但是在释放版时不造成任何影响。断言表示在其所在位置,对应表达式的值应该满足的条件,不是可以满足的条件。
断言的一般用途很多地方都说成是帮助调试。其实其用途和const变量及const成员函数一样,“帮助调试”只是其附加效果,它的真正目的是为了表现代码编写人的逻辑。就如前面const成员函数中所说的一样,如果一个程序员编写的代码断言失败,则表示他逻辑混乱,或者是对断言保护的代码(即断言后面的语句)进行了错误的使用,或者是断言的设置是不合逻辑的。
在每一个类的公共成员函数的最开头都应该断言一下this的有效性(前面的Get/Set函数中我就使用了MFC中的ASSERT_VALID断言宏,后面的断言都使用MFC中的断言宏)。因为类的公共成员函数是由外界调用的,外界可能如下书写:
CAbcList *pL = NULL;
pL->GetCount(); // GetCount里断言失败
这表示在每个公共成员函数执行以前都假定了自己的this是有效的指针,这是逻辑上的假定,因此需要用断言表示出来。但是保护及私有就不用这样保护了,因为它们一定是被自己或派生类调用,是辅助用的,而它们能够被调用,就表示调用者是有效的,逻辑上不需要前面的假定。
CAbcList有个保护成员函数DWORD SortElement( DWORD index ) const,给定的index表示第几个元素,而返回的DWORD表示按照内定的排序方法,这个元素的位置。在SortElement里,第一句话就应该是断言index的有效性:ASSERT( index < m_Count )。这里又有个隐晦的假设,就是index一定是小于元素个数的,因此应该用断言将其表示出来。同样的,在这个方法的客户端(某个成员函数中),在使用SortElement的返回值之前,应断言以确保其小于元素个数。
因此断言是非常重要的,其不是为了调试而写的,而是为了表现逻辑而写的,养成书写断言的习惯,将会使代码的可读性大有提高。
小结
C++提供的语义很正常地不止上面提到的那几个,还有许多都具有很重要的语义,如虚函数,重载函数、友员及explicit关键字等,上面的只是经常会被忽视而已。最后举一个关于语义的应用例子以说明其重要性和对代码编写的指导作用。
有个对话框的简单程序,其从COM口不断获得数据并将数据画成曲线实时显示出来。这是一个很普遍的模型(如将COM口换成编译器,就成为从编译器那里不断获得编译进度,然后再在主窗口的状态栏上实时显示出编译进度)。其有两个线程,一个工作线程负责不断获得数据并通知对话框显示数据,一个界面线程负责对话框的显示。
现在假设使用MFC编写,对话框上有个编辑控件,用于不断显示最新的数据的真实值(如水温)。则对话框派生类中就将一个float和那个编辑控件通过DDX绑定,并存有一个数组,记录历史数据以用来画曲线。这里就产生问题了。如下的线程函数:
DWORD WINAPI ThreadProc( void *pData ) // 线程函数,用于从COM口获取数据
{
// 数据获取循环
// 数据获得后放在变量i中
CAbcDialog *pDialog = reinterpret_cast< CAbcDialog* >( pData );
ASSERT( pDialog );
pDialog->m_Data = i;
pDialog->UpdateData( FALSE ); // UpdateData内部ASSERT_VALID( this )断言失败
}
注释中的调用语句断言失败,不过这并不是这个方法的失败之处,下面从语义上来分析其为什么失败,至于为什么断言失败,如果有兴趣,看参考我写的另一篇文章——《MFC界面包装类》。
一个线程代表一个具有能动性的物体,即可以自动完成一系列任务的物体,比如自动提款机,自动流水线等。而人具有主观能动性,因此也就具备能动性,因此可以被看作一个线程。在设计一个多线程的程序时,最好就是将每个线程看成是一个人,然后就能很自然的分配工作了。
前面的工作线程假设用A表示,界面线程用B表示。则A不断的询问COM口,是否有数据,有则将数据记录到一张卡片上,然后通过电话或按铃等手段通知B,接着继续询问COM口(这里假设没有使用COM口的中断功能)。B则在等待,一有电话或铃声就立刻看卡片(可能通过管道传到B手上,更可能的应该是使用类似电子邮件的业务),然后将数据画在看板上。
发现问题了吗?那个卡片就相当于上面的pDialog->m_Data(准确的说应该是相当于i,这里假设i的存在仅仅是为了缓冲,而不是传输),问题就在于m_Data是B的成员,而卡片应该是A和B共同使用的,而变成B的成员后就表示A每次都需要跑到B所在的地方,将卡片填好,再回去。或者卡片仍像刚才那样用管道传递,但问题的关键是这个卡片是由A负责的,不是B,因此如果B把它弄丢了或者损坏了将由A负责再找个新的(但是在代码上没有任何问题,因为程序员不用考虑内存坏掉的情况,这种情况属于异常,而且线程永远不会闹脾气),长久下去A和B都会觉得很不公平(A:既然我负责为什么他能用?B:为什么不是我负责?)。这是正宗的权责模糊。这看起来牛角尖钻得很严重,毕竟要实现上面的算法可以使用的方法有很多,都完成同样的功能,但哪个更好呢?如果你看重语义,那么这样分析一下是值得的。
因此上面的问题就是m_Data是CAbcDialog的公共成员,应该将其变为一静态全局变量(其只在CAbcDialog和ThreadProc之间使用。如果将ThreadProc换成CAbcDialog的静态成员函数,这将是一个大失误,这样逻辑和界面混在一起,属于不好的设计)或者将m_Data变为CAbcDialog的私有,ThreadProc只能通过一Set函数对其进行操作(削掉ThreadProc的读取权限),然后通过SendMessage将消息发个B,上面使用UpdateData来做这件事情,是不对的,表示看板也由A负责画了,则B实际什么都不干(顶多干些打杂的活,搬运看板等)。下面再来看使用语义的进一步优化。
考虑到将数据画到看板上很轻松,这样B将长时间不干事,而A则闲不下来(要监视COM口的动向),因此想到让A每次写完卡片后不用按铃或打电话(这节约了电铃和电话的成本),而是让B闲着的时候就去看看卡片的数据是否更新了,更新了就拿回来将数据画在看板上。但如果B不是很闲,即走的不是很频繁则可能丢失数据,因此决定使用3个卡片,表示B不可能忙得会错过3个时间段。
现在就变成m_Data[3]了,然后在B的CWinApp的派生类的OnIdle中调用pDialog->UpdateData来更新曲线。这节约了SendMessage的开销(发生线程切换,至少1000个CPU周期以上,A将挂起等待B把消息确认,而此时就将依靠COM口的缓冲来缓冲数据),将COM口的缓冲减小,因为A不会因为SendMessage的关系而错过数据(注意,不是中断模式)。如果界面的工作比较多(如界面上有个小动画,如Office的帮手),相当于看板很复杂,B并不是很闲,则错过的卡片数会增多,反而加大了代码的复杂程度,且不稳定。此时应该像原来那样使用SendMessage,不过为了消除SendMessage的开销,应该使用PostMessage来代替。
因此当在设计多线程程序时,将每个线程看成是一个人,而代码只是人所具备的技能,然后就是自己水平的表现了。
上面的例子只是为了说明语义在代码设计时的应用,语义准确说属于程序员的编程修养(就像编的时间多了就会使用一套自己的变量命名规则和代码书写风格),但是在代码中体现出语义不仅能使他人更易阅读代码,还能帮助自己理清思路,这在大型系统的编写中尤为重要。