Ruby Ruby 需要一个静态类型验证系统

linjunhalida · 2017年03月12日 · 最后由 499272000 回复于 2021年12月06日 · 4957 次阅读

拷贝自我的博客

越用 Ruby,越觉得 Ruby 简陋。很多其它现代语言有的特性,Ruby 都没有。其中一个就是静态类型验证系统。

Ruby 语言里面,是不需要定义函数的参数,以及变量的类型的,这样带来的好处就是写程序更简便了,这个被大家称为是优点。 但是实际上,这是一个缺陷。为什么呢?

程序在执行的过程中,如果不知道一个变量的类型,只能动态去判断变量是什么,动态地查找处理变量的函数, 比如一个方法调用:a.run(b),就要去查找 a 的 run 方法,然后传入 b 参数。 这样造成每次函数调用,都会进行一次判断和查找,极大地降低程序执行速度。

另外一个问题就是,当传入的参数不正确 (比如 a 没有 run 方法,或者 run 只接受一个参数),只有程序实际执行的时候,才会知道这点。 一方面要在函数和方法调用的时候来判断(更慢了),另外一方面本来能够静态识别出来的错误,留到了运行时。

更可怕的是,当传入的参数类型错误的时候,不能立刻就知道,而是要等到很久之后才会被发现, 这个时候你根本不知道这个参数是什么时候传进来的,Debug 变得非常困难! 其它语言定义了类型,在编译的时候就可以发现这样的错误,但是动态类型语言,比如 Ruby/Python 就没有办法。

在实际进行 Ruby 编程的时候,我深刻地体会到了这几点。 尤其是项目变得很大的时候,执行速度不说,出现问题找 Bug 变得很困难, 程序可靠性没有办法通过编译阶段发现,而是只能留到执行时(testcase), 并且执行时也做不到其它静态语言级别的函数参数类型验证。

而在其它现代语言里面,类型定义不再向以前一样手动配置那么痛苦了。 比如在C#里面,我们可以定义函数参数的类型,以及所有变量的类型,也可以不定义(dynamic),做到和Ruby一样的效果。 但是还有更好的语法 (var),程序语言自动给你做好类型定义,不用自己手写了,方便很多。

综上所述,静态类型验证系统是现代程序语言必备的一个功能,Ruby 不应该缺少这样的功能。 Matz 本人也说,下一阶段会进行这种系统的设计。 不过我觉得这么大的改动,只有在程序语言开始设计的时候才能做出,Ruby 现在已经进入成熟阶段,再改就很难了。 Racket 语言的解决方案是拆分成带有 Typed 和没有 Typed 的两个子集。

如果不是 Rails,我可能会考虑其它语言,但是谁让 web 开发框架里面,Rails 生态圈最好最成熟呢。

Ruby3 可能会有类似的

有了类型之后又会想要积类型 和类型,递归类型,依存类型。。。在没有完美的类型体系之前我觉得鸭子类型才是最好的。。。

mizuhashi 回复

类型系统这种老问题研究了几十年了,有成体系的办法

其实 Javascript 也是通过原型链搜索方法,也是弱类型语言,但运行效率并不差。而关于类型检查的问题,目前 Ruby 的做法是用单元测试,Ruby 是解释型语言不能像 Erlang 一样用 spec 在编译期间检查,但 3.0 可能会参考 go interface 那套。 最后你提到的 C# 的var 能在声明时进行类型推导,但这个除了能少写一个单词真不算优点。

linjunhalida 回复

但是也及其复杂,Scala 那套类型系统算是非常强大了,也非常成功地把他推上太阳系第一复杂语言的宝座。

然后还要去掉 Null...

说用单元测试的就好像,这团浆糊糊不住了,必须弄个碗端着。

C# 要不是在微软手里,一定能发展的更好。

该出错的总要出错,type 能发现的也只能是其中一小部分。上类型如果对人的成本较大,还是要权衡。

as181920 回复

前面说了,类型系统属于可选,一般情况可以不写,需要验证了再加。当然加起来对系统稳定性很有帮助。任何提升程序健壮性的功能都是有好处的。平衡开发效率和代码质量吧。

saiga 回复

类型系统正确实现的话不会很复杂的,都是基于推理演算,从基础变量类型,函数组合起来。

我也觉得鸭子类型才是最好的。 我给 nil 定义了很多方法,比如

nil.to_s #=> ""
nil.to_i #=> 0
nil + 1 #=> 1
nil + '1'  #=> '1'

这样就算返回值是 nil 也不会报错了,因为确实没错。

Ruby 是动态语言,在编译的时候很难发现 bug,所以大型项目用的少。

sevk 回复

duck typing 一个是性能问题,第二个是没有 type 验证的时候,无法通过限制边界来防止错误蔓延。一个函数因为参数类型不对报错你很难发现到底哪一个函数传过来的。

sevk 回复

还有就是千万千万不要隐藏错误,有错尽早抛 error,流到后面都不知道哪里出错了。

sevk 回复

👍 请问怎么定义的?

linjunhalida 回复

首先,这样还是无法避免最最常见的类型问题,空指针。第二,缺乏动态性。举个例子,ruby 里面,file, io, stringio 都有个 close 方法,函数需要针对多个类型调用 close,这个时候没有 duck typing 或者结构化类型,就会显得非常的繁琐。

saiga 回复

设置类型系统可选就可以了。

linjunhalida 回复

我想起了这个 gem 了 contracts.ruby。另外,性能是由语言性质和 vm 决定的,类型系统能提速很大程度是因为强类型语言一般都是编译型语言造成的错觉。至于类型系统可选,我觉得换个语言可能靠谱点,毕竟不像 ts 之于 js,后端可选择的余地很多。

saiga 回复

类型系统决定了可以编译期对代码进行优化,代码里面就不需要判断传进来的参数是什么类型了,直接调用对应类型的处理方法即可。这种优化并不代表需要静态编译系统。比如在 ruby 解析一个 method 的时候就把优化做掉。Typed Racket就是这样处理的。类型系统可选在C#里面就实现了,我文章里面有写。

请教一个楼主说的性能问题。在看你的文章以前,我一直认为 ducktype 是指我们在阅读代码时需要猜类型,而程序执行时参数的类型是确定的。发生方法调用时,参数会被压入执行栈里,它包含的 rclass 指针可以明确指定类型,为什么 ruby 需要去提前猜类型?我能想到合理的解释就是要提前缓存方法的查找路径,不要在执行时再去根据类型查找方法。不知道是不是这样,能不能找到对应猜类型的 ruby C 源码来说明一下。

adamshen 回复

很明显是执行的时候才会猜类型呀,能够动态定义函数,同时函数本身不带有类型信息,那么只能在执行时才可以知道传进来的参数类型。 我觉得你以为实现的方式是:函数是一个模板,类似:func(T arg),当然不可能是这样的。

linjunhalida 回复

还是有点不太明白,可能是我知识不完整存在思维盲区吧,感谢您的解释🤗

adamshen 回复

因为我就写了一句话,不明白是正常的。举个例子:

def calculate(objects)
  objects.map(&:count).sum
end

class A
  def count; rand; end
end

class B
  def count; 10; end
end

calculate(12.times.map{A.new})
calculate(12.times.map{B.new})

在 ruby 里面,calculate 就不晓得传进来的是什么东西,只会把 count 消息传给 object,让 object 返回结果,object 本身要实现接收:count 然后执行对应方法的过程。

  • 优化 1: 假设传进来的 objects 都是同样的类型,我们可以先取得 count_method,这样不需要 object 动态查找 count 的 method。

    def calculate(objects)
    # 假设objects都是同样类型
    count_method = objects.first.class.get_method(:count)
    objects.map{|o| count_method.call(o)}.sum
    end
    
  • 优化 2:宏编译优化,查找 count_method 的过程在编译期的时候就完成了。

    def create_calculate(klass, objects)
    count_method = klass.get_method(:count)
    lambda { |objects|
    objects.map{|o| count_method.call(o)}.sum
    }
    end
    

calculate_method = create_caculate(A) calculate_method.calculate(objects)


上面这两个优化可以提升很多的效率。要知道代码里面大多数的东西都是在方法调用来调用去。

实现类型验证的简单过程:

# 定义函数需要加上类型
define_function :calculate, [Array(A)] do |objects|
  lambda {
    objects.map{|o| count_method.call(o)}.sum
  }
end
def define_function(name, types)
  Function.new(method: block.call, types: types)
end

# 解析调用到这个函数的代码的时候,会进行类型验证
define_function :run do
  call_function(:calculate, :objects)
end
def call_function(func_name, args)
  func = get_function(func_name)
  type_checking(func, args)
  lambda { func.call(args) }
end

# 类型验证会去检查是否函数是否支持该类型,不支持的话就报错
def type_checking(func, args)
  func.types.each_with_index do |type, i|
    unless args[i].kind_of?(type)
      raise TypeError
    end
  end
end

Ruby 自己除了等 Matz 那个 soft type,现在可以通过 dry-types 加编码规范来做到一定的类型约束,但是这个风格跟 Ruby 主流的风格冲突太大了

jasl 回复

还是要用语言实现比较好。库写起来不好的。

linjunhalida 回复

鸭子类型这是 Matz 当初的取舍,Matz 选择信任开发者,那就只能靠同事不坑了...

个人观点:各种语言特性都不是必须品,语言的不足最后都会以最佳实践、设计模式的方式弥补。至于现在流行函数式、显式类型,我觉得是个时尚问题

另外,Ruby 虽然一直没有在类型系统上做太多改进(社区的做法是在 rdoc 里注明),但是对于更明确的函数签名,这是做过改进的,比如常见的边长参数用法 def foo(a, b, options = {}),而后改进支持 named arguments 后,很多方法都可以以更明确的方式定义

linjunhalida 回复

但是 ruby 的核心之一是打开类,在运行时修改类定义,所以编译期查找方法是不可能的;假如需要缓存方法路径,现在也可以缓存,这个和有没有类型应该没关系。

另外 matz 很讨厌 java 那种手写 types,ruby 不可能会做成 java 这样的。。Ruby3 大概会给你照 respond_to?推一下,类 ruby 的静态类型语言可以参照 crystal,但是我觉得没有意义,想真的要享用类型系统的好处的话,还是建议去用 rust 或者 haskell。。。

linjunhalida 回复

类似 Java/C# 本身也是需要要查找方法的,他们内部维护着一张保持对象内所有函数指针的 vtable,Ruby 也有一张类似的 method_table,通过 rb_method_entry_make 把方法加入到 method_table,每次调用方法 Ruby 会通过 class 和 method_id 查找 method_table,返回 rb_method_entry_t,没有就直接抛出异常。这个过程是耗时的,而且无论有没有类型系统。

你给的代码思路是后面的优化,有点像是内联函数。(既然要调用,那么不如直接把函数内联到调用点)。但是,你并没有考虑到 oop 和动态语言的特性。按你的例子来看,每个 objects 是有 count 方法,但是 oop 里面一个方法是可能需要搜索继承链的。况且,ruby 没有编译期...

Ruby 这个语言的风格本来就是通过牺牲你说的这些来换取灵活性。

程序在运行期以外是无法正确检查类型的。

随时打开类,随时修改类行为,各种魔术方法(method_missing),你这个想法要是实现的话,这些东西都是问题,这有点颠覆 Ruby 核心风格的感觉了。

你说到C#里var关键词,那你可曾知道C#的类型是没法编辑的。 一个类一旦写完编译完成以后,类成员就固定下来了。 既然类固定了,当然可以从返回值去推断变量的类型。

如果你需要一个静态类型的语言,那从一开始就不应该选择Ruby,而应该选择Java/C#系。 就像如果你要买一双适合运动的鞋子,那就去买一双运动鞋,而不是买一双有运动感的皮鞋。 你说是吗?

jasl 回复

C 语言特性以外的都不是必需品,但是作为现代通用编程语言,很多特性是保证开发效率和工程质量必须的。 烂语言有很多,比如 Autoit,PHP,Perl,都能用,但是烂。

函数式,类型系统都是极大提升开发效率和质量的,作为通用编程语言,是必须有的特性,没有理由不加。

msg7086 回复

C# partial 应该可以做到这一点。

msg7086 回复

Ruby 语言的语法特性和执行方式其实是可以支持函数边界类型验证的(比如 Racket 就实现了),这个是属于可以做的事情。

saiga 回复

解析函数的阶段可以说是编译期。在这个阶段可以对函数代码进行优化。不过 Ruby 实现不支持。。。

linjunhalida 回复

C#的Partial是语法糖,Ruby的打开类是运行时的东西,完全不一样啊。

Racket 就不太清楚了。

#35 的论点也值得商榷。有很多的特性是互相冲突的,比如打开类这个特性,关掉的话可以改善维护性,允许的话可以提高开发效率增加灵活性,你可以选其中一个,但是没法两个都选啊。

linjunhalida 回复

javascript 里面,1 + '1' 得到 '11' , 他的类型系统不错,V8 执行效率也不错。

Peter 回复
class NilClass
  def [](i=0); end
  def size; 0 ; end 
  def to_s(i=0);"";end  #这个有些gem 会出错
end
sevk 回复

谢谢!

Duck Typing 有办法做类型检查吗?结合 Ruby 可以动态定义方法的特性 好像很难。不过既然都可以打猴子补丁了,那我动态给 String 加个方法不也是没得玩?还是说我再分析一下控制流,就像编译语言的返回值检测一样?越想越是大坑啊...

ecnelises 回复

打了补丁的部分可以跳过不做类型检查嘛。给个报警之类的。

参数没有类型只能硬敲 好难受

需要 登录 后方可回复, 如果你还没有账号请 注册新账号