language icon indicating copy to clipboard operation
language copied to clipboard

Allow `var type id` in non-declaring constructor parameter lists?

Open lrhn opened this issue 1 year ago • 8 comments

@munificent has a glorious write-up of declaring constructors: https://github.com/dart-lang/language/pull/4169

It allows var int x as a parameter in declaring constructors, as a way to introduce a mutable instance variable. :tada: The grammar allows 'var' <type> <identifier> in every parameter, and the feature spec then disallows a var+type from occurring in a non-declaring constructor using prose (it's a compile-time error if a parameter containing var occurs in a non-declaring constructor parameter list).

I suggest we just allow it everywhere. It's easier than disallowing it, and the meaning is obvious. It makes var and final (finally) be symmetric. It is unnecessary, because var int x means the same as int x, but var x and x also means the same thing in parameters, and we allow both.

One argument against is that if anyone does write var int x as a normal parameter, someone else might initially read it as a field parameter, even though it's not a declaring constructor. Most likley, nobody will write that. They don't write var x today, even though it's valid. They don't write final int x in parametes, even in code that insists on every local variable being final if it can be. I don't think we'll see an up-tick in occurrences of final int x in normal parameters, or occurrences of var int x at all, so the confusion risk should be minimal. That also means that there is no benefit to allowing the syntax. (Hypothetically, allowing var int x everywhere means that you can safely change any final to var in a variable declaration, without having to do anything else. Might be easier for code generators. But I don't expect users to just blindly replace final with var without looking at a declaration.)

It does mean that the same syntax means different things in different places, which is usually bad. That's already the case for final int x in declaring constructors, and this way, the issue would be symmetric in var and final. It'll be one less exception to remember, you can think of var and final as symmetric. (Rather than: "You can use var int x and final int x in declaring constructors to declare a var/final field. In other parameters var int x is an error. In other constructors final int x means a final parameter.", it becomes "You can use (var|final) int x in declaring constructors to declare a var/final field. In other parameters, (var|final) int x just introduces a var/final parameter variable." It's symmetric, if you understand the difference between var and final, you can apply the consistenly, so I'd count this as a complexity score of 2 vs 3 when disallowing var int x elsewhere. It all counts!)

It's only parameters, though, you can't actually declare a field as var int x;. But you can write var (int x) = .... I'd be fine with allowing var int x; for variables too. (Allowing var int x; may be a stepping stone to final-by-default, if we ever want that. I do, don't know about "we".)

Options:

  • Allow var int x as parameter everywhere. (My proposal.)
    • Go all in: Allow it in variable declarations too, late var int x = 42;. (Maybe, eventually.)
  • Disallow in parameters not in a declaring constructor. (Specified in feature spec.)
    • Also, eventually, disallow final in non-declaring constructor parameters. (Suggested in feature spec.)
    • Or lint against it. (Suggested in feature spec.)

WDYT? @dart-lang/language-team

lrhn avatar Nov 26 '24 11:11 lrhn

One argument against is that if anyone does write var int x as a normal parameter, someone else might initially read it as a field parameter, even though it's not a declaring constructor.

We're talking about the ability to put the same keywords in many different locations, meaning one thing in some locations, another thing in other locations, or even nothing in some locations. I tend to think that this will worsen the readability of the code. Even in the situation where none of those "var with no effect" keywords exist in a million lines of code, it is still necessary for developers who are working with this code to think about the meaning of var every single time they encounter it in a parameter list.

Similarly for final, which might mean "make this parameter final" or "this parameter implicitly induces a final instance variable (and make this parameter final)".

I'd prefer to use a fresh keyword for the new feature. This means that developers will need to look it up if they don't know about this feature, but this is better than the situation where it's a well-known word that just slips through because it is completely unclear that it triggers a new feature.

Even better, the fact that parameters in general induce instance variables should be implied by the context ("we're in a class header, so this is a primary/declaring constructor", or "this declaration starts with this, so it's a primary/declaring constructor"), and then the parameter-that-doesn't-induce-a-variable would have a keyword that stands out and refers to the actual feature. Perhaps parameter, param, parm, or par would remind the reader that this is "just a parameter"; alternatively novariable or novar could remind the reader that this parameter does not induce a variable.

This yields the most concise form for the case where most or all parameters of a primary/declaring constructor are introducing a variable. Conciseness is the point here.

eernstg avatar Nov 28 '24 14:11 eernstg

The problem of two different interpretations of "final/var" exists only in the case where the implementation of the "declaring constructor" is placed in the body of the class. If it resides in the header, these two interpretations become the same. E.g. if we have

class Foo(final int x, var int y) {
   int get sum => x + y;
}

then the parameters x, y, viewed from the inside of the class, look like they are coming from a kind of "closure". The fact that the fields for x and y are created as part of the object structure is an implementation detail - it doesn't change the semantics.

That's how Kotlin implements primary constructors (see Kotlin's documentation). It covers most of the features expected from the primary constructors (except that there's no analog to novar). It's impossible (and not desirable) to copy the exact syntax from Kotlin, but a reasonable equivalent can be found IMO.

ghost avatar Nov 28 '24 23:11 ghost

I suggest we just allow it everywhere. It's easier than disallowing it, and the meaning is obvious. It makes var and final (finally) be symmetric. It is unnecessary, because var int x means the same as int x, but var x and x also means the same thing in parameters, and we allow both.

It only introduces an instance field in a declaring constructor. If we allow the syntax everywhere, then the meaning becomes less obvious because (as @eernstg notes), it now means something important in one context and nothing at all in others.

I agree that symmetry would be nice. I don't love this, which is what the current language plus declaring constructors implies:

                          var              final
------------------------- ---------------- --------------
Declaring constructor     declares field   declares field
Other function            error            makes parameter final

But I don't think this is much better:

                          var              final
------------------------- ---------------- --------------
Declaring constructor     declares field   declares field
Other function            does nothing     makes parameter final

There's still an asymmetry and still multiple meanings for both var and final based on their context.

I am increasingly worried about the complexity and number of edge cases in the language. I would love to try to claw back some simplicity when we can. I think we could sacrifice allowing final for parameters and make it an error. The fix is completely trivial: delete the final keyword.

Already, final on parameters is not common:

-- Parameter (1387315 total) --
1373647 ( 99.015%): non-final  =================================================
  13668 (  0.985%): final      =

Yes, there is a tiny loss of expressiveness in that you can't prevent a parameter being reassigned inside the body of a function. Who cares?

In return, the semantics are:

                          var              final
------------------------- ---------------- --------------
Declaring constructor     declares field   declares field
Other function            error            error

It's hard to get clearer and more symmetric than that. You can only use the modifiers in a context where they mean something. When you use them, they mean exactly one thing.

munificent avatar Jun 04 '25 23:06 munificent

Yes, there is a tiny loss of expressiveness in that you can't prevent a parameter being reassigned inside the body of a function. Who cares?

I'd love to get rid of final for non-reassignable parameters, it pollutes the API just for a minor restriction on the implementation. I volunteer to clean up google3 if it goes in as a breaking change :)

davidmorgan avatar Jun 05 '25 08:06 davidmorgan

I'd love to get rid of final for non-reassignable parameters, it pollutes the API just for a minor restriction on the implementation.

By that, do you mean make parameters implicitly final by default? If I had a time machine, I wouldn't be opposed to that, but doing so now would be hugely breaking for minimal benefit. Over 99% of parameters in Dart today are not final and I can't say I have heard anyone mention bugs in their code from that default.

munificent avatar Jun 05 '25 17:06 munificent

The bigger question is how many of those parameters are actually modified. If it's close to zero percent, then maybe it's not so breaking to make them final by default.

I expect it to be larger even if just because of initialization like foo ??= computedDefault(); or end = RangeError.checkValidRange(start, end, length);.

lrhn avatar Jun 06 '25 06:06 lrhn

I'd love to get rid of final for non-reassignable parameters, it pollutes the API just for a minor restriction on the implementation.

By that, do you mean make parameters implicitly final by default? If I had a time machine, I wouldn't be opposed to that, but doing so now would be hugely breaking for minimal benefit. Over 99% of parameters in Dart today are not final and I can't say I have heard anyone mention bugs in their code from that default.

No, I meant to keep them not final and there is no way to get final.

Reassigning parameters is convenient for providing defaults, so reassignable always seems good to me.

Thanks.

davidmorgan avatar Jun 06 '25 06:06 davidmorgan

The bigger question is how many of those parameters are actually modified.

The script I wrote is pretty hacky, but assuming it does the right thing...

-- Assigned (751557 total) --
 745384 ( 99.179%): no   =======================================================
   6173 (  0.821%): yes  =

So almost no parameters are assigned. (I skimmed the ones that were and, as expected, many are ??=.) But as @davidmorgan notes, it is handy to be able to assign to them when you want to. I think defaulting to mutable for local variables and parameters is harmless and fine.

munificent avatar Jun 06 '25 21:06 munificent

If we did go for immutable parameters, one thing that could make it more palatable would be changing the scope rules to allow final arg = arg ?? defaultValue;, introducing a new variable while allowing access to the existing variable of the same name in the initializer expression.

Then it doesn't matter so much that arg was a parameter and is read-only, when you can do the above, or even var arg = arg; to create a mutable variable with the same name and value.

lrhn avatar Jul 07 '25 15:07 lrhn