Wednesday 23 July 2014

CFML: Finally had a chance to look at Sean's version of case()

G'day:
This took way longer to revisit than I intended. Apologies to Sean, as it might have seemed like I solicited some code from him and then ignored it. And, indeed, that was mostly the case other than an initial peek and a "hang on... what? How the? But... oh, I think I see..." and a conclusion my assessment of it needed more than 2min.

Right, so a coupla weeks back I wrote and article "Some more TestBox testing, case/when for CFML and some dodgy code". This demonstrated some code (and its tests) that I was messing around with that implemented a case/when/then/else function in CFML. That'll make more sense if you read the article.

Sean had a look at it and could see some simplifications which could be made, and popped a revised version through to me via Github ("adamcase / case.cfm"). As I alluded to above, my initial parsing of the solution just made my eyebrow raise - Spock-like - and other than that I saw what he was doing, but not how it worked (and it did work; the relevant tests still passed). Today I sat down and had a look at the code, and made myself understand it. And I like it.

Here it is:

// case.cfm
struct function case(){
    var requireCondition = function(condition){
        condition ?: throw(type="MissingArgumentException")
        !isBoolean(condition) && !isCustomFunction(condition) && !isClosure(condition) &&
            throw(type="InvalidArgumentException")
    }
    var requireValue = function(value){
        value ?: throw(type="MissingArgumentException")
        !isCustomFunction(value) && !isClosure(value) &&
            throw(type="InvalidArgumentException")
    }
    return {
        when = function(condition){
            requireCondition(argumentCollection=arguments)
            if ( isBoolean( condition ) ? condition :
                 ( condition() ?: false ) ) {
                return {
                    then = function(value){
                        requireValue(argumentCollection=arguments)
                        var ender   = {
                            end  = function(){
                                return value() ?: javaCast("null","")
                            }
                        }
                        var whenner = { }
                        var thenner = {
                            when = function(_){ return whenner },
                            else = function(_){ return ender },
                            end = ender.end
                        }
                        whenner.then = function(_){ return thenner }
                        return thenner
                    }
                }
            } else {
                return {
                    then = function(_){
                        return {
                            when = case().when,
                            else = function(value){
                                requireValue(argumentCollection=arguments)
                                return {
                                    end = function(){
                                        return value() ?: javaCast("null","")
                                    }
                                }
                            },
                            end = function(){ return javaCast("null","") }
                        }
                    }
                }
            }
        }
    }
}

The chief difference between Sean's version and my version is that he made a very salient observation: my code continues to enforce the validity of the when() condition and the then() (or else()) value even after the condition had been met and the value had been decided upon. Or similarly I was validating the value of a then() even when its related when() condition was false. To clarify, here's an example:

result = case()
            .when(thisIsFalse).then(value1)
            .when(thisIsTrue).then(value2)
            .when(thisIsAlsoTrue).then(value3)
            .else(value4)
        .end()

Here the green bits are things we care about, the red bits are things we don't need to care about.

  • we care about the when() condition being valid until a condition is met. After that, it doesn't matter, because we have already found our value. So we can safely stop validating or paying any attention to subsequent conditions;
  • we do not care about the value of a then() if the associated when() condition is false;
  • in fact the only value we care about is the first one immediately following a true when() condition; or the final else() if no conditions get met. The rest of the time we can ignore them completely. They don't need to be "valid" as we will not be using them.

So Sean uses two different versions of each of when(), then() and else().

Also interestingly he dispensed with a lot of my code which determined which functions to return, and in the process made the code a lot tighter. And, thirdly, a side effect of all this is that there's no need to pass-the-parcel with flags showing if the condition had been met, and if they value had been met. He simply remembers which value function was the one from the appropriate then() or else() call, and returns it from end(). As the when() and then() calls only do anything for as long as they need to to find that value, there's no need to tell subsequent when() / then() calls whether they need to execute or not. They can just execute, because they don't do anything. Simple.

Let's revisit the code again, with some annotations.

var requireCondition = function(condition){
    condition ?: throw(type="MissingArgumentException")
    !isBoolean(condition) && !isCustomFunction(condition) && !isClosure(condition) &&
        throw(type="InvalidArgumentException")
}
var requireValue = function(value){
    value ?: throw(type="MissingArgumentException")
    !isCustomFunction(value) && !isClosure(value) &&
        throw(type="InvalidArgumentException")
}


Sean's reversed the logic here. I had this:

isBoolean(condition) || isCustomFunction(condition) || isClosure(condition) ? true : throw(type="InvalidArgumentException")

I like Sean's approach because it gets rid of the ?: and the placeholder true, but has the added bonus of short-circuiting the logic tests. As soon as the condition is deemed valid, that's it. If, on the other hand, we get all the way to the end... the exception still raises. Nice.

Those were the easy bits.

return {
    when = function(condition){

case() always needs to return just a when(). Because the only thing that's valid to call from a case() is a when(). One cannot do case().then(), or case().else(), or case().end(). So we need no logic as to which functions to return here.

if ( isBoolean( condition ) ? condition :
     ( condition() ?: false ) ) {
    return {
        then = function(value){
            // [etc]
        }
    }
} else {
    return {
        then = function(_){
            // [etc]
        }
    }
}

Here it gets clever. Everything about how things process changes depending on whether the condition is true or not. If the condition is true, we need to grab the value from the following then() call, and we don't need to do further processing after that, until we get to end() (which will return this value). However if the condition is false, our then() can just be a placeholder (as we already know we're not interested in its value), but we do need to still remember how to do a when(), then(), else() should we have further calls to them which might need to do something.

Let's look at the then() created by the true path:

then = function(value){
    requireValue(argumentCollection=arguments)
    var ender = {
        end = function(){
            return value() ?: javaCast("null","")
        }
    }
    var whenner = { }
    var thenner = {
        when = function(_){ return whenner },
        else = function(_){ return ender },
        end = ender.end
    }
    whenner.then = function(_){ return thenner }
    return thenner
}

  • first it needs to validate its value (as per further up)
  • now a then() can be followed by an end() (when().then().end()), so we need to define that. What end() will (always) do is to return the value() function's value. Or null if there was no condition met, so no value to run.
  • and if the condition was true, we have finished using when(), then() and else(), so they can just be stubbed.
  • There's a slight bit of recursion here: subsequent calls to when() will still need to return a then(), and that then will need to return when(), else() and end(). So we use references to build all the eventual responses from the stubbed functions and the real end() function. It's this bit that did my head in for a while.

Now for the false path:

return {
    then = function(_){
        return {
            when = case().when,
            else = function(value){
                requireValue(argumentCollection=arguments)
                return {
                    end = function(){
                        return value() ?: javaCast("null","")
                    }
                }
            },
            end = function(){ return javaCast("null","") }
        }
    }
}

Here the condition wasn't met, so:
  • whilst we do need to return a then() function, we don't need it to process, so it's stubbed.
  • However whilst stubbed, that then() does still need to all fully-functional processing to resume with the next when() call in the chain, and the easiest way to set what that does is... call the case() function afresh and use its when() function. Very clever.
  • We might - at this point - need a functional else() function (we didn't on the true path, as it would never be needed, as the when() condition had been met), so it's defined to simply "return" the result of its value argument (or explicitly null if it returns no value)...
  • ... by implementing an end() function which returns its value().
  • Finally the end() function which might follow a false-conditioned when() will - intrinsically - not have ever received a value(), so a null can be returned from that too.
I wonder if that explanation made sense? Having explained in writing, it makes perfect sense to me now, and I'm bloody impressed. It's amazing what one can do with some nested references and a small amount of recursive thinking. I am certain I would have never - in a million years - come up with a solution like this. Nice one, Sean!

--
Adam