50
33

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

シェルスクリプト「シェル芸からの脱出」 〜 コマンドをパイプで長くつなぎすぎた「パイプ地獄」のリファクタリング方法

Last updated at Posted at 2021-12-04

はじめに

シェル芸は可読性が低いです。シェルスクリプトで使うべき書き方ではありません。(そもそもシェル芸はシェルスクリプトで使うものではなかったはずですが?)この記事はこのことをはっきりと伝えるために書きました。

シェル芸とは「主に UNIX 系オペレーティングシステムにおいてマウスも使わず、ソースコードも残さず、GUI ツールを立ち上げる間もなく、あらゆる調査・計算・テキスト処理を CLI 端末へのコマンド入力一撃で終わらせること」らしいです。一般的にはワンライナーのことですね。詳細は他の記事に丸投げします。

シェル芸とシェルスクリプトの大きな違いは可読性とメンテナンス性の有無です。シェル芸は可読性やメンテナンス性やその他もろもろをガン無視し、一行でやってみせようという「芸」です(個人的な感想です)。念の為ですが一行で書いたシェル芸を折り返して複数行にしただけでは可読性やメンテナンス性は何も変わりません。

シェルスクリプトとして保存することなく、その場で使って破棄するというものであるのならシェル芸を使って構いません。しかし長く使い続ける、他の人がレビューする、別の人が将来修正する、などシェルスクリプトとしてファイルに保存するなら話は別です。シェルスクリプトは可読性が重要でありシェル芸スタイルでシェルスクリプトを書いてはいけません。その事を明確な根拠とともにこの記事で書いています。根拠のない主張ではなく、屁理屈も論理の飛躍もありません。

と言ってもシェル芸スタイルですでに書いてしまったという人もいると思いますので、この記事ではシェル芸を可読性の高いシェルスクリプトの形にリファクタリングしていく方法も紹介します。あとこの記事はシェルスクリプトの記事ですが内容はシェルスクリプトに限った話ではなくプログラミング全般に当てはまる話なのでシェルスクリプトに興味がない人でも読み物としてどうぞ。

この記事の狙い

「シェル芸」が一部の人が好んでいる書き方の一つでしかないと明確にし問題点を指摘することでシェルスクリプトに使うものではないと周知するのが目的です。好みの範疇にすぎず「書ける"人の技術"」はともかく、そこから生み出されるコードは可読性やメンテナス性が低いものです。シェル芸の勉強会(?)みたいなのもあるようですが、パズルを解いているだけのように見えます(興味がなく詳しく見たわけではないと言い訳をしておきます)。テトリスを 7 行で作るような コードゴルフ と同じたぐいのもので、頭の訓練にはなるとは思いますがシェルスクリプトとして業務で使うようなコードではありません。

シェル芸は与えられた課題をとりあえず解決できればいいという考えで書かれていることが多く、細かい所の対応ができていないことが多々あります。改行文字や制御文字など特定の文字への対応が疎かになっているなど、特定のケースへの対応が抜けていたり例外処理が中途半端だったりです。しかも十分なテストがされていません(本人はテストしたつもりでも手作業ではミスがあるし、テストした内容を証明するテストコードがない)。その場で実行して終わりなら、対応してないデータがたまたま含まれていなくて動いて終わりでしょうが、長く使われていればそういうデータが来ることもあるかもしれません。そのためシェル芸のような中途半端になりがちなコードをシェルスクリプトで使うといずれバグとして顕在化します。正直見ていて一応動くけどこれじゃだめだろうというコードや本当に問題がないのか頭を悩せるコードが多いです。ウェブアプリなど不特定の人が任意のデータを送信できるシステムでそのようなコードを使われると特定の場合に重大な問題を引き起こしたり脆弱性になってるんじゃないかと気が気ではありません。

シェル芸スタイルはシェルスクリプトにとっては適切な書き方ではなく、私のようにシェルスクリプトで使うことを批判している人もいます。

自己紹介

と言ってもどこの誰かもわからない人が批判していても信用されないと思うのでウザいと思いますが自己紹介をします。私は ShellSpec というシェルスクリプト用のユニットテストフレームワークを 2019 年からリリースしています。Ruby の RSpec のシェルスクリプト版が目標でシェルスクリプト製でありながら高機能(カバレッジ対応など)と性能(並列実行など)を実現しています。互換性が低いというシェルスクリプトの大きな問題を乗り越え POSIX シェル(dash, bash ≧ 2.03, zsh ≧ 3.1.9, ksh88 など)が動く環境であれば組み込み機器も含めてどこでも動くことを実現しました。POSIX に準拠していれば理論上動くはずとかいう机上の空論ではなく継続的に動作確認をしていますし、そもそも動作要件は POSIX シェルだけであって商用 UNIX のようなデフォルトで POSIX に準拠してない環境でもそのまま動作します。コードの行数は 1 万行を超えますが高いメンテナンス性を保っており、シェルスクリプト製でここまでの規模と機能と性能と互換性(移植性)と信頼性とメンテナンス性を実現してるソフトウェアは他にはないと言っても良いレベルだと自負しております。

ShellSpec 自身も ShellSpec でテストしていますが、世の中のシェルスクリプトで作られたツールやシステムの多くにはテストコードがありません。テストコードがないようなソフトウェアに会社の重要なデータを預けることは出来きないですよね?ShellSpec の開発ではっきりとわかりましたが、現状シェルスクリプトによる中〜大規模ソフトウェアの開発効率は最悪と言っていいほど低いものです。そもそもシェルスクリプトはそういうソフトウェアの開発のための言語として設計されていません。間違った使い方なので当然の話であり、そのせいで他の言語で簡単に実現できることを別の奇妙で独特な手法を編み出して苦労して実現しなければならなくなります。シェルスクリプトだけが使える手法なんてものはありません。それらの手法は誰も思いつかなかった発想の転換による優れた手法などではなく、シェルスクリプトの制限で仕方なくやっているもので、したがって生産性も性能も低いものです。他の言語でもシェルスクリプトと同じことは出来ますが適切ではないから使用する手法の候補から外すわけです。シェルスクリプトが簡単で生産性が高いと思えるのは小さいうちだけです。ソフトウェアの規模が大きくなればなるほど苦労が伴います。信頼性の担保や将来の機能追加修正に伴うメンテナンス性と生産性をシェルスクリプトで実現するのは困難なので他のプログラミング言語を使うべきです。シェルスクリプトで開発しなければいけない理由がないなら大きなソフトウェアの開発にシェルスクリプトを使ってはいけません。私はどうしてもシェルスクリプトで開発したいものがあったので、奇妙な手法を編み出し生産性とメンテナンス性を維持する工夫をしながら苦労してテストフレームワークを開発しました。(まだ色々言いたいことはありますが、自己紹介じゃないし長いのでこの記事の最後に書きました。)

あと私は「シェルスクリプトは変数代入で = の前後にスペースを置けない!・・・の本当の理由を知ると優れた文法が見えてくる」の記事を書いた人です。この記事結構バズったので。

「パイプ地獄」の問題点

可読性が低い

以下のコードは bash で作られた CMS である bashcms2link_keywords.cgi からの抜粋です。元のコードから無意味(実験的コード?とその消し忘れ?)と思われる行を削除しています。

はっきり言ってこんなに長いコードを見せられても何をやっているかわからないです。

sed 's/%2C/\n/g' <<< ${QUERY_STRING}     |
nkf --url-input                     |
sed -e '1s/keywords=//' -e 's/^[  ]*//' -e 's/[  ]*$//' |
nkf -w16B0                          |
xxd -plain                          |
tr -d '\n'                          |
sed 's/..../\&#x&;/g'               |
sed 's/\&#x000a;/\n/g'              |
awk '{print "<a href=\"/key.cgi?key="$1 "\">" $1 "</a>" }'	|
sed '1iContent-Type: text/html\n'

このコードは処理の区切りがわからず読み飛ばしが出来ず全てのコードを読まないと何をやっているかが分かりません。何をやっているかを実装の内容から想像しなければいけません。皆さんはこのコードをんで何がどう変わっていくか、何をやっているコードか、を頭の中ですぐに想像できるでしょうか?内容を把握する段階からコードの「解析作業」が必要になります。コードを実行して途中のデータを出力してみないと何がどうなっているかわからないでしょう。コードをんだだけではわからず実行して確かめないといけないないなら、それは可性が低い証拠です。

あと可読性というと技術的な知識がない人でも読めるコードのことだと思っている人がいるようですがそれは間違いです。技術的な知識がある人が楽に内容を把握できるのが可読性が高いコードです。技術的知識がなければ勉強すればいいだけの話で成長した人の判断を基準としなければいけません。成長した人ほど読むのに苦しむコードであれば何かが間違っているというのは説明せずともわかることだと思います。可読性の高さを判断できる人はコードを読める人だけです。この話についてはいつか記事を書きたと思っています。

話を戻すと読むのに苦労するのは、順番が逆になっているからです。

このコードを読む時「何をやっているかを把握 → コードの詳細な実装を読む」ではなく「コードの詳細な実装を読む → 何をやっているかを把握」になってしまっているはずです。何をやっているかを把握しないまま手探り状態でコードを読むことになり、一行一行考え込みながら全てを読まなければいけないので、時間がかかってしまいます。

コメントを入れればと思うかもしれませんがコメントはコードではありません。コードの可読性が低いことに変わりはなく、その問題をコメントでごまかしているだけです。

このコードを書いている本人がその場で使うだけならわかって当然です。自分で思いついたやりたいことを書くわけですから「何をやっているかを把握」する作業が必要ありません。しかし他の人や将来の自分がこのコードを見た時は「何やってんだこれ?」がスタート地点です。

テストコードが書けない

一般的にテストコードは関数に対してそのインターフェースをテストします。しかしパイプで全てがひとつなぎになっているためテストを行う箇所がありません。このシェルスクリプトは短くこのコードがシェルスクリプトのほぼ全てであるため、このスクリプトに関してはテスト可能かもしれませんが、長くなれば完全にテストが不可能となります。テストを行う箇所というのはインターフェースです。しかしパイプで繋いでいるとインターフェースがあやふやになってしまうため、例えばパイプの途中を修正するとその後のコードの全てを再確認する必要がでてきます。こういった仕様の変更に弱いのもパイプ地獄の特徴です。

関数を作るのもコマンドにするのも本質的には同じであるため、関数を作らずに小さいコマンドをたくさん作れば良いと思うかもしれません。しかし 1 ファイル 1 関数状態になるので、多数のファイルが必要となり管理が大変になってしまいます。本質的に同じであればコマンドを作る前にまず関数です。UNIX 哲学には「一つのことをうまくやる」という考え方がありますが、コマンドでは一つのことをやるのは現実的には不可能です。単体で実行可能なコマンドは初期化処理やオプション解析などが必要になってくるので本当に一つのことだけをやってるコマンドなんてまず見かけません。関数はコマンドよりも圧倒的に小さいものなので「一つのことをうまくやる」ことが出来ます。

参考までですが ShellSpec はおよそ 1 万行中、ファイル数は 120 個、関数の数は 1000 個です。この規模のソフトウェアとしては悪くない数値でしょう。ちなみにシェルスクリプトのメトリクス(ステップ数や循環的複雑度など)を計測する ShellMetrics も開発していますのでご利用ください。(参考 関数の適切な長さとは? マーチン・ファウラー氏は、長さより意図と実装の分離、そしてよい関数名が重要だと指摘

私はこの規律を採用してから短い関数を書くようなった、典型的には数行だ[2]。コードが6行を超えると、もう私にとっては関数の匂いを感じ始めるし、1行のコードからなる関数でさえ珍しくない[3]。

コマンドのように大きな機能を持ったものはテストのパターン(環境の条件や引数や入力データなど)の組み合わせが増えてしまうのでテストが大変になります。一つのことだけをする関数を作り小さくテストをし、信頼性のある関数を使って大きなものを作るのが一番簡単な実装方法です。また関数を使わないとコピペコードを大量に量産することになり、読まなければいけないコードが増え、バグが見つかった時に修正範囲が大きく、修正した後のテストも大変になります。コピペされたコードを読まなくて良いのは書いた本人だけです。他の人は本当にコードが一致しているかを調べなくてはいけませんし、もし違いがあったらそれが意図的なものなのか?どうしてそうなっているのか?を読み解かなければいけません。

他にはコマンドにしてしまうと、外部コマンド呼び出しとなってしまいパフォーマンスが下がるという問題もありますし、シェル変数にアクセスできないという制限もあります。他の言語のライブラリと考え方は同じです。役割ごとにいくつかの関数をまとめたライブラリファイル作れば、その関数毎にテストコードを書くことが出来ますし、生産性も信頼性も上がります。

パフォーマンスが落ちる

コマンドをパイプでつなぐとパフォーマンスが落ちます。パイプ間通信のシステムコールとコマンドとしての処理というオーバーヘッドが加わるのだから当然です。

# データ生成
$ cat /dev/urandom | base64 | head -n 1000000 > data.txt

# time コマンドの出力に CPU 使用率を加える (注意 bash 専用)
# 注意2 macOS の /bin/bash は古くてバグがあるようで CPU 使用率が正しく表示されない
$ TIMEFORMAT="real %3lR  user %3lU  sys %3lS  cpu %P%%"

# パターン1 パイプでつなぐ場合
$ time cat data.txt \
  | sed 's/a/A/g;' | sed 's/b/B/g;' | sed 's/c/C/g;' | sed 's/d/D/g;' \
  >/dev/null
real 0m0.537s  user 0m1.850s  sys 0m0.215s  cpu 384.87%

# パターン2 パイプでつながない場合
$ time cat data.txt \
  | sed 's/a/A/g; s/b/B/g; s/c/C/g; s/d/D/g;' \
  >/dev/null
real 0m1.360s  user 0m1.336s  sys 0m0.073s  cpu 103.59%

ね?パターン 1(パイプでつないだ場合)はパターン 2(パイプでつながない場合)よりも CPU 時間 (user + sys) が増えていますよね。

実時間 (real) はパイプでつないだ方が短いじゃないかって思うかもしれませんが、それはこの例の各コマンドの処理時間が同じで「ボトルネック」が存在しない都合のいい例だからです。実際のコードではこんな都合のいい状態にはなりません。またパイプでつないだ場合には速くなった代わりに余計に CPU を使っている(4 コア中 384.87%)ことに注意しなければいけません。他のことをする余力が無くなっています。そして逆に言えばパターン 2 では余力が有る(何もしていない CPU コアがある)ということなので、例えばこの処理を同時に 3 つ動かすことで 1 つのデータを処理する時間で 3 倍のデータ量を処理する事が可能であるということになります。

では少し手を加えてみましょう。

# パターン1 パイプでつなぐ場合(ボトルネックあり)
$ time cat data.txt \
  | sed 's/a/A/g;' | sed 's/b/B/g;' | sed 's/c/C/g;' | sed 's/d/D/g;' \
  | sed 's/[0-9]//g' >/dev/null
real 0m4.080s  user 0m6.156s  sys 0m0.493s  cpu 162.97%

# パターン2 パイプでつながない場合(ボトルネックあり)
$ time cat data.txt \
  | sed 's/a/A/g; s/b/B/g; s/c/C/g; s/d/D/g;' \
  | sed 's/[0-9]//g' >/dev/null
real 0m3.789s  user 0m5.194s  sys 0m0.219s  cpu 142.88%

このように、少し変えるだけ(ボトルネックとなる sed 's/[0-9]//g' を追加)で簡単に逆転してしまいます。本当の CPU 時間は user + sys です。パイプでつなぐとオーバーヘッドの分だけ CPU 時間は必ず増えます。そしてパイプライン全体の実時間や CPU 使用率はボトルネックに左右されます。マルチコア CPU で複数コアにうまく分散させることができれば実時間を減らすことは出来ますが、失敗すれば上記のように逆に実時間も CPU 時間も増えてデメリットしかない状態になります。余ってる CPU は使い切りたくなりますが、上記の例ではデータ処理件数が同じなのに CPU 使用率が多いわけで 「CPU を使い切る」ではなく無駄に「CPU を使い潰す」 状態になっています。パイプによるオーバーヘッドを追加すればするほど CPU を無駄に使い潰せるのは当然なわけで CPU 使用率が増えたからといって必ずしも良い結果になったとは限りません。要するにコマンドをパイプでつなげば並列処理が行われるが、正しくやらなければパフォーマンスは低下するということです。シェルスクリプトでパイプを使って"効率的に"並列処理をするのは難しく、何も考えずにパイプでつなぐだけでパフォーマンスが上がるようなものではありません。正しい知識と計測が必要です。シェルスクリプトにとって「パイプは重要な概念」というのは、こういったことも含めて正しく理解するということです。なおパイプライン並列化を使いこなしてパフォーマンスを上げる方法については別記事でもっと詳しく解説します。

リファクタリング後のコード

なるべく元のコードを保ったまま関数に分割しました。QUERY_STRING を正しくパースしてないのでそれじゃだめだろう思いますが、真面目にやると長くなる上に結局ウェブアプリはシェルスクリプトで作るものではなくライブラリやフレームワークが揃っている他の言語を使うべきというありきたりな結論になるだけなので修正せずにそのままにします。この記事の目的はシェルスクリプトの書き方とリファクタリング手法の紹介なので。

get_keywords() {
  sed 's/%2C/\n/g' | nkf --url-input \
    | sed -e '1s/keywords=//' -e 's/^[  ]*//; s/[  ]*$//'
}

escape_html() {
  nkf -w16B0 | xxd -plain | tr -d '\n' \
    | sed 's/..../\&#x&;/g; s/\&#x000a;/\n/g'
}

output() {
  sed "1iContent-Type: $1\n" # 本当は \r\n であるべき
}

get_keywords <<<$QUERY_STRING | escape_html | {
  awk '{print "<a href=\"/key.cgi?key="$1 "\">" $1 "</a>" }'
} | output "text/html"

上記のコードを読んだ時、関数の中身を読み飛ばして関数名だけを見ましたよね?そうです。関数の中身は読み飛ばしていいのです。読み飛ばせば読むべきコードが少なくなるので何をやってるかはすぐに分かることでしょう。逆に関数を使わない場合は全てを読まないといけないので理解するのに時間がかかります。それはパイプを使った場合の例から明らかでしょう。ちなみにですが関数の中身を追っていかないと全体が把握できないようなコードは、そもそも関数の書き方が間違っています。関数を書くのにも技術力は必要です。正しい関数を書けるように訓練してください。

先程のマーチン・ファウラー氏のブログ(の翻訳)からの引用です。

しかし私にとってもっとも理にかなっていると思われたのは、意図と実装の分離というものだ。もしも、なにをしているのか理解するのに努力しなければならないコードの一部分があったとしたら、そこを関数として切り出し、その「なに」にちなんだ名前を付けてしまうのである。

こうすれば、もう一度そのコードを読んだときに、そのコードの目的はすぐさま目に飛び込んでくるだろうし、その関数がやろうとしていること、すなわち関数本体に注意を払う必要はなくなるだろう

上記のコードは「$QUERY_STRING から get_keywords <<<」して「escape_html」して「A タグに整形」して「output "text/html"」しているだけです。何をやっているか把握できたと思います。この文章に実装の詳細(それをどうやって実現したか)が含まれてないことに気づいたでしょうか?なにをやっているかを把握するのに実装の詳細を知る(読む)必要はありません。まず知りたいのは「何をやっているか」であって「どうやって実現したか」ではありません。パイプでつないだコードではいきなりどうやって実現したかという実現方法を読ませられてしまいます。

このように一連の実装を関数(言語によってはクラスなども含む)にして適切な名前をつけることを「抽象化」と言います。具体的な実装を隠蔽し抽象的な名前で表現するからです。具体的な実装内容はまずはどうでも良いことです。それは必要になった時に読めば良いものなので最初は読み飛ばします。関数がなければコードの読み飛ばしができないので読むのが大変=可読性が低いということになります。関数は再利用性などよりも抽象化の方が重要です。だから一箇所でしか使わないような場合でも可読性を上げるために関数にします。

前よりコードが長くなっていると反論があるかもしれませんが読むのは関数単位です。コードのメトリクス(複雑度)の計測をしたことが有る人ならわかると思いますが計測は関数単位です。そして繰り返しますが(読む必要がない時は)関数の中身は読み飛ばして構いません。というか、いちいち何行も読んでられません。コードは、ぱっぱっぱで読めなければいけません。私は関数の名前(とコメント)だけで何をやっているかを把握します。実装の詳細を読むのはそれが必要になったときだけです。

さてここで escape_html は汎用関数です。なのでライブラリファイルに移動することでこのファイルから消し去ることが出来ます。このプロジェクトの性質から output も汎用関数にして良いでしょう。キーワードの取得も指定したキーを取得する汎用関数にすると良さそうです。そうするとこのスクリプトはもっと短いものなるでしょう。そこまで内容を減らせればこのスクリプトの中に関数を定義しなくても良くなるかもしれません。そうやって関数をライブラリとして別ファイルに分離するとこのスクリプトは上から下へと単純に処理が流れるだけの短くて単純なシェルスクリプトになります

コーディングスタイル

パイプでつなぐこととコーディングスタイルは直接関係ない話であるはずですが、どうにもパイプでつなぐ前提のスタイルに見えるため、関連の話として指摘することにしました。

その前に念の為ですがコードの書き方に唯一の正解というのものはありません。ただし(みんなそうしているから自分もそうするとかではなく)この方が読みやすいという理屈と信念を持って書くことが大切です。それが難しかったり他の人の書き方と意見のすり合わせができないのであれば既存のコードフォーマッター(shfmt など)を使うのが良いでしょう。既存のコードフォーマッターのスタイルが必ずしも最善だとは思いませんが多くの意見が取り入れられた受け入れられる妥協案であり、採用することでスタイルに関する無駄な意見の衝突も減りツールで自動的にコードを整形してくれるので書くのが楽になります。社内の力関係で偉そうな人が考えた良いと思えないオレオレスタイルを押し付けられるよりも第三者が提案するスタイルでみんな妥協する方が精神的にもマシでしょう?

継続行の行頭と末尾の | を揃えるスタイルはやめよう!

前述の通りスタイルは好みなので絶対こうすべきというものはありませんが、継続行の行頭と末尾の | を揃えて次の行を行頭から書くスタイルは読みづらいというのが私の結論です。

nkf -w16B0                          |
xxd -plain                          |
tr -d '\n'                          |

↑こういう書き方ですね。こうやって行頭と | を揃えるのはコメントを入れるためだろうと思っていて、この書き方であれば | の後ろに\ を書かなくても次の行が継続行になるし、コメントを書くことができるのでそこまで悪くはないとは思いましたが、やっぱり読んでいて継続行なのか気づかないないことが多いです。コメントの位置を揃えるための | までの長い空白があるのも要因です(視線移動が大きくてぱっと見た時に見えない)。あと単に揃えるのが面倒くさいです。

nkf -w16B0                          | # UTF16への変換

このように行末にコメントを入れられるというメリットはありますが、そもそも一行ごとにコメントを入れること自体があまりありません。殆どの場合コメントは関数レベルで十分です。行ごとのコメントはただコードの説明になりがちで一般的に不要なコメントであることが多いです。コメントはコードが読めない人へのコードの説明文ではありません。コードに書いてないことを書くのがコメントです。

コメント入れるのであればこの程度が適切です。追加で入力や出力の仕様を書くのも良いでしょう。

# 行ごとに HTML エスケープを行う(ASCII 文字も含めて全ての文字を実体参照に変換する)
escape_html() {
  nkf -w16B0 | xxd -plain | tr -d '\n' \
    | sed 's/..../\&#x&;/g; s/\&#x000a;/\n/g'
}

もしnkf -w16B0 のオプションの意味が分かりづらいからとコメントを書きたくなったら、それは関数にすべきというサインと考えることができます。

# UTF16 への変換
to_utf16() { nkf -w16B0; }

長いシェルスクリプトでよく見かける「ここから設定処理」や「ここからメイン処理」みたいなコメントも、それを関数にすべきというサインです。コードの可読性が高ければコメントは自ずと少なくなっていきます。

話を戻しますと、行末に | を書く書き方を踏まえるのであれば、せめて以下のようにすべきでしょう。

nkf -w16B0 |
    xxd -plain |
    tr -d '\n' |

読みやすさの他に、このようにする理由の一つは shfmt でこのように整形(しかもデフォルト)することができるからです。継続行の行頭と | を揃えるスタイルは shfmt では対応しておらず、したがって世界的にマイナーなスタイルであると言えます。

私は Google のスタイル を踏まえており(というか同じだっただけ)行末に \ に書き継続行の最初に | を持ってくるスタイルです。これだと行を見た瞬間に前のコマンドにパイプでつなげていることがわかります。ただし一行一コマンドというわけではありません。横の長さが 100 文字に前後に収まっているのであれば、縦の行数が短い方が「形を揃える」よりもコードを把握しやすいと思っています。なぜなら視線移動やスクロールが減るからです(小説は縦書きより横書きの方が読みやすいでしょう?)。この書き方(リファクタリング後のコード)は -bn オプションが必要になるものの shfmt の整形と矛盾しません。(一つ言い訳をすると ShellSpec は shfmt を知る前に書き始めたので shfmt と矛盾する箇所があります。いずれ修正します。)

コーディングスタイルは好みの範疇であることは否めませんが、継続行の行頭と行末の | を揃える書き方が唯一の正しいスタイルであると広まって欲しくはないので読みづらい書き方であると指摘しておきます。このような指摘をちゃんとしてないと深く考えずに真似してしまう人が増えてしまうので。私のスタイルは読みやすいとする根拠(視線移動やスクロールが少ない)があります。

といろいろ書きましたが、そんなことよりさっさと shfmt を導入して整形作業は自動化しましょう!

リファクタリング手法

このリファクタリング手法はシェル芸を使った「パイプ地獄」から可読性重視のシェルスクリプトへ書き換えるという観点で書いています。場合によってはこの逆変換をした方が良い場合もあります。たとえば関数にしてあったけれども、そこまでする必要がなければパイプラインに組み込むというのもリファクタリングです。どちらがいいかは一概に言えるものではないので、状況に応じて適切であると考える書き方をしてください。

長いパイプラインは関数にせよ!

リファクタリング後のコードを見てもらえばわかると思いますが、パイプラインの一連のコマンド(またはシェル関数)は、単純にシェル関数に抽出することができます。

例えば以下のの真ん中のブロックを関数にするのであれば、その部分を関数に移動するだけです。

sed 's/%2C/\n/g' <<< ${QUERY_STRING}     |
nkf --url-input                     |
sed -e '1s/keywords=//' -e 's/^[  ]*//' -e 's/[  ]*$//' |

nkf -w16B0                          |
xxd -plain                          |
tr -d '\n'                          |
sed 's/..../\&#x&;/g'               |
sed 's/\&#x000a;/\n/g'              |

awk '{print "<a href=\"/key.cgi?key="$1 "\">" $1 "</a>" }'	|
sed '1iContent-Type: text/html\n'
escape_html() {
  nkf -w16B0                          |
  xxd -plain                          |
  tr -d '\n'                          |
  sed 's/..../\&#x&;/g'               |
  sed 's/\&#x000a;/\n/g'              
}

sed 's/%2C/\n/g' <<< ${QUERY_STRING}     |
nkf --url-input                     |
sed -e '1s/keywords=//' -e 's/^[  ]*//' -e 's/[  ]*$//' |
escape_html |
awk '{print "<a href=\"/key.cgi?key="$1 "\">" $1 "</a>" }'	|
sed '1iContent-Type: text/html\n'

最後の | は関数の中に入れないので気をつけてください。必要ならば位置パラメータの一部、または全てを抽出したシェル関数に渡すこともできます。

複数のコマンドの関数への抽出は簡単かつ安全に行えるリファクタリングですが、案外気づきにくいのではないのかなと思っています。

長いパイプラインをグループにせよ!

関数にするまでもないなと思う場合は {} でグループにすることができます。必ずサブシェルになるという違いがありますが代わりに () を使うことも出来ます。ただしパイプでつないでいるので一部の例外を除きどちらにしろサブシェルになります。

前項の内容をシェル関数ではなくグループにする場合はこのようになります。

sed 's/%2C/\n/g' <<< ${QUERY_STRING}     |
nkf --url-input                     |
sed -e '1s/keywords=//' -e 's/^[  ]*//' -e 's/[  ]*$//' | {
  # HTML エスケープを行う
  nkf -w16B0                          |
  xxd -plain                          |
  tr -d '\n'                          |
  sed 's/..../\&#x&;/g'               |
  sed 's/\&#x000a;/\n/g'
} |
awk '{print "<a href=\"/key.cgi?key="$1 "\">" $1 "</a>" }'	|
sed '1iContent-Type: text/html\n'

この書き方によって、どの範囲が何をしているか明確になり、コメントも入れやすくなります。

リファクタリング後のコードでは以下のように awk の部分に使用しました。

get_keywords <<< ${QUERY_STRING} | escape_html | {
  awk '{print "<a href=\"/key.cgi?key="$1 "\">" $1 "</a>" }'
} | output "text/html"

これはさらに awk を使わずに以下のように書き直すことが出来ます。

get_keywords <<< ${QUERY_STRING} | escape_html | {
  while IFS= read -r url; do
    printf '<a href="/key.cgi?key=%s">%s</a>\n' "$url" "$url"
  done
} | output "text/html"

これは必ずしもこう書き直したほうが良いと言っているわけではなく、このような形に書き直すことが可能であるという事実を述べているだけです。シェルスクリプトに不慣れであると、このような書き方ができるということに気づきにくいと思います。まあ一応書き直したほうがいい理由もあって、件数が少ない場合 awk を呼び出さない方が速いというのと、ある言語の中に別の言語を混ぜる(今回の場合シェルスクリプトに awk を混ぜている)とエスケープで読みづらくなるのでなるべく避けたほうが良いということです。

この書き方のもう一つのメリットは、ループの外に変数を持ち出せるということです。

get_keywords <<< ${QUERY_STRING} | escape_html | {
  i=0
  while IFS= read -r url; do
    i=$((i + 1))
    printf '<a href="/key.cgi?key=%s">%s</a>\n' "$url" "$url"
  done
  echo "$i" # ここ
} | output "text/html"

# ただしここでは i は参照できない

上記のコードは期待通りに動きます。実は変数をループの外に持ち出せないという話は、正しくはサブシェルの外に変数を持ち出せないということで、サブシェル = ループの場合はループの外に持ち出せないということになりますが、上記の場合はパイプで直接つないでいる {} がサブシェルの開始点なので、その中にある変数はループを超えて参照することが出来ます。ちなみに {} でグループにする代わりに関数にした場合も同様です。

小さなコマンドは結合して一つにせよ!

「パフォーマンスが低い」の所で解説しましたが、ボトルネックとならない小さなコマンドをいくつもパイプでつなげても何もメリットが有りません。関係ない内容まで無理やりまとめる必要はありませんが、分ける必要がないならつなげてしまいましょう。

例えば tr コマンドは sed コマンドに置き換えられる場合があります。複数の sed は一つにまとめられます.

sed 's/..../\&#x&;/g' | sed 's/\&#x000a;/\n/g'
          ↓
sed 's/..../\&#x&;/g; s/\&#x000a;/\n/g'
          または
sed -e 's/..../\&#x&;/g' -e 's/\&#x000a;/\n/g'

そして awktrsedgrep が合わさった万能コマンドです。複数の連続する文字列処理コマンドは、基本的に一つの awk コマンドに置き換えられるはずです。

grep 'foo' | grep 'bar'
          ↓
awk '/foo/ && /bar/'

# grep + sed
awk '/foo/{ gsub(/foo/, "bar"); print }'

今回のリファクタリングでは、エスケープ周りの実装するには面倒な処理があったので関数の中まで置き換えることはしませんでしたが、その気になれば awk スクリプト一つに置き換えることができるでしょう。

また awk を使うしかないというのはただの思いこみです。Perl や Python や Ruby などに置き換えてもいいですし、速度が気なるなら C 言語や Go や Rust に置き換えてもいいでしょう。選択肢となるコマンドや言語はいくらでもあります。こういう場合でも関数にリファクタリングしていれば、その中身(実装)を後から別のものに置き換えることが簡単にできるようになります。関数にすることで修正に強くなったということです。

関数の実装は後から別のものに置き換えることが簡単であるという性質は、シェルスクリプトの互換性(移植性)の低さを解決する鍵でもあります。例えば直接 curl コマンドを使う代わりに関数に抽象化しておけば、curl がインストールできない環境に対応するために wget コマンドに置き換えることも簡単にできます。実装の置き換えは後から簡単にできることなので、最初からいろんな場合に対処しておく必要はありません。関数にすることは将来の移植可能性を高めながら開発コストを低減させるテクニックの一つです。

パラメータ置換を活用せよ!

最近思っているにはみんな sedawk を使いすぎではないのか?ということです。複数行の文字列を一括して変換するのであれば、それらのコマンドに任せたほうが速いし良いと思いますが、変数に入っている一行の文字列を加工する時にまで sedawk を使う必要はありません。この記事の例では使いませんでしたしさらっと流すだけにしますが、もっとパラメータ展開を活用しましょう。

VAR=$(echo "$VAR" | sed 's/foo/bar/g')VAR=${VAR//foo/bar} # bash 拡張ですが

短い文字列の編集はパラメータ置換の方が適切な場合が多いと思います。これはこれで記号を使うので覚えにくいというのはありますが、分かりやすい関数にすることもできます。とはいえいちいち関数を作るのは面倒なのでそういう関数ライブラリが必要だろうなとは思っています。

まとめ

  • 関数を使うと処理を理解するまでの時間が短くなる
  • 関数を使うとコードの読み飛ばしができるようになる
  • 関数を使うと仕様の変更に対処しやすくなる
  • 関数を使うとコードを上から下へ読み進めることができるようになる
  • 関数を使うとコードが減り再利用もできる
  • 関数を使うと互換性や移植性が高くなる
  • 関数を使うとテストしやすくなる

なんか関数を使うことの重要性を語るまとめになってしまいましたが、これは可読性やメンテナンス性を上げるために重要なのが関数(抽象化)で、その対極にあるのがパイプを多用した可読性の低いシェル芸なのだからだと思います。もちろん関数を使用するだけで問題が解決するわけではありません。新しいツールを導入してもやり方を変えない限り何も変わらないように、関数もただ使うだけではだめで正しい使い方を学ぶ必要があります。使うだけならともかく、正しく使えるようになるには時間がかかるかもしれませんが、関数を使わないことになにも始まりません。普段から関数を使いどのような使い方が正しいか、間違った時に何が間違いだったのかを考える必要があります。関数はどの言語でも使われており世界中で認められた技術なので、いまさらそれが間違った技術である可能性はありません。決して自分の力不足を関数のせいにはしないように。

シェルスクリプトは特徴的な言語ですが、何もかも他の言語と全く違うというわけではありません。シェルスクリプトもプログラミング言語の一つであり、他のプログラミング言の話はシェルスクリプトにも当てはまります。「シェルスクリプトは(他の言語と違って)こういう風に書くものだ」という誰かが言った(?)主張を真に受けないでください。それを言った人はどうせシェルスクリプトに詳しい人ではないでしょう。シェルスクリプトの可読性が悪くなる原因の一つはコマンドをパイプで長くつなぎすぎた「パイプ地獄」です。これは適切に抽象化(関数を作ること)をすることで可読性を高めることができ、テスト可能性やメンテナンス性や移植性などの問題を解決するのも容易になります。

念の為ですがシェル芸をやること自体は否定していません。ああいうのを短時間でさらっと書けるのであれば、それは凄いことだと思いますしシェルスクリプトを書く上でも良い訓練になると思います。否定しているのはシェル芸で書いたようなコードをシェルスクリプトとして使うことです。

おまけ シェルスクリプトで"大きな"ソフトウェアを開発するということ

私はシェルスクリプトが好きですが、シェルスクリプトで書かれた中途半端なコードを見るたびに、だから「シェルスクリプトで書くなと言われてしまうんだよ」と思ってしまいます。えぇ、そうです。問題なく動作すると安心できるコードをシェルスクリプトで書けないようなら素直に他のプログラミング言語を使ってください。シェルスクリプトでの中〜大規模ソフトウェアの開発は、いわゆる「素人にはお薦め出来ない」というやつです。まあ本当の素人であれば言語に関係なくソフトウェア開発自体に参加してはいけませんが。(将来的にもソフトウェア開発をするなという意味ではなく、まずはプログラミングの基礎を勉強してから参加してねという意味です。)

シェルスクリプトで信頼性とメンテナンス性が高い大きなソフトウェアを作るのはとても大変です。なぜなら十分にテストされて実績のある便利なライブラリやコマンドが少なうので、それらを自分で作ってメンテナンスし続けないといけないからです。自分で信頼性の高いライブラリやコマンドを作るのはとても時間がかかる難しくて大変な作業です(実際にそういうライブラリやコマンドを開発している有名なプロジェクトを見てみてください。開発の大変さがわかると思います)。作ってみて動いているっぽいから OK では出来たことにはなりません。コードを修正するたび実際に様々な環境でテストしなければいけません。勉強のために作るというのなら構いませんが、業務で使うものであれば自分で作ろうとせずにすでに世の中で使われている実績があるものを使ってください。そうするとシェルスクリプトの世界にはそのようなものが少なく自然と大きなソフトウェアはシェルスクリプトで開発すべきではないという結論になるでしょう。シェルスクリプトが簡単で生産性が高いと思うのは最初だけです。やりたいことが多くなるたび、シェルスクリプトの機能不足や限界に悩まされます。だから多くの人はシェルスクリプトが少し大きくなった程度で他の言語を使おうと言い出すのです。

私はそんな状況を改善してシェルスクリプトで大規模とまではいわなくともそれなりの規模のソフトウェアを作れるようにしたいと思っています。そのためには様々な問題を解決しなければいけません。現状シェルスクリプトの世界は問題だらけで、大きなソフトウェアを気軽に作れる状態ではありません。せいぜい使えるかもしれない小さなツールがいくつか有るぐらいで、その問題を本当に解決している人は誰もいません。だから今はまだシェルスクリプトで中〜大規模のソフトウェアを開発するのはお勧めしません。

それにそもそもですよ?例えシェルスクリプトで大きなソフトウェアを開発できるとしても、シェルスクリプトで開発すべき理由や必要性なんかないでしょう?ShellSpec の開発でよくわかりましたが、シェルスクリプトで大きなソフトウェアを開発するのはとても大変で開発コストがかかります。頼りにできる(?)POSIX は高い互換性と生産性を両立させるために全くと言っていいほど役に立ちませんでした。シェルスクリプトによる大きなソフトウェアの開発はシェルスクリプトで開発する理由が「ある!」と言える人だけがやれば良いことです。

50
33
2

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
50
33

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?