如何编写更契合Unity的高效代码?

952 阅读12分钟

本文转自Unity Connect博主 郡墙

在Unity中的 Effectie C# 思想分享

尽可能使用属性(property),而不是直接访问数据成员

  • 属性允许将数据成员作为共有接口的一部分暴露出去,同时仍旧提供面向对象环境下所需的封装。属性这个语言元素可以让你像访问数据成员一样使用,但其底层依旧是使用方法实现的
  • 可以非常轻松的在get和set代码段中加入检查机制
  • 因为属性是用方法实现的,所以它拥有方法所拥有的一切语言特性
  • 属性增加多线程的支持是非常方便的。你可以加强 get 和 set 访问器(accessors)的实现来提供数据访问的同步
  • 属性可以被定义为virtual。
  • 可以把属性扩展为abstract。
  • 可以使用泛型版本的属性类型。
  • 属性也可以定义为接口。
  • 因为实现实现访问的方法get与set是独立的两个方法,在C# 2.0之后,你可以给它们定义不同的访问权限,来更好的控制类成员的可见性
  • 而为了和多维数组保持一致,我们可以创建多维索引器,在不同的维度上使用相同或不同类型。
  • 需要在类型的公有或保护接口中暴露数据

偏向于使用运行时常量(readonly),而不是编译时常量(const)

const

  • 应仅仅在那些性能异常敏感,且常量的值在各个版本之间绝对不会变化时,再使用编译时常量。
  • 编译时常量(const)的值会被目标代码中的值直接取代
  • 仅能用于数值和字符串

readonly

  • 在运行时进行求值。引用运行时生成的IL将引用到readonly变量,而不是变量的值。
  • 可以为任意类型。运行时常量必须在构造函数或初始化器中初始化,因为在构造函数执行后不能再被修改
  • 可以用readonly值保存实例常量,为类的每个实例存放不同的值。而编译时常量就是静态的常量。
  • 有时候你需要让某个值在编译时才确定,就最好是使用运行时常量
  • 标记版本号的值就应该使用运行时常量,因为它的值会随着每个不同版本的发布而改变。
  • const优于readonly的地方仅仅是性能,使用已知的常量值要比访问readonly值略高一点,不过这其中的效率提升,可以说是微乎其微的。

结论

综上,在编译器必须得到确定数值时,一定要使用const。例如特性(attribute)的参数和枚举的定义,还有那些在各个版本发布之间不会变化的值。除此之外的所有情况,都应尽量选择更加灵活的readonly常量。

推荐使用 is 或 as 操作符而不是强制类型转换

as和is操作符都不会执行任何用户自定义的转换,它们仅当运行时类型符合目标类型时才能转换成功,也不会在转换时创建新的对象

is

  • 检查一个对象是否兼容于其他指定的类型,并返回一个Bool值,永远不会抛出异常
  • as运算符对值类型是无效,此时可以使用is,配合强制类型转换进行转换。
  • 仅当不能使用as进行转换时,才应该使用is操作符。否则is就是多余的

as

  • 作用与强制类型转换是一样,但是永远不会抛出异常,即如果转换不成功,会返回null。
  • 相对于强制类型转换来说,as更加安全,也更加高效
  • 转换失败时会返回null,在转换对象是null时也会返回null,所以使用as进行转换时,只需检查返回的引用是否为null即可

使用条件属性(Conditional attribute)而不是 #if 条件编译

  • 由于 #if/#endif 很容易被滥用,使得编写的代码难于理解且更难于调试
  • 使用条件特性可以将函数拆分出来,让其只有在定义了某些环境变量或设置了某个值之后才能编译并成为类的一部分。Conditional特性最常用的地方就是将一段代码变成调试语句。
  • Conditional特性只可应用在整个方法上
  • 任何一个使用Conditional特性的方法都只能返回void类型。
  • 不能在方法内的代码块上应用Conditional特性。
  • 不可以在有返回值的方法上应用Conditional特性
  • Conditional特性的方法可以接受任意数目的引用类型参数
  • Conditional特性生成的IL要比使用#if/#Eendif时更有效率
  • 将其限制在函数层面上可以更加清晰地将条件性的代码分离出来,以便进一步保证代码的良好结构

理解几个等同性判断之间的关系

值类型

覆写Object.Equals()实例方法和operatior==(),以便为其提供效率更高的等同性判断

引用类型

认为相等的含义并非是对象标识相等时,才需要覆写Object.Equals( )实例方法。在覆写Equals( )时也要实现IEquatable<T>。

引用相等

如果两个引用类型的变量指向的是同一个对象

值相等

两个值类型的变量类型相同,而且包含同样的内容

public static bool ReferenceEquals (object left, object right)

判断两个不同变量的对象标识(object identity)是否相等。无论比较的是引用类型还是值类型,该方法判断的依据都是对象标识,而不是对象内容。

public static bool Equals (object left, object right)

用于判断两个变量的运行时类型是否相等。

public virtual bool Equals(object right)

用于重载

public static bool operator ==(MyClass left, MyClass right)

用于重载

注意

不应该覆写Object.referenceEquals()静态方法和Object.Equals()静态方法

了解 GetHashCode() 的一些坑

  • 基于散列(hash)的集合定义键的散列值时,此类集合包括HashSet<T>和Dictionary<K,V>容器等
  • 对引用类型来讲,索然可以正常工作,但是效率很低。对值类型来讲,基类中的实现有时甚至不正确。
  • 在.NET中,每个对象都有一个散列码,其值由System.Object.GetHashCode()决定
  • 实现自己的GetHashCode( )
  • 如果两个对象相等(由operation==定义),那么他们必须生成相同的散列码。否则,这样的散列码将无法用来查找容器中的对象。
  • 对于任何一个对象A,A.GetHashCode()必须保持不变。
  • 对于所有的输入,散列函数应该在所有整数中按随机分别生成散列码。这样散列容器才能得到足够的效率提升

理解短小方法的优势

  • C#代码翻译成可执行的机器码的步骤
  • C#编译器将生成IL,并放在程序集中
  • JIT将根据需要逐一为方法(或是一组方法,如果涉及内联)生成机器码
  • 短小的方法让JIT编译器能够更好地平摊编译的代价。
  • 短小的方法也更适合内联
  • 简化控制流程
  • 控制分支越少,JIT编译器也会越容易地找到最适合放在寄存器中的变量
  • 不仅体现在代码的可读性上,还关系到程序运行时的效率

选择变量初始化而不是赋值语句

  • 初始化器将在所有构造函数执行之前执行
  • 成员初始化器是保证类型中成员均被初始化的最简单的方法
  • 若是所有的构造函数都要将某个成员变量初始化成同一个值,那么应该使用初始化器。

正确地初始化静态成员变量

静态初始化器

  • 特殊的函数 (在其他所有方法执行之前以及变量或属性被第一次访问之前执行)
  • 只是需要为某个静态成员分配空间

静态构造函数

  • 复杂一些的逻辑来初始化静态成员变量
  • 可捕获异常

使用构造函数链(减少重复初始化逻辑)

示例说明

class Foo{private int id;private string name;public Foo() : this(0, ""){}public Foo(int id, string name){this.id = id;this.name = name;}public Foo(int id) : this(id, ""){}public Foo(string name) : this(0, name){}}

Foo a = new Foo(), b = new Foo(456,"def"), c = new Foo(123), d = new Foo("abc");SomeType x = new SomeType(), y = new SomeType { Key = "abc" },z = new SomeType { DoB = DateTime.Today };

[SeeAlso]

实现标准的销毁模式

  • 若某个引用类型(值类型无所谓)的局部变量用于被频繁调用的例程中,那么应该将其提升为成员变量
  • 为常用的类型实例提供静态对象
  • 创建不可变类型的最终值(比如string类的+=操作符会创建一个新的字符串对象并返回,多次使用会产生大量垃圾,不推荐使用。对于简单的字符串操作,推荐使用string.Format。对于复杂的字符串操作,推荐使用StringBuilder类)

区分值类型和引用类型

引用类型

  • class
  • 用来定义应用程序的行为
  • 对类型未来的用图不确定

值类型

  • struct
  • 无法实现多态
  • 最佳用途就是存放数据
  • 主要职责在于数据存储
  • 类型的公有接口都是由访问其数据成员的属性定义
  • 该类型绝不会有派生类型
  • 该类型永远都不需要多态支持

保证0为值类型的有效状态

自定义枚举值时

确保0是一个有效的选项

自定义标志(flag)时

  • 0定义为没有选中任何状态的标志(比如None)
  • 作为标记使用的枚举值(即添加了Flags特性)应该总是将None设置为0

保证值类型的常量性和原子性

不要盲目地为类型中的每一个属性都创建get和set访问器

限制类型可见性

emm.... 这个意会意会

通过定义并实现接口替代继承

抽象基类(abstract class)

  • 为一组相关的类型提供了一个共同的抽象
  • 描述了对象是什么
  • 可以为派生类提供一些具体的实现

接口(interface)

  • 一种契约式的设计方式
  • 一个实现某个接口的类型,必须实现接口中约定的方法
  • 描述了对象将如何表现其行为
  • 不能包含实现
  • 不能包含任何具体的数据成员

理解接口方法和虚方法的区别

  • 接口中声明的成员方法默认情况下并非虚方法,所以,派生类不能覆写基类中实现的非虚接口成员。若要覆写的话,将接口方法声明为virtual即可
  • 基类可以为接口中的方法提供默认的实现,随后,派生类也可以声明其实现了该接口,并从基类中继承该实现。
  • 实现接口拥有的选择要比创建和覆写虚方法多。我们可以为类层次创建密封(sealed)的实现,虚实现或者抽象的契约。还可以创建密封的实现,并在实现接口的方法中提供虚方法进行调用

用委托实现回调

  • 委托为我们提供了类型安全的回调定义。虽然大多数常见的委托应用都和事件有关,但这并不是C#委托应用的全部场合。当类之间有通信的需要,并且我们期望一种比接口所提供的更为松散的耦合机制时,委托便是最佳的选择
  • 委托允许我们在运行时配置目标并通知多个客户对象。委托对象中包含一个方法的应用,该方法可以是静态方法,也可以是实例方法。也就是说,使用委托,我们可以和一个或多个在运行时联系起来的客户对象进行通信
  • 由于回调和委托在C#中非常常用,以至于C#特地以lambda表达式的形式为其提供了精简语法。
  • 由于一些历史原因,.NET中的委托都是多播委托(multicast delegate)。多播委托调用过程中,每个目标会被依次调用。委托对象本身不会捕捉任何异常。因此,任何目标抛出的异常都会结束委托链的调用。

用事件模式实现通知

  • 事件提供了一种标准的机制来通知监听者,而C#中的事件其实就是观察者模式的一个语法上的快捷实现。
  • 事件是一种内建的委托,用来为事件处理函数提供类型安全的方法签名。任意数量的客户对象都可以将自己的处理函数注册到事件上,然后处理这些事件,这些客户对象无需在编译器就给出,事件也不必非要有订阅者才能正常工作。
  • 在C#中使用事件可以降低发送者和可能的通知接受者之间的耦合,发送者可以完全独立于接受者进行开发

避免放回对内部类对象的引用

  • 值类型。当客户代码通过属性来访问值类型成员时,实际返回的是值类型的对象副本。
  • 常量类型。如System.String。
  • 定义接口。将客户对内部数据成员的访问限制在一部分功能中。
  • 包装器(wrapper)。提供一个包装器,仅暴露该包装器,从而限制对其中对象的访问。
  • 若将引用类型通过公有接口暴露给外界,那么对象的使用者即可绕过我们定义的方法和属性来更改对象的内部结构,这会导致常见的错误。

仅用 new 修饰符处理基类更新

  • 使用new操作符修饰类成员可以重新定义继承自基类的非虚成员
  • new修饰符只是用来解决升级基类所造成的基类方法和派生类方法冲突的问题
  • new操作符必须小心使用。若随心所欲的滥用,会造成对象调用方法的二义性。

原文链接:connect.unity.com/p/efficient…

戳上方链接,下载Unity官方app,话题群里,在线技术答疑,还有更多干货等你来发现~