Conversation
| while value > 0 { | ||
| new_env = execute(*stmt, &mut new_env)?.clone(); | ||
| value = eval(*cond, &env)?; | ||
| execute(stmt, env)?; |
There was a problem hiding this comment.
Hi @helpmehelpus, thanks for the detailed pull request!
Question: In this line, should we make it more straightforward that we accumulate changes in the environment during each iteration of the while loop? Would something like env = execute(stmt, env) improve readability and make the code more "functional"? Otherwise, it currently feels as though env might be intended as a global variable.
There was a problem hiding this comment.
@rbonifacio yes, we can. It looks like we two main options here.
If we just do
while value > 0 {
env = execute(stmt, env)?;
value = eval(cond, env)?;
}we get two errors, both related to the current signature of execute:
pub fn execute<'a>(stmt: &Statement, env: &'a mut Environment) -> Result<&'a Environment, String> {The first error is that we cannot assign multiple times to the immutable variable env. Note that, even though it holds a mutable reference, the parameter itself is not annotated with mut, and is therefore immutable.
The second error is that we are returning an immutable reference from the function, so there is a type mismatch between the input env and the output.
Option 1 (least preferred imo)
We can do
Statement::While(cond, stmt) => {
let mut value = eval(cond, env)?;
while value > 0 {
let env = execute(stmt, env)?; // could also be let _ = execute(stmt, env)?; still smells bad
value = eval(cond, env)?;
}
Ok(env)
}Here we are basically leveraging Rust's shadowing to define an env variable that is scoped to the while block. We assume that we are mutating the outer env on each call to execute inside the while block and storing its result in a new variable to compute the result of eval.
Keep in mind that the inner let env goes out of scope at the end of the while block, and that Ok(env) will return the mutated environment parameter. This does not look good to me, as we are essentially using two different env objects in execute(stmt, env)? and in eval(cond, env)?. Just to explain this is confusing, let alone keeping this in mind as the code gets more complicated.
Option 2 (probably best)
If I understand what you are asking, we want to use the same env parameter throughout the entire function. So we can change the signature of execute to:
pub fn execute<'a>(stmt: &Statement, mut env: &'a mut Environment) -> Result<&'a mut Environment, String> {and now we can do what you asked:
while value > 0 {
env = execute(stmt, env)?;
value = eval(cond, env)?;
}There is now a single env, the mutation is explicitly and there is less confusion.
Please let me know if this is the desired outcome, and I will commit the changes.
new tests and shadowing parameter verify
This PR does the following
cargo fmtapplies it to the codebase for usWhilearm of thematch stmtinexecuteto compile. To achieve this, a few changes to the signatures ofexecuteandevalare necessary. This also makes the code looks more like what Rust expect from us. Please see the explanation belowWhy the current code does not work
In Rust, when we call a function and the pass the values directly, we give ownership of the value to the inner scope.
This means that if we have something like
ownership of val will be moved to
do_something. Moreover, values are automatically dropped when they go out of scope.Think of it as the compiler automatically inserting a call to drop at the end of the scope. Visually, it looks like:
Because of this, we will get a few compile errors in our current code for
Statement::While.To work around this, we leverage the concept of immutable references. As their name suggests, they are references that
can be passed around but don't allow mutation. A very brief summary of the rules of Rust's borrow checker is:
Since we want to be able to call
evalmultiple times with aBox<Expression>that we do not plan to mutate, we change thesignature of eval to take an immutable reference to
Expression:This makes it so we do not need to dereference every single expression throughout the code, and instead only the
i32that
CIntwraps.Since we want to call
executeon a statement an arbitrary number of times, we can also pass an immutable reference tostmtinstead:Upon adjusting the test code to pass references to the calls, if we run
cargo checknow, we get a lifetime error:Lifetimes are my least understood aspect of Rust so far, but Rust's compiler errors are very informative. What this is
telling us is that we need to:
envin our case)at least as long as the input reference.
We do this by changing the signature of
executeto the followingWith this, and a few fixes to
env.get()andenv.insert(), we are almost done.cargo checknow throws one finalerror:
Since
envis already a mutable reference, we don't really need to clone it, and can mutate it in place, like we arealready doing in the other arms of the
match.Note that, since
envis itself a reference, we are not required to do&envin intenral calls toevalorexecute.The same applies to
cond; now that we have made them all references, we can pass them around without worrying aboutownership. We still do that in the tests because we need to pass references to the functions we call. But inside them,
we don't need
*and&all over, and it now looks a bit cleaner.Now
cargo checkpasses, and so doescargo test. We obviously need to add tests for our while statements, but at leastwe have gotten the ownership.
Summary
Passing ownership around can cause us to not be able to use values again, as they are consumed and become unavailable.
This statically prevents a lot of memory safety errors such as use after free. Having arbitrarily as many immutable references
at our disposal is also nice. Since Rust knows the objects represented by immutable references cannot be modified, "all"
we need to do is to ensure that our references will live long enough, and hence why Rust gives us control over lifetimes.
Reference
Rust book https://doc.rust-lang.org/book/