C# 中的类型——值类型和引用类型
C# 中的类型可以分为两种——值类型和引用类型,本文详细分析两种类型,并讨论它们之间的类型转换方法
什么是值类型和引用类型
值类型主要包括简单类型、枚举类型和结构体类型等。值类型的实例通常被分配在线程的堆栈上,变量保存的内容就是实例数据本事。引用类型的实例则被分配在托管堆上,变量保存的是实例数据的内存地址。引用类型主要包括类类型、接口类型、委托类型和字符串类型等
这里很多人搞不清楚什么是堆栈和托管堆,它们和内存有什么联系
其实很简单,内存有两种存储数据的结构,一种是堆栈(Stack),另一种是(堆)。堆是先进先出,栈是先进后出。根据这两种存储不同的特性,值类型数据放在栈上,引用类型数据放在堆上。(先进后出意味着内存寻址是高位到地位,根据编译前预设字节大小去挪动)
- 值类型
- 简单类型
- 有符号整型:int、long、short 和 sbyte
- 无符号整型:unit、ulong、ushort 和 byte
- 字符类型:char
- 浮点型:float、double 和高精度小数类型 decimal
- 布尔类型:bool
- 枚举类型:enum
- 结构体类型:struct
- 简单类型
- 引用类型
- 类类型
- 字符串类型:string
- 类类型:Console 类和自己自定义类类型
- 数组类型:int[] 与 int[,]
- 接口类型:interface
- 委托类型:delegate
- 类类型
回收
值类型的内存不受 GC(垃圾回收器)控制,作用域结束时,值类型会被操作系统自行释放,从而减少了托管堆的压力;而引用类型的内存管理则有 GC 完成
装箱和拆箱
既然 C# 中存在这两种类型,自然需要对它们进行转换。类型转换指的是将一种数据类型转换成另一种数据类型的过程。例如将 “1235” 转换成整数类型的 12345。但并不是所有类型之间都可以进行转换(例如不能把DateTime
对象转换为int
类型),类型之间不能完成的转换会导致编译错误火运行时错误
类型转换的方式有以下几种:
- 隐式类型转换。由低级别类型向高级别类型的转换过程。例如,派生类可以隐式地转换为它的父类,装箱过程就属于这种隐式类型转换
- 显示类型转换。也叫强制类型转换。但是这种转换可能导致精度丢失或者出现运行时异常
- 通过 is 和 as 运算符进行安全类型转换
- 通过 .Net 类库中的 Convert 类完成类型转换
下面主要介绍值类型和引用类型之间的一种转换——装箱和拆箱。装箱指的是将值类型转换为引用类型的过程,而拆箱指的是将引用类型转换为值类型
代码语言:javascript复制class Program
{
static void Main(string[] args)
{
int i = 3;
// 装箱
object o = i;
// 拆箱
int y = (int)o;
}
}
以上内容分别执行了一次装箱和拆箱操作
装箱步骤:
- 内存分配:在托管堆中分配好内存空间以及存放赋值的实际数据
- 完成实际数据的复制:将值类型实例的实际数据复制到新分配的内存中
- 地址返回:将托管堆中的对象地址返回给引用类型变量
拆箱步骤:
- 检查实例:首先检查要进行拆箱操作的引用类型变量是否为 null,如果为 null 则抛出
NullReferenceException
异常;如果不为null
则继续检查变量是否和拆箱后的类型时同一类型,若结果为否,会导致InvalidCastException
异常 - 地址返回:返回已装箱变量的实际数据部分的地址
- 数据复制:将托管堆中的实际数据复制到栈中
理解了装箱和拆箱,我们就知道转换类型实际上对系统会产生性能影响,还有可能产生异常错误,我们在辨析代码的时候,应尽量避免装箱和拆箱操作,最好用泛型来编程
参数传递问题剖析
在默认情况下,C# 方法中的参数传递都是按值进行的,但实际上参数传递的方式共有4种不同的情况,分别为:
- 值类型参数的按值传递
- 引用类型参数的按值传递
- 值类型参数的按引用传递
- 引用类型参数的按引用传递
值类型参数的按值传递
参数分为形参和实参两类。形参指的是被调用方法中的参数,也就是说方法定义中的参数为形参;实参指的是调用方法时,传递给对应参数的值
代码语言:javascript复制class Program
{
static void Main(string[] args)
{
int addNum = 1;
// addNum 就是实参
Add(addNum);
}
// addnum 就是形参,即被调用方法中的参数
private static void Add(int addnum)
{
addnum = 1;
Console.WriteLine(addnum);
}
}
值类型的按值传递,传递的是该值类型实例的一个副本,所以,方法是中对参数的修改是不会影响到实参的值的
引用类型参数的按值传递
当传递的参数是引用类型时,传递和操作的目标时指向对象的地址,而传递的实际内容是对地址的复制。由于地址指向的是实参的值,当方法对地址进行操作时,实际上操作了地址所指向的值,所以调用方法后原来实参的值会被修改
代码语言:javascript复制public class RefClass
{
public int addNum;
}
class Program
{
static void Main(stirng[] args)
{
RefClass refClass = new RefClass();
refClass.addNum = 1;
// refClasss 是实参,此时此时参数是引用类型
AddRef(refClass);
Console.WriteLine(refClass.addNum); // 2
}
private static void AddRef(RefClass addNumRef)
{
addNumRef.addNum = 1;
}
}
string 引用类型参数按值传递的特殊情况
虽然 string 类型也是引用类型,然而在按值传递是,传递的实参却不会因方法中形参的改变而被修改
代码语言:javascript复制class Program
{
static void Main(string[] args)
{
string str = "old string";
ChangeStr(str);
Console.WriteLine(str); // old string
}
private static void ChangeStr(string oldStr)
{
oldStr = "New string";
}
}
按照前面对“引用类型参数按值传递”过程的分析,这里对字符串的修改会导致实参的值发生改变,然而实际的运行结果却并非如此。造成这个原因的是 string 具有不可变性,一个 string 类型被赋值,则它就是不可改变的,即不能通过代码去修改它的值
方法中的oldStr = "New String"
代码表面上是对字符串的内容进行了修改,但由于 string 的不可变性,系统会重新分配一块内存空间存放 New String 字符串,然后把分配的内存首地址赋值给 oldStr 变量
值类型和引用类型参数按引用传递
不管是值类型还是引用类型,你都可以使用 ref 或 out 关键字来实现参数的按引用传递。并且在按引用进行传递时,方法的定义和调用都必须要显示地使用 ref 和 out 关键字,不可将他们忽略,否则会引起编译错误
在按引用传递时,不管参数时值类型还是引用类型,其本质都是一样的,都是通过 ref 或 out 关键字来告诉编译器,方法传递的是参数地址,而非参数本身
代码语言:javascript复制class Program
{
static void Main(string[] args)
{
int num = 1;
string refStr = "Old String";
// 值类型按引用传递
ChangeByValue(ref num);
Console.WriteLine(num); // 10
// 引用类型按引用传递
ChangeByRef(refStr);
Console.WriteLine(refStr); // New string
}
private static void ChangeByValue(ref int numValue)
{
numValue = 10;
}
private static void changeByRef(ref string numRef)
{
numRef = "New string";
}
}
归纳总结
本文主要是围绕值类型和引用类型来展开讨论,我们重点要掌握值类型和引用类型不同的内存存储方式,理解装箱和拆箱带来的影响。对于值类型和引用类型,进行参数传递的时候,我们要头脑清晰,一不留神可能没注意值类型传递的问题,导致 Bug,即使事后发现问题,也很难注意到是值类型值传递修改的问题