えくせるちゅんちゅん

ことりがエクセルをちゅんちゅんするブログ

VBAのクラスの作り方1 - 進化するRECT

この記事は、ユーザー定義型のRECTが俺俺Rectangleクラスに進化していくまでの一連の流れを解説したものである。

メモリっぽいイメージ描いて頑張って説明しているが、「自身がなんとなく納得」するために描いたもので、正確なイメージではないので注意されたし。


1.APIを始めた頃のRECT

Win32APIを操作している時、こんな宣言を見たことはないだろうか?

Type RECT
    Left As Long
    Top As Long
    Right As Long
    Bottom As Long
End Type

VBAのTypeとは矩形情報を保持するユーザー定義型である。C言語とかでは構造体(Struct)と呼ぶアレだ。

上の例では、左、上、右、下の数値の組み合わせで記憶できる。


ところが、またある時、こんな宣言は見たことがないだろうか?

Type RECT
    X1 As Long
    Y1 As Long
    X2 As Long
    Y2 As Long
End Type

今度は、点1・・・X1,Y1、点2・・・X2,Y2というイメージの数値の組み合わせで記憶する。


どちらの考え方もRECTなのである。

WinAPI的にどっちが正しいかはこの際きにしないことにする。

だって、どっちでも動くんだもん。


2.流用を始めた頃のRECT

図形やウィンドウ等の操作を行っていると、WinAPI以外の場面でもRECTが便利であることに気がつく。

そこで、そこら中のプログラムでRECT構造体を使い始めるのである。


あるモジュールはLeft,Right系で。

module1.bas

dim rect1 As RECT
rect1.Left = 5
rect1.Top = 5
rect1.Right = 10
rect1.Bottom = 10


またあるモジュールはX1X2系で。

module2.bas

dim rect1 As RECT
rect1.X1 = 5
rect1.Y1 = 5
rect1.X2 = 10
rect1.Y2 = 10

どんどんとプログラムは混沌としていくが、気にしないことにする。

だって、どっちでも動くんだもん。


この時のメモリはこんな感じで確保されている。

この2種類が混在している問題は、記事の終盤まで出てこないので忘れて宜しい。


3.手慣れて来た頃のRECT

RECTの利用頻度が高くなってきたので、作成用の関数を作っておく。

こうすると初期化が1行で書けるので便利なのだ。

Dim rect1 As RECT
rect1 = CreateRect(5, 5, 10, 10)

Function CreateRect(p1, p2, p3, p4) As RECT
    CreateRect.Left = p1
    CreateRect.Top = p2
    CreateRect.Right = p3
    CreateRect.Bottom = p4
End Function


ユーザー定義型の変数を別の変数に代入すると、一度に全てのメンバの内容がコピーされる。


ユーザー定義型の配列に代入した場合も同じ。

Dim rect1 As RECT
...
Dim arr_rect(1 To 4) As RECT
arr_rect(1) = rect1
arr_rect(2) = rect1
arr_rect(3) = rect1
arr_rect(4) = rect1

rect1は配列の4つの異なるメモリ領域にコピーされる。


これらarr_rect(1~4)は、同じ値を持っていたとしても別モノである。

例えばarr_rect(4).Left = 22が実行されたからと言って、rect1.Leftarr_rect(1).Leftの値が22に変わる心配はない。

このような動きはString、Longなどと同じである。いわゆる「値型」と呼ばれるグループにの属するものは全てこの動きをする。

というわけで、Typeならば初心者でもハマる可能性は低いだろう。だが、後のクラスでは注意が必要となる。


4.本格的に使い始めた頃のRECT

開発を進めていくうちに、RECTに名前を付けたり並び替えをしたくなった。

そこで、Dictionaryを使おうと思ったのだが、困ったことになった。

DictionaryにTypeが入らない

Dim rect1 As RECT
rect1 = CreateRect(5, 5, 10, 10)

'↓これはできる
Dim arr_Rect(1 To 4) As RECT
arr_Rect(1) = rect1
arr_Rect(2) = rect1
arr_Rect(3) = rect1
arr_Rect(4) = rect1

'↓これはできない
Dim dic As New Dictionary
dic.Add "a", arr_Rect(1)
dic.Add "b", arr_Rect(2)
dic.Add "c", arr_Rect(3)
dic.Add "d", arr_Rect(4)

エラー

---------------------------
Microsoft Visual Basic for Applications
---------------------------
コンパイル エラー:

パブリック オブジェクト モジュールで定義されたユーザー定義型に限り、変数に割り当てることができ、実行時バインディングの関数に渡すことができます。
---------------------------
OK   ヘルプ   
---------------------------

実は、TypeはDictionaryやCollectionには追加できないのである。

VariantにTypeは入らない

Dictionary等へTypeが格納できないそもそもの原因は、格納するデータ型がVariant型でありVariantにはTypeが代入出来ないという制限があるからだ。

Dim v As Variant
v = CreateRect(5, 5, 10, 10)

前の説明のようにType型の代入式は基本的にデータのコピーが行われる。しかしVariantの記憶領域には変数4つ分を記憶するスペースがない。だから初めから代入出来ないようにされているのだろう。(と納得しておけば良い)


オブジェクトなら入る

でも、Typeなんかよりもっと巨大なセル範囲はVariantに入るじゃないか!!!と思うかも知れない。


例えばこれは、Variant変数vにセル範囲の値(二次元配列データ)を代入する式である。

Dim v As Variant
v = Range("A1:G10").Value

実はVariantには(Type以外の)配列を記憶する専用の領域があり、普通の変数を入れる領域とは別物なのだ。


今度はセル範囲のRangeオブジェクトをSetで代入してみる。

Dim v As Variant
Set v = Range("A1:G10")

実はこれも無関係。Variantにはオブジェクトの参照を記憶する専用の領域があり、値型の変数を入れる領域とは別物なのだ。


確認のため、一応Set使って書いてみる。

Dim v As Variant
Set v = CreateRect(5, 5, 10, 10)

結果はエラー

---------------------------
Microsoft Visual Basic for Applications
---------------------------
コンパイル エラー:

オブジェクトが必要です。
---------------------------
OK   ヘルプ   
---------------------------

確かにTypeはオブジェクトではない。仕方がないのだ。


まとめると、

  • なぜかTypeは(Letで)配列のように参照として代入することができない。
  • なぜかTypeは参照型ではないので、Setでは代入できない。

以上。

(なぜなのか知りたい上級者はVariant構造体についてけんさく、けんさく~~)


とにもかくにも、Dictionaryに入れたいならばTypeを参照型(Class)にすればよいのだ!!!


5.クラス化されたRECT→Rectangle

まずは、クラスモジュールを作成して、Rectangleという名前をつける。

同じRECTという名前にしたいところだが、API系で求められているRECTはTypeでなければならないので、本番ではクラスにRECTと付けて使うことはできない。


Typeと同じメンバを全てPublicにした状態でクラスモジュールに記載しておく。

Rectangle.cls

Public Left As Long
Public Top As Long
Public Right As Long
Public Bottom As Long


標準モジュールのTypeの宣言は封印して、「VBAProjectのコンパイル」や「置換」を駆使してRECTをRectangleに置き換えていく。

Module1.bas

'Type RECT
'    Left As Long
'    Top As Long
'    Right As Long
'    Bottom As Long
'End Type


この入れ替えで「VBAProjectのコンパイル」が通るため、移行完了かのように思われる。

が、油断してはならない。

Classになるとデータ型が参照型となるため、いくつかの処理を変更する必要がある。

  1. 新しいインスタンスを生成するSet 変数名 = New クラスモジュール名を追記する(ただしCreateRectangle関数でNewされる前提の場合は気にしなくてOK)
  2. Rectangle型変数の代入文の全てにSetを追記する
  3. データを複製するCloneメソッドを作成し、複製を意図した代入文では Rectangle.Clone()を実行するようにする

※勢いで書いてるので、漏れがあったら指摘願います。


1.Newの必要性

Typeでは宣言した変数をいきなり使う事ができたが、Classではインスタンスの作成(New ClassName)を済ませないと使うことができない。

インスタンス(実体)とはクラス(設計図)を実体化したものである。

イメージとしては、Dim rect As Rectangleではオブジェクト変数を宣言しただけで実体のない空っぽのデータ(Nothing)だが、インスタンスを生成するとClassに記述どおりの変数のメモリを確保して、そのメモリアドレスがrectに格納されるという感じ。

インスタンス側から見るとrect1という名前には縛られないので、Rectangleとしている。


Typeの場合

dim rect1 As RECT'Type
rect1.Left = 5
rect1.Top = 5
rect1.Right = 10
rect1.Bottom = 10


Classの場合

dim rect1 As Rectangle 'Class
Set rect1 = New Rectangle '←クラスでは必要
rect1.Left = 5
rect1.Top = 5
rect1.Right = 10
rect1.Bottom = 10

Dim Rectangle As New Rectangleと書くこともできるが、副作用があるため初心者は避ける事を推奨する。Microsoft公式も推奨していない

なお、コロンを使って一行でdim rect1 As Rectangle : Set rect1 = New Rectangleと書くのは問題ない。


2.Setの必要性

TypeからClassにすると、代入文を修正する必要がある。Typeでは何も意識せずとも書けたものが、ひと手間かかるようになる。

値を代入する時にはLetを使って、参照(オブジェクト型変数)で代入するときはSetを使わなければならない。

これは他言語ではあまり見かけないVB特有のルールなので、そういうもんだと飲み込むしか無い。(多分、原因はデフォルトプロパティとVariant)


Typeの場合

rect1 = rect2

実は代入文は既定ではLetの省略表記となっているため、厳密にはこう解釈されている。

Let rect1 = rect2


Classの場合

Set rect1 = rect2


しかしながら、このままでは重大なバグを残す事となる。

代入におけるSetとLetの違いは下図のようになる。

LetではLeft,Top,Right,Bottomが複製されるのに対して、Setで複製されるのは参照先を示すメモリアドレスだけである。

つまり、仮にrect2.Left = 22が実行されるとrect1.Left22に変化してしまう。

示している実体が同じメモリなのでこのような問題が生じるのだが、それは事項で説明する。


3.Clone(複製)メソッドの必要性

Set rect1 = rect2

このような書き方をした場合、データが完全に複製されたことにはならない。

先の通り複製されるのは「メモリアドレスだけ」だからだ。

何方の変数も同一のインスタンスを参照している。


対策として有効なのが、インスタンスのコピーを生み出す関数である。

とはいえコピーを生み出す機能など無いので、自力で複製メソッドを作成する必要がある。

New Rectangleで新たにインスタンスを作成したあとに、全てのメンバの同じ内容をコピーすることで、あたかもインスタンスを複製出来たように振る舞わせれば良い。

Public Function Clone() As Rectangle
    Set Clone = New Rectangle
    Clone.Left = Me.Left
    Clone.Top = Me.Top
    Clone.Right = Me.Right
    Clone.Bottom = Me.Bottom
End Function


Typeの代入とClassのCloneメソッドの違いを比較すると下図のようになる。


というわけで、

  • Newの追加
  • Setの追加
  • Cloneの追加

この3つが揃えば、TypeからClassへの置き換えは完了したと言える。


この時点で完成したコードはこんな感じ。

Module1.bas

Sub Test5_Class()
    Dim rect1 As Rectangle
    Set rect1 = CreateRectangle(5, 5, 10, 10)
        
   'Dictionaryへの追加もできる
    dim dic As Dictionary
    Set dic = New Dictionary
    dic.Add "key", rect1
    
   '処理
End Sub
    
Function CreateRectangle(p1, p2, p3, p4) As Rectangle
    Set CreateRectangle = New Rectangle
    CreateRectangle.Left = p1
    CreateRectangle.Top = p2
    CreateRectangle.Right = p3
    CreateRectangle.Bottom = p4
End Function


Rectangle.cls

Public Left As Long
Public Top As Long
Public Right As Long
Public Bottom As Long

Public Function Clone() As Rectangle
    Set Clone = New Rectangle
    Clone.Left = Me.Left
    Clone.Top = Me.Top
    Clone.Right = Me.Right
    Clone.Bottom = Me.Bottom
End Function


6.クラス利便性の向上(コンストラクタ)

せっかくクラス化したのだから、Typeでは出来なかったことも実現したい。


1.CreateRectangleの移動と初期化関数

まずは先程作成したもののうち、初期値を代入する関数CreateRectangleをクラスに内包させたい。

細かいことは置いといて、CreateRectangle関数をまるごとRectangleクラスに移動すればOK。

更にCloneメソッドは共通化できるところを共通化しておく。

Rectangleクラス全文

Option Explicit

Public Left As Long
Public Top As Long
Public Right As Long
Public Bottom As Long

Public Function Clone() As Rectangle
    Set Clone = Me.CreateRectangle(Me.Left, Me.Top, Me.Right, Me.Bottom)
End Function

Function CreateRectangle(p1, p2, p3, p4) As Rectangle
    Set CreateRectangle = New Rectangle
    CreateRectangle.Left = p1
    CreateRectangle.Top = p2
    CreateRectangle.Right = p3
    CreateRectangle.Bottom = p4
End Function

すると、利用側ではこのように書くことになる。

標準モジュールにCreateRectangleがあるとき

Sub Test5_Class()
    Dim rect1 As Rectangle
    Set rect1 = CreateRectangle(5, 5, 10, 10)
    '処理
End Sub

クラスモジュールにCreateRectangleを移動したあと

Sub Test6_Class()
    Dim rect1 As Rectangle
    Set rect1 = New Rectangle '←なんか増えた・・・
    Set rect1 = rect1.CreateRectangle(5, 5, 10, 10)
    '処理
End Sub

なぜか、Set rect1 = New Rectangleが増えている。CreateRectangle関数を実行するためにインスタンスが必要となってしまったからだ。

今までの図では省略していたが、インスタンスの中には変数だけではなくメソッドも含まれている。

先のプログラム実行中のメモリを可視化するとこのような感じになる。

要するに、無駄にメモリを消費しているわ、プログラムが一行増えたわで、踏んだり蹴ったりだ。

でも、標準モジュールからは隔離できた。これがクラス化の目的である。


2.使いやすい初期化関数

上記のままでは2つ問題を抱えている事がわかった。

  • インスタンスを余分に生成してメモリを浪費している
  • 初期化の為にプログラムが1行増えている

このうち、前者はソースコードの読み・書きやすさに関係ない。そして消費メモリは鷹が知れているので無視して良い。

後者を解決すれば格段に使いやすくなる。

Dim rect1 As Rectangle
Set rect1 = New Rectangle  '←なんか増えたコイツを消したい
Set rect1 = rect1.CreateRectangle(5, 5, 10, 10)

この「なんか増えた」ヤツは、あることをすることで次のように書けるようになる。

Dim rect1 As Rectangle
Set rect1 = Rectangle.CreateRectangle(5, 5, 10, 10)

rect1.CreateRectangleRectangle.CreateRectangle


「あること」とは、次の操作である。

この操作により、グローバル変数Public Rectangle As New Rectangleが宣言されたことになる。

つまり、Rectangleという名前のクラスの他にRectangleという名前のオブジェクト変数も共存している。

混乱しそうだが、コンピュータ側からするとクラスか変数かは文法で識別できるので問題とはならない。


ちなみにこの書き方、他のプログラミング言語で見かける静的メソッドと同じである。※本質は別モノだが。

可読性は悪くないので、人間側も慣れておくと良い気がする。


というわけで、利用者はこのように書くだけで良くなった。

Dim rect1 As Rectangle
Set rect1 = Rectangle.CreateRectangle(5, 5, 10, 10)


3.コンストラク

それはそうと、一般的なプログラミング言語でクラスを初期化するものと言えばコンストラクタである。

コンストラクタとは、インスタンスを生成(New)した段階で自動的に実行される特別なメソッドである。

もちろんVBAにもある。

Private Sub Class_Initialize()
   'ここにNewされた時に実行される初期化文を書く
End Sub

ところが、VBAではコンストラクタに引数を与えることが出来ない。だから使い物にならない。忘れてよろしい。


その一方で、CreateRectangleで初期化を強制させる仕組みが必要である。

自作関数はNewと違って強制的には実行されないので、利用者が意識的に呼び出さなければならないという課題がある。

これを解決する方法はないので、初期化状態をチェックするためのフラグを導入してどうにかする。

Rectangleクラス抜粋

'↓追加
Public IsInitialized As Boolean
Private Const ERROR_NO_INITIALIZED = 9999
Private Const ERROR_NO_INITIALIZED_MSG = "RectangleクラスはCreateRectangleで初期化してください"

Function CreateRectangle(p1, p2, p3, p4) As Rectangle
    Set CreateRectangle = New Rectangle
    CreateRectangle.Left = p1
    CreateRectangle.Top = p2
    CreateRectangle.Right = p3
    CreateRectangle.Bottom = p4
    '↓追加
    CreateRectangle.IsInitialized = True
End Function

'↓追加
Private Sub CheckInit()
    If Not IsInitialized Then Err.Raise ERROR_NO_INITIALIZED, "", ERROR_NO_INITIALIZED_MSG
End Sub

Public Function Clone() As Rectangle
    '↓追加
    Call CheckInit
    Set Clone = Me.CreateRectangle(Left, Top, Right, Bottom)
End Function

メソッドごとにチェックが必要で面倒だが、後のクラス利用者は仕様など覚えちゃいないので案内しておくに越したことはない。

これで「コンストラクタ」では無いが、「コンストラクタの代わり」として十分に使えるようになったはずである。


4.別の書き方

参考までに、VBAで引数付き初期化関数をいきなり起動する方法は、他にもいくつかやり方がある。


例えば、次のようにNewの結果を一旦Arrayに入れて参照させればNewとともに関数を実行できる。

Sub Test6_4_1()
    Dim rect1 As Rectangle
    Set rect1 = Array(New Rectangle)(0).CreateRectangle(5, 5, 10, 10)
End Sub


むしろ複雑化しているが、CallByNameを使えば実行させることは可能である。

Sub Test6_4_2()
    Dim rect1 As Rectangle
    Set rect1 = CallByName(New Rectangle, "CreateRectangle", VbMethod, 5, 5, 10, 10)
End Sub


しかし、これらの方法は名称の間違いを「VBAProjectのコンパイル」で検知できない。

やり方は好みで使い分ければ良い。


7.クラスの有効活用(プロパティ)

クラス化したからには絶対に活用したいのがプロパティである。

改めてRectangleクラスの役割を確認しよう。

矩形の四辺(左、上、右、下)の座標を記憶する

Public Left As Long
Public Top As Long
Public Right As Long
Public Bottom As Long

座標情報を保持するだけ=データの記憶 以上の役割は持っていない。

すでに

  • 初期化がイマイチなので、CreateRectangleメソッド
  • データの複製が無かったので、Cloneメソッド

を作成しているが、それは自然にClassを使うために必要な措置であった。


クラスの本質は「データと処理の融合」にある。

そこで、Rectangleクラスのユースケースを考えてみよう。

  • Left,Right,Top,Bottomで四方の座標が知りたい
  • X1,X2,Y1,Y2で四方の座標が知りたい
  • 横幅や高さが知りたい
  • 長辺と短辺の長さが知りたい
  • 矩形の中心が知りたい
  • 情報を纏めて文字列で取り出したい

パッと思いつくだけでも、これだけの使い方が想定される。

これらは四辺の座標から算出すれば簡単に求められるが、このような定型処理はクラスに内包しておくと何度も書いたり調べたりする必要がなくなる。

当初のX1方式とLeft方式の混在もこれで解消できる。

Property Get X1():X1 = Left:End Property
Property Get X2():X2 = Right:End Property
Property Get Y1():Y1 = Top:End Property
Property Get Y2():Y2 = Bottom:End Property
    
'横幅
Property Get Width()
    Call CheckInit
     Width = Abs(Right - Left)
End Property

'高さ
Property Get Height()
    Call CheckInit
    Height = Abs(Top - Bottom)
End Property

Rem 長辺の長さ
Property Get LongSide()
    Call CheckInit
    LongSide = IIf(Width > Height, Width, Height)
End Property

Rem 短辺の長さ
Property Get ShortSide()
    Call CheckInit
    ShortSide = IIf(Width < Height, Width, Height)
End Property

Rem 矩形の中央
Property Get CenterPoint() As Variant
    Call CheckInit
    ReDim ret(1 To 2)
    ret(1) = (Left + Right) / 2
    ret(2) = (Top + Bottom) / 2
    CenterPoint = ret
End Property
                    
Rem 情報の取得
Property Get ToString() As String
    ToString = Join$(Array( _
        "Left  : " & Left, _
        "Right : " & Right, _
        "Top   : " & Top, _
        "Bottom: " & Bottom, _
        "", _
        "Width : " & Width, _
        "Height: " & Height, _
        "", _
        "ShortSide : " & ShortSide, _
        "LongSide  : " & LongSide, _
        "", _
        "CenterPoint : " & CenterPoint(1) & " , " & CenterPoint(2), _
        ""), vbLf)
End Property


今回はProperty Getで記載したので、ブレークポイントで中断させた状態でローカルウィンドウを見れば把握できる。

Functionの場合はローカルウィンドウには出てこないのでプロパティウィンドウに手動で追加してモニタする必要がある。情報を変化させるコードや負荷のかかるものはFunctionにしておいたほうが良い。


8.今後のクラスの育て方

使うときは最初に初期化を行いさえすれば、定型的な処理を行うときはメソッドを呼ぶだけで良い。

Sub Test8()
    Dim rect1 As Rectangle
    Set rect1 = Rectangle.CreateRectangle(5, 5, 10, 10)
    
    Debug.Print rect1.ToString
End Sub


先のプロパティはProperty Getしか実装しなかったが、Property Letを実装することでX1,X2,Y1,Y2という名前から座標を書き換えることだって出来る。


今後、Rectangleから取り出したデータを使って処理する時には、できる限り抽象化する方法を検討しクラスに内包出来ないか考えれば良い。

例えばTypeやClassを引数に持つ関数を作ったとき・・・

これは

-----Module.bas-----
Dim rect1 As RECT 'Type or Class
func rect1

Function func(rect As RECT) As Double
    rect....
next

こうしましょう

-----Module.bas-----
Dim rect1 As Rectangle 'Class
rect1.func

-----Rectangle.cls-----
Function func() As Double
    rect....
next

すると自然とクラスにメソッドが増えていき、大きく育っていく。


注意

今回作成したRectangleクラスだが、完全にType RECTの上位互換というわけではないため完全に消し去ることは出来ない。当初そうであったようにWinAPIではTypeのRECTが必要となる。

話の流れ上、Typeからの置き換えを重視するためCloneメソッドを作成したが、クラスに必ず必要なものではない。例えば初期化用関数CreateRectangleにClass Rectangleを取れるものを作成して、複製はそちらで行うようにすればCloneは必要ないかも知れない。

このクラスは値型しか存在しないので容易にCloneが作成できたが、参照型のメンバを持つクラス等では簡単に行かない。

本来クラス化する場合は、変数・関数の公開範囲を厳密に決めるように言われる事が多いが、知らなくてもクラスの旨味は十分に体感できるので端折っている。


まとめ

今回はユーザー定義型からクラスへ置き換えていく流れを説明してみました。

クラスは難しいと避けている方は多いと思いますが、使い方を身に着けておくととても便利です。

クラスを何に使えば良いか分からないという方は、手始めに目の前のTypeを置き換えて見てはいかがですか?

クラスになるまでの道筋はこれだけではありませんが、よくある例の一つとして参考にしていただければ幸いです。

以上


補足

furyutei様がクラスにRECT構造体を取り込めるように改造してくれました。

gist - RECT型(ユーザー定義型)変数とクラスモジュール間で値をダイレクトにやり取りする試みを参照



何か御座いましたらコメント欄、またはTwitterからどうぞ♪

それではまた来週♪ ちゅんちゅん(・8・)

プライバシーポリシー