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

173 阅读1小时+

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

原文:C++ 2013 for C# Developers

协议:CC BY-NC-SA 4.0

六、数据类型

游戏正在进行中。—威廉·莎士比亚,亨利五世

在本章中,我们将深入探讨 CLI 类型系统以及 Microsoft Visual C++ 2013 中 C++/CLI 的实现。您应该已经从以前的编程经验中打下了坚实的 C# 类、结构和接口基础,我们希望在这些知识的基础上揭示 C# 和 C++/CLI 之间的差异。

C# 类型与 C++ 类型

C# 语言是专门针对 CLR 而设计的。因此,它的所有数据类型都直接映射到 CLI 类型。C++/CLI 不仅为 CLI 定义了数据类型,还定义了在本机 C++ 中使用的数据类型。所有这些数据类型都是使用关键字classstruct定义的。在设计 C# 之前,这些关键字在 C++ 中就已经有了意义。在 C++ 中,class定义了一个原生类型,可以认为是相关字段和方法的一般集合。C++ struct与 C++ class相同,除了所有成员的默认可访问性是public,而 C++ struct从其基类中公开继承。

在 C# 中,class定义了一个从System::Object继承而来的 CLI 引用类型,它具有一组特定的特征和限制。一个 C# struct定义了一个 CLI 值类型,它有一组不同的特征。

为了让 C++/CLI 实现 CLI 类型,语言中添加了新的关键字组合。类限定符refvalue被添加到关键字classstruct的前面,以创建新的空白关键字ref classvalue 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++ 中的structclass完全一样,除了它有public,而不是private,并且默认情况下是公开继承的。ref structvalue struct也是如此。除了保护机制之外,它们与ref classvalue class相同。

一个 C++ struct是极其有用的。每当我希望快速原型化一个类或方法,并且不想担心保护的时候,我就使用 C++ struct而不是 C++ class。我将在第八章的中深入探讨保护机制。

本地班级

如前所述,C++/CLI 也有本机类。与直接映射到 CLI 定义的类型的ref classvalue class不同,本机类是没有 CLI 映射的非托管类型。本地类,经典 C++ 的元素,将在第十八章中进一步讨论。

值类型和引用类型

值类型和引用类型用相同的语法分配、访问和复制;将这些类型声明为structclass是主要的区别。如前所述,在分配这些类型的实例时,使用相同的语法会导致意想不到的后果。值类型是整体复制的,而在赋值期间实际上只复制引用类型的句柄。在 C# 中,除了在初始声明中声明structclass,值类型和引用类型的语法是相同的。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指令初始化的,它初始化堆栈上的Vinitobj用于在没有构造器时初始化值类型。引用类型Rnewobj指令初始化,该指令调用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 的代码视图。

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

图 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 中被重命名。

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

图 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 显示了构造器。

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

图 6-3。

The implicitly generated constructor

如您所见,生成了一个初始化变量a的构造器。如果我们现在切换到 C++/CLI 模式,我们可以查看我们需要编写的构造器来转换这个代码片段,如图 6-4 所示。

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

图 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 查看这些构造器使用。网状反射器。

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

图 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的成员,如所示。网状反射器。

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

图 6-6。

Class R in .NET Reflector

如你所见,R不仅有一个构造器,表示为.ctor,它还有一个静态构造器,表示为.cctor

静态构造器

构造器,更确切地说是实例构造器,在每次创建一个类的实例时都会被调用。静态构造器,也称为类构造器或类型初始值设定项,在创建类的任何实例之前只调用一次。它用于一次性初始化所有实例共有的数据。

现在让我们回到检查代码,看看构造器和静态构造器。这两个构造器都是 C# 编译器的重定位目标。实例构造器获取所有实例初始化,静态构造器获取所有静态初始化。首先让我们检查一下图 6-7 中所示的构造器。

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

图 6-7。

Class R’s constructor

与您之前所学的类似,vArA的初始化被移到构造器中。没什么好惊讶的。图 6-8 所示的静态构造器怎么样?

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

图 6-8。

Class R’s static constructor

类似于vArA的移动,vCrC被移动到静态构造器中。这也是有意义的,因为如果常规初始化被移到构造器中,那么静态初始化应该被移到静态构造器中。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 中,vCrC可以使用显式静态构造器进行初始化,如下所示:

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()构造器使用。网状反射器。

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

图 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# 中的stringobjectdecimal关键字在 C++/CLI 中没有对应的内置类型。这是否意味着我们不能在 C++ 中使用这些类型?一点也不。事实上,因为 C# 和 C++/CLI 都以 CLI 为目标,所以我们总是可以通过名称来指定 CLI 目标类型,并使用它来代替。

需要封送处理

什么是封送?通常,封送拆收器是在两个程序之间翻译或打包数据的程序。在两个程序无法对单个数据实例进行无缝操作的许多情况下,可能需要封送拆收器。C++/CLI 类型wchar_tbool在元数据中有附加到它们的封送处理属性,以便从 C# 和其他。网络语言。属性将在第二十章中详细讨论。在此之前,让我们看看元数据中的一些封送属性。

考虑下面简单的 C++/CLI 方法的 CIL。注意Hello()以一个wchar_t为参数,返回一个bool;这两种类型都被封送:

ref class R

{

bool Hello(wchar_t ch)

{

return true;

}

};

图 6-10 显示了使用Hello()方法的 C# 视图。网状反射器。

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

图 6-10。

C# view of Hello()

可以看到,MarshalAs(UnmanagedType.U1)返回属性被添加到bool返回值中,MarshalAs(UnmanagedType.U2)被添加到char值中(对应于wchar_t)。

UnmanagedType enumSystem::Runtime::InteropServices的一个成员,表示被整理的数据的种类。如果你想知道类型是如何定义的,你可以在mscorlib.dll中找到定义。网状反射器。你会发现U14,而U26,这并不重要!现在让我们看看 C++/CLI 版本的Hello()使用。网状反射器如图 6-11 所示。

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

图 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 中看到这一点。

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

图 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# 相同的事情。让我们一点一点地分解它。

首先,stringSystem::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++ 允许从floatint的自动转换,因为这在 C 中是允许的,而 C++ 是 C 的扩展。

如果我们现在尝试执行该程序,我们会看到以下结果:

C:\>t

1

2

3

4

浮点值4.2f在没有诊断的情况下被截断为4。这似乎有点不好,所以 Microsoft Visual C++ 添加了一个二级诊断来警告可能会截断数据的转换。

Note

使用/W{n}命令行选项启用警告,其中n0(无警告)到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();}

差异

矩形阵列和锯齿状阵列之间的主要区别之一是隔离阵列的单行的能力。例如,假设RectJagged在前面的例子中定义。如果我们试图使用下面的语法来回忆第一行的长度,会发生什么?

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();}

在这个例子中,我们创建了一个参差不齐的数组PaintersPainters的每个元素都是对应画师的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对象,因为它们是。
  • 接口:“接口”是指根据对象的特性来考虑对象;接口允许你根据对象能做什么而不是它们是什么来组织对象。比如CarDog物体都可以发出噪音。表示这两个对象都支持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对象,并将其视为HorseDonkey的实例。这就是事情开始变得棘手的地方。

马和驴不都是动物吗?让我们将它添加到代码中:

class Animal {};

class Horse : Animal {};

class Donkey : Animal {};

class Mule : Horse, Donkey {};

在这种情况下,对于给定的Mule,创建了多少个Animal的实例?应该创造多少?也许对于某些对象范例,您希望有两个公共基类。在这种情况下,实际上只有一个Animal,即Mule,我们希望在代码中表示它。C++ 允许你使用这两种范例来定义你的类层次结构。如果我们只想要一个Animal,就像我们在这个例子中所做的,我们可以使用virtual关键字进行虚拟继承。否则,我们就让它保持原样。虚拟继承告诉编译器在类层次结构中每种类型只包含一个子对象。假设我们要给骡子喂午餐,而Lunch由一个Apple对象和一个Carrot对象组成。在这种情况下,LunchApple之间的关系不是is关系;更确切的说Lunch包含AppleCarrot,它们是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;

}

如你所见,HorseDonkey实际上都是从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 不允许值类型从其他类继承,但允许值类型继承接口。默认情况下,值类型是密封类,这意味着它们不能被继承,因此也不能被扩展。我们将在本章后面重新讨论密封类。

一个简单的例子

让我们回到动物农场,看看我们能用接口做些什么。假设我们想要创建类型为DogCat的对象,并说它们可以做像吃饭和睡觉这样的事情。让EatSleep接口似乎是合理的。然后我们可以使用EatSleep作为DogCat的基类,如下所示:

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# 中相同,它允许派生类实现或重写方法。在多态术语中,最顶层(基)方法是实现,派生方法是重写。

这是一个非常基本的例子,但并不像我们希望的那样简单。由于CatDog各自支持EatsSleeps接口,它们各自被强制实现GoToSleep()Feed()功能。另一个低效之处是对于这两种动物来说,Feed()的实现实际上是相同的。

一个合理的解决方案是创建一个Animal类,它可以包含这些接口的默认行为。DogCat可以继承Animal。但是怎样才能阻止某人实例化一个Animal?我们绝不希望这种情况发生;我们只想能够实例化DogCat。下一节关于抽象类的内容会有所帮助。

抽象类

抽象类是不能实例化的类。接口回答这个问题,“这个类做这个吗?”抽象类回答了这样一个问题,“这个类是那个类的一种吗?”抽象类经常被使用;以下是一些例子:

  • 当创建一个类时,使用抽象类,该类具有接口的默认行为,但不应被实例化。我们的例子就属于这一类。
  • 当创建本质上从不实例化的类时,使用抽象类,因为它是由静态方法的集合组成的。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'

正如我们所料;它不能被扩展,因为它是一个密封类。现在让我们看看当你同时声明一个类为abstractsealed时会发生什么。

静态类

让我们使用。在mscorlib.dll中查看净反射器,并查看System::Console的声明:

public ref class Console abstract sealed

{

};

将一个类同时声明为abstractsealed会进一步限制该类。它只允许有静态成员、嵌套类型、文本字段和typedef。C # 中与abstract sealed类等价的是定义一个static类。如果你将视图切换到。NET Reflector 转换为 C#,您会看到以下内容:

public static class Console

Console同时声明为abstractsealed,或者在 C# 中声明为static,这允许它成为静态方法的容器,并且它既不能被实例化也不能从其派生。有趣的是,在 C++ 中,这可以通过使用命名空间来限定和包含一组全局函数来实现。当然,你可能会想,“但是全局函数可以导出到程序集之外吗?”你有理由怀疑;答案是他们不能。当在一个类中使用时,C# 语言中的关键字static只是一种语法糖。如果你看看 CIL。NET Reflector,您会在元数据中看到abstractsealed描述符。这些类似于 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也在派生类中使用(稍后将详细介绍)。因为是虚拟的,JohnName()的实现替换了AnimalJohn所有实例的实现。

另一方面,Hello()没有标记virtual,所以当我们把John的实例当作John时,我们看到的是JohnHello()的实现,当我们把John的实例当作Animal时,我们看到的是AnimalHello()的实现。这给了我们想要的结果:

C:\>cl /clr:pure /nologo test.cpp

C:\>test

John says Hello

Oh no! He's an Animal!

John says ugh

通过这种方式,我们能够挑选出基类中的哪些方法被派生类中的方法的实现所替换。

使用方法

C++/CLI 有几种重写虚方法的方式。每一个都是按照特定的模式创建的。我想回顾一些范例,并在适当的时候揭示动机。这让我们可以看到实现如何影响所需的语法,以及编译器如何帮助我们做正确的事情,并在潜在的危险情况发生时发出诊断。

基类和派生类中的非虚拟方法

在这种情况下,我们在基类和派生类中都有一个普通的方法。我们之前在JohnAnimal中都使用了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

当派生类隐藏基类的虚方法时,为了防止意外的结果,编译器需要一个显式的关键字,newoverride,以指示您希望该方法如何隐藏基类方法。

关键字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();

}

让我们看看这个代码示例。它有三个类,BaseDerivedDerived2.Derived也是Derived2类的基类。当您使用Derived2的一个实例并调用Method()时,您会得到Derived2的实现。当您将这个实例强制转换为Derived时,您也获得了Derived2的实现,因为override关键字在Derived2上被用来覆盖DerivedMethod()版本。然而,当你进一步转换到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中声明。

虚拟方法摘要

您现在应该对虚方法中固有的可能性有了很好的了解。使用newoverride关键字,不仅可以创建方法的虚拟链,而且可以链接虚拟链本身,即使方法的名称不同。

访问基类字段和方法

在 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++ 派生类型的可访问性。

能见度

一个非嵌套的classstructinterfacedelegateenum的可见性决定了它是否能在其父组件之外被看到。类的可见性是通过在类定义前添加 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:基类的成员publicprotected被分别视为派生类的publicprotected成员。基类的每个成员都保留其在派生类中的可访问性。
  • private:基类的成员publicprotected被视为派生类的成员private。基类的每个成员都成为派生类中的private
  • protected:基类的成员publicprotected被视为派生类的成员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;

}

在这个例子中,DerivedBase公开继承,因为它们都是引用类型,并且引用类型总是公开继承。成员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默认私有继承。

有几种方法可以解决这个问题。我们可以将Derivedclass改为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 )。

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

图 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.fDerived.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()的基本版本和派生版本具有相同的名称。然后选择剩下的唯一候选,完成从intwchar_t的隐式转换(wchar_t是 C++/CLI 对System::Char的别名,在 C# 中是char)。是唯一的,也是最好的可用匹配。

至于哪种设计更好,人们可以从两方面进行讨论。将代码从 C# 转换为 C++ 的关键是要注意这种差异。从 C# 到 C++ 的自动翻译器(反之亦然)无法轻松处理这种差异,即使最终的程序可能产生不同的结果,翻译后的代码也可能编译无误。

摘要

在这一章中,我们讨论了多态和保护,以学习如何编写干净的面向对象的代码。现在,您应该对 C# 和 C++ 之间的主要类型差异有了很好的了解。建议你用。上的网状反射器。NET BCLs 进行一些探索,看看各种常见的方法是如何实现的。

在下一章中,我们将通过观察指针和不安全代码,从相反的角度来看 C++ 中的编码。