Consider this simple class that models a real world mobile device:
/// <summary>
/// Model that represents a device.
/// </summary>
public class Device
{
public DateTime CreationDate { get; set; }
public bool Enabled { get; set; }
/// <summary>
/// Gets or sets the device's hardware identifier.
/// </summary>
/// <remarks>Commonly filled with data like device's IMEI.</remarks>
public string HardwareId { get; set; }
public int Id { get; set; }
public DateTime? LastCommunication { get; set; }
}
Now suppose a new requirement arrived and I need to start differentiating a device by platform, like Windows Phone
, Android
and iOS
. The most common option I think would be to create an enumeration class:
/// <summary>
/// Defines a type of device.
/// </summary>
enum PlatformType
{
/// <summary>
/// Device is an Android.
/// </summary>
Android,
/// <summary>
/// Device is an Windows platform.
/// </summary>
Windows,
/// <summary>
/// Device is an iOS platform.
/// </summary>
Ios,
}
and then add a new property to the device class:
public class Device
{
...
public PlatformType Platform { get; set; }
...
}
Another approach would be to create a inheritance hierarchy on the device class itself:
public class AndroidDevice : Device { }
public class IosDevice : Device { }
public class WindowsPhoneDevice : Device { }
When should I choose one of those approaches over the other? Should I always choose the second one or is there a reason not to?
2
In general I prefer composition
over inheritance
. That has several reasons:
-
Humans are bad at dealing with complexity. And dealing with high inheritance trees is complexity. I want light structures on my brain, which I could overlook easily. The same goes for dozens of types even derived from one base class.
-
You are able to switch moving parts out. You could have a simple device, which does
runOperatingSystem()
, instead of making two differentdevice
-classes, you make only one and give it anOperating System
. If you want to test behavior, you could inject amockOperatingSystem
and see, if it does, what it should. -
You are able to extend behaviour, simply by injecting more behavioral components.
In terms of abstraction
, you are better off, designing a generic device type:
In Python the design would look like the following
class Device:
def __init__(self, OS, name):
self.OS=OS
self.name=name
def browseInterNet(self):
self.OS.browseInterNet()
class Android:
def browseInterNet(self):
print("I'm browsing")
You have a generic device
which runs an operating system
. Every userinteraction is delegated to this OS
. Perhaps you want to test only the browsing call dependent on any operating systen, you could easily swap it out.
The next step for this design would be, to create a configuration
-object, which takes the common parameters (CreationDate
, HardwareId
and so on). Inject this configuration
and the appropriate OS
via constructor injection and you are done.
You define common behaviour in a contract (interface
), which determines, what you could do with a phone and the operation system deals with the implementation.
Translated to everyday language: If you text with your phone, there is no difference in doing it with an iPhone, Android or Windows in that respect, that you are texting, although the mechanisms from OS to OS differ. How they deal technically with it is uninteresting for the device. The OS runs the texting app
, which itself takes care of its implementation. It is all a question of abstracting commonalities.
On the other hand: this is only one way of doing it. It depends on you and your model of the domain.
From the comments:
But you have to agree with me that this would require one to create a lot of wrapper methods to make the API simpler
This depends on what exactly you want to model. To extend the given example of texting
:
Say, you simply have some basic jobs, you want the device to do, you define an API
for that; in our case simply the method sendSMS(text)
.You have then the Device
, where the message "send text"
is called upon. The device
in turn delegates that call to the used operating system, which does the actual sending.
If you want to model more than a handful of services
your device
offers, you have to make bloated API
.
This is a sign, that your abstraction level is too low.
The next step would be to abstract the concept of an app out of the system.
In principle, you have a device
which interacts with the inner logic of an app
, which runs on an operating system
. You have input
, which is processed and changes the display
of the device.
In terms of MVC, e.g. your keyboard is the controller
, the display is the view
and there is the model
within the app, which is modified. And since the view observes the model it reflects any changes.
With such an abstract concept, you are very flexible to build / model a lot of use cases.
I also am not seeing exactly how you would handle the operations concept here. There are still many types of operations, each with differing parameters, that can or can’t be applied to a given device.
As said above:it is all a matter of abstraction. The more power you want, the more abstract your model has to be.
Where would the OS be located now after this abstraction?
Taking the example further, you have to develop several abstractions / patterns, which help in this case.
-
Mediator
-Pattern: The operating system acts as a mediator, i.e. it takes signals in form ofcommands
, sends it to the app and takes in responsecommands
to e.g. update the view -
Command
-Pattern: The command pattern is the form of abstraction, which is used to describe the communication flow between components. Say, the user pressesA
, than this could be abstracted asKeypressed
-command with a value ofA
. Another command would beupdate display
with the value ofkeypress.value
or in this caseA
. -
MVC
The display as theview
, the keyboard as thecontroller
and in between the (app-)model.
Your imagination is your limit.
This is the kind of stuff, OOP was invented for originally: simulating independent components and their interaction
4
I would make this choice based on how much of the implementation is shared between platforms.
If 99% of the code is the same on all platforms, then have a private enum, and in the one or two places where it matters just use a switch(Platform)
block and you’re done.
But if you have to make dozens of platform-dependent system calls inside this class, each with different parameters and semantics and error handling conventions, then you’re better off with completely separate classes that merely inherit from a common interface.
The example interface you’ve shown would almost certainly end up in the latter category.
6
The OOP answer is to do the inheritance. I guess the reasoning is to avoid conditional blocks of code such as :
if(type==PlatformType.Android) { ..}
else ....
Which can be replaced with overridden methods on the specific class
However if you just have a data struct with no logic, as may be the case if you have no requirement for extra fields, or are passing to a DB or service. I think the type property is ok.
5
If there is a lot of “common code” (or abstractions that all platforms make use of), another option is composition, rather than inheritance. I believe that anything you can do with inheritance, you can also accomplish through composition (I’m probably going to regret saying “anything”). Inheritance is going to lock you into that base implementation, composition gives you a choice.
Using a contrived example, if you were going to implement a base class that had common functionality such as:
class BaseFoo
{
public DoThis() {
}
public DoThat() {
}
}
And subclasses:
class MyFoo : BaseFoo {
}
class YourFoo : BaseFoo {
}
Those subclasses are locked into the behavior of the base class. (Yes, you can make methods virtual and override their behavior, but, for the sake of argument, let’s say that what the base methods do is extremely complex and you don’t want to/can’t re-implement it.)
If you instead had each implement the same interface (representing the Foo
abstraction) and composed the classes, they are free do implement the abstraction how they see fit:
interface IFoo
{
void DoThis();
void DoThat();
}
class MyFoo : IFoo {
private readonly IThisStrategy _thisStrategy;
private readonly IThatStrategy _thatStrategy;
public MyFoo(IThisStrategy thisStrategy, IThatStrategy thatStrategy) {
_thisStrategy = thisStrategy;
_thatStrategy = thatStrategy;
}
public void DoThis() {
_thisStrategy();
}
}
It may take a bit more work to implement the solution through composition, but the benefits include decoupling, flexibility, and reusable components (the strategy implementations). This also has testing benefits as you can unit test individual behaviors that you’re injecting without having to test the object at large. And this also avoids polluting your code with a bunch of switch statements everywhere that later cause a maintenance headache should you ever need to support a new platform or change that platform’s behavior.
I’m a firm believer in composition over inheritance in a great many cases, especially when inheritance is not being used to actually extend a functional base.
Actually none of us can tell you the best way to implement your device type, since we don’t know how you’re going to (re)use your code. The problem is, that some devs will say, that you should use Device
as interface, AbstractDeviceImpl
as abstract class and Android
, etc. as concrete class which would be nice, but do you need it?
I would say the best approach is to find it out on your own by using TDD. Use your code, see what happens. Try to find out your requirements, reading your tests. Do you want it to be prepared for OCP and very abstract? Or will there be no other devices so simply one device abstraction and 3 concrete classes are your friends?
As I think that there won’t be more than those 3 devices, and assume that you only need the type for logging/debugging, I would create a class Device
implementing type
as read-only string unknown
as default. Then create 3 inner classes inheriting Device
overriding the type
-getter with the corresponding name.
As a matter of fact, using an enum in that way you described it above, is mostly bad OCP violation ending in switch blocks and/or if statements. Adding a new device means, changing the enum and adding a class. That’s actually not what an OOP wants. You can always use enums if you need the value itself, but to distinguish between execution paths, enum with more than two elements might produce ugly code.