W poprzednich dwóch lekcjach przyglądaliśmy się dwóm typom składu obiektu, kompozycji i agregacji. Kompozycja obiektów służy do modelowania relacji, w których złożony obiekt jest zbudowany z jednego lub większej liczby prostszych obiektów (części).
W tej lekcji przyjrzymy się słabszemu typowi relacji między dwoma skądinąd niepowiązanymi obiektami, zwanemu skojarzeniem. W przeciwieństwie do relacji kompozycji obiektów, w skojarzeniu nie ma domniemanej relacji całość/część.
Stowarzyszenie
Aby zakwalifikować się jako skojarzenie, obiekt i inny obiekt muszą mieć następującą relację:
- Powiązany obiekt (element) nie jest w żaden sposób powiązany z obiektem (klasą)
- Powiązany obiekt (element) może należeć do więcej niż jednego obiektu (klasy) naraz
- Powiązany obiekt (członek) nie jego istnieniem nie zarządza obiekt (klasa)
- Powiązany obiekt (członek) może wiedzieć o istnieniu obiektu (klasy) lub nie.
W przeciwieństwie do kompozycji lub skupienia, gdzie część jest częścią całego obiektu, w skojarzeniu powiązany obiekt nie jest w żaden sposób powiązany z obiektem. Podobnie jak agregacja, powiązany obiekt może należeć do wielu obiektów jednocześnie i nie jest zarządzany przez te obiekty. Jednakże w przeciwieństwie do agregacji, gdzie relacja jest zawsze jednokierunkowa, w stowarzyszeniu relacja może być jednokierunkowa lub dwukierunkowa (gdzie oba obiekty są sobie świadome).
Relacja pomiędzy lekarzami i pacjentami jest doskonałym przykładem stowarzyszenia. Lekarz wyraźnie ma więź ze swoimi pacjentami, ale pod względem koncepcyjnym nie jest to relacja część/całość (kompozycja obiektu). Lekarz może przyjąć wielu pacjentów dziennie, a pacjent wielu lekarzy (być może chce zasięgnąć drugiej opinii lub odwiedza różnych lekarzy). Żaden z okresów życia obiektów nie jest powiązany z drugim.
Możemy powiedzieć, że modele skojarzeń są relacją „używa-a”. Lekarz „wykorzystuje” pacjenta (w celu uzyskania dochodu). Pacjent korzysta z pomocy lekarza (w dowolnych celach zdrowotnych).
Wdrażanie skojarzeń
Ponieważ skojarzenia są szerokim rodzajem relacji, można je realizować na wiele różnych sposobów. Jednak najczęściej skojarzenia implementuje się za pomocą wskaźników, gdzie obiekt wskazuje powiązany obiekt.
W tym przykładzie zaimplementujemy dwukierunkową relację Lekarz/Pacjent, ponieważ ma sens, aby lekarze wiedzieli, kim są ich Pacjenci i odwrotnie.
#include <functional> // reference_wrapper
#include <iostream>
#include <string>
#include <string_view>
#include <vector>
// Since Doctor and Patient have a circular dependency, we're going to forward declare Patient
class Patient;
class Doctor
{
private:
std::string m_name{};
std::vector<std::reference_wrapper<const Patient>> m_patient{};
public:
Doctor(std::string_view name) :
m_name{ name }
{
}
void addPatient(Patient& patient);
// We'll implement this function below Patient since we need Patient to be defined at that point
friend std::ostream& operator<<(std::ostream& out, const Doctor& doctor);
const std::string& getName() const { return m_name; }
};
class Patient
{
private:
std::string m_name{};
std::vector<std::reference_wrapper<const Doctor>> m_doctor{}; // so that we can use it here
// We're going to make addDoctor private because we don't want the public to use it.
// They should use Doctor::addPatient() instead, which is publicly exposed
void addDoctor(const Doctor& doctor)
{
m_doctor.push_back(doctor);
}
public:
Patient(std::string_view name)
: m_name{ name }
{
}
// We'll implement this function below to parallel operator<<(std::ostream&, const Doctor&)
friend std::ostream& operator<<(std::ostream& out, const Patient& patient);
const std::string& getName() const { return m_name; }
// We'll friend Doctor::addPatient() so it can access the private function Patient::addDoctor()
friend void Doctor::addPatient(Patient& patient);
};
void Doctor::addPatient(Patient& patient)
{
// Our doctor will add this patient
m_patient.push_back(patient);
// and the patient will also add this doctor
patient.addDoctor(*this);
}
std::ostream& operator<<(std::ostream& out, const Doctor& doctor)
{
if (doctor.m_patient.empty())
{
out << doctor.m_name << " has no patients right now";
return out;
}
out << doctor.m_name << " is seeing patients: ";
for (const auto& patient : doctor.m_patient)
out << patient.get().getName() << ' ';
return out;
}
std::ostream& operator<<(std::ostream& out, const Patient& patient)
{
if (patient.m_doctor.empty())
{
out << patient.getName() << " has no doctors right now";
return out;
}
out << patient.m_name << " is seeing doctors: ";
for (const auto& doctor : patient.m_doctor)
out << doctor.get().getName() << ' ';
return out;
}
int main()
{
// Create a Patient outside the scope of the Doctor
Patient dave{ "Dave" };
Patient frank{ "Frank" };
Patient betsy{ "Betsy" };
Doctor james{ "James" };
Doctor scott{ "Scott" };
james.addPatient(dave);
scott.addPatient(dave);
scott.addPatient(betsy);
std::cout << james << '\n';
std::cout << scott << '\n';
std::cout << dave << '\n';
std::cout << frank << '\n';
std::cout << betsy << '\n';
return 0;
}Wypisuje:
James is seeing patients: Dave Scott is seeing patients: Dave Betsy Dave is seeing doctors: James Scott Frank has no doctors right now Betsy is seeing doctors: Scott
Ogólnie rzecz biorąc, należy unikać powiązań dwukierunkowych, jeśli sprawdzi się skojarzenie jednokierunkowe, ponieważ dodają złożoności i zwykle trudniej jest pisać bez dokonywania błędy.
Skojarzenie zwrotne
Czasami obiekty mogą mieć związek z innymi obiektami tego samego typu. Nazywa się to skojarzeniem refleksyjnym. Dobrym przykładem skojarzenia zwrotnego jest związek pomiędzy kursem uniwersyteckim a jego wymaganiami wstępnymi (które są również kursami uniwersyteckimi).
Rozważmy uproszczony przypadek, w którym kurs może mieć tylko jeden warunek wstępny. Możemy zrobić coś takiego:
#include <string>
#include <string_view>
class Course
{
private:
std::string m_name{};
const Course* m_prerequisite{};
public:
Course(std::string_view name, const Course* prerequisite = nullptr):
m_name{ name }, m_prerequisite{ prerequisite }
{
}
};Może to prowadzić do łańcucha powiązań (kurs ma warunek wstępny, który ma warunek wstępny itp.)
Powiązania mogą być pośrednie
We wszystkich poprzednich przypadkach używaliśmy wskaźników lub odniesień do bezpośredniego łączenia obiektów ze sobą. Jednak w stowarzyszeniu nie jest to ściśle wymagane. Wystarczy dowolny rodzaj danych, który pozwala połączyć ze sobą dwa obiekty. W poniższym przykładzie pokazujemy, jak klasa Driver może mieć jednokierunkowe powiązanie z Car bez faktycznego dołączania wskaźnika Car lub elementu referencyjnego:
#include <iostream>
#include <string>
#include <string_view>
class Car
{
private:
std::string m_name{};
int m_id{};
public:
Car(std::string_view name, int id)
: m_name{ name }, m_id{ id }
{
}
const std::string& getName() const { return m_name; }
int getId() const { return m_id; }
};
// Our CarLot is essentially just a static array of Cars and a lookup function to retrieve them.
// Because it's static, we don't need to allocate an object of type CarLot to use it
namespace CarLot
{
Car carLot[4] { { "Prius", 4 }, { "Corolla", 17 }, { "Accord", 84 }, { "Matrix", 62 } };
Car* getCar(int id)
{
for (auto& car : carLot)
{
if (car.getId() == id)
{
return &car;
}
}
return nullptr;
}
};
class Driver
{
private:
std::string m_name{};
int m_carId{}; // we're associated with the Car by ID rather than pointer
public:
Driver(std::string_view name, int carId)
: m_name{ name }, m_carId{ carId }
{
}
const std::string& getName() const { return m_name; }
int getCarId() const { return m_carId; }
};
int main()
{
Driver d{ "Franz", 17 }; // Franz is driving the car with ID 17
Car* car{ CarLot::getCar(d.getCarId()) }; // Get that car from the car lot
if (car)
std::cout << d.getName() << " is driving a " << car->getName() << '\n';
else
std::cout << d.getName() << " couldn't find his car\n";
return 0;
}W powyższym przykładzie mamy CarLot przechowujący nasze samochody. Kierowca, który potrzebuje samochodu, nie ma wskaźnika do swojego Samochodu - zamiast tego ma identyfikator samochodu, którego możemy użyć, aby pobrać Samochód z CarLot, kiedy go potrzebujemy.
W tym konkretnym przykładzie robienie tego w ten sposób jest trochę głupie, ponieważ wyciągnięcie Samochodu z CarLot wymaga nieefektywnego wyszukiwania (wskaźnik łączący oba jest znacznie szybszy). Odwoływanie się do rzeczy za pomocą unikalnego identyfikatora zamiast wskaźnika ma jednak zalety. Na przykład możesz odwoływać się do rzeczy, które nie znajdują się aktualnie w pamięci (być może znajdują się w pliku lub w bazie danych i można je załadować na żądanie). Ponadto wskaźniki mogą zajmować 4 lub 8 bajtów — jeśli przestrzeń jest ograniczona, a liczba unikalnych obiektów jest dość mała, odwoływanie się do nich za pomocą 8-bitowej lub 16-bitowej liczby całkowitej może zaoszczędzić dużo pamięci.
Skład a agregacja vs podsumowanie powiązań
Oto tabela podsumowująca, która pomoże Ci zapamiętać różnicę między kompozycją, agregacją i powiązanie:
| Własność | Kompozycja | Agregacja | Stowarzyszenie |
|---|---|---|---|
| Typ powiązania | Cała/część | Cała/część | W inny sposób niepowiązany |
| Członkowie mogą należeć do wielu klasy | Nie | Tak | Tak |
| Istnienie członków zarządzane przez klasa | Tak | Nie | Nie |
| Kierunkowość | Jednokierunkowa | Jednokierunkowa | Jednokierunkowa lub dwukierunkowa |
| Zależność czasownik | Część | Ma-a | Używa-a |

