この記事は、ユーザー定義型のRECTが俺俺Rectangleクラスに進化していくまでの一連の流れを解説したものである。
メモリっぽいイメージ描いて頑張って説明しているが、「自身がなんとなく納得」するために描いたもので、正確なイメージではないので注意されたし。
- 1.APIを始めた頃のRECT
- 2.流用を始めた頃のRECT
- 3.手慣れて来た頃のRECT
- 4.本格的に使い始めた頃のRECT
- 5.クラス化されたRECT→Rectangle
- 6.クラス利便性の向上(コンストラクタ)
- 7.クラスの有効活用(プロパティ)
- 8.今後のクラスの育て方
- 注意
- まとめ
- 補足
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.Left
やarr_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になるとデータ型が参照型となるため、いくつかの処理を変更する必要がある。
- 新しいインスタンスを生成する
Set 変数名 = New クラスモジュール名
を追記する(ただしCreateRectangle関数でNewされる前提の場合は気にしなくてOK) - Rectangle型変数の代入文の全てに
Set
を追記する - データを複製する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.Left
も22
に変化してしまう。
示している実体が同じメモリなのでこのような問題が生じるのだが、それは事項で説明する。
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.CreateRectangle
→Rectangle.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・)