对于一个常用语言为C++
的人来说,刚开始写C#
时很容易因为不清楚C#
中的值与引用而犯错误。下面就以一个简单的例子来说明这种小错误的来龙去脉。
1. list<T>比较的错误示例
namespace testValueAndRef
{
class Program
{
static void Main(string[] args)
{
List<int> listA = new List<int>() { 1, 2, 3 };
List<int> listB = new List<int>() { 1, 2, 3 };
Console.WriteLine(listA.Equals(listB));
Console.WriteLine(listA.GetHashCode().ToString());
Console.WriteLine(listB.GetHashCode().ToString());
}
}
}
从C++
转到C#
,我的第一印象是C#
的基本类库好强大,似乎任何你能想到的基本操作都可以找到库函数。所以,当我想比较两个list
里面的内容是否一致时,我就想当然地写了上面那句话listA.Equals(listB)
。而结果,当然是出人意料的错误了,输出如下所示:
False
21083178
55530882
这里在false下面输出的两行分别为listA
和listB
的HashCode
,而默认情况下Equals()
比较的就是两个Object
的HashCode
,也就是对象实例引用的内存地址。
所以listA
和listB
之所以不相等,是因为默认情况下这里Equals()
函数比较的是它们的地址而不是它们的内容。如果把第二句改成List<int> listB = listA;
,则最后返回结果就是True
。
2. list<T>比较的解决方案
那么,如果解决这个问题呢?下面给出两种方案:
方案一:不采用Equals()
,而是自己来实现两个列表的值的比较。例如:
var eq = listA.Except(listB).Count() == 0 && listB.Except(listA).Count() == 0;
需要指出的是,这里示例的比较方法中把两个list中元素看成无序的,及{1,2,3}
和{1,3,2}
是相等了,如果你定义的两个list
相等是指元素一对一地相等恐怕上面的方法不能胜任。
另外,有人指出可以用C#4
中引入的Zip
来实现两个list
的比较,lista.Count()==listb.Count()&&lista.Zip(listb,Equals).All(a=>a);
,参见stackoverflow
方案二:利用SequenceEqual
,它属于System.Linq
命名空间,可用于任何IEnumerable
类。
这里只要将原始例子中的Equals
那句换成listA.SequenceEqual(listB)
即可。如果你的list
里的元素是自定义类型,那么在使用SequenceEqual
的时候还需要另外一个参数IEqualityComparer
。
下面把上面列子中list
里的元素从int
换成自定义的Point
,那么在用SequencEqual
时需要提供Point
的比较方式。可以看到我们提供的TestComparer
也就是重写了Equals
和GetHashCode
两个函数,而这两个函数是C#
中类层次最高的父类Object
中定义的,所以如果你在定义Point
(他是继承Object
的)的时候就可以重写这两个函数,这样的话可以直接用SequenceEqual
。
namespace testValueAndRef
{
class Program
{
static void Main(string[] args)
{
var a = new Point(1, 2);
var b = new Point(3, 4);
var listA = new List<Point>() { a, b };
var listB = new List<Point>() { a, b };
Console.WriteLine(listA.SequenceEqual(listB, new TestComparer()));
}
}
public class Point
{
public int x;
public int y;
public Point(int s, int t)
{
x = s;
y = t;
}
}
public class TestComparer : IEqualityComparer<Point>
{
public bool Equals(Point p1, Point p2)
{
return p1.x.Equals(p2.x) && p1.y.Equals(p2.y);
}
public int GetHashCode(Point obj)
{
return obj.x.GetHashCode() + obj.y.GetHashCode();
}
}
}
3. 总结C#中的值与引用
上面的问题解决了,那么,为了更深刻地理解C#
中的值和引用,下面我们来简单总结一下。
(1) 值类型
所谓值类型,就是指变量即代表值本身。C#
中的基础数据类型(string
除外)、枚举和结构体都属于值类型。
值类型都隐式派生自System.ValueType
,而System.ValueType
又继承自最高父类System.Object
,ValueType
的作用是确保其所有派生类型都分配在栈上而不是垃圾回收堆上。
创建和销毁分配在栈上的数据都很快,因为它的生命周期是由定义的作用域决定的。当结构变量离开定义域时,它就会立即从内存中移除。而分配在堆上的数据由
.NET
垃圾回收器监控。
每个值类型都有默认值(可以用default(type)
查看),在定义值类型的变量时如果不赋值就使用(有些编译器在编译阶段就会检查并阻止这种情况)不会引起NullReferenceException
异常,而是使用默认值。
值类型的赋值操作,把一个值类型赋值给另外一个时,就是对字段成员逐一进行复制。这样进行多次赋值后,该值会在栈中有多份拷贝。
(2) 引用类型
引用类型变量的值是内存地址,其真实内容存储在该内存地址处。C#中的string
、Array
、类、接口和委托都是引用类型,引用类型都隐式继承自System.Object
。引用类型都分配在堆上,由GC
去管理他们的创建和注销。
所有引用类型的默认值都是null
,不赋值就直接使用会抛出NullReferenceException
异常。
引用类型的赋值操作,就是在内存中重定向引用变量的指向。这和C++
中很不一样,尤其要注意,引用类型赋值操作会使得多个变量共享同一数据块,任何一个变量对数据进行修改后,其他变量访问到的都是修改后的数据。
另外,既然值类型的赋值时拷贝一份数据,引用类型的赋值是直接赋数据的内存地址,那么对包含有引用类型的值类型如何处理呢?
假设我们定义了Rectangle
的结构体中包含了一个Point
类,下面是一个简单的例子。
struct Rectangle {
public Point leftTop;
public int rightBottomX, rightBottomY;
public Rectangle(Point p, int x, int y)
{
leftTop = p;
rightBottomX = x;
rightBottomY = y;
}
}
static void Main(string[] args)
{
Rectangle r1 = new Rectangle(new Point(1, 2), 3, 4);
Rectangle r2 = r1;
r2.rightBottomX = 5;
r2.leftTop.x = 6;
r2.leftTop.y = 7;
Console.WriteLine("[{0},{1},{2},{3}]", r1.leftTop.x, r1.leftTop.y, r1.rightBottomX, r1.rightBottomY);
Console.WriteLine("[{0},{1},{2},{3}]", r2.leftTop.x, r2.leftTop.y, r2.rightBottomX, r2.rightBottomY);
}
输出结果是:
[6,7,3,4]
[6,7,5,4]
所以,当值类型包含其他引用类型时,赋值将生成一个引用的副本。这样就有两个独立的结构,每个都包含指向内存中同一个对象的引用。(浅拷贝)
注:本文大部分内容来自《精通C#(第6版)》(PS:怎么写一行小字啊(-__-)b)
网友评论