面向-C--开发者的-C---2013-教程-八-

86 阅读37分钟

面向 C# 开发者的 C++ 2013 教程(八)

原文:C++ 2013 for C# Developers

协议:CC BY-NC-SA 4.0

十九、多语言支持

对真善美的追求是一个活动领域,在这个领域里,我们可以一辈子做个孩子。—阿尔伯特·爱因斯坦

在这一章中,我将介绍将不同的。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# 做一个TreeTree是我们自己的一个通用集合类,用于组织任意类型:它对学生没有什么特别的了解,但也不需要。最后,让我们用一个 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 。

A978-1-4302-6707-2_19_Fig1_HTML.jpg

图 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.ObjectFinalize()方法的重写。在垃圾回收期间,垃圾回收器例程不确定地调用此方法。此方法的执行是不确定的,因为您无法控制何时释放对象的资源。在 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。也许你需要记录学生完成的所有作业;你可能有一个CEssayCArtworkCMidterm等等。

在 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的值,看看它是否已经改变。

恒定和可变数据

特殊关键字constmutable用于制作只读对象。在 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++ 有时可能很深奥,但它是一种有着丰富历史的美丽语言,非常值得努力。

第一部分:C++ 的快速通道

第二部分:细节

第三部分:高级概念