Excel (VBA)

Excel VBAに関するフォーラムです。
  • 解決済みのトピックにはコメントできません。
このトピックは解決済みです。
質問

 
(指定なし : 指定なし)
Static の挙動について
投稿日時: 22/12/26 15:50:36
投稿者: たらのり

おつかれさまです
 
以下のコードは現象を再現するためのわざとらしいものです。
いままでも同じようなコーディングはしていたように思いますが
(たぶん)、Static の挙動はこのようなものなのでしょうか。
 

Public Sub Test()

    Dim ast()   As String   ' Array of string
    Dim i       As Long
    
    ast = GetSameArrayAnytime()
    For i = LBound(ast) To UBound(ast)
        Debug.Print ast(i)
    Next i
    
    ast = GetSameArrayAnytime()
    For i = LBound(ast) To UBound(ast)  ' (9) インデクスが有効範囲にありません
        Debug.Print ast(i)
    Next i

End Sub

' 毎回同じ配列を返す
Private Function GetSameArrayAnytime() As String()

    Static ast()    As String
    Static bInit    As Boolean
    
    If (Not bInit) Then
        ast = GetArray()    ' ast は一度だけ初期化される
        bInit = True
    End If

    GetSameArrayAnytime = ast

End Function

' 初期化のため一度だけ呼ばれる
Private Function GetArray() As String()
    
    Dim ast(1 To 3) As String
    
    ast(1) = "a"
    ast(2) = "b"
    ast(3) = "c"
    
    GetArray = ast

End Function

 
今回期待するのは、イミディエイトウィンドウに次のように
表示されることです:
 
a
b
c
a
b
c

(実行時エラーのため、前半の 3字までしか表示されません)

回答
投稿日時: 22/12/26 17:35:55
投稿者: taitani
投稿者のウェブサイトに移動

2回目でもう一度代入しようとしているからでは?

Public Sub Test()

    Dim ast()   As String   ' Array of string
    Dim i       As Long
    
    ast = GetSameArrayAnytime()
    For i = LBound(ast) To UBound(ast)
        Debug.Print ast(i)
    Next i
    
'    ast = GetSameArrayAnytime()
    For i = LBound(ast) To UBound(ast)  ' (9) インデクスが有効範囲にありません
        Debug.Print ast(i)
    Next i

End Sub

 
上記で期待された結果が出力されるはず。

回答
投稿日時: 22/12/26 17:41:24
投稿者: taitani
投稿者のウェブサイトに移動

あ、そういうことじゃないよってことであれば、無視してください。

投稿日時: 22/12/26 18:06:38
投稿者: たらのり

taitani さん、
レスをありがとうございます。
 
先ほどのプロシジャ Test をもう少し実際に近づけると
次のようになります:
 

Public Sub Test_2()

    Call CalledManyTimes

    ' Do something

    Call CalledManyTimes

End Sub

' 何回も呼ばれる処理
Public Sub CalledManyTimes()

    Dim ast()   As String   ' Array of string
    Dim i       As Long
    
    ast = GetSameArrayAnytime()
    For i = LBound(ast) To UBound(ast)
        Debug.Print ast(i)
    Next i

End Sub

何度も呼ばれる処理で、つねに同一の配列を得る目的です。
処理化後の配列の状態は処理ごとに変化しますが、
同一(一回)の処理内では不変としたいつもりです。

回答
投稿日時: 22/12/26 18:16:16
投稿者: taitani
投稿者のウェブサイトに移動

うーん、私的に違和感がありますね。
 
Dim ast() As String と Static ast() As String が。
 
Static は、実行するたびに、変動する (させる) 変数なので、

引用:
何度も呼ばれる処理で、つねに同一の配列を得る目的です。

 
ということであれば、"Dim ast() As String" だけになるんじゃないかなって。
 
 
 
 
 

回答
投稿日時: 22/12/26 19:32:40
投稿者: 半平太

>(たぶん)、Static の挙動はこのようなものなのでしょうか。
いやー、Staticだから、エラーになるのはおかしいでしょうね。
 
いろいろ実験してみると驚き
 ↓
' 毎回同じ配列を返す
Private Function GetSameArrayAnytime() As String()
 
    Static ast() As String
    Static bInit As Boolean
     
    If (Not bInit) Then
        ast = GetArray() ' ast は一度だけ初期化される
        bInit = True
    End If
 
    GetSameArrayAnytime = ast
    Debug.Print UBound(ast)  ’←★ここにこの詰まんないステートメントを1文
                ’入れるだけでトラブらなくなる
End Function

投稿日時: 22/12/26 22:00:42
投稿者: たらのり

taitani さん、
引き続きありがとうございます。
 
ちょっとタイトルが不適切だったかもと感じていまして、
元々いいたかったことは「Static な配列の挙動」に
ついてのことでした。
Static の挙動については理解しているつもりです(たぶん)。
 
よく GetArray() (のようなプロシジャ)で DBから情報を
抽出して返すようなことをするのですが、配列より
Dictionary を使用することが多かった気がします。
 

Function GetDic(ByBal key_ As String) As Scripting.Dictionary

    Static dic As Scripting.Dictionary

    if dic is Nothing Then
        set dic = new Scripting.Dictionary
        ' 登録処理
    Enf if
    
    set GetDic = dic

End function

今回の問題を含むコードには上と同等のコードを含みます。
同様の場面で Dictionayの変わりに配列を使用したことが
あったかというと、定かではありません。
 
Staticを使用するのはスコープを限定して情報を保護したい
ためでした。
 
 
半平太 さん、
レスをありがとうございます。
 
半平太 さんの環境でも、元のコードでは同様の減少が発生した
のでしょうか(のですよね)。
 
書き方は別として、なんだかおかしい(直感に反する)なと…

回答
投稿日時: 22/12/26 22:40:08
投稿者: 半平太

>半平太 さんの環境でも、元のコードでは同様の現象が発生した
>のでしょうか(のですよね)。
 
こちらも同様の現象が発生したんです。
 
理屈からするとおかしいので、一般ユーザーレベルではバグと言うしかないです。
 
まともな対策(解消方法)が何か分からないですが、先の実験からの推測だと、
配列待遇のコードを入れればいいのかなぁと漠然と思っていますけど。
 
流石にdebug.print なんて書くのはみっともないので、
こんな風にカモフラージュしたら(★)? これでもトラブらなかったですけど。
  ↓
' 毎回同じ配列を返す
Private Function GetSameArrayAnytime() As String()
 
    Static ast() As String
    Static bInit As Boolean
     
    If (Not bInit) Then
        ast = GetArray() ' ast は一度だけ初期化される
        bInit = True
    End If
 
    If IsArray(ast) Then ’★
        GetSameArrayAnytime = ast
    End If
     
 
End Function

回答
投稿日時: 22/12/26 23:36:47
投稿者: hatena
投稿者のウェブサイトに移動

動的配列をStatic(静的)宣言するのがちょっと違和感があります。
 
Static ステートメント (VBA) | Microsoft Learn
https://learn.microsoft.com/ja-jp/office/vba/language/reference/user-interface-help/static-statement
上記の公式ドキュメントには下記の記述があります。
プロシージャ レベルで 変数を宣言し 、記憶域 スペース を割り当てる場合に使用します。 Static ステートメントで宣言された変数は、コードが実行されている間はその値を保持します。
 
宣言時にはサイズが決まっていないものの「記憶域 スペース を割り当てる」というのは矛盾している感じがします。
 
そこで下記のように Variant で宣言してみたらうまくいきました。
 

' 毎回同じ配列を返す
Private Function GetSameArrayAnytime() As String()

    Static ast   As Variant ' ★
    Static bInit    As Boolean
    
    If (Not bInit) Then
        ast = GetArray()    ' ast は一度だけ初期化される
        bInit = True
    End If

    GetSameArrayAnytime = ast

End Function

 
半平太さんのコードの、
UBound(ast) とか IsArray(ast) というように関数の引数にすると「記憶域 スペース」確保することができるということになるのでしょうか。
 
まあ、内部的なことはMSの開発者にはしか分からないので、推測でなんとなく納得するしかないのですが。
 

投稿日時: 22/12/26 23:55:55
投稿者: たらのり

半平太 さん、
ありがとうございます。
 
しかし、いろいろなアイデアを思いつくものですね…
 
ちなみに、一つ前の僕のレスの GetDic()の例は問題なしです。
 
 
大変申し訳無いのですが、明日は時間を取ることができないため、
返信ができないかもしれません。
 
 
と、返信しようとしたところで、
 
hatena さん
ありがとうございます。
 
Static変数は BSSに、動的な配列の場合は LPSAFEARRAY
(VBの配列へのポインタ。固定サイズ)が配置され、配列自体は
ヒープに動的に確保されるのかなと。(嘘ですたぶん!!)
 
Dictionaryへの参照(ポインタ)は OKですし…

回答
投稿日時: 22/12/27 11:47:47
投稿者: 半平太

>動的配列をStatic(静的)宣言するのがちょっと違和感があります
 
との事ですが、マイクロソフトの別の記述によると
         ↓
     【配列を宣言する】
https://learn.microsoft.com/ja-jp/office/vba/language/concepts/getting-started/declaring-arrays
>動的配列を宣言すると、コードの実行中に配列のサイズを変更できます。
>次の例に示すように、Static、Dim、Private、または Public ステートメントを使用し、
>配列を宣言します。かっこ内は空にしてください。
 
となっていますので、Staticでも動的配列は普通のことと思えます。
 
>関数の引数にすると「記憶域 スペース」確保することができるということになるのでしょうか。
 
この解釈が正しいとすれば、一回でも確保すればいいハズですが(Staticなので)、
実験してみると、GetSameArrayAnytimeへ代入する前に関数の引数にしても効果はありません。
 
' Debug.Print UBound(ast)   '★1ここにだけ入れても、トラブル
    GetSameArrayAnytime = ast
' Debug.Print UBound(ast)   '★2ここに入れるだけで、ノントラブル
 
私の結論としては、Staticの属性を失念してしまうプロシージャ解析プログラムの
バグと思います。

回答
投稿日時: 22/12/27 13:11:06
投稿者: sk

現時点での結論としては「 VBA のバグの一種である」可能性が
高いように思われますけど。
 
まず件のプロシージャについて。
 

たらのり さんの引用:
Private Function GetSameArrayAnytime() As String()
 
    Static ast() As String
    Static bInit As Boolean
     
    If (Not bInit) Then
        ast = GetArray() ' ast は一度だけ初期化される
        bInit = True
    End If
 
    GetSameArrayAnytime = ast
 
End Function

GetSameArrayAnytime のプロシージャレベルで宣言されている
それぞれの変数の状態をウォッチウィンドウで確認した限り、
戻り値として渡すまでは ast の型が String(1 To 3) で
保持されていますが、End Function ステートメントに入ったところで
String() になる、つまり空の動的配列に初期化されているようです。
 
対して bInit の値は、1 回目の呼び出し後以降は True のまま
保持されています。
 
ちなみに GetSameArrayAnytime に Static キーワードを
付加しても同じ結果となります。
 
---------------------------------------------------------------
Private Static Function GetSameArrayAnytime() As String()
 
    Dim ast() As String
    Dim bInit As Boolean
     
    If (Not bInit) Then
        ast = GetArray() ' ast は一度だけ初期化される
        bInit = True
    End If
 
    GetSameArrayAnytime = ast
 
End Function
---------------------------------------------------------------
 
たらのり さんの引用:
Public Sub Test()
 
    Dim ast() As String ' Array of string
    Dim i As Long
     
    ast = GetSameArrayAnytime()
    For i = LBound(ast) To UBound(ast)
        Debug.Print ast(i)
    Next i
     
    ast = GetSameArrayAnytime()
    For i = LBound(ast) To UBound(ast) ' (9) インデクスが有効範囲にありません
        Debug.Print ast(i)
    Next i
 
End Sub

そして Test プロシージャ側で 2 回目の GetSameArrayAnytime が
呼び出された際に空の動的配列( 1 回目の時点で初期化された ast )が
そのまま戻り値として返され、更にそれを LBound 関数に渡した際に
実行時エラー 9 が発生する、というのがこの現象の一連の流れです。
 
半平太 さんの引用:
GetSameArrayAnytime = ast
Debug.Print UBound(ast)  ’←★ここにこの詰まんないステートメントを1文

上記のようにすると、何故か ast の型が String(1 To 3) のまま
(当然配列の各要素の値も)保持されます。
なんと適当に行ラベルを挿入するだけでも有効
 
---------------------------------------------------------------
Private Function GetSameArrayAnytime() As String()
 
    Static ast() As String
    Static bInit As Boolean
     
    If (Not bInit) Then
        ast = GetArray() ' ast は一度だけ初期化される
        bInit = True
    End If
 
    GetSameArrayAnytime = ast
 
Exit_GetSameArrayAnytime:
 
End Function
---------------------------------------------------------------
 
hatena さんの引用:
そこで下記のように Variant で宣言してみたらうまくいきました。

hatena さんの引用:
Static ast As Variant ' ★
Static bInit As Boolean

逆に GetSameArrayAnytime の戻り値の型を Variant 型に
(もしくは型宣言を省略)しても ast の状態は保持されます。
 
---------------------------------------------------------------
Private Function GetSameArrayAnytime() As Variant
 
    Static ast() As String
    Static bInit As Boolean
     
    If (Not bInit) Then
        ast = GetArray() ' ast は一度だけ初期化される
        bInit = True
    End If
 
    GetSameArrayAnytime = ast
 
End Function
---------------------------------------------------------------
 
なお、ast が静的配列である場合は問題なく実行できます。
 
---------------------------------------------------------------
Private Function GetSameArrayAnytime() As String()
 
    Static ast(1 To 3) As String
    Static bInit As Boolean
     
    If (Not bInit) Then
        ast(1) = "a"
        ast(2) = "b"
        ast(3) = "c"
        bInit = True
    End If
 
    GetSameArrayAnytime = ast
 
End Function
---------------------------------------------------------------
 
以上のことから、恐らく再現条件は次の 3 つを満たすこと。
 
・Function プロシージャの戻り値の型が動的配列である。
 
・プロシージャレベルで宣言された動的配列変数の属性が Static である。
 
・Static 属性の動的配列をプロシージャの戻り値として返すコードの直後に
 End Function ステートメントがある。
 
こちらの環境で検証した限り、Excel 2010 でも Excel 2016 でも確認できたため、
今のところバージョンに依存しない不具合である可能性が高いように思われます。

回答
投稿日時: 22/12/27 14:17:26
投稿者: hatena
投稿者のウェブサイトに移動

さすがskさん、緻密な分析。
 

引用:
なんと適当に行ラベルを挿入するだけでも有効。

 
これはバグといっていいでしょうね。

投稿日時: 22/12/28 17:30:25
投稿者: たらのり

ちょっといたずらで、Static の領域がどうなっているのか、
DLLを書いて表示してみました。
 
# 久々の Windowsプログラムなので、変なところもあるかと
 

' VBA
Private Declare PtrSafe Sub DumpMem _
        Lib "C:\Users\user\dev\dll\test\PeekStatic.dll" _
        (ByVal p As LongPtr, ByVal sz As Long)

Public Sub Test()

    Dim ast()   As String
    Dim i       As Long
    
    ast = GetSameArrayAnytime()
    For i = LBound(ast) To UBound(ast)
        Debug.Print ast(i)
    Next i
    
    ast = GetSameArrayAnytime()
    For i = LBound(ast) To UBound(ast)  ' * (9) インデクスが有効範囲にありません
        Debug.Print ast(i)
    Next i

    End
End Sub

' 毎回同じ配列を返す
Private Function GetSameArrayAnytime() As String()

    Static dummy    As Long
    Static ast()    As String   ' Array of string
    Static bInit    As Boolean
    
    Call DumpMem(VarPtr(dummy), 16)     ' ★ DLL呼び出し
    
    If (Not bInit) Then
        
        dummy = &HAAAAAAAA
        ast = GetArray()    ' ast は一度だけ初期化される
        bInit = True
    End If
    
    Call DumpMem(VarPtr(dummy), 16)     ' ★ DLL呼び出し

    GetSameArrayAnytime = ast           ' ☆
    
''  Call DumpMem(VarPtr(dummy), 16)     ' ★★ DLL呼び出し(しない)

End Function

' 初期化のため一度だけ呼ばれる
Private Function GetArray() As String()
    
    Dim ast(1 To 3) As String
    
    ast(1) = "a"
    ast(2) = "b"
    ast(3) = "c"
    
    GetArray = ast

End Function

 
/* peekstatic.c */
#include <windows.h>
#include <stdio.h>

void WINAPI DumpMem(const char *p, int sz)
{
    char *buf;
    int i;

    buf = (char *) malloc(sz * 3 + 1);
    if (!buf) {
        MessageBox(NULL, "malloc() error.", "DLL", MB_OK | MB_ICONERROR);
        return;
    }

    if (!p) {
        MessageBox(NULL, "Argment is NULL.", "DLL", MB_OK | MB_ICONERROR);
        return;
    }

    for (i = 0; i < sz; i++) {
        sprintf(buf + i * 3, "%02X,", p[i] & 0xff);
    }
    buf[sz * 3] = '\0';

    MessageBox(NULL, buf, "DLL", MB_OK | MB_ICONINFORMATION);

    free(buf);

    return;
}

 
; peekstatic.def
LIBRARY PeekStatic

EXPORTS
    DumpMem

 
DumpMem の表示結果ですが (16進数で表示)
 
1回目の呼び出し
00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,00,    … 初期化前
AA,AA,AA,AA,**,**,**,**,**,**,**,**,FF,FF,00,00,    … 初期化後

2回目の呼び出し
AA,AA,AA,AA,00,00,00,00,00,00,00,00,FF,FF,00,00,    … ast 域が、、、
AA,AA,AA,AA,00,00,00,00,00,00,00,00,FF,FF,00,00,

# 先頭の 4バイトは dummy As Long の領域
# つづく 8バイトは ast() As String (※)
# つづく 2バイトは bInit As Boolean (True は -1(&HFFFF))
#
# ※ SAFEARRAY構造体へのポインタ? (64ビットのアドレス)
 
 
1回目の呼び出しでは、5バイト目から 8バイトの領域に
アドレス(らしきもの)が入りました(「**,**, …」の部分)。
 
2回目の呼び出しでは、dummy、bInit の値は保持されている
ものの、アドレスらしき値は初期化されてしまっています。
 
★★ のコードを入れると問題なく実行できてしまうのですが、
1回目の ☆ の行の代入処理の実行後、
sk さんが指摘されたとおり、End Function へ到達時点で
ast の内容はすでに初期化されているようで、
半平太がおっしゃるように、Staticの属性を失念してしまう
ような振る舞いとなっています(Dim と同様毎回初期値に)。
 
# だからどうした給湯室ネタか!!

投稿日時: 22/12/30 12:44:35
投稿者: たらのり

こんにちは
 
自宅には実行環境がないのでテストできませんが、
1回めの呼び出し時にモジュールレベルの変数などに
Static領域(dummy)のアドレスを保持しておいて
呼び出しから戻った直後に DumpMem() で覗いて
みればよかったなと。
 
でも今回はここまでにしておきます。
 
# もしかしたら、テスト結果を年明けに給湯室で
 
 
いつもはこのようなときに Scripting.Dictionaryを
使用することが多いといいましたが、今回は順序を
維持したいために配列を使用しました。
が、Collectionがその目的に合うことを知り、
今回の問題を解決することができました。
 
ずーっと存在は知っていた Collection。
なぜか使ったことがなかったのですが、なかなか
使い勝手がよさそうです(20年気づくのが遅かった…)。
 
 
レスをくださった
taitani さん
半平太 さん
hatena さん
sk さん
ありがとうございました。
 
皆さま よいお年をお迎えください。