最近会社で聞かれて答えたことと、その一歩先のことをまとめてみました。
事の発端
バッチファイルで曜日を取得したいと聞かれました。フォルダやファイルを曜日を使って決めるようなケースで、曜日算出をしたいという内容でした。
全くの一から作るならPowerShellという選択肢もあります。既にバッチファイルで大まかな処理が組まれてあって、手早く修正を済ませたいとなると、PowerShellというわけにはいきません。今回もそのような流れで、バッチファイルをちょこっと編集するという方法を採りました。
バッチファイルで曜日取得というと、大きく二つの方法があります。
一つは、曜日算出を容易に行うことができる他言語を呼び出す方法です。VBS(CScript)を使うケースがよく見つかります。Qiita内でも見つかります。もう一つは、ツェラーの公式を用いてバッチファイルだけで頑張るという方法です。
今回は後者を採用しました。
回答
方法さえ決まれば、やり方はネットでいくらでも見つかります。
注意点は、正しく動くやり方のものを見つける必要があるというところくらいです。
ありがちなのは、8月に処理をするとM=08となり、悪いサンプルを使うと"SET /A"でエラーになるというパターンがあります。0が前置されていると8進数とみなされてしまうためです。
その一歩先
さて、今回は即座に対応しないといけないこともあり、曜日算出処理を直に差し込むような方法で直しました。
しかし、この直し方だと以下のような問題があります。
- 再利用しにくい
- 変数の衝突があり得る
- メイン処理の変数と、曜日を表す変数との衝突
- メイン処理の変数と、曜日計算に用いるワーク変数との衝突
- 変数の衝突があり得る
- 全体フローが分かりにくい
そこで"サブルーチン化、モジュール化、共通処理を独立化"です。バッチファイルだと、以下のような方法を使うことになります。
- "CALL"で呼び出し、"EXIT /B"で呼び出し元に戻る処理フローとする。
- ラベル(:~)であれば、同ファイル内のサブルーチン呼び出し
- ファイル名であれば、外部のバッチファイルのサブルーチン的呼び出し
- 呼び出し元の環境変数を破壊することを防ぐために、"SETLOCAL"~"ENDLOCAL"を使用する。
- "SETLOCAL"以降で使用された変数は、"ENDLOCAL"で元に戻ります。"SETLOCAL"時点で使用していない変数は未使用状態に、使用している変数は"SETLOCAL"時点の値に戻ります。
ここまではネットでもよく見る話です。加えて、個人的には以下のような方法を採用しています。
- 計算結果はERRORLEVELではなく環境変数で返す。
- どの環境変数に返すかは、呼び出し側から引数(%1など)で指定してもらう。
上記を考慮した形で曜日算出処理を書いてみました。
@ECHO OFF
SETLOCAL
: 曜日情報を取得
CALL :GET_WEEK WEEK
: 曜日名に変換
SET /A IDX=(WEEK + 1)
CALL :CHOOSE WEEK_NAME %IDX% 日 月 火 水 木 金 土
: 結果を表示
ECHO %DATE% ⇒ %WEEK_NAME%(%WEEK%)
EXIT /B 0
: ---------- ---------- ---------- ---------- ---------- ----------
:GET_WEEK
REM %1で指定された変数に、曜日を表す数字を返します。
REM 0=日、1=月、……6=土です。
SETLOCAL
: 年月日情報を取得
CALL :PARSE_DATE YYYY MM DD %2
: MMとDDを数値化(前0除去)
CALL :TO_NUMBER MM %MM%
CALL :TO_NUMBER DD %DD%
: ツェラーの公式を使用するための前準備
IF %MM% LEQ 2 (
SET /A YYYY=%YYYY% - 1
SET /A MM=%MM% + 12
)
: ツェラーの公式を使用して曜日を算出
SET /A WEEK=(%YYYY% + %YYYY% / 4 - %YYYY% / 100 + %YYYY% / 400 + (13 * %MM% + 8) / 5 + %DD%) %% 7
ENDLOCAL & SET %1=%WEEK%
EXIT /B 0
: ---------- ---------- ---------- ---------- ---------- ----------
:PARSE_DATE
REM %1,%2,%3に指定された変数名に、年(YYYY)月(MM)日(DD)を分割してセットします。
REM %4が指定されていない場合は現在日付を、%4が指定されている場合はYYYY/MM/DD形式で指定された日付をベースとします。
REM 注:月や日は、前0詰め2桁です。そのまま数値計算するとエラーとなる場合があります(08や09など、8進数と見なせない数字の場合)。
SETLOCAL
SET DT=%4
IF "%DT%" == "" (
SET DT=%DATE%
)
SET YYYY=%DT:~0,4%
SET MM=%DT:~5,2%
SET DD=%DT:~8,2%
ENDLOCAL & SET %1=%YYYY% & SET %2=%MM% & SET %3=%DD%
EXIT /B 0
: ---------- ---------- ---------- ---------- ---------- ----------
:TO_NUMBER
REM %2で指定された文字列を、数値と見なして、%1で示される環境変数に返します。
REM 例 : 09 ⇒ 9
SETLOCAL
SET NUM=%2
SET NUM0=%NUM%
SET NUM0=%NUM0:0=0%
SET NUM0=%NUM0:1=0%
SET NUM0=%NUM0:2=0%
SET NUM0=%NUM0:3=0%
SET NUM0=%NUM0:4=0%
SET NUM0=%NUM0:5=0%
SET NUM0=%NUM0:6=0%
SET NUM0=%NUM0:7=0%
SET NUM0=%NUM0:8=0%
SET NUM0=%NUM0:9=0%
SET /A NUM=( 1%NUM% - 1%NUM0% )
ENDLOCAL & SET %1=%NUM%
EXIT /B 0
: ---------- ---------- ---------- ---------- ---------- ----------
:CHOOSE
REM %2で指定された数値に応じて、%3以降の文字列を、%1で示される環境変数に返します。
REM %2が1なら%3を、%2が2なら%4を、……、%2がnなら%(n+2)を返します。
REM ExcelのChoose関数のイメージです。
SETLOCAL
SET VAR=%1
SET NUM=%2
CALL :TO_NUMBER NUM %NUM%
SHIFT
SHIFT
:CHOOSE_LOOP
IF "%NUM%" == "1" SET VALUE=%1
IF "%NUM%" == "1" GOTO :CHOOSE_EXIT
SHIFT
SET /A NUM=(%NUM% - 1)
GOTO :CHOOSE_LOOP
:CHOOSE_EXIT
ENDLOCAL & SET %VAR%=%VALUE%
EXIT /B 0
解説
以下の2点について解説しておきます。
- 計算結果はERRORLEVELではなく環境変数で返す。
- どの環境変数に返すかは、呼び出し側から引数(%1など)で指定してもらう。
計算結果はERRORLEVELではなく環境変数で返す。
ERRORLEVELでは、単一の数値しか返せないため、環境変数で返すようにしています。
":PARSE_DATE"は、年月日の3つの値を返しています。":CHOOSE"は、引数に指定された文字列を返しています。これらは"ERRORLEVEL"では実現できないことです。
また、環境変数による処理分岐は、"IF ERRORLEVEL"による処理分岐よりも柔軟である点も理由です。
どの環境変数に返すかは、呼び出し側から引数(%1など)で指定してもらう。
サブルーティンが固定的な環境変数で返す作りだと、結局呼び元の処理と環境変数がバッティングしてしまう可能性があります。それではせっかくモジュールを独立した意味が薄れてしまいます。そのため、返す環境変数を呼び出し元が指定する作りとしています。
なお、修正する際に"呼び元に返す環境変数の数"よりも"呼び元から渡される値の数"のほうが、将来的に変わる可能性が高いと考えています。そのため、"呼び元に返す環境変数"を左、"呼び元から渡される値"を右に配置しています。
補足
上記のコードは、実際に私が使っているものと異なる部分があります。
流用性のある処理は、サブルーチンではなくファイル独立化
上記は説明のために、それぞれのモジュールをサブルーチンとして、1本のバッチファイルとして記載しました。個人的に管理しているバッチファイルは、モジュールそれぞれを独立のバッチファイルとしています。
会社で使う分には、客先に配布するファイルが1本にまとまっているほうが管理しやすいだろうと思います。そうすると、上記のようにサブルーチン的な書き方をするほうが管理しやすいと思います。
個人で使う分には"使用するモジュールだけを他端末にコピーする"ような必要はありません。そのため、モジュールが1本のバッチファイルにまとまっている必要がありません。
また、複数のモジュールを単一ファイルにまとめると、ラベル名の競合問題が発生しやすくなります。上記のコードの":CHOOSE_LOOP"などです。それぞれのモジュールを独立のバッチファイルとしておけば、ラベル名の競合問題は気にする必要がありません。実際、個人で管理しているバッチファイルには":LOOP"と書いてあります。
このような理由から、個人で使うモジュールはそれぞれ独立のバッチファイルとしています。
エラー処理
言うまでもありませんが、エラー処理は省いてあります。
上記のような使い方をするなら"呼び元に返す環境変数がちゃんと引数指定されているか"をチェックする処理が必要です。
まとめ
"呼び元に値を返す時、呼び元から指定された環境変数に返す"というやり方が最適かどうかはわかりません。ただ、今までやってきた中では一番やりやすいやり方だと思っています。
Qiitaで検索すると、"そのようなやり方をしている人はほかにもいらっしゃる"ようです。"付録"の"ツェラーの公式"など。そのため、全くの悪い方法ではないだろうと思っています。
環境変数には"特殊文字が扱いにくい"という弱点はありますので、万能の方法ではありません。ただ、そこまで込み入ったことをするならPowerShellを使うべきなんだろうと思います。