C++/CLI解析之基于堆栈的对象与跟踪引用

作者:谢启东编译

  在托管堆上分配对象实例,似乎是使用托管扩展C++、C#、J#、VB.NET程序员的唯一方法,而使用本地C++的程序员,不但可以在堆上分配内存,甚至更惯于使用基于堆栈的对象实例。

  现在回顾一下以前定义的Point引用类,再来看一下以下变量定义:





Point p1, p2(3,4);

  从本地C++的角度来说,p1与p2应为基于堆栈的引用类Point实例,哪怕是从一般性的角度来看,它们也是。P1由默认的构造函数初始化,而p2由接受x与y坐标的构造函数初始化。从实现上来看,Point是自包含类型的(也就是说,它不包含任何指针或句柄),然而,作为一个引用类的实例,它仍处于CLI运行时的掌控之下,且在必要时,会被垃圾回收–正因为此,所以不能定义一个引用类的静态或全局实例。

  同时,也不能将sizeof应用于指明是引用类实例的表达式,因为sizeof是在编译时进行计算的,而Point对象的大小要直到运行时才能确定;但是,可将sizeof应用于句柄,因为它的大小在编译时就已经确定了。

  另外,还不能定义一个基于堆栈的CLI数组实例。

  跟踪引用

  本地C++可通过&来定义一个对象的别名,例如,对任意本地类N,可编写如下代码:





N n1;
N& n2 = n1;

  引用必须在定义时进行初始化,且在整个生命期中,它们都锁定于引用同一对象,也就是说,它的值不会改变。引用一个引用类的实例与引用一个本地类基本一致,只不过语法不同而已。

  在程序执行期间,引用类的实例会在内存中”移动”,所以,需要对它们进行跟踪,而本地指针与引用却不能够胜任这项工作(尤其指不能对一个引用类的实例使用取地址符&),因此,C++/CLI对应地提供了句柄及用于跟踪的引用–在此简称为跟踪引用(Tracking References),例如,你可以定义一个跟踪引用p3,以追踪对象p2:





Point% p3 = p2;

  跟踪引用的内存存储方式必须为自动(atuomatic),另外,尽管本地对象不会在内存中”移动”,但在上面的n2中,不能使用%来代替&。在C++/CLI中,%之于^,就如同本地C++中的&之于*。

  请看下列代码:





Point^ hp = gcnew Point(2,5);
Point% p4 = *hp;
Point% p5 = *gcnew Point(2,5);

  在此,hp是一个Point的句柄,而p4是此句柄的别名。虽然句柄不是一个指针,但也能使用一元 * 操作符来对句柄解引用。(在C++/CLI标准制定期间,是否就引入一元 ^ 操作符来取代 * 还进行过一场讨论,反方观点是,在编写模板时,* 对句柄或指针进行解引用有非常高的价值。)当然,即使hp有了一个新值,p4在此仍是同一Point的别名。另外要说明一点,当对象有一个句柄或跟踪引用时,就不能被垃圾回收器回收了。

  再来看p5,对gcnew返回的句柄进行了解引用,虽然差不多每个引用类类型的句柄,都能被解引用,但有两种类型的句柄却不能被解引用,这两种类型是:System::String与array<T>。
取句柄操作符

  如果想把p1的值写到标准输出,代码似乎应该像下面这样:





Console::WriteLine(“p1 is {0}”, p1);

  然而,这却不能通过编译,因为WriteLine没有一个可接受Point的重载版本。前面也提过,任何值类型的表达式(如int、long、double)会由一个”装箱”的过程,自动转换为Object^。虽然p1看上去比较像一个值类型的实例,但它实际上却不是,它是一个引用类的实例,所以代码需要这样修改: Console::WriteLine(“p1 is {0}”, %p1);

  通过使用一元 % 操作符,我们创建了对象p1的一个句柄,因为每个引用类最终都是从System::Object继承的,而WriteLine也有一个其第二个参数可接受Object^的重载版本,所以,%p1的Point^就转换为Object^,并显示出p1相应的值。要留意的是,此处没有装箱,但这个操作符不能应用到本地类的实例上。

  GC-Lvalues

  在C++标准中定义及使用了lvalue术语,而C++/CLI标准则添加了gc-lvalue术语,其指”一个引用CLI堆中对象、或包含此对象的数值成员的表达式”。如果有一个指向gc-lvalue的句柄,可对其使用一元 * 操作符来产生一个gc-lvalue;而跟踪引用也是一个gc-lvalue,当%h中h是一个句柄时,它也可以产生一个gc-lvalue。(因为有从lvalue至gc-lvalue的标准转换,所以一个跟踪引用可绑定至任意的gc-lvalue或lvalue。)

#p#副标题#e#

  拷贝构造函数

  在下面的例子中,p6由给定的坐标构造而成,而p7则初始化为p6的一个副本,这就需要Point有一个拷贝构造函数;然而,在默认情况下,编译器不会为这些引用类产生一个拷贝构造函数。那么,在这种情况下,就必须自己编写一个。





Point p6(3,4), p7 = p6;

  以下,是Point的拷贝构造函数:





Point(Point% p)
{
 X = p.X;
 Y = p.Y;
}

  而对一个本地类N的拷贝构造函数,一般声明成如下形式:





N(const N& n);

  但是,对引用类来说,因为%取代了&,所以在CLI的世界中,const显得有点格格不入。

  赋值操作符

  以下表达式:





p7 = p6;

  就需要一个赋值操作符,但再次提醒,这不是自动提供的。以下就是一个自定义的操作符例子:





Point% operator=(Point% p)
{
 X = p.X;
 Y = p.Y;
 return *this;
}

  之所以没有提供默认的拷贝构造函数或赋值操作符,是因为所有的引用类(除了System::Object),都有一个基类:System::Object,而这个类并没有提供一个拷贝构造函数或赋值操作符。基本上,这两者默认都会调用它们基类中相应的实现版本,但基类中却一个对应的定义也没有。
相等性操作符

  通过为Point定义一个拷贝构造函数和一个赋值操作符,就可以处理那些数值类型的实例了,你可以初始化它们、把它们传给函数、或把它们从函数中返回;但实际上,可能还再需要一个操作符–相等性比较操作符,它能像如下定义:





static bool operator==(Point% p1, Point% p2)
{
 if (p1.GetType() == p2.GetType())
 {
  return (p1.X == p2.X) && (p1.Y == p2.Y);
 }
 return false;
}

  由于一个跟踪引用不可能为数值nullptr,所以就不必对此值进行检查了,又由于p1与p2是两个Point的别名,所以可使用点操作符调用GetType和属性X与Y的get程序。

  能同时满足两方面需求吗?

  以前说过,对一个引用类而言,相等性的判别是通过一个Equals函数而不是重载 == 操作符来实现的,并且重载了一个接受句柄的 == 操作符,指出了使用上的问题。那让我们再来回顾一下这个话题。

  当在C++/CLI中设计并实现一个引用类时,就要想到”这个类的使用者,会使用C++/CLI语言进行编程,还是会使用如C#、J#、VB.NET之类的其他语言呢,或者两者都使用呢?”

  C++程序员习惯于把类实例当作数值来对待,所以,他们期待类中有一个拷贝构造函数及一个赋值操作符,且对某些类来说,还会期待实现相等或不相等操作符;另一方面,C#、J#、VB.NET程序员只能通过句柄来操纵类实例,所以他们只想要克隆或Equals函数,至于拷贝构造函数与赋值操作符,他们无须知道,也无须关心。

  即便C++程序员更倾向于使用 == 操作符,但一个带有Equals函数的引用类可被任意语言所调用,所以在设计引用类时应尽量实现此函数,不过话说回来,如果对一个不包含Equals函数的类实例调用此函数,将会产生无法预料的后果。

  如果在一个引用类中,提供了可接受两个跟踪引用的 == 操作符函数,一般上也可满足C++/CLI程序员的需要。虽然也能提供一个接受两个句柄的 == 操作符函数,但似乎不可能被这两组程序员使用。

  简而言之,既可为C++/CLI程序员,也可为其他.NET语言程序员、或同时为两者实现一个引用类,那么,是不是可把它们简单地分为C++/CLI与”其他语言”两个阵营呢,但事情似乎总不是这么简单的,举例来说,虽然System::String是一个引用类,它提供了可接受两个句柄的 == 操作符与 != 操作符函数,但是,比较的是字符串的值,而不是它们的句柄。一般来说,在引用类中使用值这个说法,是有点让人感觉怪怪的,但对一个string类来说,却又是合情合理的。

  在此非常清楚的一点是,万能的方法是不存在的。为对引用类的使用者,提供最适当的接口,就必须在基于他们所使用语言的基础上,多考虑一下他们的期望。但无论如何,C++/CLI程序员想要使用其他语言创建的引用类,就不得不要适应没有拷贝构造函数与赋值操作符这些情况。

#p#副标题#e#
其他话题

  以下的话题非常简短、但却十分有帮助:

  1、前面也提到,const不是很适合CLI,且在引用类中,C++/CLI也不允许用const(或volatile)来限定成员函数;但个关键字可用在某些类中实例构造函数或成员函数上。那么在此情况下,它的类型到底是什么呢?对本地类型N来说,它是N* const;然而,对一个引用类R来说,它只是一个R^。虽然句柄不是const限定的,但它的值却不能被修改。

  2、Point::ToString的实现使用了如下形式:





return String::Concat(“(“, X, “,”, Y, “)”);

  另外也可以像下面这样写:





return String::Format(“({0},{1})”, X, Y);

  正如它的名字,Format函数允许对文本进行格式化(如前导的空格或零、或者一些分隔符等等),而不是简单地对字符串进行连接。

  3、如果想要知道编译器是否支持C++/CLI扩展,可测试__cplusplus_cli宏是否已经预定义。如果已定义,它的值将为200406L。

  4、CLI库包含了一个称为System::Decimal的类型,其至少可表示28位数字的值,这个类型是专为那些需要在没有四舍五入情况下进行大数额金融计算而准备的。与浮点类型不同,Decimal的小数部分能表示得更加精确,通常,当某数以浮点类型表示时,其经常会有一个无穷的小数,但却更容易导致舍入上的错误;而Decimal有一个称为”scale(数值范围)”的属性,其代表了十进制下所需的位数。举例来说,2.340的scale为3,其结尾的0非常重要,当两个十进制数相加或相减时,结果的数值范围是两者数值范围中较大的一者,如:1.0 + 2.000为3.000,而5.0-2.00为 3.00;当两个十进制数相乘时,结果的数值范围是两者数值范围之和,如:1.0*2.000为2.0000;当两个十进制相除时,结果的数值范围是除数的数值范围比被除数多出的值,如:4.00000/2.000为2.00。然而,数值范围不可能小于为表示正确的值所需的范围,例如,3.000/2.000、3.00/2.000、3.0/2.000和3/2都是1.5,下面是Decimal的使用范例:





Decimal x = Decimal::Parse(“23.00”);
Decimal y = Decimal::Parse(“2.000”);
Decimal result = x * y + Decimal::Parse(“2.5”);
Console::WriteLine(result);

  输出为48.50000。请注意,C++/CLI没有字面意义上的Decimal类型,所以需要使用Parse函数。

  5、如果有这样一种情况,一个类使用了C++/CLI之外的其他语言编写,且有一个名字为C++/CLI关键字的public成员,那就可通过__identifier(x)这种形式的内部函数来访问它,此处的x可以是一个标识符、一个关键字、或一个字面上的字符串,例如,为调用一个类X中名为delete、且没有参数的静态函数,就可以像X::__identifier(delete)()这样使用。

  6、一个literal域是一个定义在类中的命名编译期常量,同样,它也必须有接受一个常量值的初始化程序。尽管一个literal域使用上像一个静态数据成员,但它并不能声明为static,且编译器会把每个literal域都替换为域值。一个literal域能有任意标量类型,但是,能用作初始化句柄的唯一常量数值,只能为字面上的字符串和nullptr。





literal double PI = 3.1415926;
literal int MinValue = -10, MaxValue = 10;
literal int Range = MaxValue – MinValue + 1;
enum Direction {North, South, East, West};
literal Direction Home = North;
literal System::String^ Title = “Annual Report”;