バッチファイルでの試行錯誤を回避するためのメモ

  • 86
    いいね
  • 0
    コメント

最近、仕事でバッチファイルを書かざるを得ないという不幸な場面に遭遇しているのですが、これがまた、ものすごくどうでもいいことでハマることが多々あり、思わず「このWindows、壊れてる!」と思ったほどでした。犠牲者を増やさないためにも、DOS文法の挙動を記録したメモを載せておきます。

for文関連、とくに環境変数の遅延展開(コマンドプロンプト界の魔境)についてはしんどいので、また次回にでも。

とりあえずバージョン情報。

C:\>ver

Microsoft Windows [Version 6.1.7601]

ifの条件文と、その直後の(の間に半角スペースを入れよ。

(の直前にスペースが無いと怒られます。知るかよ!

半角スペースが大事
if %RC% NEQ 0 (
    echo うんたらかんたら
)

文字列を囲むダブルクォーテーション

リテラルのつもりで文字列をダブルクォーテーションで囲むと、代入先の変数にダブルクォーテーションまで代入されてしまいます。バカなの?死ぬの?

set hoge="aaa"
echo %hoge%
rem 標準出力 => "aaa"

文字列を"で囲むと、文字列に含まれる制御記号をエスケープする効果があるのですが、お前自身までエスケープされたら、ミイラ取りがミイラになってるじゃないか…。

リテラル文字列内の()はエスケープせよ

×悪い例
if not exist %filePath% (
    echo ファイルが見つかりません (%filePath%)
)
rem 標準出力 ⇒ ファイルが見つかりません (D:\log\sample.ini

ifの閉じカッコと解釈されて、メッセージ内の)が消えてしまいます。)が2つあるんだからオカシイと気づけよ!もう。

()をエスケープするか、表示されるメッセージに"がついてしまうのを厭わなければ、"で囲むのも良いです。全角のを使うのが、いちばんスマートかもしれません。

○良い例
rem エスケープを使用
if not exist %filePath% (
    echo ファイルが見つかりません ^(%filePath%^)
rem 標準出力 ⇒ ファイルが見つかりません (D:\log\sample.ini)
)
rem ダブルクォーテーションを使用
if not exist %filePath% (
    echo "ファイルが見つかりません (%filePath%)"
rem 標準出力 ⇒ "ファイルが見つかりません (D:\log\sample.ini)"
)
rem 全角カッコを使用
if not exist %filePath% (
    echo ファイルが見つかりません (%filePath%rem 標準出力 ⇒ ファイルが見つかりません (D:\log\sample.ini)
)

不思議な不思議な閉じカッコ~♪ 東が(ry

echoコマンドの引数に含まれる )はエスケープされます。

echo ((()))
rem 標準出力 => ((()))

ただし、リテラル扱いでない(の出現以降、最初の)は、リテラルではなく制御文字の)として解釈されます。つまり、(が相棒を探している時、初めて出会った)は、文字列リテラルの一部だろうが何だろうが、見境無く閉じカッコとしてペアを組みます。

(echo hello world)
rem 標準出力 => hello world

((echo hello world))
rem 標準出力 => hello world

if 1 equ 1 (
    echo (hello world)
)
rem 標準出力 => (hello world
rem ※ )のみの行はNOPとなるため、2つめの)では「コマンドが見つからない」などのエラーは出ない。

ifやforのブロック内を空にする場合は、remの行が必要

エラー処理は後で書きたいので、ifブロック内を空にするつもりで

×悪い例
if not exist %filePath% (
)
rem 標準出力 ⇒ ) の使い方が誤っています。

なんて書くと、構文エラーで怒られてしまいます。
remコマンドでNOPの代用をするしかないようです。無名ラベルの:でも構文エラーとなります。

○良い例
if not exist %filePath% (
rem //あとでかく
)

)の直上行にラベルは無用

理由は分かりませんが、(とペアになる)の直上行にラベルを置くと、シンタックスエラーとなります。意味ワカンネ。

×悪い例
if 1 equ 1 (
    echo hoge
:LABEL
)
rem 標準出力 => ) の使い方が誤っています。

ラベル行の直下にNOPとしてremを入れておきましょう。というより、()ブロック内でラベルを使うのは止めたほうがいいかも。

○良い例
if 1 equ 1 (
    echo hoge
:LABEL
rem
)
rem 標準出力 => hoge

コマンド文字列・引数をダブルクォートで囲む

コマンド文字列に空白文字が含まれている場合、パス全体をダブルクォートで囲む必要があります。

rem 〇 コマンド文字列に空白文字が含まれている場合、ダブルクォートで囲む
"C:\foo bar\foo bar.bat"

rem × そうしないとこうなる
C:\foo bar\foo bar.bat
rem 'C:\foo' は、内部コマンドまたは外部コマンド、
操作可能なプログラムまたはバッチ ファイルとして認識されていません。

ただし引数まで含めて囲んでしまうと、引数込みでコマンド文字列と解釈されます(当たり前ですが)。

rem × 引数まで含めて囲むと、引数込みでコマンド文字列と解釈されてしまう
"C:\foo bar\foo bar.bat aaa bbb ccc"
rem // '"C:\foo bar\foo bar.bat aaa bbb ccc"' は、内部コマンドまたは外部コマンド、
rem 操作可能なプログラムまたはバッチファイルとして認識されていません。

また、

  • コマンドのパスや引数をダブルクォートで囲んだ場合、クォート記号も含めてバッチファイルに渡されます。
  • バッチファイル内でクォートされていない文字列を使いたい場合、%~1のように記述すると、クォートが除去された文字列(元の文字列がクォートされていない場合は、その文字列)を取得できます。
>"C:\foo bar\foo bar.bat" "aaa" bbb
["C:\foo bar\foo bar.bat"]
["aaa"]
[bbb]

[C:\foo bar\foo bar.bat]
[aaa]
[bbb]
foo△bar.bat
@echo off

echo [%0]
rem ["G:\foo bar\foo bar.bat"]
echo [%1]
rem ["aaa"]
echo [%2]
rem [bbb]
echo,

echo [%~0]
rem [G:\foo bar\foo bar.bat]
echo [%~1]
rem [aaa]
echo [%~2]
rem [bbb]
  • バッチファイルではなく、ダブルクォートで囲まれた引数をプログラムに渡した場合、プログラム内部ではダブルクォートが除去された文字列が渡っていました。バッチファイルを実行する時はダブルクォートつきの引数はクォート除去されずに渡されることを考えると、プログラムの場合はおそらくCのランタイムが除去してくれているのかも。ダブルクォートつきの引数をプログラムに渡した場合のクォートの扱いは、プログラムの処理系の実装次第ではと思います。
sample.c
#include <stdio.h>

int main(int argc, char **argv)
{
    printf("[%s] [%s] [%s]\n", argv[0], argv[1], argv[2]);

    return 0;
}
コマンドプロンプトからsampl.exeを実行
>"sample.exe" "aaa" "bbb"
[sample.exe] [aaa] [bbb]

>
  • 上記の出力結果に空行が含まれていますが、これはプログラムから出力した改行文字の後に、echoコマンドが末尾に改行文字をつけて出力したためです(見りゃ分かるって?)。

cmd.exeの翻訳ステージについても調査したかったのですが、正直パンドラの箱なので、この辺で勘弁してください…。原理原則が明確なBashとかと違って、cmd.exeの翻訳ステージは闇が深いです。

バッチファイルと同名のコマンドが存在する時

バッチファイル(sample.bat)と同名のコマンド(sample.exe)がバッチファイルのカレントに存在する場合、sampleのように拡張子なしで実行すると、CreateProcess APIにより拡張子exeが付与されるため、sample.exeの方が実行されます。

正確な情報は、CreateProcessのAPI仕様を参照下さい。
[MSDN] CreateProcess 関数

論理and/or演算子は?

そんなものはない。setコマンドのandorは、ビット単位演算子ですからね!フラグ変数を導入して、しのぐしかないようですね。次回のバージョンアップでは頼みますぜ、ゲイツさんよぉ。

代入演算子の前後にスペースを入れない

以下のようになる理由は分かるでしょうか?

set hoge=aaa
set hoge = bbb

echo #%hoge%#
rem 標準出力 => 「#aaa#」
echo #%hoge %#
rem 標準出力 => 「# bbb#」

そうです。変数の識別子には、半角スペースも使えるようなのです。なのでhogeという変数とhogeという変数は、異なる変数として解釈されます。また、=の直後のスペースも値の一部として代入されてしまいます。揚げ足取りかっ!

ただし、条件文の場合は両端のスペースは無視されるようです。

set hoge = bbb 
echo #%hoge %#
rem 標準出力 => # bbb #

if %hoge % ==bbb (
    echo match
) else (
    echo unmatch
)
rem 標準出力 => match

「ECHO は <ON> です。」と表示されるのはなぜ?

値が代入されていない変数をechoで表示している可能性があります。
変数voidに値が代入されていない場合のecho %void%は、引数を指定しないechoを実行するのと同じことなので、そうなります。

set void=
echo %void%
rem 標準出力 => ECHO は <ON> です。

ディレクトリの存在チェック

ファイルと区別するため、exist [ディレクトリパス]\nulというイディオムがありますが、UNCパスの場合nulデバイスが使えないため、パスの末尾に\*をつけます。

ディレクトリの存在チェック
rem ローカルパス
if exist C:\local\* (
    echo ディレクトリが存在します
)
rem UNCパス
if exist \\localhost\unc\* (
    echo ディレクトリが存在します
)
rem ネットワークドライブパス
if exist Z:\nfs\* (
    echo ディレクトリが存在します
)

上記コードについて、ローカルパス・UNCパス・ネットワークドライブパスの全パターンで動作検証しましたが問題ありませんでした。

ファイルの存在チェック

ファイルの存在チェックは

ファイルの存在チェック
if exist "%filename%" (
    echo ファイルが存在します.
)

...なーんて言うと思った?(by 牧瀬さん)

上のコードだと、同名のディレクトリが存在した場合でも真になってしまいます。なので、「ディレクトリが存在しない」かつ「ファイルまたはディレクトリが存在する」で判定しなければなりません。

ファイルの存在チェック(まじめ版)
if not exist "%filepath%\*" (
    if exist "%filepath%" (
        echo ファイルが存在します(メガネクイッ).
    )
)

上のコードは、対象がローカルパス、ネットワークドライブのパス、UNCパスの全パターンでテストしましたが、問題なしでした。ファイルをディレクトリであると誤認したり、逆にディレクトリをファイルであると誤認することもありません。

ファイル・ディレクトリの存在チェックについて

以前、ディレクトリの存在チェックには、パスの末尾に「\」をつけると良いというハックを書いていたのですが、これですとディレクトリが存在/非存在のパターンではローカルパス・UNCパス・ネットワークドライブパスの全パターンにおいて正しく認識できるものの、(1)対象ディレクトリと同名のファイルが存在し、かつ(2)チェック対象がUNCパス又はNFSパスである場合、当該ファイルをディレクトリであると誤認する不具合がありましたので、上記(ディレクトリの場合はパス末尾に\*を付加する)のように修正しました。

とはいうものの、これらは実験結果から帰納した結果論的なハックであること、すべてのWindowsのモデルで検証したわけではないこと、Windowsのバージョンアップやパッチ適用によって挙動が変わる可能性もあることから、上記ハックを使用される場合は、必ず実存のWindows上で動作検証を行ってください。

変数を使用した文字列置換

置換前後の文字列に文字列リテラルを指定する場合、set target=%target:置換前文字列=置換後文字列%と記述します。置換前文字列・置換後文字列が変数に格納されている場合、変数展開後の式をsetコマンドに渡す必要があるため、call set target=%%target:%OLD%=%NEW%%%という形式で記述します。

rem
rem ログファイル名の「YYYYMMDD」をシステム日付に置換
rem 
set logFile=%COMPUTERNAME%_YYYYMMDD.log
set old=YYYYMMDD
set new=%DATE:/=%

call set logFile=%%logFile:%old%=%new%%%

echo %logFile%
rem 標準出力 => hostname_20151031.log

文字列の切出し

文字列の切出しは、set substr=%target:=~開始位置,文字数%という形式です(開始位置は0オリジン)。これも変数を使用する場合は、call set substr=%%target:=~%start%,%length%%%という形式で記述します。

rem
rem 現在時刻(00時23分47秒)をhhmmss形式で取得
rem

rem ミリ秒を除外するために、8文字目までを切り出し
set start=0
set length=8
call set hhmmss=%%TIME:~%start%,%length%%%

rem 時分秒区切りの:を削除し、先頭のスペースを0に置換
set hhmmss=%hhmmss::=%
set hhmmss=%hhmmss: =0%

echo %hhmmss%
rem 標準出力 => 002347

環境変数TIMEは「hh:mm:ss.ms」(hhはスペース詰め)という形式なので、ミリ秒を除いた「hh:mm:ss」まで切り出します。時間は24時間表記ですが、09時の場合は先頭にスペースがつくので、先頭スペースを0詰めしています。時分秒ミリ秒については、0詰めなので編集は不要です。

Linuxの'>/dev/null 2>&1'のようなもの

Linuxとほぼ同じです。気が利くやんけ。

command >nul 2>&1

そういえばこの間、インストール先のディレクトリに「1」という名前のファイルができていたので、はて、なんじゃこりゃあ?と思っていたら、職場の新人さんが>nul 2>1とかタイポしていたのが原因でした。犯人はお前か!

Linuxのsleepのようなもの

インフラの人がよくping /n 2 localhost >nul 2>&1と書いてるのを見かけますが、最近はtimeoutコマンドというのがあるようです。

rem 1秒スリープ
timeout /t 1 /nobreak 1>nul 2>&1

Linuxのwc -lのようなもの

rem sample.logの最終行の末尾は改行文字でない
rem C:\>type sample.log
rem aaa
rem bbb
rem ccc
rem C:\>

rem sample.logの行数をカウント
type sample.log | find /c /v ""
rem 標準出力 => 3

wc -lは「ファイルに含まれる改行文字の数」を表示しますが、上記コードは最終行の末尾がEOFの場合でも、最終行を含めて行数をカウントします。Linuxのcat sample.log | grep -c ""と同じですね。なかなか気が利くやんけ。

Linuxの\のようなもの(改行文字のキャンセル)

^です。

echo バッチファイルよ、^
うしてお前はそんなに^
態言語なんだ?^
rem 標準出力 => バッチファイルよ、どうしてお前はそんなに(以下略)

ちなみに、remコマンドのコメント部分も^で行連結することができます。ただし、^が行頭であったり、^の直前にスペースがあってはならない様子。

rem ここはコメントなので^
やりたい放題だぜーーーーッ!^
うひょひょひょーーー!^
よし、全裸で発狂だ!^
うおおおおおぉぉぉぉぉぉぉぉぉぉぉぉおおおおお!^
ふぅ。

rem 標準出力 => ここはコメントなのでやりたい放題だz(以下略)

また、予約語やコマンド名の中間に^をはさむこともできます。

e^
c^
h^
o^
 ^
h^
o^
g^
e

rem 標準出力 => hoge

…と書いておいてなんですが、^周りはアンドキュメンテッドな未定義動作が多く、曲芸的な使い方はヤブヘビになるので、深追いは避けたほうが良いかと(batの文法全般に言えることですが)。

Linuxのtouchのようなもの

空ファイルを作成します。ファイルの中身をtruncateするので、touchというより:>ですかね。

type nul >empty.txt

rem C:\type empty.txt
rem
rem C:\

改行文字1つのみの出力

echo.と記述するのが一般的ですが、私的にはecho,をお勧めします。というのも先日、echo.と叩いたら

'echo.' は、内部コマンドまたは外部コマンド、
操作可能なプログラムまたはバッチ ファイルとして認識されていません。

というわけ分からんメッセージが出たのです。カレント直下を調べてみると、「echo」という名前のファイルが存在していたので、はて、なんじゃこりゃあ?と思っていたら、職場の新人さんが手順書のコマンドをコピペした際、hostname >echo hogeと、複数行をまとめてプロンプト記号までコピペしてコマンドプロンプトに貼り付けていたのが原因でした。犯人はお前か!

カレントにechoというファイルが存在しても、「echo,」ならば問題なく機能するようです。

無限ループの書き方

gotoコマンドを使うのが一般的ですが、以下のような書き方もできるようです。ですが、マニュアルに記載のない書き方なので、やはりgotoコマンドを使うことをオススメします。

for /l %_ in () do ( echo hoge )

数字列の算術比較

if /?と叩くと、ヘルプにこんな説明が見つかりました。

比較演算子は、次のいずれかです:

    EQU - 等しい
    NEQ - 等しくない
    LSS - より小さい
    LEQ - 以下
    GTR - より大きい
    GEQ - 以上

/I スイッチを指定すると、文字列は、大文字と小文字を区別せずに比較され
ます。
/I スイッチは、IF の文字列 1 == 文字列 2 形式で使うこともできます。
この比較は汎用であり、文字列 1 と文字列 2 が両方とも数字だけを含む場合は、
文字列が数値に変換され、数値の比較が行われます。

(これ、ヘルプの説明が紛らわしいのですが、「この比較は汎用であり」の「この比較」とは、/iスイッチを指定した==による比較ではなく、equneqなどによる比較を指すようです。また、数字列は10進数だけでなく、プリフィクス「0」をつけることで8進数として解釈され、プリフィクス「0x」をつけることで16進数として解釈されます。詳細はset /?を参照下さい。)

ほぉーん?ではゲイツちゃんよ、こういう比較もまっとうに行ってくれるんだな?と思い

if 0xff equ 0x00ff (
    echo match
) else (
    echo unmatch
)
実行結果
unmatch

やっぱり文字列として比較してるんじゃん!

ヘルプの説明は、if 255 equ 0xffのような16進数表現、if 255 equ 0377のような8進数表現でも数値として評価する、という意味なんでしょうね。0詰めには対応していないようです。また、これらはequneqなどで比較した場合の話であり、==で比較した場合は、数値として評価してくれません。

16進数を数値として比較(equ演算子)
if 255 equ 0xff (
    echo match
) else (
    echo unmatch
)
rem 標準出力 => match
16進数を数値として比較(==演算子)
if 255==0xff (
    echo match
) else (
    echo unmatch
)
rem 標準出力 => unmatch

callとstart /bの違い

バッチファイルを実行するのに以下の方法がありますが、

  • start /b sample.bat
  • call sample.bat
  • sample.bat

それぞれの違いについて説明します。

startコマンドは

  • sample.batを別インスタンスで非同期実行する。Linuxでいうところのbash sample.sh &のようなものだが、Linuxではカレントシェルの変数の値が(変数をexportするとかコマンドライン先頭に代入文を書くなどしない限り)サブシェルに引き継がれないのに対し、batではカレントインスタンスの変数の値が別インスタンスに引き継がれる点が異なる。
  • sample.bat内での値の再代入は、親インスタンスに影響しない。
  • sample.bat内でのexitコマンド(/bオプションなし)は、別インスタンスを終了させるが、親インスタンスは終了しない。

callコマンドは

  • sample.batをカレントインスタンスで起動する。Linuxでいうところの. sample.shのようなもの。
  • sample.bat内での値の再代入は、setlocalでスコープを閉じない限り、親の変数に影響してしまう。
  • sample.bat内でのexitコマンド(/bオプションなし)は、カレントインスタンスを終了させる(突然窓が落ちて驚くやつ)。

sample.batでの起動は

  • call sample.batと同じ。

のようです。以下に検証結果を載せておきます。

sample.bat
@echo off

echo **** start of sample.bat ****

rem =========================
rem 変更前の値
rem =========================
echo subshell : %local_var% %global_var%

rem =========================
rem 変数の再代入
rem =========================
setlocal
set local_var=128
echo subshell : %local_var% %global_var%
endlocal
set global_var=255

rem =========================
rem 変更後の値
rem =========================
echo subshell : %local_var% %global_var%

echo **** end of sample.bat ****
実行結果
rem ============================
rem STARTコマンド
rem ============================
G:\>set local_var=1
G:\>set global_var=2
G:\>echo %local_var% %global_var%
1 2

G:\>start /b sample.bat
G:\>**** start of sample.bat ****
subshell : 1 2
subshell : 128 2
subshell : 1 255
**** end of sample.bat ****

G:\>echo %local_var% %global_var%
1 2
rem => バッチファイル内での値(255)の再代入は親に影響しない
G:\>exit
G:\>echo %local_var% %global_var%
1 2
G:\>
rem ============================
rem CALLコマンド
rem ============================
G:\>set local_var=1
G:\>set global_var=2
G:\>echo %local_var% %global_var%
1 255

G:\>call sample.bat
**** start of sample.bat ****
subshell : 1 2
subshell : 128 2
subshell : 1 255
**** end of sample.bat ****

G:\>echo %local_var% %global_var%
1 255
rem => バッチファイル内での値(255)の再代入は親に影響する

G:\>
rem ============================
rem batを直接叩く
rem ============================
G:\>set local_var=1
G:\>set global_var=2
G:\>echo %local_var% %global_var%
1 2

G:\>sample.bat
**** start of sample.bat ****
subshell : 1 2
subshell : 128 2
subshell : 1 255
**** end of sample.bat ****

G:\>echo %local_var% %global_var%
1 255
rem => バッチファイル内での値(255)の再代入は親に影響する
G:\>

「0 の使い方が誤っています。」等のエラーが出るが原因が特定できない

バッチファイルを書くのになれないうちは、以下のような実行時エラーやメッセージにしばしば遭遇することでしょう。

  • 0 の使い方が誤っています。
  • ( の使い方が誤っています。
  • ECHO は <OFF> です。

これだけ言われても、言葉足らずで意味が分かりませんよね。ですが僕の経験上、たいてい以下が原因であることが多いです。

【原因1】変数名のスペルミス

一番多いのが単純に変数名をスペルミスしてしまい、別の変数として解釈されてしまうケースです。

以下、変数名「STATUS」を誤って「STAUTS」とタイポしてしまった例です。「STAUTS」は未初期化変数=値が設定されていないため、それをechoしようとすると引数の無いechoを実行するのと同じことになるため、「ECHO は <OFF> です。」というメッセージが出力されます。

○STATUSを×STAUTSとタイポ
@echo off

set STATUS=255
echo %STAUTS%
rem => 「ECHO は <OFF> です。」

以下は、equ演算子の左オペランドが無いと解釈されるため「0 の使い方が誤っています」というメッセージが出力されます。

○STATUSを×STAUTSとタイポ
@echo off

set STATUS=0
if %STAUTS% equ 0 (
    echo ステータスは 0 です.
)
rem => 「0 の使い方が誤っています。」

【原因2】%変数名%が即時展開されている

まともなプログラム言語に親しんでいる人にとっては、以下のコードには何も問題は無いと感じることでしょう。

rem 変数に値を代入し、それを参照しているだけのコード。
rem どこに問題があるのか...

if defined STATUS (
    rem 変数に代入
    set /a STATUS = 0

    rem 変数を参照
    if %STATUS% neq 0 (
        echo エラーが発生しました.
    )
)

しかし、バッチファイル的には許されないコードなのです!実際、このコードを実行すると「0 の使い方が誤っています。」という実行時エラーが出力されます。

なぜなら、処理系(cmd.exe)がこのif文を解釈する際、まず(遅延展開されていない)すべての%変数%を値に展開し、その上でif文を実行します。すると、if文に入るタイミングでは変数STATUSは初期化されていませんから、%STATUS%は空文字列に展開されます。

イメージとしては、処理系の視点からは以下のようなコードを実行することになります(neq演算子の左オペランドが無い)。

×ill-formed
    rem 終了ステータスを確認
    if neq 0 (
        echo エラーが発生しました.
    )

その結果、「0 の使い方が誤っています。」という実行時エラーが出力されます(neqの左右オペランドが消えた場合は「( の使い方が誤っています。」)。なお、これはif文の条件部が偽であり、ifブロックの中に入らない場合もこのようになります(構文解析時の問題なので)。

Linuxのシェルでコマンドラインを実行する際、シェルがコマンドライン中に含まれる${変数名}を値に展開するのと同様、cmd.exeにとってはif文やfor文もコマンドに近い扱いなのかもしれません。

ちなみにデバッグ時は@echo onにしてバッチファイルを実行すると、cmd.exeif文やfor文内の変数を展開した結果が出力されます。

環境変数が空の場合のdefinedの結果

definedは、オペランドに指定された名前の環境変数が定義されている場合は真を返し、定義されていない場合は偽を返します。使い方はexistと同じです。

環境変数に一度設定された値が削除された場合はどうなるの?というと、その場合も偽を返します。

if not defined hogehoge (
    echo 'hogehoge' is not defined.
)
rem => 'hogehoge' is not defined.

set hogehoge=255
if defined hogehoge (
    echo 'hogehoge' is defined.
)
rem => 'hogehoge' is defined.

set hogehoge=
if not defined hogehoge (
    echo 'hogehoge is not defined.'
)
rem => 'hogehoge is not defined.'

環境変数が空かどうかを判定するのに、僕はif "%VAR%"=="" (...)とするよりif not defined VAR (...)と書いたほうが見栄えがいいので、definedを使っています。

setlocalを入れ子にした時の変数スコープ

setlocalendlocalのブロックは入れ子にできますが、スコープが包含関係にある同名変数は、内側のスコープをもつ変数が有効となります。

set var=2147483647

setlocal
    set var=1
    setlocal
        set var=255
        echo var=%var%    // 255
    endlocal
        echo var=%var%    // 1
endlocal
        echo var=%var%    // 2147483647