VB:Tutorials:Building a Better Scripting Language by Using Dynamic Classes
Building a Better Scripting Language by Using Dynamic Classes
In this tutorial I'm going to explain how to examine a class module at run-time in Visual Basic. When I say examine, I mean the ability to get information on all public members (properties and methods) of a class. This information includes the actual string name of the member, the type (property let, property get, property set, method), parameter data, return type data, and more. On top of all that, we're then going to look at how to dynamically call methods and set/retrieve properties, using this information. All of this is accomplished without knowing a thing about the class at design time. Crazy go nuts!
You might be asking, "whatever can I use this for?" I'll give you a hint: read the title, stupid! Now I know that isn't really a hint and your name isn't really stupid, but let's move on. As the title implies, these techniques can be extremely useful when building your own scripting language. I'll discuss how in more detail below.
That takes care of the introduction. On with the tutorial.
Part 1: Examining a Dynamic Class
You may be saying, "what exactly is a dynamic class?" Stop talking to yourself. When we say "dynamic class", we're talking about that class having the ability to expose some or all of its members to other applications. In order to examine a class, it must be dynamic. So how do we make a class dynamic? We must make the class itself public. Those of you who have experience in this area may already see the problem with this: VB does not allow us to make a class public in a Standard EXE. Well, what to do? The solution is to turn our project into a standalone ActiveX EXE. This gives us the necessary flexibilty to create public classes. Now, if you're only going to examine classes from other applications (such as a separate ActiveX EXE or a DLL), then you don't have to worry about making your project an ActiveX EXE. But if you want to examine classes from within your own project - like we're going to do in the sample code - then ActiveX it is.
I should note here a bit of odd behavior on VB's part. When using this code to examine classes, it's important to test your code in both the IDE and the compiled EXE. Why? Because the class changes when you compile. Strange but true, certain things will be different. For example, the COM functions that only examination can see will not be added until compile. Also, for some reason, in the IDE any class will work with these functions. Any! So even a private class in a Standard EXE will seem to run perfectly with your code - until you compile it. Then errors will start to pop up for (seemingly) no reason. So just a little heads up there. Test well and you'll be fine.
Finally it's time for some code. If you're starting a new project instead of working from the sample, then be sure to check the TypeLib Information box in the References list. First things first: Create an instance of a class.
Set mobjClass = New clsTest
As I'm sure most of you know, the variable name and class name are completely irrelevant. That's the beauty of this method, we don't have to know a thing about the class beforehand. Now that the class has been created, pass it to a TypeLib Information (TLI) function to get the info out of it.
Set ClassInfo = TLI.InterfaceInfoFromObject(mobjClass)
ClassInfo is an object of type InterfaceInfo. It's defined in the TypeLib reference, of course. TLI is a TLIApplication object, but you don't have to define or create it. It's already done in the reference, so just use it as if you had already dimmed as new. Or simply call the functions without the "TLI." at all. In the sample project I use both methods.
Now that we have our ClassInfo object, we have everything we need. This object contains all possible data about the class, it's members, the members of classes that are properties of our class, and so on and so forth. All we have to do is grab it.
Like I mentioned above, a member of a class is any method or property. To get to the member info, we must access the Members collection. Unfortunately, however, the Members collection is cluttered with unnecessary information (like multiple entries for a single member, and those darn COM functions!). Fortunately, we have a property of the Members collection to fix this for us. Observe:
Set FilteredMembers = ClassInfo.Members.GetFilteredMembers
FilteredMembers is a SearchResults object. SearchResults objects contain less information than Members, but they are useful for getting a subset of the entire Members collection. There are other ways to use them but they're beyond the scope of this tutorial. Check the further information section at the bottom.
Even though SearchResults contains less information, we can still get to the lost info by going back to the unfiltered Members collection. We can write a function to do it for us, like so:
Function GetMemberByID(objMembers As Members, memID As Long) As MemberInfo
Dim i As Long
For i = 1 To objMembers.Count If objMembers(i).MemberId = memID Then Set GetMemberByID = objMembers(i) Exit For End If Next i
Calling this function is very simple.
Set ClassMember = GetMemberByID(ClassInfo.Members, FilteredMembers(i).MemberID)
Notice the (i) on FilteredMembers. That's there because the best way to use this method is within a loop. As you're going through the FilteredResults, retrieve the unfiltered member for each SearchItem (the objects within a SearchResults collection).
Ok, now that we have all the information we need about the member, let's go through it and see what it all is. Actually, I'm not going to go through all of it. To keep this tutorial as short and simple as possible, I'll just focus on the most important. Assume FilteredMember is a SearchItem object and ClassMember is a MemberInfo object.
FilteredMember.Name - The string name of the member. This is whatever you name your method or property in code. Identical to ClassMember.Name.
FilteredMember.MemberID - The numerical ID of the member. This is useful to have, because it's faster to call a member by ID than by Name. Identical to ClassMember.MemberID.
FilteredMember.InvokeKinds - Contains flags that let you know what type of member this is. More useful than ClassMember.InvokeKind because it combines all entries into one flag. More detail below.
ClassMember.Name - See FilteredMember.Name
ClassMember.MemberID - See FilteredMember.MemberID
ClassMember.InvokeKind - The type of member (method/property let/property get/property set/etc).
ClassMember.ReturnType - Object containing information about the return type of the function or property.
ClassMember.Parameters - Collection of ParameterInfo objects, with information on parameters required to call a function/sub or property get.
ClassMember.Value - Variant containing the current value of a property.
All right, let's take a minute and go over InvokeKind(s) in more detail. The TypeLib Information library defines several of these constants as part of the InvokeKinds Enum. I'll just list the ones we'll need. They are:
You can probably guess what they stand for. In a Members collection, there will be a separate entry for each InvokeKind of a member. For example, a property defined in the class like so:
Public Xpos As Long
will have two seperate MemberInfo objects in the Members collection. Both will have a .Name property of "Xpos", but one will have an InvokeKind of INVOKE_PROPERTYGET and one will have INVOKE_PROPERTYLET. This is because that property can be both retrieved and assigned (read/write) and it is not an object. Therefore, to save us some tricky looping and keeping track of things, it's easier to use the InvokeKinds property (note the "s") of the SearchItem object. We'll already have the object anyway, since we're going through the filtered list, so no extra coding needed. The GetFilteredMembers property combines all same-named entries in a Members collection into one object. As a result, the InvokeKinds property contains all combined InvokeKind properties. Here's an example of how we can use it.
If FilteredMember.InvokeKinds And INVOKE_PROPERTYLET Then
'member is a variable property
ElseIf FilteredMember.InvokeKinds And INVOKE_PROPERTYSET Then
'member is an object property
ElseIf FilteredMember.InvokeKinds And INVOKE_FUNC Then
'member is a method
Here's another reason this is handy: when a class has a property that's an object (like a reference to itself, or another class), that property will get three entries in the Members collection. That's right, three - INVOKE_PROPERTYGET, INVOKE_PROPERTYLET, and INVOKE_PROPERTYSET. You're probably wondering why the heck an object would have an INVOKE_PROPERTYLET. Well, I have no idea. Perhaps it's for accessing default properties, I don't know. The point is, once we've gotten the filtered members, that INVOKE_PROPERTYLET magically disappears, and that makes differentiating between variables and objects that much easier.
Are you starting to see the application to scripting languages? Say you create a class that holds all functions that your scripting language will be able to call. With this ability to examine the class at run-time, you don't have to write any huge Select Case statements. Your language can parse for the function names ahead of time, and get info on return type, parameters, and more. The best part is, you don't have to change a thing when editing or adding functions! Just add it to your class, and that is that. Properties of your class can be predefined variables. You can even have object properties that have functions and properties that are parsed themselves... imagine a Player.Speed, Player.Life type of syntax. The power and extensibility of your game could skyrocket. Just like the pros, eh?
Now that we have the ability to grab all the information from a class to parse from, we just need to be able to use this information to actually call functions or retrive property values. This takes us to...
Part 2: Manipulating a Dynamic Class
Using the Invoke set of functions included in the TypeLib Information library, we can perform all standard operations on a class using the string name or numeric ID of the classes members. By standard operations I mean calling a method and setting or retrieving the value of a property. Obviously if we code the name of the method or property directly in VB, these operations are a no-brainer. However, when creating a scripting language, we need an easy way to implement them using a string variable containing the name. Now, I know that some of you are thinking, "what do we need these for? We have CallByName!"
Well, I'll tell you why.
CallByName has three severe limitations. First of all, you can only use it in VB 6.0 Professional or higher. Secondly, you must know the exact number of parameters the method or property needs at design-time. To top those two off, it also can only call a member by name, and not by numerical ID. (We'll get to the advantages of this in a bit.) The Invoke functions let us get around these, and a powerful ally they are. Here's the list:
Let's do a quick rundown of what they can do. All of the functions beginning with InvokeHook are used to call a method or property of a class using the name or id. The two with the word Sub in them are identical to their non-Sub cousins, except that they don't return a value. InvokeHook is very similar to CallByName. Here are the parameters:
Function InvokeHook(Object As Object, NameOrID, InvokeKind As InvokeKinds, ParamArray ReverseArgList() As Variant)
In use, it looks like this:
hr = InvokeHook(mobjMyClassObject, strName, INVOKE_FUNC, 4, "blah", lngVariable)
InvokeHook, like CallByName, requires that you know the number of parameters at design time, because you code them directly into the function call. The main difference in usage between InvokeHook and CallByName is that the ParamArray must be listed in reverse order. For example, the above call would look like this with CallByName:
hr = CallByName(mobjMyClassObject, strName, vbMethod, lngVariable, "blah", 4)
As mentioned above, most of the time having to code the parameters ahead of time in a ParamArray is very inconvenient. Unless you are going to have every function in your scripting language have the exact same number of parameters, you're not going to be able to use CallByName or InvokeHook. This is where InvokeHookArray comes in. It's usage is identical to InvokeHook, but instead of a ParamArray at the end, it takes an array of Variants.
Function InvokeHookArray(Object As Object, NameOrID, InvokeKind As InvokeKinds, ReverseArgList()
Like InvokeHook, the arguments must be passed in reverse order, but this is a minor inconvenience. The above function call would be done this way with InvokeHookArray:
varParams(2) = lngVariable
varParams(1) = "blah"
varParams(0) = 4
hr = InvokeHookArray(mobjMyClassObject, strName, INVOKE_FUNC, varParams)
Note: When using InvokeHookArray, you lose the ability to pass arguments ByRef. See the sources in the further information section for a workaround to this.
By using InvokeHookArray in combination with the class examination techniques in Part 1, we can parse for class functions ahead of time, and call them later on no matter what parameters or return type they require. Ain't it grand? There's just one last Invoke function to cover, InvokeID. Here's what it looks like:
Function InvokeID(Object As Object, Name As String) As Long
And you use like so:
lngID = InvokeID(mobjMyClassObject, strName)
So what is this good for? Well as you may recall, I've mentioned a few times being able to call a member by ID instead of by name. Why would you want to do this? For speed and optimization, of course - those things that every game developer loses sleep over. Here's how it works. When you use an InvokeHook function with a string name (or CallByName), the function internally calls InvokeID to get the member ID, then calls the member using that ID. This is no biggie if we're only going to use InvokeHook once. But in a scripting language we have no idea what the scripts are going to want to do. They may form a loop and call the same function 100 times. We have no idea, so we're better off optimizing. All we have to do is run through all the member names that have been parsed from the script, get the member ID using InvokeID, and store that ID for later. Then, when executing the scripts, grab that ID and use it in place of the name.
And that concludes this tutorial. Using this library can enable you to greatly extend the capabilities of your programs. I personally happen to like its uses for scripting languages the most, but it could also be applied to other things, such as plugins or applications that must cross-communicate. The TLI library also has much more to it that was not explored in this article. It is an extremely interesting set of objects. I hope to see more stuff on it in the future.
Be sure to download the sample project, as it goes into much of this material in more detail, and provides a working example of nearly all the discussed techniques. I hope you found this tutorial informative.
For Further Information