The Liskov Substitution Principle for "Duck-Typed" Languages 105
OCP and LSP together tell us how to organize similar vs. variant behaviors. I blogged the other day about OCP in the context of languages with open classes (i.e., dynamically-typed languages). Let’s look at the Liskov Substitution Principle (LSP).
The Liskov Substitution Principle was coined by Barbara Liskov in Data Abstraction and Hierarchy (1987).
If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2, then S is a subtype of T.
I’ve always liked the elegant simplicity, yet power, of LSP. In less formal terms, it says that if a client (program) expects objects of one type to behave in a certain way, then it’s only okay to substitute objects of another type if the same expectations are satisfied.
This is our best definition of inheritance. The well-known is-a relationship between types is not precise enough. Rather, the relationship has to be behaves-as-a, which unfortunately is more of a mouthful. Note that is-a focuses on the structural relationship, while behaves-as-a focuses on the behavioral relationship. A very useful, pre-TDD design technique called Design by Contract emerges out of LSP, but that’s another topic.
Note that there is a slight assumption that I made in the previous paragraph. I said that LSP defines inheritance. Why inheritance specifically and not substitutability, in general? Well, inheritance has been the main vehicle for substitutability for most OO languages, especially the statically-typed ones.
For example, a Java application might use a simple tracing abstraction like this.
public interface Tracing {
void trace(String message);
}
Clients might use this to trace methods calls to a log. Only classes that implement the Tracer
interface can be given to these clients. For example,
public class TracerClient {
private Tracer tracer;
public TracerClient(Tracer tracer) {
this.tracer = tracer;
}
public void doWork() {
tracer.trace("in doWork():");
// ...
}
}
However, Duck Typing is another form of substitutability that is commonly seen in dynamically-typed languages, like Ruby and Python.
If it walks like a duck and quacks like a duck, it must be a duck.
Informally, duck typing says that a client can use any object you give it as long as the object implements the methods the client wants to invoke on it. Put another way, the object must respond to the messages the client wants to send to it.
The object appears to be a “duck” as far as the client is concerned.
In or example, clients only care about the trace(message)
method being supported. So, we might do the following in Ruby.
class TracerClient
def initialize tracer
@tracer = tracer
end
def do_work
@tracer.trace "in do_work:"
# ...
end
end
class MyTracer
def trace message
p message
end
end
client = TracerClient.new(MyTracer.new)
No “interface” is necessary. I just need to pass an object to TracerClient.initialize
that responds to the trace
message. Here, I defined a class for the purpose. You could also add the trace
method to another type or object.
So, LSP is still essential, in the generic sense of valid substitutability, but it doesn’t have to be inheritance based.
Is Duck Typing good or bad? It largely comes down to your view about dynamically-typed vs. statically-typed languages. I don’t want to get into that debate here! However, I’ll make a few remarks.
On the negative side, without a Tracer
abstraction, you have to rely on appropriate naming of objects to convey what they do (but you should be doing that anyway). Also, it’s harder to find all the “tracing-behaving” objects in the system.
On the other hand, the client really doesn’t care about a “Tracer” type, only a single method. So, we’ve decoupled “client” and “server” just a bit more. This decoupling is more evident when using closures to express behavior, e.g., for Enumerable
methods. In our case, we could write the following.
class TracerClient2
def initialize &tracer
@tracer = tracer
end
def do_work
@tracer.call "in do_work:"
# ...
end
end
client = TracerClient2.new {|message| p "block tracer: #{message}"}
For comparison, consider how we might approach substitutability in Scala. As a statically-typed language, Scala doesn’t support duck typing per se, but it does support a very similar mechanism called structural types.
Essentially, structural types let us declare that a method parameter must support one or more methods, without having to say it supports a full interface. Loosely speaking, it’s like using an anonymous interface.
In our Java example, when we declare a tracer object in our client, we would be able to declare that is supports trace
, without having to specify that it implements a full interface.
To be explicit, recall our Java constructor for TestClient
.
public class TracerClient {
public TracerClient(Tracer tracer) { ... }
// ...
}
}
In Scala, a complete example would be the following.
class ScalaTracerClient(val tracer: { def trace(message:String) }) {
def doWork() = { tracer.trace("doWork") }
}
class ScalaTracer() {
def trace(message: String) = { println("Scala: "+message) }
}
object TestScalaTracerClient {
def main() {
val client = new ScalaTracerClient(new ScalaTracer())
client.doWork();
}
}
TestScalaTracerClient.main()
Recall from my previous blogs on Scala, the argument list to the class name is the constructor arguments. The constructor takes a tracer
argument whose “type” (after the ’:’) is { def trace(message:String) }
. That is, all we require of tracer
is that it support the trace
method.
So, we get duck type-like behavior, but statically type checked. We’ll get a compile error, rather than a run-time error, if someone passes an object to the client that doesn’t respond to tracer
.
To conclude, LSP can be reworded very slightly.
If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2, then S is substitutable for T.
I replaced a subtype of with substitutable for.
An important point is that the idea of a “contract” between the types and their clients is still important, even in a language with duck-typing or structural typing. However, languages with these features give us more ways to extend our system, while still supporting LSP.