こんにちは、BPSの福岡拠点として一緒にお仕事させて頂いてます、株式会社ウイングドアの坂本です。
普段日本国内で生活しているとなかなか使う機会がない英語。
しかしプログラミングにはドキュメントを読んだり、コードを書いたりと避けられないものですよね。
モデルには名詞の単数形を、テーブル名にはその複数形を、等英語のルールに則り命名することも多いかと思います。
そんな英語名詞の単数から複数形への変換処理。
各フレームワークでどんな処理になっているのか覗いてみると、
それぞれ実装の仕方や定義が違って面白かったのでぜひご紹介したいと思います。
調査対象
今回調査したのは以下のフレームワークです。
- Ruby on Rails 5.2.3 (Ruby)
- CakePHP 3.5.7 (PHP)
- Symfony 4.3 (PHP)
- Laravel 5.8 (PHP)
主に自分が利用している、したことあるものを中心にピックアップしてみました。
また、今回は英語の複数形の定義のみ調査しています。
英語の複数形について
実装の中身を見る前に、改めて複数形について確認しましょう。
複数形とは
ご存知かと思いますが英語などいくつかの言語の名詞には単数形(singular)と複数形(plural)があります。
対象が1つであるか、2つ以上であるかによってスペルが代わり、また意味も異なる場合もあるそうです。
また、すべての名詞が複数形になるわけではありません。
名詞が以下のような考えによって分類され、可算名詞のみが複数形の変換が必要になります
- 可算名詞(countable noun)
- 1つ、2つなど個数を数えられるようなもの
複数形の変換が必要なのはこれ - 不可算名詞(uncountable nouns)
- 単数/複数の区別がない、個体の概念のないもの
- 単複同形名詞
- 単数/複数の区別ができるのに綴りは一緒
単複同形名詞
ちょっと難しそうなのは単複同形名詞。
単複同形名詞されやすいものは以下のパターンに分類できるそうです。
- 基本的に「群れ単位で扱われる動物や魚」
- 例) 魚/羊などの群れで生息するもの
(※ニワトリ、ヤギなど多数派の動物は単数形・複数形が存在する) - 発音上の区別がつきにくい語、発音が面倒くさくなる語
- 例) means / Japanese
- 比較的新しい外来語
- 例) sushi(寿司)/ tempura(天ぷら)
- 語尾に-craftまたは-wareのつく語
- 例) craft(船舶) / hardware(ハードウェア)
参考: 英語の「単数形と複数形の区別がない可算名詞」(単複同形名詞)
複数形のルール
複数形の変換は以下のようになります。
- 基本的には単数形に -s をつける
- 例)cat → cats
- sh, ch など(歯擦音)で終わる場合は、単数形に -es をつける
- 例)bush→ bushes
- e で終わる名詞は -s をつける
- 例)case→ cases
- o で終わる一部の名詞は -es をつける
- 例)tomato→ tomatoes
- y で終わる殆どの語は -ies をつける
- 例)lady→ ladies
- f あるいは fe で終わる一部は -ves をつける
- 例)leaf→ leaves
- sis あるいは xis で終わる殆どの語は -ses、-xes をつける
- 例) oasis → two oases
- x で終わる語は -ces をつけることがある
- 例) matrix → matrices
- us で終わる語はそれを -i に置き換えて複数形になることがある
- 例) cactus → cacti
- um あるいは on で終わる語の一部はそれを -a に置き換えて複数形になる
- 例) forum → two fora
その他、例外も多数存在する
参考: Wikipedia「複数」より
上記を踏まえて、早速各フレームワークの実装を見てみましょう。
Ruby on Rails 5.2.3 (Ruby)
調査メソッド
RailsはActiveSupport.Inflectorモジュールのpluralize
メソッドを調査します。
参考: pluralize
-- ActiveSupport::Inflector
名前的にも処理的にも複数形の定義で問題なさそうです
pluralize('post') # => "posts"
pluralize('octopus') # => "octopi"
ソースコード
require "active_support/inflections"
# 〜 略 〜
def pluralize(word, locale = :en)
apply_inflections(word, inflections(locale).plurals, locale)
end
# 〜 略 〜
def apply_inflections(word, rules, locale = :en)
result = word.to_s.dup
if word.empty? || inflections(locale).uncountables.uncountable?(result)
result
else
rules.each { |(rule, replacement)| break if result.sub!(rule, replacement) }
result
end
end
構造
大まかな流れは以下のようです。
- 複数形の変換ルールとlocaleを、メソッド
apply_inflections
に渡して変換 apply_inflections
メソッドで以下の処理実行- 最初に不可算名詞かどうか判定し、不可算名詞の場合はそのまま返す。(空文字も同様)
- 不可算名詞でなければ渡されたのルールに最初に一致するものがあれば複数形に変換して返す、という形のようです。
変換のルール
では肝心な複数形の変換ルールはどう定義されているのか見てみましょう。
変換ルールは以下のファイルで定義されているようです。
こちらではinflect.plural({検索対象文字列}, {置換文字列})
という形で複数形の変換のルールを定義していました。
ちなみに定義数は21個です。
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.plural(/(alias|status)$/i, '\1es')
inflect.plural(/(bu)s$/i, '\1ses')
inflect.plural(/(buffal|tomat)o$/i, '\1oes')
inflect.plural(/([ti])um$/i, '\1a')
inflect.plural(/([ti])a$/i, '\1a')
inflect.plural(/sis$/i, "ses")
inflect.plural(/(?:([^f])fe|([lr])f)$/i, '\1\2ves')
inflect.plural(/(hive)$/i, '\1s')
inflect.plural(/([^aeiouy]|qu)y$/i, '\1ies')
inflect.plural(/(x|ch|ss|sh)$/i, '\1es')
inflect.plural(/(matr|vert|ind)(?:ix|ex)$/i, '\1ices')
inflect.plural(/^(m|l)ouse$/i, '\1ice')
inflect.plural(/^(m|l)ice$/i, '\1ice')
inflect.plural(/^(ox)$/i, '\1en')
inflect.plural(/^(oxen)$/i, '\1')
inflect.plural(/(quiz)$/i, '\1zes')
特殊な変換
また、以下の項目は特殊な変換として、単数形/複数形のそれぞれの先頭に追加しています。
inflect.irregular({単数形}, {複数形})
という形で6個定義されています。
inflect.irregular("person", "people")
inflect.irregular("man", "men")
inflect.irregular("child", "children")
inflect.irregular("sex", "sexes")
inflect.irregular("move", "moves")
inflect.irregular("zombie", "zombies")
不可算名詞
不可算名詞の定義は以下の10個。
魚や羊がしっかり定義されています。
inflect.uncountable(%w(equipment information rice money species series fish sheep jeans police))
その他
複数形のルールはconfig/initializers/inflections.rb
に定義を追加することもできるようです。
参考: rails/active_support_core_extensions.md at 5-2-stable · rails/rails
CakePHP 3.5.7 (PHP)
調査メソッド
続いてCakePHP3。
Inflector::pluralize($singular)
を調査していきたいと思います。
ソースコード
ソースコードはこんな感じ。
Railsと違って、定義も同じファイル。
文字列をキャメルケースに変換するメソッドなどもこちらのクラスで一緒に実装されています。
class Inflector
{
// 〜 略 〜
public static function pluralize($word)
{
if (isset(static::$_cache['pluralize'][$word])) {
return static::$_cache['pluralize'][$word];
}
if (!isset(static::$_cache['irregular']['pluralize'])) {
static::$_cache['irregular']['pluralize'] = '(?:' . implode('|', array_keys(static::$_irregular)) . ')';
}
if (preg_match('/(.*?(?:\\b|_))(' . static::$_cache['irregular']['pluralize'] . ')$/i', $word, $regs)) {
static::$_cache['pluralize'][$word] = $regs[1] . substr($regs[2], 0, 1) .
substr(static::$_irregular[strtolower($regs[2])], 1);
return static::$_cache['pluralize'][$word];
}
if (!isset(static::$_cache['uninflected'])) {
static::$_cache['uninflected'] = '(?:' . implode('|', static::$_uninflected) . ')';
}
if (preg_match('/^(' . static::$_cache['uninflected'] . ')$/i', $word, $regs)) {
static::$_cache['pluralize'][$word] = $word;
return $word;
}
foreach (static::$_plural as $rule => $replacement) {
if (preg_match($rule, $word)) {
static::$_cache['pluralize'][$word] = preg_replace($rule, $replacement, $word);
return static::$_cache['pluralize'][$word];
}
}
}
// 〜 略 〜
構造
変換の流れは以下のようになってります。
- 既にキャッシュに定義がのこっていればその値を返す(通常の複数形の変換/特殊な文字列/単複同形名詞の順にチェック)
- 上記に該当しない場合、複数形の変換ルールを上から順にチェックし、最初に該当したものを置換して返す
変換のルール
Railsと同じように、{検索対象文字列}
=> {置換文字列}
の形で定義。
定義数はRailsより若干多い23個です。
protected static $_plural = [
'/(s)tatus$/i' => '\1tatuses',
'/(quiz)$/i' => '\1zes',
'/^(ox)$/i' => '\1\2en',
'/([m|l])ouse$/i' => '\1ice',
'/(matr|vert|ind)(ix|ex)$/i' => '\1ices',
'/(x|ch|ss|sh)$/i' => '\1es',
'/([^aeiouy]|qu)y$/i' => '\1ies',
'/(hive)$/i' => '\1s',
'/(chef)$/i' => '\1s',
'/(?:([^f])fe|([lre])f)$/i' => '\1\2ves',
'/sis$/i' => 'ses',
'/([ti])um$/i' => '\1a',
'/(p)erson$/i' => '\1eople',
'/(?<!u)(m)an$/i' => '\1en',
'/(c)hild$/i' => '\1hildren',
'/(buffal|tomat)o$/i' => '\1\2oes',
'/(alumn|bacill|cact|foc|fung|nucle|radi|stimul|syllab|termin)us$/i' => '\1i',
'/us$/i' => 'uses',
'/(alias)$/i' => '\1es',
'/(ax|cris|test)is$/i' => '\1es',
'/s$/' => 's',
'/^$/' => '',
'/$/' => 's',
];
特殊な変換
こちらも同じように{単数形}
=> {複数形}
で定義。合計で41個定義していました。
protected static $_irregular = [
'atlas' => 'atlases',
'beef' => 'beefs',
'brief' => 'briefs',
'brother' => 'brothers',
'cafe' => 'cafes',
'child' => 'children',
'cookie' => 'cookies',
'corpus' => 'corpuses',
'cow' => 'cows',
'criterion' => 'criteria',
'ganglion' => 'ganglions',
'genie' => 'genies',
'genus' => 'genera',
'graffito' => 'graffiti',
'hoof' => 'hoofs',
'loaf' => 'loaves',
'man' => 'men',
'money' => 'monies',
'mongoose' => 'mongooses',
'move' => 'moves',
'mythos' => 'mythoi',
'niche' => 'niches',
'numen' => 'numina',
'occiput' => 'occiputs',
'octopus' => 'octopuses',
'opus' => 'opuses',
'ox' => 'oxen',
'penis' => 'penises',
'person' => 'people',
'sex' => 'sexes',
'soliloquy' => 'soliloquies',
'testis' => 'testes',
'trilby' => 'trilbys',
'turf' => 'turfs',
'potato' => 'potatoes',
'hero' => 'heroes',
'tooth' => 'teeth',
'goose' => 'geese',
'foot' => 'feet',
'foe' => 'foes',
'sieve' => 'sieves'
];
不可算名詞
不可算名詞は以下。
定義は31項目ですが正規表現を利用しているので該当する名詞はもっとたくさんありそうです。
protected static $_uninflected = [
'.*[nrlm]ese', '.*data', '.*deer', '.*fish', '.*measles', '.*ois',
'.*pox', '.*sheep', 'people', 'feedback', 'stadia', '.*?media',
'chassis', 'clippers', 'debris', 'diabetes', 'equipment', 'gallows',
'graffiti', 'headquarters', 'information', 'innings', 'news', 'nexus',
'pokemon', 'proceedings', 'research', 'sea[- ]bass', 'series', 'species', 'weather'
];
その他
こちらも複数形のルールは追加可能。
config/bootstrap.php
に以下のように記載すれば追加できるそうです。
Inflector::rules('singular', ['/^(bil)er$/i' => '\1', '/^(inflec|contribu)tors$/i' => '\1ta']);
Inflector::rules('uninflected', ['singulars']);
Inflector::rules('irregular', ['phylum' => 'phyla']); // キーは単数形、値は複数形
Symfony 4.3(PHP)
調査メソッド
Symfony 4.3はInflector Componentのpluralize
メソッドで複数形へ変換ができるようです。
このメソッドの特徴として、複数形の結果が複数推測できる場合、文字列が配列で返ってきます。
利用する場合は配列かどうかの判定も必要です。
Inflector::pluralize('grandchild'); // 'grandchildren'
Inflector::pluralize('news'); // 'news'
Inflector::pluralize('bacterium'); // 'bacteria'
Inflector::pluralize('matrix'); // ['matricies', 'matrixes']
Inflector::pluralize('person'); // ['persons', 'people']
ソースコード
namespace Symfony\Component\Inflector;
/**
* Converts words between singular and plural forms.
*
* @author Bernhard Schussek
*/
final class Inflector
{
/**
* Map English singular to plural suffixes.
*
* @see http://english-zone.com/spelling/plurals.html
*/
private static $singularMap = [
// criterion (criteria)
['airetirc', 8, false, false, 'criterion'],
// 〜 略 〜
];
// 〜 略 〜
public static function pluralize(string $singular)
{
$singularRev = strrev($singular);
$lowerSingularRev = strtolower($singularRev);
$singularLength = \strlen($lowerSingularRev);
// Check if the word is one which is not inflected, return early if so
if (\in_array($lowerSingularRev, self::$uninflected, true)) {
return $singular;
}
// The outer loop iterates over the entries of the singular table
// The inner loop $j iterates over the characters of the singular suffix
// in the singular table to compare them with the characters of the actual
// given singular suffix
foreach (self::$singularMap as $map) {
$suffix = $map[0];
$suffixLength = $map[1];
$j = 0;
// Compare characters in the singular table and of the suffix of the
// given plural one by one
while ($suffix[$j] === $lowerSingularRev[$j]) {
// Let $j point to the next character
++$j;
// Successfully compared the last character
// Add an entry with the plural suffix to the plural array
if ($j === $suffixLength) {
// Is there any character preceding the suffix in the plural string?
if ($j < $singularLength) {
$nextIsVocal = false !== strpos('aeiou', $lowerSingularRev[$j]);
if (!$map[2] && $nextIsVocal) {
// suffix may not succeed a vocal but next char is one
break;
}
if (!$map[3] && !$nextIsVocal) {
// suffix may not succeed a consonant but next char is one
break;
}
}
$newBase = substr($singular, 0, $singularLength - $suffixLength);
$newSuffix = $map[4];
// Check whether the first character in the singular suffix
// is uppercased. If yes, uppercase the first character in
// the singular suffix too
$firstUpper = ctype_upper($singularRev[$j - 1]);
if (\is_array($newSuffix)) {
$plurals = [];
foreach ($newSuffix as $newSuffixEntry) {
$plurals[] = $newBase.($firstUpper ? ucfirst($newSuffixEntry) : $newSuffixEntry);
}
return $plurals;
}
return $newBase.($firstUpper ? ucfirst($newSuffix) : $newSuffix);
}
// Suffix is longer than word
if ($j === $singularLength) {
break;
}
}
}
// Assume that plural is singular with a trailing `s`
return $singular.'s';
}
構造
こちらはRailsやCakePHPとも違い、接尾辞基準で定義されているようです。
大まかな手順としては以下のよります。
- 不可算名詞でならばそのまま返す
$singularMap
の配列を回して各定義毎に以下をチェックし、一致するものがあれば複数形に変換して返す- 変換する文字の最後の文字が、接尾辞と一致しているかをチェック
- 変換する文字の接尾辞より前の文字の母音か子音かが一致しているかをチェック
- 上記問題なければ変換する文字から接尾辞を除いたものに、複数変換接尾辞をつけて返す
※ この時、{複数変換接尾辞}が配列で複数定義されている場合は返り値も配列になります
- 一致する定義がなければ、変換する文字にsをつけて返す
変換のルール(含む 特殊な変換)
判定する接尾辞と変換文字の他に、接尾辞の前の文字か母音か、子音かのフラグも一緒に配列で定義しています。
設定されてる配列の用途は、先頭から順に以下の通りです。
接尾辞(※反転済)
, 接尾辞文字数
, 接尾辞前母音フラグ
, 接尾辞前子音フラグ
, 複数変換接尾辞(※配列で複数定義可能)
例えば
['airetirc', 8, false, false, 'criterion']
の場合
接尾辞:eci(反転前: ice), 接尾辞文字数:3, 接尾前母音フラグ: false, 接尾前子音フラグ:true, 複数変換接尾辞:ices
つまり『何らかの文字+(子音)+ice ->何らかの文字+(子音)ices にする』という定義になります
例) dice->dices
また、複数形の変換の定義と一緒にchildrenなどの特殊な変換の定義も行っているようです。
定義数は合計で49個でした。
// ※ コメントは省略して記載
private static $singularMap = [
['airetirc', 8, false, false, 'criterion'],
['aluben', 6, false, false, 'nebulae'],
['dlihc', 5, true, true, 'children'],
['eci', 3, false, true, 'ices'],
['ecivres', 7, true, true, 'services'],
['efi', 3, false, true, 'ives'],
['eifles', 6, true, true, 'selfies'],
['eivom', 5, true, true, 'movies'],
['esuol', 5, false, true, 'lice'],
['esuom', 5, false, true, 'mice'],
['esoo', 4, false, true, 'eese'],
['es', 2, true, true, 'ses'],
['esoog', 5, true, true, 'geese'],
['ev', 2, true, true, 'ves'],
['evird', 5, false, true, 'drives'],
['evit', 4, true, true, 'tives'],
['evom', 4, true, true, 'moves'],
['ffats', 5, true, true, 'staves'],
['ff', 2, true, true, 'ffs'],
['f', 1, true, true, ['fs', 'ves']],
['hc', 2, true, true, 'ches'],
['hs', 2, true, true, 'shes'],
['htoot', 5, true, true, 'teeth'],
['mu', 2, true, true, 'a'],
['nam', 3, true, true, 'men'],
['nosrep', 6, true, true, ['persons', 'people']],
['noi', 3, true, true, 'ions'],
['no', 2, true, true, 'a'],
['ohce', 4, true, true, 'echoes'],
['oreh', 4, true, true, 'heroes'],
['salta', 5, true, true, 'atlases'],
['siri', 4, true, true, 'irises'],
['sis', 3, true, true, 'ses'],
['ss', 2, true, false, 'sses'],
['suballys', 8, true, true, 'syllabi'],
['sub', 3, true, true, 'buses'],
['suc', 3, true, true, 'cuses'],
['su', 2, true, true, 'i'],
['swen', 4, true, true, 'news'],
['toof', 4, true, true, 'feet'],
['uae', 3, false, true, ['eaus', 'eaux']],
['xo', 2, false, false, 'oxen'],
['xaoh', 4, true, false, 'hoaxes'],
['xedni', 5, false, true, ['indicies', 'indexes']],
['x', 1, true, false, ['cies', 'xes']],
['xi', 2, false, true, 'ices'],
['y', 1, false, true, 'ies'],
['ziuq', 4, true, false, 'quizzes'],
['z', 1, true, true, 'zes'],
];
不可算名詞
不可算名詞は以下で定義しています。
private static $uninflected = [
'atad',
'reed',
'kcabdeef',
'hsif',
'ofni',
'esoom',
'seires',
'peehs',
];
こちらの定義は文字列を反転して設定していますので、実際に定義されるのは以下の8項目になります。
data
、deer
、feedback
、fish
、info
、moose
、series
、sheep
Laravel 5.8 (PHP)
調査メソッド
Laravel 5.8はIlluminate\Support\Str::plural()
メソッドで複数形に変換できます。
ソースコード
Illuminate\Support\Str::plural
メソッドは違うメソッドの結果をそのままを返しています。
public static function plural($value, $count = 2)
{
return Pluralizer::plural($value, $count);
}
ということでIlluminate\Support\Pluralizer
の中身を記載します
public static function plural($value, $count = 2)
{
if ((int) abs($count) === 1 || static::uncountable($value)) {
return $value;
}
$plural = Inflector::pluralize($value);
return static::matchCase($plural, $value);
}
構造
Doctrine\Common\Inflector\Inflector::pluralize
も途中で呼び出していますね。
DoctrineはSymfonyなどでも使われるORM。こちらの複数形の処理を利用し、不可算名詞の判定だけ追加しているようです。
- 「第2引数の
$count
が1」or「文字列が不可算名詞」ならばそのまま返す - Doctrineのメッソドを利用し複数形変換
- 2.で変換した文字列を、元々の値にあわせて小文字/大文字を変換します。
(すべて小文字にする、先頭だけ大文字にする、など)
という流れ。
それではInflectorクラスの複数化の処理も解析を……、と思ったのですがそうするとLaravelの複数形の定義からそれてしまいそうなので今回は除外します。
Inflectorクラスのコードは以下で確認できます。
複数形のルール24項目、特殊な変換の定義は62項目とかなり定義されています。
また、不可算名詞の定義は正規表現で10項目となっていました。
特殊な変換
Laravelの方で定義されているものはないようです。
(Inflectorの方で62個と細かく定義されているので必要もなさそうです。)
不可算名詞
こちらは細かく、41個ほど登録されています。
fishやsheepなど基本的なものはInflectorで定義されているのですが、ここでも改めて登録されているようですね。
public static $uncountable = [
'audio',
'bison',
'cattle',
'chassis',
'compensation',
'coreopsis',
'data',
'deer',
'education',
'emoji',
'equipment',
'evidence',
'feedback',
'firmware',
'fish',
'furniture',
'gold',
'hardware',
'information',
'jedi',
'kin',
'knowledge',
'love',
'metadata',
'money',
'moose',
'news',
'nutrition',
'offspring',
'plankton',
'pokemon',
'police',
'rain',
'rice',
'series',
'sheep',
'software',
'species',
'swine',
'traffic',
'wheat',
];
とみてみて…あれ?
pokemon
が定義されてます! pokemonは不可算名詞なんですね!
(XYからは群れバトルが追加されましたし、群れで行動するという判定なのでしょうか?)
そしてよく見たらCakePHP3も不可算名詞の定義にポケモンがありました!
Laravelと違って改行がないので気が付きにくかったみたいです。
まとめ
Rails5 | CakePHP3 | Symfony4 | Laravel5 | |
---|---|---|---|---|
変換方法 | 正規表現でパターンを定義して置換 | 正規表現でパターンを定義して置換 | 接尾辞の基準で接尾辞以前の文字に変化文字をつける 配列で結果が帰ってくる場合がある |
基本的に外部のライブラリを利用 |
変換ルール定義数 | 21 | 23 | 49 (特殊な変換定義を含む) |
0 (Inflector: 24) |
特殊な変換定義数 | 6 | 41 | 0 (変換ルール定義数に含む) |
0 (Inflector: 62) |
不可算名詞定義数 | 10 | 31 (※正規表現で定義) |
8 | 41 (Inflector: 10) |
正規表現の定義の仕方などもあるので、簡単に定義数だけで比較はできませんが
Rails、CakePHPが比較的直感的で分かりやすい定義をしているのに対し、Symfonyは反転して定義したりと少し特殊。
Laravelは定義の多い優秀なライブラリを利用した上で、追加で定義したいものを追加するなど
フレームワーク毎にらしさがある実装のように見えます。
また、pokemonがしっかり定義されていたのは驚きました。
Laravel 5.8とCakePHP3は娯楽用途での利用が多いのでしょうか?
(業務系で『ポケモンを2匹追加』などと入力する場面が想像がつかないです)
複数形の定義だけでももっと細かく、また、いろんなフレームワークを見てみると更に多くの違いが見られそうですね!
株式会社ウイングドアでは、Ruby on RailsやPHPを活用したwebサービス、webサイト制作を中心に、
スマホアプリや業務系システムなど様々なシステム開発を承っています。