用Typescript在编译时捕捉运行时的错误

158 阅读8分钟

在编译时抓取运行时错误,用--Typescript?

你好,我是Domagoj Cerjan。我在Oradian工作,这是一家SaaS公司,使世界上一些最贫穷和偏远地区的金融机构能够获得将其业务转移到云端的好处。我是前端团队的一员,警惕地用FP来烦扰我的同事,并涉猎所有的东西Typescript、React和Redux。在空闲时间,我开发3D渲染引擎,因为这是我熟悉的领域,所以我经常尝试不同的语言和方法来解决同一个问题,一遍又一遍。

介绍

今天,nVidia的高端GPU可以做实时的光线追踪,开源的AMD GPU驱动摇身一变,跨平台游戏更像是一个现实而不是梦想。有几件事情仍然和几十年前一样--3D图形是C/C++等语言一直以来并且仍然几乎完全用于编写游戏引擎的领域,GPU接口API仍然很多--从古老而久经考验的OpenGL和DirectX 9-11,到所有现代而极低级的Vulkan、DirectX 12和苹果的Metal。

虽然C和C++确实为我们提供了编写真正快速的程序的能力,但它们在其他很多方面都有所欠缺,从内存安全、可调试性和易用性到可移植性和摇摆不定的类型系统--尽管人们可以用C++模板创造奇迹,而模板本身就是一种具有奇怪语法的纯FP语言,但它们并不是人们可以在一两个星期内学会的语言。

考虑到跨平台,人们可以争辩说,只有一个平台是每个人都可以使用的,从任何风味的Linux、Unix或Windows,无论是在手机、平板电脑或个人电脑,甚至洗衣机和Roomba的(因为物联网)。当然,这个平台就是网络。为网络开发3D加速游戏已经不是什么新鲜事了,WebGL已经存在了一段时间,最新的Firefox、Chrome和Opera已经支持WebGL 2.0,而且已经有了Three.js这样久经考验的引擎,或者甚至可以使用emScripten将任何C/C++引擎或多或少移植到网络上。

但是,如果我们想用更现代的东西来写一个引擎,特别是以网络浏览器为环境,并试图使它尽可能的健壮,那该怎么办?好吧,我选择了Typescript,因为它有相当强大的类型系统、令人敬畏的工具和与IDE的惊人集成(emacs通过tide,VSCode本身,......),以及WebGL 2.0,因为这个引擎是我的玩具,可能永远也看不到光明,所以只支持几个网络浏览器没有任何问题。我故意不在这里使用Idris,尽管它可以编译成Javascript,因为我想看看我可以把Typescript的类型系统推到什么程度。

问题所在

在冗长的介绍之后,我想说的是--WebGL 2.0 API仍然是一个低级别的API,涉及到一堆字节缓冲区,非常具体的规则,什么值可以进入函数的哪个参数,什么时候可以调用,以什么顺序调用。对于任何一个习惯于写和/或看纯函数式代码的人来说,这都是一团糟。就其本质而言,3D APIs是离纯函数式最远的。你毕竟是在和外部世界打交道;)

说到这些规则,它们通常来自于WebGL规范中定义的表格,这些表格定义了哪些参数组合对某个API函数有效。这篇文章将讨论如何使一个最常用的函数--一个将图像上传到纹理的函数--gl.texImage2D ,避免因不遵守这些规则而导致的运行时错误。

解决方案

首先要了解什么会出错,让我们看一下gl.texImage2D 的签名:

void gl.texImage2D(
  target: GLenum, // Of interest
  level: GLint,
  internalformat: GLenum, // Of interest
  width: GLsizei,
  height: GLsizei,
  border: GLint, // **Must** be 0
  format: GLenum, // Of interest
  type: GLenum, // Of interest
  pixels: ArrayBufferView, // Of interest
  srcOffset: GLint
 ) // _May_ throw DOMException

为了简单起见,我们只看一下internalformatformat ,因为应用在它们身上的东西可以很容易地应用到targettypepixels

所说的函数参数formattype 的值取决于internalformat 参数的,而这一点仅从函数原型中是看不到的。要知道哪些的组合是合法的,我们必须看一下规范,并仔细制作代码,以便通过遵守这里的表格定义的规则,使它永远不会把无效的值传递给gl.texImage2D

现在,依赖类型是一个自然的解决方案,可以不写一堆运行时的代码来确保没有无效的值被传递下来。值得庆幸的是,从Typescript 2.8开始,Typescript实际上已经有了一种依赖类型的系统。

类型的条件类型

假设我们有一个渲染器,能够将Mesh,LightParticleEmiter 对象渲染到屏幕上:

type Renderable = Mesh
                | Light
                | ParticleEmiter

const renderer = (context: WebGLRenderingContext) =>
  (renderables: Renderable[]): void =>
    { ... }

现在,一段时间过去了,我们得到了另一个渲染器可以处理的实体--Gizmo ,但前提是它是由WebGL 2.0上下文创建的。我们如何在不进行讨厌的运行时检查和抛出异常的情况下处理它呢?

欢迎在场景中使用条件类型:

type WebGL1Renderable = Mesh
                      | Light
                      | ParticleEmiter

type WebGL2Renderable = WebGL1Renderable
                      | Gizmo

// actual conditional type
type Renderable<Context> = Context extends WebGL2RenderingContext ? WebGL2Renderable
                         : Context extends WebGLRenderingContext  ? WebGL1Renderable
                         : never

然后通过以下方式强制执行上下文类型

type ValidContext = WebGLRenderingContext
                  | WebGL2RenderingContext
                  | Canvas2dRenderingContext

const renderer = <Context extends ValidContext>(context: Context) =>
  (renderables: Renderable<Context>[]): void =>
    { ... }

作为额外的补充,如果我们将Canvas2dRenderingContext 作为上下文提供给never ,将导致额外的编译类型错误。renderer

const webgl1renderer = renderer(canvas.getContext('webgl'))
webgl1renderer([Mesh('a mesh'), Light('a light')])
// no error
webgl1renderer([Mesh('a mesh'), Light('a light')]), Gizmo('a gizmo')])
// error -> Gizmo is not found in the type union of WebGL1Renderable

const canvas2drenderer = renderer(canvas.getContext('2d'))
canvas2drenderer([Mesh('a mesh'), Light('a light')])
// error -> Mesh and Light do not have anything in common with 'never'

条件类型是一个强大的工具,它给了我们忽略一些运行时错误的自由,因为编译器甚至不允许我们写一个首先会导致上述运行时错误的代码路径。但是我们有一个问题,我们想对我们的gl.texImage2D 函数的而不是类型进行类型检查。

好吧,我们现在需要欺骗一下了。欢迎来到typescriptenum 。这是字面价值唯一同时存在于类型领域的语言结构。

Typescript Enums

像许多其他语言一样,Typescript支持enums 。 Typescript的enum ,只不过是一个键值的字典,默认情况下,除非有不同的指定,否则值都是从0 开始的数字字符串。为了将GLenum ,也就是无符号整数引入类型领域,我们将创建一个enum

注意:这现在变得很乏味,因为它归结为通过枚举将表格中定义的内容复制到代码中。

对于internalformat 参数,我们使用这个枚举:

enum PixelInternalFormat {
  // Sized Internal Formats
  // rgba
  RGBA4   = 0x8056,
  RGBA8   = 0x8058,
  RGBA8I  = 0x8D8E,
  RGBA16I = 0x8D88,
  ... // and so on and so on

对于format 参数,我们使用这个枚举:

enum PixelFormat {
  LUMINANCE_ALPHA = 0x190A,
  LUMINANCE       = 0x1909,
  ALPHA           = 0x1906,
  RED             = 0x1903,
  ... // and so on and so on
}

对于type 参数,我们使用这个枚举。

enum PixelType {
  UNSIGNED_SHORT_5_6_5 = 0x8363,
  BYTE                 = 0x1400,
  FLOAT                = 0x1406,
  ... // and so on and so on
}

把它放在一起

有了条件类型和一堆枚举,我们现在拥有了实现一个函数所需要的一切,这个函数在试图向internalformatformattype 参数传递错误的数值组合时,会引起编译时类型错误。

实现AllowedPixelFormat 依赖的类型:

type SizedRGBAPixelFormat = PixelInternalFormat.RGBA8
                          | PixelInternalFormat.RGBA16F
                          | PixelInternalFormat.RGBA32F
                          | ... // and a bunch more

type AllowedPixelFormat<P extends PixelInternalFormat> =
  P extends SizedRGBAPixelFormat ? PixelFormat.RGBA :
  P extends SizedRGBPixelFormat  ? PixelFormat.RGB  :
  P extends SizedRGPixelFormat   ? PixelFormat.RG   :
  P extends SizedRedPixelFormat  ? PixelFormat.RED  :
  ... // and a bunch more
  never

和安全函数包装器:

const safeTexImage2D = <
  InternalFormat extends PixelInternalFormat,
  Format         extends AllowedPixelFormat<InternalFormat>,
  Type           extends AllowedPixelType<InternalFormat>,
  Buffer         extends AllowedPixelBuffer<Format, Type>,
>(gl: WebGL2RenderingContext) => (
  pixelInternalFormat: InternalFormat,
  pixelFormat:         Format,
  pixelType:           Type,
  width:               Int,
  height:              Int,
  pixels:              Buffer
): void =>
  gl.texImage2D(
	gl.TEXTURE_2D, // target, for simplicity just set to gl.TEXTURE_2D
    0, // level
    pixelInternalFormat, // internalformat
    width, // width, shocking
    height, // height, also shocking :)
    0, // border which must be 0 because specs
    pixelFormat, // pixel format dependant on pixelInternalFormat
    pixelType, // pixel type dependant on pixelInternalFormat
    pixels, // pixels buffer type dependant on both pixelFormat and pixelType
    0, // offset
  )

现在剩下的就是调用这个安全的封装函数,见证Typescript中依赖类型的神奇之处:

const texImage2D = safeTexImage2D(canvas.getContext('webgl2'))

// legal
texImage2D(PixelInternalFormat.RGBA, PixelFormat.RGBA, PixelType.UNSIGNED_BYTE, 256, 256, validBuffer)
// ilegal
texImage2D(
  PixelInternalFormat.RG16F, // constraint to FLOAT | HALF_FLOAT comes from here
  PixelFormat.RG,
  PixelType.UNSIGNED_BYTE, /*
  ^---------------------- Argument of type 'PixelType.UNSIGNED_BYTE' is not assignable to
                          parameter of type 'PixelType.FLOAT | PixelType.HALF_FLOAT' */
  256,
  256,
  validBuffer, // has to be exactly 256  *  256  *  2  *  (2 | 4) bytes big
               // 2 channels from PixelFormat.RG ---^     
               // 2 or 4 bytes from half or float --------^
)                                                      

结论

通过应用同样的方法,我们可以在不安全的函数之上创建安全的封装函数,这些函数不仅来自WebGL,而且来自其他常用的API,其复杂的规则从函数签名中无法立即看到。在大型代码库的世界里,能够证明代码在编译时不会落入可能导致数周头痛的代码路径,这是无价之宝,应该得到赞赏。

即使是Javascript的类型化方言Typescript也在其类型系统中走向依赖类型,这一事实有力地说明了依赖类型是未来的趋势。

记住,要在编译时抓取错误,而不是在运行时 :)