The previous discussion of part ownership in an aggregation relationship suggested that a whole class can have multiple instances of a part class. Rather than using a member variable for each instance, the Warehouse class used an array of pointers, establishing multiple aggregation relationships. In addition to arrays, we can implement multiple aggregation or composition with any container or collection class. Common examples are vectors, array lists, linked lists, and hash maps, to name just a few. These structures are classes that save and organize data in various ways. While they are typically more flexible and capable than arrays, arrays are adequate in many situations.
Class developers can design and implement multiple composition and multiple aggregation - the UML and C++ support both. In this context, the most significant difference between aggregation and composition is the strength or tightness of their respective bindings. Aggregation's weak or loose binding allows programs to create or change the parts when convenient. Conversely, composition's strong or tight binding requires them to create the whole and its parts simultaneously and to maintain the relationship throughout the whole object's lifetime. (The program can't replace the parts but can change the data saved in their member variables.) When a program implements multiple composition relationships with an array, it bears the expense of constructing each object, even if only using a few. Consequently, the following discussion focuses on aggregation.
Imagine that we are programming a card game. While games have various rules, we'll assume some common values: The game has a single deck of 52 cards, and each player holds a hand with five cards. We represent these aspects of the game with three classes, Deck, Hand, and Card, organized as two whole-part relationships.
The Deck and Card aggregation is an authentic problem exemplifying a pattern appearing in other complex problems. The UML defines a compact notation, called multiplicity operators, for conveying the number of parts. The notation is simple but most easily presented by examples.
(a) | (b) | (c) | (d) | (e) |
..
specifies a range.0..*
.We can implement multiple aggregation in many ways. As we explore various options, we typically encounter trade-offs between them. The following examples illustrate array and vector implementations, each offering advantages and conceding disadvantages. The examples focus on creating and using relationships and do not implement a complete game. The ellipses represent unnecessary and omitted details.
Although unrelated to aggregation, shuffling the cards in the deck is the most complex aspect of the examples. Randomizing the elements of an array is the inverse of ordering (i.e., sorting) them, so we use a modified version of the selection sort to shuffle the deck. The selection sort finds and selects the smallest element, min, in the sub-array between top and the array's bottom, then swaps the top and min elements. After swapping the elements, the algorithm advances top, shrinking the sub-array by one element. Equivalently, the selection sort algorithm can select the largest element and swap it with the bottom element in the sub-array. The shuffle algorithm works similarly, randomly selecting a card and swapping it with the card at the bottom.
The algorithm begins with bottom indicating the last array element. Its data type and how it indicates the last element is implementation-dependent. The PRNG output is always numeric, so random is always an integer: 0 ≤ random ≤ bottom. The algorithm uses random as an index into the array. At the end of each iteration, the algorithm moves bottom to the next element up, but doing this does not remove any elements or change the array's size.
The array version implements the UML diagram illustrated Figure 2(c). The array implementation is efficient, and our familiarity with them and stacks makes this approach "comfortable." Nevertheless, it also prevents us from easily adapting the Deck class to games using larger decks.
#include <iostream> // (a) #include <random> #include <chrono> using namespace std; using namespace chrono; class Card // (b) { private: . . . private: . . . void display(); }; |
class Deck { private: int count = 0; // (c) Card* cards[52]; public: Deck(); // (d) void add_card(Card* card) { cards[count++] = card; } // (e) Card* deal() { return cards[--count]; } // (f) bool has_cards() { return count > 0; } // (g) void shuffle(); // (h) private: void swap(int c1, int c2); // (i) }; |
void Deck::shuffle() { default_random_engine rng((unsigned)(system_clock::now().time_since_epoch().count())); // (a) for (int bottom = count - 1; bottom > 0; bottom--) // (b) { uniform_int_distribution<int> range(0, bottom); // (c) int random = range(rng); // (d) swap(random, bottom); } } void Deck::swap(int random, int bottom) // (e) { Card* temp = cards[random]; cards[random] = cards[bottom]; cards[bottom] = temp; } |
int main() { Deck d; d.shuffle(); while (d.has_cards()) d.deal()->display(); return 0; }
The vector class is part of the C++ standard template library or STL. The STL provides programmers with ready-made template classes (presented in a later chapter) - called containers - that store and organize data, including objects. You'll experience a more formal treatment of the STL in CS 2420. The vector version implements the UML diagram in Figure 2(e). The example consists of two variations: the first is a straightforward conversion of the array version to a vector, while the second uses iterators. An iterator is an object tightly bound to a container, allowing a program to access its stored data in various ways. (We previously used string iterators in one solution of the palindrome/number problem.) The vector and iterator operations may seem a little cryptic, but they allow us to create a Deck of any size.
class Deck { private: vector<Card *> cards; // (a) public: Deck(); void add_card(Card* card) { cards.push_back(card); } // (b) Card* deal(); bool has_cards() { return !cards.empty(); } // (c) void shuffle(); private: void swap(int c1, int c2); // (d) void swap(Card& c1, Card& c2); // (e) };
!
, reverses the logic to match the function name. We could rename the function; instead, we choose to retain the name and logic to match the previous example.Card* Deck:deal() { Card* c = cards[cards.size() - 1]; // (a) cards.pop_back(); // (b) return c; // (c) }
[]
, allowing a program to access its elements like an array (we learn about overloaded operators in the next chapter). vectors are zero-indexed, putting the last element at size - 1. We get the number of elements with the size function. The index operator gets the last element from the vector but does not remove it, and the overloaded assignment operator saves it in the local variable, c.
void Deck::swap(int random, int bottom) { Card* temp = cards[random]; cards[random] = cards[bottom]; cards[bottom] = temp; } |
void Deck::swap(Card& random, Card& bottom) { Card temp = random; random = bottom; bottom = temp; } |
(a) | (b) |
void Deck::shuffle() { default_random_engine rng((unsigned)(system_clock::now().time_since_epoch().count())); // (a) /*for (int bottom = cards.size() - 1; bottom > 0; bottom--) // (b) { uniform_int_distribution<int> range(0, bottom); int random = range(rng); swap(random, bottom); }*/ for (vector<Card *>::reverse_iterator bottom = cards.rbegin(); bottom != cards.rend(); bottom++) // (c) { uniform_int_distribution<int> range(0, cards.size() - 1); int random = range(rng); swap(*cards[random], **bottom); // (d) } }
++
, advances the bottom iterator to the next element but moving backward in the vector.*cards[random]
: Together, random and the overloaded index operator select one pointer element from cards and the dereference operator returns the Card object.**bottom
: bottom is an iterator, so *bottom returns the current vector element. The second * returns the Card object.