シェルスクリプトの文字列連結:POSIX sh・Bashの基本と安全な応用例

シェルスクリプトで文字列を連結する基本形は、result="${first}${second}" です。連結する部分を空白で区切らずに並べ、表示するときは printf '%s\n' "$result" と書きます。本記事では、この基本から、改行、コマンド出力、ファイル名、ループ、ユーザー入力の扱いまでを説明します。

例は原則として POSIX の sh と Bash で使える構文にしています。+=、配列、printf -v は Bash 用の例として区別します。Linux でも /bin/sh が Bash とは限りません。Bash 用のスクリプトは bash script.sh などで実行してください。

目次

文字列の連結と「複数の引数の表示」を区別する

引用符で囲んだ部分同士も、間に空白を置かなければ1つの単語になります。引用符は値そのものには含まれません。

# POSIX sh / Bash
message="Hello, ""World!"
printf '%s\n' "$message"
# 出力: Hello, World!

# 引数の違いを確認する
printf '<%s>\n' "Hello, ""World!"  # データ引数は1個
printf '<%s>\n' "Hello, " "World!" # データ引数は2個

最後の2行は、前者が <Hello, World!> を1行、後者が <Hello, > と <World!> を別々の行に表示します。echo "Hello, " "World!" は連結の例ではありません。2つの引数の間に echo 自身が空白を追加するため、この例ではカンマの後に空白が2個入ります。

変数の表示には固定した書式の printf を使うと、-n やバックスラッシュを含むデータも扱いやすくなります。echo -e や echo "\n" の解釈は実装・設定によって異なります。参照:GNU Coreutils の echo と printf の使い分け。

変数の連結:引用符と波括弧の役割

# POSIX sh / Bash
first='Hello'
second='World'
message="${first}, ${second}!"
printf '%s\n' "$message"

project='demo'
filename="${project}_report.txt"
printf '%s\n' "$filename"
# 出力: demo_report.txt

${project} の波括弧は変数名の終わりを示します。$project_report と書くと、project_report という別の変数を参照します。また、代入の = の前後に空白は置きません。

# POSIX sh / Bash
value='two words * [abc] 100% \n'
message="value: ${value}"
printf '%s\n' "$message"

この例では、空白、*、[abc]、%、文字としての \n がそのまま出力されます。通常のコマンド引数で $message を引用せずに展開すると、単語分割やパス名展開によって引数の数・内容が変わることがあります。"$message" と引用して渡してください。単純な代入の右辺では単語分割やパス名展開は行われませんが、引用符を使う書き方にそろえると意図が明確です。

printf "$message" では、データ内の % やバックスラッシュが書式として解釈されます。printf '%s\n' "$message" のように、書式とデータを分けます。参照:POSIX.1-2024 Shell Command Language、Bash のパラメーター展開。

既存の文字列に追加する:POSIX sh と Bash

POSIX の sh では、元の値と追加部分を連結して代入し直します。

# POSIX sh / Bash
message='Hello'
message="${message}, World!"
printf '%s\n' "$message"

Bash の通常の文字列変数なら += でも追加できます。POSIX の sh 共通の構文としては使わないでください。

# Bash 専用
message='Hello'
message+=', World!'
printf '%s\n' "$message"

Bash でも、整数属性を付けた変数の += は数値の加算になります。ここで扱うのは整数属性などを付けていない文字列変数です。参照:GNU Bash の代入と +=。

改行を含む文字列と整形出力

二重引用符の中の \n は、それだけでは改行になりません。表示するだけなら、改行を printf の固定書式に書きます。変数に保存するなら、引用符の中に実際の改行を入れる方法があります。

# POSIX sh / Bash
project='demo'
state='ready'
printf 'Project: %s\nState: %s\n' "$project" "$state"

report="Project: ${project}
State: ${state}"
printf '%s\n' "$report"

2つの方法とも、Project: demo と State: ready を2行で表示します。printf '%b' "$value" はデータ内のバックスラッシュも解釈するため、任意の入力をそのまま表示する目的には使いません。

Bash では printf -v で整形結果を直接変数に保存できます。次の例では変数の末尾にも改行を含めるため、表示時は %s だけを指定します。

# Bash 専用
project='demo'
state='ready'
printf -v report 'Project: %s\nState: %s\n' "$project" "$state"
printf '%s' "$report"

参照:GNU Bash の printf 組み込みコマンド。

コマンド出力を連結する:失敗と末尾改行に注意

$(command) はコマンドの標準出力を取り込みます。成功したときだけ値を使いたい場合は、代入を if で判定します。

# POSIX sh / Bash
if current_date=$(date '+%Y-%m-%d'); then
    message="Today is: ${current_date}"
    printf '%s\n' "$message"
else
    printf '%s\n' '日付を取得できませんでした。' >&2
    exit 1
fi

日付は実行環境のタイムゾーンに従います。コマンドを失敗時にも成功したように表示しないことが大切です。printf '%s\n' "$(command)" 全体の終了ステータスは通常 printf のものなので、内部コマンドの成否判定の代わりにはなりません。

# POSIX sh / Bash
text=$(printf 'first\nsecond\n\n')
printf '<%s>\n' "$text"
# 出力は2行:
# <first
# second>

$(...) は末尾に連続する改行を取り除きます。途中の改行は残りますが、使うときの引用符が重要です。バイナリデータやファイル内容を完全に保ちたい場合は、シェル変数に取り込まず、ファイルやパイプで渡します。参照:GNU Bash のコマンド置換。

ファイル数をメッセージに含める場合

ls | wc -l は「通常ファイルの数」を正確に数える方法ではありません。ディレクトリも表示され、通常は隠しファイルが除かれ、ファイル名に改行があれば行数もずれます。先に数える対象を定義してください。

次の例は、現在のディレクトリ直下にある通常ファイルを、隠しファイルを含めて数えます。サブディレクトリ内部とシンボリックリンクは除外します。ファイル名を行単位で解析しないため、名前に空白や改行が含まれていても数えられます。読み取り・検索が可能で、集計中に内容が変わらないディレクトリで使う例です。

# POSIX sh / Bash
count=0
for path in ./* ./.[!.]* ./..?*; do
    if [ -f "$path" ] && [ ! -L "$path" ]; then
        count=$((count + 1))
    fi
done
printf '直下の通常ファイル数(隠しファイルを含む、リンク除外): %s\n' "$count"

一致する名前がないパターンは、そのまま残る場合がありますが、この例では -f の判定で除外します。権限不足や集計中の変更がある環境で、完全な一覧・一時点の件数を保証するものではありません。

日付・連番・入力値からファイル名を作る

ここではファイル名の文字列を作って表示するだけにします。ファイル作成やバックアップは実行しません。ユーザー入力を名前の一部に使うときは、引用符だけに頼らず、用途に合う文字を許可する方式で確認します。

# POSIX sh / Bash
LC_ALL=C
export LC_ALL
prefix='project1'
case "$prefix" in
    ''|*[!A-Za-z0-9_-]*)
        printf '%s\n' '接頭辞は英数字・_・-だけにしてください。' >&2
        exit 1
        ;;
esac

if timestamp=$(date '+%Y-%m-%d_%H-%M-%S'); then
    output_dir='./output'
    filename="${prefix}_${timestamp}.txt"
    printf '保存先の候補: %s/%s\n' "$output_dir" "$filename"
else
    printf '%s\n' '日時を取得できませんでした。' >&2
    exit 1
fi

for i in 1 2 3; do
    printf '連番の候補: %s_%03d.txt\n' "$prefix" "$i"
done

例の接頭辞では / や . を許可しないため、入力によって別のディレクトリを指定する形にはできません。名前に必要な文字、長さ、予約名などの条件は用途に合わせて決めます。{1..3} という連番展開は、広く使われる sh 実装で共通には使えないので、この例では明示的に列挙しています。

秒までの日時や連番を含めても、名前が必ず一意になるわけではありません。実際に保存するときは、既存ファイルをどう扱うか、保存先の権限、書き込み途中の失敗を別途設計します。特に > は既存の出力先を切り詰めるため、安易に付け足さないでください。アーカイブ名を作ったことや tar が終了したことだけでは、復元可能なバックアップを確認したことにはなりません。

ループで区切り文字を挟んで連結する

区切り文字用の変数を最初は空にして、1要素目を処理した後に設定すると、先頭の区切り文字を後で削る必要がありません。次の例は空の要素も処理します。

# POSIX sh / Bash
set -- 'apple' '' 'cherry pie'
result=''
separator=''
for item do
    result="${result}${separator}${item}"
    separator=', '
done
printf '%s\n' "$result"
# 出力: apple, , cherry pie

set -- はスクリプトの位置パラメーターを置き換えます。元の引数が必要な場所ではそのまま使わず、関数内で扱うなどの設計にしてください。また、値自体にカンマや改行が含まれる場合、この処理は一般的な CSV の生成にはなりません。CSV には形式に対応した書き出し処理が必要です。

コマンドの引数は1本の文字列にまとめない

表示用の文字列と、コマンドに渡す複数の引数は分けて考えます。Bash では配列を使うと、空白を含む引数や空の引数を保てます。

# Bash 専用
files=('report one.txt' 'report*.txt' '')
printf '<%s>\n' "${files[@]}"
# 出力:
# <report one.txt>
# <report*.txt>
# <>

"${files[@]}" は各要素をそれぞれの引数として渡します。POSIX の sh なら、必要に応じて位置パラメーターと "$@" を使います。配列の仕様は GNU Bash の Arrays を参照してください。

文字列連結は、コマンド注入を防ぐためのエスケープ処理ではありません。入力を連結したコマンド文字列を eval や sh -c に渡さず、コマンドとデータを別々の引数として渡してください。引用符は単語分割などを防ぎますが、呼び出し先によるオプション・正規表現・SQL などの解釈は防ぎません。各コマンドが対応していれば -- やパターン指定オプションを使うなど、用途別の対策が必要です。

ユーザー入力を読み、文字列として扱う

入力行の先頭・末尾の空白とバックスラッシュを保つには、IFS= read -r を使います。read -p は POSIX の read の共通オプションではないため、プロンプトは printf で表示します。

# POSIX sh / Bash
printf '%s' '名前を入力してください: ' >&2
if IFS= read -r name; then
    greeting="Hello, ${name}!"
    printf '%s\n' "$greeting"
else
    printf '%s\n' '改行で終わる入力行を読み取れませんでした。' >&2
    exit 1
fi

この例は改行で終わる1行を受け付けます。入力終了や読み取り失敗はエラー扱いです。入力された $(...) などの文字列が、通常の引用付き変数展開だけでコマンドとして再実行されることはありません。ただし、それを別の言語のプログラムや設定ファイルに埋め込むなら、その形式に応じた検証・エンコードが別途必要です。シェルで引用しただけで JSON や設定ファイルとして正しくなるわけではありません。

大きなファイルや構造化データは専用の方法で扱う

ログファイルの内容は変数にためずに出力する

ファイル内容の連結には cat を使えます。次の例は、確認用に用意した2つのログを指定順で標準出力へ送るだけで、入力や出力ファイルを書き換えません。

# POSIX sh / Bash
if cat './logs/app.log' './logs/worker.log'; then
    printf '%s\n' '2つのログを標準出力に送りました。' >&2
else
    printf '%s\n' 'ログの読み取り、または出力に失敗しました。' >&2
    exit 1
fi

cat はファイル間に改行や区切りを自動では入れません。また、失敗時にも一部の内容がすでに出力されている場合があります。結果をファイルに保存するなら、入力と同じパスを出力先にしないこと、上書き・途中出力の扱いを決めることが必要です。

置換、CSV、JSON、URL エンコードは連結と分ける

固定した置換処理なら、次のようにデータを標準入力へ渡せます。この sed の例は、各行の最初の fox を cat に置き換えます。

# POSIX sh / Bash(sed が必要)
input_text='The quick brown fox jumps over the lazy dog.'
if modified_text=$(printf '%s\n' "$input_text" | sed 's/fox/cat/'); then
    printf '%s\n' "$modified_text"
else
    printf '%s\n' '置換に失敗しました。' >&2
    exit 1
fi

この判定はパイプライン最後の sed の終了ステータスを見ています。すべての構成コマンドの失敗を検出する汎用処理ではありません。大量データは変数に取り込まずストリームで処理し、必要な失敗検出も別途組み込みます。

  • awk -F, は単純なカンマ区切りには使えますが、引用符内のカンマや改行を持つ一般的な CSV を正しく解析する方法ではありません。
  • JSON は jq などの JSON 対応ツールで読み書きし、外部の文字列を渡す場合は --arg などでプログラムとデータを分離します。
  • Python を呼ぶときも、変数の内容を Python ソースに直接埋め込まず、引数や標準入力として渡します。

例えば、Python 3 が利用できる環境なら、URL の1つの構成要素を次のようにパーセントエンコードできます。

# POSIX sh / Bash + Python 3
input_string='Hello / world?'
if encoded=$(python3 -c 'import sys; from urllib.parse import quote; print(quote(sys.argv[1], safe=""))' "$input_string"); then
    printf 'Encoded component: %s\n' "$encoded"
else
    printf '%s\n' 'エンコードに失敗しました。' >&2
    exit 1
fi
# 出力: Encoded component: Hello%20%2F%20world%3F

safe="" はスラッシュもエンコードする指定です。URL 全体をこの方法で処理するのではなく、用途に合った構成要素を対象にします。クエリーのキーと値の組み立てには urllib.parse.urlencode() などを使います。参照:Python の URL quoting、jq の引数指定。

確認・デバッグのポイント

  • 意図した1つの文字列になっているか、複数の引数になっていないかを printf '<%s>\n' などで確認します。
  • Bash 用なら bash -n script.sh、対象が POSIX の sh なら実際の対象シェルでも構文と動作を確認します。構文チェックだけでは内容や安全性は検証できません。
  • set -x は展開後の値をトレースに出すため、パスワード、トークン、個人情報を扱う範囲では使わないでください。
  • 空文字、空白、改行、引用符、バックスラッシュ、*、- で始まる値を使って、データが変わらないか確認します。
  • コマンドの失敗時に、成功メッセージを無条件に表示しないようにします。書き込みやバックアップの成否は、文字列生成とは別に確認します。

まとめ

文字列連結は result="${a}${b}"、表示は printf '%s\n' "$result" を基本にすると整理しやすくなります。Bash 固有の機能と POSIX の sh で使える構文を区別し、改行・コマンドの終了ステータス・引数の境界を意識してください。ファイルの保存、コマンド実行、CSV や JSON の生成は、連結とは別の処理として設計することが重要です。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次