C# 语言笔记 ——————————————

本文档结合《C#语言入门讲解》和《C# 12.0 本质论》提炼C#相关知识 课堂笔记: https://www.yuque.com/yuejiangliu/dotnet/timothy-csharp-001

前言/基础概念

心法

  • 不要怕见到自己看不懂的东西
  • 要跟着操作,一遍遍练习,熟悉手里的东西

程序

程序的编写流程: 编辑 编译 调试 发布

编程的学习路径

  • 纵向:语言、类库、框架
  • 横向:语言的各种应用,命令行、桌面应用、移动端、Web、游戏…

项目

Solution 与 Project

  • Solution是总的解决方案
  • Project是解决具体的某个问题
    • Console
    • WPF
    • Windows Forms

各种 Hello World

  • Console Application
  • WPF
  • Windows Forms

基本元素

  • 关键字
  • 操作符(逻辑与或非等等)
  • 标识符(名字)
    • 命名方法
      • Pascal 法(C#常用)
      • 驼峰法 thisIsAVariable(Java常用)
  • 标点符号
  • 文本(字面值)
    • 整数:int, long(3L, 64bit)
    • 实数:float(3f), double(3D)
    • 字符:单引号,一个字符
    • 字符串:双引号
    • 布尔:
    • 空 Null
  • 注释与空白
    • 单行 //
    • 多行 /**/
    • VSCode 块注释快捷键:Shift + Alt + A(Windows/Linux),Option + Shift + A(Mac)
    • VSCode 格式化快捷键:Shift + Alt + F(Windows/Linux),Option + Shift + F(Mac)

03_类与命名空间

  • using使用的都是命名空间,命名空间是为了避免同名函数冲突
  • 冲突的时候使用全量命名
  • Assembly 类库(DLL,Dynamic Link Library)
    • 提供方法
  • Microsoft Help Viewer,MSDN 文档使用:引用带有窗口的类库,让程序显示出窗口
  • NuGet 管理器可以自动引用相关类库
  • 依赖关系
    • UML图

06&07_类型变量与对象

07 特别有用,认识了C#中很重要的一些概念,同时也是面试常问的问题,C# 语言的五大基本类型,装箱拆箱,变量在内存中的存储等等。

变量 变量就是以变量名所对应的内存地址为起点,以其数据类型所要求的存储空间为长度的一块内存区域。

变量的类型 静态变量、实例变量、数组变量、值参数、引用参数、输出形参、局部变量(局部变量都分配在栈上)

class Player
{
	public static int PlayerCount;  // 静态变量
 
	public int health = 100;        // 实例变量 / 成员变量 / 字段(Instance Variable / Field)
 
	public static void Attack(
		int monsterId,              // 值参数(Value Parameter:传值复制)
		ref int playerId,           // 引用参数(ref parameter:传引用,可修改原值)
		out bool attackSucess)      // 输出形参(out parameter:必须在方法内赋值)
	{
		int attackTimes = 3;        // 局部变量
		attackSucess = true;
 
		int[] numbers = new int[3]; // 数组元素
	}
}

C# 的五大数据类型 类、接口、委托;结构体、枚举;前三个是引用类型,后两个是值类型。

值类型分配在栈上,引用类型分配在堆上。栈只能由系统来操作,软件只能对堆进行操作,进而引出了装箱拆箱的概念。

在 Visual Studio 中,安装了离线的 Help Viewer,可以通过给 Help.F1Help 命令分配快捷键,进而查看关键字、一些数据类型的定义。

值类型的变量 byte, sbyte, short, ushort, int, bool… 引用类型的变量就是类、接口、委托,引用类型变量里面存储的数据是“对象的内存地址“

装箱和拆箱 装箱就是引用类型变量存储值类型变量的值,比如 Object obj 来存储 int x 的值,由于 x 分配在栈上且栈由系统管理,所以 obj 不能直接存储 x 在栈上的地址,只能把 x 的值复制到堆上,并且在 obj 的内存中存储堆上的内存地址,其中,把值从栈复制到堆上的操作叫做装箱。

拆箱就是把引用类型的值再放回栈上。比如 int y = (int)obj,此时 y 是值类型存储在栈上,且直接存储值,所以需要通过 obj 获取到堆上的内存地址,再把堆上的内存中的值复制到 y 所对应的栈内存中,这个过程叫做拆箱。

我的理解是,“箱子”代表引用的堆上的地址,相当于引用类型封装了一层,把原本在栈上的值通过引用地址包装到堆中,叫做装箱;通过“箱子”找到堆上对应的值,再搬回栈中,避免了通过地址再访问,叫做拆箱,拆掉了这一层引用。

10&11_操作符

基本操作符

操作符的本质:函数的简记法

internal class CustomOperator
{
	public void Main()
	{
		GymPeople ley = new GymPeople("Ley", 5);
		GymPeople csj = new GymPeople("Csj", 4);
 
		Console.WriteLine($"{ley.Name} & {csj.Name} made a fantasic Love which is worth a [{ley*csj}] stars!");
	}
}
 
class GymPeople
{
	public string Name;
	public int SexyLevel;
	public GymPeople(string name, int sexyLevel)
	{
		this.Name = name;
		this.SexyLevel = sexyLevel;
	}
 
	//public int MakeLove(GymPeople p1, GymPeople p2)
	public static int operator * (GymPeople p1, GymPeople p2)
	{
		var rnd = new Random();
		return rnd.Next(0, p1.SexyLevel + p2.SexyLevel);
	}
}

成员访问操作符:. 操作符 委托中,访问类的成员方法,不用写圆括号:

class Program{
	public static void Main(string[] args){
		Test t = new Test();
		// 这里不用写圆括号,此时这里相当于通过点操作符访问了 t 的成员 PrintHello
		Action myAction = new Action(t.PrintHello);
		myAction();
	}
}
 
class Test{
	public void PrintHello(){
		Console.WriteLine("hello");
	}
}

Metadata 元数据 可以通过 typeof 操作符来访问。

public void Main()
{
	Type t = typeof(int);
	Console.WriteLine(t.FullName);
	Console.WriteLine(t.Namespace);
	Console.WriteLine(t.Name);
 
	foreach(var method in t.GetMethods())
	{
		Console.WriteLine(method.Name);
	}
	Console.WriteLine($"Count: {t.GetMethods().Length}");
}

default 操作符 可以获取某个变量的默认值。值类型的默认值是 0,引用类型的默认值是 null,枚举类型的默认值是 0,他们的原理都是在内存中存 0。

特别要注意枚举类型,可能有的枚举类型没有为0的值,但是对枚举类型使用 default 操作符的时候,会返回 0;

new 操作符

  • 一次性
  • 构造器
  • 匿名类型 与 var
  • 紧耦合,依赖注入——避免紧耦合
  • string 和 数组的声明通过 C# 的语法糖,让我们可以不使用 new 操作符。

new 修饰符 隐藏父类方法

unchecked & checked

  • 变量检查溢出
  • 上下文检查

delegate 作操作符

较新的 C# 标准中,已经使用 Lambda 表达式来替代这一用途。

sizeof 操作符

只能获取结构体数据类型在内存中所占字节大小,比如 string 和 object 就不支持。 也支持自定义的 struct 类型,但是需要放在 unsafe 上下文中

指针访问操作符

和 cpp 差不太多,但是需要放在 unsafe 上下文中,且仅支持对 struct 类型进行操作。

一元操作符

操作数的数量为一个,叫做一元操作符。

*x, &x :取引用操作符、取地址操作符

+, -, ~;正、负、求反操作符 负操作符:按位取反再加一 求反操作符:直接01反转 这个求反操作符感觉有点像析构函数,正好意思也和构造函数反过来差不多。

C# 语法 ------------------------------------------

一些初始化声明

平常写代码的时候,声明变量完了,编辑器总是提示我这里可以 Quick Fix、那里也可以,导致我总是写着写着怀疑人生,搞得都不知道该如何声明变量了,所以在这里总结了一下。

C# 中有几种变量,基本类型、对象类型、集合类型等等。

// 基本类型 - 有默认值
int number = 0;           // 或者 int number; (默认0)
bool flag = false;        // 或者 bool flag; (默认false)
string text = "";         // 或者 string text; (默认null)
char ch = '\0';           // 或者 char ch; (默认'\0')
 
/* 对象类型 */
 
// 传统方式
Person person = new Person();
Person person2 = new Person("John", 25);
// C# 9.0+ 目标类型推断
Person person3 = new("John", 25);  // 编译器推断类型
var person4 = new Person("John", 25);
 
/* 集合类型 */
 
// 传统方式
List<int> numbers = new List<int>();
List<int> numbers2 = new List<int> { 1, 2, 3, 4 };
// C# 9.0+ 目标类型推断
List<int> numbers3 = new() { 1, 2, 3, 4 };
var numbers4 = new List<int> { 1, 2, 3, 4 };
// C# 12+ 集合表达式
List<int> numbers5 = [1, 2, 3, 4];  // 最新语法
 

主要区别在于集合类型和对象类型在不同版本标准的 C# 中的语法不同。

对于集合类型,传统方式需要在 new 后面写出类型的类名、参数;C# 9.0 之后可以省略类名和参数,声明的内容(键值对)依旧使用 {} 花括号来表示(对象初始化器语法);C# 12.0 之后,开始使用赋值初始化(索引器初始化器语法)

字典初始化方式变化:

// 传统方式
 
Dictionary<string, int> dict = new Dictionary<string, int>();
Dictionary<string, int> dict2 = new Dictionary<string, int>
{
    {"apple", 1},
    {"banana", 2}
};
 
// C# 9.0+ 目标类型推断
Dictionary<string, int> dict3 = new()
{
    {"apple", 1},
    {"banana", 2}
};
 
// C# 12+ 集合表达式
Dictionary<string, int> dict4 = new()
{
    ["apple"] = 1,
    ["banana"] = 2
};

集合初始化方式变化:

// 传统方式
List<int> numbers = new List<int>();
List<int> numbers2 = new List<int> { 1, 2, 3, 4 };
  
// C# 9.0+ 目标类型推断
List<int> numbers3 = new() { 1, 2, 3, 4 };
var numbers4 = new List<int> { 1, 2, 3, 4 };
  
// C# 12+ 集合表达式
List<int> numbers5 = [1, 2, 3, 4];  // 最新语法

条件判断:If()

短路求值及一些 Trick 写法

在 C# 中,逻辑运算符&&(逻辑与)和||(逻辑或)具有短路求值特性,这意味着当第一个操作数已经能确定整个表达式的结果时,第二个操作数不会被执行。

判空操作:避免空指针

// 判断 a、b 数组是否为空且长度是否相等,先进行判空操作,避免 a 或 b 为 null 的时候访问成员变量 Length 的时候报 NullReferenceException
if (a == null || b == null || a.Length != b.Length)
	return false;

条件执行函数 & 条件赋值 & 替代TryCatch 条件执行函数、替代TryCatch

isReady && DoSomething();
// 等价于
if (isReady) DoSomething();

条件赋值

// 在 input 不为空且不为空字符串的时候才赋值,否则为 Default
string name = input != null && input.Length > 0 ? input : "Default";
 
// user 不为空才赋值,但布尔表达式需要 `&&` 两边都是布尔值,所以右边需要再判断一下 != null
(user != null && (user.Name = "Leo") != null);

循环提前结束

可以不用写 break;

// 角色向目标点移动的逻辑(每帧更新)
void MoveToTarget(Character character, Vector3 target)
{
    // 短路逻辑:
    // 1. 先判断角色是否有效(未销毁)
    // 2. 再判断是否到达目标点(未到达则继续循环)
    // 3. 最后执行移动逻辑(只有前两个条件都满足才执行)
    // MoveToWards 未在这里实现
    while (character != null 
           && !IsReachedTarget(character, target) 
           && character.MoveTowards(target, Time.deltaTime)) 
    {
        // 循环体为空,所有逻辑在条件中通过短路求值完成
        // 每帧等待一次更新(避免死循环阻塞主线程)
        yield return null; 
    }
 
    // 移动结束后的处理
    if (character != null)
    {
        character.PlayIdleAnimation();
    }
}
 
// 检测是否到达目标点
bool IsReachedTarget(Character character, Vector3 target)
{
    return Vector3.Distance(character.Position, target) < 0.1f;
}

Char

char.GetNumericValue(x)

获取字符 x 的数值

String

String 和 string

两者使用上没什么区别,一个是类型名称,一个是关键字,在代码中两个都可以使用。

string 是 C# 的关键字,不需要引用命名空间就可以使用(与 bool, int, char 保持一致,都是小写)。 String 是类型名称(.NET Framework 的类型),需要引用命名空间 using System 才可以直接使用。 string 在编译成 IL 语言之后,会被编译成 Sytem.String

string.Concat()

连接字符串

string.Join()

string.Join(string separator, params string[] value) 连接字符串,但是可以添加连接符

Array 数组

a.SequenceEqual(b) 判断两个序列是否相等

  • 要求两个序列的元素数量必须相同,否则直接返回 false
  • 要求每个位置的元素必须相等(按顺序一一对比),顺序不同则判定为不相等。
  • 对于值类型(如 intdouble),直接比较值是否相等。
  • 对于引用类型(如自定义类),默认比较引用地址,若需比较对象内容,需重写 Equals 方法或使用 IEqualityComparer<T> 自定义比较规则。

与 Equals 或 == 的区别:

  • 数组 / 列表的 Equals 或 == 比较的是引用是否相同(是否为同一个对象),而 SequenceEqual 比较的是内容是否相同
int[] arr1 = { 1, 2 };
int[] arr2 = { 1, 2 };
 
Console.WriteLine(arr1 == arr2); // false(引用不同) Console.WriteLine(arr1.SequenceEqual(arr2)); // true(内容相同)

Linq

LINQ操作汇总

过滤

OfType

// 将 listOfItems 中的 int 类型元素过滤出来
return listOfItems.OfType<int>();

Where

return listOfItems
.Where(x => x.GetType() == typeof(int))
.Select(x => (int)x);

强制类型转换

Cast

return listOfItems
.Where(x => x is int)
.Cast<int>();

Select

操作

排序

OrderBy/OrderByDescending,按一定规则升序/降序排序

// 对整数数组升序排序
int[] numbers = {3, 1, 4, 1, 5, 9, 2, 6};
var sorted = numbers.OrderBy(x => x).ToArray();
// 结果: [1, 1, 2, 3, 4, 5, 6, 9]
 
// 对字符串数组按长度排序
string[] words = {"apple", "pie", "banana", "cat"};
var byLength = words.OrderBy(x => x.Length).ToArray();
// 结果: ["pie", "cat", "apple", "banana"]

对象排序

public class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
    public string City { get; set; }
}
var people = new List<Person>
{
    new Person { Name = "Alice", Age = 25, City = "Beijing" },
    new Person { Name = "Bob", Age = 30, City = "Shanghai" },
    new Person { Name = "Charlie", Age = 25, City = "Beijing" }
};
 
// 按年龄排序
var byAge = people.OrderBy(p => p.Age).ToList();
// 按姓名排序
var byName = people.OrderBy(p => p.Name).ToList();
// 按城市排序
var byCity = people.OrderBy(p => p.City).ToList();

ThenBy,可以继续排序

序列化与反序列化

序列化:将对象转化为可以存储在内存中/网络传输的格式的过程,比如转化成XML文件、JSON文件、二进制文件等等 反序列化:用文件的内容重建为对象

语言实战--------------------------------

Bug记录:引用类型变量的值在异步操作中会被覆盖

描述:在一个角色技能中,多个函数需要传参,且参数数量较多,所以用了一个 object[] 数组对象来存储。然而,其中一个函数会在网络消息中同一帧被调用多次,最后函数的执行结果都是最后一次被调用的结果。

Bug 关键原因在于:引用类型变量 + 异步。

即使不使用网络消息,在本地 StartCoroutine 也会造成这个结果。我们可以在 For 循环中,每次都对 object[] 变量进行复制,并且每次引用变量的函数都等待若干帧,最后输出函数的结果,代码如下:

// 共享的成员变量 - 这是问题所在  
private object[] m_SharedParameters = new object[6];
 
/// <summary>  
/// 错误示例:所有异步调用共享同一个数组  
/// </summary>  
public void WrongWay_SharedArray()  
{  
    for (int i = 0; i < 3; i++)  
    {        Vector3 pos = new Vector3(i - 1, 0, 0);  
  
        // 修改共享的成员变量  
        m_SharedParameters[4] = pos;  
  
        // 异步调用 - 不等待完成就继续  
        StartCoroutine(DelayedExecute(m_SharedParameters));  
  
        // 问题:当DelayedExecute真正执行时,  
        // m_SharedParameters[4] 可能已经被循环修改成最后一次的值了  
    }  
}
 
/// <summary>  
/// 模拟延迟执行的异步操作  
/// </summary>  
private IEnumerator DelayedExecute(object[] _parameters)  
{  
    // 等待一帧(模拟网络延迟或异步处理)  
    yield return null;  
  
    // 当这里执行时,如果_parameters是共享的,可能已经被修改了  
    Vector3 pos = (Vector3)_parameters[4];  
    Debug.Log($"异步执行:位置 = {pos}, 帧数 = {Time.frameCount}");  
}

输出结果:

要纠正这个 Bug,有多种途径:

  • 老老实实使用局部值类型变量
    • 进阶:使用 struct 传参,但仅使用参数类型一样的各个函数
  • 每次创建新的数组
    • 进阶:ArrayPool,向数据池中租一个数组
object[] arr = ArrayPool<object>.Shared.Rent(length);
ArrayPool<object>.Shared.Return(arr);

正则表达式

正则表达式采用的是 C# 中的 Regex 类,可以通过@运算符来设置 pattern 字符串,填写正则表达式,并通过 IsMatch 等函数来返回查询结果

在字符串大写字母前添加空格

public static string BreakCamelCase(string str)
=> Regex.Replace(str, "(?<!^)([A-Z])", " $1");
  • () 代表分组
  • [A-Z] 代表大写字母
  • ?< 之前,表示大写字母之前
  • !^ 感叹号表示“非”,^ 表示开头,所以 !^ 表示不能为开头
  • (?<!^) 大写字母之前不能是开头,这样不会匹配到一句话的开头
  • $1 空格、$ 表示结尾、 $1 表示空格后1个字符

字符串操作

删去首尾字符

s.Substring(startindex, length);

字符串范围表达

public static string Remove_char(string s) => s[1..^1];

数组操作

求平均数

public static double FindAverage(double[] array) => array.Length == 0 ? 0 : array.Average();

250727 汉诺塔&位运算

https://www.codewars.com/kata/534eb5ad704a49dcfa000ba6

解法:公式/位运算

位运算之左移运算:

x << n 表示把数字 x 的二进制表示向左移动 n 位

每向左移动一位,数字就相当于乘以 2

例如:

  • 1 << 0 = 1 (二进制 0001)
  • 1 << 1 = 2 (二进制 0010)
  • 1 << 2 = 4 (二进制 0100)
  • 1 << 3 = 8 (二进制 1000)
Console.WriteLine($"位运算2的3次方:{1<<3}");
位运算2的3次方:8