こんにちは、hachi8833です。2016年度最後の記事はActive Support探訪シリーズです。
今回はString#pluralize
にお邪魔します。
先日Railsが5.0.1になりましたので今後5.0.1を使うことにしました。
今回のメソッド
- メソッド:
String#pluralize
- ディレクトリ配置: https://github.com/rails/rails/blob/5-0-stable/activesupport/lib/active_support/core_ext/string/inflections.rb
条件
- Railsバージョン: 5-0-stable(執筆時点では5.0.1)
- Rubyバージョン: 2.4.0
String#pluralize
について
ご存じのとおり、RailsではMVCのクラス名やファイル名などで英語の単数形<=>複数形などの変換が多用されています。scaffold`コマンドを使ってモデル/ビュー/コントローラを生成すると、たとえば次のようになります。
要素 | 単複 | クラス名の例(キャメルケース) | ファイル名の例(スネークケース) |
---|---|---|---|
コントローラ | 複数形 | AdminUsers | admin_users.rb |
モデル | 単数形 | AdminUser | admin_user.rb |
ビュー | 複数形 | AdminUsers | admin_users.erb |
こうした変換に使われているのが、String#pluralize
などの各種活用形メソッドです。
詳しくはRailsガイド: 活用形をご覧ください。
今回は、String#pluralize
で不規則な活用(octopus->octopiなど)が行われる部分に絞って追ってみたいと思います。きっとどこかに不規則活用表に相当するデータを持っているはずなので、それを見つけるところまでやってみます。
string/inflections.rb
例によってコメント行は省略しています。
require 'active_support/inflector/methods'
require 'active_support/inflector/transliterate'
class String
def pluralize(count = nil, locale = :en)
locale = count if count.is_a?(Symbol)
if count == 1
self.dup
else
ActiveSupport::Inflector.pluralize(self, locale)
end
end
def singularize(locale = :en)
ActiveSupport::Inflector.singularize(self, locale)
end
def constantize
ActiveSupport::Inflector.constantize(self)
end
def safe_constantize
ActiveSupport::Inflector.safe_constantize(self)
end
def camelize(first_letter = :upper)
case first_letter
when :upper
ActiveSupport::Inflector.camelize(self, true)
when :lower
ActiveSupport::Inflector.camelize(self, false)
end
end
alias_method :camelcase, :camelize
def titleize
ActiveSupport::Inflector.titleize(self)
end
alias_method :titlecase, :titleize
def underscore
ActiveSupport::Inflector.underscore(self)
end
def dasherize
ActiveSupport::Inflector.dasherize(self)
end
def demodulize
ActiveSupport::Inflector.demodulize(self)
end
def deconstantize
ActiveSupport::Inflector.deconstantize(self)
end
def parameterize(sep = :unused, separator: '-', preserve_case: false)
unless sep == :unused
ActiveSupport::Deprecation.warn("Passing the separator argument as a positional parameter is deprecated and will soon be removed. Use `separator: '#{sep}'` instead.")
separator = sep
end
ActiveSupport::Inflector.parameterize(self, separator: separator, preserve_case: preserve_case)
end
def tableize
ActiveSupport::Inflector.tableize(self)
end
def classify
ActiveSupport::Inflector.classify(self)
end
def humanize(options = {})
ActiveSupport::Inflector.humanize(self, options)
end
def upcase_first
ActiveSupport::Inflector.upcase_first(self)
end
def foreign_key(separate_class_name_and_id_with_underscore = true)
ActiveSupport::Inflector.foreign_key(self, separate_class_name_and_id_with_underscore)
end
end
一読してわかるのは、String#pluralize
を含むほとんどのメソッドがActiveSupport::Inflector
に丸投げされていることです。いわゆる委譲(delegate)ですね。
pluralize
とsingularize
はロケールによって変える必要があるので、ロケールを引数として渡せるようになっています。デフォルトのロケールはen
です。
履歴をざっと見てみると、もともとはcore_ext/string/inflections.rbにあったコードが、7db0b0: Make ActiveSupport::Inflector locale aware and multilingualのマルチリンガル対応などの改修によってinflector/inflections.rbに順次移動したようです。
ActiveSupport::Inflector
というわけで、本体であるActiveSupport::Inflector
を見てみます。#pluralize
はどこでしょうか。
require 'concurrent/map'
require 'active_support/core_ext/array/prepend_and_append'
require 'active_support/i18n'
module ActiveSupport
module Inflector
extend self
class Inflections
@__instance__ = Concurrent::Map.new
class Uncountables < Array
def initialize
@regex_array = []
super
end
def delete(entry)
super entry
@regex_array.delete(to_regex(entry))
end
def <<(*word)
add(word)
end
def add(words)
self.concat(words.flatten.map(&:downcase))
@regex_array += self.map {|word| to_regex(word) }
self
end
def uncountable?(str)
@regex_array.any? { |regex| regex === str }
end
private
def to_regex(string)
/\b#{::Regexp.escape(string)}\Z/i
end
end
def self.instance(locale = :en)
@__instance__[locale] ||= new
end
attr_reader :plurals, :singulars, :uncountables, :humans, :acronyms, :acronym_regex
def initialize
@plurals, @singulars, @uncountables, @humans, @acronyms, @acronym_regex = [], [], Uncountables.new, [], {}, /(?=a)b/
end
# Private, for the test suite.
def initialize_dup(orig) # :nodoc:
%w(plurals singulars uncountables humans acronyms acronym_regex).each do |scope|
instance_variable_set("@#{scope}", orig.send(scope).dup)
end
end
def acronym(word)
@acronyms[word.downcase] = word
@acronym_regex = /#{@acronyms.values.join("|")}/
end
def plural(rule, replacement)
@uncountables.delete(rule) if rule.is_a?(String)
@uncountables.delete(replacement)
@plurals.prepend([rule, replacement])
end
def singular(rule, replacement)
@uncountables.delete(rule) if rule.is_a?(String)
@uncountables.delete(replacement)
@singulars.prepend([rule, replacement])
end
def irregular(singular, plural)
@uncountables.delete(singular)
@uncountables.delete(plural)
s0 = singular[0]
srest = singular[1..-1]
p0 = plural[0]
prest = plural[1..-1]
if s0.upcase == p0.upcase
plural(/(#{s0})#{srest}$/i, '\1' + prest)
plural(/(#{p0})#{prest}$/i, '\1' + prest)
singular(/(#{s0})#{srest}$/i, '\1' + srest)
singular(/(#{p0})#{prest}$/i, '\1' + srest)
else
plural(/#{s0.upcase}(?i)#{srest}$/, p0.upcase + prest)
plural(/#{s0.downcase}(?i)#{srest}$/, p0.downcase + prest)
plural(/#{p0.upcase}(?i)#{prest}$/, p0.upcase + prest)
plural(/#{p0.downcase}(?i)#{prest}$/, p0.downcase + prest)
singular(/#{s0.upcase}(?i)#{srest}$/, s0.upcase + srest)
singular(/#{s0.downcase}(?i)#{srest}$/, s0.downcase + srest)
singular(/#{p0.upcase}(?i)#{prest}$/, s0.upcase + srest)
singular(/#{p0.downcase}(?i)#{prest}$/, s0.downcase + srest)
end
end
def uncountable(*words)
@uncountables.add(words)
end
def human(rule, replacement)
@humans.prepend([rule, replacement])
end
def clear(scope = :all)
case scope
when :all
@plurals, @singulars, @uncountables, @humans = [], [], Uncountables.new, []
else
instance_variable_set "@#{scope}", []
end
end
end
def inflections(locale = :en)
if block_given?
yield Inflections.instance(locale)
else
Inflections.instance(locale)
end
end
end
end
# Specifies a new pluralization rule and its replacement. # Specifies a new singularization rule and its replacement.
といったコメントが目につきます。
しかしinflector/inflections.rbにはInflectorモジュールはあるものの、意外にも#pluralize
メソッドが見当たりません。
inflector/methods.rbのInflectorモジュール
探してみたところ、#pluralize
はinflector/methods.rbのInflectorモジュールにありました。長くなるので今度はメソッド定義だけ取り出してみました。
def pluralize(word, locale = :en)
apply_inflections(word, inflections(locale).plurals)
end
def singularize(word, locale = :en)
apply_inflections(word, inflections(locale).singulars)
end
今度は#apply_inflections
です。これは同じinflector/methods.rbにありました。#pluralize
も#apply_inflections
もInflectorモジュールのメソッドです。
def apply_inflections(word, rules)
result = word.to_s.dup
if word.empty? || inflections.uncountables.uncountable?(result)
result
else
rules.each { |(rule, replacement)| break if result.sub!(rule, replacement) }
result
end
end
inflections(locale).plurals
やinflections(locale).singulars
を引数として渡すことで動作を変更するようになっています。
では引数のinflections(locale)
はどこにあるのでしょうか。探してみるとinflector/inflections.rbのInflectorモジュールにありました。
def inflections(locale = :en)
if block_given?
yield Inflections.instance(locale)
else
Inflections.instance(locale)
end
end
ブロックを渡すかどうかで若干動作が異なりますが、今度はInflections.instance
を呼び出しています。だんだん不安になってきました。Inflections.instance
はどこにあるのでしょうか。
inflections.rb
活用表に相当するデータは、結局active_support/inflections.rbのActiveSupportモジュールで見つかりました。
module ActiveSupport
Inflector.inflections(:en) do |inflect|
inflect.plural(/$/, "s")
inflect.plural(/s$/i, "s")
inflect.plural(/^(ax|test)is$/i, '\1es')
inflect.plural(/(octop|vir)us$/i, '\1i')
inflect.plural(/(octop|vir)i$/i, '\1i'
...
inflect.singular(/s$/i, "")
inflect.singular(/(ss)$/i, '\1')
inflect.singular(/(n)ews$/i, '\1ews')
inflect.singular(/([ti])a$/i, '\1um')
inflect.singular(/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)(sis|ses)$/i, '\1sis')
...
inflect.irregular("person", "people")
inflect.irregular("man", "men")
inflect.irregular("child", "children")
...
inflect.uncountable(%w(equipment information rice money species series fish sheep jeans police))
end
end
英語の宿命である不規則活用は機械的な活用形を拒むので、こうした対応表がどこかで必要になります。
senpaiのような新語や外来語は、不規則活用表になければ当然規則活用が適用されることになります。
#pluralize
や#singularize
はRailsで必要な活用変化をカバーするのが目的と考えるのが自然なので、person<->peopleなど、Rails開発で使われる可能性のある語彙に絞っていると理解しました。英語のあらゆる不規則活用表をカバーするのは現実的ではなさそうですし、何より不規則活用表を進んでメンテする人がいなさそうです。
#pluralize
がこんなにたらい回しにされているあたりにRailsの歴史を感じてしまいました。
活用形のカスタマイズ方法
活用形周りをカスタマイズする方法については、以下の外部記事をご覧ください。
記事中にもあるとおり、Railsのconfig/initializers/inflections.rbに追記すればよいです。以下にRails 5のinflections.rbをざっくり訳して引用します。
# このファイルを変更後サーバーを必ず再起動すること
# 以下のフォーマットで新しい活用形ルールを追加できる
# 活用形はロケールに依存するので、好きなだけロケールごとの活用ルールを設定できる
# ちなみに以下の例はすべてデフォルトで有効になっている(のでここで足さなくてよい)
# ActiveSupport::Inflector.inflections(:en) do |inflect|
# inflect.plural /^(ox)$/i, '\1en'
# inflect.singular /^(ox)en/i, '\1'
# inflect.irregular 'person', 'people'
# inflect.uncountable %w( fish sheep )
# end
# 以下の活用ルールはサポートされているがデフォルトでは有効になっていない
# ActiveSupport::Inflector.inflections(:en) do |inflect|
# inflect.acronym 'RESTful'
# end
サンプル部分をコードハイライトしたかったので取り出してみました。
ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.plural /^(ox)$/i, '\1en'
inflect.singular /^(ox)en/i, '\1'
inflect.irregular 'person', 'people'
inflect.uncountable %w( fish sheep )
end
ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym 'RESTful'
end
最後に
ActiveSupport::Inflector
には便利メソッドが多いので、次回は他のメソッドも概観してみようと思います。