How to pass an array (Range.Value) to a native .NE

2020-02-09 16:35发布

问题:

What I am trying to do is populate an ArrayList using .AddRange() method in VBA using late binding on native C# ArrayList, but I can't figure out how to pass an object other than another ArrayList to it as argument... anything else I have tried so far fails...

So basically what I am doing now (Note: list is C#'s ArrayList via mscorlib.dll)

Dim list as Object
Set list = CreateObject("System.Collections.ArrayList")

Dim i As Long
For i = 1 To 5
    list.Add Range("A" & i).Value2
Next

But this is quite inefficient and ugly if for example i can be = 500K.

In VBA this also works:

ArrayList1.AddRange ArrayList2

But what I really need/would like is to pass an array instead of ArrayList2


So I heard I can pass an array to the .AddRange() parameter in .NET. I tested it in a small C# console application and it seemed to work just fine. The below works just fine in a pure C# console application.

ArrayList list = new ArrayList();
string[] strArr = new string[1];
strArr[0] = "hello";
list.AddRange(strArr);

So going back to my VBA module trying to do the same it fails..

Dim arr(1) As Variant
arr(0) = "WHY!?"

Dim arrr As Variant
arrr = Range("A1:A5").Value

list.AddRange arr                     ' Fail
list.AddRange arrr                    ' Fail
list.AddRange Range("A1:A5").Value    ' Fail

Note: I have tried passing a native VBA Array of Variants and Collection, Ranges - everything except another ArrayList failed.

How do I pass a native VBA array as a parameter to an ArrayList?

Or any alternative for creating a collection from Range without looping??

Bonus question: *Or maybe there is another built-in .Net COM Visible Collection that can be populated from VBA Range Object or Array without looping and that already has a .Reverse?

NOTE: I am aware that I can make a .dll wrapper to achieve this but I am interested in native solutions - if any exist.


Update

To better illustrate why I want to completely avoid explicit iteration - here's an example (it uses only one column for simplicity)

Sub Main()

    ' Initialize the .NET's ArrayList
    Dim list As Object
    Set list = CreateObject("System.Collections.ArrayList")


    ' There are two ways to populate the list.
    ' I selected this one as it's more efficient than a loop on a small set
    ' For details, see: http://www.dotnetperls.com/array-optimization
    list.Add Range("A1")
    list.Add Range("A2")
    list.Add Range("A3")
    list.Add Range("A4")
    list.Add Range("A5") ' It's OK with only five values but not with 50K.

    ' Alternative way to populate the list
    ' According to the above link this method has a worse performance
    'Dim i As Long
    'Dim arr2 As Variant
    'arr2 = Range("A1:A5").Value2
    'For i = 1 To 5
    '    list.Add arr2(i, 1)
    'Next

    ' Call native ArrayList.Reverse
    ' Note: no looping required!
    list.Reverse

    ' Get an array from the list
    Dim arr As Variant
    arr = list.ToArray

    ' Print reversed to Immediate Window
    'Debug.Print Join(arr, Chr(10))

    ' Print reversed list to spreadsheet starting at B1
    Range("B1").Resize(UBound(arr) + 1, 1) = Application.Transpose(arr)

End Sub

Please notice: the only time I have to loop is to populate the list (ArrayList) what I would love to do would be just to find a way to load the arr2 into an ArrayList or another .NET compatible type without loops.

At this point I see that there is no native/built-in way to do so that's why I think I am going to try to implement my own way and maybe if it works out submit an update for the Interop library.

回答1:

   list.AddRange Range("A1:A5").Value 

The range's Value gets marshaled as an array. That's about the most basic .NET type you can imagine of course. This one however has bells on, it is not a "normal" .NET array. VBA is a runtime environment that likes to create arrays whose first element starts at index 1. That's a non-conformant array type in .NET, the CLR likes arrays whose first element starts at index 0. The only .NET type you can use for those is the System.Array class.

An extra complication is that the array is a two-dimensional array. That puts the kibosh on your attempts to get them converted to an ArrayList, multi-dimensional arrays don't have an enumerator.

So this code works just fine:

    public void AddRange(object arg) {
        var arr = (Array)arg;
        for (int ix = ar.GetLowerBound(0); ix <= arr2.GetUpperBound(0); ++ix) {
            Debug.Print(arr.GetValue(ix, 1).ToString());
        } 
    }

You probably don't care for that too much. You could use a little accessor class that wraps the awkward Array and acts like a vector:

class Vba1DRange : IEnumerable<double> {
    private Array arr;
    public Vba1DRange(object vba) {
        arr = (Array)vba;
    }
    public double this[int index] {
        get { return Convert.ToDouble(arr.GetValue(index + 1, 1)); }
        set { arr.SetValue(value, index + 1, 1); }
    }
    public int Length { get { return arr.GetUpperBound(0); } }
    public IEnumerator<double> GetEnumerator() {
        int upper = Length;
        for (int index = 0; index < upper; ++index)
            yield return this[index];
    }
    System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() {
        return GetEnumerator();
    }

Now you can write it the "natural" way:

    public void AddRange(object arg) {
        var arr = new Vba1DRange(arg);
        foreach (double elem in arr) {
            Debug.Print(elem.ToString());
        }
        // or:
        for (int ix = 0; ix < arr.Length; ++ix) {
            Debug.Print(arr[ix].ToString());
        }
        // or:
        var list = new List<double>(arr);
    }


回答2:

Here's a proof of concept as an expansion of @phoog's comment. As he points out, the AddRange method takes an ICollection.

Implementing ICollection

In the VBA IDE, add a reference to mscorlib.tlb: Tools--->References, then browse to find your .NET Framework mscorlib.tlb. Mine was at "C:\Windows\Microsoft.NET\Framework\vX.X.XXXXX\mscorlib.tlb".

Create a new class called "clsWrapLongArray" as follows:

Option Compare Database
Option Explicit

Implements ICollection
Dim m_lngArr() As Long

Public Sub LoadArray(lngArr() As Long)
    m_lngArr = lngArr
End Sub

Private Sub ICollection_CopyTo(ByVal arr As mscorlib.Array, ByVal index As Long)
    Dim i As Long
    Dim j As Long
    j = LBound(m_lngArr)
    For i = index To index + (ICollection_Count - 1)
        arr.SetValue m_lngArr(j), i
        j = j + 1
    Next
End Sub

Private Property Get ICollection_Count() As Long
    ICollection_Count = UBound(m_lngArr) - LBound(m_lngArr) + 1
End Property

Private Property Get ICollection_IsSynchronized() As Boolean
    'Never called for this example, so I'm leaving it blank
End Property

Private Property Get ICollection_SyncRoot() As Variant
    'Never called for this example, so I'm leaving it blank
End Property

Here is the Array.SetValue method used.

Create a new module called "mdlMain" to run the example:

Option Compare Database
Option Explicit

Public Sub Main()

    Dim arr(0 To 3) As Long

    arr(0) = 1
    arr(1) = 2
    arr(2) = 3
    arr(3) = 4

    Dim ArrList As ArrayList
    Set ArrList = New ArrayList

    Dim wrap As clsWrapLongArray
    Set wrap = New clsWrapLongArray
    wrap.LoadArray arr
    ArrList.AddRange wrap
End Sub

If you put a breakpoint on the End Sub and run Main(), you can see by inspecting ArrList in the immediate window that it contains the 4 values added from the Long array. You can also step through the code to see that ArrayList actually calls the ICollection interface members Count and CopyTo to make it happen.

I could see that it might be possible to expand on this idea to create Factory functions to wrap types like Excel Ranges, VBA Arrays, Collections, etc.

This same method works with String arrays! :D

I found some very close answers by Googling "System.ArrayList" "SAFEARRAY" and "System.Array" "SAFEARRAY" and refining with "COM Marshaling", since VBA arrays are COM SAFEARRAYs, but nothing quite explained how one might convert them for use in .NET calls.



回答3:

It's inefficient because .Value2 is a COM call. Calling it thousands of times adds a lot of interop overhead. Adding items to an array in a loop should be MUCH faster:

Dim list as Object
Set list = CreateObject("System.Collections.ArrayList")

Dim i As Long
Dim a as Variant
a = Range("A1:A5").Value2
For i = 1 To 5
    list.Add a(i,1)
Next

How do I pass a native VBA array as a parameter to an ArrayList?

I don't think you can - a native VBA array is not an ICollection, so the only way to create one would be to copy the values in a loop.

The reason it works in a C# console application is because arrays in C# are ICollections, so the AddRange method accepts them just fine.