在RBS中采用Ruby 3类型

2020-12-26 11:21:18

随着Ruby 3.0的发展,我们来看看即将发布的版本的亮点之一:Ruby Type Signatures。是的,类型正在成为我们最喜​​欢的动态语言!让我们看看如何通过将类型添加到现实世界的开源项目中并查看流程的更详细点来利用它们。

这不是我第一次涉及Ruby类型:大约一年前,我第一次体验了Sorbet,并在同一个Martian博客中分享了我的经验。在我的Sorbet帖子结束时,我答应尝试另一个Ruby类型检查器:Steep。所以,我在这里,偿还债务!

我强烈建议您先阅读“排序宝石”一词,因为我会多次提及该文章。

RBS是与Sorbet完全不同的野兽:首先,它是Ruby核心团队的一项正式工作。其次,它使用一种完全不同的方法来注释程序:理想情况下,您可以将.rb文件完全保留不变。官方自述文件指出:

“结构”包括类和方法签名,类型定义等。由于它是一种独立的语言,而不是Ruby,因此使用单独的.rbs文件来存储类型。

#martian.rb class Martian<外星人def初始化(name,evil:false)super(name)@evil = evil结束def邪恶吗? @evil end end#martian.rbs类Alien attr_reader名称:字符串def初始化:(名称:字符串)->无效的末级火星人< Alien @evil:bool def初始化:(name:String,?evil:bool)->无效def邪恶? :()->布尔端

除了我们为参数,方法和实例变量指定了类型之外,签名看起来与类定义本身非常相似。到目前为止,看起来很像Ruby。但是,RBS具有某些实体,例如Ruby中缺少的实体。稍后我们将看到一些示例。

实际上,那不是100%正确;有一种运行时类型检查模式。继续阅读以了解更多信息。

RBS本身不提供执行类型检查的任何功能;只是一种语言,还记得吗?那就是陡峭进入的阶段。

在本文的其余部分中,我将描述将RBS和Steep添加到Rubanok的过程-Rubanok是一种受俄罗斯Pinnochio启发的神奇Ruby DSL,用于在HTTP控制器中转换参数。在我的Sorbet帖子中,我以相同的库为例为您提供了连续感。如果想了解有关Rubanok本身的更多信息,请查看我的“雕刻像Papa Carlo这样的控制器”博客文章。

可能很难弄清楚如何开始向现有项目添加类型。幸运的是,RBS提供了一种脚手架的方式。它带有一个CLI工具(rbs),该工具具有一定数量的命令,但我们只对原型感兴趣:

$ rbs prototype -h用法:rbs prototype [generator ...] [args ...]生成RBS文件的原型。支持的生成器是rb,rbi,runtime。示例:$ rbs prototype rb foo.rb $ rbs prototype rbi foo。 rbi $ rbs原型运行时字符串

$ rbs prototype rb lib / ** / * .rb#Rubanok提供了一个DSL ...(源文件中的所有注释)字段:无类型attr_reader activate_on:无类型attr_reader activate_always:无类型attr_reader ignore_empty_values:无类型attr_reader filter_with:无类型def初始化:(无类型字段,?activate_on:无类型Activate_on,?activate_always:bool )->无类型的def项目:(无类型的params)-> untyped def适用吗?:( untyped params)-> (:: TrueClass | untyped)def to_method_name:()->未输入类型的私人def build_method_name:()-> :: String def fetch_value :(无类型的参数,无类型的字段)-> untyped def empty?:( untyped val)-> (:: FalseClass | untyped)end##< truncated>

第一个选项(原型rb)为您使用静态分析(更确切地说,通过解析源代码和分析AST)传递的文件中指定的所有实体生成签名。

此命令将所有发现的类型流式传输到标准输出。为了保存输出,我们可以使用重定向:

我希望使用镜像签名文件来获取文件(即具有多个文件)。我们可以通过Unix的一些知识来实现​​:

查找lib -name \ *。rb -print |切-sd / -f 2- | xargs -I {} bash -c' export file = {};导出target = sig / $ file; mkdir -p $ {target%/ *}; rbs原型rb lib / $ file> sig / $ {file / rb / rbs}'

我认为,如果默认情况下具有上述功能会更好得多(或者这是一个功能-将所有签名保留在相同的文件🤔中)。

另外,将注释从源文件复制到签名会使可读性降低(尤其是在有很多注释的情况下,例如我的情况)。当然,我们可以添加更多的Unix魔术来解决此问题...

$ RUBYOPT =" -Ilib" rbs原型运行时-r rubanok Rubanok :: Ruleclass Rubanok :: Rule public def activate_always:()->未键入的def activate_on:()-> untyped def适用吗?:( untyped params)->未键入的def字段:()->无类型def filter_with:()->无类型的def ignore_empty_values:()->无类型的def项目:(无类型的params)->无类型def to_method_name:()->未输入类型的私人def build_method_name:()-> untyped def empty?:( untyped val)->无类型的def fetch_value :(无类型的参数,无类型的字段)无类型def初始化:(无类型字段,?activate_on:无类型,?activate_always:无类型,?ignore_empty_values:无类型,?filter_with:无类型)->无类型的

在运行时模式下,RBS使用Ruby的自省API(Class.methods等)来生成指定的类或模块签名。

让我们比较一下用rb和运行时模式生成的Rubanok :: Rule类的签名:

那么,为什么可能会发现运行时生成器有用呢?我猜只有一个原因:动态生成的方法。例如,在Active Record中。

因此,这两种模式都有其优点和缺点,同时使用它们将提供更好的签名覆盖率。不幸的是,目前还没有很好的方法来差异化/合并RBS文件;您必须手动执行。另一个手动工作是用实际打字信息替换未打字的信息。

但是,暂时不要弄脏我们的手。该游戏中还有一个玩家-Type Profiler,这是Ruby核心团队的另一个实验工具。

Type Profiler在执行期间动态推断程序类型签名。它监视所有加载的类和方法,并收集有关哪些类型用作输入和输出的信息,分析此数据并生成RBS定义。在幕后,它使用了自定义的Ruby解释器(因此,代码实际上并未执行)。您可以在官方文档中找到更多信息。

TypeProf和RBS之间的主要区别是我们需要创建一个示例脚本以用作概要分析入口点。

#sig / rubanok_type_profile.rb要求" rubanok"处理器=类。新的(Rubanok :: Processor)做地图:q do | q:|原始结尾匹配:sort_by,:sort,activate_on::sort_by确实具有" status" ," asc"做原始终端默认做| sort_by:,排序:" asc" |原始末端末端处理器。项目({q:" search",sort_by:" name"})处理程序。呼叫([],{q:" search",sort_by:" name"})

$ typeprof -Ilib sig / rubanok_type_profile.rb --exclude-dir lib / rubanok / rails --exclude-dir lib / rubanok / rspec.rb#Classesmodule Rubanok版本:字符串类规则未定义:对象@method_name:字符串attr_reader字段:未键入attr_reader activate_on:Array [untyped] attr_reader activate_always:false attr_readerignore_empty_values:未键入attr_reader filter_with:nil def初始化:(未键入,?activate_on:未键入,?activate_always:false,?ignore_empty_values:与未输入n,?filter nil def project:(未键入)-> untyped def适用吗? :(未键入)-> bool def to_method_name:->字符串private def build_method_name:->字符串def fetch_value :(无类型,无类型)->目的? def空吗? :(无)->错误结束#...结束

很好,现在我们定义了一些类型(尽管其中大多数仍然没有类型),TypeProf尊重了我们的方法和实例变量的可见性(我们之前从未见过)。方法的顺序与原始文件中的相同-很好!

不幸的是,尽管TypeType是一个运行时分析器,但它在元编程支持方面却不是很好。例如,使用迭代定义的方法将不会被识别:

#a.rb A级%w [a b]。每个。 with_index {|我define_method(str){i}}结束pA。新的。 a + A。新的。 b

因此,即使您生成的可执行文件提供了100%的API覆盖率但使用元编程,TypeProf仍然不足以为您的程序构建完整的类型支架。

综上所述,生成初始签名的所有三种方法都有其优点和缺点,但是将它们的结果相结合可以为在现有代码中添加类型提供非常好的起点。希望我们能够在未来实现此自动化。

跑typeprof并使用其输出来添加缺少的实例变量并更新一些签名。

我在撰写本文时,已合并了具有attr_reader self.foo支持的PR。

我还发现了一个错误,当RBS未能正确地在单个类(例如,在类<<< self块内)中定义的attr_accessor属性中。幸运的是,它是固定的并且可以快速合并,所以我的主要模块签名现在是正确的:

模块Rubanok-attr_accessor ignore_empty_values:无类型-attr_accessor fail_when_no_matches:无类型+ def self.fail_when_no_matches:()-> untyped + def self.fail_when_no_matches =:(未键入)-> untyped + def self.ignore_empty_values:()-> untyped + def self.ignore_empty_values =:(未键入)->无类型的结尾

到目前为止,我们仅讨论了如何编写和生成类型签名。如果我们不向开发堆栈中添加类型检查器,那将毫无用处。

到目前为止,唯一支持RBS的类型检查器是Steep-毫不奇怪地由负责RBS的Ruby核心提交人Soutaro Matsumoto开发。

让我们将陡峭的宝石添加到我们的依赖项中并生成一个配置文件:

这将生成具有某些配置的默认Steepfile。对于Rubanok,我将其更新为:

#Steepfile target:lib do#从sig /文件夹签名" sig"加载签名#仅检查来自lib /文件夹的文件,检查" lib" #我们不想键入检查Rails / RSpec的相关代码#(因为我们没有RBS文件),请忽略" lib / rubanok / rails / *。rb"忽略" lib / rubanok / railtie.rb"忽略" lib / rubanok / rspec.rb" #我们使用Set标准库;它的签名#随RBS一起提供,但是我们需要显式加载它们" set"结束

在淹没各种类型的东西之前,让我们考虑一下如何衡量签名的效率。我们可以使用陡峭的统计数据来查看类型覆盖范围的好坏:

令人惊讶的是,此命令输出CSV😯。让我们添加一些Unix魔术,并使输出更具可读性:

$ bundle exec陡峭的统计信息--log-level =致命| awk -F',' ' {printf"%-28s%-9s%-12s%-14s%-10s \ n",$ 2,$ 3,$ 4,$ 5,$ 7}'文件状态已键入的呼叫未键入调用键入%lib / rubanok / dsl / mapping.rb成功7 2 63.64lib / rubanok / dsl / matching.rb成功26 18 52.00lib / rubanok / processor.rb成功34 8 69.39lib / rubanok / rule.rb成功24 12 66.67lib / rubanok / version.rb成功0 0 0lib / rubanok.rb成功8 4 66.67

理想情况下,我们希望输入所有内容。因此,我打开了.rbs文件,开始用实际类型一一替换未类型化的文件。

我花了大约十分钟的时间才摆脱了无类型的定义(大部分都是这样)。我不会详细描述此过程;除了我要注意的一件事外,这非常简单。

让我们回想一下Rubanok是什么。它提供了一个DSL来定义形式(输入,参数)的数据(通常是用户输入)转换器->输入。典型的用例是根据请求参数自定义Active Record关系:

Warning: Can only detect less than 5000 characters

为什么陡峭检测到三个#empty?规则类中的方法?事实证明,它认为匿名提炼主体是该类别主体的一部分:

使用(Module。new是否精炼NilClass def空吗?真正的末端精炼对象def空吗?false末端吗)def空吗? (val)返回false,除非ignore_empty_values val。空吗结束

我提交了一个问题,并将改进内容移至文件顶部以修复这些错误。

lib / rubanok / processor.rb:56:13:NoMethodError:type =(:: Class | nil),方法=< =(超类< =处理器)lib / rubanok / processor.rb:57:12:NoMethodError:type =(:: Class | nil),方法=规则(superclass.rules)lib / rubanok / processor.rb:67: 13:NoMethodError:类型=(:: Class | nil),方法=< =(超类< =处理器)

这是继承类属性的一种非常常见的模式。为什么不起作用?首先,超类签名表示结果为Class或nil(尽管据我所知仅对BaseObject类可能为nil)。因此,我们不能立即使用< =(因为它没有在NilClass上定义)。

即使我们取消超类的包装,仍然存在.rules的问题-Steep的流量敏感性分析目前无法识别< =运算符。因此,我决定对系统进行黑客攻击,并为Processor类明确定义.superclass签名:

到目前为止,我们已经看到了与Sorbet差不多的问题。让我们来看看一些新东西。

def工程(参数)参数=参数。 transform_keys(&:to_sym)#params是一个Hash,fields_set是Set的params。 切片(* fields_set)结束 Hash#slice方法需要一个数组,但是我们传递了一个Set。 但是,我们还使用了splat(*)运算符,该运算符隐式尝试将对象转换为数组-似乎合法吗? 不幸的是,Steep还不是很聪明:我们必须添加一个明确的#to_a调用。 这种DSL方法接受一些选项作为关键字参数,然后将它们传递给Rule类初始化程序。 可能的选项是在Rule#initialize中严格定义和强制执行的,但是我们希望避免明确声明它们只是为了向前。 不幸的是,只有在我们将** options声明为无类型的情况下才有可能,这会使签名变得毫无用处。 -def map(* fields,** options,& block)-filter = options [:filter_with]-rule = ......