Summary: Other languages treat functions more as first class citizens in that one can pass a function pointer (C++) around just like a variable. In C# there are function delegates which are like an object orientated version of function pointers. We can do something similar in VBA but there is an unfortunately duality that needs to be unified or abstracted.
So other languages like C++ have function pointers which can be used for callbacks (for say event handling) or the visitor design pattern (think of passing a comparison function into a C++ sort routine). Indeed, in C++ a pointer to a COM interface is in fact a pointer a whole table of function pointers. C# has function delegates which are like object orientated versions of C++ function pointers. In VBA, we can do something like function delegates of C# but not the pointers of C++ because VBA does not have pointers. Both C++ and C# will score higher than anything I can write in VBA because they have argument type checking. Moreover, VBA will never have inline function definitions like they have in Javascript or (as far as I can see) lambda expressions.
The real problem in VBA is the duality between calling a function in a standard module using Application.Run() and calling a function of a class instance using VBA.CallByname(). We can solve this by adding a class to route execution to the correct branch of code.
Without pulling a trick the extra layer will mean that the code has to read with the Run method being visible, this is not as nice as C++ or C#.
fnFoo.Run("bar","barry")
But we can pull a trick to hide the Run() by adding an attribute to the Run making its DispID = 0 added layer. This needs to be done on a code module that has been exported and edited in a text editor (Notepad). VBA has a different name for DispID, they call VB_UserMemId = 0 so the attribute line to add in an exported code module is as following.
Attribute Item.VB_UserMemId = 0
Once this atribute is added, the text file saved and re-imported into VBA project we can then call the same line of code without Run thus ...
fnFoo("bar","barry")
So the code is given below. There are two core classes FnFactory and FunctionDelegate and three test modules (2 classes+1 standard module) FunctionDelegateTestClass, tstFunctionDelegate and AsynchronousWebCall. So import these into a fresh workbook VBA project. You'll need a reference to Microsoft WinHTTP Services, version 5.1 (C:\WINDOWS\system32\winhttpcom.dll) for the AsynchronousWebCall class to compile. Then run the tests.
The AsynchronousWebCall class and its calling code given here show the compact syntax I was aiming for in terms of specifying which functions to call for each event of OnError and OnResponseFinished on an XHR. This is as tight and as close as I can get to the Javascript syntax for ajax calls but is quite satisfactory (to me at least).
Private Sub Test_AsynchronousWebCall_CompactSyntax()
'* run this test to see function delegates in action, this has compact syntax
Static oAsyncXHR As AsynchronousWebCall '<--- needs to (a) static is locally scoped or (b) module or globally scope
Set oAsyncXHR = New AsynchronousWebCall
Call oAsyncXHR.RunAsynchronous("GET", "https://stackoverflow.com/questions/tagged/vba", _
FnFactory.FnAppRun("TestOnError"), _
FnFactory.FnAppRun("TestOnResponseFinished"))
End Sub
The Code Listings
The FnFactory class
This Contains factory methods to frees us from the syntactical constraints around the New keyword.
VERSION 1.0 CLASS
BEGIN
MultiUse = -1 'True
END
Attribute VB_Name = "FnFactory"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = True
Attribute VB_Exposed = False
Option Explicit
'---------------------------------------------------------------------------------------
' Module : FnFactory
' DateTime : 06/02/2018 15:57
' Author : Simon
' Purpose : Contains factory methods to frees us from the syntactical constraints
' around the New keyword
' Deployment: Open a text editor and ensure the line reads "Attribute VB_PredeclaredId = True"
' so that one will not need to New this module!
'---------------------------------------------------------------------------------------
'---------------------------------------------------------------------------------------
' Procedure : FnAppRun
' DateTime : 05/02/2018 14:05
' Author : Simon
' Purpose : A factory method to create a FunctionDelegate containing enough info
' to pass to Application.Run(.. , .. , .. , ...)
' Using a factory method frees us from the syntactical constraints
' around the New keyword
'---------------------------------------------------------------------------------------
' Arguments :
' [in] sMacro : the name of macro passed to Application.Run
' [in] bReturnTypeIsObject : whether or not we need to say 'Set foo=Application.Run(...' for returning an object
' [out,retval] : returns a create instance of FunctionDelegate containing the passed details
'
Public Function FnAppRun(ByVal sMacro As String, Optional ByVal bReturnTypeIsObject As Boolean) As FunctionDelegate
Dim oNewFD As FunctionDelegate
Set oNewFD = New FunctionDelegate
oNewFD.IsAppRun = True
oNewFD.AppRunMacro = sMacro
oNewFD.ReturnTypeIsObject = bReturnTypeIsObject
'oNewFD.mvArgs = vargs
Set FnAppRun = oNewFD
End Function
'---------------------------------------------------------------------------------------
' Procedure : FnCallByName
' DateTime : 06/02/2018 15:50
' Author : Simon
' Purpose : A factory method to create a FunctionDelegate containing enough info
' to pass to VBA.CallByName(.. , .. , .. , ...)
' Using a factory method frees us from the syntactical constraints
' around the New keyword
'---------------------------------------------------------------------------------------
' Arguments :
' [in] oCallByNameTarget : the object (class instance) on whom we want to call the method
' [in] sMacro : the name of method we want to call
' [in] eCallByNameType : necessary to specify one of {VbSet, VbMethod, VbLet, VbGet}
' [in] bReturnTypeIsObject : whether or not we need to say 'Set foo=VBA.CallByName(...' for returning an object
' [out,retval] : returns a create instance of FunctionDelegate containing the passed details
'
Public Function FnCallByName(ByVal oCallByNameTarget As Object, ByVal sMacro As String, _
ByVal eCallByNameType As VbCallType, Optional bReturnTypeIsObject As Boolean) As FunctionDelegate
Dim oNewFD As FunctionDelegate
Set oNewFD = New FunctionDelegate
oNewFD.IsAppRun = False
Set oNewFD.CallByNameTarget = oCallByNameTarget
oNewFD.ReturnTypeIsObject = bReturnTypeIsObject
oNewFD.CallByNameType = eCallByNameType
oNewFD.AppRunMacro = sMacro
'oNewFD.mvArgs = vargs
Set FnCallByName = oNewFD
End Function
The FunctionDelegate class
This class contains enough information to call a function either using (a) Application.Run() if function lives in a standard module or (b) VBA.CallByName() if function lives in a class instance (object). It is recommended (but not necessary) one uses the FnFactory class for more compact code for instantiation of this class.
VERSION 1.0 CLASS
BEGIN
MultiUse = -1 'True
END
Attribute VB_Name = "FunctionDelegate"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = False
Attribute VB_Exposed = False
Option Explicit
'---------------------------------------------------------------------------------------
' Module : FunctionDelegate
' DateTime : 06/02/2018 16:04
' Author : Simon
' Purpose : Contains enough information to call a function either using
' (a) Application.Run() if function lives in a standard module or
' (b) VBA.CallByName() if function lives in a class instance (object)
'
' Recommended (but not necessary) one uses the FnFactory class for more compact
' code for instantiation of this class.
'---------------------------------------------------------------------------------------
Private msAppRunMacro As String
Private mobjCallByNameTarget As Object
Private meCallByNameType As VbCallType
Private mbIsAppRun As Boolean
Private mbReturnTypeIsObject As Boolean
Public Property Get ReturnTypeIsObject() As Boolean
ReturnTypeIsObject = mbReturnTypeIsObject
End Property
Public Property Let ReturnTypeIsObject(ByVal bReturnTypeIsObject As Boolean)
mbReturnTypeIsObject = bReturnTypeIsObject
End Property
Public Property Get IsAppRun() As Boolean
IsAppRun = mbIsAppRun
End Property
Public Property Let IsAppRun(ByVal bIsAppRun As Boolean)
mbIsAppRun = bIsAppRun
End Property
Public Property Get CallByNameType() As VbCallType
CallByNameType = meCallByNameType
End Property
Public Property Let CallByNameType(ByVal eCallByNameType As VbCallType)
meCallByNameType = eCallByNameType
End Property
Public Property Get CallByNameTarget() As Object
Set CallByNameTarget = mobjCallByNameTarget
End Property
Public Property Set CallByNameTarget(ByVal objCallByNameTarget As Object)
Set mobjCallByNameTarget = objCallByNameTarget
End Property
Public Property Get AppRunMacro() As String
AppRunMacro = msAppRunMacro
End Property
Public Property Let AppRunMacro(ByVal sAppRunMacro As String)
msAppRunMacro = sAppRunMacro
End Property
'---------------------------------------------------------------------------------------
' Procedure : Run
' DateTime : 06/02/2018 16:01
' Author : Simon
' Purpose : This runs/executes/calls the function. Deployed correctly one can omit
' .Run in the calling line see unit tests for example
'
' Deployment: *** need to ensure that this has the line "Attribute Item.VB_UserMemId = 0"
' to make it default ***
'---------------------------------------------------------------------------------------
' Arguments :
' vargs() : a variable list of arguments which we'll pass on to Application.Run()
' or VBA.CallByName()
'
Public Function Run(ParamArray vargs() As Variant)
Attribute Run.VB_UserMemId = 0
Dim lArgCount As Long
lArgCount = UBound(vargs) - LBound(vargs) + 1
If mbIsAppRun Then
If lArgCount = 0 Then
If mbReturnTypeIsObject Then
Set Run = Application.Run(msAppRunMacro)
Else
Run = Application.Run(msAppRunMacro)
End If
ElseIf lArgCount = 1 Then
If mbReturnTypeIsObject Then
Set Run = Application.Run(msAppRunMacro, vargs(0))
Else
Run = Application.Run(msAppRunMacro, vargs(0))
End If
ElseIf lArgCount = 2 Then
If mbReturnTypeIsObject Then
Set Run = Application.Run(msAppRunMacro, vargs(0), vargs(1))
Else
Run = Application.Run(msAppRunMacro, vargs(0), vargs(1))
End If
Else
'requires more lines to handle multiple arguments,
'a bit ugly so will do later
End If
Else
If Not mobjCallByNameTarget Is Nothing Then
If lArgCount = 0 Then
If mbReturnTypeIsObject Then
Set Run = CallByName(mobjCallByNameTarget, msAppRunMacro, meCallByNameType)
Else
Run = CallByName(mobjCallByNameTarget, msAppRunMacro, meCallByNameType)
End If
ElseIf lArgCount = 1 Then
If mbReturnTypeIsObject Then
Set Run = CallByName(mobjCallByNameTarget, msAppRunMacro, meCallByNameType, vargs(0))
Else
Run = CallByName(mobjCallByNameTarget, msAppRunMacro, meCallByNameType, vargs(0))
End If
ElseIf lArgCount = 2 Then
If mbReturnTypeIsObject Then
Set Run = CallByName(mobjCallByNameTarget, msAppRunMacro, meCallByNameType, vargs(0), vargs(1))
Else
Run = CallByName(mobjCallByNameTarget, msAppRunMacro, meCallByNameType, vargs(0), vargs(1))
End If
Else
'requires more lines to handle multiple arguments,
'a bit ugly so will do later
End If
End If
End If
End Function
The FunctionDelegateTestClass class
This is test fodder for the function delegate and is not core to the application; we need to test the CallByName use case so we need a class and some methods to call.
VERSION 1.0 CLASS
BEGIN
MultiUse = -1 'True
END
Attribute VB_Name = "FunctionDelegateTestClass"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = False
Attribute VB_Exposed = False
Option Explicit
'---------------------------------------------------------------------------------------
' Module : FunctionDelegateTestClass
' DateTime : 06/02/2018 16:38
' Author : Simon
' Purpose : To house some procedures to be called using instances of FunctionDelegate
' (not really to be called directly)
'---------------------------------------------------------------------------------------
Public Sub CallByNameZeroArg()
'DO NOT RUN THIS DIRECTLY IT IS HERE TO BE CALLED AS PART OF A TEST
Debug.Print "ClassTestForDelegates.CallByNameZeroArg called"
End Sub
Public Function CallByNameZeroArgReturnString() As String
'DO NOT RUN THIS DIRECTLY IT IS HERE TO BE CALLED AS PART OF A TEST
Debug.Print "ClassTestForDelegates.CallByNameZeroArg called"
CallByNameZeroArgReturnString = "returned String"
End Function
Public Function CallByNameZeroArgReturnObject() As Workbook
'DO NOT RUN THIS DIRECTLY IT IS HERE TO BE CALLED AS PART OF A TEST
Debug.Print "ClassTestForDelegates.CallByNameZeroArg called"
Set CallByNameZeroArgReturnObject = ThisWorkbook
End Function
The tstFunctionDelegate standard module
The standard module houses both unit tests and some function which we call via delegates.
Attribute VB_Name = "tstFunctionDelegate"
Option Explicit
Option Private Module
'---------------------------------------------------------------------------------------
' Module : tstFunctionDelegate
' DateTime : 06/02/2018 16:26
' Author : Simon
' Purpose : Unit tests that also serve as sample calling syntax
'---------------------------------------------------------------------------------------
'---------------------------------------------------------------------------------------
' Procedure : Test_FunctionDelegateFactory_Suite
' DateTime : 06/02/2018 16:48
' Author : Simon
' Purpose : Runs the test suite
'---------------------------------------------------------------------------------------
'
Private Sub Test_FunctionDelegateFactory_Suite()
Test_FunctionDelegateFactory_FnAppRun
Test_FunctionDelegateFactory_FnAppRun_DispID0
Test_FunctionDelegateFactory_FnCallByName
Test_FunctionDelegateFactory_FnCallByName_DispID0
Test_AsynchronousWebCall_CompactSyntax
Test_AsynchronousWebCall_FullerSyntax
End Sub
Private Sub TestOnError(ByVal lErrorNo As Long, ByVal sErrDesc As String)
'DO NOT RUN THIS DIRECTLY IT IS HERE TO BE CALLED AS PART OF A TEST
'* this is called by AsynchronousWebCall when handling the OnError event
Debug.Print "TestOnError", lErrorNo, sErrDesc
End Sub
Private Sub TestOnResponseFinished(ByVal sResponseText As String)
'DO NOT RUN THIS DIRECTLY IT IS HERE TO BE CALLED AS PART OF A TEST
'* this is called by AsynchronousWebCall when handling the OnResponseFinished event
Debug.Print "TestOnResponseFinished"
Debug.Print Left$(sResponseText, 50)
End Sub
Private Sub Test_FunctionDelegateFactory_FnAppRun()
'* run this test to test calling function delegate based on application.run
Dim fnFoo As FunctionDelegate
Set fnFoo = FnFactory.FnAppRun("SubFooZero", False)
Call fnFoo.Run
Dim fnFoo1 As FunctionDelegate
Set fnFoo1 = FnFactory.FnAppRun("SubFooOne", False)
Call fnFoo1.Run("hello")
Call fnFoo1("hello") '<--- with DispID=0 we can pull a default member trick and omit Run
End Sub
Private Sub Test_FunctionDelegateFactory_FnAppRun_DispID0()
'* run this test to test calling function delegate based on application.run
'* also testing the omission of Run by using DispID=0
Dim fnFoo As FunctionDelegate
Set fnFoo = FnFactory.FnAppRun("SubFooZero", False)
Call fnFoo '<--- with DispID=0 we can pull a default member trick and omit Run
End Sub
Private Sub Test_FunctionDelegateFactory_FnCallByName()
'* run this test to test calling function delegate based on vba.callbyname
Dim oTarget As FunctionDelegateTestClass
Set oTarget = New FunctionDelegateTestClass
Dim fnCBN As FunctionDelegate
Set fnCBN = FnFactory.FnCallByName(oTarget, "CallByNameZeroArg", VbMethod, False)
fnCBN.Run
Dim fnCBN2 As FunctionDelegate
Set fnCBN2 = FnFactory.FnCallByName(oTarget, "CallByNameZeroArgReturnString", VbMethod, False)
Debug.Print fnCBN2.Run
Dim fnCBN3 As FunctionDelegate
Set fnCBN3 = FnFactory.FnCallByName(oTarget, "CallByNameZeroArgReturnObject", VbMethod, True)
Debug.Print fnCBN3.Run.Name
End Sub
Private Sub Test_FunctionDelegateFactory_FnCallByName_DispID0()
'* run this test to test calling function delegate based on vba.callbyname
'* also testing the omission of Run by using DispID=0
Dim oTarget As FunctionDelegateTestClass
Set oTarget = New FunctionDelegateTestClass
Dim fnFoo As FunctionDelegate
Set fnFoo = FnFactory.FnCallByName(oTarget, "CallByNameZeroArgReturnObject", VbMethod, True)
Debug.Print fnFoo().Name '<--- with DispID=0 we can pull a default member trick and omit Run
End Sub
Public Sub SubFooZero()
'DO NOT RUN THIS DIRECTLY IT IS HERE TO BE CALLED AS PART OF A TEST
Debug.Print "SubFooZero called"
End Sub
Public Sub SubFooOne(v)
'DO NOT RUN THIS DIRECTLY IT IS HERE TO BE CALLED AS PART OF A TEST
Debug.Print "SubFooOne called with arg " & v
End Sub
Private Sub Test_AsynchronousWebCall_CompactSyntax()
'* run this test to see function delegates in action, this has compact syntax
Static oAsyncXHR As AsynchronousWebCall '<--- needs to (a) static is locally scoped or (b) module or globally scope
Set oAsyncXHR = New AsynchronousWebCall
Call oAsyncXHR.RunAsynchronous("GET", "https://stackoverflow.com/questions/tagged/vba", _
FnFactory.FnAppRun("TestOnError"), _
FnFactory.FnAppRun("TestOnResponseFinished"))
End Sub
Private Sub Test_AsynchronousWebCall_FullerSyntax()
'* run this test to see function delegates in action, this has fuller syntax
Dim fnOnError As FunctionDelegate
Set fnOnError = FnFactory.FnAppRun("TestOnError")
Dim fnOnResponseFinished As FunctionDelegate
Set fnOnResponseFinished = FnFactory.FnAppRun("TestOnResponseFinished")
Static oAsyncXHR As AsynchronousWebCall '<--- needs to (a) static is locally scoped or (b) module or globally scope
Set oAsyncXHR = New AsynchronousWebCall
Call oAsyncXHR.RunAsynchronous("GET", "https://stackoverflow.com/questions/tagged/vba", fnOnError, fnOnResponseFinished)
End Sub
The AsynchronousWebCall class
A more real world use case where we call a web site and pass delegates to functions to be called on error and on completion
VERSION 1.0 CLASS
BEGIN
MultiUse = -1 'True
END
Attribute VB_Name = "AsynchronousWebCall"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = False
Attribute VB_Exposed = False
Option Explicit
'* Tools->References
'* WinHttp Microsoft WinHTTP Services, version 5.1 C:WINDOWSsystem32winhttpcom.dll
Private WithEvents moXHR As WinHttp.WinHttpRequest
Attribute moXHR.VB_VarHelpID = -1
Private mfnFunctionDelegateOnError As FunctionDelegate
Private mfnFunctionDelegateOnResponseFinished As FunctionDelegate
'---------------------------------------------------------------------------------------
' Procedure : RunAsynchronous
' DateTime : 06/02/2018 16:20
' Author : Simon
' Purpose :
'---------------------------------------------------------------------------------------
' Arguments :
' sHttpMethod : one of {GET, PUT, POST, DELETE}
' sURL : the web address
' fnOnErrorDelegate : the delegate of the function we want to call on an error
' fnOnResponseFinishedDelegate : the delegate of the function we want to call on completion
'
Public Sub RunAsynchronous(ByVal sHttpMethod As String, ByVal sURL As String, _
ByVal fnOnErrorDelegate As FunctionDelegate, ByVal fnOnResponseFinishedDelegate As FunctionDelegate)
Set mfnFunctionDelegateOnError = fnOnErrorDelegate
Set mfnFunctionDelegateOnResponseFinished = fnOnResponseFinishedDelegate
Set moXHR = New WinHttp.WinHttpRequest
moXHR.Open sHttpMethod, sURL, True
moXHR.send
End Sub
'---------------------------------------------------------------------------------------
' Procedure : moXHR_OnError
' DateTime : 06/02/2018 16:16
' Author : Simon
' Purpose : see https://msdn.microsoft.com/en-us/library/windows/desktop/aa383929(v=vs.85).aspx
'---------------------------------------------------------------------------------------
'
Private Sub moXHR_OnError(ByVal ErrorNumber As Long, ByVal ErrorDescription As String)
'Debug.Print "moXHR_OnError"
If Not mfnFunctionDelegateOnError Is Nothing Then
mfnFunctionDelegateOnError.Run ErrorNumber, ErrorDescription
End If
End Sub
'---------------------------------------------------------------------------------------
' Procedure : moXHR_OnResponseDataAvailable
' DateTime : 06/02/2018 16:16
' Author : Simon
' Purpose : see https://msdn.microsoft.com/en-us/library/windows/desktop/aa383941(v=vs.85).aspx
'---------------------------------------------------------------------------------------
'
Private Sub moXHR_OnResponseDataAvailable(Data() As Byte)
'Debug.Print "moXHR_OnResponseDataAvailable"
'* not interested
End Sub
'---------------------------------------------------------------------------------------
' Procedure : moXHR_OnResponseFinished
' DateTime : 06/02/2018 16:17
' Author : Simon
' Purpose : see https://msdn.microsoft.com/en-us/library/windows/desktop/aa383946(v=vs.85).aspx
'---------------------------------------------------------------------------------------
'
Private Sub moXHR_OnResponseFinished()
'Debug.Print "moXHR_OnResponseFinished"
If Not mfnFunctionDelegateOnResponseFinished Is Nothing Then
mfnFunctionDelegateOnResponseFinished.Run moXHR.responseText
End If
End Sub
'---------------------------------------------------------------------------------------
' Procedure : moXHR_OnResponseStart
' DateTime : 06/02/2018 16:17
' Author : Simon
' Purpose : see https://msdn.microsoft.com/en-us/library/windows/desktop/aa383954(v=vs.85).aspx
'---------------------------------------------------------------------------------------
'
Private Sub moXHR_OnResponseStart(ByVal Status As Long, ByVal ContentType As String)
'Debug.Print "moXHR_OnResponseStart"
'* not interested
End Sub
No comments:
Post a Comment