Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

[Rails5] Active Support Core ExtensionsのString#pluralize

こんにちは、hachi8833です。2016年度最後の記事はActive Support探訪シリーズです。

今回はString#pluralizeにお邪魔します。

先日Railsが5.0.1になりましたので今後5.0.1を使うことにしました。

今回のメソッド

条件

  • 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)ですね。

pluralizesingularizeはロケールによって変える必要があるので、ロケールを引数として渡せるようになっています。デフォルトのロケールは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).pluralsinflections(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には便利メソッドが多いので、次回は他のメソッドも概観してみようと思います。

関連記事

[Ruby] module_functionでモジュールの特異メソッドを簡潔に書く

Active Support core_extのHash#diffはRails 4で非推奨化・廃止された

Rails: ビューのHTMLエスケープは#link_toなどのヘルパーメソッドで解除されることがある


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。