虽然在很多地方GDScript看起来并不像典型的面向对象语言,但是总的来说它的接口还是Godot API都比较面向对象。只不过作为一门为了让开发者能够快速上手的语言它有意无意地弱化了很多面向对象的设计。我们先来复习一下GDScript中是怎么对待类的。
我们新建脚本总是从一个GDScript文件开始,同时还要选择一个基类。实际上来说,一个脚本中编写的代码就定义了一个类,脚本文件本身也是Godot的一种资源(Script继承了Resource)。
不过只是这样并不能让它表现得像一个传统面向对象语言中的一个类那样,比如我们还不能让它出现在类型标注中。为了让一个脚本可以当成一个类型用,我们得在脚本中写一句:
class_name Player
var player: Player
当然,你标不标这个类型很多时候也不影响代码的执行,毕竟GDScript的静态类型主要还是为了做静态类型检查和代码补全,它并没有那么严格的类型系统。
GDScript也有class关键字,可以定义“类”。但是这个类实际上只是定义在脚本内部的一个所谓内部类(inner class),用起来有一点点别扭。如果要在其它类(脚本)中使用这个类作为类型,那么需要先把定义这个内部类的脚本写上class_name,然后通过脚本的类名拿到这个类,共享起来有点麻烦。由于GDScript没有命名空间,也没有静态类,要组织一系列这种内部类的话,可能就需要注册一个Autoload/Global脚本了。
如果要真的模拟一个“正常的”数据类(非节点类),你能做的其实比较有限。你需要单独建一个脚本然后选择更抽象的基类,比如RefCounted(可以让你免于手动管理内存),然后再用class_name把它作为类型暴露出来。
在C#中,“类就是类”,教科书上的东西这里就不过多重复了。但还是“简单说两句”:
或者更加粗野地说:类就是一种复合数据类型,把一堆变量放在一起然后假装它就是一个东西(object),然后用语法糖把一些可以操作这些东西的函数包装成可以通过这个东西直接调用并修改那些变量。
但是还是要严谨一点说的是,上面说这些只针对一个“类”本身,而不能概括整个“面向对象编程”。面向对象编程(太长了,懒得打,后面可能回合OOP交替使用),本身不止一种基于类的范式,而且如果你要原教旨主义一点,现在的基于类的范式和早期的“消息传递”机制也差得很远。
言归正传。你可以在单独的C#源文件中新建一个类,也可以直接在你的Program.cs中编写一个类(记得放在可执行语句之后):
class Player {}
这就是一个合法的、最简单的类,没有任何成员——成员就是在类中定义的各种变量、方法、常量等元素。成员需要通过类或者对象通过“成 员 访 问 运 算 符”(说白了就是点.)来访问。
C#不需要你一个文件定义一个类。当然,出于代码整洁和可维护性方面的考量,你也可以这样做。IDE中创建新类之类的选项一般也会直接给你创建一个和类名同名的源文件。
前面提到过,为了方便开发者,现在用dotnet命令行工具直接新建项目会默认在Program.cs中使用顶级语句。如果你新建了另一个文件,你就没法随便写一些可执行语句了。如果你这样做了,就会收到8802错误,意思就是说一个项目中只能有一个编译单元(可以理解为C#源文件)有顶级语句。
不使用顶级语句的源文件中,实际上只能有各种类型定义。
如果你使用的是各种IDE内置的选项新建一个类,你可能会在对应的源文件中发现这样的代码:
namespace MyProject;
这个东西是命名空间的声明,表明此文件中定义的各个类型都属于这个命名空间。
命名空间,或称名字空间(namespace)主要是用来组织代码(或者说类型),避免重名。
例如你刚刚定义了一个表示玩家的Player类,然后你用了一个第三方库里面有一个播放音乐的Player类,这就会造成命名冲突。命名空间可以避免这个问题。在一个命名空间中无法直接用类名来访问定义在另一个命名空间中的类型。
较早的C#中要求命名空间声明之后必须跟一个代码块,代码块内部的各元素才属于这个命名空间。当然这种语法现在仍然有用。如果你需要在同一个文件中声明不同的多个命名空间,就必须用花括号。
命名空间其实更多地是为了便于组织代码结构,没有那么多严格的要求。你可以在同一个文件中多次使用同一个命名空间来囊括类型,也可以在不同文件中使用同一个命名空间。
namespace UI
{
class Foo{}
}
namespace UI.Components
{
class Bar
{
Foo foo;
}
}
层次比较深的命名空间中,使用同一命名空间链条上的、其它命名空间中的类型,不需要打出全名。
一旦一个类型归属于某个命名空间,它的全名实际上就变成了命名空间.类名。在其它命名空间中使用这个类型时你就必须打出它的全名。
当然,这很麻烦。所以有using关键字来帮忙。以.NET标准库中JSON转换方法为例,如果想把一个对象转化为JSON字符串,你需要用到JsonSerializer的Serialize方法,它的全名是:
System.Text.Json.JsonSerializer.Serialize
JsonSerializer位于命名空间System.Text.Json之中,写全的话太长了。如果在源文件顶部写上:
using System.Text.Json;
你就可以只写JsonSerializer.Serialize。
System是一个比较特殊的命名空间,.NET、C#标准库中的各种东西都在这里面,因此在C#中这个命名空间是默认using了的,你不用单独写出来就可以直接免去System使用里面各种东西。比如int本质上是.NET基本类型System.Int32的别名。
C++程序员注意,C#不需要using namespace 命名空间;。
类似于在GDScript中使用Player.new()这种语法来构造一个类的实例,在C#(以及很多其它OOP语言)中使用new关键字来实例化(instantiate)一个类:
Player player = new Player();
你可以已经知道这实际上是在调用所谓的构造函数(GDScript中表现为特殊方法_init),后面马上会讲到如何定义。
在比较现代的C#中,实例化类时如果可以推断出类型就可以省略类名:
var p = new Player(); // 用var声明变量
Player pp = new(); // pp声明为Player类型,调用构造函数时省略类名
var p = new(); // 错误!
类似于GDScript中给一个脚本用var定义若干变量(属性),C#类也可以:
class Player
{
int id;
string name;
}
var p = new Player();
p.id = 1;
然鹅,你的编辑器不仅没有提示你id这个东西,甚至还报错了,表示id无法访问。
实际上传统一点的OOP语言会对类的成员(字段、属性、方法啥的)的访问级别(access level)进行控制。也就是说,某个成员能否在类之外、能被哪些类访问。之所以要这么做是因为根据面向对象编程的一些指导原则,我们应该尽可能地维持类的封装,减少对外部的暴露,但又维持一定的可扩展性。
在C#中,如果一个成员没有指明访问级别,那么就默认为private(私有)。也就是说这个成员无法在类外部被访问。上面的字段等价于声明为:
private int id;
如果你希望直接访问这个字段,那么就需要用public(公共)级别,表示在任何地方都可以访问它。
C#中也可以有静态成员,也和很多编程语言一样用static关键字。静态意味着它不属于具体的实例,而是属于整个类。有些地方也叫类字段:
Console.WriteLine(Player.playerCount);
class Player
{
public int id;
public string name;
public static int playerCount;
}
在类外部,静态成员需要通过类名来访问,在类内部则不需要。
构造“函数”(constructor)在一个面向对象语言中说起来貌似有点怪,但是这个基本上是约定俗成的译法了,如果你要说成“构造器”貌似又有点拗口。
构造函数顾名思义用于构造一个类的实例——但是这个名字实际上有一定的误导性。因为构造函数并不负责划一块内存并放入各种东西,你在这里做得最多的事其实就是初始化对象各个实例。要说真正负责分配内存、构造一个对象的不如说是new运算符的具体实现。所以说,其它语言中采用__init__、_init、init之类的名字可能更贴切:initialize,就是初始化。
正如你在上面看到的,定义类时可以不定义构造函数,你仍然可以用new Player()的方式构造一个实例。此时相当于调用了一个无参构造函数。构造函数就是在类定义中定义的一系列特殊函数,它们没有返回值,名称为类名:
class Player
{
public int id;
public string name;
public Player(int id, string name)
{
this.id = id;
this.name = name;
}
}
除了名称和返回类型特殊之外,其它和定义一般的函数没有区别。
一旦定义了有参数的构造函数,就无法调用无参数的构造函数——除非你自己定义了一个无参构造函数。
this关键字类似于GDScript中的self,表示“这个实例”。this在绝大部分时候都可以省略,上面的代码中我写了出来是因为构造函数的参数和字段重名了。
在类中声明字段时,可以立即给它们进行初始化。特别是对应类型的默认值不是你想要的值时。
class Player
{
public int id = -1;
public string name = "Anonymous";
// ...
}
这些初始化代码和构造函数是独立的,初始化语句会先于自己的构造函数执行。
你可以给类定义多个参数列表不同的构造函数。“参数列表不同”指的是参数个数和/或参数类型不同,只要调用时没有歧义即可:
class Player
{
public int id;
public string name;
public static int playerCount = 0;
public Player(string name)
{
this.id = playerCount++;
this.name = name;
}
public Player(int id, string name)
{
this.id = id;
this.name = name;
}
}
这里定义了一个只需要传入玩家名字的版本。其id根据当前创建过的玩家实例数量来确定。
马上会讲到,这本质上是利用有关方法的一个特性实现的。
定义在类里面的“函数”就叫方法。在面向对象编程中,一个对象就是状态(字段)和行为(方法)的结合体。行为可以修改对象的内部状态。
class Player
{
// 省略字段定义
public void Greet()
{
Console.WriteLine($"Hello! I'm {name}!");
}
}
方法是类成员的一种,也需要指明访问级别。方法可以直接访问类中定义的其它成员,没有歧义的时候也不用加this。
方法同样可以是静态的,也就是说它不访问具体实例的成员(不修改某个对象的内部状态)。静态方法在类外同样需要通过类名调用,类内部可以直接调用。
重载(overload)听起来很炫酷,但是实际上意思就是允许同一个名字的方法可以有多个参数类型不同的版本。例如Console.WriteLine在.NET 9中有19个不同的版本。 很多语言实际上不支持方法/函数重载,也没啥问题,主要是取名字比较麻烦。
类和成员一样可以是静态的。静态类的特殊之处在于它不能被实例化,它只能包含静态成员和常量。
这样的类一般是一些工具类,常用于容纳一些不需要维护内部状态的方法。例如我们一直在用的Console.WriteLine就是静态类Console的一个静态方法。
另一(二)个比较常用的静态类是System.MathF和System.Math。里面包含了很多常用的数学常量和函数,比如E、PI、各种三角函数。区别在于MathF以float为返回值,另一个是double。
如果直接使用字段,我们实际上无法精细的控制字段的访问。如果标记为private,外部无法修改它,但是也没法读取它的值;如果标记为public,外部虽然可以读取它了,但是也就意味着可以随意修改它。还有就是我们无法在访问字段时执行一些额外的代码,完成一些额外的工作。
聪明的你可能想到了,我们可以直接写一个public方法来提供对private字段的访问:
// Player的定义中
// 假设id和name为private的字段
public int GetId() => id;
public void SetName(string newName)
{
Console.WriteLine($"Player {id} changed name from {name} to {newName}");
name = newName;
}
public string GetName() => name;
只给id编写了一个GetId方法,那么就相当于它是一个只读的东西。而name是外部可读可写的。
实际上在很多语言中就是这么干的,这些方法被称为getter和setter。别忘记了,我们在GDScript中可以写:
var name: String:
set(value):
name = value
get:
return name
C#中也有类似的语法,那就是属性(property):
private string name;
public string Name
{
get
{
return name;
}
set
{
Console.WriteLine($"Player {id} changed name from {name} to {value}");
name = value;
}
}
属性的定义的头部和字段类似,有访问级别、类型、名称。紧接着是一个代码块,里面可以包含两部分内容,也就是getter和setter。getter以get关键字开头,它实质上就是一个返回类型和属性类型匹配的无参方法,它的主体可以是任何内容。
类似地,setter以set开头。你可能会问,setter应该是一个“带有一个参数,且类型为属性类型的方法”,那么它的参数呢?请看value。你可能在GDScript中编写setter的时候按照惯例将它的参数取名为value,实际上在C#中,具体到setter中,value是一个关键字(这是一个所谓“上下文关键字”,也就是说在具体的语法结构中才会被识别为关键字)。它就是给属赋值时收到的值。
var p = new Player();
p.Name = "John";
Console.WriteLine(p.Name);
属性在使用方法控制字段存取的同时,可以保持类似直接使用字段的语法。
根据具体的需要,属性定义可以大幅简化。如果只是单纯想写一个可读可写的属性你可以直接:
public string Name { set; get; }
你甚至不需要定义一个对应的字段,setter和getter的主体都可以省略,此为自动实现的属性(automatically implemented property)。
实际上,getter和setter一样可以设置访问级别:
public string Name { private set; get; }
属性和字段一样,可以自己设置初值。虽然看起来有点别扭,但是你直接把初始化的赋值语句放到花括号后面即可:
public string Name { set; get; } = "Anonymous";
给GDScript的脚本属性写getter和setter时有个好处就是,你不需要单独定义一个实际保存属性数据的单独的变量(这种变量/字段称为backing field),属性的setter中可以直接给这个属性赋值而不会发生无限递归:
var username: String = "name":
set(value):
print("set username")
username = value
get:
print("get username")
return username
func _ready() -> void:
username = "Abc"
print(username)
这样的代码在Godot中是没有任何问题的。在getter和setter中都是直接访问属性本身而不需要(如果必要你可以)额外定义另一个变量。
public string Username
{
set(value) => Username = value;
// 略
}
但是类似的代码在C#中会造成无限递归。也就是说,尝试设置Username时会调用setter,然后在setter中设置Username的话又会调用setter,就这样无限递归下去会直接导致异常。因此只要不是自动属性的话就一般情况下就还是需要定义一个字段,当然你在类内部可以直接访问字段。
实际上在未来很有可能可以像GDScript一样不单独定义一个字段。实际上在目前.NET 9.0 SDK中,如果手动编辑项目文件设置语言版本为preview就可以用到这个特性:
public int Prop
{
get;
set
{
field = value;
}
}
这里getter直接省掉了,然后在setter中直接用field关键字指代backing field。
你可能会看到计算属性(computed property)这种说法。
class Circle
{
public float Radius {private set;get;}
public float Area => Radius * Radius * MathF.PI;
}
也就是说某个属性的值实际上单纯就是对象其它的属性算出来的,因此这种属性首先是一个只读属性。对于只读属性,可以直接用表达式主体写法直接返回一个表达式。注意和用表达式主体定义的方法进行区分,用表达式主体定义的无参方法也得有一个括号表示参数列表,只读属性没有。
类和方法一样也可以是泛型的。比如有这样一个盒子,什么都可以装:
class Box<T>
{
private T value;
public T Value => value;
public Box<T>(T value)
{
this.value = value;
}
}
类似于泛型方法,泛型类也用<T>的形式指明一个类型参数。
这里只简单介绍一下,我们会在后面看到真正的泛型类到底是怎么用的。
评论区
共 条评论热门最新