🐄

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

2022/05/08に公開

2023-11-13 更新: _get をデフォルト値に対応。コードをキャメルケースに変更。grep 版との性能比較を追加。オブジェクトを変数にできない問題への対処法 を追加

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['firstName'] = 'John';
console.log( employee['firstName'] );  // John

このコードは、次の MDN のページに貼り付けることで動作確認することができます。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/delete

注意: このサンプルは現実的ではありません。→ オブジェクトを変数にできない問題への対処法

連想配列の関数

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

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

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

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

注意: このサンプルは現実的ではありません。→ オブジェクトを変数にできない問題への対処法

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

~/_tmp.sh

function  Main() {
    employee=()
    eval "$(_set  employee  "firstName"  "John" )"
    echo "$(_get  employee  "firstName" )"  #// John
    echo "$(_get  employee  "middleName"  "default" )"  #// default
}

#// _get
#// Example:
#//    object=(keyA "1"  keyB "x")
#//    echo "$(_get  object  "keyB" )"
function  _get() {
    local  objectName="$1"
    local  key_="$2"
    local  defaultValue="$3"
    local  operation=""

    operation="_getSub  \"\${${objectName}[@]}\"  \"${key_}\"  \"${defaultValue}\""
    eval "${operation}"
}

function  _getSub() {
    local  objectEntries=("${@}")
    local  count=${#objectEntries[@]}
    local  keyIndex=$(( ${count} - 2 ))
    local  defaultValueIndex=$(( ${count} - 1 ))
    local  key_="${objectEntries[${keyIndex}]}"
    local  value="${objectEntries[${defaultValueIndex}]}"

    for (( i = 0; i < "${keyIndex}"; i += 2 ));do
        if [ "${objectEntries[${i}]}" == "${key_}" ]; then
            value="${objectEntries[${i}+1]}"
        fi
    done

    echo "${value}"
}

#// _set
#// Example:
#//    object=(keyA "1"  keyB "x")
#//    eval "$(_set  object  "keyB"  "y" )"
function  _set() {
    local  objectName="$1"
    local  key_="$2"
    local  value="$3"
    local  operation=""

    operation="_setSub \"\${${objectName}[@]}\" \"${objectName}\" \"${key_}\" \"${value}\""
    eval "${operation}"
}

function  _setSub() {
    local  objectEntries=("${@}")
    local  count=${#objectEntries[@]}
    local  objectNameIndex=$(( ${count} - 3 ))
    local  keyIndex=$(( ${count} - 2 ))
    local  valueIndex=$(( ${count} - 1 ))
    local  objectName="${objectEntries[${objectNameIndex}]}"
    local  key_="${objectEntries[${keyIndex}]}"
    local  value="${objectEntries[${valueIndex}]}"
    local  command=""

    for (( i = 0; i < "${keyIndex}"; i += 2 ));do
        if [ "${objectEntries[${i}]}" == "${key_}" ]; then

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

        command="${objectName}[${newKeyIndex}]=\"${key_}\"; ${objectName}[${newValueIndex}]=\"${value}\""
    fi

    echo "${command}"
}

function  TestOfArrayDic() {
    object=(keyA "1"  keyB "x")
    echo "$(_get  object  "keyA" )"
    echo "$(_get  object  "keyB" )"
    eval "$(_set  object  "keyA"  "2" )"
    eval "$(_set  object  "keyB"  "y" )"
    eval "$(_set  object  "keyC"  "z  z" )"
    echo "$(_get  object  "keyA" )"
    echo "$(_get  object  "keyB" )"
    echo "$(_get  object  "keyC" )"
    echo "$(_get  object  "keyD"  "def" )"
}

#// TestOfArrayDic

Main  "$@"

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

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

オブジェクトを変数にできない問題への対処法

一般的なプログラミング言語と違って、シェルスクリプトの連想配列は別の変数に代入することができません。同様に、関数の引数に連想配列を渡すこともできません。配列なら値渡し(深いコピー)はできますが参照渡しはできません。

連想配列を別の変数に代入することとは、具体的に JavaScript で示すと、

JavaScript:

const  employeeA = {};
employeeA['firstName'] = 'John';

const  employeeB = {};
employeeB['firstName'] = 'Kaite';

const  employee = employeeA;
console.log( employee['firstName'] );  // John

のうち、

const  employee = employeeA;

ができないということです。

このため、シェルスクリプトで連想配列を書くときには注意が必要になります。それは、オブジェクトの連想配列ではなく、属性の連想配列を作らなければならないことです。また、グローバル スコープ で参照できるようにしなければなりません。

eval "$(_set  FirstNames  "employee"  "John" )"   #// OK
eval "$(_set  employee   "firstName"  "John" )"   #// NG

最初のサンプルの Main 関数ではオブジェクトの連想配列を書きましたが、いずれ破綻するコードであり、現実的なコードではありませんでした。

いずれ破綻するコード:

function  Main() {
    employee=()
    eval "$(_set  employee  "firstName"  "John" )"
    echo "$(_get  employee  "firstName" )"  #// John
    echo "$(_get  employee  "middleName"  "default" )"  #// default
}

現実的なコード:

FirstNames=()
MiddleNames=()

function  Main() {
    eval "$(_set  FirstNames   "employeeA"  "John" )"
    echo "$(_get  FirstNames   "employeeA" )"  #// John
    echo "$(_get  MiddleNames  "employeeA"  "default" )"  #// default
}

なぜなら、一般的な処理においてオブジェクトは任意の数のオブジェクトに対応できなければならないのに対して、属性はシェルスクリプトがサポートする属性に限定する(静的である)ことができるからです。一般的なプログラミング言語において、クラスの属性の構成が動的に変わることができない(静的である)言語がよくありますが、それで問題になることはほとんどありません。なぜなら、クラスという仕組みは辞書と違って静的解析によってインテリセンスが使えるなどの便利になるものであるため、属性の構成が静的である制限があっても問題にならないのです。動的に属性を追加することができて便利だという主張がどこかで見られるかもしれませんが、動的に属性を追加したら静的解析することができなくなり、クラスを定義するメリットがなくなり、インテリセンスなどが使えなくなるか余計な候補を出すなど精度が悪くなり、プログラミングが難しくなります。

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

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

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

~/_tmp.sh

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

objectA["Attr2"]="Value2"
echo  ${objectA["Attr1"]}  #// "Value2"  ... 間違った値!
echo  ${objectA["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  objectA  #// associative array
objectA["Attr1"]="Value1"
echo  ${objectA["Attr1"]}  #// "Value1"

objectA["Attr2"]="Value2"
echo  ${objectA["Attr1"]}  #// "Value1"  OK
echo  ${objectA["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  objectA  #// associative array
objectA["Attr1"]="Value1"
echo  ${objectA["Attr1"]}  #// "Value1"

objectA["Attr2"]="Value2"
echo  ${objectA["Attr1"]}  #// "Value1"  OK
echo  ${objectA["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  ${objectA["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  objectA  #// associative array
objectA["Attr1"]="Value1"
echo  ${objectA["Attr1"]}  #// "Value1"

objectA["Attr2"]="Value2"
echo  ${objectA["Attr1"]}  #// "Value1"  OK
echo  ${objectA["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 で実行する方法と大して変わりませんでした。

(おまけ)grep版との性能比較

シンプルに実装できないかと考えて grep を使った方式を作ってみました。
また、スクリプトでループするより C言語で作られた grep のほうが速いだろうとも考えました。

employee=""
employee="$( SetToTabDic  "${employee}"  "firstName"  "John" )"
echo  "$(  GetFromTabDic  "${employee}"  "firstName" )"  #// John

function  SetToTabDic() {
    local  tabDic="$1"
    local  key_="$2"
    local  value="$3"
    local  tab=$'\t'

    if echo  "${tabDic}"  |  grep -qE "${tab}${key_}=" > /dev/null; then
        echo  "${tabDic}"  |  sed -E "s/${tab}${key_}=[^${tab}]*/${tab}${key_}=${value}/"
    else
        echo  "${tabDic}${tab}${key_}=${value}"
    fi
}

function  GetFromTabDic() {
    local  tabDic="$1"
    local  key_="$2"
    local  defaultValue="$3"
    local  tab=$'\t'

    local  keyValue="$( echo  "${tabDic}"  |  grep  -oE "${tab}${key_}=[^${tab}]*" )"
    if [ "${keyValue}" == "" ]; then
        echo  "${defaultValue}"
    else
        echo  "${keyValue#*=*}"  #// right of "="
    fi
}

性能計測した結果、key の数がかなり多くない限り、総じて grep 版より配列版のほうが速いという結果になりました。

key の数が少ないほど配列を使う方が2倍以上速く、
Linux なら key の数が60なら同じぐらいの速さ、それを超えると grep を使うほうが速くなりました。
Windows Git bash では grep を使うほうが常に遅くなりました。
grep を使うほうは key の数にほとんど影響しません。
grep の処理は速いですがプロセスを起動するのにかなり時間がかかっているようです。

VirtualBox Linux (Core i5 10210U Windows) 1000回ループ。set get の右の数字は key の数。結果の単位は秒。

配列版 grep版 配列/grep
set 10 1.329 2.983 0.45
get 10 1.251 3.292 0.38
set 70 3.334 2.916 1.14
get 70 3.518 3.264 1.08
set 100 4.363 2.788 1.56
get 100 4.313 3.740 1.15

Git bash (Core i5 10210U Windows) 1000回ループ換算(100回で計測し結果は10倍で表示)。set get の右の数字は key の数。結果の単位は秒。

配列版 grep版 配列/grep
set 10 39.16 100.20 0.39
get 10 39.37 176.98 0.22
set 100 42.09 103.02 0.41
get 100 42.09 176.06 0.24
set 1000 67.99 95.45 0.71
get 1000 68.00 166.24 0.41

M1 mac 1000回ループ。set get の右の数字は key の数。結果の単位は秒。

配列版 grep版 配列/grep
set 10 0.717 1.500 0.48
get 10 0.669 1.742 0.38
set 60 1.527 1.483 1.03
get 60 1.473 1.750 0.84
set 100 2.180 1.447 1.51
get 100 2.128 1.746 1.21

計測に使ったコードは、GitHub に置きました。

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

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

  • 連想配列, 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