Ready to practice?
Sign up to access interactive coding exercises and track your progress.
Modeling Uses-A Relationships
Connect objects that collaborate without ownership semantics.
Association
In the previous two lessons, we've looked at two types of object composition, composition and aggregation. Object composition is used to model relationships where a complex object is built from one or more simpler objects (parts).
In this lesson, we'll take a look at a weaker type of relationship between two otherwise unrelated objects, called an association. Unlike object composition relationships, in an association, there is no implied whole/part relationship.
To qualify as an association, an object and another object must have the following relationship:
- The associated object (member) is otherwise unrelated to the object (class)
- The associated object (member) can belong to more than one object (class) at a time
- The associated object (member) does not have its existence managed by the object (class)
- The associated object (member) may or may not know about the existence of the object (class)
Unlike a composition or aggregation, where the part is a part of the whole object, in an association, the associated object is otherwise unrelated to the object. Just like an aggregation, the associated object can belong to multiple objects simultaneously, and isn't managed by those objects. However, unlike an aggregation, where the relationship is always unidirectional, in an association, the relationship may be unidirectional or bidirectional (where the two objects are aware of each other).
The relationship between lawyers and clients is a great example of an association. The lawyer clearly has a relationship with their clients, but conceptually it's not a part/whole (object composition) relationship. A lawyer can represent many clients in a day, and a client can work with many lawyers (perhaps they want a second opinion, or they are working with different types of lawyers). Neither of the object's lifespans are tied to the other.
We can say that association models a "uses-a" relationship. The lawyer "uses" the client (to earn income). The client uses the lawyer (for whatever legal services they need).
Implementing associations
Because associations are a broad type of relationship, they can be implemented in many different ways. However, most often, associations are implemented using pointers, where the object points at the associated object.
In this example, we'll implement a bi-directional Lawyer/Client relationship, since it makes sense for the Lawyers to know who their Clients are, and vice-versa.
#include <functional> // reference_wrapper
#include <iostream>
#include <string>
#include <string_view>
#include <vector>
// Since Lawyer and Client have a circular dependency, we're going to forward declare Client
class Client;
class Lawyer
{
private:
std::string m_name{};
std::vector<std::reference_wrapper<const Client>> m_clients{};
public:
Lawyer(std::string_view name)
: m_name{name}
{
}
void addClient(Client& client);
// We'll implement this function below Client since we need Client to be defined at that point
friend std::ostream& operator<<(std::ostream& out, const Lawyer& lawyer);
const std::string& getName() const { return m_name; }
};
class Client
{
private:
std::string m_name{};
std::vector<std::reference_wrapper<const Lawyer>> m_lawyers{}; // so that we can use it here
// We're going to make addLawyer private because we don't want the public to use it.
// They should use Lawyer::addClient() instead, which is publicly exposed
void addLawyer(const Lawyer& lawyer)
{
m_lawyers.push_back(lawyer);
}
public:
Client(std::string_view name)
: m_name{name}
{
}
// We'll implement this function below to parallel operator<<(std::ostream&, const Lawyer&)
friend std::ostream& operator<<(std::ostream& out, const Client& client);
const std::string& getName() const { return m_name; }
// We'll friend Lawyer::addClient() so it can access the private function Client::addLawyer()
friend void Lawyer::addClient(Client& client);
};
void Lawyer::addClient(Client& client)
{
// Our lawyer will add this client
m_clients.push_back(client);
// and the client will also add this lawyer
client.addLawyer(*this);
}
std::ostream& operator<<(std::ostream& out, const Lawyer& lawyer)
{
if (lawyer.m_clients.empty())
{
out << lawyer.m_name << " has no clients right now";
return out;
}
out << lawyer.m_name << " is representing clients: ";
for (const auto& client : lawyer.m_clients)
out << client.get().getName() << ' ';
return out;
}
std::ostream& operator<<(std::ostream& out, const Client& client)
{
if (client.m_lawyers.empty())
{
out << client.getName() << " has no lawyers right now";
return out;
}
out << client.m_name << " is working with lawyers: ";
for (const auto& lawyer : client.m_lawyers)
out << lawyer.get().getName() << ' ';
return out;
}
int main()
{
// Create a Client outside the scope of the Lawyer
Client sarah{"Sarah"};
Client mike{"Mike"};
Client emily{"Emily"};
Lawyer anderson{"Anderson"};
Lawyer rodriguez{"Rodriguez"};
anderson.addClient(sarah);
rodriguez.addClient(sarah);
rodriguez.addClient(emily);
std::cout << anderson << '\n';
std::cout << rodriguez << '\n';
std::cout << sarah << '\n';
std::cout << mike << '\n';
std::cout << emily << '\n';
return 0;
}
This prints:
Anderson is representing clients: Sarah
Rodriguez is representing clients: Sarah Emily
Sarah is working with lawyers: Anderson Rodriguez
Mike has no lawyers right now
Emily is working with lawyers: Rodriguez
In general, you should avoid bidirectional associations if a unidirectional one will do, as they add complexity and tend to be harder to write without making errors.
Reflexive association
Sometimes objects may have a relationship with other objects of the same type. This is called a reflexive association. A good example of a reflexive association is the relationship between a software module and its dependencies (which are also software modules).
Consider the simplified case where a Module can only have one prerequisite. We can do something like this:
#include <string>
#include <string_view>
class Module
{
private:
std::string m_name{};
const Module* m_prerequisite{};
public:
Module(std::string_view name, const Module* prerequisite = nullptr)
: m_name{name}, m_prerequisite{prerequisite}
{
}
};
This can lead to a chain of associations (a module has a prerequisite, which has a prerequisite, etc...)
Associations can be indirect
In all of the previous cases, we've used either pointers or references to directly link objects together. However, in an association, this isn't strictly required. Any kind of data that allows you to link two objects together suffices. In the following example, we show how a Pilot class can have a unidirectional association with an Aircraft without actually including an Aircraft pointer or reference member:
#include <iostream>
#include <string>
#include <string_view>
class Aircraft
{
private:
std::string m_model{};
int m_tailNumber{};
public:
Aircraft(std::string_view model, int tailNumber)
: m_model{model}, m_tailNumber{tailNumber}
{
}
const std::string& getModel() const { return m_model; }
int getTailNumber() const { return m_tailNumber; }
};
// Our Hangar is essentially just a static array of Aircraft and a lookup function to retrieve them.
// Because it's static, we don't need to allocate an object of type Hangar to use it
namespace Hangar
{
Aircraft fleet[4]{{"Cessna", 172}, {"Boeing", 737}, {"Airbus", 320}, {"Piper", 180}};
Aircraft* getAircraft(int tailNumber)
{
for (auto& aircraft : fleet)
{
if (aircraft.getTailNumber() == tailNumber)
{
return &aircraft;
}
}
return nullptr;
}
};
class Pilot
{
private:
std::string m_name{};
int m_aircraftTailNumber{}; // we're associated with the Aircraft by tail number rather than pointer
public:
Pilot(std::string_view name, int tailNumber)
: m_name{name}, m_aircraftTailNumber{tailNumber}
{
}
const std::string& getName() const { return m_name; }
int getAircraftTailNumber() const { return m_aircraftTailNumber; }
};
int main()
{
Pilot john{"John", 737}; // John is flying the aircraft with tail number 737
Aircraft* aircraft{Hangar::getAircraft(john.getAircraftTailNumber())}; // Get that aircraft from the hangar
if (aircraft)
std::cout << john.getName() << " is flying a " << aircraft->getModel() << '\n';
else
std::cout << john.getName() << " couldn't find their aircraft\n";
return 0;
}
In the above example, we have a Hangar holding our aircraft. The Pilot, who needs an aircraft, doesn't have a pointer to their Aircraft -- instead, they have the tail number of the aircraft, which we can use to get the Aircraft from the Hangar when we need it.
In this particular example, doing things this way is kind of silly, since getting the Aircraft out of the Hangar requires an inefficient lookup (a pointer connecting the two is much faster). However, there are advantages to referencing things by a unique ID instead of a pointer. For example, you can reference things that aren't currently in memory (maybe they're in a file, or in a database, and can be loaded on demand). Also, pointers can take 4 or 8 bytes -- if space is at a premium and the number of unique objects is fairly low, referencing them by an 8-bit or 16-bit integer can save lots of memory.
Summary
Association definition: A relationship between otherwise unrelated objects where the associated object can belong to multiple objects simultaneously, doesn't have its existence managed by any of them, and may or may not know about the objects using it.
"Uses-a" relationship: Association models "uses-a" relationships (a lawyer uses a client, a client uses a lawyer) where neither object is part of the other.
Directionality: Unlike composition and aggregation (always unidirectional), associations can be unidirectional or bidirectional. In bidirectional associations, both objects are aware of each other.
Implementation: Most commonly implemented using pointers where the object points at the associated object. Can be implemented with any data type that links objects together (e.g., IDs, names).
Bidirectional associations: More complex than unidirectional. Require careful management to keep both sides synchronized. Avoid unless necessary, as they add complexity and increase error potential.
Reflexive associations: Associations between objects of the same type (e.g., a software module depending on another module). Creates chains where objects reference other objects of the same class.
Indirect associations: Don't require direct pointers or references. Can use unique IDs or other identifiers to establish relationships, retrieving the actual object when needed (e.g., via lookup functions).
Advantages of indirect associations: Save memory when objects aren't always in memory (can be loaded on demand from files/databases). Use less space when object count is low (small integer vs pointer size).
Composition vs aggregation vs association comparison:
- Composition: Whole/part, single owner, managed existence, unidirectional, "part-of" verb
- Aggregation: Whole/part, multiple owners allowed, unmanaged existence, unidirectional, "has-a" verb
- Association: Otherwise unrelated, multiple owners allowed, unmanaged existence, uni/bidirectional, "uses-a" verb
Prefer unidirectional associations over bidirectional ones to reduce complexity. Use the simplest relationship type that meets your program's needs.
Modeling Uses-A Relationships - Quiz
Test your understanding of the lesson.
Practice Exercises
Implement Bidirectional Doctor-Patient Association
Create a bidirectional association between Doctor and Patient classes where doctors can have multiple patients and patients can have multiple doctors. Demonstrate the 'uses-a' relationship with proper reference management.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!