Skip to content

Disallow reassignment of function parameters #2128

Closed
@MadLittleMods

Description

@MadLittleMods

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 arguments object. Often, assignment to function parameters is unintended and indicative of a mistake or programmer error.

https://eslint.org/docs/rules/no-param-reassign

Why? Manipulating objects passed in as parameters can cause unwanted variable side effects in the original caller.

https://airbnb.io/javascript/#functions--mutate-params

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.


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.

(ESLint demo)

'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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions