287
316

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.

名著「入門UNIXシェルプログラミング」の超詳細なレビューをしてみた(古い内容の訂正)

Last updated at Posted at 2022-06-18

はじめに

そりゃまあ 30 年も経てば古くなりますよ。「入門UNIXシェルプログラミング」は今もシェルスクリプトに関するオススメの本として名前が挙がる名著です。しかしこの本は古い本です。POSIX でシェルが標準化される以前の本で、内容から判断するとおそらく 1990 年ぐらいの常識に基づいて書かれています。

古いから参考にならないと言うつもりはありません。しかしどれだけ優れた本でも時間の流れには勝てません。良書であると思っているからこそ、古くなってしまった内容は訂正する必要があると考えています。なおシェルスクリプトに関する古い本はこれだけではありません。オライリーから出版されている本も古い本ばかりです。いつ頃に(原書が)書かれた本なのかを確認した方が良いでしょう。

ということでレビューというていで、古くなってしまった内容の訂正を行いたいと思います。新しく「入門UNIXシェルプログラミング」で勉強し始めた人は、この記事を参考にしながら読むことで今では当てはまらない古い知識を学んでしまうことを避けられますし、以前に読んだことがある人はこの 30 年で何が変わったのかを知ることが出来るでしょう。またこの記事の知識は他の(古い)本を読む時の参考にもなると思います。

この記事では「入門UNIXシェルプログラミング―シェルの基礎から学ぶUNIXの世界 (改訂第2版)」を当書と表記しています。基本的に当書のページの順番に沿ってレビューしています。抜けている番号は特にコメントなしの番号です。この記事での指摘点の多くは Bourne シェルと POSIX シェルの違いによるものです。その違いについては「Bourne Shell(レガシー sh)とPOSIXシェル(sh, bash, etc)の違い」でもまとめていますので、よろしければこちらもご参照ください。

入門UNIXシェルプログラミングとはどのような本なのか?

入門UNIXシェルプログラミングは、ブルース・ブリン (Bruce Blinn) によって書かれた本です。日本では改訂第2版が現在の最新版として発売されています。日本語版の初版は 1999 年ですが、原書は 1995 年です。タイトルが異なっており検索しづらいかもしれないので関連している本を書きます。

  • Portable Shell Programming: An Extensive Collection of Bourne Shell Examples 1995年10月29日
  • 入門UNIXシェルプログラミング―Bourne Shellの基礎から学ぶUNIX World (初版) 1999年3月27日
  • 入門UNIXシェルプログラミング―シェルの基礎から学ぶUNIXの世界 (改訂第2版) 2003年2月5日
  • Portable Shell Programming Using the Bash Shell and Korn Shell (未発売)

元々のタイトルに「Bourne Shell」が含まれていることからもわかるように、この本は Bourne シェルに関する本です。直訳すると「移植性のあるシェルプログラミング: Bourne シェルの豊富な例題集」です。著者は Bourne シェルから bash と ksh 用に書き換えて POSIX に準拠した改訂版「Bash シェルと Korn シェルによる移植性のあるシェルプログラミング」を出版する予定だったようですが、大人の事情で出せなかったようです。この点からも著者本人が改訂の必要を感じていたという事がわかります。これらの情報は公式ウェブサイト http://www.bruceblinn.com/ からです。(この前までアクセスできていたのですが現在アクセスできなくなっているので予備リンク

POSIX でシェルが標準化されたのは POSIX.2-1992 なので 1992 年です。当書は POSIX 標準化以降の 1995 年発売で POSIX への言及も一部あるのですが、全体的に見ると「POSIX ではこのように標準化された」と書いて然るべき所で何も説明されていません。当時は POSIX のドキュメントは有料で販売されたものしかなく入手が容易ではなかったので、POSIX への言及はもしかしたら訳者の方が後から付け加えたものなのかもしれません。

日本語版の改訂第2版の「初版の訳者まえがき」には、1999 年当時の状況として次のようなことが書かれています。「PC-UNIX が広まってきた。欧米では Linux が主流だが 日本では Linux と FreeBSD が同じように好まれているようだ。Solaris や HP-UX、AIX という定番の UNIX の需要も高い。」 それから 20 年後の現在は、欧米だけではなく日本でも Linux が UNIX 系 OS の中でほぼ主流となっており、FreeBSD や 定番だった UNIX の需要はかなり低くなりました。改訂第2版のタイトルでは Bourne の文字が消えましたが、シェルの基礎から学ぶUNIXの世界も大きく変わりました。

一つ質問があります。本編と直接関係ないのですが、私は Bourne シェルを「B シェル」と呼ぶのは日本だけなのではないか?と疑っています。この本では Bourne シェルを B シェルと呼んでいるのですが、原書をお持ちの方、原書でも「B Shell」という用語は使われているのでしょうか?

2023-05-22 追記 原著を入手しましたが、想像通り原著には「B シェル」という用語は登場しませんでした。

全体に関する訂正

当書全体に渡って変数はがダブルクォートされていませんが、必ずダブルクォートでくくるようにしてください。例えば

if [ $USERID -ne 0 ]; then

cd /home/$i

のようなものは

if [ "$USERID" -ne 0 ]; then

cd "/home/$i"

とすべきです。例外はまったくないとは言えないのですが、まずありません。

またスクリプト内部で使用する変数名(環境変数ではないユーザー定義のシェル変数)には小文字を使用することを推奨します。大文字は環境変数として使われるのが一般的です。

0 はじめに

シェルスクリプトには「Bシェル(Bourne shell、/bin/sh)」というシェルが最も利用されます。

当時は、ほとんどの環境で /bin/sh = Bourne shell だったようですが、現在において /bin/sh が Bourne shell であることはまずありません。UNIX 系では ksh ですし、Linux では dash や bash、macOS では bash、BSD 系では ash や pdksh が使われています。これらは B シェルの後継シェルであり POSIX のシェルの標準規格(POSIX シェル)に準拠しています。

注意 macOS Catalina 以降も /bin/sh は bash 3.2.57 です。zsh ではありません。ログインシェルがデフォルトで zsh に設定されるように変わっただけです。zsh は POSIX シェルに(意図的に)準拠していない部分が多いので /bin/sh として使われることは今後もないと思われます。ターミナルで使用するシェルとシェルスクリプトで使用するシェルの動作の違いに注意してください。

$ sw_vers
ProductName:	macOS
ProductVersion:	11.6.7
BuildVersion:	20G630

$ /bin/sh --version
GNU bash, version 3.2.57(1)-release (x86_64-apple-darwin20)
Copyright (C) 2007 Free Software Foundation, Inc.

echo $SHELL の結果として /bin/sh が表示されている方はほとんどいない」は現在にも当てはまりますが、ログインシェルが /bin/sh だとしても、それは Bourne shell ではなく対話シェルの機能が強化されているシェルの場合があります。当書では「ただし Linux の /bin/sh/bin/bash のリンクなので、機能が豊富で使いやすい」と書かれていますが、Debian / Ubuntu 系 Linux では対話シェルの機能が少ない /bin/dash です。なお C シェル系を使う人は今ではかなり少なくなりました。

注意 SHELL は現在実行しているシェルではなく、ログインシェルとして設定されているシェルのパスに設定されています。つまりログインシェル (SHELL) が /bin/zsh の状態で bash を起動しても SHELL の内容は変わりません。

「B シェルで書かれていれば、すべての UNIX で利用可能であることがほぼ保証される」とありますが、現在それは保証されていません。B シェル(Bourne シェル)が使われている UNIX は少ないですし、そもそも UNIX よりも Linux の方が圧倒的に多く利用されています。以前はどの UNIX でも(バージョンの違いはあれど)Bourne シェルが使われていたので、Bourne シェル (sh) を対象にシェルスクリプトを書けば「どこででも動く」がそれなりに成り立っていたわけですが、現在は様々なシェルが /bin/sh を名乗っているので、当時よりもシェルスクリプトの移植性は低くなっています。一応 POSIX シェルとして標準化された機能をだけを使って書けば、一定レベルの移植性は確保できるのですが、それでもシェルによる違いはあります。

B シェル(Bourne シェル)が使われなくなった今では、シェルプログラミングは Bourne シェルを使うことが一番いい選択とは言えません。Bourne シェルは POSIX には準拠していないからです。移植性を最大限考えるのであれば、シェルプログラミングには純粋な POSIX シェルに近い dash (ash 系) を使うのが一番であり、現実的には bash を使って良いと思います。bash がインストールされてない環境に bash をインストールするのは今ではビルド済みパッケージをインストールするだけの簡単な作業となりました。

「シェルスクリプトを作っていく流れの例」ではユーザー ID が 0 であることを確認するために、id | sed -e 's/uid=//' -e 's/(.*//' のように id コマンドの出力結果を sed で切り出しています。しかし今は id -u を使うことが出来ます。これは POSIX で標準化されています。この記事ではこのような今では当てはまらない手法を修正しています。

余談ですが当書にはテープドライブが度々登場します。現在はテープドライブは主にバックアップ用に使われる大容量の特殊な記憶媒体ですが、当時はハードディスクと同じように日常的に使われていた記憶媒体です。なんでわざわざ例にテープドライブ?と思うかもしれませんが、ごく普通に使われていた記憶媒体の名前を出しているだけです。

1 書き方にかかわる基本的な説明

1.1 シェルスクリプトを作る際の基本事項

#! で始まる一行目がシバンという名前であるという説明が無いような気がします。

繰り返しますが #!/bin/sh と書いたからといって B シェル(Bourne シェル)で動作するとは限りません。今は(POSIX 準拠モードの)さまざまな POSIX シェルであり、それらの多くは拡張機能を持っています。

シバンがない場合は、今動かしているシェル(ログインシェル)で実行されると書かれていますが、実際には今動かしているシェルの仕様によります。現在は POSIX シェルや C シェル系以外のシェル(fish や PowerShell 等)も誕生しています。当書では csh でログインしている場合は csh で動くと書かれていますが、macOS では csh をログインシェルとして設定して csh からシバンがないシェルスクリプトを実行しても csh では動かず /bin/sh で動作しました。シバンがない場合はどのシェルで動くか全くわかりません。シバンは必ず書くようにしてください。

1.2 コメントを書く場合

コメントについての補足ですが # の前には空白(スペースまたはタブ)が必要です。スペースがない場合はコメントとしては扱われません。

echo "hello"#これはコメントではない

echo "hello" #これはコメント

1.5 引用符(クォーテーション)の使い方

1.5.1 バックスラッシュ

バックスラッシュの説明ですが当書の説明からは予測できない出力を行うシェルがあります。

$ # 当書に書いてある説明
$ echo abc\\def
abc\def
$ echo abc\\tdef # d の前に t を入れた場合、こうなるだろうと予測するが・・・
abc\tdef

$ # bash の場合(予測通り)
$ echo abc\\tdef 
abc\tdef

$ # dash の場合(予測と異なる)
$ echo abc\\tdef
abc    def

これは echo がエスケープシーケンスを解釈する場合があるからです。echo はシェルによって動作が異なり完全な移植性がありません。そのため POSIX では文字列にバックスラッシュが含まれる時の動作は未規定であると注意書きがされています。

バックスラッシュの挙動を確認する場合は printf を使うと混乱することがありません。

$ printf '%s\n' abc\\def
abc\def

$ printf '%s\n' abc\\tdef
abc\tdef

1.5.4 どんなときにクォートを使うのか

変数を(ダブル)クォートするべきかどうかの違いの話で「それならいつも使うようにすればいい、とは考えないでください。たいていの場合は使っても良いのですが、使ってはならない場面もやはり多々あるものです。」と書いてありますが 変数のダブルクォートは「いつも使うようにすればいい」と考えて構いません。 使ってはならない場面は例外です。

よく見かけるダブルクォートしてはいけない例外はこのような場合です。

esed="sed -E"

"$esed" 's/a/b/g' # "sed -E" 's/a/b/g' と解釈され動かない
$esed 's/a/b/g'   # sed -E 's/a/b/g' と解釈されるので動く

しかしこのような使い方は、以下のように書くことで不要になります。

esed() { sed -E "$@"; }

esed 's/a/b/g'

従って、変数はいつもダブルクォートして構いません。どうしても使ってはならない場面の時だけクォートするのをやめればよいだけです。クォートしない場合は、単語分割やパス名展開を機能させたい時なのですが、それが必要な場面は例外です。

1.6 バッククォートによるコマンド置換

当書ではコマンド置換でバッククォート(`)が使われていますが、ネストした時にバッククォートの対応が複雑になるため $(...) を使うことが今は推奨されています。Bourne シェルの時代にはバッククォートしか使えませんでした。

当書で紹介されている以下のバッククォートのネストのコードは

$ STRING=`echo "abc \`echo def\` ghi"`
$ echo $STRING
abc def ghi

今は以下のように書くことができます。カッコの対応がわかりやすくエスケープが不要です。

$ STRING=$(echo "abc $(echo def) ghi")
$ echo $STRING
abc def ghi

1.7 コマンド終了時のステータス

終了ステータス 0 が成功、0 以外が失敗ということで、C 言語などと異なると書かれていますが、これは「終了ステータス」と「真偽値」の混同です。C 言語など、どの言語でもコマンドの終了ステータスは成功の時に 0 を返します。

/* main 関数は成功の意味として終了ステータス 0 を返す */
int main() {
  return 0;
}

1.8 コマンドセパレータ

1.8.4 OR 演算子

補足です。|| を 『OR だからといって普通に「または」と考えてしまうと混乱するかもしれません』と書いていますが、ここは「または」ではなく「さもなくば」と考えると良いでしょう。Trick or Treat(いたずらか、さもなくばお菓子か)と同じ使い方です。

1.9 コマンドのグルーピング

1.9.2 中括弧によるグルーピング

中括弧 { } を使う場合 { command1; } のように最後のコマンドはセミコロンを打っておかなくてはなりません、また中括弧の前後にはスペースが必要です。と書かれていますが、理由が書かれていないので補足します。

これはシェルの文法で丸括弧 ( , ) がそれぞれでトークンとして扱われてるのに対して、{ , } は普通の文字でそれぞれが予約語(キーワード)として使われているからです。iffordocase などが予約語であり、これらの単語と { または } は同じ種類のものです。

$ type '('
type: (: not found

$ type '{'
{ is a shell keyword

$ type 'if'
if is a shell keyword

つまりは { は文字としては記号ですが、シェルスクリプトにとっては普通の文字の一つとして扱われているのです。だから {if はつなげて 3文字の文字列とみなされますし、command1 の次に command2 を書く時に command1; としなければならないのと同じで、command1 の次に } を書くのであれば、command1; としなければならないのです。

2 シェル変数

2.1 シェル変数とは

シェル変数として $HOME$PATH$SHELL といった名前が出ていますが、厳密に言えば $ はシェル変数名には含まれせん。HOMEPATHSHELL がシェル変数名であり、$ はその中身を展開する時に付ける記号です。ただし位置パラメータに関しては $1$@ などの数字や記号の場合は 1@ ではわかりづらいので $ まで付けて表現します。

「シェル変数名はアルファベットの大文字を使うのが慣例」と書かれていますが、少なくとも現在はそのような慣例はありません(例 Shell Style Guide)。環境変数となっているものはアルファベットの大文字を使うのが慣例ですが、シェル変数は小文字が使われることが多くなっています。私は環境変数に加えて、定数として扱われる変数、コマンドラインオプションの値を格納する変数、一つのシェルスクリプトファイルに収まらないグローバル変数のように扱われている変数も大文字としています。いずれもスコープが広く注意が必要なものです。

環境変数を大文字、スクリプトだけで使われてる変数を小文字にすることのメリットは ShellCheck による変数名のスペルミスによる警告が機能することです。SC2153 と SC2154 にはエクスポートされない変数は小文字が使われることが "慣習" であると書かれています。

SC2153: Possible Misspelling: MYVARIABLE may not be assigned. Did you mean MY_VARIABLE?

Note: This error only triggers for environment variables (all uppercase variables), and only when they have names similar to another known variable in the script. If the variable is script-local, it should by convention have a lowercase name, and will in that case be caught by [SC2154] whether or not it resembles another name.

SC2154: var is referenced but not assigned.

Note: This message only triggers for variables with lowercase characters in their name (foo and kFOO but not FOO) due to the standard convention of using lowercase variable names for unexported, local variables.

「変数の値がセットされていない状態で使用した場合、ヌル値がセットされている(名前だけあって値がセットされていない状態の変数)として処理します。」と書かれていますが意味が明確ではありません。2.3 に専用の項目があるのでそちらを参照してください。

2.2 シェル変数を使ってみる

シェル変数に利用できる文字はアルファベット、数字、アンダースコアの 3 種類であるため、これ以外の文字がある場合、それはシェル変数を区切るものとして判断される。つまり cd $DIR/workcd ${DIR}/work と書かなくて良いという話がされていますが、これには例外があります。Bourne シェル、または POSIX で標準化されている範囲であればではこの説明は正しいのですが、zsh の場合 [ はシェル変数を区切る文字として扱われません。

var=ABC

# bash の場合は [ は変数名の一部ではない
echo "$var[0]" # => ABC[0]

# zsh の場合は [ は変数名の一部
echo "$var[0]" # => 空文字 
echo "$var[1]" # => A
echo "$var[2]" # => B
echo "$var[3]" # => C

# zsh の場合は {} が必要
echo "${var}[0]" # => ABC[0]

見てわかるように zsh では文字列(または配列)の添字として扱われています。従って私は"空白以外の文字"と繋げる場合に {} でくくることを推奨しています。なぜ空白の場合に {} を付けなくて良いとしているのかと言うと、空白を文字列の一部と考える人はおらず、{} がなくても十分見やすいからです。ただし 2 桁の位置パラメータ(例 ${10})は {} でくくらねばならないので例外です。逆にすべてに {} を付けると見づらくなる場合があり、特に ${@}${?}、などはやりすぎに感じます。

echo "${filename}.c"
echo "http://${hostname}/index.html"

echo "Hello, $username"
echo "Hello, $1"
echo "Hello, ${10}"

echo "$@"

2.3 値がヌルである状態のシェル変数

当書の説明がややこしく用語が混乱しているように思えます。

シェルスクリプトにおいてヌルとは空文字(長さゼロの文字列)のことを指しているようです。これは Bourne シェル、bash、ksh のマニュアルから null を検索して判断しました。文字列しか型がないシェルスクリプトに JavaScript のような null な値はありませんし、C 言語の \0 が代入されているわけでもありません。

以下の二つはどちらも全く同じ意味で空文字を代入しており、この状態をヌルと呼んでいるようです。

VARIABLE=
VARIABLE=""

そして変数の状態と言って良いのか微妙ですが、変数は「未設定 (unset) 状態」(別名「未定義 (undefined) 状態」)があります。これは unset で変数の定義を削除した状態のことです。変数そのものがありません。値が代入またはヌル状態のシェル変数は unset で未設定状態に戻すことができます。

unset VARIABLE

つまりは変数の状態には「値が代入された状態」「ヌル状態」「未設定」の 3 つがあるということです。個人的にはヌル状態は「(空文字という)値が代入された状態」と考えているのですが、まあシェルではそれをヌルと呼ぶようです。

シェル変数は、シェルスクリプトの"冒頭で"必ずしも初期化する必要はありませんが、参照する前にかならず値を代入するか unset してください。これは親シェルから環境変数として値が渡されてしまう可能性があるからです。シェルスクリプト内で値の初期化をしない限り変数の状態は確定しません。もちろん親シェルから値を受け取りたい場合は初期化する必要はありません。

2.4 シェル変数の初期設定

こちらも説明がよくわかりません。翻訳が間違っているような気もします。

まず、当書に書いてあるような以下のような書き方は推奨しません。ややこしいだけです。

echo ${variable:=value}

通常は以下のような使い方をします。この時ダブルクォートは必要です。予期せぬパス名展開を避けるためです。このパラメータ展開の意味の説明は 2.4.1 でします。

: "${variable:=value}"

以下は variable が未設定(unset)または空文字(ヌル)の状態に処理をするという意味です。: がない場合は未設定(unset)の状態に限って処理をするという意味です。

${variable:=value}
${variable:-value}
${variable:?value}
${variable:+value}

2.4.1 = によるシェル変数の設定

繰り返しますが、分かりづらいので以下のような書き方は避けてください。

echo ${ABC:=xyz}

この書き方の適切な用法は : とともに使います。

: "${ABC:=xyz}" # 値が未設定または空文字(ヌル)ならxyzを代入する
: "${ABC=xyz}"  # 値が未設定ならxyzを代入する

またダブルクォートは必要です。でないと ABC* などのパターンが含まれている場合に、カレントディレクトリのファイルにマッチすると、ファイル名に展開されます。

$ touch file1 file2 file3
$ ABC="*"

$ echo ${ABC:=xyz}
file1 file2 file3

$ echo "${ABC:=xyz}"
*

「注意」に「書き込み禁止の変数に対して利用できません」と書かれていますが、一般的に書き込み禁止とは readonly で書き込み禁止属性をつけた変数を指す言葉だと思います。通常位置パラメータに対して書き込み禁止とは言わないでしょう。ただし ${1:=xyz} という書き方ができないというのは正しいです。

2.5 位置パラメタ

位置パラメータの説明に $0 が含まれていますが、$0 は位置パラメータには含まれません。位置パラメータは $1 以上です。

$10 は作れない(「参照できない」が正しい)と書かれていますが、これは Bourne シェルの制限であり、POSIX シェルでは ${10} で参照することができます。10 番目の引数を参照する場合に shift を使わなければならないという説明も、現在の POSIX シェルには当てはまりません。

$@$* の使い分けは微妙と書いてありますが、通常は $@(正確には "$@")だけしか使いません。$* (正確には "$*")を使うのは複数の引数を一つに文字列結合したいときだけです。そのようなことをする場合を除き「いつでも "$@" を使ってください。"$*" は使わないでください。」どちらもダブルクォートなしで使ってはいけません。

「渡すべきパラメータがなにもない状態の時には "$@" がヌルに、"$*""" に置き換わる」という説明はよくわかりません。Bourne シェル SVR3 (1986) で "$@" の仕様は変更になったのですが、この説明は仕様変更後の話に見えます。しかしその場合ヌルになるという説明がシェルスクリプトの用語のヌルと一致しません。まあどちらにしろ "$*" は使いませんので読み飛ばしていいです。

${@+"$@"} と書くことで、位置パラメータに何もセットされてない場合には何もしない条件を作れる」と書かれていますが、現在は "$@" だけで同じ状態になります。

# Bourne シェル SVR3 (1986) より前のシェルのための書き方
command ${@+"$@"} # 最近のPOSIX改訂で未規定に変わったので非推奨
command ${1+"$@"} # もし使うならばこちらを使った方が良い(非推奨ではない)

# POSIXシェルではこれで良い
command "$@" 

# 上記は下記と同じ意味(ただの冗長な書き方)
if [ $# -eq ]; then
  command
else
  command "$@"
fi

# 上記の if よりも case の方が短くてオススメ(普通は必要ない)
case $# in
  0) command ;;
  *) command "$@" ;;
esac

繰り返しますが「$0$9$#$*$@」を「書き込み禁止の変数」とは言いません。

2.6 特殊な変数

こちらも同様に「書き込み禁止」とは言いません。

2.6.2 $$ 変数

補足です。$$ はプロセス ID であり、ユニークな番号をつけたい時に利用するとして以下のような例が上げられていますが

/tmp/tmp.$$

プロセス ID の番号は循環(またはランダム)で未使用の同じ番号が振られることがあるので、ユニークな番号であると決めつけるのはよくありません。一時ファイルを作るのであれば mktemp を使ったほうが良いですし $$ を使う場合はせめて現在時刻(UNIX タイム)を付けましょう。

3 シェル関数、組み込みコマンド

3.1 シェル関数

「注意」にエイリアスは無いと書かれていますが、これは Bourne シェルに限った話です。現在の POSIX シェルには alias があります。

3.1.1 動作状態による違いに関する注意

「関数内で標準入出力をリダイレクトするような場合〜サブシェルを作って動作します」と書かれていますが、これは Bourne シェルに限った話です。現在の POSIX シェルでは関数内でリダイレクトしても、関数はサブシェルにはなりません。

# カレントシェルで実行される
ls -l

lsl() {
  ls -l
}

# Bourne シェルではサブシェルで実行されたが
# POSIX シェルではこれもカレントシェルで実行される
lsl > ls_file

よって以下の説明も訂正になります。

  • ディレクトリ・・・×元のディレクトリに戻ります → ◯元のディレクトリに戻りません
  • 変数・・・×元の値に戻ります → ◯元の値に戻りません
  • exit コマンド・・・×その関数が終了するだけです → ◯シェルスクリプト全体が終了します

3.1.2 引数(位置パラメタ)に関する注意

ls1() {
  ls -l $*   # 悪い書き方
  ls -l "$@" # こちらが正しい
}

「シェル関数では $0 は関数の名前にならず、シェルスクリプトの名前になる」と書いてあります。POSIX の標準規格でもそのとおりですが、zsh に限っては $0 は関数の名前になります。これは POSIX_ARGZERO を設定すると POSIX の動作に準拠させることができます。

# zsh 専用
emulate sh -o POSIX_ARGZERO

「UNIX によっては、位置パラメタはただ一組しか用意されず」というのは、Bourne シェル SVR3 (1986) より前の Bourne シェルの話です。SVR3 から関数毎に位置パラメータを持つようになりました。従って当書に書いてあるような引数の保存は不要です。さらに言うならば当書の引数の保存のコードにはバグがあります。

# 当書にはこの方法で保存し、再利用するよう書かれているがバグが有る
POS_PARAM="$@" 
set "$POS_PARAM"

# 上記の方法では正しく位置パラメータを戻せない
set -- "a b" c # $1: "a b", $2: "c"
POS_PARAM="$@" 
set "$POS_PARAM" # $1: "a b c" となる
set $POS_PARAM   # $1: "a", $2: "b", $3: "c" となる

なおこれをバグなしで実装する場合、エスケープが必要となり少々面倒です。

3.1.3 変数を使う際の注意

補足です。POSIX で標準化されていませんが local コマンドでローカル変数が使用可能です。

3.1.4 関数をカレントシェルで利用すること

Linux なら ps -ax のハイフンを取って ps ax にすると書かれていますが、POSIX ではハイフン付きのオプションが標準化されてるので ps -ax でも動きます。ただし ps コマンドのオプションは混沌としており期待したとおりの意味になっているとは限りません。

3.2 組み込みコマンド

expr がシェルの組み込みコマンドとして実装されていた事例は知りません。どこかの実装であった可能性はありますが、少なくとも現在のメジャーなシェルではないはずです。

3.2.7 eval コマンド

bash では下記のような実行結果になると書かれていますが、その理由が書かれていません。

$ VAR2=VAR1

$ # Linux や Solaris の bash
$ echo $"$VAR2"
VAR1

$ # 標準 sh
$ echo $"$VAR2"
$VAR1

ksh や FreeBSD の bash では下の動作であると書かれていますが、おそらく ksh88 と 古いバージョンの bash だと思われます。ksh93 と bash の場合は上の動作を行います。

これは $"..." が多言語機能を意味しているからです。もし翻訳文が存在したら $"MESSAGE" は翻訳されて(頭の $ は削除されて)メッセージ のような出力になります。上記の場合は VAR1 に対応する翻訳文が存在しないため、そのまま出力されています。横着せずに $ はエスケープしましょう。

$ VAR2=VAR1
$ # どのシェルでも問題なく動く
$ echo "\$${VAR2}"
$VAR1

3.2.13 readonly コマンド

これはただの感想なのですが、「readonly コマンドはシェルスクリプトでは殆ど使われません」と書かれていました。うーん、そうなんですか?逆な気がしていたのですが。

3.2.15 set コマンド

補足ですが set コマンドを使って変数を位置パラメータに代入する時は -- を付けたほうが良いです。

set -- "$VAR"

VAR 変数に -f みたいな文字が入っている時にオプションと誤認識してしまうからです。

3.2.16 shift コマンド

位置パラメータを全部クリアさせたい場合には shift を使うように書かれていますが、

shift $#

POSIX シェルの場合 set を使っても良いです。以下の方法は Bourne シェルでは使えません。

set --

私は set を使うのですが、たいして違いはありません。

3.2.17 test コマンド

丸括弧によるグルーピングと -a-o の話が書いてありますが、現在これは POSIX で非推奨となっています。&&|| で代用できるので使う必要はありません。

[ $NUM1 -lt $NUM2 -o $NUM1 -gt $NUM3 -a $NUM2 -le $NUM3 ]

上記は以下のように書くことができます。優先順位にハマる可能性があるので {} で明確にしたほうが良いでしょう。

[ $NUM1 -lt $NUM2 ] || { [ $NUM1 -gt $NUM3 ] && [ $NUM2 -le $NUM3 ]; }

3.2.21 unset コマンド

関数を消去する時は unset -f name を使います。これが書かれていない理由は Bourne シェルでは変数と関数の名前空間が別れておらず、どちらも unset name で削除していたからです。POSIX には変数と関数の名前空間を分離すると規定されています。

PATHIFSunset できないというのも Bourne シェルの制限です。現在のシェルでは unset 可能であり、その場合、特殊な効果がなくなったりデフォルトの動作を行うようになったりします。IFS の場合は unset を行うとデフォルト値(スペース、タブ、改行)が入っているかのような単語分割を行うようになります。

4 リダイレクションによるファイル操作

4.2 リダイレクション

ファイルディスクリプタの番号は 0 から 9 と書かれており、POSIX で認められているのも同じですが、シェルによっては拡張されてそれ以外の番号が使える場合があります。書き方はシェルによって違いがあったりするので各シェルのドキュメントを参照してください。

この項目は謎な説明がなされています。十中八九間違った説明です。標準入力を受け付ける ls の実装なんてあるのでしょうか?

まずここまでは OK です。

$ ls -l abc nnn
-rw-r--r-- 1 user usergrp  7 Oct 8 21:19 abc
-rw-r--r-- 1 user usergrp 43 Oct 8 21:19 nnn

しかし「引数の abc nnn という文字列は、標準入力(すなわちキーボード)からタイプしたもの」というのはまったくもって意味不明な説明です。キーボードからタイプしたものには間違いありませんが、それは標準入力ではありません。

$ ls -l < xyz # xyz ファイルの中身は abc nnn
-rw-r--r-- 1 user usergrp  7 Oct 8 21:19 abc
-rw-r--r-- 1 user usergrp 43 Oct 8 21:19 nnn

もし上記のコードが動くとしたら、標準入力から ls で表示するファイルを指定できる実装が存在する場合ですが、私は知りませんし、そもそもコマンドライン引数を標準入力からの入力だという説明が間違っているわけで完全に間違った説明でしょう。

明らかにおかしいので正誤表を探したら説明してありました。正しくは xyzls -l abc nnn という文字が書いてあって sh < xyz が正しいというようなことが書いてありましたが、なんとなく後付な気がします。

4.5 制御構文とリダイレクション

当書に書かれていることに反して、リダイレクション機能は POSIX でサブシェルで動作しなくなったことを失念しないようにしてください。サブシェルで動作するのは Bourne シェルの話です。POSIX シェルでは {}ifforwhilecase からのリダイレクトはサブシェルで動作しません。サブシェルで動作させたい場合は () で明示的にサブシェルにします。

ただしパイプでこれらをつないでいる場合は(最後につないだコマンドを除いて)サブシェルで実行されます。最後につないだコマンドは ksh と zsh と lastpipe を有効にした bash ではサブシェルではなく現在のシェルで動作します。

4.7 ファイルからの読み込み

何度も出てきていますが、以下はサブシェルで実行されません。

while read LINE
do
  ......
done < file

従って当書のように exec コマンドでリダイレクトするファイルを切り替える必要はありません。

4.9 ファイルのゼロリセット

以下の二つの方法が紹介されていますが、: >file を使用してください。zsh のデフォルトは : を省略した場合は cat 相当の処理を行います。

>file
: >file

5 環境

5.1 シェルスクリプトを定義すること

ファイルの頭の 2 文字「#!」のことをマジックナンバーと説明しています。シバンという用語は当書には書かれていないようです。マジックナンバーはプログラミングの世界で特別な意味を持つ数値を直接ソースコードに書くことで、可読性を下げる良くないものとされています。そのようなものが使われているというのでしょうか?しかも #! は数値ではありません。どういうことでしょうか?

実はマジックナンバーという用語はもう一つ意味があり、ファイルの中身を伝えるファイルインジケーターのこともマジックナンバーと言います。また数値というのは内部で #! を 16 進数に変換した 0x23 0x21 という値で扱っているからです。

この項目ではシェルスクリプトが起動するまでの exec の処理の詳細が書かれており、ファイルの先頭をシバンではなく # (C シェルで実行する場合)または : (B シェルで実行する場合)のように書いても構わないと書いていますが、やめたほうが良いです。現在は、多くのシェルが登場しておりその中には Broune シェルにも POSIX シェルにも 当てはまらないシェルが登場しているからです。どのシェルから実行しても希望するシェル(インタプリタ)が使用されるようにシバンを明記するようにしてください。

ちなみにシバンのパスに標準的でないパスを使用しなければいけない環境があり、その環境に対応したいのであれば、インストール時にスクリプトを書き換えることを POSIX は推奨しています(参考)。

Furthermore, on systems that support executable scripts (the "#!" construct), it is recommended that applications using executable scripts install them using getconf PATH to determine the shell pathname and update the "#!" script appropriately as it is being installed (for example, with sed). For example:
(省略 実際のコードはリンク先へ)

5.2.3 PATH 変数

PATH 環境変数をエクスポートするのに以下のようなコードが紹介されています。

PATH=$PATH:$HOME/mycmd; export PATH

export; に続けて後ろに書いてあるのは、Bourne シェルでは export への代入と同時に値をセットすることが出来なかったからです。今は以下のように書くことができます。

export PATH=$PATH:$HOME/mycmd

ただし注意点が一つあります。それはコマンド置換を使っている場合です。当書に書いてあるコードは以下のようなコードです。

PATH=$PATH:`dirname $0`; export PATH

このコードは set -e された状態で dirname がエラーになれば、そこでシェルスクリプトが終了します。しかし以下のように書いた場合終了しません。それは dirname がエラーになっても export が実行され、終了ステータスが正常に戻ってしまうためです。

export PATH=$PATH:`dirname $0`

dirname がエラーになることはまずありませんが、エラーになることを考慮する場合は以下のように書くとよいでしょう。ついでにコマンド置換も新しい書き方に変更し、ダブルクォートも正しく使用しています。

dir="$(dirname "$0")"
# dir=$(dirname "$0") でもよい
export PATH="$PATH:$dir"

補足です。「注意」に PATH 変数に . (ドット)を含めてはいけないことの注意点が書かれています。. を入れてしまうとカレントディレクトリからプログラムを検索してしまうからで、どこか他人がファイルを作成することが出来るディレクトリで実行してしまうと、そこに悪意があるコマンドがあった場合に問題になるという話です。実はパスに空文字が入っていても . が入っているのと同じようにカレントディレクトリを探索するので注意してください。つまり PATH=""PATH=/bin::/usr/binPATH=/bin:/usr/bin: はカレントディレクトリも探索します。

5 環境

5.3 ユーザ情報、マシン情報

5.3.1 ユーザ名の取得

USER あるいは LOGNAME という環境変数は、任意に変更することが可能なのでさほど信用に足る値ではない、という理由で whoamilogname を勧めていますが、これらのコマンドも置き換えれば任意の値に変更することが可能なので、信用度はさほど変わりません。なんらかのミスで環境変数を変えてしまっても対応できるぐらいの効果はありますが、セキュリティレベルの信用度の話だと、どちらも大差ないです。

id -u が使えないシステムを考慮して、id の出力を sed を使って切り出していますが、id -u は POSIX で標準化されており、現在使えないシステムはまずありえません。例えば Solaris などで使えない場合、それは互換性維持のためにデフォルトで非 POSIX 環境になっているだけなので、getconf PATH を使用して POSIX に準拠した PATH に変更すれば使えるようになります。POSIX に準拠した環境に設定する方法は、システムによって異なるため、システムのマニュアルを参照してください。

よって、現在スーパーユーザー (root) かどうか判断するコードは以下のようになります。

if [ "$(id -u)" -eq 0 ]
then
    echo "Is superuser"
else
    echo "Is not superuser"
fi

関数にする場合はこれだけです。(当書のように case を使う必要はありません)

is_superuser() {
  [ "$(id -u)" -eq 0 ]
}

5.3.2 マシン名称の取得

ホスト名の取得で host.domain.co.jp から host 部分を取り出すのに以下のようなコードが書かれていますが

HOSTNAME=`hostname | sed -e 's/\..*//'`

このような場合には、(新しい)パラメータ展開を使用してください。

HOSTNAME=$(hostname) # host.domain.co.jp
HOSTNAME=${HOSTNAME%%.*}

この書き方は Bourne シェルでは使えず、新しいシェルで対応し POSIX で標準化されたものです。現在はどのシェルでも利用可能です。外部コマンドを用いた文字列の編集は、わかりにくく冗長なコードになりやすく、また外部コマンド呼び出しは遅いです。この例のように単一行の編集ではパラメータ展開を用いた方が簡潔で速いです。(複数行を一括で編集する時に sed などを使用します)

5.4 シグナルの処理

trap で指定するシグナル番号 0, 1, 2, 3, 9, 15 という数値を使用していますが、可読性と移植性のためにシグナル名を使用してください。例えば 0 の代わりに EXIT を使用します。移植性が高い書き方は名前の最初に SIG は付けずに大文字です。

0:EXIT  1:HUP  2:INT  3:QUIT  9:KILL  15:TERM

5.5 リモートシェル

リモートシェル (rsh) や rloginrexec は現在ほとんど使用されません。これらは通信内容が暗号化されておらず危険です。現在は一般的にセキュアシェル (ssh) を使います。ssh の誕生は 1995 年であるため、この本が書かれたときには存在しなかったか普及していなかったのでしょう。

6 コマンド行の解析、処理

6.3 シェルでのオプション処理

6.3.1 getopts コマンドを使う

参考コードの使い方は殆ど変わっていませんが、expr の代わりに算術式展開を使用した方が良いでしょう。シェルに内蔵されている機能であるため速いです。Bourne シェルでは算術式展開は使用できませんでした。

shift `expr $OPTIND - 1`

の代わりに

shift $((OPTIND - 1))

6.3.2 getopt コマンドを使う

getopts が使えないものがあると書かれていますが、これは Bourne シェル SVR3 (1986) より前のシェルのことです。現在は全ての POSIX シェルで使用可能で POSIX でも標準化されています。

getopt の機能を拡張したものが getopts と書かれていますが、この getopt は元のバージョンの事です。GNU 版の getoptgetopts よりも更に機能が拡張されています。FreeBSD や macOS では GNU 版ではなく元のバージョンの方が今でも使用されています。詳細は省略しますが元のバージョンは空白が含まれる引数の処理に問題があるので使用してはいけません。

7 フィルタの使用法

当書の全般を通しての話ですが、単一行の文字列に対して外部コマンドを使用するのは避けてください。POSIX で標準化された新しいパラメータ展開を用いれば殆どのものは事足ります。新しいパラメータ展開とは以下のようなものです。

  • ${VAR#pattern} パターンに一致する先頭の文字列を削除(最短)
  • ${VAR##pattern} パターンに一致する先頭の文字列を削除(最長)
  • ${VAR%pattern} パターンに一致する末尾の文字列を削除(最短)
  • ${VAR%%pattern} パターンに一致する末尾の文字列を削除(最長)
  • ${#VAR} 文字列の長さを取得

上記をうまく組み合わせれば、パターンに一致する部分を抜き出すことも出来ます。

  • ${VAR%"${VAR#pattern}"} パターンに一致する先頭の文字列を抜き出す
  • ${VAR#"${VAR%pattern}"} パターンに一致する末尾の文字列を抜き出す
  • ${VAR%"${VAR#?}"} 先頭の一文字を抜き出す
  • ${VAR#"${VAR%?}"} 末尾の一文字を抜き出す

POSIX で標準化されていませんが以下のパラメータ展開も便利です。

  • ${VAR/pattern/string} パターンに一致する文字列を置換
  • ${VAR//pattern/string} パターンに一致する文字列を全て置換
  • ${VAR:offset} 文字列の切り出し
  • ${VAR:offset:length} 指定した長さの文字列の切り出し

これらを使うことで、sedcuttrawk と言った外部コマンドの呼び出しが減り、パフォーマンスが向上し、コードもシンプルになります。外部コマンドは複数行の文字列を一括で変換する時に使用します。単一行の文字列の編集に外部コマンドを持ち出すのは過剰で重いです。

7.3 sed を使った編集

sed 全般の話ですが、一般に使われている正規表現と少し異なる基本正規表現が使われています。現在は sed -E で拡張正規表現が使えるようになっています。このオプションは POSIX issue 8 で標準化されます。

7.3.10 大文字と小文字を入れ替える

なぜ「sed を使った編集」の話の中に tr の話が出てきているのか謎ですが、 tr の書き方が古いです。

# System V 版の書き方
tr '[A-Z]' '[a-z]'

# POSIX 版の書き方
tr 'A-Z' 'a-z'

Solaris などで System V 版の形式しか使えない場合、POSIX 準拠の環境になってないので、getconf PATH などを使って POSIX 準拠の環境に変更してください。

7.3.23 ファイルを後ろから表示する

ファイルを後ろから表示する場合、通常は tactail -r を使用してください。tail -r は POSIX issue 8 でも標準化されます。古い時代にはこれらがありませんでした。

8 シェルのいろいろな機能

8.1 数値の計算

「シェルには数式を処理する機能はありません」と書かれていますが、これは Bourne シェルの話であって今となっては間違いです。数値の計算には expr ではなく POSIX で標準化された算術式展開 $((...)) または POSIX で標準化されてない算術式評価 ((...)) を使用してください。expr は外部コマンド呼び出しなので遅い上に * の書き方が面倒です。「シェルのいろいろな機能」として書かれていますが、そもそも expr は外部コマンドなのでシェルの機能ではありません。

8.1.1 整数の計算

expr コマンドを使った例がかかれていますが、これらは全て $((...) で書くことができます。変数を使って計算させた数値をもとの変数に代入するには次のようにします。

value=5
value=$((value + 1))

8.1.3 浮動小数点を含む計算

bc を使ってもよいのですが、インストールされてない環境があるので awk を使用することを推奨します。bc もシェルの機能ではありません。

8.1.4 16 進数での表記

dc コマンドを使わずとも算術式展開や printf で変換することができます。

$ echo $((0xff))
255

$ printf '%x\n' 255
ff

ただしシェル依存があったりパフォーマンス上の問題があるので、この項目は後で調べて書き直すかもしれません。外部コマンドの dc を使うよりは軽いです。dc コマンドは POSIX で標準化されておらず、その理由として bc で同じことが出来ると書かれていたので(未検証ですが)通常は dc を使う必要はないと思います。また dc もシェルの機能ではありません。

8.1.5 数値かどうかの判定

expr "$NUMBER" + 1 という直感的ではない方法を使うのではなく、以下のシェル関数を利用してください。パフォーマンスもこちらの方が高いです。

is_number() {
  case $1 in
    *[!0-9]*) return 1 ;;
    *) return 0 ;;
  esac
}

# 少し変わった書き方ですが、これもどのシェルでも動きます。
is_number() {
  case $1 in (*[!0-9]*)
    return 1
  esac
  return 0
}

8.2.3 余計なホワイトスペースの削除

以下のコードが紹介されています。

STRING=`echo $STRING`

上記のコードは STRING"*" などファイル名にマッチするパターンが含まれるとファイル名のリストに展開されるので危険です。パス名展開を行わないように事前に set -f を実行しなければなりません。またコマンド置換が使われているため遅いです。

少々技巧的になりますがパラメータ展開を用いた方が良いです。(zsh では対話シェルで動かすと ! が特殊な意味に解釈されエラーになりますがシェルスクリプトにすれば動きます。)

# IFS にはホワイトスペースが入っていることが前提です。

# 左のホワイトスペースの削除 (ltrim)
str=${str#"${str%%[!"$IFS"]*}"}

# 右のホワイトスペースの削除 (rtrim)
str=${str%"${str##*[!"$IFS"]}"}

使いやすくするために関数にすることもできます。

ltrim() {
  eval "$1=\${2#\"\${2%%[!\"\$IFS\"]*}\"}"
}

rtrim() {
  eval "$1=\${2%\"\${2##*[!\"\$IFS\"]}\"}"
}

trim() {
  ltrim "$1" "$2"
  eval "rtrim \"\$1\" \"\${$1}\""
}

trim str "   abc   "
echo "$str" # => "abc"

8.2.4 文字列の長さ

文字列の長さは ${#str} で取得できるので expr を使用する必要はありません。ただし Unicode 文字列には気をつけてください。expr でも同じですが文字数になる場合とバイト数になる場合があります。

expr の場合

$ # macOS
$ LC_ALL=ja_JP.UTF-8 expr  "あいうえお" : '.*'
15
$ LC_ALL=C expr  "あいうえお" : '.*'
15

$ # GNU
$ LC_ALL=ja_JP.UTF-8 expr  "あいうえお" : '.*'
5
$ LC_ALL=C expr  "あいうえお" : '.*'
15

シェル機能の場合

$ LC_ALL=ja_JP.UTF-8 bash -c 'str="あいうえお"; echo "${#str}"'
5
$ LC_ALL=C bash -c 'str="あいうえお"; echo "${#str}"'
15

$ LC_ALL=ja_JP.UTF-8 dash -c 'str="あいうえお"; echo "${#str}"'
15
$ LC_ALL=C dash -c 'str="あいうえお"; echo "${#str}"'
15

バイト数で取得する場合は LC_ALL=C を使えばよいのですが、文字数を取得するのは大変です。

当書にかかれていた、expr の最初の引数が演算子の場合にエラーになる問題は現在の実装でもエラーになる場合があるようです。

$ # macOS
$ expr ":" : '.*'
expr: syntax error

従って expr の使用は避けたほうが賢明です。

8.2.5 文字列の中の文字列

grep を使って文字列が含まれているか調べる場合、出力を /dev/null に捨てるよりも grep -q を使ったほうが良いです。短い上に実装によっては(最初に一致した時点で処理を中断するため)パフォーマンスが向上します。

当書にも書かれていますが、単一行の文字列の場合は case を使用した方が良いです。

if echo "$STRING" | grep -q "$SUBSTRING"; then
    echo "Found it."
fi

上記よりも下記のほうが良い。$SUBSTRING にはダブルクォートが必要です。

case "$STRING" in 
    *"$SUBSTRING"*) echo "Found it." ;;
    *)              echo "Not found." ;;
esac

# if のような書き方もできます
case "$STRING" in (*"$SUBSTRING"*)
  echo "Found it."
esac

8.2.6 文字列の中の一部分の切り出し

単一行であれば、(新しい)パラメータ展開を使って切り出したほうがパフォーマンスが高いです。

8.2.7 文字列の最初の文字だけを大文字にする

これも単一行であれば、パラメータ展開を使ったほうが良いです。POSIX では標準化されていませんが bash では ${str^} だけで先頭文字だけ大文字にすることができます。他のシェルでも別の方法で実現できたりしますが省略します。

POSIX で標準化された範囲で実現する場合は、以下のような関数が考えられます。少々技巧的なコードですが一度書いてしまえばコードを読む必要はないので問題ないでしょう。

capitalize() {
  set -- "$1" "$2" "aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuUvVwWxXyYzZ"
  while [ "$3" ]; do
    # アルファベットリストを 2 文字ペアで $4 に取り出していく
    set -- "$1" "$2" "${3#??}" "${3%"${3#??}"}"
    case $2 in ("${4%?}"*)
      set -- "$1" "${4#?}${2#?}" "$3"
      break
    esac
  done
  eval "$1=\$2"
}

capitalize ret "string"
echo "$ret" # => String

複数行を処理する場合や上記のような関数を定義したくない場合は awk を使うのが簡単です。

echo "def" | awk '{print toupper(substr($0,1,1)) substr($0,2)}'

ちなみに当書では以下のような外部コマンドを利用した方法が紹介されています。(少し変更しています)

STRING=abc
echo "$(expr "$STRING" : '\(.\).*' | tr a-z A-Z)$(expr "$STRING" : '.\(.*\)')"

このようにパズルを解くように既存のコマンドを組み合わせてあれこれ考えて回答を導き出すのは、頭の体操にはなるかもしれませんが、可読性としては最悪です。シェルスクリプトで一行で書くことに意味はありません。単純なコマンド呼び出しで実現できないのであれば、素直にシェル関数にしてしまうのが一番です。一度作れば再利用できますし可読性も高まります。

8.3 文字列の取り扱い

8.3.2 read コマンドの場合

コードの前後で IFS 変数を OLDIFS 変数にバックアップしていますが、以下のように read の前で設定すれば十分です。IFS の変更は read コマンド実行時だけで、read コマンドの呼び出しが終われば IFS は元に戻ります。

while IFS=: read USER PASSWD UID GID GCOS REMAINDER

8.3.3 for 文の場合

(校正の都合上?)数字の前後に空白が入っているため、コードの出力例がおかしなものとなっています。実際は以下のようになります。

$ date
2022年 6月16日 木曜日 20時12分38秒 JST

$ for i in `date`
> do
>     echo $i
> done
2022年
6月16日
木曜日
20時15分55秒
JST

当書に載っている以下のコード(端末に直接入力されていたのでシェルスクリプトにしています)は Bourne シェルと POSIX シェルで異なる結果を返します。

#!/bin/sh

IFS=/
for i in /abc///def/
do
    echo $i
done
# Borne シェルでの出力
abc
def

# POSIX シェルでの出力
 abc   def

これは Bourne シェルと POSIX シェルで互換性がない点の一つです。POSIX シェルでこのような出力になる理由を説明すると、まず POSIX シェルではソースコードに直接書いてある文字列に対しては単語分割は行われません。つまり /abc///def/ がそのまま変数 i に代入されます。一方その後の echo $i では変数を指定しているため単語分割が行われます。つまり / で区切られ echo '' 'abc' '' '' 'def' のように解釈されます。単語分割の際に末尾が空になった場合はなかったことにされます。echo の仕様で複数の引数は(IFSではなく)スペースを挟んで結合されるため、.abc...def となります(読みやすさのためにスペースを . に置き換えています)。

以下のように in の後を変数にした場合は単語分割が行われます。

#!/bin/sh

IFS=/
list="/abc///def/"
for i in $list
do
    echo $i
done

しかしながら出力は Bourne シェルとは異なります。


abc


def

これは IFS が空白の場合と空白以外の場合で処理が異なり、空白の場合は連続する空白が一つとして扱われるのですが、空白以外の場合はそうではないからです。空白の場合は以下のようになります。なんとも理解が難しい仕様です……

#!/bin/sh

IFS=" "
list=" abc   def "
for i in $list
do
    echo $i
done
abc
def

後半のどこかで出てくる文章ですが for FILEfor FILE in $* と同じですと書いてありますが、実際は for FILE in "$@" と同じです。

8.3.4 set コマンドでの場合

set -- を使って位置パラメータを再セットすると元の位置パラメータがクリアされるため、元の位置パラメータが必要な場合、別の変数に代入して置かなければならない」と書かれていますが、実はこれは難しいです。なぜなら POSIX で標準化された範囲では配列変数がないので、複数の位置パラメータを結合して一つの変数に入れると壊れてしまうからです。bash など配列があるシェルでは簡単です。

back=("$@")

元の位置パラメータを保存して置かなければならない場合、関数を利用するのが簡単です。

func() {
    # 位置パラメータの再セットは関数内に閉じているので、呼び出し元には影響しない
    set -- a b c "$@"
}

set -- 1 2 3
func "$1"
echo "$@" # => 1 2 3

なぜこの話が当書に書かれていないのかと言うと、Bourne シェル SVR3 (1986) よりも前のシェルでは、関数内の位置パラメータが存在しなかったからなのでしょう。昔はグローバルな一つの位置パラメータしかありませんでした。

8.3.6 cut コマンドの場合

補足ですが、このコマンドは他のコマンドと空白の扱いが異なり、連続する空白を一つとみなしません。

$ echo "a     b" | cut -d' ' -f2

$ echo "a     b" | awk '{print $2}'
b

8.4.2 問い合わせメッセージの出力

echo -n で大半のシェルは改行を抑制できるが、System V 系ではできないという話ですが、話はもっと複雑です。System V 系では \c を出力すれば改行を抑制できると書いてありますが、少なくとも Solaris ではそのように動作せず改行されてしまいます。

他にも echo-e-E のオプションが実装されていたりしなかったりしますし、エスケープ文字を解釈したりしなかったりします。

よって、ここで提示されている関数はうまく動作せず、出力の改行を抑制したければ printf を使うのが一番簡単です。

8.4.5 応答待ち状態にタイムアウトを設定する

read の読み込みにタイムアウトを付ける方法で、バックグラウンドプロセスで sleep して kill するコードですが、当書にも書いてあるようにこれは無理があるコードです。bash などでは read コマンドにタイムアウトを行うためのオプションがあるので可能であればそれを使用してください。

当書に書かれたコードでも動くと思いますが、このような処理はシェルによって動作が異なり作り込みが必要です。当書には「注意」として kill によるエラーメッセージを捨てることが出来ない旨が書かれていますが、おそらくこれは捨てることが出来ます。ただしその方法がシェルに依存し、試行錯誤が必要でシェル毎にテストが必要になるのが私の経験から明らかなのでコードは省略します。

8.4.6 ある条件下でのメッセージ出力

以下のようなコードが紹介されていますが、

if [ "$VERBOSE" = "TRUE" ]; then
    ECHO=echo
else 
    ECHO=:
fi

$ECHO "Message"

以下のようなコードをおすすめします。

if [ "$VERBOSE" = "TRUE" ]; then
    ECHO() { echo "$@"; }
else 
    ECHO() { :; }
fi

ECHO "Message"

理由は、コマンド呼び出しという形を変えずにすむのと echo を呼び出すオプションなどを単語分割に頼らずに柔軟に指定することが可能になるからです。

8.4.7 端末のクリア

tput コマンドや clear コマンドがない環境があるというのは今も事実です。しかし当書で書かれているように、空白行を画面の数だけ出力するという強引な方法を取る必要はありません。エスケープシーケンスをそのまま出力すれば良いからです。

厳密に言えば、ユーザーが使用している端末によって、対応しているエスケープシーケンスが異なる可能性があり、だから tput コマンドを使うべきということなのですが、エスケープシーケンスが異なるいろんな(ハードウェアの)端末があったのは 30 年ぐらい前の話だと思います。現在はほとんど vt100 や xtrerm 互換のターミナルソフトが使われておりエスケープシーケンスをそのまま出力してもほとんど問題はおきないでしょう。

以下が端末クリアのエスケープシーケンスです。

$ # macOS
$ clear | hexdump -C
00000000  1b 5b 48 1b 5b 32 4a                              |.[H.[2J|

$ # Linux
$ clear | hexdump -C
00000000  1b 5b 48 1b 5b 32 4a 1b  5b 33 4a                 |.[H.[2J.[3J|

つまりはこれを printf で出力するだけです。

printf '\033[H\033[2J\033[3J'

\033 は 8 進数表記で 16 進数表記に直すと \x1b です。printf はPOSIX で標準化されているのは 8 進数表記だけなので、こちらが移植性が高いです。(比較的最近の環境なら 16 進数表記でも問題ないと思いますが)

8.4.8 ベルを鳴らす

こちらも同様に echo の問題に対処する方法が長々と書かれていますが printf を使えば解決します。

8.6 ファイルとディレクトリ

pwd コマンドの話ばかりで PWD 変数が登場しないのは Bourne シェルにはなかったからです。通常は PWD 変数を参照しましょう。外部コマンドを呼び出さない分速いです。

8.6.1 ファイル名、ディレクトリ名の取り出し

ファイル名やディレクトリの部分を取り出すのに basenamedirname を使用しています。これを使って構わないと思いますが、パラメータ展開を使っても取り出すことが出来ます。ただしパラメータ展開を使う場合、パスの表記の揺れ、例えばディレクトリ名の最後に / がついている場合はどうするか?などがあるので少々厄介です。

パラメータ展開を使うと速いのですが、エッジケースへの対応が必要なのでそれを実装した関数が必要になります。作った記憶があるのですがどこにあるのか忘れました。

この項目でも IFS=/for f in ../../usr/include の引数リストを単語分割して繰り返すコードが載っていますが、POSIX シェルでは前述したように動かないので注意してください。

8.6.2 完全パスを得る

移動前のディレクトリに戻るために現在のディレクトリを変数に入れていますが OLDPWD があるので不要です。この変数には cd コマンド実行時に移動前のディレクトリが自動的に格納されます。

9 シェル関数の例

この内容をレビューするとなると、シェル関数全体を書き換えなければならなくなるので省略します。一つ言うならば、当書ではシェル関数の命名規則にキャメルケース (IsNumeric のようなもの) を採用しているのですが、これはシェル関数の命名規約として一般的ではありません。スネークケース (is_numeric) を使いましょう。

また実行環境の判断に uname を使って、Linux だったら〜、FreeBSD だったら〜 などと分岐して使用するコマンドを決めているのですが、これは良くない手法です。なぜなら FreeBSD であっても Linux (GNU) 版のコマンドを使うかもしれないからです。例えば FreeBSD の環境に GNU コマンドを標準とは異なるパスにインストールし、環境変数 PATH を設定すれば使うことが出来ます。

各コマンドの挙動の違いを吸収したい場合は uname が返す環境名を使わずに、実際にコマンドを実行し、その挙動から判断する方法が推奨されます。例えば ping-c1 オプションを付けて動かなければ ping でリトライしたり、何らかの手段でコマンドの実装を判別します。uname に依存しないことがシェルスクリプトの移植性を上げる手段の一つです。

10 シェルスクリプトの例

多数のサンプルコードがあるため、この内容も省略します。

11 デバッグの手法

11.4 書き方のスタイルに関して

11.4.3 クォーティングを避ける方法

以下のような、変数の頭に x などを入れる書き方で「クォーティングを避けられる」と書いていますが、これは絶対にやってはいけない書き方です。なぜなら $1 にスペースで区切られた文字列が入る可能性があるからです。

if [ x = x$1 ]
then
    echo '$1 is empty.'
fi

上記のコードは $1 に例えば「 -o x = x」という文字列が入っていたら、空文字だと誤判定してしまいます。文字列に変数展開を含む場合、クォーティングを避けてはいけません。以下のコードのみが許されるコードです。

if [ "" = "$1" ]
then
    echo '$1 is empty.'
fi

また比較する文字列に x_ やその他の文字が使われている場合もある)を前置するイディオムはもはや必要ありません。それは Bourne シェルや古いシェルのバグのための回避策であり今は不要です。ShellCheck はこのイディオムが使用されているのを検出すると「SC2268 - Avoid x-prefix in comparisons as it no longer serves a purpose.」という警告を出力するようになっています。(参考 What exactly was the point of [ “x$var” = “xval” ]?

# 例えば $1 が "!" の時 Bourne シェルやかなり初期のシェルでは
# エラーになっていたが POSIX に準拠したシェルであれば問題なく動作する
[ "$1" = "test" ]

11.4.9 中括弧

補足です。以下のような書き方が出来るシェルは ksh88 / ksh93 です。ksh は C 言語のような書き方が導入されたシェルです。 2022-09-24 訂正

避けたほうが良い書き方として以下のような書き方が紹介されていますが、これはむしろ使用してはならないと言って良い書き方です。

case value {
    *) echo "This is a test." ;;
}

for f in a b c
{
    echo $f
}

UNIX シェルの中にはこのような書き方が出来るシェルがあると書いていますが、どうやら Bourne シェルの文書化されていない書き方として導入されたようです。例えば Solaris 10 の Bourne シェルでも動作します。その後、おそらく Bourne シェルとの互換性を実現する目的で、bash、ksh、mksh、zsh などに移植されたようです(注意 bash は case value { ... } には対応していない)。しかし POSIX では標準化された書き方ではなく、dash や yash など使用できないシェルがあり、広く使われている書き方ではないため、使う必要はありませんし、使ってはいけません。mksh のドキュメント には以下のように、歴史的な理由で対応しているが移植性はないと書かれています。

case word in [[(] pattern [| pattern] ...) list <terminator>] ... esac
    ...
    For historical reasons, open and close braces may be used instead
    of in and esac, for example: "case $foo { (ba[rz]|blah) date ;; }"

for name [in word ...]; do list; done
    ...
    For historical reasons, open and close braces may be used instead
    of do and done, as in "for i; { echo $i; }" (not portable).


select name [in word ...]; do list; done
    ...
    For historical reasons, open and close braces
    may be used instead of do and done, as in: "select i; { echo $i; }"

12 汎用性

タイトルが「汎用性」となっていますが、この項目の英語版のタイトルは「Portability」なので「移植性」と翻訳するべきでしょう。

この項目の汎用性移植性を高める方法の話は、この本が執筆された当時の環境に依存した話なので、今読んでもほとんど役に立ちません。

12.1 シェルスクリプトの汎用性

当時の考え方が今と違っていることが読み取れます。シェルスクリプトの方が移植性が高い理由は「実行形式のプログラムはソースを入手し、そのシステムでリコンパイルしないと使えない」からと書かれています。今はプログラムはコンパイル済みのものをパッケージでインストールするのが一般的な手法です。シェルスクリプトの方が移植性が高いというのは、ソフトウェアのインストールは大変であるという前提に基づくもので、現在の基準には当てはまらないものとなっています。

しかもシェルスクリプト以外にもスクリプト言語が登場しているため、コンパイルせずに動くというのは別にシェルスクリプトだけの利点ではなくなっています。ここで語られている「(シェルスクリプトを)より汎用性を高めるにはどうしたら良いのか?」の内容は、今ではシェルスクリプトの移植性が低い理由にしかなっていません。

12.3.4 シェル関数

シェル関数はすべての POSIX シェルでサポートされています。

12.4.4 echo コマンド

移植性が気になる場合 printf を使いましょう

12.4.5 env、printenv コマンド

env コマンドだけが POSIX で標準化されています。従って printenv を覚える必要はもうありません。

12.4.6 getopts コマンド

getopts コマンドはすべてのシェルで引数をサポートしており POSIX でも標準化されています。

12.4.14 shift コマンド

shift コマンドはすべてのシェルで引数をサポートしており POSIX でも標準化されています。

12.4.19 unset コマンド

unset コマンドはすべてのシェルでサポートされており、POSIX でも標準化されています。

12.4.20 until コマンド

until コマンドはすべてのシェルでサポートされており、POSIX でも標準化されています。

13 FAQ

13.3 @(#) は何を意味しているのか?

世界初のバージョン管理システムである SCCS 用の what コマンド用のマーカーで、書いておくと以下のような出力が得られます。

$ what echo
echo
	PROGRAM:echo  PROJECT:shell_cmds-216.60.1
	PROGRAM:echo  PROJECT:shell_cmds-216.60.1

便利と言えば便利なのですが、今では SCCS 自体が使われてないので、what コマンドも殆ど使われていないと思います。一応 POSIX で標準化されているコマンドなので macOS では使えたりしますが、Ubuntu などではパッケージすら用意されていないような気がします。what コマンドだけ独立させる流れが生まれれば生き残る可能性もありえる?

13.4 goto させるにはどうすればいいのか?

このような項目があることに驚きます。今では goto なんて普通に使わないものなのですが、当書が書かれた当時はまだ気になることだったのでしょうね。当時 Bourne シェルから goto を取り除いたのは英断だと思います。

A いろんなシェルについて

A.6 どのシェルを利用するか

今の状況は全く変わっているので訂正します。以前の評価を取り消し線付きで右に書き残しておきます。太字のシェルは新しく追加したものです。

シェル 対話処理 スクリプト処理 記述形式 汎用性 移植性
Bourne 不可 不可 Bourne 不可
Korn (ksh88) POSIX Bourne 不可
Korn (ksh93) POSIX
dash・ash 不可 POSIX
POSIX 不可 POSIX Bourne 不可
bash POSIX Bourne 不可
C Shell (csh) 不可 C Shell 不可
tcsh 不可 C Shell 不可
zsh POSIX Bourne 不可
fish Fish 不可

Bourne シェルは POSIX と互換性がなく使われておらずもはや移植性は不可です。ksh88 は POSIX 標準規格の元になったシェルで POSIX に近いですが完全に準拠していません。ksh93 の移植性は(POSIX で標準化されていないがほぼ実装されている)local コマンドに未対応なので「良」に少し下げました。Perl はシェルではないので省きました。

移植性を最大限にアピールしたい場合、dash で動くようにするのが良いです。しかしどの環境にでもインストールされているシェルというものはなくなってしまったので、最大限の移植性を実現したい場合は、少なくとも標準で使われているシステムシェル(/bin/sh として使われているシェル)でテストしなければいけません。Bourne シェルで動けば他のシェルでも動くだろうという時代は終わりました。POSIX 標準規格に準拠しつつ、シェル間の違いを吸収する必要があります。

また異なるシェルとの移植性を考えず、bash が入ってない環境があるならインストールすれば良いの精神で、bash 依存で書くのも手です。昔とは異なり一般的なソフトウェアのインストールは簡単で、bash はどこでも動く(移植されている)シェルだからです。

2023-05-22 追記

項目の一番右の「汎用性」となっている部分は、原著英語版では「Availability」となっており、ここでは「利用可能」と訳すべきものです。つまり Bourne は利用しやすく Bash は利用しづらい(原著が書かれた 1995 年時点の話)というのが本来の意味でした。原著では Availability の定義も説明しており「システムに予めインストールされている」ことであり「Bash は無料で入手しやすいにも関わらず、System V の Korn シェルよりも普及していない」と書かれています。おそらく日本語版が書かれた 1999 年では状況が全く変わっていたためこの説明を削除したのでしょう。下記に原著の表を引用します。一番右の項目は 2023 年現在の私の評価です。

Shell Suitable for
Interactive
Suitable for
Scripts
Syntax Style Availability
1995年時点
Availability
2023 年時点
Bourne No Yes Bourne Excellent Rare
Korn Yes Yes Bourne Good Good
POSIX Yes Yes Bourne Rare Excellent
Bash Yes Yes Bourne Rare Excellent
C Shell Yes No C Shell Excellent Rare
tcsh Yes No C Shell Rare Rare
Perl No Yes - Good Good

B 記述法一覧

書こうと思えば書くことはありますが、結局の所シェルの文法の解説を行うことにしかならないので、POSIX などを参照してください。

さいごに

この記事を読んで訂正しなければならない内容は結構あるんだなと思ったのではないでしょうか?

UNIX シェルという言葉からもわかるようにシェルは UNIX で誕生しました。最初に誕生したのは Bourne シェルです。UNIX と Bourne シェルの立ち位置は、今は Linux と bash に置き換わっています。歴史の境界線は 1995 年あたりです。これより前は UNIX 全盛期、これより後は Linux の時代へと移り変わっていきます。POSIX でシェルとコマンドが標準化されたのは 1992 年ですが、すぐに広まったわけではありませんので 1995 年あたりはまだ POSIX 以前の知識が主流だった時代でしょう。2000 年代には Linux の時代が確定的となり、2010 年代にもなれば UNIX と Bourne シェルの知識は、十分古い知識に分類されます。

UNIX と Bourne シェルは伝説的な OS やシェルのように扱われていたりします。その伝説は今も色褪せることなく UNIX やシェルの "思想や哲学" は今に受け継がれています。しかし "技術" は 30 年も経てばさすがに古くなります。Bourne シェルは消えてしまいましたし UNIX の本家(AT&T、SystemV 系)は衰退してしまいました。Unix は BSD や macOS と言った分家だけが生き残っています。

私が言いたいことは、いまだに UNIX や Bourne シェルの技術ばかりを中心に解説している本やネット上の記事は POSIX 以前の古い話をしているのではないのか?POSIX 以前と以降の区別がついてないのではないか?と疑った方が良いかもしれないということです。内容が古い可能性があり、当時との違いを把握していない人がそれらを参考にした場合、時代遅れになったことや今では間違いになってしまったことを学習してしまうかもしれません。

困ったことにシェルスクリプトに関する良書はあまり多くはありません。オライリーの本は古い上に初心者が読むには難しいものばかりです。入門書もシェルスクリプトの基本(文法 = シェルコマンド言語)をろくに解説せずに、応用(コマンドの使いこなし)ばかりを教えている本が多いと感じます。そういった中で「入門 UNIX シェルプログラミング」はシェルスクリプトの基本を教えている数少ない良書です。しかし古いのです。今のシェルプログラミングとの違いが、この記事で少しでも伝えられればと思います。

287
316
6

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
287
316

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?