-
Notifications
You must be signed in to change notification settings - Fork 4
10. Class Relationship
#Introduction
In object oriented programming, classes are usually designed to be extended and reused. If you think about maintenance this approach makes sense because systems is all about data and how those data are represented in the computer memory, considering that requirements can and will change and you'll certainly have to change your structures to reflect that new data, but better than change is extending because it's less intrusive with a minor risk to break what is already working.
This doesn't mean that class design is easy. Actually it's just another tool in the engineer's toolbox that can be bad used like anything else. Anyway, let's see how to use that tool in C++.
This is how you create a specialized class. Specialized because it's based on another existing class. For example:
#include <string>
#include <vector>
#include <iostream>
using namespace std;
class guitar
{
public:
virtual string play() = 0;
};
class acoustic: public guitar
{
public:
string play() { return "bléééém\n"; }
};
class electric: public guitar
{
public:
string play() { return "tuummmm\n"; }
};
int main()
{
vector<guitar*> guitars;
guitars.push_back(new acoustic());
guitars.push_back(new acoustic());
guitars.push_back(new electric());
guitars.push_back(new electric());
guitars.push_back(new acoustic());
for (auto guit : guitars)
cout << guit->play();
for (auto guit : guitars)
delete guit;
cout << sizeof(guitar) << endl;
return 0;
}
In class Guitar, there is a virtual method called play()
and a 0 assigned to it. Actually we're not assigning anything to the method, it wouldn't make any sense at all. In C++, it means that the method is pure virtual:
- → We cannot instantiate any Guitar;
-
guitar guit;
: compiler will complain like error: cannot declare variable 'guit' to be of abstract type 'guitar' - → Any Guitar descendant must implement its own
play()
; - if you instantiate a sub-class that miss the implementation of a pure virtual, you will get the same compiler error.
But why do we need to create a class that cannot have any instance, why would I create such class if I can implement Acoustic() and Electric() directly?
Because you want an interface, you want a common data type that can be used to 'illustrate' any other data type based on that first one, you want to give a guitar to your player and let that player choose between either an electric or an acoustic guitar. In C++ there is no "interface" keyword, but this can be done using abstract classes, just like I implemented above.
See the implementation bellow. It's a pseudo-skeleton-game that implements that guitar player receiving a generic guitar.
class guitar
{
public:
virtual void play(note nt) = 0;
};
class acoustic: public guitar
{
public:
void play(note nt)
{
switch (nt) {
case note::C:
mediaplayer_service::play_mp3("acoustic_c.mp3");
break;
case note::D:
mediaplayer_service::play_mp3("acoustic_d.mp3");
break;
[snip]
}
};
class electric: public guitar
{
public:
void play(note nt)
{
switch (nt) {
case note::C:
mediaplayer_service::play_mp3("eletric_c.mp3");
break;
case note::D:
mediaplayer_service::play_mp3("eletric_d.mp3");
break;
[snip]
}
};
Now, let's create the player, a class representing your user that will have a guitar and will be able to play a set of notes to make her song.
class player
{
[snip]
public:
void set_guitar(guitar *guit)
{
_guit = guit
}
void play_notes(vector<note> notes)
{
for (auto nt : notes)
_guit->play(note);
}
private:
guitar *_guit;
[snip]
}
It doesn't matter what guitar her owns you just want her playing the sound accordingly, so nothing better than giving the player
class an interface to a generic guitar. At the end, you only need to ask the preferred guitar and give that guitar to the player
:
int main()
{
guitar *generic_guitar = nullptr;
int selection = 0;
cout << "Choose your guitar\n";
cout << " 1. Electric\n";
cout << " 2. Acoustic\n";
cin >> selection;
if (selection == 1)
generic_guitar = new electric;
else if (selection == 2)
generic_guitar = new acoustic;
player my_player;
my_player.set_guitar(generic_guitar);
my_player.play_notes({note::C, note::C, note::D, note::F, note::C});
return 0;
}
(we're going to revisit this class later to fix important issues...)
Note how clean and safe it's. Safe because my_player.set_guitar()
won't accept anything else than a guitar
instance AND because play()
is a pure virtual - C++ demands all sub-classes to implement it, otherwise you'll get a compiler error.
IMPORTANT: Generalization can be read as "AS A". This both acoustic
and electric
are guitar
s.
This approach can be used anywhere, it could be a generic database driver, or it could be a GUI (web, command line, printer, etc), or anything you want to give users a choice that cannot be decided beforehand. But good sense is required to avoid deep hierarchies creating unnecessary complexity.
A virtual function doesn't need to be pure (= 0), in this particular case I mean that play()
doesn't mean a thing for a guitar, it only makes sense if such guitar is either electric or acoustic. But, suppose it does, how would it be implemented?
#include <string>
#include <vector>
#include <iostream>
using namespace std;
class guitar
{
public:
virtual string play() { return "virtual..."; } // not a pure virtual anymore
};
class acoustic: public guitar
{
public:
string play() { return guitar::play() + "bléééém"; } // calling the base::play first
};
class electric: public guitar
{
public:
string play() { return "tuummmm"; }
};
int main()
{
vector<guitar*> guitars;
guitars.push_back(new guitar()); // now I can instantiate a guitar() directly
guitars.push_back(new acoustic());
guitars.push_back(new electric());
guitars.push_back(new electric());
guitars.push_back(new acoustic());
for (auto guit : guitars)
cout << guit->play() << endl;
for (auto guit : guitars)
delete guit;
return 0;
}
% ./guitars
virtual...
virtual...bléééém
tuummmm
tuummmm
virtual...bléééém
What if the base (guitar::play()) method is not virtual?
We will see all the details in the Virtual Table section, but in this case guitar
could not be used as an interface because guitar *guit = new electric; guit->play();
will call the base play() only:
[skip]
class guitar
{
public:
string play() { return "virtual..."; } // not virtual method here
};
[skip]
% ./guitars
virtual...
virtual...
virtual...
virtual...
virtual...
Do I have to use pointers?
Hmmm, you can use references:
void printer(guitar &guit)
{
cout << guit.play() << endl;
}
int main()
{
vector<guitar*> guitars;
guitars.push_back(new guitar());
guitars.push_back(new acoustic());
guitars.push_back(new electric());
guitars.push_back(new electric());
guitars.push_back(new acoustic());
for (auto guit : guitars)
printer(*guit);
for (auto guit : guitars)
delete guit;
return 0;
}
But you cannot do TODO: explain why
void printer(guitar guit)
{
cout << guit.play() << endl;
}
Within std::vector, we cannot hold references because references must be initialized when declared. However, using C++11, we can do:
int main()
{
electric el_guitar;
acoustic ac_guitar;
vector<reference_wrapper<guitar>> guitars;
guitars.push_back(el_guitar);
guitars.push_back(ac_guitar);
for (guitar &guit : guitars) // auto won't work as expected
cout << guit.play() << endl;
return 0;
}
That virtual keyword
has a price. To improve your code organisation you must be willing to pay for it but it's not pricey, to be honest I think it's cheap if you consider the benefits you have by using it. However, I'm no one to tell you such thing (simply because I don't know your problem, in some cases you couldn't afford it). So let's see what's going on underneath, take some conclusions and you'll decide whether is worth or not.
//TODO
//TODO
//TODO
This is basically a link between two or N instances; It's the most basic form of association. In the diagram below we see that zivi can have none or one guitar. But guitar doesn't want to know about its customer.
#include <string>
using namespace std;
class acoustic_guitar
{
public:
string brand() { return _brand; }
void brand(string n) { _brand = n; }
private:
string _brand;
};
class player
{
public:
string name() { return _name; }
void name(string n) { _name = n; }
acoustic_guitar guitar() { return _brand; }
void guitar(acoustic_guitar g) { _brand = g; }
private:
string _name;
acoustic_guitar _brand;
};
int main()
{
acoustic_guitar gibson;
gibson.brand("Gibson");
player zivi;
zivi.name("Zivi");
zivi.guitar(gibson);
return 0;
}
Aggregation is the "has a" relationship between object. In the diagram, a guitar has 5 strings but a string doesn't have a guitar.
Composition is like aggregation but with strong dependency over the aggregate life. For example, the player zivi has two hands, but those hands won't alive if zivi is dead.
#include <array>
#include <string>
using namespace std;
class gstring
{
};
class acoustic_guitar
{
public:
string brand() { return _brand; }
void brand(string n) { _brand = n; }
private:
string _brand;
array<gstring, 5> _strings;
};
class hand
{
};
class player
{
public:
string name() { return _name; }
void name(string n) { _name = n; }
acoustic_guitar guitar() { return _brand; }
void guitar(acoustic_guitar g) { _brand = g; }
private:
string _name;
acoustic_guitar _brand;
hand left, right;
};
int main()
{
acoustic_guitar gibson;
gibson.brand("Gibson");
player zivi;
zivi.name("Zivi");
zivi.guitar(gibson);
return 0;
}