The pass-through list is a programming-language feature intended to make it easier for programmers to modify functions to return additional values without breaking backwards compatibility, in the same way it is easy to modify functions to take additional parameters without breaking backwards compatibility. This is done by, in a sense, unifying the semantics of passing parameters to functions and returning values from functions.
Problem
Suppose I have an inverse
function that takes one number and returns one number, in some hypothetical untyped language:
func inverse(x)
return 1/x
println(2 * inverse(4))
I cannot change this function to return a second value, without breaking existing code:
func inverse(x)
return (1/x, "the inverse of " ++ x)
// This doesn't compile!
// inverse(4) returns a list, not a number.
println(2 * inverse(4))
On the other hand, I can easily modify the function to take an optional second parameter:
func inverse(x, verbose=false) // 'verbose' is optional
if(verbose) println("Calculating the inverse of " ++ x)
return 1/x // return 1/x
And code that depended on the old version of the function would not need to be modified:
println(2 * inverse(4)) // Still works!
It’s nice that I can modify inverse
to take additional parameters without breaking existing code. And it would be nice if I could also modify it to return additional parameters without breaking existing code. This would promote backwards compatibility: the ability for old code to work with newer versions of libraries – ignoring additional return values that it doesn’t use.
Proposed Solution
Here’s a proposal for a language feature that makes addition of return values backwards compatible. This proposal avoids adding additional syntactic complexity to the language using something I call implicit construction/deconstruction.
1. Functions Always Return Lists
In most languages, functions accept lists of arguments, but typically return single values. But if functions always returned lists, additional values could always be added to the end of the list.
Unfortunately, this would require extra code on the caller side to extract returned values from lists. But we could reduce that burden with a little syntactic sugar: allowing calling code to accept the values returned by a function using a deconstructing assignment:
// function that returns a list containing one number
func inverse(x)
return (1/x) // return a list with 1 item
// receive the value from `inverse` using deconstructing assignment
// the first element of the returned list is assigned to y
let (y) = inverse(4)
println(2 * y)
Now we can modify a function to return multiple values, but the caller can choose to ignore extra values:
func inverse(x)
return (1/x, "the inverse of " ++ x)
// ignore the second return value
let (y) = inverse(4)
// or use it if we want
let (z, explanation) = inverse(4)
2. Implicit Deconstruction
But this doesn’t completely eliminate the extra syntax burden: we still have to use a deconstructing assignment to receive the values returned by every function call. So a simple expression such as 2 * inverse(4)
would not be possible. We’d need to write:
let (result) = inverse(4)
println(2 * result)
But this can be solved with an implicit deconstruction rule: if a list returned by a function is not explicitly deconstructed with a deconstructing assignment, then it is implicitly deconstructed, with all but the first value being ignored.
So we can now call inverse
as if it returned a single value, and not a list:
// return a list with a number and a string
func inverse(x)
(1/x, "the inverse of " ++ x)
// implicit deconstruction: just use the first value from inverse
println(2 * inverse(4))
And now we have a language where it is easy to modify a function to return additional values, without breaking backwards compatibility.
3. Pass-Through Lists are Not Regular Lists
Implicit deconstruction means if we try to assign a list to a variable, we will only end up assigning the first value to the variable:
let myList = (1,2,3)
myList // evaluates to 1
But rather than creating syntax for taking a reference to a list, we will instead define pass-through lists as a kind of ephemeral type that only exists in the compiler. It is a syntactic construct use for “passing” values to or returning values from functions. The name pass-through list also reflects the idea that values “pass through” the parentheses as if the parentheses weren’t there.
Nesting pass-through lists would also be pointless, given consistent application of the implicit deconstruction rule:
let a = ((1,2),(3,4))
// given implicit deconstruction produces the same result as
let a = (1,2)
// which produces the same result as
let a = 1
Since pass-through lists can’t be referenced, a language that uses them probably needs an actual list type, perhaps constructed using square brackets instead of parentheses.
let fruit = ["applies","oranges","cherries"]
let x = fruit
x // evaluates to ["applies","oranges","cherries"]
Pass-Through Lists and Parentheses
Using parentheses as the syntax for constructing a pass-through lists just happens to make parentheses do other things we expect from them.
For example, we can now use parentheses for specifying the order of operations. The expression a*(b+c)
creates a single-item pass-through list containing the value of b+c
, which is then extracted with an implicit deconstruction and multiplied by a
, resulting in the intended order of operations.
Summary
By requiring functions to return lists, we’ve made code more robust with respect to changes to function signatures. The implicit construction and deconstruction rules remove the syntactic overhead from this.
These rules make pass-through lists into a kind of ephemeral type that cannot be referenced, requiring support of a more conventional list type.
Using the same semantics for passing/returning values to/from functions could enable some interesting language features, such as named and optional parameters, to be implemented consistently on both sides of the function interface.
In my next post, I discuss how adding partial application and a new feature I call folded application to a language, along with the implicit construction and deconstruction rules, result in a language where curried and un-curried versions of functions are functionally identical.