第14章 宏和模板简介
现在,读者应对基本的C++语法有深入认识,能够理解使用C++编写的程序,为学习有助于高效编写程序的语言特性做好了准备。
在本章中,您将学习:
• 预处理器简介;
• 关键字#define与宏;
• 模板简介;
• 如何编写函数模板和模板类;
• 宏和模板之间的区别;
• 使用C++11新增的static_assert进行编译阶段检查。
14.1 预处理器与编译器
在第 2 章,您首次接触到了预处理器。顾名思义,预处理器在编译器之前运行,换句话说,预处理器根据程序员的指示,决定实际要编译的内容。预处理器编译指令都以#打头,例如:
本章重点介绍上述代码演示的两种预处理器编译指令,一是使用#define定义常量,二是使用#define定义宏函数。这两个编译指令都告诉编译器,将每个宏实例(ARRAY_LENGTH或SQUARE)替换为其定义的值。
宏也进行文本替换。预处理器只是就地将标识符替换为指定的文本。
14.2 使用#define定义常量
使用#define定义常量的语法非常简单:
例如,要定义将被替换为25的常量ARRAY_LENGTH,可使用如下代码:
这样,每当预处理器遇到标识符ARRAY_LENGTH时,都会将其替换为25。
对于上述三行代码,预处理器运行完毕后,编译器看到的代码如下:
替换将在所有代码中进行,包括下面这样的for循环:
编译器看到的上述循环如下:
要准确地了解#define宏的工作原理,请参阅程序清单14.1。
程序清单14.1 声明并使用定义常量的宏
输出:
分析:
第 3~7 行定义了 4 个宏常量:ARRAY_LENGTH、PI、MY_DOUBLE 和 FAV_WHISKY。正如您看到的,第11行使用了常量ARRAY_LENGTH来指定数组的长度,而第12行使用了运算符sizeof()间接地核实数组长度。第15行使用MY_DOUBLE声明了类型为double的变量Radius,而第17行使用了PI来计算圆的面积。最后,第19行使用了FAV_WHISKY来初始化一个std::string对象,而第20行在cout语句中直接使用了该常量。所有这些语句都表明,预处理器只是进行文本替换。
程序清单14.1大量地使用了“死板的”文本替换,但这种文本替换也有缺点。
预处理器进行死板的文本替换,这减轻了您的负担,但并非总能减轻编译器的负担。在程序清单14.1的第7行中,如果您这样定义FAV_WHISKY:
则第19行实例化std::string的代码将导致编译错误。但如果没有这行代码,该程序将通过编译,并打印如下内容:
这样的输出显然不符合逻辑,而最重要的是,编译器却没有检测到这一点。另外,对于使用宏定义的常量PI,您没有太大的控制权:其类型是double还是float?答案是都不是。在预处理器看来,PI就是3.1416,根本不知道其数据类型。
定义常量时,更好的选择是使用关键字const和数据类型,因此下面的定义好得多:
14.2.1 使用宏避免多次包含
C++程序员通常在.h文件(头文件)中声明类和函数,并在.cpp文件中定义函数,因此需要在.cpp文件中使用预处理器编译指令#include <header>来包含头文件。如果在头文件 class1.h中声明了一个类,而这个类将class2.h中声明的类作为其成员,则需要在class1.h中包含class2.h。如果设计非常复杂,即第二个类需要第一个类,则在class2.h中也需要包含class1.h!
然而,在预处理器看来,两个头文件彼此包含对方会导致递归问题。为避免这种问题,可结合使用宏以及预处理器编译指令#ifndef和#endif。
包含<header2.h>的head1.h类似于下面这样:
header2.h与此类似,但宏定义不同,且包含的是<header1.h>:
#ifndef可读作if-not-defined。这是一个条件处理命令,让预处理器仅在标识符未定义时才继续。#endif告诉预处理器,条件处理指令到此结束。
因此,预处理器首次处理header1.h并遇到#ifndef后,发现宏HEADER1H还未定义,因此继续处理。#ifndef后面的第一行定义了宏HEADER1H,确保预处理器再次处理该文件时,将在遇到包含#ifndef的第一行时结束,因为其中的条件为false。header2.h与此类似。在C++编程领域,这种简单的机制无疑是最常用的宏功能之一。
14.3 使用#define编写宏函数
预处理器对宏指定的文本进行简单替换,因此也可以使用宏来编写简单的函数,例如:
这个宏计算平方值。同样,计算圆面积的宏类似于下面这样:
宏函数通常用于执行非常简单的计算。相比于常规函数调用,宏函数的优点在于,它们将在编译前就地展开,因此在有些情况下有助于改善代码的性能。程序清单 14.2 演示了如何使用这些宏函数。
程序清单14.2 使用计算平方值、圆面积、最小值和最大值的宏函数
输出:
分析:
第4~8行包含几个宏函数,它们分别计算平方值、圆面积以及两个数中的最大值和最小值。注意到第6行的AREA_CIRCLE使用了宏常量PI来计算圆面积,这表明一个宏可使用另一个宏。毕竟,宏是向预处理器发出的文本替换命令。下面来分析使用MIN宏的第25行:
编译器进行编译时,这行代码变成了下面这样,即将宏就地展开了:
宏不考虑数据类型,因此使用宏函数很危险。例如,理想情况下,AREA_CIRCLE的返回类型应为double,这样可确保返回的圆面积的精度,使其不依赖于半径的精度。
再来看一下计算圆面积的宏:
上述代码比较古怪,使用了大量的括号。而在程序清单7.1中,函数Area()的代码如下:
编写宏时使用了大量括号,而在函数中,同样的公式看起来完全不同。这是为什么呢?原因在于宏的计算方式——预处理器支持的文本替换机制。
请看下面的宏,它省略了大部分括号:
如果使用类似于下面的语句调用这个宏,结果将如何呢?
展开后,编译器看到的语句如下:
根据运算符优先级,将先执行乘法运算,再执行加法运算,因此编译器将这样计算面积:
在省略了括号的情况下,简单的文本替换破坏了编程逻辑!使用括号有助于避免这种问题:
经过替换后,编译器看到的表达式如下:
通过使用括号,让宏代码不受运算符优先级的影响,从而能够正确地计算面积。
编写程序后,立即单步执行以测试每种路径很不错,但可能不现实。比较现实的做法是,插入检查语句,对表达式或变量的值进行验证。
assert宏让您能够完成这项任务。要使用assert宏,需要包含<assert.h>,其语法如下:
下面是一个示例,它使用assert()来验证指针的值:
assert()在指针无效时将指出这一点。为演示这一点,我将 sayHello 初始化为 NULL,并在调试模式下执行,Visual Studio弹出了如图 14.1所示的窗口。
图14.1 使用assert检查无效指针的结果
在Microsoft Visual Studio中,assert()让您能够单击“重试”按钮返回应用程序,而调用栈将指出哪行代码没有通过断言测试。这让assert()成为一项方便的调试功能;例如,可使用assert对函数的输入参数进行验证。长期而言,assert有助于改善代码的质量,强烈推荐使用它。
在大多数开发环境中,assert()通常在发布模式下被禁用,因此它仅在调试模式下显示错误消息。
另外,在有些开发环境中,assert()被实现为函数,而不是宏。
由于断言在发布模式下不可用,对于对应用程序正确运行至关重要的检查(如检查dynamic_cast的返回值),为确保它们在发布模式下也会执行,应使用if语句,这很重要。断言可帮助您找出问题,但不能因此不再代码中对指针做必要的检查。
宏函数可用于不同的变量类型。再来看一下程序清单14.2中的下述代码行:
可将宏函数MIN用于整型:
也可将其用于双精度数:
如果MIN()为常规函数,必须编写两个不同的版本:MIN_INT()和MIN_DOUBLE(),前者接受int参数并返回一个int值,而后者接受double参数并返回一个double值。使用宏函数减少了代码行,这是一种细微的优势,诱使某些程序员使用宏来定义简单函数。宏函数将在编译前就地展开,因此简单宏的性能由于常规函数调用。这是因为函数调用要求创建调用栈、传递参数等,这些开销占用的CPU时间通常比MIN执行的计算还多。
虽然具备这些优点,宏也存在严重的问题,那就是不支持任何形式的类型安全。另外,复杂的宏调试起来也不容易。
如果需要编写独立于类型的泛型函数,又要确保类型安全,可使用模板函数,而不是宏函数。如果要改善性能,可将函数声明为内联的。
第7章介绍过,要编写内联函数,可使用关键字inline,如程序清单7.10所示。
现在该学习使用模板进行泛型编程了。
14.4 模板简介
模板可能是C++语言中最强大却最少被使用(或被理解)的特性之一。
在 C++中,模板让程序员能够定义一种适用于不同类型的对象的行为。这听起来有点像宏(参见前面用于判断两个数中哪个更大的简单宏MAX),但宏不是类型安全的,而模板是类型安全的。
模板声明以关键字template打头,接下来是类型参数列表。这种声明的格式如下:
关键字 template标志着模板声明的开始,接下来是模板参数列表。该参数列表包含关键字typename,它定义了模板参数objectType,objectType是一个占位符,针对对象实例化模板时,将使用对象的类型替换它。
上述代码演示了一个模板函数和一个模板类,它们都接受两个模板参数:T1和T2,其中T2的类型默认为T1。
模板声明可以是:
• 函数的声明或定义;
• 类的定义或声明;
• 类模板的成员函数或成员类的声明或定义;
• 类模板的静态数据成员的定义;
• 嵌套在类模板中的类的静态数据成员的定义;
• 类或类模板的成员模板的定义。
假设要编写一个函数,它适用于不同类型的参数,为此可使用模板语法!下面来分析一个模板声明,它与前面讨论的MAX宏等价——返回两个参数中较大的一个:
下面是一个使用该模板的示例:
注意到调用GetMax时使用了<int>,这将模板参数objectType指定为int。上述代码将导致编译器生成模板函数GetMax的两个版本,如下所示:
然而,实际上调用模板函数时并非一定要指定类型,因此下面的函数调用没有任何问题:
在这种情况下,编译器很聪明,知道这是针对整型调用模板函数,如程序清单14.3所示。然而,对于模板类,必须显式地指定类型。
程序清单14.3 模板函数GetMax,它返回两个参数中较大的一个
输出:
分析:
该程序清单包含两个模板函数:第4~11行的GetMax();第13~18行的DisplayComparison(),它使用了GetMax()。在main()函数中,第23、26和29行表明,可将同一个模板函数用于不同类型的数据:int、double 和 std::string。模板函数不仅可以重用(就像宏函数一样),而且更容易编写和维护,还是类型安全的。
请注意,调用DisplayComparison时,也可显式地指定类型,如下所示:
然而,调用模板函数时没有必要这样做。您无需指定模板参数的类型,因为编译器能够自动推断出类型;但使用模板类时,需要这样做。
程序清单14.3中的模板函数DisplayComparison()和GetMax()是类型安全的,这意味着不能像下面这样进行无意义的调用:
这种调用将导致编译错误。
第9章介绍过,类是一种编程单元,封装类属性以及使用这些属性的方法。属性通常是私有成员,如Human类中的 int Age。类是设计蓝图,其实际表示为对象。例如,可将Tom视为Human类的一个对象,其Age属性为 15。如果对于某些寿命非常长的人,您想使用 long long变量来存储其年龄,而对于寿命较短的人,则使用short变量来存储其年龄,该如何办呢?此时模板类可派生用场。模板类是模板化的 C++类,是蓝图的蓝图。使用模板类时,可指定要为哪种类型具体化类。这让您能够创建不同的Human对象,即有的年龄存储在 long long成员中,有的存储在 int成员中,还有的存储在short成员中。
下面是一个简单的模板类,它只有一个模板参数T:
类CMyFirstTemplate用于保存一个类型为T的变量,该变量的类型是在使用模板时指定的。下面来看该模板类的一种用法:
这里使用该模板类来存储和检索类型为int的对象,即使用int类型的模板参数实例化Template类。同样,这个类也可以用于处理字符串,其用法类似:
因此,这个类定义了一种模式,并可针对不同的数据类型实现这种模式。下面是一个可定制的Human类,可以通过参数T指定Age的类型:
使用这个模板时,在模板实例化语法中指定类型:
对于模板,术语实例化的含义稍有不同。用于类时,实例化通常指的是根据类创建对象。
但用于模板时,实例化指的是根据模板声明以及一个或多个参数创建特定的类型。
因此,对于下面的模板声明:
使用该模板时将编写这样的代码:
这种实例化创建的特定类型称为具体化。
模板参数列表包含多个参数,参数之间用逗号分隔。因此,如果要声明一个泛型类用于存储两个类型可能不同的对象,可以使用如下所示的代码(这个模板类包含两个模板参数):
在这里,类HoldsPair接受两个模板参数,参数名分别为T1和T2。可使用这个类来存储两个类型相同或不同的对象,如下所示:
可以修改前面的HoldsPair <…>,将模板参数的默认类型指定为 int:
这与给函数指定默认参数值极其类似,只是这里指定的是默认类型。
这样,前述第二种HoldsPair用法可以简写为:
下面使用前面讨论的HoldsPair模板来进行开发,如程序清单14.4所示。
程序清单14.4 包含两个成员属性的模板类
输出:
分析:
这个简单程序演示了如何声明模板类HoldsPair来存储两个值,这两个值的类型取决于模板的参数列表。第1行有一个模板参数列表,它定义了两个参数(T1和T2),这两个参数的默认类型分别为int和 double。存取器函数GetFirstValue ()和GetSecondValue()用于查询对象的值,它们将根据模板实例化语法返回正确的对象类型。HoldsPair定义了一种模式,可通过重用该模式针对不同的变量类型实现相同的逻辑。因此,使用模板可提高代码的可复用性。
前面说过,模板是用于创建类的蓝图,而类是用于创建对象的蓝图。在模板类中,静态成员属性的工作原理是什么样的呢?第 9 章介绍过,如果将类成员声明为静态的,该成员将由类的所有实例共享。模板类的静态成员与此类似,由特定具体化的所有实例共享。也就是说,如果模板类 T 包含静态成员X,该成员将在针对int具体化的所有实例之间共享;同样,它还将在针对double具体化的所有实例之间共享,且与针对int具体化的实例无关。换句话说,可以认为编译器创建了两个版本的X:X_int用于针对int具体化的实例,而X_double针对double具体化的实例,程序清单14.5演示了这一点。
程序清单14.5 静态成员对模板类和实例的影响
输出:
分析:
在第17和23行,分别为针对int和double的模板具体化设置了成员StaticValue。在main()中的第25和26行,通过另一个实例(Int_2和Double_1)读取了这个静态成员的值。输出表明,得到的StaticValue不同:一个是2011,这是通过另一个针对int具体化的实例设置的;另一个为1011,这是通过另一个针对double具体化的实例设置的。
也就是说,对于针对每种类型具体化的类,编译器确保其静态变量不受其他类的影响。模板类的每个具体化都有自己的静态成员。
在程序清单14.5中,第11行不可或缺,它初始化模板类的静态成员:
对于模板类的静态成员,通用的初始化语法如下:
C++11
使用static_assert执行编译阶段检查
static_assert是C++11新增的一项功能,让您能够在不满足指定条件时禁止编译。这好像不可思议,但对模板类来说很有用。您可能想禁止针对int实例化模板类,为此可使用static_assert,它是一种编译阶段断言,可用于在开发环境(或控制台中)显示一条自定义消息:
要禁止针对类型int实例化模板类,可使用static_assert(),并将sizeof(T)与sizeof(int)进行比较,如果它们相等,就显示一条错误消息:
程序清单14.6演示了一个模板类,它禁止针对特定类型实例化它。
程序清单14.6 一个挑剔的模板类,在您针对int类型实例化时,它使用static_assert发出抗议
输出:
没有输出,因为这个程序不能通过编译,它显示一条错误消息,指出您指定的类型不正确:
分析:
编译器发出的抗议是在第6行指定的。static_assert是C++11新增的一项功能,让您能够禁止不希望的模板实例化。
模板最重要也是最强大的应用是在标准模板库(STL)中。STL 由一系列模板类和函数组成,它们分别包含泛型实用类和算法。这些STL模板类让您能够实现动态数组、链表以及包含键-值对的容器,而sort等算法可用于这些容器,从而对容器包含的数据进行处理。
前面介绍的模板语法有助于读者使用本书后面将详细介绍的STL容器和函数;更深入地理解STL将有助于使用STL中经过测试的可靠实现,从而编写出更高效的C++程序,还有助于避免在模板细节上浪费时间。
14.5 总结
本章更详细地介绍了预处理器。每当您运行编译器时,预处理器都将首先运行,对#define等指令进行转换。
预处理器执行文本替换,但在使用宏时替换将比较复杂。通过使用宏函数,可根据在编译阶段传递给宏的参数进行复杂的文本替换。将宏中的每个参数放在括号内以确保进行正确的替换,这很重要。
模板有助于编写可重用的代码,它向开发人员提供了一种可用于不同数据类型的模式。模板可以取代宏,且是类型安全的。学习本章介绍的模板知识后,便为学习如何使用STL做好了准备!
14.6 问与答
问:在头文件中,为何要防范多次包含?
答:多次包含防范使用#ifndef、#define和#endif,可避免头文件出现多次包含或递归包含错误,有时还可提高编译速度。
问:如果所需的功能使用宏函数和模板都能实现,在什么情况下应使用宏函数,而不是模板?
答:在任何情况下都应使用模板,而不是宏函数,因为模板不但提供了通用实现,还是类型安全的。宏函数不是类型安全的,最好不要使用。
问:调用模板函数时,需要指定模板参数类型吗?
答:通常不需要,因为编译器能够根据函数调用使用的实参推断出模板参数类型。
问:对于特定模板类,每个静态成员有多少个版本?
答:这完全取决于针对多少种类型实例化了该模板类。如果针对int、string和自定义类型X实例化了该模板类,则每个静态成员都有三个不同的版本——每种模板具体化一个。
14.7 作业
作业包括测验和练习,前者帮助读者加深对所学知识的理解,后者提供了使用新学知识的机会。请尽量先完成测验和练习题,然后再对照附录D的答案。在继续学习下一章前,请务必弄懂这些答案。
1.什么是多次包含防范(inclusion guard)?
2.#define debug 0与#undef debug之间的区别何在?
3.如果使用参数20调用下面的宏,结果将是多少?
3.如果用10+10调用问题2中的HALVE宏,结果将是多少?
5.如何修改SPLIT宏以避得到错误的结果?
1.编写一个将两个数相乘的宏。
2.编写一个模板,实现练习1中宏的功能。
3.实现模板函数swap,它交换两个变量的值。
4.查错:您将如何改进下面的宏使其计算输入值的1/4?
5.编写一个简单的模板类,它存储两个数组,数组的类型是通过模板参数列表指定的。数组包含10个元素,模板类应包含存取器函数,可用于操作数组元素。