One of the cornerstones of object-oriented design is the concept of objects interacting by sending messages to form mechanisms – units of higher-order (or ‘emergent’) behaviour.
In order to send a message (in this case, invoke a member function) an object must have a ‘link’ to the target object. That link is formed by building in an association between the two classes as part of the type’s definition.
In this article we look at building associations between classes and forming run-time links so objects can communicate.
The basic principle
An association represents the ability of one object to send a message (invoke a function) of another. In order to do that the sending object must know the identity of the receiver.
The identity of an object is its address; therefore we must hold the address of the receiver. An association between two classes is therefore implemented as a pointer to that type.
We also need a ‘binding’ function to encapsulate the linking of the two objects.
Connected objects form a system
In a client-server relationship the client invokes the services of the server. The client sends messages to the server. That is, the client invokes the member functions of the server. This relationship is uni-directional. The server never calls the client’s member functions.
In a peer-to-peer relationship either object may invoke the operations on the other. That is, the relationship is bi-directional.
Note: Just because a member function returns a value does not make the relationship bi-directional. The association type is determined by who makes the member function calls, not the direction of data travel.
Client-Server 1:1 association
The simplest, and probably most common, form of association is the unidirectional client-server.
In this example the Positioner class is the client, with two (independent) server classes – Actuator and Sensor. Here the identities of the server objects are stored as pointers; the constructor is used as the binding function.
We form the link by binding the actual objects together at run-time. In this example we bind the Actuator object and the Sensor object by passing in the addresses of these objects to the Positioner constructor.
The Positioner can now invoke member functions on its associated objects by dereferencing its pointers.
Improving the interface
Since a reference is an alias for an (existing) object, taking the address of a reference is the same as taking the address of the original object.
Or, put another way: since a reference acts as an automatically-dereferenced (const) pointer, taking the address of a reference is semantically equivalent to:
Passing by reference hides from the client the fact that the CameraStabiliser stores pointers internally. This is yet another form of encapsulation.
A quick aside: Why not use references for associations?
In this simple case you could – and many programmers advocate this – use a reference to the server object instead of a pointer. I prefer to use a pointer for a number of reasons:
- A pointer specifies that I am holding the address (identity) of an object; references are a mechanism for efficient parameter passing in functions. Let’s use the language idioms correctly and consistently
- Pointers can be used consistently for all association types; references can’t. See below for more on this.
- Since references can’t be re-seated using a reference ‘fixes’ the association. There’s no way to associate objects differently later in the program. This precludes the ability to do substitution of specialised types at run-time. This dramatically limits the flexibility of your design. You’re probably better off using a template instead.
While we’re talking about pointers: don’t be tempted to use smart pointers for associations. The problem with a smart pointer is that it’s designed for use with dynamically-allocated objects. As a client you can’t (and shouldn’t) know the storage specification of your server object. If the server object is stack-based when your association smart pointer goes out of scope it will try and call delete on the address of the object (for those who haven’t experienced this, trust me – it never ends well).
Associations are one of the very few valid uses of ‘raw’ pointers in a C++ program.
1:N unidirectional associations
When the association is one-to-many then a simple pointer does not suffice. We could use individual (named) pointers but this would be a clumsy solution (at best). In this case we use a std::array to hold the pointers (since the maximum number of Position objects is fixed, at 16).
The moveTo() function acts as a ‘bind’ function. It adds the address of the associated object to the track array.
Notice that in this case we can’t use references to hold the identity of the object because you can’t have an array of references.
1:* Unidirectional associations
It’s a relatively trivial extension to implement a one-to-many association: simply replace the fixed-size array with a dynamic container (for example a std::list)
Bi-directional 1:1 associations
If our association is bi-directional, then both classes need pointers. The problems come when you have to bind the objects together: which object gets built first?
If both classes need a pointer to the other in their constructors to work there is a cyclic dependency.
One solution is to provide a ‘binder’ function on each class that forms the association. In this example, the UI class has an additional member function, addStabiliser() that allows a CameraStabiliser object to be bound to it. The CameraStabiliser class has a complementary function.
At construction time, the CameraStabiliser and UI objects are constructed. Then the objects are bound together by calling the ‘bind’ functions. Notice, the programmer must ensure that the objects are bound together before they are used, otherwise you may end up dereferencing null pointers! Having to check for null pointers may seem a major drawback of this technique but it should be remembered that, sometimes, your design may actually require an unfulfilled association. If that isn’t the case, using an assert in your code during development will highlight that you have failed to construct your system correctly.
A consequence of this approach is that the public interface of a class becomes ‘polluted’ with bind functions, which have nothing to do with the class’s functionality. This breaks the Single Responsibility Principle, which says an object should only have one reason to change. In this case, our classes have two reasons to change: their behaviour, which is part of their design; and their set of associations, which is a function of how they will be used in this particular project. These two aspects are orthogonal. For example, the same class could be used in a very different way in another project, meaning it would most likely have a very different set of associations; even though its behaviour hasn’t changed.
An alternative approach is to create a free function that binds the two objects together.
Note, now both objects only have default constructors. The bind function forms the association between the two objects.
This approach has the benefit that it does not add any (non-behavioural) functions to the public interface of either class.
However, there is a problem with this code.
The bind function is not a member of either the UI or CameraStabiliser classes, so therefore cannot access their private data members (in this case, the association pointers)
Making the pointers public would allow this code to work but this exposes the pointers to clients and breaks encapsulation.
The solution is to make the bind function a friend function. A friend function is granted access to the private members of the class.
Notice the bind function must be made a friend of both the UI and CameraStabiliser classes.
The above is the general case for forming associations: a pointer plus a friend bind function. This will work for all associations – even the simplest case (1:1 unidirectional). For consistency of code this is the method I generally promote.
Reducing compile-time dependencies
Including one header file within another builds a dependency (coupling) between the two interfaces. Wherever possible we want to reduce the coupling between modules in our design.
In this case, including the class definition of class Sensor is unnecessary. You only need to include the class definition if:
- You are going to allocate memory for an object
- Inherit from an class
- Call one of the class’s member functions.
Class Positioner does not instantiate a Sensor object; it merely has a pointer to a Sensor object. Since the compiler knows how much memory to allocate for a pointer we do not need to include the class definition of Sensor.
However, we must still declare that class Sensor is a valid type to satisfy the compiler. In this case we do so with a forward reference – actually just a pure declaration that class Sensor exists (somewhere).
Note: if we wish to call any of Sensor’s member functions we must include the class definition.
Adopting consistent idioms is the core of building maintainable code; making every construct a special case just makes code difficult to read, understand, extend and adapt.
In an object oriented design classes don’t just exist in isolation: they are inter-connected and their interactions are what provide the system’s behaviour. Understanding the need for, and implementation of, associations is a basic, fundamental skill for any C++ programmer.
If you’d like to know more about C++ programming – particularly for embedded and real-time applications – visit the Feabhas website. You may find the following of interest:
Latest posts by Glennan Carnie (see all)
- Technical debt - August 22, 2018
- Your handy cut-out-and-keep guide to std::forward and std::move - April 26, 2018
- Setting up Sublime Text to build your project - April 12, 2018