资源管理和序列化
资源管理(尤其是内存回收)曾经是程序员的噩梦,不过在.NET平台上这个噩梦似乎已经不复存在。CLR在后台为垃圾回收做了很多事情,使得我们现在谈起在.NET上进行开发时,都会说还是new一个对象吧!回收?有垃圾回收器呢。其实并没有这么简单,本章将会从资源管理的角度给出若干建议,以帮助大家构建一个更简洁、更高效的应用程序。
另外,对象序列化是现代软件开发中的一项重要技术,无论是本地存储还是远程传输,都会使用序列化技术来保持对象状态。本章同时也会在对象序列化方面给出若干有用的建议。
建议46 : 显式释放资源需继承接口IDisposable
在开始本建议之前,需要明确什么是C#程序(或者说.NET)中的资源。简单来说,C#中的每一个类型都代表一种资源, 而资源又分为两类:
- 托管资源由CLR管理分配和释放的资源,即从CLR里new出来的对象。
- 非托管资源不受CLR管理的对象,如Windows内核对象,或者文件、数据库连接、套接字、COM对象等。
如果我们的类型使用到了非托管资源,或者需要显式地释放托管资源,那么就需要让类型继承接口IDisposable
,这毫无例外。这相当于告诉调用者:类型对象是需要显式释放资源的,你需要调用类型的Dispose方法。
不过,这一切并没有听上去的那么简单,一个标准的继承了IDisposable 接口的类型应该像下面这样去实现。这种实现我们称为Dispose 模式:
public class SampleClass : IDisposable
{
//演示创建一个非托管资源
private IntPtr nativeResource = Marshal.AllocHGlobal(100);
//演示创建一个托管资源
private AnotherResource managedResource = new AnotherResource();
private bool disposed = false;
/// <summary>
/// 实现IDisposable中的Dispose方法
/// </summary>
public void Dispose()
{
//必须为true
Dispose(true);
//通知垃圾回收机制不再调用终结器(析构器)
GC.SuppressFinalize(this);
}
/// <summary>
/// 必须,防止程序员忘记了显式调用Dispose方法
/// </summary>
~SampleClass()
{
//必须为false
Dispose(false);
}
// 非密封类修饰用protected virtual
// 密封类修饰用private
protected virtual void Dispose(bool disposing)
{
if (disposed)
{
return;
}
if (disposing)
{
// 清理托管资源
if (managedResource != null)
{
managedResource.Dispose();
managedResource = null;
}
}
// 清理非托管资源
if (nativeResource != IntPtr.Zero)
{
Marshal.FreeHGlobal(nativeResource);
nativeResource = IntPtr.Zero;
}
//让类型知道自己已经被释放
disposed = true;
}
public void SamplePublicMethod()
{
if (disposed)
{
throw new ObjectDisposedException("SampleClass", "SampleClass is disposed");
}
//省略
}
}
class AnotherResource : IDisposable
{
public void Dispose()
{
}
}
如果类型需要显式释放资源,那么一定要继承IDispose接口。
继承IDispose接口也为实现语法糖using带来了便利。在C#编码中,如果使用using,编译器会自动为我们生成调用Dispose方法的IL代码.
如果存在两个类型一致的对象, using还可以这样使用:
using (sampleClass c1 = new SampleClass(), c2 = new SampleClass())
{
...;
}
如果两个类型不一致,则可以这样使用:
using (SampleClass c1 = new SampleClass())
using (Samp1eAnothorClass c2 = new SampleAnothorClass())
{
...;
}
建议47 : 即使提供了显式释放方法,也应该在终结器中提供隐式清理
提供终结器(~类名() {}
)的意义在于:我们不能奢望类型的调用者肯定会主动调用Dispose方法,基于终结器会被垃圾回收器调用这个特点,它被用作资源释放的补救措施。
在这里有必要对“终结器会被垃圾回收器调用”进行进一步的阐述。我们知道,在.NET中每次使用new操作符创建对象时,CLR都会为该对象在堆上分配内存,一旦这些对象不再被引用,就会回收它们的内存。
- 对于没有继承IDisposable接口的类型对象,垃圾回收器则会直接释放对象所占用的内存;
- 而对于 实现了Dispose模式的类型 ,在每次创建对象的时候,CLR都会将该对象的一个指针放到终结列表中,垃圾回收器在回收该对象的内存前,会首先将终结列表中的指针放到一个freachable队列中。同时,CLR还会分配专门的线程读取freachable队列,并调用对象的终结器,只有到这个时候,对象才会真正被标识为垃圾,并且在下一次进行垃圾回收时释放对象占用的内存。
可以看到,实现了Dispose 模式的类型对象,起码要经过两次垃圾回收才能真正地被回收掉,因为垃圾回收机制会首先安排CLR调用终结器。基于这个特点,如果我们的类型提供了显式释放的方法来减少一次垃圾回收,同时也可以在终结器中提供隐式清理,以避免调用者忘记调用该方法而带来的资源泄漏。
public void Dispose()
{
// 正常的垃圾回收
Dispose(true);
// 通知垃圾回收机制不再调用终结器
// FCL提供的静态方法来通知垃圾回收器
GC.SuppressFinalize(this);
}
建议48 : Dispose方法应允许被多次调用
一个类型的Dispose方法应该允许被多次调用而不抛异常。鉴于这个原因,类型内部维护了一个私有的布尔型变量disposed.
对象被调用过Dispose方法,并不表示该对象已经被置为null,且被垃圾回收机制回收过内存,已经彻底不存在了。事实上,对象的引用可能还在。但是,对象被Dispose过,说明对象的正常状态已经不存在了,此时如果调用对象公开的方法,应该会为调用者抛出一个ObiectDisposedException。 方法SamplePublicMethod为我们演示
public void SamplePublicMethod()
{
if(disposed)
{
throw new ObjectDisposedException("SampleClass", "SampleClass is disposed!");
}
...;
}
所以,在Dispose模式中,应该始终为类型创建-一个变量,用来表示对象是否已经Dispose过。
建议49 : 在Dispose模式中应提取一个受保护的虚方法
应该注意到:在标准的Dispose模式中,真正实现IDisposable接口的Dispose方法并没有做实际的清理工作,它其实是调用了下面这个带布尔参数且受保护的虚方法:
// 实现IDisposable中的Dispose方法
public void Dispose()
{
//必须为true
Dispose(true);
...;
}
/// 非密封类修饰用protected virtual
// 密封类修饰用private
protected virtual void Dispose(bool disposing)
{
...;
}
之所以提供这样一个受保护的虚方法,是因为考虑了这个类型会被其他类继承的情况。如果类型存在一个子类,子类也许会实现自己的Dispose模式。受保护的虚方法用来提醒子类:必须在实现自己的清理方法时注意到父类的清理工作,即子类需要在自已的释放方法中调用base.Dispose
方法。查看下面这个标准的子类Dispose 模式中是如何调用base.Dispose
方法的:
public class DerivedSampleClass : SampleClass
{
//子类的非托管资源
private IntPtr derivedNativeResource = Marshal.AllocHGlobal(100);
//子类的托管资源
private AnotherResource derivedManagedResource = new AnotherResource();
//定义自己的是否释放的标识变量
private bool derivedDisposed = false;
// 非密封类修饰用protected virtual
// 密封类修饰用private
protected virtual void Dispose(bool disposing)
{
if (derivedDisposed)
{
return;
}
if (disposing)
{
// 清理托管资源
if (derivedManagedResource != null)
{
derivedManagedResource.Dispose();
derivedManagedResource = null;
}
}
// 清理非托管资源
if (derivedNativeResource != IntPtr.Zero)
{
Marshal.FreeHGlobal(derivedNativeResource);
derivedNativeResource = IntPtr.Zero;
}
//调用父类的清理代码
base.Dispose(disposing);
//让类型知道自己已经被释放
derivedDisposed = true;
}
}
如果不为类型提供这个受保护的虚方法,很有可能让开发者设计子类的时候忽略掉父类的清理工作。所以,基于继承体系的原因,要为类型的Dispose模式提供一个受保护的虚方法。
建议50 : 在Dispose模式中应区别对待托管资源和非托管资源
我们应该已经注意到:真正撰写资源释放代码的那个虚方法是带有一个布尔参数的。带着这个参数,是因为我们在资源释放时要区别对待托管资源和非托管资源。在供调用者调用的显式释放资源的无参Dispose方法中,调用参数是true,如下所示:
// 手动调用,这表明,这个时候代码要同时处理托管资源和非托管资源。
// 实现IDisposable中的Dispose方法
public void Dispose()
{
// 必须为true
// 清理托管资源,并告诉垃圾回收器不必再调用终结器
Dispose(true);
//通知垃圾回收机制不再调用终结器(析构器)
GC.SuppressFinalize(this);
}
// 在供垃圾回收器调用的隐式清理资源的终结器中,调用参数是false
// 这表明,隐式清理时,只要处理非托管资源就可以了。
~SampleClass()
{
// 必须为false
// 不清理托管资源交给垃圾回收器处理
Dispose(false);
}
那么,为什么要区别对待托管资源和非托管资源呢?在仔细阐述这个问题之前,我们首先需要弄明白:托管资源需要手工清理吗?
不妨先将C#中的类型分为两类,
- 一类继承了IDisposable接口; 暂且称为非普通类型
- 一类则没有继承。暂且称为普通类型。
非普通类型因为包含非托管资源,所以它需要继承IDisposable接口,但是,这里面包含非托管资源的类型本身,而它是一个托管资源。所以,对于刚才提出的问题,
答案就是:托管资源中的普通类型不需要手动清理,而非普通类型是需要手动清理的(即调用Dispose方法)。
Dispose模式设计的思路基于:如果调用者显式调用了Dispose方法,那么类型就该按部就班地将自己的资源全部释放。如果调用者忘记调用Dispose方法了,那么类型就假定自己的所有托管资源(哪怕是那些上段中阐述的非普通类型)会全部交给垃圾回收器回收,所以不进行手工清理。理解了这一点,我们就理解了为什么在Dispose方法中,虚方法传入的参数是true,而在终结器中,虚方法传入的参数是false.
不管调用Dispose方法传true还是false都会清理非托管资源. 并且还要调用基类的处理方法:base.Dispose(disposing);
protected virtual void Dispose(bool disposing)
{
if (derivedDisposed)
{
return;
}
if (disposing)
{
// 清理托管资源
if (derivedManagedResource != null)
{
derivedManagedResource.Dispose();
derivedManagedResource = null;
}
}
// 清理非托管资源
if (derivedNativeResource != IntPtr.Zero)
{
Marshal.FreeHGlobal(derivedNativeResource);
derivedNativeResource = IntPtr.Zero;
}
//调用父类的清理代码
base.Dispose(disposing);
//让类型知道自己已经被释放
derivedDisposed = true;
}
建议51 : 具有可释放字段的类型或拥有本机资源的类型应该是可释放的
在建议50中,我们将C#中的类型分为:普通类型和继承了IDisposable接口的非普通类型。非普通类型除了那些包含托管资源的类型外,还包括类型本身也包含一个非普通类型的字段的类型。
在标准的Dispose模式中,我们对非普通类型举了一个例子:
一个非普通类型AnotherResource。由于AnotherResource是一个非普通类型,所以如果现在有这么一个类型,它组合了AnotherResource,那么它就应该继承IDisposable接口,代码如下
class AnotherSampleClass : IDisposable
{
private AnotherResource managedResource = new AnotherResource();
private bool disposed = false;
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
~AnotherSampleClass()
{
Dispose(false);
}
protected virtual void Dispose(bool disposing)
{
if (disposed)
{
return;
}
if (disposing)
{
// 清理托管资源
if (managedResource != null)
{
managedResource.Dispose();
managedResource = null;
}
}
disposed = true;
}
public void SamplePublicMethod()
{
if (disposed)
{
throw new ObjectDisposedException("AnotherSampleClass", "AnotherSampleClass is disposed");
}
//省略
}
}
类型AnotherSampleClass虽然没有包含任何显式的非托管资源,但是由于它本身包含了一个非普通类型,所以我们仍旧必须为它实现一个标准的Dispose模式。除此以外,类型拥有本机资源(即非托管类型资源),它也应该继承IDisposable接口。
建议52 : 及时释放资源
很多人会注意到:垃圾回收机制自动为我们隐式地回收了资源(垃圾回收器会自动调用终结器),于是不禁会问:为什么还要主动释放资源呢?我们来看以下这个例子:
// 如果连续单击打开文件按钮,则会报错,因为文件被另一进程使用中
// 如果先单击一次打开文件, 再点击清理按钮, 则运行正常
private void btnOpen_Click(...)
{
FileStream fileStream = new FileStream(@"c:\test.txt",FileMode.Open);
}
// 强制回收所有"代"的垃圾
private void btnGC_Click(...)
{
System.GC.Collect();
}
现在来分析: 在打开文件的方法中,方法执行完毕后,由于局部变量fileStream在程序中已经没有任何地方引用了,所以它会在下一次垃圾回收时被运行时标记为垃圾。那么,什么时候会进行下一次垃圾回收呢,或者说垃圾回收器什么时候才开始真正进行回收工作呢?微软官方的解释是,当满足以下条件之一时将发生垃圾回收:
- 系统具有低的物理内存
- 由托管堆上已分配的对象使用的内存超出了可接受的范围
- 调用GC.Collect方法。
几乎在所有情况下,我们都不必调用GC.Collect方法,因为垃圾回收器会负责调用它。但在本实例中,为了体会一下不及时回收资源的危害,所以进行了一次GC.Collect方法的调用,大家可以仔细体会运行这个方法所带来的不同。
垃圾回收机制中还有一个“代”的概念。一共分为3代: 0代、1代、2代。第0代包含一些短期生存的对象,如示例代码中的局部变量fleStream就是一个短期生存对象。当btnOpen.Click退出时,fleStream 就被丢到了第0代,但此刻并不进行垃圾回收,当第0代满了的时候,运行时会认为现在低内存的条件已满足,那时才会进行垃圾回收。所以,我们永远不知道fleStream这个对象(或者说资源)什么时候才会被回收。在回收之前,它实际已经没有用处,却始终占据着内存(或者说资源)不放,这对应用系统来说是一种极大的浪费, 并且,这种浪费还会干扰程序的正常运行(如在本实例中,由于它始终占着文件资源,导致我们不能再次使用这个文件资源了)。
不及时释放资源还带来另外一个问题。在建议47中我们已经了解到,如果类型本身继承了IDisposable接口,垃圾回收机制虽然会自动帮我们释放资源,但是这个过程却延长了,因为它不是在一次回收中完成所有的清理工作。本实例中的代码因为filesStream继承了IDisposable接口,故第一次进行垃圾回收的时候,垃圾回收器会调用fleStream的终结器,然后等待下一次的垃圾回收,这时fileStream对象才有可能被真正的回收掉。
改进上述代码:
// 问题版本
private void btnOpen_Click(...)
{
FileStream fileStream = new FileStream(@"c:\test.txt",FileMode.Open);
// 但是如果上句代码抛出异常,这句话就永远执行不到
fileStream.Dispose();
}
// 正确版本
private void btnOpen_Click(...)
{
// using就是try{}finally{ ..Dispose(); }的语法糖,生成的IL代码是一样的
using(FileStream fileStream = new FileStream(@"c:\test.txt",FileMode.Open))
{
.../
}
}
using(){}
就是try{}finally{ ..Dispose(); }
的语法糖,生成的IL代码是一样的.
建议53 : 必要时应将不再使用的对象引用赋值为null
在建议52中,提到了需要及时释放资源,却并没有进一步细说明是否有必要让引用等于null。本建议将进一步阐述相关知识。
可能有人会认为:等于null可以帮助垃圾回收机制早点发现并标识对象是垃圾。不过也有人会认为:这没有任何帮助。是否赋值为null的问题首先是在方法的内部被提起的。
为了更好地阐述这个问题,我们编写-一个Winform窗体应用程序。该程序完成这样的功能:单击其中一个按钮,执行某些方法,单击另外一个按钮,强制执行一次垃圾回收。代码如下所示:
private void button1_Click(object sender, EventArgs e)
{
Method1();
Method2();
}
private void button2_Click(object sender, EventArgs e)
{
GC.Collect();
}
private void Method1()
{
SimpleClass s = new SimpleClass("method1");
s = null;
}
private void Method2()
{
SimpleClass s = new SimpleClass("method2");
}
class SimpleClass
{
string m_text;
public SimpleClass(string text)
{
m_text = text;
}
~SimpleClass()
{
MessageBox.Show(string.Format("SimpleClass Disposed, tag:{0}", m_text));
}
}
先单击按钮1, 再单击按钮2, 会发现:
- 方法Method2中的对象会先被释放, 虽然它在Method1之后被调用
- 方法Method2中的对线先被释放,虽然它不像Method1那样将引用复制为null
在CLR托管的应用程序中,存在一个“根”的概念,类型的静态字段
、方法参数
,以及局部变量
都可以作为“根”存在(值类型不能作为“根”,只有引用类型的指针才能作为”根”).
上面的两个方法中,各自的局部变量在代码运行过程中会分别在内存中创建一个“根”。在一次垃圾回收中,垃圾回收器会沿着线程栈
上行(这也解释了为什么Method2中的对象先被释放)检查“根” (线程栈检查完毕后,还会检查所有引用类型对象的静态字段的根集合)。当检查到方法内的“根”时,如果发现没有任何一个地方引用了局部变量,则不管是否已经显式将其赋值为null,都意味着该“根”已经被停止。然后,垃圾回收器会发现该根的引用为空,同时标记该根可被释放,这也代表着Simple类型对象所占用的内存空间可以被释放。所以,在上面的这个例子中,为s指定为null丝毫没有意义(除了局部变量,方法的参数变量也是这种情况)。
更进一步的事实是,JIT 编译器是一个优化过的编译器,所以无论我们是否在方法内部将局部变量赋值为null,该语句都会被忽略:
s = null;
如果我们将项目设置为Release模式,这行代码根本不会被编译进运行时内。
正是由于以上分析,很多人会认为将对象赋值为null完全没有必要。但是,在另外一种情况下,却要注意及时地将变量赋值为null,那就是类型的静态字段。将类型对象赋值为null,并不意味着同时将类型的静态字段赋值为null,代码如下所示:
class SimpleClass
{
static AnotherSimpleClass asc = new AnotherSimpleClass();
string m_text;
public SimpleClass(string text)
{
m_text = text;
}
~SimpleClass()
{
//asc = null;
MessageBox.Show(string.Format("SimpleClass Disposed, tag:{0}", m_text));
}
}
class AnotherSimpleClass
{
~AnotherSimpleClass()
{
MessageBox.Show("AnotherSimpleClass Disposed");
}
}
查看以上代码运行的结果我们会发现,在执行垃圾回收时,当类型SimpleClass对象被回收时,类型的静态字段asc却并没有被回收。必须要将SimpleClass终结器中注释的那条代码启用:asc = null;
字段asc才能被正确释放(注意:要单击两次释放按钮。这是因为第一次垃圾回收仅仅执行了终结器)。之所以静态字段不被释放(同时赋值为null语句也不会像局部变量那样被运行时编译器优化),是因为类型的静态字段一旦被创建,该“根”就一直存在。所以,垃圾回收器始终不会认为它是一个垃圾。非静态字段则不存在这个问题。将asc改为非静态,再次运行上面的代码,会发现asc随着类型的释放而被释放。
上面的代码中,让asc=null
是在终结器中完成的。
在实际工作中,一旦我们感觉到自己的静态引用类型参数占用的内存空间比较大,并且用完后不会再使用,便可以立刻将其赋值为null。这也许并不必要,但这绝对是一个好习惯。试想在一个系统中那些时不时在类型中出现的静态变量吧!它们就那样静静地待在内存里,一旦被创建,就永远不会离开.
建议54 : 为无用字段标注不可序列化
序列化是指这样一种技术: 把对象转变成流。相反的过程,我们称为反序列化。在很多的场合都需要用到这项技术,例如:
- 把对象保存到本地,在下次运行程序的时候,恢复这个对象。
- 把对象传到网络中的另外一台终端上,然后在此终端还原这个对象。
- 其他的场合,如:把对象复制到系统的粘贴板中,然后用快捷键Ctrl+V恢复这个对象。
有以下几方面的原因,决定了要为无用字段标注不可序列化:
- 节省空间。类型在序列化后往往会存储到某个地方,如数据库、硬盘或内存中,如果一个字段在反序列化后不需要保持状态,那它就不应该被序列化,这会占用宝贵的空间资源。
- 反序列化后字段信息已经没有意义了。如Windows内核句柄,在反序列化后往往已经失去了意义,所以它就不应该被序列化。
- 字段因为业务上的原因不允许被序列化。例如,明文密码不应该被序列化后一同保存在文件中。
- 如果字段本身所对应的类型在代码中未被设定为可序列化,那它就该被标注不可序列化,否则运行时会抛出异常SerializationException.
类型被添加Serializable属性后,默认所有的字段全部能够被序列化。代码如下:
[Serializable]
class Person
{
private string name;
public int Age { get; set; }
public string Name
{
get
{
return name;
}
set
{
if (NameChanged != null)
{
NameChanged(this, null);
}
name = value;
}
}
public event EventHandler NameChanged;
}
一个序列化工具类
public class BinarySerializer
{
//将类型序列化为字符串
public static string Serialize<T>(T t)
{
using (MemoryStream stream = new MemoryStream())
{
BinaryFormatter formatter = new BinaryFormatter();
formatter.Serialize(stream, t);
return System.Text.Encoding.UTF8.GetString(stream.ToArray());
}
}
//将类型序列化为文件
public static void SerializeToFile<T>(T t, string path, string fullName)
{
if (!Directory.Exists(path))
{
Directory.CreateDirectory(path);
}
string fullPath = string.Format(@"{0}\{1}", path, fullName);
using (FileStream stream = new FileStream(fullPath, FileMode.OpenOrCreate))
{
BinaryFormatter formatter = new BinaryFormatter();
formatter.Serialize(stream, t);
stream.Flush();
}
}
//将字符串反序列化为类型
public static TResult Deserialize<TResult>(string s) where TResult : class
{
byte[] bs = System.Text.Encoding.UTF8.GetBytes(s);
using (MemoryStream stream = new MemoryStream(bs))
{
BinaryFormatter formatter = new BinaryFormatter();
return formatter.Deserialize(stream) as TResult;
}
}
//将文件反序列化为类型
public static TResult DeserializeFromFile<TResult>(string path) where TResult : class
{
using (FileStream stream = new FileStream(path, FileMode.Open))
{
BinaryFormatter formatter = new BinaryFormatter();
return formatter.Deserialize(stream) as TResult;
}
}
}
从上面的代码可以看到,所有的字段都反序列化成功,包括事件。但是,事件往往并不需要被反序列化。反序列化的运行环境往往和序列化时的环境是不一致的,所以这时就失去了将事件序列化到文件中的意义。我们为上面例子中的Person类型增加一个字段Department,假设Department 也没有标识序列化,那么,最终Person类型看上去应该像如下的形式才是合理的:
// 最终版Person
[Serializable]
class Person
{
...;
[NonSerialized]
private Department department;
public Department Department
{
get
{
return department;
}
set
{
department = value;
}
}
[field: NonSerialized]
public event EventHandler NameChanged;
}
- 由于属性本质上是方法,所以不能将NonSerialized特性应用于属性上,在标识某个属性不能被序列化时,自动实现的属性显然已经不能使用。
- 要让事件不能被序列化,需使用改进的特性语法field: NonSerialized.
建议55 : 利用定制特性减少可序列化的字段
特性(attribute) 可以声明式地为代码中的目标元素添加注解。运行时可以通过查询这些托管模块中的元数据信息,达到改变目标元素运行时行为的目的。在System.Runtime.Serialization命名空间下,有4个这样的特性,下面是MSDN上对它们的解释: .
- OnDeserializedAttribute,当它应用于某方法时,会指定在对象反序列化后立即调用此方法。
- OnDeserializingAttribute,当它应用于某方法时,会指定在反序列化对象时调用此方法。
- OnSerializedAttribute,如果将对象图应用于某方法,则应指定在序列化该对象图后是否调用该方法。
- OnSerializingAttribute,当它应用于某个方法时,会指定在对象序列化前调用此方法.
利用这些特性,可以更加灵活地处理序列化和反序列化过程,例如,我们可以利用这一点来进一步减少某些可序列化的字段。
[Serializable]
class Person
{
public string FirstName;
public string LastName;
// ChineseName一般都是由FirstName和LastName推断出, 可以不需要序列化此字段
[NonSerialized]
public string ChineseName;
// 提供一个方法在序列化完成后,计算ChineseName值
[OnDeserializedAttribute]
void OnSerialized(StreamingContext context)
{
ChineseName = string.Format("{0} {1}", LastName, FirstName);
}
}
建议56 : 使用继承ISerializable接口更灵活地控制序列化过程
除了利用特性Serializable之外,我们还可以注意到在序列化的应用中,常常会出现一个接口ISerializable
.
接口ISerializable 的意义在于,如果特性Serializable,以及与其相配套的OnDeserializedAttribute、OnDeserializingAttribute、 OnSerializedAttribute.OnSerializingAttribute、NonSerialized 等特性不能完全满足自定义序列化的要求,那就需要继承ISerializable了。
以下是格式化器的工作流程:如果格式化器在序列化一个对象的时候,发现对象继承了ISerializable接口,那它就会忽略掉类型所有的序列化特性,转而调用类型的GetObjectData方法来构造一 个SerializationInfo对象
,方法内部负责向这个对象添加所有需要序列化的字段(“ 添加”这个词可能不太恰当,因为我们在添加前,可以随意处置这个字段)。
以建议55中的例子为例,如果要为ChineseName构造对应的值,在类型继承ISerializable接口的情况下:
[Serializable]
public class Person : ISerializable
{
public string FirstName;
public string LastName;
public string ChineseName;
public Person()
{
}
// 反序列化所需要的构造器
// 虽然在接口ISerializable中没有地方指出需要这样一个构造器,但这确实是需要的
// 除非我们在序列化流之后不打算再把它反序列化回来
protected Person(SerializationInfo info, StreamingContext context)
{
FirstName = info.GetString("FirstName");
LastName = info.GetString("LastName");
ChineseName = string.Format("{0} {1}", LastName, FirstName);
}
// 我们在方法GetObjectData中处理序列化,然后在一个带参数的构造方法中处理反序列化。
void ISerializable.GetObjectData(SerializationInfo info, StreamingContext context)
{
info.AddValue("FirstName", FirstName);
info.AddValue("LastName", LastName);
}
}
实现ISerializable接口与使用Serializable特性相比前者还能做到后者做不到的功能. 例子是:将Person对象序列化,然后在反序列化中将其变为另一个对象: PersonAnother类型对象。要实现这样的功能,需要类型Person和PersonAnother都实现ISerializable接口,原理其实很简单,那就是在Person类的GetObjectData方法中处理序列化,在PersonAnother的受保护构造方法中反序列化。如下所示:
static void Main()
{
Person luminji = new Person() { FirstName = "Minji", LastName = "Lu" };
BinarySerializer.SerializeToFile(luminji, @"c:\", "person.txt");
PersonAnother p = BinarySerializer.DeserializeFromFile<PersonAnother>(@"c:\person.txt");
Console.WriteLine(p.Name);
}
[Serializable]
class PersonAnother : ISerializable
{
public string Name { get; set; }
// 反序列时候会自动调用
// 虽然在接口ISerializable中没有地方指出需要这样一个构造器,但这确实是需要的
// 除非我们在序列化流之后不打算再把它反序列化回来
protected PersonAnother(SerializationInfo info, StreamingContext context)
{
Name = info.GetString("Name");
}
// 我们在方法GetObjectData中处理序列化,然后在一个带参数的构造方法中处理反序列化。
void ISerializable.GetObjectData(SerializationInfo info, StreamingContext context)
{
}
}
[Serializable]
public class Person : ISerializable
{
public string FirstName;
public string LastName;
public string ChineseName;
public Person()
{
}
// 反序列化锁需要的构造器
protected Person(SerializationInfo info, StreamingContext context)
{
}
// 我们在方法GetObjectData中处理序列化,然后在一个带参数的构造方法中处理反序列化。
void ISerializable.GetObjectData(SerializationInfo info, StreamingContext context)
{
info.SetType(typeof(PersonAnother));
info.AddValue("Name", string.Format("{0} {1}", LastName, FirstName));
}
}
info.SetType(typeof(PersonAnother));
这句话特别重要, 负责告诉序列化器: 我要被反序列化为PersonAnother, 而PersonAnother都不需要知道谁将会被反序列化成它, 不需要做任何特殊处理.
ISerializable接口的这个特性很重要,如果运用得当,在版本升级中,它能处理类型因为字段变化而带来的问题。
建议57 : 实现ISerializable的子类型应负责父类的序列化
我们将要实现的继承自ISerializable的类型Employee有一个父类Person,假设Person没有实现序列化,而现在子类Employee却要求能够满足序列化的场景。不过很遗憾,序列化器没有默认去处理Person类型对象,这些事情只能由我们自己去做。
以下是一个不妥的实现,序列化器只发现和处理了Employee中的Salary字段:
// 错误版本:
class Program
{
static void Main()
{
Employee luminji = new Employee() { Name = "luminji", Salary = 2000 };
BinarySerializer.SerializeToFile(luminji, @"c:\", "person.txt");
Employee luminjiCopy = BinarySerializer.DeserializeFromFile<Employee>(@"c:\person.txt");
Console.WriteLine(string.Format("姓名:{0}", luminjiCopy.Name));
Console.WriteLine(string.Format("薪水:{0}", luminjiCopy.Salary));
// 输出:
// 姓名:
// 薪水:2000
}
}
[Serializable]
public class Person
{
public string Name { get; set; }
public Person()
{
}
}
[Serializable]
public class Employee : Person, ISerializable
{
public int Salary { get; set; }
public Employee()
{
}
protected Employee(SerializationInfo info, StreamingContext context)
{
Salary = info.GetInt32("Salary");
}
public void GetObjectData(SerializationInfo info, StreamingContext context)
{
info.AddValue("Salary", Salary);
}
}
输出:
姓名:
薪水:2000
可见,Name字段并没有正确处理,要修正该问题。这需要我们去修改类型Employee中受保护的构造方法和GetObjectData方法,为它们加入父类字段的处理,如下所示:
protected Employee(SerializationInfo info, StreamingContext context)
{
Salary = info.GetInt32("Salary");
//新增
Name = info.GetString("Name");
}
public void GetObjectData(SerializationInfo info, StreamingContext context)
{
info.AddValue("Salary", Salary);
//新增
info.AddValue("Name", Name);
}
在上面的例子中,Person 类型未被设置为支持序列化。现在,假设Person类型已经实现了ISerializable 接口,那么这个问题处理起来相对会比较容易,在子类Employee中,我们只需要调用父类受保护的构造方法和GetObjectData方法就可以了。子类和父类都实现接口的版本如下,请参考:
[Serializable]
public class Person : ISerializable
{
public string Name { get; set; }
public Person()
{
}
protected Person(SerializationInfo info, StreamingContext context)
{
Name = info.GetString("Name");
}
public virtual void GetObjectData(SerializationInfo info, StreamingContext context)
{
info.AddValue("Name", Name);
}
}
[Serializable]
public class Employee : Person, ISerializable
{
public int Salary { get; set; }
public Employee()
{
}
// 先调用父类的反序列化构造, 再增加子类中新增的字段
protected Employee(SerializationInfo info, StreamingContext context)
: base(info, context)
{
Salary = info.GetInt32("Salary");
}
// 重写父类的序列化类,
// 先调用父类的序列化方法
// 添加子类独有的字段序列化
public override void GetObjectData(SerializationInfo info, StreamingContext context)
{
base.GetObjectData(info, context);
info.AddValue("Salary", Salary);
}
}