1.复制值类型的变量和类
C#大多数基元类型(包括int,float,double和char等,但不包括string,原因稍后解释)都是值类型。将变量声明为值类型,编译器会生成代码来分配足以容纳这种值的内存块。例如,声明int类型的变量会导致编译器分配4字节(32位)内存块。向int变量赋值(例如42),将导致值被复制到内存块中。
类类型(比如上一篇博客讲的Circle类)则以不同方式处理。声明Circle变量时,编译器不生成代码来分配足以容纳一个Circle的内存块。相反,它唯一做的事情就是分配一小块内存,其中刚好可以容纳一个地址。以后,Circle实际占用内存块的地址会填充到这里。该地址称为对内存块的引用。Circle对象实际占用的内存是在使用new关键字创建对象时分配的。类是引用类型的一个例子。
注意:C#的string实际是类类型。由于字符串大小不固定,所以更高效的策略是在程序运行时动态分配内存,而不是在编译时静态分配。事实上,C#的string关键字是System.String类的别名。
值类型举例(修改i的值不会改变copyi的值):
int i = 42; //声明并初始化i
int copyi = i; //copyi包含i中的数据的拷贝,i和copyi都包含值42
i++; //i递增不影响copyi;i现在包含43,copyi仍然包含42
引用类型举例(refc和c都引用同一个Circle对象):
Circle c = new Circle(42);
Circle refc = c;
(@代表引用,容纳的是内存地址)
深拷贝和浅拷贝
上述的引用类型的复制其实就是浅拷贝(只复制了引用)
当然我们可以提供一个Clone()方法,去复制对象
例:
class Circle
{
private int radius;
//省略了构造器和其他方法
…
public Circle Clone()
{
//创建新的Circle对象
Circle clone = new Circle();
//将私有数据从this复制到clone
clone.rafius = this.radius;
//返回包含克隆数据的新Circle对象
return clone;
}
}
提供的Clone方法,就是“深拷贝”,复制的是引用的对象
2.理解null值和可空类型
C#允许将null值赋给任意引用变量。值为null的变量表明该变量不引用内存中的任何对象。
Circle c = new Circle(42);
Circle copy = null;//声明的同时进行初始化,这是最好的编程实践
…
if(copy == null)
{
copy = c; //copy和c引用同一个对象
…
}
如果下列代码在Circle对象包含空值的时候调用其Area方法:
Circle c = null;
Console.WriteLine($"The area of circle c is {c.Area()}");
这会造成c.Area方法抛出一个NullReferenceException。为避免出现该异常,我们对代码进行优化,如下:
if(c != null)
{
Console.WriteLine($"The area of circle c is {c.Area()}");
}
当然我们还可以用一种更简单的方法,使用空条件操作符:
Console.WriteLine($"The area of circle c is {c?.Area()}");
空条件操作符告诉“运行时”在操作符所应用的变量为null的前提下忽略当前语句。
2.1使用可空类型
null本身就是引用,不能把它赋给值类型
举例:
int i = null; //非法
当然我们可以将变量声明为可空值类型,此时可以将null值赋给它
例如:
int? i = null; //合法
注:不能将可空的值赋给普通的值类型的变量
2.2理解可空类型的属性
可空类型公开了两个属性,用于判断类型是否实际包含非空的值,以及该值是什么。其中,HasValue属性判断可空类型是包含一个值,还是包含null。如果包含值,可用Value属性获取该值。如下所示:
int? i = null;
…
if(!i.HasValue)
{
//如果i为null,就将99赋给它
i=99;
}
else
{
//如果i不为null,就显示它的值
Console.WriteLine(i.Value);
}
3.使用ref和out参数
先来举个例子:
static void doIncrement(int param)
{
param++;
}
static void Main()
{
int arg = 42;
doIncrement(arg);
Console.WriteLine(arg); //输出42,而不是43
}
doIncrement方法递增的只是实参(arg)的拷贝,原始实参不递增
3.1创建ref参数
为参数(形参)附加ref前缀,C#编译器将生成代码传递对实参的引用,而不是传递实参的拷贝
static void doIncrement(ref int param)
{
param++;
}
static void Main()
{
int arg = 42;
doIncrement(arg);
Console.WriteLine(arg); //输出43
}
注意:“变量使用前必须赋值”规则同样适合方法实参。不能将未初始化的值作为实参传给方法,即便是ref实参。
3.2创建out参数
编译器在调用方法之前,验证它的ref参数已被赋值。但有时希望由方法本身初始化参数,所以希望向其传递未初始化的实参。out关键字正是针对这一目的设计的。
关键字out是output(输出)的简称。向方法传递out参数之后,必须在方法内部对其进行赋值,否则无法编译
static void DoInitialize(out int param)
{
param = 42;//在方法中初始化,如果未初始化就会编译不通过
}
4.计算机内存的组织方式
为了理解值类型和引用类型的区别,有必要理解数据在内存中是如何组织的。
操作系统和“运行时”通常将用于容纳数据的内存划分为两个独立的区域,每个区域都以不同方式管理。这两个区域通常称为栈和堆。栈和堆的设计目标完全不同。
(1)调用方法时,它的参数和局部变量所需的内存总是从栈中获取。方法结束后,由于要么正常返回,要么抛出异常,所以为参数的局部变量分配的内存将自动归还给栈,并可在另一个方法调用时重新使用。栈上的方法参数和局部变量具有良好定义的生存期。方法开始时进入生存期,方法结束时结束生存期。
例如:
while()
{
int i = … ;//这时i在栈上创建
…
}
//这时i就从栈中消失了
(2)使用new关键字创建对象(类的实例)时,构造对象所需的内存总是从堆中获取。前面讲过,使用引用变量,可以从多个地方引用同一个对象。对象的最后一个引用消失之后,对象占用的内存就可供重用(虽然不一定被立即回收)。后面的博客中将进一步讨论堆内存是如何回收的。
“栈”和“堆”这两个词来源于“运行时”的内存管理方式。
(1)栈(Stack)内存就像一系列堆得越来越高的箱子。调用方法时,它的每一个参数都被放入一个箱子,并将这个箱子放到栈的最顶端。每个局部变量也同样分配到一个箱子,并同样放到栈顶。方法结束后,它的所有箱子都从栈中移除。
(2)堆(Heap)内存则像散布在房间里的一大堆箱子, 不像栈那样每个箱子都严格地堆在另一个箱子上。每个箱子都有一个标签,标记了这个箱子是否正在使用。创建新对象时,“运行时” 查找空箱子,把它分配给对象。对对象的引用则存储在栈上的一个局部变量中。“运行时” 跟踪每个箱子的引用数量(记住,两个变量可能引用同一个对象)。一旦最后一个引用消失,运行时就将箱子标记为“未使用”。将来某个时候,会清除箱子里的东西,使之能被重用。
使用栈和堆
思考调用以下方法会发生什么:
void Method(int param)
{
Circle c;
c = new Circle(param);
}
假定传给param的值是42。调用方法时,栈中将分配- - 小块内存(刚够存储一个int),并用值42初始化。在方法内部,还要从栈中分配出另一小块内存,它刚够存储.一个引用(一个内存地址),只是暂时不进行初始化(它是为Circle类型的变量C准备的)。接着,要从堆中分配一个足够大的内存区域来容纳一个Circle对象。这正是new关键字所执行的操作:它运Circle构造器,将这个原始的堆内存转换成Circle对象。对这个Circle对象的引用将存储到变量c中。下图对此进行了演示。
这时应注意以下两点。
(1)虽然对象本身存储在堆中, 但对象引用(变量c)存储在栈中。
(2)堆内存是有限的资源。堆内存耗尽,new操作符抛出OutOfMemoryException,对象创建失败。
方法结束后,参数和局部变量将离开作用域。为c和param分配的内存会被自动回收到栈。“运行时”发现已不存在对Circle对象的引用,所以会在将来某个时候,安排垃圾回收器回收它的内存(后面博客会讲到)。
5.System.Object类
.NET Framework最重要的引用类型之一是 System命名空间中的Object类。要完全理解System.object类的重要性,需要先理解继承(后面博客会单独讲继承)。就目前来说,请暂时接受这样的说法:所有类都是System.object的派生类:另外,System.object 类型的变量能引用任何对象。由于System.object 相当重要,所以C#提供了object 关键字来作System.object的别名。实际写代码时,既可以写成object,也可以写成System.object,两者没有区别。
下例的变量c和。引用同一个Circle对象。C的类型是Circle, 。的类型是object(System.object的别名),它们从不同角度观察内存中的同一个东西:
Circle c;
c = new Circle(42);
object o;
o = c;
内存图:
6.装箱
定义:将数据项自动赋值到堆的行为称为装箱
如前所述,object 类型的变量能引用任何引用类型的任何对象。除此之外,object类型的变量也能引用值类型的实例。例如,以下两个语句将int类型(一个值类型)的变量i初始化为42,并将object类型(一个引用类型)的变量。初始化为i:
int i = 42;
object o =i;
执行第二个语句所发生的事情需要仔细思考-下。1是值类型,所以它在栈中。如果直接引用i,那么引用的将是栈。然而,所有引用都必须引用堆上的对象:如果引用栈上的数据项,会严重损害“运行时”的健壮性,并造成潜在的安全漏洞,所以是不允许的。实际发生的事情是“运行时”在堆中分配一小块内存,然后i的值被复制到这块内存中,最后让o引用该拷贝。
内存图如下:
7.拆箱
首先我们先说以下转型的定义:为了访问已装箱的值,必须进行强制类型转换。这个操作会先检查是否能将一种类型安全转换成另一种类型,然后才执行转换。
比如:
int i = 42;
object o = 1;//装箱
i = (int)o;//成功编译 拆箱
拆箱:编译器生成的代码会从装箱的值类型中提取出值
内存图:
当然如果引用的不是已装箱的int,就会出现类型不匹配的情况,如下例所示:
Circle c = new Circle(42);
object o=c;// 不装箱,因为c是引用类型的变量,而不是值类型的变量
int i=(int)o;//编译成功,但在运行时抛出异常
内存图:
装箱有一定用处,但滥用会严重影响性能。后面博客中将介绍与装箱异曲同工的另一种技术—— 泛型。
8.数据的安全转型
从上面可以看出,进行强制类型转换时,如果内存中的对象的类型与指定的类型不匹配。“运行时”会抛出InvalidCastException异常。
下面介绍两个操作符,能以更得体的方式执行转型。
8.1is操作符
用is操作符验证对象的类型是不是自己希望的,如下所示:
WrappedInt wi = new WrappedInt();
…
object o = wi:
if (o is WrappedInt)
{
WrappedInt temp = (WrappedInt)o; // 转型是安全的:o确定是一个WrappedInt
…
}
is操作符取两个操作数:左边是对对象引用,右边是类型名称。如果左边的对象是右边的类型,则is表达式的求值结果为true,反之为false.换言之,上述代码只有在确定转型能成功的前提下,才真的将引用变量。转型为WrappedInt.
8.2as操作符
as操作符充当了和is操作符类似的角色,只是功能稍微进行了删减。可以像下面这
样使用as操作符:
WrappedInt wi = new WrappedInt();
…
object 0 = wi;
WrappedInt temp = o as WrappedInt;
if(temp != null)
{
//转型成功,这里的代码才会执行
}
和is操作符一样,as 操作符取对象和类型作为左右操作数。“ 运行时”尝试将对象转换成指定类型。若转换成功,就返回转换成功的结果。在本例中,这个结果被赋给WrappedInt类型的变量temp.相反,若转换失败,as表达式的求值结果为null,这个值也会被赋给temp。
后面博客也会进一步讨论is和as操作符。
参考书籍:《Visual C#从入门到精通》