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

1,992 阅读14分钟

学微软的东西特别好的一点就是,文档指南 特别用心。

0. 如何看文档

对不起,打脸了,微软这 C# 文档结构写的过于意义不明,结构混乱(文档结构,不是文档本身),幽幽子看了都会绝食。

为什么这么说呢?

首先我们看一下结构: image.png 你们猜一下正确的教程在哪里?你是不是以为是那个大大的"教程"两个字?
然而如果你点开会发现: image.png 很明显看标题就知道这不是给初学者看的东西,那么正确的第一个教程在哪里呢?在这里:
image.png 那么你猜第二个教程在哪里呢?没错,在"基础"版块下的教程里。
image.png

那么正确的全教程顺序是什么呢?

  1. 首先看完"入门"版块下的教程。
  2. 然后看完"基础"版块下的教程。
  3. 然后看完"C#中的新增功能"版块下的教程。
  4. 然后看完最外层那个光秃秃的"教程"版块。

没错,最外层的那个教程,实际上指的是最后一个教程。
相信任何人都会觉得这结构意义不明。

本篇文章前 5 节(从 C# 语言介绍 到 程序结构)来自"入门"版块的简介。看完"简介"后我发现下一个子版块,也就是"类型" 根本不是给人学习看的。经过苦苦寻找我才找到真正的初学者应该看的,也就是刚刚上面介绍的顺序。真的太难了。微软罪大滔天。

1. C# 语言介绍

C# 是一门面向对象,面向组件的语言。没别的好说的了。

2. .NET 介绍

C# 在 .NET 上运行。

  1. 介绍 .NET 之前应该先介绍一下 CLI(不是命令行那个 CLI 哦,是 Common Language Infrastructure),CLI 是一个标准,定义了一个跨语言的运行环境。
  2. 然后我们才有 CLR(Common Language Runtime),CLR 是微软开发的一个 CLI 的实现。也就是说,CLR 是一个跨语言的运行环境。(CLI 是标准,定义了一个跨语言的运行环境;CLR 是实现,它就是一个跨语言的运行环境)
  3. 而 .NET 则是一个开发平台,其中包含了 CLR,同时还有一堆类库,以及一些乱七八糟的东西。可以说 .NET 是一个统称。
  4. 既然 .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。当然,除了官方提供的,还有别人或自己写的第三方库。

可以看这张图(如有错误/模糊不清,欢迎指出):
image.png

3. Hello World

先上代码:

using System;

class Hello {
    static void Main() {
        Console.WriteLine("Hello, World!");
    }
}

虽然只是一个 HelloWorld,但是还是有很多说法的:

  1. 第一行的 using System; 表示使用 System 这个 namespace。namespace 里面可以包含类型,也可以包含其他 namespace。例如第五行里的 Console,其实就是 System 里包含的类 System.Console,此外 System 里也有其他 namespace 诸如 IOCollections。当引用了 namespace 就可以简写,比如第五行本来应该写 System.Console.WriteLine,但是由于有了第一行的引用,所以简写成 Console.WriteLine 也是可以的。
  2. 我们有一个类叫做 Hello,然后里面有一个方法叫做 Main,被 static 修饰符修饰,这个叫做静态方法。静态方法不需要实例就能使用。实例方法则需要在实例上运行,实例方法中可以通过 this 来引用实例。此外,Main 是 C# 规定的程序入口。程序运行的时候会自动去找这个方法来运行,所以必须有一个这个方法。
  3. Console 是一个类,里面的 WriteLine 是其静态方法。Cosole 类由标准库提供,默认情况下编译器会自动引用标准库。

4. 类型和变量

C# 有两种类型。值类型和引用类型。值类型的变量直接包含数据,而引用类型的变量则储存对数据的引用。

以下是类型的细分:

  • 值类型:
    • 简单类型:
      • 有符号整型:sbyte, short, int, long
      • 无符号整型:byte, ushory, uint, ulong
      • Unicode 字符:char,代表 UTF-16 代码单元
      • IEEE 二进制浮点数:float,double
      • 高精度十进制浮点数:decimal
      • 布尔值:bool
    • 枚举类型:
      • enum E {...} 格式的用户自定义类型。
    • 结构类型:
      • struct S {...} 格式的用户自定义类型
    • 可以为 null 的值类型
      • 其他所有值类型的扩展,包含那个值类型的所有值和 null
    • 元祖值类型:
      • (T1, T2, T3...) 格式的用户自定义类型
  • 引用类型:
    • 类类型:(有点拗口,断句为 类 类型)
      • 所有类型(包括值类型)的基类:object
      • Unicode 字符串:String,表示 UTF-16 代码单元序列
      • class C {...} 格式的用户自定义类型
    • 接口类型:
      • interface I {...} 格式的用户自定义类型
    • 数组类型:
      • 一维,多维,或交错:int[], int[,] 或 int[][]
    • 委托类型:
      • delegate int D(...) 格式的用户自定义类型
  1. struct 类型和 class 类型都可以包含数据成员和函数成员。区别在于 struct 是值类型,不需要堆分配。此外,struct 不支持用户指定的继承。(为什么并不直接说不支持继承,而要说不支持用户指定的既传承呢?因为所有 struct 均隐式继承 object 类,事实上所有类型都继承自 object)
  2. interface 可以继承多个其他 interface。class 和 struct 也可以同时实现多个 interface。
  3. delegate 可以让方法赋值给变量,甚至作为函数的参数。类同于函数式语言提供的函数类型。
  4. 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 基本属性

  1. string 可以用 + 拼接。
  2. 字符串内插。类似于其他语言的模板字符串。格式为 $"hello {yourName}",其中 yourName 是变量。
  3. 字符串有属性 Length,可以得到字符串的长度。格式为 yourName.Length

6.2 字符串操作方法

对字符串进行一些修改操作。

  1. Trim, TrimStart, TrimEnd 可以去掉头尾的空格。
  2. Replace 方法可以替换字符串。替换所有的。
  3. ToUpper 和 ToLower 则是全部转成大写和小写。 操作字符串方法返回的都是新的字符串。

6.3 搜索字符串

  1. Contains 方法返回该字符串是否包含作为参数的子字符串。返回值是布尔值。
  2. StartsWith 和 EndsWith 则返回字符串是否以子字符串开头或结尾。

7. 数字

  1. int.MaxValue 和 int.MinValue 分别表示 int 类型所能承载的最大值和最小值(也就是负最大值)。
  2. int.MaxValue + 1 == int.MinValue,给最大值 + 1 变成了负最大值。
  3. 小数一般都用 double 而不用 float(最好的例子,编译器遇到小数常数,会假定为 double)。double 的范围极大。高达 10 的 308 次方(也是一样通过 double.MaxValue 来查看)。比 long 还长(而且长很多)。
  4. 但是 double 的精度仍然不够。小数点后还是只能有 15 位。想要更多的小数点后位数,就要使用 decimal 类型。decimal 类型的最大值最小值范围比 double 低很多,但是精度更高。
decimal a = 1.0M;

// 常数的时候,如果不加 M,编译器会将其当做 double 类型
Console.WriteLine(1.0/3); // 0.333333333333333
Console.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}");
}

输出为:
image.png

  1. List<T> 类型,此类型储存类型为 T 的一系列元素。
  2. foreach 提供了很方便的遍历列表的方式。(当然,这里 var 完全可以换成 string,当你不确定类型的时候才使用 var)

9.1 修改列表

Add 添加元素。
Remove 删除元素。
Count 属性可以查看列表元素个数。(不是 Length)
实际上,列表长度有两种方式。

9.2 Count 属性 VS Count() 方法。

这两这种方法表现是一致的。硬要说的的话,Count 由于不会进行类型检查,所以会比 Count() 快一些。

9.3 搜索和排序列表

  1. IndexOf() 方法搜索元素所在的索引位置,如果元素不存在则返回 -1。
  2. 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 类里:
  1. 前三行是属性,属性可以定义一些验证和其他规则。get,set 表示读和写的权限,如果想让属性只读可以不加 set。
  2. 后两行是方法。

11.1 添加账户

我们给其加一个添加账户的功能。也就是实例化。那么这里要介绍 constructor 了。constructor 是一个和类名同名的成员。用于初始化实例(其实 this 可以省略) : image.png

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

试了一下效果: image.png

11.1.1 账号号码(static 修饰符)

我们有一个属性是 Number,这个属性不应该由用户提供,而是代码生成。
那么一个简单地方法是先弄个初始的 10 位的数字,然后每添加一个账户就给它 + 1。
那么我们用这么个代码来做到: image.png 细节很多,来解释:

  1. private,因为只有这个类里会用到,只是作为一个初始值。
  2. static,这样这个数字就会被共享了。有没有想过,如果没有加 static,那么每次新账号都是 1000000001,因为这个数会重新生成。但是如果加上 static,那么这个属性就是和 class 绑定的,而不是和实例绑定的。换句话说就是 static 修饰的属性可以让我们在实例之间共享数据。

然后把构造函数改成这样既可(其实 this 可以省略,但为了区分 static 和别的区别还是加上): image.png

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

image.png

11.2 创建存款和取款

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

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

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

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

image.png

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

image.png Date.Now 获取当前时间,注意是属性不是方法。

11.3 测试

image.png

使用 try catch 可以捕获错误:

image.png

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

11.4 log 所有 transaction

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

12. 继承和多态

OOP 有四个重要的特性。Abstraction,Encapsulation,Inheritance, Polymorphism。前面两个在上一节已经讲过了,这一节讲后两个。

依旧拿刚才的银行账号类来讲解。

12.1 继承

我们要新建 3 种银行账号类型。储蓄卡,信用卡和礼品卡。首先我们先将它们创建,并继承之前的类:

image.png

  1. 每一个新的类都已经有和 BankAccount 相同的属性和方法了。
  2. 可以看到报错了,因为我们需要有构造函数。而且这个构造函数不止要初始化现在这个类,还要初始化基类。 可以看下面的代码: image.png
  3. 构造函数里也要和继承一样的格式,但是继承的类直接用 base 代替,而不是写基类的名字。
  4. base 里参数不写类型,因为这是在调用而不是在声明。
  5. 原理就是,这个构造函数会调用基类的构造函数,传参则直接从当前构造函数里拿。
  6. 有些时候基类可能有多个构造函数,这个语法可以选择用哪种构造函数。

12.2 多态

假设每一张卡都会在月底做一些事情,但是每张卡做的事情不一样。那么我们使用多态来完成这件事。
首先我们去到BankAccount 类,加上以下 virtual 方法: image.png

  • virtual 关键字表示这个方法之后可能会被继承类重新实现。既然是可能,也就是说你也可以在基类里写一些代码,然后不覆盖。
  • 在继承类里使用 override 关键字来覆盖掉方法。
  • 也可以把 virtual 换成 abstract,来让继承类强制覆盖这个方法。(不过那样的话基类本身也得是 abstract 类型,具体之后再讲吧)

继承类里也可以调用基类的方法: image.png

不过我们现在不需要用,前两张卡实现代码如下: image.png

对于礼品卡,规则比较多,因此需要更改一些别的,比如我们先修改构造函数: image.png 这里因为我们只有一句,因此省去了大括号,使用了 => 也就是 lambda 运算符。
然后我们再改写 MonthlyTask 方法: image.png

测试一下 礼品卡 和 储蓄卡: image.png

但是当测试信用卡的时候,出问题了: image.png 这很 resonable,因为 BankAccount 限制不能取走比 Banlance 更多的钱。于是我们需要修改 BankAccount 了: image.png

  • readonly 属性和只给一个 get 的属性的区别在于。前者只能在构造函数阶段赋值,一旦初始化之后就无法更改了。而后者则可以在 class 内部进行更改。不过对外都是作为只读属性来看。
  • 这里我们有两个构造函数,第一个构造函数接受 3 个参数;第二个接受两个参数,同时使用 :this() 语法来调用上一个构造函数。然后这里我们只需要调用第一个构造函数(以第三个参数为 0 的形式),不需要做别的事,所以就用 :this() {} 了。

然后我们修改 MakeWithdrawal: image.png

然后我们在 CreditCardAccount 里可以修改成这样: image.png

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,就可以做出不同的行为了。