【Laravel】文字列をスネークケースに変換するアルゴリズムの紹介

 やんごとなき理由で Laravel 内で用いられている文字列をスネークケースにする処理を平の PHP 上で扱う必要があり、該当部の中身を読みました。この中身の紹介をします。

 Laravel 9.31.0 における \Str::snake メソッドの実装は次の通りです。
framework/Str.php at v9.31.0 · laravel/framework#L1045

    /**
     * Convert a string to snake case.
     *
     * @param  string  $value
     * @param  string  $delimiter
     * @return string
     */
    public static function snake($value, $delimiter = '_')
    {
        $key = $value;

        if (isset(static::$snakeCache[$key][$delimiter])) {
            return static::$snakeCache[$key][$delimiter];
        }

        if (! ctype_lower($value)) {
            $value = preg_replace('/\s+/u', '', ucwords($value));

            $value = static::lower(preg_replace('/(.)(?=[A-Z])/u', '$1'.$delimiter, $value));
        }

        return static::$snakeCache[$key][$delimiter] = $value;
    }
    
    /**
     * Convert the given string to lower-case.
     *
     * @param  string  $value
     * @return string
     */
    public static function lower($value)
    {
        return mb_strtolower($value, 'UTF-8');
    }

 小さく高速に動作する様にまとまっている処理です。これを解体していきます。

 まず処理の最初と最後にあるキャッシュ処理の部分です。

    public static function snake($value, $delimiter = '_')
    {
        $key = $value;// スネークケース化時に加工しない様に元の文字列を退避
        
        // もしこれまでに与えられた文字列と区切り文字の組み合わせで
        // 文字列変換を行っていたならばその結果を返す
        if (isset(static::$snakeCache[$key][$delimiter])) {
            return static::$snakeCache[$key][$delimiter];
        }

        // スネークケース化本体部

        // 与えられた文字列と区切り文字の組み合わせによる
        // 変換結果をこのクラスの static プロパティに格納する
        return static::$snakeCache[$key][$delimiter] = $value;
    }

 下から読んだ方がわかりやすいです。メソッドの終わりにこの処理の実行完了時に引数をキーとして実行した結果を static プロパティの $snakeCache に格納しています。上側では引数をキーとして $snakeCache に実行結果が存在するか確認し、存在するならば実行結果そのものを返し、存在しないならば改めて実行を行う様にしています。これにより同じ文字列を何度変換しても高速に処理ができます。後述しますが処理本体では正規表現が使われており速度差は倍以上につきます。もし微妙に異なる文字列を大量に変換するならば、次の様にその文字列らの共通部を変換にかける様にすると高速化できます。

// AaBbCc1, AaBbCc2, AaBbCc3, AaBbCc4,... と接頭辞が同じ文字列を1e5個用意します
// この接頭辞付きの文字列群をスネークケースにすることを考えます
$prefix  = 'AaBbCc';
$strList = array_map(fn ($i) => $prefix.$i, range(1, 1e5));

$s = microtime(true);
foreach ($strList as $str) {
    // 全てを直に変更するとキャッシュが使えないため処理が遅くなります
    \Str::snake($str);
    // 変換したい共通部である接頭辞を分離して、そこだけ変換にかけます。
    // するとキャッシュが有効になり高速化します。
    // ついでに巨大なキャッシュ配列が生成されることもなくなり、メモリにも優しいです。
    [$prefix, $idx] = str_split($str, 6);
    \Str::snake($prefix).$idx;
}
echo microtime(true) - $s;

Online PHP editor | output for RmEKI#↑のデモ

 文字列変換部をまとめると次の様になります。

    public static function snake($value, $delimiter = '_')
    {
        if (! ctype_lower($value)) {
            $value = preg_replace('/\s+/u', '', ucwords($value));

            $value = mb_strtolower(preg_replace('/(.)(?=[A-Z])/u', '$1'.$delimiter, $value), 'UTF-8');
        }

        return $value;
    }

 最初のif (! ctype_lower($value)) {ですが、これは与えられた文字列が全て小文字ならば変換処理を飛ばす、という if 文です。ctype_lowerは与えられた文字列が全て小文字ならば true、小文字以外が含まれるならば false を返す組み込み関数です。

PHP: ctype_lower – Manual

 ctype 関数の特長として高速であることが挙げられます。

ctype 関数は、正規表現よりもつねに好ましく、さらに “str_*” および “is_*” のような いくつかの等価な関数よりも好ましいことに注意してください。 これは、ctype 関数がネイティブな C ライブラリを使用しており、処理が著しく 高速であるためです。
PHP: はじめに – Manual

 C言語に触れていないといまいち名前から挙動を連想しづらい点、PHP8.0以前では数値を渡すとコードポイントと判断して期待しない true を返すことがある点といった欠点もありますが、とにかく高速です。

<?php

echo "97, ".chr(97).", ".ctype_lower(97)."\n";
// 8.1以降ならば↓の非推奨警告がでます。
// Deprecated: ctype_lower(): Argument of type int will be interpreted as string in the future in /in/4C8hc on line 7
// 97, a, true

// 8.0以前ならば下の様に何事もなく動きます。
// 97, a, true

Online PHP editor | output for dnRSd#↑のデモ

 if (! ctype_lower($value)) {の中が実際に文字列をスネークケースにしている部分です。これは次の様に動作しています。

// 初期値です
$delimiter = '_';
$value     = '  Convert a  string to snake  case.  AA ';
// ucwords 関数で文字列の各単語の最初の文字を大文字にしています。
// @see https://www.php.net/manual/ja/function.ucwords
// ここで区切られた文字列の頭を全て大文字にすることによって
// 後の処理で"_"で区切らずに合体させてしまうことを防いでいます。
$value = ucwords($value);
echo $value."\n"; //   Convert A  String To Snake  Case.  AA

// 空白を全て詰めます。
// これをしない場合、" _A"の様に謎の空白が残った実行結果になってしまいます。
// この処理はもう一度行う正規表現内にまとめることも可能です。
// おそらく貪欲な探索のループが起こるのを警戒して無難に分離しています。
$value = preg_replace('/\s+/u', '', $value);
echo $value."\n"; // ConvertAStringToSnakeCase.AA

// 先読みの正規表現で大文字が見つかり次第 _{$見つかった大文字} となる様にしています。
// (.) と $1 で大文字以前に何か文字が必要、とすることで最初の大文字の前に"_"が追加されることを防止しています。
$value = preg_replace('/(.)(?=[A-Z])/u', '$1'.$delimiter, $value);
echo $value."\n";// Convert_A_String_To_Snake_Case._A_A

// 最後に文字列中の大文字を全て小文字に変換してスネークケースとして完成させています。
$value = mb_strtolower($value, 'UTF-8');
echo $value."\n"; // convert_a_string_to_snake_case._a_a

Online PHP editor | output for BsoTs

 2013年初出のこの処理は少しづつ改善を重ねて現在の形になっていました。キャッシュ、ctype関数あたりは応用が効きやすくパフォーマンスに影響を出せるため参考にしやすいです。

>株式会社シーポイントラボ

株式会社シーポイントラボ

TEL:053-543-9889
営業時間:9:00~18:00(月〜金)
住所:〒432-8003
   静岡県浜松市中央区和地山3-1-7
   浜松イノベーションキューブ 315
※ご来社の際はインターホンで「316」をお呼びください

CTR IMG