1 类型基础
面试出现频率:基本上肯定出现
重要程度:10/10,身家性命般重要。通常这也是各种招聘工作的第一个要求,即“熟悉C#”的一部分。连这部分都不清楚的人,可以说根本不知道自己每天都在干什么。我们天天使用C#写程序,但如果连C#基础的东西都不懂,怎么证明你“熟悉C#”呢?怎么让人觉的你对C#有兴趣呢?
很多人去面试一发现面试官开始问基础题,就十分不爽,被淘汰了之后,还写博客说面试官垃圾,怎么不问问项目经历,哥可是做过不少项目的。殊不知,面试官知道你做过那些项目,但通常来说,如果那些项目不是牛逼透顶的级别(例如你参与了淘宝双11导致数据库并发问题的改进,或者AlphaGo的算法设计),或者正好是面试官所在公司需要的类型,则这并不是什么很厉害的事情,是个程序员就有几个项目在身,“做过不少项目”的牛逼程度,差不多等于“活过20几年”(我都活了20几年了,我牛逼么?)。每个人都有的东西,有什么好问的,问你了你能确定你能答得比别人好么?但是如果你不能答出什么是装箱,你会引发面试官以下的猜想:
- 这人连最基础的东西都不知道,还写了熟悉C#,他还写了熟悉XX,熟悉YY,看来他对那些东西可能也就了解皮毛。呵呵他还说他懂设计模式
- 这人连最基础的东西都不知道,说明他平常不看书。连书都不看,对技术肯定没有什么兴趣
- 这人写了他做过20个项目,但在我看来,他们大同小异,和做过1个项目也没区别
- 这人还写了他有管理经验,自己都这样,小弟的水平。。。
最重要的是,如果你装箱都不知道,面试官后面的N个连环问题马上胎死腹中,他可能会一脸尴尬,因为“我只是用这个问题当破冰的啊,你怎么已经倒地了”,甚至不知道该问你啥,你才知道。C#话题就此终结,和善点的面试官,可能会问问你在简历上写的其他东西。但无论如何,你的价值已经狂跌了不止一个档次。
在老外看来,这部分内容更为重要。很多老外,尤其是从优越国家来的那帮人,认为人找工作肯定是为了兴趣,钱只不过是顺带获得的。他们根本不知道这世界上还有人会考虑一个自身毫无兴趣,仅仅是工资高的工作。如果他们发现,你连装箱都不知道是什么,他们会觉得你不熟悉C#,对C#一点兴趣都没有,直接把你请出面试室,尽管你可能已经用C#写了几十个工程,手下可能已经有了几个小弟。也许你会央求面试官转换一个话题,例如问问设计模式,但个人认为,基础有问题的人,即使知道设计模式,做过很多项目,他写出来的asp.net代码可能是一坨屎的几率要远远高于基础没问题,但完全不懂asp.net的人。这也是为什么很多老外的C#书籍前几章的内容好像都是些“毫无意义的”,“莫名其妙的”东西。CLR via C#更是其中的战斗机,你完全不用看这本书,也能写出一个后台用asp.net MVC,前端html+css+jquery的ERP系统出来,前后端使用ajax通讯,后端连数据库,用sql查数据,做CRUD。但是你不能凭借这个找到一个工资高的工作,因为会干这个的人实在是太多了,以至于不够值钱。如果你觉得这已经是了不起的成就,那么你这一生也就停留在这里了。如果你还想挣得更多,那么你就得会别人不会或者嗤之以鼻的东西。
而工资高的工作,或对性能有很高的要求,或如果你写的代码太差,那真的会出大事,所以不能请基础差的人(当然好公司会有层层环境把关,但如果你的代码老出问题,你的水平弱于公司平均水平太多,他们也不会请你)。小公司尤其是外包,或者没什么名气的公司写的产品,本身也没有多少人用,崩溃了不会死人,所以代码垃圾一点无妨,只要能按时完成任务就得。
很多人反感基础题,一个很大的原因在于,问问题的人不会问。如果问法是考定义,比如问“值类型与引用类型有何区别?” 这种问题的答案一查都找得到,也没有什么意义。较好的问法是,把概念问题融入到情景之中,或者构造一个连环问题。例如我遇到过的一个问题:你何时会考虑使用一个结构体?我觉得一个不错的答案是”当这个对象所有的属性都是值类型时,例如刻画N维坐标系上的一个点”。如果面试者是如此作答,那么你可以继续问“可以用类型么?“这个时候,实际上还是在问你值类型与引用类型有何区别,但相比直接问就自然很多。这个问题并不是概念题,而是每天工作都会要遇到的。你总需要建立自定义的对象吧,那你就得从类型,结构,接口...中选择一个。
需要理解的程度:熟悉值类型和引用类型的区别,以及它们之间是可以转换的(虽然这种转换基本上是一定要避免的)。对栈和堆上内存的活动有着清醒的认识。
1.1 公共类型系统(CTS)
公共类型系统(CTS)是用来描述IL的,它规定了IL能做什么,能定义什么样的变量,类中允许拥有什么成员等等。如果你写了一个不遵循CTS的语言(以及一个编译器),那么你的语言不能被看成是.NET平台的语言,编译出来的中间代码(如果有的话)不是IL。CTS和IL是所有.NET语言的爸爸。
C#的数据类型可以分为值类型和引用类型。这是因为,CTS爸爸规定数据类型可以分为值类型和引用类型,而且C#实现了这部分功能。你可以开发一个遵循CTS的语言,但不实现任何值类型。
所有类型都从System.Object派生,接口是一个特例。下面是一些主要的System.Object提供的方法:
- Equals(obj):虚方法。如果两个对象具有相同的引用就返回true。
注意,尽管引用类型可能包含许多成员,比较引用类型时,仅仅考虑栈上的两个对象是否指向堆上相同的对象,而不会逐个成员比较,所以对于引用类型,不需要重写该方法。
System.ValueType(值类型)重写了该方法,使得方法不比较对象指针是否指向同一个对象,而是仅仅比较值是否相等。此时,如果值类型包含很多成员(例如结构),会使用反射逐个成员比较。为了避开反射造成的性能损失,你必须重写该方法,你只需要在其中遍历所有结构的属性,并一一进行比较即可。如果你自定义的结构的相等逻辑不要求所有的属性相等才意味着相等,而只是部分属性相等就意味着相等时,你也需要重写该方法。
值得注意的是,虽然字符串是引用类型,它也重写了该方法,其行为和值类型一样。 - Equals(obj1, obj2):静态方法,若两个输入变量均为null则返回true。若仅有一个是null则返回false。若都不是null则调用obj1.Equals(obj2)。故该方法无需重写,也不是虚方法。
GetHashCode:在FCL中,任何对象的任何实例都对应一个哈希码。为此,System.Object的虚方法GetHashCode能获取任意对象的哈希码。如果你定义的一个类型重写了Equal方法,那么还应重写GetHashCode方法。事实上如果你没有这么做的话,编译器会报告一条警告消息:重写了Equal但不重写GetHashCode。CLR via C#中说,一般都要重写Object的GetHashCode方法,因为它的算法性能不高。但我对这一部分没有深入研究。
ToString:虚方法。返回类型的完整名称(this.GetType().FullName)。重写它的可能性很大,例如你希望ToString遍历对象的所有属性,打印出它所有属性的值。
GetType:返回对象的类型对象指针指向的类型对象。
Finalize:在GC决定回收这个对象之后,会调用这个方法。如果要做一些额外的例如回收对象的非托管属性或对象,应当重写这个方法。只有在存在非托管对象时才需要这么做。在垃圾回收中会详细介绍。
1.2 New操作符
CLR要求所有对象都用new操作符来创建。对于值类型,你可以直接赋值,这相当于隐式的调用了new操作符。new操作符所做的事情有:
计算类型及其所有基类型中定义的实例字段需要的字节数,另外,如果是引用类型,还需要预留空间给”类型对象指针“和”同步块索引“。如果发现栈或者堆上的空间不足,就引发OutOfMemory异常,并激发一次垃圾回收。
如果是引用类型,从堆上分配第一步算出来的字节数。
初始化”类型对象指针“和”同步块索引“。令”类型对象指针“指向堆上该类型的类型对象。如果类型对象不存在,则创建一个。并且如果类型有静态成员,则初始化它们,如果类型有静态构造函数,调用静态构造函数,初始化或者修改(因为静态构造函数在初始化静态成员之后进行,所以可能会造成修改)类中的静态成员的值。如果类型对象已经存在,则不会再次调用静态构造函数。
调用类型的实例初始化器,初始化类型的非静态成员。
例如下面的代码中,C#首先将a初始化为5,然后再修改成10。
class SomeType
{
private static int a = 5;
static SomeType()
{
a = 10;
}
}
1.2(转) CLR via C#上的例子
CLR via C#上的这个例子可以让我们透彻理解前一小节的内容以及内存中的各种活动。假设我们有如下的定义。
如果代码如下图左下角所示,则开始执行的时刻,内存中的情况如下图:
当CLR扫描完M3方法之后,发现有两个引用类型Employee和Manager,故计算这两个类型及其所有基类型中定义的所有实例字段需要的字节数,在堆上建立两个类型对象,它们的构造相同:类型对象指针(TypeHandle),同步块索引,静态字段集合与方法表(储存了所有的方法)。
因为程序还没运行到第二行,所以栈上暂时还没有那个整型对象year。当运行完前2行时,栈中多了2个成员。一个Employee对象e被创建,但其没有指向任何东西。
但运行完第三行后,new关键字在堆上新建了一个实例,并返回这个引用,使得e指向一个Manager实例,这个实例的类型对象指针指向Manager类型对象。注意,一个类型无论有多少个实例,它们在堆中的对象都指向一个类型对象。另外需要关注的是, 静态字段在类型对象中,而类型对象是唯一的,所以所有该类型的实例都指向一个类型对象,意味着一个实例更改了静态字段的值,所有其他实例都会受影响。
第四句调用了静态方法lookup。假设结果表明,Joe是公司的一名经理,则该方法将返回一个Manager对象。此时堆中将再次创建一个新的Manager对象,而e将会被指向这个新的对象。这个新的对象将会被初始化,Joe将作为其初始化的信息的一部分(不再是默认的值,例如0或者Null)。
注意此时第一个Manager对象将会变成垃圾,等待垃圾回收器的回收。两个Manager对象指向一个Manager类型对象。
第五句代码将调用一个Employee类型的方法,假设返回5,那么year的值将变成5。
最后一句是一个虚方法,执行虚方法时,和实方法不同。我们要看虚方法有没有被人重写,还要根据调用虚方法的对象(e)确定使用父类中的方法,还是子类中重写的方法。根据上图发现,e其实是一个指向Manager对象的东西,于是,我们执行在Manager类中重写的那个方法。
注意如果在第四句中,Joe仅仅是一个Employee而不是Manager的话,那么堆中将不会有第二个Manager对象,而取而代之为一个新的Employee对象。最后一句也会执行在Employee中的方法,而不是Manager中的方法。
1.3 类型对象
一个类型无论有多少个实例,它们在堆中的对象的类型对象指针都指向同一个类型对象。之所以只有一个类型对象,是因为不需要有多于一个(所有相同类型的定义都相同,都有相同的方法表)。所以,类型对象是储存类型静态成员最恰当的地方。类型对象由CLR在堆中的一个特殊地方(加载堆)创建(在第一次使用前),其中包括了类型的静态字段和方法表。创建完之后,就不会改变,通过这个事实,可以验证静态字段的全局(被所有同类型的实例共享)性。
类型对象是反射的重要操作对象。如果你要处理一个谜之对象,你不知道他有什么方法,那么你只能通过访问它的类型对象,你才知道这个谜一般的对象究竟包括什么方法。然后你就可以调用这些方法。GetType方法会返回对象指向的类型对象(包括静态成员和方法表)。
加载堆不受GC控制,所以静态字段和属性也不受GC控制。
1 int a = 123; // 创建int类型实例a
2 int b = 20; // 创建int类型实例b
3 var atype = a.GetType(); // 获取对象实例a的类型Type
4 var btype = b.GetType(); // 获取对象实例b的类型Type
5 Console.WriteLine(System.Object.Equals(atype,btype)); //输出:True
6 Console.WriteLine(System.Object.ReferenceEquals(atype, btype)); //输出:True
这意味着,内存中只有一个Int32类型对象,否则ReferenceEquals是不可能输出True的。
注意,类型对象也有类型对象指针,这是因为类型对象本质上也是对象。所有的类型对象的“类型对象指针”都指向System.Type类型对象。特别的,System.Type类型对象本身也是一个对象,内部的“类型对象指针”指向它自己。
1.4 什么是基元类型?
属于BCL而非任何某个语言的类型叫做基元类型(Primitive Type)。你可以在mscorlib.dll中找到它们。例如:
IL 类型 C# 关键字 VB.NET关键字
System.Byte byte Byte
Sytem.Int16 short Short
System.Int64 int Integer
特别的,string映射到基元类型String。所以它们并没有任何区别。
1.5 值类型与引用类型有何区别?
C#的数据类型可以分为值类型和引用类型,它们的区别主要有:
- 所有值类型隐式派生自System.ValueType。该类确保值类型全部分配在栈上(结构体除外,结构体如果含有引用类型,则那部分也会分配在堆上)。所有引用类型隐式派生自System.Object。引用类型初始化在栈和堆上。
- 引用类型的初值为null。值类型则是0。因为字符串的初值为null,故字符串为引用类型。因为接口是一种特殊的抽象类,所以接口是引用类型。因为委托是密封类,所以委托是引用类型。
- 栈中会有一个变量名和变量类型,指向堆中的对象实例的地址。值类型仅有栈中的变量名和类型,不包括指向实例的指针。
- 值类型不能有继承,引用类型则可以。典型的例子是结构体,他是值类型,结构体不能被继承。但结构体里面可以包括引用类型。值类型也可以有自己的方法,例如Int.TryParse方法。但方法是隐式的密封方法。
- 值类型的生命周期是其定义域。当值类型离开其定义域后将被立刻销毁。引用类型则会进入垃圾回收分代算法。我们不知道何时才会销毁。
- 当我们创建了某个引用类型的实例后,再复制一个新的时,将只会复制指针。例如:
A a = new A();
A a2 = a;
此时在堆中只有一个A的实例,而a和a2都指向它。所以如果我们更改了a中某个成员的值,a2中相应的成员也会更改。(这称为浅复制,与之对应的深复制则是要逐一复制对象所有成员的值,C#没有深复制的方法,要自己实现)值类型则完全不同,复制值类型将进行逐字段的复制,而没有指针参与。所以值类型是相互独立的。更改其中一个对另外一个不会有影响。
1.6 类和结构的主要区别?结构对象可能分配在堆上吗?何时考虑使用结构体?
类和结构是C#两个最主要的研究对象:
1.结构是值类型,它继承自System.ValueType,而类是引用类型。
2.因为值类型不能被继承,故结构不能被继承。
3.结构可以有自己的方法,一个典型的例子为.NET中的结构体Int32含有方法Parse,TryParse等等。
4.结构可以实现接口。
5.虽然结构是值类型,这不意味着结构中不能包括引用类型(但如果一个结构里面包含引用类型,考虑使用类)。结构体如果含有引用类型,则那部分也会分配在堆上。
6.结构体的构造函数必须初始化它的所有成员。结构的构造函数不会被自动调用。
当试图表现例如点(X维坐标上的),形状(长,宽,面积等属性)等全部为值类型组成的对象时,考虑使用结构体。例如,如果声明一个 1000 个 Point 对象组成的数组,为了引用每个对象,则需分配更多内存(堆上的1000个实例);这种情况下,使用结构可以节约资源。当数组不用时,如果是使用结构体,则1000个对象将马上销毁,如果是使用类,则还要等GC,无形中提升了GC压力。
1.6.1 在.NET的基础类库中,举出一个是类和一个是结构的例子
Console是一个类。
Int32是一个结构。其只含有两个常数的,Int32类型的字段(最小值和最大值),和若干方法。
这两者均位于基础类库mscorlib中。
1.6.2 实例构造函数(类型)
类型的实例构造函数不能被继承。它负责将类型的实例字段初始化。对于静态字段,由静态构造函数负责。
如果类型没有定义任何构造函数,则编译器将定义一个没有参数的构造函数。其会简单地调用基类的无参构造函数。特别的,由于System.Object没有任何实例字段,所以它的构造函数什么也不做。
可以声明多个不同的构造函数。可以利用this关键字来调用其它构造函数。
1.6.3 实例构造函数(结构)
结构体的构造函数必须初始化它的所有成员。结构的构造函数不会被自动调用。
不能显式地为结构声明无参数的构造函数。
1.6.4 静态构造函数
静态构造函数是一个特殊的构造函数,它会在这个类型第一次被实例化或引用任何静态成员之前,CLR在堆上创建类型对象时执行,它具有以下特点:
- 静态构造函数既没有访问修饰符,也没有参数。
- 在创建第一个实例或引用任何静态成员之前,将自动调用静态构造函数来初始化类(的类型对象)。这个静态构造函数只会执行一次。
- 无法直接调用静态构造函数。它的访问修饰符是private(不需要写明)。
- 在程序中,用户无法控制何时执行静态构造函数。
- 静态构造函数不应该调用基类型的静态构造函数。这是因为类型不可能有静态字段是从基类型分享或继承的。
如果我们不了解堆上的内存分配方式,对静态构造函数的理解会十分困难。为什么是在创建第一个实例之前?为什么不能直接调用?为什么不能有参数?我们完全无法理解,只能通过死记硬背的方式记住这些性质。但如果你知道静态成员在类型对象中,并不存在于任何的实例中,可能你就会理解这些性质。
当我们清楚的了解了类型对象以及CLR对类型对象的处理方式时,理解静态构造函数以及类型的静态成员就显得十分自然了。当创建第一个实例之前,堆上没有类型对象,所以要调用静态构造函数,当引用静态成员之前,堆上也没有类型对象,而静态成员属于类型对象,所以也要调用静态构造函数,这两种情况的最终结果,都是堆上最终出现了一个类型对象。因为类型对象只需要建立一次,所以这个静态构造函数也只能运行一次。
为什么静态构造函数既没有访问修饰符,也没有参数?这是因为静态构造函数只负责初始化静态成员,只负责维护类型对象,它和类型的实例对象没有关系,所以你加入任何参数(你试图为非静态的字段或属性赋值?这是不可能的,因为根本就没有实例)都是没有意义的。
无法直接调用静态构造函数:现在显然十分容易理解了,因为类型对象只能有一个,如果可以随便被调用,则可能会创造出好几个类型对象,破坏静态字段的全局性。CLR也选择不把控制权交给用户。