VOOZH about

URL: https://qiita.com/ko1nksm/items/fbefb4da7e54e8a29a25

⇱ シェルスクリプト リファクタリング ~遅いシェルスクリプトが供養されてたので蘇生して256倍に高速化させました~ #Bash - Qiita


👁 Image
242

Go to list of users who liked

233

Share on X(Twitter)

Share on Facebook

Add to Hatena Bookmark

More than 1 year has passed since last update.

@ko1nksm(Koichi Nakashima)

シェルスクリプト リファクタリング ~遅いシェルスクリプトが供養されてたので蘇生して256倍に高速化させました~

242
Last updated at Posted at 2021-05-03

はじめに

ことの始まりは「シェルスクリプトでツールを作ったけど速度が遅くて使い物にならなかったので供養」というツイートを見たからです。コードを見てみると、実例をあまり見ないシェルスクリプトのリファクタリング例として丁度良い内容と分量だったため記事にいたしました。記事を書くにあたりコードの利用を快く承諾していただいた @Hayao0819 様にはこの場を借りて御礼を申し上げます。

内容は章立てで構成しており、序章で事前調査をし、第一章で一般的なリファクタリング、第二章でパフォーマンスを重視したリファクタリング、終章で少し余談をして締めくくっています。最初はパイプは並列処理されるから速くなるというのは神話(そうとは限らない)についても書いていたのですが流石に長いので分けました。それでも書きたいことを色々書いていたらめちゃくちゃ長くなってしまいましたので読み物として私がどんなことを考えながらリファクタリングしていったかをざっくり読んでいただければと思います。あとリファクタリングといいつつテスト書いてない上にあまり速度にあまり影響がない部分の仕様をこっそり変更してたりしますが、本題ではないということでご了承ください。

序章 事前調査

元のコード

元のコードはこちらです。念の為にここにもミラーしておきます。処理内容を簡単に解説するとこれは「Linuxにインストールされてる(デスクトップ)アプリの一覧をJsonで出力するスクリプト」で /usr/share/applications/ 以下にある *.desktop ファイル(ini 形式)を検索し crudini コマンド(Python スクリプト)を使ってパースし jq コマンドで JSON 形式に組み立てて出力するというものです。(ちなみにツイートした段階ではコードをろくに読まずに jq 使ってるから JSON をパースするのだろうと勘違いしています。思い込みは良くないですね。)

コードはそれほど長くないので皆さんもどこで時間がかかっているか考えてみてください。

# !/usr/bin/env bash
set -eu
AppDir="/usr/share/applications"
DesktopFileExt="desktop"
function getDesktopFile(){
 #grep -E "^${2}" "${1}" | cut -d "=" -f 2 | tr -d "\n"
 _Result="$(crudini --get "${1}" "Desktop Entry" "${2}")"
 _Result="$(echo ${_Result} | tr -d "\"")"
 if echo "${_Result}" | grep -q "^[0-9]\+$" || [[ "${_Result}" = true ]] || [[ "${_Result}" = false ]]; then
 echo -n "${_Result}"
 else
 echo -n "\"${_Result}\""
 fi
}

# Load AppList
while read -r app; do
 AppList+=("${app}")
done < <(find "${AppDir}" -maxdepth 1 -mindepth 1 -name "*.${DesktopFileExt}" -printf "%f\n" 2> /dev/null | sed "s|.${DesktopFileExt}$||g" | sort)

JSON="{}"
Count=0
for _App in "${AppList[@]}"; do
 Count=$(( Count + 1 ))
 echo "Loading ${_App} ... ${Count}/${#AppList[@]}$(awk "BEGIN { print ${Count} * 100 /${#AppList[@]}}")%" >&2
 _JsonName="$(echo -n "${_App}" | tr "." "_" | tr "-" "_")"
 _DesktopFilePath="${AppDir}/${_App}.${DesktopFileExt}"

 _setValueToJson(){
 JSON="$(echo "${JSON}" | jq -c ".${_JsonName}.${1} = $(getDesktopFile "${_DesktopFilePath}" "${1}")")"
 }

 JSON="$(echo "${JSON}" | jq -c ".${_JsonName} = {}")"
 _setValueToJson "Name"
 _setValueToJson "Exec"
 _setValueToJson "iCON"
 _setValueToJson "Type"
 _setValueToJson "Comment"
done

echo "${JSON}" | jq
# $ cat /usr/share/applications/byobu.desktop
[Desktop Entry]
Name=Byobu Terminal
Comment=Advanced Command Line and Text Window Manager
Icon=byobu
Exec=env TERM=xterm-256color byobu
Terminal=true
Type=Application
Categories=GNOME;GTK;Utility;
X-GNOME-Gettext-Domain=byobu

実行結果例

Loading byobu ... 1/10 10%
Loading debian-uxterm ... 2/10 20%
...
Loading vim ... 10/10 100%

{
 "byobu": {
 "Name": "Byobu Terminal",
 "Exec": "env TERM=xterm-256color byobu",
 "iCON": "byobu",
 "Type": "Application",
 "Comment": "Advanced Command Line and Text Window Manager"
 },
 "debian_uxterm": {
 "Name": "UXTerm",
 "Exec": "uxterm",
 "iCON": "mini.xterm",
 "Type": "Application",
 "Comment": "xterm wrapper for Unicode environments"
 },
 ...
 "vim": {
 "Name": "Vim",
 "Exec": "vim %F",
 "iCON": "gvim",
 "Type": "Application",
 "Comment": "Edit text files"
 }
}

修正前のコードの計測

実行時間は /usr/share/applications/ 以下にあるファイル数に依存します。私の環境では 10 個のファイルが存在していました。(正確には 13 個ありましたが計算しやすいようにパッケージを削除しています。) 一つのファイルの行数は数十行程度です。これらのファイルは基本的にデスクトップアプリをインストールしたときに作られるようですが、CLI コマンドのインストールでも作られる事があるようです。私がテストした環境は WSL2 で CLI 環境として利用しているので 10 個しかありませんでしたが Linux をデスクトップマシンとして使用している場合はこの 10 倍ぐらいファイルがあっても不思議ではないでしょう。WSL2 による違いが少し気になりますが、仮想マシンなので全体的に遅くなる程度で特性は大きく違わないと思います。

実行速度の計測にはより正確に調べるために hyperfine を使用しました。これを使って 10 個のファイルの処理にかかる時間を計測するとおよそ 4.5 秒であることがわかりました。

$hyperfine './AppList.sh'
Benchmark #1: ./AppList.sh
 Time (mean ± σ): 4.564 s ± 0.033 s [User: 4.097 s, System: 0.518 s]
 Range (min … max): 4.528 s … 4.628 s 10 runs

シェルスクリプトが遅くなる原因

シェルスクリプトが遅くなる原因の多くは(主にループの中で)外部コマンドを多数呼び出しているからです。外部コマンドでなくてもサブシェルを伴う処理(コマンド置換やパイプライン)は外部コマンドほどでないですが遅くなる原因となります。呼び出している回数が重要でこれが多数になるようなコードはたいていループの中で外部コマンドを呼び出しています。ループの外で呼び出す程度やループを構成するパイプラインの一部で使う分には呼び出す回数は少ないため影響は小さいです。

外部コマンドの呼び出し回数

さてこのコードで外部コマンドがどれだけ呼び出されるか数えてみましょう。 (以下は /usr/share/applications/ にファイルが 10 個ある場合)

  1. Load AppList の find, sed, sort (それぞれ1回)
  2. 10回のループ
  3. awk(1回)
  4. tr(2回)
  5. JSON="$(echo "${JSON}" | jq -c ".${_JsonName} = {}")"jq(1回)
  6. 5 回の _setValueToJson 関数呼び出しで (jqcrudinitrgrep)× 5回
  7. echo "${JSON}" | jqjq (1回)

このうち 一番最初と一番最後はループの外なので影響は小さいです。そしてループの中にある awk, tr, jq, crudini, grep が大きく影響しているであろうと推測できます。

処理をコメントアウトしたりして細かく計測すると実行時間の内訳は次のようになりました。今回の例では推測通りほとんどがループ処理で時間がかかっており、ループの外のコードはそれほど影響がないことがはっきりしました。

  1. Load AppList の find, sed, sort ・・・ 10ms
  2. 10回のループ ・・ 4,500 ms
  3. echo "${JSON}" | jqjq ・・・ 40ms

ループ処理の詳細

次に 10 回のループの中で外部コマンドが何回呼ばれるかをコマンドごとに数えてみます。

  1. awk 1 回 × 10ループ = 10回
  2. tr (2 回 + 1 回)× 10ループ = 30回
  3. jq (1 回 + 5 回)× 10ループ = 60回
  4. crudini 5 回 × 10ループ = 50回
  5. grep 5 回 × 10ループ = 50回

合計 200 回外部コマンドが呼び出されています。

さて、みなさんはコマンドの実行時間はどれくらいだと思いますか?もちろん各コマンド毎に実行時間は異なりますが、上記の例では単純計算で 平均 22.5ms ということになります。この時間を長いと思うか短いと思うかは人それぞれでしょうが 1 回あたりの実行時間としては私は十分短いように感じます。しかしながらこれを 200 回も実行すると 4.5 秒にもなるわけです。

第一章 リファクタリング

第一章ではごく普通のリファクタリングを行います。高速化が目的と言うよりも不適切な書き方を直すことでその結果速くなるといったものです。一般的に複雑な処理を行うほど実行時間は長くなります。そのためある程度経験を積んでいる人ならすぐに jqcrudini に時間がかかっているのだろうと推測すると思います。そしてはそれは正しいです。ただいきなり jqcrudini 部分を改善してしまうと解説としてはちょっともったいないので少し遠回りをします。

getDesktopFile

まず crudini コマンド部分には手を付けずに getDesktopFile 関数を改善してみます。

# 全体の実行時間 4.543 s
function getDesktopFile(){
 _Result="$(crudini --get "${1}" "Desktop Entry" "${2}")"
 _Result="$(echo ${_Result} | tr -d "\"")"
 if echo "${_Result}" | grep -q "^[0-9]\+$" || [[ "${_Result}" = true ]] || [[ "${_Result}" = false ]]; then
 echo -n "${_Result}"
 else
 echo -n "\"${_Result}\""
 fi
}

文字の削除

まず目をつけたのがこの行です。

_Result="$(echo ${_Result} | tr -d "\"")"

この処理は おそらく ini ファイルの値がダブルクォートで括られている場合にそれを取り除く処理です。(それに関する issue を作成してるのを見つけたので)。実際には値の中に含まれるダブルクォートも消えてしましますが、ini の仕様で値にダブルクォートが含まれてる場合はどうなるの? → ini に正式な仕様はない。となって結論が出ないのでそこは無視します。

何度も外部コマンドの呼び出しが遅いと言ってることから想像できるかもしれませんが、この処理を tr を使わずに実装します。bash であればパラメータ展開で文字列の置換ができるので簡単です。POSIX シェルの場合はそれがないので苦労しますが 1 文字の置換(削除)に限れば単語分割を使用して処理することができます。

# ほぼ等価のコード(細かく言えは echo の引数の解釈や末尾の改行処理などが異なるがこちらのほうがより正確)
_Result="${_Result//\"/}"

# POSIX シェル版
set -f
OLDIFS=$IFS && IFS='"'
set -- $_Result
IFS="" && _Result="$*" && IFS=$OLDIFS
set +f
# 全体の実行時間 4.425(POSIX シェル版 4.432 s)
function getDesktopFile(){
 _Result="$(crudini --get "${1}" "Desktop Entry" "${2}")"
 _Result="${_Result//\"/}"
 if echo "${_Result}" | grep -q "^[0-9]\+$" || [[ "${_Result}" = true ]] || [[ "${_Result}" = false ]]; then
 echo -n "${_Result}"
 else
 echo -n "\"${_Result}\""
 fi
}

なんらかの理由で tr を使わざるを得ないでも、次のようにすることで改善することができます。

# _Result="$(crudini --get "${1}" "Desktop Entry" "${2}")"
# _Result="$(echo ${_Result} | tr -d "\"")"
# ↓
# 全体の実行時間 4.461 s
_Result="$(crudini --get "${1}" "Desktop Entry" "${2}" | tr -d "\"")"

crudini の出力を変数に入れて改めて tr コマンドを実行するよりも crudinitr をパイプで繋いで処理したほうが速いということです。

リテラル値の判定

次に目をつけたのがこの行です。この行は _Result 変数の中身が、数値またはtrue、falseであるかどうかを確認しており、後続の行でこれらの値以外の場合のにみにダブルクォートで括って出力しています。

if echo "${_Result}" | grep -q "^[0-9]\+$" || [[ "${_Result}" = true ]] || [[ "${_Result}" = false ]]; then

ここでも外部コマンドを使わないコードに置き換えます。これも bash であれば正規表現が使えるので簡単です。POSIX シェルの場合は case を使って実装できます。

if [[ "${_Result}" =~ ^([0-9]+|true|false)$ ]]; then

# POSIX シェル版
case $_Result in
 true | false) echo -n "${_Result}" ;;
 *[!0-9]* | "") echo -n "\"${_Result}\"" ;;
 *) echo -n "${_Result}" ;;
esac
# 全体の実行時間 4.321 s(POSIX シェル版 4.321 s)
function getDesktopFile(){
 _Result="$(crudini --get "${1}" "Desktop Entry" "${2}")"
 _Result="${_Result//\"/}"
 if [[ "${_Result}" =~ ^([0-9]+|true|false)$ ]]; then
 echo -n "${_Result}"
 else
 echo -n "\"${_Result}\""
 fi
}

余談ですが、このコードでは末尾に改行がつかないように echo -n を使用しているようですが getDesktopFile 関数は jq -c "... = $(getDesktopFile ...)" のようにコマンド置換を使って呼び出されており、その時に末尾の改行が削除されるため -n は不要です。一般的に出力最後の改行を抑制する必要はほとんどありません。なお POSIX では -n オプションの挙動は実装依存となっており yash (デフォルト)や macOS の /bin/sh (POSIX モードの bash)では -n がそのまま出力されるので移植性が悪いです。

変数に入れる必要ありますか?(別解)

上記のような、なにかの出力をコマンド置換を使って変数に入れる(そしてその変数を echo して加工してまた変数に入れる)というコードは、実はあまりシェルスクリプトっぽい書き方ではありません。変数に入れずに加工してそのままの勢いで出力したほうがシェルスクリプトっぽいです。以下のコードは crudni で値を出力し、そのまま sed に渡して必要な場合にダブルクォートをつける所までを一気にやっています。

# 全体の実行時間 4.358 s
function getDesktopFile(){
 { crudini --get "${1}" "Desktop Entry" "${2}" || echo; } \
 | sed 's/"//g; /^true$/n; /^false$/n; /^[0-9]\{1,\}$/n; s/\(.*\)/"\1"/'
}
# 補足
# 値が見つからない時の挙動が変わるので || echo をつけています
# sed は -E オプションを使えばもう少し簡潔に書けますが POSIX に準拠するために標準の BRE を使用しています

シェルスクリプトっぽい書き方はこのようによりシンプルになるということがわかると思います。正直なところ ret=$(echo "$var" | cmd) のようなコマンド置換+パイプラインという書き方の多くはアンチパターンではないかと思っています。実際にはそれを使わないといけない場合もあるんですがサブシェルで遅くなったり末尾の改行が消えてしまったりであまりいい印象がありません。出力は変数に代入することはせず標準出力(または任意のファイルディスクリプタ)やパイプで別コマンドに渡すだけにした方が良いでしょう。ちなみにパラメータ展開や(ret=${var##*/} 等)や数式展開(ret=$((var+100))等)はコマンド置換($(cmd)`cmd`)ではないので問題ありません。

ここでは getDesktopFile 関数の小手先の改善を行いましたが、もともと時間がかかっている場所ではないので 2% (0.1 秒)程度しか改善することは出来ませんでした。

crudini 周りの改善の方針

crudini は 1 つの ini ファイルごとに 5 つのキーの値を取得しており、値を一つ取得するたびに crudini コマンドを実行しているため 10 回のループで合計 50 回 呼び出されています。さて本題の前に 50 回の呼び出しは果たして遅いのでしょうか? もしかしたら 50 回呼び出しても大した時間がかかってないかもしれませんね。計測することは重要です。

$ hyperfine 'crudini --version'
Benchmark #1: crudini --version
 Time (mean ± σ): 44.1 ms ± 2.5 ms [User: 36.6 ms, System: 7.4 ms]
 Range (min … max): 42.2 ms … 61.5 ms 66 runs

一般的に --version というのはオプション解析の一部で処理されてしまいメインのコードが実行されないため参考にならないことが多いのですが crudini の場合はこれだけでも 44.1ms という大きな値(大きいと思いますよね?)となりました。44.1ms は十分高速と思うかもしれませんが 50 回も実行すると 2.2 秒です。4.5 秒の中の 2.2 秒が crudini の起動(Python ライブラリの読み込みも含む)に使用されていることになります。(感想 なんでたかが ini 形式のパース程度でこんな時間かかるプログラムが出来上がるの?)

crudini コマンドの使い方で気になるのは一つの値を取得するたびにコマンドを実行している所です。値ごとにコマンドを実行するというのは遅いというのは、crudini の開発者も想定しているだろうということでリストで取得する機能があるはずです。ということでヘルプを見ると --format オプションが使えるということがわかります。(最初は名前から --list オプションかと思いましたがこれは違いました。)

$crudini --help
A utility for manipulating ini files

Usage: crudini --set [OPTION]... config_file section [param] [value]
 or: crudini --get [OPTION]... config_file [section] [param]
 or: crudini --del [OPTION]... config_file section [param] [list value]
 or: crudini --merge [OPTION]... config_file [section]

Options:

 --existing[=WHAT] For --set, --del and --merge, fail if item is missing,
 where WHAT is 'file', 'section', or 'param', or if
 not specified;all specified items.
 --format=FMT For --get, select the output FMT.
 Formats are sh,ini,lines
 --inplace Lock and write files in place.
 This is not atomic but has less restrictions
 than the default replacement method.
 --list For --set and --del, update a list (set) of values
 --list-sep=STR Delimit list values with "STR" instead of " ,"
 --output=FILE Write output to FILE instead. '-' means stdout
 --verbose Indicate on stderr if changes were made
 --help Write this help to stdout
 --version Write version to stdout

--format オプションは都合がいいことに sh 形式での出力に対応しています。これを使って出力を eval すれば簡単にシェル変数に代入することができる・・・と思いましたが、困ったことにシェル変数名として不正なキーがあるとそこでパース処理がエラーで中断してしまいます。(感想 せめて無視すればいいのに・・・)

$crudini --get --format sh /usr/share/applications/byobu.desktop "Desktop Entry"
Name='Byobu Terminal'
Comment='Advanced Command Line and Text Window Manager'
Icon=byobu
Exec='env TERM=xterm-256color byobu'
Terminal=true
Type=Application
Categories='GNOME;GTK;Utility;'
Invalid sh identifier: X-GNOME-Gettext-Domain

そのため --format ini--format lines を使ってパースしなければいけません。(感想 それするぐらいなら自力で ini ファイルをパースしても大差ない・・・)

$crudini --get --format ini /usr/share/applications/byobu.desktop "Desktop Entry"
[Desktop Entry]
Name = Byobu Terminal
Comment = Advanced Command Line and Text Window Manager
Icon = byobu
Exec = env TERM=xterm-256color byobu
Terminal = true
Type = Application
Categories = GNOME;GTK;Utility;
X-GNOME-Gettext-Domain = byobu

$crudini --get --format lines /usr/share/applications/byobu.desktop "Desktop Entry"
[ Desktop Entry ] Name = Byobu Terminal
[ Desktop Entry ] Comment = Advanced Command Line and Text Window Manager
[ Desktop Entry ] Icon = byobu
[ Desktop Entry ] Exec = env TERM=xterm-256color byobu
[ Desktop Entry ] Terminal = true
[ Desktop Entry ] Type = Application
[ Desktop Entry ] Categories = GNOME;GTK;Utility;
[ Desktop Entry ] X-GNOME-Gettext-Domain = byobu

自力でパースするのは後でするとして、とりあえずは今はエラーを無視するようにして --format sh を使って複数のキーを一度に取得することにします。一部の値が読み取れませんが速度の目安にはなります。

jq 周りの改善の方針

crudini は複数のキーを一度に取得する方針としましたが jq コマンドを呼び出す getDesktopFile 関数もキーごとに呼び出されるためこちらも修正する必要があります。

jq で行っている処理は JSON データの構築です。余談ですが jq コマンドで JSON データを作るってモヤッとしませんか? JSON データを作るならむしろ jo でしょう? jq は JSON データの変換を行うものなので入力は JSON データであるはずです。まあそれはともかく jq コマンドでシンプルな値から JSON データを作れるのは事実です。JSON データと言ってもただの文字列でしか無いのでシェルスクリプトでも簡単に作れそうですが注意しなければいけないのは jq はキーや値のエスケープ処理を行っているということです。さほど難しい処理ではありませんが重要な処理なのでこれを忘れてはいけません。

jq 周りのコードを抜き出すと次のようになります。

JSON="{}"
for _App in "${AppList[@]}"; do
 ...
 _setValueToJson(){
 JSON="$(echo "${JSON}" | jq -c ".${_JsonName}.${1} = $(getDesktopFile "${_DesktopFilePath}" "${1}")")"
 }

 JSON="$(echo "${JSON}" | jq -c ".${_JsonName} = {}")"
 _setValueToJson "Name"
 _setValueToJson "Exec"
 _setValueToJson "iCON"
 _setValueToJson "Type"
 _setValueToJson "Comment"
 ...
}

最初に JSON 変数を {} で初期化し JSON 変数の中に見つけたキーを次々と入れていくような感じで手続き型的な処理であると言えるでしょう。もうちょっと優れた改善は別にあるのですが、ひとまずこのままループ 1 回あたり 5回の jq コマンドの呼び出しを 1 回にすることだけを考えます。これはシェルスクリプトのコードの書き方と言うより単に jq コマンドの使い方の話で、以下のようにすれば 1 回の jq コマンド呼び出しで JSON を構築できます。

$jq -n --arg item item1 \
 --arg Name "名前" --arg Exec "コマンド" --arg iCON "アイコン" --arg Type タイプ --arg Comment コメント \
 '{($item): {"Name": $Name, "Exec": $Exec, "iCON": $iCON, "Type": $Type, "Comment": $Comment}}'
{
 "item1": {
 "Name": "名前",
 "Exec": "コマンド",
 "iCON": "アイコン",
 "Type": "タイプ",
 "Comment": "コメント"
 }
}
$# キーと変数が同じ場合は省略して '{($item): {$Name, $Exec, $iCON, $Type, $Comment}}' と書けるようです

この内容を JSON 変数に追加していくのにも jq コマンドを使うことが出来ます。

item1=$( jq -n --arg item item1 \
 --arg Name "名前" --arg Exec "コマンド" --arg iCON "アイコン" --arg Type タイプ --arg Comment コメント \
 '{($item): {"Name": $Name, "Exec": $Exec, "iCON": $iCON, "Type": $Type, "Comment": $Comment}}')
item2=$( jq -n --arg item item2 \
 --arg Name "名前" --arg Exec "コマンド" --arg iCON "アイコン" --arg Type タイプ --arg Comment コメント \
 '{($item): {"Name": $Name, "Exec": $Exec, "iCON": $iCON, "Type": $Type, "Comment": $Comment}}')

JSON='{}'
JSON=$(echo "$JSON" "$item1" | jq -s add)
JSON=$(echo "$JSON" "$item2" | jq -s add)
$echo "$JSON"
{
 "item1": {
 "Name": "名前",
 "Exec": "コマンド",
 "iCON": "アイコン",
 "Type": "タイプ",
 "Comment": "コメント"
 },
 "item2": {
 "Name": "名前",
 "Exec": "コマンド",
 "iCON": "アイコン",
 "Type": "タイプ",
 "Comment": "コメント"
 }
}

変数に入れる必要ありますか?(二回目)

元のコードにならって生成したアイテムごとの JSON データを JSON 変数に付け足していきましたが、実はこの処理は不要です。似たような話を上でしましたが、これも変数に入れずにそのまま出力すればよいのです。そのまま出力してしまえば JSON にならないと思うかもしれませんが、最後の段階で jq コマンドを使います。つまりこういうことです。

AppList=("item1" "item2")
for _App in "${AppList[@]}"; do
 jq -n --arg item "$_App" \
 --arg Name "名前" --arg Exec "コマンド" --arg iCON "アイコン" --arg Type タイプ --arg Comment コメント \
 '{($item): {"Name": $Name, "Exec": $Exec, "iCON": $iCON, "Type": $Type, "Comment": $Comment}}'
done | jq -s add
{"item1":{"Name":"名前","Exec":"コマンド","iCON":"アイコン","Type":"タイプ","Comment":"コメント"},"item2":{"Name":"名前","Exec":"コマンド","iCON":"アイコン","Type":"タイプ","Comment":"コメント"}}

crudini と jq 改善の反映

ここまでの話で 1 回のループで crudini (5 回) と jq (6 回)の呼び出しをそれぞれ 1 回に減らすことができるとわかりました。それを反映させたのが次のコードです。(正確には、数値/true/false を文字列として扱っているなど動作が違う所があるのですが、本質的ではなくそれらの値が入ることもないので、というか面倒なので省きました。)

# !/usr/bin/env bash
set -eu
AppDir="/usr/share/applications"
DesktopFileExt="desktop"

# Load AppList
while read -r app; do
 AppList+=("${app}")
done < <(find "${AppDir}" -maxdepth 1 -mindepth 1 -name "*.${DesktopFileExt}" -printf "%f\n" 2> /dev/null | sed "s|.${DesktopFileExt}$||g" | sort)

Count=0
for _App in "${AppList[@]}"; do
 Count=$(( Count + 1 ))
 echo "Loading ${_App} ... ${Count}/${#AppList[@]}$(awk "BEGIN { print ${Count} * 100 /${#AppList[@]}}")%" >&2
 _JsonName="$(echo -n "${_App}" | tr "." "_" | tr "-" "_")"
 _DesktopFilePath="${AppDir}/${_App}.${DesktopFileExt}"

 Name="" Exec="" iCON="" Type="" Comment=""
 eval "$(crudini --get --format sh "${_DesktopFilePath}" "Desktop Entry" 2>/dev/null)"

 jq -n --arg JsonName "$_JsonName" \
 --arg Name "$Name" --arg Exec "$Exec" --arg iCON "$iCON" --arg Type "$Type" --arg Comment "$Comment" \
 '{($JsonName): {"Name": $Name, "Exec": $Exec, "iCON": $iCON, "Type": $Type, "Comment": $Comment}}'
done | jq -s add

計測結果です。計算通り 1/5 以下の時間に速度を改善することができました。

$hyperfine './AppList.sh'
Benchmark #1: ./AppList.sh
 Time (mean ± σ): 830.8 ms ± 5.8 ms [User: 772.6 ms, System: 100.1 ms]
 Range (min … max): 824.7 ms … 843.1 ms 10 runs

細かい点の修正

少し休憩して細かい所の修正です。ここの内容はそこまで大きな効果はなくやるべきと言うよりも、私ならこうするという程度のものです。

.-_ に変換してる処理です。ここは bash のパラメータ置換で簡単に置き換えることが出来ます。tr コマンドの呼び出しが不要になるので 25ms ほど節約することができました。

_JsonName="$(echo -n "${_App}" | tr "." "_" | tr "-" "_")"
# ↓
_JsonName="${_App//[.-]/_}"

ログ表示部分です。コマンド置換を避けたかったのでこのようにしています。速度的には殆ど変わりませんでした。

echo "Loading ${_App} ... ${Count}/${#AppList[@]}$(awk "BEGIN { print ${Count} * 100 /${#AppList[@]}}")%" >&2
# ↓
log() {
 awk 'BEGIN { printf "Loading %s ... %d/%d %.2f\n", ARGV[1], ARGV[2], ARGV[3], ARGV[2]*100/ARGV[3] }' "$@"
}
log "${_App}" "${Count}" "${#AppList[@]}" >&2

標準入力の内容を配列に入れるのであれば bash 4 以降から使える readarray の方が速いです。とは言えファイルの行数はそれほど長くないため、数 ms 程度減っただけです。また -printf は POSIX で規定されていないのもあってここでは行わずにその後のコードで取り除くように変更しました。

while read -r app; do
 AppList+=("${app}")
done < <(find "${AppDir}" -maxdepth 1 -mindepth 1 -name "*.${DesktopFileExt}" -printf "%f\n" 2> /dev/null | sed "s|.${DesktopFileExt}$||g" | sort)
# ↓
readarray -t AppList < <(find "${AppDir}" -maxdepth 1 -mindepth 1 -name "*.${DesktopFileExt}" | sort)

ちなみにもし配列がない POSIX シェルで実装する場合は位置パラメータを使って実現することができます。位置パラメータは POSIX シェルで唯一使える配列なのです。

第一章 リファクタリング 完

ここまでの修正を反映させたコードです。解説が長いので大変だと思うかもしれませんが、シェルスクリプトに慣れれば最初からこのようなコードを書くことができるで、ここまでは手間がかかる作業というわけではありません。なにげにコードの行数も減っています。普段の私ならろくに計測せずにここまでコードを修正すると思います。

# !/usr/bin/env bash
set -eu
AppDir="/usr/share/applications"
DesktopFileExt="desktop"

log() {
 awk 'BEGIN { printf "Loading %s ... %d/%d %.2f\n", ARGV[1], ARGV[2], ARGV[3], ARGV[2]*100/ARGV[3] }' "$@"
}

# Load AppList
readarray -t AppList < <(find "${AppDir}" -maxdepth 1 -mindepth 1 -name "*.${DesktopFileExt}" | sort)

Count=0
for _DesktopFilePath in "${AppList[@]}"; do
 Count=$(( Count + 1 ))
 _App=${_DesktopFilePath##*/} && _App=${_App%.${DesktopFileExt}}
 JsonName=${_App//[.-]/_}
 log "${_App}" "${Count}" "${#AppList[@]}" >&2

 Name="" Exec="" iCON="" Type="" Comment=""
 eval "$(crudini --get --format sh "${_DesktopFilePath}" "Desktop Entry" 2>/dev/null)"

 jq -n --arg JsonName "$JsonName" \
 --arg Name "$Name" --arg Exec "$Exec" --arg iCON "$iCON" --arg Type "$Type" --arg Comment "$Comment" \
 '{($JsonName): {"Name": $Name, "Exec": $Exec, "iCON": $iCON, "Type": $Type, "Comment": $Comment}}'
done | jq -s add
$hyperfine './AppList.sh'
Benchmark #1: ./AppList.sh
 Time (mean ± σ): 801.7 ms ± 7.9 ms [User: 738.0 ms, System: 93.7 ms]
 Range (min … max): 793.8 ms … 816.5 ms 10 runs

この時点で速度は、元が 4.564s だったのが 801.7 ms へと 5.7 倍(-3.8 秒)に向上しました。

第二章 シェルスクリプトの実力

第二章では遅い外部コマンドをシェルスクリプトに置き換えることで速度向上を目指します。(警告 この先の技術はシェルスクリプトの黒魔術につながる技術です。暗黒面に染まりたくない人は「シェルスクリプトのための良いデザイン ~ expr と bc から知る設計の違い ~」に引き返すことをおすすめします)

注意 この章で示している手法は必ずやるべき事だとは言ってはいません。事実を示しているだけであなたが言いたいことはわかっています。手間がかかるので別の言語に変えるのは当然の選択肢だし、データ量やロジックによっては逆に遅くなります。唯一の正しい方法なんてありません。やるかどうかは意味があるかを考えて計測し自分で判断すべきことです。

やはり crudini は遅い

eval "$(crudini ~) の行をコメントアウトすると 801.7ms かかっていたのが 327.3 ms に減ります。つまり crudini の実行だけで 474.4 ms も時間がかかっているということです。参考として cat /usr/share/applications/* | grep dummy を実行すると 3 ms (10 回だと 30ms)しかかからないので、これと比べるとかなり遅いということがわかります。ということで crudini を使うのをやめます。高速な代替コマンドがあればそれを使ってもいいですが、ini の解析ぐらいならシェルスクリプトで十分実装できます。必要な機能だけに絞ればコード量も大したことはありません。

Name="" Exec="" iCON="" Type="" Comment=""
eval "$(crudini --get --format sh "${_DesktopFilePath}" "Desktop Entry" 2>/dev/null)"
# ↓
readDesktopEntry() {
 Name="" Exec="" Icon="" Type="" Comment="" in_section=''
 readarray -t lines
 for line in "${lines[@]}"; do
 case $line in
 "[Desktop Entry]") in_section=1 && continue ;;
 "["*) in_section=''&& continue ;;
 esac
 [ "$in_section" ] || continue
 case ${line%%=*} in (Name | Exec | Icon | Type | Comment)
 printf -v "${line%%=*}" '%s' "${line#*=}"
 esac
 done
}

readDesktopEntry < "${_DesktopFilePath}"

補足 この時点で iCON が正しくは Icon であることに気づいたので修正してます。

$hyperfine './AppList.sh'
Benchmark #1: ./AppList.sh
 Time (mean ± σ): 340.6 ms ± 6.3 ms [User: 350.8 ms, System: 19.1 ms]
 Range (min … max): 330.5 ms … 351.5 ms 10 runs

全体で 801.7ms かかっていたのが 340.6 ms に改善しました。コメントアウトした場合が 327.3 ms ですのでシェルスクリプトによる ini のパースには 13.3 ms しかかかっておらず大幅に改善することができました。シェルスクリプト版は crudini に比べて必要最小限のことしか行っておらず直接比較するのは不公平かもしれませんが、必要なことに絞ればシェルスクリプトでもこれだけの速度が出るということです。

やっぱり jq も遅い

JSON データの作成はエスケープ処理を除けば簡単です。なんなら jq コマンドを使うよりも短いぐらいです。

jq -n --arg JsonName "$JsonName" \
 --arg Name "$Name" --arg Exec "$Exec" --arg Icon "$Icon" --arg Type "$Type" --arg Comment "$Comment" \
 '{($JsonName): {"Name": $Name, "Exec": $Exec, "Icon": $Icon, "Type": $Type, "Comment": $Comment}}'
# ↓
printf '{"%s": {"Name": "%s", "Exec": "%s", "Icon": "%s", "Type": "%s", "Comment": "%s"}}\n' \
 "$JsonName" "$Name" "$Exec" "$Icon" "$Type" "$Comment"

さて問題は JSON エスケープです。RFC8259によると " \ / b f n r t の 8 文字はエスケープする必要があるようです。これらを "\" のように変換するようです。まあ変換のルール自体はわかってもそれをどうやって書いたらいいかが迷うところだと思います。sed を使った方法などやり方はいくつかあると思いますがシェルスクリプトで実装することにします。文字列が十分短ければ外部コマンドを呼び出すより速いです。

escape() {
 tmp=$2
 tmp=${tmp//\\/\\\\}
 tmp=${tmp//\"/\\\"}
 # }" qiita のシンタックスハイライトのバグ回避用コメント
 tmp=${tmp//\//\\\/}
 tmp=${tmp//$'\b'/\\b}
 tmp=${tmp//$'\f'/\\f}
 tmp=${tmp//$'\n'/\\n}
 tmp=${tmp//$'\r'/\\r}
 tmp=${tmp//$'\t'/\\t}
 printf -v "$1"'%s'"$tmp"}

# 使い方
escape JsonName "$JsonName"

# 補足 POSIX シェルの場合は printf -v がないので eval を使う必要がありますが
# そもそもパラメータ展開による置換もできないのでもう少し複雑なコードが必要になります。

escape 関数はエスケープしたい文字列を第 2 引数で渡します。そして第 1 引数で指定した変数に値を戻します。こういう場合にコマンド置換を使う例をよく見ますがサブシェルが使われるため遅いです。本当はグローバル変数経由で値を戻したほうが速いのですが、流石にメンテナンス性が悪くなるので 第 1 引数で指定した変数に値を戻すようにしています。

$hyperfine './AppList.sh'
Benchmark #1: ./AppList.sh
 Time (mean ± σ): 42.0 ms ± 1.0 ms [User: 60.8 ms, System: 6.1 ms]
 Range (min … max): 40.5 ms … 44.9 ms 70 runs

42.0 ms! 元が 4.5 秒なので 100 倍以上高速化できたことになります。ちなみにエスケープ処理にどれだけ時間がかかっているか気になると思いますがコメントアウトして計測しても誤差程度の違いしかありませんでした。

一つだけ残った jq

最後の行に一つだけ jq が残っているので、これも削除して jq 依存をなくしてしまいましょう。

done | jq -s add

やるべきことは簡単で、全体が JSON として正しい形になるように echo で足りないものを補完するだけです。特に難しくもないので省略します。jq が一つ減ったのでわずかに速度も上がりました。(3 ~ 4 ms 程度)

ただし副作用として標準出力(JSON データ)と標準エラー出力(ログのLoading...)が交互に表示されるようになってしまいました。

{
Loading byobu ... 1/8 12.50%
"byobu": {"Name": "Byobu Terminal", "Exec": "env TERM=xterm-256color byobu", "Icon": "byobu", "Type": "Application", "Comment": "Advanced Command Line and Text Window Manager"}
,
Loading emacs25-term ... 2/8 25.00%
"emacs25_term": {"Name": "GNU Emacs 25 (Terminal)", "Exec": "\/usr\/bin\/emacs25 -nw %F", "Icon": "emacs25", "Type": "Application", "Comment": "GNU Emacs is an extensible, customizable text editor - and more"}
...
}

これは jq コマンドはデータを JSON として解釈する必要があるため全体を読み取ってから出力するのに対して printf はすぐに出力するからです。標準出力をファイルにリダイレクトする場合は、交互に出力されても全く問題にならないのですが、画面に出力する場合は見づらいかもしれません。そういう場合は出力全体をキャプチャして出力するのが簡単です。その場合は、./AppList.sh | jq のようにスクリプトの外で jq に渡したり printf '%s\n' "$(./AppList.sh )" このような呼び出しをすることで同じように表示されます。ただしこのような出力をバッファリングさせるような動きには注意してください。修正前の done | jq -s add にも当てはまりますが、出力が揃うまで待機するため、もしこの出力を他のコマンドにパイプでつなげたりすると全体が揃うまでブロックされてしまいパイプ先が並列で処理されなってしまうからです。

このように JSON というのはデータが全部揃わないとその構造が確定できないためストリーミング処理を行うには適切な形式ではありません。そこで代わりに JSONL (JSON Line) 形式で出力するのを検討してみるのも良いかもしれません。

This page describes the JSON Lines text format, also called newline-delimited JSON. JSON Lines is a convenient format for storing structured data that may be processed one record at a time. It works well with unix-style text processing tools and shell pipelines. It's a great format for log files. It's also a flexible format for passing messages between cooperating processes.

JSONL は 一行が一データ(JSON)がなるように工夫されており、上記の太字の部分に書かれているように UNIX スタイルテキスト処理やシェルスクリプトのパイプラインに適した形式です。jq も JSONL に対応している(-s のことです)ので必要な場合には簡単に JSON に変換することもできます。シェルスクリプトでデータを扱う場合には、入出力データを適した形式にすることも重要な設計作業の一つです。

awk の存在も気になってきた

全体的にここまで高速化すると、さほど気にならなかったログ出力の awk の呼び出し(log 関数)が気になってきました。そんなに時間はかかってませんが、ループの回数分 awk は実行されます。なぜ awk が必要かと言うと進捗率として小数の値を表示するためです。bash で小数の計算ができれば簡単に問題は解決するんですけどね・・・。

すぐに思いつく方法が(小数点以下 2桁までを表示する場合)数値を 100 倍して整数として計算する方法です。ちょっと工夫が必要なのは小数点部分の頭 0 が消えないようにすることですね。例えば 100 倍された 305 という数値を 3.05 にする場合、整数部は 100 で割ればいいのですが、小数部を単純に 100 の剰余を取ってしまうと 05 ではなく 5 になってしまいます。そこで 100 を加えて 105 にしてから文字列として頭の 1 を削除しています。

v=105
n=$((v / 100))
f=$((100 + v % 100)) && f=${f#1}
echo $n.$f # 1.05

ということで log 関数から awk を削除することが出来ました。

log() {
 #awk 'BEGIN { printf "Loading %s ... %d/%d %.2f\n", ARGV[1], ARGV[2], ARGV[3], ARGV[2]*100/ARGV[3] }' "$@"
 rate=$(($2 * 10000 / $3)) && n=$((rate / 100)) && f=$((100 + rate % 100)) && f=${f#1}
 echo "Loading $1 ... $2/$3$n.$f%"
}

awk を削除したことにより処理速度はさらに 15 ms ほど減りました。

find まで消すのはやりすぎかもしれないが

外部コマンドをどんどん消し去って、残りは findsort です。もう十分だとは思うのですがキリがよくない(?)のでシェルの glob を使用して findsort もなくすことにします。

# Load AppList
# readarray -t AppList < <(find "${AppDir}" -maxdepth 1 -mindepth 1 -name "*.${DesktopFileExt}" | sort)

set -- "${AppDir}/"*".${DesktopFileExt}"
[ -e "$1" ] || set --

二行目は少し説明が必要で、ファイルが一つも見つからない場合に "${AppDir}/"*".${DesktopFileExt}" の結果が空になるのではなく "${AppDir}/*.${DesktopFileExt}" という文字列になる場合の対策です。(echo *echo no-files-* を実行して違いを確かめてみてください。) もしファイルが一つも見つからずに文字列になっていた場合=ファイルが存在しない場合は位置パラメータを空にしています。また sort も消えていますがこれはソートするのをやめたのではなく * で取得したパスは自動的にソートされるので不要だからです。

第二章 シェルスクリプトの実力 完

これでシェルスクリプトから呼び出している外部コマンドは一つもなくなりました。第一章とは異なり外部コマンドの内容をシェルスクリプトで実装したためコードは増えています。しかし他の言語で自分で実装した場合でも同じぐらいの量になるのではないでしょうか?他の言語ではライブラリを使用すれば事足りることが多いので自分で書かなくていいぶん楽です。これはシェルスクリプト自体の問題と言うよりシェルスクリプトにライブラリが少ないという問題だと私は考えています。(この状況が改善されるといいんですが・・・)

# !/usr/bin/env bash
set -eu
AppDir="/usr/share/applications"
DesktopFileExt="desktop"

escape() {
 tmp=$2
 tmp=${tmp//\\/\\\\}
 tmp=${tmp//\"/\\\"}
 # }" qiitaのシンタックスハイライトのバグ回避用コメント
 tmp=${tmp//\'/\\\'}
 tmp=${tmp//\//\\\/}
 tmp=${tmp//$'\b'/\\b}
 tmp=${tmp//$'\f'/\\f}
 tmp=${tmp//$'\n'/\\n}
 tmp=${tmp//$'\r'/\\r}
 tmp=${tmp//$'\t'/\\t}
 printf -v "$1"'%s'"$tmp"}

log() {
 rate=$(($2 * 10000 / $3)) && n=$((rate / 100)) && f=$((100 + rate % 100)) && f=${f#1}
 echo "Loading $1 ... $2/$3$n.$f%"
}

readDesktopEntry() {
 Name="" Exec="" Icon="" Type="" Comment="" in_section=''
 readarray -t lines
 for line in "${lines[@]}"; do
 case $line in
 "[Desktop Entry]") in_section=1 && continue ;;
 "["*) in_section=''&& continue ;;
 esac
 [ "$in_section" ] || continue
 case ${line%%=*} in (Name | Exec | Icon | Type | Comment)
 printf -v "${line%%=*}" '%s' "${line#*=}"
 esac
 done
}

# Load AppList
set -- "${AppDir}/"*".${DesktopFileExt}"
[ -e "$1" ] || set --

Count=0
echo '{'
for _DesktopFilePath in "$@"; do
 [ $Count -gt 0 ] && echo ","
 Count=$(( Count + 1 ))
 _App=${_DesktopFilePath##*/} && _App=${_App%.${DesktopFileExt}}
 JsonName=${_App//[.-]/_}
 log "${_App}" "${Count}" "${#@}" >&2

 readDesktopEntry < "${_DesktopFilePath}"

 escape JsonName "$JsonName"
 escape Name "$Name"
 escape Exec "$Exec"
 escape Icon "$Icon"
 escape Type "$Type"
 escape Comment "$Comment"

 printf '"%s": {"Name": "%s", "Exec": "%s", "Icon": "%s", "Type": "%s", "Comment": "%s"}\n' \
 "$JsonName" "$Name" "$Exec" "$Icon" "$Type" "$Comment"
done
echo '}'

最終結果です。

$hyperfine './AppList.sh'
Benchmark #1: ./AppList.sh
 Time (mean ± σ): 17.4 ms ± 0.8 ms [User: 11.5 ms, System: 1.1 ms]
 Range (min … max): 16.1 ms … 23.0 ms 168 runs

最初の 4564 ms から 17.4 ms、実に 262 倍に高速化したことになります。まあ何倍高速化できたかよりも何秒が何秒になったかの方が重要なんですけどね。言い直すと 4.5 秒が一瞬で終わるようになりました。5 分かかる処理が 1 秒もかからずに終わるようになったわけで実用できないレベルのものが十分実用レベルに改善されたと言えるでしょう。これが外部コマンドを使わない純粋なシェルスクリプトの実力です。他の言語と比べて最速ではなくとも十分な速さでしょう?

終章

外部コマンドの実行がどれだけシェルスクリプトの実行速度のボトルネックになり得るかがわかっていただけたでしょうか?シェルスクリプトを遅くしないためには外部コマンドの呼び出しを減らすことも重要です。何かをシェルスクリプトで実装する場合はそれを単体のシェルスクリプト(つまり外部コマンド)にするのではなくシェル関数として実装することを検討するとよいでしょう。それだけでも速くなりますし、外部コマンドとして実装すると無駄に汎用的に作って引数解析が必要になったりと無駄に複雑にしがちです。(注意 **シェル関数の引数は解析に getopts などが必要ないように設計しましょう。**シェル関数は外部コマンドとは異なり外部から呼ばれることはないので一般的な CLI コマンドの作法に従う必要はありません。複雑にせずシンプルを心がけてください。)

またシェル関数として実装すると(標準入出力によるデータのやり取りではない)より高速な引数や変数を使った受け渡し(escape関数を思い出してください)や記事では使っていませんがコールバック関数を使ったデータのやり取りなど外部コマンドではできないことができます。**シェル関数へはデータを引数で渡して変数で戻して良いです。データを標準入出力で受け渡すのは必ずしもベストプラクティスではありません。**シェル関数は呼び出し速度が速いだけではなく、より柔軟なことができるコマンドのスーパーセットなので機能を制限して使うのはもったいないです。シェル関数を使うなとか他のファイルをインクルードするなとかとんでもないことを言っている記事がありますが真に受けないでください。(参考 シェルスクリプトの書き方)有益だからこそ実装されているのです。(シェル関数は他にも外部コマンドをシェル関数でオーバライドして引数を付け足して元のコマンドを呼び出すとか面白いテクニックが使えます。それはまた別の記事で)

もちろん外部コマンドを一切使うなとは言っていません。外部コマンドを使わなければできないこともありますしデータが多い場合やシェルスクリプトが不得意な処理(ランダムなデータアクセス処理等)では外部コマンドを使ったほうが速くなる場合もあるでしょう。そもそもシェルスクリプトが得意なのは外部コマンドとの連携でありそれがシェルスクリプトを使うメリットです。実際、第二章の修正で外部コマンドを取り除くとコードは長くなり(この問題はシェル関数ライブラリが登場すれば解決します)シェルスクリプトの一般的なスタイルからは遠ざかってしまいます。どっちを取るかはトレードオフの問題です。私が言いたいのはシェルスクリプトには外部コマンドに頼らないことで改善できる選択肢があるということです。

さてこの記事では外部コマンドに焦点を当てていたためあまり言及はしませんでしたが、コマンド置換とパイプもコードから消えています。これらは遅いサブシェルを使っているため、コマンド置換やパイプが消えたのも高速化した理由の一つです。シェルスクリプトはパイプでつなげるのが正しいやり方、パイプを使うと速くなると単純に思っていた人にとっては意外な話ではないでしょうか?この話は長くなったので別記事に分けました。詳細は続編「シェルスクリプトはパイプを使うと並列処理されて速い ・・・ は神話!?」を参照ください。

外伝 awk

今回の記事でシェルスクリプトで実装した内容は ini ファイルを読み込んで JSON で出力するだけなの実は awk だけで実装することもできます。文字列の処理は awk の方が得意であるためさらに高速化できる可能性があります。この時重要なのはシェルスクリプトと awk を行ったり来たりしないことです。行ったり来たりすると awk コマンドの呼び出しで遅くなります。awk だけで実装するか、前処理だけをシェルスクリプトで実装し、あとは awk に処理を渡すようにすると良いでしょう。awk で実装すれば 1.5 倍から 2 倍ぐらいになるんじゃないかと思いますがそれはもはやシェルスクリプトではないので割愛します。というか疲れました。興味がある人はぜひ実装してみてください。awk を使ってプログラミングをする場合は「awkをプログラミング言語として使う時の技術」が参考になると思います。

さいごに

さて私はこの記事でリファクタリングを行い高速化を実現しました。それを踏まえてこちら「プログラマーの君! 騙されるな! シェルスクリプトはそう書いちゃ駄目だ!! という話」の記事を読んでみるのも楽しいかもしれません。第一章の内容はリンク先の記事の内容とほぼ適合しているでしょう。しかしその限界を超える高速化を実現した第二章の内容はリンク先の記事とは正反対に感じることでしょう。配列、使っています。位置パラメータを配列として使うと便利です。シェル関数へデータの受け渡しは標準入出力を使わず引数と変数です。echocat (参考 UUOC - Useless use of cat)も使っていません。パイプも read も排除しました、ライブラリを作るなら単独のシェルスクリプトではなくシェル関数推奨です。これらは一般的なプログラミングに近いスタイルでシェルスクリプトらしくないやり方というのは同意です。しかしこのやり方で実際に高速化が実現できますし、シェルスクリプトで 1 万行を超えるようなシェルスクリプトとしては大規模なプログラミングもできます。(注意 ユニケージのことではありません。あれは非公開の独自コマンドと UNIX 文化ではない独自の「作法」を強制するらしいベンダーロックイン技術なので嫌いです。)つまり正しいやり方は一つではありません。状況や目的によって変わるということです。そしてシェルスクリプトを理解すれば自在にシェルスクリプトスタイルとプログラミングスタイルの2つのスタイルを組み合わせることができるようになるでしょう。

おまけ

いつもこのようなことを POSIX シェル縛りでやってるから bash の正規表現対応やパラメータ展開での置換が便利すぎて困る。POSIX で標準化されないかなぁ。というか早く bash が不要になる POSIX シェル用のシェル関数ライブラリ作れっちゅーことですね。はい。

追記 おまけ その2 ポエム

私がシェルスクリプトで"プログラミング"をする理由

関連記事 パイプを使って高速化したシェルスクリプトを並列実行すると逆に遅くなる謎現象について

242

Go to list of users who liked

233
9

Go to list of comments

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
242

Go to list of users who liked

233