Saturday, 7 May 2022

ColdFusion: probable bug with the implementation of the rest operator

G'day:

ColdFusion 2021 added the spread and rest operators. These are implemented as two different usages of .... In this article I am going to be making an observation about how the implementation of the rest operator is incomplete and faulty. I first raised this in the CFML Slack channel, but I've now wittered on enough about it to copy it to here.

What does the rest operator do? The docs say:

[The] Rest Operator operator is similar to [the] Spread Operator but behaves in [the] opposite way, while Spread syntax expands the iterables into individual element[s], the Rest syntax collects and condenses them into a single element.

ibid.

That's not so useful out of the context of a discussion on the spread operator, that said. So a test should clarify:

function testRest(first, ...rest) {
    return arguments
}

function run() {
    describe("Testing CF's rest operator, eg: function testRest(first, ...rest)", () => {
        it("combines all latter argument values into one parameter value", () => {
            actual = testRest("first", "second", "third", "fourth")
            expect(actual).toBe([
                first = "first",
                rest = ["second", "third", "fourth"]
            ])
            
             writeDump(var=actual, label="actual/expected")
        })
    })
}

tinyTest.runTests()
  • The rest operator is ... as a prefix to the last parameter in a function signature.
  • Any arguments passed from that position on are combined into that one parameter's value.

So far so good. So what's the problem? I had a use case where I needed this to work for named arguments, not positional ones:

it("combines all latter argument values into one parameter value when using named arguments", () => {
    actual = testRest(first="first", two="second", three="third", four="fourth")
    expect(actual.first).toBe("first")
    expect(actual).toHaveKey("rest")
    expect(actual.rest).toBe({two="second", three="third", four="fourth"})
})

Same as before, just the arguments have names now. This test fails. Why? Because CF completely ignores the rest operator when one uses named arguments. This "passing" test shows the actual (wrong) behaviour:

it("doesn't work at all with named arguments", () => {
    actual = testRest(first="first", two="second", three="third", four="fourth")
    expect(actual.first).toBe("first")
    expect(actual?.rest).toBeNull()
    expect(actual.keyList().listSort("TEXTNOCASE")).toBe("FIRST,four,REST,three,two")
    
     writeDump(var=actual, label="actual")
     writeDump(var={first="first", rest={two="second", three="third", four="fourth"}}, label="expected")
})

As I said, I raised this on the CFML Slack channel. I got one useful response:

I think it's unusual to use rest with named parameters, but, CF supports named parameters as well as positional so I would expect it to work. I'd settle for it being fully documented though as only working with positional.

John Whish

Fair point, and as I said in reply:

My reasoning was remarkably similar. I started thinking "it's a bit unorthodox to use named arguments here", but then I stopped to think… why? And my conclusion was "because in other languages I use this operation there's no such thing as named arguments, so I've not been used to thinking about it", and that was the only reason I could come up with for me to think that. So I binned that thought (other than deciding to ask about it here).

The thing is there's no good reason I can think of that named arguments should not work. One cannot mix named and positional arguments which would be one wrinkle, so it's 100% reliable to take a set of named arguments, and match the argument names to the parameter names in the method signature. There is no ambiguity: any args that have the same name as a param are assigned as that parameter value. All the rest - instead of being passed in as ad-hoc arguments - are handled by the ... operation.

I cannot see a way that there's any ambiguity either. It's 100% "match the named ones, pass all others in the rest param".

What happens if the method call actually specifies a named argument that matched the name of the "rest" param? Same as if one specifies a positional argument in the position of the "rest" param: it doesn't matter. all arguments that don't match other named params are passed in the rest argument value.

I also think that if for some reason named arguments are not supported for use on function using the rest operator, then an exception should be thrown; not simply the code being ignored.

And whatever the behaviour is needs to be documented.

However one spins it, there are at least two bugs here:

  • Either it should work as I'd expect (or some variation thereof, if I have not thought of something), or it should throw an exception.
  • The behaviour should be documented.

I have not raised tickets for these as I'm not really a user of CF any more, so I don't care so much. Enough to raise it with them; not enough to care what they do about it. But CFers probably should care.

NB: I did not realise Lucee did not support the spread and rest operators at all, so I had to take a different approach to my requirement anyhow. I've not decided on the best way as yet.

There is a ticket for them to be implemented in Lucee: LDEV-2201.

The tests I wrote for this can be run on trycf.com.

Righto.

--
Adam