【bat】変数展開が思った通りに動かない原因と修正方法

バッチファイルで「変数が更新されない」「ループ内で古い値が出る」「% や ! を含む文字列が壊れる」という現象は、ほぼすべて“展開のタイミング”と“メタ文字の扱い”に起因します。
ここでは代表的な症状を再現しながら、即時展開と遅延展開、% と ! の取り扱い、括弧ブロックの罠、エスケープ、スコープの注意点までを順に解説します。

症状の例:ループ内で値が変わらない

次のスクリプトは 1 から 3 までカウントしたいのに、すべて 0 のまま出力されます。これは %VAR% が「行解釈時に一度だけ」展開され、ループ中の更新が反映されないためです。

@echo off
set COUNT=0
for %%A in (1 2 3) do (
  set /a COUNT+=1
  echo COUNT=%COUNT%
)

原因1:即時展開(%VAR%)と遅延展開(!VAR!)の違い

%VAR% は行を読み込んだ時点で一度だけ展開されます。括弧で囲まれたブロックや for の本体では、ブロック全体が先に解釈されるため、更新前の値しか見えません。
対して遅延展開は実行時に展開されるため、ブロック内で最新の値を参照できます。

@echo off
setlocal EnableDelayedExpansion
set COUNT=0
for %%A in (1 2 3) do (
  set /a COUNT+=1
  echo COUNT=!COUNT!   &rem 実行時に展開され 1,2,3 と増える
)
endlocal

原因2:括弧ブロックの“先読み”と二重展開の必要性

ブロックは先に一括展開されるため、ループ内で逐次的に %VAR% を変えたい場面でつまずきます。遅延展開が使えない前提や一時的に避けたいときは、call echo %%VAR%% のように 二段階展開で回避できます。

@echo off
set COUNT=0
for %%A in (1 2 3) do (
  set /a COUNT+=1
  call echo COUNT=%%COUNT%%
)

原因3:% の特別扱いとエスケープ

% は「変数展開」や「位置引数(%1 など)」のメタ文字です。文字列としての % を出したい場合は、バッチ内では %% と二重に書きます。for のメタ変数もバッチでは %%A のように二重です(対話型 cmd では単一の %A)。

@echo off
echo 100%% 完了      &rem 画面上は 100% 完了 と表示
for %%F in (*) do echo %%~nxF

原因4:! が消える/壊れる(遅延展開の副作用)

EnableDelayedExpansion 中は、! は変数区切りとして解釈されます。テキストに感嘆符が含まれていると欠落します。ログ行をそのまま処理したいなど、! を文字として保持したい場合は、ループ処理の直前だけ遅延展開を無効にするか、^^! でエスケープします。

@echo off
setlocal EnableDelayedExpansion
for /f "usebackq delims=" %%L in ("input.txt") do (
  set "LINE=%%L"
  setlocal DisableDelayedExpansion
  echo %LINE%     &rem ここでは ! が壊れない
  endlocal
)
endlocal

原因5:予約文字の未エスケープ(& | > < ^ ( ))

& や |、> などはコマンド区切り・リダイレクトとして解釈されます。パスや引数に含まれる場合は引用符で囲むのが基本で、必要に応じて ^ でエスケープします。特に括弧はブロックとして扱われるため注意します。

@echo off
set "FILE=C:\work\1&2(backup).txt"
echo "%FILE%"              &rem 引用で安全
echo ^(test^) ^& done      &rem リテラルにしたい括弧や & は ^ で逃がす

原因6:set の書き方と空白の混入

set VAR=値 は = の前後の空白も値として取り込まれます。値の末尾空白が消える・増えるといった事故は多いので、常に set "VAR=値" の書式を使います。パスにスペースがある場合も同様に安全です。

@echo off
set "DIR=C:\Program Files\App"
echo %DIR%
cd "%DIR%"

原因7:スコープ(setlocal/endlocal)で値が消える

setlocal で開始したスコープ内の変更は、endlocal で元に戻ります。スコープ外へ値を持ち出したい場合は、endlocal の同一行で再代入するテクニックが使えます。

@echo off
set VALUE=before
setlocal
set VALUE=inside
for /f "delims=" %%I in ('cmd /v:on /c echo !VALUE!') do (
  endlocal & set VALUE=%%I
)
echo %VALUE%     &rem outside にも反映

原因8:for /f の既定トークン分割と遅延展開の相性

for /f は既定で空白区切り、行頭・行末の空白を削除します。文字列をそのまま受けたい場合は delims= を指定します。さらに UTF-8 ファイルの読み書きは cmd だけでは不安定なため、PowerShell を介してエンコーディングを明示すると確実です。

@echo off
for /f "usebackq delims=" %%L in ("raw.txt") do echo(%%L
rem UTF-8 を読むなら:
for /f "usebackq delims=" %%L in (`powershell -NoProfile -Command "Get-Content -Encoding UTF8 -Path 'raw.txt'"`) do echo(%%L

診断のコツ:その場で何が展開されたかを見る

echo on でコマンドの実体を見たり、cmd /v:on で一時的に遅延展開を有効にして挙動を確認すると原因に素早く当たれます。空や未定義を見分けたい時は echo(!VAR! のように括弧直後に書くと末尾空白も含めて判別しやすくなります。

@echo off
setlocal EnableDelayedExpansion
set "S="
echo(!S!          &rem 何も出ない=未定義・空を視覚化
endlocal

まとめ:選ぶべき修正パターン

ブロック内で値が変わらないなら setlocal EnableDelayedExpansion!VAR! を使い、! を含む行を扱う時は一時的に DisableDelayedExpansion に切り替えます。% を文字として出すなら %%、予約文字は引用と ^ で保護します。set "VAR=値" の書式を徹底し、必要に応じて call による二段階展開で代用すれば、多くの「思った通りに動かない」を安定して解消できます。