Modeling Uses-A Relationships
Connect objects that collaborate without ownership semantics.
What Is Association?
An association is a relationship between two objects that are not parts of each other. Neither is built from the other, neither destroys the other, and either one can outlive the other. They are linked only because one needs to reach the other to get work done.
Composition and aggregation both answer the question "what is this object made of". Association answers a different question: "what does this object need to talk to". That is why it is described with the verb uses-a rather than has-a or part-of.
A lawyer and a client are the standard illustration. The lawyer plainly has a relationship with the client, but no one would say a client is a component of a lawyer. A lawyer acts for many clients; a client may retain several lawyers for different matters. Neither creates the other, and neither is destroyed when the other is. The lawyer uses the client to earn a fee, and the client uses the lawyer to get advice.
Where Association Sits Among the Relationship Types
The three relationship types differ along four axes. Reading the table by column tells you what each relationship promises; reading it by row tells you which question to ask about your own design.
| Question about the relationship | Composition | Aggregation | Association |
|---|---|---|---|
| Is one object part of the other? | Yes | Yes | No |
| Can the second object belong to several of the first at once? | No | Yes | Yes |
| Does the first object control when the second is destroyed? | Yes | No | No |
| Can the second object know about the first? | No | No | Yes, optionally |
| Verb | Part-of | Has-a | Uses-a |
The last row of the table is what makes association distinctive. Composition and aggregation are always one-way: the whole knows its parts and the parts know nothing. An association is free to run in either direction, or in both at once, because there is no whole and no part to impose an order.
Association is the weakest of the three relationships. Nothing about it is a promise except that one object can reach the other. If you find yourself unable to say which object is the whole, you are almost certainly looking at an association.
Choosing What to Store
Because association promises so little, it can be built out of almost anything that gets you from one object to the other. Three implementations cover nearly every case, and the middle one is the usual answer.
| What the class stores | Written as | Choose it when |
|---|---|---|
| A stand-in for a reference | std::vector<std::reference_wrapper<const Client>> |
The link always exists and you need to keep several of them in a container |
| A pointer | const Revision* m_supersedes{} |
The link is optional, so nullptr can mean "not linked to anything" |
| A key that identifies the partner | int m_imoNumber{} |
The partner might not be in memory, or a pointer per object is more storage than you want to spend |
Pointers are the most common choice in practice: an association is normally implemented by having one object hold a pointer to the object it uses. Plain references cannot be stored in a std::vector, since they can be neither reassigned nor default constructed, which is why std::reference_wrapper from <functional> appears whenever a container of links is needed. A std::reference_wrapper behaves like a reference but is copyable and reassignable, and you reach the object it refers to by calling get() on it.
An association does not keep its partner alive. Whichever object holds the reference, pointer, or key is responsible for not using it after the partner has been destroyed. Declaring both objects in a scope that outlives every link between them is the simplest way to stay safe.
One Direction First
Start with the smaller version of the relationship: lawyers track their clients, and clients know nothing about lawyers. Lawyer stores a container of links; Client stores nothing extra at all.
#include <functional>
#include <iostream>
#include <string>
#include <string_view>
#include <vector>
class Client
{
private:
std::string m_fullName{};
public:
explicit Client(std::string_view fullName)
: m_fullName{ fullName }
{
}
const std::string& fullName() const { return m_fullName; }
};
class Lawyer
{
private:
std::string m_fullName{};
std::vector<std::reference_wrapper<const Client>> m_clients{};
public:
explicit Lawyer(std::string_view fullName)
: m_fullName{ fullName }
{
}
void addClient(const Client& client) { m_clients.push_back(client); }
void printCaseload() const
{
if (m_clients.empty())
{
std::cout << m_fullName << " carries an empty caseload\n";
return;
}
std::cout << m_fullName << " acts for:";
for (const auto& client : m_clients)
std::cout << ' ' << client.get().fullName();
std::cout << '\n';
}
};
int main()
{
Client priya{ "Priya" };
Client tomas{ "Tomas" };
Lawyer whitcombe{ "Whitcombe" };
Lawyer ferreira{ "Ferreira" };
whitcombe.addClient(priya);
whitcombe.addClient(tomas);
whitcombe.printCaseload();
ferreira.printCaseload();
return 0;
}
Whitcombe acts for: Priya Tomas
Ferreira carries an empty caseload
Note what is missing. Lawyer never constructs a Client and never destroys one; the two clients are created in main() and would still be there if every lawyer disappeared. client.get() is how the loop turns a stored std::reference_wrapper<const Client> back into the Client it refers to.
Adding the Return Link
Now suppose a client also needs to list the lawyers acting for them. Both classes gain a container, and that immediately creates two problems the one-way version did not have.
The first is a circular dependency: Lawyer mentions Client and Client mentions Lawyer, so whichever is written first refers to a type the compiler has not seen. A forward declaration of Client above Lawyer resolves it.
The second is consistency. Two containers now describe the same fact, and nothing stops them from disagreeing. The fix is to allow exactly one way in. Client::addLawyer() is private, so no outside code can call it; Lawyer::addClient() is public and is declared a friend of Client, so it can. Every link therefore goes through a single function that updates both sides in the same breath.
#include <functional>
#include <iostream>
#include <string>
#include <string_view>
#include <vector>
class Client;
class Lawyer
{
private:
std::string m_fullName{};
std::vector<std::reference_wrapper<const Client>> m_clients{};
public:
explicit Lawyer(std::string_view fullName)
: m_fullName{ fullName }
{
}
void addClient(Client& client);
const std::string& fullName() const { return m_fullName; }
void printCaseload() const;
};
class Client
{
private:
std::string m_fullName{};
std::vector<std::reference_wrapper<const Lawyer>> m_lawyers{};
void addLawyer(const Lawyer& lawyer) { m_lawyers.push_back(lawyer); }
public:
explicit Client(std::string_view fullName)
: m_fullName{ fullName }
{
}
const std::string& fullName() const { return m_fullName; }
void printRepresentation() const
{
if (m_lawyers.empty())
{
std::cout << m_fullName << " is currently unrepresented\n";
return;
}
std::cout << m_fullName << " retained:";
for (const auto& lawyer : m_lawyers)
std::cout << ' ' << lawyer.get().fullName();
std::cout << '\n';
}
friend void Lawyer::addClient(Client& client);
};
void Lawyer::addClient(Client& client)
{
m_clients.push_back(client);
client.addLawyer(*this);
}
void Lawyer::printCaseload() const
{
if (m_clients.empty())
{
std::cout << m_fullName << " carries an empty caseload\n";
return;
}
std::cout << m_fullName << " acts for:";
for (const auto& client : m_clients)
std::cout << ' ' << client.get().fullName();
std::cout << '\n';
}
int main()
{
Client priya{ "Priya" };
Client tomas{ "Tomas" };
Client adaeze{ "Adaeze" };
Lawyer whitcombe{ "Whitcombe" };
Lawyer ferreira{ "Ferreira" };
whitcombe.addClient(priya);
ferreira.addClient(priya);
ferreira.addClient(adaeze);
whitcombe.printCaseload();
ferreira.printCaseload();
priya.printRepresentation();
tomas.printRepresentation();
adaeze.printRepresentation();
return 0;
}
Whitcombe acts for: Priya
Ferreira acts for: Priya Adaeze
Priya retained: Whitcombe Ferreira
Tomas is currently unrepresented
Adaeze retained: Ferreira
Priya appears on two caseloads and lists two lawyers, which is exactly the "can belong to several at once" row of the table. Tomas has no lawyers, and neither he nor the lawyers are any less valid for it.
Two ordering details make the file compile. Lawyer::printCaseload() is only declared inside Lawyer and defined after Client, because it calls Client::fullName() and needs the complete type. Lawyer::addClient() is split the same way, because it calls Client::addLawyer().
The Cost of the Second Direction
Count what the return link added: a forward declaration, an access-control decision, a friend declaration, and one member function split into a declaration and an out-of-class definition. All of that exists purely to stop the two containers from drifting apart. Add a removeClient() and the same care has to be taken again, in the opposite direction.
Make an association unidirectional unless code on the other side genuinely needs to navigate back. A bidirectional association costs more to write, and every operation on it has to update two places to stay correct.
When Both Ends Are the Same Type
Nothing requires the two objects in an association to be different types. When a class associates with other objects of its own type, the relationship is called a reflexive association.
Document revisions are a natural example: each revision supersedes at most one earlier revision, and the earliest one supersedes nothing. That "at most one" is precisely the case where a pointer beats a reference, since nullptr carries the meaning "there is no earlier revision".
#include <iostream>
#include <string>
#include <string_view>
class Revision
{
private:
std::string m_label{};
const Revision* m_supersedes{};
public:
explicit Revision(std::string_view label, const Revision* supersedes = nullptr)
: m_label{ label }, m_supersedes{ supersedes }
{
}
const std::string& label() const { return m_label; }
const Revision* supersedes() const { return m_supersedes; }
};
int main()
{
const Revision draft{ "spec-r1" };
const Revision reviewed{ "spec-r2", &draft };
const Revision approved{ "spec-r3", &reviewed };
for (const Revision* current{ &approved }; current != nullptr; current = current->supersedes())
{
std::cout << current->label();
if (current->supersedes() != nullptr)
std::cout << " supersedes ";
}
std::cout << '\n';
return 0;
}
spec-r3 supersedes spec-r2 supersedes spec-r1
A reflexive association chains. Following m_supersedes repeatedly walks backwards through the history until the pointer is null. Allow more than one link per object and the chain becomes a tree or a graph, which is how dependency lists, org charts, and scene hierarchies are usually modelled.
Linking Through a Key Instead of an Address
Every example so far stored the address of the partner object. An association does not require that. Any data that lets you find the other object will do, which means an integer, a string, or a database key is a legitimate implementation.
Ships carry a permanent IMO number. A port call can record which ship is expected simply by writing down that number, with no Vessel pointer anywhere in PortCall:
#include <iostream>
#include <string>
#include <string_view>
#include <vector>
class Vessel
{
private:
std::string m_vesselName{};
int m_imoNumber{};
public:
Vessel(std::string_view vesselName, int imoNumber)
: m_vesselName{ vesselName }, m_imoNumber{ imoNumber }
{
}
const std::string& vesselName() const { return m_vesselName; }
int imoNumber() const { return m_imoNumber; }
};
class PortCall
{
private:
std::string m_berth{};
int m_imoNumber{};
public:
PortCall(std::string_view berth, int imoNumber)
: m_berth{ berth }, m_imoNumber{ imoNumber }
{
}
const std::string& berth() const { return m_berth; }
int imoNumber() const { return m_imoNumber; }
};
const Vessel* lookup(const std::vector<Vessel>& registry, int imoNumber)
{
for (const Vessel& vessel : registry)
{
if (vessel.imoNumber() == imoNumber)
return &vessel;
}
return nullptr;
}
void announceArrival(const std::vector<Vessel>& registry, const PortCall& portCall)
{
const Vessel* vessel{ lookup(registry, portCall.imoNumber()) };
if (vessel == nullptr)
{
std::cout << portCall.berth() << ": IMO " << portCall.imoNumber() << " is not on the register\n";
return;
}
std::cout << portCall.berth() << ": " << vessel->vesselName() << '\n';
}
int main()
{
const std::vector<Vessel> registry {
Vessel{ "Kestrel Trader", 9174532 },
Vessel{ "Ambleside Star", 9268841 }
};
const PortCall morning { "Berth 4", 9268841 };
const PortCall evening { "Berth 7", 9405117 };
announceArrival(registry, morning);
announceArrival(registry, evening);
return 0;
}
Berth 4: Ambleside Star
Berth 7: IMO 9405117 is not on the register
This is called an indirect association: the link is real, but it is a value to be resolved rather than an address to be dereferenced. Resolution has a price, and the second port call shows the other consequence: a key can name something that does not exist, so every lookup has to handle failure.
Why a Key Can Beat a Pointer
For a program of this size, storing a Vessel* in PortCall would be simpler and faster than scanning a vector on every announcement. Keys earn their place for two reasons that have nothing to do with speed.
The first is that a key can name something that is not in memory. A pointer is only meaningful while its target exists in this process; an IMO number stays meaningful when the vessel record lives in a file, in a database, or on another machine, and can be loaded on demand when the lookup happens. Programs that cannot hold their whole dataset at once have no choice but to link by key.
The second is size. A pointer occupies four or eight bytes on typical platforms. If a program holds millions of links and the number of distinct partners is small, an 8-bit or 16-bit key stores the same association in a fraction of the space. Keys also survive being written to disk and read back, whereas a saved pointer is meaningless the moment the program restarts.
Looking Forward
Association is the third of the relationship types in this chapter, and the loosest one that still involves storing something about the partner. The next lesson covers dependencies, where a class uses another class briefly, usually through a function parameter or local variable, and stores nothing at all. Container classes then return to the part-whole end of the spectrum with types whose entire job is holding parts.
Key Terminology
- Association: A relationship between two otherwise unrelated objects, where neither manages the other's lifetime and either may be linked to many of the other
- Uses-a: The verb for association, contrasted with composition's part-of and aggregation's has-a
- Unidirectional association: Only one of the two objects stores a link to the other
- Bidirectional association: Both objects store links to each other, so both must be kept consistent
- Reflexive association: An association whose two ends are objects of the same type
- Indirect association: An association implemented with a key or identifier rather than a reference or pointer, resolved by a lookup when the partner is needed
std::reference_wrapper: A copyable, reassignable stand-in for a reference from<functional>, needed to store links in a container;get()returns the object it refers to- Forward declaration: A declaration that names a class without defining it, which is what breaks the circular dependency between two mutually associated classes
Summary
- An association links two objects that are not parts of each other; neither owns the other and neither controls when the other is destroyed
- Association models a uses-a relationship, and an associated object may be linked from many objects at once
- Alone among the relationship types, an association can be unidirectional or bidirectional, because there is no whole and no part to fix a direction
- Most associations are implemented with pointers; use
std::reference_wrapperwhen the links must live in a container, and a pointer when the link is optional andnullptrmeans "none" - An association keeps nothing alive, so the linked objects must outlive every link to them
- A bidirectional association needs a forward declaration to break the circular dependency, and a single entry point to keep both sides consistent
- Making the second class's add function private and friending the first class's add function enforces that entry point, which is why
Client::addLawyer()is private andLawyer::addClient()is its friend - Prefer unidirectional associations; each extra direction is more code to write and another place to keep in step
- A reflexive association links objects of the same type, which chains into histories, hierarchies, and dependency graphs
- An indirect association stores a key instead of an address and resolves it by lookup, which costs time and forces you to handle a missing partner
- Keys are worth that cost when the partner may not be in memory, when links must survive being saved and reloaded, or when a small integer is cheaper to store than a pointer
How did you find this lesson?
Your rating helps us improve the content.
Create an account to track your progress and access interactive exercises. Already have one? Sign in.
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!