这里主要讲的是C#的语言特征怎么样可以更好地在你的Design里面用到。
(1) 定义和实现接口优于继承基类
接口定义了行为,而基类定义了对象是什么。使用接口,每个实现的类必须实现所有的方法,属性和事件。而基类可以为不同的子类提供默认的实现,也可以采用Virtual关键词使子类可以重写也可以不重写,也可以用abstract关键词强迫子类必须实现。接口定义了一系列的行为,一旦接口改变了,所有的实现类都必须重写以实现新的接口;而基类则可以添加新的方法,所有的实现子类并不需要进行修改就可以使用基类改变的方法。
应该使用接口来定义参数和返回值,特别是因为C#是单继承的,不支持多继承。使用接口定义参数和返回值有以下的好处:
(a) 更灵活,只要实现了该接口的类都可以使用
(b) 采用接口,你定义了方法所要暴露的行为,而实现该接口的类可以随着时间的改变而改变,是实现方面的问题
(c) 不同的类可以实现相同的接口
(d) 对于值类型来说,使用接口还能够偶尔避免Boxing和UnBoxing的代价。
(2) 实现接口和重写虚拟函数
子类不能够重写基类所实现的接口函数,比如:
interface IMsg { void Message(); }
public class MyClass : IMsg
{
public void Message() { }
}
public class DerivedClass : MyClass
{
public new void Message() { }
}
这样定义的DerivedClass var; (var as IMsg).Message调用的实际上是MyClass.Message方法。当然,可以使得继承类也实现IMsg接口来解决这个问题。
public class DerivedClass : MyClass, IMsg
这样(var as IMsg).Message访问的就是DerivedClass.Message的方法。但这时候如果定义MyClass var2 = var; 调用(var2 as IMsg).Message访问的还是MyClass.Message方法。
当然,也有办法解决这个问题,将MyClass的Message函数声明为虚拟函数,这样就能够使得Message函数能够被正确调用了。
(3) 回调函数使用Delegate来定义
任何时候如果想要定义两个类间的交流,而又不想两个类的耦合太高,可以考虑使用回调函数,使用Delegate来实现。但要注意多个回调函数可能出现的问题:如果有些回调函数会抛异常,则会终止其它回调函数的调用;如果回调函数有返回值,则返回的是最后一个回调函数的值。
(4) 定义事件
如果你的类必须与多个客户进行通讯,并且通知系统的操作,那么可以使用事件来实现。通常的方法:
public class MyClass
{
public event MessageEventHandler Log;
public void AddMsg()
{
MessageEventHandler handler = Log;
if ( handler != null) handler (null, new EventArg());
}
}
之所以要先将Log拷贝到handler变量上,是为了保证该方法在多纯种环境下的安全。如果不这么做,则在判断是否为null和之后的handler(null, ...)可能会出现多线程下的脏数据的问题。另外,对于event来说,只需要声明一个"public event MessageEventHandler Log"就行了,C#编译器会自动生成Add/Remove方法。
如果一个类有很多的事件,则声明很多的事件会使得这个类很难维护,可以使用EventHandlerList集合来维护所有的事件列表。比如System.Windows.Forms.Control就使用了这个类来维护很多的事件。
(5) 返回只读的属性
要注意C#是引用类型,所以一个属性虽然被标记成只读,但如果该属性返回的值是引用类型并且能够修改其内部状态的话,则该只读属性的状态可能还是会被外部所修改。有以下四种方法可以保证返回只读的属性:值类型;不可变类型;返回接口(虽然用户可以猜其类型,并转换到相应的类型来进行操作,但这种情况下是用户的问题了)和包装成另一个只读的对象。
如果外部程序能够修改类的内部数据,则应该实现观察者模式,这样还能够在数据被改前和被改后进行一些操作。
(6) 采用Declarative编程甚于采用Imperative编程
通常情况下我们都是采用了Imperative编程,比如类的方法那些,但如果采用Declarative编程,则会使得算法比较不易出错,并且程序的行为随着声明的改变而改变。比如C#里面的属性(Attribute),就是Declarative编程的一种形式。例如:
[WebMethod]
public void HelloWorld() { }
这里的[WebMethod]采用的就是Declarative编程,加多一个属性给这个函数,.NET类库能够根据这个属性生成相应的文档、Web Service的调用的代码等。相比于用户每次都自己写代码实现,或者调用一些帮助函数之类的,这种Declarative的方式更容易维护,更不容易出错。
当然,C#类库提供的属性并不一定能够满足用户的需求,这时可以考虑实现自己的属性标签。
(7) 使类可序列化
数据的存储在开始的时候往往并不会引起程序员的注意,只有到需要的时候才会发现需要改动的地方已经很多了。通常情况下,如果这个类不是UI,Windows,Forms那些,其实都可以实现序列化的,也很容易实现。对于最简单的序列化的支持,就是采用[Serializable]属性标签,应用于类上,可以使整个类都支持序列化,但要求其里面的所有变量都要支持序列化,不然会出现异常。例如:
[Serializalbe]
public class MyType {}
就使得MyType这个类可以序列化。使用了Serializable属性标签,.NET类库会将所有的变量都序列化,即使在这些变量里面存在循环引用,.NET也能够保证所有的变量只被序列化一次。序列化是支持二进制和SOAP格式的。 如果某个特定的变量不需要序列化,比如用来作缓存的数据,可以在该变量上使用[NonSerialized]属性。
要特别注意的是反序列化数据的时候并不会调用构造函数,所有的NonSerialized的变量都是初始化为0或者null的,如果要在反序列化的时候初始化这些变量,可以实现IDeserializationCallBack接口,对所有实现这个接口的类,在类的所有变量都已经反序列化完之后就会调用该接口上的OnDeserialization函数,但要知道这个函数的调用在多个类之间的顺序是不确定的,如果某个类在初始化的时候需要使用到其它类的NonSerialized变量,则要考虑到可能其它类的变量还没有被初始化。
如果要提供自定义的序列化方法,则可以实现ISerializable接口,里面有一个GetObjectData函数,用于序列化所有的变量,同时还要实现构造函数(SerializationInfo info, StringContext cntxt),用于反序列化类。序列化的数据是以键/值的形式存储的。(注:书上说键的顺序也是很关键的,即先声明的变量应该先存储,但实现上根据我的实验,这个顺序并不是那么关键,序列化和反序列化是根据键来存储/读取值的,只要键值保证一样,顺序并不重要)。
实现ISerializable接口要特别留意的是如何支持继承类,需要将构造函数(info, cntxt)声明为Protected,这样继承类才能够使用,同时要提供一个虚函数供继承类在序列化的时候存储值。例如:
public class MyType : ISerializable
{
protected MyType(SerializationInfo info, ContextMenu, cntxt)
{
val = info.GetValue("val");
............
}
void ISerializable.GetObjectData(SerializationInfo info, ContextMenu, cxt)
{
info.AddValue("val", val);
...........
WriteObjectData(inf, cxt);
}
public virtual void WriteObjectData(SerializationInfo info, ContextMenu cxt) { }
}
以上的WriteObjectData就是用来给于子类序列化数据使用的。比如:
public class DerivedClass : MyType
{
private DerivedClass(SerializationInfo info, ContextMenu cntxt) : base(info, cntxt)
{
var2 = info.GetValue("var2");
}
protected override void WriteObjectData(SerializationInfo info, ContextMenu cxt)
{
info.AddValue("var2", var2);
}
}
(8) 使用IComparable和IComparer实现类的顺序关系
IComparable用于类的内在自然的顺序,这个接口有一个方法CompareTo(Object obj)。这个方法的实现并不难,比如:
public int CompareTo(object right)
{
if (! (right is ThisType)) {throw exception;}
// Compare the data.
}
但这个方法容易使用户传进不合法的参数而抛出异常,而且效率方面也会比较差,因为你需要使用强类型转换。特别是对于值类型的变量,需要进行Boxing和UnBoxing的操作。这时,我们可以这样实现:
int IComparable.CompareTo(object right) {}
public int CompareTo(ThisType right) {}
没错,就是同时实现这两个函数,如果用户在声明的时候不是使用IComparable变量,而是直接使用ThisType变量,则第一个方法是不可见,也是编译不会通过的,用户调用的是第二个强类型的方法,不需要进行强类型转换,效率方面是比较高的,而且如果用户传入不是ThisTyp类型的变量,编译的时候会报错。但是对于类库里面的排序方法,因为它还是采用IComparable接口来实现排序,因此这些好处对于类库都不可见。
在实现了IComparable方法的时候,要同时实现>,>=这些操作符。
IComparer接口用于在类需要按照其它类型来进行排序的时候使用,IComparer被显式地传进集合的排序方法里面。如果能够操作类本身,可以直接在类里面提供静态的IComparer实例,比如:
public struct Customer : IComparable
{
public static IComparer RevenueCompare {....}
}
这样对于这个类的使用者,可以直接取到该实例。当然,如果不能够在类里面提供,也可以实现自己的IComparer类。(寒,好像不知在说什么.......)
实现了IComparable和IComparer并不需要实现Equals那些操作,Equals和IComparable和IComparer可以是不一致的,IComparable和IComparer返回相等的两个对象,并不代表在Equals中一定要返回相等。
(9) ICloneable接口
应该尽量不去实现ICloneable接口。首先实现了ICloneable影响了所有的继承类,所有的继承类也必须实现ICloneable接口,并且继承类只能包括值变量或者支持ICloneable接口的变量,这样对继承类的限制太大了。而且ICloneable接口包括两种拷贝:浅拷贝(Shallow Copy)和深拷贝(Deep Copy),对于浅拷贝还是深拷贝,经常引用使用者的混淆。
对于所有的值类型,不要去实现这个接口,因为本身值类型的赋值操作是浅拷贝的,如果为这个值类型实现一个深拷贝的方法,则还是避免不了使用者通过赋值操作进行浅拷贝,反而引起使用上的不方便。所以没有一个理由去为值类型实现这个接口。
对于引用类型,只有在Sealed的类上面才去实现这个接口,其基类通过一些Protected的构造函数来为继承类提供实现这个接口的帮助。因为如果在基类上面实现这个接口,则要求继承的类也必须实现这个接口,同时只能包括值类型变量或者支持ICloneable接口的引用变量。而且这时候基类必须能够Hook的方法(还记得在Dispose方法是怎么实现的吗?),才能保证继承类的实现方法是正确的。以下是基类提供Protected构造函数为继承类实现这个接口提供帮助的示例:
public class BaseType
{
protected BaseType(Basetype right)
{// Assign the value from right to this class.}
}
public sealed class Derived : BaseType, ICloneable
{
public object clone()
{
Derived rVal = new Derived (this);
// Other assignment in this class.
}
}
(10) 避免隐式转换操作符Implicit Operator
转换操作符引入了两个不同类之间的可替代性substitutability。转换操作符会引起以下的几个问题:首先,转换出来的对象可能不是目标对象的一个理想值,这会引起一些副作用,并引起一些微妙的臭虫BUG。更糟糕的是,转换操作符创建出一个临时对象,使得在这临时对象上的操作不会反映到原来的对象中,而使用的时候可能会不注意到这些细节,而且这个临时对象马上也被GC回收了。最后就是这些转换操作符是在编译的时候进行的,而不是运行时,因此使用的时候需要经过多次Cast才能调用这个转换操作(我也不明白这句是为什么)。
以下是转换操作符的一个示例:
public class A() {}
public class B()
{
static public implicit A() {return new B();}
}
如果对于一个方法Method(A a),用户传进一个B类型的对象,这个方法也能够被调用,因为这时候隐式转换符就起作用了,但这时候会生成一个临时的对象B,任何在B上的操作都不会在A上体现出来。
从书中的意思是说这样的操作符容易引起很微妙的臭虫,要尽量避免使用。
(11) 避免使用New
如果你在类中使用了New操作符,那么对于一个对象的两个调用方式会产生不同的效果。比如
public class A() { public void Method() {} }
public class B() { public new void Method() {} }
B b = new B();
b.Method();
A a = b;
a.Method();
在上面的示例是,b.Method()和a.Method()调用的方法是不同的,这造成很多的误解。
但也不能过渡泛滥地使用Virtual方法,Virtual方法只用来表示你允许继承类修改这些方法的行为,同时也表示了你暴露出去的自定义你这个类的入口。
通过情况下我们都要避免继承类的方法和基类的方法同名,除非当你已经发布了你的接口,有客户在使用他的时候,而这时基类进行了更新,加入了你继承类中本来就已经有的方法,这时候你不得不加入New修饰词。实际上,这时候你也应该再三考虑一下是把这个方法命名为其它名字还是继续使用原来的名字。不到万不得已的时候不要去使用New操作符。
(怎么我觉得我以前经常在使用这个New呢?看来习惯不好)

本文探讨了C#编程中的多种实用设计和技术要点,包括接口与继承的选择、回调函数的使用、事件处理的最佳实践、序列化的实现方法以及类比较和克隆的建议。
3146

被折叠的 条评论
为什么被折叠?



