🟡: 代表个人还有一些理解上的问题
🟢: 代表自己面试中被问到过
🔴: 代表问题内容未完成

List

请说明 C# 中的 List 是如何扩容的

C#中的 List 是一个动态数组,其容量可以根据需要动态扩展。
初始容量:List在创建时可以指定初始容量(capacity),如果没有指定,则初始容量默认为 0。

自动扩容:当添加元素导致当前容量不足时,List会自动扩容。扩容时,List会分配一个新的数组,并将现有元素复制到新数组中。

扩容倍数:扩容时,新数组的容量通常是旧容量的两倍。这种倍增策略有助于减少频繁分配内存和数据复制的开销。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
using System;
using System.Collections.Generic;

class Program
{
static void Main()
{
// 创建一个初始容量为2的List
List<int> numbers = new List<int>(2);

// 添加元素
numbers.Add(1);
numbers.Add(2);

// 查看当前容量
Console.WriteLine("容量: " + numbers.Capacity); // 输出: 容量: 2

// 添加第三个元素,触发扩容
numbers.Add(3);

// 查看当前容量
Console.WriteLine("容量: " + numbers.Capacity); // 输出: 容量: 4

// 添加更多元素
numbers.Add(4);
numbers.Add(5);//触发扩容

// 再次查看容量
Console.WriteLine("容量: " + numbers.Capacity); // 输出: 容量: 8
}
}

List 扩容效率问题

下面两种获 10000 个数的方式,哪种效率更高?为什么?

1
2
3
4
5
6
7
8
9
10
11
12
//方式1 :
List<int> list = new List<int>();
for (int i = 0; i < 10000; i++)
{
list.Add(i);
}
//方式2 :
float[] array = new float[10000];
for (int i = 0; i < array.Length; i++)
{
array[i] = i;
}

方式 2 的效率更高。

List 本质上是一个数组,通过 Add 方法向 List 中添加元素时,如果容量不足,会触发数组扩容操作。每次扩容都会产生新的数组,导致旧数组成为垃圾,会增加内存的占用。此外,频繁的数组扩容也会增加垃圾收集(GC)的频率;数组扩容时需要将原数组中的元素搬移到新数组中,这涉及内存的拷贝和数据的移动,会影响程序的性能。
所以方式 2 的效率更高,因为它避免了数组扩容的操作,从而减少了内存和性能上的消耗。

C#中 List 初始化效率比较

以下代码,谁的效率更高?为什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
// 代码1
List<int> list1 = new List<int>();
for (int i = 0; i < 500; i++)
{
list1.Add(i);
}

// 代码2
List<int> list2 = new List<int>(500);
for (int i = 0; i < 500; i++)
{
list2.Add(i);
}

代码 2 的效率更高。因为 List 的本质是数组,在初始化时,如果不明确指定分配多少容量,它会不断扩容。扩容会带来效率的降低和垃圾的产生。

List 是链表还是数组

List 在 C#中实际上是基于数组实现的。尽管其名称中包含“List”,但它并不是传统意义上的链表。在 List 内部,元素存储在一个数组中,当数组大小不足时,List 会动态扩展数组的大小以容纳更多的元素。这种设计使得 List 在随机访问方面具有数组的性能,同时也能够像链表一样高效地进行插入和删除操作。

虽然 List 的内部实现是数组,但其提供了一系列的方法和属性,使得其用起来更像是一个动态数组而非传统的链表。因此,虽然名称上可能会让人误以为 List 是链表,但实际上它是基于数组实现的动态容器。

List 容量满时的处理过程

当 List 容量满了的时候,再加入一个元素会导致效率降低,内部是什么样的执行过程?

当 List 的容量达到上限时,再加入一个元素会导致以下过程:

  • 数组搬家:List 内部会创建一个更大容量的新数组,并将原来的元素逐个复制到新数组中。
  • 效率降低:由于数组搬家需要将原数组中的所有元素复制到新数组中,因此会导致效率降低。特别是在数据量较大时,这种效率下降会更为明显。
  • 内存垃圾:数组搬家过程中会产生内存垃圾,因为需要创建新数组并复制数据。如果频繁发生数组搬家,会增加内存垃圾的产生,可能会对程序的性能造成影响。

请问执行以上代码后,List 中还存在哪些内容?

委托 和 事件

委托和事件在使用上的区别是什么?

调用权限

  • 委托:可以被外部代码直接调用。
  • 事件:只能在声明它的类中调用(触发)。

订阅和取消订阅

  • 委托:可以被外部代码赋值、替换、调用。
  • 事件:只能被外部代码订阅(+=)和取消订阅(-=),不能直接赋值和调用。

语义和用途

  • 委托:用于通用的回调机制,允许方法作为参数或返回值。
  • 事件:用于发布-订阅模式,提供一种通知机制。

C#中委托的本质是什么?

它是如何存储函数的?

C# 中的 Action 和 Func 是什么? Unity 中的 UnityAction 是什么? 他们的区别

Action 和 Func 是 C# 中 System 命名空间下的预定义委托类型。

  • Action 本身是一个无参数且无返回值的委托。泛型版本的 Action 可以支持最多 16 个参数,但依然没有返回值。
1
2
3
4
5
Action action = () => Console.WriteLine("Hello, Action!");
action();

Action<int, string> actionWithParameters = (number, text) => Console.WriteLine($"{number}: {text}");
actionWithParameters(1, "Hello, Action with parameters!");
  • Func 本身是一个无参数但有返回值的委托。泛型版本的 Func<T, TResult> 可以支持最多 16 个参数,并且最后一个类型参数表示返回值的类型。
1
2
3
4
5
Func<int> func = () => 42;
Console.WriteLine(func());

Func<int, int, int> add = (a, b) => a + b;
Console.WriteLine(add(1, 2));

UnityAction 是 UnityEngine.Events 命名空间下 Unity 提供的预定义委托类型。

  • UnityAction 本身是一个无参数且无返回值的委托。泛型版本的 UnityAction 可以支持最多 4 个参数,且无返回值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
using UnityEngine;
using UnityEngine.Events;

public class Example : MonoBehaviour
{
void Start()
{
UnityAction action = () => Debug.Log("Hello, UnityAction!");
action();

UnityAction<int, string> actionWithParameters = (number, text) => Debug.Log($"{number}: {text}");
actionWithParameters(1, "Hello, UnityAction with parameters!");
}
}

区别

  • 命名空间:
    Action 和 Func 位于 System 命名空间。
    UnityAction 位于 UnityEngine.Events 命名空间。

  • 参数数量:
    Action 和 Func 的泛型版本可以支持最多 16 个参数。
    UnityAction 的泛型版本可以支持最多 4 个参数。

  • 返回值:
    Action 和 UnityAction 本身都没有返回值。
    Func 委托具有返回值。

Action 和 Func 是 C# 中的预定义委托,适用于一般的委托使用场景。UnityAction 是 Unity 提供的预定义委托,通常用于 Unity 事件系统中的回调函数。

C#中事件的本质是什么?

内存管理 垃圾回收

内存泄漏指什么?常见的内存泄漏有哪些?

内存泄漏是指对象在超过其生命周期后不能被垃圾回收器(GC)回收,导致内存一直被占用,最终可能导致内存不足,简单说明就是那些不再需要的对象没有被及时清理掉。

常见的内存泄漏类型

  • 静态引用:静态字段(Static Field)持有对象的引用,这些对象会一直存在于应用程序的生命周期内,除非手动清除引用。
1
2
3
4
5
6
7
8
9
public class MemoryLeakExample
{
private static List<string> staticList = new List<string>();

public void AddItem(string item)
{
staticList.Add(item);
}
}

在这个例子中,staticList 会一直持有添加的字符串,除非显式清除。

  • 未清除的引用:不使用的引用对象没有被设置为 null,导致 GC 无法回收这些对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class MemoryLeakExample
{
private object reference;

public void CreateReference()
{
reference = new object();
}

public void ClearReference()
{
reference = null; // 手动清除引用,允许GC回收
}
}
  • 文件操作未正确释放:在进行文件或资源操作时,如果没有使用 using 语句或没有调用 Dispose 方法,资源会一直占用内存。
1
2
3
4
5
6
7
public void FileOperation()
{
using (var stream = new FileStream("file.txt", FileMode.Open))
{
// 文件操作
} // using语句确保文件流被正确释放
}
  • 未解除的委托或事件注册:委托或事件注册后没有解除注册,导致引用对象不能被 GC 回收。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class MemoryLeakExample
{
public event EventHandler MyEvent;

public void RegisterEvent(EventHandler handler)
{
MyEvent += handler;
}

public void UnregisterEvent(EventHandler handler)
{
MyEvent -= handler; // 确保解除注册
}

}

请简述 GC(垃圾回收)产生的原因,并至少说出避免 GC 发生的三种方式?

GC 产生的原因是为了避免堆内存溢出而引入的回收机制。当不再使用的堆内存占用达到一定上限时,垃圾回收机制将会启动,以释放这些无用的内存。

避免方式:

  • 尽量减少创建新对象,尽量复用对象(可使用缓存池)。
  • 使用 StringBuilder 替换 String,避免字符串拼接时产生垃圾。
  • 公共对象用静态声明。
  • 避免不必要的装箱和拆箱操作。
  • 定期清理不再使用的资源和对象。

🟡 内存中,堆和栈的区别是什么?

https://blog.csdn.net/K346K346/article/details/80849966/

栈(Stack)
管理方式:由操作系统自动分配和释放。
用途:主要存放函数的参数值、局部变量值等。
生命周期:栈中数据的生命周期随着函数的执行完成而结束,即当函数执行完毕后,栈中分配的内存会自动释放。
特点:栈内存分配速度快,但分配的内存空间较小,且存储的数据必须是固定大小的。

堆(Heap)
管理方式:一般由程序员分配和释放,如果开发人员不释放,则程序结束时由操作系统回收。在 C#中,托管堆内存由 C#帮助管理,存在垃圾回收机制(GC)。
用途:用于存储动态分配的内存,例如通过 new 关键字创建的对象。
生命周期:堆中的数据在不再使用时需要程序员手动释放,或者在托管环境中由垃圾回收机制自动回收。
特点:堆内存分配灵活,可以存储大块的数据,但分配和回收速度相对较慢。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using System;

class Program
{
static void Main()
{
// 栈上分配的内存
int stackVariable = 10; // 栈上的局部变量

// 堆上分配的内存
int[] heapArray = new int[5]; // 在堆上分配一个数组

// 输出栈变量
Console.WriteLine("栈变量值: " + stackVariable);

// 输出堆变量
for (int i = 0; i < heapArray.Length; i++)
{
heapArray[i] = i * 10;
Console.WriteLine("堆数组元素 " + i + ": " + heapArray[i]);
}
// 栈上分配的内存会在函数执行完毕后自动释放
}
}

内存抖动指什么?如何避免内存抖动

内存抖动指在短时间内大量的对象被创建或者被回收的现象。这种情况下,频繁的对象创建和回收会导致垃圾回收机制频繁运行,进而引起程序卡顿,影响性能。

避免内存抖动的方法:对象池是一种常用的避免内存抖动的方法。它通过预先创建一定数量的对象,并在需要时重复利用这些对象,避免了频繁的对象创建和回收过程。

C#编程中常见的内存管理问题有哪些?

为什么说闭包可能会带来内存泄漏?

C#中的函数是何时被加载到内存中的呢?

🟢 装箱和拆箱是什么?

装箱 - 把栈中内容迁移到堆中去(值类型 转 引用类型)
拆箱 - 把堆中内容迁移到栈中去(引用类型 转 值类型)

1
2
3
int i = 3;
object o = i; //装箱
i = (int)o; //拆箱

值和引用类型在变量赋值时的区别是什么?

值类型赋值时复制值本身,而引用类型赋值时复制引用(指针)。
在变量赋值时,值类型和引用类型的区别主要在于它们的存储方式和行为:

值类型(Value Types)

  • 存储在栈(Stack)上。
  • 赋值时会创建该值的副本。
  • 更改副本不会影响原始变量。
  • 例如:基本数据类型(int, float, bool)和结构体(struct)。

引用类型(Reference Types)

  • 存储在堆(Heap)上,栈上存储指向堆内存的引用。
  • 赋值时会复制引用(地址),而不是对象本身。
  • 更改引用的对象会影响所有指向该对象的引用。
  • 例如:类(class)、数组、字符串(string)。

有两个接口 IA 和 IB,他们中有一个同名方法 Test() 一个类同时继承这两个接口,应该如何处理他们的同名方法?

显示实现接口
IA.Test()
IB.Test()

在 C#中,当一个类同时继承两个接口且接口中包含同名方法时,可以通过显式接口实现来区分这些方法。这种方法允许类实现每个接口的独立版本,从而避免命名冲突。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 定义接口IA,包含Test方法
public interface IA
{
void Test();
}

// 定义接口IB,也包含Test方法
public interface IB
{
void Test();
}

// 类实现两个接口
public class MyClass : IA, IB
{
// 显式实现IA的Test方法
void IA.Test()
{
Console.WriteLine("IA.Test() implementation");
}

// 显式实现IB的Test方法
void IB.Test()
{
Console.WriteLine("IB.Test() implementation");
}

// 类的普通方法
public void Test()
{
Console.WriteLine("MyClass.Test() implementation");
}
}

class Program
{
static void Main(string[] args)
{
MyClass obj = new MyClass();

// 调用类自己的Test方法
obj.Test(); // 输出:MyClass.Test() implementation

// 使用IA接口调用Test方法
IA objA = obj;
objA.Test(); // 输出:IA.Test() implementation

// 使用IB接口调用Test方法
IB objB = obj;
objB.Test(); // 输出:IB.Test() implementation
}
}

请说说你认为 C# 中 == 和 Equals 的区别是什么?

==Equals 是用于比较对象的两种不同方式

== 是一个运算符,用于比较两个操作数。

EqualsObject 类中的虚方法,可以在子类中重写。

Equals 方法一般用于比较两个对象的内容是否相同。默认情况下,Object 类中的 Equals 方法比较对象的引用,即判断两个对象是否是同一个实例。子类可以重写 Equals 方法以实现特定的比较逻辑。
== 运算符在没有运算符重载的情况下,对于引用类型比较对象的引用地址,对于值类型比较对象的值是否相同。

一般情况下,Equals 方法的效率没有 == 运算符高,因为 Equals 方法通常会进行更多的比较操作。尤其是在子类中重写 Equals 方法时,可能会比较对象的各个字段,而 == 运算符通常进行简单的引用或值比较。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 示例1:值类型比较
int a = 5;
int b = 5;
Console.WriteLine(a == b); // 输出:True
Console.WriteLine(a.Equals(b)); // 输出:True

// 示例2:引用类型比较
string str1 = new string("hello");
string str2 = new string("hello");
Console.WriteLine(str1 == str2); // 输出:False,因为==比较的是引用地址
Console.WriteLine(str1.Equals(str2)); // 输出:True,因为Equals比较的是字符串内容

// 示例3:自定义类比较
MyClass obj1 = new MyClass(1, "example");
MyClass obj2 = new MyClass(1, "example");
Console.WriteLine(obj1 == obj2); // 输出:False,因为==比较的是引用地址
Console.WriteLine(obj1.Equals(obj2)); // 输出:True,因为重写的Equals方法比较的是内容

浅拷贝和深拷贝的区别?

可以举例说明

浅拷贝(Shallow Copy)

浅拷贝只复制对象的引用地址,而不复制对象本身。即两个对象指向同一内存地址,修改其中一个对象的值会影响到另一个对象。当一个对象 A 被浅拷贝给另一个对象 B 时,修改对象 A 的内容会影响到对象 B。

深拷贝(Deep Copy)

深拷贝将对象及其所有值都复制一份,创建一个全新的对象。即两个对象互相独立,修改其中一个对象的值不会影响另一个对象。当一个对象 A 被深拷贝给另一个对象 B 时,修改对象 A 的内容不会影响到对象 B。

try 和 finally 块执行顺序

请说出下方代码中
1.A 处和 B 处谁先打印?

2.A、B 出打印的 i 值分别是多少?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static void Main(string[] args)
{
int i = GetInt();
Console.WriteLine($"第A处 i = {i}");
}

static int GetInt()
{
int i = 10;
try
{
return i;
}
finally
{
i = 11;
Console.WriteLine($"第B处 i ={i}");
}
}

B 处先打印,A 处后打印。
A 处 i = 10,B 处 i = 11。
解析
try 执行完后,finally 还是会执行。执行 GetInt 方法时,先 return i,外部得到 i=10。由于方法中有 finally,要执行完 finally 再执行 Main 中代码,B 处打印 11。因为 i 是值类型,所以返回的 i 不收 finally 修改 i 的影响,所以 A 处打印 10.

请问 A、B 两处 i 的值为多少?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Test
{
public int i = 10;
}

static void Main(string[] args)
{
Test test = GetObj();
Console.WriteLine($"第A处 i = {t.i}");
}

static Test GetObj()
{
Test t = new Test();
try
{
return t;
}
finally
{
t.i = 11;
Console.WriteLine($"第B处 i = {t.i}");
}
}

A、B 两处 i 的值都为 11。

先打印 B,因为有 finally 先执行。finally 中改了 test 对象的值,t 是引用类型的,外部的 t 指向的地址和内部相同,所以改了内部外部也会打印 11。

泛型的约束有哪几种?

C#中泛型允许我们编写更灵活和可重用的代码。为了限制泛型参数的类型,C#提供了多种约束类型。以下是 C#中泛型的几种常见约束:

  • 值类型约束:T:struct 要求泛型参数必须是值类型
1
2
3
4
public class Example<T> where T : struct
{
// T 必须是值类型
}
  • 引用类型约束:T:class 要求泛型参数必须是引用类型
1
2
3
4
public class Example<T> where T : class
{
// T 必须是引用类型
}
  • 公共无参构造函数约束:T:new() 要求泛型参数必须有一个公共的无参数构造函数
1
2
3
4
5
6
7
public class Example<T> where T : new()
{
public T CreateInstance()
{
return new T();
}
}

类约束:T:类名 要求泛型参数必须是指定的类或其派生类

1
2
3
4
public class Example<T> where T : MyClass
{
// T 必须是 MyClass 或其派生类
}

接口约束:T:接口名 要求泛型参数必须实现指定的接口

1
2
3
4
public class Example<T> where T : IMyInterface
{
// T 必须实现 IMyInterface
}

泛型参数的约束:T:U 要求泛型参数必须是另一个泛型参数的类型或其派生类

1
2
3
4
public class Example<T, U> where T : U
{
// T 必须是 U 或其派生类
}

🟡 什么是闭包?

闭包是指有权访问另一个函数作用域中的变量的函数。换句话说,闭包能够“记住”它创建时所处的环境,因此可以访问和操作该环境中的变量。通常,闭包是在一个函数内部创建的另一个函数。

闭包的定义
在 C#中,闭包通常是通过匿名方法或 Lambda 表达式实现的。闭包可以捕获并保留它在定义时所处的作用域中的变量,甚至在这个作用域已经销毁之后,依然可以访问这些变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using System;

class Program
{
static void Main()
{
// 创建一个闭包
Func<int> closure = CreateClosure();

// 调用闭包函数
Console.WriteLine(closure()); // 输出:1
Console.WriteLine(closure()); // 输出:2
Console.WriteLine(closure()); // 输出:3
}

static Func<int> CreateClosure()
{
int counter = 0; // 这是闭包所捕获的外部变量

// 返回一个匿名函数,这个匿名函数就是一个闭包
return () =>
{
counter++; // 匿名函数可以访问和修改外部变量
return counter;
};
}
}

函数中对不同修饰符的变量进行运算

请问 A、B、C 三处打印结果分别为多少?为什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
static unsafe void Main(string[] args)
{
int test1Value = 10;
Test1(test1Value);
Console.WriteLine($"A:{test1Value}");

int test2Value = 10;
Test2(&test2Value);
Console.WriteLine($"B:{test2Value}");

int test3Value = 10;
Test3(ref test3Value);
Console.WriteLine($"C:{test3Value}");
Console.ReadKey();
}

private static void Test1(int value)
{
value += 90;
}

private unsafe static void Test2(int* value)
{
*value += 90;
}

private static void Test3(ref int value)
{
value += 90;
}

A 是 10,B 和 C 为 100。

在 Test1 方法中,参数 value 是 按值传递 的。这意味着在函数内部,value 是在栈上重新开辟的空间,将传入参数的值拷贝到了该空间中,与传入参数 test1Value 没有关系。因此,修改 value 不会影响到 test1Value,所以 A 处的打印结果是 10。

1
2
3
4
static void Test1(int value)
{
value += 90;
}

在 Test2 方法中,参数 value 使用指针关键字,表示按指针传递。传入的是 test2Value 的地址。在函数内部,对 value 的修改会影响到外部的 test2Value,所以 B 处的打印结果是 100。

1
2
3
4
static unsafe void Test2(int* value)
{
*value += 90;
}

在 Test3 方法中,参数 value 使用 ref 关键字,也表示按引用传递。不同于 ref 关键字,ref 参数在进入方法时不需要被初始化,但在方法内部必须被赋值。此处,value 的修改也会影响到外部的 test3Value,所以 C 处的打印结果是 100。

1
2
3
4
static void Test3(ref int value)
{
value = 100; // 必须赋值
}

C#重载运算符和重写 Equals 方法的意义

C#重载运算符,重载 == 和 != 以及万物之父 Object 基类中的虚方法 virtual bool Equals(Object obj) 对于我们的意义是什么?

重载运算符 == 和 != :可以自定义对象相等的判断逻辑。默认情况下,== 和 != 操作符用于比较两个对象的引用地址,但是通过重载它们,我们可以改变比较的方式,使其根据对象的属性或字段来判断是否相等。这使得我们可以根据自己的需求定义对象相等的规则。

重写 Equals 方法是为了提供对象相等的自定义逻辑。默认情况下,Object 类中的 Equals 方法用于比较两个对象的引用地址,即判断它们是否指向同一个内存地址。在子类中重写 Equals 方法就可以根据对象的属性或字段来判断它们是否相等。通过重写 Equals 方法,我们可以实现对象的值比较,而不仅仅是引用比较。

C# 空字符串表示

请说明字符串中三者的区别

1
2
3
string str = null
string str = ""
string str = string.Empty

这三种方式都可以用来表示空字符串,但有一些细微的区别:

string str = null:这种方式表示字符串变量 str 没有引用任何对象,即它在堆内存中没有分配任何内存地址,它的值是 null。尝试对其进行字符串操作可能会引发空引用异常。

**string str = “”**:这种方式表示字符串变量 str 引用了一个空字符串对象,在堆内存中分配了空间,其中存储的是空字符串。它与 string.Empty 效果相同。

string str = string.Empty:string.Empty 是一个静态只读字段,表示一个空字符串。与 “” 相比,它更具有语义上的清晰度,可以明确表达代码意图。它在内存中只有一个实例,多个使用该字段的地方都会共享同一个实例,不会重复分配内存空间。

string 和 StringBuilder 选择

每次对 string 进行修改或拼接操作时,都会在内存中创建新的字符串对象,而原始字符串对象会被标记为垃圾,等待垃圾回收器回收。这意味着频繁对 string 进行修改或拼接操作会产生大量的垃圾对象,可能会导致性能下降和内存泄漏问题。

StringBuilder 是一个可变的字符串,它允许我们对字符串进行频繁的修改和拼接操作而不产生垃圾。StringBuilder 内部维护了一个字符数组,当需要修改字符串时,它会在原始字符数组的基础上进行修改,而不是创建新的字符串对象。另外,StringBuilder 会自动调整内部字符数组的大小,以容纳更多的字符,因此不需要手动管理字符串的容量。当我们需要对字符串进行频繁的修改或拼接操作时,推荐使用 StringBuilder。它能够有效地提高性能,减少内存占用,并避免产生大量的垃圾对象。

数组和链表的区别是什么?

  • 存储结构:数组在内存中是连续存储,链表在内存中是非连续存储。
  • 访问效率:数组通过下标访问效率高,链表需要遍历效率低。
  • 插入删除效率:数组插入删除需要移动元素,效率低;链表只需修改指针,效率高。
  • 越界问题:数组有越界风险,链表无越界风险。

C# 中的值和引用类型及特殊引用类型

请问最终的打印结果是什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public struct Record
{
public int id;
public string name;
public int[] children;
}

public void DoSomething(Record record)
{
record.id = 6;
record.name = "Bob";
record.children[0] = 7;
}

var record = new Record();
record.name = "Alice";
record.children = new int[] { 1, 2, 3 };
DoSomething(record);
Debug.Log(string.Format("{0}-{1}-{2}", record.id, record.name, record.children[0]));

0-Alice-7

结构体是在栈上,是拷贝副本而不是引用地址。string 是特殊的引用类型,不具备你变我也变的特性。数组是引用类型,会跟着改变。

C#中如何让自定义容器类能够使用 for 循环遍历?

通过在类中实现索引器实现。(通过 类对象[索引] 的形式遍历)

定义索引器:在自定义容器类中定义一个索引器,用于根据索引访问容器中的元素。
实现必要的方法和属性:通常需要实现 Count 属性来返回容器中元素的数量,以便 for 循环使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
using System;

public class CustomContainer<T>
{
private T[] items;
private int count;

public CustomContainer(int size)
{
items = new T[size];
count = size;
}

// 索引器的实现
public T this[int index]
{
get
{
if (index < 0 || index >= count)
{
throw new IndexOutOfRangeException("索引超出范围");
}
return items[index];
}
set
{
if (index < 0 || index >= count)
{
throw new IndexOutOfRangeException("索引超出范围");
}
items[index] = value;
}
}

// 容器中元素的数量
public int Count
{
get { return count; }
}
}

class Program
{
static void Main()
{
CustomContainer<int> container = new CustomContainer<int>(5);

// 添加元素到容器中
for (int i = 0; i < container.Count; i++)
{
container[i] = i * 10;
}

// 使用 for 循环遍历容器
for (int i = 0; i < container.Count; i++)
{
Console.WriteLine(container[i]);
}
}
}

🟡 C#中如何让自定义容器类能够使用 foreach 循环遍历?

通过为自定义容器类实现迭代器,可以使其能够使用 foreach 循环遍历。
语法糖方式
利用 yield return 语法糖,只需实现 GetEnumerator 方法即可完成迭代器的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
using System;
using System.Collections.Generic;

public class MyContainer<T> : IEnumerable<T>
{
private T[] items;
private int count;

public MyContainer(int capacity)
{
items = new T[capacity];
count = 0;
}

public void Add(T item)
{
if (count < items.Length)
{
items[count++] = item;
}
}

public IEnumerator<T> GetEnumerator()
{
for (int i = 0; i < count; i++)
{
yield return items[i];
}
}

IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}

🟡 C#中接口的作用

接口在 C# 中用于建立行为的继承关系,而不是对象的继承关系。它提供了一种抽象的方式来定义一组方法、属性、事件等,不包含实现细节。

  • 行为抽象:接口定义了一组行为(方法、属性等),任何实现这个接口的类都必须提供这些行为的具体实现。这使得我们可以通过接口来抽象不同对象的相同行为,而不关心这些对象的具体实现细节。
  • 多态性:接口允许不同类实现相同的接口,从而实现多态性。可以用接口类型的变量来引用不同的实现类对象,通过调用接口方法来实现不同对象的多态行为。这使得代码更加灵活和可扩展。
  • 解耦合:接口通过定义行为契约,将使用接口的代码与接口实现的代码解耦合。这样,可以在不改变使用接口的代码的情况下,替换接口的具体实现。这有助于提高代码的可维护性和可测试性。
  • 行为整合:当不同对象具有相同行为时,可以利用接口对这些对象的行为进行整合。接口可以看作是一种协议,保证不同对象实现一致的行为。

例如,不同类型的动物类都可以实现一个 IMovable 接口,统一定义移动行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 定义一个接口
public interface IMovable
{
void Move();
}

// 实现接口的类1
public class Car : IMovable
{
public void Move()
{
Console.WriteLine("Car is moving");
}
}

// 实现接口的类2
public class Person : IMovable
{
public void Move()
{
Console.WriteLine("Person is walking");
}
}

class Program
{
static void Main(string[] args)
{
// 使用接口类型的变量来引用不同的实现类对象
IMovable movable1 = new Car();
IMovable movable2 = new Person();

// 调用接口方法
movable1.Move(); // 输出: Car is moving
movable2.Move(); // 输出: Person is walking
}
}

请问这三行代码,运行后,在堆上会分配几个空间

1
2
3
4
5
6
static void Main(string[] args)
{
string str = "123";
string str2 = "123";
string str3 = "1234";
}

运行这三行代码后,在堆上会分配 2 个空间

“123” 一个房间。
“1234” 一个房间。

在 C# 中,字符串是不可变的。当你将字符串赋值或重新分配时,会创建新的字符串对象并分配新的内存空间。因此,在这三行代码中,两个不同的字符串 “123” 和 “1234” 会分别在堆上创建新的内存空间。

C#中如何让一个类不能再被其他类所继承

使用密封关键字sealed修饰该类。

1
2
3
4
5
public sealed class MyClass
{
// 类的实现
}

C#中使用泛型的好处

  • 提升代码复用率:泛型允许为不同类型对象的相同行为进行通用处理,从而减少重复代码,提高代码的复用性和可维护性。
  • 提升性能:泛型避免了值类型的装箱和拆箱操作,从而提高了程序的运行性能。装箱和拆箱是将值类型转换为引用类型(装箱)和从引用类型转换为值类型(拆箱)的过程,这些操作会带来额外的性能开销。

C#中元组的作用

  • 多返回值:可以在不用写数据结构类的情况下,利用元组处理方法的多返回值。
  • 临时数据集合:适用于临时存储和传递多个相关的数据,而不需要定义专门的数据结构。

元组提供了一种简洁的方式来组合多个值,对于处理临时数据和简化代码具有很大的帮助。

1
2
3
4
5
6
7
8
9
10
(double, int) t1 = (4.5, 3);
Console.WriteLine($"Tuple with elements {t1.Item1} and {t1.Item2}.");
//使用Item + 序号访问
// Output:
// Tuple with elements 4.5 and 3.

(double Sum, int Count) t2 = (4.5, 3);
Console.WriteLine($"Sum of {t2.Count} elements is {t2.Sum}.");
// Output:
// Sum of 3 elements is 4.5.

🟡 请说明 Thread、ThreadPool、Task 分别是什么?并简单说明彼此的区别

  • Thread:线程,可以使用它开启线程处理复杂逻辑,避免主线程卡顿。每次创建新线程时,都需要分配资源,开销较大。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Thread t;

t = new Thread(() =>
{
while (true)
{
print("123");
Thread.Sleep(1000);
//print(transform.position);//报错 不能使用主线程相关类
}
});

t.Start();

//不停止线程的话开启的线程会和Unity编辑器进程共生死
private void OnDestroy()
{
t.Abort();
}

  • ThreadPool:线程池,C#为线程实现的缓存池,主要用于减少线程的创建和销毁,减少 GC 触发的频率,提高性能。管理线程的集合,用于执行短时间的并行操作。通过重用线程来减少开销,避免频繁创建和销毁线程。适合大量小任务的场景,不适合长时间运行的任务。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//ThreadPool类中的QueueUserWorkItem方法 将方法排入队列以便执行,当线程池中线程变得可用时执行
ThreadPool.QueueUserWorkItem((obj) =>
{
print(obj);//NULL
print("开启了一个线程");
});

//第一个参数可以设置obj对象
ThreadPool.QueueUserWorkItem((obj) =>
{
print(obj);//666
print("开启了一个线程");
}, "666");

//线程的执行顺序不可控 比如如下循环将线程入队 可能不会按顺序打印
for (int i = 0; i < 10; i++)
{
ThreadPool.QueueUserWorkItem((obj) =>
{
print("第" + obj + "个任务");
}, i);
}

Task:任务,基于线程池的优化,让我们可以更方便地控制和管理线程。基于 ThreadPool 实现,提供更高层次的抽象。支持异步编程模型,可以方便地处理并行操作和等待任务完成。提供了更好的错误处理和取消任务的机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using System;
using System.Threading.Tasks;

public class TaskExample
{
public static void Main()
{
Task task = Task.Run(() => DoWork());
task.Wait();
}

public static void DoWork()
{
Console.WriteLine("Task is working");
}
}

如果我们想为 Unity 中的 Transform 类添加一个自定义的方法,应该如何处理?

使用 C#的拓展方法(Extension Methods)来实现。

拓展方法是 C#的一种语法特性,允许开发者在不修改原始类定义的情况下,向现有类添加新的方法。

以下是添加自定义方法的一般步骤:

创建一个静态类,并在类中定义静态方法。这个类应该是静态的,且不可继承。
在方法的第一个参数前加上 this 关键字,并指定要扩展的类的类型。这样编译器就知道这个方法是一个拓展方法。
在方法内部实现自定义的逻辑。

1
2
3
4
5
6
7
8
9
10
11
using UnityEngine;

public static class TransformExtensions
{
// 自定义方法,用于输出Transform的位置信息
public static void PrintPosition(this Transform transform)
{
Debug.Log($"Position: {transform.position}");
}
}

使用这个拓展方法 :

1
2
3
4
5
6
7
8
9
10
11
12
13
using UnityEngine;

public class Example : MonoBehaviour
{
void Start()
{
Transform playerTransform = GetComponent<Transform>();

// 调用自定义的拓展方法
playerTransform.PrintPosition();
}
}

using 关键字的两个作用

使用关键字 using 在 C# 中有以下两个主要作用:

  • 引入命名空间:
    使用 using 关键字可以将命名空间引入当前代码文件,使得其中定义的类型和成员可以直接使用,而无需使用完整的命名空间路径来引用。

  • 安全使用引用对象:
    在 C# 中,using 关键字还可以用于安全地使用引用对象。在 C# 中,using 关键字还可用于安全地使用引用对象。使用 using 关键字创建的代码块可以确保在代码块结束时释放资源,即使在代码块内发生异常,也会执行资源释放操作,从而避免资源泄漏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
using System;

namespace UsingExample
{
class Program
{
static void Main(string[] args)
{
// 示例1:引入命名空间
using System.IO;
string[] files = Directory.GetFiles(@"C:\");
foreach (string file in files)
{
Console.WriteLine(file);
}

// 示例2:安全使用引用对象
using (StreamReader reader = new StreamReader("example.txt"))
{
Console.WriteLine(reader.ReadToEnd());
}
}
}
}

🟡 C#中 Dictionary 相同键对应多个值

如果想要一个键对应多个值如何处理?

在 C#中,Dictionary 是一种键值对集合,每个键必须是唯一的,因此不支持相同键存储。如果想要一个键对应多个值,可以使用 Dictionary<TKey, List> 等数据结构来处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
using System;
using System.Collections.Generic;

class Program
{
static void Main()
{
// 创建一个 Dictionary,键为 string 类型,值为 List<int> 类型
Dictionary<string, List<int>> multiValueDict = new Dictionary<string, List<int>>();

// 向 Dictionary 中添加键值对
AddKeyValuePair(multiValueDict, "key1", 1);
AddKeyValuePair(multiValueDict, "key1", 2);
AddKeyValuePair(multiValueDict, "key2", 3);
AddKeyValuePair(multiValueDict, "key2", 4);

// 打印所有键值对
foreach (var pair in multiValueDict)
{
Console.Write($"{pair.Key}: ");
foreach (var value in pair.Value)
{
Console.Write($"{value} ");
}
Console.WriteLine();
}
}

// 向 Dictionary 中添加键值对,如果键已存在,则将值添加到对应的 List 中
static void AddKeyValuePair(Dictionary<string, List<int>> dict, string key, int value)
{
if (!dict.ContainsKey(key))
{
dict[key] = new List<int>();
}
dict[key].Add(value);
}
}

委托和闭包中的变量捕获

请问下面代码的最终打印结果是什么?为什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using System;
using System.Collections.Generic;

static void Main(string[] args)
{
Action action = null;

for (int i = 0; i < 10; i++)
{
action += () =>
{
Console.WriteLine(i);
}
}
action();
}

上述代码的最终打印结果是全都是 10。

这是因为在 C# 中,闭包会捕获外部作用域的变量。在循环体内,委托 actionList[i] 中的 i 是对外部变量 i 的引用,而不是值的拷贝。在循环结束后,i 的值已经变为了 10。因此,无论在什么时候调用委托,都会打印出 10。

🔴 上题中的代码,如果我们希望打印出 0~9,应该如何修改代码?

buff 系统中,如何用一个 byte,记录多种 buff 状态标识

在 buff 系统中,我们可以使用一个 byte 来记录多种 buff 状态标识。由于一个 byte 有 8 位,我们可以让每一位代表一种状态,其中 0 代表无,1 代表有。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 定义一个byte类型的变量来记录buff状态
byte buffType = 0;

// 状态标识位示例:
// 0000 0000: 初始状态,无buff
// 0000 0001: 中毒 buff
// 0000 0010: 灼烧 buff
// 0000 0100: 回复 buff

// 添加状态时,进行 或 ( | ) 运算
buffType |= 0x02; // 添加灼烧 buff,结果为 0000 0010

// 添加多个状态示例:
buffType |= 0x01; // 添加中毒 buff,结果为 0000 0011

// 移除状态时,进行 异或 ( ^ ) 运算
buffType ^= 0x01; // 移除中毒 buff,结果为 0000 0010

文件中文本信息乱码的原因

文件中保存了文本信息,但是打开后却是乱码,一般是什么原因造成的?

文件中文本信息乱码的原因是因为序列化和反序列化字符串时使用的编码格式不统一。

当文本信息在进行序列化时,如果使用了不同的编码格式,比如在写入文件时使用 UTF-8 编码,但在读取文件时使用了 ANSI 编码,就会导致读取出来的文本信息是乱码的情况。

为了避免文件中文本信息乱码的问题,应该在序列化和反序列化字符串时统一使用相同的编码格式。

C#中 new 关键字的作用(至少说出 3 种)

  • 创建新对象:最基本的用途是使用 new 关键字来实例化一个类,创建新的对象。
1
MyClass obj = new MyClass();
  • 方法隐藏:当在子类中声明一个与父类方法名称相同的方法时,可以使用 new 关键字来隐藏父类方法,使子类的方法覆盖父类的方法。这种方法称为方法隐藏。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Parent
{
public void Print()
{
Console.WriteLine("Parent class method");
}
}

class Child : Parent
{
public new void Print()
{
Console.WriteLine("Child class method");
}
}
  • 泛型约束中使用 new 关键词:在泛型约束中使用 new 关键字表示需要无参构造函数的类型参数。这种约束要求泛型类型参数必须具有公共的无参数构造函数。
1
2
3
4
public class MyClass<T> where T : new()
{
// T必须具有无参数构造函数
}

同步方法和异步方法的区别是什么?

异步编程是什么意思?对于我们来说,什么时候需要使用异步编程?(至少说出 3 种)

同步方法:当一个方法被调用时,调用者需要等待该方法执行完毕后返回才能继续执行。

异步方法:当一个方法被调用时立即返回,并获取一个线程执行该方法内部的逻辑,调用者不用等待该方法执行完毕。

异步编程的意义:异步编程是指在日常开发中,将一些不需要立即得到结果且耗时的逻辑设置为异步执行的编程方式。它的意义在于提高程序的运行效率,避免由于复杂逻辑带来的线程阻塞,从而提升系统的响应性和性能。

什么时候需要使用异步编程:异步编程的主要目的是提高程序的并发性和性能,使得程序能够更高效地利用系统资源,处理各种耗时操作,从而提升整体的系统响应速度和用户体验。

  • 复杂逻辑计算:比如寻路算法等复杂计算任务,这些任务可能会耗费较长时间,使用异步编程可以让主线程不被阻塞,保持系统的响应性。

  • 网络通信:在进行网络下载、网络通讯等操作时,由于网络请求可能会有一定的延迟,使用异步编程可以避免阻塞主线程,提高系统的并发性和网络通信效率。

  • 资源加载:在进行大量资源加载时,如加载大型纹理、模型等资源,这些操作可能会消耗较长的时间,使用异步加载可以让主线程保持流畅,提升用户体验。

🟡 回调函数指什么?一般在什么时候使用?(至少说出 3 种使用场景)

在程序设计中,回调函数指的是将一个函数作为参数传递给另一个函数,并在另一个函数执行完毕后被调用的函数。在 C#中,回调函数一般以委托的形式出现。

  • 异步编程:当需要执行一些异步逻辑,并在异步逻辑执行完毕后执行某些操作时,可以使用回调函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using System;
using System.Threading.Tasks;

class Program
{
static async Task Main(string[] args)
{
await DoAsyncWork(() =>
{
Console.WriteLine("异步逻辑执行完毕,执行回调函数。");
});
}

static async Task DoAsyncWork(Action callback)
{
// 异步逻辑执行完毕后调用回调函数
await Task.Delay(1000);
callback();
}
}
  • 事件中心:在事件驱动的编程模型中,可以使用回调函数来响应事件的触发。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
using System;

class EventPublisher
{
public event Action<string> OnEventTriggered;

public void TriggerEvent()
{
OnEventTriggered?.Invoke("事件触发了!");
}
}

class Program
{
static void Main(string[] args)
{
var publisher = new EventPublisher();
publisher.OnEventTriggered += (message) =>
{
Console.WriteLine(message);
};

publisher.TriggerEvent();
}
}
  • UI 界面中的控件逻辑回调:在 UI 界面中,当用户触发某些操作(如按钮点击)时,可以使用回调函数来执行相应的逻辑。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
using UnityEngine;
using UnityEngine.UI;

public class ButtonClickHandler : MonoBehaviour
{
void Start()
{
Button button = GetComponent<Button>();
button.onClick.AddListener(() =>
{
Debug.Log("按钮被点击了!");
});
}
}

如何用一个 int 变量,记录 32 种状态?(注意:状态可以并存)

每一位代表一个状态,1 表示存在,0 表示不存在。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
using System;

class Program
{
static void Main()
{
// 定义一个状态变量,初始值为0
int status = 0;

// 设置第 1 种状态为存在
status |= (1 << 0); // 将第 1 位设为 1

// 设置第 5 种状态为存在
status |= (1 << 4); // 将第 5 位设为 1

// 判断第 1 种状态是否存在
bool isFirstStateExist = (status & (1 << 0)) != 0; // 检查第 1 位是否为 1

// 判断第 2 种状态是否存在
bool isSecondStateExist = (status & (1 << 1)) != 0; // 检查第 2 位是否为 1

// 输出结果
Console.WriteLine("第 1 种状态是否存在:" + isFirstStateExist); // 输出: 第 1 种状态是否存在:True
Console.WriteLine("第 2 种状态是否存在:" + isSecondStateExist); // 输出: 第 2 种状态是否存在:False
}
}

CSharp 中常用的接口 IDispose 的作用

IDispose 接口是 C# 中用于手动释放资源的机制。通过显式调用 Dispose() 方法来实现资源的释放,避免资源泄漏和浪费。它允许对象在不再需要时显式地释放资源,而不依赖于垃圾回收器的自动内存管理。

CSharp 中垃圾回收机制和 IDispose 接口的关系

CSharp 有垃圾回收机制,把一个对象设置为空的时候,系统会帮我们回收,它和接口 IDispose 有什么关系吗?为什么会存在两个做析构事情的东西呢?

C#中的垃圾回收机制,只会回收托管堆上分配的对象。对于非托管资源以及其它需要显示释放的资源,垃圾回收是无法自动处理的,因为这些资源不属于托管堆,因此垃圾回收器无法自动识别和回收。这种情况下我们就需要显式地手动释放这些资源了。

而 IDisposable 接口就提供了一种通用的机制来进行资源清理,主要用于释放非托管资源。

非托管资源可以包括但不限于:

  • 文件句柄,在操作系统中打开的文件等
  • 数据库链接,与数据库服务器建立的链接
  • 网络链接,例如 Socket 的连接对象
  • 在 unsafe 关键词中使用的指针等

通过实现 IDisposable 接口,我们可以在对象被销毁时执行资源的释放工作,确保非托管资源得到正确地释放,从而避免资源泄露和内存泄漏的问题。

C#中哪些变量类型是值类型(至少说出 13 种),哪些是引用该类型(至少说出 5 种)

值类型直接包含其数据,存储在栈中

int byte ulong char
float short ushort bool
double long sbyte 自定义结构体(struct)
uint decimal

引用类型存储在堆中,并通过引用(即指针)访问其数据

  • 自定义类(class)
  • 数组(array)
  • 字符串(string)
  • 委托(delegate)
  • 接口(interface)
  • Object(万物之父)

自定义类和结构体成员变量的存储位置

  1. 在自定义类中声明的成员变量,类型为 int,该 int 变量存储在栈上还是堆上?
  2. 在自定义结构体中声明的成员变量,类型为 string,该 string 变量存储在栈上还是堆上?

一、堆上
在自定义类中声明的成员变量,无论其类型如何,都会存储在堆上。类是引用类型,其实例存储在堆上,成员变量也存储在该实例所指向的内存区域(堆上)。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MyClass
{
public int myInt; // 存储在堆上
}

public class Example
{
public void Test()
{
MyClass obj = new MyClass();
obj.myInt = 10; // myInt 存储在堆上
}
}

二、堆上
在结构体变量中的引用类型成员实际上会存储在堆上。虽然结构体本身是值类型,存储在栈上,但如果结构体的成员包含引用类型,那么引用类型的对象会存储在堆上,而结构体的实例内部会包含对这些堆上对象的引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
public struct MyStruct
{
public string myString; // myString 的内容存储在堆上
}

public class Example
{
public void Test()
{
MyStruct myStruct = new MyStruct();
myStruct.myString = "Hello"; // myString 的实际字符串内容存储在堆上
}
}

接口和抽象类使用上的区别

C#中在什么情况下会选择使用接口,什么情况下会选择使用抽象类?

接口(Interface): 不同对象的共同行为,当你需要定义一组不相关的类具有相同的行为时,使用接口是一个好的选择。接口可以提供一个公共的行为规范,但不涉及具体实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 示例:定义一个接口
public interface IFlyable
{
void Fly();
}

// 不同类实现相同接口
public class Bird : IFlyable
{
public void Fly()
{
// 实现飞行行为
}
}

public class Airplane : IFlyable
{
public void Fly()
{
// 实现飞行行为
}
}

需要多继承时:

C#不支持类的多继承,但支持接口的多继承。如果一个类需要继承多个行为规范,可以使用接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public interface IWalkable
{
void Walk();
}

public interface ISwimmable
{
void Swim();
}

//两栖动物 继承两个接口
public class Amphibian : IWalkable, ISwimmable
{
public void Walk()
{
// 实现行走行为
}

public void Swim()
{
// 实现游泳行为
}

}

抽象类(Abstract Class)
同类对象的共同行为:

当你需要定义一组相关的类共享相同的行为和状态时,使用抽象类。抽象类可以提供公共的实现和字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 示例:定义一个抽象类
public abstract class Animal
{
protected string name;

public Animal(string name)
{
this.name = name;
}

public abstract void MakeSound();

}

public class Dog : Animal
{
public Dog(string name) : base(name) { }

public override void MakeSound()
{
// 实现狗叫声
}

}

public class Cat : Animal
{
public Cat(string name) : base(name) { }

public override void MakeSound()
{
// 实现猫叫声
}

}

共享成员变量:当你需要定义共享的成员变量或方法实现时,可以使用抽象类。抽象类允许定义具体的成员变量和部分实现。

🟡 C#中托管内存和非托管内存

托管内存是由.NET 运行时(CLR,Common Language Runtime)进行管理的内存。C#中大部分对象都是托管内存,它们的内存分配、回收和资源管理都由 CLR 负责。CLR 提供了自动垃圾回收机制(GC),可以自动检测不再使用的对象并释放它们占用的内存,从而避免了内存泄漏问题。

我们平时声明的引用类型的变量都属于托管内存:

1
2
3
4
5
6
7
// 这是一个托管内存的示例
public class Example
{
public string Name { get; set; }
}

Example example = new Example();

非托管内存是由应用程序自己负责管理的内存,通常是通过调用本机 API 或与外部系统进行交互时使用的。非托管内存不受 CLR 的管理,这意味着它不会受到垃圾回收的影响。开发人员需要自己负责内存的分配和释放,否则可能会导致内存泄漏或者访问无效内存的问题。

我们平时在 unsafe 语句块中声明的指针成员,数据库链接对象,Socket 通讯对象,文件流等对象都存在非托管内存,需要我们自己释放:

1
2
3
4
5
6
7
8
9
// 这是一个非托管内存的示例
public unsafe class UnsafeExample
{
public void UsePointer()
{
int\* p = stackalloc int[10];
// 使用指针进行操作
}
}

对于非托管资源,我们需要使用如 IDisposable 接口来手动释放资源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 这是一个非托管内存的示例,使用 IDisposable 接口
public class UnmanagedResource : IDisposable
{
private IntPtr unmanagedPointer;

public UnmanagedResource()
{
// 分配非托管内存
unmanagedPointer = Marshal.AllocHGlobal(100);
}

public void Dispose()
{
// 释放非托管内存
if (unmanagedPointer != IntPtr.Zero)
{
Marshal.FreeHGlobal(unmanagedPointer);
unmanagedPointer = IntPtr.Zero;
}
}

}
// 使用非托管资源
UnmanagedResource resource = new UnmanagedResource();
resource.Dispose();