13.7.1. Aggregation With Smart Pointers

Time: 00:04:23 | Download: Large, Large (CC), Small | Streaming, Streaming (CC) | Slides (PowerPoint)
Review

Significantly, smart pointers don't alter, limit, or enhance the semantics of the class relationships implemented with pointers. Aggregation is a relationship between a whole and part class, and C++ programs implement it with pointers. Used to implement aggregation, smart pointer's primary advantage is making the implementation more robust by identifying some errors at compile time, simplifying or eliminating pointer-management functions, and preventing memory leaks. Contrasting the raw pointer implementation (introduced in Chapter 10) with smart pointers is informative.

Review: Aggregation With Raw Pointers

class Whole
{
    private:
        Part* part = nullptr;
};
 
Whole() {}
Whole(Part* p) : part(p) {}
 
 
 
 
~Whole()
{
    if (part != nullptr)
        delete part;
}
 
void set_part(Part* p)
{
    if (part != nullptr)
        delete part;
    part = p;
}
(a)(b)(c)(d)
Aggregation without smart pointers. Although standard pointers (called raw here) are error-prone and otherwise challenging to use, they are powerful and common programming feature. Once debugged, constructors, destructors, and setter functions ease the challenges but don't eliminate all errors.
  1. A partial Whole class illustrating the member variable implementing aggregation.
  2. Programs can build the initial aggregation relationship with a constructor or a setter function. The default constructor is necessary for the latter choice.
  3. A destructor is essential for effectively managing aggregation, deallocating the part when the program destroys the whole. However, if multiple whole objects share the part, the program must establish a protocol specifying which whole is responsible for deallocating the part. Note: the if-test is probably obsolete but reflects my experience with early C++ compilers.
  4. A setter function can build the initial aggregation relationship, but a setter is always necessary to update it. The if-test is necessary here because the function may replace an existing part object.

Simple Aggregation With Smart Pointers

Although not a panacea, smart pointers simplify setter functions and often eliminate the need for destructors. In the context of aggregation, they provide these benefits by wrapping raw the pointers with objects that track or count how many whole objects use (i.e., refer or point to) a part and automatically deallocate it when it is no longer used (i.e., when the count goes to zero).

A UML class diagram illustrating an aggregation relationship between a Whole and Part class.
int main()
{
    Whole whole("Widget");
    whole.display();
    whole.set_part(new Part("Bolt"));
    whole.display();

    return 0;
}
(a)(b)
class Part
{
    private:
        string name;

    public:
        Part(string n) : name(n) {}
        ~Part() { cout << "Part dtor: " << name << endl; }
        void display() { cout << name << endl; }
};
 
class Whole
{
    private:
        shared_ptr<Part> part;

    public:
        Whole(string n) { part = make_shared<Part>(n); }
        ~Whole() { cout << "Whole dtor\n"; }
        void set_part(Part* n) { part.reset(n); }
        void display() { cout << "Whole: "; part->display(); }
};
(c)(d)
Implementing simple aggregation with smart pointers. The program in this example implements a simple aggregation relationship between two objects, a Whole and a Part. It replaces the raw pointers used by previous examples with shared smart pointers and documents the function call sequence with the display and destructor functions.
  1. The UML class diagram with an aggregation relationship between the Whole and Part classes.
  2. The driver builds the initial aggregation relationship with a constructor when it instantiates a Whole object, passing to it the data necessary to build the Part. The set_part function replaces the previous Part with a new one.
  3. Aggregation is a oneway relationship, so the Part has no "knowledge" of the Whole or its pointer. The destructor is logically unnecessary, only announcing when it runs.
  4. Managing aggregation in the Whole class:
    • The constructor creates a shared_ptr (pink), building an aggregation relationship. When the program destroys the Whole object - either with delete or when it goes out of scope - the shared_ptr automatically destroys the Part if its reference count (i.e., use_count) is 1.
    • Smart pointers overload the reset function, and this version replaces the existing part member with its argument, n (blue). If the existing part is unused after the replacement, the shared pointer destroys it.
Try matching the following output with the program statements, recalling that the destructor calls are transparent.
Whole: Widget
Part dtor: Widget
Whole: Bolt
Whole dtor
Part dtor: Bolt

Shared Aggregation With Smart Pointers

Aggregation's weak binding strength allows the whole to share its part with other classes in a program. When two whole classes share a part, its ownership is a fundamental concern. Chapter 10 illustrated this problem by imagining a warehouse with many engines and sharing one with a race car. We begin the smart pointer version by limiting the warehouse to a single engine. Formally, a Car "has an" Engine and a Warehouse "has an" Engine, with the phrase "has a" indicating an aggregation relationship. Now, imagine that the program destroys one whole object (either a Car or Warehouse). Should the destroyed object's destructor also destroy the aggregated Engine object? Alternatively, imagine that the program replaces the Engine in one of the whole classes. What should the program do with the replaced Engine object?

UML Class DiagramAbstract Memory Representation
A UML class diagram illustrating a Car class with aggregation relationships with Transmission and Engine classes. A Warehouse class has an aggregation relationship with the Engine, sharing it with the Car. An abstract representation of four objects in memory: a Car, Engine, Transmission, and Warehouse. The Car has an exclusive whole-part relationship with the Transmission but shares the Engine with the Warehouse.
Implementing shared aggregation with smart pointers. A Car, the whole class, has an Engine and a Transmission, the parts. The Car shares its Engine with a Warehouse but retains exclusive ownership of its Transmission. An abstract representation of the instantiated objects illustrates the pointers binding the objects together. The example implements the pointers from the Car and Warehouse to the Engine with shared pointers and between the Car and Transmission with a unique pointer.
class Transmission
{
    private:
        string type;

    public:
        Transmission(string t) : type(t) {}
        ~Transmission() { cout << "Transmission dtor" << endl; }
        friend ostream& operator<<(ostream& out, Transmission& me)
        {
            out << "Transmission: " << me.type;
            return out;
        }
};
class Engine
{
    private:
        int size;

    public:
        Engine(int s) : size(s) {}
        ~Engine() { cout << "Engine dtor" << endl; }
        friend ostream& operator<<(ostream& out, Engine& me)
        {
            out << "Engine: " << me.size;
            return out;
        }
};
The part classes. Typical of part classes, Engine and Transmission don't "know about" any of the other classes in the program. The program includes them here to help explain the program output.
class Car
{
    private:
        unique_ptr<Transmission>  trans;
        shared_ptr<Engine>        engine;

    public:
        Car(string t) : trans(make_unique<Transmission>(t)) {}
        ~Car() { cout << "Car dtor" << endl; }
        void set_engine(shared_ptr<Engine> e) { engine = e; }
        friend ostream& operator<<(ostream& out, Car& me)
        {
            out << *me.engine << " " << *me.trans.get();
            return out;
        }
};
class Warehouse
{
    private:
        shared_ptr<Engine>  engine;

    public:
        ~Warehouse() { cout << "Warehouse dtor" << endl; }
        void set_engine(shared_ptr<Engine> e) { engine = e; }
        friend ostream& operator<<(ostream& out, Warehouse& me)
        {
            out << *me.engine;
            return out;
        }
};
 
 
The whole classes. Smart pointers demonstrate their value in the whole classes, Car and Warehouse, by eliminating the need for destructors and simplifying the setter functions. Compare the raw pointer versions in Figure 1 with the corresponding functions illustrated here. The Car and Warehouse destructors don't affect aggregation: the shared pointers manage the shared Engine object, only destroying it when both whole objects no longer use it. Furthermore, the shared pointers implement a simple "ownership" protocol: the last whole object using the part destroys it. Smart pointers also simplify the setter functions by eliminating the need for an if-test: if the setters replace an existing part, the shared pointers destroy it when the whole objects no longer use it.
int main()
{
    Car			c("Automatic");
    Warehouse		w;
    shared_ptr<Engine>	e = make_shared<Engine>(440);

    c.set_engine(e);
    w.set_engine(e);

    cout << "(1) Engine: " << *e << endl;
    cout << "(2) Car: " << c << endl;
    cout << "(3) Warehouse: " << w << endl << endl;

    e = make_shared<Engine>(380);
    //e.reset(new Engine(380));	// alternative
    c.set_engine(e);
    w.set_engine(e);

    cout << "(4) Engine: " << *e << endl;
    cout << "(5) Car: " << c << endl;
    cout << "(6) Warehouse: " << w << endl << endl;

    cout << "Destructor messages follow:" << endl;

    return 0;
}
(1) Engine: Engine: 440
(2) Car: Engine: 440 Transmission: Automatic
(3) Warehouse: Engine: 440

Engine dtor: 440
(4) Engine: Engine: 380
(5) Car: Engine: 380 Transmission: Automatic
(6) Warehouse: Engine: 380

Destructor messages follow:
Warehouse dtor
Car dtor
Engine dtor: 380
Transmission dtor
Program output
Implementing shared aggregation with smart pointers. The simple driver creates a Car, Warehouse, and Engine. The Car constructor also creates a Transmission. The set_engine functions in the whole classes establish the initial aggregation relationships, sharing the Engine object. The three cout statements illustrate the current state of each object. Note that the asterisk in statements (1) and (4) is the dereference operator overloaded in the shared_ptr class.

The program creates a new Engine object and installs it in the whole classes, leaving the previous Engine unused and ready for destruction. The second group of cout statements demonstrates the objects' new state. The program's termination triggers the destructors in the remaining objects.

One-To-Many Aggregation With Smart Pointers And A Vector

The original Car-Warehouse problem imagined a well-funded race car with many replacement engines stored in a warehouse. The outlined solution used an array of Engine pointers, accommodating a maximum of ten spare engines. Replacing the array with a vector accrues several advantages to the solution and also demonstrates another STL class:

  1. Vectors grow dynamically, accommodating any practical number of elements
  2. Vectors track their size as they grow, and client programs can retrieve it when needed
  3. Implementing a one-to-many aggregation relationship with a vector demonstrates the vector class, an iterator, and the nesting syntax necessary when using an STL container with smart pointers
  4. Revisits a previous example implementing multiple aggregation with a vector

We convert the previous one-to-one aggregation to one-to-many by modifying the Warehouse class and the driver program, capitalizing and building on prior experience.

class Warehouse
{
    private:
        vector<shared_ptr<Engine>> engines;						// (a)

    public:
        ~Warehouse() { cout << "Warehouse dtor" << endl; }

        void add_engine(shared_ptr<Engine> e) { engines.push_back(e); }			// (b)
        shared_ptr<Engine> get_engine(int index) { return engines[index]; }		// (c)

        void display(int index) { engines[index].get()->display(); }			// (d)
        friend ostream& operator<<(ostream& out, Warehouse& me)
        {
            vector<shared_ptr<Engine>>::iterator i = me.engines.begin();		// (e)
            while (i != me.engines.end())
                out << "\t" << **i++ << endl;						// (f)
            return out;
        }
};
The one-to-many Warehouse class. The updated Warehouse class can manage multiple Engine objects by storing them in a vector container.
  1. The STL vector container stores shared pointers configured to manage Engine objects. Note the nested angle brackets.
  2. The push_back function adds elements at the vector's end.
  3. The get_engine function returns the shared pointer (managing an Engine object) at the index position in the vector.
  4. Accesses the shared pointer at the index position, get returns the associated Engine, and the arrow operator calls its display function.
  5. begin and end create iterators at the vector's beginning and end. The while-loop iterates through all of the vector's elements
  6. The expression **i++ consists of three operators. The auto-increment operator has the highest precedence, and the indirection or dereference operators are right associative. Grouping parentheses illustrate the order of operation: *(*(i++)). The post-increment operator, +‍+, advances the iterator to the next vector element, but only after "using" it. The first dereference operation, *i, returns a shared pointer. The second dereferences the shared pointer, returning an Engine object. Together, the auto increment operator and while-loop iterate through the vector's elements.
#include "Car.h"
#include "Engine.h"
#include "Warehouse.h"
#include <iostream>
#include <memory>
using namespace std;

int main()
{
    Car          c("Automatic");
    Warehouse    w;

    w.add_engine(make_shared<Engine>(454));
    w.add_engine(make_shared<Engine>(440));
    w.add_engine(make_shared<Engine>(429));
    w.add_engine(make_shared<Engine>(427));
    w.add_engine(make_shared<Engine>(426));
    w.add_engine(make_shared<Engine>(380));
    w.add_engine(make_shared<Engine>(352));

    c.set_engine(w.get_engine(1));

    cout << "(1) Car: " << c << endl;
    cout << "(2) Warehouse:\n" << w << endl;

    c.set_engine(w.get_engine(5));

    cout << "(3) Car: " << c << endl;
    cout << "(4) Warehouse:\n" << w << endl;

    cout << "Destructor messages follow:" << endl;

    return 0;
}
(1) Car: Engine: 440 Transmission: Automatic
(2) Warehouse:
	Engine: 454
	Engine: 440
	Engine: 429
	Engine: 427
	Engine: 426
	Engine: 380
	Engine: 352

(3) Car: Engine: 380 Transmission: Automatic
(4) Warehouse:
	Engine: 454
	Engine: 440
	Engine: 429
	Engine: 427
	Engine: 426
	Engine: 380
	Engine: 352

Destructor messages follow:
Warehouse dtor
Engine dtor: 454
Engine dtor: 440
Engine dtor: 429
Engine dtor: 427
Engine dtor: 426
Engine dtor: 352
Car dtor
Engine dtor: 380
Transmission dtor
 
 
 
The one-to-many driver and its output.

Aggregation With Smart Pointers Code

ViewDownloadComments
aggregation.cpp aggregation.cpp A program implementing simple aggregation with smart pointers
Car.h Car.h A program implementing shared aggregation with smart pointers
Transmission.h Transmission.h
Engine.h Engine.h
Warehouse.h Warehouse.h
driver.cpp driver.cpp
Warehouse.h Warehouse.h One-to-many aggregation with shared pointers and vectors
driver.cpp driver.cpp