各数据类型的字节数
| byte、sbyte、bool | 1 |
| short、ushort、char | 2 |
| int、uint、float | 4 |
| long、ulong、double | 8 |
| string(栈内存的引用地址) | 32位4,64位8 |
char使用unicode编码,可以表示汉字
char a='的';负整数、补码
负整数用正整数的补码表示。
定义:比特定位数二进制数的最大值大1的数减去一个数。
快速算法:从右向左找到第一个1,保留该1及其右侧所有位,左侧全部取反。
小数的二进制表示
根据IEEE 754标准,32位单精度浮点数(float)的二进制表示包含以下三部分:
符号位(Sign)
1位,最高位(第31位)
0表示正数,1表示负数
指数位(Exponent)
8位(第30-23位)
采用偏移码表示,实际指数值=存储值-127
例如指数3存储为127+3=130(二进制10000010)
尾数位(Mantissa)
23位(第22-0位)
存储规格化后的有效数字小数部分,隐含整数部分1
例如1.1011的尾数存储为101100...0
转换步骤示例(以8.25为例):
转为二进制:8.25 → 1000.01
规格化:1.00001×2³
组合三部分:
符号位:0(正数)
指数位:3+127=130 → 10000010
尾数位:000010...0(补足23位)
最终二进制:0 10000010 00001000000000000000000
特殊值处理:
指数全0:表示0或非规格化数
指数全1:表示无穷大或NaN
64位双精度浮点数(double)采用类似结构,但指数位11位(偏移1023),尾数位52位。
字符串
字符串开头的@
- 不使用转义字符;
- 允许字符串换行;
函数
参数前面的in
在方法参数中使用in表示该参数是只读引用,方法内部不能修改这个参数的值,同时避免了复制大型结构体的开销。类似C++ const XXX&。
结构体
结构体是值类型,为什么还需要new,直接写MyStruct a行吗?
在C#中,结构体是值类型,可以直接声明变量后使用,但使用new关键字有不同的作用。
直接声明结构体变量是允许的,此时结构体的所有字段都保持未赋值状态,需要在使用前手动为每个字段赋值,否则编译器会报错。例如:
csharp
Copy Code
MyStruct a;
a.Field1 = 10; // 必须在使用前赋值
使用new关键字实例化结构体会调用适当的构造函数,将结构体成员初始化为它们的默认值。例如,对于包含int类型的字段,会被初始化为0,这样创建的结构体对象可以直接使用而不会出现编译错误。
因此,两种方式的主要区别在于初始化时机:直接声明需要手动初始化,而使用new会通过构造函数自动初始化。结构体作为值类型,其内存通常分配在栈上,这与引用类型的类在堆上分配内存不同
int?的意思是
底层是
public struct Nullable<T>{ public bool HasValue; public T Value; }对int就是
public struct Nullable<int>{ public bool HasValue; public int Value; }为null相当于HasValue=false。
面向对象
private的可访问范围
可以在一个A类对象a1的函数内访问另一个A类对象a2的private成员。private意思是只有本类的任意对象内可访问本成员,而不限于本对象!!!!!!!!!!!!!!!!!
internal class Person { private int id; public Person(int id) { this.id = id; } public void PrintOtherID(Person other) { Console.WriteLine(other.id); } }字段=>
表示只读属性。
继承
父类有静态变量,子类继承时,读写的还是父类那个静态变量。
public class Father{ protected static int a=0; } public class Son1:Father{ a=441; } public class Son2:Father{ a=123; }父类是泛型类,子类继承时把自己传入,不同子类使用的是不同静态变量。
public class Father<T>{ protected static int a=0; } public class Son1:Father<Son1>{ a=441; } public class Son2:Father<Son2>{ a=123; }对象引用里除了指针还有什么?
在C#中,对象引用(引用类型)的核心机制除了包含指向堆内存的指针外,还涉及以下关键组成部分:
1. 类型元数据
引用类型通过类型对象指针(Type Object Pointer)关联到其类型元数据,包括类结构、方法表、接口实现等信息。这些元数据由CLR管理,用于运行时类型检查和方法分派。
2. 同步块索引
每个引用类型对象头部包含同步块索引(Sync Block Index),用于支持线程同步机制(如lock语句)。该索引指向同步块表中的一个条目,存储锁状态和哈希码等信息。
3. 对象头开销
引用类型实例在堆中分配时会有额外的对象头开销(Object Header),包含类型指针和同步块索引等元信息。这部分对开发者透明,但影响内存布局。
4. 可空性支持
引用类型天然支持null值,这是区别于值类型的重要特性。null表示引用不指向任何有效对象,而值类型需通过Nullable<T>实现类似功能。
5. 继承与多态
引用类型通过虚方法表(VTable)实现继承和多态行为,这是指针之外的动态调度机制。所有引用类型隐式继承自System.Object,具备ToString()等基础方法。
6. 垃圾回收标记
CLR的垃圾回收器会通过引用跟踪对象可达性,引用本身可能被GC优化或重定位,这与原始指针的固定地址特性不同。
注意:C#中的引用是托管概念,不同于C++的裸指针,它由CLR统一管理并包含上述附加信息,确保类型安全和内存自动回收。
成员对象要不要直接写=new()
取决于你设计这个成员是否会为空。比如背包的步枪栏,如果要通过rifle==null判断没有步枪,则不写new(),而背包的物品列表,我们希望背包没有物品时它是一个没有元素的列表,而非null,那么定义时就写上new()。
基类的静态成员由子类共用还是各有一个
非泛型基类,子类共用一个静态成员。泛型基类,子类有各自的静态成员。因为泛型基类不是一个完整的类,本身不能使用,每填入一个类型,就实现一个独立的类。单例基类就是例子。
自定义隐式类型转换public static implicit operator
public static implicit operator Vector3(Vector3Data rValue) { return new Vector3(rValue.x, rValue.y, rValue.z); }自定义一个和Vector3能直接=隐式转换的三维向量
public struct MyVec3 { public float x, y, z; public static implicit operator Vector3(MyVec3 v) { return new Vector3(v.x, v.y, v.z); } public static implicit operator MyVec3(Vector3 v) { return new MyVec3() { x = v.x, y = v.y, z = v.z }; } }接口
接口中的抽象方法不用写abstract,类的实现也不用写override。
出现菱形继承时,孙类的实现方法直接通过方法所属的接口.方法实现,方法是爷类的就写爷类,是哪个父类就写哪个父类。类似于直接使用C++虚继承。
public interface GP { public void Ha(); } public interface P1 : GP { } public interface P2 : GP { } public class Son : P1, P2 { void GP.Ha() { throw new System.NotImplementedException(); } }扩展方法
当对象是空时希望扩展方法直接返回空,但是空对象调用方法会报错?
对象。即使是空,调用扩展方法也不会报错。
列表
FindIndex()输入一个lambda表达式,查找符合条件的元素的索引。
// 正确用法 List<int> numbers = new List<int> { 1, 3, 5, 7, 9 }; int index1 = numbers.FindIndex(x => x == 5); // 返回 2 int index2 = numbers.FindIndex(x => x > 6); // 返回 3字典
可以给字典不存在的键直接赋值吗?
在C#中,可以直接对Dictionary中不存在的键进行赋值操作,此时会自动添加新的键值对而不会抛出异常。
使用索引器为Dictionary赋值时,如果指定的键不存在,系统会自动添加一个新的键值对;如果键已存在,则会更新对应的值。这一行为与一些开发者的预期可能不同,他们认为在键不存在时应抛出KeyNotFoundException异常。
如果需要严格区分添加和更新操作,可以使用Add方法添加新元素(键存在时会抛出异常),或使用ContainsKey方法先检查键是否存在。此外,ConcurrentDictionary类型还提供了AddOrUpdate方法,能够更明确地处理键存在与否的情况。
字典内部:int[] _buckets和Entry[] _entries数组
根据键计算出哈希值h,来到_buckets[h],得到一个整数,就是本组数据的第一个在_entries的索引。然后一个Entry里记录了本组下一个数据在_entries的索引。
struct Entry { public int hashCode; // 哈希码(低31位) public int next; // 冲突链的下一个节点索引(-1 表示结尾) public TKey key; public TValue value; }HashSet<>
重复Add同一个元素,相当于没有重复Add。不报错。
装委托时,不同对象的实例函数被视为不同的函数!
HashSet<UnityAction> set = new(); BuildConfig b1 = new(); BuildConfig b2 = new(); set.Add(b1.CheckResAB); set.Add(b2.CheckResAB); MyTools.Print(set.Count, Color.cyan);//打印2!class Tuple和ValueTuple(也就是(T1,T2))
Tuple是引用类型,相等是判断内存地址,但是GetHashCode()好像是根据内容值计算,得到一个int,然后判断相等调用的是int的Equals(),所以用Tuple.Create()新创建的tuple查询字典能命中。
ValueTuple可简写成(类型1,类型2,……),是值类型,可以用来做多键字典。
委托、事件
触发可以用XXX()也可以XXX.Invoke();
输入参数、返回值类型都一样,类型不同的两个委托能赋值吗?
不能。
UnityAction ca=Update; Action c2; c2 = ca;多播委托内部是怎么记录被添加的函数的?
C#多播委托内部通过调用链表(Invocation List)记录被添加的函数,其核心机制如下:
1. 调用链表结构
底层实现:多播委托继承自System.MulticastDelegate类,内部维护一个_invocationList字段(实际是object[]数组),用于存储多个方法引用。
数据结构:每个委托实例包含一个方法链表,链表中的每个元素代表一个方法的引用(包括方法地址和所属对象,如果是实例方法)。
2. 方法添加与合并
+= 操作符:通过+=操作符将新方法添加到链表末尾,生成新的委托实例,合并原有和新增方法。
链表构建:每次添加方法时,运行时会创建一个包含所有订阅方法的链表,按注册顺序排列。
3. 调用顺序与执行机制
执行流程:调用多播委托时,系统遍历链表并按顺序执行每个方法。
异常处理:若某个方法抛出异常,后续方法将不会执行。
4. 返回值处理
无返回值委托:所有方法按顺序执行,无返回值。
有返回值委托:仅返回最后一个方法的返回值。
5. 管理调用链
GetInvocationList():可获取当前链表,手动控制执行顺序。
+= 和 -= 操作:用于添加或移除方法,影响调用顺序。
示例
public delegate void MyDelegate(string msg); MyDelegate del = MethodA; del += MethodB; // MethodB 添加到链表末尾 del("Hello"); // 按顺序执行 MethodA -> MethodB
总结:多播委托通过链表记录方法引用,按添加顺序执行,实现方法的动态组合与调用。
委托-=时这个链表发生了什么?
在C#中,当使用-=操作符从多播委托中移除方法时,其内部调用链表(Invocation List)会发生以下关键变化:
1. 链表重组机制
移除匹配项:委托会遍历调用链表,查找与待移除方法签名和对象引用完全匹配的项。若找到匹配项,则从链表中移除该方法引用。
链表重建:移除操作会生成一个新的调用链表,包含所有未被移除的方法。原链表保持不变,新链表按顺序存储剩余方法。
2. 移除失败的处理
静默失败:若未找到匹配项(如方法签名不一致或对象引用不同),移除操作无效且不抛出异常。链表保持原样,后续调用仍包含所有方法。
引用一致性要求:移除时必须精确匹配原始添加方法的引用(包括匿名方法或Lambda表达式),否则移除失败。
3. 调用链表的结构
单方法委托:若链表仅剩一个方法,委托退化为普通委托(非多播)。
多方法链表:链表按添加顺序存储方法引用,调用时依次执行。
4. 示例说明
Action del = MethodA; del += MethodB; // 链表: [MethodA, MethodB] del -= MethodA; // 链表: [MethodB](MethodA被移除)总结:-=操作通过链表重组移除匹配方法,若匹配失败链表不变。理解链表机制是避免移除失败的关键。
会生成一个新的调用链表,包含所有未被移除的方法。原链表保持不变,也就是说会有2个链表?
是的,当使用-=操作符从多播委托中移除方法时,会生成一个新的调用链表(Invocation List),包含所有未被移除的方法。原链表保持不变,因此确实会有两个链表:
原链表:包含所有方法的原始链表,未发生任何变化。
新链表:包含移除指定方法后剩余的方法,按添加顺序排列。
这种机制确保了移除操作的原子性,避免了链表被破坏。调用链表的结构如下:
text
Copy Code
原链表: [MethodA, MethodB, MethodC]
移除 MethodB 后:
新链表: [MethodA, MethodC]
原链表: [MethodA, MethodB, MethodC](保持不变)
关键点:
移除操作生成新链表,原链表不变。
若移除失败(如方法签名不匹配),链表保持原样。
链表按添加顺序存储方法引用,调用时依次执行。
多播委托最多有几个链表?
在C#中,多播委托内部最多会维护两个链表:
原链表:包含所有方法的原始链表,未发生任何变化。
新链表:在执行-=操作时生成的新链表,包含移除指定方法后剩余的方法。
生成机制
链表重组:移除操作通过遍历原链表查找匹配项,生成包含剩余方法的新链表。
链表独立性:原链表保持不变,新链表按顺序存储剩余方法。
示例
Action del = MethodA; del += MethodB; // 原链表: [MethodA, MethodB] del -= MethodA; // 新链表: [MethodB], 原链表: [MethodA, MethodB]
总结:多播委托最多维护两个链表,原链表不变,新链表包含移除后剩余方法。
多播委托多次-=时,这两个链表怎么变化?
在C#中,多播委托多次使用-=操作符移除方法时,其内部调用链表(Invocation List)会按以下规律变化:
1. 链表重组机制
每次移除操作:生成一个新的调用链表,包含所有未被移除的方法。原链表保持不变,新链表按顺序存储剩余方法。
链表独立性:每次移除后,原链表和新链表是两个独立的链表结构。
2. 链表变化示例
csharp
Copy Code
Action del = MethodA;
del += MethodB; // 原链表: [MethodA, MethodB]
del -= MethodA; // 新链表: [MethodB], 原链表: [MethodA, MethodB]
del -= MethodB; // 新链表: [], 原链表: [MethodA, MethodB]
3. 最终状态
空链表:若所有方法被移除,最终链表为空(null),后续调用会抛出NullReferenceException。
4. 关键点
链表数量:每次移除操作生成一个新链表,链表数量随移除次数增加。
原链表不变:移除操作不影响原链表,始终保留初始方法列表。
总结:多播委托多次移除时,链表会生成多个独立链表,原链表保持不变。最终链表为空时需检查null。
System.IO
File
bool File.Exists(path);
File.WriteAllText(path, str);
string File.ReadAllText(path)
Directory
Directory.Exists(path);
Directoriy.CreateDirectory(path);
string[] Directory.GetFiles(path,string 后缀);
反射
得到Type
typeof(类名);实例.GetType();Type.GetType("命名空间.类名");每一个对象,都有一个地方记着它们的类型?
对,C# 里每个对象,头上都挂着自己的 “类型信息”。在 .NET 里,每个对象在内存里长成这样:
[ 对象头 ] → 里面有指针指向 [ 类型对象指针 ] ← 这就是关键! [ 字段数据 ]得到公共成员
MemberInfo memberInfos[]=type.GetMembers();得到构造函数
得到所有:
ConstructorInfo[] constructorInfos=type.GetConstructors();得到无参:
ConstructorInfo constructorInfo=type.GetConstructor(new Type[0]);MyClass myClass=constructorInfo.Invoke(null) as MyClass;得到有参:
ConstructorInfo constructorInfo2=type.GetConstructor(new Type[]{typeof(参数类型)}); myClass=constructorInfo2.Invoke(new object[]{2}) as MyClass;Activator快速实例化
myClass=Activator.CreateInstance(type) as MyClass;得到公共字段
按字段名得到:
FieldInfo fieldInfo=type.GetField(string 字段名);得到所有字段
FieldInfo[] fieldInfos=type.GetFields();公共字段读写
fieldInfo.GetValue(实例);fieldInfo.SetValue(实例,数据);得到公共方法
得到所有
MethodInfo[] methodInfos=type.GetMethods();按方法名得到:
MethodInfo methodInfo=type.GetMethod(string 方法名,new Type[]{typeof(参数1类型),...});执行公共方法
实例方法
methodInfo.Invoke(实例,new object[]{参数1,...});静态方法
methodInfo.Invoke(null,new object[]{参数1,...});判断一个type是列表
typeof(IList).IsAssignableFrom(type) && type.IsGenericType判断一个type是字典
if(typeof(IDictionary).IsAssignableFrom(type)){ }把一个字符串转换成枚举type的值
enumValue = Enum.Parse(type, enumStr, ignoreCase: true);获得List的Fields,什么都没得到
List<int>data=new List<int>(){123,14,4234}; Type dataType=data.GetType(); FieldInfo[] fieldInfos=dataType.GetFields();正确用法:把List装到一个class里。
特性
理解特性
特性是反射的加强功能,在FieldInfo常用的名称、类型基础上给它加自定义特征,用于把一个类拆成fieldInfos后筛选出我们需要的字段。比如资源校验时,在string path上打上包含此资源的类型的特性,然后可以用反射+循环+检查特性检查类型或必要脚本。
定义特性
class MyAttribute:Attribute{ }使用特性
type.IsDefined(typeof(),bool)判断一个类是否被加了某特性,第二个参数选择是否搜索目标类的父类。
type.GetCustomAttributes(false)得到一个类加的所有特性。
[My] public class MyClass{ } static void Main(){ MyClass myClass=new MyClass(); Type type=myClass.GetType(); if(type.IsDefined(typeof(MyAttribute),false){ Console.WriteLine("加了特性My"); } object[] atts=type.GetCustomAttributes(false); for(int i=0;i<atts.Length;i++){ } }很明显,type.IsDefined是个笨方法,对于m个类,n个特性,不可能去判断m*n次。最理想的是能获得添加某特性的所有类,比如对于[MenuItem]特性,就能一下汇总要加到菜单的类。
限制特性使用范围
Inherited:是否自动给子类加上此特性
[AttributeUsage(AttributeTargets.Class|Attributes.Field,AllowMultiple=false,Inherited=false)] class MyAttribute:Attribute{ }多线程、异步
Task
实例方法运行线程
Task t1=new Task(()=>{ }); t1.Start();静态方法运行线程:
Task t2=Task.Run(()=>{ });任务工厂运行线程
Task t3=Task.Factory.StartNew(()=>{ });阻塞当前线程,直到task执行完:
task.Wait();阻塞当前线程,直到至少一个参数Task执行完:
Task.Wait(t1,t2,t3);Task<T>
线程可以返回一个T类型的值,通过task.Result得到结果。但是会阻塞所在线程,等Task线程执行完才能得到。
CancellationTokenSource
CancellationTokenSource cts=new CancellationTokenSource(3000);//取消时间 cts.Token.Register(()=>{});//取消后的回调 if(cts.IsCancellationRequested){ //任务停止 } cts.Cancel();//让IsCancellationRequested变成trueasync await
- async函数只能返回void、Task、Task<>;
- await后面跟一个Task或Task<>,Task.Run()返回Task;
异步函数延时:
async void Timer(){ //XXX await Task.Delay(1000); /YYY }我子类和父类写了同名函数,没写virtual override,子类执行哪个函数
在C#中,当子类和父类存在同名函数且未使用virtual和override关键字时,子类会默认隐藏(而非重写)父类的同名方法。具体行为如下:
隐藏机制
子类的方法会隐藏父类的同名方法(编译器会生成警告,建议显式添加new关键字以明确意图)。此时调用规则取决于引用类型:
若通过子类变量调用,执行子类方法。
若通过父类变量调用(即使实际指向子类对象),执行父类方法。
示例说明
csharp
Copy Code
class Parent {
public void Method() { Console.WriteLine("Parent"); }
}
class Child : Parent {
public new void Method() { Console.WriteLine("Child"); } // 显式使用new消除警告
}
// 测试代码
Parent obj1 = new Child();
obj1.Method(); // 输出 "Parent"(取决于引用类型):ml-citation{ref="2,5" data="citationList"}
Child obj2 = new Child();
obj2.Method(); // 输出 "Child"(子类方法):ml-citation{ref="4,6" data="citationList"}
与virtual/override的区别
若父类方法标记为virtual且子类使用override,则无论引用类型如何,实际对象类型决定调用结果(多态行为)。而隐藏机制(new或无修饰符)是静态绑定,仅依赖编译时类型。
总结:未使用virtual/override时,子类同名方法隐藏父类方法,实际调用由变量声明类型决定。
内存泄漏的原因
C#内存泄漏的常见原因及分析如下:
1. 事件订阅未取消
事件处理程序(委托)会隐式持有订阅者对象的引用,若未显式取消订阅(-=),即使订阅者不再使用,仍会被发布者长期持有,导致无法被垃圾回收(GC)。常见于UI框架(如WPF)或长时间运行的服务中。
示例场景:窗口控件订阅系统事件后未取消,即使窗口关闭,事件仍保持引用。
2. 静态变量持有引用
静态变量生命周期与应用程序一致,若静态集合(如List、Dictionary)存储对象且未清理,这些对象将永久驻留内存。单例模式(Singleton)若持有大对象引用也可能导致泄漏。
典型问题:缓存类使用静态字典存储数据,但未设置过期策略或清理机制。
3. 非托管资源未释放
C#的GC仅管理托管内存,非托管资源(如文件句柄、数据库连接、Marshal.AllocHGlobal分配的内存)需通过IDisposable接口显式释放。未调用Dispose()或未使用using语句会导致资源泄漏。
关键点:FileStream、SqlConnection等必须显式关闭或包裹在using块中。
4. 匿名函数闭包引用
Lambda表达式或匿名函数捕获外部变量时,会形成闭包并延长变量生命周期。例如,控件构造函数中订阅事件并捕获this指针,即使控件销毁,闭包仍阻止GC回收。
解决方案:避免在匿名函数中捕获不必要的上下文变量。
5. 缓存管理不当
未限制缓存大小或未设置过期策略的缓存(如静态缓存)会无限增长,占用内存。例如,缓存未实现LRU(最近最少使用)策略时,旧数据堆积导致泄漏。
6. 第三方库或框架的隐藏泄漏
某些第三方库可能存在未公开的内存管理缺陷,如未正确释放事件订阅或资源句柄。需通过工具(如dotMemory)检测库的引用链。
7. 循环引用
对象间相互引用(如双向链表、图结构)可能导致GC无法回收。需使用弱引用(WeakReference)或手动断开引用链。
扩展阅读
总结:C#内存泄漏多源于引用管理不当或资源释放遗漏,需结合工具检测(如Visual Studio内存分析器)和编码规范(如IDisposable、弱引用)预防。
函数是何时被加载到内存中的?
在C#中,函数的加载时机与程序运行时的内存管理机制密切相关,主要分为以下阶段:
类加载阶段
当程序首次使用某个类时(如创建实例、访问静态成员等),该类及其所有成员(包括方法)会被加载到内存的代码区。静态函数在此时完成加载,且整个生命周期仅加载一次。
JIT编译阶段
函数的具体机器代码并非一开始就全部生成,而是在首次调用前由JIT(即时编译器)动态编译并存入内存。这种延迟编译机制优化了启动性能。
执行过程中的内存分配
栈帧管理:调用函数时,会在栈上创建栈帧,存储局部变量、参数和返回地址,调用结束后自动释放。
上下文保存:函数调用涉及寄存器和返回地址的压栈操作,确保执行流程可回溯。
动态代码场景
反射或动态生成的代码(如System.Reflection.Emit)会在运行时动态加载到内存,其函数加载时机由程序显式控制。
总结:C#函数加载是按需触发的过程,结合了类初始化、JIT编译和运行时栈管理的协同机制。
函数里声明一个局部数组,把它返回时是把数组元素都拷贝了一遍?
直接返回数组变量:此时返回的是数组引用而非拷贝,调用方和函数内部操作的是同一数组对象