🐄

mac の zsh と Linux/Windows の bash で互換性があるシェルスクリプトの連想配列

2022/05/08に公開約8,500字

OS が異なると連想配列(associative array)の書き方が異なる問題

mac のベース部分は UNIX 系の OS ですが、Linux と同じように使えるかというとそうでもない部分があります。その1つがシェルスクリプトの連想配列です。

Linux や Windows Git bash の環境の bash と mac の環境の zsh および bash では連想配列(associative array)の書き方が異なります。

本記事では、どの OS 環境のシェルスクリプトでも動く互換性がある連想配列の関数の紹介と、その関数を使わない方法まで検討した結果を紹介します。その際、mac において bash がどう扱われているかについても学ぶことができます。

どのように書けば互換性を保てるかをあらかじめ知っておけば、安心してコードを書くことができるようになるでしょう。

連想配列とは

まず連想配列(associative array)がどういうものか説明します。

連想配列とは、連想配列と文字列の組み合わせに対して値を設定、および、取得できるものです。JavaScript で書くと以下のようなコードになります。

JavaScript:

const  employee = {};
employee['first_name'] = 'John';
console.log( employee['first_name'] );  // John

このコードは、次の MDN のページに貼り付けることで動作確認することができます。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/delete

連想配列の関数

後で説明しますが、シェルスクリプトで連想配列を使うと互換性を持たせることができません。

そこで、配列を連想配列としてアクセスする関数を作成しました。連想配列の関数を使ったコードは以下のようになります。

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

_set 関数と _get 関数は、配列にキーとバリュー(値)が交互に入っているものとして処理します。線形検索でキーを検索するのでかなり遅いですが、数十回程度のアクセスなら気にならないでしょう。

下記のコードは Linux, Windows Git bash, mac のすべてで動作します。

~/_tmp.sh

function  main_func() {
    employee=()
    eval "$(_set employee first_name "John" )"
    echo "$(_get employee first_name )"  #// John
}

#// _get
#// Example:
#//    object=(key_A "1" key_B "x")
#//    echo "$(_get "${object[@]}" key_B )"
function  _get() {
    local  object_name="${1}"
    local  key="$2"
    local  operation=""

    operation="_get_sub \"\${${object_name}[@]}\" \"${key}\""
    eval "${operation}"
}

function  _get_sub() {
    local  object_entries=("${@}")
    local  key_index=$(( ${#object_entries[@]} - 1 ))
    local  key="${object_entries[${key_index}]}"
    local  value=""

    for (( i = 0; i < "${key_index}"; i += 2 ));do
        if [ "${object_entries[${i}]}" == "${key}" ]; then
            value="${object_entries[${i}+1]}"
        fi
    done

    echo "${value}"
}

#// _set
#// Example:
#//    object=(key_A "1" key_B "x")
#//    eval "$(_set object key_B "y" )"
function  _set() {
    local  object_name="${1}"
    local  key="$2"
    local  value="$3"
    local  operation=""

    operation="_set_sub \"\${${object_name}[@]}\" \"${object_name}\" \"${key}\" \"${value}\""
    eval "${operation}"
}

function  _set_sub() {
    local  object_entries=("${@}")
    local  count=${#object_entries[@]}
    local  object_name_index=$(( ${count} - 3 ))
    local  key_index=$(( ${count} - 2 ))
    local  value_index=$(( ${count} - 1 ))
    local  object_name="${object_entries[${object_name_index}]}"
    local  key="${object_entries[${key_index}]}"
    local  value="${object_entries[${value_index}]}"
    local  command=""

    for (( i = 0; i < "${key_index}"; i += 2 ));do
        if [ "${object_entries[${i}]}" == "${key}" ]; then

            command="${object_name}[$(( ${i} + 1 ))]=\"${value}\""
        fi
    done
    if [ "${command}" == "" ]; then
        local  new_key_index=$(( ${count} - 3 ))
        local  new_value_index=$(( ${count} - 2 ))

        command="${object_name}[${new_key_index}]=\"${key}\"; ${object_name}[${new_value_index}]=\"${value}\""
    fi

    echo "${command}"
}

function  test_of_get_set_func() {
    object=(key_A "1" key_B "x")
    echo "$(_get object key_A )"
    echo "$(_get object key_B )"
    eval "$(_set object key_A "2" )"
    eval "$(_set object key_B "y" )"
    eval "$(_set object key_C "z  z" )"
    echo "$(_get object key_A )"
    echo "$(_get object key_B )"
    echo "$(_get object key_C )"
}

#// test_of_get_set_func

main_func  "$@"

動作確認コマンド (bash or zsh)

code ~/_tmp.sh
#// 上記コードを ~/_tmp.sh に貼り付けて保存
chmod +x ~/_tmp.sh
~/_tmp.sh
    #// John
rm ~/_tmp.sh

bash 3 には連想配列がありません

mac に付いている bash はバージョン 3系で declare -A による連想配列が使えません。一見、使えるような動きをしますが、連想配列ではない普通の変数の動きになります。間違った値で動き続けるので、非常に危険なコードです。

下記のコードは、mac 12.3.1 で実行したときの結果です。

~/_tmp.sh

#!/bin/bash
declare -A  object_A  #// associative array
object_A["Attr1"]="Value1"
echo  ${object_A["Attr1"]}  #// "Value1"

object_A["Attr2"]="Value2"
echo  ${object_A["Attr1"]}  #// "Value2"  ... 間違った値!
echo  ${object_A["Attr2"]}  #// "Value2"

echo $BASH_VERSION  #// 3.2.57(1)-release

mac に付いている bash がバージョン 3 で止まっている理由は、bash 4 から GPLv3 というライセンスを採用したかららしいです。GPL は伝統的にソースの公開義務がありアップルはそれを懸念したようです。なお、zsh はソースの公開義務がない MIT ライセンスです。

zsh の連想配列

mac の端末で起動するシェル(インタラクティブ シェル)は zsh ですが、スクリプトを実行するのときのデフォルトのシェルは bash 3 です。なお、スクリプトのデフォルトのシェルとは、シェバンを設定しないときのシェルのことです。

そこで、シェバンに zsh を設定すれば zsh の連想配列が使えます。ただし、zsh で連想配列を生成するときは、declare ではなく typeset を使います。名前が変わるので Linux と互換性はありません。

~/_tmp.sh

#!/bin/zsh
typeset -A  object_A  #// associative array
object_A["Attr1"]="Value1"
echo  ${object_A["Attr1"]}  #// "Value1"

object_A["Attr2"]="Value2"
echo  ${object_A["Attr1"]}  #// "Value1"  OK
echo  ${object_A["Attr2"]}  #// "Value2"

echo $ZSH_VERSION  #// 5.8

そこで、動いているシェルに応じて declare と typeset を切り替えるシェルスクリプトにします。また、シェバンを #!/bin/bash に戻します。

~/_tmp.sh

#!/bin/bash
if [ "${BASH_VERSION}" != "" ]; then
    if [ "${BASH_VERSION:0:1}" == "3" ]; then
        echo "ERROR. BASH_VERSION=${BASH_VERSION}"; exit 2
    fi
    declare=declare
elif [ "${ZSH_VERSION}" != "" ]; then
    declare=typeset
else
    echo "ERROR"; exit 2
fi

$declare -A  object_A  #// associative array
object_A["Attr1"]="Value1"
echo  ${object_A["Attr1"]}  #// "Value1"

object_A["Attr2"]="Value2"
echo  ${object_A["Attr1"]}  #// "Value1"  OK
echo  ${object_A["Attr2"]}  #// "Value2"

echo BASH_VERSION=${BASH_VERSION}
    #// mac: 3.2.57(1)-release, Windows Git bash: 4.4.23(1)-release
echo ZSH_VERSION=${ZSH_VERSION}
    #// mac: 5.8

mac から起動するときは、コマンド /bin/zsh ~/_tmp.shで起動する必要があります。~/_tmp.sh で起動すると bash 3 で実行され、前述したように連想配列のつもりで書いたコードが普通の変数になって動作し続けます。この動作は非常に危険なので先頭でエラーにしています。

チェックしている行の exit を コメント アウト すれば普通の変数として動いていることを確認できます。

echo "ERROR. BASH_VERSION=${BASH_VERSION}";  #// exit 2
    :
echo  ${object_A["Attr1"]}  #// "Value2"  NG

HomeBrew にある最新 bash の連想配列

バージョン4以上の bash では連想配列が使えます。そこで、mac に最新の bash をインストールしてみます。

mac に最新の bash をインストールするには、https://brew.sh を参考に HomeBrew をインストールして、

/bin/bash -c "$(curl ...)"

下記のコマンドで bash をインストールします。

brew install bash
/opt/homebrew/bin/bash --version

インストールした最新の bash を使えば連想配列が使えます。しかし、シェバン(1行目)が HomeBrew 独特の設定になります。

~/_tmp.sh

#!/opt/homebrew/bin/bash
declare -A  object_A  #// associative array
object_A["Attr1"]="Value1"
echo  ${object_A["Attr1"]}  #// "Value1"

object_A["Attr2"]="Value2"
echo  ${object_A["Attr1"]}  #// "Value1"  OK
echo  ${object_A["Attr2"]}  #// "Value2"

echo $BASH_VERSION  #// 5.1.16(1)-release

Linux や Windows Git bash では/bin/bash または /usr/bin/bash に最新の bash があるので、シェバンを #!/bin/bash または #!/usr/bin/bash に設定しなければどの環境でも動くようにはなりません。しかし、mac では /bin/bash または /usr/bin/bash を変更しようとするとSystem Integrity Protection (SIP) によってエラーになります。SIP をオフにすることや /bin/ の内容を変えることは危険なのでお勧めしません。

sudo mv /bin/bash /bin/bash-old
    #// mv: rename /bin/bash to /bin/bash-old: Operation not permitted
sudo ln -sf /opt/homebrew/bin/bash /usr/bin/bash
    #// ln: /usr/bin/bash: Operation not permitted
csrutil disable
    #// csrutil: This tool needs to be executed from Recovery OS.
csrutil disable; reboot
    #// (効果なし)
csrutil status
    #// System Integrity Protection status: enabled.

シェバンが #!/bin/bash などのスクリプトを、mac から起動するときは、コマンド /opt/homebrew/bin/bash ~/_tmp.sh で起動する必要があります。

結局、互換性がない部分は zsh で実行する方法と大して変わりませんでした。

(おまけ)連想配列の別名

ちなみに連想配列はいろいろな言い方をされます。

  • 連想配列, associative array(Linux bash)
  • 辞書, dictionary(Python, C#)
  • ハッシュ, hash (Ruby)
  • ハッシュテーブル、hash table, ハッシュ表 (.NET Framework)
  • オブジェクト, object(JavaScript, JSON)
  • マップ, map(JavaScript, Go, C++ STL)
  • ハッシュマップ, hash map (Java, Rust)
  • マッピング, mapping(YAML)
  • 連想リスト, associative list (LISP)
  • 連想コンテナ, associative container (C++)
  • 結合配列, joined array (PL/SQL)
  • キー バリュー, key value (データベースの種類)

これらはすべて同じ機能に対する呼び名です。独自用語が乱立していますね。

Discussion

ログインするとコメントできます