Object-Oriented Programming (C++)

Chapter 5
Object Relationships and Composition

Bachelors Program in Computer Science Engineering
Object-Oriented Programming (C++)   •   Bachelor

Object Relationships and Composition

Introduction

Object-oriented programs become useful when objects collaborate rather than merely store data. A Customer places an order, an Order contains line items, a service uses a repository, and a university associates students with courses. These connections are modeled as object relationships.

A relationship has two distinct dimensions:

  1. Structural meaning — what relationship exists between classes or objects.
  2. Implementation meaning — how references, values, pointers, smart pointers, containers, and functions realize that relationship in C++.

The central design question is ownership: which object controls another object's lifetime? A strong design makes ownership explicit, minimizes unnecessary coupling, and gives each object a clear responsibility.

Learning Objectives

After completing this chapter, you should be able to:

  • distinguish association, dependency, aggregation, and composition;
  • identify ownership, multiplicity, navigability, and lifetime constraints;
  • implement collaborations using values, references, pointers, and smart pointers;
  • select appropriate relationship structures in C++;
  • interpret UML class, object, and sequence diagrams;
  • apply composition-over-inheritance and composition-over-global-state principles;
  • evaluate relationship designs for coupling, substitutability, testability, and safety.

1. Relationship Vocabulary

A class relationship describes a structural connection between classes. An object collaboration is a runtime interaction between particular objects. UML commonly records the following properties.

PropertyMeaningTypical C++ representation
AssociationObjects know or communicate with one anotherreference, pointer, ID, or handle
DependencyOne element temporarily uses anotherparameter, local variable, return type
AggregationA whole has parts, but parts can exist independentlynon-owning references/pointers or shared ownership, depending on policy
CompositionA whole exclusively owns parts whose lifetime depends on itvalue members, std::unique_ptr, owning container
MultiplicityNumber of related objectsone member, std::vector, optional, map
NavigabilityDirection in which an object can reach anotherstored member or parameter
OwnershipResponsibility for lifetime and destructionvalue, unique_ptr, carefully justified shared_ptr

Multiplicity such as 1, 0..1, 0.., and 1.. expresses constraints, not automatically enforced behavior. C++ types can enforce some constraints: std::optional<T> suggests zero-or-one, while a nonempty collection requires an invariant or a specialized type.

2. Association and Dependency

Association

An association is a relatively stable connection. Consider an employee assigned to a department. If the department is managed elsewhere, the employee need not own it:

#include <string>

class Department;

class Employee {
public:
    Employee(std::string name, Department& department)
        : name_(std::move(name)), department_(&department) {}

    Department& department() const { return *department_; }

private:
    std::string name_;
    Department* department_; // non-owning; Department must outlive Employee
};

The example requires <utility> for std::move; therefore an independently compilable version includes both headers:

#include <string>
#include <utility>

A raw pointer does not by itself communicate whether it owns an object. In modern C++, a reference or raw pointer is often appropriate for a non-owning relationship, but the lifetime contract must be documented or enforced by architecture. A stable identifier can be safer when objects are stored in databases or independent subsystems.

Dependency

A dependency is usually temporary: a method receives an object, calls it, and does not retain it.

#include <string>

class Logger {
public:
    void write(const std::string& message);
};

class ReportService {
public:
    void generate(Logger& logger) {
        logger.write("Report generated");
    }
};

ReportService depends on Logger during generate, but does not own or permanently store the logger. Dependency injection through a constructor or method improves testing because a test double can be supplied.

3. Aggregation and Composition

The distinction is fundamentally about lifetime and ownership.

  • In aggregation, the whole refers to parts that may exist independently and may be shared or transferred separately. UML traditionally uses a hollow diamond at the whole end.
  • In composition, the whole is the exclusive owner. Parts normally have no meaningful independent lifetime, and UML uses a filled diamond at the whole end.

UML diamonds are not triangles. The diamond is placed next to the whole, not next to the part.

UML comparison of aggregation and composition Whole–part relationships Team (whole) Player (part) aggregation Parts may outlive the whole House (whole) Room (part) composition Parts are owned by the whole
UML comparison of aggregation and composition
Figure: UML comparison of aggregation and composition

Figure: Correct UML notation: hollow and filled diamonds are located at the whole end.

Composition with value members

Value members are the clearest implementation of composition when the part is small, mandatory, and has the same lifetime as the whole.

#include <string>
#include <utility>

class Address {
public:
    Address(std::string city, std::string country)
        : city_(std::move(city)), country_(std::move(country)) {}
private:
    std::string city_;
    std::string country_;
};

class Customer {
public:
    Customer(std::string name, Address address)
        : name_(std::move(name)), address_(std::move(address)) {}
private:
    std::string name_;
    Address address_; // Customer owns its Address
};

The address is constructed and destroyed as part of the customer. This avoids a null state, separate allocation, and ambiguous ownership.

Composition with a container

#include <stdexcept>
#include <string>
#include <utility>
#include <vector>

class LineItem {
public:
    LineItem(std::string sku, int quantity)
        : sku_(std::move(sku)), quantity_(quantity) {
        if (quantity <= 0) throw std::invalid_argument("quantity must be positive");
    }
private:
    std::string sku_;
    int quantity_;
};

class Order {
public:
    void addItem(std::string sku, int quantity) {
        items_.emplace_back(std::move(sku), quantity);
    }
    const std::vector<LineItem>& items() const noexcept { return items_; }
private:
    std::vector<LineItem> items_;
};

The headers are essential: <vector> declares std::vector, <string> declares std::string, <utility> declares std::move, and <stdexcept> declares std::invalid_argument. The order owns its line items; destroying the order destroys the vector and its elements.

Dynamic composition

Use std::unique_ptr when parts are polymorphic, optional, expensive to construct, or have a variable concrete type.

#include <memory>
#include <utility>

class PaymentMethod {
public:
    virtual ~PaymentMethod() = default;
    virtual void pay() = 0;
};

class Checkout {
public:
    explicit Checkout(std::unique_ptr<PaymentMethod> method)
        : method_(std::move(method)) {}
private:
    std::unique_ptr<PaymentMethod> method_;
};

Checkout has exclusive ownership. Copying is disabled implicitly because unique_ptr is non-copyable; moving can transfer ownership. A virtual destructor is required when deleting derived objects through a base pointer.

std::shared_ptr represents shared ownership, not merely “a pointer used by several classes.” It introduces reference-counting cost and can create cycles. Use it only when independent owners genuinely share lifetime. For non-owning observation of a shared object, use a raw pointer/reference under a clear lifetime contract or std::weak_ptr when the object is managed by shared_ptr.

4. Collaboration and UML Diagrams

Class diagrams

A class diagram describes types, members, multiplicities, and relationships. A line with an open arrow commonly indicates navigability; a dashed arrow indicates dependency. UML notation varies slightly by tool, so the legend should be stated in professional documentation.

For example, Order compositionally owns many LineItem objects. The filled diamond is at Order, and the multiplicity at LineItem may be 0..*.

Object diagrams

An object diagram shows instances at a particular time, such as order17:Order containing item1:LineItem. Unlike class diagrams, it records actual values and links. It is useful for explaining aliasing, ownership, and runtime state.

Sequence diagrams

A sequence diagram emphasizes time and message order. A checkout might involve:

  1. Client calls Checkout::complete.
  2. Checkout calls PaymentMethod::pay.
  3. Checkout calls ReceiptPrinter::print.
  4. Control returns to Client.

A sequence diagram should distinguish a call from a return and should not imply ownership merely because one object sends a message to another.

5. Selecting a Relationship Structure

Use the following decision process:

  1. Does the object need to retain the collaborator? If no, use a parameter or local dependency.
  2. Does the object own the collaborator? If yes, prefer a value member or std::unique_ptr.
  3. Can the part exist independently? If yes, model association or aggregation rather than composition.
  4. Is the relationship optional? Use std::optional, a nullable pointer with an explicit contract, or a state object.
  5. Is polymorphism required? Use an abstract interface and an owning unique_ptr or a non-owning reference as appropriate.
  6. Are there multiple owners? Confirm that shared lifetime is genuinely required before selecting shared_ptr.
  7. What is the navigation direction? Store only the direction required by use cases; bidirectional links increase consistency obligations.

Prefer composition because it localizes invariants, reduces global coupling, and makes lifetime deterministic. Do not use composition merely to hide a relationship that is conceptually independent.

6. Interface-Based Substitution: A Correct Repository Design

A concrete repository cannot truthfully promise substitution unless it exposes a polymorphic interface, uses templates, or accepts a callable abstraction. The following design uses an interface and virtual dispatch.

#include <memory>
#include <string>
#include <utility>

class User {
public:
    explicit User(std::string name) : name_(std::move(name)) {}
private:
    std::string name_;
};

class IUserRepository {
public:
    virtual ~IUserRepository() = default;
    virtual void save(const User& user) = 0;
};

class SqlUserRepository final : public IUserRepository {
public:
    void save(const User& user) override;
};

class MemoryUserRepository final : public IUserRepository {
public:
    void save(const User& user) override;
};

class UserService {
public:
    explicit UserService(std::unique_ptr<IUserRepository> repository)
        : repository_(std::move(repository)) {}

    void registerUser(User user) { repository_->save(user); }
private:
    std::unique_ptr<IUserRepository> repository_;
};

UserService owns an abstraction, not a concrete database implementation. Production code can supply SqlUserRepository; tests can supply MemoryUserRepository. This is dependency inversion and composition: the service is composed with a repository implementation at construction time.

7. Worked Design Example: University Enrollment

Background

This illustrative case models a university enrollment subsystem. A Student can enroll in many Course objects. A course exists independently of any one enrollment, so student–course is an association, not composition. An Enrollment records the relationship and may be compositionally owned by a StudentRecord or enrollment service, depending on the system boundary.

Problem analysis

The design must answer:

  • Can a course exist without a student? Yes.
  • Can the same course be associated with many students? Yes.
  • Should a student own the course object? No.
  • Does enrollment have its own data, such as date and grade? Yes.
  • Who owns enrollment records? The chosen aggregate boundary must define this explicitly.

A practical model uses stable course IDs and a repository for course data. A student record owns its enrollment records, while each enrollment stores a course ID rather than owning a course.

#include <string>
#include <utility>
#include <vector>

struct Enrollment {
    std::string courseCode;
    int year;
};

class StudentRecord {
public:
    explicit StudentRecord(std::string id) : id_(std::move(id)) {}

    void enroll(std::string courseCode, int year) {
        enrollments_.push_back({std::move(courseCode), year});
    }

    const std::vector<Enrollment>& enrollments() const noexcept {
        return enrollments_;
    }
private:
    std::string id_;
    std::vector<Enrollment> enrollments_; // composition
};

Outcome and learning points

The model avoids duplicate course objects and avoids dangling pointers caused by storing direct references to courses whose repository lifetime is uncertain. It also makes the student record's enrollment history an explicit owned collection. If the domain requires live course behavior, a course service can be injected as a dependency rather than owned by each student.

8. Invariants, Aliasing, and Exception Safety

Relationships impose invariants. If an order must contain at least one line item before submission, the submit operation should check that condition. If a parent stores a non-owning pointer, the parent must never outlive the target. If two objects maintain reciprocal links, every update must preserve both directions or use one authoritative direction.

Composition supports strong lifetime guarantees through RAII: resource acquisition and release are tied to object lifetime. However, moving a composed object can invalidate pointers or references to its members if members are stored by value and the object is moved. APIs should document reference stability, and callers should avoid retaining addresses across operations that may reallocate a container.

For std::vector, adding elements can reallocate storage and invalidate pointers, references, and iterators to elements. If stable addresses are required, consider std::list, std::deque with its documented guarantees, heap allocation through unique_ptr, or an ID-based design—but choose based on measured requirements, not assumption.

9. Common Errors and Professional Practice

  • Using a raw owning pointer: replace it with a value or unique_ptr.
  • Calling every relationship aggregation: aggregation requires independent part lifetime; ordinary association is often more accurate.
  • Using shared_ptr by default: shared ownership should be a domain requirement.
  • Creating bidirectional links without a consistency policy: designate an owner or authoritative source.
  • Returning mutable internal collections: expose const views or controlled operations.
  • Claiming substitutability without abstraction: use an interface with virtual functions, templates, or another explicit polymorphic mechanism.
  • Omitting headers: every compilable excerpt should include the declarations it uses.
  • Confusing UML appearance: diamonds are diamonds, placed at the whole end; filled means composition and hollow means aggregation.

10. Contemporary and Ethical Considerations

Modern C++ emphasizes RAII, smart pointers, move semantics, concepts, and modules to make ownership more explicit and reduce memory errors. Static analyzers and sanitizers can detect leaks, use-after-free, and some lifetime defects, but they cannot determine whether a domain relationship has been modeled ethically or conceptually correctly.

In systems holding personal data, relationship design also affects privacy. A globally shared object can unintentionally broaden access; dependency injection and narrow interfaces support least privilege and testability. Audit records should have deliberate retention and ownership rules rather than being retained indefinitely through accidental object graphs.

Chapter Summary

Object relationships express how classes and runtime objects collaborate. Association represents a stable connection, dependency represents temporary use, aggregation represents a whole whose parts remain independent, and composition represents exclusive ownership and dependent lifetime. UML communicates these meanings through lines, arrows, multiplicities, and correctly placed hollow or filled diamonds. In C++, values and owning containers are usually the simplest form of composition; std::unique_ptr supports polymorphic or optional ownership; shared_ptr is reserved for genuine shared lifetime. References, pointers, and identifiers can represent non-owning association, but lifetime contracts must be explicit. Interface-based dependency injection enables valid substitution and improves testing. Good designs make ownership, navigation, multiplicity, invariants, and lifetime visible in both diagrams and code.

Key Terms

Association: A structural connection between objects.

Aggregation: A whole–part relationship in which parts can exist independently.

Composition: Exclusive whole–part ownership with dependent part lifetime.

Dependency: Temporary use of another type or object.

Multiplicity: The permitted number of related instances.

Navigability: The direction in which one object can reach another.

Ownership: Responsibility for an object's lifetime and destruction.

Whole: The aggregate or composite object in a whole–part relationship.

Part: An object participating in a whole–part relationship.

Collaboration: Runtime interaction among objects.

UML class diagram: A diagram of types and their structural relationships.

UML object diagram: A snapshot of object instances and links.

Sequence diagram: A diagram showing messages ordered in time.

RAII: Resource management tied to object lifetime.

std::unique_ptr: A C++ exclusive-ownership smart pointer.

std::shared_ptr: A reference-counted shared-ownership smart pointer.

Dependency injection: Supplying collaborators from outside an object.

Aliasing: Multiple access paths referring to the same object.

Invariant: A condition that must remain true for a valid object or relationship.

Lifetime contract: The stated rule governing how long a referenced object remains valid.

Review Questions

Short Answer Questions

  1. What is the difference between association and dependency?
  2. Why is composition usually represented with a filled diamond?
  3. When is a value member preferable to std::unique_ptr?
  4. Why can uncontrolled use of std::shared_ptr be harmful?
  5. What information does a UML object diagram provide that a class diagram does not?

Long Answer Questions

  1. Compare aggregation and composition in UML and C++, including ownership and lifetime.
  2. Explain how dependency injection improves substitutability and testing.
  3. Design and justify the relationships among Order, LineItem, Product, and PaymentMethod.
  4. Discuss the dangers of bidirectional associations and propose alternatives.
  5. Explain how RAII and smart pointers contribute to relationship safety in modern C++.

Analytical / Application Questions

  1. A Library stores Book objects, but books can be loaned, sold, or transferred to another library. Decide whether the relationship is composition, aggregation, or association, and justify the C++ representation.
  2. A service stores a raw pointer to a database connection created in main. Analyze at least two lifetime failures and redesign the ownership structure.
  3. A testing team says a concrete, non-virtual Repository supports substitute implementations. Evaluate the claim and provide two technically valid redesigns.

Multiple Choice Questions with Answers

Question 1

In UML, where is the composition diamond placed?

A. At the whole end

B. At the part end

C. At the midpoint of the association

D. On the inheritance arrow

Correct Answer: A. At the whole end

Question 2

Which C++ type most directly expresses exclusive dynamic ownership?

A. std::weak_ptr<T>

B. std::unique_ptr<T>

C. T&

D. std::string

Correct Answer: B. std::unique_ptr<T>

Question 3

An Order owns its LineItem objects, and the items have no meaningful independent lifetime. Which relationship is most appropriate?

A. Dependency

B. Association

C. Composition

D. Unrelated classes

Correct Answer: C. Composition

Question 4

What is the main role of a virtual destructor in an interface used polymorphically?

A. It prevents all copying

B. It enables correct destruction through a base pointer

C. It makes every method virtual automatically

D. It guarantees thread safety

Correct Answer: B. It enables correct destruction through a base pointer

Question 5

Which situation best describes a dependency?

A. A method temporarily receives a logger and uses it

B. A house exclusively owns its rooms

C. A course exists independently of students

D. A vector owns its elements

Correct Answer: A. A method temporarily receives a logger and uses it

Question 6

Why is a concrete repository with a non-virtual method insufficient for runtime substitution?

A. It cannot contain data members

B. It provides no polymorphic dispatch contract

C. It must always be a singleton

D. It cannot be included in a header

Correct Answer: B. It provides no polymorphic dispatch contract

Question 7

Which header is required for std::vector?

A. <memory>

B. <vector>

C. <utility>

D. <stdexcept>

Correct Answer: B. <vector>

Question 8

A course can exist independently and be associated with many students. Which model is most defensible?

A. Each student compositionally owns a course copy

B. Student–course association, possibly represented by an enrollment object

C. Course must be a private nested class of Student

D. Course must be destroyed when any student leaves

Correct Answer: B. Student–course association, possibly represented by an enrollment object

Question 9

What can invalidate references to elements of a std::vector?

A. A const read of an element

B. Vector reallocation during growth

C. Calling size()

D. Constructing an empty vector

Correct Answer: B. Vector reallocation during growth

Question 10

Which statement about std::shared_ptr is correct?

A. It always means non-owning observation

B. It expresses shared ownership and can participate in cycles

C. It cannot manage polymorphic objects

D. It is the preferred replacement for every reference

Correct Answer: B. It expresses shared ownership and can participate in cycles

Object-Oriented Programming (C++)
Chapter 5 of 15