C++: 支持.NET程序设计的最强有力的语言(test blog)

原创 2005年06月02日 15:41:00

Introduction

VC++小组花了大量的时间听取了使用.NET和C++工作的用户的建议,决定重新设计VC++在CLR的支持能力。新的设计被称为C++/CLI,计划以更自然的语法使用和创建托管类型。这里有新语法的介绍以及和C#的对比。

Common Language Infrastructure (CLI) 是一组规范,是构成.NET的基础设施。CLR则是CLI的一个实现。C++/CLI的设计以提供自然简单的CLI支持为目标,而VC++2005编译器使C++/CLI兼容CLR。

在你了解即将来到的VC++2005编译器和C++/CLI时会得到两个讯息。一是VC++打算定位于作为面向CLR开发的最底层的语言,以后将没有理由选择其他.NET语言了,包括IL汇编。第二,将会可以按照尽量接近原生C++编程的方式编程。在阅读这篇文章的过程中这两个讯息会变得越来越明朗。

这篇文章是写给C++程序员的,我不打算在这里劝你从C#或者VB.NET转型。如果你喜欢C++并且希望得到所有传统C++语言强大的优势,又需要C#那样高效的生产力,那么这篇文章就是为你准备的。另外,这篇文章没有提供CLR或者.NET Framework的介绍,而是关注于介绍VC++2005是如何让你编写优雅而高效的面向.NET的代码的。

Object Construction

CLR定义了两种类型 - 引用类型和值类型。值类型是为高速创建和访问而设计的,他们表现的行为很像C++的内置类型,并且你也可以创建自己的值类型。这就是为什么Bjarne Stroustrup称之为固化类型。而引用类型是为提供所有面向对象需要和特性设计,比如分级的能力,就像这些:继承的对象,虚函数,如此等等。通过CLR,引用类型还提供其他运行时特性,比如自动内存管理(就是广为所知的GC)。CLR还同时为值类型和引用类型提供更深层次的在运行时得到类型信息的能力,这种能力源自反射。

值类型被分配在堆栈上,引用类型则在托管堆上,这是受CLR的垃圾收集器(GC)管理的堆。如果你使用C++来编写程序集,你可以像以前一样把这些原生的C++编写的类型放在CRT堆上。在以后,VC++小组还会设法让你可以把原生的C++编写的类型放在托管堆上。毕竟GC这样的东西对于原生代码来讲同样极具吸引力。

原生的C++语言允许你决定创建新对象的位置。任何类型都可以选择是放在堆栈上还是CRT堆上。

就像您看到的那样,把对象放在哪里和对象类型无关,而是完全的受程序员控制。注意,在堆上分配对象的语法,和在栈上的不太一样。

在另外一个方面,C#允许在栈上创建值类型的对象,在堆上创建引用类型的对象。System.DateTime类(在后面几个例子也要用到)被他的作者定义为值类型。

就像你看到的,你没有任何办法控制定义好的类型的对象是创建在堆上还是在栈上,这个选择完全取决于类型的设计者和运行环境(runtime)。

C++的托管扩展,引进了简单的混合进行原生C++代码和托管代码创作的能力。顺着C++的标准,扩展使C++可以支持很大范围的CLR构件。然而不幸的是,这里实在有太多的扩展,以至于用C++编写托管代码变成了巨大的痛苦。

把值类型分配在栈上的代码和原来的C++代码很像。然而托管堆的那段代码,则看起来有一点奇怪。__gc是托管C++的一个关键字。就像已经被证明了的,托管C++在有些情况其实也可以推断你的意思,所以这个例子也可以这样重写,而不用__gc关键字,这就是众所周知的默认行为。

这样子更像原生C++代码了。但是问题在于那个heapObject并不是一个真正的C++指针。C++程序员们会始终信任他们所创建的对象的存在,但是GC却可能在任何时间移除这些对象。另外一个障碍则在于这里没有任何办法显式控制对象是在原生的还是托管的堆上分配内存。你需要知道类型是如何被它的作者定义的。除此之外,这里还有大量有力的证据证明重新定义C++指针的意义实在是个糟糕的注意。

C++/CLI带来一个新的句柄的概念以区别CLR对象引用和C++指针。它取消了重载C++指针含义的方式,这样大量的含混的内容都同时从这个语言移除。除此之外,通过句柄还能提供更多更自然的CLR支持。比如,你可以在C++内直接使用引用类型上的操作符重载,因为在句柄中是支持运算符重载的。这在“托管”指针内可能没法实现,因为C++禁止在指针上作运算符重载。

这里我们同样没有定义值类型对象时的疑惑,而引用类型却不太一样。操作符^把这个变量定义为一个CLR引用对象的句柄。句柄的轨迹,意思是句柄的内容就是他指向的对象会被GC自动的更新,在内存中将不断的移动。另外,他们可以支持重新绑定,这样可以允许他们指向多个不同的对象,就像普通的C++指针。另外一个你必须注意的事情是关键字gcnew占用了原来new的位置,这清晰的标记了这个对象将会被分配在托管堆上。而new关键字不再为托管类型重载(不再含有双重含义),而只是用于在CRT堆上分配对象 - 当然除非你自己提供自己的new操作符。你还有什么理由不喜欢C++!

这样对象的创建得到了坚实的支持:原生的C++指针和CLR对象引用被清晰的区分开来。

Memory Management vs. Resource Management

当你面对一个带有垃圾收集器(GC)的环境时,把内存管理和资源管理区分开是非常有用的。通常,GC用于释放和分配你的对象所需要的内存,它不会去关心其它对象所占用的资源,比如数据库连接或者内核对象的句柄。在下面的两节,我将分别讨论内存管理和资源管理,因为他们对于理解本文内容至关重要。

Memory Management

原生C++让程序员拥有直接操作内存的能力。把对象放在堆栈上意味着对象将在进入特定函数时创建,使用的内存将在函数返回堆栈展开时释放。动态的建立对象是使用new关键字完成的,使用的内存在CRT堆上,而内存的释放必须显式的通过对指针使用delete操作符完成。这种对内存精细的控制能力使得C++可以用作高性能程序的开发,但如果程序员不够细心的话也容易导致内存泄露。显然,你并不是必须依靠GC才能避免内存泄露,但是事实上CLR就采用了这种方法,并且是一种很有效率的办法。当然GC托管的堆还有其它好处,比如提高分配内存的性能和内存寻址相关的内容。虽然所有这些使用都可以在C++通过库支持做到,但是建立在CLR上的这种机制有利于建立统一的内存管理编程模型,对所有语言都是一样的。想想操作COM组件的情形,你也许自己就可能产生这种想法。要是有一个通用的垃圾收集器,这将极大地减少编程的工作量。

很显然的CLR为了提高操作值类型时的性能保留了堆栈的概念。然而CLR也提供了一个newobj IL指令用于在托管堆上建立对象。在C#里对一个引用类型使用new操作符时会用到这个指令。在CLR里面没有任何和delete操作符等价的功能。先前分配的内存最终总是可以回收,当程序不再存在对某个对象的引用时,GC会自动进行内存收集操作。

托管的C++同样为作用在引用类型上的new操作符产生newobj IL指令,然而使用delete操作符操作托管的(GC托管的)对象 / 指针时却是不合法的。这显然是一对恼人的矛盾。这也是证明重载C++指针内涵不合理性的另一个原因。

C++/CLI没有为内存管理带来任何新的内容,就像我们在上一节已经暗示了的一样。然而资源管理,才是C++/CLI着实优越的地方。

Resource Management

到目前为止,在资源管理上还没有任何语言能超过C++。Bjarne Stroustrup"resource acquisition is initialization"技术在根本上定义了所有的资源都应该作为类模型提供构造器和析构器(用于释放资源)。这些类型可以被用于栈上的变量,或者作为更复杂类型的成员。他们的析构器可以自动释放他们所持有的资源。Stroustrup则说,"C++ is the best language for garbage collection principally because it creates less garbage."

有些奇怪的是,对于资源管理CLR没有提供任何直接的运行时支持,CLR也不支持C++意义上的析构函数。还有,.NET Framework已经把资源管理抽象为一个核心的接口称为IDisposable,意图是所有封装了资源的类必须实现这个接口的唯一的Dispose方法,在调用者不再需要这些资源时应该调用Dispose方法。不必多说,C++程序员往往会认为这种方式是在退步,因为他们早已习惯于默认就进行资源清理的操作。

必须通过调用一个函数才能进行资源清理带来的麻烦在于更难以编写异常安全的代码。你不能简单地把Dispose方法的调用放在代码块的最后,异常在任何时候都可能弹出,这样就有可能导致资源的泄漏。C#通过提供try-finallyusing声明来解决这个问题,这样提供了一种可信赖的方式来调用Dispose方法而不用担心异常造成的问题。但是这些结构有时候会带来麻烦,甚至更糟,你必须时刻记着编写这些代码,如果你忘记了那么编译器仍然会进行编译而带上了隐藏的问题。所以非常不幸的,对于缺乏真正的析构器的语言来讲try-finally或者using声明是不可少的。

在托管C++里面也有一样的问题。你必须使用try-finally声明,这些也是微软为C++的扩展。托管C++并不带有和C#里的using声明类似的等价结构,但是我们依然可以轻松的编写出一个包装了GCHandleUsing模版类,这样可以在模版类的析构函数里面调用对象的Dispose方法。

想象一下传统的C++对资源管理的强有力的支持吧,C++/CLI已经像C++那样把资源管理变成了轻而易举的事情。我们先看一下一个管理了一个资源的类,它一般会实现CLR的Dispose模式,而不能像原生C++那样简单的建立一个析构器就行了。当你编写Dispose函数的时候,你还需要保证调用父类的Dispose函数(如果有的话)。除此之外,如果你选择使用Finalize来调用Dispose方法,你将会有并发冲突的担心,因为Finalize方法运行在另外一个独立的线程上;还有,你需要保证可能在和普通程序代码运行的同时,小心的在Finalize方法内释放资源。

C++/CLI并没有能解决所有问题,但至少提供了很大的帮助。在查看它之前,让我们再次回首现在的C#和托管C++的处理方式。这个示例架设基类Base实现了IDisposable接口(如果不是的话,这个派生的类可能不需要)。

托管C++也非常的类似,那个看起来像析构器的方法实际上就是Finalize方法。编译器会小心的添加一个try-finally块以保证调用了基类的Finalize方法,所以C#和托管C++提供了简单有效的方法来实现Finalize方法,却没有为实现Dispose方法提供任何帮助 - 更加重要的东西。程序员们只能使用Dispose作为析构器,实际上这只是提供了析构的方式,而不是强制的。

C++/CLI在引用类型把Dispose作为逻辑上的“析构器”,进一步强调了它的重要性。

这样看起来更像C++程序员所熟悉的风格了,我们可以像以前那样在析构器内释放资源了。编译器会自动的添加必要的IL代码以实现IDisposable::Dispose方法,包括阻止GC调用对象上面的Finalize方法。事实上,在C++/CLI内实现Dispose方法是不合法的,继承IDisposable会导致编译器提示错误。当然,一旦一个类型编译完成,所有使用这个类型的CLI语言都会认为它实现了Dispose模式。在C#可以直接调用Dispose方法,或者使用using语句做到,就像这是用C#编写的类;但是C++呢?你该如何调用在堆上的对象的析构器?当然使用delete操作符!在句柄上使用delete操作符将调用对象的Dispose方法。回忆一下受GC管理的对象占用的内存块,我们并没有保证释放了这块内存,但是能保证立即释放对象所占有的资源。

所以如果传给delete操作符的表达式是一个句柄,对象的Dispose方法就会被调用。如果再也没有任何路径和这个对象相连,GC就会在合适的时候清理这个对象占用的内存;如果是一个原生的C++对象,在回到堆之前析构器会被调用。

显而易见的,在对象的生命周期管理上我们和原生的C++语法又靠近了一步,但是这里还是有忘记使用delete操作符的错误倾向。C++/CLI允许在引用对象上使用堆栈的语义,这就意味着我们可以使用一些现有的语法把引用对象“放”在堆栈上。编译器将会处理好这种你可能早就想要的语义,为了满足需求而已,实际上对象还是被放在托管堆上。

d离开有效作用范围时,它的Dispose方法就会被调用以释放资源。和上面提过的一样,由于对象实际上还是在托管堆上创建的,GC会在某个时间释放对象本身占用的内存。回到我们的ADO.NET的例子,现在使用C++/CLI可以写成这样:

Types Revisited

在讨论装箱/拆箱之前,再清理一下值类型和引用类型的区别会很有帮助。

你可以想象值类型就是简单的值,引用类型的实例则是对象。先不考虑内存需要存储对象的每个字段,实现面向对象的程序中所有的对象都有个头信息,比如类的虚函数,和其他的元数据一样可以用作各种用途。然而,在对象头信息 - 这些虚方法和接口上反复的查找操作的开销经常会很大 - 而你需要的往往只是一个静态类型的简单对象,并且只需要在编译阶段就确定了的操作。可被证明的,编译器在一些情况下可以优化掉这种对象上的操作,但是不是全部。如果你非常关心这种性能问题,很显然提供值和值类型是非常有好处的。这并不是要和C++的类型系统分离。当然,C++没有强加任何编程范式,所以在C++之上建立这样不同的类型系统而不是创建库来做是可行的。

Boxing

什么是装箱?装箱是填补值和对象之间隔阂的桥梁。尽管CLR要求所有的类型都要直接或者间接的继承Object类型,而实际上值类型对象并不是。在堆栈上的像整数这样的简单类型不过是一块内存,编译器保证程序可以直接操作它。如果你确实需要把一个值当作对象来看待,那它就应该是个对象,这样你就应该可以在你的值上面调用继承自Object的方法。为了实现这种要求,CLR提供了装箱的概念。了解这种装箱的过程还是有点用处的。首先一个值被IL指令ldloc压到堆栈上,然后一组装箱的IL指令开始运行:编译器提供静态类型信息,比如Int32,然后CLR使这个值出栈并且在托管堆分配一段足够的空间来存放这个值和对象头信息,一个指向这个新创建的对象的引用被压入堆栈。所有这些构成了装箱指令。最后,要得到这个对象的引用,IL指令stloc被用于从堆栈弹出这个引用并且存储在特定的本地变量上。

那么现在的问题是对于编程语言来讲,装箱操作是应该隐式的还是显式的进行。换句话说,需要显式的进行类型转换,或者使用其他构造来实现吗?C#语言设计者决定使用隐式的转换,毕竟整数确实是间接的继承于ObjectInt32类型。

然而正如我们已经看到的那样,装箱操作并不是一个简单的向上的类型转换,这是由值到对象的转换,一个潜在的代价昂贵的操作。正是由于这个原因,托管C++使用__box关键字使装箱操作必须是显式的。

当然,在托管C++里面,你在装箱的时候不需要失去静态的类型信息,这是C#所没有提供的。

强类型的装箱操作带来了转换回值类型时的方便,也就是众所周知的拆箱,我们不需要再使用动态转换的语法,只要把对象直接取消引用即可。

当然在托管C++内进行显式装箱的句法已经被证明是有些冗繁的。由于这个原因,C++/CLI的设计方针有了些改变,变成和C#一致的隐式装箱。而与此同时,我们仍然可以以类型安全的方式的直接的进行强类型的装箱操作,这一点是其他.NET语言所无法做到的。

当然,这隐含指示了不指向任何对象的句柄不应该被初始化为零值,虽然它还是可以指向装箱了的0这个数值,然而指针不是。这就是设计常数nullptr的原由。它可以赋值到任何句柄。它等价于C#的null关键字。尽管nullptr在C++/CLI的设计中只是一个新的保留字,它现在被Herb SutterBjarne Stroustrup提议加入到C++标准内。

Authoring Reference and Value Types

在下面的几节中我们将回顾CLR内类型的一些细节。

C#使用关键字class来定义引用类型,使用关键字struct来定义值类型。

而C++早已定义好了classstruct的关键字的含义,所以这在C++上是行不通的。在原来的语言的设计中,关键字__gc被放在class前面来指示引用类型,关键字__value则是用于值类型。

C++/CLI带来了新的用法来解决这个问题并且不会和已有的关键字冲突。你现在可以加一个refclass或者struct的前面来定义引用类型,类似的加一个value来定义值类型。

使用class还是struct和决定默认的成员可见性类似。主要的区别在于CLR类型只支持公有的继承。使用private或者protected继承会导致编译器错误,所以显式的定义公开的可继承的是合理而冗繁的。

Accessibility

CLR定义了一组可访问性的修饰符,而在另外一方面,原生的C++在类型的成员函数和变量上也使用它们。并且,你可以定义那些直接在命名空间内的类型的可访问性,不只是嵌套定义的类型。鉴于保持C++/CLI作为最低层的语言的目标,在CLR上它提供了更多的对可访问性的控制能力。

原生C++和CLR在可访问性定义上的最主要区别在于,C++的可访问性控制只是为了明确在同一个程序内类之间成员的互访问能力,而CLR不只是需要考虑同一个程序集的情况,还得考虑程序集间访问的情况。

直接在命名空间下的类型,或者说是非嵌套定义的类型,就像class或者delegate这样的,可以在类型定义之前加上public或者private关键字以控制在程序集外面的可访问性。

如果你没有显式的设定可访问性,那么这个类型被默认的设定为只能在程序集内部私有使用。

关于成员的可访问性修饰符已经被扩展为允许同时使用两个修饰符来控制可访问性,这样可以更精确的分别控制外部的可访问性和内部的可访问性。如果只使用单一的关键字,他会同时控制外部和内部的可访问性。这种设计可以极大提高类型和成员可访问性的灵活性,这里是一个例子。

Properties

除了嵌套的类型,CLR类型只能带有方法和字段。为了让程序员能更清楚地表达意图,元数据可以用于指示某个特定的方法是否应该被程序语言当作是对象的属性看待。严格的讲,一个CLR属性是所在的类型的成员;然而这个成员不占用任何空间,这只是一个对完成这种属性的方法的命名了的引用。当属性语法作为语言的特性时,不同的编译器都需要生成需要的元数据来支持属性的概念。这样,类型的使用者们可以使用语言的属性语法来访问那些用于实现属性的getset方法。不像原生的C++,C#提供了最一流的对属性的语法支持。

C#编译器会产生相应的get_Nameset_Name方法,然后同样的包含必要的元数据信息以指示这种关联。托管C++使用__property关键字来指示一个方法作为一个属性的语义。

很显然这不完美。不只是我们得使用难看的__property关键字,这里没有任何东西明显的指示这两个个成员方法事实上是属于同一个属性的。这在维护工作时很可能带来不经意的bug。C++/CLI为属性的设计就清晰多了,并且和C#的设计更为相似。你将看到,它甚至更强。

这是一个很大的进步。编译器将能很好的产生get_Nameset_Name方法,并且产生相关元数据来定义这个属性。进一步你还可以控制程序集外只能有读操作,而程序集内部可以同时读和写。你可以在定义属性的括号内部使用访问控制修饰符来做到这一点。

最后一个有用的东西是当你不需要自定义get和set的处理过程时你可以使用这种简短的语法。

同样的,编译器将会生成get_Nameset_Name方法,但是与此同时还会在后台提供一个私有的String^类型的成员变量。这带来的好处,当然的,你可以用它替换那些最简单的属性代码,同时也不会破化类型的接口。这样你同时得到了字段的简单性和属性的灵活性。

Delegates

原生C++的函数指针提供了异步执行代码的机制。你可以存储一个指向了函数的指针,或者更平常的功能体,以在将来的某个时刻及时调用。这可能被简单的用于使算法和实现分离,比如在搜索时比较对象大小。另外一方面,通过在不同的线程上调用一个功能体可以实现真正意义的异步编程。这里是一个简单的ThreadPool类的示例,它可以让你把函数指针加入队列以在工作者线程执行。

可以简单而自然的在C++使用线程池。

显然,我的ThreadPool类由于只能为带有特定方法签名的函数指针工作而很受局限性。不过只是这里这个例子带有这种约束,不是C++本身。要想得到更深入的对于普通函数的讨论,可以阅读Andrei Alexandrescu的精彩作品Modern C++ Design

C++程序员需要实现或者得到支持异步编程的完整类库,而CLR已经内置了这种支持。委托(Delegate)和方法指针很类似,除了目标对象(或者说包含这个方法的类型)并不参与决定委托是否可以和给定的方法绑定。在方法签名一致的情况下,一个方法可以被添加到委托用于将来调用。这和我上面的例子最起码在思路上是相似的,在那里我使用了C++的模板机制让任何类型的方法都可以使用。当然,委托提供了远优越于上面的机制来实现间接方法调用。下面是一个在C++/CLI使用委托的例子。

委托的使用是简单明了的。

Conclusion

关于C++/CLI可以说的还有很多很多,对于VC2005的编译器本身无需担心,但是我希望这篇文章能够让你很好的了解它能为程序员带来什么。新设计的语言为.NET程序提供了前所未有的能力和优雅的编码方式,而没有在生产力、简单性和性能上作任何牺牲。

在下面的表格中有常用的语法构造对比

描述C++/CLIC#
创建引用类型的对象ReferenceType^ h = gcnew ReferenceType;ReferenceType h = new ReferenceType();
创建值类型的对象ValueType v(3, 4);ValueType v = new ValueType(3, 4);
引用类型在堆栈上ReferenceType h;N/A
调用Dispose 方法ReferenceType^ h = gcnew ReferenceType;

delete h;

ReferenceType h = new ReferenceType();

((IDisposable)h).Dispose();

实现Dispose方法~TypeName() {}void IDisposable.Dispose() {}
实现Finalize 方法!TypeName() {}~TypeName() {}
装箱(Boxing)int^ h = 123;object h = 123;
拆箱(Unboxing)int^ hi = 123;

int c = *hi;

object h = 123;

int i = (int) h;

定义引用类型ref class ReferenceType {};

ref struct ReferenceType {};

class ReferenceType {}
定义值类型value class ValueType {};

value struct ValueType {};

struct ValueType {}
使用属性h.Prop = 123;

int v = h.Prop;

h.Prop = 123;

int v = h.Prop;

定义属性

property String^ Name
{
    String^ get()
    {
        return m_value;
    }
    void set(String^ value)
    {
        m_value = value;
    }
}

property String^ Name;

string Name
{
    get
    {
        return m_name;
    }
    set
    {
        m_name = value;
    }
}


关于作者:

Kenny Kerr spends most of his time designing and building distributed applications for the Microsoft Windows platform. He also has a particular passion for C++ and security programming. Reach Kenny at http://weblogs.asp.net/kennykerr/ or visit his Web site: http://www.kennyandkarin.com/Kenny/.

关于译者:

电子科技大学,夏桅

C#与.NET4 高级程序设计-----语言特性 学习笔记

1.重载操作符 C#提供operator 关键字来允许自定义类型对内建操作符做出不同的反映,Operator关键字只能用在静态方法上。[],() 操作符不能被重载。 伪代码:public class ...

Microsoft.NET_IL 语言程序设计

  • 2008年04月07日 11:26
  • 1.4MB
  • 下载

第二章:C#.NET面向对象——面向对象程序设计1(面向对象程序设计语言的三大原则)

面向对象程序设计语言的三大原则   一个面向对象的语言在处理对象时,必须遵循的三个原则是:封装、继承和多态。(1)封装   所谓“封装”,就是用一个框架把数据和代码组合在一起,形成一个对象。遵循面向...
  • boyldr
  • boyldr
  • 2011年05月07日 00:00
  • 365

C++.NET程序设计

  • 2011年05月26日 08:33
  • 5.33MB
  • 下载

Visual C++ .NET程序设计与应用

  • 2008年05月06日 21:52
  • 23KB
  • 下载

第二章:C#.NET面向对象——面向对象程序设计3(方法)

方法   方法(在C语言中叫函数,或在类的内部调用方法时也可叫调用函数)在类中是最重要的函数成员。定义格式如下: 1、方法的定义   [方法修饰符] 返回类型 方法成员名 ([形式参数列表])   ...
  • boyldr
  • boyldr
  • 2011年05月07日 22:17
  • 558

Visual c++.net程序设计书中的代码

  • 2015年06月16日 09:37
  • 5.3MB
  • 下载

.NET程序设计之四书五经

推荐书籍: 《C#入门经典》 《C#高级编程》 《.NET框架程序设计(修订版)》(李建忠译版) 《深入探 索.NET框架内部了解CLR如何创建运行时对象》 《.NET本质论》 I...
  • is2120
  • is2120
  • 2011年07月04日 19:55
  • 682
内容举报
返回顶部
收藏助手
不良信息举报
您举报文章:C++: 支持.NET程序设计的最强有力的语言(test blog)
举报原因:
原因补充:

(最多只允许输入30个字)