面向 C# 开发者的 C++ 2013 教程(七)
十七、预处理器
Power is prone to corruption; Absolute power leads to absolute corruption. -Lord acton
C 中的预处理器在历史上是一个独立的程序,它能够根据编译时定义的标志值影响代码编译。随着时间的推移,预处理器被扩展并集成到 C++ 编译器中。C# 的设计者选择采用 C++ 预处理器的子集,只保留条件编译命令,拒绝宏替换语言,以保持 C# 代码的简单性。
预处理器指令都以#符号开始。现代编译器通过将预处理器集成到编译器中来提高吞吐量,这样它们可以逐行执行预处理。预处理器在 C++ 编程中的作用比在 C# 编程中要大得多。它实现了一种富文本替换语言,允许您完全改变代码的外观。
有一段时间,使用 C 预处理器来做复杂而神奇的事情非常流行。通常,生成这些深奥结构的动机是在尚不支持面向对象编程的平台上开发面向对象编程的基础。不幸的是,这种做法经常导致混淆,代码变得越来越难以调试和维护。
C++ 的出现和标准化消除了大部分这种英雄式的措施,但是仍然有一些重要的方法可以让预处理器使你的代码更干净和更有效。
C# 预处理器命令
C# 预处理器在范围和功能上相当有限。它定义代码区域,并在不比类级别更细的粒度上确定编译选项。
代码区域
C# 有两个预处理器命令,#region和#endregion,它们允许您将代码的某些区域标记为具有函数或标签,以便您可以使用 ide 智能地隐藏它们。这些在标准 C++ 中还不存在,尽管它们作为一个#pragma指令存在于 Microsoft Visual C++ 编译器中(参见本章后面的“#pragma”部分)。
条件代码编译
C# 指令#define、#undef、#if、#else、#elif和#endif使您能够指定根据某些标志的状态编译的代码段。可以在编译开始前在文件的开头定义标志,也可以在命令行定义标志。这使得添加只在调试构建期间添加到可执行文件中的一段代码变得非常容易。在 C# 中,#define和#undef只能用在文件的开头。如果在 C# 中违反了这条规则,您将得到以下诊断信息:
test.cs(5,2): error CS1032: Cannot define/undefine preprocessor symbols
after first token in file
C++ 预处理器命令
C++ 也支持条件代码编译指令,但是没有必须在文件开头使用#define和#undef的限制。但是 C++ 预处理器比这更强大。
在 C++ 中,预处理器是一种全宏替换语言。您可以使用#define来创建宏,允许您对代码的外观和功能进行彻底的修改。
例如,如果我们想让我们的 C++ 看起来更像 C#,我们可以这样做:
#define USING using namespace
USING System;
#define NEW gcnew
#define CLASS ref class
#define STRUCT value class
#define PUBLIC public:
public STRUCT Struct
{
PUBLIC int i;
};
public CLASS Test
{
PUBLIC static void Main()
{
Struct ^ s = NEW Struct();
s->i = 42;
Console::WriteLine(s->i);
}
};
void main()
{
Test::Main();
}
当然,如果你像这样写代码,你会让其他程序员发疯,试图弄清楚你的代码到底做了什么。你也可能会把自己逼疯。
全文替换语言
就像模板元编程一样,C++ 预处理器命令集本身被认为是一个子语言。您不仅可以为标志赋值,还可以创建函数样式的宏,以便在代码中执行类似函数的任务。
调试支持
Microsoft Visual C++ 2013 以几种不同的方式支持调试预处理器宏。/E和/P编译选项可用于将编译器的执行限制在预处理器上。/E选项将输出发送到stdout,在那里它可以被重定向到一个文件或通过一个进程,而/P选项将输出直接写入一个文件。
IDE 通过显示带有宏定义的工具提示以及将非活动的条件编译块中的代码变灰来支持宏的使用。您还可以通过转到“类视图”并展开“宏和常量”节点来查看项目中定义的宏的列表。
函数语法
宏的函数语法和你预期的差不多。您没有将标志或标签的值定义为一个固定值,而是将其定义为一个作用于变量或参数的文本操作。没有与这些参数相关的类型检查概念,参数最终是标识符、关键字还是文字取决于宏定义和实现上下文。下面是一个函数式宏,用于计算两个数字的最大值:
#define max(a,b) a>b?a:b
这就是它的作用:
using namespace System;
#define max(a,b) a>b?a:b
void main()
{
Console::WriteLine(max(3,4));
}
当我们试图运行它时,我们得到
C:\>cl /nologo /clr:pure test.cpp
C:\>test
4
不幸的是,这个程序有一个错误。你能看见吗?
假设我们想在打印之前给值加 2,那么我们修改对max()的调用如下:
Console::WriteLine(2+max(3,4));
让我们编译并执行这个:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
3
我们在结果上加了 2,少了一个数。这是怎么回事?我们可以使用 C++ 编译器的/E选项来帮助确定问题。
C:\>cl /nologo /clr:pure /E test.cpp
test.cpp
#line 1 "test.cpp"
using namespace System;
void main()
{
Console::WriteLine(2+3>4?3:4);
}
使用/E选项,很清楚为什么这个程序没有做我们期望的事情。因为在max()宏中的计算没有括号,所以编译器对扩展的计算与我们希望的不同。我们可以通过用括号重新定义宏来解决这个问题,如下所示:
#define max(a,b) ((a)>(b)?(a):(b))
现在,当我们编译并执行时,我们得到如下结果:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
6
这就是我们所期待的。
Note
编译器使用#line指令跟踪行号和文件名。这允许您编译使用/E(预处理到stdout)或/P(预处理到文件)命令行选项预处理的文件的结果,并且仍然获得与直接编译相同的诊断。即使您的主文件使用#include引入了几个其他文件,也是如此。许多公司使用这些标志在预处理器和主编译阶段之间插入自定义通道。
并置算符
C++ 允许你在一个宏中使用##连接操作符来表示两个符号应该被连接起来形成一个新的符号。“你好,世界”节目的一个有趣变体如下:
using namespace System;
#define CONCAT_(x,y) x##y
void CONCAT_(ma, in) ()
{
Console::WriteLine("Hello, World");
}
在这种情况下,CONCAT_(ma, in)展开为main,程序编译。让我们编译并运行它:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
Hello, World
哇,“你好,世界”又来了!
字符串运算符
在宏中使用#前缀运算符将参数转换为字符串,例如:
#define STR_(x) #x
using namespace System;
void main()
{
Console::WriteLine(STR_(Hello));
}
如果我们编译并执行它,我们会看到以下内容:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
Hello
宏上的宏
当宏调用其他宏时,事情开始变得有趣起来。一般来说,宏递归不同于模板或函数递归,因为宏展开不是无限递归的。例如,考虑以下情况:
using namespace System;
#define CONCAT_(x,y) x##y
void main()
{
int i = CONCAT_(1, CONCAT_(2,3));
Console::WriteLine(i);
}
预处理,我们得到
C:\>cl /nologo /clr:pure /E test.cpp
#line 1 "test.cpp"
using namespace System;
void main()
{
int i = 1CONCAT_(2,3);
Console::WriteLine(i);
}
如您所见,没有执行(2,3)的连接。编译器从字面上理解封闭的宏,并将符号“1”连接到符号“CONCAT_(2,3)”,产生了“1CONCAT_(2,3),它不适合进一步的宏扩展。如果我们想要一个连接宏来解决这个问题,我们需要定义第二个宏。
考虑下面的片段:
#define CONCAT_(x,y) x##y
#define CONCAT(x,y) CONCAT_(x,y)
void main()
{
int i = CONCAT(1, CONCAT(2,3));
System::Console::WriteLine(i);
}
预处理,我们得到
C:\>cl /nologo /clr:pure /E test.cpp
#line 1 "test.cpp"
void main()
{
int i = 123;
System::Console::WriteLine(i);
}
为什么这次成功了?为了理解这一点,有必要对预处理器的工作原理有更多的了解。预处理器根据上下文将宏的参数作为潜在的标记或文字进行扫描。只有令牌能够进行进一步的宏扩展。#或##操作符的存在将参数分类为文字。如果没有这些,将扫描参数以进行宏替换。 1
考虑以下宏:
#define CONCAT_(x,y) x##y
预处理器按字面意思处理x和y,因为有了##操作符,所以没有进一步的扩展。另一方面,在
#define CONCAT_(x,y) x##y
#define CONCAT(x,y) CONCAT_(x,y)
宏CONCAT(x,y)解析x和y进行潜在的宏替换,因为第一次遇到x和y时缺少字符串或连接操作符。
这是另一个基础代数的例子。还记得二项式乘法的“先、外、内、后”(或箔)法则吗?
#define FOIL(a,b,c,d) ((a)*(c) + (b)*(c) + (d)*(a) + (b)*(d))
#define STR_(x) #x
#define STR(x) STR_(x)
using namespace System;
void main()
{
Console::WriteLine("(x+1)*(x+2)={0}", STR(FOIL(x,1,x,2)));
}
前面的代码生成
C:\>cl /nologo /clr:pure test.cpp
C:\>test
(x+1)*(x+2)=((x)*(x) + (1)*(x) + (2)*(x) + (1)*(2))
当然,这个结果并不像下面这样好看:
但它同样准确。
特殊预处理器预定义宏
有几个包含在双下划线中的宏是为在您的代码中使用而预定义的:
__LINE__:计算当前行号__FILE__:评估为当前文件名__DATE__:评估到编译日期__TIME__:评估到编译时__FUNCTION__:评估为函数或方法的名称__FUNCSIG__:计算函数或方法的完整签名
它们是不言自明的。后两者只在函数中有效。这里有一个简单的例子,可以帮助您找到代码中异常的来源:
using namespace System;
#define THROWIF(condition) ThrowIf(condition, #condition, __LINE__)
void ThrowIf(bool condition, String^ message, int line)
{
if(condition)
{
String ^s =
"(" + message + ")" + " @ line " + line + "\n"
+ "in " + __FILE__
+ ", build " + __DATE__
+ " " + __TIME__;
throw gcnew Exception(s);
}
}
void main()
{
int x = 1, y = 2;
try
{
THROWIF(x != y);
}
catch(Exception ^e)
{
Console::WriteLine("Exception: {0}\n{1}", e->Message, e->StackTrace);
}
}
编译和执行后,我们得到以下结果:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
Exception: (x != y) @ line 20
in macro.cpp, build Aug 13 2006 23:49:03
at ThrowIf(Boolean condition, String message, Int32 line)
at main()
# 定义
该命令用于定义一个宏,例如:
#define DEBUG 1
#define function(x) (x)
函数样式的宏不能重载。一旦用固定数量的参数定义了一个宏,它就保持这种定义方式,直到它未被定义或者编译单元结束。特殊类型的类似函数的宏,称为变量宏,允许您拥有数量不确定的宏参数。要定义变量宏,请使用省略号(...)作为宏的最后一个形式参数。在使用中,使用__VA_ARGS___替换标识符来访问变量参数列表。下面是一个代码示例:
#include <stdio.h>
#define err_printf(...) fprintf (stderr, __VA_ARGS__)
void main()
{
err_printf("Error number %d\n", 42);
}
编译并运行它,我们得到如下结果:
C:\>cl /nologo test.cpp
C:\>test
Error number 42
# undef
用#undef指令删除一个宏定义。
条件指令
在本节中,我们将了解以下条件编译指令:
#ifdef <macro>
#ifndef <macro>
#if <mathematical argument>
#else
#elsif <mathematical argument>
#endif
这些指令类似于 C# 中的指令。对于数学论证,所有的标准运算符都起作用,包括+、-、*、/、%、&、|、^、&&、|、!、==、!=、<、>、<=、>=、<<和>>。还有一个特殊的操作符叫做defined(),可以让你决定一个宏是否被定义。如果定义了宏,这个defined(macro)就是true。因此,以下两个宏是等效的:
#ifdef MACRO
#if defined(MACRO)
# 包括
#include用于将一个文件插入到当前的编译单元中。该指令有两种形式:
#include "file.h"
#include <file.h>
尖括号版本搜索系统包含目录,包括在/I编译器选项或INCLUDE环境变量中指定的任何目录。双引号版本还会在当前编译器的目录中搜索要包含的文件,以及包含当前文件的任何文件的任何目录。如果有包含列表层次结构,搜索算法将向上搜索。注意,任何文件都可以包含在内,不仅仅是那些扩展名为.h的文件。约定是.h文件只包含宏定义、声明,没有任何定义的实例化。包含定义的包含文件通常以扩展名.hpp命名。
# 使用
C++/CLI #using指令指示编译器在编译过程中必须引用某些程序集。这类似于在 C# 编译器中使用/reference编译器选项所获得的效果。按照以下顺序搜索这些文件:#using指令中的完整路径,编译的当前工作目录(当使用 IDE 部署时,这是包含您的 Visual C++ 项目文件的文件夹),以及。NET 框架系统目录,最后是用/AI编译器选项或在LIBPATH环境变量中添加的任何目录。与#include相反,#using的引号和尖括号形式没有区别。
# 错误
#error指令发出一个立即错误。它可用于在遇到编译时错误时停止编译,例如:
#if _MSC_VER < 1800
#error "Code requires VS2013 or above."
#endif
# 杂注
#pragma指令编码编译器特定的指令。这些指令通常是不可移植的,因为它们属于特定的实现。微软 Visual C++ 中一个更有用的#pragma指令是#pragma warning。此指令允许您在编译器中启用或禁用警告。如果您使用/WX选项编译代码,这会将所有警告视为错误,这一点尤其有用。
在 C++ 中,函数main()被定义为返回int的全局函数。微软的 Visual C++ 编译器也允许你将它声明为void类型的函数,但是这不是标准的 C++。 2 为了帮助转换main()入口点的移植过程,使它们返回void而不是int,编译器允许你跳过从main()返回值,并为你注入一个返回0。对于任何其他函数,如果您忘记返回值,它会生成警告 4716。
考虑这个单行程序:
int hello() {}
编译之后,我们得到
C:\>cl /nologo /clr:pure test.cpp
c:\test.cpp(1) : error C4716: 'hello' : must return a value
虽然4716是一个警告,但它被编译器默认为错误。你还可以用#pragma warning()禁用它。
让我们添加以下指令:
#pragma warning (disable:4716)
现在编译时没有警告或错误。
这里有一个不那么做作的例子:
#pragma warning (disable:4706)
void main()
{
int i=3; int j;
if(j=i)
{
System::Console::WriteLine(j);
}
}
结果呢
C:\>cl /nologo /clr:pure test.cpp
C:\>test
3
在编译时,编译器会告诉你在一个条件表达式中有一个赋值,警告为 4706。通常这意味着您在使用条件运算符==时忘记了额外的=,但在这种情况下,这不是问题。
一些有用的实用程序
正如我们已经讨论过的,以下指令允许您启用或禁用一个或多个警告:
#pragma warning (enable: <n>[,<n>, ...])
#pragma warning (disable: <n>[,<n>, ...])
它们还处理默认为错误的警告,例如警告 4716(前面讨论过)。数字在 4000 范围内的诊断都是警告。
下面的#pragma指令在编译过程中显示一个字符串:
#pragma message("string")
如果要重定向编译器输出,这对于后处理很有用:
#pragma message("Compiling: "__FILE__ " " __DATE__ " " __TIME__)
void main()
{
}
编译后,我们得到以下内容:
C:\>cl /nologo /clr:pure test.cpp
test.cpp
Compiling: test.cpp May 11 2006 20:44:13
关于这段代码需要注意的一件有趣的事情是,使用的字符串没有用符号+连接在一起,因为这段代码使用了一个原生数组char,而不是一个System::String的句柄。在这种情况下,预处理器会将字符串连接成一个文本。前面,我展示了一个在System::String环境中使用预定义宏的例子。这种行为不限于预定义的宏,而是实现的一种特性。C++ 中有两种字符串:char[]和System::String^。我们将在#pragma managed和#pragma unmanaged的上下文中重新审视这一点:
#pragma managed
#pragma unmanaged
这些指令允许您将代码的特定部分指定为非托管或托管,这允许您根据自己的计划将本机 C++ 转换为托管代码。由于名为 IJW 的编译器特性(它能正常工作),许多传统的原生 C++ 能够进行开箱即用的编译管理,它能自动处理到原生代码的转换。例如,您可以将下一个本机 C++ 代码示例编译为托管代码和非托管代码。如果你编译它是托管的,它会通过托管到本机和本机到托管的转换自动转换为本机库函数printf()。
让我们使用/FAsc编译器选项(配置汇编列表)来查看编译器生成的本机代码:
cl /nologo /FAsc test.cpp
我们在test.cod文件中找到了下面的例子(注意,test.cpp的源代码在汇编列表中是内联的):
_main PROC
; 3 : {
00000 55 push ebp
00001 8b ec mov ebp, esp
; 4 : printf("Hello, World\n");
00003 68 00 00 00 00 push OFFSET $SG3669
00008 e8 00 00 00 00 call _printf
0000d 83 c4 04 add esp, 4
; 5 : }
00010 33 c0 xor eax, eax
00012 5d pop ebp
00013 c3 ret 0
_main ENDP
这是为 x86 系列处理器生成的 32 位代码,它在我装有 Intel Inside 的 MacBook Pro 上运行良好。
编译托管代码
cl /nologo /clr:pure /FAsc test.cpp
我们得到以下结果:
; 4 : printf("Hello, World\n");
0000b 7f 00 00 00 00 ldsflda $SG6951
00010 28 00 00 00 00 call ?printf@@$$J0YAHPBDZZ
00015 26 pop
; 5 : }
00016 16 ldc.i.0 0 ; i32 0x0
00017 2a ret
这是 CIL 的同一个节目。它也可以在我的 MacBook Pro 上运行。对printf()的调用已替换为对转换代码的调用。
使用#pragma managed和#pragma unmanaged,我们可以将不会编译managed的代码与托管代码合并,同时为了兼容性或性能,将某些区域保留为native。
让我们考虑下面的程序:
#pragma managed
void Managed()
{
System::String ^s = "Hello " + "world";
System::Console::WriteLine(s);
}
#pragma unmanaged
#include <stdio.h>
void main()
{
Managed();
char t[]="Hello " "world";
printf("%s\n", t);
}
让我们用/clr来编译它。因为有了#pragma指令,你不能用/clr:pure或/clr:safe来编译它。
cl /nologo /FAsc /clr test.cpp
如果您现在检查test.cod文件,您会发现托管代码和本机代码的混合。在托管部分中,"Hello "和"world"通过编译器生成的对String::Concat的调用组合在一起。在本机部分,"Hello "和"world"由编译器自动连接。我们将在第十九章的中,在本地 C++、C++/CLI 和 C# 之间的互操作性的上下文中,重新审视这些#pragma指令的使用。
以下指令实现了 C++ 版本的#region和#endregion:
#pragma region any_name
#pragma endregion [any_comment]
由于#pragma指令会被无法识别它们的编译器忽略,因此避免采用 C# 版本并将它们实现为#pragma指令允许标准 C++ 预处理器对 C++/CLI 代码进行预处理。
以下指令向编译器发出信号,表明该包含文件只应由编译器处理一次:
#pragma once
在本机 C++ 编程中,由于库类声明位于包含文件中,所以类间依赖会导致包含文件依赖。包含文件本身使用#include指令来定义它们需要编译的类,而不是要求程序员通过以正确的顺序包含文件来解开这些依赖关系。在复杂的系统中,几个类可能依赖于单个类,这将导致单个包含文件被多次引入。如果包含文件不是为这种情况设计的,这可能会导致性能下降以及编译错误。
在包含文件的开头添加#pragma once向编译器发出信号,表明每次编译时只应该处理一次包含文件。考虑清单 17-1 和 17-2。
清单 17-1。文件测试. h
#pragma once
#pragma message("compiling " __FILE__)
#include "test.h"
清单 17-2。文件 test.cpp
#include "test.h"
#include "test.h"
void main() {}
在清单 17-1 中,我们不仅多次包含了test.h,还递归地包含了它:
C:\>cl /nologo test.cpp
test.cpp
compiling c:\test.h
如您所见,包含文件的主体只编译一次。
在避免使用#pragma指令的同时实现相同目标的一种方法是使用#define。在这个范例中,当编译包含文件时定义一个标志,如果没有定义标志,只编译包含文件的主体。
例如,考虑以下自保护头文件:
#ifndef TEST_H
#define TEST_H
#pragma message("compiling " __FILE__)
class Class
{
public:
void Method();
};
#endif //TEST_H
这是一个相当常见的结构。
以下#pragma指令允许您从函数的内部(内置)版本和库版本中进行选择,如果您希望创建可作为内部函数生成的函数的自定义实现,这一点尤其重要:
#pragma intrinsic( function1 [, function2, ...] )
#pragma function( function1 [, function2, ...] )
比如memset()(原生)库函数就是编译器固有生成的。
考虑以下示例:
#include <memory.h>
#pragma intrinsic(memset)
int main()
{
char Hello[12];
memset(Hello, 3 ,sizeof(Hello));
return Hello[7];
}
使用命令行选项/Oi对其进行编译,这将启用内部函数,如下所示:
cl /nologo /Oi /FAsc test.cpp
让我们检查一下test.cod文件:
; 5 : char Hello[12];
; 6 : memset(Hello, 3 ,sizeof(Hello));
00010 b8 03 03 03 03 mov eax, 50529027 ; 03030303H
00015 89 45 f0 mov DWORD PTR _Hello$[ebp], eax
00018 89 45 f4 mov DWORD PTR _Hello$[ebp+4], eax
0001b 89 45 f8 mov DWORD PTR _Hello$[ebp+8], eax
在这种情况下,我们可以看到编译器是如何生成memset()的。
将#pragma指令更改如下:
#pragma function(memset)
重新编译给了我们一个不同的test.cod文件:
; 5 : char Hello[12];
; 6 : memset(Hello, 3 ,sizeof(Hello));
00010 6a 0c push 12 ; 0000000cH
00012 6a 03 push 3
00014 8d 45 f0 lea eax, DWORD PTR _Hello$[ebp]
00017 50 push eax
00018 e8 00 00 00 00 call memset
0001d 83 c4 0c add esp, 12 ; 0000000cH
如你所见,memset()的函数版本已经生成。常见的内在函数包括:
绝对值:
#include <math.h>
fabs() //absolute value of a float
abs() //absolute value of an integer
labs() //absolute value of a long
字符串操作:
#include <string.h>
strcmp() //compare two strings
strcpy() //string copy
strlen() //string length
strcat() //string concatenation
记忆操作:
#include <memory.h>
memcmp() //memory comparison
memcpy() //memory copy
memset() //set memory to a value
可以在 MSDN 网站的 Visual C++ 文档中找到可由编译器生成的库函数的更新列表。
以下指令允许您临时重新定义宏:
#pragma push_macro("macro_name")
#pragma pop_macro("macro_name")
在可能有多个模块希望以不同方式使用宏的复杂系统中,重新定义宏非常有用。
下面的#pragma指令将注释放在编译后的文件中;这些注释由链接器或其他程序使用:
#pragma comment( lib, "emapi" )
#pragma comment( compiler )
#pragma comment( user, "Compiled on " __DATE__ " at " __TIME__ )
属性(见第二十章)用于在类或函数级别添加信息。
以下指令与/Gs命令行选项一起使用来启用或禁用堆栈检查:
#pragma check_stack(on|off)
这个#pragma和命令行选项对于调整本机程序中堆和栈分配的平衡很有用。
随着 C++ 的发展,库函数被删除或修改,以鼓励程序员使用更通用、更安全的函数。这些库函数被标记为“已弃用”,使用这些函数会在编译时生成警告:
#pragma deprecated(func1, func2)
__declspec(deprecated) void func1(int) {}
#pragma指令用于废弃整个函数,而__declspec()用于废弃函数的特定重载。__declspec()是特定于编译器的扩展的另一个来源(有关更多信息,请参见 MSDN 上的 Visual C++ 文档)。在 C++/CLI 中,类ObsoleteAttribute表示过时,就像在 C# 中一样。
下面是一个全面的例子:
#pragma unmanaged
void test1()
{
}
void test2(int)
{
}
__declspec(deprecated) void test2(char)
{
}
void func1(int)
{
}
#pragma deprecated(test1)
#pragma managed
using namespace System;
[Obsolete] void test()
{
}
void main()
{
#line 100
test1();
#line 200
test2((char)0);
#line 300
test2((int)0);
#line 400
test();
}
这个程序使用了#line指令来使我们的测试用例容易找到。
编译它,我们得到如下结果:
C:\>cl /nologo /clr test.cpp
test.cpp
test.cpp(100) : warning C4995: 'test1': name was marked as #pragma deprecated
test.cpp(200) : warning C4996: 'test2' was declared deprecated
test.cpp(8) : see declaration of 'test2'
test.cpp(400) : warning C4947: 'test' : marked as obsolete
对test2(int)的重载调用不会生成警告。其余部分根据该函数是被声明为已弃用还是已过时,生成各种诊断信息。
摘要
预处理器是 C++ 最强大的方面之一。使用得当,可能性是无穷无尽的。如果被滥用,它会使您的代码变得难以理解和不可维护。正如尼可罗·马基亚维利所写,“在所有人的行为中。。。当没有公正的仲裁者时,必须考虑最终结果。”
在下一章,我们将更深入地了解原生 C++,看看它与 C++/CLI 和 C# 有什么不同。
Footnotes 1
2003 年 4 月 1 日的 C++ 标准的第 16.3.1 节谈到了参数替换([cpp.subst]),“在用于调用类似函数的宏的参数被识别之后,进行参数替换。替换列表中的一个参数,除非前面有一个#或##预处理标记,或者后面有一个##预处理标记。。。在其中包含的所有宏展开后,由相应的参数替换。在被替换之前,每个参数的预处理标记都被完全宏替换,就好像它们构成了翻译单元的其余部分一样;没有其他预处理标记可用。
2
2003 年 4 月 1 日的 C++ 标准第 3.6.1 节,关于主函数([basic.start.main]),“一个程序应该包含一个名为main的全局函数,它是程序的指定开始。。。它应该有一个类型为int的返回类型,但它的类型是实现定义的。
十八、原生 C++
智者从别人的错误中学习,愚者从自己的错误中学习。—赫伯特·乔治·威尔斯
在这一章中,我们将对原生 C++ 编程的一些特征进行一个调查。我们将看看常见的库函数、模板库和不能在托管类型上工作的 C++ 特性。
iostream 库
在原生 C++ 编程中,最流行的文件输入和输出库之一是iostream库。它是 C++ 标准的一部分。其中,<<和>>操作符在包含文件iostream中被重载,以便向控制台提供简单的输出。它们与用于输出的类型cout和用于输入的类型cin一起使用,并提供类似于操作系统管道操作符的语法。类型endl用于指示行的结束。
下面是一个例子:
#include <iostream>
using namespace std;
int main()
{
int i;
cout << "enter a number" << endl;
cin >>i;
cout << "the number was " << i << endl;
}
编译和运行后,我们得到以下内容:
C:\>cl /nologo /EHsc test.cpp
C:\>test
enter a number
4
the number was 4
因为这是 C++,所以不用说,所有这些都可以在本地重载,例如:
#include <iostream>
using namespace std;
namespace R
{
ostream& endl ( ostream& os )
{
::operator<<(os, " <END> ");
::endl(os);
return os;
}
ostream& operator<< (ostream& os, const char* str )
{
::operator<<(os, " -> ");
::operator<<(os, str);
::operator<<(os, " <- ");
return os;
}
static int Test()
{
cout << "Hello" << endl;
return 0;
}
};
int main()
{
R::Test();
}
同样,在编译和执行之后,我们得到
C:\>cl /nologo /EHsc test.cpp
C:\>test
-> Hello <- <END>
指向成员的指针
在 C++ 中,可以创建一个指针,指向一个没有绑定到类的特定实例的类元素。这些称为指向成员的指针。
指向成员的指针本身并不是真正的指针。相反,它们是类定义中对特定成员的偏移量,当与指向类实例的指针结合时,它们可以被解析为实际成员。指向成员的指针在几个方面变得非常强大。因为不支持指向成员的指针。NET Framework,它们只能与本机类一起使用。
为什么要使用指向成员的指针?
指向成员的指针在类之间切换控制时非常有用,这在使用回调的本机 C++ 程序中非常重要。回调是一种机制,其中函数(称为回调函数)的地址。传递给第二个函数供以后调用。英寸 NET 中,可以使用委托来执行回调;本机 C++ 缺少委托。
语法
指向成员的指针函数的声明方式与常规指针类似,只是星号前面有一个类规范。它们是通过以与静态成员相同的方式获取类成员的地址来分配的——不考虑任何特定的实例。解引用在指向成员的指针函数前使用了一个额外的星号。
也许几个例子会比使用巴克斯-诺尔形式(BNF) 1 更快地阐明:
struct Class
{
int i;
void Function(int i) {}
};
void main()
{
Class c;
Class *pClass = &c;
int Class:: *pInt = &Class::i;
void (Class::*pFunction)(int) = &Class::Function;
c.*pInt = 3;
(c.*pFunction)(3);
pClass->*pInt = 4;
(pClass->*pFunction)(4);
}
在这个例子中,我们有一个带有int字段的类和一个接受int并返回void的成员函数。我们声明了几个指向成员的指针,pInt和pFunction,并初始化它们指向Class的相应成员。
使用一个实例变量c和一个指向Class、pClass的指针,然后我们观察解引用这些变量的语法。
动机
指向成员的指针函数在哪里会派上用场?假设您有一个正在被后台线程使用的任务队列。当执行每个任务时,您希望通知请求该任务的类(生产者)该任务已经完成。不同类型的任务在请求任务中可能有不同的通知入口点。
解决这个问题的一种方法是使用指向成员的指针函数。定义一个通用通知过程的原型签名,并定义几个与请求类一致的通知过程。在任务本身中,您可以声明一个指向请求类实例的 holder,以及一个指向成员的指针函数,用于请求类中适当的通知例程。当任务完成时,它能够使用指向成员函数的指针在请求类中调用适当的通知例程,在下面的示例中,指针指向成员函数:
#include <iostream>
#include <deque>
using namespace std;
enum REQUEST
{
READ, WRITE
};
struct Task;
deque<Task*> t;
struct Requestor
{
void ReadDone(bool success)
{
cout << "Read Done notification" << endl;
}
void WriteDone(bool success)
{
cout << "Write Done notification" << endl;
}
void SetupRequests();
};
struct Task
{
enum REQUEST request;
Requestor *pCallBackInstance;
void (Requestor::*Notify)(bool);
};
void Requestor::SetupRequests()
{
Task *readTask = new Task();
readTask->Notify = &Requestor::ReadDone;
readTask->pCallBackInstance = this;
readTask->request = READ;
t.push_front(readTask);
Task *writeTask = new Task();
writeTask->Notify = &Requestor::WriteDone;
writeTask->pCallBackInstance = this;
writeTask->request = WRITE;
t.push_front(writeTask);
}
int main()
{
Requestor *r = new Requestor();
r->SetupRequests();
while(!t.empty())
{
Task *pTask = t.back();
t.pop_back();
switch(pTask->request)
{
case READ:
cout << "reading " << endl;
break;
case WRITE:
cout << "writing " << endl;
break;
}
((pTask->pCallBackInstance)->*pTask->Notify)(true);
delete pTask;
}
delete r;
}
让我们编译并运行这个:
C:\>cl /nologo /EHsc test.cpp
C:\>test
reading
Read Done notification
writing
Write Done notification
随着文本的深入,例子肯定会变得越来越复杂。在这个例子中,我们定义了一个名为Requestor. Requestor的生产者类,它分配了一个READ任务和一个WRITE任务,每个任务都有一个不同的通知回调。这些任务被推送到一个标准模板库(STL) deque以供使用(参见本章后面关于 STL 的部分)。
然后,我们在主循环中先入先出地处理这些任务,使用指向成员的指针调用适当的通知回调,释放任务的内存(本章后面会详细介绍),然后我们就完成了。
类似于标准指针,指针操作符->*可以通过定义operator->*来重载。另一方面,实例operator.*不是可重载的。
操作员新建和删除
C++/CLI 使用gcnew在托管堆上分配内存,使用new在本机堆上分配内存。所有类型—引用、值和本机—也可以在堆栈上分配。但是引用类型是特殊的。即使它们在语义上表现为在堆栈上分配,它们在物理上仍然包含在托管堆中。因为在本机堆上没有垃圾收集支持,所以确保在不再需要内存时释放内存是程序员的责任。类型的确定性销毁确保在适当的时候销毁和释放堆栈上的类型;在本机堆上分配的内存必须使用delete显式释放,如前面的代码示例所示。使用operator new()和operator delete()可以使new和delete过载。
有两个版本的new和delete操作符:一个版本适用于单个实例,另一个版本适用于数组。
下面是new和delete的一个例子:
#include <iostream>
using namespace std;
static int Count = 0;
struct N
{
int _Count;
N() : _Count(Count++)
{
cout << "constructor of " << _Count << endl;
}
∼N()
{
cout << "destructor of " << _Count << endl;
}
};
void main()
{
N n;
N *pN = new N;
N *pNs = new N[3];
delete pN;
delete [] pNs;
}
编译并运行这个程序后,我们得到
C:\>cl /nologo /EHsc test.cpp
C:\>test
constructor of 0
constructor of 1
constructor of 2
constructor of 3
constructor of 4
destructor of 1
destructor of 4
destructor of 3
destructor of 2
destructor of 0
请注意,N的每个构造实例正好被销毁一次。
混淆删除和删除[ ]
务必确保不要混淆delete操作符的单个版本和数组版本。如果你混淆了它们,你将在你的代码中埋下隐藏的问题。例如,在前面的例子中,不会为所有三个分配的N实例调用析构函数。
假设我们将前面例子中的main()例程改为下面的:
void main()
{
N *pNs = new N[3];
delete pNs;
}
编译和运行这个,我们得到
C:\>cl /nologo /EHsc test.cpp
C:\>test
constructor of 0
constructor of 1
constructor of 2
destructor of 0
现在我们有一个因使用错误版本的delete而导致的内存泄漏。
/Zc:范围
在标准 C++ 中,在for循环的初始化中声明的变量的范围被限制在循环本身的范围内。这个标准行为是通过使用/Zc:forScope编译器选项打开的,该选项在编译中默认使用。通过使用/Zc:forScope-将该变量的范围扩展到包含循环声明的范围,可以放宽该行为,例如:
int main()
{
for(int i=0; i<3; i++)
{
}
i = 3;
}
C:\>cl /nologo /EHsc test.cpp
test.cpp
test.cpp(6) : error C2065: 'i' : undeclared identifier
使用/Zc:forScope-,程序编译无误:
C:\>cl /nologo /EHsc /Zc:forScope- test.cpp
/Zc:wchar_t
该开关控制是否将wchar_t视为 C++ 中的基本类型。提供此开关是为了向后兼容以前版本的 Microsoft Visual C++,在以前版本中,类型是使用typedef在头文件中定义的。
int main()
{
wchar_t c;
}
在 Visual C++ 2013 中,默认情况下会编译前面的代码。使用/Zc:wchar_t-,您将获得以下诊断信息:
C:\>cl /nologo /EHsc /Zc:wchar_t- test.cpp
test.cpp
test.cpp(3) : error C2065: 'wchar_t' : undeclared identifier
test.cpp(3) : error C2146: syntax error : missing ';' before identifier 'c'
test.cpp(3) : error C2065: 'c' : undeclared identifier
默认参数
C++ 允许您为本地类型的方法以及全局函数指定默认参数。要使用默认参数,请在函数声明中将=<value>追加到每个参数中。请注意,默认参数必须从最后一个参数开始(从右向左),必须是连续的,并且不能在函数定义中重复。
下面是一个例子:
#include <iostream>
using namespace std;
int f(int i = 3)
{
return i;
}
int main()
{
cout << f() << endl;
}
编译和运行,我们得到
C:\>cl /nologo /EHsc test.cpp
C\>test
3
CLI 仍然不支持默认参数,因此如果您尝试使用托管类的方法,将会出现编译器错误。尽管如此,这种情况将来可能会改变。同时,您可以使用 paramarrays 获得一个漂亮但不太优雅的解决方法。可以用默认参数构造一个托管方法吗?
C++ 运行时库函数
我不打算尝试覆盖整个 C++ 运行时库。关于这个主题有几十本书,宇宙不需要另一本。相反,我将尝试介绍一些主要模块并概述它们的一些功能,这里的目标是了解一些基础知识。
stdio.h
stdio是“标准输入/输出”的缩写stdio.h是包含主要输入/输出函数原型的包含文件,包括以下内容:
printf:格式化输出到stdout,控制台fprintf:格式化输出到文件sprintf:格式化输出为字符串scanf:来自控制台的格式化输入gets:从控制台获取一个字符串puts:把一根绳子放到控制台上
printf()的参数类似于。NET 方法族为System::Console::Write()和String::Format()。所有这些都接受一个格式字符串,该字符串确定参数在字符串中的位置,后跟参数本身。在哪里?NET 版本从参数本身提取信息,C++ 版本要求您确切地指定它们是什么,以及它们应该如何打印。编译器不会检查格式规范是否与提供的参数兼容,因此您必须小心。
以下是一些函数原型:
int printf(const char *format [,argument...] );
int wprintf(const wchar_t *format [,argument...] );
一些常见的格式规范如下:
%d:整数%c: char%s:字符串%ld:龙%f:浮动%x:十六进制整数
MSDN 上有完整的格式规格列表,以及尺寸和间距参数。
下面是一个简单的例子:
#include <stdio.h>
int main()
{
wchar_t s[20];
printf("%d\n", 3);
swprintf(s, sizeof(s), L"ABC \u00E9\n");
for(int i=0; s[i] ; i++)
{
printf("0x%04x ", s[i]);
}
printf("\n");
return 0;
}
编译并执行它,我们得到如下结果:
C:\>cl /nologo test.cpp
test.cpp
C:\>test
3
0x0041 0x0042 0x0043 0x0020 0x00e9 0x000a
stdlib.h .标准版
这个包含文件包含了内存分配和释放函数的原型,malloc()和free():
void *malloc(size_t size);
void free(void *ptr);
malloc()代表“内存分配”,size_t是 ANSI C 标准化的一种类型,用于定义字符串和内存块的大小。它本质上是现代 32 位架构上的一个int。
对malloc()的每次调用都应该与对free()的相应调用相匹配,否则就会有内存泄漏的风险。下面的本地类版本使用 C++ 的确定性销毁来分配和自动释放内存块:
#include <stdio.h>
#include <stdlib.h>
void main()
{
struct Memory
{
unsigned char *ptr;
Memory(int n)
{
ptr = (unsigned char *)malloc(n);
printf("Allocated %d bytes\n", n);
}
∼Memory()
{
if(ptr != NULL)
{
free(ptr);
printf("Freed memory\n");
}
}
};
Memory m(10);
m.ptr[3] = 0;
}
编译并运行这个程序后,我们得到
C:\>cl /nologo test.cpp
C:\>test
Allocated 10 bytes
Freed memory
其他包含文件
这里还有一些包含文件,对于您的原生 C++ 编程是必不可少的。
string.h
- 功能:
strcpy:复制一个字符串strlen:获取一个字符串的长度
Note
原生 C++ 中的字符串是 8 位char的零分隔数组。宽字符串类似于 C# 字符串,零分隔的数组wchar_t,它在。NET 相当于System::Char。
memory.h
- 功能:
memcpy:复制一大块内存memset:将一块内存设置为一个值
time.h
- 测量:
- 日期
- 一天中的时间
- 时间
math.h
- 测量:
- 三角函数:正割、正切、余弦和正弦
- 圆周率 3.14159
标准模板库
标准模板库(STL)是一组集合类以及作用于它们的方法。该库的天才之处在于它是真正通用的,因为它将对数据集合的操作与对数据元素本身的操作分离开来。
在其他集合类中,它实现了名为vector的数组、名为deque的双端队列和名为list的双向链表。STL 有一个string类型和算法,如sort,用于重新排列元素。
Vectors 在基本数组的概念上构建功能,因此您可以拥有两个世界的优点。
矢量
以下示例使用 STL 向量:
#include <vector>
#include <iostream>
#include <string>
#include <algorithm>
using namespace std;
int main()
{
vector<string> VString;
VString.push_back("String 1");
VString.push_back("String 4");
VString.push_back("String 3");
VString.push_back("String 2");
cout << endl << "In order:" << endl;
for (unsigned int i=0; i < VString.size(); i++)
{
cout << VString[i] << endl;
}
cout << endl << "Sorted:" << endl;
sort(VString.begin(),VString.end());
vector<string>::iterator iter;
for (iter = VString.begin(); iter != VString.end(); ++iter)
{
cout << *iter << endl;
}
cout << endl << "Reversed:" << endl;
vector<string>::reverse_iterator revIter;
for (revIter=VString.rbegin(); revIter!=VString.rend(); ++revIter)
{
cout << *revIter << endl;
}
return 0;
}
在这个例子中,我们取一个字符串向量,向它添加一些值,显示它,排序它,重新显示它,并反向显示它。
在编译和执行之后,我们得到以下内容:
C:\>cl /nologo /EHsc test.cpp
C:\>test
In order:
String 1
String 4
String 3
String 2
Sorted:
String 1
String 2
String 3
String 4
Reversed:
String 4
String 3
String 2
String 1
双端队列
一个deque是双端队列。您可以从队列的两端推送和弹出项目,也可以遍历队列。
下面是使用deque的 Carwash 的原生 C++ 版本:
#include <deque>
#include <iostream>
#include <string>
#include <algorithm>
#include <windows.h>
#include <process.h>
using namespace std;
namespace CarWashBusiness
{
CRITICAL_SECTION IOcs;
class Lock
{
CRITICAL_SECTION *pCS_;
public:
Lock(CRITICAL_SECTION *pCS) : pCS_(pCS)
{
EnterCriticalSection( pCS_ );
}
∼Lock()
{
LeaveCriticalSection( pCS_ );
}
};
struct Car
{
string Name_;
Car(string Name) : Name_(Name)
{
}
};
struct Process
{
bool open;
CRITICAL_SECTION cs;
deque<Car*> Queue;
HANDLE hThread;
unsigned int nThreadId;
static unsigned int WINAPI Proc( void *param )
{
return((Process *)param) -> Run();
}
virtual unsigned int Run() = 0;
Process() : open(false)
{
InitializeCriticalSection(&cs);
}
void Open()
{
open=true;
}
void Close()
{
open=false;
}
size_t Count()
{
Lock l(&cs);
return Queue.size();
}
void AddQueue(Car *pCar)
{
Lock l(&cs);
Queue.push_back(pCar);
}
Car *GetNext()
{
Lock l(&cs);
if (Queue.empty())
{
return NULL;
}
Car *pCar = Queue.front();
Queue.pop_front();
return pCar;
}
void Done(Car *pCar)
{
if (pNextProcess)
{
pNextProcess->AddQueue(pCar);
}
}
Process *pNextProcess;
virtual void DoStage()
{
while (!open)
{
;
}
for (;open;)
{
Car *pCar = GetNext();
if (!pCar)
{
Sleep(30);
continue;
}
Doit(pCar);
Done(pCar);
}
}
virtual void Doit(Car *pCar) = 0;
};
struct Vacuum : Process
{
virtual unsigned int Run()
{
{
Lock io(&IOcs);
cout << "vacuum running" << endl;
}
DoStage();
return 1;
}
virtual void Doit(Car *pCar)
{
Lock io(&IOcs);
cout << "vacuuming " << pCar->Name_ << endl;
Sleep(1000);
cout << "vacuuming done " << pCar->Name_ << endl;
}
};
struct Wash : Process
{
virtual unsigned int Run()
{
{
Lock io(&IOcs);
cout << "wash running" << endl;
}
DoStage();
return 1;
}
virtual void Doit(Car *pCar)
{
Lock io(&IOcs);
cout << "washing: " << pCar->Name_ << endl;
Sleep(1200);
cout << "washing done: " << pCar->Name_ << endl;
}
};
struct Done : Process
{
virtual unsigned int Run()
{
return 1;
}
virtual void Doit(Car *pCar)
{
}
};
struct CarWash
{
size_t Countin;
bool open;
string Name_;
Vacuum v;
Wash w;
Done d;
CarWash(string Name) : Name_(Name), open(false)
{
Countin = 0;
{
Lock io(&IOcs);
cout << Name_ << " Car Wash" << endl;
}
v.pNextProcess = &w;
w.pNextProcess = &d;
d.pNextProcess = NULL;
}
void Open()
{
open = true;
v.Open();
w.Open();
v.hThread=(HANDLE)_beginthreadex
(NULL,0,Process::Proc,&v,0,&v.nThreadId);
w.hThread=(HANDLE)_beginthreadex
(NULL,0,Process::Proc,&w,0,&w.nThreadId);
}
void Close()
{
open = false;
size_t Count;
do
{
Sleep(30);
Count = d.Count();
}
while (d.Count() != Countin);
v.Close();
w.Close();
WaitForSingleObject(v.hThread, INFINITE);
WaitForSingleObject(w.hThread, INFINITE);
}
∼CarWash()
{
}
void Clean(Car *pCar)
{
{
Lock io(&IOcs);
cout << "Cleaning: " << pCar->Name_ << endl;
}
Countin++;
v.AddQueue(pCar);
}
};
}
using namespace CarWashBusiness;
int main()
{
InitializeCriticalSection(&IOcs);
Car Volvo("Volvo");
Car VW("VW");
Car Audi("Audi");
CarWash PicoAndSep("Pico and Sepulveda");
PicoAndSep.Open();
PicoAndSep.Clean(&Volvo);
PicoAndSep.Clean(&VW);
PicoAndSep.Clean(&Audi);
PicoAndSep.Close();
return 0;
}
编译和运行后,我们得到以下结果:
C:\>cl /nologo /EHsc test.cpp
C:\>test
Pico and Sepulveda Car Wash
vacuum running
Cleaning: Volvo
wash running
Cleaning: VW
Cleaning: Audi
vacuuming Volvo
vacuuming done Volvo
vacuuming VW
vacuuming done VW
washing: Volvo
washing done: Volvo
vacuuming Audi
vacuuming done Audi
washing: VW
washing done: VW
washing: Audi
washing done: Audi
这个例子使用deque<T>类来跟踪清洗站和吸尘站的汽车,使用临界区来控制对队列和控制台的访问,并使用线程来进行多任务和流水线操作。简而言之,它做的事情和第十四章中的例子一样,只是以一种本地的方式。
目录
list是一个双向链表,支持双向遍历。
auto_ptr
auto_pr是一种智能指针类型,当指针超出范围时会自动释放内存。当你忘记给delete接线员打电话时,它可以帮助你。
摘要
如果你想了解更多关于原生编程的知识,你应该看看几本关于原生 C++ 的好书。我最喜欢的一些书包括 Stephen Prata 的 C++ Primer Plus (Sams,2001),Stanley Lippman,Josée Lajoie 和 Barbara Moo 的 C++ Primer (Addison-Wesley,2005),比雅尼·斯特劳斯特鲁普的任何 C++ 书籍,以及 Herb Sutter 的 excellent c++ 系列(Addison-Wesley)。我希望这一章已经让你尝到了原生编程的滋味,并启发你继续读下去!
在下一章,我们将更深入地了解多语言集成和互操作性。
Footnotes 1
BNF 是我们都喜欢的语法定义语言。