这篇文章题目叫“浅谈VC++工程的文件组织”,其实内容不光是文件组织了,内容还还很广,我很早就想写这么篇文章,一方面是总结这几年下来的经验,另一方面就是能和别人交流交流,为了不让读者在阅读中丧失兴趣,我将在文章中加入大量生动的例子,所以这篇文章内容很散,但知识本身就是一种离散的积累之后才形成关系的连贯,难道不是吗?此文的观点并不“权威”,只是我个人的观点,欢迎来信和留言,图共同进步。
1、全局变量的声明与定义
一般来说,.h文件是存放定义(Definition)的地方,而.cpp文件这是存放实现的地方(Implementation)。
简单说是这样的,不过问题来了,如果你需要一个全局变量:
HWND g_hwndMain;
那么应该放在.h文件中,还是在.cpp文件中?对单个变量来说,这既是声明也是定义也是实现。按照我做法,把它放到.cpp文件中,比如放到main.cpp中去。至于.h里,根本不需要提及它。那如果需要在别的文件中使用到这个全局变量,那应该怎么办?那就声明一下吧:
extern HWND g_hwndMain;
加上了extern前缀,就变成了声明,而不是定义。如果工程中有声明g_hwndMain,但不存在“HWND g_hwndMain”这个定义的话,在link的时候就会出错。
这里顺便提一下面试时候经常问到的一个问题,在全局变量前面加上关键字static,起到什么作用?这个问题不懂就是不懂,想不出来的。其实很简单,就是让这个全局变量只能在这个模块(文件)中使用。为什么?因为static,extern两者不同同时修饰一个变量,尝试“extern static HWND g_hwndMain”,这样会导致编译错误。
“extern HWND g_hwndMain”作为一个声明,是不是就一定把它放在.h文件中,不是,应该在哪里需要使用到g_hwndMain,就在哪里声明。
大家想到了,其实使用全局变量会降低程序可读性,但开发中全局变量确实又能提供很多便利,因此用不着盲目排斥它。
2、包含关系
习惯上来说把一个类写在一个.h和一个.cpp文件中,这样维护起来比较简单,比如我要写一个类叫CRS232Comm,一个串口通信类,那么我就创建两个文件RS232Comm.h和RS232Comm.cpp,一个是class CRS232Comm这个类的定义,一个实现。RS232Comm.cpp中有“#include "RS232Comm.h"”。
一般来说,总是.cpp包含.h,那有没有包含.cpp的时候呢?你见过吗?我不知道你见没见过,但我确实见过J。那就是在使用IDL(接口描述语言,写过COM都应该知道)的时候,MIDL(IDL的编译器)会把IDL变成四个文件,其中有一个为“xxxx_i.c”,对就是它,你得include它,于是就出现了比较尴尬的“#include "xxxx_i.c"”。为什么生成的是“xxxx_i.c”而不是“xxxx_i.h”呢?我也不知道,如果你知道,你可以告诉我。
但除了IDL这种情况,我们还是没有什么理由需要包含.c或者.cpp。
那除了.h和上面这个特殊的情况,我们还能包含些什么?也许你马上想到了,比如:
#include <iostream>
哈,这个文件既不是.c也不是.h,那我们能不能不用它,改用iostream.h?一般来说只是为了使用cout这种对象是可以的。但意义上有差别,iostream和iostream.h是不一样的,一个是模板库,一个是C++库头文件。这里顺便提起一下以前的一件事,我刚进入上上家公司的时候,头要我们写个类模板,于是我就跟写一般的类一样把它分为两个文件,一个.h,一个.cpp,一个放定义,一个放实现。你们认为我的做法有问题么?写过模板的人都应该知道,这样是不行的,因为C++中的实现是针对类的实现,而不是针对模板的实现,分开写会导致连接时候找不到实现的。因此必须将模板的“定义”与“实现”写在同一个文件中,那这个文件究竟叫“.h”好呢还是“.cpp”好呢?都不好,它既不是类的定义也不是类的实现,它是模板,模板就是模板,那干脆就不用文件扩展名,STL就怎么干的,That’s OK!
另外,个人认为,我们似乎没有什么理由创建自己的模板库了,模板的90%的用途在于“容器”,而这一切STL都做好了,自己写个模板出来容易令人费解,况且真的有必要吗?我确实没想出太多的理由。最近我甚至对STL有些排斥态度,我在一个类中用到了STL的vector,程序在_DBCS选项下编译运行没有任何问题,但如果用_UNICODE选项编译,在程序关闭时候就会出现运行时错误,这个错误发生在“}”之后,无影无踪,令我不知所措,把vector拿掉之后就没看到这个错误,我没看出我的代码有什么问题,而只要不用_UNICODE,就是正常的,实在是难题。所以有时我觉得STL还不如MFC的集合类来得好用,可惜我并不怎么喜欢用MFC。
3、进一步讨论包含关系,兼提及_UNICODE和UNICODE
先提起一下一个最最最常见的头文件,是什么?stdio.h?不是不是,现在用windows.h也不用它,那是不是windows.h?也不是,写MFC程序的时候根本就不需要windows.h。猜对了!就是stdafx.h。这个文件曾经让我如此憎恨,我不明白为什么每个.cpp都需要把它作为first include,要是不include它,就会出现惊人的错误,但现在想想这个文件有它可爱之处,由于每个cpp都需要包含,并且第一包含它,(如果你没改变默认编译选项的话)那它就有它的作用了。
大家都知道,编译选项中可以指定_UNICODE和UNICODE,来编译Unicode程序。有了_UNICODE选项,_TEXT("my string")宏就变成了L"my string";有了UNICODE选项,著名的函数CreateWindow就变成了CreateWindowW。其实查看一下那些VC++的头文件就不难发现,通常_UNICODE和UNICODE都是同时需要的,那如果在编译选项里同时指定_UNICODE和UNICODE又有些别扭,那怎么办?这个时候stdafx.h就可以好好利用起来了,在stdafx.h里(在包含其它文件前)写道:
#ifdef _UNICODE
#ifndef UNICODE
#define UNICODE
#endif
#endif
#ifdef UNICODE
#ifndef _UNICODE
#define _UNICODE
#endif
#endif
这样只需要在编译选项里指定_UNICODE或者UNICODE就可以了。另外可以把一些到处都有可能用到的头文件在这里include掉,但一般来说没必要这样,在哪里需要用,在哪里include就好了。
这里提一下反重复包含的问题,如何反重复包含?当然是用宏了。这是用VC++向导添加的文件的宏:
#if !defined(AFX_STDAFX_H__2E6B6299_78FC_4514_953C_D3F5DA24A99E__INCLUDED_)
#define AFX_STDAFX_H__2E6B6299_78FC_4514_953C_D3F5DA24A99E__INCLUDED_
//……
#endif
而自己创建的文件(不是用向导添加的)没有用GUID,一般可以这样写,假如文件名叫IOCPSocket.h,那么就这样写:
#ifndef __IOCPSOCKET_H__
#define __IOCPSOCKET_H__
//……
#endif
这是我长期养成的习惯,不强求怎样,只是一种风格,如文件名的命名,我是用大小写区分,这也是windows的风格,而linux的风格这是全小写,下划线区分。顺便提提风格而已。
OK,重点了,前段时间碰到的一个问题,我要改写一个类库,其中一个任务就是把原来全部合并一起的文件拆分开来,让一个类使用一个.h文件和一个.cpp文件,但这样就有问题了,情况是这样:
基类为CBase,子类有CDerivedA,CDerivedB,这倒没什么,但CBase中竟然有这种函数:
class CBase
{
//……
virtual CDerivedA* GetA();
virtual CDerivedB* GetB();
}
DerivedA.h和DerivedB.h中需要include Base.h,而CBase竟然也用到了它的子类……那根据哪里用到就哪里包含的法则,Base.h是不是也要include DerivedA.h和DerivedB.h?这岂不是形成了“互相包含”?是的,如果出现了这种互相包含,VC++就会给出编译警告。当然如果你做了“反重复包含” 的工作,编译警告就不会出现,也不会出现“重复定义”,而取而代之的是“未定义”,程序还是通不过的。比如下面这个简单的例子:
基类
//Base.h
#ifndef __BASE_H__
#define __BASE_H__
#include "DerivedA.h"
class CBase
{
public:
CBase();
~CBase();
virtual CDerivedA* GetA(){return NULL;};
};
#endif
子类
//DerivedA.h
#ifndef __DERIVED_A_H__
#define __DERIVED_A_H__
#include "Base.h"
class CDerivedA:public CBase
{
public:
CDerivedA();
~CDerivedA();
virtual CDerivedA* GetA(){return this;};
};
#endif
编译出现的错误大致如下,但并非一定,甚至每次都有可能不同,这和编译的顺序有关系:
error C2143: syntax error : missing ';' before '*'
error C2433: 'CDerivedA' : 'virtual' not permitted on data declarations
error C2501: 'CDerivedA' : missing storage-class or type specifiers
error C2501: 'GetA' : missing storage-class or type specifiers
总之出现了这种基类“需要”子类的情况的话,就不能这样include了。取而代之的是使用一个类的声明:在Base.h中把“#include "DerivedA.h"”去掉,用“class CDerivedA;”取代它。这样编译就没有问题了。
OK OK,可能你又有问题了,如果基类中的函数不是“virtual CDerivedA* GetA()”,而是“virtual CDerivedA GetA()”,那怎么又通不过了?哇哈哈……老兄,你别扯了,我保证你找遍全世界的高手,也没有人能解决这个问题的,因为它逻辑上已经错误了,父在诞生的时候需要子,而父没诞生,哪来的子?又一个典型的鸡生蛋,蛋生鸡的问题。至于指针为什么就可以,因为指针在Win32中归根到底只是一个long型,它并不需要理解CDerivedA究竟何方神圣,只需要连接的时候找到就行了,反过来如果不是指针,CBase就要尝试寻找CDerivedA并生成实例,这可能吗?
4、VC++中的“文件视图”
又到轻松点的话题了,最早我开始学VC++的时候是……上上上份工作以前,当时手头有一本书叫《VC++ 6.0编程指南》,忘记谁写的了,和别的VC++教材差不多,这本书一开始也是教我们如何用向导生成MFC对话框程序,厄……本人认为这种教学方法不好,MFC本来就封装了很多技术细节,再加上一开始就“向导”,就很难了解其内幕,看到一些Win32 API调用就更加不知所言。但其中最尴尬的,是我企图按照书上讲的步骤去创建一个应用程序而屡不成功,为什么?因为VC++的类视图有bug,经常出些问题,尤其是你使用向导添加派生类的时候,常常有一些本来存在的类在类视图消失,书上说“类视图中右击XXX类,在弹出菜单中选择YYY”,我就是找不到该类,于是工程重做了一遍又一遍,快把我气炸了。
后来我看了《Windows Programming》才知道:“程序,不是这样编的。”我开始使用文件视图,一个个文件地创建,手动编写,再一个个加入工程,一个个编译,终于恍然大悟,原来VC++如此用法。所以之后我就习惯了文件视图,文件视图有个好处,就是从来就不会有文件莫名其妙地消失。下面这张图是我做的一个小工程的文件组织:
该WorkSpace下有3个project,我只展开了一个,因为文件比较多,全部展开太长,我采用了很典型的存放方式,一个project下有两个目录,一个是Source Files,一个是Header Files,一个放.cpp文件,一个放.h文件,当然Source Files中除了cpp,通常还有rs(Resource Script)文件,如果写COM,那还有idl文件,总之需要编译的,都放在Source Files目录下。我们还可以看到,需要编译的文件的图标有个向下的箭头。这样分开目录来存放而不是全部都放在同一个目录下的好处是便于查看。OK,我承认这并没有什么技术含量,但如果组织得好,能给你开发带来些便利。
那么是否可以创建更多的目录呢?可以,但这又有什么必要呢?还让人难以理解,不如按照默认的做好了。
文件视图还可以这样用,即选定单个文件来编译,这样总比直接点“Build”生成一大堆的错误好看点,嗯,从排错的意义上来说。
5、目录结构
我看过不少别人写的代码,其中还有一些是开源代码,里边的内容可谓错综复杂,尤其一个叫“PGPnet”的开源项目,使用纯C编写,(包括界面)代码十分晦涩,大量使用的全局变量令人不知所措,程序何始何终让人无法捉摸,其中目录结构大约有六层之多,有些目录竟然是空的,我不知道其意义何在。总之最后就是看不懂,摸不透。也难怪这个开源项目早就停止维护了,除了作者,谁还愿意去看?
我认为,目录层次不宜过多,一层可能太少,两层合适,最多三层,四层太多,五层炼狱,六层地狱。大致如下:
<Workspace, Main project>
<Debug>
<Release>
<Lib project>
<Debug>
<Release>
LibProject.dsp
LibProject.cpp
LibProject.h
OtherLib.cpp
OtherLib.h
<Dll project>
<Debug>
<Release>
DllProject.dsp
DllProject.cpp
DllProject.h
OtherDll.cpp
OtherDll.h
MainWorkspace.dsw
MainProject.dsp
MainProject.cpp
MainProject.h
OtherMain.cpp
OtherMain.h
Common.h
这是个典型的目录结构示意,一共两层,(除了Debug和Release),主目录通常用来存放dsw和主工程的文件。什么是主工程?书上可没提这个概念,我这里为了方便描述提出来的,比如带有程序界面的工程,生成exe能直接运行的工程,总之可以自己来定。有主就有副,区别于主工程,别的工程就叫副工程,没人反对吧,如果有副工程,那么最好给他们各自创建一个目录,一个位于主工程目录下的子目录。然后将这些副工程也添加到Workspace去,这样在Workspace看来,主和副是同一级别的,所以你清楚哪个是主就行了,VC++可不知道。
我们的最终目的是为了让主工程能顺利运行,所有的副工程都是为主工程服务的,那如何把它们的“劳动成果”结合起来呢?
比如主工程需要一个Lib Project生成的“aaa.lib”,还需要头文件“aaa.h”,而“aaa.h”是在<Lib project>目录下,“aaa.lib”是在<Lib project>下的<Debug>或者<Release>目录下,那我们就需要对主工程进行些设置,让它知道它要的文件在哪里。在文件视图中右击主工程,弹出菜单中选“Setting…”,在C++标签中,Category选“Preprocessor”,“Additional include directories”中填入需要额外寻找头文件的目录,比如填入“LibProject”,这样在主工程中“#include "aaa.h"”就会自动寻找到LibProject/aaa.h。库的设置在Link标签,Category中选择“Input”,在“Additional library path”中填入“LibProject/Debug”(如果是Release版本的话就填“LibProject/Release”),然后在上边的“Object/Library”中加入“aaa.lib”,这样在link的时候,VC++就会自动从“LibProject/Debug”目录下找到“aaa.lib”。
如果是Dll Project呢?
Dll的导入分为两种,一种是静态导入,一种是动态导入,我偏向于使用静态导入,当然我以前做过一个文件分类系统的项目,是使用动态导入的,因为对文件的类别有可能会增加,我不确定一共需要多少个dll,所以动态导入。
先说静态导入。我觉得静态导入和使用静态库基本相同,除了exe运行时候需要在同个目录底下有那个dll之外。那么操作就基本同静态lib,但如何把这个dll复制到exe的那个目录去呢?手动复制么?一次两次还行,经常改呢?麻烦!所以VC++的Custom Build功能这个地方就用上了。假设dll名字叫“bbb.dll”,打开dll工程的Project Setting,选择Custom Build标签,在Commands中输入:“copy $(OutDir)/bbb.dll $(ProjDir)/../Debug/bbb.dll”,这是Debug选项的,如果是Release,那就对应改一下目录。也许你有话说了:“把dll放在系统的system32目录下不也可以么?”啊,当然可以,不过我不推荐这种做法,因为我们的软件最好也能做到“绿色”。^_^
那动态导入的情况呢?我通常会在exe文件的目录下创建一个“dll”目录,把所有的要动态导入的dll都往那个目录放,那LoadLibrary的时候就很方便了,这样让exe所在的这个目录也干净一些。
说了这么多我也觉得其实这都算不上什么技术,只是对VC++这个IDE使用上的一些心得。但希望对大家有用,本来还想多写些东西,但现在觉得换个文章标题,或发表在别处会更合适些。
(完)