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


はじめに

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

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


記事の手法に反論したい方への注意書き

私は外部コマンドもパイプも使うなとは言っていません。明確に言えばシェルスクリプトでコマンドをパイプでつなげるのは正しいスタイルです。(主に一章でのやり方)しかしそれだけが唯一のやり方ではなく場合によっては実際に無視できないレベルでの速度低下を引き起こすことがあるためそれを解決するのが第二章のやり方で、一章のやり方も二章のやり方も絶対にそれを使えと押し付けているわけではなく正しく理解して状況に応じて適切に使い分けるべきだと言っています。


またシェルスクリプトや POSIX (コマンド)に固執しているつもりもありません。必要ならば他の言語を使うべきだと思うし実際に使います。 (例 C++ で開発した Windows API を多用するツール) ただ他の言語については他の人が更にすごい記事を書いているわけで私が解説記事を書いたところでそれには及ばないため私が書く予定はありません。シェルスクリプトや(特に C 言語インターフェースではなく)POSIX コマンドは乱立した UNIX の互換性問題を解決するためにあとから作られたものなわけで、こうすべきものとして議論と取捨選択に上に選びぬかれたエリートコマンドとかいうわけではありません。どれでも同じように実装されていた互換性があるものを妥協して矛盾しないようにまとめたものにすぎず、最大公約数であるため低機能であることは当然の帰結です。通常は POSIX コマンドだけでやるものではないです。私には高い移植性を実現したいという目的があるから POSIX コマンドだけで頑張っています。(あなたの目的はなんですか?) ・・・あ、いや正確に言えば POSIX コマンドも実装に差異があったりバグがあったりで完全に同じように動くと保証できないので POSIX で規定してあったとしても信用していません。POSIX に準拠することが目的ではなく最大の移植性(実際に動くこと)を実現するのが目的(例えば POSIX 準拠が目的ではないであろう BusyBox も私のターゲット)なので、移植性を重視する時は POSIX コマンドのごく一部しか使わないようにしていて当然各実装でテストしてます。POSIX コマンド縛りよりもさらにきつい縛りです。


シェルスクリプトはスクリプト言語なので動作速度も遅いです。JavaScript のようにネイティブコードにコンパイルすればスクリプト言語でも速くなりますが起動速度は遅くなりますしシェルスクリプトの目的にはあわないのでシェルに実装されることはないでしょう。(ただ ch=${str%"${str#?}"} を文字列の 1 文字目を高速に取得するように最適化したりしてくれないなーとは思ってたりします。シェル言語開発者様へ) 外部コマンドを使う理由の一つはシェルスクリプトが遅いからなので、シェルスクリプトを使わずに外部コマンド(awk, sed, grep など)で解決できるならばそれを使うべきでしょう。パイプは外部コマンドにデータを渡すための有効な技術の一つです。実際に私も文字列をパースするのが主体のツールawk を使いました。(内部技術についてはこちら) しかし銀の弾丸はないという言葉の通り、問題を解決するために使う技術は状況次第で変わります。今実際に発生している問題を解決することが重要であって一つの技術に固執すべきではありません。また現在問題が発生していないなら今すぐ対応する必要もないです。ただし問題が発生したときにすぐに対応できるようにする技術を知っておくことは重要です。これについては近いうちに記事を書く予定でいます。


この記事で提示した技術はデモに過ぎず、実績がなく現実には使いものにならないんだろうと思うのであれば、宣伝させていただきます。ShellSpec は ksh88 以降のすべての POSIX シェルに対応しているテストフレームワークです。Windows では WSL だけではなく Windows ネイティブビルドの busybox-w32 にも対応しています。開発したのは比較的最近ですが、このツールは理論でも POSIX に準拠してれば動くはずだとかでもなく実際に 30 年前のシェルでも同じように動作することを確認しているプログラムです。もし 30 年前に作っていれば 30 年間動くプログラムになっていたということです。ただし何も修正せずに動いたわけではありません。同じように動作させるために各シェルやコマンドのバグや非互換性を吸収し実用的なパフォーマンスをだすために多くのワークアラウンドやテクニックを駆使しています。もちろん ShellSpec は ShellSpec 自身でユニットテストをしています。動くだけの小さなサンプル(動いたとしても道具として使えない、目的を達成できないなら意味がないです)でも技術アピールでもなく、自分で使うために実用レベルのものを目指して開発しており高機能なのが売りです。(ちなみにコードの行数はテストコードを除いて 1 万行を超えています。が機能の割にはコンパクトに仕上げてあると自負しています。)導入しているプロジェクトもいくつか存在しているようです。(例 snyk - 私は関係者ではなく snyk に導入を勧めたこともありません。)


さらに追記。私が外部コマンドやパイプを否定しておらず、むしろ有効活用としている例として ShellSpec の設計を少し解説します。ShellSpec は外部コマンドやパイプを可能な限り使用しないようにしていますが、一部ではパフォーマンス上げるために意図的に使用しています。ShellSpec はテストファイルごとにバックグラウンドプロセスを使った並列実行機能を備えていますが、この並列実行機能を使わずともある程度並列で動作します。ShellSpec は複数のモジュール(実行ファイル。シェルスクリプト)から構成されています。細かいモジュールはもう少しありますが大まかに言うと「テストコード(シェルスクリプト文法と互換性があるDSL)をパースして通常のシェルスクリプトに変換するトランスレーター」「その変換されたシェルスクリプトを実行するランナー」「テスト結果を表示するレポーター」です。これらのモジュールが生成・必要とするデータはパイプを使って途切れること無く一直線にストリーミングで渡されておりこれらのモジュールは並列で動作します。これを実現するために DSL とトランスレーターはワンパスで設計・実装しています。並列で動作するように(なんの考えもなしにパイプを使うのではなく)意図的にこのような設計を行っています。パイプによる並列動作が効果的に働くようここまで徹底して設計レベルで工夫しているというのに単に外部コマンドやパイプを排除しようとしているだけに思われるのは心外です。



他の言語で~と言いたい方への注意書き

シェルスクリプトで速度の話をすると Go や Rust で書くべきとか的はずれな事を言ってくる人がいるので書きますが、そのような人に対してのアドバイスとして速度至上主義はやめましょう。速度で問題になるのはボトルネックになってるときだけです。メンテナンス性や移植性など他に重要なことはいくらでもあります。「適切な言語を使いましょう」という言葉はシェルスクリプトが適切な内容であればシェルスクリプトを使いましょうという意味でもあります。シェルスクリプトが適切だからシェルスクリプトを選ぶという場合に、速度という本質ではない点でシェルスクリプトが使えなくなる(しかも実際には開発者の技術力不足が原因)としたらそれはナンセンスです。なお行数は適切かどうかの判断基準にはなりえません。スクリプト全体の行数は複雑度やメンテナンス性をや品質を評価する尺度にはならず(長いコードを関数やファイルに分けるのは基本です!)、100 行を超えたら他の言語にすべきという主張には根拠が何も示されていません。


シェルスクリプトは他の言語と設計方針や考え方が違うので(他言語で有名なエンジニアであっても!)苦手意識を持ってる人が多いようですが、結局の所特定の作業に適しているからこそ長年使われ続けているのです。シェルスクリプトは貧弱だから Python 等に置き換えようと言ってる人もいますが、置き換えたら長くなっただけで何も変わってないのではありませんか?そうであれば適切な言語に置き換えたのではなく自分が使える言語に置き換えだけにすぎません。自分(やチーム)がシェルスクリプトを使えないから得意な言語に置き換えるというのは正当な理由の一つですが、シェルスクリプトのせいにはしないでください。そして Python(や他の言語)に置き換えるならその言語に用意されているライブラリを使いましょう。たとえば subprocess を使って外部コマンドを呼び出すだけの Python コードに変換するのは書き方を変えただけで速くもなってない上に冗長になるだけでほとんど意味がありません。あと Python はシェルではないのでシェルスクリプトにはなりえません。正しく Python スクリプトと呼びましょう。


序章 事前調査

元のコード

元のコードはこちらです。念の為にここにもミラーしておきます。処理内容を簡単に解説するとこれは「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回のループ
    1. awk(1回)
    2. tr(2回)
    3. JSON="$(echo "${JSON}" | jq -c ".${_JsonName} = {}")"jq(1回)
    4. 5 回の _setValueToJson 関数呼び出しで (jqcrudinitrgrep)× 5回
  3. 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 ポエム

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

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