-
-
Notifications
You must be signed in to change notification settings - Fork 36
Should variables be mutable? #413
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
Comments
I don't think we should make this change. To explain why, I'll use an example message written using the proposed syntax: Example 1:
I'm assuming, based on #299, that the formatting spec will eventually specify some form of lazy evaluation for local variables. There are two varieties of lazy evaluation: call-by-name and call-by-need. If we choose call-by-name, then the result of evaluating this message is the result of sequentially replacing each declaration's LHS with its RHS. So under call-by-name semantics, Example 1 is equivalent to Example 2: Example 2:
The result is the result of calling Under call-by-need, for each declaration The steps in evaluating the message would be:
The result is the literal So we see that whether we choose call-by-need or call-by-name changes the meaning of the message. This is why call-by-need is not typically used in languages with mutable data. We could commit to using call-by-name semantics, which would mean this message would resolve to what the user probably expects it to resolve to (which is to say, whatever |
You might ask: "Don't we have the same problem with the existing Yes and no. If we also specify that the sequential Example 3:
is equivalent to: Example 4:
then we solve the problem. Example 4 isn't a valid message; the point is just to show what it means to treat the This solves the problem because if we consider examples 3 and 4 to be equivalent in meaning, then we can also specify that: Example 5:
is equivalent to: Example 6:
Example 6 is the result of uniquely renaming each variable. If we know that all variables are unique, then we can explain variable resolution by repeated substitution (for spec purposes; it doesn't have to be implemented as substitution):
In other words: with the existing On the other hand, if we moved towards (Edited for clarity, 2023-07-05) |
So in my opinion, if everybody agrees that local variable declarations should be evaluated lazily, we should keep the Moreover, I don't know how to precisely specify the meaning of name shadowing without specifying how the sequential |
@catamorphism Would it be possible for you to replace the expressions like
This is not necessarily a valid premise, and I don't think we've explicitly established any explicit preference or requirement for laziness or eagerness in the spec at least so far. In fact, we've gone out of way to ensure that either approach is valid. In that spirit, the recent formatting PR added this to the Error Handling section: message-format-wg/spec/formatting.md Lines 781 to 783 in 4a1e367
This is intended to allow for either eager or lazy behaviour in an implementation. So if it made the explanation simpler, with that language being included we could specify something like this in the spec and still support both:
This kind of formulation would simplify our scopes to just one, which should be easier to understand. With that as a starting point, the declarations of your first example
would modify the formatting context variable mapping as follows:
And so during the pattern formatting, |
If we disallow mutating variables, we also avoid the issue completely. |
Sure, I edited it -- let me know if it makes sense now. |
I'm assuming, perhaps wrongly, that if the difference between laziness and eagerness is observable, then the spec has to require either laziness or eagerness (there are really (at least) three options: call-by-value (eager), call-by-name (lazy with substitution), and call-by-need (lazy with memoization). That is: when the difference between laziness and eagerness is observable, the spec dictates which behavior the implementation has to produce. (Of course an implementation could simulate laziness with eagerness, or vice versa, if it wanted to, as long as the observable result is the same.) There are two ways the difference could be observable: I'm assuming the spec has to be precise enough to rule out both (1) and (2). Example 1 falls into category (a): with either call-by-value or call-by-name, the result is An example that falls into category (b) is: Example 7:
Assuming no externally defined variables: under call-by-value, an "Unresolved Variable" error is emitted because So if it's a requirement to avoid category (a) ambiguities, then the spec does have to require observably call-by-value, call-by-name, or call-by-need semantics. If the single-assignment property is enforced (either requiring all Even with the single-assignment property, call-by-name and call-by-need are still distinguishable for messages in category (b) -- for example: Example 8:
In an implementation that emits all errors, call-by-name means this emits two "Unresolved Variable" errors since the body is equivalent to Maybe category (b) isn't important since the spec implies that implementations aren't required to report more than one error in a given message. I'm assuming category (a) is important, since if two different implementations can format the same message with the same arguments and function registry (with observationally equivalent implementations of all the functions) to different strings and both be spec-compliant, that seems undesirable to me. But maybe that assumption isn't shared. |
Not necessarily. In a call-by-need implementation, the "resolved values" are thunks (closures paired with environments), and as I outlined in the first comment, the result is 42, not If the observable difference between call-by-need and call-by-name implementations is undesirable, then the spec has to either specify what the "resolved values" are, concretely; or be more detailed about requiring either call-by-value or call-by-name (but not call-by-need) semantics. |
The assumption about (b) is currently false, given the paragraph in Error Handling that I referenced in my previous comment.
That's correct. For messages that include multiple errors, we do not specify which of them would be emitted, or really any errors beyond the first.
This I agree with, and that's why I've been trying to resolve our current ambiguity by clarifying the spec text as in #381 or my previous comment to make it clear how variable resolution works.
My intent with our current spec language has been to enforce what I understand you refer to as "call-by-value", but this is admittedly unfortunately vague with respect to variable resolution. Specifically, regarding a declaration message-format-wg/spec/formatting.md Lines 70 to 71 in 4a1e367
message-format-wg/spec/formatting.md Lines 97 to 99 in 4a1e367
message-format-wg/spec/formatting.md Lines 123 to 125 in 4a1e367
message-format-wg/spec/formatting.md Lines 129 to 130 in 4a1e367
This is intended to mean that the variable
I think the simplest choice that does not prohibit mutability is to define the spec in terms of an eager call-by-value approach, while ensuring that a compliant implementation may choose to internally follow lazy call-by-name semantics instead. This is why the Error Handling includes the MAY statement about ignoring errors in unused variables. Is anyone advocating that we should follow call-by-need semantics? |
Thanks, @catamorphism, for the in-depth analysis.
Perhaps we were too quick to dismiss eager evaluation. I just commented about this in #299 (comment).
This sound reasonable to me. |
(Sorry about the delayed reply, was on vacation)
I see what you mean now. In effect (according to the current spec), variable references must be resolved eagerly, but the implementation is free to choose whether function calls are evaluated lazily or eagerly. I wasn't considering that possibility when I was writing my previous comments, because I was assuming that either both are eager, or both are lazy.
I agree that "define the spec in terms of an eager call-by-value approach" seems like the simplest choice. As far as allowing the implementation to internally follow lazy call-by-name semantics, it seems a little challenging for the implementation to actually do so. Maybe part of why it seems challenging to me has to do with the definition of "resolved value". The "Function Resolution" section of the spec reads: message-format-wg/spec/formatting.md Line 142 in 4a1e367
and: message-format-wg/spec/formatting.md Line 154 in 4a1e367
message-format-wg/spec/formatting.md Line 158 in 4a1e367
This implies that the set of possible "resolved values" that defines what variable resolution may produce is the same as the set of possible values that can be passed to a function. For a lazy implementation -- that is, an implementation designed to take advantage of the "Resolution and Formatting errors in expressions that are not used in pattern selection or formatting MAY be ignored" clause -- this causes problems. To avoid making the comment thread too hard to read, I'll post subsequent comments with some code examples that show why.
See #299 (comment) , although that comment hadn't been posted yet when you wrote your comment :) Even without anyone advocating for call-by-need explicitly, my reasoning is that "Resolution and Formatting errors in expressions that are not used in pattern selection or formatting MAY be ignored" means some implementors may wish to ignore these errors. If you do wish to ignore those errors, you can either do dynamic analysis (which amounts to lazy evaluation) or static analysis. Static analysis seems quite challenging, since functions are opaque. That leaves lazy evaluation. In practice, lazy evaluation is implemented in a call-by-need way, since the cost of repeating the same computation multiple times usually exceeds the benefit of not computing values that aren't used. It's possible MessageFormat could be an exception to that if it's unusual for variables to be referred to more than once. |
In this comment I want to address how "Resolution and Formatting errors in expressions that are not used in pattern selection or formatting MAY be ignored" could be realized in an implementation. Suppose I'm an implementor who wants to ignore all errors relating to unused expressions. Static analysis could address some, but not all of those errors: as long as no function calls are involved, I can detect unused variables with static analysis. But in an example like: Example 9:
I can't tell if So I'm left with lazy evaluation. Next I have to decide how much computation I want to do when processing a (Option 1 is compatible with either call-by-name or call-by-need, supposing immutability; Option 2 forces call-by-name, since it effectively means variables are substituted away, and I think it works with or without mutability, though I haven't thought that through.) To be concrete, I'll step through evaluation for example 9 using both options; assume that all functions are defined to be constant functions that return their own names. Option 1:
Option 2:
Stepping back:
I understand why the spec leaves the term "resolved value" abstract, but I think it's hard to reason about whether a lazy implementation complies with the spec if such an implementation can't plug in the same definition of "resolved value" everywhere that phrase is used in the spec. Also, an implementation that follows option 2 (eager variable resolution, lazy function resolution) has to pick a representation for "partially evaluated" expressions, which is neither the "resolved value" as currently used in the spec, nor a structure containing a valid MessageFormat expression and possibly an environment providing values for any of its free variables. I suppose this could be done, but it also seems like this would make reasoning about spec compliance difficult. So my two closing questions are:
|
I also have a separate question about how to handle resolution errors in a lazy implementation that uses what I called "option 2" in my previous comment (eager variable resolution / lazy function resolution). Example 10:
Stepping through evaluation again (suppose
The question is how to ensure the resolution error for This problem doesn't arise either with fully lazy evaluation (option 1), since variables are only resolved when they're used and the error can be emitted immediately. |
To make sure for myself that it's not too challenging, I went ahead and updated my polyfill implementation to resolve variables as follows: During a formatting call, a Later during the call, when we are resolving a variable, we check if our scope value for it is an I also added a small bunch of test cases for the above.
Option 2 is not quite what I had in mind: We don't need to resolve the variables immediately, if we can later resolve them with the values they ought to have in their position. Hence the
Removing that would force implementations to always eagerly resolve all expressions and to complain about any errors in them. As I hope my code demonstrated, I don't think it's impractical.
I don't think that's necessarily relevant here. A key part of what I'm seeking to achieve here is a spec that can be reasoned about as if the execution was eager, but still provides space for an implementation to process lazily. A key part of that (and the overall topic here) is that the shape of a declaration is unobservable except when a selector or pattern expression includes a variable reference for it. When we need to resolve that expression, then we need a "resolved value" representation of the variable's value. That unobservable space is where I get to introduce my |
The above dialog is a deep dive in the assessing the feasibility of mutability--more of a "How would we do", rather than a "should we do it". Are there practical, compelling use cases for mutable variables? By compelling, I propose a subjective (& surely not to be agreed by all) threshold to be:
I know this is 🎨 , but I personally do not find the hypotheticals in #310 compelling, nor do I posit that they'd satisfy my self-authored self-declared thresholds. My vote is for immutability at current time. |
[snip] I think I understand what your code is doing (basically, the dynamic equivalent of the desugaring + unique renaming I outlined in #413 (comment) -- if I follow it correctly, https://github.com/messageformat/messageformat/blob/6656c95d66414da29a332a6f5bbb225371f2b9a3/packages/mf2-messageformat/src/messageformat.ts#L85 effectively introduces a new name on the fly anytime a variable is redefined). As I said initially, that transformation is semantics-preserving as long as we avoid call-by-need (in other words, as long as we don't memoize.) It looks like your code does memoize results for variables directly referenced in the message body, just not for variables referenced on the right-hand side of a In fact, your code would probably be able to memoize all results if you did the unique renaming as a separate pass rather than interleaved with evaluating the declarations. This is what I said in an earlier comment (call-by-need works with no surprises as long as the single-assignment property holds.)
Equivalently, according to lexical scope. What you call a
The reason I thought it was impractical was that I was assuming that the environment (what your code calls a I say that it's syntactic sugar because your environment maps variables onto immutable values, not onto mutable slots containing values. So, I'm not sure that this issue is actually asking a different question from "should name shadowing be allowed?" |
I wouldn't think of my approach as dynamic renaming, but it's mostly equivalent. The way I think of it, a rename is creating a "scope boundary" (Sorry, no idea what a proper-er name might be), with the memoization always being limited to one side of the boundary. So if we have a message like
then we end up with two scope objects existing during the runtime: const a = {
x: Unresolved(Literal('13'), a),
y: Unresolved(Variable('x'), a)
}
const b = {
x: Unresolved(Variable('y'), a),
y: Unresolved(Variable('x'), a),
z: Unresolved(Variable('y'), b)
} of which we use a = { x: '13', y: '13' }
b = {
x: '13',
y: Unresolved(Variable('x'), a),
z: Unresolved(Variable('y'), b)
} where Then, after also resolving a = { x: '13', y: '13' }
b = { x: '13', y: '13', z: '13' } So the I did also consider dynamic renaming so everything could end up in the same scope and e.g. |
@eemeli In your comment above, you've described an implementation of immutable variables with name shadowing and memoization (lazy call-by-need semantics). The issue is about mutability, so I think if we agree that we want immutability, and the issue at hand is whether to allow name shadowing, we can close this issue and continue discussing name shadowing on #310. To be clear about what I mean by that, I drew a diagram: Above the green line, the diagram shows the runtime state when processing the following declarations, in the hypothetical syntax:
Below the green line, the diagram shows the runtime state when processing the following declarations, in the existing syntax:
Both diagrams assume a standard kind of evaluator based on an environment and a store. An environment maps variable names onto locations. A store (sometimes called a "heap") maps locations onto runtime values. A runtime value can either be a thunk (what you call an Starting above the green line (mutability):
Note the state of the store after the third line of code: the thunks for Below the green line (name shadowing): In the presence of name shadowing, each thunk is actually a pair of a pointer to code (equivalently, to an AST node) and a pointer to an environment. With mutability, there was no need for the environment pointers. We could just use one global environment, since there was no name shadowing. Here, each thunk points to its own local environment. This should look familiar to you since it's almost exactly what your implementation is doing. You implemented an additional optimization that basically re-uses the global environment instead of creating a new environment for each thunk, except when a name is shadowed. For simplicity, I'm not showing that optimization (or others, like immediately evaluating
Note the different state of the store at the end: the thunk for I hope this shows the difference between mutability and name shadowing, and the reason to reject mutability if memoization is desirable. I've assumed throughout that when we're talking about mutability, we don't just mean whether the keyword is spelled (The model I'm using is loosely based on on section 4.2.2 of The Structure and Interpretation of Computer Programs (second edition), but the underlying interpreter they use has a lot of complications that don't apply to a language without nested |
With mutability defined as above (yes, the diagram does help), I'd be happy to also conclude that we don't want it, and that this issue may be closed. On name shadowing we can continue in #310. |
In #402 (review) , @eemeli asked:
"For an alternative approach, could we consider changing the
let
keyword to beset
, and for references to declarations to be changed to definitions or assignments? This might make it easier to consider them as assignments rather than initializations."This would be a non-trivial change, so I wanted to open a separate issue to discuss it. The discussion in #310 overlaps with this issue, but for clarity I wanted to make a separate issue to discuss the specific question about whether the
let
construct is meant to be a definition construct or an assignment construct.The text was updated successfully, but these errors were encountered: