Dear John, How Do I... Work with Collections of Objects?

Visual Basic's Collection object lets you combine objects—or items of any data type for that matter—into a set that can be referred to as a single unit. You might think of a Collection object as an extremely flexible array that contains just about anything you want to put in it, including objects and other collections. The most common use for Collection objects, however, is to maintain a collection of an unspecified number of instances of a specific type of object. That's what the following example will demonstrate.

There are many ways to use collections in the Visual Basic objects you create. Some techniques are more "dangerous" than others, and depending on how much you want to protect yourself or other programmers, you can implement collections of objects in simple ways or in somewhat intricate but safe configurations. I recommend that you thoroughly read the Books Online topics covering this subject to gain a solid understanding of Microsoft's suggested approach. I've used an only slightly modified version of Microsoft's suggested techniques, and I think it would be helpful for you to follow their guidelines, too. (Search Books Online for House of Straw, House of Sticks, and House of Bricks to locate these topics.)

The following example presents a simple, straightforward method for implementing collections of user-defined objects within other objects. This method is fairly robust, and the technique can be easily duplicated or modified as desired. We'll create a solar system structure comprising a top-level Star object that contains a Planets collection. Each Planet object within this collection contains a Moons collection, and each Moon object is a very simple object consisting of just a few properties.

SolarSys—A Collections Example

Start up Visual Basic, and open a new Standard EXE project. Set the form's Name property to SolarSys, and save this form as SOLARSYS.FRM. Save the project using the name SOLARSYS.VBP. Change the form's Caption property to Collections Example, and add a command button named cmdBuildSolarSystem. Edit the button's Caption property to read Build Solar System. Add the following code to the form, and then save your work:

Option Explicit
Public starObject As New Star
Private Sub cmdBuildSolarSystem_Click()
    `Prepare working object references
    Dim planetObject As Planet
    Dim moonObject As Moon
    `Add planets and moons to the solar system
    With starObject.Planets
        `Create first planet
        Set planetObject = .Add("Mercury")
        `Set some of its properties
        With planetObject
            .Diameter = 4880
            .Mass = 3.3E+23
        End With
        `Create second planet
        Set planetObject = .Add("Venus")
        `Set some of its properties
        With planetObject
            .Diameter = 12104
            .Mass = 4.869E+24
        End With
        `Create third planet
        Set planetObject = .Add("Earth")
        `Set some of its properties
        With planetObject
            .Diameter = 12756
            .Mass = 5.9736E+24
            `Add moons to this planet
            With .Moons
                `Create Earth's moon
                Set moonObject = .Add("Luna")
                `Set some of its properties
                With moonObject
                    .Diameter = 3476
                    .Mass = 7.35E+22
                End With
            End With
        End With
        `Create fourth planet
        Set planetObject = .Add("Mars")
        `Set some of its properties
        With planetObject
            .Diameter = 6794
            .Mass = 6.4219E+23
            `Add moons to this planet
            With .Moons
                `Create Mar's first moon
                Set moonObject = .Add("Phobos")
                `Set some of its properties
                With moonObject
                    .Diameter = 22
                    .Mass = 1.08E+16
                End With
                `Create Mar's second moon
                Set moonObject = .Add("Deimos")
                `Set some of its properties
                With moonObject
                    .Diameter = 13
                    .Mass = 1.8E+15
                End With
            End With
        End With
    End With
    `Disable the command button
    cmdBuildSolarSystem.Enabled = False
    `Display the results
    Print "Planet", "Moon", "Diameter (km)", "Mass (kg)"
    Print String(100, "-")
    For Each planetObject In starObject.Planets
        With planetObject
            Print .Name, , .Diameter, .Mass
        End With
        For Each moonObject In planetObject.Moons
            With moonObject
                Print , .Name, .Diameter, .Mass
            End With
        Next moonObject
    Next planetObject
    `Directly access some specific properties
    Print
    Print "The Earth's moon has a diameter of ";
    Print starObject.Planets("Earth").Moons("Luna").Diameter;
    Print " kilometers."
    Print "Mars has";
    Print starObject.Planets("Mars").Moons.Count;
    Print " moons."
End Sub

Figure 5-7 shows the SolarSys form during development.

Figure 5-7. The SolarSys form, used to demonstrate nested collections of objects.

We'll refer back to this code in a minute, but first let's define the objects and collections of objects that this code creates and manipulates.

The Star Class

From the Project menu, choose Add Class Module, and double-click the Class Module icon in the Add Class Module dialog box. Set the class module's Name property to Star, add the following code , and then save the module as STAR.CLS.

Option Explicit

Public Name As String

Private mPlanets As New Planets

Public Property Get Planets() As Planets
    Set Planets = mPlanets
End Property

The Star object declares and contains a collection of planets. We'll take a look at the Planets class in a minute, but for now be aware that the Planets class defines a collection of Planet objects, which are in turn defined in their own class.

Notice that the Planets collection is declared private, as mPlanets, so it's not directly accessible by the external world. Instead, the Property Get Planets procedure provides read-only access to the collection. Since there's no corresponding Property Let procedure, external procedures are prevented from reassigning unexpected objects to this reference. We need the read-only access to the Star's Planets collection in order to access the individual Planet objects it contains.

The Planets Class

All the planets in the solar system will be maintained as individual Planet objects in a Planets collection. Notice that I named this collection the same as the objects in it, except for the addition of the natural-sounding "s" to make it plural. This is the standard technique used throughout all object collections. Add a new class module to your project, name it Planets, add the following code to it, and save it as PLANETS.CLS.

Option Explicit
Private mcolPlanets As New Collection
Public Function Add(strName As String) As Planet
    Dim PlanetNew As New Planet
PlanetNew.Name = strName
    mcolPlanets.Add PlanetNew, strName
    Set Add = PlanetNew
End Function

Public Sub Delete(strName As String)
    mcolPlanets.Remove strName
End Sub

Public Function Count() As Long
    Count = mcolPlanets.Count
End Function

Public Function Item(strName As String) As Planet
    Set Item = mcolPlanets.Item(strName)
End Function

Public Function NewEnum() As IUnknown
    Set NewEnum = mcolPlanets.[_NewEnum]
End Function

When I first started creating collections of objects, I was tempted to combine each object and its collection into one class module. This works, but there are lots of hidden problems and unforeseen gotchas that are avoided by making the collection an object separate from the objects it contains. That's why this example application includes both a Planet and a Planets class. They work hand in hand to create a fairly robust collection of planets. Likewise, as you'll see in a minute, the Moon and Moons classes work together to create a collection of moons.

The Planets collection class encapsulates and handles the creation and manipulation of individual Planet objects. The actual Collection object, mcolPlanets, is declared private to this class module to encapsulate and protect it from the outside world. The Add, Delete, Count, Item, and NewEnum collection methods provide the interface used to interact with the collection of planets. I've used some special tricks you should know about, so let's go over these procedures to explain them.

The Add method is used to create a new planet in the collection. For this example I've used the name of each planet, as passed to the Add method, both to set the planet's Name property and as the key by which the planet is to be stored and retrieved. The key can be something different from the name, as it is in the collections example described in Visual Basic's online documentation. Each element stored in the collection must have a unique string key, and letting the collection class create and automatically maintain its own set of keys is one way to guarantee each key's uniqueness. The technique I've chosen is slightly simpler to implement and provides a natural way to retrieve each planet from the collection by name. Note, however, that an error will be generated if an attempt is made to add two planets of the same name. You may want to add code to the Add method to trap errors caused by repeated planet names. To keep things simple I chose to leave this step up to the reader.

The Count procedure returns the number of planets in the Planets collection, and the Delete procedure removes a planet from the Planets collection. Again, you may wish to add error-trapping code to prevent errors resulting from attempts to delete nonexistent planets from the collection.

When given a specific planet name, the Item procedure returns that planet object from the collection. Here's the first important trick I want to mention. If you set the Item procedure as the default property of the class, then the syntax to access a specific planet is more natural. For example, you can access the planet Earth object using the Item property like this: starObject.Planets.Item("Earth"); or, if the Item procedure is set as the default procedure, the syntax is shortened to this: starObject.Planets ("Earth"). This shortened syntax is much more in keeping with the standard object syntax you'll see in many other Windows application objects.

NOTE

To set a procedure as the default, select Procedure Attributes from the Tools menu. Select the procedure in the Name drop-down list, click the Advanced button to see more options in the dialog box, and select (Default) from the Procedure ID drop-down list. Only one procedure in each class can be selected as the default.

The second important trick enables you to access objects in a collection class using the For Each looping construct. This one's a little obscure, so follow along closely. The first step is to add the special NewEnum IUnknown procedure to your collection class, as shown in the code for the Planets class. The square brackets are required, as is the underscore for the hidden enumeration property of Visual Basic's Collection object. The second step is to hide and enable the NewEnum function in your class, using the steps detailed in the following note:

NOTE

To hide and enable the special NewEnum procedure in your class, choose Procedure Attributes from the Tools menu. Select the NewEnum procedure in the Name drop-down list, and click the Advanced button to see more options. Click to put a check mark in the Hide This Member check box, and type the special code number of minus four (-4) in the Procedure ID list. This enables your collection for access using the For Each looping construct.

The Planet Class

Add another class module to the SolarSys project, set its Name property to Planet, and save the module as PLANET.CLS. This class defines the individual Planet objects that are collected within the Planets collection. Add the following code to this class module.

Option Explicit

Public Name As String
Public Diameter As Long
Public Mass As Single

Private mMoons As New Moons

Public Property Get Moons() As Moons
    Set Moons = mMoons
End Property

Each Planet object has a few simple properties (Name, Diameter, and Mass) and a Moons collection containing anywhere from zero to several Moon objects. Once again, the actual collection is declared private so that external code won't be able to set unexpected references into the Moons object reference. The read-only Property Get Moons procedure allows you to reference the moons for this planet.

The Moons Class

The Moons class is a collection class very similar in structure to the Planets collection class. Add another class module to your SolarSys project, set its Name property to Moons, add the following code to it, and save it as MOONS.CLS.

Option Explicit

Private mcolMoons As New Collection

Public Function Add(strName As String) As Moon
    Dim MoonNew As New Moon
    MoonNew.Name = strName
    mcolMoons.Add MoonNew, strName
    Set Add = MoonNew
End Function

Public Sub Delete(strName As String)
    mcolMoons.Remove strName
End Sub

Public Function Count() As Long
    Count = mcolMoons.Count
End Function

Public Function Item(strName As String) As Moon
    Set Item = mcolMoons.Item(strName)
End Function

Public Function NewEnum() As IUnknown
    Set NewEnum = mcolMoons.[_NewEnum]
End Function

Notice the striking similarity to the Planets collection class. The procedures are nearly identical; only the referenced object names have been changed to protect the innocent.

The Moon Class

This class is ultrasimple. Each Moon object will have Name, Diameter, and Mass properties, and that's all. Add another class module to the project, set its Name property to Moon, save it as MOON.CLS, and add the following code to define each Moon object:

Option Explicit

Public Name As String
Public Diameter As Long
Public Mass As Single

How the Nested Collections Work

Let's take a closer look at the top-level code in the SolarSys form. When the Build Solar System command button is clicked, a new Star object, named starObject, is created. Since we aren't concerned with creating more than one Star object, we won't bother to set its Name property. (If we were creating a program to catalog all star systems in a collection of science fiction stories, we might want to create multiple Star objects, perhaps in a Stars collection, and unique names for each would probably be appropriate.)

To simplify the code, I added only the first four of our solar system's planets and their moons. Feel free to build up the whole solar system if you feel so inclined! The following code adds the first Planet object named Mercury. Mercury has no moons, so we won't add any of these objects to this planet:

    `Add planets and moons to the solar system
    With starObject.Planets
        `Create first planet
        Set planetObject = .Add("Mercury")
        `Set some of its properties
        With planetObject
            .Diameter = 4880
            .Mass = 3.3E+23
        End With

I've used the With statement to simplify the object references because we're going several layers deep here. However, we could directly access the planet Mercury object's properties by explicitly using dot notation to get to them. For example, here's what the line to set Mercury's Diameter property would look like using the complete dot notation:

starObject.Planets("Mercury").Diameter = 4880

Paraphrased, this says to set the value 4880 as the Diameter property of the planet Mercury object in the Planets collection maintained by the Star object named starObject. Whew! You can see why the With statement is useful. In addition to being easier for humans to comprehend, With blocks reduce the number of de-referencing steps required to access each object and its properties, thus speeding things up.

The next block of code repeats the process, adding the second Planet object, named Venus, which also lacks Moon objects. Let's skip ahead then to the third planet from the sun, named Earth, which does have one moon. Here's the code to add this planet and its moon to the nested collections:

`Create third planet
Set planetObject = .Add("Earth")
`Set some of its properties
With planetObject
    .Diameter = 12756
    .Mass = 5.9736E+24
    `Add moons to this planet
    With .Moons
        `Create Earth's moon
        Set moonObject = .Add("Luna")
        `Set some of its properties
        With moonObject
            .Diameter = 3476
            .Mass = 7.35E+22
        End With
    End With
End With

Once again, notice how the nested With constructs help in referencing the structure of nested object collections.

Once this simplified model of our solar system is built up as a set of collections of objects that are contained within other collections of objects, the next few lines of code access this structure to display the names of all the nested objects:

`Display the results
Print "Planet", "Moon", "Diameter (km)", "Mass (kg)"
Print String(100, "-")
For Each planetObject In starObject.Planets
    With planetObject
        Print .Name, , .Diameter, .Mass
    End With
    For Each moonObject In planetObject.Moons
        With moonObject
            Print , .Name, .Diameter, .Mass
        End With
    Next moonObject
Next planetObject

We've enabled the For Each looping construct, described earlier in this chapter, which greatly aids in the process of referencing the various properties of the planets and moons. The code above is fairly straightforward and easy to understand. For purposes of comparison, in the next few lines of the code I've used some direct property referencing using the full dot notation to access and print some properties:

    `Directly access some specific properties
    Print
    Print "The Earth's moon has a diameter of ";
    Print starObject.Planets("Earth").Moons("Luna").Diameter;
    Print " kilometers."
    Print "Mars has";
    Print starObject.Planets("Mars").Moons.Count;
    Print " moons."

Figure 5-8 shows the results as displayed on the SolarSys form after the command button is clicked.

Figure 5-8. The simplified solar system's Planets and Moons collections.

There are a lot of ways you could modify the technique presented here to suit your needs. As mentioned, you might want to add some code to prevent accidental attempts to add multiple objects of the same name and to handle missing objects in a more robust fashion. This method serves as a strong conceptual foundation on which to build without getting too lost in all the possible complexities. For many programming situations, this method can provide everything you need.