LoginSignup
582
520

【脱sed】いい加減シェルスクリプトで文字列をsedで置換するなんてやめよう

Last updated at Posted at 2023-08-31

はじめに

もう文字列の置換で sed コマンド使うの禁止して良いんじゃないですかね? 言いすぎだとわかってあえて言っていますが。

悪い書き方(外部コマンドに頼る方法)

# 変数 line に入ってる文字列を echo コマンドで出力して sed コマンドに渡し、
# sed の s コマンドで "from" を "to" に置換して出力したものを ret 変数で受け取る
ret=$(echo "$line" | sed "s/from/to/")

良い書き方(シェル言語ネイティブの方法)

# 変数 line に入ってる文字列の "from" を "to" に置換して ret 変数に代入する
ret=${line/from/to}

ksh、bash、zsh なら一行の文字列の置換に sed コマンドを使うメリットはありません。同様の話は程度の差はありますが、cut コマンドなど ret=$(echo "$line" | ...) の形をした多くの場合に当てはまります。

一行の文字列の置換にsedは重すぎる

文字列として扱っている場合は大抵一行なのでわかりやすく一行と書きましたが、正確には一行限定ではなく(改行が含まれていたとしても)短い文字列という意味です。

まず、見ての通り sed コマンドは冗長です。簡単な置換にあんなに長いコードが必要になります。シェル言語ネイティブの方法なら半分の長さで書けます。

ret=$(echo "$line" | sed "s/from/to/")
ret=${line/from/to}

sed コマンドはシェル言語の一部ではありません。外部コマンドなので起動が遅いです。数百倍遅いです

$ time bash -c 'for((i=0; i<100000; i++)); do ret=$(echo "$i" | sed "s/from/to/"); done'

real	2m26.530s
user	2m24.668s
sys 	0m30.479s

$ time bash -c 'for((i=0; i<100000; i++)); do ret=${i/from/to}; done'

real	0m0.538s
user	0m0.538s
sys 	0m0.000s

約 272.360594795539033 倍

2 倍程度の遅さなら 1 分が 2 分になる程度ですみますが、数百倍遅いということは 1 分ですむ処理が数時間にもなるということです。ループの中で何度も sed コマンドを呼び出すことがどれだけのパフォーマンス低下になるのか言うまでもありません。

シェル言語ネイティブの方法は、冗長で重い sed コマンドの使用を不要にすることが出来る、Bourne シェルの後継として UNIX を開発した AT&T で開発された ksh93 の拡張機能です。後に bash や zsh にも追加されました。30 年も前から使えます。

例えて言うなら JavaScript の言語仕様が進化して、言語ネイティブで実現できるようになったのに、わざわざ jQuery のような外部ライブラリを使うようなものです。jQuery は短く書けるという利点がありますが、シェル言語ネイティブの方が短く書けるのだから、まったく必要ないですよね?

sedは複数行のテキストを正規表現で置換するために使う

ログファイルのような複数行のテキストデータ(テキストファイル)を正規表現で置換するなら、sed コマンドの方が適しています。複数行のテキストデータを置換するというのは、一行一行 sed コマンドを呼び出すということではなくパイプでつないで、sed コマンドの起動回数を一回にするということです。外部コマンドの起動回数を減らせばシェルスクリプトは速くなります。(シェルに組み込まれたシェルビルトインコマンドの呼び出しは大きな影響はありません)

(別のコマンドからの標準出力) | sed 's/from/to' | ・・・ (別のコマンドにつなげる)

(別のコマンドからの標準出力) | sed 's/from/to' | {
  # もしくはシェル言語ネイティブの標準入力読み取りにつなげる
  while IFS= read -r line; do
    echo "$line"
  done
}

そもそも sed というのは stream editor の略で、ed という「行指向のテキストエディタ」の代わりに「ストリーミング処理」できるテキストエディタとして作られたコマンドです。ストリーミング処理が必要だったのは ed コマンドが扱えるバッファ(正確にはメモリのことではなく一時ファイル)が小さくて巨大なファイルを編集できなかったからです。テキストファイルの修正にはテキストエディタを使うと思いますが、プログラミング言語で文字列の変更にテキストエディタを使うでしょうか? 一行の文字列にストリーミング処理が必要でしょうか? 文字列処理に sed コマンドを使うというのは、小さなことを処理するのに大きな道具を使っているということです。

昔はシェルの機能を小さくするためになるべく機能を持たせていませんでした。それが現在ほぼ消滅してしまった Bourne シェルです(注意 現在の sh は Bourne シェルではありません)。シェルの機能が貧弱だったため昔は大きな道具を使って処理するしかありませんでした。人々はより高度な言語機能を望みシェルに実装されました。それが 30 年前に誕生した ksh、そして bash や zsh です。これらのシェルを使っているのに人々が望んだはずの高度な言語機能を使わないなんておかしい話ですよね。

ファイルを置換する場合(お好きな書き方でどうぞ)

sed 's/from/to' file.txt | ・・・ (別のコマンドにつなげる)
sed 's/from/to' <file.txt | ・・・ (別のコマンドにつなげる)
<file.txt sed 's/from/to' | ・・・ (別のコマンドにつなげる)
sed <file.txt 's/from/to' | ・・・ (別のコマンドにつなげる)

# 以下は効率が悪いので非推奨(UUOC参照) ・・・ cat コマンドの起動は無駄!
cat file.txt | sed 's/from/to' | ・・・ (別のコマンドにつなげる)

# え? file.txt は左にある方がわかりやすい? 一番左にある文字は cat ですがなにか?
#   cat  file.txt | sed 's/from/to'
#   sed <file.txt       's/from/to'
# そもそもcatはファイルを連結するコマンドなんですが、いきなり「連結する」とか意味不明でしょ?
# catを使うと次のコマンドがランダムアクセスできなくなり遅くなる(例sort)というデメリットもある

UUOC: 無駄なcatの使用(Useless Use of cat)

シェル言語ネイティブの文字列処理の方法は「変数展開」

変数展開はパラメータ展開とも言います。名前から結びつかないかもしれませんが、変数展開は他の言語で言う文字列を操作する関数に相当します。これらを使えばすべての文字列処理をシェル言語ネイティブの機能で実装することができます。

全ての POSIX シェルで使える
v=foobarbaz

echo "${#v}"      # => 9      文字列の長さを調べる(strlen 関数相当)
echo "${v#*a}"    # => rbaz   前から最短の a (指定したパターン)までを削除
echo "${v##*a}"   # => z      前から最長の a (指定したパターン)までを削除
echo "${v%b*}"    # => foobar 後ろから最短の b (指定したパターン)までを削除
echo "${v%%b*}"   # => foo    後ろから最長の b (指定したパターン)までを削除
ksh、bash、zsh で使える
v=foobarbaz

echo "${v/a/A}"   # => foobArbaz  a を A に最初の一つだけ置換(指定したパターンを置換)
echo "${v//a/A}"  # => foobArbAz  a を A に全て置換(指定したパターンを置換)
echo "${v:2:5}"   # => obarb      2 文字目から 5文字取得(substr 関数相当)
echo "${v:2}"     # => obarbaz    2 文字目から最後まで取得

他にも色々ありますが、シェルによって使えるものが異なるので注意が必要です。変数名に配列を使用すると配列の要素全てを変換する、他の言語で言う map 処理相当のこともできます。

$ ary1=(foo bar baz)
$ ary2=("${ary1[@]//a/A}") # 配列の全要素を置換して別の配列に入れる

$ printf '[%s]\n' "${ary2[@]}" # 配列の全要素を一行ごとに出力
[foo]
[bAr]
[bAz]

可読性が悪い? そうかも知れませんが慣れの問題です。どうしても嫌ならシェル関数にすればよいです。自分で可読性を上げるすべを知っていればどうとでもなる問題です。

replace_all() {
  # -v は標準出力に出力する代わりに変数に代入するオプション
  printf -v "$1" "%s" "${2//"$3"/"$4"}" # bash、zsh、ksh93u+m で使える
  # eval "$1=\${2//\"\$3\"/\"\$4\"}"    # ksh93u+ の場合はこちら
}

v=foobarbaz
replace_all ret "$v" "a" "A"
echo "$ret" # => foobArbAz

echo | sed の罠をすべて言えますか?

よく見かける、これ、

ret=$(echo $line | sed "s/from/to/")

echo の引数の変数をダブルクォートしないのはだめです。スペースや改行が一つのスペースに置き換わります。

line="foo       bar     

baz"

ret=$(echo $line | sed "s/from/to/")
echo "$ret" # => foo bar baz スペースや改行が一つになってる!

echo コマンドの引数の変数をダブルクォートしてても、末尾の改行が消えます。

line="foo


"

ret=$(echo "$line" | sed "s/from/to/")
echo "[$ret]" # => [foo]  改行がない!

echo コマンドは移植性がありません。printf コマンドを使いましょう。mksh では printf コマンドは外部コマンドで遅いので、mksh では代わりに print コマンドを使ったほうが良いです。

line='foo\tbar'
ret=$(echo "[$line]" | sed "s/from/to/")
echo "$ret" # => [foo\tbar]       /bin/sh (RedHat Linux, AlmaLinux), bash
            # => [foo    bar]    /bin/sh (Ubuntu, macOS, Solaris 11), zsh

# 推奨は echo ではなく printf
ret=$(printf '%s\n' "[$line]" | sed "s/from/to/")

補足ですが macOS の /bin/sh は今も zsh ではなく bash ですが、/bin/sh として起動すると POSIX モードで動作し、さらに標準の bash から変更された macOS 版 bash 特有の動作として、\t などのエスケープシーケンスを解釈します。この動作は POSIX では要求されていません(POSIX ではどちらでも OK)が、POSIX の XSI 拡張で指定されている動作で(POSIX の認証ではなく)Unix の認証を得るために必要な要件です。他にも macOS や Solaris 11 の /bin/sh では echo -n foo-n foo という文字列が出力されます。由緒ある Unix なので -n は改行しないという意味ではありません(参照)。

このような罠は、シェル言語ネイティブの方法にはありません。

ret=${line/from/to}

正規表現なんていらないでしょ?

正規表現は必要な時に使います。必要ないときには使いません。

ret=$(echo "$line" | sed "s/$from/$to/")

もし from 変数の中に正規表現のメタ文字が含まれていたらどうしますか? もし to 変数の中に / などが含まれていたらどうしますか?

正規表現は文字列の上位互換ではありません。一部の文字は特別な意味を持つので単純な文字列の置換にはそのまま使えません。シェル言語ネイティブの方法は、単純な文字列の置換なので変数の中に正規表現のメタ文字が入ってたら?などと心配する必要はありません。

ret="${line/"$from"/"$to"}" # シェルのメタ文字を防ぐためのダブルクォートは必要

ret=${line/"$from"/"$to"} # これでも問題ない

ksh、bash、zsh であればシェル言語ネイティブで正規表現に対応してるので、(それぞれのシェルで書き方は異なりますが)正規表現が必要な場合でも外部コマンドに頼る必要はありません。

#!/bin/bash

line="abcaBcabcaBc" from="[bB]" to="-"

# 最初の一個だけ置換
if [[ $line =~ $from ]]; then
  line=${line/"$BASH_REMATCH"/"$to"}
fi
echo "$line" # => a-caBcabcaBc

# 全置換
tmp=''
while [[ $line =~ ($from) ]]; do
   tmp="${tmp}${line%%"$BASH_REMATCH"*}${to}"
   line="${line#*"$BASH_REMATCH"}"
done
line="${tmp}${line}"
echo "$line" # => a-ca-ca-ca-c

もっともこの例は glob パターンを使えば良いので正規表現は必要ありません

#!/bin/bash

line="abcaBcabcaBc"
from="[bB]"
to="-"

# glob パターンを使うときは $from をダブルクォートしない
# zsh の場合は setopt KSH_GLOB (または SH_GLOB)が必要
echo "${line/$from/"$to"}" # => a-caBcabcaBc
echo "${line//$from/"$to"}" # => a-ca-ca-ca-c

本当に正規表現が必要なら、その前に extglob を使いましょう。extglob はシェル言語版の正規表現です。

  • ?(pattern-list) 指定されたパターンの 0 個か 1 個にマッチ
  • *(pattern-list) 指定されたパターンが 0 回以上出現した場合にマッチ
  • +(pattern-list) 指定されたパターンが 1 回以上出現した場合にマッチ
  • @(pattern-list) 指定されたパターンのいずれかにマッチする
  • !(pattern-list) 与えられたパターンの一つ以外にマッチする

extglob を使えば正規表現の出番はほとんどなくなるでしょう。2022年9月にリリースされた最新の bash 5.2(macOS の人は Homebrew でインストールしましょう)では、マッチした文字列を意味する & が追加されました。シェル言語の機能は今も改善され続けています。

bash 5.2 以降の機能
line="abcaBcabcaBc"
from="[bB]"
to="<&>"
echo "${line//$from/$to}" # => a<b>ca<B>ca<b>ca<B>c

# 従来の動作が必要なら $to をダブルクォートするか
# shopt -u patsub_replacement で無効にする
echo "${line//$from/"$to"}" # => a<&>ca<&>ca<&>ca<&>c

extglob の書き方がわからんという人は、ksh を使って正規表現から extglob に変換してやればよいです。変換するためだけに ksh をインストールするだけで、シェルスクリプトを ksh で書かなければいけないわけではありません。

kshの機能
$ printf '%P\n' '^abc([0-9])+'
abc+([0-9])*

$ printf '%R\n' 'abc+([0-9])*' # 逆変換
^abc([0-9])+

拡張機能を持たない POSIX シェルで動かないだって?

ret=${line/from/to}

は、ksh、bash、zsh でしか動きません。「俺、POSIXにこだわってるから〜

それなら自分で POSIX シェルの範囲で実装すれば良いだけです。POSIX にこだわってるぐらいなら、それぐらい朝飯前で書けますよね?

面倒でしょうから(どうしても POSIX にこだわりたい人のために)用意しておきました。これだけの処理をしてても常識的な長さの一行の文字列なら sed コマンドを呼び出すより速いです。

replace_all() {
  set -- "$1" "$2$3" "$3" "$4" ""
  while [ "$2" ]; do
    set -- "$1" "${2#*"$3"}" "$3" "$4" "$5${2%%"$3"*}$4"
  done
  eval "$1=\${5%\"\$4\"}"
}

# 全置換なので「ret=${line//"$from"/"$to"}」と同等
replace_all ret "$line" "$from" "$to"

(一部の人以外は)bash の拡張機能を使わないことにこだわる必要性はないと思います。そうしないと移植性がない? いいえ違います。bash は POSIX に準拠しており移植性が高く、実際にどの OS にも移植されているので、bash をインストールすれば bash 専用のシェルスクリプトはどの環境でも動く移植性が高いシェルスクリプトです。さまざまな POSIX シェルで動くように苦労するよりも、bash をインストールするほうが明らかに簡単です。同様に ksh や zsh も bash ほどではないかもしれませんが移植性が高いシェルです。sh を作っている組織はいくつもあるので、 /bin/sh で動かすシェルスクリプトは多数のシェルを考慮しなければいけなくなり移植性を高めるのは大変ですよ?

同じ名前のコマンドを実装している組織が多ければ多いほど、それらのコマンドに依存するシェルスクリプトは移植性を高めるのが大変になります。(多くの人の印象とは反対に)POSIX コマンドを作っている組織はいくつもあるので POSIX コマンドに依存するシェルスクリプトの移植性を高めるのは大変です。POSIX で標準化されている機能ですら完全な互換性はありません。

さいごに

拡張機能を持たない POSIX シェルでは、sed コマンドのほうが短くて簡単にかけるというのはそのとおりですが、すでにほぼすべての OS に移植済みで「どの環境でも動く bash」を使えばいいじゃんの時代ですし、bash 使うなら可読性が悪くなりやすい sed コマンドを使う必要なんかありません。

外部コマンドの使いこなしの前に、まずシェル言語の機能をしっかり学びましょう。シェル言語の知識はシェルスクリプトを書くときの基礎知識です。新しいシェルを使っているのに、30 年前の古いシェルのための書き方をする必要はありません。時代は変わっているんです。シェルスクリプトの書き方も変わっているんです。シェルスクリプトの「考え方」は普遍ですが、それを実現する「手段」であるシェル言語やコマンドなどは変わっていくのです。普遍な考え方を学び手段はアップデートしていく必要があります。

582
520
10

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
582
520