• 设为首页
  • 点击收藏
  • 手机版
    手机扫一扫访问
    迪恩网络手机版
  • 关注官方公众号
    微信扫一扫关注
    公众号

《EffectiveC#》翻译札记

原作者: [db:作者] 来自: [db:来源] 收藏 邀请

最近在翻译Bill Wagner先生的《Effective C#》一书,由于自己早先也有写作Effective .NET的打算,所以对书中很多条款,也有很多自己的思考。如果作为译注来添加,担心把最后的译本添得四不象,不添又甚感遗憾。遂考虑把翻译过程中自己的所思所想直接记录下来,并在自己的bloghttp://blog.dreambrook.com/jzli/)上开辟专门的Effective C#区,供大家讨论打磨,弥补作/译者认识不足的地方,相信也许可以收到正常出版渠道不能取得之效果。言归正传,开始我的Item讨论之旅。

 

使用属性,避免将数据成员直接暴露给外界

ItemAlways Use Properties Instead of Accessible Data Members.

 

学习研究.NET的早期,经常碰到一些学习C#/.NET的朋友问,要属性这种华而不实的东西做什么?后来做项目时也时常接到team里的人的抱怨反馈,为什么不直接放一个public字段?如:

 

class Card

{

   public string Name;

}

 

而非要做一个private字段+public属性?

class Card

{

   private string name;

   public string Name

   {

      get { return this.name;}

      set { this.name=value;}

   }

}

 

我记得在早期的一个项目里,team中的一个朋友甚至厌烦了写private字段+public属性,尤其是碰到一大堆臃肿的data object class的时候,索性自己写了一个小工具,来提供一个类的字段名和类型,然后自动为该类生成相应的private字段+public属性。

 

我在编程的时候是个彻底的实用主义者,用稍微高雅一点的话说叫“不喜欢过度的设计”。如果真的像上面那样写Card,而且在将来没有什么改变的需求,我也不喜欢像上面第2段程序那样把事情故意搞得复杂。但如果从component的角度来讲,总有一些class是要供外部长久地使用,也潜在地在将来有被改变的需求。这时候,提供属性就很有必要了。

 

这就是这个Item试图要归纳的使用属性的理由:

1.可以对赋值做校验、或者额外的处理

2.可以做线程同步

3.可以使用虚属性、或者抽象属性

4.可以将属性置于interface

5.可以提供get-only或者set-only版本,甚至可以给读、写以不同的访问权限(C# 2.0支持)

 

个人感觉34条是属性最大的优点,可以填补没有“虚字段”或“抽象字段”的缺憾,在设计组件的时候非常有用,也体现了C#这样的component-oriented语言的精神内涵。

 

但如果没有上述理由,而且日后对程序做大的改动可能性比较小时,我想也大可不必非要把每个public字段都要变成属性。比如在设计一些轻型的struct,用于互操作的时候,直接使用public字段没什么不好。所以,感觉本条目Bill Wagner先生使用“Always Use Properties Instead of Accessible Data Members”显得太过强硬。

 

其实,这里的讨论也表明阅读《Effective C#》一书时需要注意的地方,即Effective原则并不是放之四海而皆准的。不同的项目(组件化、复用程度较高的项目?还是“一次编写、N年都run”的项目),不同的角色(类库/组件开发人员?还是应用程序开发人员?),有着不同的Effective准则。事实上,书中很多Items都是从类库/组件开发人员的角度来考虑的。

 

关于属性的性能问题需要谈一点,如果仅仅是简单地以存取模式来使用属性,在相当程度上是没有性能损失的。因为在JIT编译过程中已经做了inline的处理。不过inline处理还是有一些基本的条件,有些情况下JIT编译器不会inline,比如虚调用,方法的IL代码长度过长(目前CLR的规定是超过32bytes为代码长度过长),有复杂的控制流逻辑,有异常处理等。这些条件都是要么根本不能使用inline(比如虚属性),要么inline的代价太大,容易导致代码的bloat,要么是inline起来很费时间——已经丧失了inline的意义,因为.NETinline机制发生在JIT过程中。使用属性有个别让人感觉不舒服的地方,比如它影响开发人员的开发效率,但对代码运行的效率不产生影响

 

明辨值类型和引用类型的使用场合

ItemDistinguish Between Value Types and Reference Types

 

这个条款讨论的是类型设计时候的tradeoff——是将类型设计为结构还是类。Bill Wagner先生给出了一个原则“值类型用于存储数据,引用类型用于定义行为(value types store values and reference types define behavior)”。

 

如何判断这个原则的适用性,Bill Wagner也给出了一个方法,那就是首先回答下面几个问题:

1.  该类型的主要职责是否用于数据存储?

2.  该类型的公有接口是否都是一些存取属性?

3.  是否确信该类型永远不可能有子类?

4.  是否确信该类型永远不可能具有多态行为?

 

如果所有问题的答案都是yes,那么就应该采用值类型。这样的判断确实有很好的理由支撑,但是我个人认为“将这4个问题回答为yes”还不足以构成采用值类型的全部理由。因为在很多项目实践中,我发现值类型带来的性能问题不可小视。值类型带来的性能问题主要有两个:

1.由于值类型实例在栈和托管堆之间的转换而导致的box/unbox,以及由此带来的托管堆上的垃圾。

2.值类型默认情况下采用的是值拷贝语义,如果是比较大的值类型,在传递参数和函数返回值时,同样会带来性能问题。

 

关于第1条,Bill Wagner在本条款中提到了“引用类型会给垃圾收集器带来负担”这个表面看似正确的判断。但是由于box/unbox的效应,有些情况下,反倒是值类型给垃圾收集器带来了更多的负担。比如将一些值类型放到一个集合中,然后又频繁地对其进行读写操作。如果碰到这种情况,我想“放弃结构而采用类”未尝不是一种更好的做法。事实上,将一个用作数据存储的值类型(比如System.Drawing.Point)添加到一个集合(System.Collections.ArrayList)中是一个太常见不过的操作。不过,C# 2.0中新引入的泛型技术对box/unbox的问题有极大的改善。

 

关于第2条,Scott Meyers先生在Effective C++的第22条“尽量使用pass-by-reference(传址),少用pass-by-value(传值)”中讲的比较清楚。虽然由于C#中的结构类型具有默认的深拷贝语义,没有拷贝构造器的调用。而且结构类型也没有子类,因此在某种程度上来讲不具有多态性,也就没有C++对象传值时可能出现的切割(slicing)效应。但是值拷贝的成本仍然不小。尤其是在这个值类型比较大的情况下,问题就比较严重。实际上,在.NET框架的Design Guidelines for Class Library Developers文档中,在说明什么时候应该使用结构类型的时候,其中提到了一项原则(还有其他一些并行原则)——类型实例数据的大小要小于16个字节。该文档主要是从类型的运行效率层面来考虑的,而Bill Wagner先生这里的条款主要是从类型的设计层面来考虑的。

 

从上述两条讨论来看,我个人倾向于对结构类型采取更为保守的设计策略。而对于类则可以积极大胆地使用。因为“将结构类型不适当地设计为类”带来的不良后果要远远小于“将类不适当地设计为结构类型”所带来的不良后果。就目前的经验来看,我甚至认为只有和非托管互操作打交道的情况才是使用结构类型最充足的理由,其他情况都要“三思而后行”。当然,在C# 2.0中引入泛型技术之后,box/unbox将不再是一个沉重的负担,应付一些非常轻量级的场合,结构类型依然有自己的一席之地。

最小化内存垃圾

Item 16 – Minimize Garbage

 

内存开销一直是影响软件效率的一大因素,由于.NET中垃圾收集器的引入,许多程序员对此更是耿耿于怀。一个可见的事实是许多程序员都发现使用.NET开发的托管应用程序“占用”的内存通常会比本地应用程序多一些。暂不论使用“任务管理器”查看到的内存“占用”本身就不是一个准确的数字。就算这个数字具有比较意义,“.NET应用程序占用内存多”这一事实背后也有很复杂的因素,并不都是垃圾收集器惹的祸,比如.NET庞大的运行时支持设施本身就会占用可观的内存。

 

虽然不必对.NET中垃圾收集器耿耿于怀,但也不意味着我们可以将所有内存管理的问题完全交给垃圾收集器,而无视内存管理这一问题本身的存在。垃圾收集器只能在很大程度上帮助我们更好地管理内存,而不能彻底屏蔽内存管理问题。毕竟所有对象都要经过分配、活动、销毁这些阶段。

 

因此,降低内存开销一个最直观的做法便是“尽可能地减少所创建的实例对象”。根据该原则,Bill Wagner先生在本条款中提出了以下几个技巧:

 

1.将频繁使用的引用类型局部变量实现为类型的字段。

2.使用Singleton模式来创建频繁使用的实例对象。

3.创建类似StringBuilder这样的可变类型来处理类似String这样具有常量性的类型。

 

这些都是通过对代码进行技巧性的处理,来尽可能地避免创建不必要的实例对象,从而降低内存的开销。这些做法通常也适用于其他编程语言,而且也有很多灵活的变体。比如在一个大的循环结构内,我们应该避免在其中创建实例对象,而尽可能地将创建实例对象的工作放在循环结构之外。

 

事实上,除了要“尽可能地避免创建不必要的实例对象”外,“创建什么样的对象——即如何设计类型”同样会影响内存的开销,并进而影响程序的效率。一些效率低下的隐患可能在类型设计时就埋了下来。其原因通常是类型的实例对象由于内部特殊的结构,从而延缓了垃圾收集器对其的回收。改善这个问题必须从类型设计入手。可惜的是Bill Wagner先生在本书中很少触及这个话题。

 

那么什么样类型的对象实例会延缓垃圾收集对其的回收呢?或者说反过来说,怎样的类型设计是“垃圾收集友好”的呢?基本上来讲,我们有以下几个原则。

 

1.  避免对象之间有过多的引用关系

2.  避免将老对象引用新对象

3.  利用分割设计,将终止化对象设计得尽可能小,避免在其中引用其他对象

 

 

这几个原则基于这样一个事实:.NET采用的是分代式的垃圾收集器,越“新”的对象,释放速度越快;越“老”的对象,释放速度越慢。而垃圾收集器判断对象一个重要的依据就是对象间的引用关系,即所谓的“对象图”。垃圾收集器每次进行内存回收之前,都要构建一个“可达对象图”,如果对象之间有过多的引用关系,那么构建这个图就要花费可观的代价。另外,在这样的对象图中,如果一个老对象引用了一个新对象,那么从某种意义上来讲,这个新对象也被“拖到了老一代”,从而延缓了内存的回收。终止化对象(即重写了Finalize方法的对象)也由于先天的属于“老一代”,因此在其中引用其他对象、或者设计得很大,都是“垃圾收集不友好”的做法。这时候应该进行分割设计,将终止化对象单独隔离出来。

 

值得指出的是,上述几种类型设计技巧通常并不适用于非.NET平台上的语言,因为这些技巧非常依赖于特定的垃圾收集模式(比如.NET的分代式垃圾收集器)。

 

除了类型设计需要考虑特定的垃圾收集模式外,在如何使用垃圾收集器时,还有一个误区需要避免,即调用GC.Collect()方法。很多初学者经常容易把GC.Collect()方法想象成C++中的delete操作符,因此很自然地想通过调用GC.Collect方法来释放内存。这样做对于.NET中的垃圾收集器是不合适的。这是因为GC.Collect不会精确地回收单个对象的内存,一旦执行就是回收一个代、甚至所有代的对象内存。而GC在做这样的回收时,需要花费相当的时间来构造一个可达的对象图。对于一个具有自学习和自调解能力的垃圾收集器来说,“什么时候进行收集、收集哪些代的对象”应该由垃圾收集器根据整个系统中的对象分布情况,以及内存占用情况来做判断,程序员绝大多数情况下无需进行任何干预。调用GC.Collect往往会打乱垃圾收集器自学习和自调节的过程,收到适得其反的效果。

 

最小化装箱与拆箱

 

Item 17 – Minimize Boxing and Unboxing

 

装箱与拆箱虽然是.NET中一个创新的名词,但却是一个彻头彻尾的旧技术——“新瓶装旧酒”好像是微软在技术界一贯的作风J。不过这并不影响人们对它进行三番五次的讨论,几乎在所有有关C#的书中,装箱与拆箱都被放到了显著的位置,Effective C#也不例外。

 

除了需要避免一些不必要的装箱与拆箱操作(比如将一些频繁的装箱操作变为一次装箱操作、比如在struct中重写所有ValueTypeObject中的虚方法来减少struct被装箱的机会)外,对于某些“通常很难避免装箱与拆箱”的情况,.NET社区很早都有一种改善的做法——即“通过实现接口来修改托管堆中的装箱值类型实例”,这个技巧在本条款中得到了详细的讨论。不过我对这种做法一直持保留态度,至少我在项目中很少使用。因为我在设计上比较崇尚“单目标”原则——接口是用来定义组件间的契约合同的,不是用来解决装箱/拆箱的性能问题的。就像我看到在Java中通过实现Serializable接口来表达允许一个类可序列化,我同样感到不舒服(所幸的是.NET有了Attributes这么好的东西J):

class MyClass implements Serializable

{....}

 

如果真的是装箱和拆箱掉到了不可思议的循环语句里,我更喜欢将值类型直接修改为引用类型——而不是给它实现一个看起来很不舒服的接口。当然不舒服并不是错误,至少是一种可选的解决方法。

 

这些讨论都不错,不过如果能够跳出C#来看问题,也许别有一番景致。所谓“不知庐山真面目,只缘身在此山中”。装箱/拆箱本身没有错,既然.NET对象主要有两种存储方式(栈与托管堆),那么允许在两者之间进行转换是一个自然的需求。但问题是,C#在进行装箱之后就失去了它的类型信息。比如我们有一个MyStruct结构,装箱之后只能访问System.Object的那些成员,要访问MyStruct结构本身所具有的成员就必须再进行拆箱——当然拆完箱之后通常还需要再装箱J。“通过实现接口来修改托管堆中的装箱值类型实例”固然为装箱值类型实例提供了更强的类型信息,但毕竟不是解决问题的永久办法。

 

事实上,.NET是提供有“强类型的装箱值类型”的,只不过C#语言不支持这样的类型,目前只有C++/CLI(即2005版的Visual C++)支持这样的类型,例如对于一个结构类型MyStruct,我们可以在C++/CLI中这样来操作:

       MyStruct^ s=gcnew MyStruct();

       s->MyMethod();

 

第一行语句进行了装箱操作,但是随后对MyMethod()成员的访问则不需要C#中通常的“拆箱、拷贝到栈上,再装箱”的过程。

 

       这里需要先解释一下“拆箱、拷贝到栈上,再装箱”。实际上,.NET中的装箱与拆箱操作并不完全是一对相反的操作。准确地讲,“拆箱+拷贝”才和“装箱”是一对相反的操作——换言之,装箱操作本身含有一个“将对象从栈上拷贝到托管堆上的过程”。事实上,拆箱操作本身没有太大效率问题,真正的效率问题是在“拷贝”上。

 

       从类型层面来看,C#中没有类型可以表达“一个装箱值类型实例拆箱后的实例”——注意这里谈的是“只拆箱,而不拷贝”,因此不得不把它拷贝到栈上。而在C++/CLI中,内部指针(Interior Pointer)可以表达这样的类型——这样的类型既位于托管堆上,又具有MyStruct强类型信息。无需再拷贝到栈上来访问MyStruct的成员。自然也就免除了进一步装箱的必要。

 

       我不知道当初C#的设计师们为什么决定不支持内部指针(在CLI中,该类型被称为托管指针,即managed pointer)。至少从C++/CLI语言来看,内部指针有很多用武之地——解决“强类型的装箱值类型实例”只是内部指针一个很小的施展。事实上,内部指针是C++/CLI用来构建STL.NET的关键设施。

鲜花

握手

雷人

路过

鸡蛋
该文章已有0人参与评论

请发表评论

全部评论

专题导读
热门推荐
热门话题
阅读排行榜

扫描微信二维码

查看手机版网站

随时了解更新最新资讯

139-2527-9053

在线客服(服务时间 9:00~18:00)

在线QQ客服
地址:深圳市南山区西丽大学城创智工业园
电邮:jeky_zhao#qq.com
移动电话:139-2527-9053

Powered by 互联科技 X3.4© 2001-2213 极客世界.|Sitemap