Post

【JavaScript】いろんな部分文字列を一気に置換したい

できればString#trとかtrコマンドとかみたいなすっきりした置換がしたいんです。JavaScriptで
たまにあるんですよ。特定の文字列を別の特定の文字列に置換しなければならない、しかもいっぱい置換のペアがあるってとき。

1
2
3
4
5
'hello world'.tr('eo', '30')
#=> "h3ll0 w0rld"

'hello world'.tr('a-z', 'a-z')
#=> "hello world"

まずはMDN

まずはMDNを漁るんですけど、無いんですよ。近しいのが。 String.prototype.replace()とかreplaceAll()とかは見かけるんですけどね。

じゃあ誰か発明してないかとググるわけですよ。

ネットの記事

replace()でメソッドチェーン

まず見かけたのがこちらです。特定の文字列を全て置換する[Javascript]

この記事曰く、replace()のチェーンでゴリ押せば良いと。

1
2
3
"hello world".replace(/e/g, '3')
             .replace(/o/g, '0');
//=> 'h3ll0 w0rld'

うーんなるほど。思ってたんとちょっと違うけどまあそうかと。

でも、これ大変じゃないかって思うんです。種類数によっては。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
"hello world"
  .replace(/a/g, 'a')
  .replace(/b/g, 'b')
  .replace(/c/g, 'c')
  .replace(/d/g, 'd')
  .replace(/e/g, 'e')
  .replace(/f/g, 'f')
  .replace(/g/g, 'g')
  .replace(/h/g, 'h')
  .replace(/i/g, 'i')
  .replace(/j/g, 'j')
  .replace(/k/g, 'k')
  .replace(/l/g, 'l')
  .replace(/m/g, 'm')
  .replace(/n/g, 'n')
  .replace(/o/g, 'o')
  .replace(/p/g, 'p')
  .replace(/q/g, 'q')
  .replace(/r/g, 'r')
  .replace(/s/g, 's')
  .replace(/t/g, 't')
  .replace(/u/g, 'u')
  .replace(/v/g, 'v')
  .replace(/w/g, 'w')
  .replace(/x/g, 'x')
  .replace(/y/g, 'y')
  .replace(/z/g, 'z')

//=> 'hello world'

手打ちしたくなさすぎてRubyで錬成しました。

1
2
3
4
5
6
7
8
puts (<<EOS) + ("a".."z").zip("a".."z").map{|zen, han| "  .replace(/#{zen}/g, '#{han}')"}.join("\n")
"hello world"
EOS
#=> "hello world"
#     .replace(/a/g, 'a')
#     .replace(/b/g, 'b')
#     .replace(/c/g, 'c')
#	  ...

forでまとめて

もっと他に良いこと書いている記事はないのかと思いまして、お次は こんな記事を見かけました。 JavaScriptで複数置換をスマートにやりたい ほう、スマートに。

なんとこの記事の執筆者さんもメソッドチェーン解法を見た模様です。 そしてなんと同じようなことを考えておられます。

ですが、求めていたのはこれじゃない。置換パターンが2, 3個ならまだ良いですが、ある程度多くのパターンをまとめて置換したいという場合、replaceを繋げまくるのは大変です。しかも見た目もよろしくない。

この方に従えば、forループでこういうことでしょうか。

1
2
3
4
5
6
7
8
9
let str = "hello world";
const keys = [/e/g, /o/g];  // i番目の置換対象
const reps = ["3" , "0" ];  // i番目の置換後

for (let i = 0; i < keys.length; ++i) {
  str = str.replace(keys[i], reps[i]);
}

console.log(str);  // h3ll0 w0rld

なるほど、これなら配列の中身を増やせばいいだけだから、さっきみたいなことにならずに済むわけですね。

1
2
3
4
5
6
7
8
9
let str = "hello world";
const keys = [/a/g, /b/g, /c/g, /d/g, /e/g, /f/g, /g/g, /h/g, /i/g, /j/g, /k/g, /l/g, /m/g, /n/g, /o/g, /p/g, /q/g, /r/g, /s/g, /t/g, /u/g, /v/g, /w/g, /x/g, /y/g, /z/g];
const reps = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"];

for (let i = 0; i < keys.length; ++i) {
  str = str.replace(keys[i], reps[i]);
}

console.log(str);  // hello world

メソッドチェーンよりは、かなりすっきりして見えます。 でも、置換対象に毎回//gをくっつけるのはちょっと面倒な気もします。

1
2
puts "[" + ("a".."z").map{|c| "/#{c}/g"}.join(", ") + "]"
#=> [/a/g, /b/g, /c/g, /d/g, /e/g, /f/g, /g/g, /h/g, /i/g, /j/g, /k/g, /l/g, /m/g, /n/g, /o/g, /p/g, /q/g, /r/g, /s/g, /t/g, /u/g, /v/g, /w/g, /x/g, /y/g, /z/g]

(追記: MDNのreplace()のページを見ると文字列も受け入てくれそうなので、クオーテーションでもよさそうですね。)

変換表を定数で持つので、その場限りでしか使われない変換表が処理の後にずっと残っちゃうのも、ちょこっとだけ気になります。

reduce()で一行

ここまで知ったら私としてはワンライナーにしたくなるんです。

むずむずしてきたので私も考えました。

これ、replace()って、返り値が置換後の文字列だからメソッドチェーンできるんですよね。

各時点での返り値.png 各時点での返り値.png

ってことはEnumerable#inject的な物が使えると思うんです。返り値を蓄積して結果にしてくれるやつです。

JSなのでArray.prototype.reduce()ですね。reduce()はちょうどRubyのinjectに相当しています。コールバックの返り値を蓄積する仕組みが備わっているんです。
あと、置換対象と置換後文字列をペアで持てると嬉しいですね。 そんな願望を携えたなら、こんな感じでよっこらせ。

1
2
3
 const str = 'hello world';
 [["e", "3"], ["o", "0"]].reduce((s, [from, to]) => s.replaceAll(from, to), str); 
 // 'h3ll0 w0rld'

2次元配列になってますが、中身は[置換対象, 置換後]です。これならもっとすっきり使えるのではないでしょうか。

JavaScriptはとっても寛容なので、Stringにちょろっと失礼しますと。

1
2
3
String.prototype.replaceEvery = function(patterns) { // レシーバを`this`として使いたいので`function`
   return patterns.reduce((s, [from, to]) => s.replaceAll(from, to), this)
};

すると、こんな具合で使えるわけです。

1
2
> 'hello world'.replaceEvery([["e", "3"], ["o", "0"]])
'h3ll0 w0rld'

trのようにとまではいかないものの、他の2つの方法よりは手軽に扱えそうな気がします。

1
2
3
4
5
6
7
'hello world'.replaceEvery([
  ["", "a"], ["", "b"], ["", "c"], ["", "d"], ["", "e"], ["", "f"], ["", "g"],
  ["", "h"], ["", "i"], ["", "j"], ["", "k"], ["", "l"], ["", "m"], ["", "n"],
  ["", "o"], ["", "p"], ["", "q"], ["", "r"], ["", "s"], ["", "t"], ["", "u"],
  ["", "v"], ["", "w"], ["", "x"], ["", "y"], ["", "z"]
]);  //=> 'hello world'

でも、連番なら、一気にできないものか…。とも思います。

1
2
puts "[" << ("a".."z").zip("a".."z").map{|from, to| %Q_["#{from}", "#{to}"]_ }.join(", ") << "]"
#=> [["a", "a"], ["b", "b"], ["c", "c"], ["d", "d"], ["e", "e"], ["f", "f"], ["g", "g"], ["h", "h"], ["i", "i"], ["j", "j"], ["k", "k"], ["l", "l"], ["m", "m"], ["n", "n"], ["o", "o"], ["p", "p"], ["q", "q"], ["r", "r"], ["s", "s"], ["t", "t"], ["u", "u"], ["v", "v"], ["w", "w"], ["x", "x"], ["y", "y"], ["z", "z"]]

本当に目指していたもの

せっかくなので劣化版trも実装してみました。長ーい。範囲指定しかついていないので劣化版です。
私は範囲指定が欲しかっただけなのでそこだけ。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
String.prototype.tr = function tr(from, to) {
  const arrayLast = arr => arr[arr.length-1];

  const from_expanded = expand_hyphenation(from);
  const to_expanded   = expand_hyphenation(to);
  const maxLength = Math.max(from_expanded.length, to_expanded.length);
  const from_last = arrayLast(from_expanded);
  const to_last   = arrayLast(to_expanded);
  from_expanded.length = maxLength;
  to_expanded  .length = maxLength;
  from_expanded.fill(from_last, from_expanded.length-1);
  to_expanded  .fill(to_last  , to_expanded  .length-1);

  return from_expanded.reduce((s, from, i) =>
    s.replaceAll(from, to_expanded[i])
    , this);

  function expand_hyphenation(source) {
    const src = [...source];
    const expanded = [];

    src.forEach((c, i) => {
      if (c === '-' && i !== 0 && i !== src.length-1) {
        // ハイフンで範囲指定されている部分を展開
        for (let pt = src[i-1].codePointAt(0) + 1  // ハイフン指定の手前側(の次の文字)
             ;   pt < src[i+1].codePointAt(0)      // ハイフンの後の文字
             ;  pt++
            ) {
          expanded.push(String.fromCodePoint(pt));
        }
        return;

      } else {
        expanded.push(c);
      }
    });
    return expanded;
  }
}

console.log('abcdefg'.tr('a-cf', '1-3'));              // 123de3g
console.log('hello world'.tr('eo', '30'));             // h3ll0 w0rld
console.log('hello world'.tr('a-z', 'a-z')); // hello world

おわりに

ところで、reduce()って嫌われてるんですか…?

This post is licensed under CC BY 4.0 by the author.