A message in OOP implies effects on entities[1], rather than mathematical functions. If you use mathematical functions in your code, how often they're evaluated isn't important:
print cos 0*cos 0 is equivalent to:
let x=cos 0 in print x*x
If you instead say that cos 0 is a message you send to an object, then you can't make that optimisation without knowing how the code you're calling works, because you'd be eliminating a message. cos 0 as a message may cause effects that you can't easily see as a caller; collapsing two messages to one can introduce different behaviour.
However, most of the time that you send an object a message it doesn't appear to perform an action, it just returns you some value. I'll name a method called for its value a function, and a method called for its effects simply a method.
If you can intercept effects that methods cause, then the method no longer causes effects, but describes them. In other words, if you notice an action and control whether it really happens, you've made the method appear to be a function, and you've made it easier to test, because you can observe all the actions that happen.
Such an interceptor, a body of code that intercepts effects as described could use metaprogramming of some sort, perhaps by changing classes directly at runtime, perhaps through compile-time techniques such as macros. However implemented, it would apply an automated transformation to the innards of methods. Let's see what we'd want that to generate, by writing the result of the transformation ourselves.
The interceptors in the following code vet, log or reject effects. I've made an interceptor return an interceptor on each call so that interceptors themselves can be implemented using return values rather than state changes. The code in this article is something a bit like Java, so that implementation details of a particular language don't get in the way.
public Interceptor writeSomeTextToFile(interceptor,text,file)
{
(interceptor,val out)=interceptor.new FileStream(file)
interceptor=interceptor.write(out,text)
return interceptor.close(out)
}
It looks doable, but pretty ugly. One part of it can be improved. If we change Interceptor so that it can has a type parameter, we can get rid of the tuple return that interceptor.create gave:public Interceptor[Nothing] writeSomeTextToFile
(Interceptor[Nothing] interceptor,text,file)
{
Interceptor[FileStream] out=interceptor.new FileStream(file)
Interceptor[Nothing] two=out.write(text)
return two.close(out)
}
It's really up to the Interceptor now what it does with that code. It could run the effects there and then, store them and never execute them, and our code would be none the wiser, because we haven't seen any mechanism for getting values out of the Interceptor.
The code processor to add interceptors would be pretty handy. Let's say we have an annotation that instructs some build tool or macro to do that, so now our source code looks like:
@WithInterceptor
public void writeSomeTextToFile(text,file)
{
val out=new FileStream(file)
out.write(text)
out.close()
}
Our unit test can look like:val passed={
val ceptor=new LoggingInterceptor()
return ceptor.invoke(writeSomeTextToFile,"hello","/etc/passwd")
.matches(list(creating(FileStream,"/etc/passwd"),
writing(FileStream,"hello"),
closing(FileStream)))
}
It's now clearly far easier to reason about and test the method, because you can trivially observe all of its side-effects. You could even decide which ones to allow, externally to the code, to implement a sandbox. In the usual case that you want to execute the effects immediately, you can still do that.
This is a very long-winded way of showing that methods are functions in disguise. Allowing methods to have difficult-to-notice side-effects makes them harder to reason about. It's harder to write tests for them, it's harder to think about them.
This interceptor technique could be applied to existing code, to compare the effects that a 1,000 line method has, to the effects that a refactored version of it has, in the same way we often write unit tests that compare returned values. It seems to make such a good regression test framework that I'd be very surprised if it didn't already exist for most mainstream languages.
The interceptor technique is very heavily based on monads (and may even just be a monad). Haskell programmers, the biggest monad users today, even have special syntax for the interceptor chaining; the translation I mentioned is built into their compiler. In fact, they can do all the things OO programmers do, but they make it harder to have unwanted side effects. To my knowledge though, Haskell's IO monad is largely implemented as a compiler hack, so it's hard to write the same tests for side effects that I've showed in this article.
[1] "in object-oriented programming languages such as Smalltalk or Java, a message is sent to an object, specifying a request for action." -- http://en.wikipedia.org/wiki/Message
2 comments:
Is this style different from mock testing (with frameworks like jMock and easyMock) in any way?
This whole concept is called Aspect Oriented Programming (AoP). It exists in most object languages now to some degree.
Post a Comment