Skip to content

API variables #4137

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

Open
eernstg opened this issue Oct 23, 2024 · 8 comments
Open

API variables #4137

eernstg opened this issue Oct 23, 2024 · 8 comments
Labels
request Requests to resolve a particular developer problem

Comments

@eernstg
Copy link
Member

eernstg commented Oct 23, 2024

The stable getters proposal aims to make immutable properties of objects an API matter. That is, we can declare that a particular member of an interface type (class, mixin, etc) will return the same value every time it returns a value. It is a compile-time error if this property is not statically known to hold, and (when the getter is an instance member) it is a compile-time error to override or implement it in a way that violates this constraint.

The fact that a given property of an object is immutable can be important when reasoning about the correctness of a piece of code; for example, the value of any such property can safely be cached and reused, which might not be correct for a getter which isn't stable.

This issue is intended to promote the idea that "being a plain variable", in particular a variable that supports mutation, could also be an API property. The point is that it could be important for correctness and code comprehension to know this when reading code where the variable is used.


The specification of an API variable is that its getter/setter invocation history faithfully determines the behavior of the getter:

Assume that x is an API variable whose value at a given time t is v0. Assume that the getter/setter invocations that occurred at time t or higher is i1 .. ik, where ij is either a getter invocation get:u that returned the value u, or a setter invocation set:w where the value w was assigned to x. We do not include throwing invocations in the list i1 .. ik, only invocations that completed normally are included.

The value u of a getter invocation ij of the form get:u is then the value w which was stored in the most recent setter invocation ik for some k < j of the form set:w, or the initial value v0 in the case where no such setter invocation exists.


In other words, the API variable will return the value which was assigned to it most recently, and it isn't playing around in any way.

It is allowed for the API variable to be implemented in terms of a getter and a setter that may have other side effects. It is a matter of good style to keep those side effects non-observable in a suitable sense. The simplest way to obtain an API variable is to declare a regular non-local variable (a top-level variable, a static variable, or an instance variable).

But it is not allowed for the API variable to obtain a new value for any other reason than having had a setter invocation as described above. In particular, it can't be a getter/setter pair with a backing variable whose value is sometimes changed by other means than calling said setter, and it can't be a getter/setter pair whose setter at times doesn't set the backing variable at all, or sets it with another value than the actual argument of the setter invocation.

It can be a setter/getter pair where the getter is stable and the setter always throws. In general, being an API variable doesn't prevent invocations (of the getter or setter) that throw. It just specifies that when the invocation doesn't throw, the value follows this "return the most recently stored value" semantics. Also, it's not allowed for a setter to change the value and then throw, it must leave the value unchanged in the case where it throws.


A very simple language mechanism that supports API variables as instance members would be (1) a way to mark an instance variable declaration D as an API variable, (2) a compile-time error for every override of the getter or setter of D that isn't an API variable declaration.

// As a strawman, `API` is a modifier that declares an API variable.
// It can only be specified on a variable declaration, and it must be non-final.

class A {
  API int i;
  A(this.i);
}

class B1 implements A {
  API int i;
  B1(this.i);
}

class B2 implements A {
  int get i => 42; // Error, this is not the getter of an API variable.
  set i(int _) {} // Error, this is not the setter of an API variable.
}
@eernstg eernstg added the request Requests to resolve a particular developer problem label Oct 23, 2024
@eernstg
Copy link
Member Author

eernstg commented Oct 23, 2024

Yes, I know, nobody wants this feature today. I just think it's a good mental exercise to be aware of the concept. ;-)

@hydro63
Copy link

hydro63 commented Oct 23, 2024

Could you please provide an example of use in code? The explanation you've provided is quite complex and could use a code example to get across the core idea.

But, based on what i've understood, the API variable will have these properties:

  • The variable can only be set with an explicit setter; that applies also to the library, which implements the said variable
  • The variable is cachable, it keeps the last value, where the setter has not thrown
  • The variable has an implicit getter, that gets the cached value
  • The variable is public
  • The setter caches the dependent variables, so that if the setter throws, there will be no change done to the variables mutated in the setter

Am i correct with my understanding?

@eernstg
Copy link
Member Author

eernstg commented Oct 23, 2024

The idea is simply that it is known (because it will be checked by the compiler) that the given property is semantically like a plain variable: You can assign to it, and you can read it, and it won't change its value for any other reason than "somebody assigned a new value to this variable".

The getter/setter can certainly be the implicitly induced ones that we get along with a plain variable declaration (that's the vastly most typical case, I'd expect), but it could also be an explicitly written getter and setter, as long as it behaves like a plain variable.

The variable can only be set with an explicit setter, that applies also to the library, which implements the said variable

It can be a plain variable, too, which is actually the easy case (which is also the only case that the strawman mini-feature supports).

However, if it is implemented as an explicitly written getter and setter then they must satisfy the semantic property: "The value (that is, the value returned by the getter) is the same as the most recently stored value (that is, the actual argument passed to the setter).

The variable is cachable, it keeps the last value, where the setter has not thrown

Yes. This works just like a plain variable declaration.

If we're caching a variable, and it turns out to be overridden by a getter/setter that does something which is "not like a plain variable", then it's just a bug today.

With support for API variables we could make it a compile-time error to declare such an override by declaring that it's an API variable.

Another case which allows a shorter example than the caching scenario is the simple fact that a variable will have the assigned value after an assignment:

// Today.

class A {
  int i;
  A(this.i);
}

class B implements A {
  int get i => 42; // Insist on having this value at all times!
  set i(int _) {} // Ignore assignments.
}

void foo(A a) {
  // This will always work if `a.i` works like a plain variable.
  a.i = 24;
  assert(a.i == 24);
}

void main() {
  foo(A()); // No problems.
  foo(B()); // Fails at the assertion!
}

In other words, current Dart won't help us specifying that we'd like a given property to work like a plain variable.

// If we have support for marking a variable as `API`, we can do this.

class A {
  API int i; // This is exactly the same as `int i;`, except for override checking.
  A(this.i);
}

class B implements A {
  // We get errors because this getter/setter pair isn't an API variable.
  int get i => 42; // Compile-time error.
  set i(int _) {} // Compile-time error.
}

The point is that a property (that is: a getter/setter pair) like B.i doesn't satisfy the expectations that we may reasonably have for a property which looks like an instance variable. We expect to be able to assign a new value to the "variable", and then we expect the variable to have that value. But B.i doesn't live up to that expectation.

This used to be a bug at run time, and we'd just have to (1) change the implementation of classes like B, or (2) change the expectation, such that we don't assume that "the variable will have the value that we just assigned to it".

If we have support for API variables then it can be a compile-time error.

The variable has an implicit getter, that gets the cached value

If we declare a setter explicitly then we must also declare a corresponding getter in order to be able to make it work like a mutable variable (in terms of members).

However, it is again fine to just use a completely normal variable as the representation of an API variable.

The variable is public

That's not important, the concept may be equally useful with private variables. But it does have to be a non-local variable. Local variables do not have associated getters and setters. This again means that every mutable local variable already works in the way that we require when we specify API for a non-local variable.

The setter caches the dependent variables, so that if the setter throws, there will be no change done to the variables mutated in the setter

This is the more tricky part. The very simple mechanism I mentioned simply requires that an API variable is a plain variable, it can't be a getter/setter pair.

If we start developing a more flexible language mechanism then it very quickly gets difficult to enforce the desired property.

class A {
  API int i;
  A(this.i);
}

// This must definitely be allowed.
class B implements A {
  API int _i; // Backing variable.

  // `i` implemented as an explicit getter/setter pair.
  API int get i => _i;
  API set i(int newValue) => _i = newValue;
}

We'd like to enhance the feature such that the getter and setter can do other things than just reading and writing a backing variable, but we must do it in a way that provably preserves the "plain variable" behavior.

So that's where it quickly gets complicated. ;-)

@hydro63
Copy link

hydro63 commented Oct 23, 2024

Okay, i think i understand now. The point of this feature is to make the getters+setters pairs behave the same as any plain variable you can find in any function. In other words, it is trying to tighten up getters and setters, so that we ALWAYS get the thing we last set. It also doesn't forbid doing custom getters, as long as the getter returns the value that was last set. The issue also currently doesn't talk about corruption prevention, which is admittedly outside the scope of this feature.

I can see use-cases of API variables, primarily as a hint to compiler for type promotion. I wouldn't mind having this in the language, and i also can't think of any obvious footguns it could cause.

Still, the exact interaction between the type promotion and API variables is way beyond my knowledge, so i'm gonna leave this issue for the language team.

@Jetz72
Copy link

Jetz72 commented Oct 23, 2024

If we start developing a more flexible language mechanism then it very quickly gets difficult to enforce the desired property.

You could perhaps take away the responsibility of setting and getting from an API variable's setter and getter methods, and utilize an internal variable for storage. Perhaps something like:

API int x;
set x(int oldX, int newX) { //Extra parameter supplies the current underlying value, without needing to invoke the getter.
   if(oldX != newX)
      onUpdatedX(oldX, newX);
   //Internal underlying variable is updated to newX if and when the setter completes without throwing.
}
get x {
   if(isInvalidState())
      throw "Can't access x right now!";
   //Getter doesn't use an explicit return value. The caller always gets the underlying value if it completes.
   //Runtime error if the corresponding setter gets called while a getter is resolving.
}

In this case, it would be possible to write a setter without a getter or vice versa. They'd only be used for side effects and callbacks, and the stability of x would be guaranteed.

It would be nice to be able to slim down common getter/setter pairs with a feature like this. Often times I only need to define them because I want some code to run when the variable changes:

int _realX; //Have to define a secondary variable, only used by the setter and getter, for keeping the actual value
set x(int x) {
   int oldX = _realX;
   _realX = x;
   if(oldX != x)
      onUpdatedX(oldX, x);
}
int get x => return _realX; //Have to write a trivial getter method just so everything can be connected up.

@lrhn
Copy link
Member

lrhn commented Oct 23, 2024

(because it will be checked by the compiler)

But how?

Obviously a plain API int x; satisfies the "API-variable property", but how is it possible to have user written getters and setters which provably has the API-variable property?
Would we have special API getter/setters, that work on top of another API variable, like augmentations, but which cannot change which value is being written. (Which prevents changing the representation, say doing json.encode when storing, json.decode when reading. Would that be valid, since it's a different, wrt. identity, value being read than what was written, even if they are structurally equivalent? Probably not allowed.)

Will an API getter cache the value and return it, instead of recomputing, if no write has been made?
And if a write is made, will that value be stored in the cache, or will the cache just be cleared. In that case, the getter and setter are more like onRead and onWrite callbacks than actual getters and setters. (So it really is more of an API int x { onRead(v){ }, onWrite(v){ ... }} feature.)

Can a getter throw? If the goal is to allow a compiler to not read the variable again, because it already has the value and nobody has called the setter in the meantime (as if we could know that), then the it matters whether the next read would throw or not.

So many questions :)

@eernstg
Copy link
Member Author

eernstg commented Oct 24, 2024

So many questions :)

Indeed! That's the reason why I made it a request rather than a feature proposal, only briefly mentioning that there is a trivial implementation. Anything beyond that seems to get real complex, real fast.

However, I like the idea (here and here) that we could introduce a getter/setter declaration that basically hides the underlying backing variable and only allows the getter/setter to specify what "else" to do, while handling exceptions in a well-defined and useful way.

@eernstg
Copy link
Member Author

eernstg commented Oct 24, 2024

@hydro63 wrote:

... it is trying to tighten up getters and setters, so that we ALWAYS get the thing we last set.

That's a good way to say it!

The issue also currently doesn't talk about corruption prevention

The scenario that comes to mind is (1) a setter is called, but the actual argument is somehow not acceptable, (2) the setter detects the problem and throws. Hence, the variable doesn't change, and the potential corruption is avoided.

Is it fair to say that this scenario does prevent corruption, or did you have some other scenario in mind?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
request Requests to resolve a particular developer problem
Projects
None yet
Development

No branches or pull requests

4 participants