367
@yz2cm

バッチファイルでの試行錯誤を回避するためのメモ-または人類には早すぎた言語

この記事の背景

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

(本記事を見てもらえば納得されると思いますが、DOSバッチの言語設計は「驚き最大の原則」を方針とし、その言語仕様は「実装」です。言語法律家というものが成立しえない、非常に平和な世界でもあります。)

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

(重要)バッチファイルは人類には早すぎる

バッチファイルの難しさは、以下の二点に起因します。

  1. バッチファイルのユーザーフレンドリーでない言語仕様により、バグを作りこみやすい点。
  2. cmd.exeのバグに遭遇する確率が小さくない点。

この二点が混在するため、問題発生時、原因の切り分けに非常に時間がかかります。本記事は、仕方なくバッチファイルを書かざるをえない状況において、上記1に頭を抱える時間を最小化することを目的とした記事です。

2について。バッチファイルは、cmd.exeの不可解なバグに悩まされることが少なくないです。cmd.exeのバグを踏んだせいで、バッチファイルに不備は無いにもかかわらず「自分が書いたコードに不備がある」と思い込み、書いたコードを穴の開くほど見直したり、ひたすらググって半日溶かした結果、原因不明のまま終わることが本当に多いんです。

「処理系のバグなんて、どうせ言語仕様の重箱の隅をつつく話でしょう」
「僕には私には関係ない話だな」と思ったそこのあなた。

cmd.exeを舐めてはいけない。ネットに転がってるオーソドックスなサンプルコードをマネして書いたのに、なぜか不可解な挙動をする。それがcmd.exeの恐ろしいところなんです。

「バッチファイルは人類には早すぎる」…これが僕のバッチファイル観です。

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

C:\>ver

Microsoft Windows [Version 6.1.7601]

環境変数の遅延展開はなるべく使わない

大事なことなので冒頭に持ってきました。環境変数の遅延展開とは、setlocal enabledelayedexpansionとか、!varname!でおなじみのアイツです。

なぜ事前展開がデフォルトで、遅延展開がオプトインなのか理解に苦しみますが、ハマるケースが多いので使わずにすませられるなら使わないほうが無難です。

環境変数の遅延展開を使わずに済ませる方法は、以下の記事を参考にしてみて下さい。

SetLocal EnableDelayedExpansionの罠とその回避方法

変数名の先頭文字が単一文字変数とかぶらないように注意する

クイズです。以下のコードを実行すると、何が表示されるでしょうか。

set "customer_name=****"
for /f "tokens=1-4" %%a in ("xxx yyy zzz") do (
  call func "%%customer_name%%"
)

:func
  echo %~1
exit/ b 0
  1. ****
  2. %customer_name%
  3. zzzustomer_name
  4. 何も表示されない

【回答】
正解は3です。変数%%customer_name%%は、%%customer_name%%に分離されます。%%czzzに展開された結果、zzzustomer_nameが出力されます。

てか、なんでそもそも%%customer_name%%%2つ)にしてるの?と思うかもしれませんが、これには理由があり、サーカムフレックス増殖バグを回避するためのハックなのです。

ようこそ、バッチファイルの狂気の世界へ!

「%~dp0」の末尾の「¥」を除去したい

set "curDir=%~dp0"
call set curDir=%%curDir:~0,-1%%

rem %~dp0 => 'c:\local\'
rem %curDir% =>  'c:\local'

%~dp0で取得されるパスは、C:\local\のように末尾にパスセパレータ(\)がつきます。
末尾にパスセパレータがついてることを承知の上で、%curDir%binのようにパスを組み立てるのもありですが、「フォルダパスは末尾セパレータなし」に統一したい人は、上の方法で取り除けばよいでしょう。

僕は、バッチファイルではできるだけテクニカルなことをしたくないので、%~dp0の末尾のパスセパレータはつけたままにしています。

現場ではソースコードをバージョン管理してるにも関わらずコメントアウトで変更履歴を残してるのは何故?バカなの?

rem 2020/04/12 (山田)修正開始

↑ソースコードをバージョン管理しているにも関わらず、こういうコメントアウトによる変更履歴が散見される場合、それはSIerによく見られるマーキング行為です。目障りであれば削除して構いません。

先輩に変更履歴を書けと言われたかもしれませんが、白痴(Excel方眼紙系サラリーマンSEの雑魚)の言うことは無視して全く問題ありません。

if文やfor文の書き方をすぐ忘れてしまう…

if /?for /?set /? をどーぞ。

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

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

〇よい例
if 1 equ 1 (
    echo Hello world!
)
rem =>Hello world!
×悪い例
if 1 equ 1(
    echo Hello world!
)
rem =>コマンドの構文が誤っています。

for文のin(の間にはスペースを入れよ

これもよく忘れますが、for文におけるin(間にも半角スペースが必要です。

〇よい例
for /l %%a in (1 1 10) do @echo a=[%%a]
×悪い例
for /l %%a in(1 1 10) do @echo a=[%%a]
rem ->「in(1 の使い方が誤っています。」

コマンドプロンプトで単一文字変数を使用するときは%a

単一文字変数(%%aのような変数)は、バッチファイルでは%%aのように書きますが、コマンドプロンプトから実行するときは%aのように書きます。

バッチファイル
for /l %%a in (1 1 10) do echo %%a
コマンドプロンプト
for /l %a in (1 1 10) do echo %a

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

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

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のwhichのようなもの

whereです。ロッテに対するロッチみたいですね。

>where cmd
C:\Windows\System32\cmd.exe

完全に余談ですが…「というと年がばれる」とよく言いますけど、「というと年がばれる」と口走ること自体が年だと思うのですが。「(メーカー名)の回し者ではありません」も年がばれるシリーズですね。ちぃ、おぼえた。

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」という名前のファイルが存在していたので、はて、なんじゃこりゃあ?と思っていたら、職場の新人さんが手順書のコマンドをコピペした際、hostname >echo hogeと、複数行をまとめてプロンプト記号までコピペしてコマンドプロンプトに貼り付けていたのが原因でした。犯人はお前か!

echo.echo^.も同様)は「echo」「echo=」「echo;」「echo,」という名前(大文字・小文字は不問)のファイルがカレントに存在する時に実行すると、エラーとなります。これが冒頭で言った「レアケースな罠」です。

そんなこと言われても...じゃあどうすりゃいいのさ?(コロコロを転がす)

そこで暇人な私が調査しました。以下は、カレントに「echo」「echo=」「echo;」「echo,」という名前のファイルが存在しても正常動作する(空行を出力する)コマンドの一覧です。お好きなものを選んで下さい。

# 空行出力コマンド 備考
1 echo(
2 echo= echo^=でも可
3 echo[ echo^[でも可
4 echo; echo^;でも可
5 echo+ echo^+でも可
6 echo: echo^:でも可
7 echo] echo^]でも可
8 echo, echo^,でも可
9 echo/ echo^/でも可
10 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

話が少し煩雑になってきましたが、表にまとめるとこうなります。

# 演算子 説明
1 == 来たものはなんでも文字列として比較する。
/iスイッチの指定は大文字・小文字を区別しない。
2 equ
neq
lss
leq
gtr
geq
数字のみならば数値として比較する。
数字以外の文字を含むならば文字列として辞書順で比較する。
/iスイッチの指定は大文字・小文字を区別しない。

なお、文字列同士の比較はダブルクォートを含めて比較されますのでご注意を。

文字列同士の比較
set a=foo

if %a% equ "%a%" (
    echo "matched"
) else (
    echo "mismatched"
)
実行結果
"mismatched"

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

UNCパス上でバッチファイルを実行する場合の注意点

バッチファイルでは、カレントディレクトリをUNCパスに変更できないので、cd /d \\Server\\SharedFolderなどを実行するとエラーになります。

これ即ち、バッチファイルをUNCパス上に置いて実行した場合、バッチファイルの常套である「冒頭でバッチファイルのカレントに移動して、バッチファイル内でのパス指定は相対パスを使用する」が使えなくなります。UNCパスでの実行を想定するならば、バッチファイル内では絶対パス指定を使うしかない...ということになります。

pushd/popdを使用するというテもありますが、バッチファイルがpopdせずに終了した場合、バッチ終了後もドライブのマウントが残ってしまう問題もあります)

それじゃあ、バッチファイルのカレントも取得できないのでは?絶対パスも作成できないのでは?と僕も思っていましたが、じつは幸いにも制限は「UNCパスへの移動ができない事」だけで、バッチファイルのカレントパス(%~dp0)の取得や、ファイルやフォルダのパス指定にUNCパスは使用可能です(例:dir \\Server\\SharedFolder\hoge.txt

絶対パスを使用する場合、ひとつ注意点が。

バッチファイルは、独自に決めたディレクトリ構成(例:バッチのカレントにbinフォルダ、logフォルダなどが存在)のもとで実行されることが多いので、相対パス指定を使う場合は奇異なフォルダ名に悩まされることは無いと思います。

しかし絶対パス指定の場合、バッチファイル自身がどんなディレクトリに置かれて実行されるかは予測できないので、バッチファイルの中で絶対パス指定を使用する際はダブルクォートで囲むなどしましょう(パスにスペースが含まれるケースの対策)。

※本Tipsは「バッチファイルでの「UNC パスはサポートされません」はエラーではない」を参考にさせていただきました。

-2147483648を代入したい

DOSバッチファイルの整数は32bit符号付き整数であるため、-2147483648を直接代入することはできません。「2147483648をオペランドにとる単項-演算子の式」と解釈されるためです。

×悪い例
>set /a n = -2147483648
無効な数字です。数値は 32 ビットで表記される数値です。

C言語の例のアレと同じ原理で、以下のように書けば問題なく代入できます。

〇よい例
>set /a n = -2147483647 - 1
-2147483648

SetLocal EnableDelayedExpansionの罠と回避方法

以下は、カレントディレクトリにあるtxtファイルについて、その内容を表示するというバッチファイルです(エラー判定でERRORLEVELを遅延展開させたいので、setlocal enabledelayedexpansionを呼出しています)。

簡単なバッチファイル
setlocal enabledelayedexpansion

for %%a in (*.txt) do (

    type "%%a" >nul 2>&1

    if !ERRORLEVEL! neq 0 (
        echo "[%%a] Error has occurred."
    ) else (
        echo "[%%a] is OK."
    )
)
endlocal

SetLocal EnableDelayedExpansionの罠

上記のバッチファイルは作り的に考慮漏れです。なぜなら、カレントディレクトリに「!xxx!.txt」のような名前のファイルが存在するとエラーが発生するからです。

実行結果
>check.bat
"[.txt] Error has occurred."

エラー発生時に%%aに代入されていたのは!xxx!.txtという文字列ですが、!xxx!の部分が遅延展開と認識されてしまうようです。

つまり、%%a!xxx!.txtに展開され、さらに!xxx!を展開しようとします。しかしxxxという変数は未定義であるため、!xxx!の展開結果はブランクとなります。そのなれの果てが.txtという文字列です。

パスワードやファイル名など、含まれる文字をコントロールできない変数を扱う場面では、事実上setlocal enabledelayedexpansionが使えなくなってしまいます。

では、どんな回避方法があるでしょうか。

回避方法

以下のように、SetLocal~EnableDelayedExpansionのスコープを最小限にしたり

回避方法1:スコープを最小限にする
for %%a in (*.txt) do (

    type "%%a" >nul 2>&1

setlocal enabledelayedexpansion
    if !ERRORLEVEL! neq 0 (
endlocal
        echo "[%%a] Error has occurred."
    ) else (
endlocal
        echo "[%%a] is OK."
    )
)

forブロック内の処理を関数に逃すというテもあります。こうすればSetLocal EnableDelayedExpansionを使う必要がなくなります。

回避方法2:関数に逃がす
for %%a in (*.txt) do (
    call :func "%%a"
)
exit /b 0

:func
    type "%~1" >nul 2>&1
    if %ERRORLEVEL% neq 0 (
        echo "[%~1] Error has occurred."
    ) else (
        echo "[%~1] is OK."
    )
exit /b 0

というわけですので、バッチファイル全体をSetLocal EnableDelayedExpansionで囲むなどという恐ろしいことをしてはなりません…。

余談

なお、値を関数の引数に渡すときに以下のように値をダブルクォートしているのは「ファイル名に空白文字が含まれたファイル」対策です。ダブルクォテーションが無いと、空白文字で区切られた複数の引数として認識されてしまうためです。

スペースをエスケープ
call :func "%%a"

(本Tipsは「SetLocal EnableDelayedExpansionの罠とその回避方法」から転載したものです。)

サーカムフレックスの不思議

ダブルクォートで囲まれたサーカムフレックス("^")を関数の実引数に指定してcallし、呼出し先の関数内で仮引数を%Nまたは"%~N"と展開すると、なんと1個の^につき2個の^に増えてしまいます。

サーカムフレックスを含む文字列を関数に渡した時の不思議な挙動
call :func "^"
rem => 「"^^"」が出力される

call :func_quoted_tilda "^"
rem => 「"^^"」が出力される

exit /b 0

:func
    echo %1
exit /b 0

:func_quoted_tilda
    echo "%~1"
exit /b 0

この現象は、"%N"%~N形式の展開では発生しません。また、for文の単一文字変数の場合でも発生しません。

for %%a in ("^") do (echo %%a)
rem =>「"^"」が出力される(期待通り)

for %%a in ("^") do (echo "%%~a")
rem =>「"^"」が出力される(期待通り)

なぜこうなるのか、皆目見当がつきません。

ワークアラウンドとしては、文字列を関数に渡すときに値ではなく変数名を渡すようにします。関数内で当該変数を使用する時は、仮引数を変数名に展開し、展開された変数名を使用してさらに値に展開します。こうすれば、関数に値を渡すときの"^"の自動変換が抑止されます。

ワークアラウンド「変数名の値渡し」
set circumflex="^"
call :EchoCircumflex circumflex

exit /b 0

:EchoCircumflex
setlocal
    set varName=%~1
    call set string=%%%varName%%%
    echo %string%
endlocal

exit /b 0
実行結果
"^"

このテクニック、名づけるなら「変数名の値渡し」といったところでしょうか。それにしても酷いワークアラウンドだ…。

(2018/08/16追記)

こんな七面倒くさいことをせずとも、もっと簡単な回避方法がありました…。

実引数に「%%変数名%%」を指定
set circumflex="^"
call :EchoCircumflex %%circumflex%%
exit /b 0

:EchoCircumflex
setlocal
    echo %1
    echo "%~1"
endlocal

exit /b 0
実行結果
"^"
"^"

サーカムフレックス増殖の原因は、おそらくcall文の二回の構文解析の副作用と思われます。

構文解析の過程(サーカムフレックスが増殖するパターン)
set circumflex="^"
call echo %circumflex%
  --(構文解析1)--> call echo "^"
  --(構文解析2)--> call echo "^^" --> 実行
構文解析の過程(サーカムフレックスが増殖しないパターン)
set circumflex="^"
call echo %%circumflex%%
  --(構文解析1)--> call echo %circumflex%
  --(構文解析2)--> call echo "^" --> 実行

「構文解析の結末としてサーカムフレックスが現われる」ようにしたいので、一回目の構文解析では%%circumflex%%%%を一個の%に展開させて%circumflex%の変数展開を先送りします。

こうすれば「二段階目の構文解析をした結果としてサーカムフレックスが出現する」、つまり構文解析がサーカムフレックスを触るチャンスがなくなります。

結局のところ、なぜ構文解析対象の文字列リテラル中にサーカムフレックスが出現するとサーカムフレックスが増殖するのか?という問いには答えられませんでしたが、とにかく構文解析にサーカムフレックスを触らせなければ増殖を防げる、というワークアラウンドでした。

DOSバッチはループを中断できないのか?最終鬼畜処理系CMD.EXE

もう何も言うまい。CMD.EXEよ、そのまま我が道を突き進んでくれ。

CMD.EXEのバカさ加減がよくわかるコード
for /l %%a in (0,1,2147483647) do (
    echo hello
    goto :end_of_loop
)
:end_of_loop

echo good bye
実行結果
hello
(ここでフリーズ...)

タスクスケジューラのタスクをコマンドで作成したい

タスクスケジューラからタスクを作成しようとすると、オプションが沢山あって結局何をどう設定すりゃいいのさ?となりがちなので、コマンドでバシッと決めたいところです。

毎日00時00分にPowerShellを実行するタスク
> schtasks /create ^
/tn "SampleTask" ^
/tr "'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' -ExecutionPolicy RemoteSigned -File 'C:\local\sample.ps1'" ^
/st "00:00" ^
/sc daily ^
/np ^
/rl highest ^
/f
毎時00分にPowerShellを実行するタスク
> schtasks /create ^
/tn "SampleTask" ^
/tr "'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' -ExecutionPolicy RemoteSigned -File 'C:\local\sample.ps1'" ^
/st "00:00" ^
/sc hourly ^
/np ^
/rl highest ^
/f

なお、タスクスケジューラを開くには、「ファイル名を指定して実行」からcontrol schedtasksを実行します。

バッチファイルの絶対パスに半角開きカッコ「(」が含まれる時に発生する不具合

バッチファイルのフルパスに「(」が含まれている場合、そのバッチファイルの右クリックメニューから「管理者として実行」を選択すると、バッチファイルは実行に失敗します。

例えば、以下のようなパスのバッチファイルが該当します。

C:\usr\local\test(1)\sample.bat
C:\usr\local\test\sam(ple.bat

おそらくですが、cmd.exeがトークン「(」で区切った結果、(以降の文字列が無視され、(より前の文字列を実行対象のパスとして取得した結果、C:\usr\local\test.exeC:\usr\local\test\sam.exeを実行しようとしているのだと思います。またユニークな解釈をしてくれはる処理系どすなあ(DOSだけに)。

実際、以下の状態で「sample.bat」を「管理者として実行」すると、C:\usr\local\test(1)\sample.batではなくC:\usr\local\test.exeが実行されます。

image.png

不思議でしょう?

まって!C:\Program Files (x86)の配下にあるバッチファイルは大丈夫なの!? …とお思いになった方もいらっしゃることでしょう。

ご安心なさい。パスに(が含まれていたとしても「パスのどこかに半角スペースが含まれていればこの不具合は発生しない」という例外があるのです。

パスをよくご覧なさい。(x86)の直前に半角スペースが含まれているでしょう?バッチファイルらしい、よく意味の分からない挙動ではありますわね。

この不具合は、以下のブログ記事で知りました。感謝。
Windowsバッチファイルを管理者として実行したときの問題点と回避方法2

再帰関数の制限

コンテキスト依存なのでしょうが、僕が試した場合では340回目の再帰呼出しで以下のエラーが発生しました。再帰は339回までって、残念ながら使い物にならないですね…

@echo off

call :f

:f
  call :f
  exit /b
実行結果
>sample.bat
******  バッチの再帰数がスタック制限を越えました ******
再帰回数=339, スタック使用率=90 パーセント
******       バッチ処理が中止されました      ******
367
ユーザー登録して、Qiitaをもっと便利に使ってみませんか。
  1. あなたにマッチした記事をお届けします
    ユーザーやタグをフォローすることで、あなたが興味を持つ技術分野の情報をまとめてキャッチアップできます
  2. 便利な情報をあとで効率的に読み返せます
    気に入った記事を「ストック」することで、あとからすぐに検索できます
yz2cm
チャメさん(茶トラの猫ちゃん)のことを愛しています。AWS認定SAA/DVA/SOA/SCS。

コメント

質問とかOKですか?

0

はいOKです。

1

有益な纏め、ありがとうございます。
setコマンドを使うとき、代入式(?)全体を一纏めに「"」で括るようにしてからトラブルがグッと減りましたよ。
こんな感じ:

set "key=value"
echo key=%key%

2

@ymgch-16 さん
""で括るテクニックをコメントで教えてもらってから、大変重宝してます。

0

わざわざ記事と関係ない愚痴を最初に書くなんて相当溜まってるんだなー

1

@yoshiaki1105 さん

まあね。お客様お客様と言いながら、エンドユーザーの使い勝手などこれっぽっちも考えてない設計・実装を目の当たりにするとキレそうになりますわ。

1
(編集済み)

この記事で大変助かりました。
偉大な記事に以下のKUSO過ぎるコードを捧げます・・・。

 遅延展開しつつ、
 ゼロパディングしつつ、
 powershell上で処理するために複数の命令を1行で投げつつ改行したい(単に複数行で投げると1行ごとに変数リセットされるので、セミコロン区切りの1行で投げる必要あり)、
 それをforで反復させたいとなると「丸カッコ閉じ」をことごとくエスケープ必要。「丸カッコ開き」はエスケープしなくても動くけど気持ち悪いのでついでにエスケープ。
 変数セット時、powershell は半角スペースが許されるが、cmd は許されない。

ChangeLastWriteMinute.bat
@echo off
@rem 連番ファイルの最終更新日時の「分」だけ異なるファイル群を作る
@rem ファイル自身はこのコードの前に生成済み。
@rem 以下のコードでは 0000.txt, 0001.txt の2ファイルだけ変更
setlocal enabledelayedexpansion
set set_min=00
set num=0000
for /L %%m in (1,1,2) do (
    @rem ゼロパディング4桁の連番ファイル名を生成
    set file=!num:~-4!.txt
    set /a num=num+1
    set num=0000!num!
    @rem 現在時刻から 「分」 のみ連番にする
    @rem "yyyy/MM/dd hh:" と "mm" と ":ss" を生成して結合
    @rem sp(Set-ItemProperty) 命令にて最終更新日時を変更
    powershell ^
        $tmp1 = ^(Get-Date^).ToString^(\"yyyy/MM/dd HH:\"^); ^
        $tmp2 = \"!set_min:~-2!\"; ^
        $tmp3 = ^(Get-Date^).ToString^(\":ss\"^); ^
        $tmp4 = $tmp1 + $tmp2 + $tmp3; ^
        echo $tmp4; ^
        $date = [DateTime]::ParseExact^($tmp4,\"yyyy/MM/dd HH:mm:ss\", $null^); ^
        echo $date; ^
        sp !file! LastWriteTime $date;
    set /a set_min=!set_min!+1
    set set_min=0!set_min!
)
pause
0
あなたもコメントしてみませんか :)
ユーザー登録
すでにアカウントを持っている方はログイン
記事投稿イベント開催中
Azure AIを活用した機械学習に関する記事を投稿しよう!
~
フロントエンド強化月間 - 開発する上で知っておくべき知見を共有しよう
~
  • 変数を使用した文字列置換
  • 文字列の切出し
  • Linuxの'>/dev/null 2>&1'のようなもの
  • Linuxのwhichのようなもの
  • Linuxのsleepのようなもの
  • Linuxのwc -lのようなもの
  • Linuxの\のようなもの(改行文字のキャンセル)
  • Linuxのtouchのようなもの
  • 改行文字1つのみの出力
  • 無限ループの書き方
  • 数字列の算術比較
  • callとstart /bの違い
  • 「0 の使い方が誤っています。」等のエラーが出るが原因が特定できない
  • 環境変数が空の場合のdefinedの結果
  • setlocalを入れ子にした時の変数スコープ
  • UNCパス上でバッチファイルを実行する場合の注意点
  • -2147483648を代入したい
  • SetLocal EnableDelayedExpansionの罠と回避方法
  • サーカムフレックスの不思議
  • DOSバッチはループを中断できないのか?最終鬼畜処理系CMD.EXE
  • タスクスケジューラのタスクをコマンドで作成したい
  • バッチファイルの絶対パスに半角開きカッコ「(」が含まれる時に発生する不具合
  • 再帰関数の制限