Ruby语言特性总结

前言

  之前在做Gitlab源代码的扩展工作,一直没有时间总结一下Ruby和Rails的内容。最近在看《Effective Ruby》,顺便总结一下Ruby的一些语言特性与进阶内容。此博文将持续不定期更新。

Ruby最基础的语法特性

true和false

  在Ruby中,一切皆是对象。这个“一切”的概念很广,除了一般面向对象语言的对象概念以外,像class也是对象,true,false,nil等也都是对象。Ruby里用到的==,>=等比较操作符,统统都是对象方法。

  在其他语言中,true和false往往是关键字。但在Ruby中,它们是不遵循命名规范和赋值规范的全局变量,它们的行为和全局对象一样,只是不以字符$开头,也不能放在赋值语句的左半边。他们都是TrueClass和FalseClass的实例对象。在Ruby中,除了false和nil,一切都是真值,都能让表达式为真,所以true的存在只是方便,是冗余的。

  凡是某表达式返回false或nil都不成立,其他值都成立。但这样也有一个问题,就是不好区分false和nil方法。最简单的区分方法是用nil?方法,另一种是用==方法,false是方法的调用者。因为Object#==方法会比较方法调用者和参数是否属于同一对象。但要注意false要放在==左边作为方法的调用者,因为如果其他对象放在==左边,相当于调用它们的==方法,而这些对象很可能覆盖了Object#==方法,造成诡异的返回。

留心nil对象

  在方法调用过程中,很可能产生nil对象,也很可能传入的参数本身就是nil。对这些对象调用方法往往会抛出nil:NilClass的NoMethodError错误。所以我们在处理对象是要十分小心。

  最普通的方法是用nil?方法判断一下。或者用to_s,to_i,to_a等方法将对象进行转换。意思是,假如你某个对象是String类的对象,在调用方法前先用to_s处理一下,如果是正常对象则返回本身,如果是nil则返回””,这样在调用方法时就不会抛异常。同理,如果你要处理的对象正常情况下是Integer或Array对象,则可以用to_i,to_a方法转换一下。

  Array#compact方法还能去除Array里所有nil元素。

避免古怪的语法

  由于受Perl语言的影响,Ruby的部分语法显得非常古怪,难以阅读,我们应有意识地避免这些用法。

  String#=~方法匹配函数右边的正则表达式,返回匹配到的位置或者nil。同时,会将匹配到的结果设置到几个全局变量中,$1,$2,$3等等。但是,这些全局变量非常特殊,它们的作用域仅限于当前进程的当前方法内,相当于全局变量的局部变量,非常容易引起歧义,令人费解。我们可以用String#match方法代替,它会返回一个MatchData的对象的类数组对象,用数组的索引方法取得相应的匹配结果。还有一些其他的有关正则表达式的用法也会设置$1,$2,$3这些值,我们都要尽量避免。

  Ruby用require来引入其他文件,它用一个全局变量$:来表示一个包含所有库的目录的数组,可见这种奇怪的变量非常难以理解,我们可以用$LOAD_PATH来代替。除此之外,还有$;,$/等类似的古怪变量,这些全局变量我们可以通过require(‘English’)来用一些更有描述性的别名来代替。具体的别名完整列表可以查阅English模块的文档。

  Kernel#readline方法会从标准输入中读取一行,存储在变量$中。并且,如果Kernel#print没有参数调用时,会自动打印$变量的内容。Regexp#~试图使用右边的正则表达式匹配$_的内容,如果匹配成功返回位置索引否则返回nil。这种语法同样十分难以理解。我们应尽量避免。

常量

  Ruby中的常量是可变的,改变常量的值会有warning,但不会报错。我们在使用常量时应用freeze方法将其冻结。

  所有以大写字母开头的标识符都是常量,类和模块的名字自然也是常量。我们可以用freeze方法冻结常量所在的类或者模块,则该模块内的常量也不会被改变。

  要冻结数组,还必须冻结数组内的每一个元素。

充分利用警告信息

  警告分为编译时警告和运行时警告。我们在开发时建议把两种警告都开启。尤其是编译阶段警告非常重要。

  Ruby允许在方法调用时不带空格,这样有时候会产生模糊代码,会有警告信息输出。这时候需要Ruby解释器去猜测你的意图,虽然暂时不影响程序的正确性,但最好还是规避这种情况。而且不带括号,会难以分清哪些是关键字哪些是方法调用。

  还有一些警告是定义了变量没有使用,或者同一作用域内重复定义了相同的变量名。这些情况很可能是你在程序里有某些疏忽产生,所以警告信息非常有用。

  在运行ruby时加上-w参数可以开启编译和运行时警告。或者直接设置环境变量RUBYOPT为-w。设置全局变量$VERBOSE为true开启运行时警告,为false关闭运行时警告。

面向对象

继承体系

  当调用方法时,会从该对象的继承体系中寻找该方法。由该对象的类开始,沿着继承链上溯。在继承体系中还有一个单例类的概念,相当于匿名类,当我们include模块时会生成一个单例类插入继承体系中。寻找方法时后include的模块先寻找,相当于以压栈的方式把模块一个个插进去。我们可以单独定义某个对象的方法,这个方法为这个对象所独有的,也会作为单例类的方法插入继承体系中。前面说到,类也是一个对象,因此类方法也是作为该类的的单例类方法插入继承体系中的。

  当继承体系中找不到该方法时,就会从起点开始搜索method_missing方法。

关于super

  在子类中如果要调用基类重载的方法,就要用super关键字。如果调用时不加括号和参数,就会将子类的所有参数都传进基类方法中。如果基类方法不含参数,也要用空括号。

  对于构造器方法也需要显式调用super来进行基类的初始化。

getter与setter

  Ruby允许我们在方法末尾使用3个特殊符号:?,!,=。?和!只是规范与美学上的需要,但=的作用就非常大了。假如在类内有一个@value的实例变量,通过定义两个方法value和value=便相当于定义了getter与setter方法。并且,在使用value=方法时,Ruby解析器允许我们在=两边加入空格,显得像是直接赋值语句一样。

  Ruby解析器解析=时,只有当有显式的方法接收者,才会解析成setter方法的调用,否则当成普通的赋值语句。因此,如果在类内部调用setter方法需要加上self。getter方法则方便很多,Ruby解析器现在作用域内寻找是否有同名变量,如果没有则解析为方法调用。

轻量化的类结构往往比哈希更好

  我们可能比较喜欢用哈希来实现结构化数据。但如果我们对数据的封装性有更高的需求,或者需要一些更像类的特性时,用Struct类往往是更好地选择。

  通过Struct.new把返回值赋给一个常量,就可以像类一样使用这个常量,它有getter和setter方法,并且initialize方法能初始化每个属性的值。我们还可以传入一个block,里面定义一些类方法和实例方法。

命名空间

  为了防止相同名字的类产生冲突,Ruby用module作为命名空间来隔离。我们知道,module也是一种常量,而Ruby寻找常量使用两种技术:当前词法作用域以及继承体系。如果在当前词法作用域内无法找到该常量,就会沿着继承体系寻找。因此,有时候常量需要加上module限定符。module可以视作顶级常量,所有顶级常量都会存在Object类中,因此可以通过继承体系寻找到该常量。可以用::来限定顶级常量。

  让命名空间结构与目录结构一致。

  这里补充一下,常量可以在继承体系中寻找,类变量也可以。但实例变量是不继承的。

比较符

  Ruby中所有运算符都是方法,例如==,>,<,>=等等。首先从表示等价开始谈起。

  Ruby表示等价有4种方法:

  1. equal?是比较是否是同一对象,具有相同的object_id才为真。
  2. ==的默认实现与equal?相同,但我们一般用它来表示对象实际的值相等。基本类型的==实现往往有隐含的类型转换,我们自己重载的话可以考虑是否实现转换。
  3. eql?的默认行为也是和equal?一样,一般用来比较哈希的键是否相同。如果你定义的类有可能作为哈希的键,那么就要实现这个方法,同时还要实现hash方法。在比较两个对象是否表示相同键时,先比较它们的hash方法返回值是否相等,如果相等在比较eql?的返回值。我们在重载这两个方法时,往往是用实例变量或基本类型的这两个方法来代理实现。
  4. ===的默认实现是传给==。case语句相当于让每个when语句的操作数作为调用者调用===方法。Regexp定义了===函数,如果正则表达式与字符串参数匹配成功,则返回真。类和模块也定义了类方法版本的===操作符,如果右操作数是左操作数的一个实例,也返回真。如果我们有必要考虑自己定义的类对象用在case语句里的when语句,那么可以考虑重载===。

  与==不同,其他比较符没有默认的继承。但<=>会从Object默认继承,只是没有按我们要求实现而已。我们需要当接收者和参数的比较没有意义时返回nil;接收者较小时,返回-1;较大时返回1;相等时返回0。在重载了<=>以后,通过引入Comparable模块,我们就实现了各种比较操作符的重载,我们一般用这种方法来实现重载比较符,包括==。如果我们需要让==额外包含类型转换功能,可以自己再手动重载。

  Array的sort方法实际上用到了<=>。

  这些重载一般是让类里的实例变量来代理实现的。

protected方法

  Ruby里子类可以继承父类的私有方法,protected方法的用处是便于同类或有同一超类的两个对象共享私有状态用的。

优先使用类实例变量

  我们可能考虑用类变量来实现单例模式。但类变量是与类绑定在一起的,超类中的类变量会被所有子类共享,任何这些类的实例变量都可以改变类变量。所以我们应该用类实例变量来代替。类也是对象,在类级别定义的和在类方法下定义的叫做类实例变量。它们的访问权限与一般对象的实例变量一样,也当然不会被子类继承。

集合

复制作为参数的集合

  Ruby的对象作为参数传递进方法是采用引用传递的方式,这与Java是一样的。意味着改变传入的参数也会同时改变原对象。建议在不想改变原对象时用dup或clone方法进行复制。其中clone方法有两点不同于dup方法,一是复制时不改变冻结性,二是如果存在单例方法也会复制单例类。所以一般dup方法用得更多。

  但是,如果对集合对象进行复制,只是浅复制,如果改变集合里的元素,仍然会影响原集合里的元素。如果是自己实现的类,可以重写initialize_copy方法,但如果是已有的集合类,则只能用Marshal来对集合进行序列化和反序列化来复制。但这样消耗的内存会更多,而且有些对象是无法序列化的。

用Array方法将函数参数转换成数组

  由于Ruby变量不显示指定类型,在传入方法时有时候跟预期不一样。例如希望传入一个数组,但只传入一个变量或者nil。这时候,可以调用Array方法将参数转换成数组。nil将转换成nil,单个变量将转换成只含它本身的数组,原本是数组则简单地转发出去。

使用集合类

  高效地查找数据我们倾向于使用哈希而非数组。但如果我们的需求仅仅是判断容器内是否包含某元素,哈希的功能显得有点冗余了,而且由数组转换成哈希时不够美观。Set类可以满足我们的需求。它的内部是用哈希实现的,可以传入一个数组来直接用Set.new来初始化,形式较简洁。之后就可以用include?方法来判断是否包含某元素了。

  但如果查找的次数极少,相对于初始化来说几乎次数差不多,那么用Set或哈希其实时间上优化不是很明显,因为初始化仍然需要把原数组遍历一遍。

  使用Set需要显式require,因为它在Ruby标准库而不是核心库中。

  插入Set的对象当做哈希的键,因此如果要保存的是自定义的类,之前的eql?和hash方法在这里也能应用得上。

reduce方法

  集合的reduce方法非常好用,它接受一个初始值和一个block作为参数。它会遍历集合,对每一个元素调用block,并且block里有一个累加器参数。这个累加器参数会保留上一次块调用的返回值,第一次调用的话则是reduce方法传入的初始值参数。

  reduce方法允许省略初始值参数,这时会使用集合的第一个元素作为初始值而从第二个元素开始遍历。如果原始集合为空则会返回nil。所以最好都要带上初始值。并且要记住block的返回。

  很多涉及集合的操作都可以用reduce重构,会使代码更简练。

考虑使用哈希的默认值

  可以用Hash.new方法传入一个参数作为默认值,当访问不存在的键时会返回默认值,并且不会修改哈希对象。但是如果默认值是可修改的,例如数组,那么很可能会修改默认值,造成很多错误。建议传入的参数是一个块,它的返回值会作为默认值,并且不会修改默认值。但用块的话访问一个不存在的键会将这个键存入哈希。

  正确检查一个哈希是否包含某个键使用has_key?方法。因为设置了默认值的哈希不会返回nil,不能用hash[key]的返回值来判断。这时候设置默认值需要十分谨慎。用Hash#fetch方法会更加安全。

使用代理而不是继承

  我们可能想要实现一个改良版的集合类或者其他核心类,这时候应该优先使用代理,这可以给我们带来更大的灵活性。通过require(‘forwardable’)并且继承Forwardable类,我们可以使用def_delegators方法实现代理。

异常

使用定制的异常

  当raise直接传字符串时,抛出RuntimeError,这个异常有点表意不明,推荐使用其他特定的异常类或者自定义的异常类作为参数传给raise。这样能抛出更有用的信息。自定义异常类需要继承StandardError,并且类名要以Error结尾。

  如果自定义的异常类重写了initialize方法,需要确保其调用了super方法,最好以错误信息作为参数。在initialize方法中再次设置错误信息,会覆盖在initialize中设置的。

  raise方法可以传入类对象或者实例对象作为参数,这两种方法没有什么不同。

捕获具体的异常

  以白名单法捕获异常,即捕获知道怎么处理的异常,尽量不捕获不知道如何处理的异常。如果在rescue中又抛出了另一个异常,此时会离开原来的作用域,前一个异常信息会被丢弃,新异常会被抛出。可以在rescue中执行一个特殊方法,以异常对象作为参数,在方法中执行rescue,把原异常抛出。

通过块和ensure管理资源

  ensure用来执行异常处理后的清理工作,但如果处处都有ensure就显得比较笨重,我们可以进行一下封装。将一个块与类方法绑定起来,在这个方法里调用块,并且执行一些清理工作。在方法里还可以用block_given?来判断是否传入块,用这种方法使得清理工作变得非常灵活且自动化。

不在ensure里使用return

  在rescue里使用return会忽略掉所有StandardError异常,在ensure里使用return会忽略掉所有异常,必须避免。ensure里使用next和break也会丢弃异常。

限制retry的次数

  如果需要在rescue里重试,可以使用retry关键字。避免可能无限重试,添加一个计数器来限制retry次数,并且等待一定的时间再重试。但延时有可能会加剧问题,可以用指数退避算法等方法来调整等待时间。

  如果retry过程中又抛出了另一个异常,那么原始异常信息就会丢失。建议在日志里记录一下retry的信息。

throw控制流程

  throw比raise更适合用来控制流程,可以搭配catch和throw来跳出作用域。但如果滥用,会使得调用栈十分复杂,应尽量用简单的办法来控制流程,例如方法调用和return,代替catch和throw。