世にも奇妙な物語?
自分の専門は土木なのですが、土木コンサル → IT会社(正社員)→ IT会社(契約社員)→ 土木施工会社(現在)という出戻り経歴がありまして、10年ちょいプログラマー兼プロジェクトマネージャーとして過ごしたIT時代に思った事です。
1.VBもオブジェクト指向言語になった
Visual Basic(以後VB)も、Windowsが.Net仕様になったのを境に、いちおう完全な(完璧ではない)オブジェクト指向言語になりました。それに文句があるわけではありません。言いたいのは、VBに一番なれてるので以降の話では、言語構文は基本的にVB仕様にさせてもらいますという事です。またこの話はMicrosoftの言語パッケージ(方言)特有の癖の可能性もある、と最初にお断りしておきます(^^;)。
2.オブジェクトに関するよくある解説
オブジェクト指向言語の参考書を開くと、たいてい最初に出てくるのは代入文の仕様です。aとbを変数として、
Dim a as Object
Dim b as Object
と宣言します。オブジェクトは基本的にコンストラクタを起動させ、初期化(メモリ確保)する必要があります。それを行わないとNull参照エラーが出ます。Null参照エラーは、メモリに確保されていない実体のない変数を操作しようとした時に出ます。実体のないものは操作できませんから、出て当然のエラーです。VBではコンストラクタは、New演算子になります。
a = New Object(***)
b = New Object(***)
コンストラクタの引数(***)には好きな値を書いてやり、これでオブジェクトに都合の良い初期値を与えられます。このとき代入演算子「=」は何をやってるかというと、Newで発生したObjectが格納される、そのObjectのために自動指定されたメモリ領域のPointer(Address)を、変数aやbに与えます。
つまり「=」は、次のSubst関数と同じです。以下は、説明のための模式図です。
Static Function Subst (a as Object , c as Object) as Object
Target(c) --> a
Return a
End Function
VBで統一すると言っておきながら、Target(*)は、じつはDVF(デジタル・ビジュアル・フォートラン)でのPointer取得関数です。そして-->はCっぽい(^^;)。いやこれはしょうがないんです。VBではPointerは決して表に出ない仕様なので、他言語の用語を借りるしかありません。Target(c) --> aで、cのPointerをaに与えると読んでください。ともあれこれでa = New Object(***)は、
a=Subst(a , New Object(***))
と同等になります。ところで、
a = b
を実行させたらどうなるでしょう?。Subst関数の仕様から、aはbのPointerを記憶する事になります。という事は、bの値を変更したら、その影響はaにも及びます。しかしこの言い方は正しくありません。aもbも同じメモリ領域を見てるのですから、同一の実体を別名で参照してるだけです。aがbと同一の変更を受けるのは当然の事です。
例えばaとbが2次元座標系の点の座標値を格納するためのクラス:Coodinate_2Dのインスタンスだったとします。Coodinate_2Dはメンバとしてx座標値とy座標値を持つとします。
'宣言
Dim a as Coodinate_2D
Dim b as Coodinate_2D
Dim c as Coodinate_2D
'初期化
a = New Coodinate_2D (0 , 0)
b = New Coodinate_2D (0 , 0)
c = New Coodinate_2D (0 , 0)
プログラムでは、aとbにはあるケースの結果を保存しておきたくて、cは計算のために使い回したいなどという事は良く起こります。
'cを使った計算1を行う
結果はc.x=0.1,c.y=0.2(意味はc=(0.1,0.2))になった。a = cで、cの値をaに退避させる.
'cを使った計算2を行う
結果はc.x=10,c.y=20(c=(10,20))になった。b = cで、cの値をbに退避させる.
計算終了後、a=(0.1,0.2),b=(10,20)であろうと確信していたら、a=b=c=(10,20)になっていた(^^;)。このような事態は、オブジェクト指向言語以前の従来言語では、明示的にPointerを使用しない限りあり得ない事でした。そのために、たいていの参考書の冒頭にはオブジェクトのふるまいに関する注意が書いてありますが、オブジェクト指向言語が普及してもう20年以上も経ちます。今の若い人たちも、こういう事に悩んでるのかしら?。
3.ほとんど無用の概念、Primitive型
しかしこれでは不便すぎます。オブジェクト指向言語では、Double(倍精度実数)やSingle(単精度実数)などの数値型もオブジェクトで表現されます。そして数値計算はプログラムで頻繁に行われますから、そこでさっきのような事態になると、バグの温床になりかねません。特に従来言語に慣れ親しんだ親爺プログラマー達にとっては(^^;)。そういう事情で(憶測です)、参照型と値型が導入されました。
さきほどのようにオブジェクトの挙動そのままというか、オブジェクトのまんまのクラス(型)を参照型といいます。一方で従来の数値のように代入文で退避可能なクラス(型)を値型といい、両方合わせてPrimitive型と呼びます。後でみるように、このPrimitive型という概念は無用な上に、実際の動作に照らすと非常にわかりにくく、混乱を引き起こします。混乱するという事は、バグの温床になりかねんという事です。
オブジェクト指向言語では全てはオブジェクトでした。では、まんまオブジェクトのようにふるまうはずの数値型をどのように値型に見せかけているかというと、代入演算子「=」の定義であるSubst関数の実質的なOver Load(オーバーロード)です。
Static Function Subst ( a as Double , c as Double ) as Double
a = c.clone
Return a
End Function
clone method(クローン・メソッド)は、言語内の全ての型(クラス)が持つ組み込みmethodです。それは新しくメモリ領域を自動指定し、そこに対象オブジェクト(上記ではc)の型情報と値を書き込み、そのPointerをaに格納させる機能を持ちます。よってDoubleなどの数値型の場合は、
c = 0.1
a = c
c=10
b = c
によって、a=0.1,b=c=10と普通に値を退避させる事が可能になります。
Over Loadとは、同じ名前(識別名)で引数が異なる関数やサブルーティンを複数定義できる言語仕様です。識別名(上記ではSubst)の後に続く引数a,cに、具体的に割り当てられるオブジェクトの型をコンパイラーが判断し、型の合致する関数やサブルーティンを自動で呼び出します。従って数値型にはDoubleだけでなく、
Static Function Subst ( a as Single , c as Single ) as Single
Static Function Subst ( a as Byte , c as Byte ) as Byte
Static Function Subst ( a as Integer , c as Integer ) as Integer
Static Function Subst ( a as Long , c as Long ) as Long
などのOver Loadがあると思って間違いではありません。ただしこれらは表には出ません。全て「=」と書くだけです。clone methodはユーザー(プログラマー)にも公開されています。なので参照型で値を退避したい場合は、
'cを使った計算1を行う
結果はc.x=0.1,c.y=0.2(意味はc=(0.1,0.2))になった。a = c.cloneで、cの値をaに退避させる。
'cを使った計算2を行う
結果はc.x=10,c.y=20(c=(10,20))になった。b = c.cloneで、cの値をbに退避させる。
とやれば良いわけです。
同様の配慮から値型ではコンストラクタの記述も省略でき、Newと初期値セットを同時に行う仕様にもなっています。
Dim a as Double ':Dim a as Double,a = New Double(0) と同じ
と書いたら、新しく確保されたメモリ領域のPointerをaに渡し、そのメモリ領域には初期値0が書き込まれます。だからaの値をDim a as Doubleの実行後に調べると、ちゃんとa=0になっていてNull参照エラーも出ません。逆に「Dim a as Double,a = New Double(0) と同じ」の部分は決して公開されませんので、プログラムにa = New Double(0)を書くとコンパイラーに怒られます(^^;)。
値型のもう一つの代表は文字列型:Stringです。Stringも「Dim s as String」で使用可能になり、初期値はEmpty文字列:"" です。
4.参照型,値型という用語の出自
関数やサブルーティンの引数に対して、参照渡し,値渡し という考えは、旧来言語にもありました。
Private Function R( a as Double , n as Integer ) as Double
と識別名Rの関数で、Rに続く引数a,nは参照渡し,値渡しでしょうか?。たいがいの言語は、Defultで値渡しです。その方が安全だからだと思います。そうでないのはFortranくらいです。例えばRの中でaに関する再帰計算が行われるとします。
Private Function R( a as Double , n as Integer ) as Double
'aに関する再帰計算,nは再帰計算のStep数.
'a = a + ** ような計算をn回行う.
Return a
End Function
再帰計算なので、「a = a + **」のように変数aを使い回すプログラムを書いた方が、わかりやすいです。しかし一方で、後で使うために、aの初期値も残しておきたいかも知れません。
'Main Routine
Dim a as Double = 1
Dim n as Integer = 100
Dim b as Double = R( a , n )
'aの初期値を使った事後処理
値型では、上記のような初期化も許されます。コードがシンプルになるので便利です(^^)。関数Rの引数が値渡しなら、上記のコードでaの初期値1はaに残り、bにaの再帰計算の結果が入ります。しかし関数Rの中では「a = a + ** ような計算をn回行った」のですから、Rの中でaの値はどんどん変化したはずです。
値渡しでは、= R( a , n )でRが呼び出され「Private Function R(a as Double , n as Integer) as Double」の部分が実行されると、Rがスタックメモリにロードされて、R内部でaで表されるメモリ領域がMainのaとは別にスタックメモリに確保されます。
次にMainのa が持つPointerを使って、Mainのaの型情報と値をそのメモリ領域から読み出し、スタックメモリに確保された領域にそれらがコピーされて計算に突入します。この動作は、さっきのclone methodと全く同じ機能です。
よってRの中でaの値がいくら変わろうと、RのaとMainのaは別の場所に記憶されるので、Mainではaの初期値がちゃんと残り、事後処理まで考えるとかなり安全なコードになります。
VBでは(たいがいの言語では)、引数に何の修飾子をつけなければDefaultで値渡しですが、値渡しと参照渡しを明示的に指定もできます(たいがいできるはず)。
Private Function R( a as Double by Val , n as Integer by Val ) as Double
by Val修飾子によってaは明示的に値渡しになり、実行時にaのコピーがスタックに発生します。nも同じです。一方、参照渡しにする場合はby Refを使います。参照渡しを使うと関数Rと同等な機能を、サブルーティンでも実現できます。
Private Sub S( b as Double by Ref , n as Integer by Val )
'bに関する再帰計算,nは再帰計算のStep数.
'b = b + ** ような計算をn回行う.
Return
End Sub
参照渡しでは、スタックにロードされたサブルーティンSのbに対して、MainのbのPointerが代入されます。この部分の動作を明示的に書くと、[S].b = [Main].bです。ここで[S].bと[Main].bはスタックメモリ上のSのbと、ヒープメモリ上のMainのbを表します。これは要するに、オブジェクトに対する代入演算子「=」そのものです。
S内部ではそのPointerを通じてMainのbの実体であるヒープメモリのメモリ領域を操作し、Mainのbの値を再起計算によってどんどん書き変えていきます。従って参照渡しでは、
'Main Routine
Dim a as Double = 1
Dim n as Integer = 100
Dim b as Double = a
S( b , n )
'aの初期値を使った事後処理
で、さっきと同じ機能が実現されます。
どちらが良いかはケースバイケースです。値渡しはコードが堅牢になる反面、MainとSubやFunctionは全くの別物ですのでアルゴリズムが細切れになり、可読性が低下する場合があります。読みにくいプログラムは、バグの温床です。
対して参照渡しでは、MainとSubやFunctionで共通の変数の使いまわしが可能なので、有機的に連結したスマートでスムーズなアルゴリズムを書ける可能性もありますが、そのかわりにMainの変数の書き変えを許すという危険を冒す事にもなります。また注意して書かないと、一般には値渡しよりも可読性は低下します。
参照渡しと値渡しでは、メモリ消費量や実行パフォーマンスにも違いがあり、結局一長一短です。
ところで引数渡しにおけるby Valとby Refの違いって、値型と参照型の違いそのものじゃないですか?。それには「=」の定義として模式的に与えた、Subst関数とそのOver Loadの仕様を思い出せば十分です。そういう訳で値型,参照型という用語は、SubやFunctionにおける引数の値渡し,参照渡しから由来したと予想できます。
5.引数渡しにおける値型と参照型の挙動
以後、値渡しか参照渡しかは明示する事にします。まず値型の変数aをSubやFunctionへ値渡ししたらどうなるでしょう?。
'Private Sub S( a as Double by Val )は定義済とする.
Dim a as Double
S(a)
値型とは、旧来のオブジェクトを持たない言語の変数をシミュレートするものだった、という事実を思い出して下さい。結果はSの中でaをいくら書き変えても、Mainのaの値は変わりません。値型を値渡ししたら値のコピーが渡った!、という予想通りの結果です(^^)。
次に参照型の変数cをSubやFunctionへ値渡ししたらどうなるでしょう?。
'Private Sub R( c as Coordinate_2D by Val ) は定義済とする.
Dim c as Coordinate_2D = New Coordinate_2D(0 , 0) ':こういう初期化も可能です(^^)
R(c)
今度は、Rの中でcを書き変えるとMainのcの内容も変わるんですよ。値渡ししたにも関わらず(^^;)。でも、これはまだいちおう想定内です。引数を通じてSubへ具体的に渡る引数の内容とは、その引数が指示するメモリ領域のPointerだったという事実を思い出せば。
つまり参照型の値渡しでコピーされるのは、Pointerが指示するメモリ領域の内容ではなくPointer自体です。それが可能なのは、全ての言語は必ずPointer型とでもいうべき型を持つ事からわかります。それが公開されるかどうか(使用可能かどうか)は、言語によりますが。
従って参照型の値渡しとは、旧来の非オブジェクト指向言語における参照渡しと、全く同じものです。そして値型も参照型も参照渡しすれば、従来言語の参照渡しと同じにふるまいます(この一見無意味な仕様の中に、じつは罠が潜みます)。
でもこれで終わりではないんですよ。値型の要素から構成される、配列やCollectionはどうでしょう?。Collectionとは要するに連結リストの事です。
Dim A(9) as Double
Dim L as Collecttion( of Double ) = New Collecttion( of Double )
配列A(9)については、さすがDoubleは値型だけあって、Dim A(9) as Doubleのみで使用可能になり、10個の要素の値は0に初期化されます。A(9)なのに要素10個というのは、配列IndexはDefaultで0から始まるからです。
CollecttionのLについてはNewでコンストラクタを起動させる必要がありますが、Collecttionは可変長配列の機能拡大版なので、初期状態は要素数0のリストが想定されます。そこで引数を持たないコンストラクタが用意されていて、= New Collecttion( of Double )で空のリストが出来上がります。これらを値渡ししてみます。
Private Sub S( A() as Double by Val )
Private Sub R( L as Collection by Val )
・・・なんかへ。するとどちらも、参照渡しと同じにふるまうんです。つまり値型の配列やCollectionは、参照型なのです!。
Collectionはコンストラクタで初期化しなければならないので、明示的に参照型とわかりますよ。でも値型の配列はそれを省略できるんだから、値型と思うじゃないですか。じつは参照型の配列も、配列自体の初期化は省略できるんです(固定長配列の場合)。
言語の開発者にしてみれば、オブジェクト指向言語では本来は全てオブジェクトで参照型なんだから、配列が参照型で何が悪い?(値型は本来不要)となりますが、ユーザー(プログラマー)にしてみれば、
「これは言語パッケージのバグだ!。設計思想がおかしいだろう。少なくともユーザー目線でない!」
と喚き散らします(^^;)。
自分は、値型の配列は値型に違いないと頭から決めてかかってMainの値が書き変わり、大失敗した経験があります。こういう事もあり、Primitive型という概念は無用の長物で混乱を招くものだと思います。
しかしまぁ~、こういう事は一回でも痛い目をみればおぼえるものです。そして条件付けされて行くんですね。次のように・・・。
[呪文]
「参照型は参照渡し。値型は値渡し。バクかも知れないが値型の配列さえも参照渡し」
「バクであっても仕様は仕様で、仕様がない(しょうがない)」
・・・ぶつぶつぶつ・・・と。ここで大概の言語では、引数渡しはDefaultで値渡しであった事を思い出してください。
6.世にも奇妙な物語?
大概の言語では引数渡しはDefaultで値渡しなので、以後、渡しの修飾子は省略します。by Val,by Refがなかったら、値渡しby Valです。ここまででも十分奇妙な話に個人的には思えるのですけれど、もうオブジェクト指向言語の基本実装は十分にわかったと思っていても、やっぱりやっちゃうんですね(^^;)。
Collectionは可変長配列の機能拡大版でした。例えばこういう風に使います。
'Main Routine
Dim L as Collecttion( of Double ) = New Collecttion( of Double ) '空のリストの生成
L_Setter(L)
'Lを使った処理
L_Setterの定義は次だとします。
Private Sub L_Setter( L as Collecttion( of Double ) )
'Lに必要な値をセットするn回Loop.
'L.Add(**)をn回繰り返す.**:必要な値.
Return
End Sub
これは問題なく動きます。CollectionであるLは参照型なので、L_Setterの中で行った変更はMainにも反映され、「'Lを使った処理」に移行できます。ところで可変長配列でも同じ事が出来ます。一般にCollectionより配列の方が速く動きます。同じ参照型でも配列の方が造りが単純だからです。それはデータ数が増えると、だんだん見えてきます。
'Main Routine
Dim A() as Double '空の配列の生成ではない
A_Setter(A)
'Aを使った処理
A_Setterの定義は次だとします。
Private Sub A_Setter( A () as Double )
'Aに必要な値をセットするn回Loop.
' j:配列Index ,j=0~n-1
'Redim Preserve A( j )
'A( j ) = ** '**:必要な値.
'j = j + 1
Return
End Sub
Dim A() as Doubleが可変長配列の宣言の仕方です。固定長はDim A(n)ですが、寸法子nを書かないと可変長配列とみなされます。じつは可変長配列は宣言だけではメモリ確保されていません。null状態です。このまま動かすとnull参照エラーになります(参照型ですからね)。コンストラクタを起動し初期化する必要がありますが、大抵はRedimで代用します。Redim A( -1 )がそれです。配列Indexは0から始まるので、Indexの上限+1=要素数となり、-1+1=0は要素数0個の空の配列の生成というわけです。ここまでわかっていても、やっちまうんですよね。
Aを使用するコードのMainにAの初期化部分はありません。ないですが、A_Setterの中にRedim Preserve A( j )があるので、大丈夫なはずです。修飾子Preserveはこの際おいときますが、とにかくこれでAは(j-1)個の要素を持つ配列に初期化されたことになります。何個の要素を持つ配列に初期化しようと、それはこっちの勝手でしょ?っと(^^)。そしてその変更は、Mainに反映されるはずです。何故なら、
[呪文]
「参照型は参照渡し。値型は値渡し。バクかも知れないが値型の配列さえも参照渡し」
「バクであっても仕様は仕様で、仕様がない(しょうがない)」
・・・だからです。ところが実際に動かすと、このコードはnull参照エラーを出しやがるのです。場所は「'Aを使った処理」の部分。
こういう時は再び喚き散らします(^^;)。
「配列は参照渡しじゃなかったのかよぉ~!」
こういう時、今まで培ってきた自信は一気に失われるものです。IT時代の会社に、相当のベテラン親爺プログラマーがいてSQLのコード書きなどは名人芸クラスだったのですが、参照型の値渡しは参照渡しになるのがどうしても理解できず(というか納得できず)、去って行きました。「俺も(同じ親爺プログラマー)そろそろ撤収時期なのか?」と一瞬本気で思いましたよ。たぶん30分くらい格闘したと思います。で、気づきます。
「あっ、いずれにしろ渡ってるのはPointerだ・・・」
これが正しいのです。
Private Sub A_Setter( A () as Double by Ref )
今でも調子が悪いと同じ事をやり、5分くらい右往左往したりします(^^;)。