木木的布劳格

  • 首页
  • 学无止尽
  • 愉悦生活
  • 中医
  • 软件
  • 单反摄影
  • 收藏的软件
  • About Me
木木的布劳格
记录生活点滴的静默博客
  1. 首页
  2. 学无止尽
  3. 正文

C#引用类型详细剖析

6 9 月, 2010 2868点热度 0人点赞 0条评论

本文介绍了C#引用类型和值类型的区别的第二部分,包括值类型和引用类型在内存中的部署、正确使用值类型和引用类型等。

C#引用类型和值类型的区别——值类型和引用类型在内存中的部署

经常听说,并且经常在书上看到:值类型部署在栈上,引用类型部署在托管堆上。实际上并没有这么简单。

MSDN上说:托管堆上部署了所有引用类型。这很容易理解。当创建一个应用类型变量时:

object reference = new object();

 

关键字new将在托管堆上分配内存空间,并返回一个该内存空间的地址。左边的reference位于栈上,是一个引用,存储着一个内存地址;而这个地址指向的内存(位于托管堆)里存储着其内容(一个System.Object的实例)。下面为了方便,简称引用类型部署在托管推上。

再来看值类型。《C#语言规范》上的措辞是“结构体不要求在堆上分配内存(However, unlike classes, structs are value types and do not require heap allocation)”而不是“结构体在栈上分配内存”。这不免容易让人感到困惑:值类型究竟部署在什么地方?

数组

考虑数组:

int[] reference = new int[100];

 

根据定义,数组都是引用类型,所以int数组当然是引用类型(即reference.GetType().IsValueType为false)。

而int数组的元素都是int,根据定义,int是值类型(即reference[i].GetType().IsValueType为true)。那么引用类型数组中的值类型元素究竟位于栈还是堆?

如果用WinDbg去看reference[i]在内存中的具体位置,就会发现它们并不在栈上,而是在托管堆上。

实际上,对于数组:

TestType[] testTypes = new TestType[100]; 

如果TestType是值类型,则会一次在托管堆上为100个值类型的元素分配存储空间,并自动初始化这100个元素,将这100个元素存储到这块内存里。

如果TestType是引用类型,则会先在托管堆为testTypes分配一次空间,并且这时不会自动初始化任何元素(即testTypes[i]均为null)。等到以后有代码初始化某个元素的时候,这个引用类型元素的存储空间才会被分配在托管堆上。

类型嵌套

更容易让人困惑的是引用类型包含值类型,以及值类型包含引用类型的情况:

 public class ReferenceTypeClass 

{ 

 private int _valueTypeField; 

 public ReferenceTypeClass() 

      { 

         _valueTypeField = 0; 

     } 

 public void Method() 

      { 

 int valueTypeLocalVariable = 0; 

     } 

} 

ReferenceTypeClass referenceTypeClassInstance = new ReferenceTypeClass();//Where is _valueTypeField?

referenceTypeClassInstance.Method();//Where is valueTypeLocalVariable?

 

 public struct ValueTypeStruct 

{ 

private object _referenceTypeField; 

 public void Method() 

     { 

          _referenceTypeField = new object(); 

 object referenceTypeLocalVariable = new object(); 

     } 

} 

ValueTypeStruct valueTypeStructInstance = new ValueTypeStruct(); 

valueTypeStructInstance.Method();//Where is _referenceTypeField?And where is referenceTypeLocalVariable?

单看valueTypeStructInstance,这是一个结构体实例,感觉似乎是整块扔到栈上的。但是字段_referenceTypeField是引用类型,局部变量referenceTypeLocalVarible也是引用类型。

referenceTypeClassInstance也有同样的问题,referenceTypeClassInstance本身是引用类型,似乎应该整块部署在托管堆上。但字段_valueTypeField是值类型,局部变量valueTypeLocalVariable也是值类型,它们究竟是在栈上还是在托管堆上?

规律是:

引用类型部署在托管堆上;值类型总是分配在它声明的地方:作为字段时,跟随其所属的变量(实例)存储;作为局部变量时,存储在栈上。 我们来分析一下上面的代码。

对于引用类型实例,即referenceTypeClassInstance:

从上下文看,referenceTypeClassInstance是一个局部变量,所以部署在托管堆上,并被栈上的一个引用所持有; 值类型字段_valueTypeField属于引用类型实例referenceTypeClassInstance的一部分,所以跟随引用类型实例referenceTypeClassInstance部署在托管堆上(有点类似于数组的情形);

valueTypeLocalVariable是值类型局部变量,所以部署在栈上。

而对于值类型实例,即valueTypeStruct:

根据上下文,值类型实例valueTypeStructInstance本身是一个局部变量而不是字段,所以位于栈上; 其引用类型字段_referenceTypeField不存在跟随的问题,必然部署在托管堆上,并被一个引用所持有(该引用是valueTypeStruct的一部分,位于栈); 其引用类型局部变量referenceTypeLocalVariable显然部署在托管堆上,并被一个位于栈的引用所持有。所以,简单地说“值类型存储在栈上,引用类型存储在托管堆上”是不对的。必须具体情况具体分析。

C#引用类型和值类型的区别——正确使用值类型和引用类型

这一部分主要参考《Effective C#》,并非本人原创,希望能让你加深对值类型和引用类型的理解。辨明值类型和引用类型的使用场合C#中,我们用struct/class来声明一个类型为值类型/引用类型。

考虑下面的例子:

TestType[] testTypes = new TestType[100]; 

如果TestTye是值类型,则只需要一次分配,大小为TestTye的100倍。而如果TestTye是引用类型,刚开始需要100次分配,分配后数组的各元素值为null,然后再初始化100个元素,结果总共需要进行101次分配。这将消耗更多的时间,造成更多的内存碎片。所以,如果类型的职责主要是存储数据,值类型比较合适。

一般来说,值类型(不支持多态)适合存储供 C#应用程序操作的数据,而引用类型(支持多态)应该用于定义应用程序的行为。

通常我们创建的引用类型总是多于值类型。如果以下问题的回答都为yes,那么我们就应该创建为值类型:

该类型的主要职责是否用于数据存储?该类型的共有借口是否完全由一些数据成员存取属性定义? 是否确信该类型永远不可能有子类? 是否确信该类型永远不可能具有多态行为? 将值类型尽可能实现为具有常量性和原子性的类型

具有常量性的类型很简单:

如果构造的时候验证了参数的有效性,之后就一直有效;省去了许多错误检查,因为禁止更改; 确保线程安全,因为多个reader访问到同样的内容; 可以安全地暴露给外界,因为调用者不能更改对象的内部状态。具有原子性的类型都是单一的实体,我们通常会直接替换一个原子类型的整个内容。

下面是一个典型的可变类型:

public struct Address  

{  

 private string _city;  

 private string _province;  

 private int _zipCode;  

 public string City  

     {  

 get { return _city; }  

 set { _city = value; }  

      }  

 public string Province  

     {  

 get { return _province; }  

 set

         {  

            ValidateProvince(value);  

             _province = value;  

         }  

     }  

 public int ZipCode  

     {  

get { return _zipCode; }  

 set

         {  

             ValidateZipCode(value);  

             _zipCode = value;  

         }  

     }  

}

 

下面创建一个实例:

Address address = new Address();  

address.City = "Chengdu";  

address.Province = "Sichuan";  

address.ZipCode = 610000; 

然后更改这个实例:

address.City = "Nanjing"; //Now Province and ZipCode are invalid

address.ZipCode = 210000; //Now Province is still invalid

address.Province = "Jiangsu";

 

可见,内部状态的改变意味着可能违反对象的不变式(invariant),至少是临时的违反。如果上面是一个多线程的程序,那么在 City更改的过程中,另一个线程可能看到不一致的数据视图。如果不是多线程的程序,也有问题:

当ZipCode的值无效而抛出异常时,对象仅作了一部分改变,因此处于无效的状态,为了修复这个问题,需要在Address中添加相当多的内部校验代码;

为了实现异常安全,我们需要在所有改变多个字段的客户代码处放上防御性的代码;

线程安全也要求我们在每一个属性的访问器上添加线程同步检查。

显然,这是一个相当可观的工作量。下面我们把Address实现为常量类型:

public struct Address  

{  

private string _city;  

 private string _province;  

private int _zipCode;  

 public Address (string city, string province, int zipCode)  

     {  

         _city = city;  

         _province = province;  

          _zipCode = zipCode;  

          ValidateProvince(province);  

         ValidateZipCode(zipCode);  

     }  

public string City  

     {  

get { return _city; }  

      }  

 public string Province  

    {  

 get { return _province; }  

     }  

 public int ZipCode  

     {  

 get { return _zipCode; }  

     }  

}

 

如果要改变Address,不能修改现有的实例,只能创建一个新的实例:

 Address address = new Address("Chengdu", "Sichuan", 610000);//create a instance 

address = new Address("Nanjing", "Jiangsu", 210000);//modify the instance

 

address将不存在任何无效的临时状态。那些临时状态只存在于Address的构造函数执行过程中。这样一来,Address是异常安全的,也是线程安全的。

确保0为值类型的有效状态

.NET的默认初始化机制会将引用类型设置为二进制意义上的0,即null。而对于值类型,不论我们是否提供构造函数,都会有一个默认的构造函数,将其设置为0。

一种典型的情况是枚举:

 public enum Sex  

{  

     Male = 1;  

     Female = 2;  

} 

 

然后用做值类型的成员:

 public struct Employee  

{  

 private Sex _sex;  

//other

} 

 

创建Employee结构体将得到一个无效的Sex字段:

Employee employee = new Employee (); 

employee的_sex是无效的,因为其为0。我们应该将0作为一个为初始化的值明确表示出来:

 public Sex  

{  

      None = 0;  

     Male = 1;  

     Female = 2;  

}

 

如果值类型中包含引用类型,会出现另一种初始化问题:

public struct ErrorLog  

{  

private string _message;  

//other

} 

 

然后创建一个ErrorLog:

ErrorLog errorLog = new ErrorLog ();  

errorLog的_message字段将是一个空引用。我们应该通过一个属性来将_message暴露给客户代码,从而使该问题限定在ErrorLog 的内部:

public struct ErrorLog  

{  

 private string _message;  

public string Message  

      {  

get

          {  

 return (_message ! = null) ? _message : string.Empty;  

         }  

 set { _message = value; }  

      }  

//other

} 

 

尽量减少装箱和拆箱

装箱指把一个值类型放入一个未具名类型的引用类型中,比如:

 int valueType = 0;  

object referenceType = i;//boxing

 

拆箱则是从前面的装箱对象中取出值类型:

object referenceType;  

 int valueType = (int)referenceType;//unboxing

 

装箱和拆箱是比较耗费性能的,还会引入一些诡异的bug,我们应当避免装箱和拆箱。

装箱和拆箱最大的问题是会自动发生。比如:

Console.WriteLine("A few numbers: {0}, {1}.", 25, 32); 

其中,Console.WriteLine()接收的参数类型是(string,object,object)。因此,实际上会执行以下操作:

 int i = 25;  

obeject o = i;//boxing

 

然后把o传给WriteLine()方法。在WriteLine()方法的内部,为了调用i上的ToString()方法,又会执行:

int i = (int)o;//unboxing

 string output = i,ToString();

 

所以正确的做法应该是:

Console.WriteLine("A few numbers: {0}, {1}.", 25.ToString(), 32.ToString()); 

ToString()只是执行一个方法并返回一个引用类型,不存在装箱/拆箱的问题。

另一个典型的例子是ArryList的使用:

public struct Employee  

{  

 private string _name;  

 public Employee(string name)  

      {  

         _name = name;  

      }  

 public string Name  

     {  

 get { return _name; }  

set { _name = value; }  

    }  

public override string ToString()  

    {  

return _name;  

     }  

}  

ArrayList employees = new ArrayList();  

employees.Add(new Employee("Old Name"));//boxing

Employee ceo = (Employee)employees[0];//unboxing

ceo.Name = "New Name";//employees[0].ToString() is still "Old Name"

 

上面的代码不仅存在性能的问题,还容易导致错误发生。

在这种情况下,更好的做法是使用泛型集合:

List< Employee>
employees = new List< Employee>(); 

由于List< T>是强类型的集合,employees.Add()方法不进行类型转换,所以不存在装箱/拆箱的问题。

C#引用类型和值类型的区别——总结

C#中,变量是值还是引用仅取决于其数据类型。

C#的值类型包括:结构体(数值类型,bool型,用户定义的结构体),枚举,可空类型。

C#的引用类型包括:数组,用户定义的类、接口、委托,object,字符串。

数组的元素,不管是引用类型还是值类型,都存储在托管堆上。

引用类型在栈中存储一个引用,其实际的存储位置位于托管堆。为了方便,本文简称引用类型部署在托管推上。

值类型总是分配在它声明的地方:作为字段时,跟随其所属的变量(实例)存储;作为局部变量时,存储在栈上。

值类型在内存管理方面具有更好的效率,并且不支持多态,适合用作存储数据的载体;引用类型支持多态,适合用于定义应用程序的行为。

应该尽可能地将值类型实现为具有常量性和原子性的类型。

应该尽可能地确保0为值类型的有效状态。

应该尽可能地减少装箱和拆箱。

关键字new将在托管堆上分配内存空间,并返回一个该内存空间的地址。左边的reference位于栈上,是一个引用,存储着一个内存地址;而这个地址指向的内存(位于托管堆)里存储着其内容(一个System.Object的实例)。

本作品采用 知识共享署名 4.0 国际许可协议 进行许可
标签: c#
最后更新:6 9 月, 2010

chat

这个人很懒,什么都没留下

点赞
< 上一篇
下一篇 >

文章评论

razz evil exclaim smile redface biggrin eek confused idea lol mad twisted rolleyes wink cool arrow neutral cry mrgreen drooling persevering
取消回复
标签聚合
PHP win10 激活 wm python 序列号 CENTOS c#
广告
分类
  • C sharp
  • Delphi
  • Python
  • VPS
  • 中医
  • 伤寒论
  • 其他
  • 单反摄影
  • 学无止尽
  • 工作相关
  • 愉悦生活
  • 未分类
  • 软件

COPYRIGHT © 2023 木木的布劳格. ALL RIGHTS RESERVED.

Theme Kratos Made By Seaton Jiang