不要让编写set访问器成为习惯

在C#中写一个类的时候,你会毫不犹豫地为类的每个字段(field)写上get和set访问器吗?如果是在从前,我会这么做的,但现在知道,不能这么做。

感觉许多很重要很经典的话都很短,往往看得懂,都以为自己理解了,但事实上并没有完全理解,就像Set访问器。刚开始学习C#的时候,就知道了访问器,书上说了,把字段声明为private,然后通过设置set访问器来暴露它,是为了更好的封装性,在设值的时候可以有更多的控制,这其实还是很好理解的,但就这“封装”二字,我当时并非真正理解(其实现在也没有真正理解,只能说相比以前理解得更深入些)。于是,我也就那么跟着书上做了,声明一个private的字段,然后写上setter,而setter中几乎都是XXX = value,这虽然比直接用public公开字段要好,但其实还是没有很好的“封装”,在OOP中,我们不希望一个对象过多的访问其它对象的数据,而是希望通过调用其它对象的某个方法,来达到自己的目的,用通俗的话说,就是一个对象只需要告诉其它某个对象要做成什么事情,比如去把水烧开了,把电视机关掉,而不关心他所吩咐的那个对象是如何完成事情的。所以,有许多字段,我们不应该为其提供set访问器。

举个例子,一个客户类Customer:
public class Customer {
    private int id; //客户ID
    private string name;
    //...
    public void Save() {
        //如果id大于0就更新数据库中的客户记录,否则就往数据库插入新记录
    }
}
就说这Cutomer的id字段。假设一个客户拥有一个惟一的ID,如果此时为id字段写一个set访问器,那程序员就可以随时随意地更改id,这是很容易犯错的,比如把客户A的id改成了数据库中一个已有的客户B的id(不是故意的),那一旦调用Save()时,这个客户A的信息全给更新到客户B上面去了,可怕吧?还有更糟的,就是此时可能程序看起来还一切正常,等将来出问题时,那就是大问题了!

既然没有了set访问器,那自然就要提供一个带id参数的构造方法:Customer(int id),这样就可以在创建新客户时指定id,一旦指定后就不能改。

假设现在确实有更改一个客户的id的需求,那要咋办?我想可以通过提供一个SetId(int newId)的方法来实现。这会儿可能会有人想问:“前面不是说了尽量不要用set访问器,你这来个SetId(int newId)方法,不就相当于set访问器吗?这不是前后矛盾了?”。我是这么认为的:首先,如果我是机器,那我就不会觉得set访问器和SetXXX()方法有什么不同,反正都是将一个新值赋给一个字段并做些控制。但我觉得它们的区别更多的应该从语义的层面去看:set访问器强调改变“数据”,而SetXXX()方法则强调改变数据的这个“操作”。就比如前面说的id,如果用set访问器,那就容易出错,如果是用SetId(int newId)呢?当程序员调用这个方法时,他很可能就会注意到,哦,现在我是在做“改变客户id”的操作,那他就会更谨慎一些,出错的机率也就更小(是更小,而不是说用了SetId(int newId)来替代set访问器就能杜绝错误)。也就是说,set访问器和SetId(int newId)的关键区别,是前者易使程序员犯错(逻辑错误),而后者不会。尤其当时间久了以后,或者程序是作为类库给别人使用的时候。如果是写库给别人用,我们就应该考虑让一个水平不怎么样的程序员也能用自己的库轻松地写出高质量的程序。

同样,get访问器和GetXXX()方法也是有区别的,还是上面的那个例子,假设Cutomer加了一个IList<Order> orders字段,Order是订单类,表示客户的订单(假设是网上商城的场景)。代码类似下面:
public class Customer {
    private IList<Order> orders;
    public IList<Order> Orders {
        get { //...  }
    }
}
我们不希望其它的对象能随意修改orders列表,但又需要取出订单列表来显示,所以我们往往在“取订单”时返回订单列表的一个副本(我们可以通过提供AddOrder(Order order)方法来实现对订单的添加等需要的操作。如果不是返回副本,而是直接返回订单列表的引用:IList<Order> orders = customer.Orders,当orders这个引用散布在程序中时,订单中的数据就可能会被错误更改,比如表现层中要对订单进行过滤,把一个月以前的订单剔除后再显示,此时可能就会调用orders.Remove(XXX)来移除一个月前的元素,事实上我们只是希望在表现层显示时剔除一个月前的元素,如果此时有另一个操作要保存Customer实例,保存Customer时同时保存了订单信息,于是一个隐蔽的错误就产生了:订单数据莫名的丢失。可怕的是它还不容易被马上发现,等我们发现这个Bug时,可能就已经酿下大错了),也就是说,get访问器中要执行一个拷贝操作,这个操作是要损失性能的,如果要遍历订单,程序员很可能就会这么写:
for (int i = 0; i < customer.Orders.Count; i++) {  //...  }
每循环一次,就得调用一下get访问器,每调用一次get访问器,就得执行整个订单列表的拷贝操作,想想这有多损失性能。 但如果我们不提供get访问器,而是提供GetOrders()方法的话,程序员可能就会在调用这个方法时注意到,哦,现在是做取订单操作,这个操作会不会是返回订单列表的拷贝呢?于是他查看了文档,写下了下面的代码:
IList<Order> orders = customer.Orders;
for (int i = 0; i < orders.Count; i++) {  //...  }
这段代码的性能可要比上面那段好得多得多了。是不是说用GetOrders()方法后程序员就不会写出下面这样的代码呢:
for(int i = 0; i < customer.GetOrders().Count; i++) {  //...  }
会的,但是我相信会这么写代码的人相比前者要少许多。

所以,我觉得get访问器和GetXXX()方法的区别还是要更多的从语义上来看。用最自然的方式,用最不容易让类的使用者出错的方式来设计类。

总结一下,本文的观点有两:
1. 不要想当然地为每个字段提供一个set访问器;
2. 区分get访问器和getXXX()方法;
posted @ 2008-11-28 18:40  水言木  阅读(0)  评论(2编辑  收藏  举报