Well, it sounds like your semantic domain has an IS-A relationship, but you're a bit wary of using subtypes/inheritance to model this—particularly because of the runtime type reflection. I think however that you're scared of the wrong thing—subtyping does indeed come with dangers, but the fact that you're querying an object at runtime is not the problem. You'll see what I mean.
Object-oriented programming has leaned quite heavily on the notion of IS-A relationships, it has arguably leaned too heavily on it, leading to two famous critical concepts:
But I think there's another, more functional-programming-based way to look at IS-A relationships that perhaps doesn't have these difficulties. First, we want to model horses and unicorns in our program, so we are going to have a Horse
and an Unicorn
type. What are the values of these types? Well, I'd say this:
- The values of these types are representations or descriptions of horses and unicorns (respectively);
- They are schematized representations or descriptions—they're not free-form, they're constructed according to very strict rules.
That may sound obvious, but I think one of the ways people get into issues like the circle-ellipse problem is by not minding those points carefully enough. Every circle is an ellipse, but that doesn't mean that every schematized description of a circle is automatically a schematized description of an ellipse according to a different schema. In other words, just because a circle is an ellipse doesn't mean that a Circle
is an Ellipse
, so to speak. But it does mean that:
- There is a total function that converts any
Circle
(schematized circle description) into an Ellipse
(different type of description) that describes the same circles;
- There is a partial function that takes an
Ellipse
and, if describes a circle, returns the corresponding Circle
.
So, in functional programming terms, your Unicorn
type doesn't need to be a subtype of Horse
at all, you just need operations like these:
-- Convert any unicorn-description of into a horse-description that
-- describes the same unicorns.
toHorse :: Unicorn -> Horse
-- If the horse described by the given horse-description is a unicorn,
-- then return a unicorn-description of that unicorn, otherwise return
-- nothing.
toUnicorn :: Horse -> Maybe Unicorn
And toUnicorn
needs to be a right inverse of toHorse
:
toUnicorn (toHorse x) = Just x
Haskell's Maybe
type is what other languages call an "option" type. For example, the Java 8 Optional<Unicorn>
type is either an Unicorn
or nothing. Note that two of your alternatives—throwing an exception or returning a "default or magic value"—are very similar to option types.
So basically what I've done here is reconstruct the concept IS-A relationship in terms of types and functions, without using subtypes or inheritance. What I would take away from this is:
- Your model needs to have a
Horse
type;
- The
Horse
type needs to encode enough information to determine unambiguously whether any value describes a unicorn;
- Some operations of the
Horse
type need to expose that information so that clients of the type can observe whether a given Horse
is a unicorn;
- The clients of the
Horse
type will have to use these latter operations at runtime to discriminate between unicorns and horses.
So this is fundamentally an "ask every Horse
whether it is an unicorn" model. You are wary of that model, but I think wrongly so. If I give you a list of Horse
s, all that the type guarantees is that the things that the items in the list describe are horses—so you are, inevitably, going to need to do something at runtime to tell which of them are unicorns. So there's no getting around that, I think—you need to implement operations that will do that for you.
In object-oriented programming, the familiar way of doing this is the following:
- Have a
Horse
type;
- Have
Unicorn
as a subtype of Horse
;
- Use runtime type reflection as the client-accessible operation that discerns whether a given
Horse
is an Unicorn
.
This does have a big weakness, when you look at it from the "thing vs. description" angle that I presented above:
- What if you have a
Horse
instance that describes a unicorn but is not an Unicorn
instance?
Going back to the beginning, this is what I think is the really scary part about using subtyping and downcasts for modeling this IS-A relationship—not the fact that you have to do a runtime check. Abusing the typography a bit, asking a Horse
whether it's an Unicorn
instance is not synonymous with asking a Horse
whether it is an unicorn (whether it is a Horse
-description of a horse that is also an unicorn). Not unless your program has gone to great lengths to encapsulate the code that constructs Horses
so that every time a client tries to construct a Horse
that describes an unicorn, the Unicorn
class is instantiated. In my experience, seldom do programmers do things this carefully.
So I'd go with the approach where there is an explicit, non-downcast operation that converts Horse
s to Unicorn
s. This could either be a method of the Horse
type:
interface Horse {
// ...
Optional<Unicorn> toUnicorn();
}
...or it could be an external object (your "separate object on a horse that tells you if the horse is a unicorn or not"):
class HorseToUnicornCoercion {
Optional<Unicorn> convert(Horse horse) {
// ...
}
}
The choice between these is a matter of how your program is organized—in both cases, you have the equivalent of my Horse -> Maybe Unicorn
operation from above, you're just packaging it in different ways (that will admittedly have ripple effects on what operations the Horse
type needs to expose to its clients).