在C#中,有两种大类型——值类型和引用类型。
1、值类型与引用类型
深入的理解这两种类型是非常重要的,面试官会考验面试者对这两个知识的了解来判断基础是否扎实,并且有没有深入的去思考。
1.1 什么是值类型与引用类型
值类型主要包括简单类型、枚举类型和结构体类型等。值类型的实例通常被分配到线程的堆栈上,变量保存的内容就是实例数据本身。
引用类型的实例则被分配到托管堆上,变量保存的是实例数据的内存地址。引用类型包括类类型、接口类型、委托类型和字符串类型等。
对于堆栈和托管堆,在前面将IL知识点的时候就已经有提及过的。可以把它们理解为内存中存储数据的两种结构。
下面的表格列出了C#中的基本类型
类别 | 说明 | |
值类型 | 简单类型 | 有符号整数:int、long、short、sbyte |
无符号整数:uint、ulong、ushort、byte | ||
字符类型:char | ||
浮点型:float、double和高精度小数类型decimal | ||
布尔类型:bool | ||
枚举类型 | 枚举类型:enum | |
结构体类型 | 结构体类型:struct | |
引用类型 | 类类型 | 字符串类型:string |
类类型:Console类和自己自定义的类类型 | ||
数组类型 | 一维数组和多维数据,如int[]与int[,](二维数组) | |
接口类型 | 由interface关键字定义的类型 | |
委托类型 | 由delegate关键字定义的类型 |
1.2 值类型与引用类型的区别
这是面试中的重点,面试官经常问到的问题。
它们之间最大的区别在于——不同的内存分布。
值类型通常是被分配到线程的堆栈上的(并不是绝对的),而引用类型则被分配到托管堆上。不同的分配位置导致了不同的管理机制,值类型的管理由操作系统负责,而引用类型的管理则由垃圾回收器(GC)负责。
多说无益,先上个简单的代码说明一下:
class Program
{
static void Main(string[] args)
{
//valuetype是值类型
int valuetype=3;
//reftype是引用类型
string reftype="abc";
}
}
内存分布如下图所示:
值类型与引用类型的区别就在于实际数据的存储位置:值类型的变量和实际数据都存储在堆栈中;而引用类型则只有变量存储在堆栈中,变量存储着实际数据的地址,实际数据存储在与地址相对应的托管堆中。
Warning:
前面讲过了,值类型通常是放在堆栈上,但这并不是绝对的。值类型实例不一定总会被分配到线程栈上。在引用类型中嵌套值类型时,或者在值类型装箱的情况下,值类型的实例就会被分配到托管堆中。
嵌套结果包括值类型中嵌套定义了引用类型和引用类型中定义了值类型两种情况。
1.2.1 引用类型中嵌套定义值类型
如果类的字段类型是值类型,它将作为引用类型实例的一部分,被分配到托管堆中。但那些作为局部变量的值类型,则仍然会被分配到线程堆栈中。
//引用类型嵌套定义值类型的情况
public class NestedValueTypeInRef
{
//valuetype作为引用类型的一部分被分配到托管堆上
private int valuetype=3;
public void method()
{
char c='c';//因为是方法中的局部变量,所以还是会存放在堆栈上。
}
}
class Program
{
static void Main(string[] args)
{
NestedValueTypeInRef reftype=new NestedValueTypeInRef();
}
}
还是以图来解释:
1.2.2 值类型中嵌套定义引用类型
值类型嵌套定义引用类型时,堆栈上将保存该引用类型的引用,而实际的数据则依然保存在托管堆中。
public class TestClass
{
public int x;
public int y;
}
//值类型嵌套定义引用类型的情况
public struct NestedRefTypeValue
{
//结构体字段,注意,结构体的字段不能被初始化
private TestClass classinValuetype;
//结构图中的构造函数,注意,结构体中不能定义无参的构造函数
public NestedRefTypeInValue(TestClass t)
{
classinValuetype.x=3;
classinValuetype.y=5;
classinValuetype=t;
}
}
class Program
{
static void Main(string[] args)
{
NestedRefTypeInValue valuetype=new NestedRefTypeInValue(new TestClass());
}
}
以上代码中的内存分配情况如下图所示:
总结:
从以上两个例子可以总结出:值类型实例总会被分配到它声明的地方,声明的是局部变量,将被分配到栈上,而声明为引用类型成员时,则被分配到托管堆上;而引用类型实例总是分配到托管堆上。
上面值分析了值类型与引用类型在内存分布方面的区别,除此之外,还有以下几个区别:
(1)值类型继承自ValueType,ValueType又继承自System.Object;而引用类型则直接继承于System.Object.
(2)值类型的内存不受GC(垃圾回收器)控制,作用域结束时,值类型会被操作系统自行释放,从而减轻了托管堆的压力;而引用类型的内存管理则由GC来完成。所以与引用类型相比,值类型在性能方面更具优势。
(3)值类型是密封的(sealed),你将不能把值类型作为其他任何类型的基类;而引用类型则一般具有继承性,这里指的是类和接口。
(4)值类型不能为null值,它会被默认初始化为数值0;而引用类型在默认的情况下会被初始化为null值,表示不指向托管堆中的任何地址。对值为null的引用类型的任何操作,都会引发NullReferenceException异常。
(5)由于值类型变量包含其实际数据,因此在默认情况下,值类型之间的参数传递不会影响变量本身;而引用类型变量保存的是数据的引用地址,它们作为参数被传递时,参数会发生改变,从而影响类型变量的值。
2、参数传递问题
在默认的情况下,C#方法中的参数传递都是按值进行的,但实际上参数传递的方式共有4种不同的情况,它们分别为:
(1)值类型参数的按值传递
(2)引用类型参数的按值传递
(3)值类型参数的按引用传递
(4)引用类型参数的按引用传递
2.1 值类型参数的按值传递
值类型的按值传递,传递的是该值类型实例的一个副本,也就是说形参接收到的是实参的一个副本,被调用方法操作的是实参的一个副本罢了。
class Program
{
static void Main(string[] args)
{
//1.值类型按值传递
Console.WriteLine("值类型按值传递的情况");
int addNum=1;
Add(addNum);
Console.WriteLine("调用方法后,实参addNum的值:"+addNum);
Console.ReadKey();
}
//1.值类型按值传递的情况
private static void Add(int addnum)
{
addnum=addnum+1;
Console.WriteLine("方法中addnum的值:"+addnum);
}
}
运行结果如下:
上图从内存的角度说明了值类型参数按值传递的情况。
2.2 引用类型参数的按值传递
当传递的参数是引用类型时,传递和操作的目标是指向对象的地址,而传递的实际内容是对地址的复制。由于地址指向的是实参的值,当方法对地址进行操作时,实际上操作了地址所指向的值,所以调用方法后原来实参的值就会被修改。
class Program
{
static void Main(string[] args)
{
Console.WriteLine("引用类型按值传递的情况");
RefClass refClass = new RefClass();
refClass.addNum = 1;
AddRef(refClass);
Console.WriteLine("调用方法后,实参addNum的值:"+refClass.addNum);
Console.ReadKey();
}
private static void AddRef(RefClass addnumRef)
{
addnumRef.addNum += 1;
Console.WriteLine("方法中addNum的值:"+addnumRef.addNum);
}
}
public class RefClass
{
public int addNum;
}
结果为:
2.3 string引用类型参数按值传递的特殊情况
这个是比较特殊的一种方式。string类型也是引用类型,然而在按值传递时,传递的参数却不会因方法中形参的改变而被修改。
class Program
{
static void Main(string[] args)
{
Console.WriteLine("String引用类型按值传递的特殊情况");
string str = "old string";
ChangeStr(str);
Console.WriteLine("调用方法后,实参str的值:"+ str);
Console.ReadKey();
}
private static void ChangeStr(string oldStr)
{
oldStr = "New String";
Console.WriteLine("方法中oldStr的值:"+oldStr);
}
}
按照前面对“引用类型参数按值传递”过程分析,这里方法对字符串的修改会导致实参的值发生改变,然而实际运行结果并非如此。造成这个特殊性的原因是string具有不变性,一旦string类型被赋值,则它就是不可改变的,即不能通过代码去修改它的值。
方法中oldStr="New String"代码执行时,系统会重新分配一块内存控件来存放New String字符串,然后把分配的内存首地址赋值给oldStr变量。所以,调用完方法后,str变量所指向的仍然是old string字符串,而oldStr变量则指向了New String字符串。
2.4 值类型和引用类型参数的按引用传递
不管是值类型还是引用类型,你都可以使用ref或out关键字来实现参数的按引用传递。并且在按引用进行传递时,方法的定义和调用都必须显式地使用ref和out关键字,不可以将它们省略,否则会引起编译错误。
还是用具体的代码来说明:
class Program
{
static void Main(string[] args)
{
Console.WriteLine("值类型和引用类型参数的按引用传递情况");
//num是值类型形参
int num = 1;
//refStr是引用类型实参
string refStr = "Old String";
ChangeByValue(ref num);
Console.WriteLine(num);
ChangeByStr(ref refStr);
Console.WriteLine(refStr);
Console.ReadKey();
}
private static void ChangeByStr(ref string numRef)
{
numRef = "new string";
Console.WriteLine(numRef);
}
private static void ChangeByValue(ref int numValue)
{
numValue = 10;
Console.WriteLine(numValue);
}
}
结果截图:
从结果就可以看出,在值类型参数按引用传递的过程中,传递的是值类型变量的地址,其效果类似于引用类型的按值传递。不同的是,值类型参数按引用传递的地址是栈上值类型变量的地址,而引用类型按值传递的地址是变量所指向的托管堆中实际数据的地址。当方法对值类型变量的地址进行操作时,实现的是对值类型变量的实际数据的操作,所以改变了实参中的值。
而引用类型参数按引用传递的过程中,传递的是引用类型变量的地址,该地址是变量在堆栈上的地址,即传递的是引用的引用而不是引用本身。
总结:多动手,多思考。深入理解两种类型的不同,对于面试的问题就会游刃有余。