Skip to content

Should constant expressions be potentially constant? #4311

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
eernstg opened this issue Apr 1, 2025 · 8 comments
Closed

Should constant expressions be potentially constant? #4311

eernstg opened this issue Apr 1, 2025 · 8 comments
Labels
bug There is a mistake in the language specification or in an active document

Comments

@eernstg
Copy link
Member

eernstg commented Apr 1, 2025

The language specification implies in commentary that every constant expression is also a potentially constant expression:

The constant expressions is a subset of the potentially constant expressions

However, this is not expressed in the case of conditional expressions (e1 ? e2 : e3):

\item An expression of the form \code{$e_1$\,?\,$e_2$\,:\,$e_3$}
  is potentially constant if $e_1$, $e_2$, and $e_3$
  are all potentially constant expressions.
  It is constant if $e_1$ is a constant expression and either
  \begin{enumerate}
  \item $e_1$ evaluates to \TRUE{} and $e_2$ is a constant expression, or
  \item $e_1$ evaluates to \FALSE{} and $e_3$ is a constant expression.
  \end{enumerate}

For example, true ? 1 : v is a constant expression which is not potentially constant in the following example:

void main() {
  var v = 0;
  const c = true ? 1 : v; // OK.
}

We could add the word 'further' to make it 'It is further constant', like many other cases in the same itemized list, which would cause true ? 1 : v to be non-constant because it isn't potentially constant (because v isn't a constant variable, and it isn't a formal parameter of a constant constructor).

Alternatively, we could decide that it is not a problem that some constant expressions aren't potentially constant, and we could then adjust the commentary in the language specification.

I'd recommend the former. @dart-lang/language-team, WDYT?

Note that this would be a mildly breaking change. For instance, the CFE follows the specification and does not report the error on line 48 and 52 of LanguageFeatures/Static-access-shorthand/constant_expression_A10_t17.dart, but the analyzer does report these errors.

@lrhn
Copy link
Member

lrhn commented Apr 1, 2025

Likely also applies to the other conditionally branching expressions, ??, && and ||. If the first operand expression short-circuits, then the second operand is not evaluated. Being a constant expression is defined by having a specific form and evaluating to a value. There is no "syntactically a constant expression".

Adding "further" won't be enough, because it still won't evaluate the expression, and being a potentially constant expression also depends on being a constant expression.

class C { 
  static const wtf = bool.fromEnvironment("break-me");
  List<int> list;
  const C(int? v, bool useV) : list = v != null ? const [wtf ? v : 0] : [];
  }
}

Here const [wtf ? v : 0] is a valid potentially constant expression if it's a valid constant expression.
A list literal is a valid constant expression if its element expressions are valid constant elements.
It's an expression, which is a constant element if it's a constant expression.
And, back to where we started, wtf ? v : 0 is a constant expression if it's potentially constant (which it is because wtf, v and 0 all are), and because because wtf is constant and evaluates to false, and 0 is constant and evaluates to 0.
Bingpot, it's constant.

But now we're back to the original problem, that we'd like to say that v can never occur inside a const expression.
We removed the ability to refer to anything, but you're still able to refer to something that won't ever be valid if actually evaluated. Something where we could potentially tell you ahead of time that it'll never be a valid constant.

(By all means add the "further", it will reduce the problem, it just won't remove it entirely.)

@lrhn
Copy link
Member

lrhn commented Apr 1, 2025

What would a real solution to this problem be. (And while we're there, maybe add more obvious static errors to constant expressions).

Modes and contexts

Define: Mode of evaluation. Dart has two modes of evaluation: Non-Constant ("normal"), and Constant. When evaluating an expression, it is evaluated as one of those.

Define: Constantness Context. Every expression occurs syntactically in a context which either implies, allows or requires one of those evaluation modes: a non-constant, potentially-constant, constant (aka constant-implies) or constant-required context.) The default is non-constant, we'll only specify which constructs introduce the other contexts.

That is, every expression has an associated "constantness-context" which it must satisfy. Different expressions propagate their context to their sub-expressions.

Collection elements: Like expressions, they have a constantness context and are evaluated in a mode of evaluation.
Type arguments: Like expressions have a constantess context and are evaluated in a nurse of evaluation.

Define: Constant availability. Dart constant values exist in two variants: Eager and Late, where late constants arise from using String.fromEnvironment or similar constructors. Every constant value has one or the other kind. Non-constant values, the result of evaluation that is not constant evaluation (but not all such evaluations), are neither eager nor late, we just call them non-constant values.

Some non-consistent evaluations create (inherently) constant values, if they're built bottom-up from constant operations on constant values. That's entirely to allow canonicalization of strings in non-constant contexts.

We'll then define the expressions that are valid in different contexts.

Constant expressions

An expression is only allowed in a potentially-constant, constant or constant-required context if it's specified below.
It's a compile-time error if any other expression occurs in a potentially constant, constant or constant-required context. Those expression forms cannot possibly be evaluated using constant evaluation.

First define a constant reference as a name, possibly qualified, that denotes some declaration:
* An unqualified identifier (denoting something of that name in scope).
* A non-deferred import-prefixed identifier, prefix.id, where prefix denotes a non-deferred import prefix,
and id is the name of a declaration in that import scope.
* A constant reference to a namespace (one of the prior two denoting a static namespace declaration, a
class, enum, mixin, extension or extension type declaration, or a typedef declaration
denoting (directly or transitively) one of these) qualifying another identifier (or new) which then denotes a static (or constructor)
declaration in that namespace.

(The important part is that it denotes some declaration, and it doesn't go through a deferred import.)

Then the following expressions are not automatically compile-time errors if they occur in a context other than a non-constant context.

  • A number literal, boolean literal, the expression null, and a string literal with no interpolations.

    • If evaluated as constant or non-constant, they evaluate to an eager constant value. (All expression evaluate to the same value no matter which mode of evaluation, if they evaluate to a value at all.)
    • Constant string values are canonicalized if they contain the same code unit sequence. If two expressions evaluate to constant string values with the same content, then they evaluate to the same object (as reported by identical). The constant values of different expressions need not have the same eagerness, even if they are the same values. Constant values track both the object and where it came from.
  • A string literal with interpolation expression e1, ..., en in constantness context C:

    • Every interpolation expression is then also in the constantness context C.
    • If C is not a non-constant context:
      • It's a compile-time error if the static type of any ei is not assignable to
        one of String, num, bool or Null. (So the static type is a subtypes of one of those, or it's dynamic.)
    • If evaluated as constant, it's a compile-time error if any of the ei
      evaluates to a value that is not an instance of int, double, String, bool or Null.
    • The value is a constant value
      if every ei evaluates
      to a constant value which is an instance of int, double, String, bool or Null.
    • The resulting constant value is eager if the value of every ei is eager, and laty if just one
      of them is late.
    • Constant string values are canonicalized if they have the same code unit sequence.
  • A constant reference denoting a const variable declaration:

    • Evaluates to the same constant value as the variable's initializer, with the same eagerness.
  • An (unqualified) identifier denoting a parameter (including initialisering formals and super parameters) of a generative const constructor:

    • Is a compile-time error in a constant or constant-required context. (But not in a potentially constant context.)
    • If evaluated as part of a constant evaluation (which means as part of a const invocation
      of the constructor), the value and its associated eager/late-ness is that of the constant argument expression of the invocation.
  • A constant type literal expression, which is a constant reference denoting a type declaration, optionally followed by type arguments:

    • The constantness context of every type argument, if any, is the context of the entire type literal.
    • ...

... etc. ...

Default value expressions and instance variable initializer expressions of a class with a const generative constructor are in const-required contexts. I think it's still the only ones.

Generative const constructor initializer lists entries/redirections are potentially constant contexts.

The usual places introduce const contexts.

A const constructor invocation evaluates the constructor in constant mode.

Then rather than saying anything about how an expression must evaluate, we say that it's in a constant context, and then only some expressions are allowed there. And among those which are, some may still cause compile-time errors because of types or, if evaluted, concrete values. Or failed downcasts from dynamic.
We can be more precise about type errors, and give constant related errors during type inference. We could do that anyway, the current spec just doesn't.

I think something like this could be both well-defined, implementable, and possible to reason about.

@FMorschel
Copy link

maybe add more obvious static errors to constant expressions

FYI @bwilkerson, for that revision on const errors, you want to do.

@eernstg
Copy link
Member Author

eernstg commented Apr 2, 2025

@lrhn wrote:

Likely also applies to the other conditionally branching expressions, ??, && and ||.

All of those already have the word 'further' as proposed here.

Adding "further" won't be enough, because it still won't evaluate the expression,

This is working as intended, and I do not wish to change anything such that those expressions will be evaluated during constant evaluation, or during a determination of whether or not a given expression is constant.

class A {
  final int i;
  const A(Object o): i = o is bool && o ? 1 : 0;
}

void main() {
  const c = [A("Hello!"), A(true)];
}

and being a potentially constant expression also depends on being a constant expression.

Looking at the rules about constant expressions, I can see that we have lots of cases where an expression e is potentially constant only if certain subexpressions of e are potentially constant. We also have a couple of cases where e is potentially constant only if a certain subterm of e which is a type is a constant type expression.

A special exception (a mistake, I'd say) is that potentially constant collection literals require subexpressions to be constant (not just potentially constant, as with lists and sets), or leaves them completely underspecified (as with maps).

We should fix that. Good catch!

class C {
  static const wtf = bool.fromEnvironment("break-me");
  final List<int> list;
  const C(int? v) : list = v != null ? const [wtf ? v : 0] : const [];
}

(I made a couple of changes to avoid errors, and removed useV because it was unused.)

Now, const [wtf ? v : 0] needs wtf ? v : 0 to be a constant expression. This is true if wtf evaluates to false and false if wtf evaluates to true. This means that it will be known at the time where the compilation environment which is consulted by bool.fromEnvironment(_) has been determined. I don't see anything which is really malfunctioning here, nor much of a connection to the question "should constant expressions be potentially constant?".

I don't think we can hope to report an error for constant expressions based on criteria like "if some subexpressions in this constant expression were to have a different value then the expression wouldn't be constant". For example, we probably shouldn't emit a warning in the situation where bool.fromEnvironment("break-me") is false because the expression would not be constant if the value had instead been true. (Or: because the expression would not be constant, and that causes an error to occur). There are simply too many useful constant expressions which would be turned into errors if we did anything like that.

More realistically, we could introduce a rule along the lines of "an expression that contains a formal parameter of an enclosing constructor declaration is not constant." This would turn const [wtf ? v : 0] into an error even in the case where the value of bool.fromEnvironment("break-me") is not yet known. This would give us the following:

that we'd like to say that v can never occur inside a const expression

Next comment:

maybe add more obvious static errors to constant expressions

That would be an interesting adventure, and I can see that you have taken a number of steps. Also, it's potentially a non-trivial change. In a similar vein, we have #1296 where I've suggested that we should improve on the static analysis of potentially constant expressions.

However, right here I think we can get a reasonable improvement simply by (1) adding 'further' to the ?: rule as proposed in the OP, (2) fixing the rule about constant maps, and, maybe, (3) adding the rule I mentioned above ("an expression that contains a formal parameter of an enclosing constructor declaration is not constant").

For instance, #4313 (where I haven't done (3)).

@lrhn
Copy link
Member

lrhn commented Apr 2, 2025

A special exception (a mistake, I'd say) is that potentially constant collection literals require subexpressions to be constant

I just noticed the same thing reviewing your change. If people did more constant ?s, I think this would be a bigger problem.

The example I wrote in that review is:

void main() {
  const v1 = bool.fromEnvironment("maybe") ? "A string" : 42; // is 42.
  const v2 = v1 is String ? <int>[v1.length] : <int>[v1 as int];
  print(v2.first); // 42
}

This runs, but the analyzer reports an error because v1.length is not a valid constant expression, even though it should not be evaluated. (And if it would be evaluated, then it would be valid, but then v1 as int isn't.)

That would be the consequence of requiring the non-taken branch to be a potentially constant expression.

Which also means that it's already the case today for:

const dynamic v1 = bool.fromEnvironment("maybe") ? "A string" : 42; // is 42.
class C {
  final List<int> l;
  C() : l = v1 is String ? const <int>[v1.length] : const <int>[v1 as int];
}

because that puts the conditional expression in a position where it must be potentially constant,
therefore both its branches must be potentially constant,
and therefore both list element expressions must be constant, and one of them always isn't.
(And indeed compilers also report that error.)
Is that a soundness issues? We're evaluating an expression with a promoted variable even though the condition for promoting it isn't satisfied.

The "easiest" way to a better static-constant-checking seems (IMO) to be adding a notion of "statically constant expression", which does not do evaluation, but which does check that the expression doesn't contain non-constant variables or type variables. (That's what the propagated constant context above was about. If a parameter occurs in a constant context, it's an error, whether it's evaluated or not. Something like this.)

Then the only thing that actually evaluates is evaluation. Something can be invalid as a constant or potentially constant expression when not evaluated, and it can fail when evaluated, but we never need to know what evaluation does to a branch that isn't taken.

@eernstg
Copy link
Member Author

eernstg commented Apr 3, 2025

but we never need to know what evaluation does to a branch that isn't taken.

Great point, and a great analysis overall!

@eernstg
Copy link
Member Author

eernstg commented Apr 4, 2025

Proposed spec change: #4313, updated recently.

@eernstg
Copy link
Member Author

eernstg commented Apr 8, 2025

#4313 has landed.

@eernstg eernstg closed this as completed Apr 8, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug There is a mistake in the language specification or in an active document
Projects
None yet
Development

No branches or pull requests

3 participants