10.7. Multiplicity

Time: 00:03:53 | Download: Large, Large (CC), Small | Streaming (CC) | Slides (PDF)
Review

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.

UML class diagram with a whole class named 'Hand' with five separate aggregated part classes named 'Card.' UML class diagram with a whole class named 'Deck' with many separate aggregated part classes named 'Card.'
The Hand, Deck, and Card classes: An introduction to multiplicity. Modeling a player's hand as a whole class with five distinct parts is cumbersome, time-consuming, and consumes considerable diagram space. But modeling a deck of 52 cards this way is clearly impractical: it would take too much time to draw, use too much diagram real estate, and be too difficult to read.

Multiplicity Operators

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.

The Hand and Card classes connected by aggregation. The connector has '5' near the Card class. The Deck and Card classes connected by aggregation. The connector has '52' near the Card class. The Deck and Card classes connected by aggregation. The connector has '0..52' near the Card class. The Whole and Part classes connected by aggregation. The connector has '0..1' near the Part class. The Whole and Part classes connected by aggregation. The connector has '*' near the Part class.
(a)(b)(c)(d)(e)
UML multiplicity operators. The UML multiplicity operators are optional and unnecessary when a whole class has exactly one part. But in other cases, the operators succinctly specify the number of part classes a whole has. Class designers place the operators on the undecorated end of the aggregation or composition connector, the end nearest to the part.
  1. A Hand has 5 Cards: A non-negative integer specifies an exact number of parts.
  2. A Deck has 52 Cards.
  3. A Deck has 0 to 52 Cards: .. specifies a range.
  4. A Whole class has 0 or 1 Part.
  5. A Whole class has any number of Parts: the asterisk indicates an unspecified number. Some designers write this multiplicity as 0..*.
A whole class, 'Person,' has two composed parts, both 'strings.' Class designers can label the relationships (e.g.,  'name' and 'address'), clarifying the roles the two parts play in the composition relationships.
Relationship names vs. multiplicity operators. Multiplicity operators are appropriate when all the parts play the same role. For example, in the previous figure, each Card is one element of a Hand or Deck - none are "special" or distinct. However, if the parts play distinct roles, even when they are instances of the same class, we generally show them as different relationships. To avoid the resulting ambiguity, we name or label the relationships, choosing names that clarify the part's role or purpose in the composition or aggregation.

Multiplicity Examples

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.

The Shuffle Operation

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.

  1. bottom = the last or bottom card in the deck
  2. while (bottom != the top card in the deck)
    1. random = a card selected at random from the cards between 0 and bottom, inclusive
    2. swap (the random card, with the bottom card)
    3. bottom = one card up from the current bottom
    4. goto to 2
The picture begins with an ordered array of six elements. The variable 'bottom' indicates the element at the bottom of the array. The algorithm randomly selects an element between the top or zeroth element and 'bottom' and swaps the two. The operation updates the variable 'bottom' to indicate the next element above and repeats the process.
Shuffling the deck. The shuffle operation uses a library pseudo random number generator (PRNG), seeded by the system clock, to select a card at random. Figure 2 illustrates the swap sub-operation and Figure 3 outlines it. The algorithm progresses left to right in the illustration.

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.

Multiple Aggregation With An Array

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)
};
Implementing multiple aggregation: array version. The Deck class implements multiple aggregation with an array of Card pointers. The array behaves like a Stack, with the add_card and deal functions performing the same operations as push and pop.
  1. The <random> and <chrono> header files provide the prototypes for accessing the random number generator and system time functions.
  2. Beyond having a display function, the Card class's details are unnecessary for demonstrating multiple aggregation.
  3. Initially, the Deck is empty (i.e., the card count is zero), but it has a capacity of 52 aggregated cards. Please see Creating an array of objects in C++, example (c).
  4. The constructor can fill the deck or
  5. The program can add cards one at a time.
  6. Once the program randomizes the cards, it deals them one at a time, removing them from the deck as it deals them.
  7. Returns true while at least one card remains in the deck.
  8. Randomizes the cards.
  9. A helper function exchanging two cards in a deck as part of the randomizing or shuffling operation.
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;
}
Deck shuffle and swap member functions: array versions.
  1. Creates a pseudo random number generator object initialized by the system's current time:
    • default_random_engine: the generator's class name
    • rng: the name of the local generator object
    • The program evaluates the two dot operators from left to right. now is a static member of the system_clock class. It returns a chrono::time_point object.
    • The time_point class has a member function named time_since_epoch that returns a duration object.
    • The duration class has a member function named count that returns a representation of the system's internal time, which the program casts to an unsigned integer.
    • Please see system_clock::now and follow the links for more detail.
  2. The loop selects a card randomly and exchanges it with the card at the bottom of the deck. It moves the bottom of the deck up one card and repeats the operation.
  3. Choosing a card at random:
    • uniform_int_distribution<int> is a template class that selects a value in a range. Each value has an equal probability of selection. We'll discuss template classes in a later chapter.
    • range is the name of the local uniform_int_distribution object and is a call to its constructor. The arguments, 0 and bottom, define the inclusive range (i.e., the least and greatest possible value) for the generated random number.
  4. Generates a random number indicating which card is swapped or exchanged with the bottom card.
  5. Swaps the two cards. (See The Swapping Problem, Figure 3.)
int main()
{
    Deck d;

    d.shuffle();
    while (d.has_cards())
        d.deal()->display();

    return 0;
}
Aggregation driver. The driver instantiates a Deck object. The code does not illustrate how the program fills the deck, but once filled, it shuffles it and deals the cards one at a time. The driver displays each card as it's dealt.

Multiple Aggregation With A vector

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(d). 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.

To simplify the presentation, please note the following:
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)
};
The Deck class: vector version.
  1. Creates a vector container saving any number of Card pointers. Vectors manage their size, making a count or size member variable unnecessary.
  2. The vector class has a member function, push_back, that adds an element at the end.
  3. The empty function returns true if the vector is empty, and false otherwise. The C++ NOT operator, !, 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.
  4. The array-like version.
  5. The vector version.
Please see <vector> and vector (C++11) for detail and more functions.
Card* Deck:deal()
{
    Card* c = cards[cards.size() - 1];		// (a)
    cards.pop_back();				// (b)
    return c;					// (c)
}
The Deck deal function: vector version. Oddly, the vector class doesn't have a function that removes and returns an element, making the vector version more complex than the corresponding array-based function.
  1. The vector class overloads the index operator, [], 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.
  2. The pop_back function removes but doesn't return the last element.
  3. Finally, deal returns the element saved in 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)
Overloaded swap functions. If you experience difficulty understanding the data types each version uses, try drawing a picture of the cards array.
  1. The first vector version uses this function, unchanged from the array example.
  2. The "Element access" functions return element references, not pointers. So, taking advantage of the vector functions requires us to work with reference. Notice that temp is not a reference. The function assumes Card supports the assignment operation.
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)
    }
}
The Deck shuffle functions: vector versions.
  1. Creates a PRNG, rng, as described in the previous example, Figure 4(a).
  2. The array-like for-loop walks the vector backwards, from the last element (cards.size() - 1) to the first. The body of the loop randomizes one card as described in Figure 4(b-d).
  3. The iterator-version of the for-loop also visits the vector elements one at a time, but understanding how it does this is challenging.
    • The rbegin function creates a reverse iterator bound to the cards vector and initially positioned at the last element.
    • A reverse_iterator walks the vector backwards, from the end to the beginning.
    • The rend function also returns a reverse iterator but positioned at the first element.
    • The auto-increment operator, ++, advances the bottom iterator to the next element but moving backward in the vector.
    • The loop continues until bottom reaches the first element in the cards vector.
  4. Calls one of the overloaded swap functions, but preparing the arguments may be hard to follow. The vector saves a list of Card pointers, but the swap function's arguments (illustrated in the next figure) are references. So, the asterisks represent the indirection or dereference operator.
    • *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.