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属性很可能是只读的,以避免每当它们的值改变时必须引发事件的复杂性(这样控件就可以知道如何调整自己的大小)。作为只读属性,你将被迫通过创建一个新的对象来改变它们---这样我们就又回到了起点。

 

posted @   chenlight  阅读(187)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!
点击右上角即可分享
微信分享提示