🚀

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

2024/07/08に公開

前の記事

シェルスクリプトが扱えるのは文字列だけじゃない

シェル スクリプト の変数に代入できる値の型は基本的に文字列型だけです。しかし、その文字列を様々な型として処理することができます。

name="ABC"

文字列を整数や浮動小数点数として計算することができます。(浮動小数点数は bc コマンドを使用)

echo  $(( 2 + 3 ))  #// 5

配列や連想配列は、シェルの機能にあります。

論理型(true, false)も扱えます。

テーブルのフィルターなどの簡単な集合演算ならシェルスクリプトでもできます(grep コマンドなどを使用)。CSV, TSV, SSV といった表形式データの中の値を取り出すこともできます(awk コマンドを使用)。

YAML, JSON などの データ シリアライゼーション 形式として文字列を使えば、複雑なデータ構造を取り扱うことができます。Web サーバーから取得した JSON の中の値を取り出すこともできます(jq コマンドを使用)。YAML で書かれた設定ファイルの中の値を取り出すこともできます(yq コマンドを使用)。

これらの一部は、シェルが直接処理するのではなくコマンドに処理をさせる場合がありますが、インターフェース(コマンド名など)が事実上標準化されているので、シェルスクリプトという言語のライブラリと見なすことができます。

本記事では、様々な型のデータを取り扱うシェルスクリプトをどのように書くのかについて説明します。

ちなみに、Linux でよく扱うデータは、空白区切りテキスト(いわば SSV=Space-Separated Values)です。しかし、それに対して SSV という用語が使われることは少ないです。それはおそらく Linux(POSIX)は美しいアーキテクチャーであり、「要は SSV を扱ってるだけじゃん」もしくは「空白を含むデータは多いのに区切り文字が空白だと扱えないじゃん」と言われるのがイヤだから使われないのでしょう。

ダウンロード

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

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

文字列

まずは、基本である文字列の演算について説明します。

結合

文字列と文字列を結合して1つの文字列を作ります。${ } は、前回に変数の章で説明したとおり変数の参照です。" " で囲んだ中でも使えることを応用して文字列の結合ができます。´ ´ で囲んだ中で変数の参照はできません。

"結果: ${result}"

+ 演算子を使うことはできません。ちなみに、一般的なプログラミング言語でも文字列の結合に + 演算子を使うことは少なくなりました。

"結果: " + result  #// この書き方はできない

複数行の文字列

複数行の文字列が入った変数を初期化する(リテラルで書いた値を代入する)には、基本的には以下のように書きます。多くは言語仕様というより定番の書き方のパターンです。

Lines=\
"Line1
Line2
Line3"

関数の中など、インデントがある場所で複数行の文字列を書くときは、同様に書くとエラーになります。=" の間に空白を入れるとエラーになるからです。\ だけは次の行へ続く記号として入れることができる仕様なので間に入れることができますが、次の行のインデント部分が空白になるためエラーになります。

function  Set() {
    local  lines=\
        "Line1"
#// エラーの原因は = と " の間に空白があるため

インデントがある場所で複数行の文字列を書くときは、次のように書くことができますが、インデントを無くさなければ、文字列の2行目以降にインデントが含まれてしまいます。

function  SetLines() {
    local  lines="Line1
        Line2
        Line3"

    echo  "${lines}"
}

出力:

Line1
        Line2
        Line3

なので、複数行の文字列が入った変数は関数の中で定義せず、グローバル変数で定義したほうが読みやすくなります。

ただし、" 文字や ' 文字は、\ を使って \"\' にエスケープしなければなりません。JSON ファイルの内容など、エスケープが大量に必要なときは、ヒア ドキュメント を使って以下のように書きます。2つの __HERE_DOCUMENT__ の間の行に空白8個のインデントで複数行の文字列を書きます。逆に言えば、インデントを含めて書くことができます。

Lines="..."  #// This value will be overwritten
    read -r -d '' Lines <<- __HERE_DOCUMENT__
        zone "vmlocal.com" IN {
            ...
        }
__HERE_DOCUMENT__
    Lines="$( echo "${Lines}"  |  sed -E 's/^ {8}//' )"  #// Cut space indent

💡 ヒア ドキュメント で書くことでコードの検索性が損なわれることが無くなります。エスケープしたコードの場合、たとえば、エラーメッセージ message="error" が出たときにコードを検索したいなら message="error" で検索してもダメです。 message=\"error\" で検索すればヒットしますが、コードを知らずにそれに気づくことは少ないと思います。

💡 ヒア ドキュメント で書かれていることは、文法的には <<- など << から始まる記号で判定できるのですが、非常に分かりにくいです。__HERE_DOCUMENT__ と書くことで ヒア ドキュメント で書かれていることが分かります。よく EOF と書かれることが多く、意味はかろうじて思い出せるかもしれませんが、知らない人には全く読めないコードですし、仕様やバリエーションを調べたくなったときに ヒア ドキュメント というキーワードが思い出せなくて調べられなくなるでしょう。

ファイルに複数行の文字列を出力するのであれば、リダイレクトが使えます。

    echo  "Line1"  >  "text.txt"
    echo  "Line2"  >> "text.txt"
    echo  "Line3"  >> "text.txt"

改行文字を使えば、複数行の文字列を変数に代入することもできます。ただし、読みにくくなるので、動的に行の数が変わるときなど、必要なときにのみ使うのが良いでしょう。

    local  LF=$'\n'
    local  lines="Line1${LF}"
    lines="${lines}Line2${LF}"
    lines="${lines}Line3"
    #// 最終行に ${LF} を入れると、echo 表示したときの最終行に空行が出力されてしまうので注意

1行ずつ取り出す処理については、配列の章で説明します。

文字列の一部

インデックス指定の場合(何文字目の文字から何文字だけ取り出す場合)、次のように書きます。パラメーターは開始位置(0から)と文字数です。

${__VAR__:__Start0__:__Length__} 

サンプル:

$ Variable="ABCDE"
$ echo ${Variable:2:2}      #// ${__VAR__:__Start__:__Second__} は、__Start__+1 文字目から __Second__ 文字(文字数)
CD                          #// zsh の場合、$VAR[2,3] = "BC"  #// __Start__ 文字目から __Second__ 文字目まで
$ echo ${Variable: 2 : 2}   #// 空白は1つ目の : の右と、2つ目の : の左右に入れることができます
CD
$ echo ${Variable: 2 : -2}  #// __Second__ がマイナスなら、右から指定した文字数だけ除く
C                           #// mac では使えません  エラー substring expression < 0 になります
$ echo ${Variable: 2}       #// 2つ目の数字を指定しないなら、__Start__+1 文字目から最後まで。
CDE
$ echo ${Variable: $((${#Variable}-2))}  #// 右から 2文字
DE
$ echo "abcdefghijk" | rev | cut -c 2-3 | rev   #// 右から 2文字目から 3 文字目まで。 ただし rev は Git bash にありません
ij

💡 2つ目の数字は取り出す文字数です。JavaScript の substring 関数や Go 言語のスライスでは 2つ目の数字が先頭からの文字数になっているので違いに注意してください。ちなみに、Node.js の substr 関数の 2つ目の数字は取り出す文字数ですが substr は訳の分からない理由で非推奨にされてしまっています。先頭からの文字数に肩入れしすぎです。

区切り文字指定の場合(指定の文字より右または左の場合)、次のように書きます。記号で表現し、読むことが不可能なので、必ずコメントで処理内容を説明してください。

$ variable="A.B.C"
$ echo ${variable.*}  #// left of "."   #// ${__VAR__%%__keyword__*} は、最初(左)にマッチした位置より左
A
$ echo ${variable#*.*}  #// right of "."  #// ${__VAR__#*__keyword__*} は、最初(左)にマッチした位置より右
B.C
$ echo ${variable.*}  #// left of last "."    #// ${__VAR__%__keyword__*} は、最後(右)にマッチした位置より左
A.B
$ echo ${variable##*.}  #// right of last "."  #// ${__VAR__##*__keyword__} は、最後(右)にマッチした位置より右
C

区切り文字が * の場合、\* を指定してください。

区切り文字が複数文字の場合は、この書き方はできません。sed などを使ってください。

区切り文字にマッチしなかったときは、変数の値がそのまま返ります。区切り文字を含んでいるかどうかを判定するときは、そのまま返っているかどうかで判定します。ただ、その仕様は ""(空文字)が返る仕様と勘違いしやすいため、コメントで含んできるかどうかを判定していることを説明してください。

local  value="${keyValue#*=*}"  #// right of "="
if [ "${#value}" != "${#keyValue}" ]; then  #// if "${keyValue}" has "="

両端の空白文字やタブ文字を取り除くときは sed コマンドを使います。

$ tab=$'\t'
$ before=" ${tab} a  b ${tab} "
$ echo "(${before})"
(    a  b    )

$ after="$( echo "${before}"  |  sed -E "s/^( |${tab})*|( |${tab})*$//g" )"
$ echo "(${after})"
(a  b)

$ after=${before}  #// "" で ${before} を囲まない場合、zsh では両端の空白が取り除けますが bash では取り除けません
$ echo "(${after})"
(    a  b    )

ただし、上記のコードを読むには sed 記号を読まなくては理解できないため、関数を定義したほうが読みやすいコードになります。積極的に関数定義を書きましょう。

function  Trim() {
    local  before="$1"
    echo  "${before}"  |  sed -E "s/^( |${tab})*|( |${tab})*$//g"
}
tab=$'\t'

after="$( Trim  " ${tab} a  b ${tab} " )"

文字数

前回も説明しましたが、文字列の文字数は以下のように書くと取得できます。

length=${#__Variable__}

位置

文字(長さ 1)の位置を調べるには、区切り文字指定で文字列の一部を切り出して、その文字数をカウントします。

$ variable="example.com"
$ head="${variable%%.*}"  #// left of "."
$ position=${#head}  #// position = length
$ echo  "${position}"
7

検索、含み判定、フィルター

検索は基本的に grep を使います。指定したキーワードが含まれているかどうかを判定するときは、以下のようなコードになります。なお、-E オプションを有効にすると移植性が高まります。

含む:
    if echo "${__Variable__}"  |  grep -E '^[0-9]+$' > /dev/null; then
マッチしない行がある:
    if echo "${__Variable__}"  |  grep -E -v '^[0-9]+$' > /dev/null; then
含まない:  #// すべての行でマッチしない
    if !( echo "${__Variable__}"  |  grep -E '^[0-9]+$' > /dev/null ); then
または
    if [ "$( echo "${__Variable__}"  |  grep -E '^[0-9]+$' )" == "" ]; then

-q オプションを使えば > /dev/null を書かなくて済みます。個人的には -q オプションの意味を忘れてしまい、マッチした行が表示されてしまうと考えてしまうので、使わないですけど。

if echo "${__Variable__}"  |  grep -qE '^[0-9][0-9]*$'; then

数値

bash のシェルスクリプトでは、整数の計算もできます。文字列から整数に変換してから計算します。

使える整数値の範囲は 64bit CPU なら 64bit(-9,223,372,036,854,775,808 〜 +9,223,372,036,854,775,807)、32bit CPU なら(-2,147,483,648 〜 2,147,483,647)ですが、32bit CPU はほとんど無くなったので 64bit 整数としてコーディングしていいでしょう。

整数演算

$(( )) の中に式を書きます。

echo  $(( 2 + 3 ))  #// 5
echo  $(( 7 / 2 ))  #// 3 ... 小数は切り捨て
echo  $(( 1 + 2 * 3 ))  #// 7 ... 掛け算が先

変数の値を参照するときは、$(( )) の中であれば ${ } を書いても書かなくてもどちらでも良いです。ただ、$a 形式では VSCode で $ まで変数であることを示す色がついてしまうので、個人的には a または ${a} で書いています。C言語のポインター参照である *a と同様に a だけに色が付けばいいのですが。

a=2
echo $(( a + 1 ))     #// 3
echo $(( ${a} + 1 ))  #// 3
echo $(( $a + 1 ))    #// 3

計算結果を変数に代入するときは、普通に書けます。文字列として代入されます。

result=$(( a + 2 ))

非推奨ですが、(( )) を使い、代入する変数をその中に書くこともできます。他の人のコードを読むためには、知っておいたほうがいいかもしれません。この書き方でも文字列として代入されます。ただし、local と同時に書くことはできません。また、2つではなく 1つのカッコ ( ) の中(サブシェル)で代入した結果は ( ) の外に反映されないのですが、その仕様と勘違いするかもしれません。以上から (( ))非推奨です

function  CalcA() {
    local  num=2
    local  result=$(( num + 3 * 2 ))
    local  result2

    (( result2 = num + 3 * 2 ))  #// 非推奨
        #// 以下のように書けません:
        #//    (( local result2 = num + 3 * 2 ))
        #//    local (( result2 = num + 3 * 2 ))
    echo  "${num}"  #// 2
    echo  "${result}"  #// 8
    echo  "${result2}"  #// 8
}

浮動小数演算

浮動小数演算をするときは bc コマンドを使います。
ただし、最初に scale で小数の桁数を書かないと整数演算になってしまいます。

echo  "scale=3;  5 / 3"  |  bc   #// 1.666
echo  "5 / 3"            |  bc   #// 1 になってしまう
Result=$( echo  "scale=3;  5 / 3"  |  bc )

なお、Windows Git bash で使うには、MSYS2 に bc をインストールして Git bash の PATH が通ったフォルダーにコピーしてください。WSL2 であれば、ディストリビューションに応じた インストール コマンド(yum など)で bc をインストールできます。

sudo yum install -y  bc

論理型

論理型とは、true(真)または false(偽)という 2つの値を持つことができるデータ型ですが、シェルスクリプトではいくつかの表現方法が使われています。それらは異なる型として取り扱います。つまり、型を合わせるための変換が必要です。なお、下記の変数は非推奨ですが、それ以外はすべて覚えておいたほうがいいです。

方法 True False
文字列 "true" "false"
終了コード 0 1
コマンド true false
変数 True False
既定は偽 "yes" ""

文字列で定義するなら、"true", "false" がいいでしょう。この定義はよく使われているので、私も推奨しています。

condition="true"
    #// または
condition="false"

if [ "${condition}" == "true" ]; then
    echo  "OK"
fi

終了コードに基づいて定義すると、0 なら true で、1 なら false です。これは C言語の true (1), false (0) とは逆であることに注意してください。なぜなら、シェルスクリプトの if 文は終了コードが 0 なら真として分岐しますが、C言語の if 文は演算結果や返り値などが 0 以外なら真として分岐するからです。たとえば、文字列の含み判定で説明した以下のコードは、指定したキーワードが含んでいたら grep の終了コードが 0 になり、含んでいなかったら終了コードが 1 になることを踏まえたコードです。

if echo "${__Variable__}"  |  grep -E '^[0-9]+$' > /dev/null; then

コマンドの先頭に ! を書くと、終了コードを not で反転させます。つまり、終了コードが 0 なら 1 になり、終了コードが 0 以外なら 0 になります。

if !( echo "${__Variable__}"  |  grep -E '^[0-9]+$' > /dev/null ); then

💡 シェルスクリプトの if 文は終了コードが 0 なら真として分岐します。終了コードが 0以外だったら、終了コード以外が 0 でも真として分岐しません。つまり、文字列の "0" if 0(0 コマンドの実行になる)、または、変数に 0 を入れてその変数を参照 if ${a}(0 コマンドの実行になる)、または、標準出力 if echo "0" では正しく分岐しません。if の右に書かれたコマンドを実行した結果として得られる終了コードを基に分岐します。

シェルスクリプトのコマンドの中には true コマンドと false コマンドがあるのですが、これらは何も処理せず終了コードを返すコマンドです。もちろん true の終了コードは 0, false の終了コードは 1 です。

true
echo $?   #// (終了コード)0

false
echo $?   #// (終了コード)1

if true; then
    echo  "OK"
fi

終了コードの数字を変数(定数)に代入する方法も考えられます。数字だけではマジックナンバーであり分かりにくいからです。昔の C言語の方法と同じです。しかし、この方法では論理型の定義を上に書かなければならない制約ができてしまうため、非推奨です。以前私はこの方法を使っていましたが、未初期化によるバグへの対応に何度も時間が取られてきたためやめました。

True=0
False=1
EnableAccess="${True}"

未定義の変数を参照することがよくある場合、文字列の "true", "false" の代わりに、文字列の "yes", "" で定義すると便利です。定義の変数を参照すると空文字列 "" が返りますが、これを false と扱うのです。たとえば、あるコマンドのオプション指定があったら true、無かったら(デフォルトなら)false とする場合です。もちろん、"true", "false" で定義して、"false" で全ての変数を初期化するようにコマンドのオプション指定をコーディングしても動きますが、重要な情報を置くべきファイルの先頭に初期化が並んでしまうとコードの理解の妨げになるというデメリットがあるからです。その場合、true を "yes" (という "true" とは別の文字列)にすることで、false が "" であることを型推論できるようになります。また、条件判定において "" かそうでないかで判定するように書けば、そこでも "yes" または "" の値をとるタイプの論理型という型推論ができるようになります。なお、ここで言う型推論とはプログラミング言語の型推論機能のことではなく、読む人が推論できると言う意味です。

if [ "$1" == "-x" ]; then
    OptionX="yes"
fi

if [ "${OptionX}" != "" ]; then
    echo  "enabled"
fi

なお、コマンドのオプション指定を解析するコードは、【最終完全版】 bash/zsh 用オプション解析テンプレート (getopts→shift) を元に書くと簡単に書けます。

配列

すいませんが、説明が長くなるので、別の記事に書きたいと思います。

連想配列

連想配列(associative array)は、辞書や、キー バリュー とも呼ばれるもので、文字列や数値から関連する値を取得するためのデータ形式です。下記のコードは、employee という連想配列に firstName というキーとそれに関連する値(バリュー) John を設定し、そして参照しています。

eval "$(_set  employee  "firstName"  "John" )"
echo "$(_get  employee  "firstName" )"  #// John

詳しくは、mac の zsh と Linux/Windows の bash で互換性があるシェルスクリプトの連想配列 を参照してください。

データ構造

複雑なデータ構造を扱う場合、CSV, YAML, JSON などの データ シリアライゼーション 形式の文字列を使います。他のプログラミング言語では配列や連想配列を組み合わせてどんなデータ構造でも取り扱うことができますが、シェルスクリプトの配列や連想配列にはいろいろと制約があるので、文字列のほうが簡単に扱うことができます。たとえば、関数の引数に指定できないなどの制約です。

CSV

CSV の特定の列が一致する行に含まれる別の列の値を取得するには、awk を使います。

#!/bin/bash

function  Query() {
    local  idKey="$1"
    local  csvFilePath="data.csv"

    local  value="$( awk -F ','  -v idKey="${idKey}" \
        '$1 == idKey {print $2}' \
        "${csvFilePath}" )"

    if [ -n "${value}" ]; then
        echo  "ID ${idKey} の value: (${value})"
    else
        echo  "ID ${idKey} は見つかりませんでした。"
    fi
}
Query  "123"

上記は 1列目 $1 を検索キーとし、検索キーの値が idKey に一致する行の 2列目 $2 を返します。-v オプションは、パターン アクション ステートメント $1 == idKey {print $2} の中で使えるシンボルを定義します。

もし、CSV のセルの中にコンマが含まれる可能性があるときは、エスケープしてから awk を実行し、結果をアンエスケープすることが考えられますが、具体的には調べられていません。

YAML

YAML の特定のフィールドの値を取得するには、yq を使います。

data.yaml:

database:
    host: localhost
    port: 5432

read_YAML.sh

Host=$( yq e '.database | .host'  "data.yaml" )
echo  "${Host}"  #// localhost

一度の yq の呼び出しで、必要なフィールドを全て指定するほうが早く処理できます。

HostAndPort=$( yq e '.database | .host + ":" + .port'  "data.yaml" )
echo  "${HostAndPort}"  #// localhost:5432

ただし、実行には yq のインストールが必要です。RHEL 系なら yum コマンドです。

sudo yum install -y  yq

JSON

JSON の特定のフィールドの値を取得するには、jq を使います。

data.json:

{
    "database": {
        "host": "localhost",
        "port": 5432
    }
}

read_JSON.sh

Host=$( jq -r '.database | .host'  "data.json" )
echo  "${Host}"

一度の yq の呼び出して必要なフィールドを全て指定するほうが早く処理できます。数値の場合、tostring で文字列に変換しないと、文字列の結合ができません。yq の場合、変換は不要ですが変換しても構いません。

HostAndPort=$( jq -r '.database | .host + ":" + (.port | tostring)'  "data.json" )
echo  "${HostAndPort}"

ただし、実行には jq のインストールが必要です。RHEL 系なら yum コマンドです。

sudo yum install -y  jq

シェルスクリプトの主な処理は集合処理

シェルスクリプトは、1つ1つのデータを扱うことよりも、データベースの SQL のように集合を扱うことのほうが多いです。シェルスクリプトの場合、集合を SSV や データ シリアライゼーション 形式の文字列で表します。

たとえば、SSV に対する grep コマンドは、SQL の SELECT に相当します。sed コマンドは、SQL の UPDATE に相当します。

ですので、1つ1つのデータを演算する処理をループさせるのではなく、集合の文字列に対して集合の処理を書いていってください。集合の文字列を 1行ずつ取り出して処理する必要はありません。

今回はここまで

シェルスクリプトでも色々なデータを扱えることが知れたでしょうか。次回は、

  • 配列

について書きたいと思います。

Discussion