Description
Rule request
Thesis
Add a lint rule similar to ESlint no-param-reassign
for JavaScript which disallows reassignment of function parameters.
Assignment to variables declared as function parameters can be misleading and lead to confusing behavior,
as modifying function parameters will also mutate the. Often, assignment to function parameters is unintended and indicative of a mistake or programmer error.arguments
object
Why? Manipulating objects passed in as parameters can cause unwanted variable side effects in the original caller.
It's also about the clarity of mutating and reassigning - it's easier to understand functions when the arguments are static and constant throughout the life of the function.
eslint/archive-website#686 (comment)
Related discussion, eslint/eslint#5306
When a function parameter gets re-assigned, it masks the original argument passed in. With a sufficiently long function, it's not obvious that the variable was assigned to something different than what you expect from quickly reading the function signature then jumping down to the relevant piece you're interested in. When this occurs, it's probably more the case, that you accidentally used the same variable name which is why it should be disallowed.
Even if re-assigning the function parameter was intended, it would probably be more clear to assign a new local variable and maybe make a copy to avoid modifying the underlying object:
def example(context):
new_context = context
if True:
new_context = compute_context()
- shadowing
- masking
- overriding
- overwriting
Reasoning
There are similar violations already in the best practices section but they don't cover the following scenario.
BlockAndLocalOverlapViolation
: Forbid overlapping local and block variables.OuterScopeShadowingViolation
: Forbid shadowing variables from outer scopes.ControlVarUsedAfterBlockViolation
: Forbid control variables after the block body.
Recently ran into a real-life bug because of accidentally re-assigning a function parameter, https://github.com/matrix-org/synapse/pull/10439/files/a94217ee34840237867d037cf1133f3a9bf6b95a
Simplified reproduction case:
def ensure_correct_context(event, context):
remote_auth_chain = get_event_auth(event)
for auth_event in remote_auth_chain:
# XXX: Violation should trigger here!
# We are introducing a bug because `context` which corresponds to `event`
# is being re-assigned to the `context` for the `auth_event`
context = compute_event_context(auth_event)
persist_event(auth_event, context)
# logic for context_needs_updating
if context_needs_updating:
return compute_event_context(event)
return context
Fixed code:
def ensure_correct_context(event, context):
remote_auth_chain = get_event_auth(event)
for auth_event in remote_auth_chain:
auth_event_context = compute_event_context(auth_event)
persist_event(auth_event, auth_event_context)
# logic for context_needs_updating
if context_needs_updating:
return compute_event_context(event)
return context
This rule wouldn't protect from a similar scenario where context
is a local variable because Python does not have block scope and there is no distinction between declaration and assignment so we can't be protected that way either. I don't think we can solve this scenario with lints unless the linter wants to interpret Python and enforce block-scope with a unique variable name constraint (not suggesting this).
def ensure_correct_context(event):
context = compute_event_context(event)
remote_auth_chain = get_event_auth(event)
for auth_event in remote_auth_chain:
# XXX: Bug is here but no violation would be triggered
context = compute_event_context(auth_event)
persist_event(auth_event, context)
# logic for context_needs_updating
if context_needs_updating:
return compute_event_context(event)
return context
Comparing to JavaScript
How the same code would behave in JavaScript
This isn't trying to claim JavaScript vs Python. I'm just trying to document what I am familiar with and come up with some lints we can use for Python to avoid the same scenarios.
Converting that problem Python code to JavaScript, it works as expected even though the function parameter and variable in the loop are both named context
because JavaScript is block-scoped. The const
usage can also be enforced with ESLints prefer-const
rule.
'use strict';
function get_event_auth() { return [1,2,3]; }
function compute_event_context(event) { return { foo: event } }
function persist_event() {}
function ensure_correct_context(event, context) {
const remote_auth_chain = get_event_auth(event);
for (const auth_event of remote_auth_chain) {
const context = compute_event_context(auth_event);
persist_event(auth_event, context);
}
// logic for context_needs_updating
const context_needs_updating = false;
if (context_needs_updating) {
return compute_event_context(event);
}
return context;
}
ensure_correct_context(999, { foo: 999 })
// -> {foo: 999}
// ✅ expected result
If we forget the const
, ESLint warns us about re-assigning a function parameter thanks to the ESlint no-param-reassign
rule.
'use strict';
function get_event_auth() { return [1,2,3]; }
function compute_event_context(event) { return { foo: event } }
function persist_event() {}
function ensure_correct_context(event, context) {
const remote_auth_chain = get_event_auth(event);
for (const auth_event of remote_auth_chain) {
context = compute_event_context(auth_event);
persist_event(auth_event, context);
}
// logic for context_needs_updating
const context_needs_updating = false;
if (context_needs_updating) {
return compute_event_context(event);
}
return context;
}
ensure_correct_context(999, { foo: 999 })
// -> ESLint: 10:5 - Assignment to function parameter 'context'.
// ✅ The linter caught our mistake
If we change the example where the context
is already a local variable in the function instead of a function parameter, it still works because of block scope.
'use strict';
function get_event_auth() { return [1,2,3]; }
function compute_event_context(event) { return { foo: event } }
function persist_event() {}
function ensure_correct_context(event) {
const context = compute_event_context(event);
const remote_auth_chain = get_event_auth(event);
for (const auth_event of remote_auth_chain) {
const context = compute_event_context(auth_event);
persist_event(auth_event, context);
}
// logic for context_needs_updating
const context_needs_updating = false;
if (context_needs_updating) {
return get_new_context_for_event(event);
}
return context;
}
ensure_correct_context(999)
// -> {foo: 999}
// ✅ expected result
And finally, if we forget the const
, on the second context
, we see Uncaught TypeError: Assignment to constant variable.
because of the nice const
guarantees (can't be reassigned or redeclared).
'use strict';
function get_event_auth() { return [1,2,3]; }
function compute_event_context(event) { return { foo: event } }
function persist_event() {}
function ensure_correct_context(event) {
const context = compute_event_context(event);
const remote_auth_chain = get_event_auth(event);
for (const auth_event of remote_auth_chain) {
context = compute_event_context(auth_event);
persist_event(auth_event, context);
}
// logic for context_needs_updating
const context_needs_updating = false;
if (context_needs_updating) {
return get_new_context_for_event(event);
}
return context;
}
ensure_correct_context(999)
// -> Uncaught TypeError: Assignment to constant variable.
// ✅ The language mechanics caught our mistake