Ruby and the Interface Segregation Principle
Clients should not be forced to depend upon interfaces that they do not use.
--Robert Martin
Students of object-oriented programming and the SOLID Principles will have come across ISP, or the Integration Segregation Principle. Much attention has been lavished on first letter of SOLID, but developers in the Ruby community rarely pay much attention to ISP.
The original paper has examples written in C++ and warns against the
consequences of unnecessary #include
statements and recompiles. Rails
developers don't do much manual class loading and they rarely compile, at least
when writing Ruby. On its face, this principle has few obvious ideas to offer
Ruby developers. Before we file it under "Maybe for TypeScript" and move on,
maybe it's worth diving into what an "interface" is in Ruby.
Duck Typing
If it walks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck.
--Kurt Cobain, probably
Much ado has been made about Ruby's celebrated Duck Typing. Rather than requiring Ruby developers to declare what methods must be implemented, methods send messages to objects which will or won't respond to them. You can swap in a new object that quacks like the old duck and the client object will be none the wiser.
Some of the more common quacking we see in Ruby comes from enumerable ducks,
IO-like ducks, and query-related ducks in the Rails universe like
ActiveRecord::Base
.
Duck typing works nicely when the interface has a small surface area and we know
up front that we might change it out. Ruby developers learn to keep object usage
agnostic where possible, starting by avoiding is_a?
statements and sometimes
pondering the difference between Enumerable
and Enumerator
.
Principle of Least Surprise
I designed Ruby to minimize my surprise.
--Matz, almost certainly
Another oft-touted idea in Ruby is the Principle of Least Surprise - similar to
the Principle of Least Astonishment and related to the Principle of Lesser
Disappointment - which loosely says that a language feature should work largely
how you'd expect it to. What happens when you add an Integer
to a Float
?
Whatever would be least surprising.
This idea has at least partially made it into Rails doctrine in the "The Principle of The Bigger Smile (of DHH)" (no, really). Rails APIs are designed to make developers smile (at least, one of them), and part of that effort goes towards making interfaces that behave like you're expecting.
I've seen instances of tension between Least Surprise and Duck Typing. One
common example I've seen is when developers are confused about which type of
query object they have. There are methods which will all exist on User
,
User.all
, and Group.last.users
. In each case, you can call first
or
last
, but the implementations are slightly different. For newer Rails
developers, calling a method on a collection of users and ending up in a class
method on User
is a perpetual source of surprise, and hence confusion.
At this point, you might be thinking, "I've had enough duck jokes, Joe. When do we talk about ISP?" Fair enough. Let's talk about interfaces in Ruby.
Names in Lieu of Types
Ruby gets all this Duck Typing - and I get to make all my duck jokes - because Ruby doesn't have explicit types for method arguments. You give them a name and a default value if you like. They can be an instance of any class.
One thing we lose without explicit types is the added documentation. If you
accept an argument with a type of User
, you know it's going to be an instance
of that class, and you therefore know its interface. How do we remember what the
interface will be in a Ruby application?
The practical answer is that Ruby developers use names as interface
documentation. This is where Least Surprise and Duck Typing don't always get
along. If you have a User
class and a method argument named user
, developers
will expect user
to be a User
. Passing anything else will result in surprise
and confusion. Ruby's dynamic types allow you to pass anything that responds to
the messages you're passing to user
, but the developer will be confused if you
take advantage of this feature. Maintaining explicit types requires discipline
and forethought, but method arguments with implicit types must specify
everything important in the name alone.
The Incredible Expanding Interface
In a Rails application, if a method sends the valid?
and attributes
messages
to an argument, you can pass it an ActiveRecord::Base
instance. You could also
pass it an ActiveModel
implementation that has similar validations and
attributes.
But what happens to all those other ActiveRecord
methods you didn't use? Do
they dry up, like ducks in the sun? Are they deprecated like older scripts you
run?
What I've found is that a method's required interface expands to include the
entire interface. This is subjective and could be specific to my experience, but
here's a phenomenon I hypothesize is commonplace: if you give somebody an
instance of ActiveRecord::Base
, they will eventually call reload
on it.
Another way of thinking about it is that implicit interfaces save you from specifying what methods you're going to implement, but they prevent you from specifying which methods you won't implement.
Interface Segregation
After exploring how Ruby interfaces work in practice, we can reconsider these problems through the lens of ISP. The key symptom of polluted interfaces in a statically typed language is that methods are forced to accept interfaces with irrelevant methods. The upshot of this is that every user of those methods must provide the full, inflated interface. Changing the interface anywhere may have effects everywhere, even in places where the extra methods were never in use.
In Ruby, we have a similar issue: if you provide an ActiveRecord instance to a method from one class, the path of least resistance is to provide an ActiveRecord instance in every case. Given the size of ActiveRecord's API, this quickly results in tight coupling every time you use ActiveRecord.
Without explicit interfaces, we can't use the solutions proposed for C++ in the original paper. What's a Ruby dev to do?
Dynamic Solutions
Duck Typing Considered Harmful
--Not me, definitely
Given the pitfalls of implicit typing and the subsequent lack of interfaces, how can we keep things flexible in Ruby without wiping the smiles off developers' faces? Surely1 there are ways to work with dynamic typing without throwing the quack out with the duckwater.
Modules
Ruby sort of has explicit interfaces in the form of modules. You can make modules with empty method definitions and then include them in your classes.
module Person
def name; end
def email; end
end
class User
include Person
end
This provides some documentation for those who look at the User
class.
However, it introduces some problems:
- Methods which accept are sent a
User
argument would have to know that there is a separate concept of aPerson
to consider the interface. - The implementation is likely surprising to other developers. Ruby emphasises
behavior over declared interfaces, so modules are expected to contain
behavior. Developers will look for the behavior of a
Person
in vain.
Names
What do you actually get from a module without behavior? You get a name.
One can simply use more nuanced names to describe what interface you expect:
def describe(person)
# No mention of user, probably shouldn't call `login`
end
The issue here is that you have no way of expressing what a "person" is in code. Methods in Ruby are declared in modules and classes, and empty methods cause confusion.
Proxies
Another option is to make proxy objects which expose only a subset of behavior:
class Person
def initialize(subject)
@subject = subject
end
def name
@subject.name
end
end
A developer who encounters an instance of Person
as a method argument will
have a name and some behavior to go with it. I've seen little proxies like this
used for other reasons, such as the adapter pattern, but I'm not sure I've
seen them used strictly for interface segregation.
The behavior is pretty boring and is tedious to read and write, but that could be solved with a healthy dose of metaprogramming, which I'm sure has never caused any problems. An API that works something like Strong Parameters in reverse or validation contexts, where you can export a smaller interface with a useful name could be nice:
class Person < ActiveModel::Interface
delegates :name
end
class User < ApplicatonRecord
implements :person
end
render 'partials/person', user.as_person
The need to reference each new interface from User
could be considered a
violation of the Open/Closed Principle, but that's a SOLID post for another
day.
Quack.
Footnotes
-
Don't call me Shirley. ↩