.NET的很多概念如果总是从语法的角度或许你永远都不会理解到底为什么他会这么架构,但是如果你换个角度或许这些都会迎刃而解。从底层理解.NET的架构你就离高手更近一步了。本文只是从个人角度来瞅一眼为什么.NET的架构,若有不对的地方,还请各位指正。OK, here we go.
C/C++等程序如何使用内存
技术毕竟是一个逐渐积累进步的过程,.NET的推出不乏抄袭其他语言的地方,但是他有自己独特的地方,至于为什么会独特肯定会有语言的过人之处。
1、预备知识
C/C++编译的程序占用的内存分为以下几个部分(这里和《数据结构》中的说的是两码事哦)
1.1 栈(stack)— 由编译器自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
1.2 堆(heap) — 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收。
1.3、全局区(静态区)(static)—,全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域, 未初始化的全局变量和未初始化的静态变量在相邻的另 一块区域。 程序结束后由系统释。
1.4、文字常量区 —常量字符串就是放在这里的。 程序结束后由系统释放
1.5、程序代码区—存放函数体的二进制代码。
上代码:
//这是一个前辈写的,非常详细 //main.cpp int a = 0; 全局初始化区 char *p1; 全局未初始化区 main() { int b; 栈 char s[] = "abc"; 栈 char *p2; 栈 char *p3 = "123456"; 123456/0在常量区,p3在栈上。 static int c =0; 全局(静态)初始化区 p1 = (char *)malloc(10); p2 = (char *)malloc(20); 分配得来得10和20字节的区域就在堆区。 strcpy(p1, "123456"); 123456/0放在常量区,编译器可能会将它与p3所指向的"123456" 优化成一个地方。 }
2.理论知识
2.1 申请方式
stack: 由系统自动分配。 例如,声明在函数中一个局部变量 int b; 系统自动在栈中为b开辟空间
heap: 需要程序员自己申请,并指明大小,在c中malloc函数
如p1 = (char *)malloc(10);
在C++中用new运算符
如p2 = new char[10];
但是注意p1、p2本身是在栈中的。
2.2 申请后系统的响应
栈:只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出。
堆:首先应该知道操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序,另外,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样,代码中的delete语句才能正确的释放本内存空间。
另外,由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。
2.3 申请大小的限制
栈:在Windows下,栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意是栈顶的地址和栈的最大容量是系统预先规定好的,在WINDOWS下,栈的大小是2M(也有的说是1M,总之是一个编译时就确定的常数),如果申请的空间超过栈的剩余空间时,将提示overflow。因此,能从栈获得的空间较小。
堆:堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。
2.4 申请效率的比较:
栈由系统自动分配,速度较快。但程序员是无法控制的。
堆是由new分配的内存,一般速度比较慢,而且容易产生内存碎片,不过用起来最方便. 另外,在WINDOWS下,最好的方式是用VirtualAlloc分配内存,他不是在堆,也不是在栈是直接在进程的地址空间中保留一块内存,虽然用起来最不方便。但是速度快,也最灵活。
2.5 堆和栈中的存储内容
栈:在函数调用时,第一个进栈的是主函数中后的下一条指令(函数调用语句的下一条可执行语句)的地址,然后是函数的各个参数,在大多数的C编译器中,参数是由右往左入栈的,然后是函数中的局部变量。注意静态变量是不入栈的。当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,也就是主函数中的下一条指令,程序由该点继续运行。
堆:一般是在堆的头部用一个字节存放堆的大小。堆中的具体内容由程序员安排。
2.6 存取效率的比较
char s1[] = "aaaaaaaaaaaaaaa";
char *s2 = "bbbbbbbbbbbbbbbbb";
aaaaaaaaaaa是在运行时刻赋值的;
而bbbbbbbbbbb是在编译时就确定的;
但是,在以后的存取中,在栈上的数组比指针所指向的字符串(例如堆)快。
比如:
#include
void main()
{
char a = 1;
char c[] = "1234567890";
char *p ="1234567890";
a = c[1];
a = p[1];
return;
}
对应的汇编代码
10: a = c[1];
00401067 8A 4D F1 mov cl,byte ptr [ebp-0Fh]
0040106A 88 4D FC mov byte ptr [ebp-4],cl
11: a = p[1];
0040106D 8B 55 EC mov edx,dword ptr [ebp-14h]
00401070 8A 42 01 mov al,byte ptr [edx+1]
00401073 88 45 FC mov byte ptr [ebp-4],al
第一种在读取时直接就把字符串中的元素读到寄存器cl中,而第二种则要先把指针值读到
edx中,再根据edx读取字符,显然慢了。
2.7小结
Stack由系统控制,特性是FIFO,越过使用域即被系统清理,立马分配给其他变量,它的分配空间有限,但是因为在内存上所以速度比较快,常用于值类型等较轻量的变量存储.
Heap由猿们自个儿控制,比较灵活,其主要存储引用类型.其主要是系统搜索内存的空闲链表来实现分配,可分配空间较大。它的速度不快主要效率损失在一些三方面:
a.虚拟内存存储
虚拟内存是WINDOWS为了弥补内存不足,系统从硬盘中匀出的一部分磁盘空间作为内存的补充,它与内存的速度不能相比。
b.寻址过程
链表决定了系统在分配空间的时候要逐个遍历。系统在分配空间的时候会产生很多不连续的空间,假使内存足够大,寻址过程就是个不小的消耗。另外由于引用类型的销毁实际上是Stack中指针的销毁,对应的Heap中的部分并未做处理,因此就要求猿们要手动写析构函数,释放可用空间,不至于系统Heap空间不足报Overflow。
c.读取损耗
引用类型的分配是在Stack中分配一个空间作为指针,再从Heap中分配一段连续空间作为其指向,因此在使用的时候首先要从Stack中读取Heap中的内容再进行操作。
堆和栈的区别可以用如下的比喻来看出:
使用栈就象我们去饭馆里吃饭,只管点菜(发出申请)、付钱、和吃(使用),吃饱了就走,不必理会切菜、洗菜等准备工作和洗碗、刷锅等扫尾工作,他的好处是快捷,但是自由度小。
使用堆就象是自己动手做喜欢吃的菜肴,比较麻烦,但是比较符合自己的口味,而且自由度大。
.NET的如何使用内存
上面我们说了,简单的轻量类型是直接放到Stack上的,这点.NET也是这么干的。传统程序是将引用类型放到了Heap上,但是有个很头疼的问题就是对于使用之后空间的处理问题,当电脑使用时间比较长之后,系统就会明显变慢,甚至会出现内存溢出。针对这个问题.NET提出了自己的解决方案:托管堆.意思是,微软为我们提供一个管理Heap的"保姆"兼"监工"。一方面,我们代码申请的空间受它检查,另一方面它会对申请之后我们不使用的Heap空间做处理。他使用的工具就是大名鼎鼎的GC(garbage collection),说的GC我们就顺便提一下他的工作过程吧。
基本算法很简单:
● 将所有的托管内存标记为垃圾
● 寻找正被使用的内存块,并将他们标记为有效
● 释放所有没有被使用的内存块
● 整理堆以减少碎片
我们看一下后面两个工作,再针对之前C/C++对Heap的处理方面是不是有点什么意会了呢?是的,它主要就是监控已经被抛弃的Heap块,然后回收。那么为什么还要进行整理呢?前面说过,Heap的寻址是很耗时的,但是GC将整理的结果排序一下,下次申请Heap的时候只要顺着从低地址向高地址直接找就是啦,而每块地址的大小也是有记录的,指针只要沿着每块的边界找,即可很快找到未被使用的块。GC会把可回收的Heap内存块分成3个Generation,新分配的对象会放到Generation 0 中,这个因为是刚被使用过,所以回收的效率最高,回收的内存也最多,至于那些“关系户”,还在被引用的Heap块,是少数,暂时不做处理,放到Generation 1中。Generation 0的尺寸很小(小到足以放进处理器的L2 cache中),当Generation 0很快被装满之后,GC就触发了回收操作,这个动作非常块,如此循环之后Generation 1 中也总有装满的那一刻,此时便出发了GC回收Generation 1的操作,Generation 1的容量比Generation 0 要大许多,当Generation 1中发现还有被引用的Heap块,经过此时的一次回收后这些不可回收的就会放到Generation 2中,Generation 2比Generation 1 容量还要大,对Generation 2的回收过程具有很高的开销,并且此过程只有在Generation 0和Generation 1的GC过程不能释放足够的内存时才会被触发。如果对Generation 2的GC过程仍然不能释放足够的内存,那么系统就会抛出OutOfMemoryException异常。
GC中回收内存的时候会将Heap内存压缩到一起,使其连续,想一想引用类型的内存分配过程,是不是有些地方有些不对劲?是的,Stack中指针也要调整的嘛。
GC做了那么多的工作,会不会拖慢程序执行的效率呢?微软认为,虽然GC额外的增加了程序的开销,但是他预防了Overflow,另外从其工作原理来看也相应的增加了内存分配的速度,所以还是值得大家拥有的。
GC并非万能,碰到另外两种情况它也很无奈:
1.大对象。通常,大对象具有很长的生存期。当一个大对象在.NET托管堆中产生时,它被分配在堆的一个特殊部分中,这部分堆永远不会被整理。因为移动大对象所带来的开销超过了整理这部分堆所能提高的性能。
2.外部资源(External Resources)
垃圾收集器能够有效地管理从托管堆中释放的资源,但是资源回收操作只有在内存紧张而触发一个回收动作时才执行。那么,类是怎样来管理像数据库连接或者窗口句柄这样有限的资源的呢?等待,直到垃圾回收被触发之后再清理数据库连接或者文件句柄并不是一个好方法,这会严重降低系统的性能。
所有拥有外部资源的类,如果文件名柄、网络连接和数据库连接等,在这些资源已经不再用到的时候,都应当执行Close或者Dispose方法。从Beta2(译注:本文中所有的Beta2均是指.NET Framework Beta2,不再特别注明)开始,Dispose模式通过IDisposable接口来实现。这将在本文的后续部分讨论。
需要清理外部资源的类还应当实现一个终止操作(finalizer)。在C#中,创建终止操作的首选方式是在析构函数中实现,而在Framework层,终止操作的实现则是通过重载System.Object.Finalize 方法。以下两种实现终止操作的方法是等效的:
~OverdueBookLocator()
{
Dispose(false);
}
和:
public void Finalize()
{
base.Finalize();
Dispose(false);
}
在C#中,同时在Finalize方法和析构函数实现终止操作将会导致错误的产生。
除非你有足够的理由,否则你不应该创建析构函数或者Finalize方法。终止操作会降低系统的性能,并且增加执行期的内存开销。同时,由于终止操作被执行的方式,你并不能保证何时一个终止操作会被执行。
带有终止操作的对象的垃圾收集过程要稍微复杂一些。当一个带有终止操作的对象被标记为垃圾时,它并不会被立即释放。相反,它会被放置在一个终止队列(finalization queue)中,此队列为这个对象建立一个引用,来避免这个对象被回收。后台线程为队列中的每个对象执行它们各自的终止操作,并且将已经执行过终止操作的对象从终止队列中删除。只有那些已经执行过终止操作的对象才会在下一次垃圾回收过程中被从内存中删除。这样做的一个后果是,等待被终止的对象有可能在它被清除之前,被移入更高一级的generation中,从而增加它被清除的延迟时间。
需要执行终止操作的对象应当实现IDisposable接口,以便客户程序通过此接口快速执行终止动作。IDisposable接口包含一个方法——Dispose。这个被Beta2引入的接口,采用一种在Beta2之前就已经被广泛使用的模式实现。从本质上讲,一个需要终止操作的对象暴露出Dispose方法。这个方法被用来释放外部资源并抑制终止操作,就象下面这个程序片断所演示的那样:
public class OverdueBookLocator: IDisposable
{
~OverdueBookLocator()
{
InternalDispose(false);
}
public void Dispose()
{
InternalDispose(true);
}
protected void InternalDispose(bool disposing)
{
if(disposing)
{
GC.SuppressFinalize(this);
// Dispose of managed objects if disposing.
}
// free external resources here
}
}
小结:
内存格局通常分为四个区
全局数据区:存放全局变量,静态数据,常量
代码区:存放所有的程序代码
栈区:存放为运行而分配的局部变量,参数,返回数据,返回地址等,
堆区:即自由存储区
C#使用到的有两个即堆与栈,区别于其他语言的重要地方就是他提供的叫托管堆,托管堆主要维护Stack到Heap的指针维护功能,整理碎片,回收压缩Heap块内存,分配Heap的内存块给new出来的引用。大对象CLR是不管的,因为回收成本太高。对于外部资源如文件句柄、网络连接、数据库链接等GC不做任何处理,因为C#并未提供这些功能,它提供的仅仅是调用Windows的API,获取资源之后再进行操作,为了提高效率这些方法必须使用实现了IDisposable的方法,用完即释放,这些外部资源对象在回收时会被放到终止队列,终止队列为此对象创建一个引用,之后后台线程就会逐个终止对象,回收其占用的Heap块。
C#提供自己写析构函数的功能,由于手动回收消耗大量资源,所以最好把这些功能留给GC处理。GC会不定时的将一些不使用的对象做回收,这个是系统由系统做判断的,如果强制回收可能会造成意想不到的结果。
这些就是CLR的基础功能。
为增加效率.NET所做的努力
1.提供非托管代码的实现
些时候C#程序也需要追求速度,比如对一个含用大量成员的数组的操作,如果仍使用传统的类来操作,将不会得到很好的性能,因为数组在C#中实际是System.Array的实例,会存储在托管堆中,这将会对运算造成大量的额外的操作,因为除了垃圾收集器除了会压缩托管堆、更新引用地址、还会维护托管堆的信息列表。所幸的是C#中同样能够通过不安全代码使用C++程序员通常喜欢的方式来编码,在标记为unsafe的代码块使用指针,这和在C++中使用指针没有什么不同,变量也是存府在堆栈中,在这种情况下声明一个数组可以使用stackalloc语法,比如声明一个存储有50个double类型的数组:
double* pDouble=stackalloc double[50]
stackalloc会给pDouble数组在堆栈中分配50个double类型大小的内存空间,可以使用pDouble[0]、*(pDouble+1)这种方式操作数组,与在C++中一样,使用指针时必须知道自己在做什么,确保访问的正确的内存空间,否则将会出现无法预料的错误。
2.泛型
泛型是什么,为什么要使用泛型,泛型的出现是解决什么问题?
先看一个例子:
ClassDemo obj1;
obj1=new ClassDemo();
Console.WriteLine("{0}",(int)obj1);
第一句定义了一个ClassDemo的引用,实质上只是在堆栈中分配了一个4个字节的空间,它将用来存府后来实例化对象在托管堆中的地址,在windows中这需要4个字节来表示内存地址。第二句实例化object1对象,实际上是在托管堆中开僻了一个内存空间来存储类class1的一个具体对象,假设这个对象需要36个字节,那么object1指向的实际上是在托管堆一个大小为36个字节的连续内存空间开始的地址。由此也可以看出在C#编译器中为什么不允许使用未实例化的对象,因为这个对象在托管堆中还不存在。当对象不再使用时,这个被存储在堆栈中的引用变量将被删除,但是从上述机制可以看出,在托管堆中这个引用指向的对象仍然存在,其空间何时被释放取决垃圾收集器而不是引用变量失去作用域时。
第三句时间上很可能会出问题,将obj1转换成int,我们知道轻量类型是存在于Stack中的而引用类型是存储值Heap中以Stack作为指针的,所以这里有个引用类型到值类型的转换过程,即拆箱。相应的值类型转换到引用类型的叫装箱。
一句话总结:泛型的出现就是为了解决装箱与拆箱的麻烦的。那么为什么要防止装拆箱呢?后面会做分解。现在先看为什么会发展到泛型。
我们先看下面的代码,代码省略了一些内容,但功能是实现一个栈,这个栈只能处理int数据类型:
public class Stack { private int[] m_item; public int Pop(){...} public void Push(int item){...} public Stack(int i) { this.m_item = new int[i]; } }
但是,当我们需要一个栈来保存string类型时,该怎么办呢?很多人都会想到把上面的代码复制一份,把int改成string不就行了。当然,这样做本身是没有任何问题的,但一个优秀的程序是不会这样做的,因为他想到若以后再需要long、Node类型的栈该怎样做呢?还要再复制吗?优秀的程序员会想到用一个通用的数据类型object来实现这个栈:
public class Stack { private object[] m_item; public object Pop(){...} public void Push(object item){...} public Stack(int i) { this.m_item = new[i]; } }
这个栈写的不错,他非常灵活,可以接收任何数据类型,可以说是一劳永逸。但全面地讲,也不是没有缺陷的,主要表现在:
当Stack处理值类型时,会出现装箱、折箱操作,这将在托管堆上分配和回收大量的变量,若数据量大,则性能损失非常严重。?
在处理引用类型时,虽然没有装箱和折箱操作,但将用到数据类型的强制转换操作,增加处理器的负担。?
在数据类型的强制转换上还有更严重的问题(假设stack是Stack的一个实例):
Node1 x = new Node1();
stack.Push(x);
Node2 y = (Node2)stack.Pop();
上面的代码在编译时是完全没问题的,但由于Push了一个Node1类型的数据,但在Pop时却要求转换为Node2类型,这将出现程序运行时的类型转换异常,但却逃离了编译器的检查。
针对object类型栈的问题,我们引入泛型,他可以优雅地解决这些问题。泛型用用一个通过的数据类型T来代替object,在类实例化时指定T的类型,运行时(Runtime)自动编译为本地代码,运行效率和代码质量都有很大提高,并且保证数据类型安全。
使用泛型
下面是用泛型来重写上面的栈,用一个通用的数据类型T来作为一个占位符,等待在实例化时用一个实际的类型来代替。让我们来看看泛型的威力:
public class Stack<T> { private T[] m_item; public T Pop(){...} public void Push(T item){...} public Stack(int i) { this.m_item = new T[i]; } }
类的写法不变,只是引入了通用数据类型T就可以适用于任何数据类型,并且类型安全的。这个类的调用方法:
/实例化只能保存int类型的类 Stack<int> a = new Stack<int>(100); a.Push(10); a.Push("8888"); //这一行编译不通过,因为类a只接收int类型的数据 int x = a.Pop();
这个类和object实现的类有截然不同的区别:
1. 他是类型安全的。实例化了int类型的栈,就不能处理string类型的数据,其他数据类型也一样。
2. 无需装箱和折箱。这个类在实例化时,按照所传入的数据类型生成本地代码,本地代码数据类型已确定,所以无需装箱和折箱。
3. 无需类型转换。
泛型类实例化的理论
C#泛型类在编译时,先生成中间代码IL,通用类型T只是一个占位符。在实例化类时,根据用户指定的数据类型代替T并由即时编译器(JIT)生成本地代码,这个本地代码中已经使用了实际的数据类型,等同于用实际类型写的类,所以不同的封闭类的本地代码是不一样的。按照这个原理,我们可以这样认为:
泛型类的不同的封闭类是分别不同的数据类型。
例:Stack<int>和Stack<string>是两个完全没有任何关系的类,你可以把他看成类A和类B,这个解释对泛型类的静态成员的理解有很大帮助。
泛型类中数据类型的约束
程序员在编写泛型类时,总是会对通用数据类型T进行有意或无意地有假想,也就是说这个T一般来说是不能适应所有类型,但怎样限制调用者传入的数据类型呢?这就需要对传入的数据类型进行约束,约束的方式是指定T的祖先,即继承的接口或类。因为C#的单根继承性,所以约束可以有多个接口,但最多只能有一个类,并且类必须在接口之前。这时就用到了C#2.0的新增关键字:
public class Node<T, V> where T : Stack, IComparable
where V: Stack
{...}
以上的泛型类的约束表明,T必须是从Stack和IComparable继承,V必须是Stack或从Stack继承,否则将无法通过编译器的类型检查,编译失败。
通用类型T没有特指,但因为C#中所有的类都是从object继承来,所以他在类Node的编写中只能调用object类的方法,这给程序的编写造成了困难。比如你的类设计只需要支持两种数据类型int和string,并且在类中需要对T类型的变量比较大小,但这些却无法实现,因为object是没有比较大小的方法的。 了解决这个问题,只需对T进行IComparable约束,这时在类Node里就可以对T的实例执行CompareTo方法了。这个问题可以扩展到其他用户自定义的数据类型。
如果在类Node里需要对T重新进行实例化该怎么办呢?因为类Node中不知道类T到底有哪些构造函数。为了解决这个问题,需要用到new约束:
public class Node<T, V> where T : Stack, new()
where V: IComparable
需要注意的是,new约束只能是无参数的,所以也要求相应的类Stack必须有一个无参构造函数,否则编译失败。
C#中数据类型有两大类:引用类型和值类型。引用类型如所有的类,值类型一般是语言的最基本类型,如int, long, struct等,在泛型的约束中,我们也可以大范围地限制类型T必须是引用类型或必须是值类型,分别对应的关键字是class和struct:
public class Node<T, V> where T : class
where V: struct
泛型方法
泛型不仅能作用在类上,也可单独用在类的方法上,他可根据方法参数的类型自动适应各种参数,这样的方法叫泛型方法。看下面的类:
public class Stack2
{
public void Push<T>(Stack<T> s, params T[] p)
{
foreach (T t in p)
{
s.Push(t);
}
}
}
原来的类Stack一次只能Push一个数据,这个类Stack2扩展了Stack的功能(当然也可以直接写在Stack中),他可以一次把多个数据压入Stack中。其中Push是一个泛型方法,这个方法的调用示例如下:
Stack<int> x = new Stack<int>(100);
Stack2 x2 = new Stack2();
x2.Push(x, 1, 2, 3, 4, 6);
string s = "";
for (int i = 0; i < 5; i++)
{
s += x.Pop().ToString();
} //至此,s的值为64321
泛型中的静态成员变量
在C#1.x中,我们知道类的静态成员变量在不同的类实例间是共享的,并且他是通过类名访问的。C#2.0中由于引进了泛型,导致静态成员变量的机制出现了一些变化:静态成员变量在相同封闭类间共享,不同的封闭类间不共享。
这也非常容易理解,因为不同的封闭类虽然有相同的类名称,但由于分别传入了不同的数据类型,他们是完全不同的类,比如:
Stack<int> a = new Stack<int>();
Stack<int> b = new Stack<int>();
Stack<long> c = new Stack<long>();
类实例a和b是同一类型,他们之间共享静态成员变量,但类实例c却是和a、b完全不同的类型,所以不能和a、b共享静态成员变量。
泛型中的静态构造函数
静态构造函数的规则:只能有一个,且不能有参数,他只能被.NET运行时自动调用,而不能人工调用。
泛型中的静态构造函数的原理和非泛型类是一样的,只需把泛型中的不同的封闭类理解为不同的类即可。以下两种情况可激发静态的构造函数:
1. 特定的封闭类第一次被实例化。
2. 特定封闭类中任一静态成员变量被调用。
泛型类中的方法重载
方法的重载在.Net Framework中被大量应用,他要求重载具有不同的签名。在泛型类中,由于通用类型T在类编写时并不确定,所以在重载时有些注意事项,这些事项我们通过以下的例子说明:
public class Node<T, V> { public T add(T a, V b) //第一个add { return a; } public T add(V a, T b) //第二个add { return b; } public int add(int a, int b) //第三个add { return a + b; } }
上面的类很明显,如果T和V都传入int的话,三个add方法将具有同样的签名,但这个类仍然能通过编译,是否会引起调用混淆将在这个类实例化和调用add方法时判断。请看下面调用代码:
Node<int, int> node = new Node<int, int>();
object x = node.add(2, 11);
这个Node的实例化引起了三个add具有同样的签名,但却能调用成功,因为他优先匹配了第三个add。但如果删除了第三个add,上面的调用代码则无法编译通过,提示方法产生的混淆,因为运行时无法在第一个add和第二个add之间选择。
Node<string, int> node = new Node<string, int>();
object x = node.add(2, "11");
这两行调用代码可正确编译,因为传入的string和int,使三个add具有不同的签名,当然能找到唯一匹配的add方法。
由以上示例可知,C#的泛型是在实例的方法被调用时检查重载是否产生混淆,而不是在泛型类本身编译时检查。同时还得出一个重要原则:
当一般方法与泛型方法具有相同的签名时,会覆盖泛型方法。
泛型类的方法重写
方法重写(override)的主要问题是方法签名的识别规则,在这一点上他与方法重载一样,请参考泛型类的方法重载。
泛型的使用范围
本文主要是在类中讲述泛型,实际上,泛型还可以用在类方法、接口、结构(struct)、委托等上面使用,使用方法大致相同,就不再讲述。
小结
C# 泛型是开发工具库中的一个无价之宝。它们可以提高性能、类型安全和质量,减少重复性的编程任务,简化总体编程模型,而这一切都是通过优雅的、可读性强的语法完成的。尽管 C# 泛型的根基是 C++ 模板,但 C# 通过提供编译时安全和支持将泛型提高到了一个新水平。C# 利用了两阶段编译、元数据以及诸如约束和一般方法之类的创新性的概念。毫无疑问,C# 的将来版本将继续发展泛型,以便添加新的功能,并且将泛型扩展到诸如数据访问或本地化之类的其他 .NET Framework 领域。
3.装箱与拆箱
先说一下C#中的"值类型"和"引用类型",这个先要从C#的原理中才好理解:
值类型: 它的数据存储在内存中的堆栈中,每个变量或程序都有自己的堆栈,不可以共用一个堆栈地址。当数据一个值类型的变量传递到另一个相同类型的变量时,会在堆栈中分配两个不同的地址。
值类型数据:
所有数字数据类型
Boolean、Char 和 Date
所有结构,即使其成员是引用类型
枚举,因为其基础类型总是 Byte、Short、Integer 或 Long
引用类型:它的数据存储在内存中的堆中,可以不同的变量或程序共同使用同一个位置的数据。当数据从一个引用类型的变量传递到另一个相同类型的变量时,只是把这个变量的引用地址传递给新的变量,同时引用当前堆中存储的数据。
引用类型数据:
String
所有数组,即使其元素是值类型
类类型,如 Form
委托
总结性的一句话:可以将引用类型指派给 Object 数据类型的变量,Object 变量总是持有指向数据的指针,从不持有数据本身。然而,如果将值类型指派给 Object 变量,则 Object 变量将表现得像持有自己的数据一样。
例子是最能说明问题的:
/// <summary> /// 引用类型的人 /// </summary> class clsPerson { public string name; } /// <summary> /// 值类型的人 /// </summary> struct strPerson { public string name; } static void Main(string[] args) { clsPerson p1 = new clsPerson(); p1.name = "point"; clsPerson p2 = new clsPerson(); p2 = p1; Console.WriteLine("现在p1和p2的中name的值分别为:{0}和{1}", p1.name, p2.name); Console.WriteLine("现在把p1中的name的值改变"); p1.name = "deng"; Console.WriteLine("现在p1和p2的中name的值分别为:{0}和{1}", p1.name, p2.name); Console.WriteLine(""); strPerson sp1 = new strPerson(); sp1.name = "lydia"; strPerson sp2 = new strPerson(); sp2 = sp1; Console.WriteLine("现在sp1和sp2的中name的值分别为:{0}和{1}", sp1.name, sp2.name); Console.WriteLine("现在把sp1中的name的值改变"); sp1.name = "xia"; Console.WriteLine("现在sp1和sp2的中name的值分别为:{0}和{1}", sp1.name, sp2.name); }
在引用类型中,改变其中一个的值,另一个也会跟差改变,因为他们都是引用的同一个内存地址,
在值类型中,改变其中一个的值,另一个不会变,因为在创建的时候,他们分别有了不同的内存地址。
但是要注意一种情况,如下示例:
string a = "this is string a";
string b = a;
a = "this is not string a now!";
Console.WriteLine("Now a is: {0}", a);
Console.WriteLine("Now b is: {0}", b);
或:
Object a = 10;
Object b = a;
a = 20;
Console.WriteLine(a.ToString());
Console.WriteLine(b.ToString());
这里的b不是指向a的地址,而是重新开了另一个内存空间,所以这里显示的结果a和b是绝对不相同的!
这个地方为什么会产生这样的结果还不是很明白,等明白了再补上~~
明白了引用类型和值类型这后,对了装箱和折箱就简单得多了,
装箱:是把值类型转为引用类型,如:
int a = 10;
object b = a;
Console.WriteLine(b.ToString());
在这个装箱的过程中,有以下动作:1)划分堆栈内存,在堆栈上分配的内存 = a的大小 +b及其结构所占用的空间;(2)a被复制到新近分配的堆栈中;(3)将分配给b的地址压栈,此时它指向一个object类型,即引用类型。
折箱则相反,把引用类型转为值类型,如:
object c = 5;
int d = (int)c;
Console.WriteLine(d.ToString());
在这个折箱的过程中,有以下动作:1)环境须先判断堆栈上指向合法对象的地址,以及在对此对象向指定的类型进行转换时是否合法,如果不合法,就抛出异常;(2)当判断类型转换正确,就返回一个指向对象内的值的指针。