ゆるおたノート

Tomorrow is another day.

【VBA】クラスの作り方を整理してみた(移行手順編)

標準モジュールに書いていた処理を、何とかクラスモジュールに移行出来るようになってきました。

まだ世界がごちゃごちゃですが、記憶の新しいうちにメモを残してみます。

基本用語・概念については、こちらからどうぞ。 www.yuru-wota.com

手順

今回は「フォルダ内の複数のブックで、シートの値を置き換えて名前を変えて保存する」のを例として考える。

手続きの流れを考える

だいたいこんな処理を書くと思う。(日本語でごめんなさい)

  • フォルダ内に処理対象のブックがあるか確認する
  • 拡張子付きファイル名を取得
  • パスを取得
  • パスを指定してブックを開く
  • シートを取得
  • テーブルを取得
  • セルの値を取得
  • 文字列に変換
  • 文字列を置き換える
  • ブックの拡張子付きファイル名を取得
  • 現在のファイル名から新しいファイル名を取得
  • 格納先のフォルダを取得
  • ファイル名とフォルダの場所からフルパスを取得
  • 新しいパスでブックを保存する
  • ここまでの処理を同じフォルダのブックの数だけ繰り返す
  • メッセージボックスで「完了」をお知らせ。

これを例に考える。

処理の分解

処理を区切ってプロシージャを分ける

「〜して、〜して、〜して、…」のように、「〜して毎」*1に1つのプロシージャにしてみる。
※詳しくはこちらが参考になります*2 thom.hateblo.jp

これを、一般に「部品化」と呼ぶ。

ただし、あまり細かくし過ぎると行数が増えてかえって管理が大変になるばかり。
よって、内容によっては2~3個の処理を1つのプロシージャにまとめることもある。

目安としては、1プロシージャあたりPCのディスプレイ1枚分(20~30行)くらい。
…区切るサイズや場所には個人差もあると思うけど。

上記の例の場合、ざっくり下記のように分けられると思う。

  • フォルダを確認
  • フォルダ内に処理対象のブックがあるか確認する
  • パスを取得
    • 1つ目のファイル名を取得
    • ファイル名とフォルダの場所からフルパスを取得
    • パスを取得
  • 値を取得
    • パスを指定してブックを開く
    • シートを取得
    • テーブルを取得
    • データ範囲を取得
    • 二次元配列として値を取得
  • 値を置き換える
    • セルの値を取得
    • 文字列に変換
    • 文字列を置き換える
    • セルの値を置き換える
    • シートの数だけ繰り返す
  • ブックを保存する
    • ブックの拡張子付きファイル名を取得
    • 現在のファイル名から新しいファイル名を取得
    • 格納先のフォルダを取得
    • ファイル名とフォルダの場所からフルパスを取得
    • 新しいパスでブックを保存する
  • ブックの数だけ繰り返す
  • メッセージボックスで「完了」をお知らせ。
出来るだけ共通の処理を抽象化する

「他のブックでもこのマクロを使うとしたら?」と考えながら、プロシージャや変数、引数の名前を考える。

たとえば…

  • (変更前)⚫️⚫️社Bookの全シートの文字列を▼▼に置き換える
  • (変更後)全文字列を置き換える(ワークブック、置き換え前、置き換え後)

処理を分類してメソッド化

似たようなプロシージャをまとめる

上記の例であれば、「ファイル名とフォルダの場所からフルパスを取得」という処理は共通している。
また、その前後にある「フォルダのパスやファイル名をなんやかんやする」処理も「パスの操作」という意味で似ている。
このように、似たものを操作するプロシージャを分類して、いくつかのカテゴリに分けてみる。

標準モジュール(もしくはシートモジュール)にメイン・プロシージャを作って、こちらから部品化・カテゴリ分けしたプロシージャを呼び出すことにする。

上記の例であれば、ざっくりこんな感じに分けられそう。

  • メイン・プロシージャ
  • 処理対象のブックの判定
  • パスの操作
  • ブックを開く、保存する
  • シートの値の取得
  • 文字列の操作

※「パスの操作」は、プロシージャのサイズによっては「文字列の操作」にまとめても良いかもしれない。

分類ごとにクラスモジュール化し、途中経過はPrivateにする

メイン・プロシージャ以外を、プロシージャのカテゴリごとに別々のクラスモジュールとして独立させる。
この時に、「モジュール内専用の処理」と「外部から呼び出せる処理(=メソッド)」をそれぞれ区別し、スコープを設定しておく。

こうすることで、パブリックのものは入力補完が効くようになり、この後のコーディングが楽になる。

呼び方 スコープ キーワード
パブリック・モジュール・レベル 全モジュール共通 Public
プライベート・モジュール・レベル モジュール内専用 Private

ちなみに、クラスの中で処理や値をPrivate以下にすると、外からは内部処理やデータの状態が見えなくなる。
このことを、オブジェクト指向的には「隠蔽(いんぺい)」と呼び、それを「クラス」という枠の中に閉じ込めることカプセル化という。

こうすることで、外部から値の変更などの干渉を受けづらくなり、コードが少し安全になる(はず)。

※「隠蔽」や「カプセル化」の目的について、詳しくはこちらをご参照いただけたら幸いです。 www.yuru-wota.com

プロパティの設定

共通の引数を探す

同じものを参照・利用しているのであれば、まとめて「プロパティ」にする。
プロパティに代入して、メソッド内ではプロパティから呼び出すようにすることで引数を減らせる。

また、メソッドの中からMe.プロパティ名で呼び出せるようになる(後述)。
入力補完も効くので、コーディングの効率や可読性も上がる*3

プロパティの値をモジュールレベル変数とする

プロパティの中身を参照する時はProperty Getプロシージャを呼び出す。
しかし、Property Getプロシージャの戻り値には値を直接代入できないので、下記のような書き方はできない
<2019/10/14追記>
「戻り値に代入」はできます。失礼いたしました…
下記が正しく、作例も差し替えております。

Property Getプロシージャには値を直接代入できないので、下記のような書き方はできない。

'[クラスモジュール(Exampleクラス)]
Property Get プロパティ名()As 型
    プロパティ名 =End Property
'[標準モジュール]
Sub test()
    Dim ex As New Example
    ex.プロパティ名 = 新しい値
End Sub

実行すると、「値の取得のみ可能なプロパティに値を設定することはできません。」というコンパイルエラーが発生する。

このため、上述のProperty LetプロシージャProperty Setプロシージャを使って「値を設定」できるようにする(詳細は後述)。
このときにプロパティではなく「変数」を経由して「値」を代入することで、間接的にプロパティの中身を変更できるようになる。

そんなわけで、今の段階でProperty Getプロシージャも変数の値を返すようにしておく。

'[クラスモジュール(Exampleクラス)]
Private 変数 AsProperty Get プロパティ名()As 型
    プロパティ名 = 変数
End Property

また、この時に、プロシージャと同じように「プロジェクト全体で使用するもの」と「モジュール内で完結するもの」、「プロシージャ内で完結するもの」を整理してスコープを調整しておく。

呼び方 スコープ キーワード
パブリック・モジュール・レベル 全モジュール共通 Public
プライベート・モジュール・レベル モジュール内専用 PrivateまたはDim
プロシージャレベル プロシージャ内専用 Dim

プロパティ用の変数でスコープを「プライベート・モジュール・レベル」にしておくことで、「このクラス専用」の変数とすることができる。
つまり、プライベートなプロシージャと同じように他のモジュールからは呼び出せなくなり、「隠蔽」になる。
(以下、当記事ではこの変数のことを便宜上「隠し変数」と呼ぶことにする。)

コンストラクタで「隠し変数」に代入する

Class_Initializeという特別なプロシージャを作ると、インスタンスの生成時に必ず呼び出され、自動でプロパティに値を設定するように出来る*4

ただし上述のように、プロパティではなく隠し変数に値を代入する。

Private 隠し変数1 AsPrivate 隠し変数2 AsPrivate 隠し変数3 As オブジェクト系の型
Private 隠し変数4 As オブジェクト系の型

Public Sub Class_Initialize()
    隠し変数1 =Call 必ず実行する処理 '追加処理を入れても良い
    隠し変数2 =Set 隠し変数3 = オブジェクト
    Set 隠し変数4 = 値の準備とか(引数) '関数も使える
End Sub

なお、Class_Initializeプロシージャは「引数を設定できない仕様」になっているので、インスタンスの初期値は固定になってしまう。
いつも同じ値にしたいとは限らないので、これでは少し使いづらいことがある。

そこで一工夫。

Initializeメソッド(名前は自分で分かりやすいように変えてもOK)を別途作って、インスタンス生成時に引数として値を渡しつつ呼び出すことにする。
※この場合は、Class_Initializeプロシージャは作成しなくても良い。

'[クラスモジュール(Exampleクラス)]
Private 隠し変数1 AsPrivate 隠し変数2 AsPrivate 隠し変数3 As オブジェクト系の型
Private 隠し変数4 As オブジェクト系の型
Private 隠し変数5 As オブジェクト系の型

Public Sub Initialize(ByVal 仮引数1 As, ByRef 仮引数2 As オブジェクト系の型)
    隠し変数1 = 仮引数1

    Call 必ず実行する処理
    隠し変数2 = '引数ではなく値を指定しても大丈夫

    Set 隠し変数3 = 仮引数2
    Set 隠し変数4 = 値の準備とか(引数)
    Set 隠し変数5 = オブジェクト 'オブジェクトも大丈夫
End Sub
'[標準モジュール]
Public Sub Main()
    Dim test As New Example
    'Initializeメソッドを呼び出してプロパティの初期化
    test.Initialize 仮引数1:=引数1, _
                    仮引数2:=引数2    
End Sub

これで、インスタンスの生成時にまとめて任意の値を設定できる。
(別の言語では、このような処理をするメソッドを「コンストラクタ」と言う。)

終了処理を追加する

モジュールレベル以上の変数は、マクロの実行が完了しても値が破棄されないので、マクロを複数回実行する時などは注意が必要になってしまう。
<2019/10/14追記>
当初、上記例の中で変数の初期化・破棄処理を書いておりましたが、ことりちゅん (id:Kotori-ChunChun)さんより下記アドバイスを頂きました。

クラスはインスタンスを破棄した時点でメンバの変数は一緒に消失しますから、サンプルのTerminateのように変数の初期化は意味が無いです。

これに従い、作例は下記に修正いたしました。ご指摘ありがとうございます!

Class_Terminateという特別なプロシージャを作ると、インスタンスが不要になった時に自動で終了処理(=Terminate)されるようにできる。

Public Sub Class_Terminate()
    Call 必ず実行する処理 'ゴミデータのお掃除など
End Sub
Property Getプロシージャでプロパティに隠し変数を代入する

ここまで準備ができたら、プロパティの値を設定していく。
上述のように、隠し変数を経由してプロパティに値を設定する。

値を代入
'▼NG例
Property Get プロパティ名()As '~処理~
    プロパティ名 =End Property
'▼OK例
Private 隠し変数 AsProperty Get プロパティ名()As '~処理~
    隠し変数 = 値
    プロパティ名 = 隠し変数
End Property
オブジェクトの参照値を代入
'▼NG例
Property Get プロパティ名()As オブジェクト系の型
    '~処理~
    Set プロパティ名 = オブジェクト
End Property
'▼OK例
Private 隠し変数 As オブジェクト系の型

Property Get プロパティ名()As オブジェクト系の型
    '~処理~
    Set 隠し変数 = オブジェクト
    Set プロパティ名 = 隠し変数
End Property

これで外部のプロシージャからはインスタンス名.プロパティ名でプロパティの中身を見られるようになる。

なお、外部ではなく同クラス内で呼び出す時は、Me.プロパティ名と書く。

'[クラスモジュール(Exampleクラス)]
'▼プロパティ
Private 変数 AsProperty Get プロパティA()As 型
    プロパティA = 変数
End Property

'▼メソッド
Public Function test()
    test = "このインスタンスのプロパティAは、現在" & Me.プロパティA & "の状態です。"
End Function
Property Let/Setプロシージャで、プロパティに代入出来るようにする

プロパティは、中身を見るだけでなく処理の途中で値を変えたくなるときもある。
そういう時は、Property Let/Setプロシージャで値を設定できるようにしておく。
これで、外部のモジュールからプロパティに値を代入できるようになるので、実用に耐える(はず)。

プロパティの設定は、何度も言うように隠し変数を経由する。

Private 隠し変数 AsProperty Let プロパティ名(仮引数 As)
    '処理
    隠し変数 = 仮引数
End Property

そうしないと、プロパティに代入後も「隠し変数」の値は変わらず、改めてプロパティを呼び出して中身を見たら「元の値に戻ってる?」なんて混乱することに…

あとがき

VBAの神々によれば、ここまでがクラス入門編だそうです。
私はここに至るまで半年以上かかってしまいました…

「分かったような分かんないような…」で何度も書いては消してを繰り返してるので、キレイな文章はもう放棄です。
話の整理は未来の私に託します。

慣れてきたら、プロパティを先に作り込んでからメソッドを作り始める方が、入力補完の助けも得られてもっと書きやすいかもですね。
「どの基準・どの単位でクラスにまとめるか?」もまだ感覚が掴めてないです。
数をこなして慣れるしかないんですかね…

*1:日本語の文法的に言うと「文節単位」?

*2:いつもお世話になっております!!
この記事に限らず、VBA使いの人は読んで損はないブログです。

*3:入力は補完に任せられるから、多少長い名前をつけても大丈夫。

*4:Initializeは「初期化」のこと