🚀

プログラミング言語としての シェルスクリプト チュートリアル (1)

2024/05/13に公開

次の記事

シェル スクリプト で構造化プログラミングをする

シェル スクリプト といえば、いくつかのコマンドを並べて実行するぐらいで、本格的なプログラミングをするのであれば Perl や Python を使うべきと思っているかもしれません。しかし、実際は、シェルスクリプトでも C言語に近い関数や制御構文を使った構造化プログラミングができますデバッガーもあります

シェルスクリプトを使う利点は、CLI に入力するコマンドをそのまま書くことができることです。ライブラリの システム コール の関数を使って、パラメーターの区切りをコンマに変えることは不要です。環境変数を設定するのにライブラリを使うことも不要です。1つのコマンドで使えるものが複数の関数に分かれることもありません。

ただ、その代わりに、かなりクセの強いコードになってしまっているスクリプトが多いです。謎の記号や略語がたくさん出てきて読むことができないことがよくあります。

しかし、それは書き方が悪いだけで、書き方さえ分かってしまえば、普通のプログラミング言語とほとんど変わらなく、シェルスクリプト固有の知識がほとんどなくても読むことができるようになります。1980年代のシェルとの互換性を維持するためにクセの強い文法に従うことは価値がありません。

function  ChkConfig() {
    local  first="$1"
    local  sub="$2"  #// コメント
    local  number="$3"

    if [ "${sub}" == "" ]; then
        echo  "${number}: ${first}"
    else
        echo  "${number}: ${first}-${sub}"
    fi
}

本記事は、シェルスクリプトをプログラミング言語として新たに学ぶ方のために書かれたチュートリアルです。章の構成は A tour of Go という Go言語のチュートリアルになるべく合わせています。

ダウンロード

本書で紹介しているスクリプトのファイルは、Git からダウンロードすることができます。Linux のシェルを開くか、Windows の Git bash を開いて、以下のコマンドを実行してください。

$ cd  ${HOME}
$ git clone https://github.com/Takakiriy/shell-script-tutorial

Hello, world

新しくプログラミング言語を学ぶときの最初のサンプルは、Hello, world を表示することが定番ですので、まずはそのサンプルを示します。

~/shell-script-tutorial/1-print/1-hello.sh

#!/bin/bash

echo  "Hello, world"

1行目はシェバン(シバン, shebang)と呼ばれるもので書いておくと Linux ではそのパスのシェルを使い、Windows Git bash ではファイルに実行可能モードが付きます。

このスクリプトを実行するには、以下のコマンドを実行します。実行するときやマニュアルに書くときは、cd コマンドに カレント ディレクトリ(カレント フォルダー)を正しく設定してください。

$ cd  ~/shell-script-tutorial/1-print
$ ./1-hello.sh
Hello, world

自分でファイルを作ったときは、Linux では chmod で実行属性を付与してから実行できます。

$ cd  ~/shell-script-tutorial/1-print
$ chmod +x 1-hello.sh
$ ./1-hello.sh
Hello, world

本書ではシェルスクリプトをプログラミング言語として使うので、Main 関数を作るとそれらしくなります。ただし、ファイルの最後で Main 関数を呼び出す必要があります。

~/shell-script-tutorial/1-print/2-main.sh

#!/bin/bash

function  Main() {
    echo  "Hello, world"
}
Main

スタイル

シェルスクリプトを原理主義に従って書くと非常に独特で読みにくいスクリプトになってしまうのですが、他のプログラミング言語と同様に読むことができるようなスタイルを採用すると、かなり読みやすくなります。

関数

シェルスクリプトはコマンドを並べたものが基本なので、シェルスクリプトのスタイルもコマンドに合わせて ケバブ ケース(小文字とハイフン)で書くべきだと考える人が多いでしょう。しかし、違うものまで統一していくのは乱暴であり逆に良くありません。

コマンドの呼び出しなのか関数の呼び出しなのか、どちらなのかを知りたくなることはかなり多いです。

そこで関数は パスカル ケース(先頭と区切りは大文字で他は小文字)で名付けます。

ChkConfig  example_service  on

次のスクリプトの chkconfig は関数かコマンドかどちらを呼び出すでしょうか。

chkconfig  example_service  on

答はどちらの可能性もあります。chkconfig は SysVinit に関する Linux コマンドですが、スクリプトで同じ名前の関数を定義したら関数呼び出しになるからです。次の実証コードで確認することができます。なお、動きの分かりやすさのために chkconfig コマンドの代わりに head コマンドを対象にしています。

~/shell-script-tutorial/2-function/a-bad-override.sh

#!/bin/bash

head -1  "a-chkconfig.sh"  #// #!/bin/bash と表示

function  head() {
    echo  "in Function"
}

head -1  "a-chkconfig.sh"  #// in Function と表示

もし、パスカル ケース で関数を定義するルールであれば、すぐにコマンドか関数かどちらであるかを判別できます。

ChkConfig  example_service  on

判別できると、その内容をすぐに調べることができるようになります。関数であれば、同じファイルの中を検索し、コマンドであればネットを検索するか which コマンドで検索します。

which  chkconfig

ネットで見つからなかったら、なら関数だと考えることはほとんどありません。適切な検索キーワードではなかったんだと試行錯誤して見つかるまで探そうとします。これでは開発効率が非常に悪くなります。

ちなみに、コマンド名は ケバブ ケース にほぼ統一されていますが、フォルダー名(ディレクトリ名)は ssh_config.bash_profile など スネーク ケース だったりして自由だなと感じます(皮肉)。やはり ケバブ ケース は読みやすさよりシフトキーを押すのがめんどくさいだけだと思います。

また、定義する関数名の左は 2つの空白文字で区切ります。こうすることで 2つの空白にちょっとした空間を感じるようになり、主役である関数名を見つけやすくなります。

function  CheckConfig() {
    ...
}

後で説明するコマンド置換は $( ) で書きます。このスタイルで書く理由は後で説明します。

local  names="$( cat users.txt )"

変数

関数のローカル変数は、キャメル ケース(先頭は小文字で区切りは大文字で他は小文字)で書きます。なお、シェルでは変数に代入するときのイコールの左右に空白を入れることはできません

local  mailAddress="user@example.com"

グローバル変数は、パスカル ケース で書きます。

MailAddress="user@example.com"

環境変数は、大文字のスネーク ケース(区切りはアンダースコアで他は大文字)で書きます。

MAIL_ADDRESS="user@example.com"

この方針はスタイルによってスコープを変える Go言語のスタイルに合わせています。ちなみに小文字で定義された http_proxy 環境変数はスタイル違反をしていて自由だなと感じます(皮肉)。パスカル ケース や キャメル ケース で書くと、同じファイルで定義される関数名のスタイルと同じなので、同じファイルの中で定義されていると直感で気づくでしょう。

変数を参照するときは、$var ではなく "${var}" で書くようにします。

"${var}"

{ } で囲む理由は、JavaScript の テンプレート リテラル

`var = ${var}`

や Python のフォーマット文字列

f"var = {var}"

と同じように読むことができるからです。また、スクリプトでは ${var:1:3}${var%%.*} といった {} を必須とする部分参照をよく使うので、それも理由です。

" " で囲む理由は、複数の空白文字列を 1つの空白文字に勝手に置き換えられることを防ぐためです。

関数

定義

関数定義との基本な書き方はすでに説明したように以下のようになります。

function  ChkConfig() {
    ...
}

次のように関数定義を書くこともできますが、かなり独特で分かりにくいです。JavaScript のアロー関数よりも分かりにくいです。なぜなら Go, Python, JSON などのリテラルのデータと似ているからです。

ChkConfig {  #// データ? いえ、関数です。非推奨
    ...
}

ちなみに、多くのスクリプトでは、関数定義に function が書かれていませんが () は書かれています。これは、TypeScript のメソッドも同様の書き方ですが、ほとんどの言語では(TypeScript でも)関数定義では function 相当の予約語を書くので、function を書いたほうが分かりやすいです。また、多くのスクリプトでは、小文字の スネーク ケース で関数名が命名されることが多いのですが、前述の通りの問題があります。

chk_config() {  #// 非推奨
    ...
}

引数

渡し方

関数呼び出しに引数(パラメーター)を指定するときは、コマンド実行と同様に空白区切りで書きます。

ChkConfig  "first"  second  3

" " で囲むか囲まないかは、原理主義的には空白を含む可能性があるときに囲みますが、空白の可能性に関わらず文字列リテラルや文字列変数や数値変数なら必ず囲みます。文字列なら他の言語と同様のスタイルなのでかなり読みやすくなります。また、連続した空白が1つの空白に勝手に変わらないようになりますし、変数が未定義でも引数の数が変わらないようになります(後述する引数の省略で説明します)。

ChkConfig  "first"  "second"  "${num}"

一方、数値やサブコマンド名(git clone の clone など第1引数に指定するコマンド名の続き)には囲みません。

git clone  "https://github.com/Takakiriy/shell-script-tutorial"

関数の右の空白や引数と引数の間の空白は 2つの空白にします。その方が空白文字列と引数の区切りの違いが明確になります。

他のプログラミング言語との統一性を重視し、読みやすいほうで書きます。もしスタイルが違ったら、違うんじゃない?というレビュー指摘をしますが、必須対応を求めないことです。

引数名

関数定義に引数を受け取るときは、() の中に引数を書くことができないので次のように関数の最初に変数宣言するように書きます。引数は $1, $2, $3 にすでに入っているので処理的には冗長なのですが、かなり読みやすくなります。リーダブルコードの説明変数ですね。

~/shell-script-tutorial/2-function/1-arguments.sh

function  ChkConfig() {
    local  first="$1"
    local  sub="$2"  #// ここに引数の補足説明を書く
    local  number="$3"

    if [ "${sub}" == "" ]; then
        echo  "${number}: ${first}"
    else
        echo  "${number}: ${first}-${sub}"
    fi
}

一般によく見られる、最初に引数を説明変数に代入しないスクリプトと見比べてみましょう。処理内容は変わりませんがかなり読みにくくなります。マジック ナンバー をそのまま使わない、とはよく言われますが、それだけでなく、どんな引数を渡せるか全く分かりません。引数を 3つ渡す必要があることすら分かりません。下記のサンプルは小さいからまだ読めますが普通の関数は $1,$2,$3,... の存在すら探さなければなりません。見落とす可能性が高いです。

function  ChkConfig() {
    if [ "$2" == "" ]; then
        echo  "$3: $1"
    else
        echo  "$3: $1-$2"
    fi
}

もちろん、宣言する順番は引数の順番に合わせてください。下記のように順序がバラバラで関数定義が書いてあったら、関数を呼び出すスクリプトを書く人のほとんどは、2番目に number の値を書いてしまい、バグに悩み始めることでしょう。よくやるのが引数の順番を変えるときに行を入れ替えるだけでなく数字も入れ替えることを忘れることです。注意しましょう。(だから引数を変数宣言することを禁止する、というルールを作るのはアホです)

~/shell-script-tutorial/2-function/2-bad-arguments.sh

function  ChkConfig() {
    local  first="$1"
    local  number="$3"
    local  sub="$2"

    if [ "${sub}" == "" ]; then
        echo  "${number}: ${first}"
    else
        echo  "${number}: ${first}-${sub}"
    fi
}

ChkConfig  "first"  3  "second"  #// 動きが変
    #// 3: first-second と表示されず
    #// second: first-3 と表示されます

ローカル変数

local を書くことで関数内での変数値の変更が呼び出し元に影響しなくなります。

引数でないローカルでしか使わない変数にも、積極的に local を書いていきましょう。

function  ChkConfig() {
    ...
    local  response="..."
    ...
}

ちなみに呼び出し先でも local 変数の値は参照できてしまいます。なので引数に渡していないのに動いてしまったということが暗黙のうちに発生します。しかし、そのスクリプトは読むことが非常に困難です。

引数の省略

呼び出すときに引数の数が足らなくてもエラーにはなりません。なので、引数を省略して呼び出すこともできます。しかし、引数を省略しても正しく動くように関数が定義されていなければ、エラーになったり期待と違う動きをしたりしてしまうでしょう。

引数を省略すると、"$1" などは空文字列になります。厳密には空文字列ではなく未定義なのですが、変数値を参照すると空文字列が返ってきます。

function  ChkConfig() {
    local  first="$1"
    local  sub="$2"
    local  number="$3"  #// 下記のように呼び出したときは空文字列
    ...
}

ChkConfig  "first"  "second"

最後以外の引数を省略することはできませんが、空文字を指定することはできます。その場合、"" を書く必要があります。コンマ区切りならコンマを連続すれば間の引数を省略できますが、空白区切りでは空白を連続しても間の引数を省略することにはなりません。

ChkConfig  "first"  ""  3

引数のを関数の中で参照するときは、$# または "$#" を書きます。

~/shell-script-tutorial/2-function/3-argument-count.sh

#!/bin/bash

function  ChkConfig() {
    echo  $#     #// 3 と表示
    echo  "$#"   #// 3 と表示
}

ChkConfig  "first"  "second"  3

オプション解析

コマンドに指定するハイフンから始まるオプションを解析するときは、【最終完全版】 bash/zsh 用オプション解析テンプレート (getopts→shift) を参照してください。

返り値

終了コード, return

シェルスクリプトでも return 文を書くことができますが、終了コードしか返すことしかできません。終了コードは 0〜255 の整数です。return に渡す値がマイナスでも 256以上でもエラーにはなりませんが、256で割った余り(剰余)が返ります。たとえば return 256 は 0 が返り、return 258 は 2 が返り、return -1 は 255 が返ります。

関数やコマンドを呼び出した後で終了コードを参照するには $? を書きます。整数が返されますがシェルスクリプトには型が無いので文字列が返ります。

~/shell-script-tutorial/2-function/4-return.sh

#!/bin/bash

function  Return1() {
    return  1
}
function  ReturnMinus1() {
    return  -1
}

Return1
echo  $?  #// 1 と表示

ReturnMinus1
echo  $?  #// 255 と表示

また、終了コードが 0 以外だったときは、環境や設定によっては、スクリプトの実行が中断されます。特に CI/CD 環境で実行する場合、ほとんどの環境で中断されます。関数やコマンドを呼び出すコードで、次で説明するコマンド置換 $( ) の中で関数やコマンドを呼び出した場合は、終了コードが 0 以外でも必ず中断されませんが、関数定義するときは基本的に中断されるものと考えてエラーコードとして終了コードを関数定義すべきです。

標準出力を返り値にするコマンド置換

return 文では終了コードしか返せませんが、コマンド置換(command substitution)を使うことで echo コマンドや cat コマンドなどの標準出力を変数に代入することができ、それは文字列を返すことができるとみなせます。次のコードは、users.txt ファイルの内容の全体が names 変数に代入されます。1行ではなく全ての行です。標準出力の内容が変数に代入されるので、インストールしたコマンド(プログラム)の標準出力の内容も代入できます。

~/shell-script-tutorial/2-function/5-command-substitution.sh

local  names="$( cat users.txt )"

コマンドを実行したときに表示される終了のうち、標準エラー出力の部分は変数に代入されずに表示されます。表示されるのでエラーの内容をユーザーが知ることができます。コマンドの表示内容が全て変数に代入されるわけではありません。このような動作はリダイレクトやパイプと同じです。どうしても標準エラー出力も代入したいときは 2>&1 でリダイレクトします。ログを保存するためならリダイレクトすべきですが、そうでないときはユーザーにエラーを知らせるためにリダイレクトさせません。

local  log="$( cat no_file  2>&1 )"

標準出力の内容をスクリプトで直接扱える機能をコマンド置換と言います。コマンド置換であることを判別するポイントは、ダラーの右がカッコであること $() です。

"$( CheckConfig )"

カッコの内側には空白を入れたほうがかなり読みやすくなります。変数との違いが明確になります。空白を入れないスタイルに統一したところでバラバラ感はなく、むしろコマンド置換ではない通常のコマンドと統一感があります。

ちなみに、古い書き方としてコマンド置換を以下のように書くこともできます。

`CheckConfig`

しかし、

"$( CheckConfig  "$( GetItem )" )"

のようにネストさせることは古い書き方ではできません。引数に関数呼び出しを指定することはよくあるので、$( と ) に統一した方がいいでしょう。

標準出力を代入しつつ終了コードを判定したいこともよくありますが、普通に書くと $( ) の中のコマンドの終了コードは捨てられてしまい判定できません。判定したいときは、次のようなトリッキーなスクリプトを書けばできます。

local  variable="$( CommandExample  ||  echo "(ERROR)" )"

~/shell-script-tutorial/2-function/6-command-substitution-error.sh

function  Main() {

    local  variable="$( CommandExample  ||  echo "(ERROR)" )"
    echo  "variable = \"${variable}\""
    ErrorIfLastIs  "${variable}"  "(ERROR)"
    echo  "Done."
}

function  CommandExample() {
    echo  "output example"
    return  0  #// or 1
}

function  ErrorIfLastIs() {
    local  output="$1"
    local  tag="$2"

    local  last="${output:${#output}-${#tag}:${#tag}}"

    if [ "${last}" == "${tag}" ]; then
        exit  2
    fi
}

実行すると

$ ./6-command-substitution-error.sh  
variable = "output example"
Done.
$ echo $?
0

CommandExample 関数の中を return 1 に変えて実行すると

$ ./6-command-substitution-error.sh 1
variable = "output example
(ERROR)"
$ echo $?
2

主な処理は Main 関数の最初で呼び出す CommandExample です。 $( ) の中にあるので出力される output example が variable 変数に入ります。return 0 なので終了コードは 0 であり、|| によって echo "(ERROR)" は実行されません。もし、return 1 に変えたら終了コードが 0以外なので || によって echo "(ERROR)" は実行されます。つまり、終了コードの違いによって variable 変数の最後に (ERROR) が付くか付かないかが違ってきます。 ErrorIfLastIs 関数は今回の場合、variable 変数の値の最後が (ERROR) であるなら exit 2 が実行されスクリプトが中断します。CommandExample が正常に終了しても標準出力の最後が (ERROR) だったら中断してしまいますが、それが問題になることはないでしょう。よくエラーの内容を調べるときにログを検索するときに error というキーワードで検索すると思いますが、このように error という文字列は特別扱いされているからです。

コマンド置換によって変数に代入された標準出力の内容は画面に表示されなくなります。これはリダイレクトと同じ動きです。なので、デバッグのために echo "ERROR" を一時的に書いたとしても画面には表示されず、かつ変数の値に混ざってしまいます。これを避けるには、echo コマンドなどの出力を >&2 で標準エラー出力に出力させることです。

echo  "ERROR"  >&2

~/shell-script-tutorial/2-function/7-stderr.sh

#!/bin/bash

function  Main() {
    local  result="$( ChkConfig )"
}

function  ChkConfig() {
    echo  "output example"
    echo  "ERROR"  >&2
}

Main  "$@"
    #// output example は表示されません
    #// ERROR は表示されます

ライブラリ

同じファイルに書いてコピペする

複数のシェルスクリプトで共通に使われる関数を、別のファイルに定義することは、基本的には行いません。呼び出す関数の定義は、すべて呼び出し元の関数が書かれているシェルスクリプトと同じファイルに書くことが基本です。

~/shell-script-tutorial/3-library/1-same-file.sh

#!/bin/bash

function  Main() {
    Sub
}

function  Sub() {
    echo  "Sub!"
}

Main

同じファイルに定義することのメリット(別のファイルに定義を一元化しないことのメリット)は次の通りです。

  • 関数定義の場所がすぐに見つかる
  • 実績を積んでから利用するようになる(他への影響が少ない)

他への影響が少ないことは、アジャイルな開発に大きく貢献するでしょう。もし、ライブラリにバグが見つかったらライブラリの修正だけでなくデグレードが発生していないことをテストしなければならず、修正にかなり時間がかかってしまうからです。他でも更新された内容を使いたいときはコピペするだけです。それだけです。それは再利用とは言えないとか過大なコストだと勘違いする中級者が多くて悩ましいです。

もし、いろいろな場所で修正が発生する場合は、バージョン管理のために最新版を置く場所を用意すればいいです。それだけで、最新版がすぐに見つかります。

随時コピーすることで、突然動かなくなり、その原因は利用するバージョンがいつのまにか違っていたというバージョン地獄から抜け出せます。

別のシェルスクリプトを呼び出す

どうしても別のファイルに関数定義を書きたいときは、シェルスクリプトのファイルを実行するように書くとよいです。

~/shell-script-tutorial/3-library/2-main.sh

#!/bin/bash
if echo "$0" | grep "/" | grep -E -v "bash-debug|systemd" > /dev/null; then  cd "${0%/*}"  ;fi  # cd this file folder

function  Main() {
    ./lib.sh
}

Main

~/shell-script-tutorial/3-library/lib.sh

#!/bin/bash

function  Sub() {
    echo  "Sub!"
}

Sub

実行

$ cd  ~/shell-script-tutorial/3-library
$ chmod +x  2-main.sh
$ chmod +x  sub.sh
$ ./2-main.sh
Sub!

引数の渡し方や返り値(終了コードや標準出力)の受け取り方は変わりません。

ただし、新しくプロセスが起動されるため、グローバル変数は共有されません。必要なデータをコマンドの引数に全て渡すか一時ファイルに格納する必要があります。しかし、それは分離されているというメリットでもあります。

また、カレント フォルダー に注意しないとファイルが見つからないというエラーになります。ちなみに、下記サンプルの最初にある if echo ... は、カレント フォルダー をスクリプトが置いてあるフォルダーに変更しています。これでスクリプトを起動するときの カレント フォルダー の影響を受けずにスクリプトを確実に呼び出すことができます。なお、if echo ... が少々複雑になっている理由は、デバッガーなどで発生する問題に対処するためです。起動する新しいプロセスに ステップ イン することはできませんが、そのプロセスから改めてデバッグを開始すればできます。その方法はデバッガーについて書いた記事に載っています。

関数定義をロードする(非推奨)

別プロセスになることが深刻な問題になる場合など、シェルスクリプトを起動することをどうしても避けたいときは、source コマンドを使えば関数定義を読み込むことができます。しかし、どこで定義されているのか非常に分かりにくくなります(source は動的なので静的解析できません)。前述したように、バージョンがいつのまにか違っていたという地獄が発生する可能性が高まります。それは修正したのにバージョンが更新されていない場合もあります。また、デバッガーで ステップ イン することもできません。そもそも、source を使ったスクリプトのロードは殆ど知られていないので、将来スクリプトをデバッグすることになった担当者は、ライブラリの関数を呼び出していることすら分からず、対応方法がさっぱりわからなくなってしまいます。なので非推奨です。柔軟にロードできて便利だと考えるのは安直すぎます。副作用を考えずに goto 文はどこにも飛べて便利だと言っているようなものです。

~/shell-script-tutorial/3-library/3-main.sh

#!/bin/bash
if echo "$0" | grep "/" | grep -E -v "bash-debug|systemd" > /dev/null; then  cd "${0%/*}"  ;fi  # cd this file folder

source  "function.sh"

function  Main() {
    Sub
}

Main

~/shell-script-tutorial/3-library/function.sh

function  Sub() {
    echo  "Sub!"
}

コマンドの表示

シェルスクリプトを使う目的はユーザーが入力する内容を自動的に実行することなので、主役はあくまでコマンドです。なので実行するコマンドがどういうものであるかをユーザーに知らせるべきです

echo  "$ ChkConfig  \"first\"  second  3"
ChkConfig  "first"  second  3

RunWithEcho 関数(独自)を使えば、echo 文を書かなくても、自動的にコマンドを表示します。

~/shell-script-tutorial/1-print/3-run-with-echo.sh

function  Main() {

    RunWithEcho  ChkConfig  "first"  second  3
}

function  RunWithEcho() {
    echo  "$ cd  \"${PWD}\""
    echo  "$ $( GetArgumentsString  "$@" )"  >&2
    "$@"
}

function  GetArgumentsString() {
    local  arguments=""
    until [ "$1" == "" ]; do
        if [ "${1:0:1}" == "-" ]; then
            arguments="${arguments} $1"
        elif [ "$( echo "$1" | sed -E 's- |\.|/--' )" != "$1" ]; then  #// has space, period or slash
            arguments="${arguments} \"$1\""
        else
            arguments="${arguments} $1"
        fi
        shift
    done

    echo  "${arguments:1}"
}

実行すると次のように表示されます。もちろん、表示されたとおりにターミナルにコマンドを入力すれば、同じ処理を実行できます。

$ cd  "____/shell-script-tutorial/1-print"
$ ChkConfig  first  second  3

今回はここまで

関数呼び出しだけでかなりの文字数になってしまいました。他にも

  • 配列や辞書などの変数
  • 条件分岐や繰り返しなどの制御構造
  • エラー処理

などのトピックがありますが別の記事でいつか書きたいと思います。

次の記事

Discussion