Sorbet - Ruby的静态类型检查器

509 阅读8分钟

Sorbet - Ruby的静态类型检查器

Sorbet是一个类型检查器,现在正在加入鸭子类型的Ruby土地上的羊群。在这篇文章中,我们将探讨为什么我们需要静态类型检查,以及纳入它的意义是什么。

什么是类型系统?

类型是程序员如何将意义赋予原始的比特序列,如程序中的值、常量或一些对象,也有助于语言的编译器/解释器相应地分配内存。

类型安全被用来对编程语言中使用的各种类型进行约束。这有助于抓住不兼容的类型之间的操作。这些检查要么在编译时进行--静态类型检查,要么在运行时进行--动态类型检查

为了执行类型安全,语言需要一个类型系统,它被定义为编程语言的编译器/解释器的一部分。基于编译器/解释器执行类型安全检查的严格程度,决定了一种语言是强类型还是松散类型。

Ruby在这一切中的地位如何?

Ruby是一种动态和强类型的语言。正是它使我们能够做鸭式类型或元编程能力,而这正是我们喜欢Ruby的原因。

Ruby的强类型特性帮助我们执行类型安全。但是这些类型的错误只能在运行时被发现。由于这个原因,我们一直依赖测试来帮助我们捕捉它们。

在Ruby中,当有一个巨大的代码库和一个庞大的合作团队时,某些痛点会出现:

  1. 隐藏的bug:代码中的错误,如:没有找到方法,提供了无效的参数,未初始化的常量错误,等等,只能在运行时发现。而使用静态类型检查器,我们甚至可以在程序执行前发现它,从而节省时间。
  2. 明确的文档:在处理无文档的代码时,程序员通常会有这样的困惑:这个方法需要什么参数,它能返回什么?变量是什么类型的?即使是有文档的代码,也有可能会出现不同步的情况。静态检查器在代码中执行类型,这样就可以很容易地避免:种混乱。
  3. 代码重构:为了捕捉重构周期后的错误,我们必须依靠测试来确保没有任何破坏。如果有什么破绽,我们只能在运行时知道它。但是有了静态检查器,重构周期就变得容易了,因为程序员在改变接口时可以放心,类型检查器会捕捉到程序中与更新的接口不一致的任何部分。

此外,解释器可以利用指定的显式类型来产生更好的优化机器代码,IDE可以实现自动完成功能。

为了解决这些痛点和因没有静态类型检查器而丧失的生产力,我们必须依赖一个精心设计的测试套件,它不能保证100%的类型安全,或者考虑用一种能解决这些问题的语言重写。重写在大多数情况下可能并不实用,因为它并不倾向于实际的商业目标。

进入Sorbet

一个渐进式的类型系统,可以逐步采用,以便在你的代码中引入静态类型检查。你可以在开发其他功能的同时,开始向代码库的现有部分添加类型检查。

采用Sorbet

在你的Gemfile中添加Sorbet命令行界面和运行时的这两个宝石:

gem 'sorbet', :group => :development
gem 'sorbet-runtime'
> bundle install

现在通过运行该命令为项目初始化Sorbet:

> srb init

这将创建以下目录并需要进行版本控制:

sorbet/
│ # Default options to passed to sorbet on every run
├── config
└── rbi/
    │ # Community-written type definition files for your gems
    ├── sorbet-typed/
    │ # Autogenerated type definitions for your gems
    ├── gems/
    │ # Things defined when run, but hidden statically
    ├── hidden-definitions/
    │ # Constants which were still missing
    └── todo.rbi

配置文件包含简单的选项和参数,以传递给命令srb tc (它静态地检查代码的类型)。

RBI文件是 "Ruby接口 "文件。Sorbet使用RBI文件来学习常量、祖先和以它不理解的方式定义的方法。这些文件是自动生成的,但也可以是手写的。你可以从官方文档中了解更多关于RBI文件的信息。

sorbet-typed 是一个文件夹,包含了从社区驱动的中央资源库中取出的宝石的RBI文件。

对了!你就可以开始对你的代码进行类型检查了。

类型检查你的代码

这一切都始于一个神奇的注释# typed: ,sorbet团队称之为sigils。这将被添加到要进行类型检查的文件中。有各种不同的严格程度srb ,以决定什么要报告,什么要沉默。

我们可以从以下几点开始# typed: true

# typed: true ,通常被称为**"类型错误 "**的东西被报告。这包括调用一个不存在的方法,调用一个参数数不匹配的方法,使用变量时与它们的类型不一致,等等。

一个例子

让我们考虑一个愚蠢的例子来展示sorbet的能力:

# typed: true

class Farm
  def initialize(animals = [])
    @animals = animals
  end

  def all_speak
    make_animals_speak(@animals)
  end

  def animal_count
    @animals.length
  end

  def insert_animals(animals)
    animals.each { ||animal| @animals << animal }
  end

  private

  def make_animals_speak(animals)
    if animals
      animals.each { |animal| p animal.speak }
    else
      p 'awkward silence.. 😪'
    end
  end
end

class Duck
  def initialize
    @speech = 'quack'
  end

  def speak
    @speech
  end
end

class Cow
  def initialize
    @speech = 'moo'
  end

  def speak
    @speech
  end
end

class Dog
  def initialize
    @speech = 'woof woof'
  end

  def speak
    @speech
  end
end

farm = Farm.new
farm.speak # undefined method 'speak'

ducks = Array.new(3) { Duck.new }
cows = Array.new(2) { Cow.new }
dogs = Array.new(1) { Doggo.new } # uninitialized constant Doggo

farm.insert_animals(ducks, cows, dogs) # wrong number of arguments (given 3, expected 1)
farm.speak
p "count: " + farm.animal_count # no implicit conversion of Integer into String (TypeError)

如果我们只运行这个程序,那么我们会发现农场对象的错误undefined method 'speak' 。一旦我们纠正了这个错误,接下来就会出现下一个错误uninitialized constant Doggo 。哎呀,错过了这一点,真是太傻了。我们解决了这个问题,但当我们运行程序wiz.wrong number of arguments (given 2, expected 1) ,又发现了下一个错误。

*'但是,嘿,发现这些错误是多么的乏味啊?我们很早就开始做这种调试了,我们保持测试以确保我们程序意图的正确性。-*人们可以争辩说。

然后在命令行上,我就会输入:

> srb tc

sorbet-example.rb:56: Unable to resolve constant Doggo https://srb.help/5002
    56 |dogs = Array.new(2) { Doggo.new } # uninitialized constant Doggo
                              ^^^^^
    sorbet-example.rb:41: Did you mean: Dog?
    41 |class Dog
        ^^^^^^^^^

sorbet-example.rb:52: Method speak does not exist on Farm https://srb.help/7003
    52 |farm.speak # undefined method 'speak'
        ^^^^^^^^^^

sorbet-example.rb:58: Too many arguments provided for method Farm#insert_animals. Expected: 1, got: 3 https://srb.help/7004
    58 |farm.insert_animals(ducks, cows, dogs) # wrong number of arguments (given 3, expected 1)
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    sorbet-example.rb:16: insert_animals defined here
    16 |  def insert_animals(animals)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^

sorbet-example.rb:59: Method speak does not exist on Farm https://srb.help/7003
    59 |farm.speak

Errors: 4

在不得不运行程序之前,所有的错误都已经很好地列出来了。在我们的代码库中采用这种方法,我们可以真正受益,并且使我们的测试更加关注程序的行为。但是,等等,这些都是简单的语法和常数解析错误。为了从这个宝石中得到真正的好处,我们需要签名

使用签名的运行时检查

签名是简单的Ruby代码,它被添加到一个方法之上,作为一个契约。为了使用签名,我们需要在我们各自的类或模块上添加extend T::Sig

签名是由可选的参数和需要指定的返回类型组成的:

sig {params(x: SomeType, y: SomeOtherType).returns(MyReturnType)}

这类注释(以增加程序的冗长性为代价)可以帮助我们捕捉类型错误,并为方法增加强制文档。这可以进一步用于IDE的自动完成和即时类型检查反馈,并被解释器利用来产生更好的优化机器代码。

让我们考虑一下前面的程序,并将签名添加到类Farm

class Farm
  extend T::Sig

  sig { params(animals: Array).void }
  def initialize(animals = [])
    @animals = animals
  end

  sig { void }
  def all_speak
    make_animals_speak(@animals)
  end

  sig { returns(Integer) }
  def animal_count
    @animals.length
  end

  sig { params(animals: Array).void }
  def insert_animals(animals)
    animals.each { |animal| @animals << animal }
  end

  private

  sig { params(animals: Array).void }
  def make_animals_speak(animals)
    if animals
      animals.each { |animal| p animal.speak }
    else
      p 'awkward silence.. 😪'
    end
  end

现在我们已经指定了类型信息,让我们看看类型检查的情况:

> srb tc

sorbet-example.rb:78: Expected String but found Integer for argument arg0 https://srb.help/7002
    78 |p "count: " + farm.animal_count
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    https://github.com/sorbet/sorbet/tree/80e1b24dadafc4ead575cb5e3166a691d2eb73e7/rbi/core/string.rbi#L24: Method String#+ has specified arg0 as String
    24 |        arg0: String,
                ^^^^
  Got Integer originating from:
    sorbet-example.rb:78:
    78 |p "count: " + farm.animal_count
                      ^^^^^^^^^^^^^^^^^

sorbet-example.rb:34: This code is unreachable https://srb.help/7006
    34 |      p 'awkward silence.. 😪'
                ^^^^^^^^^^^^^^^^^^^
Errors: 2

我们刚刚发现了一个类型错误和一个不可达的代码部分。很好!让我们来解决这个问题:

class Farm
...
  sig { params(animals: Array).void }
  def make_animals_speak(animals)
    if !animals.empty?
      animals.each { |animal| p animal.speak }
    else
      p 'awkward silence.. 😪'
    end
  end
...
end

...
...
p "count: " + farm.animal_count.to_s

我只想冒昧地指出一个显而易见的问题,以防你没有想到--我们到现在为止写的测试是零。

通过这种方式,我们可以以我们喜欢的速度和粒度逐步增加类型检查。当处理代码库中没有给出任何类型的部分时,它被认为是类型的--T.untyped

T.untyped 有两个特殊的属性:

  1. 每个值都可以被断言为具有类型T.untyped
  2. 每一个T.untyped类型的值都可以被断言为任何其他类型!。

如果你想了解为什么会这样,请查看文档

最初,当我们开始的时候,我们的大部分代码都是T.untyped ,通过增加静态类型的代码,我们应该逐渐减少T.untyped

测试会受到什么影响?

Ruby是一种动态语言,在构建大型程序时,测试是不可或缺的。我们仍然会依赖自动化测试,但也会对类型安全增加信心。这些自动化测试隐含地成为这些增加的签名合同的测试。此外,我们也可以将类型检查添加到CI/CD管道的一部分。

现在怎么办?

Sorbet是用C++编写的,它的速度相当快,因为它是多线程的,可以跨CPU核心扩展。它对IDE的支持即将推出,这样类型检查的反馈是即时的。你可以在线尝试一下编辑器的支持`。当我们把它作为我们工具链的一部分时,它将有助于解决痛点并提高生产力。

鉴于静态类型检查在Js中的TypescriptFlow、Python中的mypy和PHP中的Hack的流行和采用趋势--静态类型检查器也正在进入我们的Ruby社区,这是一件好事。Matz在RubyKaigi 2019的主题演讲中为Ruby 3提出了3个目标:性能优化、并发支持和静态类型检查。因此,类型不可避免地会出现在Ruby中(类型注释或单独保留在RBI文件中的类型是否会占上风还有待观察)。

Sorbet最初是为Stripe的内部工具开发的。这后来被开源了。它已经被大约30家公司在他们的代码库中进行了测试,这包括Shopify、Coinbase、Sourcegraph、Kickstarter等。因此,如果你正处于我们前面提到的慢慢淹没在技术债务中,或者也许想防止它们。请尝试一下Sorbet,看看它是否能让你的船浮起来。