高质量程序设计指南:C++/C语言
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

4.1 C++/C程序的基本概念

4.1.1 启动函数main()

C++/C程序的可执行部分都是由函数组成的,main()就是所有程序中都应该提供的一个默认全局函数——主函数——所有的C++/C程序都应该从函数main()开始执行。但是语言实现本身并不提供main()的实现(它也不是一个库函数),并且具体的语言实现环境可以决定是否用main()函数来作为用户应用程序的启动函数,这是标准赋予语言实现的权利(又是一个“实现定义的行为”☺)。

虽然main不是C++/C的保留字(因此你可以在其他地方使用main这个名字,比如作为类、名字空间或者成员函数等的名字),但是你也不可以修改main()函数的名字。如果修改了main()的名字,比如改为mymain,连接器就会报告类似的连接时错误:“unresolved external symbol _main”。这是因为C++/C语言实现有一个启动函数,例如,MS C++/C应用程序的启动函数为mainCRTStartup()或者WinMainCRT- Startup(),同时在该函数的末尾调用了main()或者WinMain(),然后以它们的返回值为参数调用库函数exit(),因此也就默认了main()应该作为它的连接对象,如果找不到这样一个函数定义,自然会报错了。如此看来,main()其实就是一个回调函数。main()由我们来实现,但是不需要我们提供它的原型,因为我们并不能在自己的程序中调用它,这又和普通的回调函数有所不同。

基于应用程序框架(Application Framework,如MFC)生成的源代码中往往找不到main(),这并不是说这样的程序中就不需要main(),而是应用程序框架把main()的实现隐藏起来了,并且它的实现具有固定的模式,所以不需要程序员来编写。在应用程序的连接阶段,框架会将包含main()实现的library加进来一起连接。

由于main()函数如此重要,C++标准特别规定了标准main()函数的原型(参见ISO/IEC 14882:1998 3.6.1节):

                  “…It shall have a return type of type int, but otherwise its type is
              implementation-defined. All implementations shall allow both of the following
              definitions of main() :
                    int main() { /* …… */ }
              and
                    int main( int argc, char *argv[] ) { /* …… */ }
              …It is recommended that any further(optional) parameters be added after argv.”
                  “…main()应该返回int,但是具体返回什么类型可以由实现来定义(注:
              即可由实现来扩展)。不过所有实现版本都应该至少允许下面两种形式的main()
              函数:
                    int main() { /* …… */ }
              和
                    int main( int argc, char *argv[] ) { /* …… */ }
              ……并允许实现在参数argv后面增加任何需要的也是可选的参数(注:这也是
              可扩展的)。”

也就是说,上述两种形式是最具有可移植性的正确写法,其他形式都是特定实现的扩展形式,比如MS C++/C允许main()返回void,以及增加第三个参数char* env[]等。读者可参考编译器的帮助文档,以了解当前的编译器支持怎样的扩展形式。

关于main()函数的返回值问题,C++标准如是说:

                  “…A return statement in main() has the effect of leaving the main function
              (destroying any objects with automatic storage duration) and calling exit() with the
              return value as the argument. If control reaches the end of main() without
              encountering a return statement, the effect is that of executing
                    return 0;”
                  “…main()中的return语句的作用是离开main()(返回到C运行时库的启动
              模块,并启动销毁过程,销毁任何具有自动存储生命期的对象),就像其他函数
              一样,并且用其返回值作为参数调用exit()返回操作系统。如果控制到达main()
              的结尾,却没有遇到任何return语句,则效果相当于执行一条return 0;语句。”

当main()返回int类型时,不同的返回值具有不同的含义。当返回0时,表示程序正常结束;返回任何非0值表示错误或者非正常退出。exit()用main()的返回值作为返回操作系统的代码,以指示程序执行的结果(当然你也可以在main()或其他函数内直接调用exit()来结束程序)。

特别地,C++标准对main()有几个不同于一般函数的限制:

(1)不能重载。

(2)不能内联。

(3)不能定义为静态的。

(4)不能取其地址。

(5)不能由用户自己调用。

……

4.1.2 命令行参数

我们可能希望可执行程序具有处理命令行参数的能力,如常用的“dir X:\document /p /w”等DOS或UNIX命令。标准C++/C规定,可以在main()函数中添加形式参数以接收程序在启动时从命令行中输入的各个参数。这里需注意,不要把程序启动时的“命令行参数”与调用main()的“函数实参”的概念混淆了,命令行参数是由启动程序截获并打包成字符串数组后传递给main()的一个形参argv,而包括命令字(即可执行文件名称)在内的所有参数的个数则被传递给形参argc。

以上所述,包括main()的连接规范(Linkage Specification)和调用约定(Calling Convention)在内,不同的语言实现很可能是不同的。具体可参考编译器文档或者C Runtime Library和类库的帮助文档,甚至类库的源代码也可拿来读一读(如Visual C++的crtexe.c和internal.h等)。

示例4-1是一个文件拷贝的程序,和DOS内部命令copy的功能一样。

示例4-1

            // mycopy.c : copy file to a specified destination file.
            #include <stdio.h>
            int main(int argCount, char* argValue[])
            {
                  FILE *srcFile = 0, *destFile = 0;
                  int ch = 0;
                  if (argCount != 3) {
                    printf("Usage: %s src-file-name dest-file-name\n", argValue[0]);
                  } else {
                    if (( srcFile = fopen(argValue[1], "r")) == 0) {
                    printf("Can not open source file \"%s\" !", argValue[1]);
                  } else {
                    if ((destFile = fopen( argValue[2], "w")) == 0) {
                          printf("Can not open destination file \"%s\"!", argValue[2]);
                          fclose(srcFile);  /*!!!*/
                    } else {
                          while((ch = fgetc(srcFile)) != EOF) fputc(ch, destFile);
                          printf("Successful to copy a file!\n");
                          fclose(srcFile);   /*!!!*/
                          fclose(destFile);  /*!!!*/
                          return 0;       /*!!!*/
                    }
                  }
              }
              return 1;
            }
            // 用法示例:
            mycopy C:\file1.dat C:\newfile.dat

如果你还不了解文件操作,没有关系,你不妨输入这个程序,把源文件名改为mycopy.c(或.cpp),编译连接。在DOS命令行方式下随便找几个文件测试一下,是不是很有成就感呢?

4.1.3 内部名称

请注意4.1.1节的连接错误信息中的“_main”,这是编译器为main生成的内部名称。C和C++语言实现都会按照特定的规则把用户(指程序员)定义的标识符(各种函数、变量、类型及名字空间等)转换为相应的内部名称。当然,这些内部名称的命名方法还与用户为它们指定的连接规范有关,比如使用C的连接规范,则main的内部名称就是_main。

内部名称是否多此一举呢?非也!

在C语言中,所有函数不是局部于编译单元(文件作用域)的static函数,就是具有extern连接类型和global作用域的全局函数,因此除了两个分别位于不同编译单元中的static函数可以同名外,全局函数是不能同名的;全局变量也是同样的道理。其原因是C语言采用了一种极其简单的函数名称区分规则:仅在所有函数名的前面添加前缀“_”,从唯一识别函数的作用上来说,实际上和不添加前缀没什么不同。

但是,C++语言允许用户在不同的作用域中定义同名的函数、类型、变量等,这些作用域不仅限于编译单元,还包括class、struct、union、namespace等,甚至在同一个作用域中也可定义同名的函数,即重载函数。那么编译器和连接器如何区分这些同名且又都会在同一个编译单元中被引用的程序元素呢?在示例4-2中,假设两个类都定义在同一个作用域中,且都定义了名为foo的成员函数。

示例4-2

由于成员函数并不属于某一个对象,那么编译器如何区分下面这些语句分别调用的是哪个函数呢?

            Sample_1  a;
            Sample_2  b;
            a.foo(“aaa”);
            a.foo(100);
            b.foo(“bbb”);
            b.foo(false);

你也许会说“通过它们各自的对象和成员标识符就可以区分”。是的,你说得没错,但这只是源代码级或者说是形式上的区分。在连接器看来,所有函数都是全局函数,能够用来区分不同函数调用的除了作用域外就是函数名称了。但是,上面的调用显然都是合理合法的。因此,如果不对它们进行重命名,就会导致连接二义性。在C++中,重命名称为“Name-Mangling”(名字修饰或名字改编)。例如,在它们的前面分别添加所属各级作用域的名称(class、namespace等)及重载函数的经过编码的参数信息(参数类型和个数等)作为前缀或者后缀,产生全局名字Sample_1_foo@pch@1、Sample_1_foo@int@1、Sample_2_foo@pch@1和Sample_2_foo@int@1,这样就可以区分了。关于这方面更详细的信息请参考Lippman的《Inside The C++ Object Model》相关章节,你也可以从MS C++/C编译器输出的MAP文件了解一下它所Mangling出来的函数的内部名称。

另外,标准C++的不同实现会采取不同的Name-Mangling方案(标准没有强制规定),这正是导致不同语言实现之间的连接器不能兼容的原因之一。

4.1.4 连接规范

在使用不同编程语言进行软件联合开发的时候,需要统一全局函数、全局变量、全局常量、数据类型等的连接规范(Linkage Specification),特别是在不同模块之间共享的接口定义部分。为什么呢?因为连接规范关系到编译器采用什么样的Name-Mangling方案来重命名这些标识符的问题,而如果同一个标识符在不同的编译单元或模块中具有不一致的连接规范,就会产生不一致的内部名称,这肯定会导致程序连接失败。

同样道理,在开发程序库的时候,明确连接规范也是必须要遵循的一条规则。通用的连接规范则属C连接规范:extern“C”,其使用方法如下。

(1)如果是仅对一个类型、函数、变量或常量等指定连接规范:

            extern "C" void WinMainCRTStartup();
            extern "C" const CLSID CLSID_DataConverter;
            extern "C" struct Student{……};
            extern "C" Student g_Student;

(2)如果是对一段代码限定连接规范:

            #ifdef  __cplusplus
            extern "C" {
            #endif
            const int MAX_AGE = 200;
            #pragma pack(push, 4)
            typedef struct _Person
            {
                char *m_Name;
                int   m_Age;
            } Person, *PersonPtr;
            #pragma pack(pop)
            Person g_Me;
            int    __cdecl  memcmp(const void*,const void*,size_t);
            void*  __cdecl  memcpy(void*,const void*,size_t);
            void*  __cdecl  memset(void*,int,size_t);
            #ifdef  __cplusplus
            }
            #endif

(3)如果当前使用的是C++编译器,并且使用了extern“C”来限定一段代码的连接规范,但是又想令其中某行或某段代码保持C++的连接规范,则可以编写如下代码(具体要看你的编译器是否支持extern“C++”):

            #ifdef  __cplusplus
            extern "C" {
            #endif
            const int MAX_AGE = 200;
            #pragma pack(push, 4)
            typedef struct _Person
            {
                char *m_Name;
                int   m_Age;
            } Person, *PersonPtr;
            #pragma pack(pop)
            Person g_Me;
            #if  _SUPPORT_EXTERN_CPP_
            extern “C++” {
            #endif
            int    __cdecl  memcmp(const void*,const void*,size_t);
            void*  __cdecl  memcpy(void*,const void*,size_t);
            #if  _SUPPORT_EXTERN_CPP_
            }
            #endif
            void*  __cdecl  memset(void*,int,size_t);
            #ifdef  __cplusplus
            }
            #endif

(4)如果在某个声明中指定了某个标识符的连接规范为extern“C”,那么也要为其对应的定义指定extern“C”连接规范,如下所示:

            #ifdef  __cplusplus
            extern "C" {
            #endif
            int  __cdecl  memcmp(const void*,const void*,size_t);  // 声明
            #ifdef  __cplusplus
            }
            #endif
            #ifdef  __cplusplus
            extern "C" {
            #endif
            int  __cdecl  memcmp(const void*p,const void*a,size_t len)
            {
              ……  // 功能实现
            }
            #ifdef  __cplusplus
            }
            #endif

但是对COM接口方法(Interface Methods,Interface中的pure virtual functions)使用的C复合数据类型来说(它们也是COM对象接口的组成部分),是否采用统一的连接规范,对COM对象及组件的二进制数据兼容性和可移植性都没有影响。因为即使接口两端(COM接口实现端和接口调用端)对接口数据类型的内部命名不同,只要它们使用了一致的成员对齐和排列方式、一致的调用规范、一致的virtual function实现方式,总之就是一致的C++对象模型,并且保证COM组件升级时不改变原来的接口和数据类型定义,则所有方法的运行时绑定和参数传递都不会存在问题(所有方法的调用都被转换为通过对象指针对vptr和vtable以及函数指针的访问和调用,这种间接性不再需要任何方法名即函数名的参与,而接口名和方法名只是为了让客户端的代码能够顺利通过编译,但是连接时就全部不再需要了)。

4.1.5 变量及其初始化

变量就是用来保存数据的程序元素,它是内存单元的别名,取一个变量的值就是读取其内存单元中存放的值,而写一个变量就是把值写入到它代表的内存单元中。在C++/C中,全局变量(extern或static的)存放在程序的静态数据区中,在程序进入main()之前创建,在main()结束之后销毁,因此在我们的代码中根本没有机会初始化它们,于是语言及其实现就提供了一个默认的全局初始化器0。如果你没有明确地给全局变量提供初值,编译器会自动地将0转换为所需要的类型来初始化它们。函数内的static局部变量和类的static数据成员都具有static存储类型,因此最终被移到程序的静态数据区中,也会默认初始化为0,除非你明确地提供了初值。但是自动变量的初始化则是程序员的责任,因为它们是运行时在堆栈上创建的并且可以在运行时由程序员来初始化的,不要指望编译器会给它一个默认的初值。

全局变量的声明和定义应当放在源文件的开头位置。

【提示4-1】: 要区分初始化和赋值的不同。前者发生在对象(变量)创建的同时,而后者是在对象创建后进行的。要区分什么是编译器的责任,什么是程序员的责任,不可错把程序员的责任推给编译器,否则结果可能出乎意料!例如,全局变量的初始化、数据类型的隐式转换、类的隐含成员的初始化等都是编译器的责任,而局部变量的初始化、强制类型转换、类的非静态数据成员的初始化等都是程序员的责任。

在一个编译单元中定义的全局变量的初始值不要依赖定义于另一个编译单元中的全局变量的初始值。这是因为:虽然编译器和连接器可以决定同一个编译单元中定义的全局变量的初始化顺序保持与它们定义的先后顺序一致,但是却无法决定当两个编译单元连接在一起时哪一个的全局变量的初始化先于另一个编译单元的全局变量的初始化。也就是说,这一次编译连接和下一次编译连接很可能使不同编译单元之间的全局变量的初始化顺序发生改变。例如:

当这两个文件编译完成并连接时,在最后的可执行程序启动时,到底是先初始化g_x还是先初始化g_d呢?答案是:我们无法预料,连接器也不会给你保证一个顺序!所以下面的做法是不当的:

如果g_x初始化被排在g_d的前面,那么g_d就会被初始化为110;但是如果反过来,那么g_d的初始值就无法预料了。

4.1.6 C Runtime Library

一般来说,一个C++/C程序不可能不使用C运行时库,即使你没有显式地调用其中的函数也可能间接地调用,只是我们平时没有在意罢了。例如,启动函数、I/O系统函数、存储管理、RTTI、动态决议、动态链接库(DLL)等都会调用C运行时库中的函数。我们在每一个程序开头包含的stdio.h头文件中的许多I/O函数就是它的一部分。C运行时库有多线程版和单线程版,开发多线程应用程序时应该使用多线程版本的库,仅在开发单线程程序时才使用单线程版本。另外,同一软件的不同模块最好使用一致的运行时库,否则会出现连接问题。

4.1.7 编译时和运行时的不同

我们把编译预处理器、编译器和连接器工作的阶段合称“编译时”。语言中有些构造仅在编译时起作用,而有些构造则是在“运行时”起作用的,分清楚这些构造对于程序设计很重要。例如,预编译伪指令、类(型)定义、外部对象声明、函数原型、标识符、各种修饰符号(const、static等)及类成员的访问说明符(public、private、protected)和连接规范、调用规范等,仅在编译器进行语法检查、语义检查和生成目标文件(.obj或.o文件)及连接的时候起作用的,在可执行程序中不存在这些东西。容器越界访问、虚函数动态决议、函数动态连接、动态内存分配、异常处理和RTTI等则是在运行时才会出现和发挥作用的,因此运行时出现的程序问题大多与这些构造有关。

我们举两个例子来说明编译时和运行时的不同,见示例4-3和示例4-4。

示例4-3

            int *pInt = new int[10];
            pInt+=100;          // 越界,但是还没有形成越界访问
            cout<<*pInt<<endl;   // 越界访问!可能行,也可能不行!
            *pInt=1000;         // 越界访问!即使偶尔不出问题,但不能确保永远不出问题!

上述代码在编译时绝对没有问题,但是运行时会出现错误!千万不要写出这样的代码来!

示例4-4

            class Base {
            public:
              virtual void Say(){ cout<< "Base::Say() was invoked!\n"; }
            };
            class Derived : public Base {
            private:      // 改变访问权限,合法但不是好风格!
              virtual  void Say(){cout<<“Derived::Say()was invoked!\n”;}
            };
            // 测试
            Base *p = new Derived;
            p->Say();    // 输出:Derived::Say()was invoked!
                         // 出乎意料地绑定到了一个private函数身上!

示例4-4在编译时没有问题,在运行时也不会出现错误,但是违背了private的用意。

我们在程序设计时就要对运行时的行为有所预见,通过编译连接的程序在运行时不见得就是正确的。虽然你能够一时“欺骗”编译器(因为编译器还不够聪明),但是由此造成的后果要你自己来承担。这里我们引用Bjarne Stroustrup的一段话来说明这一问题:“C++的访问控制策略是为了防止意外事件而不是防止对编译器的故意欺骗。任何程序设计语言,只要它支持对原始存储器的直接访问(如C++的指针),就会使数据处于一种开放的状态,使所有有意按照某种违反数据项原有类型安全规则所描述的方式去触动它的企图都能够实现,除非该数据项受到操作系统的直接保护。”

4.1.8 编译单元和独立编译技术

语言实现和开发环境支持的独立编译技术并非语言本身所规定的。每一个源代码文件(源文件及其递归包含的所有头文件展开)就是一个最小的编译单元,每一个编译单元可以独立编译而不需要知道其他编译单元的存在及其编译结果。例如,一个编译单元在单独编译的时候根本无法知道另一个编译单元在编译的时候是否已经定义了一个同名的extern全局变量或全局函数,所以每个编译单元都能够通过编译,但是如果另一个编译单元也定义了同名的extern全局变量或全局函数,那么当把两个目标文件连接到一起的时候就会出错。

独立编译技术最大的好处就是公开接口而隐藏实现,并可以创建预定义的二进制可重用程序库(函数库、类库、组件库等),在需要的时候用连接器把用户代码与库代码连接成可执行程序。另一方面,独立编译技术可以大大减少代码修改后重新编译的时间。