Let’s be clear: it’s worse than just that the “=” symbol is used for both declarations and reassignments. The “voodoo” is the system by which Python determines which it assumes you mean.
Names mentioned in function definitions without explicit scoping refer to the lexically innermost existing binding of that name (if no such binding is found, the name is construed as bound at the outermost level, an awfulness of its own), unless there is any line anywhere in the function (but not within a further nested function) which assigns to that name, in which case this creates a new binding. It’s not possible to determine the scope of a particular instance of a name simply by reading up to that instance; one must also check whether there are any assignments referring to that same name later on. Thus, “def foo(): return x” and “def foo(): return x; x = x” have very different behavior, with the latter guaranteed to throw UnboundLocalError no matter the environment.
This is particularly egregious since, if the first mention of a name within a function (again, not counting mentions within further nested functions) reads it, then the mention can only make sense as a reference to an external binding. Thus, even if one is committed to a voodoo system of inferring scoping from use, there is an infinitely better rule available: just check whether the first mention reads the variable before or without assigning to it. This gets rid of the lookahead problem. (Here, “first” might as well be interpreted syntactically rather than in terms of execution order, even if the latter is more directly relevant; it’s better for voodoo to be predictable than clever)
For that matter, there’s a second major awfulness in the explicit scoping rules: The “nonlocal” keyword can be used to suppress the creation of a new binding for a given name, even in the presence of assignment statements, unless this would cause that name to refer to a binding at the very outermost level, in which case this is a syntax error, and one must use the “global” keyword instead (which can always be used to make a name refer to bindings at the outermost level, regardless of the presence of innermore bindings).
Thus, when writing “x = 5; def foo(): {SCOPEWORD x; return x}”*, with the intent that all the instances of the name ‘x’ refer to the same variable, one must know whether this code will be written at the outermost level or within a function definition to know whether SCOPEWORD should be “global” or “nonlocal”. When moving such code between the outermost level and the inside of a function definition, one must hunt down all the relevant instances of “global” and change them to “nonlocal”.
Why? Why on Earth shouldn’t “nonlocal” simply and naturally cover the global case as well?! What in God’s name is the point of enforcing a distinct keyword for this special case?
Related to this, a minor awfulnesses: the “global” keyword allows reference from arbitrarily nested functions to bindings at the outermost level. However, there is no way to refer from arbitrarily nested functions to bindings at any level inbetween the outermost level and the innermost level with a matching binding. Which means the code “x = 5; def foo(): {x = 6; def foo2(): {SCOPEWORD x; return x}; return foo2()}”*, with the intent that foo() returns 5, can be written at the top-level using “global” for SCOPEWORD, but it is impossible to find an appropriate substitution for SCOPEWORD at any other level. When moving such code between the top level and the inside of a function definition, one must hunt down all the relevant instances of “global” and then… sigh loudly and give up, because the desired keyword doesn’t exist.
Instead of all this: Either all names should refer to the innermost existing binding by default and there should be a special syntax to declare new variables, or all names should refer to local variables by default and there should be a keyword to suppress this behavior and use the innermost matching binding instead. I prefer the former, but I understand that Python intentionally shied away from this to make initialization of variables indistinguishable from later assignments to them. There oughtn’t be a keyword specifically for reference to bindings at the outermost level, but it would be acceptable to have means of referring to second-innermost-binding, third-innermost-binding, etc. Finally, I would prefer that it not be possible to refer to uninitialized variables; every variable should be initialized at declaration time. However, even if this was not adopted, a reference to a variable for which no binding exists should be considered a syntax error, not an opportunity to pretend there is such an uninitialized binding automatically at the outermost level.
These are basically the biggest mistakes of Python’s design. Were they fixed, I would have an order of magnitude less quibbles with the language (much like Kernighan re: Pascal’s arrays). Yes, eventually you get used to it… but getting used to a terrible design doesn’t make it not terrible. The fact that Python seems to keep having to change its scoping system between versions (I am referring to Python 3 in the above) bolsters my confidence that it has never been well thought out and this is all just poor language design on their part, rather than simply a failure of appreciation on my part.
(A talented programmer with decades of experience once said to me, about the Python scope-disambiguation system, “There are rules? I just fiddle with the code until it works.”. I suspect they are not alone in having adopted that attitude…)
It was particularly shitty for my intro programming students, who did not yet have any experience with more sanely designed programming systems to draw on, as they would largely either be confused and overwhelmed at random intervals by this nonsense, or, worse, grow to accept it as the natural, proper way of things. (Yes, I sought to explain the details of this system to them early on, even as colleague teachers adopted the “Eh, let’s just not mention it and hope it never comes up” attitude, but it was unfortunate to see that the students had difficulty appreciating the distinction between the general concepts of scope of variables, environments, etc., and the particular complications which arise in determining which scope will be assigned in Python due to its particular boneheaded design. Students stymied by the latter would find it to frustrate their understanding of the former as well.). Thus, the need for early exposure to other languages…
[*: I am using semicolons and braces here not as valid Python syntax, but simply to indicate how the newlines and indentation would go in valid Python syntax, because I’m too lazy to bother with “code” tags right now]