ActiveSupport::HashWithIndifferentAccess源码分析-赋予散列表无差别访问功能的类
今天这篇文章主要是对ActiveSupport::HashWithIndifferentAccess
这个类进行源码分析。它的实例是一个类Hash对象,它们能够同等对待“内容相同”的符号与字符串,让用户可以进行无差别地去访问该键所对应的值。
上面所提到的“内容相同”指的是能够通过Symbol#to_s
与String#to_sym
进行相互转换的符号与字符串。
起源
Ruby中的散列表,会有一个问题(其实也不能算是问题),就是针对符号与字符串,哪怕它们长得很像,散列表还是会把它们当成不同的键,大概是这样:
irb(main):006:0> hash = { a: 1, b: 2}
irb(main):007:0> hash[:a]
=> 1
irb(main):008:0> hash['a']
=> nil
用户往往会期待,Hash表能够把符号:a
与字符串"a"
当作同一个键来对待,也就是:
irb(main):007:0> hash[:a]
=> 1
irb(main):008:0> hash['a'] // 这是期望
=> 1
它们会希望Ruby能够默默地帮忙做这种转换:
irb(main):008:0> hash['a'.to_sym]
=> 1
个人观点:这种区别对待也是Ruby的一种设计理念,某种程度上能够避免掉一些不必要的Bug。如果我们真的想要无差别访问的功能,可以自己复写Hash
原来的方法,或者直接寻求第三方工具库的支持。
如果在找第三方库的话ActiveSupport::HashWithIndifferentAccess
或许就是你想要的东西。
有的人已经这样做了
Rails里面确实有些对象能够做到符号与字符串的无差别访问了。我们最熟悉的params
就是这样一个东西。以下是我在控制器中利用binding.pry
调试的结果
[1] pry(#<HomeController>)> params
=> <ActionController::Parameters {"controller"=>"home", "action"=>"index"} permitted: false>
[2] pry(#<HomeController>)> params[:sym_key] = "You can access me by String"
=> "You can access me by String"
[3] pry(#<HomeController>)> params['sym_key'] = "You can access me by String"
=> "You can access me by String"
从actionpack/lib/actioncontroller/metal/strongparameters.rb的源码来看,params
其实是一个方法
module ActionController
# ...
class Parameters
# ...
def initialize(parameters = {})
@parameters = parameters.with_indifferent_access
@permitted = self.class.permit_all_parameters
end
# ....
def []=(key, value)
@parameters[key] = value
end
end
module StrongParameters
def params
@_params ||= Parameters.new(request.parameters)
end
end
end
利用内部类ActionController::Parameters
进行实例化,参数是request.parameters
。先不管request.parameters
是啥玩意,从初始化方法来看,哪怕传入的参数是一个普通的Hash
实例,它都会调用Hash#with_indifferent_access
这个方法。从字面上来看,它就是把当前的散列对象转换成有无差别访问能力的对象并返回。
原生的Hash
并不具有这个方法,一定是Rails在某处对它进行的扩展。全局搜索了一下,源码位于activesupport/lib/activesupport/coreext/hash/indifferent_access.rb
require "active_support/hash_with_indifferent_access"
class Hash
def with_indifferent_access
ActiveSupport::HashWithIndifferentAccess.new(self)
end
alias nested_under_indifferent_access with_indifferent_access
end
实际上就是把原生的Hash
实例转换成ActiveSupport::HashWithIndifferentAccess
的实例,而这个类就是做到无差别访问的关键。
不过单凭上面的例子,证据稍显不足。毕竟封装层数有点多,难免眼花缭乱。有个简便的方法可以验证一下,来看看request.parameters
的类是什么
[1] pry(#<PostsController>)> request.parameters.class
=> ActiveSupport::HashWithIndifferentAccess
恰好它就是一个ActiveSupport::HashWithIndifferentAccess
的实例,再看看它是否具有无差别访问能力?
[1] pry(#<PostsController>)> hash = request.parameters
=> ...
[2] pry(#<PostsController>)> hash[:sym_key] = "Access Me"
=> "Access Me"
[3] pry(#<PostsController>)> hash[:sym_key]
=> "Access Me"
[4] pry(#<PostsController>)> hash['sym_key']
=> "Access Me"
看来判断没错,无差别访问的能力就是隐藏在ActiveSupport::HashWithIndifferentAccess
这个类下面。接下来就把注意力放在它的源码上。
源码分析
ActiveSupport::HashWithIndifferentAccess
的源码位于activesupport/lib/activesupport/hashwithindifferentaccess.rb。这源码文件有点长,笔者只取关键的部分来分析。
1. 简单的读写
module ActiveSupport
class HashWithIndifferentAccess < Hash
alias_method :regular_writer, :[]= unless method_defined?(:regular_writer)
# ....
# Assigns a new value to the hash:
#
# hash = ActiveSupport::HashWithIndifferentAccess.new
# hash[:key] = 'value'
#
# This value can be later fetched using either +:key+ or <tt>'key'</tt>.
def []=(key, value)
regular_writer(convert_key(key), convert_value(value, conversion: :assignment))
end
def [](key)
super(convert_key(key))
end
end
end
首先可以注意到的是HashWithIndifferentAccess
是Hash
的子类,接下来要做的其实就是对Hash
类进行扩展。
上面有这么一行代码:
alias_method :regular_writer, :[]= unless method_defined?(:regular_writer)
这段代码的作用是,对原生的Hash#[]=
方法进行备份。我们接下来不仅仅要复写这个方法,而且还需要在新的方法里面调用原生的方法。备份也避免了同名方法嵌套调用而导致的递归行为。
def []=(key, value)
regular_writer(convert_key(key), convert_value(value, for: :assignment))
end
当然,Hash#[]
这个方法采用super
关键字也达到了相同的效果:
def [](key)
super(convert_key(key))
end
然而我们并不仅仅是在复写原来方法的时候会用到Hash#[]=
,该文件的很多地方都会用到,只是我这里没有贴出来而已。故而,我们依旧需要采用一个别名来承接原生的Hash#[]=
方法,这里把它改名为regular_writer
。
2. 内部转换
经过上述代码的复写,这个文件中得到了两个新方法,分别是HashWithIndifferentAccess#[]
,HashWithIndifferentAccess#[]=
。它们所做的事情无非就是在对散列表进行读写之前,先对键值对进行转换处理。而这里对键跟值的转换分别采用了HashWithIndifferentAccess#convert_key
与HashWithIndifferentAccess#convert_value
这两个方法来处理,接下来就分析一下这两个方法。
1). 键转换
先来看HashWithIndifferentAccess#convert_key
方法
module ActiveSupport
class HashWithIndifferentAccess < Hash
# ...
private
def convert_key(key)
key.kind_of?(Symbol) ? key.to_s : key
end
# ...
end
end
它是一个私有方法,做的事情相当简单,无非就是把符号类型的键转换成字符串(采用Symbol#to_s
),其他的类型则保持原样。
2). 值转换
值的转换就稍微麻烦些了,为了方便查看,我把跟HashWithIndifferentAccess#convert_value
有关的方法都统一列出来
module ActiveSupport
class HashWithIndifferentAccess < Hash
def nested_under_indifferent_access
self
end
# Convert to a regular hash with string keys.
def to_hash
_new_hash = Hash.new
set_defaults(_new_hash)
each do |key, value|
_new_hash[key] = convert_value(value, conversion: :to_hash)
end
_new_hash
end
private
def convert_value(value, conversion: nil)
if value.is_a? Hash
if conversion == :to_hash
value.to_hash
else
value.nested_under_indifferent_access
end
elsif value.is_a?(Array)
if conversion != :assignment || value.frozen?
value = value.dup
end
value.map! { |e| convert_value(e, conversion: conversion) }
else
value
end
end
# ...
end
end
同样的,它也是一个私有方法。只是会根据不同的类型来区分处理。
类型是散列表:
检测到值类型为Hash
时,会解构出的配置项中的conversion
属性,并判断conversion == :to_hash
,如果结果是真,则调用Hash#to_hash
这个方法。这个方法在该类中也是被复写过的,简单来说它的作用就是:把HashWithIndifferentAccess
的实例转换成Hash
的实例,只是这个时候,所得到的Hash
实例中所有的键都是字符串(这里就不具体分析那段代码了)。
反之,就会调用Hash#nested_under_indifferent_access
这个方法。这个方法我在上一章也贴过,它会以当前Hash
实例为范本构造出一个HashWithIndifferentAccess
的实例。不过在这个类中有对这个方法进行过优化:
def nested_under_indifferent_access
self
end
调用者如果已经是HashWithIndifferentAccess
的实例的话,则直接返回调用者本身,不需要再走冗长的初始化流程了。
类型是数组:
如果检测到值是数组类型,那么会先检测配置项中的conversion
是否等于:assignment
。如果结果为假值,又或者是值本身已经被冻结了(frozen?),那么就用Object#dup
来对值进行一次浅拷贝。(关于Object#dup
的用法,可以参考笔者之前翻译的文章)。
接着,再以递归的形式对数组中的每一个元素用convert_value
进行处理,并以相同的配置项({ conversion: xxxx })作为配置参数。因为这里采用的是Array#map!
方法,所以会改变数组自身。
其他类型:
其他类型则不做任何转换。
3. 更新(合并)
还有一个稍微繁杂点的操作-更新操作。原生方法是Hash#update
。它主要是用来合并两个,或多个散列表。大概就像这样:
> a = {a: 1, b: 2, c: 1}
> b = {a: 100, b: 10000, d: 90}
> c = {e: 3000}
> a.update(b)
=> {:a=>100, :b=>10000, :c=>1, :d=>90}
> a.update(b, c)
=> {:a=>100, :b=>10000, :c=>1, :d=>90, :e=>3000}
个人觉得这个方法改成Hash#merge
会更好吧。事实上,Ruby社区也是这样想的:
> a.merge(b)
=> {:a=>100, :b=>10000, :c=>1, :d=>90}
该方法在HashWithIndifferentAccess
这个类中也被复写了
module ActiveSupport
class HashWithIndifferentAccess < Hash
# ...
alias_method :regular_update, :update unless method_defined?(:regular_update)
# ...
def update(*other_hashes, &block)
if other_hashes.size == 1
update_with_single_argument(other_hashes.first, block)
else
other_hashes.each do |other_hash|
update_with_single_argument(other_hash, block)
end
end
self
end
private
# ...
def update_with_single_argument(other_hash, block)
if other_hash.is_a? HashWithIndifferentAccess
regular_update(other_hash, &block)
else
other_hash.to_hash.each_pair do |key, value|
if block && key?(key)
value = block.call(convert_key(key), self[key], value)
end
regular_writer(convert_key(key), convert_value(value))
end
end
end
end
end
首先是常规操作,采用别名regular_update
来备份原生的Hash#update
方法。接下来再去定制自身的HashWithIndifferentAccess#update
。
更新方法本身能够接收多个参数,如果参数只有一个的时候就直接调用HashWithIndifferentAccess#update_with_single_argument
,否则的话则遍历参数数组,依次对每个元素调用该方法。
最后来分析一下HashWithIndifferentAccess#update_with_single_argument
。首先它会检测需要被合并的值是否为HashWithIndifferentAccess
的实例,如果是的话,则直接用原生的Hash#update
方法(已经被别名为regular_update
)进行处理。
否则的话则先调用to_hash
。这个调用的好处在于,如果对象自身并没有to_hash
这个方法,就会直接抛出异常。不需要做更多的类型判断,就能提示用户采用合法的数据,这也是鸭子类型的好处。
> HashWithIndifferentAccess.new.update(1)
Traceback (most recent call last):
1: from (irb):6
NoMethodError (undefined method `to_hash' for 1:Integer)
Did you mean? to_s
接着,遍历该值的所有键值对,并采用原生的写方法Hash#write
(此处被别名为regular_write
)把对应的键与值写入到当前对象中去。
另外要注意一下,这段代码:
if block && key?(key)
value = block.call(convert_key(key), self[key], value)
end
update
方法可能会接收代码块,该代码块会被传递到HashWithIndifferentAccess#update_with_single_argument
方法中。如果对应的键已经存在于当前对象中,并且代码块也存在的时候,则用代码块来换算出最新的值。接着再往下执行,用新值来插入。
尾声
篇幅有限就先分析到这,若有哪里分析得不对的,还望指正。除了上述的方法之外ActiveSupport::HashWithIndifferentAccess
还对其他许多Hash
已有的方法进行了复写,是为了更全面地赋予散列表无差别访问功能,有兴趣的可以自己去看看。