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(frontend)!: Restrict capturing mutable variable in lambdas #7488

Open
wants to merge 9 commits into
base: master
Choose a base branch
from

Conversation

vezenovm
Copy link
Contributor

@vezenovm vezenovm commented Feb 21, 2025

Description

Problem*

Resolves #4795

Summary*

We now restrict capturing mutable variables that are not mutable references in lambdas.
This is now banned:

fn main() {
    let mut x = 3;

    let f = || {
        x += 2;
    };

    f();
    assert(x == 5);
}

A mutable reference must be used for x instead.

We can still capture a mutable variable if it is only ever used immutably.
e.g. this code is still allowed:

fn main() {
        let mut x = 3;
        let f = || x;
        let _x2 = f();
        assert(x == 3);
   }

Additional Context

Documentation*

Check one:

  • No documentation needed.
  • Documentation included in this PR.
  • [For Experimental Features] Documentation to be submitted in a separate PR.

PR Checklist*

  • I have tested the changes locally.
  • I have formatted the changes with Prettier and/or cargo fmt on default settings.

@vezenovm vezenovm requested a review from a team February 21, 2025 20:34
Comment on lines 1223 to 1233
let typ = self.interner.definition_type(capture.ident.id);
if let Some(definition) = self.interner.try_definition(capture.ident.id) {
if definition.mutable && !typ.is_mutable_ref() {
let location = definition.location;
self.push_err(
TypeCheckError::MutableCaptureWithoutRef {
name: definition.name.clone(),
span: location.span,
},
location.file,
);
Copy link
Contributor

Choose a reason for hiding this comment

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

Is it necessary to issue an error here? The original intent for lambda captures was not to prevent capturing a mutable variable completely, but rather that any mutable variables should be captured by value and thus be immutable within the lambda. E.g. the equivalent of let mut x = Foo(); bar(x) where fn bar(y: Foo) { ... }

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It is not. This is what we currently allow. Taking the example from #4795 (comment):

fn main() {
    let mut x = 3;

    let f = || {
        x += 2;
    };

    f();
    assert(x == 5);
}

This example will fail on master. assert(x == 3); passes.

Copy link
Contributor Author

@vezenovm vezenovm Feb 21, 2025

Choose a reason for hiding this comment

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

I thought we desired to have an error, but I could just have this be a warning.

Otherwise, lambdas are working as expected and we just need to provide greater documentation/education.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If we wanted to capture a mutable variable immutably it would require devs to reassign their variable before capturing in a lambda. This is a bit cumbersome in my opinion.

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 aware of the original issue, but maybe I was misunderstanding this check. I was suggesting that the original intent was to allow captures of x still but capture it immutably by value instead. So if you just did a print(x) in f that'd be allowed even if x was declared as let mut x = 3;

Can you add a test to ensure

fn main() {
        let mut x = 3;
        let f = || x;
        let _x2 = f();
        assert(x == 3);
    }

(or similar) is valid?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah I see. I misunderstood as I thought from the original issue you desired this restrictive of a check. I'll update and add this test.

@vezenovm vezenovm marked this pull request as draft February 26, 2025 19:25
@vezenovm
Copy link
Contributor Author

vezenovm commented Feb 26, 2025

Looks like the dispatch aztec-nr macro is going to need updating.

Fix here: AztecProtocol/aztec-packages#12311

Comment on lines +160 to +171
let capture_ids =
lambda_context.captures.iter().map(|var| var.ident.id).collect::<Vec<_>>();
let (id, name, location) = self.get_lvalue_error_info(&lvalue);
let typ = self.interner.definition_type(id);
for capture_id in capture_ids {
if capture_id == id && !typ.is_mutable_ref() {
self.push_err(TypeCheckError::MutableCaptureWithoutRef {
name: name.clone(),
location,
});
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

I didn't try it but I thought of a way to avoid collecting the capture IDs into a Vec:

Suggested change
let capture_ids =
lambda_context.captures.iter().map(|var| var.ident.id).collect::<Vec<_>>();
let (id, name, location) = self.get_lvalue_error_info(&lvalue);
let typ = self.interner.definition_type(id);
for capture_id in capture_ids {
if capture_id == id && !typ.is_mutable_ref() {
self.push_err(TypeCheckError::MutableCaptureWithoutRef {
name: name.clone(),
location,
});
}
}
let (id, name, location) = self.get_lvalue_error_info(&lvalue);
let typ = self.interner.definition_type(id);
if !typ.is_mutable_ref() && lambda_context.captures.iter().any(|var| var.ident.id == id)
{
let name = name.clone();
self.push_err(TypeCheckError::MutableCaptureWithoutRef { name, location });
}

@asterite
Copy link
Collaborator

I think this doesn't completely solve the issue, though it solves one case of it.

For example I think this code should also be banned:

fn main() {
    let mut x = 3;

    let f = || mutate(&mut x);

    f();
    assert(x == 3);
}

fn mutate(x: &mut Field) {
    *x = 5;
}

And this one:

struct Foo {
    value: Field,
}

impl Foo {
    fn mutate(&mut self) {
        self.value = 2;
    }
}

fn main() {
    let mut foo = Foo { value: 1 };

    let f = || foo.mutate();

    f();

    println(foo);
}

(no need to fix those in this PR, though!)

TomAFrench pushed a commit to AztecProtocol/aztec-packages that referenced this pull request Feb 27, 2025
)

Post noir-lang/noir#7488 aztec-nr is going to
fail to compile as we now explicitly error when attempting to use a
capture mutably (lambda captures are meant to be entirely immutable).
The current code only worked because it was done at comptime.
Comment on lines +157 to +169
// We must check whether the mutable variable we are attempting to assign
// comes from a lambda capture. All captures are immutable so we want to error
// if the user attempts to mutate a captured variable inside of a lambda without mutable references.
let capture_ids =
lambda_context.captures.iter().map(|var| var.ident.id).collect::<Vec<_>>();
let (id, name, location) = self.get_lvalue_error_info(&lvalue);
let typ = self.interner.definition_type(id);
for capture_id in capture_ids {
if capture_id == id && !typ.is_mutable_ref() {
self.push_err(TypeCheckError::MutableCaptureWithoutRef {
name: name.clone(),
location,
});
Copy link
Contributor

Choose a reason for hiding this comment

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

I was thinking instead of checking on assignment or mutable-ref creation or method calls we could go to the source of where a lambda capture is introduced into a lambda's scope and change that variable to be immutable. Is that possible?

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok, I looked over the lambda capture code some more and we don't create any new definition ids.

So instead of changing the definition (which we can't since it is not a copy and would change the original to be immutable), we should have a single function to verify whether something is mutable and have the check only in that function. I looked across the codebase and found that we actually have already accidentally duplicated this code. &mut x calls check_can_mutate while obj.mutating_method() calls verify_mutable_reference. Assignment uses elaborate_lvalue` which returns a mutable boolean, although that method operates on an LValue rather than an ExprId so it is a bit different.

We should unify these methods into one (probably keeping elaborate_lvalue) and add the mutable capture check on the Ident case for this shared method. elaborate_lvalue should also call a new helper method to check the capture mutability on its Ident case as well.

AztecBot pushed a commit to AztecProtocol/aztec-nr that referenced this pull request Feb 28, 2025
…311)

Post noir-lang/noir#7488 aztec-nr is going to
fail to compile as we now explicitly error when attempting to use a
capture mutably (lambda captures are meant to be entirely immutable).
The current code only worked because it was done at comptime.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Closure environments are treated as mutable when they actually aren't
3 participants