Ruby中常量的运行机制:
1.嵌套
类和模块可以嵌套,来组成命名空间
命名空间在ruby中就是一个沙盒,在其中定义的常量名和方法名可以不用担心名称与其他常量名和方法名冲突
module XML
class SAXParser
# (1)
puts Module.nesting
end
end
可以在代码的任意位置调用Module.nesting来审查当前的嵌套结构,比如(1)处的嵌套就是[XML::SAXParser, XML]
借着上述的类XML::SAXParser来解释几个关键概念:
- 该类的名字叫做
XML::SAXParser
- 该类内部的嵌套为
[XML::SAXParser, XML]
- 该类内部的命名空间是
XML::SAXParser
- 该类的嵌套是由类
XML::SAXParser
和模块XML
组成,外层是一个模块,第二层一个类
这里还涉及一个官方特意点出的概念:
组成嵌套的是类和模块“对象”,而不是访问它们的常量,与它们的名称也没有关系。
解析:
- 组成嵌套的是类和模块“对象”,首先这里的类和模块对象指的是它们都是
Class
和Module
的对象,在ruby中我们常用的类它们本身也是个对象,只不过它们是Class的对象,而模块是Module的对象,从上面的Module.nesting结果也可以看到,嵌套中有个2个元素,一个类XML::SAXParser
和一个模块XML
- “而不是访问它们的常量”,在上面的例子中我们是通过XML::SAXParser常量即可访问到我们定义的类,但事实上同样的常量名,是可以有完全不同的嵌套结构的,可以见下面的示例
- “与它们的名称也没有关系”,的确,在上面定义的类中,该类的名称是"XML::SAXParser",但是对于另一种定义该类的方式,名称一样的情况下,嵌套也可以截然不同,见下面的示例
class XML::SAXParser
# (2)
puts Module.nesting
end
puts XML::SAXParser.name
上面这个例子中,访问的常量也是XML::SAXParser
,常量的名称也是"XML::SAXParser",所以说光看到一个常量的名称或者访问它的方式,是无法确定它的嵌套的。
为什么要弄清嵌套的含义呢?
因为嵌套影响着其内部的常量的查找的方式,这样当在一个嵌套内使用一个未定义的常量的时候我们能够知道这个常量是如何查找的
嵌套是解释器维护的一个内部堆栈,根据以下规则修改:
执行 class 关键字后面的定义体时,类对象入栈;执行完毕后出栈。
执行 module 关键字后面的定义体时,模块对象入栈;执行完毕后出栈。
执行 class << object 打开的单例类时,类对象入栈;执行完毕后出栈。
调用 instance_eval 时如果传入字符串参数,接收者的单例类入栈求值的代码所在的嵌套层次。调用 class_eval 或 module_eval 时如果传入字符串参数,接收者入栈求值的代码所在的嵌套层次.
顶层代码中由 Kernel#load 解释嵌套是空的,除非调用 load 时把第二个参数设为真值;如果是这样,Ruby 会创建一个匿名模块,将其入栈。
这个入栈规则我们仍旧以上面的XML::Parser来做解释:
module XML
class SAXParser
end
end
-
先碰到module关键字,将XML模块对象入栈
-
解释到第二行,发现还没执行完,继续执行,发现class关键字,将类对象XML::Parser入栈
定义类和模块是为常量赋值
前面说到通过class或者module定义类或者模块的时候其实都是在新建类或者模块对象
module Admin
end
等效于
Admin = Module.new
类和模块都有维护一个常量表
通常我们所说的String类,是不准确的,实际上它是Object常量(至于为什么是Object,这是由于ruby中的常量解析算法决定的)存储的类对象中所维护一个常量表中的一个常量指向的一个类对象,该类对象名叫"String"
2.解析算法
相对常量解析算法
举一个例子:
module A
class B
class C
p Module.nesting # => [A::B::C, A::B, A]
User
end
end
end
使用cref表示嵌套中第一个元素,比如前面的XML::SAXParser
,如果没有嵌套则表示Object
解析算法如下:
- 如果嵌套不为空,在嵌套中按元素顺序查找常量。元素的祖先忽略不计。
按照嵌套所示顺序,从A::B::C::User开始查找,一直到 A::User
- 如果未找到,算法向上,进入 cref 的祖先链。
如果查找到A::User都不存在的话,进入cref的祖先链
祖先链也就是cref的父类
这里还需要注意一点,所有的类在没有显示地继承于哪个类的情况下,都是继承自Object这个类,同时我们在顶层自定义的类或者模块也是属于Object中的,在查找顶层常量的时候大多到这一步就结束了,举个例子
class User
end
module Admin
class Man
puts User
end
end
上面这段代码,User能够正确找到它是个顶层常量,因为Admin::Man的父类是Object
如果未找到,而且 cref 是个模块,在 Object 中查找常量。
这个比较好理解,父级命名空间是个模块的话,最后也是会到顶层的Object中查找如果未找到,在 cref 上调用 const_missing 方法。这个方法的默认行为是抛出 NameError 异常,不过可以覆盖。
注意,结合2,3这两点我们会疑惑,好像父级命名空间不论是类还是模块最终都会去Object中也就是顶层中去查找,事实上如果父级命名空间是类并且继承自BasicObject(BasicObject是Object的父类)的话,那么在该命名空间下就会连顶层的常量都找不到了
class User
end
module Admin
class Man < BasicObject
# 同时测试下第4点提到的const_missing方法,应该是定义在cref中,也就是这里的Admin::Man
def self.const_missing(*args)
puts "Ah oh! 找不到了#{args}"
end
puts User
end
end
但是把上面的BasicObject换成Object或者去掉 < BasicObject就不会有这样的问题
限定常量的解析算法
所谓限定常量指的就是这种形式的常量:
Billing::Invoice
这种形式限定了Invoice常量最起码都是Billing这个命名空间之下的,这个时候先将左边的命名空间作为相对常量查找,找到之后再在该常量下或者该常量的祖先类中查找常量
词汇表
父级命名空间
仅仅指代常量的名称中去除最右边的那一部分后剩余的部分
加载机制
Rails中的通过config.cache_classes设置是否需要缓存已加载的常量。如果是true则在程序中使用Kernel#require来加载,否则使用Kernel#load
自动加载可用性
自动加载,就是执行代码时碰到一个暂时未定义的常量(可以是类或者模块或者一个其他的常量)的时候,Rails会按照该常量的嵌套去尝试自动加载,比如:
class BeachHouse < House
end
如果加载这个文件时发现House还未定义,就会去自动加载该类
在Rails的生产环境中,会及早地加载应用中的文件
autoload_paths
在ruby中,如果使用require引入一个相对文件名时,那么ruby将会在LOAD_PATH类似,Rails会在autoloads中寻找对应的文件,默认情况下这些目录包含:
-
引擎(rails应用和用到Railsengine的gem)中的app目录下所有子目录
如图是foreman项目的autoload_paths目录,因为katello、foreman_remote_execution和foreman-tasks都是用到了RailsEngine所以它们的app目录也会加在当前的autoload_paths目录下
- 应用和引擎中的名为
app/*/concerns
的二级目录 - test/mailers/previews目录
此外,这些目录可以使用config.autoload_paths配置。例如,以前这些lib在这一系列目录中,但是现在不在了,可以通过config/application.rb文件添加下述配置,将其纳入其中:
config.autoload_paths << "#{Rails.root}/lib"
自动加载算法
相对引用
以一个例子来讲解:
class PostsController < ApplicationController
def index
@posts = Post.all
end
end
在这里,PostsController,ApplicationController,Post三者都是属于相对引用,假设代码执行到这个位置的时候,这个三个常量全都是未定义的情况下,该如何处理,分别对应了3种情况:
- class,module后面的关键后的常量:即便是一个未定义的常量也不会触发自动加载,而是由ruby直接定义这个控制器类
- 顶层常量:ApplicationController就是一个顶层常量,因为从当前的代码来看,外部没有任何嵌套了。然后rails会开始检查所有autoload_paths中的目录,是否有application_controller.rb文件。
- 命名空间:不同于ApplicationController是直接去autoload_paths中寻找对应文件,这是因为它没有嵌套。而Post的就不同了,它的嵌套是PostsController,此时就需要涉及命名空间的算法
那么在上面的代码中,就会按照:
- PostsController::Post
- Post的顺序来查找
这样的顺序来查找,上面这两步在rails中有些许的不同:
1.1 查找PostsController::Post时,首先会利用underscore方法,将常量名转换为文件名,以此来查找是否有对应的posts_controller/post.rb文件
1.2 如果没有找到的话,那么会去寻找名叫post的目录,也就是:
app/assets/posts_controller/post
app/controllers/posts_controller/post
app/helpers/posts_controller/post
...
test/mailers/previews/posts_controller/post
因为,在rails中,只要在autoload_paths目录中新建了目录,那么我们即便不显示地去用module来定义这个模块,rails也会自动定义一个模块赋值给该常量
限定引用
所谓限定引用,前面已经提过就是类似:
Billing::Invoice
这样的形式的调用
如果缺失限定常量,Rails不会在父级命名空间中查找,例如:
#module Man
# class User
# end
#end
#class User
#end
module Admin
# 在Admin中调用User
User
end
或者
Admin::User
在Admin模块中调用了User常量,假如User不存在的话,那么Rails仅仅知道当前缺的是Admin模块下的一个名叫User的常量(而不会知道说这个User应该是在顶级命名空间或者在Man这个模块下的一个常量)
如果存在一个顶级User常量,那么在前面的例子中ruby将会解析,但是在后者的情况下不会解析(因为已经限定了必须是在Admin模块的命名空间下)。通常来说,Rails的解析算法和Ruby不太一样,Rails会尝试以下方式来解析:
如果父级命名空间中的类或者模块没有缺失的常量,那么Rails就认为其是一个相对常量。否则是限定常量
比如上面的例子中,父级命名空间就是Admin,并且在此之前我们并没有定义过Admin::User常量,那么这个时候,就当作相对引用来处理(也就是先查找/admin/user.rb etc.,在查找/user.rb etc.),否则Admin中也存在User常量,那么就当限定引用来处理,也就是当作Admin::User来处理
再来举个例子:
假设User是一个已经定义了的顶层常量,并且现在有如下调用:
module Admin
User
end
如果和ruby的解析算法一样的话,那么很明显,这里的User就会解析为顶层的那个User,但是对于Rails而言,这样就不会触发自动加载了,所以正如官方提到的,Rails会认为其是一个限定常量,会去查找Admin::User这个常量
总结:
ruby的常量解析算法指示了在ruby中碰到一个常量时会如何去解析它,前提是在所有文件常量都已经加载完成的情况下;而Rails还含有自动加载,也就是当常量不存在的时候还会去寻找对应的文件去自动加载,查找顺序还是和ruby中的相对引用和限定引用的查找顺序一样,只是在每一层嵌套中查找时,如果未定义的话会先去尝试自动加载,自动加载仍然没找到后再去找下一层的嵌套
碰到常量未加载如何解决?
- 如果打开控制台或者模拟的运行环境(比如rails runner或者直接在项目中打印日志),那么就打印缺失常量所在的命名空间关联的模块/类是否已加载,没有加载的话,检查autoload_paths,再检查这些路径中是否已经有对应相同层级结构的文件或者目录,有个时候常量找不到可能是因为其父级命名空间的嵌套已经被其他地方的同名结构的模块覆盖,这个时候也可以找到那个模块/类,在其中打印日志检查是否生效