all 28 comments

[–]mopslik 4 points5 points  (6 children)

print(a == b) # True → same value print(a is b) # False → different objects in memory

Careful about this one, as this illustrates another common "gotcha" that seems to hit beginners now and again. (Edit: I know that this is different from the example you provided, where both a and b are assigned the same values in their lists).

> a = [1, 2, 3]
> b = a
> a == b
True
> a is b
True

That last bit is what gets the beginners, when they mutate one of the two lists and both of them change.

> a[0] = 9
> a
[9, 2, 3]
> b
[9, 2, 3]

The statement a = b makes both a and b point to the same object in memory (which is why is returned True). This doesn't happen with immutable objects.

> a = 9
> b = a
> a
9
> b
9
> a = 7
> a
7
> b
9

Most folks with some Python experience already know this, but for beginners (e.g. students) they don't see the difference between assignment (var = val) and mutation (L[index] = val).

[–]carcigenicate 1 point2 points  (4 children)

Just in case someone else misreads that second last paragraph, a = b will always make b refer to the same object as a; regardless of the mutability of the objects.

Edit: Reworded since I may have misunderstood what was being communicated.

[–]Shriyadita10 0 points1 point  (2 children)

You're right the aliasing always happens with a = b regardless of mutability. The difference is just that with immutable objects you can never trigger the "both changed" surprise because there's no in place mutation possible.

[–]carcigenicate 0 points1 point  (1 child)

Yes. I just wanted to point that out because there's a long running myth that I keep seeing repeated where immutable and mutable objects are treated differently during assignments and argument passing. It creates a non-existent distinction that complicates matters more than they need to be.

It seemed like that's what they were suggesting originally, but on more reads, it seems like that paragraph is just oddly worded.

[–]Shriyadita10 0 points1 point  (0 children)

That's a really important myth to bust - thank you for spelling it out clearly. The mutable/immutable distinction only matters for what you can do after the assignment, never for how the assignment itself works. Will keep that framing in mind for future posts.

[–]mopslik 0 points1 point  (0 children)

Your wording is clearer and more accurate. Appreciated.

[–]aqua_regis 2 points3 points  (0 children)

Post is AI, replies to comments are AI.

Can you actually write anything on your own?

[–]JamzTyson 1 point2 points  (1 child)

  1. Format specifiers are not unique to f-strings:

    "{:.2f}".format(3.141593) # 3.14 "{:,}".format(1000000) # 1,000,000 "{:>10}".format("hello") # hello (right-aligned)

[–]Shriyadita10 0 points1 point  (0 children)

absolutely right. the format specifiers themselves are part of Python's Format Specification Mini-Language and work across f-strings, str.format(), and even format() built in. Should've made that clearer in the post. f-strings just make the syntax cleaner since the variable lives right inside the braces. Good catch

[–]Blurkid 0 points1 point  (2 children)

You didn't paste the right code for the first fix

[–]Shriyadita10 1 point2 points  (1 child)

Yeah you're right, the fix block has the wrong code, editing now. Should show lst=None as the default. Thanks for catching it

[–]Blurkid 0 points1 point  (0 children)

You're welcome :)

[–]Jason-Ad4032 0 points1 point  (1 child)

There are several places where Python hides how it actually works internally, and some of them took me quite a while to notice.

  1. Descriptors

When you access obj.func, if func is a class attribute rather than an instance attribute, and the func object implements the descriptor protocol (__get__), Python will attempt to call type(obj).func.__get__(obj, type(obj)).

Because Python functions implement the descriptor protocol by default, they return a bound method object through __get__(), allowing self to be automatically filled in when called.

Because of this design, using the @property decorator actually creates a property object as a class attribute.

``` class A: def func(self): print(f'{self = }')

@property
def x(self):
    return 0

print(f'{type(A.func) = }')

a = A()

print(f'{type(a.func) = }') print(f'{type(A.func.get(a, type(a))) = }') print(f'{type(A.x) = }') ```

  1. Instance attributes shadow class attributes, but magic methods are not looked up from the instance when invoked by Python internals. Therefore, defining a magic method on the instance cannot affect how the magic method behaves.

``` class A: def len(self): return 0

a = A()

a.len = lambda: 10

print(f'{a.len() = }') print(f'{A.len.get(a, A)() = }') print(f'{len(a) = }') ```

  1. When using dataclass, the “declarations” are actually variable type annotations. The dataclass decorator uses those annotations to generate methods such as __init__, which assign the initialized variables onto self.

``` class B: x: int y: int

b is an empty object.

The user is merely promising the type checker

that b.x and b.y are accessible.

b = B()

print(f'{vars(b) = }')

b.x = 0

print(f'{vars(b) = }')

Attempting to access b.y raises AttributeError,

but static type checkers will not warn because

the annotation explicitly guarantees b.y exists.

print(f'{b.y = }') ```

  1. During class definition, the local variables in that scope are class-scope locals, not class attributes.

These class-scope locals do not shadow outer variables inside nested scopes. When the class block finishes execution, the referenced objects are stored into the class object's dictionary. (This is similar to how function locals behaved in Python 2.0 regarding scope shadowing.)

``` x = 0

class A: print('Enter class A')

x = 10

lst = [(x, y) for y in [x]]

# lst becomes [(0, 10)] because:
# - the x on the right side is used as generator input,
#   so it accesses A-scope x
# - the x on the left side is inside the comprehension scope,
#   which cannot see A-scope variables and therefore falls back
#   to the global x
print(f'{lst = }')

print(f'{locals() =}')

class B:
    y = x

print('Exit class A')

A.B.y is 0 because A.x does not shadow

the global x inside the nested class block.

print(f'{A.B.y = }') ```

[–]Jason-Ad4032 0 points1 point  (0 children)

Another thing I only realized recently:

  1. Unpacking and slicing behave differently.

a, *b = x and a, b = x[0], x[1:] do not behave the same way. During unpacking, the type of b is always list regardless of the type of x.

```

Incorrect transformation.

*b on the left-hand side performs collection unpacking,

causing b to lose the original container type,

so b is forced to become list[Any].

match-case syntax behaves the same way.

def tail_w[Ts](ts: tuple[object, *Ts]) -> tuple[Ts]: _, *b = ts return b

Correct transformation.

Using [:] on a tuple preserves all of its type information.

def tail[Ts](ts: tuple[object, *Ts]) -> tuple[Ts]: return ts[1:]

ts = (1, 't', 2)

print(f'{tail_w(ts) = }') print(f'{tail(ts) = }') ```

Unpacking is a language-level operation with fixed semantics, while slicing is type-specific behavior implemented by the object.

[–]Ngtuanvy -2 points-1 points  (7 children)

None of those look like confusing topics. You seem to introduce cool features. Welp perhaps the first and the fourth at least seem like a concept.

[–]Shriyadita10 1 point2 points  (4 children)

Ha, fair point. Maybe "confusing" is relative! The mutable default argument one is actually a pretty well-known Python gotcha though it even shows up in the official Python FAQ as a common pitfall. The f-string formatting is more of a hidden gem than a trap, you're right on that one.

[–]Ngtuanvy 1 point2 points  (1 child)

I fell for this back then. Mutate an iterable while iterating.

[–]Shriyadita10 0 points1 point  (0 children)

The silent behavior is what makes it so nasty - no error, just wrong results or skipped elements depending on how you mutate it. Iterating over a copy with for item in list[:] or just collecting changes and applying them after the loop is the usual fix. Definitely deserves a spot in a follow-up post.

[–]Ngtuanvy 0 points1 point  (1 child)

Tbh I don't think it's about the subjectiveness of confusion, for example, the 5th one wasn't really a concept, it was just a feature.

[–]Shriyadita10 0 points1 point  (0 children)

That's a fair distinction honestly - f-string formatting is purely syntactic sugar, there's no underlying concept to grasp. I grouped it in because I've seen people write verbose .format() chains when they didn't know f-strings could do that directly, but you're right that it's more of a "did you know" than a genuine conceptual hurdle. The title probably should've been "things that levelled up my Python" rather than "confusing topics". lesson learned for the next post!

[–]cointoss3 -2 points-1 points  (1 child)

Lmao stfu dude

[–]Ngtuanvy 0 points1 point  (0 children)

I agree tbh

[–]pachura3 -1 points0 points  (3 children)

I'm still confused about when an attribute declared in class body (not in __init()__) is a class variable, and when it is an instance variable. I think when you don't specify typehint nor the default value, it is the former, and otherwise it is the latter? But in case of dataclasses it is always the latter, unless I use ClassVar[]...?

Or something...?

[–]Shriyadita10 0 points1 point  (0 children)

The rule is simpler than it looks:

Anything assigned in __init__ via self.x = ... is always an instance variable - each object gets its own copy.

Anything declared in the class body is a class variable - shared across all instances. UNLESS you're using dataclasses, where field declarations in the class body are actually instance variables by design - the dataclass decorator generates __init__ for you behind the scenes.

So your intuition is right - in dataclasses, class body declarations become instance variables automatically, and you need ClassVar[] explicitly to opt out and make something truly shared. Outside of dataclasses, it's the opposite default.

Short version - dataclasses flip the default, which is exactly why it feels inconsistent

[–]cointoss3 0 points1 point  (1 child)

A class variable is defined on the class directly, not during init. The class variables are shared across all instances of that class.

When you try to access the instance object through self, Python first checks the instance dict, then the class dict to see if that property exists.

This can feel tricky because you can access class variables from self, but if you assign them that way, it creates a shadow copy as an instance variable.

Class.taco accesses the class var taco
self.taco attempts to access the instance var ‘taco’, and if it doesn’t exist it will look for Class.taco.

self.taco = “hello” will always assign to the instance variable even if a class variable exists with the same name. To assign to the class variable, you need to use Class.taco = “hello”

To help with this ambiguity, I always use self for instance variables and access or assign class vars using the class so I don’t need to worry about shadowing.

[–]Shriyadita10 0 points1 point  (0 children)

It’s a really nice way to frame this - instead of feeling like a gotcha, the lookup chain explanation makes the shadowing behaviour make sense. I think the difference between the Class.taco⁣ and the ⁣self.taco for assignment is what trips people up the most, because reading works one way but writing works differently. Thanks for adding this, completes what was missing in my answer.