andremm / typedlua

An Optional Type System for Lua
563 stars 53 forks source link

idea: destructuring of union types in multiple-assignment #80

Open hishamhm opened 8 years ago

hishamhm commented 8 years ago

(Leaving this here for consideration, perhaps in a distant future. :-) )

Here's an idea of a construct that would make writing code with multiple-assignment and error checking much cleaner. I'll present via an example which should be self-explanatory:

local foo, bar | nil, err = func()
if not foo then
   print("func failed: " .. err)
end

Using the name of a return value like bar in the above example to mean not what the variable name says but an error message is an awkward part of using Lua, and having types around makes something like this even more desirable.

Note the use of nil in the lvalue. This would be a shorthand for _: nil. A rule for valid destructuring should be that at least one variable would have to be explicitly typed in an unambiguous manner (i.e. the nth entry of the multiple-return tuple so that no two cases have the same type in the nth entry). Usually, having a nil entry like this would suffice to satisfy this rule.

Level 1: Minimal implementation

Serves only a syntactic aid to be able to write err instead of bar where it's more logical to.

Merely treat err as an alias for bar at the compilation stage, generating this code:

local foo, bar = func()
if not foo then
   print("func failed: " .. bar)
end

No flow analysis required. It wouldn't even be necessary to enforce the rule for valid destructuring. It's simplistic, but already better than plain Lua.

Using bar and err interchangeably in the code regardless of the value of foo would look a bit odd, though. With flow analysis enforcing the disciplined use of the variables (you can only use err if you tested that foo is nil), the types of bar and err could be simpler from the get-go.

Another con is that using the debug library to inspect locals would reveal the trick.

Level 2: Generating all locals

Generate this code:

local foo, bar, err; local _t1, _t2 = func(); foo, bar, err = _t1, _t2, _t2;
if not foo then
   print("func failed: " .. err)
end

Solves the debug.getlocal problem. The extra multiple-assignment of locals after the function call cannot produce any side-effects and cannot produce runtime errors, so it will be essentially invisible at runtime (in the sense that, if generated right beside the end of the assigment, it cannot affect error messages).

The same observation on flow analysis above applies.

A complication when we think of these values as being smartly typed and not just aliases: boolean types. Typing if not foo for a situation like the above is a lot more idiomatic than is foo ~= nil or is type(foo) == "string". Adding a restriction such as "can't disambiguate on boolean vs. nil" would be problematic, since "true or nil, message" is typical idiom too.

Level 3: Only assign to the correct case

The solution for this complication might be something else entirely. In an even nicer world, what I'd like to write instead in my code would be this:

local foo, bar | nil, err = func()
if err then
   print("func failed: " .. err)
end

This would mean generating code like this:

local foo, bar, err; local _t1, _t2 = func(); if _t1 == nil then err = _t2 else foo, bar = _t1, _t2 end;
if err then
   print("func failed: " .. err)
end

Note that, in this scenario, if the type of func() is * -> (boolean, * | nil, string), then the original code with if not foo then should cause a compiler error on the use of err, since if not foo can't work as a type assertion when foo is boolean. A solution for this would be typing func() as * -> (true, * | nil, string) (which I suspect would be nicely inferrable in most functions which use return true, value and return nil, "message" in their implementations.)

When using literal types it should be possible to use literals in the lvalue (such as nil, true or even numbers and strings) and generate equality comparisons in the generated if code (destructuring tables would open a whole other can of worms because of the risk of triggering metamethods in the == test, but testing for type(_t1) == "table" is harmless). Disambiguating with explicit type annotations would generate type() tests. For example, given a function f with type () -> (string, number | number, boolean) this

local a:string, b | c, d = f()

would generate this

local a, b, c, d; local _v1, _v2 = f(); if type(_v1) == "string" then a, b = _v1, _v2 else c, d = _v1, _v2 end;

Bonus advanced usage:

Assuming the type system has built-in knowledge that pcall() is a special kind of apply, then this kind of usage should be possible as well:

local ok, foo, bar | ok, nil, err | nil, perr = pcall(func)
if ok then
   if foo then
      usefoobar(foo, bar)
   else
      print("func failed: " .. err)
   end
else
   print("pcall failed: " .. perr)
end

Note nil being used in two different places of a three-way union to satisfy the disambiguation rule. Also, note that ok is declared twice: this should be allowed if the name appears in the same position of the tuple and has the same type.

The relevant line above would be translated in Level 2 to:

local ok, foo, bar, err, perr; local _v1, _v2, _v3 = pcall(func); ok, foo, bar, err, perr = _v1, _v2, _v3, _v2, _v3;

or in Level 3 to:

local ok, foo, bar, err, perr; local _v1, _v2, _v3 = pcall(func); if _v1 == nil then perr = _v2 elseif _v2 == nil then ok, err =  _v1, _v3 else ok, foo, bar = _v1, _v2, _v3 end;

Establishing a typed idiom

In a language supporting the above feature, I believe the use of tests like if type(x) == "string" then would reduce a lot.

Especially in Level 3, there would be little use for type() as a director of control flow:

n:number | s:string = func()
if n then
   ...
end

I don't believe the performance impact of the extra generated code of Level 3 would be significant; typical use would incur in a nil test and a few extra local assignments; in the cases where type() tests would be generated, we'd usually have to write those in the code anyway, they just become implicit.

(Whew! That was a lot, but I thought I'd share this spur of sudden creativity here than to let it fizzle away. :-) )