C#知识树整理——C#基础
c#基础
-
-
类(Class)
-
类可以继承
-
包含属性
-
包含方法
-
可以用来创建对象
-
类的访问权限
- Internal:在打dll的时候作用很大,可以控制有些类不然用户访问到
- private:用得不多,一般作为内部类存在
- public:这个不用多说懂的都懂
-
抽象类与接口
- 抽象类中的抽象方法,子类在继承时必须要进行覆写,抽象类不一定有抽象方法,抽象类中可以有非抽象方法
- 接口中的方法都是抽象方法,接口中可以设置变量。接口分为显示实现和隐式实现,详见底下的接口展开。
-
内部类
public class A { private class B{}//内部类 public void NewB() { B b = new B(); } }
- 有些类只需要在类内部使用,就可以创建内部类
-
partial关键字
public partial class A { }
- 类定义的代码可以拆分到不同的文件当中,只要实现的时候在类前加上partial就行
-
泛型类
public partial class<T> A { private T inst; }
- 类需要适配不同类型的时候可以用泛型类,比如单例的实现
-
-
结构(struct)
public struct MyStruct { /* 会报错,隐式的无参数的构造函数在结构中无论如何都是存在的,所以程序员不能手动的为结构添加1个无参数的构造函数. public MyStruct() { } //struct没有析构 ~ MyStruct() { Debug.Log("我析构了"); } */ public MyStruct(int a,int b) { this.a = a; this.b = b; } private int a; private int b; // private int a = 1;//设置初始值编译会报错 public static int staticA = 1;//static和const可以 public const int constA = 1;//static和const可以 } //与Class对比 public class MyClass { public MyClass(){} ~MyClass() { Debug.Log("我析构了"); } public MyClass(int a,int b) { this.a = a; this.b = b; } private int a = 1; private int b; public static int staticA = 1; public const int constA = 1; } public class Test:MonoBehaviour { void Start() { MyStruct myStruct = new MyStruct(); MyStruct myStruct1 = new MyStruct(1,2); MyClass myCls = new MyClass(); MyClass myCls1 = new MyClass(1,2); } }
- 常见、重要的特性
- 是值类型
- 不能设置为null
- 声明变量时本身就有值了
- 赋值时是深拷贝
- 不能局部赋值
- 其他细节
- 在结构声明中除非声明static或const,否则无法初始化
- 结构不能声明无参构造函数或析构
- 结构在分配时进行复制,将结构分配给新变量时,将复制所有数据,并且对新副本所做的任何修改不会影响原始副本的数据,在处理值类型的集合(如
Dictionary<string,myStruct>
)请注意这点 - 结构是值类型,不同于类,类是引用类型
- 与类不同无需用new进行初始化
- 结构可以声明具有参构造
- 一个结构无法继承自另一个结构或者类,也不能成为类的基类。所有结构都直接继承自ValueType,否则继承自Object
- 结构可以实现接口
- 和类的区别
- 结构不能声明无参构造函数或析构
- 与类不同无需用new进行初始化
- 一个结构无法继承自另一个结构或者类,也不能成为类的基类。所有结构都直接继承自ValueType,否则继承自Object
- 结构是值类型,不同于类,类是引用类型
- 在结构声明中除非声明static或const,否则无法初始化
- 常见、重要的特性
-
接口(interface)
public interface IMyInterface { void Func(); } public class MyHideCls : IMyInterface { public void Func()//隐式实现 { } } public class MyShowCls : IMyInterface { void IMyInterface.Func()//显式实现 { } } void Main() { (new MyHideCls()).Func();//调用隐式实现 // (new MyShowCls()).Func();//调用显示实现会报错 ((IMyInterface)(new MyShowCls())).Func();//必须要当接口对象使用 }
-
隐式实现:就基本的方式
-
显式实现:需要当成接口对象用才能使用此方法
-
显示实现的应用场景:
-
解决重名方法问题:
public interface IDog { void Run(); } public interface IPig { void Run(); } public class MyCls : IDog,IPig { void IDog.Run()//指定实现的是哪个,避免方法重名 { } void IPig.Run() { } }
-
降低方法的访问权限
public interface ICmommand { void Run(); } public class Cmommand : ICmommand { void ICmommand.Run() { Debug.Log("Cmommand Run"); } } public class ChildCommand : Cmommand { } public class CmommandHelper { public static void Run(ICmommand cmd) { cmd.Run(); } } void Main() { ChildCommand child = new ChildCommand(); // child.Run();//防止用户误操作直接调用隐式实现 CmommandHelper.Run(child);//托管给专门的执行器去做 }
-
-
细节补充:
- 实现接口的任何类或者结构都必须实现其所有成员
- 接口类似于只有抽象成员的抽象基类
- 一个类可以继承一个基类,还可以实现多个接口
- 接口无法直接进行实例化
- 其成员由实现接口的任何类或结构来实现
- 接口可以包含事件、索引器、方法和属性
- 接口不包含方法的实现
- 一个类或者结构可以实现多个接口
-
什么是接口和抽象类
-
接口和抽象类都是“软件工程产物”
-
具体类 -> 抽象类 -> 接口:越来越抽象,内部实现的东西越来越少
-
抽象类是未完全实现逻辑的类(可以有字段和非 public 成员,它们代表了“具体逻辑”)
-
抽象类为复用而生:专门作为基类来使用。也具有解耦功能
-
- 解耦的具体内容留待下一节讲接口时讲
-
封装确定的,开放不确定的(开闭原则),推迟到合适的子类中去实观
-
接口是完全未实现逻辑的“类”(“纯虚类”;只有函数成员;成员全部 public)
-
接口为解耦而生:“高内聚,低耦合”,方便单元测试
-
接口是一个“协约”。早已为工业生产所熟知(有分工必有协作,有协作必有协约)
-
-
它们都不能实例化。只能用来声明变量、引用具体类(concrete class)的实例
-
-
胖接口及其产生原因
- 观察传给调用者的接口里面有没有一直没有调用的函数成员,如果有,就说明传进来的接口类型太大了。换句话说,这个“胖”接口是由两个或两个以上的小接口合并起来的
-
属性器(Property)
- Get和Set方法
-
隔离内部变化,凸显封装性
-
-
委托(Deldgates)
- 委托(delegate)是函数指针的“升级版”
- 委托的基本用法:
- 模板方法,借用指定的外部方法产生结果(现在一般用接口替代)
- 回调方法,调用指定的外部方法。
- 高级使用:
- 多播:
action1 += action2;
- 隐式异步调用:
action1.BeginInvoke(null, null);
- 常用委托:
Action<T>
返回值是voidFunc<T>
返回值是T
- 多播:
- 使用经验:
- 为了防止空指针异常,可以给委托一个初始值
- 委托可以用Lambert表达式
-
事件(Event)
-
事件模型的五个组成部分
- 事件的拥有者
-
事件的成员(事件本身)
- 事件的响应者
-
事件的处理器
- 事件的订阅
-
有了委托为什么还需要事件
-
为了 程序的逻辑更加“有道理”,谨防借刀杀人
-
事件的本质是委托字段的一个包装器
-
只能在声明类的内部invoke
-
属性器不是get和set而是add和remove
-
事件与委托的关系
-
事件真的是“以特殊方式声明的委托字段/实例”吗?
-
- 不是!只是声明的时候“看起来像”(对比委托字段与事件的简化声明,field-like)
- 事件声明的时候使用了委托类型,简化声明造成事件看上去像一个委托的字段(实例),而 event 关键字则更像是一个修饰符 —— 这就是错觉的来源之一
- 订阅事件的时候 += 操作符后面可以是一个委托实例,这与委托实例的赋值方法语句相同,这也让事件看起来像是一个委托字段 —— 这是错觉的又一来源
- 重申:事件的本质是加装在委托字段上的一个“蒙版”(mask),是个起掩蔽作用的包装器。这个用于阻挡非法操作的“蒙版”绝不是委托字段本身
-
为什么要使用委托类型来声明事件?
-
-
-
站在 source 的角度来看,是为了表明 source 能对外传递哪些消息
- 站在 subscriber 的角度来看,它是一种约定,是为了约束能够使用什么样签名的方法来处理(响应)事件
-
委托类型的实例将用于存储(引用)事件处理器
-
对比事件与属性
-
- 属性不是字段 —— 很多时候属性是字段的包装器,这个包装器用来保护字段不被滥用
- 事件不是委托字段 —— 它是委托字段的包装器,这个包装器用来保护委托字段不被滥用
- 包装器永远都不可能是被包装的东西
-
-
-
表达式
- 表达式值
- 数值
- 字符串
- 调用表达式
- 调用方法
- 调用委托
- 表达式值
-
查询表达式
- LINQ
- lambda表达式
- 其他概念
- 表达式树
- 表达式主体
-
重要特性
-
运算符优先级
-
语句
- 定义:可以用分号结尾的代码就是语句
- 类型:
- 声明表达式
- 表达式语句
- 迭代语句
- 跳转语句
- 异常处理语句
- await语句
- yield return 语句
- fixed语句
- lock语句
- 空语句
- ...
-
特性
- 自定义Attribute
- 继承system.Attribute
- 与反射配合使用
- 自定义Attribute
-
面向对象
- 核心是对象的建模
- 类能比较容易的进行面向对象的编程,没有类也能做到但是不那么容易
- C、lua这样的面向过程的语言也都能模拟面向对象
- 面向对象和面向过程不是独立的
- 面向对象更擅长设计
- 面向过程更擅长实现
- 面向对象以对象为基础单位
- 面向过程以函数为基础单位
-
引用类型和值类型
- 用类创建的类型就是引用类型(string,class)
- 用结构体或者值类型创建出来的类型就是值类型(bytes,short,int,long,float,double,decimal,char,bool和struct)
- 值类型直接存储其值,而引用类型存的是其引用(也就类似于指针指向了那个内存地址)
-
-
- 定义:
- 提供封装程序集、模块和类型的对象
- 就是对Type的Api进行一系列的操作
- 给我一个对象,我能在不用 new 操作符也不知道该对象的静态类型的情况下,我能给你创建出一个同类型的对象,还能访问该对象的各个成员
- 当程序处于动态期(dynamic)用户已经用上了,不再是开发时的静态期(static)。动态期用户和程序间的操作是难以预测的,如果你要在开始时将所有情况都预料到,那程序的复杂度难免太高,指不定就是成百上千的 if else,即使你真的这样做了,写出来的程序也非常难维护,可读性很低。很多时候更有可能是我们在编写程序时无法详尽枚举出用户可能进行的操作,这时我们的程序就需要一种以不变应万变的能力,反射就是这个窍门。
- 反射毕竟是动态地去内存中获取对象的描述、对象类型的描述,再用这些描述去创建新的对象,整个过程会影响程序性能,所以不要盲目过多地使用反射
- type的获取
- typeof
- object.GetType()
- var type = Type.GetType("类的fullName");
- TypeApi分类
- 类查询API
- FuName
- Name
- ...
- 检测API
- IsClass
- IsAbstract
- ...
- 类结构查询API
- BaseType 获取父类类型
- GetMembers 获取类成员(方法,属性,字段)
- GetFields 获取所有字段
- GetProperties 获取所有属性器
- GetMethod 获取所有方法
- BindingFlags控制搜索结果
- 类查询API
- Assembly提供的API
- Assembly.LoadFrom(fullPath)
- Assembly.GetExecutingAssembly()
- Assembly.CreateInstance(typeName)
- 定义:
-
-
控制反转(依赖反转)
-
举一个现实生活的例子:
海尔公司作为一个电器制商需要把自己的商品分销到全国各地,但是发现,不同的分销渠道有不同的玩法,于是派出了各种销售代表玩不同的玩法,随着渠道越来越多,发现,每增加一个渠道就要新增一批人和一个新的流程,严重耦合并依赖各渠道商的玩法。实在受不了了,于是制定业务标准,开发分销信息化系统,只有符合这个标准的渠道商才能成为海尔的分销商。让各个渠道商反过来依赖自己标准。反转了控制,倒置了依赖。 我们把海尔和分销商当作软件对象,分销信息化系统当作IOC容器,可以发现,在没有IOC容器之前,分销商就像图1中的齿轮一样,增加一个齿轮就要增加多种依赖在其他齿轮上,势必导致系统越来越复杂。开发分销系统之后,所有分销商只依赖分销系统,就像图2显示那样,可以很方便的增加和删除齿轮上去。
-
-
依赖注入
-
就是将实例变量传入到一个对象中去(Dependency injection means giving an object its instance variables)。
-
什么是依赖:
public class Human { ... Father father; ... public Human() { father = new Father(); } }
类 Human 中用到一个 Father 对象,我们就说类 Human 对类 Father 有一个依赖。
仔细看这段代码我们会发现存在一些问题:
1、如果现在要改变 father 生成方式,如需要用new Father(String name)初始化 father,需要修改 Human 代码;
2、如果想测试不同 Father 对象对 Human 的影响很困难,因为 father 的初始化被写死在了 Human 的构造函数中;
3、如果new Father()过程非常缓慢,单测时我们希望用已经初始化好的 father 对象 Mock 掉这个过程也很困难。
-
依赖注入
public class Human { ... Father father; ... public Human(Father father) { this.father = father; } }
上面代码中,我们将 father 对象作为构造函数的一个参数传入。在调用 Human 的构造方法之前外部就已经初始化好了 Father 对象。像这种非自己主动初始化依赖,而通过外部来传入依赖的方式,我们就称为依赖注入。
现在我们发现上面 1 中存在的两个问题都很好解决了,简单的说依赖注入主要有两个好处: 1、解耦,将依赖之间解耦
2、因为已经解耦,所以方便做单元测试,尤其是 Mock 测试
-
-
控制反转和依赖注入的关系
- 我们已经分别解释了控制反转和依赖注入的概念。有些人会把控制反转和依赖注入等同,但实际上它们有着本质上的不同。
- 控制反转是一种思想
- 依赖注入是一种设计模式
- 我们已经分别解释了控制反转和依赖注入的概念。有些人会把控制反转和依赖注入等同,但实际上它们有着本质上的不同。
-
-
-
泛型
-
泛型可以防止代码膨胀,最大限度的保证代码的重用性,还可以保护类型安全和提高性能
-
典型案例
ArrayList=>List<T>
-
泛型约束
public class Base<T> where T : Base<T> { public T inst { get; set; } } public class ChildCls : Base<ChildCls> { }
-
经验谈
- 底层、通用的代码用的多
- 逻辑层UI层只需要会用List、Dictionary的程度就行
- 如果自己写框架和库,泛型的深入用法必不可少
-
偏僻知识点
-
获取带有泛型类的类型
var t = typeof(Dictionary<,>); Debug.Log(t); //System.Collections.Generic.Dictionary`2[TKey,TValue]
-
-
-
partial关键字
-
将类拆分成不同部分可以在不同类文件甚至不同命名空间内实现
-
也可以放在方法上
public partial class Person { public void Say() { Hello(); } //先在一个类里声明 partial void Hello(); } public partial class Person { //在另一个partial类里实现 partial void Hello() { Debug.Log("Fuck"); } }
-
-
匿名方法,可空值类型
-
匿名方法
private Action<int> func; void Start() { //1.0委托只能支持方法 func = MyFunc; //2.0可以支持匿名方法 func = delegate(int i) { Debug.Log("2.0"); }; } private void MyFunc(int a) { Debug.Log("1.0"); }
-
可空值类型
int? a = null;
值类型后加个?
-
-
迭代器
public class MyEnumerable:IEnumerable { private int[] arr = {1, 2, 3}; public MyEnumerable() { } public IEnumerator GetEnumerator() { // return new MyEnumerator(arr.Length, arr); for (int i = 0; i < arr.Length; i++) { yield return arr[i]; } } } public class MyEnumerator:IEnumerator { private int idx = -1; private int[] data; private int max = 0; public MyEnumerator(int max,int[] data) { this.max = max; this.data = data; } public bool MoveNext() { idx++; return idx < max; } public void Reset() { idx = -1; } public object Current { get { return data[idx]; } } } void Start() { var a = new MyEnumerable(); foreach (var i in a) { print(i); } }
-
-
-
对于Dictionary的实现原理,其中有两个关键的算法,一个是Hash算法,一个是用于应对Hash碰撞冲突解决算法。
-
Hash算法
-
Hash算法是一种数字摘要算法,它能将不定长度的二进制数据集给映射到一个较短的二进制长度数据集,常见的MD5算法就是一种Hash算法,通过MD5算法可对任何数据生成数字摘要。而实现了Hash算法的函数我们叫她Hash函数。Hash函数有以下几点特征。
- 相同的数据进行Hash运算,得到的结果一定相同。
HashFunc(key1) == HashFunc(key1)
- 不同的数据进行Hash运算,其结果也可能会相同,(Hash会产生碰撞)。
key1 != key2 => HashFunc(key1) == HashFunc(key2)
. - Hash运算时不可逆的,不能由key获取原始的数据。
key1 => hashCode
但是hashCode ==> key1
。
- 相同的数据进行Hash运算,得到的结果一定相同。
-
常见的构造Hash函数的算法有以下几种。
1. 直接寻址法:取keyword或keyword的某个线性函数值为散列地址。即H(key)=key或H(key) = a•key + b,当中a和b为常数(这样的散列函数叫做自身函数)
2. 数字分析法:分析一组数据,比方一组员工的出生年月日,这时我们发现出生年月日的前几位数字大体同样,这种话,出现冲突的几率就会非常大,可是我们发现年月日的后几位表示月份和详细日期的数字区别非常大,假设用后面的数字来构成散列地址,则冲突的几率会明显减少。因此数字分析法就是找出数字的规律,尽可能利用这些数据来构造冲突几率较低的散列地址。
3. 平方取中法:取keyword平方后的中间几位作为散列地址。
4. 折叠法:将keyword切割成位数同样的几部分,最后一部分位数能够不同,然后取这几部分的叠加和(去除进位)作为散列地址。
5. 随机数法:选择一随机函数,取keyword的随机值作为散列地址,通经常使用于keyword长度不同的场合。
6. 除留余数法:取keyword被某个不大于散列表表长m的数p除后所得的余数为散列地址。即 H(key) = key MOD p, p<=m。不仅能够对keyword直接取模,也可在折叠、平方取中等运算之后取模。对p的选择非常重要,一般取素数或m,若p选的不好,容易产生碰撞.(C#用的就是这种,后面贴源码)
-
-
Hash桶算法
-
说到Hash算法大家就会想到Hash表,一个Key通过Hash函数运算后可快速的得到hashCode,通过hashCode的映射可直接Get到Value,但是hashCode一般取值都是非常大的,经常是2^32以上,不可能对每个hashCode都指定一个映射。
-
因为这样的一个问题,所以人们就将生成的HashCode以分段的形式来映射,把每一段称之为一个Bucket(桶),一般常见的Hash桶就是直接对结果取余。
-
解决冲突算法
-
对于一个hash算法,不可避免的会产生冲突,那么产生冲突以后如何处理,是一个很关键的地方,目前常见的冲突解决算法有拉链法(Dictionary实现采用的)、开放定址法、再Hash法、等等
1. 拉链法:这种方法的思路是将产生冲突的元素建立一个单链表,并将头指针地址存储至Hash表对应桶的位置。这样定位到Hash表桶的位置后可通过遍历单链表的形式来查找元素。
2. 再Hash法:顾名思义就是将key使用其它的Hash函数再次Hash,直到找到不冲突的位置为止。(但是再Hsah的值难免会再碰撞,还需要再hash)
-
-
-
Dictionary实现
-
Entry结构体
//首先我们引入Entry这样一个结构体,它的定义如下代码所示。这是Dictionary种存放数据的最小单位,调用Add(Key,Value)方法添加的元素都会被封装在这样的一个结构体中 private struct Entry { public int hashCode; // 除符号位以外的31位hashCode值, 如果该Entry没有被使用,那么为-1 public int next; // 下一个元素的下标索引,如果没有下一个就为-1 public TKey key; // 存放元素的键 public TValue value; // 存放元素的值 }
-
其它关键私有变量
//除了Entry结构体外,还有几个关键的私有变量,其定义和解释如下代码所示。 private int[] buckets; // Hash桶 private Entry[] entries; // Entry数组,存放元素 private int count; // 当前entries的index位置 private int version; // 当前版本,防止迭代过程中集合被更改 private int freeList; // 被删除Entry在entries中的下标index,这个位置是空闲的 private int freeCount; // 有多少个被删除的Entry,有多少个空闲的位置 private IEqualityComparer<TKey> comparer; // 比较器 private KeyCollection keys; // 存放Key的集合 private ValueCollection values; // 存放Value的集合
-
Add
private void Insert(TKey key, TValue value, bool add) { if( key == null ) { ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key); } if (buckets == null) Initialize(0); //初始化上面的那些字段 int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF; int targetBucket = hashCode % buckets.Length; //通过哈希值和取余法获取对应的目标Bucket //寻找是否有相同key的元素 for (int i = buckets[targetBucket]; i >= 0; i = entries[i].next) { if (entries[i].hashCode == hashCode && comparer.Equals(entries[i].key, key)) { if (add) { ThrowHelper.ThrowArgumentException(ExceptionResource.Argument_AddingDuplicate); } entries[i].value = value; version++; return; } } int index; if (freeCount > 0) { //判断现在是否有空闲的元素,优先使用空闲的元素 index = freeList; freeList = entries[index].next; freeCount--; } else { if (count == entries.Length) //判断是否存储的项和Entries的长度,相等的话,就重新扩容。 { Resize();// 扩容Buctet和Entries的大小 targetBucket = hashCode % buckets.Length; } index = count; count++; } entries[index].hashCode = hashCode; entries[index].next = buckets[targetBucket]; entries[index].key = key; entries[index].value = value; buckets[targetBucket] = index; version++; }
- 初次添加元素时,如果构造函数中不传入大小,默认会自动取一个最小的质数(即:3)来作为桶的大小和元素集合的大小,并且初始换里面的字段变量。
- 在寻找元素插入的位置的时候,首先通过元素的hashcode % bucket的长度优先得到要插入的目标的Bucket是哪个,然后将元素的hashCode和next赋值。
- 这里next赋值的话详细说一下。首先,我们先分析buckets,它里面的每个bucket保存的是对应链表最后一个元素的下标,可以通过最后一行代码得知,每次给元素赋值之后,当前元素的下标,会赋值给对应的bucket。
而每个新插入元素,只需要当前bucket里面的值赋值给它当前的next指向的index就可以了。
-
Find
private int FindEntry(TKey key) { if( key == null) { ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key); } if (buckets != null) { int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF; //首先获取key的hashCode //寻址的第一个元素就是对应目标桶里面记录的index,然后通过对应元素的next指向下一个元素,当next为-1时,就是代表已经到最后一个元素了。 for (int i = buckets[hashCode % buckets.Length]; i >= 0; i = entries[i].next) { //判断元素的hashcode和key是否都相等。 if (entries[i].hashCode == hashCode && comparer.Equals(entries[i].key, key)) return i; } } return -1; }
-
是先找到目标桶,因为目标桶里面记录的是它对应链表的最后一个元素的下标,然后顺着元素的next找,直到找到这个元素为止。
-
可以仔细想想,这样的话,每次查找就可以过滤一大批的数据,所以查的速度就更快了,但是当数据量大的时候,也是会有效率问题。
-
-
Remove
public bool Remove(TKey key) { if(key == null) { ThrowHelper.ThrowArgumentNullException(ExceptionArgument.key); } if (buckets != null) { int hashCode = comparer.GetHashCode(key) & 0x7FFFFFFF; int bucket = hashCode % buckets.Length; int last = -1; //这个变量主要是记录上一个元素的下标。 //和上面一样,先查找要删除的元素。 for (int i = buckets[bucket]; i >= 0; last = i, i = entries[i].next) { if (entries[i].hashCode == hashCode && comparer.Equals(entries[i].key, key)) { if (last < 0) { //代表第一个元素就是要找的元素 buckets[bucket] = entries[i].next; //把buctet指向的下标,指向下一个元素 } else { entries[last].next = entries[i].next; //将上一个元素的下标,指向下一个元素的下标,去掉被删除的元素。 } entries[i].hashCode = -1; entries[i].next = freeList; //当前元素指向上一个空闲元素的下标 entries[i].key = default(TKey); entries[i].value = default(TValue); freeList = i; //记录最后一个被移除元素的下标 freeCount++; //每次移除,空闲的元素+1 version++; return true; } } } return false; }
-
查找对应的元素和上面的逻辑其实是一样的,它这里定义了一个变量,用来记录上一个元素的下标。
-
当找到对应的元素时,把上个元素的next指向当前被移除元素的next,即把当前被移除的元素跳过去。
-
然后将被移除元素的字段初始化,需要注意的是这个next的值,它用的是freelist,记录最后一个被移除元素的index,每移除一个元素,被移除的元素数量就+1,即freecount。
被移除的元素也会形成一个链表,它的next首部元素next指向-1,后边被移除的元素next指向上一个被移除元素的index。 -
回过头再去看添加的时候,它会判断,freeCount的数量是否是大于0的,如果大于0的话,优先使用被移除元素的位置并填充它们,它的index就是freeList,然后再把当前元素的next赋值给
freelist(即下次再插入元素的时候,就是上一个被移除元素的下标)。 -
最后在下面给当前元素赋值的时候,它的next又指向当前bucket里面的值,即作为对应链表的尾部。
-
-
Resize操作(扩容)
private void Resize(int newSize, bool forceNewHashCodes) { Contract.Assert(newSize >= entries.Length); // 这个newSize是获取大于count的最小质数 int[] newBuckets = new int[newSize]; for (int i = 0; i < newBuckets.Length; i++) newBuckets[i] = -1; //初始化每个bucket的值 Entry[] newEntries = new Entry[newSize]; Array.Copy(entries, 0, newEntries, 0, count); //将原来的entries的值copy到新的entries里面 for (int i = 0; i < count; i++) { if (newEntries[i].hashCode >= 0) { //判断hashcode代表是有效的entry int bucket = newEntries[i].hashCode % newSize; newEntries[i].next = newBuckets[bucket]; newBuckets[bucket] = i; //上面的操作就是重新找新的桶,然后重新给entey的next赋值。 } } buckets = newBuckets; entries = newEntries; }
- 这里的newSize会在里面的元素数达到entries的长度是扩容,它是在一个helper类里面进行取值,它是拿大于它的最小质数。
- 这个新的size就是buckets的长度和entries的长度,先把原来的entries的值copy到新的entries里面。
- 然后循环新的的entries,重新定义新的桶的值并且给原来的数据,重新形成新的链表。
-
再谈Add操作
- 实际上Add操作会优先使用
freeList
的空闲entry
位置 - 因为
count
是通过自增的方式来指向entries[]
下一个空闲的entry
,如果有元素被删除了,那么在count
之前的位置就会出现一个空闲的entry
;如果不处理,会有很多空间被浪费。 - 这就是为什么Remove操作会记录
freeList、freeCount
,就是为了将删除的空间利用起来。
- 实际上Add操作会优先使用
-
Collection版本控制
-
在上文中一直提到了
version
这个变量,在每一次新增、修改和删除操作时,都会使version++
;那么这个version
存在的意义是什么呢?首先我们来看一段代码,这段代码中首先实例化了一个Dictionary实例,然后通过
foreach
遍历该实例,在foreach
代码块中使用dic.Remove(kv.Key)
删除元素。Dictionary<int,string> dic = new Dictionary<int, string>(); dic.Add(1,"1"); dic.Add(2,"1"); dic.Add(3,"1"); foreach (var kAv in dic) { dic.Remove(kAv.Key); } //Collection was modified; enumeration operation may not execute
-
结果就是抛出了
System.InvalidOperationException:"Collection was modified..."
这样的异常,迭代过程中不允许集合出现变化。如果在Java中遍历直接删除元素,会出现诡异的问题,所以.Net中就使用了version
来实现版本控制。那么如何在迭代过程中实现版本控制的呢?我们看一看源码就很清楚的知道。
public bool MoveNext() { if (this.version != this.dictionary.version) ThrowHelper.ThrowInvalidOperationException(ExceptionResource.InvalidOperation_EnumFailedVersion); for (; (uint) this.index < (uint) this.dictionary.count; ++this.index) { if (this.dictionary.entries[this.index].hashCode >= 0) { this.current = new KeyValuePair<TKey, TValue>(this.dictionary.entries[this.index].key, this.dictionary.entries[this.index].value); ++this.index; return true; } } this.index = this.dictionary.count + 1; this.current = new KeyValuePair<TKey, TValue>(); return false; }
-
在迭代器初始化时,就会记录
dictionary.version
版本号,之后每一次迭代过程都会检查版本号是否一致,如果不一致将抛出异常。这样就避免了在迭代过程中修改了集合,造成很多诡异的问题。
-
-
-
-
-
概述
-
简单来说装箱是将值类型转换为引用类型 ;拆箱是将引用类型转换为值类型
-
装箱:用于在垃圾回收堆中储存值类型。装箱是值类型到Object类型或到此类型所实现的任何接口类型的隐式转换。
void Start() { MyFunc(1); } public void MyFunc(Object o) { print(o); } //将值类型转换为了引用类型System.Int32
-
拆箱:从object类型到值类型或从接口类型到实现该接口的值类型的显示转换。
void Start() { int i = 1; Int32 j = i; i = 2; print(i); print(j); } //2 //1
-
-
装箱和拆箱的内部操作是什么样的?
-
.NET中,数据类型划分为 值类型 和 引用 (不等同于C++的指针) 类型 ,与此对应,内存分配被分成了两种方式,一为栈,二为堆,注意:是托管堆。
值类型只会在栈中分配。 引用类型分配内存与托管堆。(托管堆对应于垃圾回收。) -
o 和 i 的改变将互不影响,因为装箱使用的是 i 的一个副本。
-
装箱:
1:首先从托管堆中为新生成的引用对象分配内存(大小为值类型实例大小加上一个方法表指针和一个SyncBlockIndex)。
2:然后将值类型的数据拷贝到刚刚分配的内存中。
3:返回托管堆中新分配对象的地址。这个地址就是一个指向对象的引用了。
可以看出,进行一次装箱要进行分配内存和拷贝数据这两项比较影响性能的操作。 -
拆箱:
1:首先获取托管堆中属于值类型那部分字段的地址,这一步是严格意义上的拆箱。
2:将引用对象中的值拷贝到位于线程堆栈上的值类型实例中。
经过这2步,可以认为是同boxing是互反操作。严格意义上的拆箱,并不影响性能,但伴随这之后的拷贝数据的操作就会同boxing操作中一样影响性能。
-
-
为什么要进行装箱和拆箱?
- 作用:为了保证通用性和提高系统性能
- 一种最普通的场景是,调用一个含类型为Object的参数的方法,该Object可支持任意为型,以便通用。当你需要将一个值类型(如Int32)传入时,需要装箱。
- 另一种用法是,一个非泛型的容器,同样是为了保证通用,而将元素类型定义为Object。于是,要将值类型数据加入容器时,需要装箱。
-
装箱/拆箱对执行效率的影响
- 显然,从原理上可以看出,装箱时,生成的是全新的引用对象,这会有时间损耗,也就是造成效率降低。 那该如何做呢?
首先,应该尽量避免装箱。 - 比如上例2的两种情况,都可以避免,在第一种情况下,可以通过重载函数来避免
- 第二种情况,则可以通过泛型特化来避免。
- 当然,凡事并不能绝对,假设你想改造的代码为第三方程序集,你无法更改,那你只能是装箱了。 对于装箱/拆箱代码的优化,由于C#中对装箱和拆箱都是隐式的,所以,根本的方法是对代码进行分析,而分析最直接的方式是了解原理结何查看反编译的IL代码。比如:在循环体中可能存在多余的装箱,你可以简单采用提前装箱方式进行优化。
- 显然,从原理上可以看出,装箱时,生成的是全新的引用对象,这会有时间损耗,也就是造成效率降低。 那该如何做呢?
-
对装箱/拆箱更进一步的了解
-
装箱/拆箱并不如上面所讲那么简单明了,比如:装箱时,变为引用对象,会多出一个方法表指针,这会有何用处呢?
我们可以通过示例来进一步探讨。struct A:ICloneable { public Int32 x; public override String ToString() { return String.Format("{0}",x); } public object Clone() { return MemberwiseClone(); } } void Start() { A a; a.x = 100; print(a.ToString()); //a.ToString()。编译器发现A重写了ToString方法,会直接调用ToString的指令。因为A是值类型,编译器不会出现多态行为。因此,直接调用,不装箱。(注:ToString是A的基类System.ValueType的方法) print(a.GetType()); //a.GetType(),GetType是继承于System.ValueType的方法,要调用它,需要一个方法表指针,于是a将被装箱,从而生成方法表指针,调用基类的System.ValueType。(补一句,所有的值类型都是继承于System.ValueType的)。 A a2 = (A)a.Clone(); //a.Clone(),因为A实现了Clone方法,所以无需装箱。 ICloneable c = a2; //ICloneable转型:当a2为转为接口类型时,必须装箱,因为接口是一种引用类型。 Object o = c.Clone();//c.Clone()。无需装箱,在托管堆中对上一步已装箱的对象进行调用。 /*其实上面的基于一个根本的原理,因为未装箱的值类型没有方法表指针,所以,不能通过值类型来调用其上继承的虚方法。另外,接口类型是一个引用类型。对此,我的理解,该方法表指针类似C++的虚函数表指针,它是用来实现引用对象的多态机制的重要依据。*/ }
-
-
如何更改已装箱的对象
-
对于已装箱的对象,因为无法直接调用其指定方法,所以必须先拆箱,再调用方法,但再次拆箱,会生成新的栈实例,而无法修改装箱对象。有点晕吧,感觉在说绕口令。还是举个例子来说:(在上例中追加change方法)
struct A { public Int32 x; public override String ToString() { return String.Format("{0}",x); } public void Change(Int32 x) { this.x = x; } } interface IChange { void Change(Int32 x); } struct B : IChange { public Int32 x; public override String ToString() { return String.Format("{0}",x); } public void Change(int x) { this.x = x; } } void Start() { A a ; a.x = 100; Object o = a; //装箱成o,下面,想改变o的值。 // o.Change(300); //报错,因为o没有change方法 ((A)o).Change(200); print(o);//100 没改变 /* 没改掉的原因是o在拆箱时,生成的是临时的栈实例A,所以,改动是基于临时A的,并未改到装箱对象。 (附:在托管C++中,允许直接取加拆箱时第一步得到的实例引用,而直接更改,但C#不行。) */ //那该如何是好? //嗯,通过接口方式,可以达到相同的效果。 B b; b.x = 100; o = b; ((IChange)o).Change(200); print(o);//200 //在将o转型为IChange时,这里不会进行再次装箱,当然更不会拆箱,因为o已经是引用类型,再因为它是IChange类型,所以可以直接调用Change,于是,更改的也就是已装箱对象中的字段了,达到期望的效果。 }
-
-