声明类
类可以使用帮助你管理一组相互依赖的数据,来完成某些职责。
类使用class
关键字定义,并且必须在所有顶级语句之下。
类的成员只能有声明语句,不能有执行语句。
class Player1
{
int Hp;
int MaxHp;
int Atk;
int Def;
int Overflow()
{
if (Hp < 0)
{
int i = Hp;
Hp = 0;
return i;
}
else if (Hp > MaxHp)
{
int i = Hp - MaxHp;
Hp = MaxHp;
return i;
}
else
{
return 0;
}
}
}
使用new
+类名+括号可以构造出一个这种类型的值。但无法访问里面的东西。
Player1 player1 = new Player1();
player1.Hp = 400;//不可访问,因为它具有一定的保护级别
访问权限
如果希望外部可以访问到你定义类的成员,你需要公开他们的访问权限。
可以在所有的声明前面加上public
,这样你将可以在外部任意访问和修改他们。
Player2 player2 = new Player2();
player2.Hp = 400;
class Player2
{
public int Hp;
public int MaxHp;
public int Atk;
public int Def;
public int Overflow()
{
if (Hp < 0)
{
int i = Hp;
Hp = 0;
return i;
}
else if (Hp > MaxHp)
{
int i = Hp - MaxHp;
Hp = MaxHp;
return i;
}
else
{
return 0;
}
}
}
访问权限列表
调用方的位置 | public(公开) | protected internal | protected(保护) | internal(内部) | private protected | private(私有) |
---|---|---|---|---|---|---|
内部 | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ |
派生类 | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ❌ |
相同程序集 | ✔️ | ✔️ | ❌ | ✔️ | ❌ | ❌ |
不同程序集的派生类 | ✔️ | ✔️ | ✔️ | ❌ | ❌ | ❌ |
任何 | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ |
默认访问权限
成员 | 默认(没写修饰符时)可访问性 | 允许的成员的声明的可访问性 |
---|---|---|
命名空间 | public | 无(不能添加修饰符) |
枚举 | public | 无(不能添加修饰符) |
顶级类 | internal | internal 或 public |
嵌套类 | private | 全部 |
类成员 | private | 全部 |
嵌套结构 | private | 除了带有protected的访问权限。因为派生对他不可用。 |
内部类
类可以声明在其他的类中。这样的类也可以使用他的私有成员。
internal class Barrack
{
private int level;
private class Marine
{
public int Atk;
public void Init(Barrack barrack)
{
Atk += barrack.level;
}
}
}
命名约定
具有public
或protected
修饰的所有类成员,都意味着将会被他人使用。
为了代码风格的一致性,约定公开的类成员,以驼峰命名法。
- 使用英文作为名字
- 把所有的空格后的第一个字母大写,然后去掉所有空格
- 把首字母大写。
例如:超光速
- faster than light
- fasterThanLight
- FasterThanLight
不公开的命名规范,不好说。我随手翻了一些下的扩展包,就见到了一堆不同命名风格。
封装
为什么要阻止调用者访问所有的东西呢?
- 对于不想知道所有东西的人来说:如果把你带到飞机上的驾驶舱,你看着眼前琳琅满目的按钮你会怎么想?但是,如果我只给你6个键,上下左右前后。你是不是敢说你也会开飞机了。
- 对于想知道所有东西的人来说:不允许外部修改一些关键数据。例如我游戏只做了70关。然后调用者一改,跳关到80关。那我怎么运行呢?
- 对于你自己写的代码而言,你可能觉得没有必要,因为想改就改。但是请记住,真实的开发绝对不会是靠你一个人就能完成的。你可能会下扩展包,可能会用你的同学同事写好的代码。有可能很久以后你想起来你写的一个代码刚好能解决问题,但是已经看不懂你写的具体内容。
职责
在定义类的时候,应该先想好它应该做什么,规划他的职责范围。
在你只有一个地方使用的时候,你可能觉得无所谓,写谁身上都一样。
但是如果东西多起来了,可能就会出现重复的代码。
但是如果把恢复生命的方法,写在人物里面,那么就只需要写一份恢复生命。
而且本来这个操作修改的也是人物自己的东西,恢复生命,本应是人物的职责。
这样,以后不管是谁使用人物,都可以直接使用他的恢复生命,不需要自己写一份了。
类成员
方法中的变量有作用域,可以根据代码块判断它们何时不再需要,立刻释放内存。
但是类中的变量无法这样判断,因为它们可能被类中的任何一个方法所使用,而方法的调用时机是不确定的。
所以类中的变量不会被单独清除,它们只会随着类的实例化和销毁而分配和回收内存。
只有当整个类的对象都不再被引用时,它们才会被一起清除。
由于类的生命周期是动态的,所以class
声明的类都是引用类型。
字段
在方法内部定义的变量和方法,称为局部变量和局部方法。
局部一词也可以用本地替换,因为它们都是英文单词local的翻译。
类中直接定义的变量,称为字段,它们是类的成员之一。
字段必须指定数据类型,不能使用var进行隐式类型推断。
字段可以在声明时赋予初始值,但是不能使用其他的实例成员参与赋值表达式。
class Person
{
string name = "Alice"; // 可以赋予常量值
int age = GetAge(); // 错误,不能使用实例方法参与赋值
double height = weight * 0.9; // 错误,不能使用其他实例字段参与赋值
double weight; // 可以不赋值
int GetAge()
{
return 12;
}
}
只读
一个字段可以使用readonly
修饰。让他只能保持初始值。
只读只能保证这个变量不被更改。一些引用类型可以在不改变变量的情况下改变值。
class Sudent
{
public readonly int Age = 60;
public readonly string Name = "小明";
}
方法
在一些语言中,方法和函数有明确的区别,类的成员才能叫方法。以这个观点局部方法只能称为局部函数。
但在 C# 中,这种区分并不是很重要。即便是编写的局部函数,在编译后也会变成类成员。
重载
作为类成员的方法,具有重载的性质。
不同的方法之间可以同名,只要他们的参数列表不同(数量,类型,顺序)。
引用参数和基本类型的参数不一样,可以重载。
但他们之间都是相同的引用参数,不能只有in
,out
,ref
不同的情况下重载。
class Printer
{
public void Print(int number)
{
Console.WriteLine($"这个数字是 {number}。");
}
public void Print(string message)
{
Console.WriteLine($"这个信息是 {message}。");
}
public void Print(ref string message)
{
Console.WriteLine($"这个信息是 {message}。");
}
public void Print(double value, string unit)
{
Console.WriteLine($"这个数值是 {value} {unit}。");
}
}
在调用方法时,编译器会查找更为具体的方法。
- 如果调用方法的参数列表有一个直接匹配的重载,那么会忽略掉不定参数的重载。
- 检查所有参数的隐式转换和自身,如果方法重载有参数更为具体(int)的参数,则忽略掉更抽象的(object)重载。
in
参数不需要在调用时添加in
,但如果用in
引用参数和普通参数重载,则根据调用时是否有in
决定重载。- 如果没有或有多个这样的匹配方法,则会报错。
例如这种情况下,调用new Calculator().Add(40,20);
就会出现歧义。
class Calculator
{
public void Add(int x, object y)
{
switch (y)
{
case string s:
Console.WriteLine(x + s);
break;
case int i:
Console.WriteLine(x + i);
break;
default:
Console.WriteLine("Invalid argument");
break;
}
}
public void Add(object x, int y)
{
switch (x)
{
case string s:
Console.WriteLine(s + y);
break;
case int i:
Console.WriteLine(i + y);
break;
default:
Console.WriteLine("Invalid argument");
break;
}
}
}
属性
在早期,如果使用者希望获得,或修改类成员的变量,必须使用方法
class Hp1
{
private int now;
private int max;
public int GetNow()
{
return now;
}
public void SetNow(int value)
{
now = Math.Clamp(value, 0, max);
}
}
因为封装的特性,所有对值的修改应该是在自己的可控范围,
或者是自己应该收到通知,执行一些操作。
属性可以简化这个过程,它能声明一个像字段的方法。
class Hp2
{
private int now;
private int max;
public int Now { get => now; set => now = Math.Clamp(value, 0, max); }
}
get访问器
属性的使用方式和字段类似,但必须具有get
访问器的属性才能够获取值。
对于返回引用变量的属性,只允许存在get
访问器
Hp3 Hp3 = new Hp3();
int hp3 = Hp3.Now;//错误,这个属性无法取值
class Hp3
{
private int now;
private int max;
public int Now { set => now = Math.Clamp(value, 0, max); }
public int Now2 { get => now; }
public ref int Max { get => ref max; }
}
set访问器
具有set
访问器的属性,才能像变量一样进行赋值。
Hp4 Hp4 = new Hp4();
Hp4.Now = 40;//错误,这个属性无法赋值
class Hp4
{
private int now;
private int max;
public int Now { get => now; }
public int Now2 { set => now = Math.Clamp(value, 0, max); }
}
init访问器
属性无法使用readonly
进行修饰。作为补偿,可以使用init
访问器来代替set
访问器。
init
访问器仅能在赋值初始值时使用。并且这个访问器可以操作只读的字段。
class Hp5
{
private int now;
private readonly int max;
public int Now { get => now; }
public int Max { get => max; init => max = value; }
}
自动实现属性
如果一个属性对字段的控制不需要逻辑,并且有get
访问器,
可以省略掉访问器的逻辑。编译器会自动生成一个字段交给他控制。
并且,你可以对这个属性进行赋值,来给这个自动生成的字段赋初始值。
class Student
{
public string Name { get; set; } = "张三";
public int Age { get; init; }
public string Password { get; } = "password";//至少要有get属性
}
访问器的访问权限
访问器的get
,set
,init
都可以单独设置一个访问权限。
但至少要存在一个没有设置权限的访问器,并且访问器的权限必须低于属性的权限。
class Counter
{
public int Count { get; private set; }
public void Increment()
{
Count++;
}
}
lamda表达式
=>
只能对单行语句使用。如果逻辑复杂,可以扩展为完整的方法。
get
访问器是一个返回值和属性类型一样的无参方法。
set
和init
访问器是仅有一个类型和属性一样,名为value
参数的无返回值方法,
class AccessCounter
{
private int accessCount;
public int AccessCount
{
get
{
int currentCount = accessCount;
accessCount++;
return currentCount;
}
set
{
accessCount = value;
}
}
}
反过来,如果内容只有一条语句,那么方法也可以使用lamda表达式。
class AccessCounter2
{
private int accessCount;
public int GetAccessCount() => accessCount++;
public void SetAccessCount(int value) => accessCount = value;
}
何时使用属性
属性的介绍是像使用字段一样使用方法。
所以它有了字段的特点:无法作为语句单独放置。
而方法可以作为语句单独放置,因为方法会执行操作,会改变一些东西。
所以,如果不会改变太多的东西,即只改变自己控制的那些字段,就使用属性。
甚至有的时候,属性只给读取值,什么也不改变。
对于仅get属性,还能够再简写。去掉属性的大括号和get
,直接用=>
返回。
class Hero
{
public int Hp { get; private set; }
public int MaxHp { get; private set; }
public double HpRatio => 1.0 * Hp / MaxHp;
}
索引器
索引器和属性类似,使用访问器来简化方法调用。他牺牲了名字换取了参数。
class StringCollection
{
private string[] arr = new string[100];
public string this[int i]
{
get => arr[i];
set => arr[i] = value;
}
}
索引器的名字必须是this
。他的参数使用[]
来代替()
,且不能没有参数。
索引器的调用方式类似于数组的索引。
StringCollection sc = new StringCollection();
sc[0] = "Hello";
sc[1] = "World";
Console.WriteLine(sc[0]); //Hello
Console.WriteLine(sc[1]); //World
构造器
构造器是定义初始字段的地方。构造器没有返回值,方法名和类名一样。他有一些特点:
- 必定会被调用,且先于其他方法被调用。
- 只会执行一次。
- 可以为只读字段赋值。
class Circle
{
private readonly double radius;
public double Area => radius * radius * Math.PI;
public double Perimeter => 2 * radius * Math.PI;
public Circle(double radius)
{
this.radius = radius;
}
}
因为这些特点,构造器很适合用来初始化字段的初始值。
new
构造器的调用方法是new
+构造器
Circle circle = new Circle(20);
构造器只能搭配new
进行调用。new
是一个操作符,
- 首先会计算类型占用的空间
- 找一个合适的地方分配空间
- 取地址
- 执行构造器
虽然取地址在执行构造器之前,但赋值语句总是最慢的,
它会等待构造器完成执行才会执行赋值。
如果你没有写任何构造器,编译器会帮你弄一个没有参数的公开构造器。
在你写了以后就不会生成这个了。但是记住,构造器默认权限也是私有的。
构造器链
构造器比较特殊,如果要递归调用,只能出现在另一个构造器的开头。
并且使用特殊语法。使用:
表示首先执行,使用this
表示构造器。
Contract
{
private int id;
public string PartyA { get; private set; }
public string PartyB { get; private set; }
public Contract(int id)
{
this.id = id;
Console.WriteLine($"员工{id}进入打印室");
}
public Contract(string PartyA, int id) : this(id)
{
this.PartyA = PartyA;
}
public Contract(string PartyA, string PartyB, int id) : this(PartyA, id)
{
if (id.ToString().Length == 4)
this.PartyB = PartyB;
else
Console.WriteLine("检测到违规操作");
}
}
终结器
和构造器相反,终结器是在一个对象被清理时触发的。
它由.Net调用,我们无法主动调用他,所以不能有参数,也不能有访问修饰符。
他的语法是在构造器前加一个~
class Waste
{
~Waste()
{
Console.WriteLine("一个垃圾被清理了");
}
}
不过.Net不是时刻监视引用类型是否不再使用的,只会在觉得内存占用过多,
或内存不够的时候执行一次清理。所以如果想观察到它,需要创建很多对象。
for (int i = 0; i < 1000_000; i++)
{
new Waste();
}
解构方法
解构方法可以让实例能像元组一样被析构,或使用模式匹配的位置模式。
解构方法是公开无返回值的,名为Deconstruct的方法。所有参数均为out参数。
class Point
{
public int X;
public int Y;
public void Deconstruct(out int x, out int y)
{
x = X;
y = Y;
}
public void Deconstruct(out int x, out int y, out double length)
{
(x, y) = this;
length = Math.Sqrt(x * x + y + y);
}
}
实例和静态
类和实例这两个术语不是好名字。不能直接从名字看出来他是什么。
让我们换成民法中的术语种类物和特定物
种类物,比如说钱。我给你的一百元,五十元,和一堆硬币,都是钱。
你不在乎是哪个钱,只要是钱就行。
特定物,比如说照片。有人把你的全家福弄坏了,把他的旅游照赔给你,你会同意吗?
那肯定不同意,但是为什么?明明都是照片,为什么不一样呢?
特定物的含义就是只有这个东西才行。不能用其他的同种东西替代。
实例,就是能说这个的东西。比如这只猫,那棵树,我家的狗。这些都能说这个。
类,是一个抽象概念。例如鸭子有两条腿,这里的鸭子不是说哪只真正存在的鸭子,只指代鸭子这个概念。
重量,年龄这种属性,是实例属性。必须指出是哪一个才能讨论。
比如只能说我家的猫3岁,不能说猫是3岁。
静态成员
在一个成员前加static
修饰就会变成静态的。
实例成员对于每一个实例都是不一样的,他们互不干扰。
但是静态成员是跟随类的,所有实例访问到的都是同一份静态成员。
Cat cat1 = new Cat(30, 10);
Cat cat2 = new Cat(20, 20);
cat1.Show();
cat2.Show();
cat1.Weight = 60;
cat1.Height = 50;
Cat.Legs = 8;//静态成员必须直接通过类名访问
cat1.Show();
cat2.Show();//实例字段没有变化,但腿的数量变成了8
class Cat
{
public int Height;
public int Weight;
public static int Legs;
public Cat(int height, int weight)
{
Height = height;
Weight = weight;
}
public void Show()
{
Console.WriteLine("身高" + Height);
Console.WriteLine("体重" + Weight);
Console.WriteLine("腿的数量" + Legs);
Console.WriteLine("===============");
}
}
this
静态成员也可以有属性,方法,字段。但唯独不能有索引器。因为this
的含义就是当前实例。
索引器的语法this[ index]
的this就是表示你的变量,只是声明的时候不知道你变量叫什么。
在方法的内部可以通过this访问实例成员,通过类名访问静态成员。这在参数和成员同名时很有用。
class Dog
{
public int Height;
public int Weight;
public static int Legs;
public Dog(int Height, int Weight, int Legs)
{
this.Height = Height;
this.Weight = Weight;
Dog.Legs = Legs;
}
}
静态构造器
静态字段同样可以有只读字段,也同样只能在构造器里修改。
不同的时,静态字段的初始值可以使用其他静态成员参与表达式中。
会按照顺序赋值,还没有赋值的字段在表达式中会以默认值计算。
静态构造器会在这个类第一次被访问时(不是程序启动时)由.Net调用,
所以同样不能添加访问修饰符和参数。
静态成员先于所有实例创建。实例字段的初始值可以使用静态成员参与表达式。
class Duck
{
public int Height;
public int Weight;
public static readonly int Legs;
static Duck()
{
Legs = 2;
Console.WriteLine("鸭子有两条腿");
}
}
Console.WriteLine("还没有使用鸭子");
Console.WriteLine(Duck.Legs);
静态类
可以给类声明为静态,这样就无法创建他的实例。
无法创建实例的静态类,讲无法拥有任何实例成员,包括编译器自动添加的无参构造器。
一般来说都是一些只有方法的工具类才这样做。
static class HelloWorld
{
public static void Hello(string name)
{
Console.WriteLine("你好," + name);
}
}
扩展方法
在顶级(不是内部类)静态类中,方法的第一个参数可以添加this
进行修饰。
在使用this
修饰的类型的值时,可以像调用实例方法一样调用这个静态方法。
static class Tool
{
public static void Hello(ref this bool b)
{
b = !b;
Console.WriteLine(b + "被逆转了");
}
public static void Hello(this string s)
{
Console.WriteLine("你好" + s);
}
}
string s1 = "世界";
bool b1 = true;
s1.Hello();//像实例方法调用
b1.Reverse();//不需要添加ref,修改会作用到这个变量上
Tool.Hello(s1);//也能正常用静态方法调用。
Tool.Reverse(ref b1);//只有值类型才能声明ref扩展方法
运算符重载
使用operator
可以为这个类型定义运算符,一些规则如下
- 参数至少有一个是自己类型
- 对于二元运算,参数的顺序是有影响的(有些运算不满足交换律)
- 不能在双方类型定义相同顺序和类型的运算符,会出现歧义
- 必须有返回值,不能是void
- 一些运算符必须成对出现,但对于返回bool的,不要求互斥。
关于哪些运算符可以重载,请参阅之前的文章
class Speed
{
public int MetrePerSecond;
public Speed(int metrePerSecond = 0)
{
MetrePerSecond = metrePerSecond;
}
public static bool operator !(Speed L) => L.MetrePerSecond != 0;
public static int operator ~(Speed L) => -L.MetrePerSecond;
public static Speed operator +(Speed L, Speed R)
=> new Speed(L.MetrePerSecond + R.MetrePerSecond);
}
自增和自减
++
和--
要求返回值类型必须是自己。
当一个变量完成了++
或--
后,这个变量会执行一个赋值操作,
用这个运算符的返回值将他替换。
true和false
一个类型可以重载true
运算符,他将能作为条件,放入到if
,while
.三元运算符中作为条件。
不过,他还是不能直接给bool
变量赋值或是以其他形式当作bool
。
虽然true
运算符要求同时重载false
运算符,但false
的作用极其有限。
作为条件时只会使用true
运算符。false
运算符唯一的作用是
- 你需要重载
&
运算符 - 你的
&
运算符的返回值类型要和自己一样 - 然后你就能使用
&&
逻辑运算符,运算规则是false(x) ? x : x & y
自定义类型转换
类型转换使用implicit
(隐式),explicit
(显示)之一,加上operator
指定。
参数和返回值其中有一个是自身的类型。
class Electric
{
public static explicit operator Magnetism(Electric L) => new Magnetism();
}
class Magnetism
{
public static implicit operator Electric(Magnetism L) => new Electric();
}
转换没有传递性,但每一个隐式转换都可以以显示转换调用。
有必要的话可能需要使用这种方式转换(生物能)(化学能)(热能)电能
。
命名空间
定义命名空间
类同样不能重名。为了区分类,可以使用命名空间隔离他们。
命名空间的作用类似于文件夹。不同文件夹下的文件是可以同名的。
namespace Plain//郊外
{
namespace Castle//古堡
{
class Ghost
{ }//幽灵
}
class wildBoar
{ }//野猪
}
声明命名空间时可以一次性声明多层级的命名空间,使用.
隔开
namespace Plain.Castle
{
class Candle//诡异的蜡烛
{ }
}
使用文件命名空间,可以指定该文件下所有类都处于此空间中。
但不能再声明其他命名空间,或使用顶级语句。
namespace Plain.Castle;
完全限定名
在调用有重名的类或没有引用命名空间时,
需要带上他的完整命名空间名。
对于没有命名空间的,使用global::
(对,是两个冒号)表示根路径。
class Boo { }
namespace A.B.C
{
class Boo { }
}
调用:
global::Boo boo1 = new global::Boo();
A.B.C.Boo boo2 = new A.B.C.Boo();
引用命名空间
在文件的开头,或一个命名空间的类定义之前,可以使用using
引用命名空间。
引用命名空间后,在作用域内使用这些命名空间下的类不需要再写完全限定名。
namespace A.B.C
{
class Foo { }
}
using A.B.C;
Foo foo = new Foo();
类型别名
使用using
可以用类似变量赋值的操作,给一个类型指定一个别名。
namespace Gas
{
class CarbonDioxide { }
}
using CO2 = Gas.CarbonDioxide;
CO2 co2 = new CO2();
静态引用
使用static using
可以导入一个类型的所有静态成员,
在不点出他的类名的情况下使用他的静态成员。
using static System.Int32;//int关键字就是这个类型的类型别名
int int32 = Parse("32");
int max = MaxValue;
类的成员常量也会被认为是静态成员。
全局引用
使用global
修饰的命名空间引用,类型别名,静态引用,会作用到这个程序集下的所有文件。
global using System;
在你的控制台模板项目生成时,就带有一些默认的全局引用。
可以在你的编译器左上角看到他们。