間接展開と printf -v|変数名を安全に参照

設計パターン&テンプレ

この記事の狙い

eval を使わずに動的な変数名を安全に扱い、値の取得・代入・検証を行えるようにします。
Bash の間接展開(${!name}nameref(declare -nprintf -v を組み合わせ、配列・連想配列・スコープを壊さない実装指針を身につけます。

前提と対象

  • Bash 4.3+ を推奨(declare -n を利用)。4.2 以前でも一部は可(${!name} は利用可、制約あり)。
  • GNU ユーザーランド想定。set -Eeuo pipefail 前提の安全設計に馴染みがある方向け。

TL;DR(最小実装・コピペ可)

eval 禁止。参照は declare -n、代入は printf -v、簡易参照は ${!}

#!/usr/bin/env bash
set -Eeuo pipefail

# 動的な「変数名」を受け取り、安全に読み書きする
get_by_name() {
  local name="$1"
  # nameref: name が指す変数を「別名」で参照
  declare -n ref="$name" || return 22   # 無効名なら 22(EINVAL) とする
  printf '%s\n' "${ref-}"               # 未定義は空
}

set_by_name() {
  local name="$1" value="$2"
  # printf -v は「変数名」を取り、フォーマットして代入する(クォート不要)
  printf -v "$name" '%s' "$value"
}

# 例:動的名を作り、読み書き
user_id="42"
key="user_id"

echo "GET:${key} -> $(get_by_name "$key")"   # => 42
set_by_name "$key" "100"
echo "SET:${key} -> $(get_by_name "$key")"   # => 100

設計の要点(原則)

参照は参照、代入は代入の仕組みを使い分けます。

  • 読み取りdeclare -n ref="$name"nameref を作り、"$ref" を読む。
  • 書き込みprintf -v "$name" '%s' "$value"フォーマット済み代入
  • 単純参照だけなら ${!name} も有効(ただし配列要素や複雑式には弱い)。
  • eval は使用しない。意図しない展開・注入・シグナルで壊れやすい。

ステップ実装(分解解説)

段階1:単純な間接参照(${!var})

文字列としての「変数名」を介して値を取る最小手段。読み取り専用に近い使い方。

foo="abc"
name="foo"
printf '%s\n' "${!name}"   # -> abc

制約として、配列要素(arr[i])や連想配列要素の参照には向きません。複合名の生成で壊れがちです。

段階2:nameref(declare -n)で強い参照

nameref は左辺にも右辺にも使える参照です。関数引数で“変数そのもの”を扱えるため、スコープ安全でテストもしやすくなります。

# 任意の「変数名」にポインタのように触れる
set_default_if_empty() {
  local varname="$1" def="$2"
  declare -n ref="$varname"
  [[ -n "${ref-}" ]] || ref="$def"
}

msg=""
set_default_if_empty "msg" "hello"
echo "$msg"                # -> hello

配列にも効きます。

nums=(1 2 3)
bump_last() {
  declare -n a="$1"
  local last_index=$(( ${#a[@]} - 1 ))
  (( last_index >= 0 )) || return 0
  a[$last_index]=$(( a[$last_index] + 1 ))
}
bump_last "nums"
printf '%s\n' "${nums[@]}"   # -> 1 2 4

段階3:printf -v で安全代入

printf -v <name> fmt args... は、展開済み文字列をクォート無しで代入できます。
改行・スペース・* などのメタ文字を含んでも壊れません。

varname="path"
value="/tmp/a b*c?.txt"
printf -v "$varname" '%s' "$value"
declare -p path   # -> declare -- path="/tmp/a b*c?.txt"

テンプレ文字列の生成にも使えます。

line=""
printf -v line '[%(%Y-%m-%d %H:%M:%S)T] %s' -1 "started"
echo "$line"

段階4:連想配列/ネームスペース風の設計

「変数名を組み立てて管理」より、連想配列で素直に持つほうが安全で読みやすい場面が多いです。

declare -A cfg=(
  [host]="db1"
  [port]="5432"
)

# 参照は一箇所に寄せる
cfg_get() {
  local k="$1"; printf '%s\n' "${cfg[$k]-}"
}
cfg_set() {
  local k="$1" v="$2"; cfg[$k]="$v"
}

どうしても “prefix_key” のように動的変数を作る必要があるときだけ、printf -v を採用します。

ns_set() {
  local ns="$1" key="$2" val="$3"
  local name="${ns}_${key}"
  printf -v "$name" '%s' "$val"
}
ns_set "DB" "HOST" "db1"
echo "$DB_HOST"    # -> db1

失敗しやすい点(アンチパターン)

  • eval "$name=value" を使う:展開・グロブ・コマンド置換が混入すれば即事故。
    → 代入は printf -v、参照は declare -n へ置換。
  • ${!} で配列要素を無理に読む"${!arr[i]}" のような記述は壊れやすい。
    → nameref で declare -n a="arr" として a[i] を扱う。
  • 未定義変数に触れて set -u で落ちる
    declare -n ref="$name" || return 22"${ref-}" のように 未定義ガードを徹底。
  • 名前にユーザー入力をそのまま使う
    → 許可リスト([[ $name =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]])で 識別子検証を入れる。

テストと品質(最小)

入出力と終了コードだけでも十分に効きます。shellcheck は nameref も理解します。

# test/run.sh
#!/usr/bin/env bash
set -Eeuo pipefail

. ./lib.sh   # 関数群を分離している想定でも可

x=""
set_default_if_empty "x" "v"
[[ "$x" == "v" ]] || { echo "FAIL default"; exit 1; }

path=""
val='/tmp/a b*c?.txt'
printf -v path '%s' "$val"
[[ "$path" == "$val" ]] || { echo "FAIL printf-v"; exit 1; }

echo "PASS"

運用設計(実務)

  • 外部入力から「変数名」を作らない。必要なら、連想配列へ寄せる。
  • nameref は寿命(スコープ)に依存するため、関数内で完結させる。返り値は stdout、または printf -v で“呼び出し元の変数名”に代入する設計が定石。
  • ロギングは必ずクォートし、展開結果を出す(名ではなく値)。

互換性と移植性

  • declare -n は Bash 4.3+。それ以前(や dash)では使えません。
  • ${!name} は古い Bash でも動くが、単純変数名の間接参照に限ると割り切る。
  • macOS の古い /bin/bash は 3.x 系の場合あり。Homebrew の bash などで 5.x を使うのが無難。

セキュリティと安全設計

  • 変数名に使えるのは 識別子のみ。ユーザー入力をそのまま名前化しない。
  • 代入時は常に printf -v を通してクォート不要化
  • eval/動的コード生成/コマンド置換を避け、データはデータとして扱う

パフォーマンスの勘所(短く)

  • nameref/printf -v はオーバーヘッドが小さく、ループ内でも現実的。
  • ただし巨大ループのキー・名組み立てはコストになるので、可能なら連想配列に一本化。

参考実装(拡張版・コピペ可)

名の検証を含めた 安全 API の最小セット。

# lib_indirect.sh
#!/usr/bin/env bash
set -Eeuo pipefail

_is_ident() { [[ "${1-}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; }

indirect_get() {
  local name="$1"
  _is_ident "$name" || return 22
  declare -n ref="$name" || return 22
  printf '%s\n' "${ref-}"
}

indirect_set() {
  local name="$1" value="$2"
  _is_ident "$name" || return 22
  printf -v "$name" '%s' "$value"
}

indirect_append_array() {
  local arrname="$1" value="$2"
  _is_ident "$arrname" || return 22
  declare -n a="$arrname" || return 22
  a+=("$value")
}
# demo.sh
#!/usr/bin/env bash
set -Eeuo pipefail
. ./lib_indirect.sh

user="alice"
echo "$(indirect_get user)"   # alice
indirect_set user "bob"
declare -p user               # "bob"

nums=()
indirect_append_array nums "1"
indirect_append_array nums "2"
printf '%s\n' "${nums[@]}"    # 1 2

よくある質問(Q&A)

Q. ${!name}declare -n はどう使い分けますか?
A. 値を“読むだけ”なら ${!name} で軽く済みます。配列・連想配列・書き込みまで踏み込むなら declare -n を選びます。

Q. printf -v と通常の代入 var="$val" の違いは?
A. **左辺が動的(文字列で与えられる)**ときに printf -v <name> が必要です。右辺のフォーマットも同時に行えるのが利点です。

Q. 旧環境(Bash 3.x)では?
A. ${!name} の範囲でしのぐか、設計自体を連想配列やキー→値テーブルへ寄せて“変数の名を増やさない”方針に切り替えます。

参考リンク

Bash玄

はじめまして!Bash玄です。

エンジニアとしてシステム運用に携わる中で、手作業の多さに限界を感じ、Bashスクリプトを活用して業務を効率化したのがきっかけで、この道に入りました。「手作業は負け」「スクリプトはシンプルに」をモットーに、誰でも実践できるBashスクリプトの書き方を発信しています。

このサイトでは、Bashの基礎から実践的なスクリプト作成まで、初心者でもわかりやすく解説しています。少しでも「Bashって便利だな」と思ってもらえたら嬉しいです!

# 好きなこと
- シンプルなコードを書くこと
- コマンドラインを快適にカスタマイズすること
- 自動化で時間を生み出すこと

# このサイトを読んでほしい人
- Bashに興味があるけど、何から始めればいいかわからない人
- 定型業務を自動化したい人
- 効率よくターミナルを使いこなしたい人

Bashの世界に一歩踏み出して、一緒に「Bash道」を極めていきましょう!

Bash玄をフォローする