Skip to content
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

fix: support use of constant in other constant and for struct field default value before declaration #1478

Open
wants to merge 11 commits into
base: main
Choose a base branch
from

Conversation

i582
Copy link
Contributor

@i582 i582 commented Jan 21, 2025

@jeshecdom I don't know any of the nuances of your vision for this interpreter, so maybe this PR is complete nonsense and it should be done other way. I'd be glad to hear any suggestions!

The main idea of ​​this PR is that we recursively (on demand) calculate the value of a constant without depending on the order. The most important part is to handle circular dependencies correctly, otherwise the compiler will crash with an exception due to the depth of the recursion.

Issue

Closes #1477.
Closes #879.

Checklist

  • I have updated CHANGELOG.md
  • I have added tests to demonstrate the contribution is correctly implemented: this usually includes both positive and negative tests, showing the happy path(s) and featuring intentionally broken cases
  • I have run all the tests locally and no test failure was reported
  • I have run the linter, formatter and spellchecker
  • I did not do unrelated and/or undiscussed refactorings

@i582 i582 added this to the v1.6.0 milestone Jan 21, 2025
@i582 i582 requested a review from jeshecdom January 21, 2025 22:39
@i582 i582 requested a review from a team as a code owner January 21, 2025 22:39
Copy link
Contributor

@jeshecdom jeshecdom left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a nice solution, I actually like it. It is treating the constants as inlining their definition, as long as they have not been evaluated already. Since evaluation is on demand, it handles cases like this:

const A: Int = B;
const B: Int = foo(1);

function foo(v: Int) {
   if (v == 1) {
      return 0;
   } else {
      return A;   // The else never executes, so no circular dependence at compile-time
   }
}

I was wondering if we could do this process for contract constants, but then I realized that the solution already evaluates contract constants. Note however that contract constants cannot depend on each other, because self is not allowed in their initializers and only calls to global functions are allowed (which cannot reference the contract's constants). Nevertheless, the solution already handles any dependencies contract constants may have with global constants, which is nice.

Just a couple of minimal comments below. Suggestions from other team members are welcomed.

src/optimizer/interpreter.ts Outdated Show resolved Hide resolved
src/optimizer/interpreter.ts Outdated Show resolved Hide resolved
@anton-trunov
Copy link
Member

Note however that contract constants cannot depend on each other, because self is not allowed in their initializers

@jeshecdom Why is self not allowed?

@i582
Copy link
Contributor Author

i582 commented Jan 22, 2025

Note however that contract constants cannot depend on each other, because self is not allowed in their initializers

Why is self not allowed?

Looks like this code is not working:

contract Master {
    const C: Int = 10;
    const D: Int = self.C;

    get fun foo(a: Int): Int {
        if (a == D) {
            return 10;
        }
        return 0;
    }
}

With this error:

test/main.tact:6:20: Unable to resolve id 'self'
  5 |     const C: Int = 10;
> 6 |     const D: Int = self.C;
                         ^~~~
  7 | 

So we need to support this case in the resolver first in order to work with it in the interpreter
#1481

@jeshecdom
Copy link
Contributor

jeshecdom commented Jan 22, 2025

So we need to support this case in the resolver first in order to work with it in the interpreter
#1481

We need to be careful though, because as soon as self is allowed in constant's initializers, the contract constants could be defined using contract fields, meaning that their value will be dependent on what the Interpreter has in its environment at the moment of interpretation:

contract Test {
   f: Int = 5;
   const A: Int = self.foo();
   const B: Int = 10 + self.f;  
}
.........

One may think that in the above code B = 15, but what if self.foo() changes the value of self.f to, say 2?. Then, B = 12. Even worse, changing the order of the constant declarations:

contract Test {
   f: Int = 5;
   const B: Int = 10 + self.f;  
   const A: Int = self.foo();
}
.........

would now give B = 15, because B would be interpreted first. So, I think that references to fields should be avoided in constant declarations (and probably other expressions involving self that I have not thought of).

@i582
Copy link
Contributor Author

i582 commented Jan 22, 2025

I think that a contract constant cannot (and shouldn't) depend on a contract field

Copy link
Contributor

@jeshecdom jeshecdom left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I approve these changes, unless @anton-trunov and @verytactical have additional comments.

): CompilerContext {
for (const constant of constants) {
if (constant.ast.kind === "constant_def") {
constant.value ??= evalConstantExpression(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm unsure it's even safe to do it here: typechecking is not over, how can we evaluate it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But this function is called only after checkConstants, where we check types

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mmmm.... @verytactical has a good point. What if the constant initializer is calling a function that has not been typechecked yet? Or is there a guarantee that before constant evaluation all functions have been typechecked?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mmmm.... @verytactical has a good point. What if the constant initializer is calling a function that has not been typechecked yet? Or is there a guarantee that before constant evaluation all functions have been typechecked?

Looks like ctx = initializeConstantsAndDefaultContractAndStructFields(ctx, util); is the last step in type checking, so this is not the case

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is correct that it is the last step in resolveDescriptors. But then I checked precompile.ts and noticed that resolveDescriptors is done before resolveStatements, which means that functions are actually not type-checked before evaluating constants.

This explains why the tests did not complain about assigning an integer to self (which has the type of a contract), like self = 100. I was wandering about that when reading the tests.

If we want to keep this behavior, then the interpreter should not rely on the type-checker and carry out type checks while it interprets the code (we will need to extensively test the interpreter). The other option is to move constant evaluation as the last step in precompile.ts.

@anton-trunov, we need your opinion on this, because I do not know if some process after resolveDescriptors in precompile.ts requires constants to be already evaluated.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const eval should be done after typechecking, it's just historically it wasn't the case :)

@anton-trunov anton-trunov self-assigned this Jan 24, 2025
src/optimizer/interpreter.ts Outdated Show resolved Hide resolved
): CompilerContext {
for (const constant of constants) {
if (constant.ast.kind === "constant_def") {
constant.value ??= evalConstantExpression(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const eval should be done after typechecking, it's just historically it wasn't the case :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const eval tests should not go to the typechecker tests

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's add a test with nested function calls foo() -> bar() -> ...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, let's add a test like const A: Int = A

Comment on lines +907 to +911
this.constantComputationPath.push(name);
constant.value = this.interpretExpression(
constant.ast.initializer,
);
this.constantComputationPath.pop();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since these push / pop operations need to be always balanced, let's define a helper that ensures that

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
4 participants