拒绝背话术,帮助你深刻理解其原理,真正做到实战时游刃有余!适用于Unity游戏开发、C#语言相关面试等相关学习。
此为C#中级篇,不久将更新后续篇目。
初级篇:C#面试题(初级篇),详细讲解,帮助你深刻理解,拒绝背话术!-CSDN博客
1.构造函数为什么不能是虚函数?
对象初始化顺序角度:在创建对象时,首先要分配内存,然后调用基类的构造函数,接着调用派生类的构造函数,这是一个自顶向下的过程。虚函数是基于对象的运行时类型来确定具体调用哪个实现的,其调用依赖于对象的虚表(一个存储虚函数地址的表格)。而在构造函数执行期间,对象还处于初始化阶段,虚表还未完全构建好。如果构造函数是虚函数,就无法准确地根据对象的运行时类型来调用正确的构造函数,因为此时对象的运行时类型还在构建过程中,可能会导致不可预测的行为。
明确的初始化顺序:构造函数的调用顺序是明确规定的,即先调用基类构造函数,再调用派生类构造函数。这种固定的顺序有助于保证对象的正确初始化。如果构造函数可以是虚函数,就会打破这种明确的顺序,使得对象的初始化过程变得复杂和难以理解,增加了代码的维护难度。
2.什么是匿名⽅法?还有Lambda表达式?
详解:C#面试常考随笔7:什么是匿名⽅法?还有Lambda表达式?-CSDN博客
匿名方法本质上是一种没有显式名称的方法,它可以作为参数传递给需要委托类型的方法,常用于事件处理、回调函数等场景,能够让代码更加简洁和紧凑。
优点
- 代码简洁:避免了为每个简单的委托实现单独定义一个命名方法,减少了代码量,使代码更加紧凑。
- 内联定义:可以在需要委托的地方直接定义逻辑,提高了代码的可读性和可维护性。
缺点
- 复用性差:由于匿名方法没有名称,不能在其他地方复用,只能在定义的地方使用。
- 调试不便:在调试时,由于没有明确的方法名称,可能会增加调试的难度。
与 Lambda 表达式的关系
在 C# 3.0 及以后的版本中,引入了 Lambda 表达式,它是一种更简洁的创建委托实例的方式,通常可以替代匿名方法。Lambda 表达式在语法上更加简洁,因此在实际开发中使用更为广泛。
Lambda 表达式参数列表规则
- 无参数:如果 Lambda 表达式不需要参数,可以使用空的参数列表,例如:
() => Console.WriteLine("无参数的Lambda表达式");
- 单个参数:当只有一个参数时,可以省略参数列表的括号,例如:
num => num * 2;
- 多个参数:多个参数需要用逗号分隔,并放在括号内,例如:
(a, b) => a + b;
3.using关键字有哪些用法?
(1)using 指令:引入命名空间
(2)using static 指令:引入静态成员
通过using static + 类型
,可以指定无需指定类型名称即可访问其静态成员的类型。使用该指令后,在代码中可以直接使用该类型的静态成员,而不必通过类型名来调用。例如:
using static System.Math;
class Program
{
static void Main()
{
double result = Sqrt(16); // 直接使用System.Math的静态方法Sqrt
Console.WriteLine(result);
}
}
(3)using 别名:为类型创建别名
当同一个 C# 文件引用了两个不同的命名空间,且这两个命名空间中都包含一个相同名字的类型时,为了避免混淆,可以使用using + 别名 = 包括详细命名空间信息的具体类型
的方式为类型创建别名。例如:
using aClass = NameSpace1.MyClass;
using bClass = NameSpace2.MyClass;
namespace NameSpace1
{
public class MyClass
{
public override string ToString()
{
return "You are in NameSpace1.MyClass";
}
}
}
namespace NameSpace2
{
class MyClass
{
public override string ToString()
{
return "You are in NameSpace2.MyClass";
}
}
}
namespace testUsing
{
class Class1
{
static void Main()
{
aClass my1 = new aClass();
Console.WriteLine(my1);
bClass my2 = new bClass();
Console.WriteLine(my2);
}
}
}
(4) using 语句:自动释放资源
using
语句用于定义一个范围,在范围结束时自动调用对象的Dispose
方法来释放资源。通常用于处理实现了IDisposable
接口的对象,比如文件流、数据库连接等。语法形式为:
using (var resource = new ResourceType())
{
// 使用resource对象的代码
}
// 离开这个代码块后,resource的Dispose方法会被自动调用,释放相关资源
例如,使用文件流读取文件内容时:
using (System.IO.StreamReader reader = new System.IO.StreamReader("test.txt"))
{
string line;
while ((line = reader.ReadLine()) != null)
{
Console.WriteLine(line);
}
}
// 读取完毕后,StreamReader对象的Dispose方法会自动被调用,释放文件资源
这样可以确保即使在代码块中发生异常,相关资源也能被及时释放,避免资源泄漏。
扩展:
C#面试常考随笔8:using关键字有哪些用法?-CSDN博客
如何使用C#的using语句释放资源?什么是IDisposable接口?与垃圾回收有什么关系?-CSDN博客
什么情况下,C#需要手动进行资源分配和释放?什么又是非托管资源?-CSDN博客
4.什么是闭包?
最简单的例子:
Lambda可以访问Lambda表达式块外部的变量,叫闭包。
定义
闭包是指有权访问另一个函数作用域中的变量的函数。即使该函数已经执行完毕,其作用域内的变量也不会被销毁,而是会被闭包所捕获并保留,供闭包函数后续使用。
详解:C#面试常考随笔8:using关键字有哪些用法?-CSDN博客
5.Dictionary<K, V>、Hashtable的内部实现原理是什么?
Dictionary<K, V>
- 底层数据结构:使用哈希表(Hash Table),由一个数组和链表(或在.NET Core 2.1 及之后版本中,当链表长度达到一定阈值时转换为红黑树)组成。数组中的每个元素称为一个桶(Bucket),用于存储键值对。
- 工作原理:添加键值对时,先计算键(Key)的哈希码,通过哈希码对数组长度取模,得到对应的桶索引。若该桶为空,直接将键值对存入;若不为空(即发生哈希冲突),则以链表(或红黑树)的形式将新的键值对添加到该桶的链表(或红黑树)中。查找元素时,先计算键的哈希码定位到桶,然后在桶内的链表(或红黑树)中通过键的比较(调用
Equals
方法)找到对应的值。
Hashtable
- 底层数据结构:同样基于哈希表实现,由数组和链表构成。数组中的每个元素是一个桶,用于存储键值对。
- 工作原理:添加键值对时,计算键的哈希码,对数组长度取模确定桶索引。若桶为空,直接存入键值对;若桶已有元素(哈希冲突),则将新键值对添加到该桶的链表中。查找元素时,先计算键的哈希码定位桶,再在桶内链表中通过键的比较(调用
Equals
方法)获取对应的值。与Dictionary<K, V>
不同的是,Hashtable
是线程安全的,因为其方法实现中添加了synchronized
关键字(在多线程环境下可确保线程同步,但性能相对Dictionary<K, V>
较低),并且Hashtable
的键和值都是object
类型,存储和获取时可能需要类型转换,存在装箱和拆箱操作。
查找效率
- 理想情况:二者在理想情况下查找效率都较高。它们内部都基于哈希表结构,通过计算键的哈希码来定位存储位置。在没有哈希冲突(即不同键计算出的哈希码对应到哈希表中的不同位置)时,查找操作可以在接近常数时间(O (1))内完成,即能快速定位到目标键值对。
- 存在哈希冲突时:
- Hashtable:当发生哈希冲突,同一桶中存在多个键值对(以链表形式存储)时,查找时需要在链表中顺序比较键。如果哈希冲突严重,链表较长,查找效率会降低,时间复杂度可能退化为 O (n),n 为链表长度。不过,由于其内部实现对哈希函数等进行了优化,在一般情况下能较好地分散哈希值,减少冲突。
- Dictionary<K, V> :在.NET Core 2.1 之前,处理哈希冲突与
Hashtable
类似,通过链表存储冲突的键值对,冲突严重时查找效率受链表长度影响。在.NET Core 2.1 及之后版本中,当链表长度达到一定阈值(通常为 8 )时,会将链表转换为红黑树。红黑树是一种自平衡的二叉搜索树,查找操作的时间复杂度为 O (log n),相比于长链表的 O (n),在冲突较多时能提供更稳定和高效的查找性能。
6.双检锁实现⼀个单例模式?
双检锁(Double-Checked Locking)概念
双检锁是一种用于实现线程安全的单例模式的优化技术。在多线程环境下,为了确保一个类只有一个实例,并且在需要时才进行实例化,同时避免不必要的同步开销,就可以使用双检锁机制。
其核心思想是在实例化对象之前进行两次检查:第一次检查实例是否已经创建,如果没有创建再进入同步块;进入同步块后,再进行第二次检查,确保在多线程环境下只有一个线程能创建实例。这样可以减少同步操作的次数,提高性能。
(比如两个对象都到 Lock处,前一个创建实例后,下一个对象Lock后,就需要判断是否被创建过实例)
using System;
// 定义单例类
public sealed class Singleton
{
// 私有静态字段,用于存储单例实例
private static Singleton _instance;
// 用于线程同步的锁对象
private static readonly object _lock = new object();
// 私有构造函数,防止外部实例化
private Singleton()
{
Console.WriteLine("Singleton instance is created.");
}
// 公共静态属性,用于获取单例实例
public static Singleton Instance
{
get
{
// 第一次检查:判断实例是否已经创建
if (_instance == null)
{
// 进入同步块
lock (_lock)
{
// 第二次检查:确保在同步块内只有一个线程能创建实例
if (_instance == null)
{
_instance = new Singleton();
}
}
}
return _instance;
}
}
}
class Program
{
static void Main()
{
// 获取单例实例
Singleton singleton1 = Singleton.Instance;
Singleton singleton2 = Singleton.Instance;
// 验证两个实例是否相同
Console.WriteLine(object.ReferenceEquals(singleton1, singleton2)); // 输出: True
}
}
7.关于设计模式
分为:创建型、结构型、行为型模式
常用的有:
创建型模式:
-
单例模式:确保类只有一个实例,并提供全局访问点。如游戏中的游戏管理器、音效管理器等常采用单例模式,保证在游戏任何地方都能方便访问和操作。
-
工厂模式:用于创建对象,将对象的创建和使用分离。在游戏中创建不同类型的敌人、道具等可使用工厂模式,根据配置或游戏逻辑创建相应对象。
行为型模式:
-
观察者模式:定义对象间的一对多依赖关系,当一个对象状态改变时,其依赖者会收到通知并自动更新。游戏中的任务系统、UI 界面更新等常采用此模式,如任务完成时通知相关 UI 更新任务状态。(类似事件系统)
-
状态模式:允许对象在内部状态改变时改变行为,看起来像修改了类。游戏中角色的不同状态(如奔跑、跳跃、攻击等)可用状态模式实现,每个状态有不同行为逻辑。(类型状态机)
结构型模式:
-
代理模式:为其他对象提供代理以控制对它的访问。在网络游戏中,可能用代理模式来处理远程对象访问,本地代理对象代替远程对象进行操作,减少网络通信开销。
-
装饰器模式:动态地给对象添加额外职责。游戏中给角色添加装备或增益效果可使用装饰器模式,不修改角色原有代码基础上,动态添加新属性和行为。
-
适配器模式:将一个类的接口转换成客户希望的另一个接口。在 Unity 中使用不同外部插件或 SDK 时,可能需要用适配器模式使它们接口与游戏项目兼容。