C#概念:值类型VS引用类型
引言
对于那些来自JAVA或VB6背景的人来说,一个可能引起混淆的方面是C#中值类型和引用类型的区别。特别是,C#提供了两种类型---类和结构,除了一个是引用类型而另一个是值类型外他们几乎是相同的。这篇文章就是探讨它们之间的本质区别,以及在C#编程时的实际影响。
这篇文章假设您具有C#的基本知识,并且能够定义类和属性。
首先,什么是结构?
简单来说,结构就是简化的类。想像一下,类不支持继承也不支持终结器(以前称为析构函数),你拥有精简版本:结构。结构的定义方式和类的定义方式是一样的(只是结构使用struct关键词),并且除了刚才描述的限制外,结构可以具有相同的富成员,包括字段、方法、属性和运算符。下面是一个简单的结构声明:
1 struct Point 2 { 3 private int x, y; // private fields 4 5 6 7 public Point (int x, int y) // constructor 8 { 9 this.x = x; 10 this.y = y; 11 } 12 13 public int X // property 14 { 15 get {return x;} 16 set {x = value;} 17 } 18 19 public int Y 20 { 21 get {return y;} 22 set {y = value;} 23 } 24 }
值类型和引用类型
结构和类之间还有另一个区别,这也是要理解的最重要的一点。结构是值类型,而类是引用类型,运行时以不同的方式进行处理。当一个值类型实例被创建时,在内存中分配一个空间来存储该值。基本数据类型(Primitive types)诸如int ,float,bool和char都是值类型,它们的工作方式是一样的。当运行时处理值类型时,它直接处理底层数据,这可能非常有效,尤其是对于基本数据类型。然而,对于引用类型,对象是在内存中创建的,然后通过单独的引用来处理---就像指针一样。假设Point是结构,Form是类,我们可以像下面这样进行初始化:
1 Point p1 = new Point(); // Point is a *struct* 2 Form f1 = new Form(); // Form is a *class*
在第一个示例中,内存中的一块空间分配给P1,然而在第二个示例中,分配了两块空间:一块分配给Form对象,另一块分配给引用(f1)。当我们以较长的表达式进行继续拆分时会看得更加清晰:
1 Form f1; // Allocate the reference 2 f1 = new Form(); // Allocate the object
如果我们将对象复制到新的变量:
1 Point p2 = p1; 2 Form f2 = f1;
p2,做为结构,成为p1的一个独立副本,具有自己的单独的字段;但是在f2的示例中,我们复制的只是一个引用,结果是f1和f2都指向同相同的对象。将参数传递给方法时,这一点尤其要注意。在C#中,参数(默认情况下)是按值传递,这意味着它们在传递给方法时被隐式复制。对于值类型参数来说,从物理层面上这意味着复制对象实例(与复制p2的方式相同),而对于引用类型来说,它意味着复制了一个引用(与复制f2的方式相同)。下面是一个示例:
1 Point myPoint = new Point (0, 0); // a new value-type variable 2 Form myForm = new Form(); // a new reference-type variable 3 4 Test (myPoint, myForm); // Test is a method defined below 5 6 7 8 void Test (Point p, Form f) 9 10 { 11 p.X = 100; // No effect on MyPoint since p is a copy 12 f.Text = "Hello, World!"; // This will change myForm’s caption since 13 // myForm and f point to the same object 14 f = null; // No effect on myForm 15 }
将null赋给f没有任何影响,因为f是引用的副本,我们只是擦除了副本而已。
我们可以使用ref修饰符更改参数的打包(编排)方式。当通过“引用”传递时,该方法直接与调用方的参数进行交互。在下面的示例中,你可以将参数p和f替换为myPoint和myForm:
1 Point myPoint = new Point (0, 0); // a new value-type variable 2 Form myForm = new Form(); // a new reference-type variable 3 4 Test (ref myPoint, ref myForm); // pass myPoint and myForm by reference 5 6 7 8 void Test (ref Point p, ref Form f) 9 10 { 11 p.X = 100; // This will change myPoint’s position 12 f.Text = “Hello, World!”; // This will change MyForm’s caption 13 f = null; // This will nuke the myForm variable! 14 }
在这个例子中,将null赋给f也会使myForm为null,因为这次我们处理的是原始引用变更,而不是它的副本。
内存分配
CLR为对象分配内存主要是两个位置:stack和heap。stack是一种简单的先入后出的内存结构,效率非常高。当调用函数方法时,CLR会在stack顶部添加书签标记。然后,该函数方法执行时会将数据推送到stack上。当函数方法执行结束后,CLR只需将stack重置为其以前的标签---“弹出”所有函数的内存分配是一个简单的操作。
相反地,heap可以被描述为一种随机混杂的对象。它的优点是允许以随机顺序分配或释放对象。正如我们销后将看到的,heap需要内存管理器和垃圾收集器的开销来保持事物的有序进行。
为了说明stack和heap如何使用,请考虑以下方法:
1 void CreateNewTextBox() 2 { 3 TextBox myTextBox = new TextBox(); // TextBox is a class 4 }
在这个方法中,我们创建一个引用对象的局部变量。
stack始终用于存储以下两件事情:
1、引用类型的局部变量和参数的引用部分(比如myTextBox引用)
2、值类型局部变量和方法参数(structs,还有整数,bools,chars,DateTimes,等等)
以下数据是存储在heap中:
1、引用类型的内容。
2、引用类型对象内部的任何结构。
内存处理
一旦CreateNewTextBox结束运行,它的本地stack分配变量,myTextBox,将从作用域中消失,并从stack中“弹出”;然而,它正在指向heap上现在已经孤立的对象会发生什么情况呢?答案是我们可以忽略它---CLR的垃圾回收器将在一段时间后捕获它,并自动从heap中释放它。垃圾回收器将知道如何删除它,因为对象已没有了有效的引用(其引用链源自于stack分配的对象)。C++程序员可能对此有点不太适应,无论如何可能还是想要删除该对象(仅仅是为了确定!),但事实上,没有办法显式删除该对象。我们必须依赖CLR来处理内存,事实上,整个.NET framework都是这么做的。
然而,有一个关于自动销毁的警告。当不再需要对象时,需要明确地告诉已经分配了资源而不是内存的对象(特别是“句柄”,如Windows句柄、文件句柄和SQL句柄)来释放那些资源。这包括所有的Windows控件,因为它们都拥有Windows句柄。你可能会问,为什么不把释放这些资源的代码放在对象的析构函数中呢?(析构函数是CLR在对象销毁之前运行的方法)主要原因是垃圾收集器关心的是内存问题,而不是资源问题。所以,在一台只有几个GB内存可用的PC机上,垃圾回收器可能要等一两个时间才能开始工作。
那么,我们如何让textbox文本框释放Windows句柄,并在完成后让文本框在屏幕上消失呢?好吧,首先,我们做的代码示例不是非常好。实际上,我们应该将textbox控件放在窗体上,使控件首先是可见的。假设myForm早已经创建好,并且仍然有效,那下面就是我们通常要做的:
1 myForm.Controls.Add (myTextBox);
除了使控件可见之外,还给了它另一个引用(myForm.Controls);这意味着,当本地引用变量myTextBox退出作用域时,textbox不会有被垃圾回收器收回的危险;将它添加到控件集合的另一个影响就是.NET framework会在不再需要它的所有成员时,确定性地调用一个名为Dispose的方法函数。在Dispose方法中,控件会释放其windows句柄,并将文本框从屏幕上删除。
所有实现IDisposable的类(包括所有Windows窗体控件)都有Dispose方法。当不再需要对象,以释放资源而不是内存时,必须调用此方法。这种情况有两种发生方式: 1、手动(通过显式调用Dispose方法) 2、自动:通过将对象添加到.NET容器,比如Form,Panel、TabPage或者是UserControl。容器将确保当它被释放时,容器中的所有成员也会被释放。当然,容器自身必须被释放(或者说是,成为另一个容器的一部分)。 对于 Windows窗体控件来说,我们基本总是将它们添加到一个容器中---因此会依赖于自动化处理。
同样的事情也适用于像FileStream这样的类---这些类也需要进行垃圾回收处理。
using (Stream s = File.Create ("myfile.txt")) { ... }
可以翻译为如下代码:
Stream s = File.Create ("myfile.txt"); try { ... } finally { if (s != null) s.Dispose(); }
当在主代码语句块中发生异常时,Dispose的finally语句块仍会得到执行。
什么是WPF?
WPF中的大多数元素不包装需要显式释放的非托管句柄。所以你可以忽略WPF的垃圾回收处理!
Windows Forms 示例
让我们看看在Windows窗体应用程序中经常遇到的几种类型。Size是一种用于表示二维范围的数据类型,Font,如你所料想的,封装了字体和它的属性。你可以在.NET framework框架,System.Drawing命名空间中找到它们。Size类型是结构体---类似于Point,而Font类型是类。我们针对每个类型创建了一个对象:
Size s = new Size (100, 100); // struct = value type Font f = new Font (“Arial”,10); // class = reference type
我们将创建一个窗体,Form是一个在System.Windows.Forms命名空间中的类,因此是一个引用类型:
Form myForm = new Form();
为了设置窗体的尺寸和字体,我们可以通过属性将对象s和f分配给form:
myForm.Size = s;
myForm.Font = f;
不要对修饰符Size和Font的双重用法感到迷惑:现在他们只是涉及myForm的成员,不涉及Size和Font类。这种双重用法在C#中是可以接受的,并在整个.NET framework框架中广泛应用。
下面是它们在内存中的样子:
正如你所看到的那样,对于s,我们复制了它的内容;而对于f来说,我们复制了它的引用(导致在内存中有两个指针指向同一个Font对象)。这意味着s的更改变化不影响form,而f的更改变化
In-Line分配 先前我们说过,对于值类型的局部变量,内存是分配在stack上的。那么,这是否意味着新复制的Size结构也是会分配在stack上呢?答案是no,因为它不是局部变量!相反,它存储在heap上分配的另一个对象(本例中为form)的字段中。因此,它也必须分配在heap上。这种存储称为“in-line”。
结构的乐趣
我们在图表中做了一个稍微简单点的假设,即Font和Size在Form类中被描述为字段。更准确地说,它们是属性,属性所面向的内部表现我们是无法看到的。我们可以想像它们的定义如下:
class Form { // Private field members Size size; Font font; // Public property definitions public Size Size { get { return size; } set { size = value; fire resizing events } } public Font Font { get { return font; } set { font = value; } } }
通过使用属性,当窗体的尺寸或字体发生改变时,类就有机会触发事件。它为其它与尺寸相关的属性提供了更进一步的灵活性。比如ClientSize(控件内部区域的大小,不包括标题栏、边框或滚动条)可以与相同的私有字段共同工作。
但是有一个困难存在。假设我们想要通过form的一个属性,将form的高度增加一倍,似乎合理的做法如下:
myForm.ClientSize.Height = myForm.ClientSize.Height * 2;
或者更简洁的:
myForm.ClientSize.Height *= 2;
然而,这样会产生编译错误:
Cannot modify the return value of 'System.Windows.Forms.Form.ClientSize' because it is not a variable
无论是使用Size还是ClientSize,我们都会遇到同样的问题。我们看一看是为什么呢。
将ClientSize想象为一个公共字段,而不是一个属性。表达式myForm.ClientSize.Height只需简单一步就可以遍历成员层次结构,并按预期访问Height成员。但由于ClientSize是一个属性,首先计算ClientSize(使用属性的get方法),返回Size类型的对象。因为Size是一个结构(因此也是一个值类型),我们得到的是form大小的副本。就是这个副本,我们把它的尺寸扩大了一倍!。C#意识到了我们的错误,并生成了一个错误,而不是编译一些它知道行不通的东西。如果Size被定义为一个类,就不会有问题,因为ClientSize的get访问器会返回一个引用,让我们可以访问form的实际Size对象。
那么,我们如何改变form的大小呢?你必须给它分配一个全新的对象:
myForm.ClientSize = new Size (myForm.ClientSize.Width, myForm.ClientSize.Height * 2);
更多的好消息是,对于大多数控件,我们通常通过外部度量(Size而不是ClientSize)来设置它们的大小,并且对于这些控件,我们还可以get和set普通的整数宽度和高度属性。
你可能想知道他们是否可以通过将Size定义为一个类而不是一个结构来省去这些麻烦。但是,如果Size是一个类,那么它的Height和Width属性很可能是只读的,以避免每当它们的值改变时必须引发事件的复杂性(这样控件就可以知道如何调整自己的大小)。作为只读属性,你将被迫通过创建一个新的对象来改变它们---这样我们就又回到了起点。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!