面向-C--开发者的-C---2013-教程-全-
面向 C# 开发者的 C++ 2013 教程(全)
一、你好,世界
一致性是缺乏想象力的人最后的避难所。—奥斯卡·王尔德
自古以来,可以追溯到 Kernighan 和 Richie 关于 C 的书的发行,有一个传统,即打开一本关于 C 或其后代的书时,用一个简短的例子来说明显示“Hello World”是多么容易。本书也不例外。让我们并排检查一下“Hello World”的 C# 和 C++ 版本(见表 1-1 )。
表 1-1。
“Hello World” in C# and C++
| C# | C++ | | --- | --- | | `using System;` | `using namespace System;` | | `class HelloWorld` | | | `{` | | | `static void Main()` | `void main()` | | `{` | `{` | | `Console.WriteLine(“Hello World”);` | `Console::WriteLine(“Hello World”);` | | `}` | `}` | | `}` | |如表 1-1 所示,语言明显不同。另一方面,C# 和 C++ 就像法语和意大利语;尽管 C++ 的语法看起来像是外来的,但是它的意思是很清楚的。
以下是一些需要注意的事项:
- 在 C# 中,
Main()
总是一个类的方法。在 C++/CLI(公共语言基础设施)中,main()
不是类方法;这是一个全局函数。这很简单——只要记住全局函数没有类。 - 就像在任何 C# 程序中都有一个名为
Main()
的唯一静态成员函数一样,在任何 C++ 程序中都有一个名为main()
的唯一全局函数。在 C# 中,通过将多个Main()
方法嵌入到不同的类中,可以避开这一要求。然后,您可以使用/main:<type>
选项告诉编译器哪个类包含启动方法。这个技巧在标准 C++ 中不起作用,因为main()
必须是一个全局函数,任何版本的main()
在全局名称空间中都有相同的签名和冲突。 - C++ 使用
::
(冒号-冒号)来分隔名称空间和类名,用一个点(.
)来访问类成员;C# 对所有东西都用一个点。C++ 希望你对你正在做的事情更加明确。 - C++/CLI
using
语句需要额外的关键字namespace
。
Note
在 Microsoft Visual C++ 中,入口点可以是任何函数,只要它满足链接器文档中定义的某些限制。它可以是全局函数,也可以是成员函数。通过指定/entry: <function_name>链接器选项可以做到这一点。标准 C++ 需要一个名为 main 的唯一全局函数,它有一个整数返回值和一个可选的参数列表。参见 C++ 标准的第 3.61 节,ISO/IEC 14882:2003(E)。该标准的 PDF 版本可从 webstore 下载。安西。org
收取少量费用。</function_name>
启动 Visual Studio 2013 控制台
我打赌你一定很想试一试。“真正的程序员”使用命令行,所以让我们从那里开始。我们现在要构建一个控制台应用程序。
点击开始,打开 Visual Studio Tools 文件夹,如图 1-1 所示,然后双击 VS2013 的开发者命令提示符。
图 1-1。
Open the Visual Studio Tools folder
这产生了一个新的命令提示符,其环境变量设置为与 Visual Studio 2013 一起工作。所有 Visual Studio 编译器都可以从命令行运行,包括 Visual C++、Visual C# 和 Visual Basic。
检索源文件
要么弹出notepad.exe
(肯定是你最喜欢的编辑器)并开始输入,要么从 Apress 网站的源代码部分获取源代码。去 www.apress.com
,用 ISBN 978-1-4302-6706-5 搜索这本书。
正在执行 HelloCpp.cpp
导航到这个第一章的样本目录,并转到HelloWorld
子目录。这里是HelloCpp.cpp
:
using namespace System;
void main()
{
Console::WriteLine(“Hello World”);
}
输入以下命令:
cl /nologo /clr HelloCpp.cpp
此命令指示 C++ 编译器针对公共语言运行时(CLR)编译此文件,并创建 C++/CLI 可执行文件。可执行文件是包含元数据和公共中间语言(CIL)的托管程序集,就像 C# 可执行文件一样。CIL 在 CLR 上也被称为 MSIL。
让我们执行这个例子。首先,键入
HelloCpp
接下来,按回车键。您应该看到以下内容:
Hello World
这是件好事。
Visual C++ IDE 快速浏览
在本节中,我们将介绍使用 Visual Studio 2013 C++ 集成开发环境(IDE)制作基本 C++/CLI 项目的步骤。这非常类似于创建一个 C# 项目。
Load Visual Studio 2013. From the File menu, select New Project. My system is set up with Visual C++ as the default language, so my New Project dialog box looks like the one shown in Figure 1-2.
图 1-2。
Creating a new HelloWorld project and solutionNavigate to the CLR project types under Visual C++. Select CLR Console Application. Enter HelloWorld in the Name text box. Click OK.
默认情况下,Visual Studio 2013 在 C:\ Users \ % USERNAME % \ Documents \ Visual Studio 2013 \ Projects 中创建新项目。如果您愿意,可以随意更改目录并将项目放在其他地方。单击确定。
了解项目和解决方案
Visual C++ CLR 控制台应用程序向导在也称为 HelloWorld 的解决方案中创建了一个名为 HelloWorld 的新项目。项目和解决方案有什么区别?
Visual Studio 中使用的基本范例是您创建一个解决方案,它是您正在处理的内容的容器。一个解决方案可以由几个项目组成,这些项目可以是类库或可执行文件。每个项目都是特定于语言的,尽管也可以使用定制的构建规则在一个项目中混合使用不同的语言。
在我们的例子中,我们需要一个 Visual C++ 项目来生成一个名为HelloWorld.exe
的可执行文件,所以我们的解决方案只有一个项目。默认情况下,项目是在子目录中创建的,但是我们可以通过取消选择“为解决方案创建目录”来更改此行为。在本书的后面,我们将会有依赖于几个项目的更复杂的解决方案。
现在您应该看到两个平铺的窗口:Solution Explorer 和包含HelloWorld.cpp
的编辑器窗口。看起来 Visual C++ 2013 已经费尽心思为我们编写了这个程序—
现在这不是很好吗?
了解差异
我们的基本 HelloCpp 应用程序和由 Visual Studio C++ CLR 控制台应用程序向导创建的 HelloWorld 应用程序之间存在一些差异,如图 1-3 所示。最明显的区别是向导创建了几个额外的支持文件。
图 1-3。
The HelloWorld application as created by the CLR Console Application Wizard
让我们看看那些新文件。
资源
这些文件为您的应用程序配备了一个漂亮的小图标,并为将来的应用程序开发铺平了道路。Visual C++ 允许您在二进制文件中嵌入资源。它们可以是位图、图标、字符串和其他类型。有关更多信息,请参考 Visual C++ 文档。
resource.h
app.ico
app.rc
预编译头
这些文件通过避免公共代码的多次编译来提高编译速度:
stdafx.h
stdafx.cpp
本书中反复出现的一个主题是 C++ 中声明和定义的区别。与 C# 不同,类原型(称为声明)可以从类定义中分离到不同的文件中。这提高了编译速度,避免了循环依赖,并为复杂项目提供了面向对象的抽象层。在许多 C++ 项目中,通常只包含声明的文件,称为头文件,以扩展名.h
结束,在每个源文件的开头被编译为一个单元。如果项目中的头文件是相同的,编译器最终会用每个源文件编译相同的代码块。Visual C++ 提供的一个优化是在所有其他编译之前,将stdafx.h
文件中引用的头文件全部编译成二进制 PCH(预编译头文件)文件。这称为预编译头文件。只要头文件没有被修改,源文件的后续编译就会大大加快,因为预编译头文件是作为一个单元从磁盘加载的,而不是单独重新编译。Visual C++ 生成了两个文件stdafx.h
和stdafx.cpp
来辅助这种机制。有关更多信息,请参考 Visual C++ 文档。
可以通过更改项目属性来禁用预编译头。要修改项目设置,在解决方案资源管理器中右键单击HelloWorld
项目。导航到配置属性,并单击三角形以展开列表。然后展开 C/C++ 旁边的三角形,并选择预编译头。如图 1-4 所示,属性页窗口出现在屏幕上,允许你在应用程序中配置预编译头文件。
图 1-4。
Configuration of precompiled headers from the Property Pages window
AssemblyInfo.cpp
文件AssemblyInfo.cpp
包含组件的所有属性信息。这个和 C# 出品的AssemblyInfo.cs
差不多。这包括但不限于版权、版本和基本程序集描述信息。默认值对于开发来说很好,但是您需要在发布之前填写一些信息,包括版权属性。图 1-5 显示了一个样本AssemblyInfo.cpp
的摘录。
图 1-5。
An excerpt from AssemblyInfo.cpp
hello world . CPP . hello world . hello world . hello world . hello world . hello world . hello world . hello world
主源文件也有一些显著的不同,如图 1-6 所示:
图 1-6。
HelloWorld.cpp
main
函数被定义为接受一个System::String
的托管数组,相当于 C#Main(string[] Args)
。这允许您访问命令行参数。- 包含预编译头文件
stdafx.h
是为了支持预编译头文件的使用。 - 文字字符串“Hello World”前面加了一个
L
来表示一个宽字符串。在本机 C++ 中,默认情况下,字符串是字节数组。编译 C++/CLI 时,编译器试图通过上下文来区分宽字符串和字节数组。不管你在这个上下文中有没有一个L
,一个宽字符System::String
被创建。
窗口布局
Visual Studio 的一个精心设计的功能是能够通过使用简单的鼠标移动来重新排列窗口,从而自定义 IDE 的外观。在本节中,我们将学习如何停靠和定位窗口。
停靠窗口
解决方案资源管理器自然出现在 Visual Studio 的左侧或右侧,这取决于默认情况下选择的设置。幸运的是,自定义重排既简单又直观。右键点击标题栏,弹出窗口如图 1-7 所示,可以停靠窗口,停靠为选项卡式文档,或者浮动在顶部。
图 1-7。
Right-clicking on the title bar reveals options for displaying the window
现在,当您单击并按住标题栏时,您会在光标悬停的框架中看到一个小指南针,以及每个其他窗口框架上的引用标记。指南针允许您根据您悬停的框架来指示窗口的位置。将窗口移到另一个框架上,指南针会跳到那个框架。
图 1-8。
Clicking and holding down the title bar reveals a compass
指南针的中心
指南针本身有方向标签(北、南、东、西)和一个中心框。如果在中心框上释放鼠标,窗口将在当前框架内变成选项卡式窗口。将它放在主框架上,在这里编辑文档。您现在可以看到,它与其他主窗口共享一个框架。
当您将鼠标悬停在其中一个指南针方向选项卡上时,目标框架的相应部分会变灰,以便您可以预览新的窗口排列。如果你把窗口放到了错误的地方,你总是可以把它撕下来或者手动把它设置为可停靠或者浮动,这取决于它的状态。
玩玩这个。在图 1-9 中,您可以在主窗口中看到作为选项卡式文档的解决方案窗口。
图 1-9。
Solution Explorer as a tabbed document in the main frame
构建、执行和调试
让我们在构建和测试 HelloWorld 时快速浏览一些关键的 Visual C++ IDE 命令(见表 1-2 )。
表 1-2。
Common IDE Commands Quick Reference
| C# | C++ | 说明 | | --- | --- | --- | | 第三子代 | 第三子代 | 查找下一个 | | F8 | 法乐四联症 | 转到源代码中的下一个编译错误 | | 移位-F8 | 移位-F4 | 转到源代码中的上一个编译错误 | | F5 | F5 | 调试时执行 | | Ctrl-F5 | Ctrl-F5 | 不调试就执行 | | F6 | F7 | 建设 | | F9 | F9 | 切换断点 | | F10 | F10 | 跨过 | | F11 | F11 | 进入 |构建程序
根据我们的键绑定,我们可以使用 F6 或 F7 来构建。如果有任何错误,它们会出现在屏幕底部的输出窗口中,您可以使用 F8 或 F4 来循环显示它们。
在 C++ 中,就像在 C# 中一样,多个编译错误经常是乱真的;编译器尝试在第一个检测到的问题之后进行编译,可能会丢失。这通常允许您看到两三个错误,并在一次编辑过程中修复它们。通常,额外的错误是编译器基于不正确的语法出去吃午饭的产物,修复一两个错误可能会使其余的错误消失。我建议经常建设。
执行 HelloWorld
F5 键是执行命令。因为这是一个控制台应用程序,所以执行会产生一个显示“Hello World”的命令窗口,然后很快关闭,这有点不令人满意。有几种方法可以解决这个问题。一种方法是创建另一个开发人员命令提示符,导航到创建可执行文件的调试目录,手动运行程序,就像我们前面所做的那样。另一种方法是将下面的调用添加到main()
函数的末尾:
Console::ReadLine()
该方法要求用户输入一行内容,并保持控制台窗口打开,直到用户按下 Enter 键。
另一组解决方案通过利用内置的 Visual C++ 调试器而呈现出来。您可以使用 F9 命令在程序的最后一行设置断点,也可以一行一行地单步执行程序。无论哪种方式,您都可以切换到衍生的命令提示符来查看所需的输出。
让我们试着使用调试器。
使用 Visual C++ 2013 调试器
调试器集成在 Visual Studio 2013 中,因此启动调试非常简单。输入任何调试命令都会在调试器下启动应用程序。窗口布局肯定会改变,因为默认情况下,有几个状态窗口只有在调试时才可见。
Note
编辑和调试有不同的窗口配置。每个配置都必须单独定制。
基本的调试命令是 F5(带调试执行)、F9(切换断点)、F10(单步执行源代码行)和 F11(单步执行源代码行)。
单步执行代码
Step 命令执行程序中的一行代码。“单步执行”命令有两种:F10(单步执行)和 F11(单步执行)。这些是相似的,但是当应用于函数调用时,它们是不同的。F10 执行到函数调用后的一行,而 F11 在函数体的第一行停止执行。当然,使用 F11 总是取决于调试信息是否可用于该函数所来自的二进制文件。因为Console::WriteLine()
的调试信息没有随 Visual C++ 2013 一起发布,所以 F10 和 F11 都跳过了该函数。
按 F10 开始用 Visual C++ 2013 调试 HelloWorld。标题栏更改为显示“HelloWorld(调试)”以指示调试模式。此外,在单独的窗口中会产生一个命令窗口。此时,它是空白的,因为 HelloWorld 尚未显示任何信息。
编辑器窗口的左边缘会出现一个黄色小箭头,指示当前正在执行的代码行。图 1-10 显示执行已经停止,调试器等待下一个命令。
图 1-10。
Debugging HelloWorld
箭头表示我们开始执行main()
函数,下一个要执行的行包含Console::WriteLine()
语句。
再次按下 F10。执行Console::WriteLine()
函数调用,并且“Hello World”出现在单独的命令窗口中。
如果你敢多按几次 F10,你就会在屏幕上制造一场噩梦。第一次,你执行返回函数。下一次,您从 HelloWorld 代码返回到 C/C++ 运行时,或 CRT。此模块执行重要的任务,包括在 Windows 中初始化程序、打包程序的命令行参数以及处理程序退出 Windows。注意,这段代码通过名字显式地调用main()
,这解释了为什么每个 C++ 程序都需要一个名为main()
的全局函数。
完成执行
按一次 F5 执行退出代码的剩余部分,并返回到编辑器。如果HelloWorld.cpp
不可见,您可以点击选项卡再次显示信号源。此时,调试已经完成,标题栏不再显示调试。
摘要
本章向您提供了如何从控制台创建简单的 C++/CLI 应用程序以及如何使用 IDE 创建更复杂的应用程序的基本概述。我还向您展示了如何使用集成调试器在 Visual C++ 2013 中执行基本调试。
在下一章,我们将看到如何从一个简单的 C++ 程序中调用 C#。
二、没有什么地方比得上家
我没有停止恐惧,但我不再让恐惧控制我。我已经接受了恐惧是生活的一部分,特别是对变化的恐惧,对未知的恐惧,尽管心里怦怦直跳,说着:回头,回头;如果你走得太远,你会死的。—埃里卡·琼
在这一章中,我们将介绍 C++ 的互操作性特性,并向您展示一种结合 C# 和 C++ 的快速方法。我们首先用 C# 开发一个洗牌类。接下来,我们添加一个使用 C# 类的 C++ 存根。在第四章中,我们更进一步,将整个应用程序迁移到 C++。我们将在第十九章中更详细地讨论语言集成和互操作性。
开发程序
假设你有一个非常好的 C# 类,你想把它和你的 C++ 代码一起使用。如果不得不抛弃这一切并用 C++ 重写,那就太可惜了,不是吗?
当我在开发。NET Reflector add-in for C++/CLI 时,我发现自己正处于这种情况。在我的开发过程中。NET Reflector,正处于改进反射器接口的过程中,结果删除了我需要的一个类。为了帮我,他给我发了一个 C# 文件,里面有被删除的代码。我没有被迫用 C++ 重写他的代码,而是在我的项目中添加了对他的类的引用,然后回去继续编写插件。
给我发牌
似乎面试的问题总是相关的,不管你在这个行业已经多少年了。它们可以发人深省并富有娱乐性。我最喜欢的一个游戏,洗牌,应该是有教育意义的。
从表面上看,这似乎是一个简单的问题,但是在您开始编码之前,有几种方法会让您陷入困境。
过程
面试开始出错的第一次是当你在洗牌之前试图找出如何表现这副牌的时候。噩梦会像这样展开:
- 你:套牌是什么样子的?
- 面试官:随机的。
- 你:我如何表示随机输入?你会给我一个输入状态的卡片列表吗?
让我说,在这一点上,面试官会退到洞穴里,以某种形式重复这个问题:
- 记者:给你一副任意的牌,你需要洗一副牌。我就说这么多。
他是这么说的,但他想的是“不雇佣”你需要在这里停下来想一想目标。目标是产生一副完全随机的洗牌牌。开始时牌的顺序并不重要。你可以选择任何你喜欢的顺序。
列举卡片
面试中的下一个障碍是通过四种不同的花色来表现王牌中的王牌。有一个更简单的方法:用一个从1
到52
的数字来标识每张牌。如果卡片从0
到51
编号,那么用 C++ 和 C# 编程就更容易了,因为在这些语言中数组是零索引的。
给花色分配一个任意的顺序,例如 0 到 3 之间的一个数。Bridge 采用字母顺序,为什么不效仿呢?
namespace CSharp
{
class Deck
{
enum Suit
{
Clubs = 0, Diamonds, Hearts, Spades
}
}
}
你可以对卡片本身使用同样的技巧:
namespace CSharp
{
class Deck
{
enum Card
{
Ace=0, Deuce, Trey, Four, Five, Six, Seven,
Eight, Nine, Ten, Jack, Queen, King
}
}
}
因此,我们有两种类型的信息来分别表示:0
和12
之间的Card
号,以及0
和3
之间的Suit
号。这个问题的一个常见解决方案是使用以下公式将它们映射到一个数字:
Number = Suit*13+Card
由于Card
小于13
,很明显(int)(Card/13) ==0
,所以两边除以 13 得到Suit
,余数为Card
。因此,我们已经导出了用于逆变换的以下方程:
Suit = Number/13
Card = Number%13
Number
在Card
和Suit
都为0
时达到最小值,在Card=12
和Suit=3
时达到最大值。
min(Number) = 0 * 13 + 0 = 0
max(Number) = 3 * 13 + 12 = 51
因此,我们将任意一张卡片(Suit
,Card
)映射到 0 到 51 之间的唯一数字。实际上,这个问题可以归结为 0 到 51 之间的随机数的随机化问题。你可能会认为这是一件容易的事情,但事实证明这并不简单,而且很容易出错。鉴于在线赌博的激增,这尤其令人不安。
Note
这里有一个诱人的算法,只是不工作。将卡片放在一个数组中,遍历它们,用随机位置的一张卡片交换每张卡片。事实上,这确实非常壮观地混淆了牌,但是它有利于某些牌的顺序并产生不均匀的分布。你能看出为什么吗?
每一次交换都有 52 分之一的机会与自己交换——一次微不足道的交换。你可能会想,如果洗牌的结果是一副未洗牌的牌,比如说,{0 1 2 3 4… .
51},那么一定有偶数的非平凡交换。现在这副牌{2 1 3 4… .
51}需要奇数个非平凡交换。这应该是一个危险信号,因为我们的算法总是精确地执行 52 次交换,这是偶数,所以这两副牌以相等的可能性生成似乎是可疑的。
洗牌算法
一个声音算法模仿你发牌时的动作。首先,你从 52 张牌中随机抽取一张,然后从剩下的 51 张中抽取一张,以此类推。在这个算法中,你得到一个均匀的分布,直到随机数发生器的随机性:
namespace CSharp
{
class Deck
{
void Shuffle()
{
for (uint u = 52; u > 0; --u)
{
Swap(ref Cards[u - 1], ref Cards[RandomCard(u)]);
}
}
}
}
完整的 C# 程序
这个实现将一副牌洗牌,并“分发”出前五张牌供观看。我们可以断定这个游戏的名字是五牌梭哈。
using System;
namespace CSharp
{
public class Deck
{
uint[] Cards;
Random randomGenerator;
public enum Suit
{
Clubs = 0, Diamonds, Hearts, Spades
}
public enum Card
{
Ace = 0, Deuce, Trey, Four, Five, Six, Seven,
Eight, Nine, Ten, Jack, Queen, King
}
Deck()
{
randomGenerator = new Random();
Cards = new uint[52];
for (uint u = 0; u < 52; ++u)
{
Cards[u] = u;
}
}
void Swap(ref uint u, ref uint v)
{
uint tmp;
tmp = u;
u = v;
v = tmp;
}
void Shuffle()
{
for (uint u = 52; u > 0; --u)
{
Swap(ref Cards[u - 1], ref Cards[RandomCard(u)]);
}
}
uint RandomCard(uint Max)
{
return (uint)((double)Max * randomGenerator.NextDouble());
}
string CardToString(uint u)
{
Suit s = (Suit)(Cards[u] / 13);
Card c = (Card)(Cards[u] % 13);
return c.ToString() + " of " + s.ToString();
}
public static void Main()
{
Deck deck = new Deck();
deck.Shuffle();
for (uint u = 0; u < 5; ++u)
{
Console.WriteLine(deck.CardToString(u));
}
Console.ReadLine();
}
}
}
快速浏览一下代码
如同在每个 C# 应用程序中一样,代码以static Main()
开始。在那里,我们创建一个新的Deck
,在上面调用Shuffle()
,然后显示前五张卡。因为WriteLine()
不熟悉如何打印卡片,我们创建了一个将卡片转换成字符串的函数,然后用它的结果调用WriteLine()
。函数CardToString(uint cardnumber)
完成了这个任务。
项目和解决方案
首先让我们创建一个简单的 C# shuffle 项目。这个 C# 项目没有什么特别独特的地方。要创建它,请选择文件➤新➤项目。浏览新的项目树视图,创建一个名为 Shuffle 的 Visual C# 控制台应用程序。如果你的系统设置和我的一样,控制台应用程序会出现如图 2-1 所示。
图 2-1。
The C# Shuffle console application
C# 和 C++ 编译器都将元数据打包成模块和程序集。模块是程序集的构建块。程序集由一个或多个模块组成,是部署单元。程序集被部署为可执行文件或类库。在第一个版本中,Shuffle 项目是一个独立的可执行文件。在本章的后面,我们将把这个可执行文件变成一个类库,而不需要修改任何一行 C# 代码。
快速浏览
选择编辑➤概述➤折叠到定义。这给了你一个代码的鸟瞰图,如图 2-2 所示。
图 2-2。
A bird’s-eye view of the code
将光标放在任何包含省略号的框上都会弹出一个窗口,显示代码的折叠部分。
构建和执行项目
选择“生成➤生成解决方案”来生成项目。对于 Visual C++ 键绑定,这是 F7 键。对于 Visual C# 键绑定,这是 F6 键。在任一情况下,您都可以用 F5 键执行它。
您会看到类似如下的输出—您的手牌可能会有所不同:
Ten of Diamonds
Deuce of Clubs
Trey of Clubs
Jack of Hearts
Deuce of Spades
由于调用了Console.ReadLine()
,命令窗口现在暂停,等待您按回车键。
嗯。一对 2——还不错,但还没好到可以打开。
绑定 C++
现在我们要从 C++ 中调用这个 C# 类。我们将利用 C++/CLI 程序以名为main()
的全局函数开始的事实,而 C# 程序以名为Main()
的静态函数开始。因为这些名字是截然不同的,所以它们并不冲突,我们可以将它们无缝地绑定在一起。
创建 C++ 项目
首先,我们将 C# 程序与 C++/CLI 合并。要创建一个 C++ 项目,选择文件➤添加➤新项目。在模板下,依次选择 Visual C++、CLR 和 CLR 控制台应用程序。将项目命名为 CardsCpp,从解决方案下拉列表中选择添加到解决方案,如图 2-3 所示。然后单击确定。
Note
您也可以使用解决方案资源管理器中的“添加项目”。这样,您就不会冒意外创建新解决方案的风险。
图 2-3。
Creating the C++/CLI project
设置启动项目和项目依赖项
您应该有一个名为 CardsCpp 的新项目。在解决方案资源管理器中按照下列步骤操作:
Right-click the CardsCpp project, and select Build Dependencies ➤ Project Dependencies. Check the box so that CardsCpp depends on Shuffle. This ensures that the C# project Shuffle is built before the C++ project CardsCpp. We want a dependency in this direction, because we will bring in the completed C# project as a class library DLL and the C++ project will be the master project. See Figure 2-4.
图 2-4。
Project Dependencies dialog boxRight-click the CardsCpp project again, and select Set as Startup Project.
使 C# 项目成为类库
现在,我们将变一点魔法,修改 C# 应用程序,以便它可以作为类库被 C++ 应用程序引用。在解决方案资源管理器中右击 Shuffle,然后选择 Properties。在应用选项卡中,将输出类型改为类库,如图 2-5 所示。
图 2-5。
Convert the C# project to a class library
添加对 C# 类库的引用
右键单击 CardsCpp 项目,并选择“添加➤引用”。然后单击“添加新引用”按钮。单击“项目”选项卡;洗牌项目应该已经被选中,如图 2-6 所示。单击 OK 向 C++ 项目添加对 Shuffle 的引用。
图 2-6。
Add a reference to the C# project
创建 C++/CLI 存根
对 C++ 源文件CardsCpp.cpp
有一个小的改动。替换以下行:
Console::WriteLine(L"Hello World");
随着
CSharp::Deck::Main();
请注意,当您键入时,Visual C++ IntelliSense 会弹出一个窗口来帮助您。就像 C# IntelliSense 一样,它是一个上下文敏感的代码引擎,可以帮助您在键入时发现类成员和参数信息。如图 2-7 所示,智能感知揭示了CSharp::Deck
类的方法和字段。它们是什么以及如何访问它们由名称左侧的小图标决定。较小的框添加了关于所选项的更多信息,以及 XML 文档注释(如果有的话)。
图 2-7。
IntelliSense helps you code
您的代码现在应该如图 2-8 所示,准备好使用 F5 执行。
图 2-8。
The finished C++/CLI stub
在没有 IDE 的情况下进行洗牌
在没有 IDE 的情况下,组合 C++ 和 C# 程序也很容易,尽管它不容易扩展到大型项目。IDE 为您提供了强大的处理能力,但也增加了一层复杂性。使用 IDE,您可以获得以下内容:
- 使用智能感知和浏览功能编辑帮助和代码信息
- 项目管理
- 构建管理
- 集成调试
基本命令行编译
因为这是一个小而简单的项目,所以我们不需要通过完整的 IDE 设置来展示我们的演示。
使用以下去掉预编译头文件的基本 C++ 程序。在与Program.cs:
相同的目录中创建一个名为cardscpp1.cpp
的文件
#using "shuffle.dll"
void main()
{
CSharp::Deck::Main();
}
打开 Visual Studio 2013 命令提示符并导航到此目录。编译并执行该程序,如下所示:
csc /target:library /out:shuffle.dll program.cs
cl /clr cardscpp1.cpp
cardscpp1
King of Diamonds
Trey of Clubs
Jack of Hearts
Deuce of Diamonds
Four of Hearts
看来这次我们该弃牌了!
使用模块
模块是比 DLL 更小的编译单元。使用模块,可以将几个模块组合成一个 DLL。下面是一个使用模块而不是 DLL 的例子。在这种情况下,使用模块和 DLL 没有什么区别。
在与shuffle.cs
相同的目录下创建一个名为cardscpp2.cpp
的文件:
#using "shuffle.netmodule"
void main()
{
CSharp::Deck::Main();
}
将 C# 编译成一个模块,使用 C++ 制作一个可执行文件,并运行它:
csc /target:module /out:shuffle.netmodule program.cs
cl /clr cardscpp2.cpp
cardscpp2
King of Clubs
Queen of Diamonds
Queen of Spades
Ten of Spades
Ace of Clubs
这是一手好牌!
摘要
在这一章中,我们开发了一个简单的 C# 程序。首先,我们从 IDE 中编译并独立运行它。然后,我们将它的输出类型更改为库,以便创建一个供 C++ 可执行文件使用的 DLL,既可以从 IDE 也可以从命令行使用。最后,我们给出了一个使用模块的例子。这应该给你一个很好的介绍,让你知道在. NET 下使用 C# 和 C++ 的各种方法。在第十九章的中,我们将重温这些主题,并讨论与本地代码的互操作性。但是我们不要想得太多;首先要涵盖许多基础知识,我们将在下一章探讨语法差异。
三、语法
纯粹而简单的真理很少是纯粹的,也从不简单。—奥斯卡·王尔德
前面的章节强调了 C# 和 C++/CLI 之间的相似之处。现在我们触及它们不同的主要领域,并开始理解为什么。这些包括附加的或不同的关键字、分隔符和运算符。
关键词和分隔符
在 C++ 中,当using
是一个名称空间时,需要额外的关键字namespace
(见表 3-1 )。
表 3-1。
Namespaces in C# and C++/CLI
| C# | C++/CLI | | --- | --- | | `using System.Threading;` | `using namespace System::Threading;` | | `System.Console.WriteLine("H");` | `System::Console::WriteLine("H");` |此外,在 C# 使用点作为通用分隔符的情况下,C++ 根据上下文以及被分隔项的含义使用几种不同的分隔符。这些分隔符中最常见的是冒号-冒号(::
)和点(.
)。冒号-冒号分隔符或范围解析运算符用于用命名空间、类、属性和事件限定标识符,以及访问静态字段和方法。在这两种语言中,点分隔符或成员访问运算符都用于访问类实例的成员。
C++ 的范例(不同上下文中的不同分隔符)和 C# 的范例(所有上下文中的单个分隔符)与每种语言的总体设计理念是一致的。C# 喜欢简单,而 C++ 需要更深层次的特异性来换取更大的灵活性。
表 3-2 显示了 C# 和 C++ 之间的分隔符差异。随着本书的进展,我将详细介绍所有这些分隔符。
表 3-2。
Separators in C++
| 分离器 | 名字 | 意义 | | --- | --- | --- | | `::` | 结肠-结肠 | 作用域解析操作符,当`::`左边的表达式是名称空间、类、属性或事件名称,而`::`右边的表达式是名称空间、类名或类的静态成员时使用。如果没有左表达式,右边的表达式就是一个全局变量。 | | `.` | 点 | 类成员访问运算符,当箭头左侧的表达式是类对象时使用 | | `->` | 箭 | 类成员访问运算符,当箭头左侧的表达式是指向类对象的指针或句柄时使用 | | `.*` | 圆点星 | 指向成员运算符的指针,当箭头左边的表达式是类对象,而箭头右边的表达式是指向同一类成员的指针时使用 | | `->*` | 箭头星 | 指向成员运算符的指针,当箭头左边的表达式是指向类对象的指针,而箭头右边的表达式是指向同一类成员的指针时使用 |C# 和 C++ 对类和结构的定义是不同的。除了一个明显的语法差异——c++ 要求在类型定义后面有一个分号——还有显著的语义差异。参见表 3-3 中比较 C# 和 C++ 中的类和结构的示例。
表 3-3。
Classes and Structures in C# and C++/CLI
| C# | C++/CLI | | --- | --- | | `class R {}` | `ref class R {};` | | 不适用的 | `ref struct R {};` | | `struct V {}` | `value class V {};` | | 不适用的 | `value struct V {};` | | `enum E {}` | `enum class E {};` | | 不适用的 | `enum struct E {};` | | 不适用的 | `class C {};` | | 不适用的 | `struct C{};` |在 C# 中,类和结构是实现 CLI 定义的引用类型和值类型的工具。在 C++ 中,类和结构定义了一个类型——一般来说,是字段、方法和子类型的相关集合。
C++/CLI 引入了两个类修饰符,ref
和value
,它们提供了一种在 C++ 中表示 CLI 类类型的方法。它们与class
或struct
关键字一起,由空格分隔,如在ref class
中,它们形成一个新的关键字,恰当地称为空格关键字。
引用类型和值类型在中非常重要。NET 编程,在我们继续之前,最好先回顾一下这些类型。引用类型和值类型之间有许多实际差异,但主要差异与它们的分配方式有关。引用类型分为两部分。引用类型的数据在托管堆上分配,而该数据的单独句柄在堆栈上分配。值类型是在堆栈上自动分配的。
A C# class
是引用类型;一个 C# string
也是如此。A C# struct
和大多数 C# 内置类型,包括int
和char
,都是值类型。引用类型中包含的值类型(通过装箱显式或隐式地)成为引用类型的元素,并在托管堆上分配。
C# 类(引用类型)
假设你有一个名为Hello
的 C# class
。使用分配实例
Hello h = new Hello();
从语法上看,似乎您已经创建了一个类型为Hello
的统一实体。在幕后还有更多的事情在进行,因为数据是在堆栈和托管堆上分配的。在托管堆上分配了一个Hello
对象的实例,这个实例的句柄存储在堆栈的变量h
中。
C# 结构(值类型)
如果Hello
被定义为 C#,那么就会发生完全不同的操作。Hello
的整个实例在堆栈上分配,h
代表这个对象的实例。
警告
当你给引用类型赋值时,引用类型在栈和堆之间划分的事实会产生一些有趣的和有些不直观的结果。将一种值类型分配给另一种值类型时,会将与该类型的一个实例关联的数据复制到另一个实例。将一个引用类型分配给另一个引用类型时,会用另一个实例的句柄覆盖一个实例的句柄。实例本身保持不变。
考虑以下 C# 代码:
class Hello
{
int i;
Hello(int number)
{
i=number;
}
static void Main()
{
Hello h = new Hello(1);
Hello j = new Hello(2);
j = h;
System.Console.WriteLine(j.i);
h.i = 3;
System.Console.WriteLine(j.i);
}
}
编译并运行这段代码后,我们得到
C:\>csc /nologo test.cs
C:\>test
1
3
在这个程序中,我们在托管堆上分配了两个类型为Hello
的对象。这些类的句柄h
和j
被分配在堆栈上。我们用h
和孤儿Hello(2)
中的句柄覆盖j
中的句柄。Hello(2)可以被垃圾收集器回收。h
和j
现在都引用了Hello(1)
对象,使用h
或j
访问成员字段i
没有区别。
换句话说,因为Hello
是引用类型,h
和j
是指向托管堆上数据的句柄。当赋值j=h
发生时,h
和j
都指向相同的数据。将3
赋给h.i
也会影响j.i
,显示j.i
会导致数字3
。
对比
另一方面,如果Hello
是值类型,您会看到不同的结果。将Hello
的申报从class
变更为struct
:
struct Hello
{ /**/ }
编译和执行程序后,我们看到
C:\>csc /nologo test.cs
C:\>test
1
1
这次的结果不同,因为我们的对象都被分配到堆栈上,并且相互覆盖。
缺乏局部性
方法Main()
的局部检查不足以确定程序的结果。你不能通过查看周围的代码来确定WriteLine
将会产生什么结果。C# 要求你参考Hello
的定义,发现Hello
是class
还是struct
。
缺乏局部性是危险的,并且违背了 C++/CLI 的设计理念。在 C++/CLI 中,引用类型和值类型之间的区别要明显得多。程序员更精确地指定他或她想要做什么,这避免了混淆,并最终使代码更易于维护。代价是语法稍微难一点。
C++ 方法
在 C++/CLI 中,通常使用句柄标点符号^
来标记句柄。它也被称为跟踪句柄,因为它指向一个在垃圾收集期间可能被移动的对象。
将前面的代码翻译成 C++/CLI,我们实现了以下内容:
private ref class Hello
{
private:
int i;
Hello(int number)
{
i=number;
}
public:
static void Main()
{
Hello ^h = gcnew Hello(1);
Hello ^j = gcnew Hello(2);
j = h;
System::Console::WriteLine(j->i);
h->i = 3;
System::Console::WriteLine(j->i);
}
};
void main()
{
Hello::Main();
}
编译和执行之后,我们得到
C:\>cl /nologo /clr:pure test.cpp
C:\>test
1
3
与 C# 版本有一些明显的语法差异。然而,我想先指出一个语义上的区别。在 C++/CLI 中,通过将空白关键字ref class
更改为value class
,将Hello
从引用类型更改为值类型,不会在编译和执行上产生不同的结果。
将类型从引用类型更改为值类型会影响类型的分配位置,但这不会改变在前面的代码片段中我们将数据视为引用数据的事实。如果Hello
变成值类型,那么编译器生成不同的 IL,这样h
和j
仍然是托管堆上数据的句柄,结果是一致的。在幕后,值类型是封闭的——我们将在第六章的中再次讨论。
成员访问运算符的类型
C++ 代码片段和 C# 代码片段的另一个重要区别是 C++ 句柄使用不同的类成员访问操作符。语法类似于 C++ 中的指针,因为句柄可以被认为是一种特殊的指针。如果您正在使用指向某个对象的句柄或指针,您可以使用箭头成员访问运算符(->
)来访问该对象的成员。如果您正在处理对象本身的实例,您可以使用点成员访问操作符(.
)。虽然有两种不同类型的成员访问操作符看起来更复杂,但一个好处是像我们前面的例子这样的代码总是做您期望它做的事情,因为您被迫在编写时注意您正在做的事情——这是一件好事。
关键词差异
在这一节中,我们将讨论 C# 和 C++ 之间的关键字差异。这些差异中的大部分是由于 C++ 语言的发展以及添加到 C++ 语法中的兼容性和歧义消除限制。
让我们从关键字foreach
开始,如表 3-4 所示。
表 3-4。
foreach in C# and for each in C++/CLI
| C# | C++/CLI | | --- | --- | | `foreach` | `for each` |在 C++/CLI 中,关键字for each
有一个空格,用法与 C# 中的foreach
略有不同。转换后的代码出现在表 3-5 中。
表 3-5。
Examples of foreach in C# and for each in C++/CLI
| C# | C++/CLI | | --- | --- | | `using System;` | `using namespace System;` | | `using System.Collections;` | `using namespace System::Collections;` | | `class R` | `ref class R` | | `{` | `{` | | | `public:` | | `static void Main()` | `static void Main()` | | `{` | `{` | | `ArrayList list = new ArrayList(0);` | `ArrayList ^list = gcnew ArrayList(0);` | | `list.Add("hello");` | `list->Add("hello");` | | `list.Add("world");` | `list->Add("world");` | | `foreach (Object o in list)` | `for each (Object ^o in list)` | | `{` | `{` | | `Console.WriteLine(o);` | `Console::WriteLine(o);` | | `}` | `}` | | `}` | `}` | | `}` | `};` | | | `void main()` | | | `{` | | | `R::Main();` | | | `}` |回顾
让我们回顾一下到目前为止您所看到的内容。C# 和 C++/CLI 之间的差异包括:
- 使用了附加关键字
namespace
。 - 名称空间由冒号-冒号(
::
)而不是点(.
)分隔。 - 用
ref class
代替class
。 - 标点符号
^
用于声明句柄。 - 箭头(
->
)用作句柄成员访问操作符,而不是点(.
)。 for each
包含一个空格。- 类定义以分号(
;
)结束。 - C++/CLI 用名为
main()
的全局函数开始程序。
现在让我们继续;可以看到 C++/CLI 在表 3-6 中使用了关键字nullptr
而不是null
。
表 3-6。
null and nullptr
| C# | C++/CLI | | --- | --- | | `null` | `nullptr` |这些关键字的使用如表 3-7 所示。
表 3-7。
Usage of null and nullptr
| C# | C++/CLI | | --- | --- | | `class R` | `ref class R` | | `{` | `{` | | `static void Main()` | `static void Main()` | | `{` | `{` | | `R r = null;` | `R ^r = nullptr;` | | `}` | `}` | | `}` | `};` |C# 和 C++ 中的switch
和goto
有明显的区别,如表 3-8 所介绍。
表 3-8。
switch, case, and goto in C# and C++
| C# | C++ | | --- | --- | | 不允许 case 语句失败 | 允许 case 语句失败 | | `goto`案例 _ 陈述 | 不适用的 | | `goto`标签 | `goto`标签 | | `switch(string s)` | 不适用的 |在 C# 中,如果一个非空的 case 语句中缺少一个break
或goto
,编译器会发出一个错误。在 C++ 中,据说执行是从一个案例到它下面的案例,然后继续下一个案例。
两种语言都支持用户自定义标签的关键字goto
。C# 允许 case 语句显式地使用goto
。没有与 C++ 等价的语言,原因很大程度上是历史原因。在 C 语言中,switch
/ case
/ break
与其说是一个正式的分支,不如说是对goto
的宏替换。案例不是不同的块,而是作为切换目标的标签。c 开关是模仿汇编语言跳转表设计的。C++ 保留了它的传统。C# 试图采用一种更正式的抽象,在这种抽象中,案例是真正不同且不相关的实体,所以 C# 自然不支持 fall through。这两种抽象都有各自的优缺点。
C++ 不支持 C# 构造switch(string)
。在 C++ 中,你必须使用if
和else
来扩展你的switch
语句。参见表 3-9 了解goto
中switch
的用法以及 C# 和 C++ 中的穿越案例。
表 3-9。
Usage of switch in C# and C++
| C# | C++ | | --- | --- | | `// switch on a System.String and goto case` | `// equivalent to switch on a System::String` | | `string s="1";` | `System::String ^s="1";` | | `switch(s)` | `if(s=="1")` | | `{` | `{` | | `case "1":` | `}` | | `goto case "2";` | `else if(s=="2")` | | `case "2":` | `{` | | `break;` | `}` | | `}` | | | `// fall through case not available` | `// fall through case` | | | `int i,j=0;` | | | `switch(i)` | | | `{` | | | `case 1:` | | | `j++;` | | | `// no break, so case 1 falls into case 2` | | | `case 2:` | | | `break;` | | | `}` |数组和函数
在 C++/CLI 中,托管数组的声明是不同的(参见表 3-10 )。
表 3-10。
Managed Arrays in C# and C++/CLI
| C# | C++/CLI | | --- | --- | | `reftype []` | `array虽然它们都是使用System::Array
实现的,但是 C++/CLI 使用伪模板语法来声明它们。托管阵列将在第七章的中详细解释。伪模板的语法与过去 C++ 语言中添加扩展的方式一致,比如 cast 操作符(见第十六章)。
在 C# 和 C++ 中,都可以将修饰符附加到函数参数上。C# 和 C++/CLI 传递参数数组、引用参数、out 参数不同,如表 3-11 所示。
表 3-11。
Function Argument Modifiers
| C# | C++/CLI | | --- | --- | | `params T[]` | `... array我们稍后将再次讨论这些内容。
转换运算符
C# 运算符is
和as
执行的操作可以由 C++ 伪模板转换运算符static_cast<>()
和dynamic_cast<>()
执行(见表 3-12 )。
表 3-12。
C# and C++/CLI Conversion Operators
| C# | C++/CLI | | --- | --- | | `as` | `dynamic_cast<>()` | | `as` | `static_cast<>()` | | `is` | `(dynamic_cast<>()!=nullptr)` |转换运算符将在第十六章的中详细解释。
存储器分配
在 C++ 中,new
操作符表示本机堆上的分配。在 C++/CLI 中添加了gcnew
操作符来指示托管堆上的分配。C# 也使用new
操作符在堆栈上分配值类型。在 C++ 中,这是不必要的,因为用于分配用户定义值类型实例的 C++ 语法与用于内置类型(如int
)的语法是相同的。请参见表 3-13 了解在托管堆上分配时使用的关键字列表。
表 3-13。
Allocation on the Managed Heap in C# and C++/CLI
| C# | C++/CLI | | --- | --- | | `new`(参考类型) | `gcnew` | | `new`(值类型) | 不需要操作员 |下面是 C++/CLI 中本机堆和托管堆内存分配的一个简短示例:
value struct V {}; //value type
ref struct R {}; //reference type
struct N {}; //native type
void main()
{
N n;
N *pN = new N();
R ^r = gcnew R();
V v;
}
存储器分配将在第六章的中详细讨论。
可达性和可见性
accessibility 和 visibility 关键字相似,但语法不同。表 3-14 中列出了关键字差异,语法差异将在第八章中详细解释。
表 3-14。
Basic Protection Mechanisms
| 类型属性 | C# | C++/CLI | | --- | --- | --- | | 公众 | `public` | `public:` | | 不公开 | `private` | `private:` | | 装配 | `internal` | `internal:` | | 家庭的 | `protected` | `protected:` | | 家庭或集会 | `internal protected` | `protected public:` | | family 和 Assembly | 不适用的 | `protected private:` |属性、事件和委托
在第十章的中,我们将讨论属性、事件和代表,但参见表 3-15 中的介绍。
表 3-15。
Simple Example of a Property in C# and C++/CLI
| C# | C++/CLI | | --- | --- | | `class R` | `ref class R` | | `{` | `{` | | | `private:` | | `private int V;` | `int V;` | | | `public:` | | `public int Value` | `property int Value` | | `{` | `{` | | `get` | `int get()` | | `{` | `{` | | `return V;` | `return V;` | | `}` | `}` | | `set` | `void set(int newV)` | | `{` | `{` | | `V = value;` | `V = newV;` | | `}` | `}` | | `}` | `}` | | `}` | `};` |无商标消费品
在第十四章到第十六章中,你将学习泛型和模板,但是请参见表 3-16 中的介绍。
表 3-16。
Simple Example of a Generic in C# and C++/CLI
| C# | C++/CLI | | --- | --- | | `public class R内置类型
C# 和 C++/CLI 映射到具有不同关键字的 CLI 类型,并且 C++/CLI 映射尽可能与原生 C++ 保持一致。在我们进入第六章的之前,请参见表 3-17 进行介绍。
表 3-17。
Built-in Types
| C# | C++/CLI | | --- | --- | | `byte` | `char` | | `sbyte` | `signed char` | | `short` | `short` | | `ushort` | `unsigned short` | | `int` | `int, long` | | `uint` | `unsigned int`,`unsigned long` | | `long` | `long long` | | `ulong` | `unsigned long long` | | `single` | `float` | | `double` | `double` | | `string` | `System::String^` | | `object` | `System::Object^` | | `decimal` | `System:Decimal` | | `char` | `wchar_t` | | `bool` | `bool` |摘要
虽然 C# 和 C++ 之间的巨大差异初看起来令人望而生畏,但过一会儿就会出现一种模式。每种语言都经过智能设计,内部一致,C++ 语法很快就会变得直观。在下一章,我们将通过逐行将 C# 程序转换成 C++/CLI 来应用我们所学的知识。
四、C# 到 C++/CLI
这种感觉似曾相识。—约吉·贝拉
在这一章中,我将向你展示如何将一个基本的 C# 应用程序转换成 C++/CLI。我将在后面的章节中更详细地介绍更高级的转换方法。
转换 C# 应用程序
让我先从第二章中的 C# 洗牌程序的元素开始,并详细说明必要的改变,一次一个。我将从代码中抽取有代表性的样本并展示最终产品,而不是一行一行地迂腐地重复代码。
接下来是第二章的代码,插入了行号,这样你就不用来回翻动了:
01 using System;
02 namespace CSharp
03 {
04 public class Deck
05 {
06 uint[] Cards;
07 Random randomGenerator;
08 public enum Suit
09 {
10 Clubs = 0, Diamonds, Hearts, Spades
11 }
12 public enum Card
13 {
14 Ace=0, Deuce, Trey, Four, Five, Six, Seven,
15 Eight, Nine, Ten, Jack, Queen, King
16 }
17 Deck()
18 {
19 randomGenerator = new Random();
20 Cards = new uint[52];
21
22 for (uint u = 0; u < 52; ++u)
23 {
24 Cards[u] = u;
25 }
26 }
27 void Swap(ref uint u, ref uint v)
28 {
29 uint tmp;
30 tmp = u;
31 u = v;
32 v = tmp;
33 }
34 void Shuffle()
35 {
36
37 for (uint u = 52; u > 0; --u)
38 {
39 Swap(ref Cards[u - 1], ref Cards[RandomCard(u)]);
40 }
41 }
42 uint RandomCard(uint Max)
43 {
44 return (uint)((double)Max * randomGenerator.NextDouble());
45 }
46 string CardToString(uint u)
47 {
48 Suit s = (Suit)(Cards[u] / 13);
49 Card c = (Card)(Cards[u] % 13);
50 return c.ToString() + “ of “ + s.ToString();
51 }
52
53 public static void Main()
54 {
55 Deck deck = new Deck();
56 deck.Shuffle();
57 for (uint u = 0; u < 5; ++u)
58 {
59 Console.WriteLine(deck.CardToString(u));
60 }
61 Console.ReadLine();
62 }
63 }
64 }
我们将从最初的声明性块开始,并从那里开始构建。
使用后添加关键字名称空间
在第 1 行,通过更改以下 C# 代码,将关键字namespace
添加到using
语句中:
using System;
到这个 C++
using namespace System;
当应用于一个名称空间时,using
语句将该名称空间中的所有符号带入using
语句的范围。在本例中,我们想使用Console
类将数据输出到屏幕上。如果没有using
语句,我们就必须通过编写System::Console
来明确地告诉编译器如何找到Console
类,只要我们想使用它。这称为完全限定名称。在 C++/CLI 中,我们需要将关键字namespace
添加到using
声明中。
在引用类型声明中添加标点符号^
在第 7 行和第 55 行,通过更改以下 C# 代码来更改引用类型声明:
Random randomGenerator;
Deck deck
到这个 C++
Random ^randomGenerator;
Deck ^deck
如果你在。MSDN 上的. NET Framework 类库引用,你发现Random
是引用类型。你怎么知道?
在 MSDN 页面的 C# 部分,您会发现Random
被声明如下:
public class Random
在 C++/CLI 部分,您会发现Random
被声明如下:
public ref class Random
这两个声明都指示引用类型。在这种情况下,randomGenerator
实际上是在托管堆上分配的Random
实例的句柄。在 C++/CLI 中,它是一个句柄的事实是用句柄标点符号^
明确表示的,randomGenerator
的类型是Random^
。在许多方面,randomGenerator
可以被认为是指向托管堆上的类型为Random
的对象的指针,但是不要把这个类比得太远。C++ 中的句柄和指针有很大的不同,我们将在第九章中进一步详细讨论。
修改内置类型
在第 6、22、27、29、37、42、44、46 和 57 行,更改 C# 别名:
uint
string
到 C++
unsigned int
System::String^
因为为 CLR 编译的 C# 和 C++ 都面向 CLI,所以一种语言的托管类型和另一种语言的托管类型之间总是有直接的相似之处。C++ 没有缩写形式uint
,要求你使用扩展的unsigned int
。
C++ 也没有内置string
的类型,所以需要使用System::String^
。在 C# 中,可以在内置别名string
和System::String
之间选择;它们是等价的。类型string
内置于语言中,而System.String
是等价的,通常通过using System
语句引入。
Note
为什么 C++ 没有普通内置类型的标准缩写形式,比如unsigned int
?嗯,这是我在 1987 年向 ANSI 委员会提出的,但是被否决了,因为“为现有类型添加同义词会将标识符添加到保留名称集中,而不会增加功能。”这种哲学在今天已经不那么流行了,C# 有一个System.String
的同义词string
就是证明。这不会增加任何功能,只是方便而已。在 C++ 中,固定宽度的整数类型,比如 int32_t,最终成为了语言。从 C99 开始,它们就已经是 C 语言了。
那么如果string
和String
相同,实际区别是什么呢?考虑下面的 C# 程序,注意没有任何using
语句:
public class Hello
{
static void Main()
{
String s;
}
}
现在尝试编译它:
C:\Users\deanwi\Documents>csc g1.cs
Microsoft (R) Visual C# Compiler version 12.0.21005.1
for C# 5
Copyright (C) Microsoft Corporation. All rights reserved.
g1.cs(5,9): error CS0246: The type or namespace name 'String' could not be found (are you missing a using directive or an assembly reference?)
如你所见,没有using System
语句,编译器不知道String
是什么。如果我们将String
改为System.String
,它将会编译。它甚至可以简单地编译成string
。
注意,由于System.String
是一个引用类型,C++/CLI 中使用的是句柄标点,所以正确转换到 C++/CLI 就给了我们System::String^
。既然我们已经有了using namespace System
,用String^
就够了。
更改数组声明和类型参数
在第 6 行,更改 C# 数组声明:
uint[] Cards;
到 C++
array<unsigned int>^ Cards;
在这种情况下,声明看起来如此不同,很难想象它们表示相同的东西。这里的目标是告诉编译器,我们希望Cards
是一个长度未知的无符号整数的托管数组。在表 4-1 中,你可以看到托管数组语法是如何在 C# 和 C++/CLI 之间映射的。
表 4-1。
Managed Array Declaration in C# and C++/CLI
| C# 数组语法 | C++/CLI 数组语法 | | --- | --- | | `type[]`变量名`;` | `array< type >^`变量名`;` |第一个变化是数组声明中使用的实际语法。C++/CLI 语言使用一种称为伪模板格式的语法,这是因为它看起来像 C++ 模板,但并不具备模板的所有特征。它使用了<
和>
字符,就像模板声明、实例化和使用的情况一样。此外,托管数组存储在托管堆上,所以 variable-name 是一个句柄,它需要^
标点符号。
第二个变化是使用 typename unsigned int
而不是uint
,如前所述。
更改枚举声明
在第 8 行和第 12 行,更改 C# 中的以下枚举声明:
public enum Suit {}
public enum Card {}
到 C++
public:
enum class Suit {};
enum class Card {};
为了使这个枚举声明的语法正确,我们必须做三个小的改动。请注意,为了让事情变得更有趣,我在我们的翻译问题中添加了可访问性。首先,在 C++/CLI 中,嵌套类型、类字段、方法的可访问性指示符和public
一样,都不是项特定的;它们是上下文特定的。在 C# 版本中,关键字public
表示特定的enum Suit
具有公共可访问性。在 C++/CLI 版本中,类中的关键字public:
表示从那时起所有类型、方法和字段都具有公共可访问性。如果我们忽略了将关键字public
添加到在Suit
之后声明的下一个enum
中,它将获得 C# 类的默认可访问性,这是私有的。另一方面,在 C++/CLI 版本中,缺少 accessibility 关键字,后续的enum
声明将获得上下文可访问性级别,在这种情况下,它是公共的,因为在enum
之前显式使用了public:
。当在全局范围内处理可访问性时,C++ 语言也有一个特定于项目的public
关键字。
第二个变化是 C++/CLI 托管枚举(类似于 C# 枚举)需要额外的关键字class
来区别于本机 C++ 枚举。
最后一个变化是我们以前见过几次的——c++/CLI 类型定义在右花括号后以分号结尾。枚举是类型;名称空间不是。记住这一点的简单方法是,如果你能做一个,你需要一个尾随的分号。您可以实例化类型,但不能实例化命名空间。
更改对象分配和实例化
在第 19、20、22、37 和 55 行,更改以下 C# 代码:
randomGenerator = new Random();
Cards = new uint[52];
Deck deck = new Deck();
到 C++
randomGenerator = gcnew Random()
Cards = gcnew array<unsigned int>(52);
Deck^ deck = gcnew Deck();
这些表达式很容易从 C# 映射到 C++/CLI,这实际上只是一个习惯差异的问题。主要区别在于,C++ 通过要求关键字gcnew
而不是关键字new
来区分本机堆上的分配和托管堆上的分配。如果你碰巧用错了,编译器通常会礼貌地发出一条错误消息来提醒你。
更改“通过引用传递”参数
在第 27 行和第 39 行,更改以下 C# 代码:
void Swap(ref uint u, ref uint v)
Swap(ref Cards[u - 1], ref Cards[RandomCard(u)]);
到 C++
void Swap(unsigned int %u,unsigned int %v)
Swap(Cards[u - 1], Cards[RandomCard(u)]);
我们需要修改Swap()
函数声明以及Swap()
的所有用法。代替 C# 关键字ref
,C++/CLI 使用%
标点符号来表示跟踪引用。在 C++/CLI 中,标点符号只在函数声明中使用,不在函数用法中使用。
在表 4-2 中,我列出了 C# 和 C++ 的对应关系,包括 C# out
关键字的 C++ 实现。
表 4-2。
Parameter Passing in C# and C++/CLI
| C# | C++/CLI | | --- | --- | | `ErrorCode GetData(ref int data)` | `using namespace System::Runtime::InteropServices;` | | `{` | `ErrorCode GetData(int %data)` | | | `{` | | `}` | `}` | | `ErrorCode GetResult(out int result)` | `ErrorCode GetResult ([Out] int %result)` | | `{` | `{` | | `return GetData(ref result);` | `return GetData(result);` | | `}` | `}` |C++ 没有out
关键字,但是可以使用[System::Runtime::InteropServices::Out]
属性复制它的行为。
更改句柄的类成员访问运算符
在第 44、56 和 59 行,我们修改了 C# 中的类成员访问操作符:
randomGenerator.NextDouble()
deck.Shuffle()
deck.CardToString(u)
到 C++
randomGenerator->NextDouble()
deck->Shuffle()
deck->CardToString(u)
句柄和指针使用->
类成员访问操作符访问它们的成员。如前所述,randomGenerator
是一个句柄,访问托管堆上的数据需要->
类成员访问操作符。
更改命名空间和静态访问的分隔符
在第 59 行和第 61 行,我们更改了 C# 中的以下分隔符:
Console.WriteLine()
Console.ReadLine()
到 C++
Console::WriteLine()
Console::ReadLine()
.
类成员访问操作符是为实例化保留的,这表明您是直接访问数据,而不是通过句柄或指针间接访问。::
分隔符,即范围解析操作符,用于限定名称空间和静态成员。
更改类声明
在第 4 行,我们修改了类声明。
class
到 C++
ref class
In addition, add a semicolon to the end of the class at line 63.
在 C++ 中,struct
与class
的区别仅在于可访问性和继承保护机制。此外,两者都不对应于 CLI 引用和值类型。空白关键字ref class
和value class
(以及ref struct
和value struct
)被添加到 C++ 中,以便表示这些类型。
添加函数 main()
C++ 程序以一个名为main()
的全局函数开始。C# 程序以一个名为Main()
的公共静态成员函数开始。因为这些函数有不同的签名,所以它们可以共存于一个 C++ 程序中,我们可以添加一个全局的main()
函数来调用 C# Main()
方法。这是添加函数main()
的最简单的方法,无需改变代码的其余部分:
void main()
{
CPP::Deck::Main();
}
In addition, change line 53 from:
public static void Main()
到 C++
public:
static void Main()
完整的程序如下:
using namespace System;
namespace CPP
{
public ref class Deck
{
array<unsigned int>^Cards;
Random^ randomGenerator;
public enum class Suit
{
Clubs = 0, Diamonds, Hearts, Spades
};
public enum class Card
{
Ace=0, Deuce, Trey, Four, Five, Six, Seven,
Eight, Nine, Ten, Jack, Queen, King
};
Deck()
{
randomGenerator = gcnew Random();
Cards = gcnew array<unsigned int>(52);
for (unsigned int u = 0; u < 52; ++u)
{
Cards[u] = u;
}
}
void Swap(unsigned int %u,unsigned int %v)
{
unsigned int tmp;
tmp = u;
u = v;
v = tmp;
}
void Shuffle()
{
for (unsigned int u = 52; u > 0; --u)
{
Swap(Cards[u - 1],Cards[RandomCard(u)]);
}
}
unsigned int RandomCard(unsigned int Max)
{
return(unsigned int)((double)Max * randomGenerator->NextDouble());
}
String^ CardToString(unsigned int u)
{
Suit s = (Suit)(Cards[u] / 13);
Card c = (Card)(Cards[u] % 13);
return c.ToString() + " of " + s.ToString();
}
public:
static void Main()
{
Deck^ deck = gcnew Deck();
deck->Shuffle();
for (unsigned int u = 0; u < 5; ++u)
{
Console::WriteLine(deck->CardToString(u));
}
Console::ReadLine();
}
};
}
void main()
{
CPP::Deck::Main();
}
将这段代码放在名为cardsconverted.cpp
的文件中,编译并运行:
C:\>cl /nologo /clr:pure cardsconverted.cpp
cardsconverted
C:\>cardsconverted
Four of Diamonds
Ten of Spades
Ace of Spades
Ace of Hearts
Trey of Spades
那是更开放的。下注时间;我没有虚张声势。
摘要
在这一章中,我们经历了第二章的简单洗牌程序,逐行拆开,转换成 C#。因为将不同的语言结合在一起非常容易。NET,你不需要经常这样做。您将能够用 C# 和 C++/CLI,甚至 Visual Basic 创建模块和类库,并将它们绑定在一起,而无需更改单独的源文件。尽管如此,实际经历转换步骤的经验还是很有价值的。
在下一章,我们将探索一些工具,帮助你更有效地用 C#、C++ 和. NET 编程。
五、工具
当其他方法都失败时,清洁你的工具。—罗伯特·皮尔西格
在这一章中,我们将让自己熟悉一些工具。NET 开发在 C# 和 C++ 中都更容易。
卢茨.罗德尔的。网状反射器
最强大的工具之一。今天可用的. NET 开发是 Lutz Roeder 的。网反射器,现在由红门公司销售。这个程序是理解的无价工具。NET 程序集。它允许你反编译。NET 可执行文件以及库转换成 IL、C#、C++/CLI 和其他语言。
罗德的。NET Reflector 实现了一种独立于。NET 框架。它旨在通过反编译将元数据和 CIL 转换到更高的抽象层次。这个范例略有不同,因为它不是从程序本身内部调用的,所以从技术上讲它不是一个镜像;这更像是一次穿越镜子的旅行。你发射。NET Reflector,指定要查看哪个程序集或可执行文件,然后在里面看看。而System::Reflection
中的类库主要用于运行时分析或处理。NET Reflector 擅长在封装后检查程序集。
什么是反思?
a。NET 汇编不仅仅是一系列的执行指令。它包含关于程序集内容的描述和属性,统称为元数据。英寸用. NET 的说法,反射是程序在运行时读取和分析另一个程序集的元数据的能力。当程序读取自己的元数据时,有点像照镜子,所以“反射”这个术语似乎很合适。名称空间System::Reflection
专用于。NET 的类库实现了主要的反射机制。还有另外两个:CCI,允许你访问System::Reflection
不能访问的信息的公共编译器基础设施,和IMetadata
API。所有这些机制相辅相成。
反射允许您发现关于一个类型的所有元数据信息,而不需要实例化它。这包括但不限于以下项目:
- 名字
- 菲尔茨
- 方法
- 能见度
- 属性
反射使你能够发现这些物品的各种特征。这些包括字段修饰符(initonly
、literal
等)。)、方法类型(generic
与否)、属性和事件。反射甚至允许您使用Reflection::Emit
动态创建类型。
解码基类库程序集
基类库(BCL)是我们称之为。NET 框架。这个程序适用于所有的 BCL。NET 程序集,因此您可以查看任何 Microsoft DLLs 内部以帮助您的编程工作。当我试图找出程序中哪些 dll 必须通过#using
引用或解析才能正确编译时,我发现这特别有用。例如,System::Collections::Generic
名称空间被分成两部分,一部分在mscorlib.dll
中,另一部分在System.dll
中。很容易发现哪些类是在哪些 dll 中实现的。网状反射器。例如,如果你正在使用System::Collections::Generic::List<T>
,你不需要引用任何 DLL,因为mscorlib.dll
在每一个 C++/CLI 程序中都被隐式和自动引用。另一方面,如果您使用的是System::Collections::Generic::Stack<T>
,您需要在项目设置中添加对System.dll
的引用,或者在代码中添加以下代码行:
#using "System.dll"
在这两种情况下,如果您想在没有显式名称空间限定的情况下引用Stack<T>
或List<T>
,也需要下面一行:
using namespace System::Collections::Generic
关键字#using
和using
不相同;它们在 C++/CLI 中有不同的用途。这有点令人困惑,但这就是这种语言的定义。
#using
是一种编译器指令,意思是它指示编译器在编译时如何做一些事情。在#using
的情况下,它指示它添加一个对它的引用。NET 汇编,比如在 C# 编译器的命令行上用/reference
做的事情。using
是语言的一部分,而using namespace
将符号纳入范围。
查看元数据和 CIL,或者进入深渊
我提到过。NET Reflector“允许你窥视内部”程序和“解码程序集”,但我没有解释这到底是什么意思。程序集,无论是编译为可执行文件还是动态链接库,都包含远不止可执行代码。这个信息集合被称为元数据,理想情况下,它是一个自包含的包,描述了使用这个程序集所需要知道的一切。
几年前,程序本身仅仅是可执行代码和数据的组合。能够执行另一个程序的唯一程序是操作系统本身,它充当信息路由器,定义如何在专用组件之间传递信息,例如设计用于硬件组件的设备驱动程序和想要与这些设备驱动程序通信的高级程序。
随着时间的推移,操作系统和程序不断发展。程序被设计用来与其他程序交换信息。在 Windows 世界中,这最初采取剪贴板的形式用于被动交换,OLE 1 用于主动交换。程序进化到不仅仅包含可执行代码;程序被绑定到资源文件,其中包含本地化和全球化信息,因此它们不仅可以被翻译成其他语言,还可以处理外国字符集、不同的货币、处理时间的方式以及其他特定于文化的信息。
那个。NET Framework 代表了一种范式的转变,它真正地将这些责任卸给了操作系统,或者在本例中是。NET 框架,可以认为是操作系统的扩展。
将有关程序集的尽可能多的信息绑定到单个文件中,并将其智能地组织为多种类型的数据或元数据的集合,这是。NET 框架。
可扩展类浏览器
微软和。Visual Studio 附带的. NET Framework IL 反汇编程序(ildasm.exe
)和 Dependency Walker ( Depends.exe
)允许用户检查或理解元数据的各个方面。ILDasm 允许您查看通用中间语言(CIL)以及元数据。CIL 构成了组成程序的可执行指令。卢茨.罗德尔的。NET Reflector 是一个类浏览器,可以显示程序集中所有方法的 CIL。它还更进一步,能够将 CIL 反编译成半普通的 C#、Visual Basic 和 Delphi。
Note
这个程序的优点之一是它有一个定义良好的代码模型,并接受第三方插件。一个将 CIL 反编译成 C++/CLI 的插件是我和 Jason Shirk 写的。它可以在 github —
上免费获得,关于它的更多信息,后来已经有几十个程序被编写出来,用它来做令人惊奇的事情。网状反射器。在 http://www.red-gate.com/products/dotnet-development/reflector/add-ins
.
找到他们
从 C# 到 C++/CLI
可以用鲁兹的。NET Reflector 作为学习 C++/CLI 语法的教育工具。也可以用这个作为从 C# 转换到 C++/CLI 的工具。这样做不太令人满意,因为 Reflector 将元数据中的内容反编译成高级格式;通常,CIL 中有一些工件是由语言本身的编译或语法便利性创造出来的。实现这一点的算法很简单,使用以下步骤:
Create a C# program. Compile the program. Load the program in .NET Reflector. View any class definition or procedure using the C++/CLI add-in.
安装和装载。NET 反射器和 C++/CLI 外接程序
第一步是导航到红门的网站并获得。网状反射器。
http://www.red-gate.com/products/dotnet-development/reflector/
它可以试用 14 天,也可以立即购买。安装软件,然后打开 Visual Studio。它既可以独立运行,也可以作为 Visual Studio 外接程序运行。如果安装正确,您会在屏幕顶部看到一个新菜单。网状反射器。
现在安装 CppCliReflector 加载项。该项目位于:
http://www.sandpapersoftware.com
目前在 GitHub 上有一个带源代码的版本,在砂纸软件页面上有一个内置的二进制文件。从源代码构建是有指导意义的,所以请从以下网址下载源代码:
https://github.com/lzybkr/CppCliReflector
下载项目并解压缩源代码后,打开解决方案 CppCliReflectorAddin.sln,可能会要求您为最新版本的 Visual C++ 更新解决方案或项目。去吧,那不是问题。
它可能无法编译,因为插件需要直接从 Reflector 可执行文件中获取代码引用。我们将把这看作是一个学习如何引用外部可执行文件的机会。
首先,导航到解决方案资源管理器中的 References 部分。你会发现对反射器的引用不准确,如图 5-1 所示。
图 5-1。
The reference to the .NET Reflector executable
现在让我们通过再次添加来更新它—
右键单击引用,然后单击添加引用。使用导航到。NET Reflector 可执行文件,它可能位于以下文件的某个变体中:
C:\Program Files (x86)\Red Gate\.NET Reflector\Desktop 8.3
现在构建项目。
接下来,让我们设置项目来加载反射器。右键点击图 5-1 中的 CppCliReflectorAddin,选择属性。选择调试选项卡,选择启动外部程序单选按钮,如图 5-2 。
图 5-2。
Exception generated by the .NET Framework
进入 Reflector.exe 之路,这可能是:
C:\Program Files (x86)\Red Gate\.NET Reflector\Desktop 8.3\Reflector.exe
现在,从 Visual Studio 调试菜单中,选择开始调试。。净反射器应加载。
从。反射器工具菜单,选择加载项。使用+按钮添加加载项。导航到 CppCliReflectorAddin 解决方案目录,向下浏览项目和 bin 子目录,直到找到:
CppCliReflectorAddin.dll
然后单击确定。
么事儿啦在那里。
现在 C++/CLI 已被添加到语言下拉列表中。这允许您通过在两种语言之间进行切换来查看 C# 代码在 C++/CLI 中的外观。
正在执行。网状反射器
让我们用做一个示例。网状反射器。编译下面的 C# 程序:
class Program
{
public static void Main()
{
System.Console.WriteLine("Hello, World!");
}
}
C# 视图
使用。NET Reflector,使用文件➤打开打开可执行文件(参见图 5-3 )。点击加号导航至Main()
程序。展开{}
,它对应于全局名称空间。然后展开Program
,导航到Main()
,双击。确保下拉窗口显示 C# 作为反编译视图。
图 5-3。
C# view of the sample code using .NET Reflector
C++/CLI 视图
现在将下拉视图更改为 C++/CLI。视图应该切换成如图 5-4 所示的样子。
图 5-4。
C++/CLI view of the sample code using .NET Reflector
可以看到,在 C++/CLI 中,值类型和引用类型的声明和初始化是不同的;这将在第六章中详细讨论。
C++ 即时
即时 C++ 2 是一个有用的 C# 到 C++/CLI 的翻译器,可从有形软件解决方案( www.tangiblesoftwaresolutions.com
)获得。该公司为遗留 C# 项目提供了一个易于使用、价格合理的翻译器。该软件不仅将孤立的 C# 代码片段转换成 C++/CLI,还能翻译完整的项目。
例如,假设我们使用 Snippet 转换器转换前面的示例(参见图 5-5 )。
图 5-5。
Conversion from C# to C++/CLI using Instant C++
这个代码片段已经可以编译了,保存了所有 C++ 程序都需要的必要的全局main()
。只需添加下面一行,程序就可以用 C++ 编译了:
void main() { Program::Main();}
Visual Studio 附带的工具
Visual Studio 附带了许多非常有用的工具。在这一节中,我将介绍两个我最喜欢的。
微软。NET Framework IL 反汇编程序(ILDasm)
ildasm.exe
是一个元数据和 CIL 浏览器,很像。净反射器,在更基本的水平切割。要在 VS2013 的开发人员命令提示符下使用 ILDasm,只需输入以下内容:
ildasm <assembly name>
您也可以从 IDE 的“工具”菜单中启动它。无论哪种情况,你都会看到一个类似于图 5-6 所示的窗口。
图 5-6。
ILDasm’s view of the test executable
单击加号展开每个类别下的定义。
依赖沃克(依赖)
Depends.exe
是 Windows 二进制文件的依赖遍历器。它适用于。NET 程序集以及本机 Win32 二进制文件。这是一个追踪丢失的 dll 以及解决清单问题的非常有价值的工具。
首先从以下网址下载:
然后使用这个直观的命令来调用它:
depends <binary name>
您将看到一个类似于图 5-7 所示的窗口。
图 5-7。
Dependency Walker
如你所见,我似乎对一些丢失的 dll 有某种依赖,我想知道是什么导致了它们?
更多 Visual Studio 工具
无论你有多聪明,你的生产力都会受到工具质量的限制。虽然向您介绍 Visual Studio 2005 附带的所有工具已经超出了本书的范围,但是这里有一些更值得研究的工具:
- 可移植的可执行验证器确定一个程序集是否满足可验证代码的要求。
SN.exe
:强名称实用工具对程序集进行数字签名。- 这个实用程序处理全局程序集缓存,这是一个机器范围的代码缓存,用于在一台计算机上的多个应用程序之间共享的程序集。
NMake.exe
:Make 实用程序从命令行执行构建过程。MT.exe
:清单工具是用来处理清单的。RC.exe
:资源编译器用于处理.rc
文件。ResGen.exe
:资源生成器在格式之间转换资源。CLRVer.exe
:CLR 版本工具确定安装在机器上的 CLR 的版本。- 这个工具允许你使用一个环境变量来追踪一个文件。
- 这个工具可以让你直观地比较同一个文本文件的不同版本。
guidgen.exe
:该工具生成唯一的全局标识符。TLBImp.exe
:类型库转换工具用于从类型库中导入类。
摘要
现在我们有了深入研究 C++/CLI 所需的工具。NET 实现。事不宜迟,让我们在下一章讨论数据类型。
Footnotes 1
OLE 是 COM 的前身,代表对象链接和嵌入。
2
Instant C++ 的版权归有形软件解决方案所有。所有图片和参考资料均经许可使用。
六、数据类型
游戏正在进行中。—威廉·莎士比亚,亨利五世
在本章中,我们将深入探讨 CLI 类型系统以及 Microsoft Visual C++ 2013 中 C++/CLI 的实现。您应该已经从以前的编程经验中打下了坚实的 C# 类、结构和接口基础,我们希望在这些知识的基础上揭示 C# 和 C++/CLI 之间的差异。
C# 类型与 C++ 类型
C# 语言是专门针对 CLR 而设计的。因此,它的所有数据类型都直接映射到 CLI 类型。C++/CLI 不仅为 CLI 定义了数据类型,还定义了在本机 C++ 中使用的数据类型。所有这些数据类型都是使用关键字class
和struct
定义的。在设计 C# 之前,这些关键字在 C++ 中就已经有了意义。在 C++ 中,class
定义了一个原生类型,可以认为是相关字段和方法的一般集合。C++ struct
与 C++ class
相同,除了所有成员的默认可访问性是public
,而 C++ struct
从其基类中公开继承。
在 C# 中,class
定义了一个从System::Object
继承而来的 CLI 引用类型,它具有一组特定的特征和限制。一个 C# struct
定义了一个 CLI 值类型,它有一组不同的特征。
为了让 C++/CLI 实现 CLI 类型,语言中添加了新的关键字组合。类限定符ref
和value
被添加到关键字class
和struct
的前面,以创建新的空白关键字ref class
和value class
。这些指示由 CLI 定义的托管类型。表 6-1 说明了对应关系。 1
表 6-1。
C++, C#, and CLI Type Comparision
| C++ 类型 | C# 类型 | CLI 类型 | 默认可访问性 | 存储在 | | --- | --- | --- | --- | --- | | 参考类 | 班级 | 参考 | 私人的 | 托管堆,堆栈 | | 参考结构 | 不适用的 | 参考 | 公众的 | 托管堆,堆栈 | | 价值等级 | 结构体 | 价值 | 私人的 | 本机堆、托管堆、堆栈 | | 价值结构 | 不适用的 | 价值 | 公众的 | 本机堆、托管堆、堆栈 | | 班级 | 不适用的 | 不适用的 | 私人的 | 本机堆,堆栈 | | 结构体 | 不适用的 | 不适用的 | 公众的 | 本机堆,堆栈 |C++ struct 关键字
让我重申一下,struct
关键字在 C++/CLI 中不用于表示 C# class
或 C# struct
。C++ 中的struct
与class
完全一样,除了它有public
,而不是private
,并且默认情况下是公开继承的。ref struct
和value struct
也是如此。除了保护机制之外,它们与ref class
和value class
相同。
一个 C++ struct
是极其有用的。每当我希望快速原型化一个类或方法,并且不想担心保护的时候,我就使用 C++ struct
而不是 C++ class
。我将在第八章的中深入探讨保护机制。
本地班级
如前所述,C++/CLI 也有本机类。与直接映射到 CLI 定义的类型的ref class
和value class
不同,本机类是没有 CLI 映射的非托管类型。本地类,经典 C++ 的元素,将在第十八章中进一步讨论。
值类型和引用类型
值类型和引用类型用相同的语法分配、访问和复制;将这些类型声明为struct
或class
是主要的区别。如前所述,在分配这些类型的实例时,使用相同的语法会导致意想不到的后果。值类型是整体复制的,而在赋值期间实际上只复制引用类型的句柄。在 C# 中,除了在初始声明中声明struct
或class
,值类型和引用类型的语法是相同的。C# 对程序员隐藏了值类型和引用类型之间的区别,这可能是好的也可能是坏的。另一方面,C++/CLI 不隐藏这些细节,并区分值类型和引用类型。快速回顾一下程序执行期间的内存分配是理解不同概念的好地方。
动态内存池
在 C# 程序执行期间,新项被分配到两个位置之一:堆栈或托管堆。C++/CLI 增加了第三个池,原生堆,这将在第十八章讨论原生 C++ 时进一步讨论。
托管堆
当您在 C# 类上调用new
时,类数据在托管堆中的连续块中顺序分配。当 CLR 计算出不再有对某个对象的引用时,该对象将成为垃圾回收的候选对象。
随着时间的推移,多个对象分配和孤立对象会导致单个连续的大块空闲内存被分割成已分配内存和未引用内存的气泡。对分配机制的后续调用可能无法找到足够大的连续内存块来包含新数据,即使系统中的总空闲内存大于所需的数量。在这种情况下,CLR 能够收集垃圾并在托管堆内重新安排内存。在这个过程中,类似于对硬盘进行碎片整理,正在使用的内存被移动以合并可用的内存气泡,从而创建更大的连续内存块。它被称为垃圾收集,因为可用的内存气泡不是有效的数据,它们实际上是“垃圾”,组合可用的气泡实质上就是收集垃圾。
堆栈
在 CLI 中,用于动态分配数据的另一个主内存缓冲区是程序堆栈。a 是一个单向增长、反向收缩的内存缓冲区。新的分配只能在栈顶进行,并且只能释放栈顶的内存。在堆栈上分配内存称为推送,从堆栈中释放内存称为弹出。按照计算机科学的说法,堆栈是一个先入后出(FILO)缓冲区,这意味着您压入堆栈的第一个数据是您弹出的最后一个数据。
乍一看,使用堆栈似乎限制太多,而且没有您希望的那么有用。实际上,堆栈对于进行函数调用特别有用,对于递归调用也是必不可少的。今天所有的处理器都为使用堆栈进行了速度优化。在 C# 和 C++ 中,程序堆栈是存储过程调用的返回地址、值类型和引用类型的句柄的地方。由于堆栈的分配和释放方式,堆栈永远不会变得支离破碎,也不需要垃圾收集,因此堆栈的限制性本质实现了性能优势。
本机堆
本机 C++ 有第三个也是最后一个用于动态分配的内存区域,称为本机堆。本机堆上的分配是使用new
关键字进行的。C++/CLI 应用程序可以使用本机堆以及托管堆和堆栈来进行内存分配。我们将在第十八章中进一步讨论这个问题。
碎片帐集
回想一下,当托管堆变成碎片时,必须在称为垃圾收集的过程中四处移动对象以创建更大的连续内存块。正如将在第二十章中进一步讨论的,引用类型的特定实例可能会被称为 pinning 的进程从垃圾收集中排除,但这可能会对性能产生负面影响。
让我重申:正在使用的内存被移动。这意味着如果你的程序中有一个引用类型,它可能会在你不知情的情况下被移动。
当您使用引用类型时,它由两部分组成:数据本身(在托管堆上分配)和数据句柄(在堆栈上分配)。我们将在本章后面更详细地讨论堆栈,但是现在,只要说堆栈不以同样的方式移动就够了。
当执行垃圾回收时,托管堆中的数据将被移动,以便为分配释放更大的连续块,同时,在垃圾回收完成后,任何指向该数据的句柄都必须继续指向该数据。如果您愿意,可以将句柄视为指针,并将指针可视化为托管堆上的数据实例,每次移动数据时都会更新这些实例。这在 CLR 中实际上是如何实现的并不重要,但是如果垃圾收集机制工作正常,那么在垃圾收集完成后,您的句柄将继续跟踪您的数据。
初始化
正如我前面提到的,C# 隐藏了引用类型和值类型之间的实现差异。考虑下面的 C# 示例:
struct V
{
}
class R
{
static public void Main()
{
V v = new V();
R r = new R();
}
}
在这个例子中,我们有一个简单的值类型V
和一个引用类型R
。过程Main()
是一个公共静态函数,它分配一个V
和一个R
。当您编译这个示例代码并使用ildasm.exe
检查生成的可执行文件时,您会在Main()
方法中发现以下 CIL:
.method public hidebysig static void Main() cil managed
{
.entrypoint
// Code size 16 (0x10)
.maxstack 1
.locals init (valuetype V V_0, class R V_1)
IL_0000: nop
IL_0001: ldloca.s V_0
IL_0003: initobj V
IL_0009: newobj instance void R::.ctor()
IL_000e: stloc.1
IL_000f: ret
} // end of method R::Main
从 CIL 中可以看出,值类型V
是用initobj
指令初始化的,它初始化堆栈上的V
。initobj
用于在没有构造器时初始化值类型。引用类型R
由newobj
指令初始化,该指令调用R
的构造器,在托管堆上分配R
的数据,并返回该数据的句柄。这些是非常不同的操作。
等效的 C++/CLI
让我们看看 C++/CLI 中的等价代码:
value class V
{
};
ref class R
{
static public void Main()
{
V v = V();
R^ r = gcnew R();
}
};
如您所见,在分配v
时没有使用gcnew
,这是有意义的,因为我们不想在托管堆上分配v
。它是在堆栈上分配的,C++/CLI 代码反映了这一点。CIL 也反映了这一点,因为它使用initobj
而不是newobj
来实例化v
。当然,gcnew
可以用来在托管堆上分配一个V
的实例。这个操作叫做拳击。我们将在本章后面讨论拳击。为了这个例子,我们想在栈上分配它。
从这个简单的例子中我们可以看出,C# 试图对用户隐藏实现以简化编程,而 C++/CLI 仍然忠于实现并直接映射到 CIL。
未初始化的声明
C# 具有未初始化值类型的声明语法,但要求在使用它们之前对它们进行初始化。考虑下面的 C# 代码:
struct V
{
public int i;
}
class R
{
static public void Main()
{
V v;
System.Console.WriteLine(v.i);
}
}
如果您试图编译它,您会得到以下错误:
h.cs(10,34): error CS0170: Use of possibly unassigned field 'i'
C# 阻止你使用未初始化的内存。C++/CLI 中的类似语法会产生不同的结果:
private value class V
{
public:
int i;
};
private ref class R
{
public:
static void Main()
{
V v;
System::Console::WriteLine(v.i);
}
};
这段看似相似的代码编译和运行无误,并产生以下结果:
0
让我们通过它。NET Reflector 查看Main()
并找出 C++/CLI 编译器生成的代码。图 6-1 所示。NET Reflector 的代码视图。
图 6-1。
.NET Reflector’s view of the translation of an uninitialized declaration to C++/CLI
从图中可以看出,C++ 实际上初始化了v
,运行程序产生了0
,因为int
的默认初始化值为零。
初始化变量
让我们用。NET Reflector 来分析下面的代码:
value struct V
{
V(int i)
{
}
};
ref struct R
{
static public void Main()
{
V v1;
V v2(1);
V v3 = V(2);
}
};
图 6-2 显示了编译器生成的内容。请注意,变量在 IL 中被重命名。
图 6-2。
Initialization of value types in C++/CLI
从图中可以看出,C++ 以某种方式初始化了V
的所有变体。
菲尔茨
考虑下面的 C# 代码:
using System.Collections;
class R
{
ArrayList a = new ArrayList();
static void Main() {}
}
虽然这段代码看起来很正常,但它并不直接映射到 C++/CLI。这是因为变量a
是非静态字段,每次实例化一个类时都需要初始化。因此,C# 隐式创建了一个负责初始化实例字段的构造器。这并不直接映射到 C++/CLI,但是很容易模拟。
让我们雇用。NET Reflector 查看为该代码段生成的构造器 c#;图 6-3 显示了构造器。
图 6-3。
The implicitly generated constructor
如您所见,生成了一个初始化变量a
的构造器。如果我们现在切换到 C++/CLI 模式,我们可以查看我们需要编写的构造器来转换这个代码片段,如图 6-4 所示。
图 6-4。
The C++/CLI version of the constructor
我们也可以使用即时 C++ 来辅助翻译。这是代码片段转换器的输出:
//.h file code:
class R
{
private:
ArrayList *a;
static void Main();
private:
void InitializeInstanceFields();
public:
R()
{
InitializeInstanceFields();
}
};
//.cpp file code:
void R::Main()
{
}
void R::InitializeInstanceFields()
{
a = new ArrayList();
}
在这种情况下,Instant C++ 会自动创建一个由类构造器调用的初始化函数。
请注意,这个版本的 C++/CLI 反射器外接程序没有用名称空间System::Collections
限定ArrayList
。如果代码中有using
语句,我们就不需要使用它,如下所示:
using namespace System::Collections;
我们现在可以使用这些积累的知识来进行 C# 代码的转换:
using namespace System::Collections;
ref class R
{
ArrayList ^a;
static void Main() {}
public:
R()
{
this->a = gcnew ArrayList();
}
}
多个构造器
正如您之前所学的,我们需要将对象初始化代码移动到类的构造器中。如果类有不止一个构造器会发生什么?C# 是做什么的?
考虑下面的 C# 代码片段:
class R
{
class R1 {}
R1 rA = new R1();
R(int i) {}
R() {}
static void Main() {}
}
在这种情况下,rA
需要为每个对象初始化一次,有两个不同的构造器可用。见图 6-5 查看这些构造器使用。网状反射器。
图 6-5。
Class initialization with multiple constructors
如图所示,C# 将初始化代码独立地复制到两个构造器中。
静态初始化
现在让我们考虑一个更广泛的例子,这个例子建立在前面的例子之上,同时使用了静态初始化和普通初始化。考虑下面的 C# 代码:
class R
{
class R1
{
}
struct V1
{
}
V1 vA = new V1();
R1 rA = new R1();
V1 vB;
R1 rB;
static V1 vC = new V1();
static R1 rC = new R1();
R()
{
vB = new V1();
rB = new R1();
}
static public void Main()
{
R r = new R();
}
}
即使这个类已经有了一个构造器,C# 仍然移动初始化以使 CIL 正确运行。我们可以雇佣。NET Reflector 来发现在编译过程中移动了哪些初始化,这将指导我们如何创建一个等效的 C++/CLI 程序。图 6-6 显示了类R
的成员,如所示。网状反射器。
图 6-6。
Class R in .NET Reflector
如你所见,R
不仅有一个构造器,表示为.ctor
,它还有一个静态构造器,表示为.cctor
。
静态构造器
构造器,更确切地说是实例构造器,在每次创建一个类的实例时都会被调用。静态构造器,也称为类构造器或类型初始值设定项,在创建类的任何实例之前只调用一次。它用于一次性初始化所有实例共有的数据。
现在让我们回到检查代码,看看构造器和静态构造器。这两个构造器都是 C# 编译器的重定位目标。实例构造器获取所有实例初始化,静态构造器获取所有静态初始化。首先让我们检查一下图 6-7 中所示的构造器。
图 6-7。
Class R’s constructor
与您之前所学的类似,vA
和rA
的初始化被移到构造器中。没什么好惊讶的。图 6-8 所示的静态构造器怎么样?
图 6-8。
Class R’s static constructor
类似于vA
和rA
的移动,vC
和rC
被移动到静态构造器中。这也是有意义的,因为如果常规初始化被移到构造器中,那么静态初始化应该被移到静态构造器中。C++/CLI 自动将静态初始化移到静态构造器中,因此我们可以让编译器隐式地创建它。C++/CLI 可以做到这一点,因为一个类中最多有一个静态构造器,尽管一个类中可能有多个实例构造器。
现在,我们可以构建最终的 C++/CLI 代码,并完成初始化主题的这一方面:
ref class R
{
ref class R1
{
};
value class V1
{
};
V1 vA;
R1 ^rA;
V1 vB;
R1 ^rB;
static V1 vC = V1();
static R1 ^rC = gcnew R1();
R()
{
vA = V1();
rA = gcnew R1();
vB = V1();
rB = gcnew R1();
}
public:
static void Main()
{
R ^r = gcnew R();
}
};
或者,在 C++/CLI 中,vC
和rC
可以使用显式静态构造器进行初始化,如下所示:
private:
static R()
{
vC = V1();
rC = gcnew R1();
}
静态构造器在类R
的任何实例化执行之前执行。考虑以下应用:
using namespace System;
ref struct R
{
static R()
{
Console::WriteLine('Static Constructor');
}
R()
{
Console::WriteLine('Constructor');
}
};
int main()
{
R ^r;
Console::WriteLine('in main()');
r = gcnew R();
}
该程序有以下输出:
Static Constructor
in main()
Constructor
该输出显示在实例化任何R
对象之前,调用了R
的静态构造器。
拳击
因为在。NET、value 和 reference,所以我们偶尔需要在它们之间执行某种类型的转换也就不足为奇了。
通常,我们需要将值类型传递给需要引用类型的方法。这项任务可能令人望而生畏,因为值类型存储在堆栈上,而引用类型存储在托管堆上。Java 有包装类来解决这类问题;CLR 提供装箱功能。
将值类型表达为引用类型的过程称为装箱。装箱会返回一个引用值数据副本的System::Object^
,当在托管堆上分配数据时,可以用它来引用数据。拳击一般是自动的,含蓄的。
从装箱的对象中检索原始值类型的反向操作称为取消装箱。与装箱不同,取消装箱必须显式完成。这很直观,因为所有的值类型都变成了一个单独的装箱对象,所以 CLR 确切地知道该做什么。另一方面,给定一个装箱的对象,CLR 无法在没有显式强制转换的情况下确定其中包含的值类型。
方法拳击
许多 CLR 方法接受引用类型作为参数。例如,Console::WriteLine()
接受内置类型或引用类型作为参数。
装箱和取消装箱的示例
考虑以下装箱和取消装箱的例子。在这个例子中,我们取一个值类型V
,将其装入一个Object
,并作为一个Object
发送给Console::WriteLine()
。接下来,我们显式地将其拆箱到一个V
并再次发送到Console::WriteLine()
,后者隐式地将其装箱。因此,以下示例包含隐式和显式装箱,以及显式取消装箱:
using namespace System;
value struct V {};
ref struct R
{
static void Main()
{
V v;
Object ^o = v;
Console::WriteLine(o);
v = (V) o;
Console::WriteLine(v);
}
};
int main()
{
R::Main();
}
该计划的结果如下:
V
V
深入研究伊尔,我们可以看到 CIL 拳击运动:
.method public hidebysig static void Main() cil managed
{
// Code Size: 47 byte(s)
.maxstack 1
.locals (
V v1, //this is ldloc.0
object obj1) //this is ldloc.1
L_0000: ldnull // 0
L_0001: stloc.1 // obj1 = 0
L_0002: ldloca.s v1 //
L_0004: initobj V // v1 = V()
L_000a: ldloc.0 // get v1
L_000b: box V // box it (explicit)
L_0010: stloc.1 // obj1 = boxed(v1)
L_0011: ldloc.1 // get obj1
L_0012: call void [mscorlib]System.Console::WriteLine(object)
L_0017: ldloc.1 // get obj1
L_0018: unbox V // unbox obj1 of type V
L_001d: ldobj V // get V
L_0022: stloc.0 // v1 = unboxed
L_0023: ldloc.0 // get v1
L_0024: box V // box it (implicit)
L_0029: call void [mscorlib]System.Console::WriteLine(object)
L_002e: ret
}
你不需要成为 CIL 的专家就能看出这里发生了什么,特别是因为我已经注释了单独的指令。
清除危险
因为取消装箱是显式的,所以存在程序员将对象取消装箱为错误类型的危险,这通常会导致 CLR 引发异常。考虑以下示例:
using namespace System;
using namespace System::Collections;
ref struct R
{
static void Main()
{
ArrayList^ a = gcnew ArrayList();
int i=3;
double d=4.0;
a->Add(i);
a->Add(d);
for each(int j in a)
{
Console::WriteLine(j);
}
}
};
void main() { R::Main();}
在这个例子中,我们通过将一个int
和一个double
添加到一个ArrayList()
来隐式装箱它们。for each
循环将这些值解装箱到一个int
中,当double
解装箱时导致一个异常。屏幕显示的结果如下:
Unhandled Exception: System.InvalidCastException: Specified cast is not valid.
at R.Main()
at mainCRTStartup(String[] arguments)
安全释放装置安全释放装置安全释放装置安全释放装置安全释放装置安全释放装置安全释放装置安全释放装置安全释放装置安全释放装置安全释放装置安全释放装置安全释放装置安全释放装置安全释放装置安全释放装置安全释放装置安全释放装置
在 C# 中,您可以通过使用关键字is
来修复前面的代码,如下所示:
using System;
using System.Collections;
class R
{
public static void Main()
{
ArrayList a = new ArrayList();
int i = 3;
double d = 4.0;
a.Add(i);
a.Add(d);
foreach(Object o in a)
{
if(o is int)
{
int i1 = (int)o;
Console.WriteLine(i1);
}
else
{
double d1 = (double)o;
Console.WriteLine(d1);
}
}
}
}
在这段代码中,可以看到检查对象是否是装箱的int
( o is int
)。要在 C++/CLI 中执行相同的技巧,您可以如下使用dynamic_cast<>()
:
using namespace System;
using namespace System::Collections;
ref struct R
{
static void Main()
{
ArrayList^ a = gcnew ArrayList();
int i=3;
double d=4.0;
a->Add(i);
a->Add(d);
for each(Object ^o in a)
{
if(dynamic_cast<int^>(o) != nullptr)
{
int i1=(int)o;
Console::WriteLine(i1);
}
else
{
double d1=(double)o;
Console::WriteLine(d1);
}
}
}
};
void main() { R::Main();}
在第十六章的中,我们将更详细地回顾铸造操作员。
构造器转发
C# 有一个特殊的语法,允许您在构造器之间延迟对象的初始化。这被称为构造器转发或委托构造器,现在在当前版本的 C++/CLI 中受到支持。下面是一个构造器转发的 C# 示例:
class R
{
R(int i) {}
R() : this(5) {}
public static void Main() {}
}
以下是 C++/CLI 中的一个示例,使用的语法略有不同:
ref struct R
{
int value;
R(int i)
{
value = i;
}
R() : R(5)
{
}
};
void main()
{
R^ r = gcnew R();
System::Console::WriteLine(r->value);
}
运行此示例会显示数字 5。
在这个例子中,R()
构造器将构造转发给R(int)
构造器,并继续用R()
方法进行构造。
请注意,下面的尝试是错误的:
ref struct R
{
R(int i)
{
}
R()
{
R(5);
}
};
void main()
{
R^ r = gcnew R();
}
这段代码不起作用的原因如下:R()
构造器调用R(5)
时,并没有指示编译器将构造转发给R(int)
构造器。相反,它创建了一个用0
初始化的R
的临时副本,在退出方法时被丢弃。图 6-9 显示了R()
构造器使用。网状反射器。
图 6-9。
A failed attempt at constructor forwarding in C++/CLI
正如您所看到的,编译器创建了一个类型为R
的临时变量,并将一个5
作为初始化参数,这与 C# 构造器的转发并不相同。
C# 分部类
C# 允许使用partial
关键字将单个类的定义跨越多个文件。C++/CLI 没有直接的类比,因为它支持不同的范例。正如前面提到的,C++ 允许您将类声明放在一个头文件中,并在多个 C++ 文件中实现类成员,但这与分部类的概念无关。
堆栈上的引用类型
C++/CLI 的一个不太常用的功能是声明和使用引用类型的能力,就像它是一个值类的堆栈变量一样。这是严格的语法规则,标准 C++/CLI 编程不需要。即使内存仍然在物理上分配在托管堆上,编译器也会让对象观察在堆栈上分配的对象的语义。
基本类型
让我们看看表 6-2 ,其中包含内置的 C# 类型,看看它们如何映射到 C++/CLI。
表 6-2。
Mapping Basic C# Types to C++/CLI
| C# | C++/CLI | 字节 | 。网络类型 | 签名 | 整理 | 例子 | | --- | --- | --- | --- | --- | --- | --- | | sbyte(字节) | 茶 | one | SByte(字节) | 是 | 不 | –1,“A” | | 字节 | 无符号字符 | one | 字节 | 不 | 不 | 3u, 0xff | | 短的 | 短的 | Two | Int16 | 是 | 不 | –1 | | 乌肖特 | 无符号短 | Two | UInt16 | 不 | 不 | 3u | | (同 Internationalorganizations)国际组织 | int 或 long | four | Int32 | 是 | 不 | –1l | | 无符号整型 | 无符号整数或 | four | UInt32 | 不 | 不 | 3u,第 3 个 | | | 无符号长 | | | | | | | 长的 | 龙龙 | eight | Int64 | 是 | 不 | –1ll | | 乌龙!乌龙 | 无符号长整型 | eight | UInt64 | 不 | 不 | 3 满 | | 单一的 | 漂浮物 | four | 单一的 | 是 | 不 | 4.0f | | 两倍 | 两倍 | eight | 两倍 | 是 | 不 | Three | | 线 | System::String^ | 不适用的 | 线 | 不适用的 | 不 | “一个” | | 目标 | System::Object^ | 不适用的 | 目标 | 不适用的 | 不 | 不适用的 | | 小数 | 系统:十进制 | Sixteen | 小数 | 是 | 不 | 不适用的 | | 茶 | wchar_t | Two | 茶 | 不 | 是 | 洛杉矶 | | 弯曲件 | 弯曲件 | one | 布尔代数学体系的 | 不适用的 | 是 | 真实的 |基本类型差异
需要注意的一点是,一些常见的 C# 类型在 C++/CLI 中没有类似的类型,或者在 C++/CLI 中略有不同,需要某种级别的封送处理来进行转换。
缺少关键字
C# 中的string
、object
、decimal
关键字在 C++/CLI 中没有对应的内置类型。这是否意味着我们不能在 C++ 中使用这些类型?一点也不。事实上,因为 C# 和 C++/CLI 都以 CLI 为目标,所以我们总是可以通过名称来指定 CLI 目标类型,并使用它来代替。
需要封送处理
什么是封送?通常,封送拆收器是在两个程序之间翻译或打包数据的程序。在两个程序无法对单个数据实例进行无缝操作的许多情况下,可能需要封送拆收器。C++/CLI 类型wchar_t
和bool
在元数据中有附加到它们的封送处理属性,以便从 C# 和其他。网络语言。属性将在第二十章中详细讨论。在此之前,让我们看看元数据中的一些封送属性。
考虑下面简单的 C++/CLI 方法的 CIL。注意Hello()
以一个wchar_t
为参数,返回一个bool
;这两种类型都被封送:
ref class R
{
bool Hello(wchar_t ch)
{
return true;
}
};
图 6-10 显示了使用Hello()
方法的 C# 视图。网状反射器。
图 6-10。
C# view of Hello()
可以看到,MarshalAs(UnmanagedType.U1)
返回属性被添加到bool
返回值中,MarshalAs(UnmanagedType.U2)
被添加到char
值中(对应于wchar_t
)。
UnmanagedType enum
是System::Runtime::InteropServices
的一个成员,表示被整理的数据的种类。如果你想知道类型是如何定义的,你可以在mscorlib.dll
中找到定义。网状反射器。你会发现U1
是4
,而U2
是6
,这并不重要!现在让我们看看 C++/CLI 版本的Hello()
使用。网状反射器如图 6-11 所示。
图 6-11。
C++/CLI view of Hello()
等等——编组去哪儿了?嗯,当我们编写 C++/CLI 插件时,我们意识到使用一个wchar_t
或一个bool
总是发出一个MarshalAs
属性,所以这些属性从输出中被隐藏。另一方面,如果您想进行一些非标准的通信,比如将一个 short 作为非托管 bool 进行封送,如下所示:
using namespace System::Runtime::InteropServices;
ref class R
{
[returnvalue: MarshalAs(UnmanagedType::Bool)]short Hello(wchar_t ch)
{
return (short)true;
}
};
然后是 C++/CLI。NET Reflector 外接程序不会取消封送处理属性,因为它们不同于默认值。我们可以在图 6-12 中看到这一点。
图 6-12。
C++/CLI Hello() with special marshaling
正如您在截图中看到的,我们为到非托管的简短转换添加的特殊封送处理清楚地显示在。网状反射器。
表 6-3 列出了 C++ 类类型的各种优点和缺点。
表 6-3。
Feature Limitations by Class Type
| 特征 | 土著阶级 | 参考类 | 价值等级 | 连接 | | --- | --- | --- | --- | --- | | 赋值运算符 | X | X | | | | 类别修饰符 | X | X | X | | | 复制构造器 | X | X | | | | 委托定义 | X | X | X | X | | 默认构造器 | X | X | | | | 破坏者 | X | X | | | | 事件 | | X | X | X | | 终结器 | | X | | | | 功能修饰符 | X | X | X | X | | initonly 字段 | | X | X | X | | 文字字段 | | X | X | X | | 委托类型的成员 | | X | X | | | 覆盖说明符 | X | X | X | | | 参数数组 | X | X | X | X | | 性能 | | X | X | X | | 保留的成员名称 | X | X | X | X | | 静态构造器 | | X | X | X | | 静态运算符 | X | X | X | X |摘要
我们对类和类类型的介绍到此结束。
如果你没有理解本章的所有内容,没关系,继续下一章。你看,这一章的目标不是钻关于类型系统的细节,而是让你多了解一些幕后发生的事情。随着本书的展开,我们将继续这一策略,并一次又一次地回到重要的概念上来。
接下来让我们看看最重要的基本数据结构,数组。
Footnotes 1
C++/CLI 的未来版本可能会实现混合类型;根据 C++/CLI 规范,混合类型是“需要通过声明或继承在 CLI 堆和本机堆上分配对象成员的本机类或 ref 类。”
七、数组
给我一个支点,我可以撬动地球。—锡拉丘兹的阿基米德
在这一章中,我们将从语法差异开始看 C++ 数组。C++/CLI 提供了两种类型的数组:
- 作为经典 C++ 元素的本机数组
- 与 C# 数组相同的托管数组,但语法不同
关于 C++ 中的托管数组,您注意到的第一件事是,它们的声明与 C# 中的声明完全不同,这不是一件坏事。当两种语言像 C# 和 C++ 一样相似时,你会有一种错误的安全感,最终会写出错误的代码,这些代码会回来困扰你。你可能在 C++ 中使用一个在 C# 中有不同含义的关键字,比如class
,并期望它以同样的方式运行。或者,您可能很难记住看似深奥的语法变化,例如在特定的右花括号后是否需要分号。对于托管数组,这不太可能发生,因为 C++ 声明语法与 C# 声明语法完全不同。
在 C++ 中,本机数组和托管数组在声明和实现上都有所不同。由于以语言兼容的方式为 C++ 扩展定义良好的数组结构的限制,托管数组的 C++/CLI 语法变得有些复杂。不过,不要担心——过一会儿,语法感觉就很直观了。
本机数组
本机数组总是一个缓冲区,其中数组元素连续排列。无论是排名还是维度都是如此。数组是一个缓冲区,数组索引是计算缓冲区内偏移量的快捷方式。换句话说,在原生 C++ 中,数组的每一次使用都可以通过使用单个一维缓冲区和一点数学知识来模拟。因此,许多作者将原生 C++ 数组视为其秩或维数始终为 1。高维的原生数组总是矩形的;在任何给定的维度中,元素的数量总是一个常量。
通常,在原生 C++ 中,程序员使用指针和数组来访问缓冲区,这可能会很混乱。此外,通过直接计算和强制转换直接访问 C++ 数组缓冲区可能不是类型安全的。
托管阵列
托管数组是不同的。托管数组已经成熟。网络公民源于System::Array
。多维数组可以是矩形的,也可以是锯齿状的。矩形阵列在每个维度上都有固定数量的元素。你可以把它们想象成长方形或块状。
交错数组是其中特定维度的元素数量可以变化的数组;您可以将交错数组视为数组的数组,每个数组都有自己的声明和定义。
使用一种伪模板语法来声明托管数组。您不需要成为模板专家就能理解如何在 C++ 中声明和使用托管数组。该语法借用了模板语法,但数组不是模板,不能专门化。
要使用托管数组,您只需要学习这种特定的语法,无论是通过定义还是通过示例(我一定会提供很多示例)。
托管数组是使用array
上下文相关关键字声明和定义的,后跟尖括号中数组元素的类型,再加上括号中数组元素的数量。秩大于 1 的矩形数组也可以以稍微复杂一点的方式声明,方法是在尖括号内包含秩,在括号内包含单独的定义。我将在本章的后面详细描述交错数组的声明、定义和使用。
托管数组的内置定义如下所示:
namespace cli
{
template<typename Type, unsigned int dimension = 1>
ref class array : System::Array
{
public:
array(unsigned int size);
};
}
一个简单的例子
我们举个例子,把它包装在一个函数里使用。首先,让我们看看如何在 C++/CLI 中声明、分配和初始化一个简单的托管数组。
申报
这是一个 C# 数组:
int [] a = new int[] {1,2,3,4};
这是 C++/CLI 中的相同语句:
array<int>^ a = gcnew array<int>(4) {1,2,3,4};
现在让我们稍微复习一下。首先,考虑下面的表达式:
array<int>^ a
这个表达式将a
声明为一个句柄,因为有^
标点符号,所以是一个整型数组。这个数组中的元素个数不是a
声明的一部分。
接下来的部分是:
gcnew array<int>(4)
我们使用gcnew
关键字,因为我们想要在托管堆上分配一个数组。记住,gcnew
相当于 C# 中用于托管堆分配的new
关键字。这个语句的意思是“在托管堆上分配一个由四个整数组成的数组。”C++ 中的new
关键字用于本机堆上的分配。如果在这个上下文中错误地使用了new
关键字,编译器会发出这样的错误
t.cpp(3) : error C2750: ‘cli::array<Type>’ : cannot use ‘new’
on the reference type; use ‘gcnew’ instead
with
[
Type=int
]
分配的最后一部分如下所示:
{1,2,3,4}
在 C++ 行话中,这被称为聚合初始化,它定义了新分配数组的元素。在 C++ 语言中也支持聚合初始化,而不需要使用gcnew
来提供不太冗长的数组初始化语法:
array<int>^ a = {1,2,3,4};
将它投入使用
现在让我们在一个简单的独立示例的上下文中检查这个声明。我们可以像前面描述的那样声明、分配和初始化数组,并使用一个简单的for each
循环在控制台上显示数组的值。若要编译此代码片段,请使用 Visual Studio 2013 命令提示符,并输入以下代码:
cl /clr test.cpp
int main()
{
array<int>^ a = gcnew array<int>(4) {1,2,3,4};
for each(int i in a)
{
System::Console::Write(i);
}
System::Console::WriteLine();
}
执行时,这个代码片段显示了数组的四个元素{1,2,3,4}
,如预期的那样:
1234
作为参数和返回值的数组
与 C# 类似,在 C++/CLI 中,数组可以作为参数传递给方法,也可以作为返回值从方法返回。
将数组传递给方法
标准参数列表是将数组作为参数传递给方法的一个很好的例子。当执行 C# 或 C++/CLI 控制台应用程序时,命令行参数数组被传递到唯一入口点,该入口点是 C# 中类的static Main()
方法或 C++/CLI 中的main()
。
在 C# 中,传递给Main()
的参数列表声明如下:
public static void Main(string [] args) {}
这一行表示Main()
将一个string
类型的数组作为参数。使用伪模板语法,C++/CLI 等效项如下所示:
public:
static void Main(array<String^>^ args) {}
这条看似完全不同的语句做了与 C# 相同的事情。让我们一点一点地分解它。
首先,string
是System::String
的 C# 别名。注意String
在上下文中有一个大写的“S”。这是一个引用类型,所以它需要^
标点符号。让我们继续,假设我们已经添加了
using namespace System;
添加到文件的顶部,因此解析String
不需要前缀System::
。接下来,我们将 C# 关键字string
映射到 C++/CLI 表达式String^
。
继续动态组装伪模板语法,我们有字符串数组的array<String^>
,由于args
实际上是托管堆上分配的数组的句柄,我们以array<String^>^ args
结束。现在,你可能会问我们怎么知道args
是一个句柄而不是数组本身。请记住,在 C++ 中,句柄是指托管堆上的对象,而。NET 数组总是在托管堆上分配,从不在堆栈上分配。栈上只分配值类型,System::Array
是引用类型。
从方法中返回数组
与 C# 类似,在 C++/CLI 中,方法可以将数组作为返回值。实际上,数组本身并没有被返回;相反,返回托管堆上数组的句柄。C++/CLI 清楚地反映了这一点。考虑下面的 C# 代码片段:
static string[] GetStrings()
{
string[] strings = {"1", "2"};
return strings;
}
等效的 C++/CLI 是这样构造的:
static array<String^>^ GetStrings()
{
array<String^>^strings = {"1", "2"};
return strings;
}
如您所见,在GetStrings()
函数中的托管堆上分配了一个数组,该数组的句柄是该方法的返回值。
传递可变数量的参数
在 C# 中,可变数量的参数可以传递给一个方法,并使用params
关键字转换成一个数组。与关键字params
相对应的 C++/CLI 是一个省略号(. . .
)。一个方法最多只能有一个参数数组,并且它必须是最后一个参数。像在 C# 中一样,使用前面带有参数数组构造的数组类型来声明参数数组,在 C++ 中是省略号。
例子
假设您想用 C# 编写一个返回任意数量整数之和的方法。您可能会得到如下结果:
class R
{
static int Sum(params int [] Arr)
{
int r = 0;
foreach(int i in Arr)
{
r+=i;
}
return r;
}
static void Main()
{
int[] Arr2 = {1,2,3,4,5};
System.Console.WriteLine(Sum(Arr2));
System.Console.WriteLine(Sum(1,2,3,4));
}
}
方法Sum()
将一个整数数组作为它的单个参数。它接受整数数组或任意整数序列,由编译器自动打包成一个数组。
在示例中,Main()
调用了Sum()
两次。第一次,Sum()
被显式声明和初始化的整数数组调用。第二次,Sum()
被调用,使用一系列整数作为参数,它们被编译器打包成一个数组。
下面是一些类似的 C++/CLI,带有转换后的数组和params
语法:
ref struct R
{
static int Sum(... array<int> ^Arr)
{
int r = 0;
for each(int i in Arr)
{
r+=i;
}
return r;
}
static void Main()
{
array<int> ^Arr2 = {1,2,3,4,5};
System::Console::WriteLine(Sum(Arr2));
System::Console::WriteLine(Sum(1,2,3,4));
}
};
void main() {R::Main();}
请注意,params
关键字已经改为省略号,并且数组声明、分配和初始化已经被转换。C++ 语言已经在本地代码中支持类似的东西。选择省略号来实现。因为它是 C++ 程序员熟悉的语言的自然扩展。在本机代码中,采用不确定数量的变量的函数使用省略号。这种函数称为 vararg 函数,类似于下面这样:
int printf(char *format, ...);
第一个参数用作内存中的占位符,通过偏移量从内存中访问其余的参数。编译器提供了一个函数库来帮助您提取和使用函数体中的参数。相比之下,。因为所有的支持都内置在语言中,而不是在库中访问,所以数组感觉起来很自然,也很容易被程序员掌握。
类型安全和隐式转换
C# 和 C++/CLI 参数数组都是类型安全的。如果你有一个接受整型参数数组的方法,给它传递一个类型为System::String
的元素是没有意义的。在这种情况下,你得到一个诊断。但是其他不太明显的转换呢,比如传递一个浮点而不是一个整数 ???假设我们有下面的 C# 程序:
class R
{
static void Test(params int [] Arr)
{
foreach(int i in Arr)
{
System.Console.WriteLine(i);
}
}
static void Main()
{
Test(1, 2, 3, 4.2f);
}
}
在这里,我们试图使用浮点数来组装一个整数类型的数组。如果您尝试用 C# 编译器编译它,您会看到以下诊断信息:
Microsoft (R) Visual C# Compiler version 12.0.21005.1
for C# 5
Copyright (C) Microsoft Corporation. All rights reserved.
t.cs(12,9): error CS1502: The best overloaded method match for 'R.Test(params int[])' has some invalid arguments
t.cs(12,23): error CS1503: Argument 4: cannot convert from 'float' to 'int'
如您所见,编译器识别出4.2f
不是一个整数,并发出一个诊断。让我们看看 C++/CLI 中的翻译示例:
void Test(... array<int> ^Arr)
{
for each(int i in Arr)
{
System::Console::WriteLine(i);
}
}
void main()
{
Test(1,2, 3, 4.2f);
}
现在让我们尝试编译它,尽管我们可能不希望这样做成功:
cl /clr:pure t.cpp
Microsoft (R) C/C++ Optimizing Compiler Version 18.00.21005.1
for Microsoft (R) .NET Framework version 4.00.30319.34014
Copyright (C) Microsoft Corporation. All rights reserved.
t.cpp
Microsoft (R) Incremental Linker Version 12.00.21005.1
Copyright (C) Microsoft Corporation. All rights reserved.
/out:t.exe
/clrimagetype:pure
t.obj
我们又惊了!事实证明,C# 对于隐式转换的规则比 C++/CLI 更严格。C++ 允许从float
到int
的自动转换,因为这在 C 中是允许的,而 C++ 是 C 的扩展。
如果我们现在尝试执行该程序,我们会看到以下结果:
C:\>t
1
2
3
4
浮点值4.2f
在没有诊断的情况下被截断为4
。这似乎有点不好,所以 Microsoft Visual C++ 添加了一个二级诊断来警告可能会截断数据的转换。
Note
使用/W{n}
命令行选项启用警告,其中n
从0
(无警告)到4
(严格警告)不等。编译器中的缺省值是n=1
,尽管使用 Visual C++ IDE 创建的项目会得到一个警告级别n=3
。
如果您现在在警告级别2
重新编译,您会看到以下内容:
cl /clr:pure /W2 t.cpp
Microsoft (R) C/C++ Optimizing Compiler Version 18.00.21005.1
for Microsoft (R) .NET Framework version 4.00.30319.34014
Copyright (C) Microsoft Corporation. All rights reserved.
t.cpp
t.cpp(10) : warning C4244: 'argument' : conversion from 'float' to 'int', possible loss of data
Microsoft (R) Incremental Linker Version 12.00.21005.1
Copyright (C) Microsoft Corporation. All rights reserved.
/out:t.exe
/clrimagetype:pure
t.obj
这给了你所需要的诊断。
C++ 标准中详细列出了自动转换及其优先级的完整列表。 1 一般来说,在3
级干净地编译代码是个好主意。级别4
警告,编译器生成的最高级别的警告,通常是虚假的,可以考虑。尽管如此,在每次发布之前至少在4
级别编译一次你的代码是值得的,并且能够证明你选择忽略的任何警告是正确的。
参数数组摘要
参数数组是类型安全的,但是我们必须时刻注意转换,尤其是可能丢失数据的类型,因为 C# 的转换规则比 C++ 的转换规则更严格。我们将在第十一章中再次讨论转换。
复杂的例子
下面是一个在 C++/CLI 中使用托管数组的更复杂的例子。在这种情况下,我们试图回答一个常见的算法问题:给定一个序列,如何找到和最大的子序列?
这是使用参数数组的理想位置,因为我们希望用几个不同的例子调用该方法,每个例子都有一个任意长的序列。
乍一看,您可能会想象对同一个数组进行多次遍历,从不同的索引开始寻找最大的部分和。事实证明,高效的一次通过算法是存在的;你需要的洞察力是认识到一旦部分和变成负的,它不能使任何和继续变大,所以没有理由继续增加这个和。许多人在这个问题上犯了错误,因为他们要么没有意识到问题的一次性本质,要么没有有效地处理所有数字都是负数的特殊情况。
我不会一行一行地研究下面的程序,因为它很短,足以成为一篇优秀的研究文章,并作为 C++/CLI 数组的一个实例:
using namespace System;
void MaxSubSequence(...array<int>^Sequence)
{
int MaxStart, MaxEnd, MaxSum, Sum, Start;
for each(int j in Sequence)
{
Console::Write("{0} ", j);
}
MaxSum = Int32::MinValue;
Start = 0;
Sum = 0;
for(int i=0; i<Sequence->Length; i++)
{
// don’t carry negative sums forward
if(Sum<0)
{
Sum = 0;
Start = i;
}
Sum += Sequence[i];
// is our new sum better?
if(Sum > MaxSum)
{
MaxSum = Sum;
MaxStart = Start;
MaxEnd = i;
}
}
Console::Write(" has subsequence: ");
for(int j=MaxStart; j<= MaxEnd; j++)
{
Console::Write("{0} ", Sequence[j]);
}
Console::WriteLine();
}
int main()
{
MaxSubSequence(1,1,-1,-4,5,-3,6,7,-17,3,5,-2,8);
MaxSubSequence(1,1,-1,-4,5,2,6,7);
MaxSubSequence(-5,1,-3,-4);
MaxSubSequence(-5,-2,-3,-4);
MaxSubSequence(-5,1,1,1,-1,-3,1,1);
MaxSubSequence(-10,2,3,-2,0,5,-15);
}
让我们试一试:
1 1 -1 -4 5 -3 6 7 -17 3 5 -2 8 has subsequence: 5 -3 6 7
1 1 -1 -4 5 2 6 7 has subsequence: 5 2 6 7
-5 1 -3 -4 has subsequence: 1
-5 -2 -3 -4 has subsequence: -2
-5 1 1 1 -1 -3 1 1 has subsequence: 1 1 1
-10 2 3 -2 0 5 -15 has subsequence: 2 3 -2 0 5
这是正确的。
高维数组
C# 和 C++/CLI 都允许多维托管数组的分配和初始化。如前所述,有两种类型的多维数组:
- 矩形阵列:这些是矩形,或块,其中每个维度的元素数量是常量。
- 交错数组:这些是数组的数组。尽管每个子阵列的类型必须相同,但每个子阵列都有不同的维度。
基础
下面是一个简短的 C# 程序,展示了矩形和锯齿状数组的运行情况:
using System;
class R
{
static void Main()
{
int[,] Rect = new int[3,4];
for(int i=0; i<3; i++)
{
for (int j=0;j<4;j++)
{
Rect[i,j]=i+j;
}
}
int [][] Jagged = new int[3][];
for(int i=0; i<3; i++)
{
Jagged[i] = new int[i+1];
for(int j=0; j<i+1; j++)
{
Jagged[i][j]=i+j;
}
}
}
}
第一个数组Rect
是 12 个元素的 3,4 矩形数组。它是用以下语法声明的:
int[,] Rect = new int[3,4];
下面是使用伪模板语法的 C++/CLI 对等用法:
array<int, 2>^ Rect = gcnew array<int, 2>(3, 4);
这一行表示我们正在分配一个整数类型的二维数组。第二个数组是锯齿状的,包含三个不同长度的数组。它是用以下语法声明的:
int [][] Jagged = new int[3][];
C++/CLI 等效项使用伪模板语法,如下所示:
array<array<int>^>^ Jagged = gcnew array<array<int>^>(3);
C++/CLI 代码说Jagged
是对整型数组的引用数组的引用。以下是等效 C++/CLI 中的完整代码片段;注意数组元素的用法是相同的:
using namespace System;
ref struct R
{
static void Main()
{
array<int, 2> ^Rect = gcnew array<int, 2>(3,4);
for(int i=0; i<3; i++)
{
for (int j=0;j<4;j++)
{
Rect[i,j]=i+j;
}
}
array<array<int>^> ^Jagged = gcnew array<array<int>^>(3);
for(int i=0; i<3; i++)
{
Jagged[i] = gcnew array<int>(i+1);
for(int j=0; j<i+1; j++)
{
Jagged[i][j]=i+j;
}
}
}
};
void main() {R::Main();}
差异
矩形阵列和锯齿状阵列之间的主要区别之一是隔离阵列的单行的能力。例如,假设Rect
和Jagged
在前面的例子中定义。如果我们试图使用下面的语法来回忆第一行的长度,会发生什么?
int k = Rect[0]->Length;
编译器发出以下诊断信息:
t.cpp(23) : error C3262: invalid array indexing: 1 dimension(s)
specified for 2-dimensional ‘cli::array<Type,dimension>^’
with
[
Type=int,
dimension=2
]
t.cpp(23) : error C2227: left of ‘->Length’ must point to
class/struct/union/generic type
type is ‘int’
这个正确的诊断指出Rect
需要两个索引而不是一个,并且Rect[0]->Length
不解析任何东西(因为它不是子数组)。另一方面,下面的代码
int k = Jagged[0]->Length;
是完全有效的,结果是k=1\. Jagged[i]
本身就是一个数组。事实上,我们甚至可以将它作为参数传递给前面定义的MaxSubSequence()
方法,如下所示:
MaxSubSequence(Jagged[0]);
交错数组的真实示例
在我们结构化和有组织的世界中,人们更难想象交错数组的用途。作为一个例子,虽然,每个学生都有一些关于每个家庭作业和测试的记录信息,无论该信息是分数还是没有进行测试的指示,但是不同的班级有不同数量的学生,并且学生参加了不同数量的班级。交错数组是表示数据结构的理想方法,在这种数据结构中,子项的元素数量因项目而异。
假设我们正在跟踪著名画家的主要作品。一个艺术家留存下来的画作数量和这个艺术家的影响力之间往往没有什么关联。米开朗基罗只有一幅现存的架上绘画,但试图忽视他的影响肯定会被误导。
考虑以下交错数组的示例:
using namespace System;
ref struct Painting
{
String ^artist;
String ^name;
int date;
Painting(String ^artist, String ^name, int date)
{
this->artist = artist;
this->name = name;
this->date = date;
}
virtual String ^ToString() override
{
return String::Format("{0} ({1})", name, date);
}
};
ref struct R
{
static void Main()
{
array<array<Painting^>^> ^Painters =
{
{
gcnew Painting("Leonardo da Vinci", "Mona Lisa", 1505)
},
{
gcnew Painting("Marc Chagall", "I and the Village", 1911),
gcnew Painting("Marc Chagall", "La Mariee", 1927)
}
};
for each(array<Painting^>^ painter in Painters)
{
Console::WriteLine("Paintings by {0}", painter[0]->artist);
for each(Painting ^painting in painter)
{
Console::WriteLine(" {0}",painting);
}
}
}
};
void main() {R::Main();}
在这个例子中,我们创建了一个参差不齐的数组Painters
。Painters
的每个元素都是对应画师的Painting
的一个子数组,长度不一。
如果我们编译并执行它,我们会看到以下内容:
C:\>cl /nologo /clr:pure test.cpp
test.cpp
C:\>test
Paintings by Leonardo da Vinci
Mona Lisa (1505)
Paintings by Marc Chagall
I and the Village (1911)
La Mariee (1927)
高维托管数组摘要
矩形数组是已定义类型的块,它们被如此分配、定义和访问。交错数组是数组的数组,它们反映了这一点。
本机数组
原生数组也可以在 C++/CLI 中使用,但是最好在第九章和第十八章中的指针和其他原生结构的上下文中讨论原生数组的使用。
摘要
关键在于如何处理数据,这是编程的关键。现在,您应该对内置类型和数组非常有信心了。请继续关注队列、树等等!
在下一章,我们将开始深入研究 C++ 在多态和保护方面的面向对象特性。
Footnotes 1
标准转换包含在 C++ 标准、ISO/IEC 14882:2003(E)的第四章中。
八、多态和保护
我们遇到了敌人,他就是我们。—沃尔特·凯利
C# 和 C++ 都是面向对象的语言,这意味着这两种语言都支持创建对象,这些对象不仅封装数据,还封装数据上的相关方法或操作。这两种语言还提供了将不同对象相互关联的机制,范围从运算符重载到继承、接口和参数多态。这两种语言都提供了限制和控制数据访问的机制,从限制可访问性和可见性到使用属性扫描和过滤数据。
在这一章中,我们将讨论其中的一些机制,因为它们与 C# 的机制不同。
多态
多态,来自希腊语 poly morphos,意思是“许多形状”,是许多科学领域的常用术语,并正在迅速进入日常用语。在面向对象的编程中,多态指的是对象可以被分组和分类的方式,因此一个对象可以被视为不同的对象、一组对象的成员,或者根据定义的特征被访问。下面的列表强调了多态中的一些重要概念:
- 继承:“继承”是指把对象当作不同的对象。这是通过允许一个对象或一般意义上的类从其他类(称为基类)中提取数据和特征来实现的。例如,
GoldenRetriever
对象可能有一个基类Dog
。在这种情况下,GoldenRetriever
对象可以被视为Dog
对象,因为它们是。 - 接口:“接口”是指根据对象的特性来考虑对象;接口允许你根据对象能做什么而不是它们是什么来组织对象。比如
Car
和Dog
物体都可以发出噪音。表示这两个对象都支持MakeNoise
接口通常要简单得多,而不是让它们都从一个公共的NoiseMaker
基类继承。从NoiseMaker
类继承会有问题,因为不是所有的动物都会说话,但很多动物会。比如长颈鹿没有声带,兔子会吱吱叫,咆哮;法律规定汽车要有喇叭,但喇叭可能会断。 - 泛型:将对象视为对象组(在 CLI 中)是通过参数多态或泛型实现的。在这种情况下,
Kennel<Animal>
可以表示一个Kennel
中的一个Animal
。在Kennel
中个体Animals
行为的专门化可以使用约束来完成。泛型最适用于对泛型类不透明的类型和实现泛型类支持的多个接口的类型。 - 模板:“模板”是指将对象视为对象组(仅在 C++ 中)。模板类似于泛型,因为它们允许为一组对象编写代码,但它们也允许特定于对象的代码或专门化(全部或部分)来处理对象之间的差异。模板通常比泛型更强大,也更复杂。
继承
继承是初级 C# 书籍中的常见主题。与本文的目标相一致,在这一节中,我试图将重点放在 C# 中的继承和 C++ 中的继承之间的区别上。
通常,C# 实现 CLI 继承模型。C++ 也实现了这个模型,但是它在几个方面建立并扩展了它。其中一些扩展在 CLI 上受支持,并在安全或纯模式下编译。其他的则超出了 CLI 的范围,必须使用本机 C++ 来实现。
多重和虚拟继承
在 C# 中,每个类只能从一个基类继承。C++ 支持多重继承,这意味着一个类可以有多个基类。多重继承无疑增加了复杂性,而这正是 CLI 所避免的。让我们考虑一个简短的例子。
假设你有一个Mule
对象。嗯,一个Mule
物体可以被认为是一个Horse
和一个Donkey
,对吗?毕竟,Mule
是一个雄性Donkey
和一个雌性Horse
的产物,因此您可能拥有以下有效的本地 C++:
class Horse {};
class Donkey {};
class Mule : Horse, Donkey {};
为了编译它,让我们使用/c
选项来表示我们只想编译,而不是创建一个可执行文件;这样我们就跳过了链接的开销,我们并不关心链接是为了观察编译,并且避免了链接器错误,这表明我们还没有定义全局函数main()
:
cl /c /clr:pure /nologo test.cpp
现在我们知道我们可以创建一个Mule
对象,并将其视为Horse
或Donkey
的实例。这就是事情开始变得棘手的地方。
马和驴不都是动物吗?让我们将它添加到代码中:
class Animal {};
class Horse : Animal {};
class Donkey : Animal {};
class Mule : Horse, Donkey {};
在这种情况下,对于给定的Mule
,创建了多少个Animal
的实例?应该创造多少?也许对于某些对象范例,您希望有两个公共基类。在这种情况下,实际上只有一个Animal
,即Mule
,我们希望在代码中表示它。C++ 允许你使用这两种范例来定义你的类层次结构。如果我们只想要一个Animal
,就像我们在这个例子中所做的,我们可以使用virtual
关键字进行虚拟继承。否则,我们就让它保持原样。虚拟继承告诉编译器在类层次结构中每种类型只包含一个子对象。假设我们要给骡子喂午餐,而Lunch
由一个Apple
对象和一个Carrot
对象组成。在这种情况下,Lunch
和Apple
之间的关系不是is
关系;更确切的说Lunch
包含Apple
和Carrot
,它们是Food
的两块。在这种情况下,我们不想使用虚拟继承。让我们看看完整的 C++ 程序:
using namespace System;
class Animal
{
public:
Animal()
{
Console::WriteLine("Animal");
}
};
class Horse : virtual Animal {};
class Donkey : virtual Animal {};
class Mule : Horse, Donkey {};
class Food
{
public:
Food()
{
Console::WriteLine("Food");
}
};
class Apple : Food {};
class Carrot : Food {};
class Lunch : Apple, Carrot {};
void main()
{
Mule m;
Lunch l;
}
如你所见,Horse
和Donkey
实际上都是从Animal
继承的。让我们试一试:
C:\>cl /nologo /clr:pure test.cpp
test.cpp
C:\>test
Animal
Food
Food
我们完成了创建一只动物并为它提供两份食物的目标。
私有和受保护的继承
与 C# 不同,对基类的访问可以通过私有或受保护的继承来限制。这些将在“保护机制”一节中讨论
CLI 和多重继承
CLI 允许使用接口的多重继承类型。假设我们试图为我们的Food
类层次结构使用引用类型而不是本机类型:
ref class Food {};
ref class Apple : Food {};
ref class Carrot : Food {};
ref class Lunch : Apple, Carrot {};
让我们编译这段代码:
C:\>cl /c /nologo /clr:pure test.cpp
test.cpp
test.cpp(4) : error C2890: 'Lunch' : a ref class can only have one non-interface
base class
the ref class 'Apple' is a base class of 'Lunch'
the ref class 'Carrot' is a base class of 'Lunch'
错误 2890 表明我们不能这样做,在某种程度上,这是一种祝福。让你的对象从多个对象继承会使你的代码难以理解,而接口允许你做许多相同的事情而不会混淆。
接口
接口定义了一个对象能够做什么,而不是根据它是什么来处理一个对象并暗示它在此基础上做什么。
接口是规范
接口包含了类必须实现什么来支持接口的规范;如果你愿意,就把它当作一份合同。接口只允许实现静态方法。接口类似于抽象类,这将在本章后面描述和区分。
一个类可以从多个接口继承
不仅接口可以被继承,单个类除了从单个基类继承之外,还可以从多个接口继承。这允许您创建一个对象,该对象具有几个定义明确的方法或接口,您可以在这些方法或接口中使用它,但在类层次结构中仍然是有序的,因为它有一个唯一的基类。这种范式适用于绝大多数面向对象的应用程序。
接口可以从其他接口继承
因为接口可以从其他接口继承,所以对象可以定义与对象通信的基本协定。如果对象支持契约的更高级版本,这也允许更精细层次的通信的可能性。
值类型可以从接口继承
CLI 不允许值类型从其他类继承,但允许值类型继承接口。默认情况下,值类型是密封类,这意味着它们不能被继承,因此也不能被扩展。我们将在本章后面重新讨论密封类。
一个简单的例子
让我们回到动物农场,看看我们能用接口做些什么。假设我们想要创建类型为Dog
和Cat
的对象,并说它们可以做像吃饭和睡觉这样的事情。让Eat
和Sleep
接口似乎是合理的。然后我们可以使用Eat
和Sleep
作为Dog
和Cat
的基类,如下所示:
using namespace System
;
interface class Sleeps
{
void GoToSleep();
};
interface class Eats
{
void Feed();
};
ref struct Cat : Eats, Sleeps
{
virtual void GoToSleep()
{
Console::WriteLine("Cat is Catnapping");
}
virtual void Feed()
{
Console::WriteLine("Cat is Eating");
}
};
ref struct Dog : Eats, Sleeps
{
virtual void GoToSleep()
{
Console::WriteLine("Dog is Sleeping");
}
virtual void Feed()
{
Console::WriteLine("Dog is Eating");
}
};
void main()
{
Cat ^c = gcnew Cat();
Dog ^d = gcnew Dog();
c->Feed();
c->GoToSleep();
d->Feed();
d->GoToSleep();
}
Note
在 C++ 中,类型可以继承和限制对基类成员的访问,这设置了它们的默认可访问性。这种继承可以是公共的、私有的或受保护的。默认情况下,任何 C++ struct
、接口或 CLI 类型都会继承public
。使用struct
确保我们不会遇到任何保护问题。我们将在本章的后面重新讨论继承。
关于这个例子,有一些重要的事情需要注意:
- C++/CLI 使用
interface class
而不仅仅是interface
来声明一个接口,类似于它使用enum class
而不是enum
。 - 关键字
virtual
的使用与 C# 中相同,它允许派生类实现或重写方法。在多态术语中,最顶层(基)方法是实现,派生方法是重写。
这是一个非常基本的例子,但并不像我们希望的那样简单。由于Cat
和Dog
各自支持Eats
和Sleeps
接口,它们各自被强制实现GoToSleep()
和Feed()
功能。另一个低效之处是对于这两种动物来说,Feed()
的实现实际上是相同的。
一个合理的解决方案是创建一个Animal
类,它可以包含这些接口的默认行为。Dog
和Cat
可以继承Animal
。但是怎样才能阻止某人实例化一个Animal
?我们绝不希望这种情况发生;我们只想能够实例化Dog
和Cat
。下一节关于抽象类的内容会有所帮助。
抽象类
抽象类是不能实例化的类。接口回答这个问题,“这个类做这个吗?”抽象类回答了这样一个问题,“这个类是那个类的一种吗?”抽象类经常被使用;以下是一些例子:
- 当创建一个类时,使用抽象类,该类具有接口的默认行为,但不应被实例化。我们的例子就属于这一类。
- 当创建本质上从不实例化的类时,使用抽象类,因为它是由静态方法的集合组成的。
System::Console
是这种类型的类,包含静态方法,如Write()
和WriteLine()
。甚至不要尝试实例化一个类型为Console
的对象——你将无法做到。
让我们将Animal
做成一个抽象类,并为它配备接口的默认方法。我们还可以应用一个小技巧。让我们用ToString()
来获取动物的名字,这样就不用硬编码到例程中了。下面是新代码:
using namespace System;
interface class Sleeps
{
void GoToSleep();
};
interface class Eats
{
void Feed();
};
ref struct Animal abstract: Eats, Sleeps
{
virtual void GoToSleep()
{
Console::WriteLine("{0} is Sleeping", ToString());
}
virtual void Feed()
{
Console::WriteLine("{0} is Eating", ToString());
}
};
ref struct Cat : Animal
{
virtual void GoToSleep() override
{
Console::WriteLine("{0} is Catnapping", ToString());
}
};
ref struct Dog : Animal
{
};
void main()
{
Cat ^c = gcnew Cat();
Dog ^d = gcnew Dog();
c->Feed();
c->GoToSleep();
d->Feed();
d->GoToSleep();
}
这段代码得到了极大的改进,并且更易于维护。类名Animal
后面的abstract
关键字表示Animal
是一个抽象类。Dog
不包含任何方法,对所有接口使用默认的Animal
行为。Cat
只包含需要改变的方法,并使用Eats::Feed()
的默认行为。由于GoToSleep()
函数已经存在于Animal
中,我们被迫添加关键字override
来表明我们想要如何替换这个方法。关键词new
也是一种可能;我们将在本章后面讨论这一点。
关于这个例子,只有一点令人不安,因为它与我们之前对System::Console
的讨论有关。在这个例子中,我们不能实例化一个类型为Animal
的对象,但是我们能够从Animal
中派生出一个类Dog
并实例化它,并且Dog
使用了Animal
的所有方法。在某种程度上,Dog
是不抽象的Animal
的克隆。这看起来像是实例化一个System::Console
的后门方法,我们不希望允许这样——如果有一种方法可以表明一个类不能作为任何其他类的基类。你的愿望就是我的命令。
密封类
回想一下,密封类是不能通过继承来扩展的类。例如,让我们看看当您有一个名为Base
的密封类,并试图从中派生时会发生什么:
class Base sealed {};
class Derived : Base {};
现在让我们编译它:
C:\>cl /clr:pure /nologo test.cpp
test.cpp
test.cpp(2) : error C3246: 'Derived' : cannot inherit from 'Base' as it has been
declared as 'sealed'
test.cpp(1) : see declaration of 'Base'
正如我们所料;它不能被扩展,因为它是一个密封类。现在让我们看看当你同时声明一个类为abstract
和sealed
时会发生什么。
静态类
让我们使用。在mscorlib.dll
中查看净反射器,并查看System::Console
的声明:
public ref class Console abstract sealed
{
};
将一个类同时声明为abstract
和sealed
会进一步限制该类。它只允许有静态成员、嵌套类型、文本字段和typedef
。C # 中与abstract sealed
类等价的是定义一个static
类。如果你将视图切换到。NET Reflector 转换为 C#,您会看到以下内容:
public static class Console
将Console
同时声明为abstract
和sealed
,或者在 C# 中声明为static
,这允许它成为静态方法的容器,并且它既不能被实例化也不能从其派生。有趣的是,在 C++ 中,这可以通过使用命名空间来限定和包含一组全局函数来实现。当然,你可能会想,“但是全局函数可以导出到程序集之外吗?”你有理由怀疑;答案是他们不能。当在一个类中使用时,C# 语言中的关键字static
只是一种语法糖。如果你看看 CIL。NET Reflector,您会在元数据中看到abstract
和sealed
描述符。这些类似于 C++ 语言的上下文相关的关键字,所以如果你计划从 C++ 程序集中导出你包含的函数组,不要使用名称空间;只需要使用一个抽象的密封类。C++ 是一种灵活的语言,根据您的需求,您可以自由地以不同的方式实现您的目标,在这种情况下,这就是函数组的封装级别。
方法
基类和派生类中的方法通常具有相同的名称并执行相同的功能。在前面的例子中,我们有一个方法GoToSleep()
,它在基类中实现,在派生类中重新实现。如果你创建了一个派生类的实例,并把它当作一个派生类来对待,不用说你会更喜欢使用这个方法的派生版本。但是,当您将派生类视为基类的实例时,会发生什么呢?这在面向对象编程中相当常见;例如,您可能想从一个Animal
集合中创建一个Menagerie
。如何告诉编译器使用公共方法的哪个实现?
虚拟方法
简而言之,虚方法是一个基类方法,可以通过派生类的实现来更改。如果一个方法没有被标记为virtual
,那么将派生类作为基类的一个实例将会恢复基类的实现。
使用虚方法使您能够选择派生类中的方法实现是否替换基类中的实现。在 C# 和 C++ 中,都可以创建虚方法。除了语法之外,C# 和 C++ 中的虚方法之间没有什么区别,但是我想在这里回顾一下这个主题,因为它似乎在许多文本中被掩盖了或者解释得不够充分。
让我们考虑一个有点做作的例子,只是为了娱乐。
虚拟和非虚拟方法的例子
Animal
是基类,John
是派生类。John
是一个普通人,当你让他问好时,他会说:“你好。”另一方面,如果你像对待Animal
一样对待John
,他就被降低到了动物的水平,只能咕哝着“唉”不管你怎么对待他,他还是John
,他的名字也不会变:
using namespace System;
ref struct Animal
{
virtual String ^ Name()
{
return "Animal";
}
String ^Hello()
{
return "ugh";
}
};
ref struct John : Animal
{
virtual String ^ Name() override
{
return "John";
}
String ^Hello()
{
return "Hello";
}
};
void main()
{
John ^j = gcnew John();
Console::WriteLine("{0} says {1}", j->Name(), j->Hello());
Console::WriteLine("Oh no! He's an Animal! ");
Animal ^a = j;
Console::WriteLine("{0} says {1}", a->Name(), a->Hello());
}
在这个例子中,我们在每个基类和派生类中都有两个方法:Name()
和Hello()
。方法Name()
是虚拟的。它在两个类中都用关键字virtual
声明,关键字override
也在派生类中使用(稍后将详细介绍)。因为是虚拟的,John
对Name()
的实现替换了Animal
对John
所有实例的实现。
另一方面,Hello()
没有标记virtual
,所以当我们把John
的实例当作John
时,我们看到的是John
对Hello()
的实现,当我们把John
的实例当作Animal
时,我们看到的是Animal
对Hello()
的实现。这给了我们想要的结果:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
John says Hello
Oh no! He's an Animal!
John says ugh
通过这种方式,我们能够挑选出基类中的哪些方法被派生类中的方法的实现所替换。
使用方法
C++/CLI 有几种重写虚方法的方式。每一个都是按照特定的模式创建的。我想回顾一些范例,并在适当的时候揭示动机。这让我们可以看到实现如何影响所需的语法,以及编译器如何帮助我们做正确的事情,并在潜在的危险情况发生时发出诊断。
基类和派生类中的非虚拟方法
在这种情况下,我们在基类和派生类中都有一个普通的方法。我们之前在John
和Animal
中都使用了Hello()
方法。这里要注意的关键是,尽管在使用派生类的实例时,派生实现隐藏了基实现,但是当相同的实例被强制转换为基类时,基实现会恢复。此外,使用Base::
限定前缀仍然可以从派生类中访问基方法,类似于我们在名称空间中指定和访问定义的方式:
using namespace System;
ref struct Base
{
void Method()
{
Console::WriteLine("Base::Method");
}
};
ref struct Derived : Base
{
void Method()
{
Console::WriteLine("Derived::Method");
}
void MethodBase()
{
Base::Method();
}
};
void main()
{
Derived ^d = gcnew Derived();
Console::Write("from the Derived class: ");
d->Method();
Console::Write("from the Derived class: ");
d->MethodBase();
Base ^b = d;
Console::Write("from the Base class: ");
b->Method();
Console::Write("from the Base class: ");
d->Base::Method();
}
让我们编译一下,试一试:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
from the Derived class: Derived::Method
from the Derived class: Base::Method
from the Base class: Base::Method
from the Base class: Base::Method
输出清楚地表明,我们能够从派生类中访问派生类和基类方法,并从基类中访问基类方法。可以从基类中访问派生的方法吗?
基类和派生类中的虚方法
让我们从一个类似的代码示例开始这个副标题:
using namespace System;
ref struct Base
{
virtual void Method()
{
Console::WriteLine("Base::Method");
}
};
ref struct Derived : Base
{
virtual void Method() override
{
Console::WriteLine("Derived::Method");
}
void MethodBase()
{
Base::Method();
}
};
void main()
{
Derived ^d = gcnew Derived();
Console::Write("from the Derived class: ");
d->Method();
Console::Write("from the Derived class: ");
d->MethodBase();
Base ^b = d;
Console::Write("from the Base class: ");
b->Method();
}
此代码生成以下输出:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
from the Derived class: Derived::Method
from the Derived class: Base::Method
from the Base class: Derived::Method
现在这个产量是非凡的。将方法更改为virtual
仅更改了第三个输出行;在Base
中,Method()
已经被替换为Derived::Method()
用于隐式调用。显式调用原来的Base::Method()
仍然是可能的,如第二行所示。事实上,以下代码的第一行总是调用原始的Base::Method()
,不管它是在基类中还是在派生类中,因为它完全限定了名称:
void Test()
{
Base::Method();
Method();
}
第一个调用Base::Method()
,调用基类实现。第二个调用Method()
,调用Derived::Method()
或Base::Method()
,这取决于这个方法在哪里实现以及我们是否使用了虚函数。
请注意,override
关键字被添加到派生类中Method()
的声明中。
如果您没有在此处包含关键字,您将看到以下诊断信息:
C:\>cl /clr:pure /nologo test.cpp
test.cpp
test.cpp(15) : error C4485: 'Derived::Method' : matches base ref class
method 'Base::Method', but is not marked 'new' or
'override'; 'new' (and 'virtual') is assumed
test.cpp(4) : see declaration of 'Base::Method'
Specify 'override' (and 'virtual') to override the ref class virtual method
Specify 'new' (and 'virtual') to hide the ref class virtual method with a
new virtual method
Position for 'new' and 'override' keywords is after method parameter list
这个诊断相当复杂,但它基本上归结为这样一个事实,即在这个上下文中要么需要关键字override
要么需要关键字new
。
关键字 new 和 override
当派生类隐藏基类的虚方法时,为了防止意外的结果,编译器需要一个显式的关键字,new
或override
,以指示您希望该方法如何隐藏基类方法。
关键字override
的基本原理相当简单,在本章中我们已经多次看到了它的用法。当您希望基类的实现被派生类的实现覆盖时,可以使用override
关键字。
另一方面,关键字new
则完全不同。该关键字用于说明您正在指定该方法作为该类的虚方法,作为另一个类的基类。它有效地开始新的虚拟链并丢弃旧的虚拟链。
下面是一个例子:
using namespace System;
ref struct Base
{
virtual void Method()
{
Console::WriteLine("Base::Method");
}
};
ref struct Derived : Base
{
virtual void Method() new
{
Console::WriteLine("Derived::Method");
}
};
ref struct Derived2 : Derived
{
virtual void Method() override
{
Console::WriteLine("Derived2::Method");
}
};
void main()
{
Derived2 ^d2 = gcnew Derived2();
d2->Method();
Derived ^d = d2;
d->Method();
Base ^b = d;
b->Method();
}
让我们看看这个代码示例。它有三个类,Base
、Derived
、Derived2.
、Derived
也是Derived2
类的基类。当您使用Derived2
的一个实例并调用Method()
时,您会得到Derived2
的实现。当您将这个实例强制转换为Derived
时,您也获得了Derived2
的实现,因为override
关键字在Derived2
上被用来覆盖Derived
的Method()
版本。然而,当你进一步转换到Base
时,你就超越了Derived::Method()
虚拟链,因为new
关键字被用在Derived
上来说明Method()
相对于Base
应该如何被处理。结果是使用了Base::Method()
的调用,因为Derived2::Method
没有覆盖Base::Method
。
以下是反映这一点的输出:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
Derived2::Method
Derived2::Method
Base::Method
您可能会问,“什么可能的范例会要求您使用这样的构造?”如果不对每个方法的声明进行彻底的检查,这似乎是困难和不可预测的。事实证明,你不用费多大力气就能找到这种结构的合理需求。
假设您正在编写使用第三方基本类库的代码。假设您正在创建从一个名为Component
的第三方对象派生的对象。
据你所知,Component
看起来像下面这样:
ref struct Component
{
};
您创建了自己的更复杂的组件,因此您可以从中派生出其他子类。您向它添加了一个方法,名为Act()
,它在基类库中还不存在。您可以向高级组件添加一个更高级的版本来覆盖基本版本。您最终会得到如下结果:
ref struct MyBasicComponent : Component
{
virtual void Act() {}
};
ref struct MyAdvancedComponent : MyBasicComponent
{
virtual void Act() override {}
};
假设这在几代代码中都能很好地工作。你已经发布了你的接口,其他人正在依赖你的名为Act()
的例程。然后,您的第三方宣布它已经升级了它的基本类库并修复了几个令人烦恼的问题。您购买了它并试图重新编译,您发现已经为基本组件发布了一个版本的Act()
。
您现在有以下选择:
- 重命名代码中的每一个
Act()
实例,这样它就不会冲突,从而混淆您的客户并破坏他们对您的接口的实现。 - 覆盖第三方版本的
Act()
,使其内部例程调用您的版本Act()
。这是使用override
关键字完成的。 - 忽略第三方版本的
Act()
,因为它要么不做同样的事情,要么用你的第三方版本替换它是不合适的。和平共处是可能的,你可以使用Component::Act()
或者通过将你的对象转换为Component
来调用另一个版本的Act()
。这是使用new
关键字完成的。
第一个选项通常是不合理的,但是另外两个独立地证明了它们各自的关键字的必要性。
用不同的方法名重写
如果第三方基类库出来了一个你想重写的新方法,但是你已经在代码里给了一个不同的名字怎么办?
您可以添加代码来链接到第三方方法,或者您可以使用命名重写语法。命名重写语法将关键字override
替换为您正在替换的方法的限定名,并允许您重写基类的实现,即使它的名称不同。下面是它的使用方法:
using namespace System;
ref struct Component
{
virtual void ActOut()
{
Console::WriteLine("Component::ActOut");
}
};
ref struct MyBasicComponent : Component
{
virtual void Act() = Component::ActOut
{
Console::WriteLine("MyBasicComponent::Act");
}
};
ref struct MyAdvancedComponent : MyBasicComponent
{
virtual void Act() override
{
Console::WriteLine("MyAdvancedComponent::Act");
}
};
void main()
{
MyAdvancedComponent ^ac = gcnew MyAdvancedComponent();
ac->Act();
MyBasicComponent ^bc = ac;
bc->Act();
Component ^c = bc;
c->ActOut();
}
如你所见,MyBasicComponent::Act()
声明中的ActOut()
被替换为Act()
。
让我们看看当我们尝试执行这个命令时会发生什么:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
MyAdvancedComponent::Act
MyAdvancedComponent::Act
MyAdvancedComponent::Act
如你所见,Act()
和ActOut()
的所有版本都被替换为最先进的组件的Act()
方法,在MyAdvancedComponent
中声明。
虚拟方法摘要
您现在应该对虚方法中固有的可能性有了很好的了解。使用new
和override
关键字,不仅可以创建方法的虚拟链,而且可以链接虚拟链本身,即使方法的名称不同。
访问基类字段和方法
在 C# 中,当你想访问基类方法和字段时,你使用base
关键字。因为 C++ 中的本地类支持多重继承,所以对于 C++/CLI 来说,这样的语法要么是不明确的,要么是不一致的。C++/CLI 采用 C++ 语法,并要求您使用完全限定语法指定基类的名称。我们在前面的例子中做了一点;让我们在这里更详细地回顾一下。
考虑下面的 C# 示例:
using System;
class Base
{
public int i;
}
class Derived : Base
{
public new int i;
public void Access()
{
base.i = 3;
i=4;
Console.WriteLine("Base i = {0}, Derived i = {1}",base.i, i);
}
public static void Main()
{
Derived d = new Derived();
d.Access();
}
}
请注意以下要点:
- 使用派生类
Derived
中的关键字new
声明变量i
,以表明它隐藏了基类Base
中的变量i
。在这个上下文中没有使用override
关键字。 - 使用表达式
base.i
从方法Access()
中访问基类中的变量i
。
让我们比较和对比以下 C++/CLI 版本:
using namespace System;
ref struct Base
{
int i;
};
ref struct Derived : Base
{
int i;
void Access()
{
Base::i = 3;
i=4;
Console::WriteLine("Base i = {0}, Derived i = {1}",Base::i, i);
}
static void Main()
{
Derived ^d = gcnew Derived();
d->Access();
}
};
void main() {Derived::Main();}
关于此代码,需要注意以下几点:
Derived
中的变量i
不需要关键字new
。它隐式隐藏基类版本。- 使用语法
Base::i
从方法Access()
中访问基类中的变量i
。因此,基类的名称是显式命名的。这对于本机类有几个好处,因为它不仅允许您在各种基类之间进行选择,还允许您轻松地访问祖父类。
保护机制
像 C# 一样,C++ 有几种保护机制来管理数据访问:
- 可见性:这种机制影响外部程序集是否可以使用程序集中的顶级类型。
- 可访问性:可访问性影响构造是否可以访问给定类型中的方法和字段。
- 受限继承:这项功能在 C# 或 CLI 对象模型中不存在,它允许您重写本机 C++ 派生类型的可访问性。
能见度
一个非嵌套的class
、struct
、interface
、delegate
或enum
的可见性决定了它是否能在其父组件之外被看到。类的可见性是通过在类定义前添加 visibility 关键字来设置的。非嵌套类型的默认可见性是private
,例如:
public ref class R {}; //visible outside the assembly
private ref class S {}; //visible only within the assembly
ref class T {}; //defaults to private visibility
表 8-1 将 C# 可见性关键字映射到顶级类型的 C++/CLI 可见性关键字;包含了来自System::Reflection::TypeAttributes
名称空间的名称。
表 8-1。
Visibility Keywords in C# and C++/CLI for Top-Level Types
| 顶级类型 | 类型属性 | C# | C++/CLI | | --- | --- | --- | --- | | 可见性仅限于当前装配 | 不公开 | `internal` | `private` | | 对外部组件和当前组件可见 | 公众 | `public` | `public` |表 8-2 将 C# 可见性关键字映射到嵌套类型的 C++/CLI 可见性关键字。
表 8-2。
Visibility Keywords in C# and C++/CLI for Nested Types
| 嵌套类型 | 类型属性 | C# | C++/CLI | | --- | --- | --- | --- | | 公众可见度 | NestedPublic | `public` | `public:` | | 私人可见性 | NestedNotPublic | `private` | `private:` | | 仅对其程序集中类型的方法可见 | 嵌套装配 | `internal` | `internal:` | | 对其自身类型和子类型中的方法可见 | 成套类 | `protected` | `protected:` | | 对其自身程序集或自身类型或子类型中的方法可见 | nestedfamilyorasassembly | `internal protected` `protected internal` | `public protected:` | | 对它自己的程序集中和它自己的类型或子类型中的方法可见 | NestedFamilyAndAssembly | 不适用的 | `private protected:` |嵌套类型的 C++/CLI 代码示例如下:
public ref class publicClass
{
public:
ref class NestedPublic
{
};
private:
ref class NestedPrivate
{
};
internal:
ref class NestedAssembly
{
};
protected:
ref class NestedFamily
{
};
private protected:
ref class NestedFamilyAndAssembly
{
};
public protected:
ref class NestedFamilyOrAssembly
{
};
};
易接近
可访问性经常与可见性混淆。可见性决定了哪些类型是可见的;可访问性决定了在可见类型中可以访问哪些字段和方法。
有几种不同的可访问性指标;它们由一个后跟冒号的关键字构成。
在 C# 中,为每个成员声明可访问性。如果在 C# 中没有声明成员的可访问性,默认情况下可访问性变成private
。
在 C++ 中,可访问性是模态的,可访问性的设置独立于任何成员。所有后续成员都被赋予前面的可访问性声明的可访问性。如果在 C++ 中没有在一个类型中声明可访问性,那么对于struct
默认为public
,对于class
默认为private
。
总之,C# 可访问性是为每一项设置的,C++ 可访问性是由影响所有其他成员的可访问性声明设置的。C++/CLI 可访问性的定义就像嵌套类型一样(参见表 8-2 )。
Note
class
默认具有private
的可访问性;struct
默认有public
可达性。
继承
在 C++ 中,你也可以通过继承来影响基类成员的可访问性。基类的公共和受保护成员可以像是派生类的成员一样被访问。无论派生类如何继承,派生类都无法访问基类的私有成员。
Note
CLI 类型,包括引用和值类型,总是继承public
。
通过在基类的名称前指定下列关键字之一,可以声明派生类应该如何继承:
public
:基类的成员public
或protected
被分别视为派生类的public
或protected
成员。基类的每个成员都保留其在派生类中的可访问性。private
:基类的成员public
或protected
被视为派生类的成员private
。基类的每个成员都成为派生类中的private
。protected
:基类的成员public
或protected
被视为派生类的成员protected
。
如您所见,继承只能降低成员的可访问性,而不能增加。在派生类中允许比基类中更大的可访问性会违背保护和类封装的目的。
一个派生的class
默认继承private
,一个派生的struct
默认继承public
,有以下主要的警告:默认情况下 CLI 类型总是继承public
。
例如,考虑以下情况:
ref struct Base
{
int var;
};
ref class Derived : Base
{
public:
void Test()
{
var = 3;
}
};
void main()
{
Derived d;
d.var = 3;
}
在这个例子中,Derived
从Base
公开继承,因为它们都是引用类型,并且引用类型总是公开继承。成员var
在基类中是公共的,所以无论派生类如何继承,它在派生类中都是可访问的。唯一的问题是它是否可以通过函数main()
中的实例变量d
访问。
让我们试一试:
C:\>cl /c /clr:pure /nologo test.cpp
C:\>
没有诊断出现,所以我们是成功的。现在让我们尝试用这些引用类型进行私有继承。更改以下行:
ref class Derived : Base
到
ref class Derived : private Base
现在让我们再试一次:
C:\>cl /c /clr:pure /nologo test.cpp
test.cpp(6) : error C3628: 'Derived': managed classes only support public
inheritance
test.cpp(17) : error C2247: 'Base::var' not accessible because 'Derived' uses
'private' to inherit from 'Base'
test.cpp(3) : see declaration of 'Base::var'
test.cpp(5) : see declaration of 'Derived'
test.cpp(2) : see declaration of 'Base'
如您所见,托管(CLI)类型总是公开继承。
现在更改以下代码:
ref struct Base
ref class Derived : private Base
要使这些类型成为本机类型并删除private
关键字:
struct Base
class Derived : Base
让我们再试一次:
C:\>cl /c /clr:pure /nologo test.cpp
test.cpp
test.cpp(17) : error C2247: 'Base::var' not accessible because 'Derived' uses
'private' to inherit from 'Base'
test.cpp(3) : see declaration of 'Base::var'
test.cpp(5) : see declaration of 'Derived'
test.cpp(2) : see declaration of 'Base'
在这种情况下,Derived
是一个类,一个 C++ class
默认私有继承。
有几种方法可以解决这个问题。我们可以将Derived
从class
改为struct
,或者在基类名称前添加public
关键字。或者,我们可以将d
转换为Base
的一个实例,并以这种方式访问变量。
这里有一个改进的例子:
struct Base
{
int var;
};
struct Derived : Base
{
public:
void Test()
{
var = 3;
}
};
int main()
{
Derived d;
static_cast<Base&>(d).var = 4;
System::Console::WriteLine("{0}", d.var);
}
让我们运行它:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
4
在这个例子中,我们从Base
中公开继承,因为我们把Derived
改成了struct
。此外,我们还可以使用强制转换来访问基类变量,如函数main()
所示。我们将在第十六章中重温案例操作符。
声明 ref 结构和 ref 类
如您所见,使用struct
关键字而不是class
关键字声明引用类型或值类型会影响类型的默认可访问性。它不影响继承,因为在 CLI 对象模型中,所有继承都是公共的。它也不影响类型的可见性。
例如,考虑下面的简短代码示例:
ref class
R
{
static void Main() {}
};
void main()
{
R::Main();
}
如果您尝试编译它,您会得到以下结果:
C:\>cl /clr:pure /nologo test.cpp
test.cpp(7) : error C3767: 'R::Main': candidate function(s) not accessible
现在将R
改为ref struct
而不是ref class
,如下所示:
ref class R
{
static void Main() {}
};
该程序现在编译良好。我们可以使用检查生成的元数据和 CIL。网状反射器(见图 8-1 )。
图 8-1。
ref struct R under .NET Reflector
正如你所看到的。NET 反射器,类型R
仍然有私有可见性(private ref class R
),但是默认的可访问性是公共的(参见//Methods
中的第一个public:
)。因此,更改为ref struct
会影响可访问性,但不会影响可见性。
出于可访问性和可见性的目的,以下代码:
ref struct
R
{
};
相当于这样:
private ref class R
{
public:
};
霸王决议
可见性和可访问性之间的一个重要区别是,如果一个方法是可见的,即使它是不可访问的,它也会被考虑用于重载解析。这样做可能会隐藏另一个可行的重载。例如,考虑下面的例子:
class Base
{
public:
int f(int i)
{
return i;
}
};
class Derived : public Base
{
};
class Hello : Derived
{
void test()
{
f(3);
}
};
在本例中,f(3)
解析为Base::f(int i)
。另一方面,看看当我们修改Derived
来添加一个不可访问的函数时会发生什么:
class Derived : public Base
{
private:
int f(int i)
{
return i;
}
};
现在我们试着编译它:
C:\>cl /clr:pure /nologo test.cpp
test.cpp
test.cpp(21) : error C2248: 'Derived::f' : cannot access private member declared in
class 'Derived'
test.cpp(12) : see declaration of 'Derived::f'
test.cpp(9) : see declaration of 'Derived'
Base
中潜在可访问的方法被Derived
中不可访问的方法完全隐藏。解决办法是通过完全限定其名称— Base::f(3)
来访问Base
的方法。
按姓名隐藏和按签名隐藏
C# 和 C++ 中方法可访问性的一个关键区别是 C# 通过签名隐藏,而 C++ 通过名称隐藏。区别如下:如果派生类中的方法与基类中的方法同名,则基类方法是隐藏的,因为 C++ 实现了“按名称隐藏”在 C# 中,如果基类方法具有不同的签名,即它采用不同的函数参数集,则基类方法是可见的。
通过签名隐藏
考虑下面的 C# 示例:
using System;
class Base
{
public void f(int i)
{
Console.WriteLine("Base.f()");
}
}
class Derived : Base
{
public void f(char c)
{
Console.WriteLine("Derived.f()");
}
static void Main()
{
Derived d = new Derived();
d.f(3);
}
}
当我们编译并执行它时,我们得到如下结果:
C:\>csc /nologo test.cs
C:\>test
Base.f()
编译器首先收集可行的候选列表,然后根据传递的函数参数选择最佳匹配。在这个例子中,这个可行的候选列表包括了f()
的两个版本,因为它们具有不同的签名;f(int)
是最佳搭配。这个例子显示 C# 是“通过签名隐藏”,因为Base.f
和Derived.f
有不同的签名。
按名字隐藏
让我们看一个使用 C++/CLI 的类似示例:
using namespace System;
ref struct Base
{
public:
void f(int i)
{
Console::WriteLine("Base.f()");
}
};
ref struct Derived : Base
{
void f(wchar_t c)
{
Console::WriteLine("Derived.f()");
}
static void Main()
{
Derived ^d = gcnew Derived();
d->f(3);
}
};
void main() { Derived::Main(); }
请注意,这段代码或多或少是相同的,我们可以期待看到类似的结果:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
Derived.f()
同样,编译器首先收集可行的候选列表,然后根据传递的函数参数选择最佳匹配。在这个例子中,可行的候选列表只包括f()
的派生版本,因为f()
的基本版本和派生版本具有相同的名称。然后选择剩下的唯一候选,完成从int
到wchar_t
的隐式转换(wchar_t
是 C++/CLI 对System::Char
的别名,在 C# 中是char
)。是唯一的,也是最好的可用匹配。
至于哪种设计更好,人们可以从两方面进行讨论。将代码从 C# 转换为 C++ 的关键是要注意这种差异。从 C# 到 C++ 的自动翻译器(反之亦然)无法轻松处理这种差异,即使最终的程序可能产生不同的结果,翻译后的代码也可能编译无误。
摘要
在这一章中,我们讨论了多态和保护,以学习如何编写干净的面向对象的代码。现在,您应该对 C# 和 C++ 之间的主要类型差异有了很好的了解。建议你用。上的网状反射器。NET BCLs 进行一些探索,看看各种常见的方法是如何实现的。
在下一章中,我们将通过观察指针和不安全代码,从相反的角度来看 C++ 中的编码。
九、指针和不安全代码
为了你的工作可以增加人类的幸福,你应该了解应用科学是不够的。对人类自身及其命运的关注必须始终构成所有技术努力的主要兴趣;关心劳动组织和商品分配等尚未解决的重大问题,以使我们头脑中的创造成为人类的幸福而不是诅咒。在你的图表和方程式中,永远不要忘记这一点。—阿尔伯特·爱因斯坦
从一开始,指针的使用就是 C 和最终 C++ 中最好和最差的(尽管许多人会认为 C++ 模板的深奥使用给了指针一个机会)。没有任何其他特性能够让您剥去编程语言的外衣,直达核心,也没有任何其他特性容易产生隐藏的错误,而这些错误可能会在几年内不被发现。
C# 视图:是福是祸
C# 和。NET Framework 已经做了大量工作,使得在日常编程中不必使用指针。大多数常见的任务现在可以写成安全、可验证的代码。整个垃圾收集系统的设计考虑到了指针的废弃。引用类型,实现为分配的数据和指向。NET 中,在 C# 中显示为单个实体。句柄上的类型安全也被强制以避免指针问题。
可验证的代码更可取,因为它允许执行系统随时知道所有分配项的类型;它保证了所有类型的通用异常处理系统;它还提供了一个无泄漏、公共内存、托管内存分配系统。
尽管如此,C# 的设计者还是克制了自己,没有完全省略 C# 中的指针。
在使用unsafe
关键字并使用/unsafe
编译器选项编译的代码中,支持在 C# 中使用指针。另一方面,C++ 支持指针作为其主要编程模型的一部分。一个有趣的事实是,C# 默认情况下是可验证的,否则需要关键字或编译器选项来编译,而 C++ 默认情况下是不可验证的,使用编译器选项来编译可验证的。
指针:定义和警告
那么,到底什么是指针呢?指针是一种包含另一种数据类型地址的数据类型。这似乎没有什么害处,但是有一个问题:假设你设置一个指针指向一个类型的实例。这个想法是,你可以单独使用指针来引用实例。您可以将这个指针传递给其他方法,每个方法都在处理它认为是指向数据类型实例的指针;这通常很好。当数据超出范围,或者某个指针持有者以其他人意想不到的方式更改了它所指向的数据时,事情就开始出错了,因此,它不是指向堆栈上的数据实例,而是从一个完全不同的方法指向堆栈数据。那么,使用指针的方法实际上并没有写入数据实例;是写垃圾。
指针错误最常见的发生方式是指针和数据不同步;然后,指针和数据之间的契约,也就是指针指向有效数据的契约就被打破了。也许指针是旧的,或者数据不再存在,指针不指向任何地方。或者,指针可以作为访问数据的唯一方式开始,这在使用new
时很常见,后来指针超出范围,对数据的访问就丢失了。如果没有垃圾收集机制,这将导致不可引用或孤立的数据对象以及内存泄漏。
我分享一个朋友的战争故事作为例子。
HOW TO BRING A 5,000,000-FAULT–TOLERANT SERVER TO ITS KNEES BY PAUL CAYLEY
我假设所有阅读这本书的人都知道什么是内存泄漏,并且可能实际上已经造成了一些。尽管聪明人尽了最大努力,内存泄漏仍然司空见惯。当我听说 Java 将终结内存泄漏时,我不知道是应该欣喜若狂,还是应该持怀疑态度。“怀疑”似乎是正确的答案,因为贸易出版物充斥着消除 Java 内存泄漏工具的广告。
为什么内存泄漏如此难以消除?有很多原因。我认为最大的一个问题是设计时的决策对下游是不透明的。内存分配和释放发生在不同的时间和不同的地方。架构师和设计师没有充分地预见到他们的代码将如何被使用。更糟糕的是,有些人甚至认为其他人会阅读他们的文档和行内注释,并正确理解其中隐含的智慧和警告。(当然,这假设程序员记录了他们的代码,这可能是也可能不是。)
当然,愚蠢和草率也可能是原因之一。程序员可能会忘记他们正在做什么或已经做了什么。因为有些人写的代码很差——最终可能会链接到您的代码,调用您的位,甚至成为您的位——内存可能会一字节一字节地泄漏!我们来看一些例子。
还记得微软 Outlook 吗?Outlook 的早期版本每小时会泄漏大约 100KB 的内存,就在那里。因此,如果你习惯于连续几周打开电脑,你必须每隔几天退出并重新启动 Outlook。如果你不这样做,大约一周后,你的系统会变得缓慢和不可预测。
不幸的是,大多数程序员都希望事情能够正常运行,错误检查有时是不完整的或者完全被忽略了。在malloc()
空手返回后,一些程序可能继续前进,丢弃位并产生错误。该场景中的步骤如下:
Outlook leaks memory to the point that other programs and/or the OS become starved. Calls to malloc()
start failing in new and unusual places. Even though memory is freed by exiting Outlook, things are no longer stable. You bring up the Task Manager to kill the lingering Outlook Messaging Application Programming Interface (MAPI) pump, but things are iffy still. You rename outlook.exe
to lookout.exe
as a reminder and reboot.
没什么大不了的,您必须重新启动您的工作站——当然,除非您处于一个终端服务器环境中,在这个环境中,一个单独的机器托管许多用户会话。然后,您还可以享受 Outlook 的多个实例和谐地一起工作来泄漏大量内存的乐趣。当你重新启动时,每个人都必须重新开始——快乐,快乐,快乐,快乐!
让我们看看长时间运行流程的另一个场景——系统状态监视器。在这个场景中,您有一个支持关键任务应用程序的大型容错服务器。你不仅要为一个装有多个电源、RAID 驱动器、冗余网络等的盒子支付 50 万美元,还要为一些系统监控工具支付 5 万美元。这个系统将在未来十年全天候运行。然后,发布了操作系统的服务包,并公开了新的代码路径。监控应用程序运行良好,不会泄漏任何内存。不幸的是,检索 OS 状态更新的 OS API 确实会泄漏内存,所以每次通过这个 API 调用时,都会返回一个记录集,并且会丢失 50 字节或更多的内存。大约 3 个月后,监控软件将完成它的工作,重新启动防弹镀金服务器。
简而言之,内存泄漏是邪恶的。电脑不错。但是没有恶就没有善吗?
有效的目标和语法
在 C# 中,指针可以设置为值类型的地址或另一个指针。此外,指针可以自由地转换为其他指针。
由于托管堆中的对象被垃圾收集器以看似任意的时间间隔移动。NET 语言必须限制指向托管堆上对象的指针。C# 指针主要设计用于指针或值类型,它们驻留在堆栈上。C++/CLI 和 C# 都允许您临时固定对象在托管堆上的位置,尽管它们使用不同的语法。阻塞垃圾收集机制,即使是短暂的阻塞,看起来也很危险,但是对于调用本机 API(称为 InterOp)来说,这是非常有用的。我们将在第十九章和第二十章中再次讨论这些话题。
常见指针运算符
表 9-1 中列出了 C# 指针操作符。所有这些运算符都存在,并且在 C++ 中具有相同的定义和用法。
表 9-1。
Common Pointer Operators in C++ and C#
| 操作员 | 意义 | | --- | --- | | `&` | 取一个值的地址。这是 C++ 命名法中运算符的地址。 | | `*` | 获取指针或引用指向的值。这是 C++ 中的解引用运算符。当你使用它的时候,你正在解引用一个指针。 | | `->` | `ptr->`是`(*ptr)`的别名。`ptr->`是会员接入运营商。当`ptr`指向一个 C# `struct`或者值类型的实例,并且你想要访问该结构的一个成员时,这是一个方便的快捷方式。 |指针用法示例
下面是一些在 C# 和 C++/CLI 中使用指针的简短例子。
声明一个指向整数的指针:
int *ptr;
将整数的地址分配给整数指针:
int i;
ptr = &i
通过取消对指针的引用,为原始整数赋值:
*ptr = 3;
此时,整数i
被赋予3
的值。现在让我们将这个功能包装在一个程序中并尝试一下。
C# 中可验证的代码和指针用法
由于指针的使用是不可验证的,C# 将指针操作符的使用限制在标有unsafe
关键字的块中。此外,编译时必须指定/unsafe
命令行选项。这可以直接从命令行完成,也可以通过在 Visual Studio IDE 的“项目属性”对话框的“生成”选项卡中选中相应的框来完成。例如,看看下面这个程序,叫做test.cs
:
class R
{
static void Main()
{
int i;
unsafe
{
int *ptr = &i;
*ptr = 3;
}
System.Console.WriteLine(i);
}
}
要编译和运行该程序,请执行以下操作:/nologo
选项取消版权信息:
csc /unsafe /nologo test.cs
test
您应该会收到以下输出:
3
如您所见,/unsafe
命令选项用于指示编译器接受unsafe
关键字的用法。如果您忽略了使用/unsafe
选项进行编译,您会看到下面的诊断,如果编译器正在工作的话:
test.cs(12,9): error CS0227: Unsafe code may only appear if compiling with /unsafe
编写不安全代码的副作用
编写不安全的代码有一些有趣的副作用。因为可以使用指针间接初始化变量,所以使用不安全代码会影响编译器检测未初始化变量的能力。例如,考虑下面的 C# 片段:
int i;
System.Console.WriteLine(i);
如果您在程序的上下文中编译它,您会看到以下诊断信息:
test.cs(9,29): error CS0165: Use of unassigned local variable 'i'
如果您将此代码包装在一个不安全的块中,并添加了一个指针引用,如下所示:
unsafe
{
int i;
int *p = &i;
System.Console.WriteLine(i);
}
即使变量i
仍未初始化,编译器也不会进行诊断。
如果您随后执行这个块,您会看到一个未初始化变量的默认值:
C:\>test
0
在这种情况下,这没什么大不了的,但是您可以看到使用不安全的块和指针是如何限制编译器帮助您编写可靠代码的能力的。
C++ 中的指针用法
C++ 指针类似于 C# 指针,有许多相同的限制。基本语法是相同的,考虑到语言的历史和发展,这并不奇怪。有一些重要的区别,所有这些都证明了 C++ 的强大:
- C++ 不要求在源代码中使用
unsafe
关键字。 - C++ 有四个编译选项;C# 有安全和不安全之分。
- C++ 允许指针指向数组。
- C++ 允许指向本地函数的指针。
- C++ 允许指针指向独立于实例的成员。
- C++ 允许在同一个声明中混合和组合指向数组的指针、指向指针的指针、指向成员的指针和指向本机函数的指针。
因为本章的目标是介绍指针和相关概念,所以我们将高级方面的讨论推迟到第十八章和第十九章进行。毕竟,在你准备好之前,你不想花太多时间去尝试解码一个像下面这样的有效 C++ 声明!
void (**(*(*p)(int, char))[])(int);
C++ 中的可验证代码
/clr
开关决定了如何编译 C++ 代码以适应 CLR。它规定了编译器在禁止指针和非托管类型等构造时需要有多严格,以及确定您的代码是否被编译为在中的 CLR 下运行。或者作为独立的本机可执行文件。
以下是用 C++ 编译代码的几种方法:
- 产生一个仅可验证的 IL 输出文件,并且只能用于托管类型和托管代码。
/clr:pure
生成一个仅包含 IL 的输出文件(没有本机可执行代码),并且只能用于托管和本机类型以及托管代码。- 产生本地和 IL 文件的混合。允许托管和本机类型以及托管代码和本机代码。
<default>
表示没有指定选项。该程序为本机执行而编译。
此外,为了与 Visual C++ 2002 和 2003 兼容,还提供了另外两个选项:
/clr:oldSyntax
接受 Visual C++ 2002 和 2003 中的托管扩展语法。/clr:initialAppDomain
表示使用 Visual C++ 2002 的初始AppDomain
1 行为。
一般来说,C# 默认生成可验证的代码,但是可以使用指针和使用unsafe
关键字或命令行选项的不可验证的代码。C++ 希望您在命令行上定义目标可执行文件,因为 C# 和 C++ 倾向于反映不同的范例,而不是像 C# 那样,通过可选的优化或调整来反映可验证的代码。在 C++ 中,与 C# 编译直接对应的是/clr:safe
,但是这种模型不允许您利用 C++ 的大部分真正功能。出于这个原因,我一般更喜欢使用/clr:pure
,并根据需要切换到/clr:safe
或/clr
。
现在,让我们采用与 C# 示例完全相同的指针用法,并将其转换为 C++/CLI:
ref struct R
{
static void Main()
{
int i;
int *ptr = &i;
*ptr = 3;
System::Console::WriteLine(i);
}
};
void main() {R::Main();}
如您所见,程序中 C++ 指针的用法与 C# 版本相同。因为指针是不可验证的,或者是不安全的,我们应该把它编译成pure
。输入以下行:
cl /clr:pure test.cpp
这编译成一个 CLR 可执行文件,将3
写入控制台,就像 C# 版本一样。
另一方面,如果您试图将其编译为safe
,您会看到以下内容(/nologo
选项隐藏了版权信息):
cl /clr:safe /nologo test.cpp
test.cpp
test.cpp(6) : error C4956: 'int *' : this type is not verifiable
如果您试图在没有任何形式的clr
标志的情况下编译这段代码,您会收到一条更加深奥的消息:
cl /nologo test.cpp
test.cpp
test.cpp(2) : error C2143: syntax error : missing ';' before '<class-head>'
test.cpp(2) : error C4430: missing type specifier - int assumed. Note: C++ does not
support default-int
test.cpp(8) : error C2653: 'System' : is not a class or namespace name
test.cpp(8) : error C3861: 'WriteLine': identifier not found
如果不指定/clr
选项,编译器会将这段代码编译为本机 C++。在原生 C++ 中,ref
不是一个关键字,所以编译器不知道它指示了什么特殊的东西,你得到的东西遵循旧的计算机科学规则,即编译器垃圾进,垃圾出。当编译器似乎抛出看似无意义的诊断时,在 IDE 或 makefile 中仔细检查clr
标志的设置总是一个好主意。
BEHIND THE SCENES
在前面的例子中,理解解析器在做什么是很有启发性的。当它看到关键字class
时,它意识到,因为类只能在某些地方声明,所以之前的类应该已经完成了,所以它输出下面的诊断:
test.cpp(2) : error C2143: syntax error : missing ';' before '<class-head>'
注意,这发生在第 2 行,因为解析器在对表达式作出判断之前继续到左花括号。
error C4430: missing type specifier - int assumed. Note: C++ does not
support default-int
下一个错误消息是传统 C 语言的遗留问题,它允许您在不指定类型的情况下声明变量或函数。默认情况下,该类型将被标识为int
。毫不奇怪,这种行为被称为“??”。
因此,在传统的 C # 中,以下声明是有效的:
ref;
这一行声明ref
是一个int
类型的全局变量。
C++ 句柄
在 CLR 中,引用类型分为两个实体:托管堆上的对象和这些对象的句柄。C# 模糊了这种划分,并提供了允许您像处理对象本身一样处理句柄的语法。另一方面,C++/CLI 将句柄视为指向托管堆上的对象的指针。这样做时,它使用了本章中概述的指针语法。
让我们在这里回顾一下语法。首先让我们声明一个引用类型和一个值类型:
//declare a reference type
ref struct R { int i; };
//declare a value type
value struct V { int j; };
现在让我们实例化它们(在方法的上下文中):
V v;
R ^ r = gcnew R();
变量v
已经被分配到堆栈上。变量r
也被分配在堆栈上,它是一个句柄,或者托管指针,指向一个分配在托管堆上的R
对象。
假设现在我们想要访问V
中的字段变量j
。我们将使用.
字段访问操作符并编写如下代码:
v.j = 3;
在第三章的中,我展示了对于引用类型,你需要使用->
成员访问操作符来代替。之前我也提到过ptr->
是(*ptr)
的别名。让我们看看这些是如何组合在一起的。
使用指针语法将字段i
设置为3
:
r->i = 3;
常规语法中的等效语法如下:
(*r).i = 3;
在这种情况下,变量r
位于堆栈上,是托管堆上的R
对象的句柄或指针:
(*r)
这个表达式表明指针应该被解引用,这意味着“转到由变量r
指向的对象。”换句话说,这个表达式返回托管堆上的实际对象。添加.i
访问字段本身,这与 C# 或 C++ 中的堆栈分配值类型相同。
当然,使用->
操作符更简单,但是了解语法很重要。
C++/CLI 地址运算符
我们已经知道了&
操作符用于获取值类型或指针类型的地址。具体来说,在 C# 中,这意味着它可以获取堆栈上变量的地址。如果在 C++ 中使用这个操作符获取一个不在堆栈上的变量的地址,就会得到一个诊断。为了测试这一点,我们需要使用表达式(*r)
来获取托管堆上的一个对象。让我们用 operator 的地址来确定它的地址,看看会发生什么:
&(*r);
如果您尝试编译它,您会看到以下诊断信息:
C:\>cl /clr:pure /nologo test.cpp
test.cpp
test.cpp(12) : error C3072: operator '&' cannot be applied to an instance of a
ref class
use the unary '%' operator to convert an instance of a ref class to a
handle type
错误 3072 是这里的重要诊断,因为它指出操作符的地址不能用于托管堆中的对象。
为了允许您获取托管堆上对象的地址,C++/CLI 引入了%
操作符。这个操作符相当于托管堆上对象的&
操作符。
总之,表 9-2 显示了 C++/CLI 中本机指针和托管指针之间的对应关系。
表 9-2。
Native to Managed-Pointer Operator Correspondence in C++/CLI
| 操作员 | 堆栈/本机堆 | 托管堆 | | --- | --- | --- | | 地址 | `&` | `%` | | 指针声明 | `*` | `^` | | 解除…的关联 | `*` | `*` | | 例子 | `value struct V{ int i; };` | `ref struct R{ int i; };` | | | `V v;` | `R ^r1 = gcnew R();` | | | `V * pV = &v;` | `R ^r2 = %(*r1);` | | | `v.i;` | `R r;` | | | `pV->i;` | `R ^r3 = %r;` | | | | `r1->i;` | | | | `(*r2).i;` |如您所见,根据目标是在托管堆、本机堆还是堆栈上,有不同的地址和指针声明操作符语法。另一方面,只有一个解引用操作符,因为解引用指针是明确的。在任何情况下,您最终都会得到一个表示堆栈或托管堆上的对象的表达式。
复杂的例子
下面是一个更复杂的例子,展示了如何获取指针的地址。注意,由于指针和句柄是在堆栈上分配的,我们在两种情况下都使用了&
地址操作符:
static void Main()
{
V v;
V *pV = &v;
R ^ r;
R ^ * phR = &r;
}
这个例子为phR
提供了以下有趣且更高级的声明:
R ^ * phR
在这种情况下,phR
是一个指针,指向一个R
对象的句柄。这一开始可能有点奇怪,但是你会慢慢习惯的。请注意以下详细信息:
*phR
是一个R^
指针。**phR
是一个R
对象。
这是另一个有趣的构想:
V ^hV = %v;
这是正确的吗?它说,“给我一个驻留在托管堆上的值类型的句柄。”但是值类型不是活在栈上吗?这应该不会编译吧?事实证明,这可以很好地编译。为什么呢?原来,值类型V
的装箱版本是在托管堆上自动创建的。然后,表达式用该对象的句柄设置变量hV
。
噩梦
由于在 C++ 中类型是递归定义的,所以除了 CLR 的限制之外,对您可以创建的噩梦般的表达式的类型没有什么限制。
例如,下面的有效表达式将变量p
声明为一个指针,该指针指向一个采用int
和char
的函数,并返回一个指针数组,该数组指向采用int
并将句柄返回给R
的函数的指针:
R ^ (**(*(*p)(int, char))[])(int);
一般来说,这是你阅读复杂声明的方式:从中间的变量名开始。在这种情况下,变量名是p
。从那里,向右看。如果你看到一个左括号,它就是一个函数。如果你看到一个方括号,它就是一个数组。如果您看到分号、右括号或什么都没有,请向左看。如果你看到一个^
,那就是手柄。如果你看到一个*
,那就是一个指针。从这里开始,继续向外扩展,注意圆括号,跳过已经使用过的标记。显然,这是一个说起来容易做起来难的算法。
复杂声明的好处是唯一的限制是你的想象力。
摘要
在这一章中,我向你介绍了指针和不安全代码,希望不会吓到你,让你放弃编程,搬到南极洲去。如果我失败了,一定要给我寄张明信片——最好是有企鹅或狗的明信片。
你现在可能不是指针方面的专家,但是考虑到本章中的贯穿和例子,你至少会知道如何识别它们以及在简单的情况下应用它们。在下一章,我们将看看 C++/CLI 中的属性和事件。
Footnotes 1
有关clr:initialAppDomain
的详细信息,请参考 Visual C++ 文档。
十、属性和事件
年收入 20 英镑,年支出 1996 英镑,结果幸福。年收入 20 英镑,年支出 20 英镑应该和 6,导致痛苦。—查尔斯·狄更斯、大卫·科波菲尔
创建属性是为了在一个上下文中为数据提供类似字段的功能,该上下文允许程序员以与类型系统完全不同的方式访问数据或抽象数据。
普通字段是类中的简单类型声明。属性包含一个检索数据的方法(称为 getter ),一个存储数据的方法(称为 setter ),或者两者都包含在一个统一的语法中,该语法允许属性看起来和行为起来像一个字段。getter 和 setter 通常也被称为get
访问器和set
访问器。
属性是 C# 和 C++/CLI 的共同元素,尽管它们的语法有很大不同。属性目前还不是标准 C++ 的一个元素,尽管没有理由不在将来的某一天将它们添加到语言中并标准化。
C# 中使用属性的基本示例
假设我们想写一个Clock
类,在这个类中我们将小时存储为 0 到 11 之间的一个数字,但是我们仍然希望调用者能够将小时作为 1 到 12 之间的一个数字来使用。我们可以使用 C# 中的属性以任何我们想要的方式存储数据,并使用 getters 和 setters 在格式之间转换数据。我不会在这里详述这种表述的优点;只要说它对于执行各种计算是有用的就够了。
这是一个用 C# 编写的示例:
using System;
class Clock
{
public int Hour
{
get
{
if(hour == 0)
{
return 12;
}
else
{
return hour;
}
}
set
{
hour = value % 12;
}
}
private int hour;
public static void Main()
{
DateTime t = DateTime.Now;
Clock c = new Clock();
c.Hour = t.Hour;
Console.WriteLine("The little hand is on the {0}", c.Hour);
c.Hour = 12;
Console.WriteLine("at midnight it will be {0} o'clock", c.Hour);
}
}
在本例中,Hour
是一个属性。让我们来看看二传手:
set
{
hour = value % 12;
}
在这种情况下,变量value
是 setter 的隐含输入。然后,我们的私有变量hour
被设置为value
模12
,将其转换为 0 到 11 之间的数字。
getter 处理另一个方向的交互。如果hour
为0
,则返回12
,其他值返回时钟小时:
get
{
if(hour == 0)
{
return 12;
}
else
{
return hour;
}
}
结果如下:
C:\>csc /nologo test.cs
C:\>test
The little hand is on the 7
at midnight it will be 12 o'clock
在 C++/CLI 中使用属性的基本示例
C++/CLI 中的类似程序如下:
using namespace System;
private ref class Clock
{
public:
property int Hour
{
int get()
{
if(hour == 0)
{
return 12;
}
else
{
return hour;
}
}
void set(int value)
{
hour = value % 12;
}
}
private:
int hour;
public:
static void Main()
{
DateTime t = DateTime::Now;
Clock ^c = gcnew Clock();
c->Hour = t.Hour;
Console::WriteLine("The little hand is on the {0}", c->Hour);
c->Hour = 12;
Console::WriteLine("at midnight it will be {0} o'clock", c->Hour);
}
};
void main() { Clock::Main();}
让我们看看 C++/CLI 中的结果:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
The little hand is on the 7
at midnight it will be 12 o'clock
虽然这个例子产生相同的结果,但是语法完全不同。设置器编写如下:
void set(int value)
{
hour = value % 12;
}
C++/CLI 访问器不具有独特的语法,而是像方法一样编写。在这种情况下,C# 隐式参数value
被显式声明为函数参数。事实上,在 C++/CLI 中它可以被命名为任何名称,而不仅仅是value
。注意,set
访问器返回void
;标准要求返回void
。
吸气剂也有类似的区别:
int get()
{
if(hour == 0)
{
return 12;
}
else
{
return hour;
}
}
它具有方法样式的语法,并有一个与属性类型相同的返回值int
。尽管 C++/CLI 语法与 C# 语法完全不同,但它也非常直观,因为它反映了 getter 和 setter 实际上是方法的事实。
语法内部的观察
在 C++/CLI 中解析属性时,根据属性是被读取还是被写入,访问被转换为set()
或get()
方法调用,例如:
c->Hour = t.Hour
该表达式将被转换为以下形式:
c->Hour::set(t.Hour);
现在以下面一行为例:
int i = c->Hour;
该表达式将被转换为
int i = c->Hour::get();
事实上,语言本身接受这种显式语法和隐式语法。我们可以如下重写前面的Main()
方法,并看到相同的结果:
static void Main()
{
DateTime t = DateTime::Now;
Clock ^c = gcnew Clock();
c->Hour::set(t.Hour);
Console::WriteLine("The little hand is on the {0}", c->Hour::get());
c->Hour::set(12);
Console::WriteLine("at midnight it will be {0} o'clock",c->Hour::get());
}
当然,这首先违背了属性的一些目的。尽管如此,这种转换的知识有助于理解我们将在本章后面遇到的一些棘手的诊断。同样重要的是要注意属性名的范围是get
和set
方法。换句话说,它可以被看作是一个包含类内部方法的名称空间,这使得用于显式访问方法的语法非常直观。
琐碎的属性
平凡属性是指没有显式 getter 或 setter 的属性;编译器根据属性声明创建默认的 getter 和 setter,以及保存信息的数据占位符。属性只是类中的另一个字段,在编译器术语中称为后备存储。这显然是最基本的一种属性,与字段声明仅略有不同,但也有优点。对于最终将被重写为完整属性的项目,平凡属性在开发阶段作为占位符非常有用。同时,它们作为属性的存在可以防止您无意中编写出只适用于字段而不适用于属性的表达式;请参见下面标题为“注意事项”的部分。
语法
要声明一个小属性,可以声明一个不带花括号的 getter 或 setter 的属性,如下所示:
property int Hour;
这创建了一个名为Hour
的属性,带有隐式创建的方法Hour::get()
和Hour::set()
,以及一个类型为int
的后备存储来存储属性数据。编译器生成的 getter 和 setter 方法有以下声明:
int get();
void set(int);
换句话说,普通属性的基本语法是
property-type``property
你可能会问自己,“如果这被称为一个微不足道的属性,我们怎么称呼我们在前面的例子中明确定义的属性?”嗯,你猜对了。我们直觉地称它为非平凡的性质。
例子
下面是一个使用平凡属性的简单示例:
using namespace System;
ref struct Test
{
property int Item;
int UseItem()
{
Item = 3;
return Item;
}
};
方法UseItem()
既读取又写入普通属性项。
索引属性
索引属性类似于属性数组。索引属性使用其他参数(称为索引)来确定get
或set
操作的结果。这些参数不必是整数;事实上,财产指数可以是任何类型的。
语法
若要声明索引属性,请用方括号将逗号分隔的参数列表括起来。您还需要将这个列表复制到 getter 和 setter 的声明中,对于 setter,从左到右从索引开始,到属性类型结束。这种语法使得某些复杂指针类型(类似于第九章中遇到的噩梦)的属性声明成问题。你可以用一个typedef
来解决这个问题,这是一个 C++ 语言的特性,将在第二十章的中介绍。此外,索引属性不能是无关紧要的,因为编译器会不知所措,弄不清要创建哪种隐式访问器。
例子
下面是一个使用索引属性的示例:
using namespace System;
ref struct R
{
String ^m_Key;
int m_Value;
property int Hash[String ^]
{
int get(String^Key)
{
if(Key == m_Key)
{
return m_Value;
}
else
{
return -1;
}
}
void set(String^Key, int Value)
{
m_Key = Key;
m_Value = Value;
}
}
R()
{
Hash["dog"]=3;
}
static void Main()
{
R ^ r = gcnew R();
r->Hash["first"]=42;
Console::WriteLine(r->Hash["first"]);
Console::WriteLine(r->Hash["second"]);
}
};
void main() { R::Main(); }
在这个例子中,我们创建了一个非常简单的散列。它只能存储单个值;尝试读取任何其他值都会返回–1
。使用一个字符串来索引这个散列。在main()
中,我们首先通过将索引为"first"
的Hash
属性设置为42
来初始化散列。如果使用了索引first
,所有后续的散列读取都返回42
,否则返回–1
表示错误。暂时忽略构造器;本章后面将使用它来演示 C# 和 C++/CLI 之间的区别。
让我们检查结果:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
42
-1
默认索引属性
对于索引属性,可以用关键字default
代替属性标识符。这对于标量或非索引属性是不允许的。使用default
允许您将类本身视为属性的容器,因为属性没有唯一的标识符。使用一个例子可能更容易理解这个概念。
我们可以将前面的示例转换为默认索引属性,如下所示:
using namespace System;
ref struct R
{
String ^m_Key;
int m_Value;
property int default[String ^]
{
int get(String^Key)
{
if(Key == m_Key)
{
return m_Value;
}
else
{
return -1;
}
}
void set(String^Key, int Value)
{
m_Key = Key;
m_Value = Value;
}
}
R()
{
default["dog"]=3;
}
static void Main()
{
R ^ r = gcnew R();
r["first"]=42;
Console::WriteLine(r["first"]);
Console::WriteLine(r["second"]); }
};
void main() { R::Main(); }
如您所见,这些代码示例之间的唯一区别是标识符Hash
不用于默认索引的属性。
C# 属性
因为在 C++/CLI 中,属性是作为方法透明地实现的,所以我觉得直接解释 C++/CLI 索引的属性比将讨论建立在从 C# 翻译过来的基础上更有启发性。就我个人而言,我认为 C# 属性语法有些特别。
然而,仍然有一些有趣的 C# 特性值得在这一部分占有一席之地。
C# 中的标量属性
下面是 C# 中标量或非索引属性的一个示例:
class R
{
int savedValue;
public int BasicProperty
{
get
{
return savedValue;
}
set
{
savedValue = value;
}
}
}
在 C# 中,set
访问器的值参数是隐式声明的,并且有一个标识符value
。使用。NET Reflector,我们可以看到这些访问器转换为以下方法:
public int get_BasicProperty()
{
return this.savedValue;
}
public void set_BasicProperty(int value)
{
this.savedValue = value;
}
如您所见,C# 语法生成的代码类似于 C++/CLI 语法。
C# 中的索引属性
C# 中的索引属性和 C++/CLI 中的索引属性之间的主要区别在于,在 C# 中,所有索引属性都是默认索引属性。在 C++/CLI 中,一个类中可以有多个索引属性,而在 C# 类中只能有一个。由于这个限制,C++/CLI 和 C# 都允许您避免使用类似于default
的关键字来访问属性。因为一个类中最多有一个默认属性,所以标识符是隐式的。下面是我们转换为 C# 的默认索引属性示例:
using System;
class R
{
string m_Key;
int m_Value;
public int this[string Key]
{
get
{
if (Key == this.m_Key)
{
return this.m_Value;
}
return -1;
}
set
{
this.m_Key = Key;
this.m_Value = value;
}
}
R()
{
this["dog"]=3;
}
public static void Main()
{
R r = new R();
r["first"]=42;
Console.WriteLine(r["first"]);
Console.WriteLine(r["second"]);
}
}
在Main()
函数中,使用以下语法访问属性:
r["first"]=42;
在 C++/CLI 中,等效的语法是相同的:
r["first"]=42;
C# 构造器使用以下语法,使用关键字this
来访问属性:
this["dog"]=3;
在 C++/CLI 中,等效的语法是
default["dog"]=3;
C++/CLI 版本使用关键字default
,尽管对于引用类型,您也可以使用关键字this
。 1
属性的高级属性
在实现面向对象的范例和抽象时,选择属性而不是字段有很多好处,因为您可以用属性做几乎所有可以用方法做的事情。因为它们在框架中得到支持,所以您几乎可以拥有两个世界的精华。
只读和只写属性
将属性设为只读或只写非常简单。为此,不要分别提供 setter 或 getter。
只读属性
一个只读属性跟在后面;只读属性是指缺少set
访问器的属性:
using namespace System;
ref struct R
{
property DateTime Time
{
DateTime get()
{
return DateTime::Now;
}
}
static void Main()
{
R ^ r = gcnew R();
Console::WriteLine(r->Time);
}
};
void main() { R::Main(); }
这个属性允许我们读取当前时间。以下是我运行时得到的结果:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
2/18/2006 12:16:12 PM
您的结果可能会有所不同。
只写属性
创建只写属性类似于创建只读属性,只是在这种情况下缺少 getter。我无论如何都要包括下面的例子,向您展示只写是如何有用的;使用属性并不是访问数据的唯一方式:
using namespace System;
ref struct R
{
int SavedValue;
property int SetOptions
{
void set(int Value)
{
SavedValue = Value;
}
}
static void Main()
{
R ^ r = gcnew R();
r->SetOptions = 3;
Console::WriteLine(r->SavedValue);
}
};
void main() { R::Main(); }
结果如下:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
3
静态属性
就像字段和方法一样,属性也可以是静态的,因此它们不需要实例化就可以使用。事实上,让我们使用一个静态属性来重写我们的时间示例,这样做更有意义:
using namespace System;
ref struct R
{
static property DateTime Time
{
DateTime get()
{
return DateTime::Now;
}
}
};
void main()
{
Console::WriteLine(R::Time);
}
虚拟财产
属性不仅可以是虚拟的,还可以用来覆盖其他方法,就像常规方法一样。考虑下面的例子,其中get
访问器在基类中被覆盖,就像第八章中的例子一样:
using namespace System;
ref struct Base
{
property int Prop
{
virtual int get()
{
return 1;
}
}
void Test()
{
Console::WriteLine(Prop);
}
};
ref struct Derived : Base
{
int value;
property int Prop
{
virtual int get() override
{
return 3;
}
}
};
void main()
{
Derived ^d = gcnew Derived();
Base ^b = gcnew Base();
b->Test();
Console::WriteLine(d->Prop);
d->Test();
}
结果如下:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
1
3
3
在这个例子中,基类的get()
方法返回1
,而被覆盖的方法返回3
。从输出中可以看出,Derived::Prop::get()
不仅返回3
,而且在派生类的实例上被调用时还会影响Base::Prop::get()
。
抽象和密封属性
属性访问器也可以声明为abstract
和sealed
。下面的示例显示了在派生类中实现和密封的基类中的抽象 getter:
using namespace System;
ref struct Base abstract
{
property int Prop
{
virtual int get() abstract;
}
};
ref struct Derived : Base
{
property int Prop
{
virtual int get() override sealed
{
return 1;
}
}
};
void main() {}
根据定义,接口中声明的属性也是abstract
。没有必要明确说明这一点。
命名覆盖
属性显式重写虚函数也是可能的。考虑以下示例:
using namespace System;
ref struct Base
{
virtual String ^GetProp()
{
return "Base";
}
};
ref struct Derived : Base
{
property String ^ Prop
{
virtual String ^ get() = Base::GetProp
{
return "Derived";
}
}
};
void main()
{
Derived ^d = gcnew Derived();
Base ^b = d;
Console::WriteLine(b->GetProp());
}
结果如下:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
Derived
在第八章中,你看到了一个第三方库版本强迫你显式覆盖一个不同名字的虚函数的例子。这是同样的情况。在这种情况下,您在基类中有一个现有的函数GetProp()
,您希望通过派生类中的属性get
访问器来覆盖它。这允许您用一个更抽象的范式替换一个过时的范式,而对先前存在的代码影响最小。
虽然不太常见,但也可以在相反的方向进行命名重写,例如:
using namespace System;
ref struct Base
{
property String^ Prop
{
virtual String^ get()
{
return "Base";
}
}
};
ref struct Derived : Base
{
virtual String ^ GetProp() = Base::Prop::get
{
return "Derived";
}
};
void main()
{
Derived ^d = gcnew Derived();
Base ^b = d;
Console::WriteLine(b->Prop);
}
在这个例子中,派生的方法覆盖了基类中的get
访问器。以下是朝此方向覆盖时的结果:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
Derived
如您所见,无论哪种方式,我们总是显示来自具有覆盖方法的Derived
类的结果。
财产保护机制
您可以限制属性、其 getter、其 setter 或其 getter 和 setter 的可访问性。除非另外指定,否则应用于属性的可访问性将延续到 getter 和 setter 的可访问性。唯一的限制是 getter 和 setter 的可访问性不能比属性本身的限制更少。
例如,考虑以下代码:
using namespace System;
ref struct R
{
private:
static property DateTime Time
{
public:
DateTime get()
{
return DateTime::Now;
}
}
};
void main()
{
Console::WriteLine(R::Time);
}
当我们尝试编译它时,我们会遇到以下诊断:
C:\>cl /clr:pure /nologo test.cpp
test.cpp(7) : error C3908: access level less restrictive than that of 'R::Time'
test.cpp(5) : see declaration of 'R::Time'
发出该诊断是因为属性访问级别是private
,这将 getter 和 setter 限制为最多private
。
另一方面,考虑下面的代码:
using namespace System;
ref struct R
{
public:
static property DateTime Time
{
DateTime get()
{
return DateTime::Now;
}
private:
void set(DateTime t)
{
}
}
};
void main()
{
Console::WriteLine(R::Time);
}
在这种情况下,setter 的保护级别比属性的保护级别更严格,此程序可以很好地编译。剩下要做的就是填充set
访问器!
财产警告
因为属性遵循字段语法,所以在复杂的表达式中使用它们很有吸引力,就像使用常规字段一样。这并不总是可行的,因为限制是set
访问器返回void
而不是属性的类型。 2 考虑以下例子:
ref struct Test
{
property int PropInt;
int RealInt;
};
void main()
{
Test ^a = gcnew Test();
Test ^b = gcnew Test();
Test ^c = gcnew Test();
a->RealInt = b->RealInt = c->RealInt;
a->PropInt = b->PropInt = c->PropInt;
}
让我们试着编译一下:
C:\>cl /clr:pure /nologo test.cpp
test.cpp(12) : error C2664: 'Test::PropInt::set' : cannot convert parameter 1 from
void' to 'int'
Expressions of type void cannot be converted to other types
第一个构造a->RealInt = b->RealInt = c->RealInt
工作正常,使用 C++/CLI 的评估规则顺序从右到左进行解析。第二个例子使用了一个int
类型的property
,由于 setters 返回了void
,所以没有编译。
编译器将属性表达式转换为以下形式:
a->PropInt::set(b->PropInt::set(c->PropInt::get()));
事实上,如果用这个表达式替换属性表达式,就会得到完全相同的错误代码。如您所见,a->PropInt::set()
正试图对类型为void
的项目进行操作,这是由b->PropInt::set()
返回的。
这样设计是出于优化和设计的考虑。要求从set()
方法返回值会给程序员带来困惑。如果该属性模拟或访问一个硬件设备,其中的set()
方法指示设备编程信息,而get()
方法返回状态,那该怎么办?每次出现这种语法时,编译器应该总是自动插入对get()
的额外调用吗?有定义良好的规则来处理定义可接受的优化的编程语言。这些规则决定了编译器是否可以尝试伪读地址等等,因为这些操作通常会产生真实的结果,尽管 CLR 当然是一个仿真环境,或者至少现在是这样。有一天可能会有直接实现 CLR 的硬件。
我认为这种担心影响了属性的设计,尽管不能保证编译器的未来版本会支持返回值不是 ?? 的 ?? 函数。
杂项属性详细信息
属性类型不能是const
、volatile
或mutable
。您可以将const
关键字添加到变量中,使其成为只读的。类可以是常量,这使得类实例是只读的;只有const
方法可以被const
类调用。附加到const
类变量的mutable
关键字使该变量免于成为const
。volatile
关键字表示不应该执行假设变量永远不变的优化。这对内存映射变量很有用。随着本书的展开,我们将在上下文中重新审视这些关键词。
事件和代表
C++/CLI 事件和委托在形式和功能上类似于相应的 C# 形式。本节主要关注语法差异,但是仍然提供了一些说明性的例子。
代表
委托是指向函数的指针的类型安全版本。英寸 NET 中,一个委托可以包含对几个方法的调用,这是封装来自外部或异步事件的回调的理想方式。
整理
在这一节中,我将给出一个简单排序的例子,并使用委托来指导它的发展。假设您有一个名为MyObject
的类数组,每个类都有一个名为Value
的字段,并且您想通过这个Value
对它们进行排序。冒泡排序的简单实现可能如下所示:
using namespace System;
ref struct MyObject
{
int Value;
MyObject(int Value)
{
this->Value = Value;
}
virtual String ^ToString() override
{
return Value.ToString();
}
};
void main()
{
array<MyObject^> ^myObjectArray = gcnew array<MyObject^>
{
gcnew MyObject(5),
gcnew MyObject(3),
gcnew MyObject(1),
gcnew MyObject(4),
gcnew MyObject(2),
};
for(int i=1; i<myObjectArray->Length; i++)
{
for (int j=0; j<i; j++)
{
if(myObjectArray[i]->Value < myObjectArray[j]->Value)
{
MyObject ^tempObject;
tempObject = myObjectArray[i];
myObjectArray[i]=myObjectArray[j];
myObjectArray[j]=tempObject;
}
}
}
for each(MyObject^ o in myObjectArray)
{
Console::Write(o);
}
Console::WriteLine();
}
这都是非常标准的:你在数组上通过几次,如果邻居的顺序错了,就交换邻居,较大的对象冒泡到顶部。
关于代码,有几件有趣的事情需要注意。我有时候有点懒,喜欢用ToString()
打印类型。在这种情况下,我是这样做的:
int Value;
return Value.ToString();
这将在托管堆上分配一个字符串,该字符串具有整数Value
值的 Unicode 表示形式。
构造器的另一个有趣部分如下:
int Value;
MyObject(int Value)
{
this->Value = Value;
}
在这种情况下,有两个不同的整数被命名为Value
;的确,我不必给他们俩起同一个名字。事实上,人们通常不这样做,对成员变量使用类似于m_Value
的东西。在这种情况下,我想展示如何使用this
来区分它们;this
指向的变量是实例变量,缺少this
的变量是输入参数。
我们可以通过以下方式使用委托使代码更加通用:
- 将排序过程从数组类型中分离出来。创建一个通用的
sort
类,它对任意类型的对象进行排序。 - 将排序算法与排序过程分离。排序算法根据数组的顺序执行不同的操作。一些算法擅长对随机数组进行排序;当数组中只有几个元素没有按顺序排列时,其他的就更好了。如果允许用户根据数据选择算法就好了。
我们将使用委托来完成这两项工作。
- 我们将首先进行强制转换,以便排序过程可以对一个数组
Object^
进行排序,并使用特定于类的比较过程对实际对象进行排序。将使用委托来访问比较过程。 - 接下来,我们将为排序算法本身实现一个委托。在这种情况下,委托将指向冒泡排序算法的实现。
确定代表
要创建一个委托,我们首先要决定我们想要调用哪种方法。有几种处理任意类型对象的基本方法。一种方法是执行参数的多态,这将在第十四章中讨论,当我讨论泛型和模板的时候。另一种是执行类型的多态,将任意类型强制转换为公共基类,并对基类执行操作。让我们采用后一种策略,将任意对象投射到Object^
。
这种策略将对象的类型与排序过程分离开来。我们的调用算法为排序程序传递一个可以比较两个项目的方法的地址。我们用一个委托来表示它。我们方法的模型如下:
bool Compare(Object ^, Object ^);
如果第一个Object
小于第二个,Compare()
方法返回true
。委托是一种类型,因此它出现在类级别的范围内,并且具有可见性说明符。它是通过在类似的方法声明前添加关键字delegate
来创建的。为了实施类型安全,输入参数和返回类型必须与目标方法完全匹配:
public delegate bool DelCompare(Object ^o1, Object^ o2);
这里的语法有点微妙。假设您忘记了返回类型:
public delegate dog();
如果是这样,您可能会看到一个有些误导性的诊断:
test.cpp(1) : error C2059: syntax error : 'public'
如果您得到一个误导性的诊断,不要认为它是由编译器错误引起的。而是再三检查你的代码,以确保它是有效的 C++。
带有比较方法和委托声明的新MyObject
类如下:
using namespace System;
public delegate bool DelCompare(Object^, Object^);
ref struct MyObject
{
int Value;
static DelCompare ^dCompare = gcnew DelCompare(Compare);
MyObject(int Value)
{
this->Value = Value;
}
static bool Compare(Object ^o1, Object ^o2)
{
MyObject ^m1 = (MyObject^) o1;
MyObject ^m2 = (MyObject^) o2;
return (m1->Value < m2->Value);
}
virtual String ^ToString() override
{
return Value.ToString();
}
};
现在我们需要为任意类型创建一个排序类。这使我们有机会看到委托类型的方法将委托作为参数。我们希望我们的排序算法有一个委托,为它提供排序数组所需的准确信息——在本例中,有一个用于比较过程的委托和一个对数组的引用。因此,在这种情况下,我们有以下内容:
public delegate void DelAlgorithm(DelCompare ^dCompare, array<Object^> ^a);
接下来,我们添加排序类和冒泡排序算法本身:
ref struct Sorter abstract sealed
{
static DelAlgorithm ^dAlgorithm = gcnew DelAlgorithm(Bubble);
static void Bubble(DelCompare ^dCompare, array<Object^> ^a)
{
for(int i=1; i<a->Length; i++)
{
for (int j=0; j<i; j++)
{
if(dCompare(a[i], a[j]))
{
Object ^tempObject;
tempObject = a[i];
a[i]=a[j];
a[j]=tempObject;
}
}
}
}
static void Sort(array<Object^> ^a, DelCompare ^dCompare)
{
dAlgorithm(dCompare, a);
}
};
注意这个类是abstract sealed
。我们希望确保没有人实例化这个类,无论是作为它自己还是作为一个派生类。它被设计成Sort()
方法的容器,就像System::Console
是Write()
的容器一样。
最后,下面是修改后的main()
程序:
void main()
{
array<MyObject^> ^myObjectArray = gcnew array<MyObject^>
{
gcnew MyObject(5),
gcnew MyObject(3),
gcnew MyObject(1),
gcnew MyObject(4),
gcnew MyObject(2),
};
Sorter::Sort(myObjectArray, MyObject::dCompare);
for(int i=0; i< myObjectArray->Length; i++)
{
Console::Write(myObjectArray[i]);
}
Console::WriteLine();
}
就这些吗?也许我们可以把它提高一个档次。
下一关
这个实现相当巧妙,但是仍然有一点不必要的繁琐。我们需要将dCompare
委托传递给Sort()
例程。也许我们对MyObject
太过于圆滑了;很高兴还记得MyObject
是一个拥有所需委托的类。界面可以帮助我们解决这个问题。我们创建了一个接口ICompare
,它告诉编译器我们的特殊对象能够返回一个指示数组中元素排序的委托。完整的程序如下,供您自己学习:
using namespace System;
public delegate bool DelCompare(Object^, Object^);
interface class ICompare
{
virtual DelCompare ^getCompareDelegate();
};
ref struct MyObject : ICompare
{
int Value;
static DelCompare ^dCompare = gcnew DelCompare(Compare);
MyObject(int Value)
{
this->Value = Value;
}
static bool Compare(Object ^o1, Object ^o2)
{
MyObject ^m1 = (MyObject^) o1;
MyObject ^m2 = (MyObject^) o2;
return (m1->Value < m2->Value);
}
virtual String ^ToString() override
{
return Value.ToString();
}
virtual DelCompare ^getCompareDelegate()
{
return dCompare;
}
};
public delegate void DelAlgorithm(DelCompare ^dCompare, array<Object^> ^a);
ref struct Sorter abstract sealed
{
static DelAlgorithm ^dAlgorithm = gcnew DelAlgorithm(Bubble);
static void Bubble(DelCompare ^dCompare, array<Object^> ^a)
{
for(int i=1; i<a->Length; i++)
{
for (int j=0; j<i; j++)
{
if(dCompare(a[i], a[j]))
{
Object ^tempObject;
tempObject = a[i];
a[i]=a[j];
a[j]=tempObject;
}
}
}
}
static void Sort(array<Object^> ^a)
{
ICompare ^ic = (ICompare^)a[0];
dAlgorithm(ic->getCompareDelegate(), a);
}
};
void main()
{
array<MyObject^> ^myObjectArray = gcnew array<MyObject^>
{
gcnew MyObject(5),
gcnew MyObject(3),
gcnew MyObject(1),
gcnew MyObject(4),
gcnew MyObject(2),
};
Sorter::Sort(myObjectArray);
for(int i=0; i< myObjectArray->Length; i++)
{
Console::Write(myObjectArray[i]);
}
Console::WriteLine();
}
当我们运行该例程时,我们得到以下结果:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
12345
多播代理
多播委托是调用多个方法的委托。若要创建多播委托,请向委托本身添加更多方法。申报没有区别。可以使用+=
操作符将方法添加到委托中,同样也可以使用-=
将其减去。为了完成这项工作,我们需要对每个方法使用gcnew
操作符,如下所示:
using namespace System;
public delegate void Handler(String^);
ref struct Class1
{
static void News(String^s)
{
Console::WriteLine("Class1 : {0}",s);
}
};
ref struct Class2
{
static void News(String^s)
{
Console::WriteLine("Class2 : {0}",s);
}
};
ref struct Class3
{
static void News(String^s)
{
Console::WriteLine("Class3 : {0}",s);
}
};
void main()
{
Handler ^dNews1 = gcnew Handler(Class1::News);
Handler ^dNews2 = gcnew Handler(Class2::News);
Handler ^dNews3 = gcnew Handler(Class3::News);
Handler ^dNews;
dNews = dNews1 + dNews2 + dNews3;
dNews("News has arrived!");
dNews -= (dNews2+dNews3);
dNews("We lost subscribers");
dNews += dNews3;
dNews("A subscriber has returned");
}
在这个例子中,我们有三个类,Class1
、Class2
和Class3
,每个类都对接收新闻感兴趣。它们各有一个静态方法,News()
,应该是在我们有新闻要播的时候调用。在main()
中,我们创建了一个委托dNews
,使用+
操作符将消息发送给所有三个类。然后我们使用+=
和-=
操作符来改变谁接收新闻。这样,许多类可以使用委托订阅同一个新闻提要。
实例委托
到目前为止,所有委托的例子都在一个类中使用了一个static
方法来接收通知。不幸的是,static
方法不能是virtual
,这限制了我们如何覆盖它们。我们可以将实例方法传递给委托。而不是写以下内容:
Handler ^dNews1 = gcnew Handler(Class1::News);
写:
Handler ^dNews1 = gcnew Handler(gcnew(Class1),&Class1::News);
这样,我们将一个句柄传递给类的实例以及方法的地址,如操作符&
所示。我们不必把这些写在一行上;以下内容也适用:
Class1 ^ pClass1 = gcnew(Class1);
Handler ^dNews1 = gcnew Handler(pClass1,&Class1::News);
既然我们正在使用实例,我们可以重构前面的示例:
using namespace System;
public delegate void Handler(String^);
ref struct Base
{
virtual void News(String^s)
{
Console::WriteLine("{0} : {1}",ToString(),s);
}
};
ref struct Class1 : Base {};
ref struct Class2 : Base {};
ref struct Class3 : Base {};
void main()
{
Handler ^dNews1 = gcnew Handler(gcnew(Class1),&Class1::News);
Handler ^dNews2 = gcnew Handler(gcnew(Class2),&Class2::News);
Handler ^dNews3 = gcnew Handler(gcnew(Class3),&Class3::News);
Handler ^dNews;
dNews = dNews1 + dNews2 + dNews3;
dNews("News has arrived!");
dNews -= (dNews2+dNews3);
dNews("We lost subscribers");
dNews += dNews3;
dNews("A subscriber has returned");
}
这个版本干净多了。基类现在有了通用消息,如果需要的话,派生类可以自由地修改它。
事件
事件是。NET 通知机制。事件为委托提供保护,并允许通过添加和删除订阅者以及激活另一个事件(如前面示例中的发送新闻)来进行定制。
事件包含三种方法:add
、remove
和raise
。这些方法都有自己的可访问性。像属性一样,这些方法可以由编译器显式或隐式地声明和实现。带有隐式add
、remove
和raise
方法的事件被称为琐碎事件。
琐碎的事件
下面是前面的示例,修改后使用了一个小事件:
using namespace System;
public delegate void Handler(String^);
ref struct Base
{
virtual void News(String^s)
{
Console::WriteLine("{0} : {1}",ToString(),s);
}
};
ref struct Class1 : Base {};
ref struct Class2 : Base {};
ref struct Class3 : Base {};
ref struct Holder
{
void Deliver(String ^s)
{
News(s);
}
event Handler ^News;
};
void main()
{
Holder ^h = gcnew Holder();
h->News += gcnew Handler(gcnew(Class1),&Class1::News);
h->Deliver("News has arrived!");
}
我们可以编译并运行这个:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
Class1 : News has arrived!
这是一个只有一条新闻的简单例子。
非同寻常的事件
在前面的示例中,我们使用了以下结构:
event Handler ^News;
宣布一件小事。我们可以声明一个显式事件并实现add
、remove
和raise
方法。下面的代码是前面转换成同等重要事件的普通事件代码;注意add
、remove
和raise
的不同保护等级:
using namespace System;
public delegate void Handler(String^);
ref struct Base
{
virtual void News(String^s)
{
Console::WriteLine("{0} : {1}",ToString(),s);
}
};
ref struct Class1 : Base {};
ref struct Class2 : Base {};
ref struct Class3 : Base {};
ref struct Holder
{
void Deliver(String ^s)
{
News(s);
}
event Handler ^News
{
public:
void add( Handler^ d )
{
this->_News += d;
}
protected:
void remove( Handler^ d )
{
this->_News -= d;
}
private:
void raise( String ^s)
{
this->_News(s);
}
}
private:
Handler ^_News;
};
void main()
{
Holder ^h = gcnew Holder();
h->News += gcnew Handler(gcnew(Class1),&Class1::News);
h->Deliver("News has arrived!");
}
如果我们编译并运行它,我们会得到相同的结果:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
Class1 : News has arrived!
摘要
本章中的示例应该为您提供使用属性、委托和事件的基础。如果一开始觉得力不从心也不用担心。尽管这是一个需要花点时间来适应的领域,但它无疑为您的编码库添加了一些强大的工具。
在下一章,我们将看看 C# 和 C++ 中的表达式和运算符。
Footnotes 1
当这个类是value type
的时候,有趣的事情发生了。在值类型中,this
被认为是值类型的内部指针,而不是跟踪句柄。因此,当编译器看到this["dog"]
时,它试图像解引用原生数组一样解引用该指针,并抱怨"dog"
不是有效的数组下标。请记住,在 C++ 中,本机数组的行为与指针完全一样,只是它们被绑定到一个特定的长度。
2
在 C++ 语言的当前版本最终确定之前,关于 setters 需要返回 c++ 语言设计者分发列表上的void
有一场大讨论。我和本书的技术评论家都支持允许用户灵活使用set
方法返回代码;我们输了。
十一、表达式和运算符
为你的局限性争辩,毫无疑问,它们是你的。—理查德·巴赫,幻觉
在这一章中,我们将讨论表达式和操作符与 C# 的区别。我们将从一个警告开始:不要假设 C# 和 C++ 中的表达式求值是相同的。C# 和 C++/CLI 有不同的规则来控制表达式的计算,如果您正在编写过于复杂的表达式,这可能会让您感到惊讶。
这里有一个 C++ 的老把戏,经常出现在面试问题上。它不能保证按照 C++ 标准工作,但是它可以在 x86 的所有主要 C++ 编译器上工作。它被称为 XOR 交换,它允许您交换两个整数的值,而无需声明显式的临时。代码如下:
using namespace System;
void main()
{
int i=3, j=6;
Console::WriteLine("{0}, {1}", i, j);
i ^= j ^= i ^= j;
Console::WriteLine("{0}, {1}", i, j);
}
让我们运行它:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
3, 6
6, 3
看下面一行:
i ^= j ^= i ^= j;
如您所见,它交换了i
和j
的值,因为它的计算结果如下:
i ^= j;
j ^= i;
i ^= j;
第一个 XOR 交换将i
和j
之间的位差存储在i
中。下一行根据这个差异改变j
,变成i
。最后一行通过将原来的i
(当前为j
)也改变为差异(当前为i
),从而将i
的剩余部分改变为j
。这依赖于以下身份进行异或:
x == y ^ (x^y)
它或多或少地将x
和y
分成两部分:相同的部分和不同的部分,就像取两个数字并知道它们与平均值等距。
现在让我们试着用 C# 来做这件事:
using System;
class R
{
public static void Main()
{
int i=3, j=6;
Console.WriteLine("{0}, {1}", i, j);
i ^= j ^= i ^= j;
Console.WriteLine("{0}, {1}", i, j);
}
}
结果如下:
C:\>csc /nologo test.cs
C:\>test
3, 6
0, 3
如你所见,这是行不通的。大概就是缺少括号吧?如果我们试试这个呢?
i ^= (j ^= (i ^= j));
那也不行;我们得到同样的结果。这里的答案是 C# 和 C++ 对表达式求值的方式不同。这些规则相当复杂,除非你选择的职业是规则律师,否则你并不需要非常了解它们。
Note
在这种情况下,C# 代码的求值方式不同,因为 C# 将表达式的求值与变量的求值分开,以便帮助优化器。C++ 计算带括号的表达式;C# 可以自由地扫描整个语句,预先计算变量,并使用这些值来计算表达式。该代码依赖于在表达式中间更新的临时值i
和j
,以便正确工作。
避免这些深奥的结构是很好的编程实践。编写两种语言都能正常工作的代码的简单而安全的方法是细分表达式:
i ^= j;
j ^= i;
i ^= j;
这个序列在 C# 和 C++ 中都可以正常工作。十年前,将这些表达式编织在一起可能会产生更快的代码;今天的优化编译器已经足够成熟,可以计算出你要做什么,并补偿这种扩展。
运算符重载
C# 和 C++/CLI 最重要的方面之一是它们支持将用户定义的类型提升到内置类型的级别;其中一个重要的方面是定义操作符来处理新类型的能力。出版文献中最常见的例子定义了复杂变量或分数的类型,但这只是冰山一角。定义运算符来执行与其数学定义完全无关的运算也是常见的做法,这拓展了我们有限范式的边界,并经常重新定义新的范式。
当然,也有局限性,包括以下几点:
- 一元运算符必须保持一元;二元运算符必须保持二元。换句话说,您不能重新定义加号(
+
)来接受三个参数而不是两个。 - 您不能编造不存在的运算符。您不能定义一个
/%
操作符,即使它在逻辑上可以被语法消除歧义。您只能使用该语言的内置操作符。 - 您不能控制预定义的求值顺序,也不能期望复杂的表达式在 C++/CLI 和 C# 中以相同的方式求值。如前所述,C# 和 C++/CLI 有不同的规则来控制表达式的求值。
复数,一个基本例子
回想一下,我们可以考虑 C++/CLI 中的一个简单的复数类,复数是以下形式的数字:
a + bi
在哪里
这有助于我们为第十五章打下基础,当我们在以下形式的数字环境中使用模板重温复数时:
这种形式在处理黄金比例时非常有用:
利用黄金分割率,我们可以用一种非递归的、简单的、封闭的形式来计算斐波那契数列。 1
复数的回顾
使用复数的基本数学运算的回顾如下: 2
添加:
(a + bi) + (c + di) = (a + c) + (b+d)i
减法:
(a + bi) - (c + di) = (a - c) + (b - d)i
复杂共轭:
乘法:
(a+bi)(c+de)=(AC-BD)+(ad+BC)I
除以标量(实数):
复数之间的除法:使用复数共轭、乘法、标量除法和下列恒等式,
我们可以推导出复数之间的除法:
注意这个除法运算如何遵从复共轭以及乘法来计算商。
简单实现
我们通过定义类数据以及作用于数据的操作符来实现这个类。非常简单,类数据是对应于实部和虚部的两个双精度值,乘以以下内容:
数据结构如下:
value struct Complex
{
double re;
double im;
}
至于操作符本身,有几种方法来定义它们,这取决于我们是否希望我们的代码符合公共语言规范(CLS )(我们将在本章后面再讨论这一点)。本质上,我们的操作符是静态成员函数,它们返回对象而不是引用。
一元运算符
CLI 一元运算具有以下格式:
static``type``operator``op``(``type
我们将在我们的类中使用以下操作符:
复杂共轭:
static Complex operator ∼ (Complex a);
二元运算符
CLI 二进制操作具有以下格式:
static``type``operator``op``(``type``a,``type``b)
我们将在我们的类中使用这些运算符:
添加:
static Complex operator + (Complex a, Complex b);
减法:
static Complex operator - (Complex a, Complex b);
乘法:
static Complex operator * (Complex a, Complex b);
除以二:
static Complex operator / (Complex a, double b);
按复合体划分:
static Complex operator / (Complex a, Complex b);
秩序至关重要
注意,代码没有假设交换性;将a/b
定义为不同于b/a
是完全合理的,因此也可以实现以下代码行:
static Complex operator / (Complex a, double b)
用不同于这行的方法:
static Complex operator / (double a, Complex b)
我们努力的成果
完整的程序如下:
using namespace System;
value struct Complex
{
double re;
double im;
Complex(double re, double im)
{
this->re = re;
this->im = im;
}
static Complex operator + (Complex a, Complex b)
{
return Complex(a.re+b.re, a.im+b.im);
}
static Complex operator - (Complex a, Complex b)
{
return Complex(a.re-b.re, a.im-b.im);
}
static Complex operator ∼ (Complex a)
{
return Complex(a.re, - a.im);
}
static Complex operator * (Complex a, Complex b)
{
return Complex(a.re*b.re - a.im*b.im, a.re*b.im + a.im*b.re);
}
static Complex operator / (Complex a, Complex b)
{
return a / (b.re*b.re+b.im*b.im) * ∼b;
}
virtual String ^ ToString() override
{
String ^s = re.ToString();
if(im != 0)
{
return s += " + " + im.ToString() + "i";
}
return s;
}
private:
static Complex operator / (Complex a, double f)
{
return Complex(a.re/f, a.im/f);
}
};
void main()
{
Complex a(-5,10), b(3,4);
Console::WriteLine("({0}) / ({1}) = {2}",a,b,a/b);
}
正如您所看到的,基本操作符+
、-
、*
和/
已经被重载来操作Complex
类型,而不是它们所基于的子类型,在本例中是double
。
布尔逻辑中的一元补码运算符∼
并不直观地对应于你在实数上对复数执行的任何运算。因此,它是满足我们对复共轭一元运算符需求的理想候选,我们需要实现operator/
。除了参数数量和参数类型之外,编译器不会强制任何逻辑范例。你可以自由定义operator*
为除法,operator/
为乘法。这当然是不好的形式,除非混淆视听是你的目标。
快速编译和运行的结果如下:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
(-5 + 10i) / (3 + 4i) = 1 + 2i
过载的解决方案
你可能还注意到有两种不同的除法方法。C# 和 C++ 都有选择调用哪个方法的内置规则,但这些规则的不同之处令人惊讶。两个名称相同但参数不同的方法称为重载。为给定的一组参数确定最匹配的过程被称为重载决策,我在这里将在operator/
的上下文中介绍它,尽管它将继续是我们顺便涉及的一个主题。
假设我们用下面的函数替换前面的main()
函数:
void main()
{
Complex a(-5,10);
float b = 5.0f;
Console::WriteLine("({0}) / ({1}) = {2}",a,b,a/b);
}
让我们运行这个例子:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
(-5 + 10i) / (5) = -1 + 2i
当这个例子被执行时,编译器需要查找如何计算a/b
,其中变量a
的类型为Complex
,变量b
的类型为float
。
编译器解析a/b
并开始寻找兼容的方法
operator/(Complex a,float b)
在程序的源代码中,没有方法具有这种精确的签名,所以编译器收集了一个可能的候选方法列表,并试图确定最佳匹配。在这种情况下,可能的选择如下:
operator/(Complex a,double b)
operator/(Complex a,Complex b)
这两个都不完全匹配。C++ 标准中有明确定义的规则来管理重载的解析,这些规则不仅适用于运算符,也适用于一般的函数。我不想在这个问题上纠缠太多;现在,要知道在这种情况下直觉的选择是赢家。允许的操作是将float
提升(扩展)到double
并选择以下选项:
operator/(Complex a,double b)
重载决策的规则提供了一种处理隐式和显式转换的多层方法。某些转换优于其他转换,这绝不是任意的。乍一看,这似乎不是一个充满危险的话题,但请考虑以下情况:假设我们在代码中添加了一个从double
到Complex
的隐式转换。如果编译器可以自动执行这种转换,我们还会担心创建无限递归吗?由于operator/(Complex, Complex)
调用operator/ (Complex, double)
,添加一个从double
到Complex
的隐式转换可能会导致歧义或无限循环。在这种情况下,这是因为 C++ 规范中的优先级规则,它为每种类型的转换分配一个等级,并根据等级对它们进行优先级排序。我们将在本章后面讨论隐式和显式转换。
当你认为复数的主题变得过于数学化时,请做好准备——我很高兴向你展示下面的数学转移。
数学转移:数字模素数
C# 和 C++/CLI 都使用百分号作为运算符来计算一个数对另一个数的模。回想一下,当number
除以p
时,(number%p)
等于余数。很容易定义一类以一个数为模的数p
。下面就是这组简单的数字:
{0,1, .。。(p - 1)}
现在我们只需要弄清楚如何对它们进行操作。
通过计算结果模p
,我们可以很容易地重新定义加法、乘法和减法的基本运算符。除法通常会让我们遇到分数的使用,但是初等数论的一个结果告诉我们,当模数p
是质数时,除法可以不用分数来定义。例如,让我们考虑以 13 为模的数字,假设我们正在试图计算四分之一,1 除以 4,是多少。换句话说,4 的倒数是多少?
一个简单的计算表明(4 * 10) % 13 = 1
,由于4*10=40=39+1
,因此 1 是 40 除以 13 的余数。
让我们用编译器来证明这一点:
using namespace System;
void main()
{
Console::WriteLine("4 * 10 = {0} (13)", 4*10%13);
}
当我们编译并执行它时,我们得到了以下内容:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
4 * 10 = 1 (13)
10 是 4 的倒数。如果我们将两边除以 4,我们得到如下结果:
因为
同样,所有以 13 为模的非零数字都以同样的方式求逆。为了找到它,我们必须使用数论的另一个结果。事实证明,对于每两个数字 a 和 b,都存在数字 x 和 y
如果其中一个数是质数,另一个不是这个质数的倍数,那么这两个数的最大公约数(gcd)就是 1,我们得到如下:
因为根据定义,p 的任何倍数都是 0,所以我们得到
阅读前面的表达式如下:py 等于 0 模 p,因为当 py 除以 p 时余数为 0。结合这些事实,我们得出结论,存在一个数 x,使得
换句话说,我们只需要找到数字 x,我们就有了它的逆!我不会用更多的细节或推导来烦你,但是有一个扩展版本的欧几里德算法可以帮你做到这一点。 3 它在下面的代码中;请注意,ExtendedEuclid()
是作为一个全局函数实现的,而不是一个类方法,它将对整数的引用作为它的一些参数:
using namespace System;
void ExtendedEuclid(int a, int b, int %d, int %x, int %y)
{
if(b==0)
{
d=a;
x=1;
y=0;
}
else
{
ExtendedEuclid(b,a%b, d, y, x);
y-= (a/b)*x;
}
}
value struct F13
{
unsigned Value;
initonly static unsigned P = 13;
F13(unsigned Val)
{
Value = Val % P;
}
static F13 operator * (F13 arg1, F13 arg2)
{
return F13((arg1.Value * arg2.Value) % P);
}
static F13 operator + (F13 arg1, F13 arg2)
{
return F13((arg1.Value + arg2.Value) % P);
}
static F13 operator - (F13 arg1, F13 arg2)
{
return F13((arg1.Value - arg2.Value) % P);
}
static F13 operator - (F13 arg1)
{
return F13((P - arg1.Value) % P);
}
static F13 operator / (F13 arg1, F13 arg2)
{
int d, x, y;
ExtendedEuclid(arg2.Value,P,d,x,y);
return arg1*F13(x*d);
}
virtual String ^ ToString() override
{
Value = (Value+P) % P;
String ^s = Value.ToString();
return s;
}
};
void main()
{
F13 a(6), b(9), c(4), d(10);
Console::WriteLine("{0} * {1} is {2}", a, b, a*b);
Console::WriteLine("{0} / {1} is {2}", a, b, a/b);
Console::WriteLine("{0} * {1} is {2}", c, d, c*d);
}
结果如下:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
6 * 9 is 2
6 / 9 is 5
4 * 10 is 1
内置类型的隐式和显式转换
C# 和 C++/CLI 都支持定义类型之间的隐式和显式转换。这是用户定义的类型,相当于将float
提升为double
,或者将short
提升为int
。隐式转换是编译器可以自动应用的转换,而显式转换需要 cast 运算符。让我们谈一谈内置类型之间的转换。
C# 和 C++ 之间的转换差异
不幸的是,内置类型的隐式转换在 C++ 和 C# 之间有所不同。众所周知,C++ 在防止有数据丢失风险的转换方面非常松懈。考虑以下示例:
using namespace System;
void main()
{
long l=65537;
short s=0;
s=l;
l=s;
Console::WriteLine(l);
}
现在让我们试一试:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
1
在这种情况下,编译器会在short
和long
之间进行隐式转换,反之亦然,并且不会发出可能丢失数据的警告。如果我们将警告级别提高到 3,我们会得到以下输出:
C:\>cl /clr:pure /nologo /W3 test.cpp
test.cpp(6) : warning C4244: '=' : conversion from 'long' to 'short', possible loss
of data
这才像话!
假设我们将long
改为int
,并在警告级别 3 进行编译。数据丢失仍然存在:
C:\>cl /clr:pure /nologo /W3 test.cpp
C:\>test
1
充其量,这是一个烦恼。在最坏的情况下,这是一个召回类的错误。幸运的是,如果我们将警告级别提高到 4 级,我们会得到以下结果:
C:\>cl /clr:pure /nologo /W4 test.cpp
test.cpp
test.cpp(6) : warning C4244: '=' : conversion from 'int' to 'short', possible loss
of data
4 级警告的唯一问题是,它们被认为是建议性的,而不是诊断性的,有时会出现虚假或嘈杂的警告。吸取的教训是你需要小心。在这方面,C++ 编译器不像 C# 编译器那样小心翼翼,正如我以前所建议的,当你开发代码时,不时地打开/W4
警告。
有符号/无符号不匹配
如果您试图将有符号值赋给无符号变量,C++/CLI 编译器会发出警告,反之亦然。默认情况下,它是禁用的,但是可以使用/Wall
编译器选项来启用,这将启用编译中默认禁用的所有警告。
例如,考虑以下情况:
void main()
{
unsigned u=0;
int i=0;
i=u;
}
编译后,我们得到
C:\>cl /Wall /nologo test.cpp
test.cpp(5) : warning C4365: '=' : conversion from 'unsigned int' to 'int',
signed/unsigned mismatch
整数转换表
让我们回顾一下 C++ 和 C# 中的一些内置转换。使用以下缩写列表作为解释整数换算表的关键(表 11-1 至表 11-5 ):
例如:明确的
im:隐式,没有警告
i2:隐含,警告级别 2
i3:隐含,警告级别 3
i4:隐含,警告级别 4
ia:隐式,仅警告/Wall
(表示有符号/无符号不匹配)
x:不需要转换
我们先来看看表 11-1 中的整数类型。
表 11-1。
C++/CLI Conversion Table for a Sampling of Built-in Integer Types
让我们看看 C# 表中的表 11-2 中的整数类型。
表 11-2。
C# Conversion Table for a Sampling of Built-in Integer Types
请记住,当您阅读这些表格时,您必须考虑到long
在 C# 中的含义与在 C++ 中的含义不同。在 C++/CLI 中,long
和int
都是System::Int32
的别名,long long
用于System::Int64
,而 C# 是通过使用long
来实现的。表 11-3 摘自第六章中的类型表。
表 11-3。
A Partial Type Table
现在让我们看看换算表。您可能会注意到,在 C# 表中没有可能丢失数据。我是 C++ 的拥护者,但我必须承认,在这方面我更喜欢 C# 实现。还要注意,C++/CLI 也将 C# 认为是隐式的每个转换视为隐式的,但在从unsigned int
扩展到long
时仍会报告有符号/无符号不匹配。
好消息是,当我们查看这些表格时,我们发现 C++/CLI 有办法获得与 C# 中相同的警告级别。这不直观但很简单——不要用int
。
在 C++/CLI 中,int
和long
都映射到System::Int32
,而unsigned int
和unsigned long
都映射到System::UInt32
,但 C++ 编译器出于警告目的对它们进行了不同的处理。造成这种情况的大部分原因是历史原因;short
和long
最初被定义为目标架构支持的最小和最大整数大小。类型int
被定义为目标架构的最有效尺寸。随着时间的推移,实现发现这种浮动定义使得在平台之间移植程序成为问题。这导致了上的当前实现。NET 将short
固定为 16 位,将int
和long
固定为 32 位。类型long long
被添加到. NET 的 64 位语言中。
对于其他目标体系结构,int
要么被实现为short
要么被实现为long
,这使得警告的发布成为问题。非官方的编程实践是,当你不真正关心转换问题并且需要快速、高效的代码时,使用int
;如果数据本身需要,你可以使用short
或long
。这种做法今天仍然适用于。NET:在int
上使用short
和long
,编译器会做好自己的工作并发出警告。
现在我们来看看浮点转换。
浮点转换表
让我们在表 11-4 中考察一些 C++ 中的交叉转换。
表 11-4。
C++ Conversion Table for Floating Point Types and for a Sampling of Integer Types
让我们在表 11-5 中检查一些 C# 中的交叉转换。
表 11-5。
C# Conversion Table for Floating Point Types and for a Sampling of Integer Types
关于浮点转换表需要注意的一件有趣的事情是,C++ 在将int
提升为float
时会发出 2 级警告,但在将int
提升为double
时不会。考虑下面的片段:
using namespace System;
void main()
{
int i0 = int::MaxValue;
int i;
float f;
double d;
f = i0;
i = f;
Console::WriteLine("int {0}, to float {1}, back to int {2}", i0, f, i);
d = i0;
i = d;
Console::WriteLine("int {0}, to double {1}, back to int {2}", i0, d, i);
}
在本例中,我们取最大正整数,并将其转换为一个float
,然后返回,数据会丢失。尽管int
和float
都是 4 个字节长,float
使用其中的一些位来存储指数和符号信息,所以它不能完全精确地存储整数信息。如果您使用double
进行类似的往返,则不会有数据丢失。因此,警告是正确的:
C:\>cl /clr:pure /nologo /W4 test.cpp
test.cpp(8) : warning C4244: '=' : conversion from 'int' to 'float', possible loss
of data
test.cpp(9) : warning C4244: '=' : conversion from 'float' to 'int', possible loss
of data
test.cpp(12) : warning C4244: '=' : conversion from 'double' to 'int', possible loss
of data
C:\>test
int 2147483647, to float 2.147484E+09, back to int -2147483648
int 2147483647, to double 2147483647, back to int 2147483647
你可能认为 C# 编译器有问题,因为它允许从int
到float
的转换,而没有警告可能的数据丢失。然而,C# 要求从float
到int
的转换有一个显式的转换或强制转换,所以你可以说出站方向的警告是多余的。C# 代码如下:
using System;
class R
{
public static void Main()
{
int i0 = int.MaxValue;
int i;
float f;
double d;
f = i0;
i = (int)f;
Console.WriteLine("int {0}, to float {1}, back to int {2}", i0, f, i);
d = i0;
i = (int)d;
Console.WriteLine("int {0}, to double {1}, back to int {2}", i0, d, i);
}
}
C# 版本的结果如下:
C:\>csc /nologo test.cs
C:\>test
int 2147483647, to float 2.147484E+09, back to int -2147483648
int 2147483647, to double 2147483647, back to int 2147483647
请注意,C# 版本需要显式转换才能编译。
用户定义的转换
与编译器定义内置类型之间的隐式和显式转换相同,用户也可以定义用户定义类型之间的隐式和显式转换。在 C# 中,你使用implicit
和explicit
关键字。在 C++/CLI 中,默认情况下转换是隐式的,您可以使用explicit
关键字来指定显式转换。
隐式转换
在我们的Complex
类中,我们使用了一个私有的 helper 函数来定义一个复数除以一个double
。为什么不揭露这个?除此之外,为什么不允许用户将一个复数乘以一个double
或者将一个double
乘以一个复数呢?
我们可以为这些操作中的每一个编写特定的重载,或者我们可以定义一个隐式操作符,将一个double
转换成一个Complex
。在这里,在 C++/CLI 语法中;它是一个静态成员函数,接受一个double
参数:
static operator Complex(double re)
{
return Complex(re,0);
}
现在,用户可以对复数和双精度数执行所有基本的数学运算。下面是一个新版本的main()
使用了这个隐式操作符:
void main()
{
Complex a(-5,10), b(3,4);
double c(3.5);
Console::WriteLine("({0}) / ({1}) = {2}",a,b,a/b);
Console::WriteLine("({0}) * ({1}) = {2}",a,c,a*c);
Console::WriteLine("({0}) / ({1}) = {2}",c,a,c/a);
}
编译和运行后,我们得到以下内容:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
(-5 + 10i) / (3 + 4i) = 1 + 2i
(-5 + 10i) * (3.5) = -17.5 + 35i
(3.5) / (-5 + 10i) = -0.14 + -0.28i
对于一点点工作来说,这是很大的能量。从一个Complex
到一个double
的另一个方向呢?
显式转换
从Complex
到double
将会丢失一些数据,因此这不应该是隐式转换。这可能意味着什么?是不是应该把复数投影到实线上,只返回复数的实部?应该返回复数的大小吗?向double
的转变应该存在吗?走哪条路真的要由我们来决定。
就我个人而言,我被使用量值的想法所吸引:
除了关键字explicit
之外,显式转换看起来与隐式转换一样:
static explicit operator double(Complex c)
{
return Math::Sqrt(c.re*c.re + c.im * c.im);
}
为了简洁起见,我们可以替换以下代码:
static Complex operator / (Complex a, Complex b)
{
return a / (b.re*b.re+b.im*b.im) * ∼b;
}
随着
static Complex operator / (Complex a, Complex b)
{
return a / ((double)b * (double)b) * ∼b;
}
这给了我们以下完成的程序:注意operator/(Complex, double)
不再是private
:
using namespace System;
value struct Complex
{
double re;
double im;
Complex(double re, double im)
{
this->re = re;
this->im = im;
}
static Complex operator + (Complex a, Complex b)
{
return Complex(a.re+b.re, a.im+b.im);
}
static Complex operator - (Complex a, Complex b)
{
return Complex(a.re-b.re, a.im-b.im);
}
static Complex operator ∼ (Complex a)
{
return Complex(a.re, - a.im);
}
static Complex operator * (Complex a, Complex b)
{
return Complex(a.re*b.re - a.im*b.im, a.re*b.im + a.im*b.re);
}
virtual String ^ ToString() override
{
String ^s = re.ToString();
if(im != 0)
{
return s += " + " + im.ToString() + "i";
}
return s;
}
static Complex operator / (Complex a, Complex b)
{
return a / ((double)b * (double)b) * ∼b;
}
static operator Complex(double re)
{
return Complex(re,0);
}
static explicit operator double(Complex c)
{
return Math::Sqrt(c.re*c.re + c.im * c.im);
}
static Complex operator / (Complex a, double f)
{
return Complex(a.re/f, a.im/f);
}
};
void main()
{
Complex a(-5,10), b(3,4);
double c(3.5);
Console::WriteLine("({0}) / ({1}) = {2}",a,b,a/b);
Console::WriteLine("({0}) * ({1}) = {2}",a,c,a*c);
Console::WriteLine("({0}) / ({1}) = {2}",c,a,c/a);
}
符合 CLS 标准的运营商
在 C++ 中有几种定义操作符的方法,这取决于你的意图和目标。在这种情况下,目标是创建一个符合 CLS 标准的应用程序。公共语言规范(CLS)定义了如何使代码与多种 CLI 语言兼容。因此,在编写与 C# 或其他链接的程序时,最好采用这种范式。网络语言。当满足以下所有标准时,我们认为运营商符合 CLS 标准:
- 如 CLS 所述,该操作器列在符合 CLS 标准的表格中。
- 运算符是引用或值类的静态成员。
- 运算符函数的参数和返回值不由任何指针、引用或句柄传递或返回。
让我们检查一下表 11-6 中符合 CLS 的一元运算符。 4
表 11-6。
CLS-Compliant Unary Operators
| 操作员名 | 函数名 | C# | C++ | | --- | --- | --- | --- | | `operator&` | `AddressOf` | 不 | 是 | | `operator!` | `LogicalNot` | 是 | 是 | | `operator∼` | `OnesComplement` | 是 | 是 | | `operator*` | `PointerDereference` | 不 | 是 | | `operator-` | `UnaryNegation` | 是 | 是 | | `operator+` | `UnaryPlus` | 是 | 是 | | `operator true` | `true` | 是 | 不 | | `operator false` | `false` | 是 | 不 |让我们检查一下表 11-7 中符合 CLS 的二元运算符。
表 11-7。
CLS-Compliant Binary Operators
| 操作员名 | 函数名 | C# | C++ | | --- | --- | --- | --- | | `operator+` | `Addition` | 是 | 是 | | `operator&` | `BitwiseAnd` | 是 | 是 | | `operator|` | `BitwiseOr` | 是 | 是 | | `operator,` | `Comma` | 是 | 是 | | `operator--` | `Decrement` | 是 | 是 | | `operator/` | `Division` | 是 | 是 | | `operator==` | `Equality` | 是 | 是 | | `operator^` | `ExclusiveOr` | 是 | 是 | | `operator>` | `GreaterThan` | 是 | 是 | | `operator>=` | `GreaterThanOrEqual` | 是 | 是 | | `operator++` | `Increment` | 是 | 是 | | `operator!=` | `Inequality` | 是 | 是 | | `operator<<` | `LeftShift` | 是 | 是 | | `operator<` | `LessThan` | 是 | 是 | | `operator<=` | `LessThanOrEqual` | 是 | 是 | | `operator&&` | `LogicalAnd` | 不 | 是 | | `operator||` | `LogicalOr` | 不 | 是 | | `operator%` | `Modulus` | 是 | 是 | | `operator*` | `Multiply` | 是 | 是 | | `operator>>` | `RightShift` | 是 | 是 | | `operator-` | `Subtraction` | 是 | 是 |这些操作符中的大多数都是不言自明的。只有几个值得特别一提:
operator*
可以是Multiply
也可以是PointerDereference
,这取决于它是二元还是一元运算符。operator&
可以是BitwiseAnd
也可以是AddressOf
,这取决于它是二元还是一元运算符。operator&&
和operator||
都可以在 C++/CLI 中重载。这些不能在 C# 中重载。- C++ 中没有实现
operator true
和operator false
。
运算符 true 和运算符 false
operator true
和operator false
在下面的 C# 代码中使用,在 C++/CLI 中不能类似地编写:
using System;
class R
{
int value;
R(int V)
{
value = V;
}
public static bool operator true (R r)
{
return r.value!=0;
}
public static bool operator false ( R r)
{
return r.value==0;
}
public void Test(String name)
{
if(this)
{
Console.WriteLine("{0} is true", name);
}
else
{
Console.WriteLine("{0} is false", name);
}
}
public static void Main()
{
R r3 = new R(3);
r3.Test("r3");
R r0 = new R(0);
r0.Test("r0");
}
}
如果在 C# 中编译并运行,您会得到以下结果:
C:\>csc /nologo test.cs
C:\>test
r3 is true
r0 is false
在 C++ 中有一个很好的解决方法,使用到bool
的隐式转换:
using namespace System;
ref class R
{
private:
int value;
R(int V)
{
value = V;
}
public:
static operator bool(R^ r)
{
return r->value != 0;
}
void Test(String^ name)
{
if(this)
{
Console::WriteLine("{0} is true", name);
}
else
{
Console::WriteLine("{0} is false", name);
}
}
static void Main()
{
R ^r3 = gcnew R(3);
r3->Test("r3");
R ^r0 = gcnew R(0);
r0->Test("r0");
}
};
void main()
{
R::Main();
}
其他操作员
C++ 允许你重载赋值操作符、函数调用(operator()
)和索引(operator[]
),其方式不符合 CLS 标准。
摘要
对表达式和操作符有良好的感觉在面向对象编程中是很重要的。它们允许您扩展您的类,并像处理内置类型一样处理它们。
在下一章,我们将通过补充一些在前几章的大类中没有涉及到的细节来完成我们的基本 C++ 之旅。在那之后,你将为后面的章节做好更深入和详细的准备。
Footnotes 1
计算机编程艺术,第一卷:基本算法,第三版。(波士顿:艾迪森-韦斯利出版社,1997 年)。
2
这些是从复数域的结合律、交换律和分配律以及i
的定义中推导出来的。
3
《算法导论》,第二版。(麻省剑桥:麻省理工学院出版社,2001 年)。
4
这些表格来自 C++/CLI 语言规范。
十二、开始的结束
余,要不要我教你什么是知识?当你知道一件事时,要意识到你知道它;当你不知道一件事时,允许你不知道:这就是知识。—孔子
在这一章中,我们将填补空白,完成对 basic C++ 的介绍。我们将讨论包含文件、范围解析、各种操作符细节和异常。
包含文件
C# 和 C++/CLI 的一个主要区别是 C# 的多通道特性。C++ 是作为 C 语言的一个包装器而设计的,C 语言的设计目的是在功能远不如当代家用个人电脑的电脑上运行,现代家用个人电脑由于速度太慢,除了查看电子邮件之外,不能做任何事情。因此,C 语言本质上是一种单程语言,因为编译器可以编译整个程序,一条语句一条语句地将 C 语言消化并翻译成汇编语言。这需要程序员安排代码,首先是基本子程序,然后是调用基本子程序的子程序,最后是调用前面任何子程序的main()
过程。
这一要求适用于简单的代码,但不适用于子例程或类交叉引用的情况,这在日常编码中很常见。一种通常交叉引用的常见数据结构是类型安全函数回调:一个类的方法调用第二个类来注册对第一个类的回调。
远期申报
为了解决这个问题,C 语言增加了向前声明的概念。前向声明是一个函数或类的原型,它告诉编译器某个标识符是一个类或函数,或者提供类结构本身的细节。C++ 在给类添加方法的时候,也扩展了类的设计,让类本身不是单通而是双通元素。如果你想知道,在 C 中,一个类是用关键字struct
声明的。
C++ 是一种一次通过的语言,但是类本身在两次通过中被解析。因此,当您在定义函数或变量之前访问它时,需要前向声明,除非它们是同一类的成员。此外,C++ 首先将文件编译成目标文件,并需要声明来使用称为链接器的程序将这些目标文件绑定到单个模块或可执行文件中。
相比之下,C# 不仅本质上是一种两遍语言,而且在编译时会同时考虑单个模块的所有文件。当添加越来越多的源文件而没有细分成模块时,C# 的编译就变得越来越低效。另一方面,C++ 伸缩性很好,因为每个源文件都会生成一个新的目标文件。此外,声明描述了文件的外部内容,因此可以添加新文件并重新编译,而不需要完全重建整个模块。
问题
C++ 方法的优点和缺点是相当清楚的。由于 C++ 需要准确的声明才能正确编译并将目标文件绑定在一起,如果声明不准确会发生什么?如果你改变了一个函数的定义,而没有改变该函数相应的声明,会发生什么?如果这些变得不同步,程序就不能正确编译。更糟糕的是,你可能在编译的顺序上犯了一个错误,它可能会编译,因此链接器将会把一个特定函数的使用与另一个函数的函数定义绑定在一起。
解决方案
为了保持所有的前向声明和原型同步,C 和 C++ 都使用包含文件来跟踪原型。包含文件是包含声明和原型的文件,这些声明和原型在程序编译时包含在程序的源代码中。通过这种方式,一个包含文件可以被多个源文件共享,因此每个人总是使用最新版本的原型和声明。如果包含文件发生更改,可以使用 make 程序强制重新编译包含它的所有源文件。如果包含文件中项的声明与源文件中相应的定义不同步,编译器会发出一个错误,该声明可以被更正。对于包含声明、类定义和预处理器定义的头文件,Include 文件通常具有扩展名.h
,对于同样包含实例化和变量定义的头文件,则具有扩展名.hxx
和.hpp
。
包括文件警告
注意,这个范例是用户定义的。如果代码来自包含文件而不是顶级源文件,编译器不会对其进行不同的编译——对编译器来说,C++ 就是 C++。因此,如果将定义放在包含文件中,可能会遇到麻烦。如果一个定义包含在多个源文件中,那么它会在每个源文件中定义,最终会出现一个多重定义的符号错误。例如,考虑以下三个文件:
// test1.cpp
#include "a.h"
// test2.cpp
#include "a.h"
// a.h
void hello()
{
}
使用以下命令行编译test1.cpp
和test2.cpp
:
C:\>cl /nologo test1.cpp test2.cpp
test1.cpp
test2.cpp
Generating Code...
test2.obj : error LNK2005: "void __cdecl hello(void)" (?hello@@YAXXZ) already
defined in test1.obj
LINK : fatal error LNK1561: entry point must be defined
注意,我们还会得到一个链接器错误,因为全局函数main()
在我们的任何源文件中都找不到。
声明的类型
声明有各种形状和大小。它们可以定义类或全局对象,并且类声明可以发生在从没有定义到完整定义的任何情况下。
没有定义的类声明
当编译文件不需要了解类的内部结构时,声明标识符是一个类就足够了,如下例所示:
class A;
class B
{
A *pA;
};
因为我们只有一个指向类型A
的指针,所以为了给指针pA
分配空间,类B
不需要知道A
的定义——简单的声明就足够了。
带定义的类声明
以下代码无法编译:
class A;
class B
{
A a;
};
编译器被要求用每个B
实例创建一个A
实例。在这个例子中,编译器需要知道A
的大小和结构,然后才能完成B
的定义。定义A
来修复代码。
编译器输出如下:
C:\>cl /nologo test.cpp
test.cpp(4) : error C2079: 'B::a' uses undefined class 'A'
全局变量的声明
全局变量可以用关键字extern
声明,也可以不用,如下所示:
class A;
extern int i;
extern A * pA;
默认情况下,在全局范围内声明的变量是extern
,这意味着它们在声明它们的编译单元之外是可见的。这也称为外部链接。外部链接的反义词是内部链接,内部链接是用static
关键字实现的。
类的声明和定义的分离
类的声明可以从定义中分离出来,如下所示:
//include file (a.h)
class A
{
A();
void Method();
};
//source file (a.cpp)
A::A()
{
}
void A::Method()
{
}
在这个例子中,类A
的声明可以出现在一个包含文件中,该文件包含在几个源文件中,而使用完全限定名的各个方法的定义出现在单个源文件中。回想一下,C++ 不支持分部类;分部类与 C++ 范式不兼容。在 C# 中,编译器可以在所有源文件中搜索分部类定义,并在定义类之前将它们收集在一起。在 C++ 中,这是不可能的。
对类解析的多遍性质的隐式前向引用
以下代码在 C++ 中运行良好,即使没有前向声明。真正的问题是编译器用的是哪个版本的Hello()
?
using namespace System;
void Hello()
{
Console::WriteLine("::Hello");
}
ref class R
{
public:
R()
{
Hello();
}
void Hello()
{
Console::WriteLine("R::Hello");
}
};
void main()
{
R ^r = gcnew R();
}
让我们试试看:
C:\>cl /nologo /clr test.cpp
C:\>test
R::Hello
如你所见,Hello()
的类版本优先于全局版本,即使从构造器调用Hello()
时R::Hello()
还没有出现在代码中。类版本总是比全局版本更受青睐,因为它在范围上更接近全局版本,并且类版本将是候选版本,因为在解析任何定义之前,会先解析整个类的声明。这是 C++ 类解析的两遍性质的一个例子。这个实现与 C++ 的作用域规则是一样的,它规定在类作用域内部,类作用域优先于全局作用域。
范围解析运算符
在前面的例子中,如果我们想要调用函数Hello()
的全局版本而不是类版本呢?在 C++ 中,通过在名称前面加冒号来指定全局命名空间。例如,让我们将构造器更改如下:
R()
{
::Hello();
Hello();
}
现在让我们看看会发生什么:
C:\>cl /nologo /clr test.cpp
C:\>test
::Hello
R::Hello
可以看到,第一个调用::Hello()
调用了全局函数,第二个调用Hello()
调用了成员函数。在标识符前面加上冒号-冒号范围解析运算符,开始在全局命名空间中搜索标识符。
在下面的例子中,所讨论的标识符不是一个全局函数,而是另一个类的成员函数:
using namespace System;
ref struct Outer
{
ref struct Inner
{
Inner()
{
Console::WriteLine(__FUNCSIG__);
}
ref struct Outer
{
ref struct Inner
{
Inner()
{
Console::WriteLine(__FUNCSIG__);
}
public:
static void Test()
{
Outer::Inner ^m0 = gcnew Outer::Inner();
::Outer::Inner ^m1 = gcnew ::Outer::Inner();
}
};
};
};
};
void main()
{
Outer::Inner::Outer::Inner::Test();
}
在这段代码中,我们使用__FUNCSIG__
宏来打印每个Inner()
方法的签名。如果我们编译并运行它,我们会得到以下结果:
C:\>cl /nologo /clr:pure test.cpp
test.cpp
C:\>test
__clrcall Outer::Inner::Outer::Inner::Inner(void)
__clrcall Outer::Inner::Inner(void)
该输出显示,第二次分配开始时的冒号-冒号范围解析操作符引用了全局上下文中的Outer::Inner
,而不是本地上下文。
可空类型
的 2.0 版。NET Framework 添加了可空数据类型。可空数据类型是基础类型的扩展,它将null
值添加到基础类型的合法值集合中。C++/CLI 和 C# 都支持可空类型。
C# 和 C++/CLI 中的示例
在 C# 中,可空类型的构造是通过在类型名后附加一个问号来表示的。例如,考虑下面的可空实例bool
的 C# 代码:
class R
{
static void Main()
{
bool? b = null;
if(b != null)
{
System.Console.WriteLine(b);
}
else
{
System.Console.WriteLine("null");
}
}
}
表情
bool? b = null;
基于一个bool
声明一个可空类型,并将其设置为null
。下一行包含以下表达式:
(b != null)
这个表达式反过来确定b
是否已经被设置为null
。C++/CLI 也支持可空类型,但语法不那么简单。C++/CLI 语法更接近于反映生成的 IL,而 C# 语法处于更高的抽象层次。
为了在 C++/CLI 中使用可空类型,您需要执行以下操作:
- 基于关键字
Nullable
对可空类型使用伪模板语法。 - 使用
System
名称空间;Nullable<>
就是在这个命名空间中定义的。 - 当你赋值给一个可空类型的实例时,使用
Nullable<type>()
而不是null
。 - 在比较表达式中使用实例时,使用属性
HasValue
而不是与null
进行比较。
随后的 C# 声明和初始化行
bool? b = null;
在 C++/CLI 中变成如下形式:
Nullable<bool> b = Nullable<bool>();
这个 C# 表达式
(b != null)
在 C++/CLI 中变成如下形式:
(!b.HasValue)
考虑到这些指导原则和转换,我们发现 C++/CLI 代码可以完成同样的事情:
using namespace System;
ref struct R
{
static void Main()
{
Nullable<bool> b = Nullable<bool>();
if(!b.HasValue)
{
System::Console::WriteLine("null");
}
else
{
System::Console::WriteLine(b);
}
}
};
void main() {R::Main();}
执行任一版本都会产生以下结果:
null
请注意,可空数据类型可以从用户定义的类型以及内置类型中创建。考虑以下基于用户定义类型V
的示例,它也显示null
:
using namespace System;
value struct V {};
ref struct R
{
static void Main()
{
Nullable<V> b = Nullable<V>();
if(!b.HasValue)
{
System::Console::WriteLine("null");
}
}
};
void main() {R::Main();}
那个??C# 中的运算符
在 C# 中,??
是一个二元运算符,用于可空类型。如果不等于null
,则计算第一个参数,否则计算第二个参数。尽管 C++/CLI 不支持这种语法,但编写代码来执行相同的操作还是很简单的。
考虑下面的 C# 代码:
using System;
class R
{
static void Main()
{
bool? b;
bool? c = true;
b = null;
Console.WriteLine(b ?? c);
b = false;
Console.WriteLine(b ?? c);
}
}
在编译和执行之后,我们会发现以下内容:
C:\>csc /nologo test.cs
C:\>test
True
False
第一次(b??c)
被求值(b==null)
,所以表达式的结果就是c
的值。第二次(b!=null)
,那么表达式的结果就是b
的值。
在 C++/CLI 中,您可以使用?:
三元运算符通过更改以下内容来完成同样的事情:
(b??c)
到
Nullable<bool>( b.HasValue ? b : c );
该语句检查b
是否有值,如果有,则使用该值。否则,使用c
的值。它不像 C# 版本那样简洁,但是它完成了同样的事情。下面是重做的整个片段:
using namespace System;
ref struct R
{
static void Main()
{
Nullable<bool> b;
Nullable<bool> c = Nullable<bool>(true);
b = Nullable<bool>();
Console::WriteLine(Nullable<bool>( b.HasValue ? b : c ));
b = Nullable<bool>(false);
Console::WriteLine(Nullable<bool>( b.HasValue ? b : c ));
}
};
void main() {R::Main();}
在后台
有趣的是如果你加载。NET Reflector 并检查 IL 中的可空类型,您看不到任何与圆滑的 C# 语法type?
或??
有丝毫相似之处。相反,您看到的是更类似于 C++/CLI 代码的东西,它显示了作为泛型类实现的Nullable
。泛型将在第十四章的中有更详细的解释。事实上,方法Main()
的 C# 版本是从的遗留版本反编译成 C++/CLI。网状反射器。和 C++/CLI 版本差不多吧?
private:
static void Main()
{
Nullable<Boolean> nullable2 = Nullable<Boolean>(1) ;
Nullable<Boolean> nullable1 = Nullable<Boolean>();
Nullable<Boolean> nullable3 = nullable1;
Console::WriteLine((nullable3.HasValue ? nullable3 : nullable2));
nullable1 = Nullable<Boolean>(0) ;
nullable3 = nullable1;
Console::WriteLine((nullable3.HasValue ? nullable3 : nullable2));
}
检查表达式
在前面的示例中,我们使用了以下表达式:
int i0 = int::MaxValue;
这个表达式允许我们确定一个整数的最大值。在 C++ 中,有一种非常可疑,但不幸的是有些普遍的方法来做同样的事情。它利用了这样一个事实,即在大多数目标体系结构上,整数以二进制补码存储,整数和无符号整数之间的转换是相同大小的数据类型之间的转换。在这种情况下,int
“–1
”对应的是最大可能的unsigned int
,这个数除以 2,可以让我们计算出最大可能的整数。以下是 C++ 中的表达式:
int i0=(int)(((unsigned)(int)-1)/2);
即使将关键字unsigned
改为uint
,前面的代码也不会在 C# 中编译。C# 编译器知道你在用这个表达式做一件可怕的事情,它要求你把它嵌入到一个unchecked
表达式块中来编译它,如下所示:
class R
{
public static void Main()
{
unchecked
{
int i0 = (int)(((uint)(int)-1)/2);
System.Console.WriteLine(i0);
}
}
}
在用 C# 编译和执行这段代码后,我们得到了以下结果:
C:\>csc /nologo test.cs
C:\>test
2147483647
在 C# 中,有两种方法可以控制表达式的检查:
- 将代码嵌入选中或未选中的块中;这些是以关键字
checked
或unchecked
开头的块。 - 在命令行上指定
/checked+
或/checked-
,对未嵌入已检查或未检查块的代码启用或禁用全局表达式检查。
C++ 没有类似于这种类型的检查表达式。
匿名方法
C# 有一个漂亮的语法,使用称为匿名方法的委托来创建嵌套方法。它是为 C# 2.0 添加的,C++ 还没有类似的版本。匿名方法允许您在另一个方法的上下文中动态创建一个方法;它们节省时间,并能使代码更加清晰。允许匿名方法访问包含方法中的局部变量,如下例所示:
using System;
class R
{
public delegate void SayHello(string Message);
SayHello dSayHello;
public static void Main()
{
int Count = 0;
R r = new R();
r.dSayHello += delegate(string Message)
{
Console.WriteLine("{0} : {1} ", ++Count, Message);
};
r.dSayHello("call");
r.dSayHello("call");
}
}
上下文相关的关键字
上下文相关关键字是在特定语法上下文中被解释为关键字的标识符。大多数新的 C++/CLI 关键字都是作为上下文相关的关键字添加的,因此大多数遗留代码都可以在 C++/CLI 下编译,没有任何变化。
例如,考虑下面的代码片段:
void main()
{
int property = 3;
int event = 2;
}
在开发新的 C++/CLI 语法之前,这段代码是有效的 C++,编译器需要正确地编译它。它使用标识符property
和event
,但是在这个上下文中它们不被认为是关键字,所以这段代码可以为 CLR 编译而不会出错。
上下文敏感的关键字可以产生一些有趣的例子,其中标识符有时是关键字,有时不是。考虑以下有效的 C++/CLI 代码:
value struct property {};
ref struct Test
{
property property property;
};
void main() {}
在这种情况下,我们有一个名为property
的普通property
,它的类型是property
。
方法组转换
C# 有一个注册事件处理程序的缩写语法,称为方法组转换。这为使用事件和委托提供了更简单的语法,如下例所示:
using System;
class R
{
public delegate void SayHello(string Message);
SayHello dSayHello;
int Count = 0;
public void DisplayMessage(string Message)
{
Console.WriteLine("{0} : {1} ", ++Count, Message);
}
public static void Main()
{
R r = new R();
// r.dSayHello = new SayHello(r.DisplayMessage);
r.dSayHello = r.DisplayMessage;
r.dSayHello("call");
r.dSayHello("call");
}
}
注释掉的行可能会被后面的粗体行替换。C++ 没有等价的语法。
这种语法在 C# 语言中很常见。这种语言提供了许多这样的小快捷方式,为用户节省了几行代码,从而加快了用 C# 开发应用程序的速度。
Note
委托和事件代码总是很难翻译,因为诊断可能会产生误导。我们可以将大部分责任归咎于编译器解释上下文相关关键字的能力。这些关键字是在歧义消除语法中确定的,如果该语法断定delegate
或event
不是该上下文中的关键字,则它们被解释为常规标识符,并且诊断没有多大帮助。
下面是上一个转换为 C++/CLI 的示例,没有使用方法组转换:
using namespace System;
ref struct R
{
delegate void SayHello(String^ Message);
SayHello ^dSayHello;
int Count;
R() : Count(0) {}
void DisplayMessage(String^ Message)
{
Console::WriteLine("{0} : {1} ", ++Count, Message);
}
static void Main()
{
R^ r = gcnew R();
r->dSayHello = gcnew SayHello(r, &R::DisplayMessage);
r->dSayHello("call");
r->dSayHello("call");
}
};
void main() {R::Main();}
构造器初始化的变量
在 C# 中,只能在构造器、构造器成员初始化列表或类声明中初始化的变量是通过标记它们readonly
来定义的。在 C++/CLI 中,这些变量是用initonly
关键字标记的。
在 C++ 中,只有静态变量可以在类声明中初始化,不管它们是否被标记为initonly
。C# 能够创建初始化类变量的隐式实例和静态构造器,而 C++/CLI 只能创建隐式静态构造器。
例如,考虑下面的 C# 代码:
using System;
class R
{
readonly int i0=3;
static readonly int i1=4;
static void Main() {}
}
下面是等效的 C++/CLI:
using namespace System;
ref class R
{
initonly int i0;
static initonly int i1=4;
R()
{
i0=3;
}
public:
static void Main() {}
};
void main() {R::Main();}
编译器隐式创建静态构造器;非静态initonly
变量的初始化必须在实例构造器中显式执行。
没有效果的表达式语句
C# 不允许大多数无效的表达式语句。这些在 C++ 中是允许的,因为它们通常对调试有用,或者用作条件预处理器语句的剩余部分。
考虑下面的 C# 代码:
using System;
class R
{
static void Main()
{
int i=3;
(i==2); // invalid in C#
}
}
结果如下:
C:\>csc /nologo test.cs
test.cs(7,9): error CS0201: Only assignment, call, increment, decrement, and new
object expressions can be used as a statement
像(i==2);
这样的表达式语句在 C++ 中是有效的语法,编译时既没有错误也没有警告。
例外
在很大程度上,C# 和 C++/CLI 中的异常处理非常相似。C++ 在与 C# 相同的上下文中支持异常,只是增加了一个称为 function-try 块的特殊构造,我们将在本节稍后介绍。
让我们从基础开始。
基本异常处理
下面是 C# 中基本异常处理的一个简短而全面的示例:
using System;
class MyException : Exception
{
public MyException(string message) : base(message)
{
}
}
class R
{
static void Main()
{
try
{
throw new MyException("exception");
}
catch (MyException e)
{
Console.WriteLine("caught : {0}", e);
return;
}
catch
{
}
finally
{
Console.WriteLine("in the finally block");
}
}
}
在这个例子中,我们从定义一个从System::Exception
派生而来的名为MyException
的定制异常开始。构造器将一个string
作为参数,并将其转发给基类。为了将它转换成 C++,我们必须采取以下步骤:
Change class
to ref class
. Change string
to String^. string
is an alias that does not exist in C++; C++ invokes the reference type System::String
explicitly. Change base
to Exception
, as C++ refers to the base class constructor explicitly by name. Add a semicolon to the end of the class definition. Add a colon to the public
keyword.
然后,我们有以下内容:
ref class MyException : Exception
{
public:
MyException(String ^message) : Exception(message)
{
}
};
接下来,我们对身体进行类似的改变。
因为MyException
是一个引用类型,我们需要像对待引用类型一样对待它,并改变它
MyException e
到
MyException ^e
此外,我们需要采取以下步骤:
Change class
to ref class
. Change new
to gcnew
. Change Console.WriteLine
to Console::WriteLine
. Add a semicolon to the end of the class definition. Change the generic catch handler from catch
to catch(. . .)
. The generic catch handler is the last handler that catches all exceptions previously uncaught. The C++ syntax requires the ellipsis. Insert the public:
keyword before the Main()
function, and add a global main()
function to invoke it.
结果代码如下:
using namespace System;
ref class MyException : Exception
{
public:
MyException(String ^message) : Exception(message)
{
}
};
class R
{
public:
static void Main()
{
try
{
throw gcnew MyException("exception");
}
catch (MyException ^e)
{
Console::WriteLine("caught : {0}", e);
return;
}
catch(...)
{
}
finally
{
Console::WriteLine("in the finally block");
}
}
};
void main() {R::Main();}
让我们编译并执行这个:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
caught : MyException: exception
at R.Main()
in the finally block
注意finally
块总是在try
块的末尾执行,不管是否有异常。这是真的,即使有一个return
或break
声明可能表面上规避这一机制。事实上,finally
总是被执行。
您可能会尝试使用finally
语句来控制非托管资源或其他类似应用程序的释放,但是有更好的方法可以做到这一点,从 C# 中的 using 语句到。NET 和 C++。我们将在第二十章中详细介绍这一点。
函数-尝试块
Function-try 块是 C++ 独有的特性,它允许您捕捉整个函数体内发生的任何异常。这个特性可能看起来无关紧要,直到您意识到它允许您在派生类的构造过程中捕获基类构造器中的异常。
问题
让我们看一个例子来说明。在 C# 中,很难在派生类的上下文中捕获基类中生成的异常。它需要在创建派生类对象的方法中被捕获,该方法不在异常的本地。因此,这使得异常处理很成问题。下面是一个带有基类和派生类的 C# 示例:
using System;
class Base
{
public Base(int i)
{
throw new Exception("throwing in Base's constructor");
}
}
class Derived : Base
{
Derived(int i) : base(i)
{
}
static void Main()
{
Derived r = new Derived(3);
}
}
让我们试着编译并运行这个:
C:\>csc /nologo test.cs
C:\>test
Unhandled Exception: System.Exception: throwing in Base's constructor
at Base..ctor(Int32 i)
at Derived.Main()
进入函数-Try 块
在 C++ 中,您可以使用 function-try 块捕获这个异常。function-try 块捕获发生在函数体中任何地方的异常,应用于构造器时,还捕获基类构造器中引发的异常。以下是构造器上 function-try 块的语法:
Derived(int i)
try
: Base(i)
{
}
catch(Exception ^e)
{
}
有点不一样,但是很管用。
因为我们在构造中抛出了一个异常,所以在这个序列的末尾有一个隐式的重新抛出来通知试图创建一个Derived
实例的方法,就像在 C# 中一样。我们可以让这种重新抛出发生,并在顶层捕获它,或者我们可以改变异常的类型,以表明它是在更早的时候被捕获的。下面是一个完整的示例,其中重新引发了一种不同类型的异常:
using namespace System;
ref class MyException : Exception
{
public:
MyException(String ^message) : Exception(message)
{
}
};
ref class Base
{
public:
Base(int i)
{
throw gcnew Exception("throwing in Base's constructor");
}
};
ref class Derived : Base
{
Derived(int i)
try
: Base(i)
{
}
catch(Exception ^e)
{
Console::WriteLine("caught {0}", e);
throw gcnew MyException("caught");
}
public:
static void Main()
{
try
{
Derived ^r = gcnew Derived(3);
}
catch(Exception ^e)
{
Console::WriteLine("caught {0}", e);
}
}
};
void main() {Derived::Main();}
在这个序列中,我们使用 function-try 块捕获基类构造中抛出的异常,然后在 catch 子句中抛出一个不同类型的异常。
以下是代码编译和执行时的结果:
C:\>cl /nologo /clr:pure test.cpp
test.cpp
C:\>test
caught System.Exception: throwing in Base's constructor
at Base..ctor(Int32 i)
at Derived..ctor(Int32 i)
caught MyException: caught
at Derived..ctor(Int32 i)
at Derived.Main()
EXERCISE
异常处理的复杂性往往会迷惑反编译器。编译这个示例,并使用。网状反射器。IL 看起来怎么样?它是如何反编译成 C# 的?
摘要
在这一章中,我们完成了基本 C++ 的旅程。我们讨论了包含文件、范围解析、各种操作符细节和异常。在接下来的章节中,我们将更深入地探索 C++/CLI 和原生 C++,并学习实际例子和更高级的结构。
我认为将这一探索推迟一章,在第十三章短暂休息一下,做几个常见的面试问题会很有趣。
Footnotes 1
本机 C++ 异常处理可能会变得复杂,因为编译器支持多种变体。有关详细信息,请参考 Visual C++ 文档。
十三、有趣,有趣,更有趣
一个项目是如何迟到一年的?一天一天来。—小佛瑞德·P·布鲁克斯,神话中的人月
当我们结束对 C++ 的基本介绍并准备处理更高级的主题时,也许稍微转移一下注意力来奖励我们的努力是合适的。我们将通过编程 C++ 解决方案来看一些有趣的面试问题。
掉落的灯泡
网上流传着一个面试问题,让你判断一个超强的灯泡能跌落多远而不破碎。我们将从数学上分解这个程序,并实现一个很好的递归算法来展示解决方案。
LIGHT BULBS
你有两个几乎打不坏的灯泡和一座 100 层的大楼。使用尽可能少的跌落次数,确定这种类型的灯泡可以承受多大的冲击(例如,它可以承受从 17 楼跌落,但从 18 楼跌落时会破碎)。注意,一直流行的二分搜索法给你的最坏情况是 50 滴。你应该能在 20 分钟内完成。
最初的想法
这个问题首先要注意的是,你只有两个灯泡。一旦第一个被打破,你就不能再冒险了。你需要遵循最保守的策略来护理你的灯泡,直到你确定当它坏掉的时候你已经找到了正确的地板。这意味着你必须一次爬一层楼,从一层楼开始,你要确定灯泡从一层楼掉下来还能存活。你不能冒第二个灯泡破裂的风险,除非这能确切地告诉你最大允许冲击力是多少。
换句话说,如果第二个灯泡从 n 层坠落时破碎了,你应该已经确定这个灯泡从 n–1 层坠落时不会损坏。
我们最初的直觉是将楼层数除以 2,然后从那里开始,所以让我们来看看当我们从第 50 层放下灯泡时会发生什么。
好吧,如果它在第一次落下时就断了会怎么样?因为只剩下一个灯泡了,我们不能冒险跳过一层楼。剩下的唯一策略就是极端保守,从尽可能低的楼层开始,一次升一层。然后我们被迫从一楼扔下第二个灯泡,然后是第二个,以此类推。最糟糕的情况是,我们被迫一次一层地一路爬到 49 楼。这将意味着总共 50 滴(1+49 滴)。
所以把楼层数除以 2 作为初始落差是非常低效的。如果我们把楼层数除以一个更大的数作为初始落差,会发生什么?
粗略的估计
假设我们将楼层数除以五。那么我们的第一次空投会从 20 楼开始。如果最初的灯泡没有坏掉,我们可以尝试从 40 楼,60 楼,等等直到 100 楼。如果灯泡坏了,我们最多只剩下两次下降之间的楼层数来确定灯泡坏了。在这种情况下,会有 19 次下降(20–1 次下降)。现在我们可以计算最坏的情况。第一个灯泡对于每层楼最多掉落一次,每层楼是 20 的倍数,或者{20,40,60,80,100}。这就产生了五种可能的下落,也等于 100 除以 20,或 100/20。在任何中断之后,第二个灯泡最多会掉落这些 20 的倍数之间的差值,或 20-1 倍。因此,我们看到最差情况下的跌落次数如下:
当然,24 次跌落比我们之前算法的最坏情况 50 次要好得多。
一点代数
或许,用一点点代数,我们可以看到我们能把这个想法推进多远。假设我们从第 n 层扔下第一个灯泡,对于 n 的所有可能值,按照前面导出的模式,我们得到
100 可能不能被 n 整除,这一事实会造成轻微的不准确,并且在之前从 80 跌落后从地板 100 跌落灯泡不会给你提供与之前从 60 跌落后从 80 跌落一样多的新信息。不过,这个公式很容易让我们计算出最佳算法的上限。
假设 k 是给定 n 的算法的最差情况,则我们有以下公式:
本质上,
现在我们有一个 n 中的二次方程,可以用一点点初等代数来粗略计算一下。我们希望在 n 的所有可能值上使 k 最小,这将给出最好的最坏情况。
一个二次因素为:
因为算术平均值总是至少与几何平均值一样大,
当 a=b 时,这实现了等式,并且我们得到如下:
还有这里
还有这里。
我们应该每隔十层放下第一个灯泡,以便从这个算法中获得最佳结果。在这种情况下,即 k=19 滴。 2
面试问题的目标是看你是否能发现 19 降算法,即使你不用代数来揭示它。
不要误会,我并不是说这是最好的算法。第一次从十楼掉下第一个灯泡似乎是有道理的,但是如果灯泡没有坏,我们能不能不用这个信息来重新评估这个问题?现在不是新问题了吗?现在是两个灯泡和 90 层楼的问题。出于这个原因,如果第一个灯泡从下落中幸存下来,那么减少下落之间的距离似乎是一个明显的优化。这可能会随着每次下降而继续被重新评估。我们不应该仅仅因为十楼是第一次空投的最佳地点,就被束缚于每十楼空投一次灯泡。
发现递归算法
这样,这个问题的递归性就出现了。与其用 100 个故事来思考这个问题,不如用 N 个故事来思考问题,递归考虑。那么也许我们可以找到一种更有效的算法。
为此,我们需要定义一个函数:
int Drop(int floors);
函数Drop()
返回给定楼层数的最小下降次数。当然,这并没有告诉我们从哪个楼层往下掉,但是如果我们愿意,我们可以修改程序来做到这一点。
假设我们在一个楼层数等于floors
的建筑里。如果我们从i
楼层掉下第一个灯泡,灯泡坏了,我们需要用另一个灯泡来确定它从1, 2, 3...(i-1)
楼层掉下时是否会坏。这最多需要(i-1)
滴。如果灯泡没有坏,那么Drop(floors-i)
计算出我们能做的最高高度floors-i
的建筑。只要floors-i
总是严格小于floors
,并且我们注意到Drop(1)==1
这个事实,我们就不会被锁定在一个无限循环中。 3
在这种情况下,最差的掉线次数是
max(i, Drop(floors-i)+1)
如果我们循环合理的i
值,我们可以找到最小的最坏情况。i
的合理值是多少?尝试i
的所有可能值,从1
到floors
都是矫枉过正。一个简单的优化是认识到我们不应该从前面显示的二进制例子下降到中间以上。从1
到floors
的平方根的i
的尝试值似乎直观地满足了之前提出的论点。在下面的代码中,我使用了一系列的[1, floors/2]
:
using namespace System;
ref struct Drops
{
array<int>^floordata;
Drops()
{
floordata = gcnew array<int>(300);
floordata[1] = 1;
}
int Drop(int floors)
{
if(floordata[floors])
{
return floordata[floors];
}
int best = Int32::MaxValue;
if(floors == 1)
{
best = 1;
}
else
{
int i;
for(i=1;i<floors/2+1;i++)
{
int drops = Drop(floors-i) + 1;
int thisone = (drops>i) ? drops : i;
best = thisone<best ? thisone : best;
}
}
floordata[floors]=best;
return best;
}
};
void main()
{
Drops ^d = gcnew Drops();
Console::WriteLine("For {0} floors, the minimum is {1}", 100, d->Drop(100));
}
输出如下:
For 100 floors, the minimum is 14
履行
代码本身非常简单,尽管需要一次优化。
我写的最初版本运行得太慢了,以至于我想边喝咖啡边等它完成。因为代码是递归的,Drop(n)
被称为Drop(n-1)
,Drop(n-2)
,等等,并导致所有这些被重新计算。Drop(n+1)
除了调用Drop(n)
之外,做更多相同的事情,所以有如此多的重复计算,我们必须非常小心如何度过我们的周期。
我们需要确保对于floors
的每个值,最多只计算一次Drop(floors)
,否则我们最终会一次又一次地重复同样的工作。为此,我们创建了floordata
数组来保存之前对Drop()
调用的结果。这个数组是Drops
类的成员,由构造器Drops()
分配。对于楼层的每个值,我们检查数组以查看是否已经计算了一个值。如果没有,我们继续执行Drop()
函数中的工作,并在退出时将结果保存在floordata
数组中。
该程序确实使用了?:
三元条件运算符:
expression ? value1 : value2
如果expression
为真,则该运算符计算为value1
,否则计算为value2
。它允许我们避免简单结构的if else
语句,并以更紧凑的形式编写。
SHOW THE WAY: PART 1
程序计算出我们的算法至少需要 14 次投放。发现灯泡强度,并显示导致 100 层建筑跌落 14 次的最坏情况的跌落顺序。
第一滴
这个程序不会告诉你放置灯泡的最佳位置,尽管它可以被修改来做到这一点。结果是,你应该从哪一层放下灯泡,这很大程度上取决于还剩几层,因为这个问题是离散的。因此,不可能打印出一份简短的清单,说明如何扔掉灯泡;相反,解决方案是一个图表。如果灯泡在这一层坏了,就这样做,否则就那样做。然而,对于任何给定的灯泡强度,打印这种最佳算法将遵循的液滴序列是简单的。
不过,你可能会注意到一件事,那就是你最好从 14 楼第一次往下掉。这样做的理由很简单:我们希望从尽可能高的楼层进行第一次坠落,这样我们可以最快地解决问题。如果灯泡坏了,我们最多有 13 秒钟的灯泡跌落时间来确定灯泡强度,因此我们不会比 14 次跌落的最佳最坏情况更糟。使用一个程序,你可以确定从 11 楼到 14 楼的初始下落仍然允许你实现最多 14 次下落的最佳顺序。
跨线桥
另一个在互联网上流传的常见问题是,四个人拿着一个手电筒走过一座黑暗的桥。
FOUR MEN ON A BRIDGE
四个人必须在晚上过桥。这座桥破旧不堪,一次最多只能容纳两个人。没有栏杆,人们只有一个手电筒。任何时候有人过,不管是一个人还是两个人去,手电筒都必须带着。手电筒必须来回走;不能扔。每个人走路的速度不同。一个需要 1 分钟穿越,另一个 2 分钟,另一个 5 分钟,最后 10 分钟。如果两个人一起过马路,他们必须以较慢的人的速度行走。这四个人穿越最少需要多少时间?
背景
现在这个问题如此流行的原因是,有一个明显的、逻辑上不正确的结论,所有人都跳到了这个结论上。只有经过更深入的思考和一点洞察力,你才能得出正确的答案。 4
在你继续读下去之前,想一想这个问题。你在桥上,来回走着。几个跑得快的人,一个非常快,还有几个比较慢的人,坐在桥上。你甚至可以放纵自己;想象你是一个能在一分钟内穿越的步行者。
最初不正确的结论是,走得更快的人应该来回引导大家。1 分钟步行者和 10 分钟步行者一起走过,需要 10 分钟。她回来了,花了 1 分钟。她去找下一个男人,等等。这个解决方案给你 19 分钟的最佳时间让所有人过桥。你自豪地向面试官宣布这一点,面试官沾沾自喜地说有更好的解决方案。惊恐之余,你跌跌撞撞地回到白板前,试着写些有智慧的东西。
更好的解决方案依赖于经典的调度算法。你试图平衡你的任务,所以任务是同时完成的。在 19 分钟的解决方案中,10 分钟和 5 分钟的步行者分别走过,这样你的基本时间是 15 分钟,不管谁和他们一起走。这是一件很难优化的事情。如果你把 5 分钟和 10 分钟的步行者放在一起,那么慢的人只用 10 分钟就可以走完,再用一点手电筒,你计算出你可以在总共 17 分钟内让所有人都走完。我会把如何解决这个问题留给你。
这当然给我们带来了另一个问题:这是最好的算法吗?
我们也很想知道,如果我们改变穿越者的速度,会得到什么样的结果。那么最好的旅行时间是什么时候?
我们可以通过编写一个简短的计算机程序来回答这些问题。为了实现它,我们做如下假设:
- 从起点到终点的每个路口正好有两个行人。
- 从终点到起点的每一个交叉点都恰好包含一个行走者。
考虑到问题的性质,这些都是相当合理的假设。我确信有可能证明没有最优解会违反这些假设,但证明不是这里的目标,所以我们将继续。
算法和实现
一个显而易见的算法是,在给定这些假设的情况下,尝试所有可能的步行者过桥。为了做到这一点,我们必须跟踪谁在桥的哪一边以及手电筒的位置。因为这些假设,我们知道每一次有两个步行者的过桥,后面都会有一个步行者的返回,直到我们完成。那么手电筒的位置是隐含的。
然后我们可以写一个函数crossover(State, Direction)
,我们可以用它在任何方向上过桥。我最初是这样写程序的,它确实工作了,但是事实证明,将交叉点分成一个出站start(State)
函数和一个入站end(State)
函数更简洁。
对于我们如何跟踪穿越的State
,有几种可能性,但是一个简单的位字段表明谁在桥的起始端就足够了,而且是干净的。我们用一个unsigned int
来表示谁在桥的起始端。对于start(unsigned here)
函数,这个数据表明谁在桥的起始端,或者“这里”对于end(unsigned there)
函数,该数据指示谁在另一边,或者“在那里”在这两种情况下,数据总是指示谁在桥的起始端。我们可以通过对带有一个位字段的数据连续应用异或来传递谁在手电筒的那一边,但是我发现总是传递起始端的状态是用于调试目的的最简单的方式。
《守则》
我们使用一个名为Crossing
的ref struct
,它包含我们不希望递归传递的常量信息。在这种情况下,我们有步行者的穿越速度,存储在times
数组中,还有一个Mask
,它允许我们通过简单的异或运算来计算谁在桥的对面。我们不在构造器中初始化它们;相反,我们从输入参数中保存times
,并在每次开始穿越时重新计算Mask
。这与其说是追求准确,不如说是品味和风格的问题。代码本身很有趣,也很有教育意义;花点时间浏览一下,继续熟悉 C++/CLI:
using namespace System;
ref struct Crossing
{
array<unsigned>^ times;
unsigned int Mask;
int cross(...array<unsigned>^ times)
{
this->times = times;
Mask = (1u<<times->Length)-1;
return start(Mask);
}
int end(unsigned there)
{
if(there==0)
{
return 0;
}
unsigned here = Mask^there;
unsigned best = 0xffff;
for(int i=0;i<times->Length; i++)
{
if(here & (1<<i))
{
unsigned thistrip;
thistrip = times[i] + start(there^(1<<i));
if(thistrip<best)
{
best = thistrip;
}
}
}
return best;
}
int start(unsigned here)
{
if(here==0)
{
return 0;
}
unsigned best = 0xffff;
for(int i=0;i<times->Length; i++)
{
if(here & (1<<i))
{
unsigned thistrip;
for(int j=i+1;j<times->Length; j++)
{
if(here & (1<<j))
{
thistrip =
(times[i]>times[j]? times[i] : times[j])
+ end(here^((1<<i)|(1<<j)));
if(thistrip<best)
{
best = thistrip;
}
}
}
}
}
return best;
}
};
void main()
{
Crossing ^c = gcnew Crossing();
int time = c->cross(1,2,5,10);
Console::WriteLine("It takes at least {0} minutes", time);
}
如果您执行这个程序,您会得到预期的结果:
It takes at least 17 minutes
SHOW THE WAY: PART 2
增强程序,以便显示实际的最小交叉顺序。这需要递归地传递更多的数据。
带着算法兜一圈
当然,因为我们用接受可变数量参数的param
数组实现了这一点,所以很容易用不同数量的 walkers 尝试不同的情况。如果我们改变下面一行:
int time = c->cross(1,2,5,10);
到
int time = c->cross(1,2,5,10,7);
那么凭直觉,我们预计这可能需要额外的 7 分钟。真相就在那里:
It takes at least 23 minutes
这有点让人吃惊。增加一个 7 分钟的步行器只会让整个过程慢 6 分钟。结果是,7 分钟的步行者最终与 10 分钟的步行者交叉,剩下 5 分钟的步行者来处理。5 分钟步行器与 1 分钟步行器进行一次往返,总共需要额外的 6 分钟。好吧,有道理。
如果我们再增加一个 7 分钟步行机呢?
int time = c->cross(1,2,5,10,7,7);
既然他不能和 10 分钟步行者一起过马路,他必须和 5 分钟步行者一起过马路,这样就多了 7 分钟。让我们试一试:
It takes at least 29 minutes
等等,只多了 6 分钟——这又是一个反直觉的答案。我肯定有一个简单的解释。也许 1 分钟步行机的回程可以避免?
这绝对是一个好玩的程序。
对付食人族
这个问题似乎不像其他两个问题那样经常在面试中出现,但它有一个有趣而有启发性的转折。
RIVER CROSSING
三个食人族和三个人类学家必须过河。他们的船只够两个人坐。如果在任何时候,河这边的食人族比人类学家还多,食人族就会吃掉他们。人类学家可以用什么计划过河,这样他们就不会被吃掉?
假设
乍一看,这个问题似乎不像桥梁问题那样困难。我们有一些离散的食人族和人类学家,没有附带的穿越时间(或者说进食时间)的概念。).
我们能够通过对谁在过河做出几个明确的假设来解决前一个问题。我们很乐意在这里做类似的假设,例如:
- 两个人总是从发射侧穿越到着陆侧。
- 总有一个人会回来。
不幸的是,这些假设是无效的。有非常清楚的理由,为什么你可能想在回程中派两个人去维持河两边的人的安全组合。事实上,11 个交叉点的最优解就是这样做的。
怎样才能想出一个保证在所有情况下都终止的算法?如果我们尝试一条路径,在这条路径上,我们正好来回发送两个人,我们的递归算法不起作用。
这里的技巧是根据可能性的深度优先遍历来考虑这个算法。我们不是让算法在一条潜在的无限路径中越来越深地寻找一个解,而是构造算法,让它迭代地问:“有没有一个有iteration
个交叉点的解?”我们从1
的iteration
开始,直到得到肯定的回答。如果有解决方案,这个算法一定会成功。如果没有解决方案,这个循环将继续下去,直到资源耗尽;在这种情况下,资源就是堆栈。
《守则》
在这个程序中,我创建了一个单独的子程序,用于任意方向的交叉,因为这些操作是完全对称的,因为缺少假设。变量dir
在集合{1,–1}
中,它决定了我们要穿越的方向。
因为船上的人只有五种可能的组合,所以我用逻辑“或”把它们分开列出来。就像在 C# 中一样,一旦调用返回true
,短路评估会阻止对crossover()
的进一步调用。
代码的其余部分相当简单,可能就是将来某一天您需要在白板上生成的内容:
using namespace System;
ref struct Crossing
{
int MaxA;
int MaxC;
int cross(int MaxA, int MaxC)
{
this->MaxA=MaxA;
this->MaxC=MaxC;
int iterations;
for(iterations=1; ;iterations++)
{
if(crossover(MaxA, MaxC, iterations, -1))
{
break;
}
}
return iterations;
}
bool crossover(int A, int C, int iterations, int dir)
{
if(iterations--<0)
{
return false;
}
if(A==0 && C==0)
{
return true;
}
if(A<0 || C<0)
{
return false;
}
if(A>0 && C>A)
{
return false;
}
int Ap = MaxA-A;
int Cp = MaxC-C;
if(Ap>0 && Cp>Ap)
{
return false;
}
return (
crossover(A+dir,C,iterations,-dir) ||
crossover(A,C+dir,iterations,-dir) ||
crossover(A+dir,C+dir,iterations,-dir) ||
crossover(A+dir+dir,C,iterations,-dir) ||
crossover(A,C+dir+dir,iterations,-dir)
);
}
};
void main()
{
Crossing ^c = gcnew Crossing();
int Count = c->cross(3,3);
Console::WriteLine("It takes at least {0} crossings", Count);
}
在我们用三个食人族和三个人类学家运行这个程序后,我们得到了预期的结果:
It takes at least 11 crossings
如果我们尝试更多或更少会发生什么?如果只有两个食人族和两个人类学家,我们得到以下结果:
It takes at least 5 crossings
如果每种都有 4 个呢?事实证明是无解的。程序会一直运行,直到它的存储容量溢出。
摘要
所有这些例子都展示了棘手问题的优雅的递归解决方案。这是一个很好的练习,用这些类型的问题挑战你的假设,不仅寻找逻辑的解决方案,也寻找程序的解决方案。
在下一章中,我们将通过学习基本泛型上下文中的参数多态来开始对 C++ 有更深的理解。
Footnotes 1
你可以在 http://www . com 找到这一章中的问题以及其他类似的问题。技术访谈。org 。这个网站转而引用了威廉·庞德斯通的《你会如何移动富士山》作为资料来源。庞德斯通没有声称发明了这些问题,但他将它们收集在一个有趣的文本中。
2
这个方法可以推广。对于一栋 N 层高的建筑,如果我们选择,我们可以进行同样的分解:
那么我们可以看到
3
你注意到递归算法的实现和数学归纳法的证明之间有什么相似之处吗?
4
或者我们应该说,当你坐在面试官的办公室里,汗流浃背,头脑被恐惧冻结的时候,你有一点运气?
十四、泛型
当人们试图设计完全万无一失的东西时,一个常见的错误是低估了十足傻瓜的聪明才智。—道格拉斯·亚当斯
在这一章中,我将介绍泛型。此功能是在的 2.0 版中引入的。NET Framework,并且受所有 Microsoft Visual Studio 2013 语言支持。您将了解泛型如何帮助我们解决各种问题,这些问题是在我们希望构建处理不同类型数据而又不牺牲类型安全性的类时出现的。在本章中,我们还将开始使用各种。NET Framework 类来创建更有趣、更有指导意义的示例。
一系列任务
让我们从一个简单的例子开始。我们有一个名为Task
的定制类队列。我们用主线程将Task
项添加到队列的末尾,并用第二个线程从队列的开头读取它们。然后,我们以先进先出(FIFO)的方式执行我们的类。
下的 C# 实现。NET 1.0
英寸 NET 1.0 版,我们将使用System.Collections.Queue
类在 C# 中实现它。让我们使用 Visual Studio 2013 IDE 中的对象浏览器来看看这一点。
从“视图”菜单中选择“对象浏览器”,主框架中会添加一个“对象浏览器”选项卡。我们可以展开mscorlib
、System.Collections
和Queue
上的加号,然后我们可以查看System.Collections.Queue
类的方法、字段、属性和事件。Enqueue(object o)
和Dequeue()
方法提供了我们需要的功能。
使…入队
System.Void Enqueue(System.Object obj)
obj
:添加到System.Collections.Queue
的对象。值可以是null
。成员:
System.Collections.Queue
的成员。描述:在
System.Collections.Queue
的末尾添加一个对象。参数:
出列
System.Object Dequeue()
System.InvalidOperationException
:队列System.Collections.Queue
为空。例外:
从
System.Collections.Queue
开始处移除的对象。成员:
System.Collections.Queue
的成员。描述:移除并返回
System.Collections.Queue
开头的对象。返回值:
履行
为了实现我们的Task
实例队列,我们需要将Task
对象转换为System.
Object
或object
,因为我们使用的是 C#,所以它可以被Enqueue()
例程使用。这是由编译器自动执行的,因为object
是一个基类。不过,出队带来了更多的问题。出列返回一个object
,它必须被重新转换为Task
的一个实例。这种重铸带来了一种可能性,即某个人可能会Enqueue()
一个非Task
类型的对象,从而生成一个异常,使这个实现不是类型安全的。
我们的应用程序的核心如下所示:
using System.Collections;
namespace Task
{
class Task
{
public void Execute()
{
}
}
class Program
{
static Queue q = new Queue();
static void ExecutionThread()
{
Task t = (Task)q.Dequeue();
t.Execute();
}
static void Main(string[] args)
{
q.Enqueue((object) new Program());
ExecutionThread();
}
}
}
注意,编译器自动将Task
的实例转换为Enqueue()
的object
,因为object
是Task
的基类。然而,来自Dequeue()
的重铸需要显式的强制转换,因为从表面上看,编译器无法知道队列包含一个Task
对象。事实上,您可以将Enqueue()
行改为如下所示:
q.Enqueue(new Program());
该代码片段编译无误。现在尝试执行它:
Unhandled Exception: System.InvalidCastException: Unable to cast
object of type 'Task.Program' to type 'Task.Task'.
at Task.Program.ExecutionThread()
at Task.Program.Main(String[] args)
您的程序编译时没有任何错误,但在执行时会产生一个错误。如果它在您的主代码路径中,您肯定会在测试时发现它,但是如果它只存在于一些很少使用的、只在特定情况下调用的例程中,会发生什么呢?这可能会变得很难看。当我们切换到泛型时,我们将回头解决这个问题。同时,让我们回到样本。
线
为了让我们的示例更有意义,我们希望将Enqueue()
和Dequeue()
任务放在不同的线程上。为此,我们利用了System.Threading
名称空间。
线程类
让我们转向 Visual Studio 对象浏览器。展开[mscorlib] System.Threading
下的Thread
类,找到Thread
类。我们对这个构造器重载感兴趣。
构造器
public Thread(System.Threading.ThreadStart start)
System.ArgumentNullException
:start
的说法是null
。例外:
start
:一个System.Threading.ThreadStart
委托,代表当这个线程开始执行时要调用的方法。成员:
System.Threading.Thread
的成员。描述:初始化
System.Threading.Thread
类的新实例。参数:
方法
我们将使用Start()
和Sleep()
方法。
开始
public void Start()
System.Threading.ThreadStateException
:线程已经启动。System.Security.SecurityException
:呼叫者没有合适的System.Security.Permissions.SecurityPermission
。没有足够的内存来启动这个线程。
成员:
System.Threading.Thread
的成员。描述:使操作系统将当前实例的状态更改为
System.Threading.ThreadState.Running
。例外情况:
睡眠
public static void Sleep(int millisecondsTimeout)
System.ArgumentOutOfRangeException
:超时值为负,不等于System.Threading.Timeout.Infinite
。例外:
millisecondsTimeout
:线程被阻塞的毫秒数。指定零(0
)表示该线程应该被挂起,以允许其他等待线程执行。指定System.Threading.Timeout.Infinite
无限期阻塞线程。成员:
System.Threading.Thread
的成员。描述:挂起当前线程一段指定的时间。
参数:
ThreadStart 类
英寸 NET 中,线程是通过使用包含新线程的 main 方法的委托来初始化的。如您所见,Thread
类接受了一个ThreadStart
委托。
我们来看看描述。
构造器
public delegate void ThreadStart()
- 成员:
System.Threading
的成员。 - 描述:表示在
System.Threading.Thread
上执行的方法。
让线程工作
为了使用第二个线程,我们声明了一个不带参数并返回void
的方法,以便它匹配ThreadStart
委托的签名。接下来,我们创建一个ThreadStart
的实例,并将其传递给Thread
构造器,以便创建一个新线程。然后我们可以使用Thread
类的Start()
方法来启动这个线程。
把它放在一起
我们现在有两个线程在我们的队列上操作:主线程添加任务,后台线程执行它们。因为它们都在访问同一个数据对象,即队列,所以我们需要尽力确保它们轮流使用这个对象。如果我们不这样做,线程切换机制可能会在更新队列状态的过程中挂起一个线程,而恢复的线程可能会被传递一个无效状态的队列。为了确保线程轮流,我们在 C# 中使用了lock
关键字:
static Queue q = new Queue();
lock(q)
{
//exclusive access to q here
}
使用。NET Reflector 在 IL 视图中,我们可以看到lock
关键字在lock
块的开头调用了System.Threading.Monitor.Enter(object)
,在结尾调用了System.Threading.Monitor.Exit(object)
。通过这种方式,我们可以确保对q
对象的访问被保留,直到该块完成,即使线程在lock
块的中间被切换。
因此,我们得出以下结论:
using System;
using System.Collections;
using System.Threading;
namespace Task
{
class Task
{
private string taskname;
public Task(string s)
{
taskname = s;
}
public void Execute()
{
Console.WriteLine(taskname);
}
}
class Program
{
static Queue q = new Queue();
static Thread executionThread =
new Thread(new ThreadStart(ExecutionThread));
static void ExecutionThread()
{
while (true)
{
Task t;
lock (q)
{
if (q.Count == 0)
{
continue;
}
t = (Task)q.Dequeue();
}
if (t == null)
{
return;
}
t.Execute();
}
}
static void Main(string[] args)
{
executionThread.Start();
lock(q)
{
q.Enqueue(new Task("task #1"));
q.Enqueue(new Task("task #2"));
q.Enqueue(null);
}
while (true)
{
Thread.Sleep(10);
lock (q)
{
if (q.Count == 0)
{
break;
}
}
}
}
}
}
在这个示例中,我们生成并启动执行线程。接下来,我们对各种任务使用Enqueue()
方法,将一个 final null
放入队列以指示所有的工作都已完成。然后,我们进入一个循环,等待任务完成。
需要注意的一件重要事情是,我们尽量减少了持有队列锁的时间,以便不干扰其他任务。在执行线程中,这采取的形式是将任务的执行推迟到锁被释放之后。
执行该程序会产生以下输出:
C:\>csc /nologo task.cs
C:\>task
task #1
task #2
转向泛型
让我们转到 C++/CLI 和。NET 2.0 中使用了一个更复杂的示例。为了便于转换,我们将先用 C# 来解释一下。
在洗车场工作
假设我们在模拟洗车。首先,每辆车都要用真空吸尘器清扫内部。接下来,它被移动到传送带上,传送带拖着它通过一台清洗其外部的机器。我们可以利用流水线作业:汽车可以在清洗外部的同时清洗内部。为了实现这一点,我们为每个内部和外部工作站维护一个单独的队列和专用的执行线程。流水线式洗车让我们能够在不影响等待时间的情况下增加洗车的吞吐量。
让我们看看 C# 中的代码:
using System;
using System.Collections.Generic;
using System.Threading;
namespace CarWash
{
class Car
{
private string CarName;
public override string ToString()
{
return CarName;
}
public Car(string s)
{
CarName = s;
}
}
class Program
{
static Queue<Car> washQueue = new Queue<Car>();
static Queue<Car> vacuumQueue = new Queue<Car>();
static Thread WashThread = new Thread(new ThreadStart(Wash));
static Thread VacuumThread = new Thread(new ThreadStart(Vacuum));
static void Wash()
{
for (; true; Thread.Sleep(10))
{
Car c;
lock (washQueue)
{
if (washQueue.Count == 0)
{
continue;
}
c = washQueue.Dequeue();
}
if (c == null)
{
break;
}
Console.WriteLine("-Starting Wash of {0}", c);
Thread.Sleep(1300);
Console.WriteLine("-Completing Wash of {0}", c);
}
}
static void Vacuum()
{
for(;true;Thread.Sleep(10))
{
Car c;
lock(vacuumQueue)
{
if(vacuumQueue.Count == 0)
{
continue;
}
c = vacuumQueue.Dequeue();
}
if (c != null)
{
Console.WriteLine("+Starting Vacuum of {0}", c);
Thread.Sleep(1000);
Console.WriteLine("+Completing Vacuum of {0}", c);
}
lock (washQueue)
{
washQueue.Enqueue(c);
}
if (c == null)
{
break;
}
}
}
static void Main(string[] args)
{
VacuumThread.Start();
WashThread.Start();
lock (vacuumQueue)
{
vacuumQueue.Enqueue(new Car("Volvo"));
vacuumQueue.Enqueue(new Car("VW"));
vacuumQueue.Enqueue(new Car("Jeep"));
vacuumQueue.Enqueue(null);
}
while (VacuumThread.IsAlive || WashThread.IsAlive)
{
Thread.Sleep(10);
}
}
}
}
现在让我们运行它:
C:\>csc /nologo carwash.cs
C:\>carwash
+Starting Vacuum of Volvo
+Completing Vacuum of Volvo
-Starting Wash of Volvo
+Starting Vacuum of VW
+Completing Vacuum of VW
+Starting Vacuum of Jeep
-Completing Wash of Volvo
-Starting Wash of VW
+Completing Vacuum of Jeep
-Completing Wash of VW
-Starting Wash of Jeep
-Completing Wash of Jeep
请注意,从输出中任务开始和完成的顺序可以看出任务的重叠以及依赖延迟。
C# 代码的回顾
这段代码的元素与前面的示例或多或少有些相同,但是有一些主要的区别。
在第一个例子中,我们使用来自System.Collections
的Queue
类来管理任务队列。由于Queue
类对类型System.Object
的元素进行操作,我们必须将Task
转换为基类System.Object
,以便让它入队。这是由编译器自动完成的。当我们想让它出列时,我们必须显式地将其重新转换回Task
。编译器在编译时不会捕捉到对错误类型的重新转换,这可能会导致运行时错误。
队列的通用版本
在这个例子中,我们使用了通用版本的Queue
,称为Queue<T>
。使用对象浏览器,我们可以发现这个类是System.dll
而不是mscorlib
的一部分,并且存在于System.Collections.Generic
名称空间中。现在,不要被这些类有相似名字的事实所误导。Queue
和Queue<T>
是完全不同的档次。它们各自代表不同的类型。Queue<T>
具有更复杂的类型,因为它依赖于类型参数T
。由于这些类型完全不同,编译器在它们之间没有定义任何隐式或显式的转换。此外,在指定类型参数T
之前,Queue<T>
是早期类型。当使用带有定义的类型参数的Queue<T>
的代码被执行时,运行时使用适合类型参数T
的中间语言专门处理Queue<T>
。这可能因T
是引用类型还是值类型而异。
让我们检查方法。
使…入队
public void Enqueue(T item)
item
:添加到System.Collections.Generic.Queue<T>
的对象。引用类型的值可以是null
。成员:
System.Collections.Generic.Queue<T>
的成员。描述:在
System.Collections.Generic.Queue<T>
的末尾添加一个对象。参数:
出列
public T Dequeue()
System.InvalidOperationException
:System.Collections.Generic.Queue<T>
为空。例外:
从
System.Collections.Generic.Queue<T>
开始处移除的对象。成员:
System.Collections.Generic.Queue<T>
的成员。描述:移除并返回
System.Collections. Generic.Queue<T>
开头的对象。返回值:
分析
如你所见,Queue<T>
中Enqueue()
和Dequeue()
的方法与Queue
中的方法相似。在这种情况下,“差异万岁”似乎是一个合适的短语。Enqueue()
方法接受类型为T
的项目,而Dequeue()
返回类型为T
的项目。在我们的示例代码中,我们通过管道移动类Car
的实例。因为我们想要一个Car
的队列,所以我们首先创建一个Queue<T>
的实例,其中T
是Car
:
static Queue<Car> washQueue = new Queue<Car>();
如果我们把Queue<Car>
看作一个单独的标识符,我们会发现这是一个对构造器的简单调用。接下来,我们可以让类Car
的实例入队和出队,编译器会为我们做所有的类型检查:
Car c;
washQueue.Enqueue(c);
//
c = washQueue.Dequeue();
这就是全部了。不管怎样,Queue
是一个行为与Queue<object>
非常相似的类,当然,它们并不完全相同。但是它们完成相同的事情——它们各自管理一个类型为object
或System.Object
的元素队列。
我们还使用IsAlive
属性来管理洗车处的线程。我们使用这个属性来等待清空和清洗线程完成。
伊萨维
public bool IsAlive { get; }
true
如果该线程已经启动并且没有正常终止或中止。false
否则。如您所见,IsAlive
是一个只读属性。它有一个get
访问器,但没有set
访问器。成员:
System.Threading.Thread
的成员。描述:获取一个值,该值指示当前线程的执行状态。
返回值:
转移到 C++/CLI
在这一节中,我们将回顾泛型的 C++ 语法。在描述名称空间之类的东西时,我也会切换到 C++ 语法。
在 C# 中创建和定义泛型类的语法与 C++ 非常不同。然而,使用和消费它们的语法几乎是相同的。这类似于托管数组在 C# 和 C++ 中声明方式的不同,但它们在这两种语言中的用法是相似的。
由于语法上的差异,我们首先将 CarWash 程序转换为 C++/CLI,并检查 C++/CLI 中泛型的定义和用法。接下来,我们将开始创建自己的泛型类。
现在让我们将洗车程序示例翻译成 C++/CLI:
#using <System.dll>
#include <msclr\lock.h>
using namespace msclr;
using namespace System;
using namespace System::Collections::Generic;
using namespace System::Threading;
namespace CarWash
{
ref class Car
{
private:
String ^CarName;
public:
virtual String ^ ToString() override
{
return CarName;
}
Car(String ^s)
{
CarName = s;
}
};
ref class Program
{
static Queue<Car^> ^washQueue = gcnew Queue<Car^>();
static Queue<Car^> ^vacuumQueue = gcnew Queue<Car^>();
static Thread ^washThread =
gcnew Thread(gcnew ThreadStart(wash));
static Thread ^vacuumThread =
gcnew Thread(gcnew ThreadStart(vacuum));
static void wash()
{
for (; true; Thread::Sleep(10))
{
Car ^c;
{
lock l(washQueue);
if (washQueue->Count == 0)
{
continue;
}
c = washQueue->Dequeue();
}
if (c == nullptr)
{
break;
}
Console::WriteLine("-Starting wash of {0}", c);
Thread::Sleep(1300);
Console::WriteLine("-Completing wash of {0}", c);
}
}
static void vacuum()
{
for(;true;Thread::Sleep(10))
{
Car ^c;
{
lock l(vacuumQueue);
if(vacuumQueue->Count == 0)
{
continue;
}
c = vacuumQueue->Dequeue();
}
if (c != nullptr)
{
Console::WriteLine("+Starting vacuum of {0}", c);
Thread::Sleep(1000);
Console::WriteLine(
"+Completing vacuum of {0}", c);
}
{
lock l(washQueue);
washQueue->Enqueue(c);
}
if (c == nullptr)
{
break;
}
}
}
public:
static void Main(...array<String^> ^ args)
{
vacuumThread->Start();
washThread->Start();
{
lock l(vacuumQueue);
vacuumQueue->Enqueue(gcnew Car("Volvo"));
vacuumQueue->Enqueue(gcnew Car("VW"));
vacuumQueue->Enqueue(gcnew Car("Jeep"));
vacuumQueue->Enqueue(nullptr);
}
while (vacuumThread->IsAlive || washThread->IsAlive)
{
Thread::Sleep(10);
}
}
};
}
void main()
{
CarWash::Program::Main();
}
事实证明,这个示例在翻译成 C++/CLI 时有些困难。除了一般的翻译,我想在下面的部分指出一些。
推翻
要覆盖一个虚方法,我们需要在 C++/CLI 中同时使用virtual
和override
关键字。还有,关键词的顺序不一样。
在 C# 中,我们可以使用下面一行来覆盖一个虚方法:
public override string ToString()
在 C++/CLI 中,我们需要这个:
public:
virtual String ^ ToString() override
添加对 System.dll 的引用
当我们使用Queue<T>
时,我们不仅需要告诉编译器哪个名称空间包含该类,还需要告诉编译器哪个汇编 DLL。这可以通过 C# 或 C++/CLI 命令行或者通过代码中的显式#using
引用来完成。您也可以使用 IDE 指定对此 DLL 的引用。在代码中指定引用,如下所示:
#using <System.dll>
如果您忽略添加这一行,编译器会发出一个语法错误,因为它没有发现标识符Queue
被定义,或者它将它与mscorlib.dll
中定义的System::Collections::Queue
类混淆,后者不是泛型。
翻译 lock 关键字
C# 中的lock
关键字使用System::Threading::Monitor
类生成一组块。在 C# 中,下面的代码:
class Program
{
public static void Main()
{
string s = "hello";
lock(s)
{
//inside the lock
}
}
}
相当于以下代码:
using System.Threading;
class Program
{
public static void Main()
{
string s = "hello";
Monitor.Enter(s);
try
{
//inside the lock
}
finally
{
Monitor.Exit(s);
}
}
}
Monitor.Enter()
声明在try
块期间独占访问对象。当try
程序块完成时,无论是否出现异常,都使用Monitor.Exit()
释放对象。
进入
public static void Enter(object obj)
System.ArgumentNullException
:obj
的说法是null
。例外:
obj
:获取监视器锁的对象。成员:
System.Threading.Monitor
的成员。描述:获取指定对象的独占锁。
参数:
出口
public static void Exit(object obj)
System.Threading.SynchronizationLockException
:当前线程不拥有指定对象的锁。System.ArgumentNullException
:obj
的说法是null
。例外情况:
obj
:要解除锁定的对象。成员:
System.Threading.Monitor
的成员。描述:释放指定对象上的独占锁。
参数:
锁定 onc++/cli
在 C++/CLI 中可以使用完全相同的结构:
using namespace System;
using namespace System::Threading;
ref class Program
{
public:
static void Main()
{
String ^s = "hello";
Monitor::Enter(s);
try
{
//inside the lock
}
finally
{
Monitor::Exit(s);
}
}
};
此外,在 C++ 中还有其他创建锁的方法,因为它具有对象的确定性销毁。其中 C# 将析构函数视为。NET 终结器,C++/CLI 有显式终结器和析构器,一旦对象超出范围,它们就能够释放资源。使用IDisposable
接口和。NET Dispose()
模式,但是要繁琐的多。在 C++ 中,这是自动的。分配一个类的实例,当块结束时它被销毁。因此,可以使用构造器来声明锁,使用析构函数来释放锁。
实现这一点的 C++ 实现如下所示:
using namespace System;
using namespace System::Threading;
ref struct Locker
{
Object ^o;
Locker(Object ^s)
{
o = s;
Monitor::Enter(o);
}
∼Locker()
{
Monitor::Exit(o);
}
};
ref class Program
{
public:
static void Main()
{
String ^s = "hello";
{
Locker lk(s);
//inside the lock
}
}
};
我们首先创建一个名为Locker
的类,它是System::Threading:: Monitor
类的容器。当进入锁定节时调用构造器,当退出锁定节时调用析构函数。
下面的代码行创建了一个Locker
的实例,并使用参数s
调用构造器:
Locker lk(s);
当实例变量lk
在下一个右花括号处超出范围时,Locker
的析构函数会和Monitor::Exit()
一起被调用。
注意,即使锁内的代码抛出异常,析构函数也会被调用。例如,让我们修改前面的代码以显示状态并抛出异常:
using namespace System;
using namespace System::Threading;
ref struct Locker
{
Object ^o;
Locker(Object ^s)
{
o = s;
Console::WriteLine("Lock acquired");
Monitor::Enter(o);
}
∼Locker()
{
Console::WriteLine("Lock released");
Monitor::Exit(o);
}
};
ref class Program
{
public:
static void Main()
{
String ^s = "hello";
{
Locker lk(s);
Console::WriteLine("throw exception");
throw;
}
}
};
void main()
{
try
{
Program::Main();
}
catch(Exception ^e)
{
Console::WriteLine("catch exception");
}
}
如果我们运行它,我们会看到以下内容:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
Lock acquired
throw exception
Lock released
catch exception
这个输出表明,没有留下锁的危险,在 C++/CLI 中,确定性类构造和销毁是对try finally
块的可行替代。
要在 C# 中做同样的事情,您可以利用using
的变体将实例的范围限制在一个具有实现System::IDisposable
的类的块中。C# 中此方法的原型如下:
using System;
class Locker : IDisposable
{
public void Dispose()
{
}
public Locker()
{
}
∼Locker()
{
}
}
class Program
{
public static void Main()
{
using(Locker l = new Locker())
{
}
}
}
预定义的锁类
包含文件msclr\lock.h
定义了一个能够锁定资源的复杂类。它使用System::Threading::ReaderWriterLock
以及模板编程。
为了利用它,我们只需添加以下几行:
#include <msclr\lock.h>
using namespace msclr;
然后我们可以像实例化Locker
类一样实例化msclr::lock
类:
{
lock l(vacuumQueue);
// locked code
}
// destructor has been called and lock is released.
编译并运行
让我们编译 CarWash,并检查结果:
C:\>cl /clr:pure /nologo carwash.cpp
C:\>carwash.exe
+Starting vacuum of Volvo
+Completing vacuum of Volvo
-Starting wash of Volvo
+Starting vacuum of VW
+Completing vacuum of VW
+Starting vacuum of Jeep
-Completing wash of Volvo
-Starting wash of VW
+Completing vacuum of Jeep
-Completing wash of VW
-Starting wash of Jeep
-Completing wash of Jeep
在 C++/CLI 中创建泛型类
在 C++/CLI 中,泛型类型的语法是根据模板的语法建模的。模板与泛型相似,都接受类型参数。它们之间的主要区别在于模板是在编译时处理的,而泛型是在运行时处理的。由于这种区别,模板变得更加复杂和强大,我们将在第十五章中详细介绍这一点。现在,重要的是要认识到泛型的 C++/CLI 语法是基于 C++ 模板的语法。
C# 中声明了一个带有单个类型参数T
的基本泛型类,如下所示:
class R<T> {}
同一个类在 C++/CLI 中声明如下:
generic <typename T> ref class R {};
在 C++/CLI 中,有必要告诉编译器T
是一个类型参数,而这在 C# 中是隐式的。原因是 C++ 有几种不同类型的模板参数,包括以下几种:
- 类型模板参数
- 非类型模板参数
- 模板模板参数
下的泛型。NET 2.0 中,唯一支持的场景是T
表示一个类型,但是为了语法的一致性和在当前 C++ 编译器语法下实现的容易性,需要长形式的声明。
在类的定义中,字母T
被自由地用作类型名的替代,但有一些注意事项。
当你写一个泛型类时,编译器不知道你打算用什么类型作为泛型参数。因此,该类必须针对所有类型进行编译,而不仅仅是您所想的类型。Queue<T>
类是一个很好的例子,它对项目的句柄进行操作,而不对项目本身做任何事情。编译器不需要知道任何关于类型T
的信息,只需要知道它是从System::Object
派生出来的。所有的泛型类型都被认为是从System::Object
派生的。可以使用模板、泛型约束或 cast 运算符创建更多可以处理数据实例本身的专用类。下两章关于模板和高级泛型和类将帮助你编写更强大的类。
下面是一个简单的泛型类:
using namespace System;
generic <typename T>
ref struct Test
{
static void Print(T t)
{
Console::WriteLine(t->ToString());
}
};
int main()
{
Test<int>::Print(3);
}
编译和运行之后,我们得到
C:\>cl /nologo /clr:pure test.cpp
C:\>test
3
在这段代码中,我们创建了一个名为Test
的泛型类。在它的内部,我们有一个名为Print()
的静态方法,它调用ToString()
。我们的类型T
从System::Object
继承了方法ToString()
。注意,唯一可以在T
实例上使用的方法是那些来自System:Object
的方法。
例如,考虑以下代码:
using namespace System;
ref struct Hello
{
void Function()
{
Console::WriteLine("Hello!");
}
};
generic <typename T>
ref struct Test
{
static void Run(T t)
{
t->Function();
}
};
void main()
{
Hello ^ hello = gcnew Hello();
Test<Hello^>::Run(hello);
}
这个会编译吗?我们要担心的片段就在这里:
static void Run(T t)
{
t->Function();
}
为了编译这段代码,类型T
需要有一个可以调用的方法Function()
,对吗?在这种情况下,当调用Run()
时,我们传递Hello^
作为我们的类型T
。这似乎是合理的,因为当Run()
执行时,它发现Hello^
有一个方法叫做Function()
并且这段代码编译了,对吗?
让我们试一试:
C:\>cl /nologo /clr:pure test.cpp
test.cpp
test.cpp(13) : error C2039: 'Function' : is not a member of 'System::Object'
c:\windows\microsoft.net\framework\v2.0.50727\mscorlib.dll :
see declaration of 'System::Object'
泛型类(或函数)中的代码只有在每一个可能为T
插入的类型对代码都有效时才编译。在这种情况下,编译器做出的唯一假设是T
派生自System::Object
;因此,您会得到所述的错误消息。有几种方法可以做到这一点,我们将在第十五章和第十六章中详细介绍,但我想在这里介绍一下。
使用约束
约束告诉编译器泛型类型总是具有某些特征。一种可能是说类型T
有Hello
作为基类。为了声明这一点,我们将Test
的通用声明改为如下:
generic <typename T>
where T : Hello
ref struct Test
{
static void Run(T t)
{
t->Function();
}
};
我们添加了这一行:
where T : Hello
这一行指示编译器假设Hello
是T
的基类。现在让我们编译它:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
Hello!
当然,如果我们修改main()
来传递一个不是从Hello
派生的类型,我们会得到一个编译错误。尝试以下几行:
void main()
{
Test<int>::Run(0);
}
并编译代码:
C:\>cl /nologo /clr:pure test.cpp
test.cpp
test.cpp(21) : error C3214: 'int' : invalid type argument for generic
parameter 'T' of generic 'Test', does not meet constraint 'Hello ^'
test.cpp(12) : see declaration of 'Test'
有关约束的更多信息,参见第十六章。
使用模板
泛型是运行时机制,其中的代码必须适用于所有类型。模板是编译时机制,其中的代码可以针对特定类型进行特殊化。如果在我介绍约束之前,我们仅仅将原始示例代码中的单词generic
改为template
,我们将得到如下结果:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
Hello!
一切正常。如果我们使用经过修改的传递一个int
而不是一个Hello^
的main()
,我们得到
C:\>cl /nologo /clr:pure test.cpp
test.cpp(14) : error C2227: left of '->Function' must point to
class/struct/union/generic type
type is 'int'
test.cpp(13) : while compiling class template member function
'void Test<T>::Run(T)'
with
[
T=int
]
test.cpp(20) : see reference to class template instantiation
'Test<T>' being compiled
with
[
T=int
]
关于模板的更多信息,参见第十五章。
使用石膏
处理这个问题的第三种方法是让编译器假设泛型类型参数只从System::Object
派生,并在运行时将其转换为所需的类型。因为这不是类型安全的,所以我们想多添加一点代码来以智能的方式实现这一点。使用 C# is
和as
关键字的等价物,我们如下构造代码,检查类型是否确实是从
Hello^
:
static void Run(T t)
{
if(t->GetType() == Hello::typeid)
{
Hello ^hello = safe_cast<Hello^>(t);
hello->Function();
}
}
有关铸造机制的更多信息,参见第十六章。
通用函数
当您只需要特定函数的类型参数而不是整个类时,可以使用泛型函数。泛型函数比泛型类简单,其优点是编译器通常可以从函数的参数中自动推导出函数的类型参数。要声明泛型函数,可以使用与泛型类相似的语法。
下面是一个简单的通用函数,它只打印出类型参数的类型:
using namespace System;
generic <typename T>
void Function(T t)
{
Console::WriteLine(t->GetType());
}
ref class Test {};
int main()
{
Function(0);
Function<short>(0);
Test ^t = gcnew Test();
Function(t);
}
我们第一次调用Function()
的时候,使用了类型演绎,编译器确定0
的类型是int
。在第二个调用中,我们明确声明我们正在传递一个short 0
。在最后一个调用中,我们回到了类型演绎,因为编译器确定Function()
被传递了一个类型为Test^
的实例。
现在让我们编译并执行它:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
System.Int32
System.Int16
Test
摘要
现在,您应该对泛型有了基本的了解,并且对。NET 框架。现在让我们进入下一个阶段。我们将在此基础上构建并解决第十五章中的模板和第十六章中的高级泛型。
十五、模板介绍
即使是为了实用的目的,理论通常最终会成为最重要的东西。—奥利佛·文德尔·霍马斯
在前一章中,我们看到了你可以用 C++/CLI 泛型做什么。在这一章中,我将介绍模板,它在许多方面与泛型相似,在许多其他方面又不同。
与泛型的比较
模板和泛型都允许您创建实现多种类型功能的函数和类。我们可以用两种方法之一来解决这个问题:
- 编写与所处理的数据类型无关的代码。代码只实现不依赖于数据类型的算法,所以算法以通用的方式作用于数据。这是 CLI 泛型背后的思想。
- 编写编译器适用于每种数据类型的通用高级代码。结果是,对于所处理的每种类型的数据,都有一组独立的依赖于数据的方法和类型。这是 C++ 模板背后的思想。
在执行之前,泛型类型与数据类型是分离的,模板也是分离的,但在编译期间,编译器会将其绑定到特定的数据类型。泛型方法的结果是 IL 中指定的单个方法;模板方法的结果是在 IL 中为编译期间传递给模板的每个类型参数组合生成一个方法。
Note
泛型是 CLR 支持的一项功能,泛型在各种。NET 语言,包括 C#。然而在 Visual Studio 2013 中,模板是 C++ 独有的。
因为泛型方法和类不知道它们的类型参数,所以它们可以很容易地在一个模块中定义,在另一个定义新类型的模块中被引用,并且仍然可以在该类型上使用。例如,System.Collections.Generics
中的泛型Stack<T>
类可以用来管理任意数量的用户定义类型的堆栈。
尽管模板方法和类型是以通用方式定义的,但它们依赖于它们的类型参数,并且需要 CLI 中不存在的运行时编译级别来操作在其他地方定义的类型。因此,模板是特定于编译单元的。
泛型方法和类完全不知道它们的类型参数的要求对泛型有严格的限制。泛型约束有一点帮助,因为它们允许泛型类型对可能的类型参数做出一些假设,并且只作用于实现一组给定接口的类型参数。
模板没有这样的限制。模板可以应用于任何类型,编译器可以确定语法错误是否由无效的类型参数生成。类型参数甚至可能在初始替换阶段失败,编译器不会中止编译,而是继续寻找合适的模板候选来进行替换,而不会生成语法错误。模板也可以有非类型的参数,称为非类型模板参数,以及模板本身的参数,称为模板模板参数。
通过允许程序员编写适用于多种类型的单个方法,泛型类型代表了减少代码的有效方法,而模板作为一种强大的 C++ 元编程环境出现,并允许程序员利用编译过程在编译时实现复杂的算法和语言扩展。
语法差异
这两种类型参数机制有很多共同之处,通常可以互换使用。模板和泛型在功能上的相似性反映在 C++/CLI 语法中。从一个到另一个的切换通常很简单,只需将关键字generic
改为template
,反之亦然。
考虑下面一行:
generic <typename T> ref class TypeGeneric {};
TypeGeneric <T>
是一个完整的泛型类型,能够在运行时创建。现在考虑这一行:
template <typename T> ref class TypeTemplate {};
TypeTemplate <T>
根本不是一个类型。它是一种类型的模型,可以由编译器在以后的编译过程中使用或实例化它时生成。
比较摘要
虽然模板和泛型在很多方面都很相似,但是这两种机制之间有一个主要的区别。与运行时由虚拟执行系统(VES)实例化的泛型类型不同,模板完全由 C++ 编译器解析、实例化和编译。泛型类型可以被认为是 CLI 类型的打包机制,而模板更像是在编译时扩展、验证和执行的智能宏。
编译过程
当编译器遇到一个泛型函数时,它会验证在给定约束和实现的情况下,它对所有可能的类型都有意义。然后在 IL 中生成一个函数。
考虑以下简单的通用函数:
generic <typename T>
ref class Test
{
public:
Test()
{
System::Console::WriteLine(this->ToString());
}
};
void main()
{
Test<int> i;
Test<float> j;
}
当解析类Test
时,为通用类创建 IL 和元数据。让我们编译并运行它:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
Test
1[System.Int32]`
Test
1[System.Single]`
现在让我们在上面运行ildasm.exe
。在 ILDasm 主窗口中,打开构造器(.ctor
)定义。您应该会发现类似于以下内容的 IL 和元数据:
.method public hidebysig specialname rtspecialname
instance void .ctor() cil managed
{
// Code size 18 (0x12)
.maxstack 1
IL_0000: ldarg.0
IL_0001: call instance void [mscorlib]System.Object::.ctor()
IL_0006: ldarg.0
IL_0007: callvirt instance string [mscorlib]System.Object::ToString()
IL_000c: call void [mscorlib]System.Console::WriteLine(string)
IL_0011: ret
} // end of method Test
1::.ctor`
编译器创建了一个名为Test
1::.ctor的方法,它是一个名为
Test的类的构造器,只有一个泛型参数。注意,在这种情况下,
Test和
Test与构造器无关。
Test
另一方面,举一个完全相同的例子,用关键字generic
代替template
。编译并运行它:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
Test<int>
Test<float>
如您所见,创建了两个完全不同的类,Test<int>
和Test<float>
。让我们运行ildasm.exe
看看这个(见图 15-1 )。注意Test<float>
和Test<int>
是不同的。
图 15-1。
Metadata for the different compile-time–generated template instantiations
现在让我们看看代码,以及编译器如何解析它的描述:
01: template <typename T>
02: ref class Test
03: {
04: public:
05: Test()
06: {
07: System::Console::WriteLine(this->ToString());
08: }
09: };
10: void main()
11: {
12: Test<int> i;
13: Test<float> j;
14: }
到第 9 行,编译器已经意识到有一个模板。然后,它判断哪些类型是确定的和不确定的,绑定它能绑定的任何引用,发出任何可检测的语法错误,并将模板存档以备将来实例化。
如果在第 9 行使用了泛型,编译器将有足够的信息将类提交给元数据。对于模板,在实例化之前,它只是一个潜在的类。模板告诉编译器如何创建一个特定类型的类,如果你决定创建一个的话。
现在回到解析。在第 12 行,编译器试图实例化Test<int>
。模板被重新解析,int
被替换为typename T
,任何未绑定的引用被绑定,类被生成。编译器现在准备将Test<int>
提交给元数据。同样,在第 13 行,编译器能够解析并确定Test<float>
是否有效。
Note
CLR 要求泛型设计为适用于满足任何给定类型约束的所有可能类型,而模板只需要设计为适用于您实际实例化的类型。
现在打开Test<int>
构造器(.ctor
)定义。您应该可以找到如下所示的元数据和 IL:
.method public hidebysig specialname rtspecialname
instance void .ctor() cil managed
{
// Code size 18 (0x12)
.maxstack 1
IL_0000: ldarg.0
IL_0001: call instance void [mscorlib]System.Object::.ctor()
IL_0006: ldarg.0
IL_0007: callvirt instance string [mscorlib]System.Object::ToString()
IL_000c: call void [mscorlib]System.Console::WriteLine(string)
IL_0011: ret
} // end of method 'Test<int>'::.ctor
快速的比较表明,模板类的方法和泛型类的方法的元数据和 IL 是相同的。在这种情况下,使用泛型似乎更有意义,因为int
和float
是重复的。实际上,性能含义并不清楚,因为 CLR 为值类型创建了重复的实例化,并为引用类型创建了单一的通用实例化。
模板并不真正存在
因为模板被实例化为常规的 CLI 类型和方法,所以从 CLI 的角度来看,它们不是一个特殊的实体。CLR 显式支持泛型,这意味着它支持 IL 打包类型作为泛型参数。模板没有特定的 IL,很容易看出为什么。
编译器将模板视为基于类型参数创建类的一组指令。一旦用模板参数实例化了类型,编译器就确切地知道要创建什么样的类,并且这个类被写入特定于实例化中使用的类型的元数据和 IL 中。只有模板的实例,而不是模板本身,才能进入模块。因此,模板不能像泛型一样从程序集中导出,程序员重用模板的唯一方法是重用该模板的源代码定义。
EXERCISE
理论上,您可以创建一个模板泛型,尽管这还不是 C++/CLI 规范的一部分。你能想出一个令人信服的理由来添加这个功能吗?
限制自由是件好事
为了使泛型功能变得有用,以某种方式限制类型变得很有必要。这被称为通用约束。使用一个完全任意的类型来编写有用的东西是非常困难的。毕竟,你能用它做什么呢?甚至前面的示例例程也依赖于这样一个事实,即所有 CLR 类型都支持具有ToString()
方法的接口。如果没有这种隐式约束,甚至前面的简单示例都无法编译。因此,在编译时,约束限制了可用作泛型类型参数的类型种类,也就是告诉泛型它需要支持什么类型。典型的范例是,您有一个想要使用的特定接口,因此您将泛型类型约束为从该接口派生。我将在本章的后面给出一个例子。
与泛型不同,模板不支持显式类型约束。因为泛型是在运行时由 VES 实例化的,所以它们需要一种编译时机制来防止用无效的类型参数实例化泛型类型,而泛型类型约束就是这种机制。另一方面,模板是由编译器在编译时解析和实例化的,因此不会遇到无效的运行时实例化问题。仍然可以争论的是,当与模板一起使用时,某些形式的显式类型约束可能是有用的,但是,作为一个设计问题,C++ 使用了模板代码中隐式存在的类型约束。
模板范例
泛型类型被设计为在以下范例下工作:
- 您只从
System::Object
调用方法。 - 你使用一个约束来限制可能的类型参数,并且只使用由约束激活的方法(见第十六章)。
- 使用强制转换运算符对类型参数执行运行时切换,并执行特定于类型的操作。
基本思想是泛型要么必须为所有可能的类型工作,这意味着要么System::Object
要么你定义的任何约束,要么类型必须在编译时被分叉和处理。
模板更广泛。编译时,编译器不检查类或方法是否适用于所有或一类类型参数,而是只检查它是否适用于某些类型;也就是说,模板是为语法错误而准备的。
当模板用一组特定的类型参数实例化时,该模板在此上下文中被重新计算,并被编译成特定于类型参数的代码。
专门化
可以通过用一组更具体的类型参数重新定义模板来专门化它。模板引擎在编译期间倾向于最专门化的版本。例如,如果您想为所有类型创建一个默认的类模板,您应该编写以下代码:
template <typename T> ref class R {};
如果您后来决定要以一种定制的方式处理某种类型,比如一个double
,那么您应该声明
template<> ref struct R<double> {};
下面是一个使用模板函数和非模板函数重载的更复杂的例子:
using namespace System;
template <typename T> void function(T t)
{
Object ^o = t;
Console::WriteLine(o->GetType());
};
template<> void function(int i)
{
Console::WriteLine("template integer");
};
void function(int j)
{
Console::WriteLine("not a template");
}
void main()
{
String ^s="hello";
function(s);
function(3.0);
function(1);
function<int>(1);
}
在这个例子中,我们声明了一个模板函数,恰当地命名为function()
。主模板函数在屏幕上显示类型参数的类型名称。对于一个整型参数和一个具有相同函数名的最终非模板函数,存在一些专门化。在main()
例程中,我们使用编译器推断参数类型的能力来调用正确的模板函数。
让我们编译并运行这个示例:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
System.String
System.Double
not a template
template integer
在这种情况下,对带有String
和double
的function()
的调用被路由到主模板。第一次调用function()
传递一个整数到非模板版本,显式指定function<int>()
的调用被发送到带有int
的显式专门化。
需要注意的一件重要事情是主模板中的显式装箱。在代码中,我写了以下内容:
Object ^o = t;
Console::WriteLine(o->GetType());
而不是
Console::WriteLine(t->GetType());
与合并引用和值类型语法的泛型类型不同,模板是代码的文字分析。在这种情况下,我们希望模板既适用于值类型,也适用于引用类型。如果是句柄,下面的语法是正确的:
t->GetType()
如果是值类型(或堆栈上的引用类型),下面一行是正确的语法:
t.GetType()
我们可以通过转换为System::Object^
来处理这种语法差异,如示例所示,或者通过使用引用类型的部分专门化,如我接下来将展示的。
部分专业化
部分限定模板参数的类模板声明称为部分专门化。部分专门化仍然是模板,而不是实例化或显式专门化,因为它们需要在生成代码之前用完整的类型参数进行实例化。模板参数可以通过指针间接寻址、类型、参数复制以及其他方式来专门化,如 C++ 标准中所述。
考虑以下示例:
using namespace System;
template <typename T, typename W> ref struct R
{
static R()
{
Console::WriteLine("T,W: "+__FUNCTION__);
}
};
template <typename T, typename W> ref struct R<T^,W>
{
static R()
{
Console::WriteLine("T^,W: "+__FUNCTION__);
}
};
template <typename T, typename W> ref struct R<T*,W>
{
static R()
{
Console::WriteLine("T*,W: "+__FUNCTION__);
}
};
template <typename T> ref struct R<T,T>
{
static R()
{
Console::WriteLine("T,T: "+__FUNCTION__);
}
};
void main()
{
R<int,double> Primary;
R<R^,int> First;
R<int,int> Second;
R<char*,int> Third;
}
我们有一个主要模板和三个部分专门化。第一个专门化要求第一个类型参数是某种引用类型,第二个专门化要求两个参数相同。第三种专门化需要一个指向本机类型的指针。
让我们编译并运行这个:
C:\>cl /nologo /clr:pure test.cpp
test.cpp
C:\>test
T,W: R<int,double>::R (static class constructor)
T^,W: R<struct R ^,int>::R (static class constructor)
T,T: R<int,int>::R (static class constructor)
T*,W: R<char *,int>::R (static class constructor)
正如您从结果中看到的,主模板和两个部分专门化中的每一个都被依次实例化。
函数模板的偏序
如果您想以类似于部分专门化的方式来区分函数,该怎么办呢?例如,假设您想用一个定制版本替换ToString()
,该版本以某种方式为您的内置变量格式化字符串;也许你想用科学记数法显示所有的双精度数:
using namespace System;
template <typename T> String ^MyToString(T t)
{
return t.ToString();
}
template <typename T> String ^MyToString(T ^t)
{
return t->ToString();
}
template <> String ^MyToString(double d)
{
return d.ToString("0.###e+00");
}
value struct V
{
virtual String ^ToString() override
{
return "it's V";
}
};
void main()
{
V v;
int i = 23;
double d=47.3;
Console::WriteLine(MyToString<int>(i));
Console::WriteLine(MyToString<double>(d));
Console::WriteLine(MyToString<V>(v));
}
在本例中,我们为最常见的情况提供了一个模板:
template <typename T> String ^MyToString(T t) {}
还有一个更具体的霸主:
template <typename T> String ^MyToString(T ^t) {}
当我们调用MyToString()
时,编译器在重载选择期间通过一个称为部分排序的过程挑选出更显式或限制性的匹配。部分排序允许我们区分由值传递的典型值类型和由句柄传递的典型引用类型。
让我们编译并运行这个:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
23
4.73e+01
it's V
在这种情况下,对于我们的例子,我们仍然使用来自System::Object
的方法ToString()
。与泛型类型不同,即使模板不识别要调用的方法,只要在没有确定类型参数的情况下模板是不可解析的,它也会编译。例如,考虑以下情况:
using namespace System;
template <typename T> String ^MyToString(T ^t)
{
return t->MyFunction();
}
void main() {}
即使编译器不能确定假想句柄T^t
是否有一个名为MyFunction()
的成员方法,这个程序也能很好地编译。只有当用户试图使用实类型参数实例化模板时,编译器才能确定成功或失败。泛型假设最坏的情况;模板希望最好的。
现在让我们完成这个程序,实现模板的希望:
using namespace System;
template <typename T> String ^MyToString(T ^t)
{
return t->MyFunction();
}
ref struct R
{
String ^ MyFunction()
{
return "Hello";
}
};
void main()
{
R ^r = gcnew R();
Console::WriteLine(MyToString(r));
}
现在让我们编译并运行它:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
Hello
这是正确的。
非类型模板参数
模板的主要优点之一是能够拥有非类型的模板参数;例如,它们可以是整数值。
斐波那契数字模板
考虑下面的模板,它生成斐波那契数:
using namespace System;
template <int N>
struct Fibonacci
{
enum { Value = Fibonacci<N-1>::Value + Fibonacci<N-2>::Value};
};
template <>
struct Fibonacci<0>
{
enum {Value = 0 };
};
template <>
struct Fibonacci<1>
{
enum {Value = 1 };
};
void main()
{
Console::WriteLine(Fibonacci<7>::Value);
}
此代码使用枚举值在类中设置一个数字;然后,它使用带有两个显式专门化的递归算法来计算斐波那契数。
让我们编译并运行它:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
13
这个程序有趣的地方在于,由于它的编写方式,它允许在编译时计算模板的值。如果我们查看 ildasm 中的程序,我们会看到算法的整个递归性质已经解决,代码被浓缩为:
int main()
{
Console::WriteLine(13);
return 0;
}
平方根模板
让我们尝试一个更复杂的例子。此示例计算整数的四舍五入平方根。它在模板参数中使用默认值,这在 C++ 中是允许的:
using namespace System;
template <int N, int low=1, int high=N>
struct Root
{
enum { Value = Root<N, ((low+high)/2), N/((low+high)/2)>::Value};
};
template <int N, int R>
struct Root<N,R,R>
{
enum {Value = R};
};
void main()
{
Console::WriteLine(Root<196>::Value);
}
让我们编译并运行它:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
14
这个程序试图通过不断平均一个猜测和除以猜测的数来迭代计算平方根。平方根总是介于两者之间。只有当输入是一个完美的正方形时,程序才能保证工作,否则可能会导致编译错误。
质数模板
1994 年,Erwin Unruh 在一次 C++ 标准委员会会议上分发了一个程序,用模板打印出素数作为错误信息。尽管原始程序在 Visual C++ 2005 上不发布相同的诊断,但我写的这个版本具有相同的效果:
template <int p, int i=p-1> struct is_prime
{
enum { value = (p%i) && is_prime<p,i-1>::value };
};
template<int p> struct is_prime<p,1>
{
enum { value=1 };
};
template<> struct is_prime<2>
{
enum { value=1 };
};
template <int p> ref struct Prime_print
{
Prime_print<p-1> a;
static void *ptr = (int)is_prime<p>::value;
};
template<> ref struct Prime_print<1> {};
void main()
{
Prime_print<9> d;
}
让我们编译并运行这个:
C:\>cl /nologo /clr:pure test.cpp
test.cpp
test.cpp(16) : error C2440: 'initializing' : cannot convert from 'int' to 'void *'
Conversion from integral type to pointer type requires
reinterpret_cast, C-style cast or function-style cast
This diagnostic occurred in the compiler generated function
'void Prime_print<p>::Prime_print(void)'
with
[
p=2
]
test.cpp(16) : error C2440: 'initializing' : cannot convert from 'int' to 'void *'
Conversion from integral type to pointer type requires
reinterpret_cast, C-style cast or function-style cast
This diagnostic occurred in the compiler generated function
'void Prime_print<p>::Prime_print(void)'
with
[
p=3
]
test.cpp(16) : error C2440: 'initializing' : cannot convert from 'int' to 'void *'
Conversion from integral type to pointer type requires
reinterpret_cast, C-style cast or function-style cast
This diagnostic occurred in the compiler generated function
'void Prime_print<p>::Prime_print(void)'
with
[
p=5
]
test.cpp(16) : error C2440: 'initializing' : cannot convert from 'int' to 'void *'
Conversion from integral type to pointer type requires
reinterpret_cast, C-style cast or function-style cast
This diagnostic occurred in the compiler generated function
'void Prime_print<p>::Prime_print(void)'
with
[
p=7
]
如您所见,所有小于或等于非类型模板参数的质数都显示在诊断中。
复数
在第十一章中,我们做了几件有趣的事情:
- 我们定义了一个复数类。
- 我们定义了一类模为 13 的整数。
- 我们介绍了斐波纳契数列可以用黄金分割率来计算的事实。
在这一节中,我们将把这些表面上不同的概念收集到一个单一的程序中,该程序使用黄金分割公式计算斐波那契数模任意素数。这个程序将使用模板、类型和非类型参数以及专门化来创建干净的模块化代码。
概观
将一个复数类与斐波那契数公式结合起来使用似乎有些奇怪,但这完全取决于我们对复数对的定义。让我们通过考虑对(a,b)来概括复数的含义,其中第二个数乘以 5 的平方根而不是–1 的平方根。事实上,我们可以使用 integer 类型的非类型模板参数来指示平方根下的数字。我们的复数类也将有一个类型参数来指示我们对(a,b)
中每个数字的类型。我们将使用这个类型参数来提供Modulo<P>
的类类型,其中Modulo
是模素数P
的类模板,P
是非类型模板参数。
数学公式
这是一个数学事实,斐波那契数列可以由黄金分割率及其共轭的幂产生。具体公式如下:
如前所述,我们的Complex
类模板能够使用任意类型对,其中部首中的数字是任意整数,比如 5。为此,我们使用一个非类型模板参数,它的非类型模板参数是整数常量,并使用另一个类型参数作为该对的基础类型:
template <typename T, int N> value struct Complex;
我们在第十一章的复数类中,使用了两个 doubles 作为我们的私有数据成员,对应于复数的实部和虚部。虚部是一个双精度数,它隐式地乘以–1 的平方根以生成复数。在本章的课程中,我们还将使用任意类型的实部和虚部,并考虑虚部平方根下乘以整数的部分。
我们将使用 5 作为基数来实现复数类,从而为斐波那契公式创建黄金比例及其补数。但是,为什么就此打住呢?与其使用双精度数,为什么不用整数模素数来表示我们的数字呢?让类型带有用于基本数学运算的运算符让我们可以做到这一点。
所以我们将把我们的复数建立在不同于int
的类型上,比如float
和double
。这似乎是泛型的自然任务,对吗?为了实现一个通用的复数类,我们所要做的就是能够对这些内置类型进行加、减、乘、除运算。这表面上看起来很简单,但外表可能具有欺骗性。
通用实现
通用操作都是根据 IL 中的虚函数调用来定义的。因此,在解析泛型时,编译器必须能够判断出为特定的表达式调用哪些函数。如果有一个所有类型都支持的公共multiply
接口,那么泛型类将能够为任意类型调用一个multiply
函数并解析表达式。事实上,没有一个通用的算术接口适用于所有类型,原因有二:
- 算术对于从
Object^
派生的任意类型并不总是有任何意义。比如,String^
乘以String^
是什么意思? - 为任意类型实现额外的接口对程序员来说是很麻烦的。
当然,追溯重写内置类型以支持算术接口也是不可能的,所以我们面临着为每个内置类型创建一个包装类的令人不快的任务,它支持实现基于任意内置类型的Complex
变量所需的算术接口。然后,我们可以约束从算术接口派生的类型。这个解决方案既不优雅也不是最佳的。
拯救模板
使用类模板实现既简单又高效。编译器可以在编译时确定它已经知道如何将两个类型为float
的项相乘。当模板被实例化时,类型对编译器来说并不神秘,所以它能够在每种情况下产生正确的 IL。没有必要创建一个能够处理任意类型的函数。事实上,实例化可能基于类型本身的定义产生完全不同的代码!
事不宜迟,下面是Complex<typename T,int N>
、Modulo<int P>
和Fibonacci<typename T>
类;这可能是书中最长的例子,但我认为这是值得的:
using namespace System;
template <int P> String^ Radix()
{
return " sqr("+P.ToString()+")";
}
template <> String^ Radix<-1>()
{
return "i";
}
template <typename T, int N>
value struct Complex
{
T re;
T im;
Complex(T _re, T _im)
{
re = _re;
im = _im;
}
Complex(T _re)
{
re = _re;
im = T(0);
}
static Complex operator* (Complex lhs, Complex rhs)
{
return Complex(lhs.re*rhs.re + (lhs.im*rhs.im*T(N)),
lhs.re*rhs.im + lhs.im*rhs.re);
}
static Complex operator/ (Complex lhs, Complex rhs)
{
T bottom = rhs.re*rhs.re - rhs.im*rhs.im*T(N);
Complex product = Complex(lhs * ∼rhs);
return Complex(product.re/bottom, product.im/bottom);
}
static Complex operator+ (Complex lhs, Complex rhs)
{
return Complex(lhs.re+rhs.re, lhs.im+rhs.im);
}
static Complex operator- (Complex lhs, Complex rhs)
{
return Complex(lhs.re-rhs.re, lhs.im-rhs.im);
}
static Complex operator∼ (Complex lhs)
{
return Complex(lhs.re, -lhs.im);
}
virtual String^ ToString() override
{
return re.ToString() + (!!im ? " + " + im.ToString() + Radix<N>() : "");
}
};
template <int P>
value struct Modulo
{
int Value;
Modulo(int Val)
{
Value = Val % P;
}
static bool operator!(Modulo rhs)
{
return !rhs.Value;
}
static void ExtendedEuclid(int a, int b, int %d, int %x, int %y)
{
if(b==0)
{
d=a;
x=1;
y=0;
}
else
{
ExtendedEuclid(b,a%b, d, y, x);
y-= (a/b)*x;
}
}
static bool operator==(Modulo lhs, Modulo rhs)
{
return((rhs.Value-lhs.Value) % P == 0);
}
static bool operator!=(Modulo lhs, Modulo rhs)
{
return !(lhs == rhs);
}
static Modulo operator* (Modulo lhs, Modulo rhs)
{
return Modulo((lhs.Value * rhs.Value) % P);
}
static Modulo operator+ (Modulo lhs, Modulo rhs)
{
return Modulo((lhs.Value + rhs.Value) % P);
}
static Modulo operator- (Modulo lhs, Modulo rhs)
{
return Modulo((lhs.Value - rhs.Value) % P);
}
static Modulo operator- (Modulo lhs)
{
return Modulo((P - lhs.Value) % P);
}
static Modulo operator/ (Modulo lhs, Modulo rhs)
{
int d, x, y;
ExtendedEuclid(rhs.Value,P,d,x,y);
return lhs*Modulo(x*d);
}
virtual String ^ ToString() override
{
Value = (Value+P) % P;
String ^s = Value.ToString();
return s;
}
};
template <typename T> ref struct Fibonacci
{
static T half = T(1)/T(2);
static Complex<T,5> phi = Complex<T,5>(half,half);
static Complex<T,5> theta = Complex<T,5>(half,-half);
static Complex<T,5> difference = phi-theta;
template <int N>
ref struct Result
{
static initonly Complex<T,5> phi_n = Result<N-1>::phi_n * phi;
static initonly Complex<T,5> theta_n = Result<N-1>::theta_n * theta;
static initonly Complex<T,5> Value = (phi_n-theta_n)/difference;
};
template <>
ref struct Result<0>
{
static initonly Complex<T,5> phi_n = Complex<T,5>(T(1));
static initonly Complex<T,5> theta_n = Complex<T,5>(T(1));
static initonly Complex<T,5> Value = Complex<T,5>(T(0));
};
template <int N>
static void Print()
{
Print<N-1>();
Console::Write("{0,4} ", Result<N>::Value);
}
template <>
static void Print<0>()
{
}
};
void main()
{
Fibonacci<double>::Print<14>();
Console::WriteLine();
Fibonacci<Modulo<7>>::Print<14>();
Console::WriteLine();
Fibonacci<Modulo<13>>::Print<14>();
Console::WriteLine();
}
C:\>cl /clr:safe /nologo test.cpp
C:\>test
1 1 2 3 5 8 13 21 34 55 89 144 233 377
1 1 2 3 5 1 6 0 6 6 5 4 2 6
1 1 2 3 5 8 0 8 8 3 11 1 12 0
注意第 7 个斐波那契数是(–1 模 7),第 13 个是(–1 模 13)。事实上,对于 5 以外的质数p
,第p
个斐波那契数总是等于 1 或-1 模p
。
核心区别:模板和泛型
模板和泛型有以下核心区别:
- 泛型保持泛型,直到在运行时执行类型替换。模板在编译时是专门化的。
- CLR 特别支持元数据和 IL 中的泛型类型。模板的类型在编译时被解析成普通类型。
- 编译器为所有类型生成一个类或函数。模板为每个专用参数列表生成不同的类和函数。
- 在具有相同类型参数的不同程序集中专门化的泛型是相同的泛型类型;模板类型在编译中是唯一的,不能导出到程序集之外。
- 只有模板允许非类型参数。
- 只有模板允许类型参数有默认值。
- 模板允许部分和显式专门化。
- 模板支持模板模板参数。
- 模板类可以将类型参数作为基类。
摘要
模板乍一看似乎很简单,但是实现的细节和分支却令人困惑。有了对模板的这种了解,我们就可以在下一章再看一看泛型类型了。
十六、高级泛型和类型转换
如果一个人倾其所有去追求他的头脑,那么没有人能从他那里拿走它。—本杰明·富兰克林
在第十四章的中,我们看到了泛型是如何从System::Object
派生出来的,并且能够访问所有的System::Object
方法。此外,我们发现可以使用集合类如Stack<T>
和Queue<T>
来维护泛型对象的类型安全列表。在这一章中,我们将学习如何使用约束、强制转换操作符和运行时类型标识来编写泛型类和函数,它们不仅仅是收集从System::Object
派生的对象的句柄;这些类和函数将作用于数据本身的实例。
限制
约束允许我们这样做:它们指示编译器,给定的泛型函数或类只能用具有某些已定义特征的类型来实例化。这允许编译器完成以下任务:
- 验证给定的泛型类型仅与满足给定约束的类型参数一起使用。
- 将类型参数视为满足泛型类或函数体内的给定约束。例如,如果一个约束说一个类型拥有一个给定的接口,那么这个类型的实例化的接口方法在泛型类或函数的主体中变得可用。
约束的种类
在 C++/CLI 和。NET 框架。两者的语法是相似的。在generic
关键字和类型列表之后,在类或函数声明之前,插入where
关键字来指定对类型参数的约束。这采取以下形式:
generic <typename T1, typename T2>
where T1 : Constraint1, Constraint2
where T2 : T1, Constraint3
ref class R {};
支持的约束有很多种。
类型约束
类型约束告诉编译器类型参数是给定的类型。类型参数可能是约束类型的后代,约束类型要么是类型参数的直接基类,要么是更远的祖先。同样,约束类型也可以是类型参数支持的接口。在任一情况下,约束的成员字段、方法、属性和事件都可以在泛型类中访问。
考虑以下代码:
using namespace System;
ref struct R
{
virtual property String^ P
{
String^ get()
{
return "Property P";
}
}
};
ref struct R1 : R
{
};
generic <typename T>
where T : R
void Function(T t)
{
Console::WriteLine(T::typeid);
Console::WriteLine(t->P);
}
int main()
{
R1 ^r = gcnew R1();
Function(r);
}
在这个例子中,我们有一个包含属性P
的ref
类R
。我们将R
既用作R1
的基类,又用作名为Function()
的通用函数的类型约束。在我们的通用函数体内,我们访问属性P
并显示属性中的String
数据。在函数main()
的主体中,我们通过实例化R1
的一个元素并将其传递给Function()
来设置球滚动。类型演绎确定T
是R1
并验证R
是R1
的基类,代码编译无误。
假设我们编译并运行它:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
R1
Property P
您可能会注意到泛型中有一点奇怪,因为函数将“T t
”而不是T^t
作为参数。移除插入符号允许编译器在泛型类或函数的主体中使用通用语法,而无需强迫程序员指定T
是表示引用类型还是值类型。
假设我们通过改变R1
的定义来稍微修改这个例子:
ref struct R1 : R
{
int P;
};
现在R1::P
的意思完全不同了。它不再是基类中定义的String
属性,而是派生类中定义的整数。然而,对于通用函数,什么都没有改变。在执行过程中,它通过运行时类型标识确定T
的类型为R1
,但根据约束规则继续访问P
,即T
的类型为R
。
如果我们说T
既是类型R
又是类型R1
会发生什么,如下所示?
where T : R, R1
现在编译器很难确定使用哪个版本的P
。让我们试一试:
C:\>cl /nologo /clr:pure test.cpp
test.cpp(18) : error C3219: 'T' : generic parameter cannot be
constrained by multiple non-interfaces : 'R ^'
test.cpp(21) : error C2385: ambiguous access of 'P'
could be the 'P' in 'R'
or could be the 'P' in 'R1'
避免了模糊性,因为类型约束的规则与继承的规则相同:一个类型最多可以拥有一个不是接口的基类。
让我们再改变一次这个例子。让我们将另一个属性P
添加到派生类R1
中,并使其成为virtual override
:
ref struct R1 : R
{
virtual property String^ P
{
String^ get() override
{
return "overridden Property P";
}
}
};
乍一看,我们期望通用函数为P
、R::P:get()
调用基类get
访问器。实际上,我们最终得到的是派生版本,因为它是virtual override
。我们来看看:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
R1
overridden Property P
满足类型约束
C++/CLI 标准规定类型参数可以通过以下方式满足类型约束:
- 身份转换
- 句柄转换
- 装箱转换
以下示例包含了其中的每一项:
using namespace System;
interface class I {};
ref struct R : I {};
value struct V : I {};
generic <typename T>
where T : I
ref struct G
{
generic <typename V>
ref struct N
{
G<T>^g;
};
};
int main()
{
G<I^> ^ i;
G<R^> ^ r;
G<V> ^ v;
}
在这个例子中,我们有一个泛型类G
,它有一个类型参数T
。对T
的类型约束表明它必须支持接口I
。在这种情况下,I^
通过身份转换满足了这一点;R^
通过句柄转换支持I^
,值类型V
可以装箱传递给G
。此外,嵌套类N
能够使用T
作为类型参数,因为T
根据定义满足V
的约束。
gcnew 约束
gcnew
约束允许您通过调用默认的构造器来分配类型参数的实例。由于不是所有类型都可以自由分配(考虑一下sealed
或abstract
类型),这个约束允许你编写可以分配实例的通用函数和类。gcnew
约束还验证泛型函数或类的受约束类型参数是否可以通过公共的无参数构造器在托管堆上分配,例如:
using namespace System;
generic <typename T>
where T : gcnew()
void Function()
{
T t = gcnew T();
}
ref class R {};
int main()
{
Function<R^>();
}
值类约束
value class
约束指示编译器类型参数是值类型。回想一下,当您使用值类型时,您是在使用数据本身,而不是数据的句柄。这使得如下函数成为可能:
using namespace System;
generic <typename T>
where T : value class
void Swap(T % a, T % b)
{
Console::WriteLine("Swapping...");
T temp = a;
a = b;
b = temp;
}
int main()
{
int i=3, j=4;
Console::WriteLine("i = {0}, j={1}", i, j);
Swap<int>(i,j);
Console::WriteLine("i = {0}, j={1}", i, j);
}
在这个程序中,我们将T
约束为值类型。全局函数Swap()
需要两个对值类型的引用,并执行一个简单的交换。结果如下:
C:\>cl /nologo /clr:pure test.cpp
test.cpp
C:\>test
i = 3, j=4
Swapping...
i = 4, j=3
尝试用一个ref class
参数实例化泛型类型参数,看看编译器是如何实施约束的。
ref 类约束
下面是一个使用引用类型的类似构造的示例:
using namespace System;
interface class I
{
virtual property int P;
};
ref struct R : I
{
virtual property int P;
};
value struct V : I
{
virtual property int P;
};
generic <typename T>
where T : I
void ChangePropertyData(T t)
{
t->P = 2;
}
int main()
{
R ^ r = gcnew R();
Console::WriteLine("changing R...");
ChangePropertyData(r);
Console::WriteLine("r->P == {0}", r->P);
V v = V();
Console::WriteLine("changing V...");
ChangePropertyData(v);
Console::WriteLine("v.P == {0}", v.P);
}
在这个代码示例中,我们有一个带有属性P
的接口。我们有一个泛型函数,它被约束来接受一个具有这个接口的类型参数。此函数使用属性修改基础值。如果我们用一个引用类型调用这个函数,我们会得到想要的行为,因为我们传递了一个数据句柄,这个句柄可以用来修改原始数据。另一方面,如果我们用值类型调用这个函数,我们传递的是数据的副本,而原始数据是不变的。
结果如下:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
changing R...
r->P == 2
changing V...
v.P == 0
如您所见,v.P
的原始值没有改变,这不是我们想要函数做的。为了防止这种行为,我们可以通过添加ref class
约束来将类型T
约束为ref class
:
where T : I, ref class
现在当我们编译时,我们得到以下结果:
C:\>cl /nologo /clr:pure test.cpp
test.cpp(28) : error C3390: 'V' : invalid type argument for generic parameter 'T'
of generic 'ChangePropertyData', must be a reference type
test.cpp(16) : see declaration of 'ChangePropertyData'
约束摘要
约束对于创建在自定义类型集或具有预定义接口的类型集上操作的泛型函数和类非常有用。然而,通常情况下,您无法控制您希望为其编写泛型的类型的定义;比如System::Int32
。假设您想编写一个简单的通用函数来将两个数字相加,其中数字可以是float
或double
,也可以是整数类型,这取决于上下文。约束解决不了这个问题;一份遗嘱。我将在下一节展示如何操作。
演职人员
在 C++ 中,有几种类型的强制转换运算符,旨在使您的代码准确且易于维护。把标准的“输入内括号”操作符想象成一把大锤,可以打碎一切。C++ 强制转换运算符受到限制,因为它们只能执行某些类型的转换,这样,您就可以确保代码不会以意外的方式发生变化。首先,我们可以试着看看我们的钥匙是否合适,而不是用大锤或消防队员的斧头开门。
特殊的强制转换运算符通过两种主要方式帮助您避免问题:
- 开发期间的强制转换:当您在代码开发中使用强制转换时,您实际上是在重写编译器,并迫使它尝试转换有问题的项。这样做可以防止编译器发出诊断信息,告诉你你所做的是一个坏主意。每次你使用石膏时,你应该问问自己这是不是一个好主意。当使用特殊的强制转换操作符时,编译器并没有完全消失。相反,它被指示允许某些类型的转换通过,并阻止其他类型的转换。因此,您不得不考虑您正在做什么,并确保您头脑中设想的类型与创建准确代码所需的实际类型相一致。
- 维护期间的强制转换:使用具有抽象接口的多态类意味着您对类的定义了解有限。您可以根据类拥有某个已定义的基类谱系以及这些类是以某种方式定义的这一事实来编写代码,但这可能会随着代码的成熟而改变。您认为一个类的实例可以毫无问题地转换成另一个类的实例,这种分析可能是完全错误的。此外,标准的 cast 运算符阻止编译器通知修改类定义的人下游可能有问题。因此,引入了隐藏的 bug,这些 bug 在代码交付给最终用户之前可能不会被注意到。特殊的强制转换运算符降低了这种情况发生的可能性。显式和隐式转换运算符以及运行时类型标识的使用进一步降低了这种情况发生的可能性。
运行时类型标识
运行时类型识别是类的实例本身识别它们是什么类型的能力。在。NET Framework 中,方法GetType()
是System::Object
的成员,所以它总是可以获得关于引用类型和值类型的对象实例的信息。C# 有typeof()
操作符,它允许你获得一个类的System::Type
信息。C++/CLI 的等价物是::typeid
静态属性。您可以使用这些函数来识别一个类或对象并使用它。
考虑以下通用函数:
using namespace System;
generic <typename T>
void Function()
{
Console::WriteLine(T::typeid);
}
ref struct R {};
generic <typename T> ref struct GenericType {};
generic <typename T> ref struct Outer
{
generic <typename V, typename W> ref struct Inner
{
};
};
int main()
{
Function<int>();
Function<R^>();
Function<GenericType<int>^>();
Function<Outer<int>::Inner<R^, short> ^>();
}
这个函数打印出typeid
s。如果我们执行并编译它,我们得到如下结果:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
System.Int32
R
GenericType
1[System.Int32]`
Outer
1+Inner2[System.Int32,R,System.Int16]
如您所见,泛型的typeid
带有装饰。反勾号(```cpp)表示一个泛型,它表示嵌套层次上泛型参数的数量。加号(+
)分隔泛型类型的嵌套。虽然这里没有显示,但是泛型函数的签名是相似的,除了它们在函数标识符和泛型参数的数量之间使用了两个反勾号(````)。
const_cast <>()
C# 和 C++ 中有几个修饰符会影响字段和类型的可变性。const_cast<>()
操作符允许您添加或删除表达式类型的const
或volatile
类型限定符。下面是一个例子:
void main()
{
const int i = 0;
//i=2 will not compile
*const_cast<int *>(&i) = 2;
}
我将在第二十章中回到可变性类型限定符。
静态 _ 强制转换<>()
static_cast<>()
用于调用用户定义的转换,以及将基类的实例强制转换回派生类。只有当你确信后一种方法可行时,你才应该这样做,因为这是一种快速造型,编译器不会做任何进一步的检查。
使用static_cast<>()
的示例如下:
using namespace System;
ref class Base {};
ref class Derived : Base {};
void main()
{
Base ^ b = gcnew Derived();
Derived ^ d = static_cast<Derived^>(b);
Console::WriteLine("{0}, {1}", b->GetType(), d->GetType());
}
结果如下:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
Derived, Derived
动态 _ 转换<>()
dynamic_cast<>()
也用于将基类的实例强制转换回派生类,但是dynamic_cast<>()
只在涉及的类型是多态的时候使用,这意味着它们有一个虚函数。编译器使用这些信息来确定是否允许强制转换。如果不允许强制转换,dynamic_cast<>()
返回nullptr
。由于引用类型实际上是从System::Object
继承的,而value
类型是sealed
类型,根本不能继承,所以如果强制转换有效,它就工作;例如:
using namespace System;
ref class Base {};
ref class Derived : Base {};
ref class Cat {};
void main()
{
Base ^ b = gcnew Derived();
Derived ^ d = dynamic_cast<Derived^>(b);
Cat ^ c = dynamic_cast<Cat^>(b);
Console::WriteLine("Derived^d = {0}, Cat^c = {1}",
d!=nullptr ? d->ToString() : "nullptr",
c!=nullptr ? c->ToString() : "nullptr");
}
C:\>cl /nologo /clr:pure test.cpp
C:\>test
Derived^d = Derived, Cat^c = nullptr
在前面第十四章的示例中,我们使用了typeid
或GetType()
以及safe_cast<>()
而不是dynamic_cast<>()
,尽管dynamic_cast<>()
似乎是更准确的解决方案。
考虑以下代码:
using namespace System;
ref class Base {};
ref class Derived : Base {};
generic <typename T>
void Function(T t)
{
if(Derived ^d = dynamic_cast<Derived^>(t))
{
Console::WriteLine("success");
}
}
void main()
{
Base ^b = gcnew Derived();
Function(b);
}
如果您尝试编译它,您会收到以下错误:
C:\>cl /nologo /clr:pure test.cpp
test.cpp(9) : error C2681: 'T' : invalid expression type for dynamic_cast}
有一种情况是dynamic_cast<>()
不返回nullptr
并抛出异常——当强制转换为引用(&
或%
)时。由于只能给指针和句柄类型赋值nullptr
,而引用既不是指针也不是句柄类型,编译器需要另一种方法来报告不成功的强制转换。尝试以下代码:
ref class A
{
public:
virtual void f() {}
};
ref class B : public A
{
};
void main()
{
A a;
B %rb = dynamic_cast<B%>(a);
}
重新解释 _ 转换<>()
reinterpret_cast<>()
用于将常量转换为实例类型,并在完全不相关的指针类型之间进行转换,例如:
void main()
{
char *pVideo = reinterpret_cast<char *>(0xb8000000);
}
在不安全或低级代码中,这有时是必要的。当您使用本机编程、内联汇编和低级编码时,它会很方便。这个剧组是“你必须真正知道你在做什么”剧组。
安全 _ 强制转换<>()
safe_cast<>()
允许你做和static_cast<>()
一样的事情,除了当它不能执行转换时会引发一个异常。它也只尝试有意义的转换。它也适用于泛型类型。它是一个仅限于 C++/CLI 的变体,因为它不存在于本机 C++ 中。下面是一个safe_cast<>()
的例子:
using namespace System;
ref class Base {};
ref class Derived : Base {};
ref class Cat {};
void main()
{
Base ^ b = gcnew Derived();
Cat ^ c = safe_cast<Cat^>(b);
}
结果如下:
C:\>cl /nologo /clr:pure test.cpp
test.cpp(8) : error C2682: cannot use 'safe_cast' to convert from
'Base ^' to 'Cat ^'
No user-defined-conversion operator available, or
Types pointed to are unrelated; conversion requires reinterpret_cast,
C-style cast or function-style cast
以下示例将safe_cast<>()
用于泛型:
using namespace System;
ref class Base {};
ref class Derived : Base {};
ref class Cat {};
generic <typename T>
void Function(T t)
{
try
{
Cat ^c = safe_cast<Cat^>(t);
}
catch(Exception ^e)
{
Console::WriteLine("Exception: {0}", e);
}
}
void main()
{
Base ^ b = gcnew Derived();
Function(b);
}
结果如下:
C:\>cl /nologo /clr:pure test.cpp
test.cpp
C:\>test
Exception: System.InvalidCastException: Unable to cast object of type
'Derived' to type 'Cat'.
at FunctionT
一条蝰蛇
在本节的前面,我保证向您展示如何使用强制转换来做一些您不能用约束来做的事情——添加两个整数或浮点数。问题的出现是因为没有定义的接口声明System::Int32
有一个Add()
函数。当然,我们可以用这个功能创建自定义类型,但是最好只使用内置类型——下面是方法:
using namespace System;
generic <typename T>
where T : value class
T Add(T a, T b)
{
if(T::typeid == int::typeid)
{
int a0 = safe_cast<int>(a);
int b0 = safe_cast<int>(b);
return safe_cast<T>(a0+b0);
}
if(T::typeid == double::typeid)
{
double a0 = safe_cast<double>(a);
double b0 = safe_cast<double>(b);
return safe_cast<T>(a0+b0);
}
String ^s = String::Format("{0} is not a valid type for addition", T::typeid);
throw gcnew Exception(s);
}
int main()
{
try
{
Console::WriteLine("{0}+{1}={2}", 3, 4, Add(3,4));
Console::WriteLine("{0}+{1}={2}", 3.1, 4.2, Add(3.1,4.2));
Console::WriteLine("{0}+{1}={2}", 3.1, 4.2, Add(3.1f,4.2f));
}
catch(Exception ^e)
{
Console::WriteLine("Exception: {0}", e);
}
}
在这个代码片段中,我们使用::typeid
和safe_cast<>()
来添加我们知道如何添加的类型的实例。在这种情况下,我们可以将整数和双精度数相加。当我们试图用float
值调用Add<T>()
时,我们会生成一个异常:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
3+4=7
3.1+4.2=7.3
Exception: System.Exception: System.Single is not a valid type for addition
at AddT
at main()
以下代码版本使用模板:
using namespace System;
template <typename T>
T Add(T a, T b)
{
return a+b;
}
int main()
{
try
{
Console::WriteLine("{0}+{1}={2}", 3, 4, Add(3,4));
Console::WriteLine("{0}+{1}={2}", 3.1, 4.2, Add(3.1,4.2));
Console::WriteLine("{0}+{1}={2}", 3.1, 4.2, Add(3.1f,4.2f));
}
catch(Exception ^e)
{
Console::WriteLine("Exception: {0}", e);
}
}
如您所见,使用模板的版本要短得多。我们要求编译器对类型参数的两个实例执行 add 操作,如果不能对类型执行该操作,比如当我们试图添加System::Object
的实例时,就会得到一个编译器错误。
通用委托
委托也可以是泛型的。下面是一个简单的例子:
using namespace System;
ref struct Test
{
generic<typename T> delegate void Del(T item);
static void Notify(int i)
{
Console::WriteLine("notified...");
}
};
void main()
{
Test::Del<int> ^m1 = gcnew Test::Del<int>(Test::Notify);
m1(3);
}
在这个例子中,我们创建了一个泛型委托,并用整数类型调用它。让我们编译并执行它:
C:\>cl /clr:pure /nologo test.cpp
C:\>test
notified...
摘要
在这一章中,我对泛型做了一些深入的研究,但是你真的不知道兔子洞有多深。在第二十章中,我们将再次讨论模板和泛型,最后一次将它们分开和组合起来考虑。
在下一章,我们将看看另一种 C++ 编译时元编程子语言,预处理器。
十七、预处理器
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 是我们都喜欢的语法定义语言。
十九、多语言支持
对真善美的追求是一个活动领域,在这个领域里,我们可以一辈子做个孩子。—阿尔伯特·爱因斯坦
在这一章中,我将介绍将不同的。NET 语言以及与非托管代码的接口。我将从回顾. NET 中管理语言集成和互操作性的标准开始。
随着 Microsoft Visual Studio 2013 的到来,使用多种编程语言开发应用程序比以往任何时候都更容易。您不仅可以利用。NET 语言,但也可以用。NET 互操作性。
让我们来看看。NET 语言集成,然后继续学习。NET 互操作性。
。网络语言集成
那个。NET 平台与编程语言无关。您可以在 Visual Basic 中开发一个类,在 C# 中从它派生一个类,然后在 C++/CLI 中使用这两个类。语言集成的关键在于公共语言基础设施(CLI)、公共类型系统(CTS)和公共语言规范(CLS)标准。
CLI 标准描述了. NET. All 的执行引擎和文件格式。NET 实现遵循这个标准,尽管它们可能在不同的平台上以不同的方式实现不同的元素。
通用类型系统(CTS)是 CLI 标准的一个子集,它描述兼容 CLI 的语言可用的类型。
CLS 是 CLI 标准的子集,它描述从程序集导出的项的语言互操作性规则。它不适用于程序集内的内部实现。它影响联系类型的子集以及与这些类型相关的方法。
所有这些的实际意义在于,符合 CLI 的语言不仅可以共享数据,还可以共享数据类型,它们可以基于一种语言创建多态类型,并从另一种语言派生。
正在收集元数据
CLI 定义元数据、公共中间语言(CIL)指令集、模块和程序集。模块是元数据单元,其中包含类型描述和 CIL 中的托管代码。程序集是一个或多个模块的部署单元。程序集被打包为类库文件(DLL)或应用程序文件(EXE),它们可以选择从其他类库加载类。
语言集成通常是通过将模块限制为单一的。NET 语言,可以选择将这些模块收集到程序集中,并将结果绑定在一起进行部署。
如果一个构建块是一个完整的模块,后续代码将通过将它与其他模块集成到一个新的模块或程序集中来使用该模块。如果一个构造块是一个程序集或类库,那么后续代码将通过链接到它并在运行时将其作为 DLL 文件加载来使用它。
在下面的例子中,我们采用第一种选择——使用模块创建一个可执行文件。
跟踪学生示例
假设我们在 Visual Basic .NET 中开发了一个Student
类,这个类封装了我们需要了解的关于学生的所有管理信息。它支持IComparable<T>
接口,该接口允许我们使用通用集合类按标识号对学生进行排序。除此之外,让我们用 C# 做一个Tree
。Tree
是我们自己的一个通用集合类,用于组织任意类型:它对学生没有什么特别的了解,但也不需要。最后,让我们用一个 C++/CLI 类来结束,这个类覆盖了Tree
的树遍历方法VisitLeaf()
,将树更改为一个链表。我们将显示按身份证号排序的学生名单。
学习 Visual Basic 的学生。网
VB Student
类有一个小的Main()
程序用于单元测试。Student
类有以下重要的方法:
New()
:施工方CompareTo()
:用于实现IComparable
NewStudents()
:创建一个Student
对象数组的静态方法
下面是 VB Student
类:
Imports System
Namespace Sample
Public Class Student
Implements IComparable(Of Student)
' Constructor
Private Sub New(ByVal Id As Integer)
Me.Id = Id
End Sub
' IComparable
Public Function CompareTo(ByVal other As Student) As Integer _
Implements System.IComparable(Of Student).CompareTo
Return Me.Id.CompareTo(other.Id)
End Function
Public Shared Function
NewStudents(ByVal ParamArray Ids As Integer()) As Student()
Dim students As Student() = New Student(Ids.Length - 1) {}
Dim index As Integer = 0
For Each id As Integer In Ids
students(index) = New Student(id)
index += 1
Next
Return students
End Function
Public Overrides Function ToString() As String
Return String.Format("ID:{0}", Me.Id)
End Function
' Fields
Private Id As Integer
End Class
Module Run
Sub Main()
Dim students As Student() = Nothing
students = Student.NewStudents(5, 2, 6, 8, 10, 9, 7, 1, 3, 4)
For Each student As Student In students
Console.WriteLine(student)
Next
End Sub
End Module
End Namespace
让我们编译并运行这个:
C:\>vbc /nologo student.vb
C:\>student
ID:5
ID:2
ID:6
ID:8
ID:10
ID:9
ID:7
ID:1
ID:3
ID:4
前面的 Visual Basic 命令行将该示例编译成可执行的程序集;稍后,我们将把同一个示例编译成一个模块,用于 C# 和 C++/CLI。
C# 中的通用树类
接下来,我们研究 C# 中的Tree<T>
类。它接受实现了IComparable<T>
接口的类型。让我们稍微检查一下代码,因为这个程序看起来比实际情况差得多。Tree<T>
类有一个名为Leaf
的嵌套类。每个Leaf
不仅包含一个数据元素,还包含对树的左右分支的引用。Leaf
包含以下方法:
CompareTo()
:工具IComparable
operator>=
和operator<=
:CompareTo()
方法的快捷键Leaf()
:施工方ToString()
:显示Leaf
中包含的数据元素
Tree<T>
类是实现IComparable<T>. Tree<T>
的类型的一般集合,它对Student
类一无所知。它在嵌套类Leaf. Tree<T>
的实例中存储通用类型T
的数据项,有以下公共方法:
Add()
:Add()
方法有两个重载:一个向树中添加单个数据项;另一个添加数据项的数组。这个方法看起来有点复杂,因为它很长,但是实际上,它所做的只是向下遍历树,寻找一个存储新的Leaf
的地方。Inorder()
:该方法使用有序算法遍历树;这个算法访问左边的孩子,当前的叶子,最后是右边的孩子。基于我们将树叶添加到树中的方式,该算法将按照应用于两个数据实例的IComparable<T>
的结果所定义的顺序访问所有树叶。VisitLeaf()
:每次访问一片叶子时,这个方法被Inorder()
调用。目前实现的是使用Leaf.ToString()
将叶子写到控制台。这个方法很重要,因为我们将在 C++ 类中覆盖它来保存叶数据,而不是将其写入控制台。
Tree<T>
的代码如下:
using System;
using System.Collections.Generic;
namespace Sample
{
public class Tree<T> where T : IComparable<T>
{
public class Leaf
{
public Leaf left = null;
public Leaf right = null;
public T data;
public Leaf(T data)
{
this.data = data;
}
public static bool operator>=(Leaf lhs, Leaf rhs)
{
return lhs.data.CompareTo(rhs.data) >=0;
}
public static bool operator<=(Leaf lhs, Leaf rhs)
{
return lhs.data.CompareTo(rhs.data) <= 0;
}
public override string ToString()
{
return data.ToString();
}
}
public Leaf root = null;
public void Add(T[] adata)
{
foreach(T data in adata)
{
Add(data);
}
}
public void Add(T data)
{
Leaf leaf = new Leaf(data);
if(root == null)
{
root = leaf;
}
else
{
Leaf current = root;
for(;;)
{
if(current >=leaf)
{
if(current.left == null)
{
current.left = leaf;
break;
}
else
{
current = current.left;
}
}
else
{
if(current.right == null)
{
current.right = leaf;
break;
}
else
{
current = current.right;
}
}
}
}
}
public virtual void VisitLeaf(Leaf leaf)
{
Console.WriteLine(leaf);
}
private void DoInorder(Leaf leaf)
{
if(leaf==null)
{
return;
}
DoInorder(leaf.left);
VisitLeaf(leaf);
DoInorder(leaf.right);
}
public virtual void Inorder()
{
DoInorder(root);
}
}
class Test
{
public static void Main()
{
Tree<int> tree = new Tree<int>();
tree.Add(3);
tree.Add(1);
tree.Add(5);
tree.Inorder();
}
}
}
最后一个类Test
只是用于单元测试;它有一个静态公共方法Main()
,所以让我们如下编译并运行它:
C:\>csc /nologo tree.cs
C:\>tree.exe
1
3
5
同样,csc
命令行将样本编译成汇编可执行文件;稍后,我们将把同一个示例编译成一个模块,用于 C# 和 C++/CLI。
在 C++/CLI 中收集片段
我们的 C++ 应用程序的目标是创建一个按标识号排序的学生链表。Tree<T>
类中的Inorder()
方法可以按顺序遍历树,但是它的副作用是用VisitLeaf()
方法在控制台上显示学生。我们可以通过从Tree<T>
派生链表类并覆盖VisitLeaf()
方法来利用这一点。由于在遍历树时按排序顺序为每个元素调用了VisitLeaf()
,我们可以在派生类中重写VisitLeaf()
以将元素添加到链表中。我们在链表中使用System::Collections::Generic
中的LinkedList<T>
。
由于嵌套类Tree<T>::Leaf
,这个类也是跨语言使用泛型处理嵌套类的一个很好的例子。
下面是 C++ 代码,它使用并结合了 C# 和 VB:
#using "System.dll"
using namespace System;
using namespace Collections::Generic;
using namespace Sample;
generic <typename T>
where T : IComparable<T>
ref struct LList : public Tree<T>
{
LinkedList<T> list;
virtual void VisitLeaf(Leaf^ leaf) override
{
list.AddLast(leaf->data);
}
virtual void Dump()
{
for each(T t in list)
{
Console::WriteLine(t);
}
}
};
void main()
{
array<Student^>^ students = Student::NewStudents(25, 46, 34, 12, 1);
LList<Student^>^ ll = gcnew LList<Student^>();
ll->Add(students);
ll->Inorder();
ll->Dump();
}
这个类比其他两个简单得多;LList<T>
只有两种显著的方法:
VisitLeaf()
:覆盖Tree<T>::VisitLeaf()
将数据项添加到我们的链表中,而不是像基类方法那样显示它Dump()
:在控制台上显示整个链表
这一次,我们想为 VB 和 C# 创建模块。我们将使用这些模块编译 C++ 代码。我们可以编译并运行完成的程序:
C:\>vbc /nologo /target:module /out:student.netmodule student.vb
C:\>csc /nologo /target:module /out:tree.netmodule tree.cs
C:\>cl /nologo /clr:pure /FUstudent.netmodule /FUtree.netmodule test.cpp
C:\>test
ID:1
ID:12
ID:25
ID:34
ID:46
注意,我们使用/FU
(强制使用)来添加对 C# 和 VB 模块的引用;使用 force using 相当于在源代码本身内部添加了一个#using
语句。
使用 IDE
核心 IDE 项目系统仅支持使用自定义构建步骤的模块。在 Visual C++ 中,您也可以使用/LN
命令行选项来实现这一点,您可以在项目的属性中设置该选项。为了利用 IDE 的强大功能,让我们稍微修改一下我们的应用程序。我们将创建三个项目:一个用 VB,一个用 C#,一个用 C++。
我们将 VB 和 C# 目标设置为类库。确保 VB 项目属性页(Application 下)中的根命名空间为空,否则整个 VB 应用程序将隐藏在根命名空间中。
在 C++ 项目中添加对 VB 和 C# 项目的引用。这自动为 C++ 项目创建了对其他两个的依赖。此外,确保 C++ 项目是启动项目。
应该可以了。这将创建一个在运行时加载 VB 和 C# 类库 dll 的可执行文件。虽然您的应用程序将由三个不同的程序集组成,一个 EXE 和两个 dll,而不仅仅是上一个示例中的一个 EXE 程序集,但结果将与以前相同。
。NET 语言集成摘要
好吧,从三种不同的语言中收集代码并不难,不是吗?您确实需要对每种语言有一个基本的了解来完成它,因为您需要能够跨语言映射类声明和方法声明,但是一旦您对每种语言有了基本的了解,这并不是非常困难的。如果你遇到困难,有几个公开可用的语言工具,包括。NET Reflector,可以帮你解决基本的语言差异。
与本机代码的互操作性并不简单,但对于 C++/CLI 程序来说却很简单。使用语言集成,我们还可以使用 C++/CLI 作为通向本机代码的桥梁,并轻松地将 C++/CLI 模块合并到 C# 或 VB 代码中。
。NET 互操作性
互操作性,简称为 InterOp,是. NET 应用程序与非托管或本机代码连接的能力。
“当然,”您说,“这对于遗留代码开发很重要,但是随着这么多开发发生在。网方,真的对我有影响吗?”
如今,你可能很容易被误导,认为本机代码不再重要。让我现在澄清一下——本地开发至关重要。
当我在新西兰 TechEd 2006 上发表关于 C++ 的演讲时,Visual Studio 产品营销经理 Jason McConnell 建议我使用下面的幻灯片:
Windows = Native + Managed
好消息是,当你使用 C++ 时,API 是托管的还是本地的并不重要。用 C++ 做 InterOp 极其容易;这是语言中固有的。即使开发人员将 C++ 作为特定项目的主要开发平台,他们仍然依赖托管 C++ 模块或程序集来调用本机 API。他们也依靠。NET 语言集成将托管 C++ 绑定到它们的 C# 或 VB 程序集。
在 C# 中,就没这么简单了。您需要遵循某种机制,如平台调用或 COM 互操作,并注意在托管代码和本机代码之间正确地封送或转换数据。此外,C++ 头文件包含必须传递给本机 API 的参数定义,在 C# 中不可用,因此您必须定义并行构造。如果 API 发生变化,您的代码就会中断。
C++ 有内置的互操作性,所以你可以直接访问你所有的本地代码和 API。原生 C++ 头文件可以直接在 C++/CLI 中使用,这样您就不必经历一个容易出错的步骤来将任何内容翻译成不同的语言,并且如果 API 发生变化,使用更新的头文件重新编译您的代码会将您的代码更新到新的 API。同样重要的是,C++ 知道本机类型和托管类型,所以封送是自动的。C++/CLI 的设计使得本机类型成为语言的自然组成部分。例如,int
既可以被视为本机类型,也可以被视为System::Int32
的实例。两种范式都受支持。C++ 也知道托管字符串和字节或字符数组之间的区别。这是语言的一部分。
让我们来看一些 C# 和 C++ 中的互操作的例子。
等待哔哔声
让我们挑选一个非常简单的原生 Windows 调用,MessageBeep()
。根据 MSDN 定义MessageBeep()
播放一段波形声音。每种声音类型的波形声音由注册表中的条目标识。MessageBeep()
代码如下:
BOOL MessageBeep(
UINT uType
);
因素
对于参数,MessageBeep()
采用声音类型,由注册表中的条目标识:
uType
该输入参数可以是表 19-1 中显示的值之一。
表 19-1。
Possible uType Parameter Values
| 价值 | 意义 | | --- | --- | | `–1` | 简单的哔哔声。如果声卡不可用,则使用扬声器产生声音。注意,这个值在函数中被解析为`0xFFFFFFFF`。 | | `MB_ICONASTERISK` `0x00000040L` | `SystemAsterisk` | | `MB_ICONEXCLAMATION` `0x00000030L` | `SystemExclamation` | | `MB_ICONHAND` 0 `x00000010L` | `SystemHand` | | `MB_ICONQUESTION` `0x00000020L` | `SystemQuestion` | | `MB_OK` `0x00000000L` | `SystemDefault` |现在让我们看看如何从 C# 调用MessageBeep(ICONEXCLAMATION)
。
C# 平台调用
平台调用,简称 P/Invoke,是 C# 调用本机代码的主要方法。我们可以通过在 C# 中声明MessageBeep()
函数并使用 C# 语言特性定义参数定义来使用 P/Invoke。名为 www.pinvoke.net
的第三方网站非常有用,可以用来获取在 Windows API 调用中使用 P/Invoke 所需的所有信息。如果您正在与其他本机代码进行交互,那么您必须自己去发现所有这些。当你这么做的时候,记住调用本机代码的方法在 C++ 中都是内置的和自动的。
下面是MessageBeep()
的 C# 代码:
using System.Runtime.InteropServices;
class Test
{
public enum beepType
{
Beep = -1,
OK = 0x00,
Question = 0x20,
Exclamation = 0x30,
Asterisk = 0x40,
}
[DllImport("User32.dll", ExactSpelling=true)]
static extern bool MessageBeep(uint type);
public static void Main(string[] args)
{
MessageBeep((uint)beepType.Exclamation);
}
}
这个例子有几个地方令人不安。首先,我们必须为传递给MessageBeep()
的参数创建一个enum
。这本身就容易出错,因为现在 API 有两种不同的定义:最初的定义在User32.dll
中,声明在 C++ 头文件中,而我们的副本在这里。
接下来,我们必须通过将我们的参数强制转换为一个uint
以传递给MessageBeep()
来显式地整理数据。任何时候进行造型,都有隐藏不该隐藏的东西的风险,这种情况也不例外。
在这种情况下,您会发现一个危险信号,即在MessageBeep()
API 定义中有一个 bug 我们在 API 中发现了Beep
的有符号/无符号不匹配。API 期望一个无符号整数,以及一个标准哔哔声的–1
。MSDN 上有一个注释,在这种情况下使用了0xFFFFFFFF
而不是–1
,但它仍然指出了试图在 C# 中使一些实际上不干净的东西变得干净是徒劳的。
通过将enum
定义更改为以下内容,可以尝试做一点小小的改进:
public enum beepType : uint
如果我们现在编译它,我们会看到
C:\>csc /nologo test.cs
test.cs(6,23): error CS0031: Constant value '-1' cannot be converted to a 'uint'
我们可以着手修改我们的 C# 代码来使用0xFFFFFFFF
而不是–1
,但是这只会导致我们进一步偏离已发布的 API,使得我们的代码更难维护。
C++ 内置支持
下面是本机 C++ 中的代码:
#include "windows.h"
int main()
{
MessageBeep(MB_ICONEXCLAMATION);
return 0;
}
它非常干净,使用 Windows 头文件定义,并使用发布的 API 参数定义调用 API。不需要参数强制转换或enum
,代码自行维护。
为了编译它,您需要输入以下内容:
cl /nologo test.cpp user32.lib
注意,user32.lib
被添加到命令行,因为这是 Windows 中MessageBeep()
的位置。在原生 C++ 中也有一种使用 DLL 导入样式属性引入库的方法,但是将它添加到命令行或项目中是标准的做法。
您可能发现自己对从原生 C++ 调用MessageBeep()
是多么容易没有印象。毕竟,它是一个原生 API,所以它会被无缝支持是非常直观的。看看下面的 C++/CLI 应用程序:
#include <windows.h>
using namespace System;
int main()
{
MessageBeep(MB_ICONEXCLAMATION);
Console::WriteLine("Did you hear the beep?");
return 0;
}
我们用/clr
命令行选项编译它:
C:\>cl /nologo /clr test.cpp user32.lib
C:\>test
Did you hear the beep?
现在,这很容易。你所要做的就是添加/clr
选项,添加你的管理呼叫,它就工作了。产生了本机代码和托管代码的混合。
使用 Visual C++,您可以保留现有代码并添加托管功能。您向托管代码的迁移会根据需要逐渐自然地发生,没有人会被迫接受新的范例。毕竟,唯一比遗留代码更难更新的是遗留程序员。
让我们再次回顾一下托管代码和本机代码的编译器选项:
/clr:safe
:这将生成一个仅 IL 可验证的输出文件,并且只能用于托管类型和托管代码。/clr:pure
:这将生成一个仅包含 IL 的输出文件(没有本机可执行代码),并且只能用于托管和本机类型以及托管代码。- 这产生了本地和 IL 输出的混合。允许托管和本机类型以及托管代码和本机代码。
<default>
:没有指定选项,所以程序编译为本机执行。
有关直观表示,请参考图 19-1 。
图 19-1。
/clr command line options
使用 C++ 实现 C# 互操作
在这一节中,我将重点介绍使用 C++/CLI 作为 C# 和本机 C++ 之间的转换层是多么容易。
在 C++ 类中包装 MessageBeep
让我们从创建一个 C++ /clr
应用程序开始,该应用程序将MessageBeep()
包装在一个引用类型中,以便在 C# 中使用:
#include <windows.h>
using namespace System;
using namespace System::Runtime::InteropServices;
public ref struct Beep
{
enum struct BeepTypes : unsigned int
{
Beep = (unsigned int) -1,
Asterix = MB_ICONASTERISK,
Exclamation = MB_ICONEXCLAMATION,
Hand = MB_ICONHAND,
Question = MB_ICONQUESTION,
OK = MB_OK,
};
static void MessageBeep(enum class BeepTypes beep)
{
::MessageBeep((unsigned int)beep);
}
};
void main()
{
Beep::MessageBeep(Beep::BeepTypes::Exclamation);
}
我们可以编译并运行如下代码:
C:\>cl /nologo /clr beep.cpp user32.lib
C:\>beep
可惜这本书不是写在“声音开”或类似的东西,所以你可以听到哔哔声。
您可能会注意到,在 C# 示例中,我为 beep 创建了一个托管的enum
,但是这一次,我能够利用 Windows 中的 C++ 头文件定义,并且只需要显式地将基本 beep 定义为–1
。这使得代码更能适应 API 的可能变化。
在 C# 中使用包装类
让我们创建一个使用包装的 C++/CLI 类的简单 C# 程序:
class Test
{
public static void Main()
{
Beep.MessageBeep(Beep.BeepTypes.Exclamation);
}
}
如你所见,这是非常紧密和干净的。管理本机转换和接口差异的困难隐藏在 C++ 部分中。
让我们编译并运行这个最后的程序。再次注意听哔哔声,就像一只手拍手的声音。
C:\>cl /nologo /LD /clr beep.cpp user32.lib
C:\>csc /nologo /r:beep.dll test.cs
C:\>test
在这种情况下,我们创建一个混合的特定于处理器的程序集beep.dll
和一个与处理器无关的程序集test.exe
,它们一起执行托管/本机调用。
摘要
C# 和原生 C++ 之间的切换不是一件小事,但是 C++/CLI 在两者之间形成了一个天然的桥梁。此外,由于 C++/CLI 与 C# 一样是托管语言,所以完全用 C++ 开发应用程序实际上是一个极其简单方便的解决方案。
此外,。NET 语言集成使得用不同语言编写的模块和程序集的连接变得简单。为了连接到其他语言,需要重写代码或遵循带有编组环和障碍的限制性 API 的日子已经一去不复返了。CTS 和 CLS 标准确保了这一点。NET 语言以兼容的方式传递数据。
全部。NET 程序的目标是相同的平台 CLI 标准中定义的 IL。换句话说,所有。NET 编译器共享相同的目标语言,运行时和 JIT 不知道它是在运行从 C#、C++/CLI,甚至是 VB.NET 生成的 IL。VES 读取并解释由编译器产生的二进制元数据,并执行程序。
原生 C++ 是一个不同的故事。它由处理器准备执行的实际机器代码组成。在本机代码和托管代码之间进行转换的中间层受运行时支持,并且是透明集成的。如果你问我,我会说这一切都做得相当好。
在下一章,也是最后一章,我将通过补充一些细节和建议进一步研究的方向来结束这本书。
二十、最后的想法
我们被告知,悲观主义者看着一个装有 50%空气和 50%水的杯子,看到的是半空的。相比之下,乐观主义者认为它是半满的。当然,工程师们明白玻璃是它需要的两倍大。——鲍勃·刘易斯,信息世界
这一章让我想起了感恩节,据说这完全是为了填饱肚子——这一章的目标是在这本书结束之前尽可能多地塞进信息。
嗯,你不觉得这太落后了吗?当一个作者把该说的都说了的时候,你会认为这本书已经完成了。有几个原因说明这对于一本关于 C++ 的书来说是不正确的,更不用说在出版业中是正确的了。在一本书可以出版之前,你需要在最后一章的结尾点上每一个“我”和每一个“t”。
这里的问题是 C++ 几乎是无限的。语言的特征是有限的,但是范例和实践是无限的。C++ 是一种出色的通用语言。因为 C++ 是如此的通用、通用和无限制,所以有些地方在本书的其他地方不太合适,但是很容易理解。在这一章中,我挑选了一些比较重要的;其中包括标准的 dispose 模式、函数对象、伪模板特殊指针、可变、常量和可变数据的关键字,以及关于 IntelliSense 的一些最终想法。本章并不打算包含所有这些概念,只是介绍它们。
该开始填馅了。
标准处置模式
为了确保非托管资源在不再使用时被释放,在。NET 编程,您需要遵循标准的 dispose 模式。这种模式使用System::IDisposable
接口,允许程序员启动对象销毁,同时与垃圾收集线程一起工作,以确保遵循这种模式的每个对象都被销毁一次。
虽然在 C# 中实现标准的 dispose 模式很简单,但在 C++ 中实现更容易,因为 C++ 通过确定性销毁直接支持这种模式,并且支持与对象释放相关的两个特殊成员函数:析构函数和终结函数。
析构函数和终结函数
在 C# 和 C++ 中,析构函数是一种特殊的成员函数,当不再需要某个对象时就会调用它。在 C# 中,析构函数是对System.Object
的Finalize()
方法的重写。在垃圾回收期间,垃圾回收器例程不确定地调用此方法。此方法的执行是不确定的,因为您无法控制何时释放对象的资源。在 C# 中,标准的 dispose 模式为您提供了一种在使用完对象后立即释放对象资源的方法。
控制对象的发布对于本机代码和托管代码都很重要。如果您依赖垃圾收集器来释放您的托管资源,您会在本机代码中遇到麻烦,因为您不仅无法控制何时执行垃圾收集,而且根本无法保证垃圾收集会被执行。您的托管代码可能正在做它需要做的事情,而您的本机代码由于缺乏资源而嘎然而止。
在托管代码中,内存是为您管理的,所以这不是问题。但是,如果您创建了一个锁定文件供独占使用的对象,该怎么办呢?如果您将发布代码放在对象的析构函数中,您需要确保析构函数被及时调用,否则依赖该文件的其他进程将会停止。
标准的 dispose 模式会为您处理这个问题。它为您提供了一个在使用完对象时显式调用的方法。如果你创建了一个using
块,这个方法可以被隐式调用,或者在一个try finally
块中被显式调用。此外,通过处理在调用销毁对象之前调用垃圾收集终结例程的可能性,以及通过禁止在之后调用终结例程,它考虑到了垃圾收集的确定性。
在 C++ 中,也支持标准的 dispose 模式,但它是通过支持析构函数和终结器以及对象的确定性析构来直接在语言中实现的。C++ 中的确定性销毁为每个分配的对象提供了一个隐式的嵌套 C# using
块。
在 C++ 中,析构函数是一个特殊的成员函数,当在堆栈上分配的对象超出范围时,或者当在对象的指针或句柄上调用delete
关键字来释放对象的资源时,都会调用这个函数。当在托管类上使用析构函数时,它实现了IDisposable
接口的Dispose()
方法。
终结器是一个特殊的成员函数,它覆盖了System.Object.Finalize()
方法;它相当于 C# 的析构函数。析构函数和终结器的 C++/CLI 定义允许您隐式实现标准的 dispose 模式。
用 C# 实现
在 C# 中,标准的 dispose 模式类似于下面的代码:
using System;
class R : IDisposable
{
R()
{
}
∼R()
{
Dispose(false);
}
public void Dispose()
{
GC.SuppressFinalize(this);
Dispose(true);
}
protected virtual void Dispose(bool disposing)
{
if(disposed_)
{
return;
}
if(disposing)
{
Console.WriteLine("Free managed resources");
}
Console.WriteLine("Free unmanaged resources");
disposed_ = true;
}
private bool disposed_ = false;
public static void Main()
{
using(R r = new R())
{
;
}
}
}
如您所见,实现起来并不简单。您需要从System.IDisposable
中派生出您的类,并通过定义Dispose()
方法来实现这个接口。Dispose(bool)
方法用于区分处理对象的直接调用(比如由using
块生成的调用)和间接调用(比如在垃圾收集期间生成的调用)。
理解这段代码有几个要点:
- 使用一个本地标志,比如前面例子中的
disposed_
,确保资源不会被错误地释放多次。 - 在示例代码中,通过从
using
块对Dispose
的直接调用和从垃圾收集器的间接调用来调用的代码是不同的。直接调用产生Dispose(true)
调用,间接调用产生Dispose(false)
调用。 - 如果对象的释放发生在垃圾回收期间,则不应释放与该对象关联的任何托管资源。因此
Dispose(false)
不会释放被管理的资源。 - 当直接调用
Dispose()
时,比如在using
块的末尾,必须通知垃圾收集器垃圾收集是不必要的,应该被抑制。这是通过调用GC.SuppressFinalize(this)
完成的。
C++/CLI 中的实现
在 C++/CLI 中,IDisposable
组件是由编译器自动生成的。C++/CLI 中的示例如下:
using namespace System;
ref struct R
{
R()
{
disposed_ = false;
}
!R()
{
Console::WriteLine("Free unmanaged resources");
}
∼R()
{
GC::SuppressFinalize(true);
if(!disposed_)
{
disposed_ = true;
Console::WriteLine("Free managed resources");
this->!R();
}
}
static void Main()
{
R r;
}
private:
bool disposed_;
};
void main()
{
R::Main();
}
C++/CLI 示例更容易理解和维护。你确实需要独立于声明在构造器中初始化成员变量disposed_
,但是你已经在第六章中看到了与 C# 的区别。
C++ 析构函数包含了等效的Dispose(true)
,C++ 终结器包含了Dispose(false)
。代码自动实现了IDisposable
接口,在方法R::Main()
中的堆栈上分配R
确保了它在方法完成执行时被销毁。这要归功于 C++ 语言中的确定性销毁。
让我们看看这个用反编译的 C++ 例子。网状反射器:
private ref class R : public IDisposable
{
// Methods
private:
void !R() {}
public:
R() {}
private:
void ∼R() {}
public:
virtual void Dispose() sealed override {}
protected:
virtual void Dispose(bool ) {}
protected:
virtual void Finalize() override {}
public:
static void Main() {}
// Fields
private:
bool disposed_;
};
如您所见,编译器自动实现了IDisposable
接口。让我们编译并执行 C++ 示例;C# 示例生成相同的结果:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
Free managed resources
Free unmanaged resources
为了进一步研究
这个对IDisposable
的介绍只是展示了冰山一角。微软的项目经理乔·达菲(Joe Duffy)已经就这个话题写了一篇相当详尽的论文。你可以在 www.bluebytesoftware.com/blog
找到,方法是在他博客的“按类别浏览”部分的“设计指南”类别中,搜索“处置、终结和资源管理”
功能对象
函数对象允许对象像函数一样被调用。当您遇到一个解决方案需要比函数多一点但比类少一点的问题时,这很有用。
例如,假设您有一个将数据从数据存储区读入缓冲区的函数。理想情况下,您应该将指向缓冲区的指针传递给该函数,该函数会自动更新您的指针,使其超过数据的末尾,然后该指针将为后续调用做好准备。这种方法的缺点是,您总是将同一个指针变量传递给函数。如果指针变量像 object 一样在函数内部,效率会更高。
在 C++ 中,函数对象通过使用operator()
向常规对象授予函数语法来解决这个问题。
一个常见的例子是斐波那契数生成器,它跟踪生成序列中下一个数所需的两个整数。实现如下:
using namespace System;
ref struct Fibonacci
{
int f0;
int f1;
Fibonacci()
{
f0=0;
f1=1;
}
int operator()()
{
int temp = f0+f1;
f0 = f1;
f1 = temp;
return temp;
}
};
void main()
{
Fibonacci fib;
for(int i=0; i<10; i++)
{
Console::Write("{0} ", fib());
}
Console::WriteLine();
}
让我们编译并运行这个:
C:\>cl /nologo /clr:pure test.cpp
test.cpp
C:\>test
1 2 3 5 8 13 21 34 55 89
在前面的例子中,使用函数语法fib()
将fib
对象作为伪函数调用,并用于生成序列中的下一个斐波那契数。
特殊指针
C++/CLI 支持两种特殊类型的指针来引用托管堆上的数据。因为托管堆上的数据可能会在没有警告的情况下移动,所以当数据移动时,您需要使用称为内部指针的跟踪指针来更新指针,或者使用称为固定的过程来防止托管堆上的数据移动。内部指针设计用于托管代码中;对于本机代码,我们不能跟踪对象,我们必须首先防止它们移动。这就是牵制的用武之地。
内部指针
内部指针是能够引用托管或本机数据的指针。它们是定义能够引用堆栈、托管堆和本机堆上的数据的单个指针对象的理想选择。
在本例中,我们在托管堆上创建一个托管数组,并在本机堆上声明一个本机数组,然后通过将引用传递给函数来隐式创建一个内部指针。
using namespace System;
int native_array[] = {120, 24, 6, 2, 1};
void Show(interior_ptr<int> ptr, int length)
{
for(int i=0; i<length; i++, ++ptr)
{
Console::Write("{0} ", *ptr);
}
Console::WriteLine();
}
void main()
{
array<int> ^managed_array = {1,2,6,24,120};
Show(&managed_array[0], managed_array->Length);
Show(&native_array[0], sizeof(native_array)/sizeof(int));
}
让我们编译并运行这个:
C:\>cl /nologo /clr:pure test.cpp
C:\>test
1 2 6 24 120
120 24 6 2 1
在前面的例子中,我还使用了sizeof()
来计算原生数组中元素的数量,因为原生数组不是System::Object
的后代,因此没有一个名为Length
的成员允许您访问数组的长度。
Note
内部指针必须在堆栈上分配。如果您尝试在托管堆或本机堆上分配一个,就会看到语法错误。
锁定指针
就像在 C# 中一样,可以使用一个称为固定的过程来临时固定托管堆上某个项的位置。这通常不是一个好主意,因为它可能会使托管堆碎片化,并对应用程序的性能产生负面影响。尽管如此,通常还是需要固定一个对象,以便将对象的地址传递给本地 API。
尽可能避免固定,并使用临时对象将数据传输到本机 API。当这不可能时,特殊的锁定指针和确定性销毁使得 C++ 中的锁定变得容易。
在 C++ 中,通过使用pin_ptr<T>
特殊指针创建一个固定对象。C++ 支持的范例是,当创建pin_ptr
时锁定对象,当销毁pin_ptr
时解除锁定。用花括号范围操作符包围pin_ptr
的用法很方便。
一个例子可能是说明性的。这个示例使用托管和非托管#pragma
指令在单个源文件中混合本机代码和托管代码。该代码创建一个类的实例,固定该类的一个元素,并使用非托管代码修改它。当pin_ptr
超出范围时,该项会自动取消固定。然后使用Console::WriteLine()
显示更改后的类别:
using namespace System;
#pragma unmanaged
void change(int *ptr)
{
*ptr = 3;
}
#pragma managed
ref struct R
{
R()
{
i = -1;
}
int i;
};
void main()
{
R ^ r = gcnew R();
{
pin_ptr<int> p_int = &r->i;
change(p_int);
}
Console::WriteLine(r->i);
}
现在让我们试一试:
C:\>cl /nologo /clr test.cpp
C:\>test
3
注意,由于这个文件中的本机代码,我们需要使用/clr
而不是/clr:safe
或/clr:pure
。使用pin_ptr
伪模板的一个常见错误是将跟踪句柄固定到对象上,而不是对象本身。在前面的代码示例中,请注意,我们将锁定指针分配给对象R::i
内部的数据,而不是将句柄分配给对象r.
以下代码不正确;会编译吗?
using namespace System;
ref struct R
{
R()
{
i = -1;
}
int i;
};
void main()
{
R ^ r = gcnew R();
{
pin_ptr<R^> p_r = &r;
Console::WriteLine("R is not pinned");
}
}
记住,在 C++ 中,代码编译了,并不代表它就是正确的。
重新审视模板
模板是 C++ 的一个丰富而复杂的领域,在第十五章中我们实际上只触及了它们应用的表面。我提到过,使用泛型很难将功能添加到内置类型中。这是合乎逻辑的,因为您可能希望向无符号类型添加一个方法,以二进制字符串的形式返回其值。我们的第一个尝试是用基类做一些事情。对于模板,基类可以是模板参数,如下所示:
ref struct Base
{
};
template <typename T> ref struct Wrapper : T
{
};
public ref struct Test
{
static void Main()
{
Wrapper<Base> ^b = gcnew Wrapper<Base>();
}
};
void main()
{
Test::Main();
}
不幸的是,这个技巧不会帮助你为内置类型添加功能,因为内置类型被声明为sealed
,不能用作基类。要让一个基类支持一个定制的接口,你必须稍微聪明一点。
以下简单的模板代码允许您接受内置或用户定义的类型,为其定义一个接口,并使用此接口和泛型类型来完成数学平方运算:
using namespace System;
generic <typename T>
interface class MyMath
{
T Multiply(T lhs, T rhs);
};
generic <typename T>
where T : MyMath<T>, ref class
void square(T t, int N)
{
while(--N > 0)
{
t = t->Multiply(t,t);
}
Console::WriteLine(t);
}
template <typename T>
ref struct Container : MyMath<Container<T>^ >
{
T value;
Container(T t)
{
value = t;
}
virtual Container<T> ^Multiply(Container<T> ^lhs, Container<T> ^rhs)
{
return gcnew Container<T>(lhs->value * rhs->value);
}
virtual String ^ToString() override
{
return value.ToString();
}
};
void main()
{
Container<int> ^r = gcnew Container<int>(2);
square(r,4);
}
我建议将 David Vandevoorde 和 Nicolai M. Josuttis 的《C++ 模板:完整指南》作为模板教育的下一站(Addison-Wesley Professional,2002)。
类层次陷阱
在 CLI 中,一个类可以实现多个接口,但它仅限于单个基类。这使得访问基类成员的 C# 语法非常逻辑和直观。您只需使用base
关键字,这就很明确了:
using System;
public class B
{
public void method()
{
Console.WriteLine("B method");
}
}
public class A : B
{
public new void method()
{
base.method();
}
public static void Main()
{
A a = new A();
a.method();
}
}
让我们编译并运行这个:
C:\>csc /nologo test.cs
C:\>test
B method
下面是 C++ 的等价形式:
using namespace System;
public ref struct B
{
void method()
{
Console::WriteLine("B method");
}
};
public ref struct A : B
{
void method() new
{
B::method();
}
static void Main()
{
A ^a = gcnew A();
a->method();
}
};
void main()
{
A::Main();
}
现在这看起来很自然,但是如果你在层次结构的中间添加一个新类,在 C# 中会发生什么呢?
using System;
public class B
{
public void method()
{
Console.WriteLine("B method");
}
}
public class D : B
{
public new void method()
{
Console.WriteLine("D method");
}
}
public class A : D
{
public new void method()
{
base.method();
}
public static void Main()
{
A a = new A();
a.method();
}
}
如果您现在尝试编译并运行它,您会得到不同的结果。这是因为A
方法显式调用了基类方法,而A
现在有了不同的基类。
csc /nologo test.cs
C:\>test
D method
在标准 C++ 中,没有引用基类的base
关键字。所有对base
类的引用都显式命名基类,这可能会产生不同的结果。如果我们现在在 C++ 层次结构中插入一个新类,结果不会改变:
using namespace System;
public ref struct B
{
void method()
{
Console::WriteLine("B method");
}
};
public ref struct D : B
{
void method() new
{
Console::WriteLine("D method");
}
};
public ref struct A : D
{
void method() new
{
B::method();
}
static void Main()
{
A ^a = gcnew A();
a->method();
}
};
void main()
{
A::Main();
}
C:\>cl /nologo /clr:pure test.cpp
test.cpp
C:\>test
B method
如前所述,C++ 的强大之处在于它的灵活性和多功能性。如果程序员希望获得与上面的 C# 代码片段相同的结果,她可以使用__super
关键字,这是一个 Microsoft Visual C++ 扩展,其行为与 C# base
关键字完全相同。如果我们把A::
的定义method
改成如下:
void method() new
{
__super::method();
}
编译和执行,我们观察到完全相同的结果:
C:\>cl /nologo /clr:pure test.cpp
test.cpp
C:\>test
D method
类型别名(typedef)
在 C# 中,可以使用using
语句为类型名创建快捷方式或别名。例如,考虑以下几行:
using Hello = System.Console;
public class R
{
public static void Main()
{
Hello.WriteLine("Hello, World");
}
}
如果我们编译并运行这个例子,我们会得到
C:\>csc /nologo test.cs
C:\>test
Hello, World
在 C++ 中,typedef
是类型定义的别名。在原生 C++ 中扮演着极其重要的角色,因为类型声明可能会变得极其复杂。由于简化的类型结构,它们在 CLI 编程中的作用较小。
通过在声明前添加关键字typedef
来创建typedef
。声明中的标识符成为该类型的别名。
当您维护不同类型的对象列表时,您可能通常会使用typedef
。也许你需要记录学生完成的所有作业;你可能有一个CEssay
、CArtwork
、CMidterm
等等。
在 CLI 编程中,所有对象都是从System::Object
继承而来,因此很自然地创建一个对这个基类的引用列表,如第十四章中所述。在本机代码中,可能没有一个公共基类,您可能会将一个对象描述为指向void
的指针或指向某个定义头的指针(例如,每个对象中的第一个整数可能会确定类型),并在以后使用一些其他机制将其转换回原始数据类型。
如果我们要使用一个指向void
的指针,创建一个新的数据类型会更好,而不是总是需要将对象称为void*
。
下面是一个例子:
typedef void * pvoid_t;
在这种情况下,标识符pvoid_t
用于引用指向数据类型void
的指针。还记得第九章结尾那个龌龊的例子吗?看看使用typedef
有多简单:
using namespace System;
ref struct R;
typedef R ^ (**(*(*pDEF)(int, char))[])(int);
void main()
{
pDEF p;
}
老友记
本机 C++ 和 CLI 具有不同的可见性和可访问性范例。CLI 定义了一种方法,通过允许在程序集中访问,您可以授予对层次结构树之外的类的访问权限。
在 C++ 中,你也可以使用friend
关键字授予一个类对另一个类或函数的特定访问权。顾名思义,一个friend
类被允许访问,否则在类层次结构之外或者甚至在类本身之外会被拒绝。这里有一个简单的例子:
using namespace System;
class CPlusPlusModule
{
friend class CSharpModule;
static int CSharpModuleCount;
};
int CPlusPlusModule::CSharpModuleCount=1;
struct CSharpModule
{
int MyCount;
CSharpModule()
{
MyCount = CPlusPlusModule::CSharpModuleCount++;
}
};
void main()
{
CPlusPlusModule cpp;
CSharpModule cs0;
CSharpModule cs1;
Console::WriteLine(cs1.MyCount);
}
在这个例子中,CPlusPlusModule
有一个私有的静态成员CSharpModuleCount
,用于跟踪其他类的实例化,特别是CSharpModule
。如果没有CPlusPlusModule
中的friend
声明,CSharpModule
的实例将无法访问其私有数据。这个例子展示了friend
声明是如何工作的,以及 C# 模块是如何与 C++ 模块友好地集成在一起并紧密互操作的。NET 环境。
易变数据
C++ 中的关键字volatile
与 C# 中的关键字有着相似的含义。它表示某个字段可能在幕后被另一个线程或进程修改,因此编译器不应该优化该变量的值。下面是一个例子:
void main()
{
volatile bool fWait = true;
WaitFiveSeconds(&fWait);
while(fWait)
{
Sleep(1);
}
}
前面的例子调用了一个例程来产生一个定时器线程五秒钟,这个线程将变量fWait
重置为false
。如果没有volatile
关键字,编译器会在循环开始时读取一次fWait
的值,并优化代码,使其永远循环下去。volatile
关键字指示编译器禁用fWait
的优化,并继续读取fWait
的值,看看它是否已经改变。
恒定和可变数据
特殊关键字const
和mutable
用于制作只读对象。在 C++ 中,一个类的整个实例可以通过声明为const
来设置。如果你有一个const
对象,你只能调用也被设置为const
的成员函数。关键字mutable
允许您在const
对象中设置非常量字段,例如:
#include <iostream>
using namespace std;
struct N
{
int ValueC;
mutable int ValueM;
void Show() const
{
cout << ValueC << " " << ValueM << endl;
}
N()
{
ValueC = ValueM = 0;
}
};
void main()
{
const N n;
n.Show();
n.ValueM = 3;
n.Show();
}
我们创建一个const N
对象n
。我们可以称之为n.Show()
,因为它是一个const
函数。同样,我们被允许改变n.ValueM
,因为它被声明为mutable
。
让我们编译并运行这个:
C:\ >cl /nologo /EHsc test.cpp
test.cpp
C:\ >test
0 0
0 3
属性
在 C++ 中,可以像在 C# 中一样附加属性。比如在第三章中,我提到过out
参数在 C++ 中有特殊的语法。下面是使用 C++ 的方法:
using namespace System;
public ref struct R
{
static void f( [System::Runtime::InteropServices::Out] int % i)
{
i = 20;
}
};
void main()
{
int Number = 0;
Console::WriteLine("Before: {0}", Number);
R::f(Number);
Console::WriteLine("After: {0}", Number);
}
让我们编译并运行这个:
C:\>cl /nologo /clr:pure test.cpp
test.cpp
C:\>test
Before: 0
After: 20
原生 C++ 中也有属性。例如,您可以参考 Visual C++ 文档来获取 ATL 属性。
摘要
在本章中,您学习了 dispose 模式、函数对象、常量、可变和易变数据,以及其他一些细节。
如果你已经读到了这一段,你肯定已经学了很多 C++,你应该已经在 C++/CLI 中打下了非常坚实的基础。C# 映射到 C++ 语言 C++/CLI 的子集。这本书的目的是利用你的 C# 基础,让你熟悉 C++ 编程,这样你就可以在你的 C# 知识的基础上学习所有关于 C++ 的知识,而不会影响这些知识。你已经做好了充分的准备,可以大无畏地阅读你最喜欢的书店书架上的几十本优秀的原生 C++ 书籍。虽然 C++ 有时可能很深奥,但它是一种有着丰富历史的美丽语言,非常值得努力。