Sorbet - Ruby的静态类型检查器
Sorbet是一个类型检查器,现在正在加入鸭子类型的Ruby土地上的羊群。在这篇文章中,我们将探讨为什么我们需要静态类型检查,以及纳入它的意义是什么。
什么是类型系统?
类型是程序员如何将意义赋予原始的比特序列,如程序中的值、常量或一些对象,也有助于语言的编译器/解释器相应地分配内存。
类型安全被用来对编程语言中使用的各种类型进行约束。这有助于抓住不兼容的类型之间的操作。这些检查要么在编译时进行--静态类型检查,要么在运行时进行--动态类型检查。
为了执行类型安全,语言需要一个类型系统,它被定义为编程语言的编译器/解释器的一部分。基于编译器/解释器执行类型安全检查的严格程度,决定了一种语言是强类型还是松散类型。
Ruby在这一切中的地位如何?
Ruby是一种动态和强类型的语言。正是它使我们能够做鸭式类型或元编程能力,而这正是我们喜欢Ruby的原因。
Ruby的强类型特性帮助我们执行类型安全。但是这些类型的错误只能在运行时被发现。由于这个原因,我们一直依赖测试来帮助我们捕捉它们。
在Ruby中,当有一个巨大的代码库和一个庞大的合作团队时,某些痛点会出现:
- 隐藏的bug:代码中的错误,如:没有找到方法,提供了无效的参数,未初始化的常量错误,等等,只能在运行时发现。而使用静态类型检查器,我们甚至可以在程序执行前发现它,从而节省时间。
- 明确的文档:在处理无文档的代码时,程序员通常会有这样的困惑:这个方法需要什么参数,它能返回什么?变量是什么类型的?即使是有文档的代码,也有可能会出现不同步的情况。静态检查器在代码中执行类型,这样就可以很容易地避免:种混乱。
- 代码重构:为了捕捉重构周期后的错误,我们必须依靠测试来确保没有任何破坏。如果有什么破绽,我们只能在运行时知道它。但是有了静态检查器,重构周期就变得容易了,因为程序员在改变接口时可以放心,类型检查器会捕捉到程序中与更新的接口不一致的任何部分。
此外,解释器可以利用指定的显式类型来产生更好的优化机器代码,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 有两个特殊的属性:
- 每个值都可以被断言为具有类型
T.untyped。 - 每一个T.untyped类型的值都可以被断言为任何其他类型!。
如果你想了解为什么会这样,请查看文档。
最初,当我们开始的时候,我们的大部分代码都是T.untyped ,通过增加静态类型的代码,我们应该逐渐减少T.untyped 。
测试会受到什么影响?
Ruby是一种动态语言,在构建大型程序时,测试是不可或缺的。我们仍然会依赖自动化测试,但也会对类型安全增加信心。这些自动化测试隐含地成为这些增加的签名合同的测试。此外,我们也可以将类型检查添加到CI/CD管道的一部分。
现在怎么办?
Sorbet是用C++编写的,它的速度相当快,因为它是多线程的,可以跨CPU核心扩展。它对IDE的支持即将推出,这样类型检查的反馈是即时的。你可以在线尝试一下编辑器的支持`。当我们把它作为我们工具链的一部分时,它将有助于解决痛点并提高生产力。
鉴于静态类型检查在Js中的Typescript或Flow、Python中的mypy和PHP中的Hack的流行和采用趋势--静态类型检查器也正在进入我们的Ruby社区,这是一件好事。Matz在RubyKaigi 2019的主题演讲中为Ruby 3提出了3个目标:性能优化、并发支持和静态类型检查。因此,类型不可避免地会出现在Ruby中(类型注释或单独保留在RBI文件中的类型是否会占上风还有待观察)。
Sorbet最初是为Stripe的内部工具开发的。这后来被开源了。它已经被大约30家公司在他们的代码库中进行了测试,这包括Shopify、Coinbase、Sourcegraph、Kickstarter等。因此,如果你正处于我们前面提到的慢慢淹没在技术债务中,或者也许想防止它们。请尝试一下Sorbet,看看它是否能让你的船浮起来。