Workspace 6.21.5
More about object groups


The following are suggested pre-reading for this tutorial:


Tutorial contents


Files used in this tutorial


Tutorial goals

You can do a lot with the built-in data types and a few simple custom data types of your own. Nevertheless, sometimes your custom data type has more of a hierarchical nature to it. You might want to build up a custom data type from a collection of simpler types, and then use this custom data type as a sub-component of a higher level data type, and so on.

Assembling a custom data type in this way is called composing an object. The main advantage of building up objects in this way is that you can re-use the smaller components to compose a variety of higher level components with relatively little effort. For example, in 3D model applications, a three-element vector will frequently be re-used to specify things like translations, rotations and scale factors. These three things can, in turn, be assembled together to specify a general transformation.

When using custom data types with Workspace, there are some more reasons why re-using objects to compose higher level ones is advantageous. This tutorial shows how to compose objects and explains the advantages of doing so.


Using ObjectGroup as a base class

The Writing a Workspace Datatype tutorial showed how to create your own custom data type containing two values. We will now see how to do this as an object group and then show how to compose a parent group containing some of these.

Workspace provides a special class called ObjectGroup for composing hierarchical data objects. The best way to understand it is to see it in action, so let's have a look at the new pair class:

#ifndef CSIRO_MYNAMESPACE_MYPAIR_H
#define CSIRO_MYNAMESPACE_MYPAIR_H
#include "objectgroupsplugin_api.h"
namespace CSIRO
{
namespace MyNamespace
{
class CSIRO_OBJECTGROUPSPLUGIN_API MyPair : public DataExecution::ObjectGroup
{
void setObjects();
public:
DataExecution::TypedObject<int> valueA;
DataExecution::TypedObject<int> valueB;
MyPair();
MyPair(const MyPair& p);
MyPair(int a, int b);
MyPair* clone() const override;
};
} // namespace MyNamespace
} // namespace CSIRO
DECLARE_WORKSPACE_DATA_FACTORY(CSIRO::MyNamespace::MyPair, CSIRO_OBJECTGROUPSPLUGIN_API)
#endif
#define DECLARE_WORKSPACE_DATA_FACTORY(T, WORKSPACE_EXPORT_SYMBOL)
Definition: datafactorytraits.h:759
Top level namespace for all Workspace code.
Definition: applicationsupportplugin.cpp:32

There are three key things to notice about this class compared with the equivalent in the previous tutorials. Firstly, there is now base class, namely the ObjectGroup class provided by Workspace. Secondly, instead of holding two integers, the class holds two TypedObject data members. Finally, there is a virtual clone() function. There are also some more header files included at the top, but we'll cover those shortly. We will also talk about the private setObjects() function a bit later.

The ObjectGroup class provides a few functions for manipulating the data you add to the group. Generally, you won't need most of them for basic use, so we'll skip over these for now. The important difference is that our class now holds TypedObject's instead of int's. For our client code, this means we have to treat the data members as pointers to integers instead of as integers directly. Other than that though, we can do everything with this class that we could do with our earlier versions of the class.

The Workspace/DataExecution/DataObjects/objectgroup.h header defines the ObjectGroup base class for us. It also includes the Workspace/DataExecution/DataObjects/datafactorytraits.h header indirectly, so we don't need to explicitly add that header ourselves. The Workspace/DataExecution/DataObjects/typedobject.h header is needed because our class now holds TypedObject's instead of int's.

The implementation file for the MyPair class starts out like the previous tutorials:

#include "objectgroupsplugin.h"
namespace CSIRO
{
namespace MyNamespace
{

The three constructors, however, are a little different to before:

MyPair::MyPair() :
// Default constructor
valueA(0),
valueB(0)
{
setObjects();
}
MyPair::MyPair(const MyPair& p) :
// Copy constructor
ObjectGroup(), // (1)
valueA(p.valueA),
valueB(p.valueB)
{
setObjects();
}
MyPair::MyPair(int a, int b) :
// Custom constructor
valueA(a),
valueB(b)
{
setObjects();
}

The copy constructor at (1) uses the default ObjectGroup constructor. This is a bit unusual for C++, but because of how the ObjectGroup class works, it makes sense for us here. Some platforms will issue a compiler warning if you omit this, so it is a good habit to explicitly use the default constructor like this in your ObjectGroup subclasses for their copy constructors.

For the default constructor, we still assign default values, but now we also call a function called setObjects() in the body of the constructor. We do a similar thing with the other two constructors, only the valueA and valueB variables are set from the parameters passed to the constructor.

The purpose of the setObjects() function is to inform the ObjectGroup base class about the two data objects the class defines:

void MyPair::setObjects()
{
add("A", valueA);
add("B", valueB);
}

The add() function takes two parameters. The first is the name to use for the data object being added to the group and the second parameter is the data object to add. The name must not already have been used by another object already added to the group. Similarly, you should not add a data object to a group more than once. Once an object is added to a group, it is not normally removed. It should also be noted that the name cannot be changed, but you can iterate over the objects held by an ObjectGroup and retrieve the names of each one.

The only thing left to implement is the clone() function and to close off the namespaces:

MyPair* MyPair::clone() const
{
return new MyPair(*this);
}
} // namespace MyNamespace

The clone() function is almost always implemented this way. It merely creates a copy of the object and returns it. Note that this approach requires you to define a copy constructor, but you can do something totally different if you want. The important thing to remember is that the function is expected to return a complete clone of the object, so you should ensure that the object returned is indeed logically the same as the one making the copy.

The rest of the implementation file looks familiar from previous tutorials:

} // namespace CSIRO
DEFINE_WORKSPACE_DATA_FACTORY(CSIRO::MyNamespace::MyPair,
#define DEFINE_WORKSPACE_DATA_FACTORY(T, P)
Definition: typeddatafactory.h:1426
CSIRO::MyNamespace::ObjectGroupsPlugin::getInstance());

The advantage of using object groups is that if your group is composed of just the built in data types or of types that are themselves already serializable, then you get serialization for your object group class for free. Workspace uses intelligent code selection based on C++ templates to detect when your class has ObjectGroup as a base class and defines serialization functions for you automatically without you having to write a single line of code. This is especially useful when your group has a large number of data objects!


Hierarchical object groups

One of the more powerful features of object groups is that you can nest them to any level you like. To show an example, we will now define a new class as an object group containing three MyPair data objects. We will also take the opportunity to show how to define the class in a more modular way (ie with better encapsulation):

#ifndef CSIRO_MYNAMESPACE_MYGROUP_H
#define CSIRO_MYNAMESPACE_MYGROUP_H
#include "objectgroupsplugin_api.h"
namespace CSIRO
{
namespace MyNamespace
{
class MyPair;
class MyGroupImpl;
class CSIRO_OBJECTGROUPSPLUGIN_API MyGroup : public DataExecution::ObjectGroup
{
MyGroupImpl* pImpl_;
public:
MyGroup();
MyGroup(const MyGroup& g);
~MyGroup() override;
MyGroup& operator=(const MyGroup& g);
MyGroup* clone() const override;
void setFoo(const MyPair& f);
void setBar(const MyPair& b);
void setGus(const MyPair& g);
const MyPair& getFoo() const;
const MyPair& getBar() const;
const MyPair& getGus() const;
};
} // namespace MyNamespace
} // namespace CSIRO
DECLARE_WORKSPACE_DATA_FACTORY(CSIRO::MyNamespace::MyGroup, CSIRO_OBJECTGROUPSPLUGIN_API)
#endif

Our MyGroup class uses ObjectGroup as a base class, just like we did for MyPair, but there does not appear to be a single data object, only the private pImpl_ object. We've used this pattern once before in the Writing a Simple Workspace Plugin tutorial for our Workspace operation class to hide the details of how the class is constructed, and we use it again here for the same reason. We will hide the actual data objects and only provide access to them with the three set/get functions shown. This generally makes your code more portable and restricts the number of other files that need to be rebuilt any time you change something in your class.

So let's have a look at the implementation file to see just how MyGroup is defined:

#include "mygroup.h"
#include "mypair.h"
#include "objectgroupsplugin.h"
namespace CSIRO
{
namespace MyNamespace
{

The first thing to notice is that we have moved the Workspace/DataExecution/DataObjects/typedobject.h header here instead of needing to put it in the header file for MyGroup. Other than that though, the top of this implementation file is more or less the same as that for MyPair. Next, however, we define the MyGroupImpl class:

class MyGroupImpl : public Application::BasicTextLogger
{
void setObjects(MyGroup& g);
public:
DataExecution::TypedObject<MyPair> foo_;
DataExecution::TypedObject<MyPair> bar_;
DataExecution::TypedObject<MyPair> gus_;
MyGroupImpl(MyGroup& g);
MyGroupImpl(MyGroup& g, const MyGroupImpl& impl);
};

You can see that this implementation class is similar in style to the way we defined MyPair. It holds three data objects: foo_, bar_ and gus_, each of which is a MyPair object and also an object group. This is how you create hierarchical group structures, by adding data objects for the sub groups rather than sub groups directly (ie use TypedObject<SomeGroup> instead of just plain SomeGroup ). The definition of the MyGroupImpl class is very similar to what we did for MyPair:

MyGroupImpl::MyGroupImpl(MyGroup& g) :
foo_(MyPair(0, 1)),
bar_(MyPair()),
gus_(MyPair(3, 2))
{
setObjects(g);
}
MyGroupImpl::MyGroupImpl(MyGroup& g, const MyGroupImpl& impl) :
foo_(impl.foo_),
bar_(impl.bar_),
gus_(impl.gus_)
{
setObjects(g);
}
void MyGroupImpl::setObjects(MyGroup& g)
{
g.add("Foo", foo_);
g.add("Bar", bar_);
g.add("Gus", gus_);
}

Just like before, we supply values to the data objects in the constructor and call a common setObjects() function in the body of each constructor to add the data objects to the group. The setObjects() function assigns names to each data object we add.

We next define the MyGroup constructors and destructor:

MyGroup::MyGroup()
{
pImpl_ = new MyGroupImpl(*this);
}
MyGroup::MyGroup(const MyGroup& g) :
ObjectGroup() // (1)
{
pImpl_ = new MyGroupImpl(*this, *g.pImpl_);
}
MyGroup::~MyGroup()
{
delete pImpl_;
}

The constructors essentially just create the private pImpl_ data member of the class and the destructor deletes it again. You can more or less cut and paste these and just change the names for your own classes in most cases. Notice how the copy constructor also explicitly calls the ObjectGroup default constructor, as we did for the MyPair class earlier.

The assignment operator comes next:

MyGroup& MyGroup::operator=(const MyGroup& g)
{
MyGroupImpl* impl = new MyGroupImpl(*this, *g.pImpl_);
delete pImpl_;
pImpl_ = impl;
return *this;
}

This rather awkward but short function is simply re-using much of the code from the constructors to create a copy of the source object. This pattern is again usually able to be cut and pasted with the names changed for your own classes.

The clone() function is just like what we used for the MyPair class too:

MyGroup* MyGroup::clone() const
{
return new MyGroup(*this);
}

The remaining functions are just set/get functions on the three data objects. Let's look at those for foo_ first:

void MyGroup::setFoo(const MyPair& f)
{
pImpl_->foo_.ensureHasData();
*pImpl_->foo_ = f;
}
const MyPair& MyGroup::getFoo() const
{
WS_ASSERT_RUNTIME(pImpl_->foo_.hasData());
return *pImpl_->foo_;
}
#define WS_ASSERT_RUNTIME(cond)
Definition: errorchecks.h:146

Because our MyGroupImpl class holds data objects and not MyPair objects directly, the setFoo() function first has to ensure that the data object actually points to a valid MyPair. We do that by calling foo_'s ensureHasData() function. If foo_ already has data, then ensureHasData() will do nothing, but if foo_ does not yet point to valid data then ensureHasData() will create the data for it. When the data foo_ points to is an object group, the ensureHasData() function fully populates any child objects of the group recursively.

Once we know we have valid data in foo_ we assign it the value passed to setFoo(). For the getFoo() function, we set a pre-condition that getFoo() should only be called when it is guaranteed that foo_ has data. We could instead choose to create default data if none is present or even to return a copy of a temporary with a few small modifications. But for the purpose of this tutorial, we choose the simplest option and just use an assert() to verify the pre-condition.

The code for bar_ and gus_ is entirely analogous (these are the last things we need to define, so we also close off the namespaces and add the usual DEFINE_WORKSPACE_DATA_FACTORY macro):

void MyGroup::setBar(const MyPair& b)
{
pImpl_->bar_.ensureHasData();
*pImpl_->bar_ = b;
}
const MyPair& MyGroup::getBar() const
{
WS_ASSERT_RUNTIME(pImpl_->bar_.hasData());
return *pImpl_->bar_;
}
void MyGroup::setGus(const MyPair& g)
{
pImpl_->gus_.ensureHasData();
*pImpl_->gus_ = g;
}
const MyPair& MyGroup::getGus() const
{
WS_ASSERT_RUNTIME(pImpl_->gus_.hasData());
return *pImpl_->gus_;
}
} // namespace MyNamespace
} // namespace CSIRO
DEFINE_WORKSPACE_DATA_FACTORY(CSIRO::MyNamespace::MyGroup,
CSIRO::MyNamespace::ObjectGroupsPlugin::getInstance());

As you can see, defining a hierarchical group structure is not that much different to defining a simple one-level group. Again, we get all the serialization functionality for free, but we also gain further advantages which we will discuss in the next section.


Other benefits of using object groups

Using object groups can be a useful way to get automatic serialization of complex data types. This is an advantage from the developer's point of view. There is another big reason for favouring object groups though, and that is because it allows users to build up an object from its components. The user might be defining those components in unusual ways and not all at once. If any of the components require large amounts of memory to store, the user might even be sharing data objects between different object groups. These things can be done very easily with the Workspace editor application. Indeed, Workspace defines a dedicated ComposeGroup operation specifically to allow the user to build up object groups from their subcomponents.

Another great reason for using object groups is that Workspace knows how to present them as a hierarchical tree of widgets in GUI applications. Workspace interrogates the object group about the name and type of each component in the group heirarchy and populates the appropriate items in the tree representing the group. It can do this to any arbitrary level of nesting of subgroups. Thus, using object groups for your data types gives your users instant access to them in the GUI application without you the developer having to write any code for it in most cases.


Summary of important points

This tutorial has introduced you to the essentials of composing object groups to create hierarchical custom data types. The main points to remember are the following:

  • To make a class into an object group, add ObjectGroup as a base class.
  • Object groups must define the clone() function to replicate themselves.
  • You have to add each data object to the group, usually in a function called by every constructor. When adding data objects to the group, you assign a unique name to each one. The name only has to be unique among the data object's siblings (ie those that are an immediate component of the same group).
  • You can nest object groups to any level.
  • There are substantial benefits to the user if you can represent complex data objects as object groups, so prefer it where possible for anything other than fairly trivial custom data types.