My answer tries to cover grounds which have not yet been covered in existing answers. I recommend reading the existing answers first.
What do we already know?
- To call AppleLover.Load(Apple), the caller must have an AppleLover and an Apple.
- To call OrangeLover.Load(Orange), the caller must have an OrangeLover and an Orange.
The entire discussion on OOP is beneficial if only:
- The caller might have an AppleLover or OrangeLover without knowing it.
- For example, it might only know that it has a FoodEater.
- The caller might have an Apple or Orange without knowing it.
- For example, it might only know that it has a Fruit.
- A caller (function) who might not know the full specifics might want to delegate to another caller (function), in which case it is unable to propagate the specific type information.
What is my gut feeling?
- I feel that judicious use of dynamic_cast, side-casting, and down-casting
should be justified, if all other alternatives require code that is too ugly.
- It might actually be okay (acceptable) to have sub-interfaces inherit from a
common interface.
Why would we need FoodEater? Or: what benefit do we get by having FoodEater?
- We would like to store instances of objects in containers (such as vector) or handles
(such as shared_ptr) knowing that each instance supports a FoodEater interface,
without more specific knowledge.
- We would like to use such instances via the FoodEater interface, without more
specific knowledge.
- (Some other use cases which I cannot think of right now)
Devil's advocate - what harm would we do if we get rid of FoodEater?
Suppose we modify AppleLover and OrangeLover by simply copying and pasting
the methods from FoodEater into AppleLover and OrangeLover, and then remove
the inheritance relationships between these interfaces.
- FoodEater
- AppleLover
- Eat(string), Foo(string), Load(Apple)
- OrangeLover
- Eat(string), Foo(string), Load(Orange)
What is the harm if we modify the interfaces as such?
- We can't store instances of AppleLover or OrangeLover in a container or handle
of type FoodEater.
- Note, however, we can still store a
shared_ptr
of AppleLover or OrangeLover
in a shared_ptr<void>
. This is a secret super-power of shared_ptr
implementation. However, it would require additional safeguards and casting to
achieve safety.
- We cannot write a single piece of code that can perform Eat(string) or Foo(string)
on either an instance AppleLover or OrangeLover.
- However, we can still write function templates, and then instantiate for the type
AppleLover and OrangeLover.
- Or, we can use adapters, which is explored below.
Interfaces vs Adapters
Benefits of adapter pattern
- Not subject to the limitations of interfaces and inheritance hierarchy.
Drawbacks of adapter pattern
- The adapter can only be created by someone with knowledge of the type
(whether AppleLover or OrangeLover) of the implementation.
- Only the creator of that adapter knows about the existence of it. Once the
adapter is created, it will have to be propagated to other places of code
that use the adapter.
- It is hard to enforce one-to-one relationship between an object and the
instances of its adapters. Code may become fragile if two adapter instances
of the same type are created on the same concrete object.
- Navigating from the object to its adapter is not possible. In other words,
the object (Jerry, Mike) knows nothing about the existence of adapters.
Given a pointer or reference to the object (Jerry, Mike), one cannot ask
for access to the adapter. It is possible to work around this limitation;
see example below.
Situations where there are no drawbacks
- If the adapter does nothing other than adapting between function calls,
then the adapter can be created and destroyed at will. However, to instantiate
an adapter, the code must know the type (AppleLover or OrangeLover)
of the object.
The drawback can be eliminated (by having the concrete implementation be
the creator and owner of the adapter), but which requires some boilerplate
code. It reduces ambiguity surrounding the use of the adapter, but it does
not completely prevent all misuse.
class Jerry
{
private:
const std::unique_ptr<Adapter> aa;
// any other stuff
public:
Jerry(...)
{
// any other stuff
aa = std::make_unique<Adapter>(*this);
}
Adapter& getAdapter() const
{
return *aa;
}
};
What if we implement an adapter between FoodEater and (Fruit) Lover?
In other words, what if we implement an adapter that covers the methods:
Eat(string), Foo(string), and nothing else?
Sample code
class AppleLoverToFoodEaterAdapter : public FoodEater
{
private:
const std::shared_ptr<AppleLover> mp;
public:
AppleLoverToFoodEaterAdapter(
const std::shared_ptr<AppleLover>& p)
: mp(p)
{
if (!mp) throw bad();
}
// This method helps satisfy FoodEater interface.
void Eat(const std::string& s)
{
// This method knows that AppleLover has a method Eat(string)
mp->Eat(s);
}
void Foo(const std::string& s)
{...}
};
What if we implement an adapter that abstracts over the Load method?
This idea is a twist on the factory method pattern.
Even though this approach looks verbose and pointless in its current form,
the code can be rolled into the factory method for Jerry (a concrete
implementation of AppleLover) and Mike (a concrete implementation of
OrangeLover), which removes the verbosity.
Whether this is pointless depend on whether there is a benefit from
abstracting over the Load method.
class GenericLoader
{
public:
virtual ~GenericLoader() = 0;
virtual void Load() = 0;
};
class AppleLoader : public GenericLoader
{
private:
std::shared_ptr<AppleLover> mp;
std::shared_ptr<Apple> ma;
public:
// To create this loader, the caller must have an AppleLover
// and an Apple.
AppleLoader(
const std::shared_ptr<AppleLover>& p,
const std::shared_ptr<Apple>& a)
: mp(p), ma(a)
{
if (!mp || !ma) throw bad();
}
// This method helps satisfy GenericLoader interface.
void Load()
{
// This method knows that AppleLover has a method Load(Apple)
mp->Load(*ma);
}
};
Can we follow the Interface Segregation Principle in C++ as we do in C#?
A concrete C++ class can inherit (implement) multiple abstract classes
(interfaces), and more.
But how do we write a function that will accept an argument which must
implement two or more interfaces?
Suppose we start with these C++ interfaces, by removing inheritances
between interfaces and also removing all overlapping methods, according
to the Interface Segregation Principle:
- FoodEater
- AppleLoader (renamed from AppleLover)
- OrangeLoader (renamed from OrangeLover)
And the following concrete implementation:
- Jerry : public FoodEater, AppleLoader
- Mike : public FoodEater, OrangeLoader
We want to write a function that will call AppleLoader.Load(Apple) as
well as FoodEater.Eat(string). The argument must therefore implement
both AppleLoader as well as FoodEater.
void LoadAndEat(T& t, Apple& apple, const string& stringToEat)
{ ... }
What would "T" be?
In C#, we can constrain T as follows:
void LoadAndEat(T t, ...)
where T: FoodEater, AppleLoader
{ ... }
In C++, this is trickier.
Given that AppleLoader doesn't inherit from FoodEater, it is possible
for one to create a concrete class that implements AppleLoader but not
FoodEater. While this is the gist of Interface Segregation Principle, this
freedom may be undesirable within this particular scenario.
To prevent mistakes, a comment should be added to the AppleLoader
declaration that says "concrete implementations of AppleLoader are expected
to also implement FoodEater."
Going back to the LoadAndEat(T t, ...) method. There are several approaches.
In approaches 1 and 2, the try-catch block can be simplified by dynamic_cast
to a pointer type, in which a cast failure results in a nullptr instead of
an exception (std::bad_cast) being thrown.
Approach 1 - pass in an AppleLoader, side-cast to FoodEater
void LoadAndEat(AppleLoader& appleLoader, Apple& apple, const string& stringToEat)
{
appleLoader.Load(apple);
try
{
FoodEater& foodEater = dynamic_cast<FoodEater&>(appleLoader);
foodEater.Eat(stringToEat);
}
catch (std::bad_cast&)
{
// Decide what to do. This happens only if a programmer ignores
// the warning we put in the comments that AppleLoader are
// expected to also implement FoodEater.
}
}
Approach 2 - pass in a FoodEater, side-cast to AppleLoader
void LoadAndEat(FoodEater& foodEater, Apple& apple, const string& stringToEat)
{
try
{
AppleLoader& appleLoader = dynamic_cast<AppleLoader&>(foodEater);
appleLoader.Load(apple);
}
catch (std::bad_cast&)
{
// Decide what to do. This is far more likely to happen, since
// we can't prevent the caller from passing in an instance of
// OrangeLover (Mike)
}
foodEater.Eat(stringToEat);
}
Approach 3 - write a function template with a complicated SFINAE "type filter"
template <class T>
auto LoadAndEat(T& t, Apple& apple, const std::string& stringToEat)
-> std::enable_if_t<
std::is_base_of_v<FoodEater, T> &&
std::is_base_of_v<AppleLoader, T> >
{
dynamic_cast<AppleLoader&>(t).Load(apple);
dynamic_cast<FoodEater&>(t).Eat(stringToEat);
}
Other directions to be explored:
- std::variant (C++17), or boost:variant
Superclass
exist, why do bothA
andB
have to inherit from it, and why do they both haveLoad
functions that take different types?