munificent / ui-as-code

Design work on improving Dart's syntax for UI code
BSD 3-Clause "New" or "Revised" License
121 stars 11 forks source link

control flow elements: alternative to "if" #22

Open tatumizer opened 6 years ago

tatumizer commented 6 years ago

TL;DR: while passing parameters, instead of new form of "if", consider standard form of ternary operator cond? v1 : v2, where each of v1,v2 might have a special value "default". Though I'm pretty sure this (trivial) idea was considered and rejected, let's take another look at it.

Let's revisit some examples from the proposal.

// example 1
var map = {
  key1: value1,
  if (isTuesday) key2: value2,
  key2: value3
};
// example 2
function(
  arg1,
  if (isTuesday) (arg2, anotherArg),
  arg3
);
// example 3
Widget build(BuildContext context) {
  return Container(
    height: 56.0,
    padding: const EdgeInsets.symmetric(horizontal: 8.0),
    decoration: BoxDecoration(color: Colors.blue[500]),
    Row(
      IconButton(
        icon: Icon(Icons.menu),
        tooltip: 'Navigation menu',
        if (!isWindows) padding: const EdgeInsets.all(20.0),
      ),
      Expanded(child: title),
      if (!isAndroid) IconButton(icon: Icon(Icons.search), tooltip: 'Search')
    ),
  );
}

I think it's a far-reaching evolution of dart syntax. I understand the natural desire for any new construct to have a broad application - quoting Bob: "But to the degree that we can widen the scope of the syntax without watering it down, we should".

And philosophically, I am in full agreement with the principle. However, practically, there's a number of caveats. First, indeed, it looks like uncanny valley to me. This new form of "if" is a bit artificial and probably even confusing. Second, when we talk about broad application of some feature, I think it makes sense to consider each potential use case along with corresponding statistical weight. And for the issue under discussion, I suspect that example 3 outweighs everything else by a large factor. If this is so, perhaps examples 1 and 2 do not really widen the scope of the new construct but rather create an illusion of widening the scope? This is certainly a matter of debate, but to keep it short, here's the suggestion: let's consider only a much more modest feature for skipping parameters in parameter list, so that examples will look like

// no example 1 - there's no parameter list there
// example 2
function(
  arg1,
  isTuesday? arg2: default,
  isTuesday? anotherArg : default,
  arg3
);
// example 3
Widget build(BuildContext context) {
  return Container(
    height: 56.0,
    padding: const EdgeInsets.symmetric(horizontal: 8.0),
    decoration: BoxDecoration(color: Colors.blue[500]),
    Row(
      IconButton(
        icon: Icon(Icons.menu),
        tooltip: 'Navigation menu',
        padding: !isWindows ? const EdgeInsets.all(20.0) : default
      ),
      Expanded(child: title),
      !isAndroid ? IconButton(icon: Icon(Icons.search), tooltip: 'Search') : default
    ),
  );
}

"default" is a reserved word in dart, so it won't cause any backwards-compatibility problems. It will cover the majority of practically significant cases. And the meaning is easy to describe: "I defaulted on my obligation to pass this parameter" :)

Please note that in case argument blocks proposal is implemented, it will become a more general method to express selective parameter passing with control flow, so the effort spent on adding this feature, especially with too broad generalization, might prove to be redundant.

tatumizer commented 5 years ago

Variant: instead of "default", use "pass" or "nop" or "noop"

yjbanov commented 5 years ago

The if makes the syntax consistent across maps, lists, and argument lists. In fact, it seems the ternary expression is not applicable to example 3, where a collection (presumably rest args?) are passed to Row and so there's no default value. What would default mean there?

tatumizer commented 5 years ago

I agree that "default" is not a very good word. Probably "noop" is better, and applicable in every context. Having "noop" operator is not a bad idea regardless - e.g., you can use it while writing empty loops, to be more explicit. In a context of passing parameters or defining map entry, it might be treated as an operator for skipping the assignment. In contexts where it is not applicable, it will cause an error automatically (because it returns void)

var x = a > 0 ? 42 : noop; // standard error: the value of a variable cannot be void

Dart (unlike other languages) already supports a special case of ternary operator allowing "throw" clause, as in

var x = a < 0 ? 42: throw "argument error";
b > 0 ? throw "foo" : throw "bar"; // also a valid form

So we at least have some precedent of sorts - in the sense that even with "throw", it looks unusual for java programmer; we can add another "unusual" case, to help "throw" to feel less lonely :)

tatumizer commented 5 years ago

My main objection against "if" is (as already noted in the write-up) that we already have "if" statement, and now we are adding "if" expression. But we already have "if" expression (ternary operator). The third one would be an overkill. (e.g. python, which has "if" expression, has no "?:" operator - they simply chose an "if" notation for it).

Also, I am not sure too many users will find this construct aesthetically pleasing:

var map = {
  key1: value1,
  if (isTuesday) key2: value2,
  key2: value3
};

There are other things, like

function(
  arg1,
  if (isTuesday) (arg2, anotherArg),
  arg3
);

It won't work with named parameters. So we would have to introduce several syntactic rules for "if", depending on the context. (BTW, it's not clear whether new "if" can be nested in another "if").

The noop doesn't have these limitations - ternary operator is familiar, can be nested, and doesn't look especially weird.

Example of nested expressions:

function(
   key1: a > 0? 42 : 
            b > 0? 43 : noop
}

Again, we can argue about the best choice of the word - if "noop" is too unfamiliar, we can try other variants (skip, pass, etc),

yjbanov commented 5 years ago

@tatumizer The nesting a ? b : c ? d : e does not have much effect. It acts as else if. It's more a chain than nesting. The control flow collections spec does not mention else if (@munificent, is this intentional?). But if support is added then it won't have this disadvantage.

I think if/else is more readable than the conditional expression and it's friendlier to new developers. It is shorter when there is no else. The ? is used to deal with nulls, and it will be used even more with NNBD. The : is used to separate key from value in a map or named argument.

In fact your last example shows the disadvantage of the conditional. Try looking at it from the perspective of someone new to programming. It's a lot of overloaded operators to parse through. Compare that to:

function(
  if (a > 0)
    key1: 42
  else if (b > 0)
    key1: 43
)
yjbanov commented 5 years ago

Also, how about reusing void rather than introducing noop?

tatumizer commented 5 years ago

Yes, I was thinking about "void" too :) It's a good word indeed, I was afraid it can cause some parsing conflicts with another meaning as a data type, but my worries were probably exaggerated. Also would be interesting to know what @munificent thinks about it :)

tatumizer commented 5 years ago

There's another concern that new type of "if" will be made redundant if argument block is implemented. Argument block is a very promising idea, it adds a lot of power into the language IMO. (Though I think it argument block should be applied to constructors only. I'm afraid in other contexts it might be confusing and ever weird).

munificent commented 5 years ago

There's a lot to reply to here. :)

First, I think we should distinguish between control flow in argument lists and in collection literals. The two contexts are pretty different. For argument lists (ignoring rest params for the moment), the control flow always produces one value, it's just a question of which value. For collections, control flow may produce zero, one or more elements.

This is the precise reason why ?: and other expression forms aren't a good fit. An expression always evaluates to a single value. The ... spread syntax and control flow proposals produce a variable number of values. You can think of them more like generators or the way execution works in Icon.

OK, having said that. Re:

function(
  arg1,
  isTuesday? arg2: default,
  isTuesday? anotherArg : default,
  arg3
);

This is technically even longer than just using : null, and does the exact same thing in almost all contexts. If we do non-nullable types, there's a good chance we'll change the behavior of default values such that passing null explicitly causes the default value to be used, which would make this syntax here entirely redundant.

Also, as I said above, this still only lets you pick one of two values. The interesting thing about control flow is being able to vary the number of arguments/elements provided.

My main objection against "if" is (as already noted in the write-up) that we already have "if" statement, and now we are adding "if" expression.

It's not an expression. It's an... element? It can only be used in a context where zero or more values might appear and it may expand to a variable number of values.

It won't work with named parameters. So we would have to introduce several syntactic rules for "if", depending on the context.

It should work with named parameters, but there are general problems with using the syntax in argument lists. In particular, most of the value proposition goes away without rest parameters. Due to complexity and Flutter not strongly enthused about it, we're not currently planning to add rest params. Because of that, we're also not planning to do if in argument lists. We are still looking at doing it in collection literals.

But, if we did extend it to argument lists, then it would work with named arguments since that's a primary motivation. The single argument case is easy:

function(if (condition) named: argument);

For multiple arguments, it's more difficult. The parenthesis syntax I initially sketched out doesn't play nice if we add tuples to the language, since it collides with that syntax. But, on the other hand, we may be able to combine tuples with named fields and spread:

function(
  if (condition) ...(
    named: argument,
    another: argument
  )
)

The control flow collections spec does not mention else if (@munificent, is this intentional?).

else if is implicitly supported. The then and else bodies of an if are themselves "elements" and not expressions, which means you can nest if arbitrarily. Languages like C doesn't explicitly support else if — it falls out naturally from the else statement simply being another if statement.

There's another concern that new type of "if" will be made redundant if argument block is implemented. Argument block is a very promising idea, it adds a lot of power into the language IMO.

I agree argument blocks are really interesting. And I do worry about adding both incremental syntax and something more radical causing more redundancy than if we just went all the way to something radical.

But I haven't been able to come up with any good proposal for a block syntax that works well for Flutter. Block syntax works great for imperative, mutating code. That makes since, since a block is a collection of statements, not expressions. But Flutter code is mostly side-effect free expressions that construct widgets, so it's not clear to me that using a block notation is a good fit.

tatumizer commented 5 years ago

if you add spread operator, then the language doesn't gain much from "if" element, because then you can always write

  var extraParams = condition? {
    named: argument,
    another: argument
  }: {};
  function(
     ...extraParams,
     // other arguments
  )
)

EDIT: dart really needs spread operator like this to call constructors, even at the cost of sacrificing some compile-time type safety. For regular (non-constructor) functions, there's at least a workaround (method "apply"), but it's not available for constructors. Yeah, I know, too many unknown unknowns in this labyrinth :)

tatumizer commented 5 years ago

We are still looking at doing it in collection literals.

For collection literals, there's much simpler solution:. by analogy with lists, define concatenation operator for maps. For lists, we already can write:

var list = [1, 2, 3] + cond?[5, 6]:[]

Same is possible for maps:

var map = {
  key1: 42.
  key2: 0
}+ cond? {
  key3: 5
}:{}

Precedent: scala (operator ++ for both lists and maps)

yjbanov commented 5 years ago

The +, ?, :, [], {} all look too cryptic. I don't like the readability of that at all.

The addIf(cond, f) is expensive, as it requires that we allocate closures just to add an element or two to a list.