今回は私が普段どんなことを考えながらコードを整理しているのかを紹介する。
題材としてはなるべくシンプルなものがよかったので、カレンダーのヘッダー部分を作成するマクロを取り上げた。
最初に書いたコードがどういう風に変化していくか、またなぜそのように変更したのかに注目していただければと思う。
最初のコード
まずはよく初心者が書くようなコード。
Book1.xlsmの、標準モジュールに書く想定である。
Sub カレンダー() Workbooks("Book1.xlsm").Sheets(1).Activate Range("B2").Value = "日" Range("C2").Value = "月" Range("D2").Value = "火" Range("E2").Value = "水" Range("F2").Value = "木" Range("G2").Value = "金" Range("H2").Value = "土" End Sub
コードを書きながら常に考えるのは変更に強いコードを書くということだ。
変更に強いとは、将来何かが変更された場合にそのままうまく動作する、またはコードの修正が容易であるということだ。
上のコードでまず目につくのはWorkbooks("Book1.xlsm")の部分。
これだとブックの名前が変わっただけで動かなくなる。
他のブックに書き込むのなら別だけれど、今回は同一ブック内にマクロを作る想定なので、これはThisWorkbookに変更しよう。
2番目のコード
さて、自身のブックを指すのにブック名で指定するのをやめて、ThisWorkbookに変更してみた。
これでファイル名が変更されても動作する。ひとつ変更に強くなったわけだ。
Sub カレンダー2() ThisWorkbook.Sheets(1).Activate Range("B2").Value = "日" Range("C2").Value = "月" Range("D2").Value = "火" Range("E2").Value = "水" Range("F2").Value = "木" Range("G2").Value = "金" Range("H2").Value = "土" End Sub
次に考えるのが、依存関係を断ち切るということだ。
Rangeへのアクセスは親オブジェクトを指定しない場合、ActiveWorkbookのActiveSheetのRangeという意味になる。
つまり、Rangeへのアクセスはすべて、先頭に書いたThisWorkbook.Sheets(1).Activateに依存している。
Active○○はExcelが管理する超グローバル変数のようなもので、ユーザーもプログラムもここにアクセスできる。便利ではあるが、その時々で指すものが変わるオブジェクトなので、このように特定シートを指す目的で使うべきではない。
つまり「今どのシートがアクティブか」という状況に依存しているので、これを断ち切る。
※ユーザーの領分を侵しているという点でもActive○○の乱用はよろしくない。
thom.hateblo.jp
3番目のコード
さて、状況依存を避けるために親オブジェクトをきちんと指定することにした。
Sub カレンダー3() ThisWorkbook.Sheets(1).Range("B2").Value = "日" ThisWorkbook.Sheets(1).Range("C2").Value = "月" ThisWorkbook.Sheets(1).Range("D2").Value = "火" ThisWorkbook.Sheets(1).Range("E2").Value = "水" ThisWorkbook.Sheets(1).Range("F2").Value = "木" ThisWorkbook.Sheets(1).Range("G2").Value = "金" ThisWorkbook.Sheets(1).Range("H2").Value = "土" End Sub
次に考えるのは、コードの重複をまとめるということ。
親オブジェクトを持ってきたのはいいけれど、これじゃ長ったらしくてみっともない。
また、シートが1から2に変更になったら7箇所の変更が生じる。
4番目のコード
With文を用いて、親オブジェクトを一か所にまとめてみた。
Sub カレンダー4() With ThisWorkbook.Sheets(1) .Range("B2").Value = "日" .Range("C2").Value = "月" .Range("D2").Value = "火" .Range("E2").Value = "水" .Range("F2").Value = "木" .Range("G2").Value = "金" .Range("H2").Value = "土" End With End Sub
あれ?これも依存なんじゃないの?
はい、そのとおり。
でもユーザー操作に影響を与える・受けるActive○○と違って、Withはプログラム上で一時的に作成される箱庭みたないもの。End Withとともに消滅するので後腐れがない。Active○○が自分で戻すまでActiveにしっぱなしなのとは対照的である。
さて、まだまだ課題はある。
変更に強いコードといいながら、肝心のセル指定がベタ書きだ。
これでは"日"の記入位置をC3にしようと思ったら全部書き換えないといけない。
5番目のコード
セルアドレスのベタ書きでは、記入位置の変更時に面倒なので、Range指定をやめてCellsで行と列を変数で管理してみた。
Sub カレンダー5() Dim x As Long Dim y As Long x = 2 y = 2 With ThisWorkbook.Sheets(1) .Cells(y, x).Value = "日" .Cells(y, x + 1).Value = "月" .Cells(y, x + 2).Value = "火" .Cells(y, x + 3).Value = "水" .Cells(y, x + 4).Value = "木" .Cells(y, x + 5).Value = "金" .Cells(y, x + 6).Value = "土" End With End Sub
これでxとyを変更すれば、開始位置をコントロールできる。
でもxに足す値が1~6まで順に並んでるんだから、これもFor文でまとめられそうだ。
でも"日"から"土"まではどうすれば良いだろうか。
6番目のコード
実は、"日"~"土"の文字列もFormat関数を使うとループさせることができる。
ここで考えるのは、実験的な小さなマクロを作るということ。
あるロジックに集中したいとき、メインのコードをそのままいじってしまうと動かなくなったり、あるいは他のロジックが邪魔してうまく考えられない。
そんなときは、小さなマクロを別に作って実現したいロジックに集中するのだ。
今回やりたいのは、"日"~"土"の文字列ループ。そこで、以下のように適当なマクロを作り、その中で考える。
Sub hoge() 'どうにかする End Sub
失敗してもメインコードを壊さないように、箱庭を作るのだ。
まず曜日のループで使えそうなのは、Format関数である。
Format関数は、数値や日付を書式指定の文字列に整形してくれる関数だ。たとえばFormat(Now, "yyyymmdd")と書くと、"20161223"という風に日付を所定の文字列で返してくれる。
この書式指定に"aaa"を渡すと、曜日の文字列を返してくれるのだ。
つまり適当な日曜の日付を初期値をして選び、1日ずつ足しながらFormat関数で曜日を取り出すという処理が可能。
日付型は1を足すと1日後になるので、以下のように足す数をFor文で増やしていけば"日"~"土"まで出力される。
Sub hoge() For i = 0 To 6 日付 = CDate("2016/12/25") + i Debug.Print Format(日付, "aaa") Next End Sub
さらに、そんなことしなくても実はVBAの日付型ってDouble型で管理されているので最初から数値で管理することもできる。
Excelに日付を値貼り付けしたら実数になった経験があると思うけれど、あれと同じ。
整数部が日付、小数部が時刻になっていて、シリアル値と呼ばれる。
今回ほしいのは日付だけなので、整数とみなしてよい。
それで整数1を日付型に変換すると、1899/12/31が取得できる。
これ、都合の良いことに、日曜日なのだ。
ということで、"日"から"土"までループで出力するには、単に1~7をFormatで曜日に変換すれば良いことになる。
Sub hoge() For i = 1 To 7 Debug.Print Format(i, "aaa") Next End Sub
ここまでで箱庭は終了。メインコードに組み込んでいく。
できたのがこちら。
Sub カレンダー6() Dim x As Long Dim y As Long Dim i As Long x = 2 y = 2 With ThisWorkbook.Sheets(1) For i = 1 To 7 .Cells(y, x + i - 1).Value = Format(i, "aaa") Next End With End Sub
7番目のコード
さて、次に考えるのは変数のスコープを縮めるということ。
これについてはこちらの記事で書いたので説明を省略。
thom.hateblo.jp
コードは少しコンパクトになった。
Sub カレンダー7() Dim x As Long: x = 2 Dim y As Long: y = 2 Dim i As Long With ThisWorkbook.Sheets(1) For i = 1 To 7 .Cells(y, x + i - 1).Value = Format(i, "aaa") Next End With End Sub
8番目のコード
こうなると、Withも煩わしいので外してしまう。
Sub カレンダー8() Dim x As Long: x = 2 Dim y As Long: y = 2 Dim i As Long For i = 1 To 7 ThisWorkbook.Sheets(1).Cells(y, x + i - 1).Value = Format(i, "aaa") Next End Sub
もともと親オブジェクトを複数行書くのが面倒だからWithでまとめたので、1行になってしまえばWithのほうが行数が増えてしまう。
9番目のコード
Withを外したら今度は一行がごちゃっとしてしまったので変数shに変更。
Sub カレンダー9() Dim x As Long: x = 2 Dim y As Long: y = 2 Dim i As Long Dim sh As Worksheet: Set sh = ThisWorkbook.Sheets(1) For i = 1 To 7 sh.Cells(y, x + i - 1).Value = Format(i, "aaa") Next End Sub
ただやはり代入の部分がややこしい。
問題はおそらく(y, x + i - 1)である。
10番目のコード
ということで、直観的にわかるRangeプロパティに戻し、オフセットで位置決めすることに。
Sub カレンダー10() Dim i As Long Dim sh As Worksheet: Set sh = ThisWorkbook.Sheets(1) For i = 1 To 7 sh.Range("B2").Offset(0, i - 1).Value = Format(i, "aaa") Next End Sub
それでもやはり(0, i - 1)が気になる。つまり、曜日のスタートが1なのに対し、オフセット値は0スタートだからそこのズレを埋めるのにマイナス1しているのだ。iを0スタートにすると今度Format関数がi+1になってしまう。
あちらを立てればこちらが立たず。困った。
11番目のコード
どうしてもiの差分調整が気に入らないので、最近よくやるオブジェクト変数の中身を入れ替えていく手法を採用してみた。
Sub カレンダー11() Dim i As Long Dim sh As Worksheet: Set sh = ThisWorkbook.Sheets(1) Dim r As Range: Set r = sh.Range("B2") For i = 1 To 7 r.Value = Format(i, "aaa") Set r = r.Offset(0, 1) Next End Sub
変数iは1スタートで曜日だけを管理し、記入先のセルはRangeオブジェクトでSet r = r.Offset(0, 1)を呼ぶ度に右にズレていくという方法。
12番目のコード
記入先セルをオブジェクト変数での管理にしたなら、変数shは要らなくなり、直接変数rに対象セルを入れたら良い。
そのように変更した。
Sub カレンダー12() Dim i As Long Dim r As Range: Set r = ThisWorkbook.Sheets(1).Range("B2") For i = 1 To 7 r.Value = Format(i, "aaa") 'Format(1~7,"aaa") ⇒ 日~土 Set r = r.Offset(0, 1) Next End Sub
それから、Format関数が直観的にわかるようにコメントを記入。
13番目のコード
ここでまた、変更に強いコードを考える。
確かに変更箇所は一か所にまとまったけれど、これはヘッダーの開始位置に限った話だ。
カレンダーの中身を書き出すとまたそのスタートセルを考える必要があり、最終的に開始セルの管理が2箇所に散らばってしまう。
そこでヘッダーを作成するコードでは開始セルを管理せず、独立したプロシージャ化して引数で持ってくるという手法を考える。
つまり、曜日ヘッダー作成プロシージャを作り、メインマクロのカレンダー13から呼び出す形に変更するのだ。
Sub 曜日ヘッダー作成(開始セル As Range) Dim i As Long Dim r As Range: Set r = 開始セル For i = 1 To 7 r.Value = Format(i, "aaa") 'Format(1~7,"aaa") ⇒ 日~土 Set r = r.Offset(0, 1) Next End Sub Sub カレンダー13() 曜日ヘッダー作成 開始セル:=ThisWorkbook.Sheets(1).Range("B2") End Sub
曜日ヘッダー作成プロシージャは記入位置を直接Range型で受け取るので、メインコードから開始セルをそのまま渡してやれば日~土まで記入される。
曜日ヘッダー作成プロシージャは、わずか6行!
。。。
えっ?
。。。あと1行で曜日の数と一緒やん。
14番目のコード
Sub 曜日ヘッダー作成2(開始セル As Range) 開始セル.Offset(0, 0) = "日" 開始セル.Offset(0, 1) = "月" 開始セル.Offset(0, 2) = "火" 開始セル.Offset(0, 3) = "水" 開始セル.Offset(0, 4) = "木" 開始セル.Offset(0, 5) = "金" 開始セル.Offset(0, 6) = "土" End Sub Sub カレンダー14() 曜日ヘッダー作成2 開始セル:=ThisWorkbook.Sheets(1).Range("B2") End Sub
完成~!
いやいや、いままで散々こねくり回したのは何だったのか。
悪い夢でも見ていたようだ。
まとめ
まさにシンプル・イズ・ベスト。
KISSの法則(Keep It Simple, Stupid!!!) について再考させられた。
訳) ややこしいことすな、アホ!の法則
↓まぁ他にもいろいろ試してみたんだけれど、
Sub 曜日ヘッダー作成3(開始セル As Range) Dim i As Long For i = 0 To 6 開始セル.Offset(0, i) = Format(i + 1, "aaa") Next End Sub Sub 曜日ヘッダー作成4(開始セル As Range) Dim i As Long For i = 0 To 6 開始セル.Offset(0, i) = Split("日 月 火 水 木 金 土")(i) Next End Sub
コード理解のコストと天秤にかけると、今回のケースでは曜日ヘッダー作成2がベストかなと思う。
経験を積んだプログラマーにとってコードの重複はどうしても気持ち悪く見えてしまうのだが、なんでもかんでも重複排除すれば良いってものではない。
一瞥しただけで処理が把握できるようなコードを書けたらベストだ。とはいえ、なんでもかんでも愚直に書くとそれはそれでコードの肥大化を招いてそもそも長すぎて一瞥できないなんてことにもなるし、修正箇所が多くなって変更にも弱くなる。
逆に少々トリッキーなコードになってもプロシージャや変数の名前をきっちりわかりやすくしておけば中身を詳細に読まなくてもやってることは容易に想像できるし、複雑なテクニックを用いた箇所はコメントで補足すればよい。
このへんのバランス感覚は非常に大事だと思う。