从零开始独立游戏开发学习笔记(十)--Unity学习笔记(四)--微软C#指南(一)

学微软的东西特别好的一点就是,文档指南 特别用心。
0. 如何看文档
对不起,打脸了,微软这 C# 文档结构写的过于意义不明,结构混乱(文档结构,不是文档本身),幽幽子看了都会绝食。
为什么这么说呢?
首先我们看一下结构:

你们猜一下正确的教程在哪里?你是不是以为是那个大大的"教程"两个字?
然而如果你点开会发现:

很明显看标题就知道这不是给初学者看的东西,那么正确的第一个教程在哪里呢?在这里:

那么你猜第二个教程在哪里呢?没错,在"基础"版块下的教程里。

那么正确的全教程顺序是什么呢?
首先看完"入门"版块下的教程。
然后看完"基础"版块下的教程。
然后看完"C#中的新增功能"版块下的教程。
然后看完最外层那个光秃秃的"教程"版块。
没错,最外层的那个教程,实际上指的是最后一个教程。
相信任何人都会觉得这结构意义不明。
本篇文章前 5 节(从 C# 语言介绍 到 程序结构)来自"入门"版块的简介。看完"简介"后我发现下一个子版块,也就是"类型" 根本不是给人学习看的。经过苦苦寻找我才找到真正的初学者应该看的,也就是刚刚上面介绍的顺序。真的太难了。微软罪大滔天。
1. C# 语言介绍
C# 是一门面向对象,面向组件的语言。没别的好说的了。
2. .NET 介绍
C# 在 .NET 上运行。
介绍 .NET 之前应该先介绍一下 CLI(不是命令行那个 CLI 哦,是 Common Language Infrastructure),CLI 是一个标准,定义了一个跨语言的运行环境。
然后我们才有 CLR(Common Language Runtime),CLR 是微软开发的一个 CLI 的实现。也就是说,CLR 是一个跨语言的运行环境。(CLI 是标准,定义了一个跨语言的运行环境;CLR 是实现,它就是一个跨语言的运行环境)
而 .NET 则是一个开发平台,其中包含了 CLR,同时还有一堆类库,以及一些乱七八糟的东西。可以说 .NET 是一个统称。
既然 .NET 包括了 CLR,也就是说 .NET 并不是和 C# 绑定的。你可以使用任何 CLR 支持的语言在 .NET 上编写程序,例如 F# 等。
在 .NET 上写完源代码后,会被编译成 IL 语言。IL 代码和资源会被储存在扩展名通常为 dll 的程序集中。
执行 C# 程序时,这些程序集会被加载到 CLR 上(因此想要运行 C# 程序必须得有 .NET,这也是为什么很多程序尤其是游戏,经常会让你去下载 .Netframework 第几几几版本)。然后 CLR 会对这些 IL 代码执行实施编译(JIT,Just-In-Time),编译成本机指令。CLR 提供像是垃圾回收,异常处理,资源管理等等这些功能。
IL 是一种中间语言,因此可以和其他 .NET 版本的 F#,C++ 等语言编译出来的 IL 代码进行交互。一个程序集中可以有很多模块,这些模块由不同语言编写而成,但是它们之间却能够互相引用。
除了 CLR,.NET 还提供了类库(class libraries)。如果熟悉其他语言的话,很容易理解。总之包含了各种像是输入输出,web框架,字符串控制等等类库,都被分成对应的 namespace。当然,除了官方提供的,还有别人或自己写的第三方库。
可以看这张图(如有错误/模糊不清,欢迎指出):

3. Hello World
先上代码:
using System;class Hello { static void Main() { Console.WriteLine("Hello, World!"); } }
虽然只是一个 HelloWorld,但是还是有很多说法的:
第一行的 using System; 表示使用 System 这个 namespace。namespace 里面可以包含类型,也可以包含其他 namespace。例如第五行里的 Console,其实就是 System 里包含的类
System.Console
,此外 System 里也有其他 namespace 诸如IO
和Collections
。当引用了 namespace 就可以简写,比如第五行本来应该写System.Console.WriteLine
,但是由于有了第一行的引用,所以简写成Console.WriteLine
也是可以的。我们有一个类叫做 Hello,然后里面有一个方法叫做 Main,被 static 修饰符修饰,这个叫做静态方法。静态方法不需要实例就能使用。实例方法则需要在实例上运行,实例方法中可以通过 this 来引用实例。此外,Main 是 C# 规定的程序入口。程序运行的时候会自动去找这个方法来运行,所以必须有一个这个方法。
Console 是一个类,里面的 WriteLine 是其静态方法。Cosole 类由标准库提供,默认情况下编译器会自动引用标准库。
4. 类型和变量
C# 有两种类型。值类型和引用类型。值类型的变量直接包含数据,而引用类型的变量则储存对数据的引用。
以下是类型的细分:
值类型:
(T1, T2, T3...) 格式的用户自定义类型
其他所有值类型的扩展,包含那个值类型的所有值和 null
struct S {...} 格式的用户自定义类型
enum E {...} 格式的用户自定义类型。
有符号整型:sbyte, short, int, long
无符号整型:byte, ushory, uint, ulong
Unicode 字符:char,代表 UTF-16 代码单元
IEEE 二进制浮点数:float,double
高精度十进制浮点数:decimal
布尔值:bool
简单类型:
枚举类型:
结构类型:
可以为 null 的值类型
元祖值类型:
引用类型:
delegate int D(...) 格式的用户自定义类型
一维,多维,或交错:int[], int[,] 或 int[][]
interface I {...} 格式的用户自定义类型
所有类型(包括值类型)的基类:object
Unicode 字符串:String,表示 UTF-16 代码单元序列
class C {...} 格式的用户自定义类型
类类型:(有点拗口,断句为 类 类型)
接口类型:
数组类型:
委托类型:
struct 类型和 class 类型都可以包含数据成员和函数成员。区别在于 struct 是值类型,不需要堆分配。此外,struct 不支持用户指定的继承。(为什么并不直接说不支持继承,而要说不支持用户指定的既传承呢?因为所有 struct 均隐式继承 object 类,事实上所有类型都继承自 object)
interface 可以继承多个其他 interface。class 和 struct 也可以同时实现多个 interface。
delegate 可以让方法赋值给变量,甚至作为函数的参数。类同于函数式语言提供的函数类型。
class,struct,interface,delegate 全部支持泛型。
可以为 null 的类型无需定义。对于所有没有 null 值的类型 T,都可以加一个 ? 变成 T? 类型。这样这个变量就可以为 null 了。
C# 采用统一的类型系统,因此所有的类型包括值类型都可以看做 object 类型,所有类型都直接或间接派生自 object 类型。
可以通过装箱,拆箱来把值类型当引用类型来使用。
int i = 123;object o = i; // 装箱int j = (int)o; // 拆箱
装箱后,值会被复制到箱里。拆箱的时候会检查这个箱里是不是有正确的值类型。
C# 的统一类型系统实际上意味着按需将值类型当引用类型来引用。如果有库使用 object,那么实际上无论是值类型还是引用类型都可以使用。
5. 程序结构
C# 中的关键程序结构包括 程序,命名空间,类型,成员,程序集。程序声明类型,类型包含成员,并被整理进命名空间。类型包括 类,接口,结构。成员包括 字段,方法,属性,事件。编译完的 C# 实际会被打包进程序集,视打包成应用还是库,程序集后缀可能为 exe 或 dll。
6. 字符串的用法
这里开始正式教程。
6.1 基本属性
string 可以用 + 拼接。
字符串内插。类似于其他语言的模板字符串。格式为
$"hello {yourName}"
,其中 yourName 是变量。字符串有属性 Length,可以得到字符串的长度。格式为
yourName.Length
。
6.2 字符串操作方法
对字符串进行一些修改操作。
Trim, TrimStart, TrimEnd 可以去掉头尾的空格。
Replace 方法可以替换字符串。替换所有的。
ToUpper 和 ToLower 则是全部转成大写和小写。
操作字符串方法返回的都是新的字符串。
6.3 搜索字符串
Contains 方法返回该字符串是否包含作为参数的子字符串。返回值是布尔值。
StartsWith 和 EndsWith 则返回字符串是否以子字符串开头或结尾。
7. 数字
int.MaxValue 和 int.MinValue 分别表示 int 类型所能承载的最大值和最小值(也就是负最大值)。
int.MaxValue + 1 == int.MinValue,给最大值 + 1 变成了负最大值。
小数一般都用 double 而不用 float(最好的例子,编译器遇到小数常数,会假定为 double)。double 的范围极大。高达 10 的 308 次方(也是一样通过 double.MaxValue 来查看)。比 long 还长(而且长很多)。
但是 double 的精度仍然不够。小数点后还是只能有 15 位。想要更多的小数点后位数,就要使用 decimal 类型。decimal 类型的最大值最小值范围比 double 低很多,但是精度更高。
decimal a = 1.0M;// 常数的时候,如果不加 M,编译器会将其当做 double 类型Console.WriteLine(1.0/3); // 0.333333333333333Console.WriteLine(1.0M/3); // 0.3333333333333333333333333333
8. 分支和循环
没什么好说的。
9. 列表
因为要说的东西挺多,因此从一串代码开始看起:
var names = new List<string> {"<name>", "asshole", "joe"};foreach (var name in names) { Console.WriteLine($"Hello {name}"); }
输出为:

List<T>
类型,此类型储存类型为 T 的一系列元素。foreach 提供了很方便的遍历列表的方式。(当然,这里 var 完全可以换成 string,当你不确定类型的时候才使用 var)
9.1 修改列表
Add 添加元素。
Remove 删除元素。
Count 属性可以查看列表元素个数。(不是 Length)
实际上,列表长度有两种方式。
9.2 Count 属性 VS Count() 方法。
这两这种方法表现是一致的。硬要说的的话,Count 由于不会进行类型检查,所以会比 Count() 快一些。
9.3 搜索和排序列表
IndexOf() 方法搜索元素所在的索引位置,如果元素不存在则返回 -1。
name.Sort() 会对列表进行排序。(会改变原来的列表!!这个方法不返回值!!)
10. 如何显示命令行参数
其实如果做游戏的话,这个其实不用学。但是反正教程就那么几个字,看看也不亏。
命令行参数会作为一个数组被传进 Main 方法。如下:
static void Main(string[] args) { }
就这样,没了。
11. 类简介
作为一个 OOP 语言,类必然是最重要的概念之一。
所以现在我们就建立一个类试试,来表示一个银行账号。
using System;namespace classes { public class BankAccount { public string Number {get;} public string Owner {get; set;} public decimal Balance {get;} public void MakeDeposit (decimal amount, DateTime date, string note) { } public void MakeWithdrawal (decimal amount, DateTime date, string note) { } } }
namespace 用于组织代码,像是我们现在写的小代码,一个 namespace 即可。
public 表示能否被其他文件引用。
BanckAccount 类里:
前三行是属性,属性可以定义一些验证和其他规则。get,set 表示读和写的权限,如果想让属性只读可以不加 set。
后两行是方法。
11.1 添加账户
我们给其加一个添加账户的功能。也就是实例化。那么这里要介绍 constructor 了。constructor 是一个和类名同名的成员。用于初始化实例(其实 this 可以省略) :

constructor 和其他方法的区别,一个是名字和类名相同,还有一个就是没有返回值,void 都不能写,加了会被当做方法,从而报错,因为方法不能和类名同名。
试了一下效果:

11.1.1 账号号码(static 修饰符)
我们有一个属性是 Number,这个属性不应该由用户提供,而是代码生成。
那么一个简单地方法是先弄个初始的 10 位的数字,然后每添加一个账户就给它 + 1。
那么我们用这么个代码来做到:

细节很多,来解释:
private,因为只有这个类里会用到,只是作为一个初始值。
static,这样这个数字就会被共享了。有没有想过,如果没有加 static,那么每次新账号都是 1000000001,因为这个数会重新生成。但是如果加上 static,那么这个属性就是和 class 绑定的,而不是和实例绑定的。换句话说就是 static 修饰的属性可以让我们在实例之间共享数据。
然后把构造函数改成这样既可(其实 this 可以省略,但为了区分 static 和别的区别还是加上):

由于现在在类内,所以直接调用 initialAccountNumber 即可,如果是在外面的话,就要用 BankAccount.initialAccountNumber。当然了,由于我们是 private,就算在外面也调用不了就是了。
测试一下效果:

11.2 创建存款和取款
单纯地改变 Balance 没意思,也不合理。我们先创建一个类叫做 transaction。用这个来记录 Balancce 的变化。

然后我们创建一个属性用于存储每个账号所有的历史 transaction。

然后我们修改 Balance 属性。让其等于所有 transaction 计算后的结果。

然后终于开始写方法,这个时候会引入 exception:

然后我们需要对 constructor 进行修改,因为初始化 balance 也需要改变了(此时 Balance 可以去掉 set 变成只读了):

Date.Now 获取当前时间,注意是属性不是方法。
11.3 测试

使用 try catch 可以捕获错误:

看起来差不多,但是没有说未捕获的异常了。而且这个错误是我自己主动打印出来的。
11.4 log 所有 transaction
看代码:

StringBuilder 类型的 AppendLine 方法会自动在每一行后面加空格。然后我们再用制表符调整缩进。 测试效果如下:

12. 继承和多态
OOP 有四个重要的特性。Abstraction,Encapsulation,Inheritance, Polymorphism。前面两个在上一节已经讲过了,这一节讲后两个。
依旧拿刚才的银行账号类来讲解。
12.1 继承
我们要新建 3 种银行账号类型。储蓄卡,信用卡和礼品卡。首先我们先将它们创建,并继承之前的类:
每一个新的类都已经有和 BankAccount 相同的属性和方法了。
可以看到报错了,因为我们需要有构造函数。而且这个构造函数不止要初始化现在这个类,还要初始化基类。
可以看下面的代码:
构造函数里也要和继承一样的格式,但是继承的类直接用 base 代替,而不是写基类的名字。
base 里参数不写类型,因为这是在调用而不是在声明。
原理就是,这个构造函数会调用基类的构造函数,传参则直接从当前构造函数里拿。
有些时候基类可能有多个构造函数,这个语法可以选择用哪种构造函数。
12.2 多态
假设每一张卡都会在月底做一些事情,但是每张卡做的事情不一样。那么我们使用多态来完成这件事。
首先我们去到BankAccount 类,加上以下 virtual 方法:
virtual 关键字表示这个方法之后可能会被继承类重新实现。既然是可能,也就是说你也可以在基类里写一些代码,然后不覆盖。
在继承类里使用 override 关键字来覆盖掉方法。
也可以把 virtual 换成 abstract,来让继承类强制覆盖这个方法。(不过那样的话基类本身也得是 abstract 类型,具体之后再讲吧)
继承类里也可以调用基类的方法:
不过我们现在不需要用,前两张卡实现代码如下:
对于礼品卡,规则比较多,因此需要更改一些别的,比如我们先修改构造函数:
这里因为我们只有一句,因此省去了大括号,使用了 => 也就是 lambda 运算符。
然后我们再改写 MonthlyTask 方法:
测试一下 礼品卡 和 储蓄卡:
但是当测试信用卡的时候,出问题了:
这很 resonable,因为 BankAccount 限制不能取走比 Banlance 更多的钱。于是我们需要修改 BankAccount 了:
readonly 属性和只给一个 get 的属性的区别在于。前者只能在构造函数阶段赋值,一旦初始化之后就无法更改了。而后者则可以在 class 内部进行更改。不过对外都是作为只读属性来看。
这里我们有两个构造函数,第一个构造函数接受 3 个参数;第二个接受两个参数,同时使用
:this()
语法来调用上一个构造函数。然后这里我们只需要调用第一个构造函数(以第三个参数为 0 的形式),不需要做别的事,所以就用:this() {}
了。
然后我们修改 MakeWithdrawal:
然后我们在 CreditCardAccount 里可以修改成这样:
12.2.1 protocted
现在想让信用卡能够超过 creditLimit,可以用 virtual 方法。在 BankAccount 里,更改 MakeWithdrawal 方法:
public void MakeWithdrawal(decimal amount, DateTime date, string note){ if (amount <= 0) { throw new ArgumentOutOfRangeException(nameof(amount), "Amount of withdrawal must be positive"); } var overdraftTransaction = CheckWithdrawalLimit(Balance - amount < minimumBalance); var withdrawal = new Transaction(-amount, date, note); allTransactions.Add(withdrawal); if (overdraftTransaction != null) allTransactions.Add(overdraftTransaction); }protected virtual Transaction? CheckWithdrawalLimit(bool isOverdrawn) { if (isOverdrawn) { throw new InvalidOperationException("Not sufficient funds for this withdrawal"); } else { return default; } }
我写在这里是因为我新建的项目用的是 C# 7.3,而只有 C# 8.0 才有"可以为null的引用类型"的支持。
这里除了修改 MakeWithdrawal 方法以外,还添加了一个新的方法。这个方法前的修饰符是 protected 和 virtual。
protected 和 public,private 是一类的,代表着这个方法只能被继承类调用。
virtual 代表着这个方法可以被继承类覆盖。
加在一起就是只能被继承类调用,同时可以被覆盖。
于是我们在 CreditCardAccount 里覆盖它:
public override Transaction? CheckWithdrawalLimit(bool isOverdrawn) => isOverdrawn ? new Transaction(-20, DateTime.Now, "Apply overdraft fee") : default;
通过这样的方式,把代码分离出成一个 virtual 方法,然后 override,就可以做出不同的行为了。