Skip to content

erroneous behavior when creating classes inside a closure #53472

Description

@monsanto
mannequin
BPO 9226
Nosy @gvanrossum, @terryjreedy, @mdickinson, @ncoghlan, @taleinat, @ericvsmith, @benjaminp, @merwok, @bitdancer, @iritkatriel
Files
  • test.py: Test case
  • obscure_corner_cases.patch
  • obscure_corner_cases2.patch
  • test3.py
  • Note: these values reflect the state of the issue at the time it was migrated and might not reflect the current state.

    Show more details

    GitHub fields:

    assignee = None
    closed_at = None
    created_at = <Date 2010-07-11.19:56:34.789>
    labels = ['interpreter-core', 'type-bug', '3.9', '3.10', '3.11']
    title = 'erroneous behavior when creating classes inside a closure'
    updated_at = <Date 2021-12-01.16:29:28.072>
    user = 'https://bugs.python.org/monsanto'

    bugs.python.org fields:

    activity = <Date 2021-12-01.16:29:28.072>
    actor = 'iritkatriel'
    assignee = 'none'
    closed = False
    closed_date = None
    closer = None
    components = ['Interpreter Core']
    creation = <Date 2010-07-11.19:56:34.789>
    creator = 'monsanto'
    dependencies = []
    files = ['17950', '17953', '17954', '18164']
    hgrepos = []
    issue_num = 9226
    keywords = ['patch']
    message_count = 15.0
    messages = ['110037', '110038', '110039', '110040', '110041', '110045', '110046', '110048', '111386', '111488', '111489', '111509', '111512', '322383', '407468']
    nosy_count = 11.0
    nosy_names = ['gvanrossum', 'terry.reedy', 'mark.dickinson', 'ncoghlan', 'taleinat', 'eric.smith', 'benjamin.peterson', 'eric.araujo', 'r.david.murray', 'monsanto', 'iritkatriel']
    pr_nums = []
    priority = 'normal'
    resolution = None
    stage = 'needs patch'
    status = 'open'
    superseder = None
    type = 'behavior'
    url = 'https://bugs.python.org/issue9226'
    versions = ['Python 3.9', 'Python 3.10', 'Python 3.11']

    Activity

    1. monsanto commented on Jul 11, 2010

      monsantomannequin
      MannequinAuthor

      I have a function whose closure contains a local variable that shadows a global variable (lets call it x). If I create a class as follows:

      class Test(object): x = x

      Test.x will contain the value of the global x, not the local x. This ONLY happens when the names are the same, and it only happens in the class body; i.e., "class Test(object): y = x" and class "Test(object): pass; Test.x = x" work fine.

      However, if there is an assignment x = x AND you make other assignments, such as y = x, in the body, the other variables will have the wrong value too.

      Test case attached. Problem noticed on Python 2.6.2 on Windows and 2.6.5 on Linux.

    2. monsanto commented on Jul 11, 2010

      monsantomannequin
      MannequinAuthor

      A friend confirmed that this was the case on 3.1.2 as well.

    3. added
      interpreter-core(Objects, Python, Grammar, and Parser dirs)
      type-bugAn unexpected behavior, bug, or error
      on Jul 11, 2010
    4. benjaminp commented on Jul 11, 2010

      @benjaminp
      Contributor

      I'm not sure what I correct behavior is in this case. Consider the function equivalent:

      x = 3
      def f(x):
          def m():
              x = x
              print x
          m()
      f(4)

      which gives:

      Traceback (most recent call last):
        File "x.py", line 7, in <module>
          f(4)
        File "x.py", line 6, in f
          m()
        File "x.py", line 4, in m
          x = x
      UnboundLocalError: local variable 'x' referenced before assignment

      The class example works because name namespaces are unoptimized, so failing to find a binding in the local (class) namepsace, Python looks at the globals and finds the global definition.

    5. mdickinson commented on Jul 11, 2010

      @mdickinson
      Member

      I don't see anything in

      http://docs.python.org/reference/executionmodel.html#naming-and-binding

      to suggest that the class should behave differently from a nested function here; that is, I'd expect UnboundLocalError.

    6. mdickinson commented on Jul 11, 2010

      @mdickinson
      Member

      Jython 2.5.1 gives the same results as Python:

      newton:~ dickinsm$ cat test.py
      x = "error"

      def test(x):
          class Test(object):
              x = x
          print("x: ", x)
          print("Test.x: ", Test.x)
      
      test("success")
      newton:~ dickinsm$ jython2.5.1/jython test.py
      ('x: ', 'success')
      ('Test.x: ', 'error')
    7. bitdancer commented on Jul 11, 2010

      @bitdancer
      Member

      I agree with Mark, I'd expect an UnboundLocalError. I remembered this thread on Python-dev that may or may not be relevant, but is certainly analogous:

      http://www.mail-archive.com/python-dev@python.org/msg37576.html

    8. benjaminp commented on Jul 11, 2010

      @benjaminp
      Contributor

      Here's a patch. It raises a NameError in that case.

    9. mdickinson commented on Jul 11, 2010

      @mdickinson
      Member

      I think it would be worth bringing this up on python-dev, especially since it affects alternative Python implementations.

      It would also be good to have a documentation fix to the reference manual that clearly explains whatever behaviour is decided on; it's not at all clear (to me, anyway), how to extract this information from the docs.

    10. terryjreedy commented on Jul 23, 2010

      @terryjreedy
      Member

      Chris, when posting something like this, *please* include the output. I had to insert ()s to run this with 3.1. I will upload the py3 version as test3.py. Is your output the same as mine?

      x: success
      Test.x: error
      Test2.y: success
      Test3.x: error
      Test3.y: error
      Test4.x: success

      There is an obvious inconsistency between Test2 and Test/Test3. This shows up also in the dis.dis(test) output. So there is definitely a bug.

      To me, the Test2 result is the error. I base this on 7.7 Class Definitions: "The class’s suite is then executed in a new execution frame (see section Naming and binding), using a newly created local namespace and the original global namespace." I interpret this to mean that intermediate namespaces are not used (as was the case before 2.2). Indeed, this sentence is unchanged from the 2.1 doc (and before).
      http://docs.python.org/release/2.1/ref/class.html

      Of course, the intent could have changed without changing the wording, by reference to the Naming and Binding section, but then this sentence really should be changed too.

      The current Naming and Binding section includes:

      "A scope defines the visibility of a name within a block. If a local variable is defined in a block, its scope includes that block. If the definition occurs in a function block, the scope extends to any blocks contained within the defining one, unless a contained block introduces a different binding for the name. The scope of names defined in a class block is limited to the class block; it does not extend to the code blocks of methods."

      So class blocks are an exception in propagating down, and I thought they were also an exception for propagating into, for the reason stated above.

      It is possible that this is an undefined corner of the language. Certainly, the compiler is confused as it treats one nested class (Test2) as a closure and the other three nested classes as not.

      Since the name and binding design is Guido's and central to Python's operation, I personally would not touch it without his input. Hence I have added him as nosy and second the idea of pydev discussion.

    11. gvanrossum commented on Jul 24, 2010

      @gvanrossum
      Member

      Hm. This seems an old bug, probably introduced when closures where first introduced (2.1 ISTR, by Jeremy Hylton).

      Class scopes *do* behave differently from function scopes; outside a nested function, this should work:

      x = 1
      class C(object):
        x = x
      assert X.x == 1

      And I think it should work that way inside a function too.

      So IMO the bug is that in classes Test and Test3, the x defined in the function scope is not used. Test2 shows that normally, the x defined in the inner scope is accessed.

      So, while for *function scopes* the rules are "if it is assigned anywhere in the function, every reference to it references the local version", for *class scopes* (outsided methods) the lookup rules are meant to be dynamic, meaning "if it isn't defined locally yet at the point of reference, use the next outer definition".

      I haven't reviewed the patches.

    12. gvanrossum commented on Jul 24, 2010

      @gvanrossum
      Member

      I meant, of course,

      assert C.x == 1

    13. terryjreedy commented on Jul 24, 2010

      @terryjreedy
      Member

      Guido clarified:

      Class scopes *do* behave differently from function scopes;
      outside a nested function, this should work:

      x = 1
      class C(object):
        x = x
      assert C.x == 1
      

      And I think it should work that way inside a function too.

      I take that to mean that

      x = 0
      def f()
        x = 1
        class C(object):
          x = x
        assert C.x == 1
      f()

      should work, meaning that C.x==0 and UnboundLocalError are both wrong.

      That would mean to me that in "The class’s suite is then executed in a new execution frame (see section Naming and binding), using a newly created local namespace and the original global namespace." the phrase "the original global namespace" should be changed to "the surrounding namespaces".

      I also think this from Guido

      "So, while for *function scopes* the rules are "if it is assigned anywhere in the function, every reference to it references the local version", for *class scopes* (outsided methods) the lookup rules are meant to be dynamic, meaning "if it isn't defined locally yet at the point of reference, use the next outer definition"."

      should somehow also be clearer, probably also in the class page, so that people will neither expect an UnboundLocalError.

    14. gvanrossum commented on Jul 24, 2010

      @gvanrossum
      Member

      On Sat, Jul 24, 2010 at 3:21 PM, Terry J. Reedy <report@bugs.python.org> wrote:

      Terry J. Reedy <tjreedy@udel.edu> added the comment:

      Guido clarified:
      > Class scopes *do* behave differently from function scopes;
      > outside a nested function, this should work:
      > x = 1
      > class C(object):
      >   x = x
      > assert C.x == 1
      > And I think it should work that way inside a function too.

      I take that to mean that

      x = 0
      def f()
       x = 1
       class C(object):
         x = x
       assert C.x == 1
      f()

      should work, meaning that C.x==0 and UnboundLocalError are both wrong.

      Indeed.

      That would mean to me that in "The class’s suite is then executed in a new execution frame (see section Naming and binding), using a newly created local namespace and the original global namespace." the phrase "the original global namespace" should be changed to "the surrounding namespaces".

      Those words sound like they were never revised since I wrote them for
      Python 0.9.8 or so...

      I also think this from Guido

      "So, while for *function scopes* the rules are "if it is assigned anywhere in the function, every reference to it references the local version", for *class scopes* (outsided methods) the lookup rules are meant to be dynamic, meaning "if it isn't defined locally yet at the point of reference, use the next outer definition"."

      should somehow also be clearer, probably also in the class page, so that people will neither expect an UnboundLocalError.

      FWIW, unless something drastically changed recently, the language
      reference is likely out of date in many areas. I would love it if a
      team of anal retentive freaks started going through it with a fine
      comb so as to make it describe the state of the implementation(s) more
      completely.

    15. 11 remaining items

    16. devdanzin commented on Sep 21, 2025

      @devdanzin
      Member

      Reproduced on main (3.15).

    17. sergey-miryanov commented on Sep 24, 2025

      @sergey-miryanov
      Contributor

      I want to try taking this.

    18. self-assigned this
      on May 24, 2026
    19. jeremyhylton commented on May 26, 2026

      @jeremyhylton
      Contributor

      Sergey, I don't know if you got a chance to look at this issue. I think we need two changes, one more complicated than the other. The symbol table needs to track references to variables in the class scope and potentially identify bindings to non-globals. This change shouldn't be too significant, because that context already flows around class namespaces, because methods can have free variables bound to enclosing scopes. The more significant change is that LOAD_NAME only knows how to read locals from the dict. It doesn't know how to reference cells. Worse it gets passed a string, and it won't know how to interpret that string at runtime.

    20. jeremyhylton commented on Jul 13, 2026

      @jeremyhylton
      Contributor

      The class namespace is complicated. The current implementation allows a variable to switch between local and global dynamically.

      a = 1
      class C:
          x = a # a refers to the global
          a = a + 1 # now a is a local
          y = a 
          del a # the local is gone, so a is a global again
          z = a
      assert C.x == 1
      assert C.y == 2
      assert C.z == 1
      
      

      If we want this switching semnatics to be preserved for a class defined inside a function, then any variable referenced in the class and also defined in an enclosing scope must be a cell variable at its definition site and pass through to the class. We can't guarantee that the variable is current live as a local at the time it is used.

      Is that the desired intent?

    21. gvanrossum commented on Jul 14, 2026

      @gvanrossum
      Member

      Hm. So if you move things into a function you get something like this:

      def outer(a):
          class C:
              x = a   # outer a
              a = 42  # now a is local in class namespace
              y = a
              del a
              z = a   # outer a again
          return C

      That should work, whereas currently it fails with a NameError on x = a. I guess the implementation should use a special read operation that tries the class-local first and falls back to the cell (and no further). Write operations always set the class-local.

      We can use static analysis to find an outer a, but not to find the class-local a or a global a -- those are always handled completely dynamically. (I'm not sure if there's a way to set a class-local using a dynamic API that static analysis would miss, but I suspect there might be a way. Also, this is how things work currently.)

      Static analysis should also find an a if it's several function scopes away. However if there's another class scope surrounding all this (e.g. outer is itself a method of some class) that class namespace is skipped, as usual.

      PS. I thought there was a special opcode that did something related (something with a cell and a dict entry) but I can't find it.

      PPS. What should happen if you were to put nonlocal a in the class body? The logical answer seems to be that it skips looking in the class-local namespace and goes straight to the cell (and there must be a cell -- no fallback to globals).

      PPPS. I think my opinion hasn't changed since 2010. :-)

    22. jeremyhylton commented on Jul 14, 2026

      @jeremyhylton
      Contributor

      A version of this change was implemented by #103764 to support PEP 695.
      https://jellezijlstra.github.io/pep695#class-scopes describes the implementation in more detail.

      It looks like one thing that didn't get changed here is the language spec, which still says that unbound variables in the class scope are looked up in the global namespace: "These references follow the normal rules for name resolution with an exception that unbound local variables are looked up in the global namespace."

    23. jeremyhylton commented on Jul 14, 2026

      @jeremyhylton
      Contributor

      I'll slow down a little after clarifying my previous comment. The recent change affects free variables in a class body, but not "local" variables. If you assigned to a variable in the class body, the classic behavior of LOCALS & GLOBALS is maintained. If you refer to a variable that is not assigned to, then it changes to refer to name bindings in an enclosing scope. If I'm following that correctly, then this is a bit inconsistent. We should have local variables and free variables resolved using the same semantics.

    24. gvanrossum commented on Jul 14, 2026

      @gvanrossum
      Member

      Oh, so this uses dynamic lookup in class + globals:

      def outer(a):
          class C:
              a = 42
              x = a  # 42; uses LOAD_NAME
              del a
              y = a  # raises NameError

      but this example references the outer a:

      def outer(a):
          class C:
              x = a  # outer a; uses LOAD_FROM_DICT_OR_DEREF

      and this fails on the print(a):

      def outer(a):
          class C:
              print(a)  # raises NameError; uses LOAD_NAME
              a = 42

      and this doesn't work:

      def outer(a):
          class C:
              a = a  # raises NameError; uses LOAD_NAME

      (All the assignments use STORE_NAME.)

      The difference between LOAD_NAME and LOAD_FROM_DICT_OR_DEREF is as follows:

      • LOAD_NAME looks in the class scope and falls back on the global scope.
      • LOAD_FROM_DICT_OR_DEREF looks in the class scope and in a cell (for a variable defined in a containing function scope).

      The bytecode compiler generates LOAD_FROM_DICT_OR_DEREF iff:

      • there is a containing function scope defining the name, and
      • there is no assignment anywhere in the class scope to that name.
    25. jeremyhylton commented on Jul 14, 2026

      @jeremyhylton
      Contributor

      @JelleZijlstra OOC did you intend to change this behavior as part of PEP 695? We should update the language reference to reflect the behavior that is currently implemented. We may want to write a PEP that considers explicitly whether the current behavior should be preserved, or something closer to the semantics proposed here should be implemented.

    26. JelleZijlstra commented on Jul 14, 2026

      @JelleZijlstra
      Member

      I didn't intend to change any behavior for existing code in the PEP 695 implementation, and I don't think I did. This issue's original example works the same in all recent versions:

      $ for python in 3.9 3.10 3.11 3.12 3.13 3.14; do uv run --python=$python python -c '''x = 1
      def f(x):
          class Test: x = x
          return Test.x
      print(f(2))'''; done
      1
      1
      1
      1
      1
      1
      

      I haven't fully read the issue, but my view is that class namespaces have some surprising behaviors, but they do follow a set of rules that makes sense if you follow the logic closely enough, and we can't realistically afford to change any such behaviors.

    27. jeremyhylton commented on Jul 14, 2026

      @jeremyhylton
      Contributor

      Thanks for clarifying. It does look like this fell out of sync with the language spec a long time ago.

    28. jeremyhylton commented on Jul 14, 2026

      @jeremyhylton
      Contributor

      Apparently we discussed this a long time ago

    29. jeremyhylton commented on Jul 14, 2026

      @jeremyhylton
      Contributor

      The implementation changed in Python 3.4:
      3b0431d

    Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

    Metadata

    Metadata

    Assignees

    Labels

    3.13only security fixes3.14bugs and security fixes3.15bugs and security fixesinterpreter-core(Objects, Python, Grammar, and Parser dirs)type-bugAn unexpected behavior, bug, or error

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions