相关文章连接:
编程之基础:数据类型(二)
- 3.1 引用类型与值类型 41
- 3.1.1 内存分配 42
- 3.1.2 字节序 44
- 3.1.3 装箱与拆箱 45
- 3.2 对象相等判断 46
- 3.2.1 引用类型判等 46
- 3.2.2 简单值类型判等 47
- 3.2.3 复合值类型判等 47
- 3.3 赋值与复制 50
- 3.3.1 引用类型赋值 50
- 3.3.2 值类型赋值 51
- 3.3.3 传参 52
- 3.3.4 浅复制 55
- 3.3.5 深复制 57
- 3.4 对象的不可改变性 60
- 3.4.1 不可改变性定义 60
- 3.4.2 定义不可改变类型 61
- 3.5 本章回顾 63
- 3.6 本章思考 63
3.3 赋值与复制
3.3.1 引用类型赋值
通过赋值运算符"="操作后,运算符两头的变量应该是相等的,这个是永远不会变的,不管在什么计算机语言中,也不管是值类型还是引用类型甚至其它类型。将.NET中一个引用类型变量赋值给另外一个引用类型变量后,两个变量相等,既然相等,那么两个变量(栈中引用)肯定是指向堆中同一个实例,见下代码Code 3-7:
1 //Code 3-7 2 3 class RefType //NO.1 4 { 5 int _a; 6 bool _b; 7 public RefType(int a,bool b) 8 { 9 _a = a; 10 _b = b; 11 } 12 } 13 class Program 14 { 15 static void Main() 16 { 17 RefType r = new RefType(1,true); //NO.2 18 RefType r2 = r; //NO.3 19 } 20 }
如上代码Code 3-7所示,代码先创建了一个引用类型RefType(NO.1处),然后创建该类型的一个对象(NO.2处),变量r指向该对象在堆中的实例,最后将变量r赋值给变量r2(NO.3处),该操作执行后,r和r2相等,都指向堆中同一个实例。见下图3-10:
图3-10 引用类型赋值
如上图3-10所示,赋值后,栈中两个变量指向堆中的同一实例。
可以看出,引用类型变量赋值后,对两个变量中任何一个操作,都会影响另外一个,这种情况会发生在引用类型"传参"过程中,详见3.3.3小节。
3.3.2 值类型赋值
既然赋值后,两个变量是相等的,既然相等,那么两个变量中包含的内容也应该一一相等。这个对于简单值类型来讲,很好理解,整型变量a(值为1)赋值给整型变量b后,a和b的值相等(值都等于1);对于复合值类型来讲,赋值的过程就是成员的一一赋值,最后对应的成员一一相等,下面代码Code 3-8显示了复合值类型赋值情况:
1 //Code 3-8 2 3 struct MultipleValType1 //NO.1 4 { 5 int _a; 6 bool _b; 7 public ValType(int a,bool b) 8 { 9 _a = a; 10 _b = b; 11 } 12 } 13 struct MultipleValType2 //NO.2 14 { 15 int _a; 16 int[] _ref; 17 public MultipleValType(int a,int[] ref) 18 { 19 _a = a; 20 _ref = ref; 21 } 22 } 23 class Program 24 { 25 static void Main() 26 { 27 MultipleValType1 mvt1 = new MultipleValType(1,true); //NO.3 28 MultipleValType1 mvt2 = mvt1; //NO.4 29 MultipleValType2 mvt3 = new MultipleValType2(1,new int[]{1,2,3}); //NO.5 30 MultipleValType2 mvt4 = mvt3; //NO.6 31 } 32 }
如上代码Code 3-8所示,代码中创建了两种复合值类型MultipleValType1和MultipleValType2,一种只由简单值类型组成(NO.1处),另外一种由简单值类型和引用类型组成(NO.2处),然后定义一个MultipleValType1类型的变量mvt1(NO.3处),将它赋值给变量mvt2(NO.4处),之后还定义了一个MultipleValType2类型的变量mvt3(NO.5处),将它赋值给变量mvt4(NO.6处)。赋值操作后,mvt1和mvt2相等,mvt3和mvt4相等,见下图3-11:
图3-11 值类型赋值
如上图3-11所示(为了方便绘图,注意图中栈里的数据并没按正确顺序存储),值类型变量赋值时,逐个成员依次赋值,当成员中不包含引用类型时,赋值后两个对象完全独立(如mvt1和mvt2),操作其中一个不会影响另外一个;当成员中包含引用类型时,由于引用类型成员指向堆中同一实例,赋值后两个对象不完全独立(如mvt3和mvt4),操作其中一个有可能影响另外一个。
引用类型赋值和值类型赋值有很大的区别,前者传递的是对象在堆中的"地址"(索引、引用),其它都不变;后者会发生一次完整的"成员赋值",逐个成员依次赋值。正因为这种赋值差异的存在,导致引用类型和值类型在"传参"过程中有明显差别,详见下一小节。
3.3.3 传参
所谓"传参",其实就是赋值,将实参赋给形参。由于.NET中的引用类型与值类型的赋值存在差异,所以在传递参数时,我们需要分清参数到底是什么类型,这个非常重要,因为引用类型传参后,实参和形参指向的是堆中同一个实例,使用形参操作堆中的实例,直接能影响到实参指向的实例;而值类型传参后,形参是实参的一个副本,形参和实参包含有相同的内容,一般对形参进行的操作不会影响到实参,但也有例外,比如值类型中包含有引用类型的成员,不管在形参还是实参中,这个引用类型成员指向堆中同一个实例,通过该引用类型成员,形参照样可以影响到实参。
注:"形参"指方法执行时,用于接收(存储)外部传值的临时变量,它在方法体内部可见;"实参"则是调用方在调用方法时,用来给方法传递参数的变量,它在方法体内部不可见。
void Func(int a)
{
//这里的a是形参
//…
}
调用方法代码:
int a = 1;
Func(a); //这里的a是实参,实参a将值赋给形参a
下面代码演示值类型传参和引用类型传参:
1 //Code 3-9 2 3 class RefType //NO.1 4 { 5 public int a; 6 public bool b; 7 public RefType(int a,bool b) 8 { 9 this.a = a; 10 this.b = b; 11 } 12 } 13 struct ValType //NO.2 14 { 15 public int a; 16 public int[] ref; 17 public ValType(int a,int[] ref) 18 { 19 this.a = a; 20 this.ref = ref; 21 } 22 } 23 class Program 24 { 25 static void Main() 26 { 27 RefType rt = new RefType(1,true); //NO.3 28 UpdateRef(rt); //NO.4 29 // rt.a == 2 30 ValType vt = new ValType(1,new int[]{1,2,3}); //NO.5 31 UpdateVal(vt); //NO.6 32 // vt.a == 1 and vt.ref contains {2,2,3} 33 } 34 static void UpdateRef(RefType tmp) 35 { 36 tmp.a += 1; //NO.7 37 } 38 static void UpdateVal(ValType tmp) 39 { 40 tmp.a += 1; //NO.8 41 tmp.ref[0] += 1; //NO.9 42 } 43 }
如上代码Code 3-9所示,代码中定义了一个引用类型RefType(NO.1处)和一个值类型ValType(NO.2处),然后分别创建一个变量rt(NO.3处)和vt(NO.5处),将这两个变量作为实参,分别传递给UpdateRef()方法和UpdateVal()方法,UpdateRef方法改变形参tmp中成员a的值(加1,NO.7处),这时候,实参rt的成员a也会受到影响(也会加1);UpdateVal方法中改变形参tmp中成员a的值(加1,NO.8处),实参vt的成员a不会受到影响,但是,UpdateVal方法中使用形参tmp中引用类型成员ref去改变堆中数组中的值时(首元素加1,NO.9处),实参vt中的数组也会受到影响。详细过程参见下图3-12:
图3-12 引用类型传参与值类型传参
如上图3-12所示(为了方便绘图,注意图中栈里的数据并没按正确顺序存储),左边为传参之前栈和堆中的情况,右边为传参之后栈和堆中的情况。可以看出,引用类型传参时,形参tmp可以完全操控实参rt指向的实例,而值类型传参时,形参tmp不能操控vt中值类型成员,但是仍然可以通过引用类型成员ref操控vt中ref指向的堆中实例。
引用类型传参和值类型传参各有优点,可以分场合使用不同的类型进行传参,有些时候我们在调用方法时,希望方法在操作形参的时候,同时影响到实参,也就是说希望方法的调用能够对方法体外部变量起到效果,那么我们可以使用引用类型传参;如果仅仅是为了给方法传递一些必需的数值,让方法能够正常运行,不需要方法的执行能够影响到外部变量,那么我们可以使用值类型(不包含引用类型成员)传参。
不管哪种类型传参,我们会发现,当有引用类型出现的时候(值类型中可以包含引用类型成员),方法体内总能通过形参去影响到实参,尤其是在调用一些别人开发好的类库,由于我们根本不知道类库中一些方法的具体实现,所以很难确定调用的方法会不会影响到我们传进去的实参,如果此时我们恰恰不希望方法能够影响到我们传递进去的实参,那该怎么做才能确保方法执行时(后),我们的实参不会受到影响呢?我们可以以实参为基础,复制出一个一模一样的副本,然后将该副本传递给方法,这样一来,方法体内部就不会影响到原来的对象。复制分两种,一种叫"浅复制",另一种叫"深复制",详见下一小节。
3.3.4 浅复制
所谓"浅复制",类似值类型赋值,将源对象的成员一一进行拷贝,生成一个全新的对象。新对象与源对象包含相同的组成,值类型赋值就是一种"浅复制"。对于引用类型,怎么实现浅复制呢?下面代码Code 3-10演示引用类型怎样实现浅复制:
1 //Code 3-10 2 3 class RefType:IClonable //NO.1 4 { 5 int _a; 6 int[] _ref; 7 public RefType(int a,int[] ref) 8 { 9 _a = a; 10 _ref = ref; 11 } 12 public Object Clone() 13 { 14 return new RefType(_a,_ref); //NO.2 15 } 16 } 17 class Program 18 { 19 static void Main() 20 { 21 RefType rt = new RefType(1,new int[]{1,2,3}); //NO.3 22 RefType rt2 = rt; //NO.4 23 RefType rt3 = rt.Clone(); //NO.5 24 } 25 }
如上代码Code 3-10所示,代码定义了一个引用类型RefType(NO.1处),它包含一个值类型成员和一个引用类型成员。RefType实现了IConable接口,该接口包含一个Clone()方法,意为克隆出一个新对象,在实现Clone()方法时,方法中调用了RefType的构造方法创建一个全新的RefType对象(NO.2处),并将其返回。显而易见,新创建的副本与源对象包含相同的组成。接着,在客户端代码中,我们实例化一个RefType对象(NO.3处),我们首先执行"赋值"操作,将引用rt赋给rt2(NO.4处),这时候rt和rt2指向了堆中同一个实例,紧接着,我们调用rt的Clone方法,将返回值赋给rt3(NO.5处),这时候rt3指向了堆中另外一个实例,见下图3-13:
图3-13 引用类型的浅复制
如上图3-13所示,很容易看到,引用类型的赋值和复制之间的区别,"rt2=rt;"这样的赋值语句,导致rt和rt2指向堆中的同一实例;而"rt3=rt.Clone();"这样的复制语句,不会导致rt和rt3指向堆中的同一实例,rt3会指向一个副本,而该副本的内容与原对象中的内容一致,成员之间进行了一一拷贝。我们将图3-13与图3-11进行比较会发现,引用类型的浅复制与值类型赋值(前面说过,值类型赋值就是一种浅复制)非常相似,都是将成员进行逐一拷贝,产生了一个全新的对象,唯一的区别是:值类型的浅复制发生在栈中,而引用类型的浅复制发生在堆中。对象浅复制的过程见下图3-14:
图3-14 对象浅复制过程
如上图3-14所示,任何一个对象包含三种成员:简单值类型、复合值类型以及引用类型。浅复制发生时,简单值类型成员直接一一赋值,引用类型成员直接一一赋值,复合值类型成员由于本身可以再包含其它的成员,所以需要递归赋值。
注:int、bool本质上也是struct类型,本书中只是将这些.NET内置类型归纳为"简单值类型",简单值类型与复合值类型的定义并不是官方的。另外浅复制也称为"浅拷贝"。
不管是值类型还是引用类型,它们的浅复制都只是将对象内部成员进行一一赋值,然后产生一个新对象。如果成员是引用类型,那么新对象与源对象中该引用类型成员会指向堆中同一实例,换句话说,复制产生的副本与源对象仍有关联(见图3-13中,rt与rt3中的_ref指向同一个整型数组),操作其中一个很有可能影响到另外一个。要想复制出来的副本与源对象彻底断绝关联,那么需要将源对象成员(包括所有直接成员和间接成员)中所有的引用类型成员全部进行浅复制,如果引用类型成员本身还包括自己的引用类型成员,那么必须依次递归进行浅复制,由上向下递归进行浅复制的过程叫"深复制",详细过程请参见下一小节。
注:对于值类型来讲,浅复制出来的副本与源对象是相等的,原因很简单,副本与源对象中包含的成员一一相等;但是对于引用类型来讲,浅复制出来的副本与源对象是不相等的,原因也很简单,副本和源对象在堆中占有不同的内存地址。
3.3.5 深复制
"浅复制"仅仅是将对象成员进行一一赋值,而无论成员是简单值类型、复合值类型还是引用类型,这就造成了一个问题:当一个对象包含有引用类型成员(包括直接成员和间接成员)时,浅复制出来的副本内部与源对象内部都包含一个指向堆中同一实例的引用。要想避免此问题,对象在进行浅复制时,如果存在引用类型成员,不能直接赋值,必须对该引用类型成员再进行浅复制,如果该引用类型成员本身还包含引用类型成员,必须依次递归进行浅复制。
注:直接成员指对象包含的第一级成员,间接成员指对象包含的一级成员(比如引用类型成员和复合值类型成员)本身包含的成员。
下图3-15显示了引用类型赋值、浅复制、深复制的区别:
图3-15 引用类型的深复制
如上图3-15所示,浅复制只是将对象的直接成员一一赋值,包括引用类型成员;而深复制指将对象的所有(直接或间接的)引用类型成员依次进行浅复制。值类型的赋值、浅复制、深复制的区别见下图3-16:
图3-16 值类型的深复制
如上图3-16所示,值类型的赋值与浅复制的效果是一样的,值类型的深复制指将对象的所有(直接或间接的)引用类型成员依次进行浅复制。
显而易见,不管是值类型还是引用类型的深复制,均是一个递归的过程,它要求对象的所有引用类型成员均能够进行浅复制。下图3-17显示了对象深复制的过程:
图3-17 对象深复制过程
如上图3-17所示,对象深复制发生时,简单值类型成员直接一一赋值;复合值类型成员由于本身可以包含其它引用类型成员,所以它需要递归浅复制;引用类型成员不再直接一一赋值,而是需要进行浅复制,如果引用类型成员中又包含其它引用类型成员,那么依次递归浅复制。下面代码Code 3-11显示了一个值类型的深复制:
1 //Code 3-11 2 3 struct ValType //NO.1 4 { 5 int _a; 6 RefType _ref; 7 public ValType(int a,RefType ref) 8 { 9 _a = a; 10 _ref = ref; 11 } 12 public ValType Clone() 13 { 14 return new ValType(_a,_ref.Clone()); //NO.2 15 } 16 } 17 class RefType //NO.3 18 { 19 int _b; 20 bool _c; 21 public RefType(int b,bool c) 22 { 23 _b = b; 24 _c = c; 25 } 26 public RefType Clone() 27 { 28 return new RefType(_b,_c); //NO.4 29 } 30 } 31 class Program 32 { 33 static void Main() 34 { 35 ValType vt = new ValType(1,new RefType(2,true)); //NO.5 36 ValType vt2 = vt; //NO.6 37 ValType vt3 = vt.Clone(); //NO.7 38 } 39 }
如上代码Code 3-11所示,先定义了一个复合值类型ValType(NO.1处),它包含一个简单值类型成员和一个引用类型成员,然后给该类型定义了一个Clone()方法,注意该方法中,并不是像浅复制那样逐个成员一一赋值,而是当遇到引用类型成员时,对引用类型成员同样调用了Clone方法进行浅复制(_ref.Clone())。在定义的引用类型RefType中(NO.3处),同样给出了一个Clone方法,实现该类型的浅复制(NO.4处)。最后在客户端代码中,先定义了一个值类型vt(NO.5处),然后将vt的赋值给vt2(相当于浅复制,NO.6处),将vt深复制出来的返回值赋给vt3(NO.7处),最后浅复制出来的副本vt2和深复制出来的副本vt3的区别见下图3-18:
图3-18 浅复制与深复制区别
如上图3-18所示,vt2是vt浅复制出来的副本,由于源对象vt中包含有引用类型,很显然,副本vt2与 源对象vt仍有瓜葛;相反,vt3是vt深复制出来的副本,我们可以看见,vt3与vt毫无关联,对vt3的任何操作都不会影响到vt。
Code 3-11中的ValType类型中只包含一个引用类型成员,如果它还包含有其它的复合值类型成员,那么该成员必须也要提供浅复制的方法,另外引用类型RefType中仅仅包含两个简单值类型,如果还包含其它的引用类型或者复合值类型成员,那么这些成员都必须提供能够浅复制的方法,这样要求的原因很简单:对象深复制是一个递归的过程,每个引用类型、复合值类型成员都必须能够浅复制自己。正因为这种限制的存在,所以并不是每种类型都能够进行深复制操作,一种类型能够进行深复制操作的前提是,它所有的引用类型成员(包括直接和间接的)都必须提供深复制的方法。
注:.NET中可以使用"序列化和反序列化"的技术实现对象的深复制,只要一个类型以及该类型中所有的成员类型都标示为"可序列化",那么我们就可以先序列化该类型对象到字节流,然后再将字节流反序列化成源对象的副本,这样一来,源对象与副本之间没有任何关联,达到深复制的效果。
3.4 对象的不可改变性
3.4.1 不可改变性定义
一个类型对象创建后,它的状态不能再改变,直到它死亡,它的状态一直维持着跟它创建时一样。这时候称该对象具有不可改变性,称这样的类型为不可改变类型(Immutable Type)。
注:有的地方称这样的对象具备"常量性"。
不可改变对象在创建的时候,必须完全初始化,因为对象的状态之后再没机会发生改变。如果想要在不可改变对象的身上进行操作,试图想让它"变成"另一个全新的对象,多数时候,该操作只会返回一个全新的对象,如String类型就是一种不可改变类型,对它的所有操作,String.Replace()、String.Trim()等方法都不会影响原有String对象,取而代之的是,这些方法都会返回一个全新的String对象。另外,.NET中的委托类型也是不可改变的,我们对一个委托进行的所有操作,均会产生一个全新的委托,而不会改变委托本身,详见本书后面有关委托与事件的章节。下图3-19显示在String对象上调用ToUpper()方法,不会影响原有对象:
图3-19 String类型的不可改变特性
注:String类型是一个特殊的类型,前面提到过,它虽然是引用类型,但是它不遵守引用类型判等的标准;另外,它还是不可改变类型,所有的操作均不会改变原有的String对象。
3.4.2 定义不可改变类型
定义一个不可改变类型时需要注意以下三点:
1)类型的构造方法一定要设计好,能够充分的初始化对象,因为对象创建后,无法再修改,构造方法是唯一能够修改对象状态的地方;
2)涉及到改变对象状态的方法均不能真正地改变对象本身,而都应该返回一个全新的对象,否则操作就没有实际意义;
3)类型所有的公开属性都应该是只读的,并且注意一些引用类型虽然是只读的,但是在类型外部还是可以通过只读的引用去改变堆中的实例,从而能够修改原对象的状态。
下面代码定义了一个不可改变类型:
1 //Code 3-12 2 3 class ImmutableType 4 { 5 private int _val; 6 private int[] _ref; 7 public int Val 8 { 9 get //NO.1 10 { 11 return _val; 12 } 13 } 14 public int[] Ref 15 { 16 get //NO.2 17 { 18 int[] b = new int[_ref.Length]; 19 for(int i=0;i<b.Length;++i) 20 { 21 b[i] = _ref[i]; 22 } 23 return b; 24 } 25 } 26 public ImmutableType(int val,int[] ref) //NO.3 27 { 28 _val = val; 29 _ref = ref; 30 } 31 public ImmutableType UpdateVal(int val) 32 { 33 return new ImmutableType(this.Val + val,this.Ref); //NO.4 34 } 35 } 36 class Program 37 { 38 static void Main() 39 { 40 ImmutableType a = new ImmutableType(1,new int[]{1,2,3}); //NO.5 41 a = a.UpdateVal(2); //NO.6 42 } 43 }
如上代码Code 3-12所示,代码创建了一个不可改变类型ImmutableType,它包含两个成员,一个值类型和一个引用类型,值类型属性是只读的(NO.1处),引用类型属性也是只读的(NO.2处),注意该引用类型属性不是简单的将引用对外公开,而是深度复制出来了一个副本,对外公开的是这个副本引用,外部不能使用该副本修改类内部的_ref数组。类型的构造方法也很完善,能够初始化所有成员(NO.3处),类型还包含一个"更新"_val成员的方法UpdateValue(),它的功能就是将_val增加一个指定数,但是我们可以看见,操作并没有真正地影响到对象本身,而是新new出了另外一个全新对象(NO.4处),将所有的效果都转嫁给新创建的对象。NO.5处创建一个不可变对象,NO.6处调用对象的UpdateValue()方法,并将返回的新对象赋给a引用,NO.5和NO.6处栈和堆中的变化见下图3-20:
图3-20
如上图3-20所示,左边为执行"a=a.UpdateValue(2);"之前栈和堆中的情况,右边为执行之后栈和堆中的情况,可以看到,对a的操作实质上不会改变堆中的对象实例,只会产生一个全新的对象。
3.5 本章回顾
学习"数据类型"是能够学习好编程的前提,熟练掌握各种数据类型的特点有利于提高我们编程的效率、提升开发系统性能以及稳定性。本章介绍了.NET中的两种数据类型:值类型和引用类型,并分别从对象的内存分配、对象判等、变量赋值以及对象复制等方面一一做出了介绍。章节最后还提到了不可改变类型,熟悉每种数据类型的不同表现差异是我们能够编写出好代码、好程序的第一步。
3.6 本章思考
1."值类型对象分配在栈中,引用类型对象分配在堆中"这句话是否准确?为什么?
A:严格上讲,不太准确,值类型对象也可以被包含在引用类型对象内部,一起分配在堆中,因此不能一概而论。
2."值类型对象的赋值就等于对象的浅复制"这句话是否正确?为什么?
A:正确。值类型对象赋值的过程就是浅复制的过程,依次将对象成员一一进行拷贝。
3.下面代码Code 3-13运行后会输出"true",因此可以判断String是值类型,因为只有值类型判等时才比较两者所包含的内容是否一一相等,以上陈述是否正确?为什么?
1 //Code 3-13 2 3 class Program 4 { 5 static void Main() 6 { 7 String a = new String("123"); 8 String b = new String("123"); 9 if(a == b) 10 { 11 Console.WriteLine("true"); 12 } 13 else 14 { 15 Console.WriteLine("false"); 16 } 17 } 18 }
A:错。String类型是一个特殊的引用类型,它的判等不同于其它引用类型去比较对象引用是否指向堆中同一实例,而是和值类型判等一致,比较对象内容是否一一相等。除此之外,String类型还是不可改变类型,对String对象的任何操作均不能改变该对象。
4.简要描述深复制与浅复制的区别。
A:对象进行浅复制时,只将对象的直接成员一一进行拷贝,当对象包含有引用类型成员时,源对象与副本之间有关联;对象进行深复制时,会将对象的所有成员(包括直接成员于间接成员)依次进行拷贝,不管对象是否包含引用类型成员,源对象与副本都无任何关联。
(本章完)