mac の zsh と Linux/Windows の bash で互換性があるシェルスクリプトの連想配列
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 のページに貼り付けることで動作確認することができます。
注意: このサンプルは現実的ではありません。→ オブジェクトを変数にできない問題への対処法
連想配列の関数
後で説明しますが、シェルスクリプトで連想配列を使うと互換性を持たせることができません。
そこで、配列を連想配列としてアクセスする関数を作成しました。連想配列の関数を使ったコードは以下のようになります。
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