LoginSignup
34
48

More than 1 year has passed since last update.

シェルスクリプトの互換性と生産性の問題を解決する高度なプログラミング技術 〜1万行のコードをメンテしつづけるのに必要なもの〜

Last updated at Posted at 2021-07-18

シェルスクリプトで高い移植性と生産性を両立させるシリーズ

タイトル
第一弾 なぜシェルスクリプトはPOSIXに準拠しても環境依存が激しいのか?
第二弾 高い移植性と生産性を両立するソフトウェアを書くのに必要な知識と考え方
第三弾 中〜大規模シェルスクリプトのためのメンテナンス性の高いディレクトリ構造
【第四弾】 シェルスクリプトの互換性と生産性の問題を解決する高度なプログラミング技術
第五弾 (タイトル未定)

はじめに

macOS で開発したシェルスクリプトが本番環境の Linux で動かなかったという経験はないでしょうか?シリーズの第一弾で POSIX の問題点について解説しましたが根本的な問題は POSIX コマンドに互換性がないからです。この問題は GNU Coreutils をインストールすることで解決することができますが、環境によっては必ずしもインストールできるとは限りません。そういう場合は自分の手で互換性問題を解決する必要があります。また他の人が書いたシェルスクリプトがメンテナンスできなくて困ったことはないでしょうか?それはある程度の大きさにも関わらずメンテナンス性を考慮した書き方をしていないからです。ソフトウェアは規模に応じた適切な書き方があります。ある程度の大きさのシェルスクリプトを使い捨てシェルスクリプトの書き方(ワンライナー多用で関数を使わないような書き方)の延長で書かないようにして下さい。それではすぐにメンテナンスができなくなります。

シェルスクリプトの互換性問題を解決したりメンテナンス性・生産性を向上させるにはシェルスクリプトに関する深い知識が必要となります。しかしシェルスクリプトは補助的に使われることが多いためか高度なプログラミング技術が紹介されることはあまりありません。よく誤解されていますがシェルスクリプトは最初からプログラミング言語として開発された言語です。コマンド実行が簡単に記述できるというだけでなくコマンドの互換性問題を吸収する機能を持ったプログラミング言語なのです。コマンドの羅列に簡易的な制御機能が追加してなんとなくできた言語ではなく、よく設計されて開発された言語です。この記事ではシェルスクリプト入門記事では解説されないような高度なプログラミング技術を紹介しています。

この記事の第一章では、シェルスクリプト全般のプログラミング技術を、第二章では互換性問題を解決するための技術(主に第一章の応用)を紹介しています。また前提としてメンテナンス性向上のためにソフトウェア全体を小さなファイルに分割することを推奨しています。そのためのプロジェクトのディレクトリ構造については第三弾「中~大規模シェルスクリプトのためのメンテナンス性の高いディレクトリ構造」を参照して下さい(本来はこの記事に入れる予定でしたが長くなったため分離しました)。

この記事の対象

この記事の内容は特定のシステムで動けば良いような使い捨てのシェルスクリプトは対象としていません。100 行程度の短いコードで寿命も短いシェルスクリプトはそもそも高い移植性や保守性を考える必要はありません。シェルスクリプトは使い捨てスクリプトが多いので、そういう場合にはこの記事の内容はあまり使えないかもしれません。

シェルスクリプトで高い移植性と保守性が必要な場合が思いつかないという人がいると思うので説明しておくと「シェルスクリプトで実装するのが適している汎用的でいろんな環境で動かすソフトウェア」です。シェルスクリプトは他の言語よりもコマンドの実行やそれらを組み合わせることに優れた言語なので、そのようなことを多用するソフトウェアを作るならばシェルスクリプトで実装するのが適しています。

補足ですが、この記事で紹介している技術は主にシェルスクリプト用のテストフレームワークである ShellSpec で使用している技術です。ShellSpec はすべての POSIX シェルに対応しておりこの記事の内容も原則として POSIX シェルを対象としています。(一部を除いて)bash などの拡張機能を前提としてないので、最低限の機能しか持たない POSIX シェル(例 dash)でも使うことができます。ただし古い Bourne シェルは POSIX シェルと互換性がないため対象外です。

第一章 高度なシェルスクリプトプログラミング技術

シェル関数

シェルスクリプトの他の言語にない大きな特徴はシェル関数とコマンドが交換可能であるという点です。シェル関数をコマンドに置き換えることが可能ですし、コマンドをシェル関数で実装することも可能です。どちらで実装したとしてもそれを使う側のコードは同じように書くことが出来ます。つまりシェルスクリプトからコマンドを実行するのとシェル関数を実行するのは本質的に同じことです。この交換可能性によってシェルスクリプトはコマンドをシェル関数を使って自然な形で拡張することが可能となっています。シェル関数を使いこなすことがシェルスクリプト外部の問題(つまり POSIX コマンドの互換性問題)を解決する大きな鍵です。

シェル関数の引数

シェル関数はコマンドと交換可能ですがコマンドと同じ設計が適切であるとは限りません。(コマンドとの互換性が必要ない場合は)シェル関数はコマンドよりももっと小さな処理を行うようにすべきです。一般にコマンドは複雑です。多くのハイフンで始まるオプションを持っており一つのコマンドで多くの処理を行います。しかしこのオプションの解析は手間がかる処理です。小さな処理だけを行うようにすればシェル関数にはコマンドのようなハイフンで始まるオプションは不要になります。

オプションは大きく「フラグ引数」と「オプション引数」の二種類があります。「フラグ引数」というのは、例えば base64 コマンドの --decode オプションのようなものです。base64 のエンコードとデコードはアルゴリズムが全く異なります。このような場合にオプションを使うと関数内部で処理を分岐させることになり関数を複雑にしてしまうだけです。コマンドの場合はコマンドをむやみに増やすのも困るのでオプションでもよいのですが、フラグ引数はアンチパターンであることが多いため、関数の場合は base64encodebase64decode のようにそれぞれ別の関数にすることでフラグ引数のオプションをなくすことができます。「オプション引数」とは head -n 5 のようなものです。「省略したときは 10 だがオプションによってそれを上書きすることが出来る」というようなものです。この場合は処理する値が変わるだけで基本的に分岐が増えるわけではないのでアンチパターンにはなりません。オプション引数は省略可能な引数として扱うことが出来ます。例えば head コマンドの簡易版(first 関数)をシェルスクリプトで実装した場合は以下のようになります。first 関数の引数を省略した場合はデフォルト値 10 となり指定した場合はその値で処理を実行します。

first() {
  i=0
  while [ "$i" -lt "${1-10}" ] && IFS= read -r line; do
    printf '%s\n' "$line"
    i=$((i + 1))
  done
}
seq 10 | first 5

繰り返しますがオプションが必要になるのは処理が複雑だからです。シンプルな処理だけを行うようにすれば多くの場合オプションは不要になります。すなわちシェル関数は固定または可変の引数だけを扱えば十分であるということです。またオプション解析が不要ということはシェル関数では getoptsgetopt を使う必要もありません。

引数省略時のデフォルト値

関数の引数を省略した場合のデフォルト値をどのように書くかという話です。「シェル関数の引数」で例としてあげた first 関数ではコードの途中に引数が省略された時の値として "${1-10}" という形でデフォルト値を書きました。この例では $1 は一回しか参照しておらずコードも十分短いので問題ありませんが、参照する場所が複数ある場合にその都度 "${1-10}" を書くのは面倒です。この問題は関数の冒頭で set を使用することで一箇所にまとめることが出来ます。これにより引数が省略されているかどうかを気にせずに処理を記述することが可能になります。

first() {
  set -- "${1-10}"
  i=0
  while [ "$i" -lt "$1" ] && IFS=  read -r line; do
    printf '%s\n' "$line"
    i=$((i + 1))
  done
}

フラグ引数の指定方法

どうしても「フラグ引数」が必要な場合は一つの引数で複数のフラグを扱うようにします。例えば正規表現による検索では大文字小文字を無視する i やグローバル検索の g といったフラグがありますが、それをシェル関数で指定するのであれば search "regexp" "ig" のように指定します。これにより複雑なオプション解析をすることなく複数のフラグを扱うことが出来ます。

search() {
  ...
  case $2 in (*i*)
    # i が指定された場合の処理
  esac
  case $2 in (*g*)
    # g が指定された場合の処理
  esac
  ...
}

ig のように 1 文字では短すぎて分かりづらいと思うならば , 区切り(もちろん区切り文字を何にするかは自由です)で search "regexp" "ignore,global" のように指定することも出来ます。この場合の実装コードは次のようになります。

search() {
  ...
  case ",$2," in (*,ignore,*)
    # ignore が指定された場合の処理
  esac
  case ",$2," in (*,global,*)
    # global が指定された場合の処理
  esac
  ...
}

パイプラインの各コマンドの関数への抽出

多数のコマンドをパイプでつなげすぎるとコードの可読性が下がってしまうことがあります。もちろん十分短ければそのままでもいいのですが、長くて複数行になったり一行ごとにコメントを書かなければ何をやってるかわからないような状況になったら危険信号です。個人的にはパイプで直接つなげるコマンドの数は 3 ~ 5 個程度までとし、それ以上の数をつなげたいのなら意味がある単位で小さな関数にわけるべきだと考えています。また一つのコマンドであってもオプションや引数が長かったり見慣れないオプションを使う場合もわかり易い名前の関数にすることで可読性を上げることができます。

リファクタリングの一つに「メソッドの抽出」というものがありますが、それと同じようにパイプラインのコマンドは簡単に関数に抽出することができます。簡単な例を紹介します。

cat data.ini | awk 'long code...' | grep --extended-regexp '^SECTION\.(KEY1|KEY2|KEY3)=' | sed --regexp-extended 's/^[^=]+=//' | tr 'a-z' 'A-Z'

上記のようなパイプでつないだ各コマンドは以下のような複数の小さな関数にすることができます。

ini_to_key_value_list() {
  awk 'long code...'
}

fetch_value() {
  grep --extended-regexp "^$1\.($2)=" | sed --regexp-extended 's/^[^=]+=//'
}

toupper() {
  tr 'a-z' 'A-Z'
}

cat data.init | ini_to_key_value_list | fetch_value "SECTION" "KEY1|KEY2|KEY3" | toupper

関数の抽出は各コマンドをそのまま関数にするだけです。オプションは関数内に隠蔽することができ処理に対して適切な名前をつけることが出来ます。また複数のコマンドを一つの関数にまとめることも出来ます。fetch_value を参照してください。grep コマンドと sed コマンドをまとめて一つの関数にしています。このように意味がある単位で複数のコマンドをまとめて関数にすることが出来ます。

関数にすることのもう一つのメリットはテストがしやすくなるということです。単純にパイプでつなげてしまうと、パイプラインの途中のコマンドでバグがあったりしたときにデバッグがしづらくなってしまいます。関数にするとその単位で実行・テストが可能になり、後から内部の実装の修正することも簡単になります。これを利用するとシェルスクリプトで書いたコードを段階的に他の言語に置き換えていくことも可能になります。

使い捨てスクリプトではコードを短くしようと関数を使わずにパイプでつなげて書こうとしがちですが、保守性が必要となるような寿命が長いソフトウェアの場合はそれとは異なる設計が必要です。小さな関数を作りテスト容易性を高めながら作ることが重要です。小さな関数となっていれば後からコードを読む人にとってもそれぞれで何をしているかを小さい単位で把握することが出来るのでメンテナンス性も高くなります。

長いパイプラインは小さな関数に簡単にリファクタリングできるということを知っていると、他の人が書いた長いコードをメンテナンスするのに役に立つでしょう。

関数の動的定義

シェルスクリプトはスクリプト言語らしく関数は動的に定義されます。例えば以下のような条件に応じて別々の関数を定義するコードは正しく動作します。

DEBUG_MODE=1

# デバッグモードのみログ出力を行う
if [ "$DEBUG_MODE" ]; then
  log() { echo "$@" > /dev/tty; }
else
  log() { :; } # 何もしない
fi

total=0
for i in $(seq 10); do
  total=$((total + i))
  log "[total] $total"
done
echo "$total"

上記の処理はデバッグモード(DEBUG_MODE 変数に値がある場合)の場合はログ出力を行いますが、そうでない場合は何も出力しません。この方法を使うと log 関数を使う場所でいちいちDEBUG_MODE 変数のチェックを行う必要がなくなります。Null オブジェクトパターンの関数版といえるでしょう。

またシェル関数は新たに再定義することができたり unset -f で削除することも出来ます。ちなみに awk ではこのようなことは出来ません。文字列処理が速いのは awk ですがスクリプト言語として柔軟なことが出来るのはシェルスクリプトです。

コマンドの関数による拡張

コマンド(外部コマンド or ビルトインコマンド)はシェル関数で再定義することができます。別の処理に置き換えるだけでは混乱するだけであまり意味がありませんが、置き換えたシェル関数から元のコマンドを呼び出す事ができるとしたらどうでしょうか?つまりシェル関数でコマンドの処理を拡張することができるのです。他の言語で言えば関数呼び出しのフックや Python のデコレータに相当することができます。

例えば特定のコマンドの呼び出しの前後にログ出力を行いたい場合は次のようなコードになります。

tr() {
  echo "[before] tr $@"
  command tr "$@"
  ret=$?
  echo "[after] tr (exit status: $ret)"
  return "$ret"
}

echo "abc" | tr b B

command コマンドはシェル関数の探索をバイパスして呼び出すためのコマンドです。これによりシェル関数を再帰呼び出しすることなくシェル関数から元のコマンドを呼び出すことができます。これを利用することで外部コマンドの互換性問題を解決することができます。

少し注意点があります。zsh では command コマンドはビルトインコマンドは呼び出されず必ず外部コマンドが呼び出されてしまいます。POSIX では規定されていませんが zsh を含む一部のシェルでは builtin コマンドを使うことで必ずビルトインコマンドを呼び出すことができます。また必ず外部コマンドを呼び出したい場合は command コマンドの代わりに env コマンドを使用すると良いでしょう。

出力引数(参照渡し)

関数から終了ステータス以外の値(文字列)を返す場合、一般的には標準出力に文字列を出力しコマンド置換を使って変数で受け取ります。しかしこの方法はサブシェルが必要となるためパフォーマンス低下の原因となります。それを回避するテクニックに引数で指定した名前の変数に戻り値を返す方法があります。これは read コマンドや getopts コマンドが使用しているのと同様の方法です。

# read コマンドは引数に指定した line 変数に値を返す
while IFS= read -r line; do
  echo "$line"
done

# getopts コマンドは引数に指定した OPT 変数に値を返す
while getopts f OPT; do
  case $OPT in
    f) FLAG=1
  esac
done

これと同じことを行いたい場合 eval を使用します(一部のシェルでは eval を使わないやり方もあります)。eval も多少パフォーマンスが低下するのですが、サブシェルよりはずっとマシです。

add() {
  eval "$1=\$((\$2 + \$3))"
}

# ret 変数に値を返す
add ret 1 2
echo "$ret" # => 3

POSIX シェルの範囲ではシェルの変数はグローバル変数しかありません。可能ならばグローバル変数は使いたくありませんが、このテクニックをうまく使うと関数内で使用するグローバル変数を減らしたり無くすことができます。戻り値を返す変数はワーク変数としても利用することができます。これによりグローバル変数を使用しないという制限を少し緩和することができます。

# 普通に書いた場合
sum() {
  work=0 # work 変数に引数の値を加算していく
  varname=$1
  shift
  while [ $# -gt 0 ]; do
    work=$((work + $1))
    shift
  done
  eval "$varname=\$work"
}

# 工夫すると work、varname 変数を使わずに書くことが出来る(ret 変数に加算している)
sum() {
  eval "$1=0; shift; while [ \$# -gt 0 ]; do $1=\$(($1 + \$1)); shift; done"
}

sum ret 1 2 3 4
echo "$ret" # => 10

この方法の欠点はコードの可読性が大幅に落ちる場合があることです。そのため前提として小さな関数を作ることが要求されます。また eval を使いますので脆弱性にならないように注意が必要です。戻り値を返す変数名はソースコードに直接書くので問題になることはありませんが、環境変数など外部から与えられる信頼できない値を使う場合には気をつける必要があります。

このテクニックの応用として、複数の値を返したり、入力引数(変数名を名前で与える)や入出力引数(入力と出力の両方に使う)というテクニックもあります。

補足ですが、ShellSpec ではグローバル変数をなるべく使用したくなかったのでこのテクニックを多用しています。ただしメンテナンス性は落ちるため、将来作ろうとしているシェル関数ライブラリではローカル変数の代わりとなる別の仕組みを作りたいと考えています。将来的に local コマンドが POSIX で規定される可能性があると考えているので、できれば容易に local コマンドを使ったコードに置き換えられるような形にしたいと思っています。いくつかアイデアはあるのですが再帰処理への対応などが必要なため具体的な実装はできていません。

高階関数(コールバック関数)

シェルスクリプトでは関数名を変数に入れて呼び出すことは簡単にできます。他の言語では特殊な手法が必要となったり eval が必要だったりしますが、シェルスクリプトでは普通に変数を使うだけです。

TR=tr
echo "abc" | "$TR" b B

これを利用するとシェルスクリプトで高階関数を実現することができます。例としてfor_each 関数を実装してみましょう。

for_each() {
  callback=$1
  shift
  while [ $# -gt 0 ]; do
    "$callback" "$1"
    shift
  done
}

# おまけ 作業変数(callback 変数)を使わない実装
for_each() {
  eval "while shift; [ \$# -gt 0 ]; do $1 \"\$1\"; done"
}

sum() {
  total=$((total + $1))
}

total=0
for_each sum 1 2 3 4 5 6 7 8 9 10
echo "$total" # => 55

高階関数を使うと値の列挙と、その値に対する処理を分離することで、値を列挙するコードを汎用化することができます。ShellSpec ではファイル探索を find コマンドを使わずにシェルスクリプトで実装していて、ファイルの探索とそのファイルに対する処理を分離するためにこのテクニックを利用しています。

デフォルト処理の再定義(オーバーライド)

フレームワークやプラグインといった仕組みを作る場合、読み込むモジュール(シェルスクリプトファイル)で関数が定義されていない場合はデフォルトの処理を行い、関数を定義することでデフォルトの処理を上書きできると便利です。シェルスクリプトでは関数の再定義ができるためこれを実現することができます。

例えばプラグインの仕組みを作る場合、このような感じとなるでしょう。

plugin_default_setup() {
  # デフォルト初期化処理
}

plugin_default_main() {
  # デフォルトメイン処理
}

plugin_default_teardown() {
  # デフォルト終了処理
}

load_plugin() {
  eval "$1_setup() { plugin_default_setup \"\$@\"; }"
  eval "$1_main() { plugin_default_main \"\$@\"; }"
  eval "$1_teardown() { plugin_default_teardown \"\$@\"; }"
  . "$1.sh"
}
load_plugin "myplugin" # プラグインモジュールの読み込み

myplugin.sh ファイルではデフォルトの処理を上書きしたい場合にのみ、myplugin_setupmyplugin_mainmyplugin_teardown 関数を定義すればよく、定義しなければデフォルトの処理が実行されます。また上書きした関数から独自の処理を追加してデフォルトの処理を呼び出すことができることもわかると思います。これによって拡張可能な仕組みを備えたシェルスクリプトを作ることができます。

位置パラメータ

map 処理(位置パラメータの加工)

戻り値を返してないので map 関数ではありませんが、例えば位置パラメータの全ての値を 10 倍したい場合は以下のよう書くことで実現できます。ぱっと見どうしてこれで動くのか分かりづらいと思いますが、簡単に説明するとパラメータを前から一つずつ取り出して計算を加えつつ後ろに追加するというのを繰り返して、すべてのパラメータを一周させています。

foo() {
  for i; do
    set -- "$@" $((i * 10))
    shift
  done
  echo "$@"
}
foo 1 2 3 4 5 # => 10 20 30 40 50

これを応用すると特定のパラメータだけを加工したり位置パラメータの途中に値を追加したり特定の値を削除したりといったこともできるようになります。

ローカル変数の代用

POSIX シェルにはローカル変数がありません。もっとも殆どのシェルでは拡張機能としてローカル変数が使えるので通常はそれを使用すればよいのですが ShellSpec のように完全に POSIX に準拠したい場合は困ります。そこで ShellSpec では一部の処理で位置パラメータをローカル変数の変わりに使用しています。位置パラメーターは厳密にはローカル変数ではありませんが関数毎に新たに作られるため再帰関数でも問題なく動きます。

例えば以下は現在の UNIX 時間を取得する関数です。一般的には date +%s でほとんどの環境で UNIX 時間を取得することが出来るのですが %s は Solaris(歴史的な UNIX ?)で使えず POSIX でも規定されていません。そのため通常の日付形式から計算しています。その際に年月日時分秒や途中の計算結果を保持するために変数を使う代わりに位置パラメータを使用しています。

unixtime() {
  IFS=" $IFS"
  set -- $(date -u +'%Y %m %d %H %M %S') "$1"
  # $1:年, $2:月, $3:日, $4:時, $5:分, $6:秒, $7:戻り値を返す変数名
  IFS=${IFS# }
  set -- "$1" "${2#0}" "${3#0}" "${4#0}" "${5#0}" "${6#0}" "$7"
  [ "$2" -lt 3 ] && set -- $(( $1-1 )) $(( $2+12 )) "$3" "$4" "$5" "$6" "$7"
  set -- $(( 365*$1 + $1/4 - ($1/100) + $1/400 )) "$2" "$3" "$4" "$5" "$6" "$7"
  set -- "$1" $(( (306 * ($2 + 1) / 10) - 428 )) "$3" "$4" "$5" "$6" "$7"
  set -- $(( ($1 + $2 + $3 - 719163) * 86400 + $4 * 3600 + $5 * 60 + $6 )) "$7"
  eval "$2=$1"
}
unixtime ut
echo "$ut"

このコードの欠点は見ての通り位置パラメータの多用でコードがわかりづらくなることです。そのためシェル関数を短くすることはとても重要なことなのです。そして最初は普通に変数を使いテストコードを書いてリファクタリングするという流れで作ると良いでしょう。

コマンドライン引数の組み立て(配列の代用)

POSIX シェルには配列がありませんが、配列を使いたいという場合が度々登場します。その中でもコマンド引数の組み立てで配列を使いたいという場合が一番多いのではないでしょうか?

TIMEOUT=2

get() {
  opts=()
  if [ "$TIMEOUT" ]; then
    opts+=(--connect-timeout "$TIMEOUT")
  fi
  curl "${opts[@]}" "$@"
}

get "http://example.com"

しかしコマンドライン引数を組み立てるときに配列は不要です。位置パラメータを使って同様のことを行うことができます。

TIMEOUT=2

get() {
  if [ "$TIMEOUT" ]; then
    set -- --connect-timeout "$TIMEOUT" "$@"
  fi
  curl "$@"
}

get "http://example.com"

位置パラメータは最後の値を参照するのに eval が必要だったり、途中の値や最後の値を取り除くのが面倒ですが、配列の代用として使うことが可能です。もっとも配列を多用するような処理をするのであれば他の言語を使うことを考慮したほうが良いと思います。

サブシェル

ローカルスコープ

local (typeset) コマンドが使えない状況でローカル変数相当のものを使いたい場合、最初の候補はサブシェルになるでしょう。サブシェルは多くのシェルで子プロセスとして実行されるので、サブシェル内で行った変更はサブシェルの外には影響がありません。つまり以下のように書くことで簡単にローカル変数相当のことを実現することができます。

value=123
(
  value=456
)
echo "$value" # => 123

# コマンド置換もサブシェル
foo() {
  value=456
  echo "$value"
}
echo "$(foo)" # => 456
echo "$value" # => 123

ローカルスコープになるのは変数だけではありません。別プロセスなのでディレクトリ移動、関数定義、シェルオプションの変更等もサブシェル内に閉じ込められます。例えばシェル関数を別のシェル関数で再定義し、元のシェル関数に戻したい場合にも使うことができます。

(
    foo() { echo "foo"; }
    foo # => foo
    (
        foo() { echo "FOO"; }
        foo # => FOO
    )
    foo # => foo
)

ShellSpec ではこれを利用しシェル関数ベースのモック機能を実現しています。一つ注意点として ksh93u+ では サブシェルの外で定義された関数をシェル関数で再定義することが出来ません。そのため上記のコードではコード全体を () で括っています。これはバグであり ksh93u+m では修正されています。

サブシェルはローカルスコープを作るのに便利ですが、サブシェルは遅いため何度も実行するとパフォーマンス低下の原因になるので注意が必要です。

致命的なエラーのトラップ

サブシェルは子プロセスで動くためサブシェル内でシェルを停止させるようなエラーが発生してもそれをトラップすることができます。例えばサブシェル内で exit を実行してもシェルスクリプト全体は停止しません

(exit)
echo "end" # 実行される

例えば ksh ではシェルのバージョンを取得するのに $KSH_VERSION または ${.sh.version} を参照しますが、後者は ksh 独自の文法であるため他のシェルでは致命的なエラーとなり zsh や yash では停止してしまいます。サブシェルを使うとこのような致命的なエラーでも止まらないようにすることができます。

( eval 'echo "${.sh.version}"' ) # サブシェルがないと zsh や yash ではここで停止してしまう
echo "$?" # => zsh では 1、yash では 2
echo "$end"

これを利用すると現在のシェルが拡張された文法に対応しているかどうかのテストを行ったりすることができます。

メタプログラミング

オブジェクト指向(クラスインスタンス)

オブジェクト指向プログラミングがしたいのであれば他の言語を使うべきですが、場合によってはコードをシンプルにする事ができます。例えば連想配列のクラスを作ると以下のようなコードを書くことができます。

Hash h1
h1 set key1 "value1"
h1 set key2 "value2"

h1 get ret "key2"
echo "$ret" # => value2

h1 keys ret
echo "$ret" # => key1 key2

h1 destroy

これを実現する Hash クラスは次のようなコードです。見ての通りインスタンス変数のアクセスなどで eval の嵐となることが多いのでおすすめはしません。(ただ今回の場合、クラスを使わずに連想配列を扱うコードを書いても同じように eval の嵐になると思いますが)

Hash() {
  eval "$1() { eval 'shift; hash_'\$1' $1 \"\$@\"'; }; HASH_$1=''"
}

hash_set() {
  eval "
    HASH_$1_$2=\$3
    case \" \$HASH_$1 \" in (*\\ $2\\ *) return 0; esac
    HASH_$1=\"\$HASH_$1\${HASH_$1:+ }$2\"
  "
}

hash_get() {
  eval "$2=\"\$HASH_$1_$3\""
}

hash_keys() {
  eval "$2=\"\$HASH_$1\""
}

hash_destroy() {
  IFS=" $IFS"
  eval "
    set -- \$HASH_$1
    while [ \$# -gt 0 ]; do unset HASH_$1_\$1; shift; done
    unset HASH_$1
    unset -f $1
  "
  IFS=${IFS# }
}

alias を使った文法拡張

alias コマンドはコマンドに別名をつけるためのコマンドです。一般的な使い方としてはそういう理解で良いのですが、実は C 言語のマクロのように文法を拡張することが可能です。参考として modernish プロジェクトで使われている拡張されたループ命令を紹介します。

LOOP repeat 3; DO
  putln "This line is repeated 3 times."
DONE

詳細な実装の解説は省略しますが、ここで登場している LOOPDODONE というのはエリアスで、それぞれ以下のような定義となっています。

alias LOOP='{ { { _Msh_loop'
alias DO="}; _Msh_loop_c && while _loop_E=0${_loop_Ln}; IFS= read -r _loop_i <&8 && eval \" \${_loop_i}\"; do { "
alias DONE='} 8<&-; done; } 8<&-; _Msh_loop_setE; }'

展開すると以下のようになり

{ { { _Msh_loop repeat 3; }; _Msh_loop_c && while _loop_E=0${_loop_Ln}; IFS= read -r _loop_i <&8 && eval \" \${_loop_i}\"; do {
  putln "This line is repeated 3 times."
} 8<&-; done; } 8<&-; _Msh_loop_setE; }

整形するとこうなります。

{
  {
    { _Msh_loop repeat 3; }
    _Msh_loop_c && while
      _loop_E=0${_loop_Ln}
      IFS= read -r _loop_i <&8 && eval \" \${_loop_i}\"
    do
      { putln "This line is repeated 3 times."; } 8<&-
    done
  } 8<&-
  _Msh_loop_setE
}

見てのとおり alias は別のコマンドに置き換えるだけでなく、別のコードに置き換えることが可能なのです。置き換えるコードは alias LOOP='{ { { _Msh_loop' のようなコードの断片であっても構いません。カッコの対応など全体で辻褄が合っていれば正しく動作します。

実は ShellSpec の初期のプロトタイプでは alias を利用して DSL を変換するという案がありました。残念ながら alias は posh で実装されていなかったり別の問題(シェルによっては $LINENO 変数で行番号を取得できない)があったりエイリアス展開の順番の問題でうまく動かすのが難しかったりで、トランスレーターを作ってソースコードを変換する方が柔軟なことが出来るという結論となったためこの案は採用しませんでしたが modernish プロジェクトの例からもわかるようにうまく使えば POSIX シェルの範囲だけでも文法を拡張することが可能です。

その他のテクニック

文法チェックの自動実行

シェルには文法チェック機能が標準で搭載されています。(POSIX でも規定されています)

$ sh -n script.sh

一般的にはコマンドラインから文法をチェックするという使い方をしますが、シェルスクリプトの冒頭に以下のように書くとコマンド実行時にチェックすることができます。

#!/bin/sh
set -eu
sh -n "$0"

# おまけ . コマンドの代わりにこの関数を使うことでチェックしてから読み込むことができる
load() {
  sh -n "$1"
  . "$1"
}

スクリプト実行時のオーバーヘッドとなるため最終的には取り除いたほうが良いと思いますが、開発時やデバッグモードとして使用するには便利かもしれません。また sh -n の代わりに shellcheckshfmt を使うことでより高度な文法チェックを行うこともできます。

第二章 互換性問題を解決するための技術

第一弾で POSIX コマンドは互換性が低いという話をしましたが、シェルスクリプトを使うとその互換性問題を吸収することが出来ます。コマンドの実行が得意なシェルスクリプトはコマンドの問題を解決するのも得意なのです。この章ではその具体的なテクニックを紹介します。

優先パスによる依存コマンドの置き換え

シェルスクリプトのコマンドは環境変数 PATH を検索して最初に見つかったコマンドを使用します。これを利用するとシェルスクリプトが使用するコマンドを任意のコマンドに変更することが可能になります。例えば macOS で Homebrew でインストールした GNU Coreutils のコマンドを使う方法の一つとして公式に紹介されているのが PATH 環境変数にコマンドのパスを追加する方法(PATH="$(brew --prefix)/opt/coreutils/libexec/gnubin:$PATH")です。これによりソフトウェアが GNU Coreutils に依存している場合でもソフトウェアを修正することなく動かすことが出来ます。

その他の利用例としては、システム標準でインストールされているコマンドが古いまたは新しくてシェルスクリプトで想定しているコマンドと互換性がない場合、適切なバージョンをインストールして PATH 環境変数で優先させることで、シェルスクリプトを適切に動かすことができます。またシェルスクリプトでは . (source) コマンドでシェルスクリプトライブラリを読み込むことができますが、これも PATH 環境変数を参照します。このことから PATH 環境変数というのは他の言語のライブラリの検索パス(Ruby でいう RUBYLIB)と同じ仕組みであると言えます。

ただし一般的には環境変数 PATH はプロファイル(.bash_profile 等)で行うべきものでソフトウェア内部で設定するのは推奨しません。ソフトウェア内部で(安易に)設定してしまうとユーザーが PATH 環境変数を設定して特定のコマンドが使われるようにしていても、それよりも優先されてしまいユーザーの選択の余地をなくしてしてしまうからです。もしソフトウェア内部で PATH 環境変数を設定したい場合はオプションや設定ファイル等で PATH 環境変数全体を設定可能にしておくと良いでしょう。

コマンド名の違いの吸収

macOS で Homebrew でインストールした GNU Coreutils のコマンドを使う方法は二通りあります。一つは前項の PATH 環境変数にコマンドのパスを追加する方法で、もう一つは頭にプリフィックス g をつけて呼び出す方法(例 date コマンドは gdate コマンド)です。ここでは後者の方法を使ってコマンド名の違いを吸収する方法を解説します。この方法では PATH 環境変数を変更せずに使えるというメリットがあります。

まずよく見かける(あまり良くない)コードを最初に紹介します。

DATE="date"
case $(uname) in (Darwin | FreeBSD)
  DATE="gdate"
esac

"$DATE" --iso-8601

使用するコマンド名を DATE 変数に入れて呼び出しています。この書き方の問題は、本来は date --iso-8601 と書いていたものを "$DATE" --iso-8601 と書き直さなければいけないところです。コードのあちこちでこのような書き方をしなければいけないので忘れてしまうことがありますしコードも見づらいです。よりよい方法はシェル関数を使ってコマンド名の違いを吸収する方法です。

case $(uname) in (Darwin | FreeBSD)
  date() { gdate "$@"; }
esac

date --iso-8601

この方法であれば、スクリプトの冒頭部分でコマンド名の違いを吸収することができ、メインのコードは今まで通りのコマンド名で書くことができます。複数のコマンドに対応する場合も簡単です。冒頭にこのような処理を入れておくだけでコマンド名の違いを吸収することができます。

case $(uname) in (Darwin | FreeBSD)
  for cmd in date sed tr awk; do
    eval "$cmd() { g${cmd} \"\$@\"; }"
  done
esac

コマンドのデフォルトオプション

コマンドのデフォルトのオプションを定義する場合に以下のようなデフォルトのオプションを変数に入れて使うコードを良く見かけます。

CURL_OPTS="--connect-timeout 5 --max-time 10"
curl $CURL_OPTS "http://example.com"

そこまで悪くはないのですが、やはり curl コマンドを使うすべての箇所でこのような書き方をするのは面倒です。これは以下のように書くとよりシンプルにすることができます。

curl() {
  command curl --connect-timeout 5 --max-time 10 "$@"
}
curl "http://example.com"

異なるコマンドの吸収(ラッパー関数)

インターネット上の URL からコンテンツを GET したい場合、curl または wget がよく使われます。しかしユーザーの環境によってはどちらかしかインストールされていない場合があります。そういう場合にユーザーの利便性のためインストールされている方のコマンドを使って処理をしたいと思うでしょう。

良くないやり方がプログラム全体にわたって curl コマンドや wget コマンドを直接使う方法です。

if type curl >/dev/null 2>&1; then
  UA="curl"
else
  UA="wget"
fi

if [ "$UA" = "curl" ]; then
  curl -X GET --some-options "https://example.com/endpoint/foo"
else
  wget --some-options "https://example.com/endpoint/foo" -O -
fi

... # いろいろな処理

if [ "$UA" = "curl" ]; then
  curl -X POST -d "$1" --some-options "https://example.com/endpoint/bar"
else
  wget --post-data="$1" --some-options "https://example.com/endpoint/bar" -O -
fi

こんなやり方では何かを修正したいときにコードのあちこちに手を入れる必要がでてくるので大変になります。また例えば curlwget も入っていないが perl は入っているという場合に対応するために後から Perl を使った実装を追加するのも大変になってしまいます。

一般的にプログラムの中で何かしらのコマンドを使う場合でも、そのコマンドの機能を全て使うことはまずありません。例えば今回の場合は、GET と POST の 2 つだけあれば十分です。であれば get 関数 と post 関数を作ってそれぞれのコマンドをラップしましょう。

if type curl >/dev/null 2>&1; then
  UA="curl"
else
  UA="wget"
fi

if [ "$UA" = "curl" ]; then
  get() { curl -X GET --some-options "$1"; }
  post() { curl -X POST -d "$2" --some-options "1"; }
else
  get() { wget --some-options "$1" -O -; }
  post() { wget --post-data="$2" --some-options "$1" -O -; }
fi

get "https://example.com/endpoint/foo"

... # いろいろな処理

post "https://example.com/endpoint/bar" "$1"

それぞれのコマンドのオプションも get 関数、post 関数に入れるのでラッパー関数を作ることでコードはシンプルになります。もしラッパー関数を作るという発想がなければ「後から追加するのは大変だから最初から curlwget の両方に対応しなきゃいけない」という発想になってしまいます。たいていその考えは YAGNI です。実際に必要になることはありません。しかし将来どうなるかはわからないので「もしかしたら必要になるかもしれない」という場合にラッパー関数にしておきます。ラッパー関数にしておけば後から wget に対応するのも Perl に対応するのも他の言語で独自に作るのも簡単です。つまりラッパー関数を作ることで実装するタイミングを必要になったときに延期することができメンテナンス性も移植性も高くすることができるのです。

コマンドの互換性問題の回避(フォールバック)

前項ではコマンドの存在チェックを行って curl 版と wget 版のどちらを使うかを切り替えていました。実はこの処理には穴があります。それはコマンド自体はインストールされているが、特定の機能の互換性がない場合です。

開発環境と異なる環境にインストールした場合や、GNU 版 wget と BusyBox 版 wget の違い等によって対応している機能やオプションが異なる場合があります。そういう場合だとコマンド自体は存在してるので動くだろうと判断して処理を分岐しますが、実際にはコマンドに互換性がないためエラーとなってしまいます。そういう場合でも動くようにするには、どちらかを実行してうまく行かなかったらもう一方の方法で実行する方法(フォールバック)を使います。

# curl 版
curl_get() { curl -X GET --some-options "$1"; }
curl_post() { curl -X POST -d "$2" --some-options "1"; }

# wget 版
wget_get() { wget --some-options "$1" -O -; }
wget_post() { wget --post-data="$2" --some-options "$1" -O -; }

get() { curl_get "$@" 2>/dev/null || wget_get "$@"; }
post() { curl_post "$@" 2>/dev/null || wget_post "$@"; }

効率は多少悪くなりますが、この方法を用いるとより高い可搬性が得られます。ShellSpec では od コマンドでこのテクニックを使用しています。od コマンドは POSIX で規定されており大体の環境にインストールされているのですが、古い BusyBox では実装されていません。しかも古い BusyBox の od コマンドは特定のオプションに対応していません。つまり od コマンドがインストールされているのに使えないという状況が実際にありました。そういう場合に対応するために od コマンドの実行でエラーが発生したら hexdump コマンドを使用するようになっています。(BusyBox では hexdump の方が安定して使えるようです。)

シェルスクリプトでの代替実装(ポリフィル)

例えば seq コマンドは比較的よく使われるコマンドですが POSIX では定義されておらず環境(例えば Solaris 10)によってはインストールされていません。そういう場合にシェルスクリプトで同等の処理を実装することで互換性を保つ手法です。

if ! type seq >/dev/null 2>&1; then
  # 簡易版でありオプションはサポートしてません
  seq() {
    case $# in
      1) set -- 1 1 "$1" ;;
      2) set -- "$1" 1 "$2" ;;
      3) ;;
      *) return 1 ;;
    esac
    while [ "$1" -le "$3" ]; do
      echo "$1"
      set -- "$(($1 + $2))" "$2" "$3"
    done
  }
fi

この手法はウェブのフロントエンド開発では Polyfill と呼ばれています。

ポリフィルとは、最近の機能をサポートしていない古いブラウザーで、その機能を使えるようにするためのコードです。

なお seq コマンドがない場合のみ定義しているのは Polyfill の説明に書いてあるのと同じ理由で高速かつ高機能だからです。

ポリフィルのみを使用しない理由は、機能やパフォーマンスをより良くするためです。ネイティブな API の実装は、ポリフィルよりも多くの機能を持ち、高速です。

バージョン検出

最初に注意しておくとこの方法はあまり推奨しません。可能な限り次の「機能検出」を利用してください。

バージョン検出はシェルのバージョンや OS 情報、コマンドのバージョンを検出して動作を変える方法です。バージョンの検出は BASH_VERSION 変数、uname コマンド、--version オプションなどがよく用いられます。

典型的なコードは次のようなものです。

if [ "${BASH_VERSION:-}" ]; then
  if [ "${BASH_VERSION%%.*}" -lt 5 ]; then
    some_func() { bash 5 未満用のコード; }
  else
    some_func() { bash 5 以上用のコード; }
  fi
else
  some_func() { それ以外用のコード; }
fi

case $(uname) in
  Linux) some_func() { Linux 用のコード; } ;;
  Darwin) some_func() { macOS 用のコード; } ;;
esac

case $(git --version) in (2.25.*)
  git 2.25 系の場合
esac

ただしこの方法は正確であるとは限りません。同じバージョンでも OS 固有でパッチが当てられており挙動が違っていたり、同じ環境でも優先されるパスに同じコマンド名の別実装がインストールされている可能性があるからです。例えば Solaris の tr コマンドはデフォルトのパスは POSIX と互換性がない歴史的な tr コマンドが使われますが、/usr/xpg4/bin が優先されるパスとして設定されている場合には POSIX 準拠 の tr コマンドが使われます。また細かいバージョンを把握する必要があるためメンテナンスが大変になります。

機能検出

実際にコマンドを実行してその挙動から実装を判断する方法です。

たとえば tr コマンドが POSIX に準拠しているかを調べる場合は次のようにします。

if [ "$(echo b | tr -d 'a-z')" = "" ]; then
  # POSIX 準拠
else
  # POSIX に準拠していない(Solaris等)
fi

もう一つ少し難しい例として stat コマンドの場合を紹介します。このコマンドは GNU と BSD で同じコマンドを持っていながら、オプションの意味が全く異なるコマンドです。こういう場合は両方で使えるオプション(ただし意味が異なる)でなるべく無害なオプションがないかを探してみると良いでしょう。その観点から探してみると -f オプションが見つかります。このオプションは GNU ではファイルシステムの情報を表示するオプションで BSD では出力のフォーマットを指定するオプションです。このオプションを使うとうまく実装を判断することができます。

if [ "$(stat -f /)" = "/" ]; then
  # BSD
else
  # GNU
fi

もしそういったオプションが見つからない場合、一部の環境にしか無いオプションを使って調べることができます。ロングオプションは GNU でしか用いられないので --version が使えるかどうかで調べるのは、GNU とそれ以外を検出するのに良いオプションです。ただしこの方法は注意が必要です。それは新しいバージョンでオプションが追加される可能性があるからです。特にショートオプションの場合、GNU 版にしかないオプションだと思っていても、将来の BSD 版で(別の意味のオプションとして)追加される可能性があります。その場合に誤認識する可能性があります。そのためオプションの有無で調べるよりは、すでに存在しているオプションで調べるほうが安全です。

localtypeset コマンドの互換性問題の吸収

POSIX シェルでは変数はグローバル変数しかありませんが dash を含む殆どのシェルで local コマンド(シェルによっては local キーワード)で変数をローカル変数にすることが出来ます。例外は ksh で typeset コマンドを使ってローカル変数にします。そのため POSIX 準拠にこだわらなければシェルスクリプトでローカル変数を使うことができます。

本題の前に少しシェルスクリプトのローカル変数の話をします。シェルスクリプトのスコープは多くの言語で一般的なレキシカルスコープではなくダイナミックスコープです。レキシカルスコープとダイナミックスコープの詳しい説明は省きますが、ダイナミックスコープの世間一般の説明がわかりづらく感じるので、私なりに説明すると以下のような動きをします。

  1. シェルスクリプトの変数はデフォルトでグローバル変数ですが local (typeset) コマンドで「ローカル変数属性」を付与することが出来ます。
  2. local (typeset) コマンドを関数内で使用すると、変数の現在の値が保存され、ローカル変数属性が付与されます。
  3. local (typeset) コマンドを使用した関数を抜けると、変数は元の値に戻ります。ローカル変数属性は local (typeset) コマンド実行前の状態に戻ります。

つまり実行せずともソースコードの字句 (レキシカル)からスコープが決まるのがレキシカルスコープで、実行時(ダイナミック)に local (typeset) コマンドでローカル変数属性を付けたかどうかでローカル変数かどうかが決まるのがダイナミックスコープです。

さてほぼ全てのシェルでローカル変数は使えるわけですが ksh では local ではなく typeset を使う必要があります。全てのシェルに対応するにはこの違いを吸収しなければいけません。この時以下のようなコードでは ksh で動作しません。

if [ "${KSH_VERSION:-}" ]; then
  alias local=typeset
fi
foo() {
  local value
  value=456
}
value=123
foo
echo "$value" # => 123 が出力されてほしい

なぜなら ksh ではローカル変数を使う場合は関数に function キーワードが必要だからです。この問題を解決するには以下のようなコードを書きます。

if [ "${KSH_VERSION:-}" ]; then
  function foo # POSIX シェルで文法エラーを防ぐためにここに改行が必要
  {
    typeset value=''
    foo_ "$@"
  }
else
  foo() {
    local value=''
    foo_ "$@"
  }
fi

foo_() {
  value=456
}
value=123
foo
echo "$value" # => 123 が出力されてほしい

foo 関数はシェルに応じて適切なものを定義し value 変数をローカル変数にするだけの処理を行います。メインの処理は foo_ 関数で行います。foo_ 関数を抜けても value の値は元に戻りませんがローカル変数属性はついています。そしてローカル変数属性をつけた foo 関数を抜けたときに値は戻ります。つまりダイナミックスコープであれば呼び出し元の関数側でローカル変数にすることができるのです。これによって localtypeset の違いを吸収することができます。

実際にはこのコードでは冗長すぎるので何らかのヘルパー関数を作ったほうが良いでしょう(実は昔作ったのですが満足してないので省略します)。例えば以下のようなコードで動くような localize 関数を作ればよいと思います。中身は eval を使ってシェルに応じた関数を定義するコードです。

localize foo value value1 value2
foo_() {
  value=456
}

さいごに

この記事のタイトルの 1 万行のコードというのは ShellSpec の話です。1 万行はシェルスクリプト製のソフトウェアとしては大きな部類に入ると思いますが、高いメンテナンス性を維持しています。さほど長くない行数なのにシェルスクリプトはメンテナンスができなくなると言っている人が多いですが、シェルスクリプトもプログラム言語である以上、他の言語と同じような設計・実装手法を取り入れれば長いコードのメンテナンス性を維持できない道理はありません。簡単に言えばシェルスクリプトがメンテナンスできなくなるのは何も考えずに使い捨てスクリプトの延長でコードを書くからです。この記事によってシェルスクリプトで互換性と生産性を向上させることが出来るでしょう。

この記事は ShellSpec で実装したものを思い出しながら書いています。おそらく忘れているものがいくつかあると思いますので思い出したら追記していきます。

34
48
0

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
34
48