Python Objects
OOP Core
Working with Objects
Every class instantiates objects. Understand identity, equality, and lifecycle events for predictable behavior.
Everything is an object
In Python this is literal, not a slogan. Numbers, strings, functions — even classes themselves — are objects with a type and an identity:
n = 42
print(type(n)) # -> <class 'int'>
print(id(n) > 0) # -> True — every object has an identity
def greet():
return 'hi'
print(type(greet)) # -> <class 'function'>
print(type(len)) # -> <class 'builtin_function_or_method'>
print(type(int)) # -> <class 'type'> — classes are objects too
print(isinstance(int, object)) # -> True
Because functions are objects, you can pass them as arguments, store them in lists, and attach attributes to them. Because classes are objects, you can pass a class to a function or keep one in a dict. The whole language is built on this uniformity — the data types you use daily are all instances of some class.
Identity vs equality
Identity asks "is this the same object?" (is, compares id()); equality asks "does it hold the same value?" (==, defined by __eq__). Two instances built from the same arguments are equal-looking but not identical:
class Invoice:
def __init__(self, total, customer):
self.total = total
self.customer = customer
a = Invoice(100, "Ada")
b = Invoice(100, "Ada")
print(a is b) # False
print(a == b) # Depends on __eq__ — False here, default falls back to identity
c = a
print(a is c) # -> True — two names, one object
Define __eq__ to give == a real meaning for your type:
class Invoice:
def __init__(self, total, customer):
self.total = total
self.customer = customer
def __eq__(self, other):
return (self.total, self.customer) == (other.total, other.customer)
print(Invoice(100, "Ada") == Invoice(100, "Ada")) # -> True
Use is only for singletons like None; use == for values. The full story, including the small-integer caveat, is in identity vs equality. And remember mutable objects share references — two names bound to one list see each other's changes; copy the list when you need independence.
Discovering attributes with dir()
Every object carries its attributes and methods with it, and dir() lists them — handy in the REPL when exploring an unfamiliar object:
s = 'hello'
methods = [name for name in dir(s) if not name.startswith('_')]
print(methods[:5]) # -> ['capitalize', 'casefold', 'center', 'count', 'encode']
print(hasattr(s, 'upper')) # -> True
print(getattr(s, 'upper')()) # -> HELLO — methods are attributes you can call
Lifecycle
__new__allocates memory (rarely overridden).__init__initializes attributes.__repr__/__str__render human-readable info.__del__runs when garbage collected — timing is non-deterministic, so avoid relying on it for cleanup. Use context managers (withstatements) instead.
CPython keeps objects alive by reference counting: each object tracks how many names and containers point at it. sys.getrefcount shows the count (one higher than expected, because passing the object as an argument adds a temporary reference):
import sys
data = []
print(sys.getrefcount(data)) # -> 2 — the name data + the argument reference
alias = data
print(sys.getrefcount(data)) # -> 3
del alias
print(sys.getrefcount(data)) # -> 2
del removes a name, decrementing the count — it does not directly destroy the object. When the count hits zero, CPython reclaims the object immediately (a separate garbage collector handles reference cycles):
class Resource:
def __del__(self):
print('finalized')
r = Resource()
del r # last reference gone, count hits zero
# finalized
That immediacy is a CPython detail, not a language guarantee — which is exactly why the advice above stands: do real cleanup in a context manager, not in __del__.
Make objects debuggable with repr
The default representation is nearly useless: <__main__.Point object at 0x7f3a...>. Define __repr__ to say what the object is; print and error messages pick it up automatically:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f'Point({self.x}, {self.y})'
p = Point(2, 3)
print(p) # -> Point(2, 3) — str falls back to __repr__
print([p]) # -> [Point(2, 3)] — containers always use __repr__
Add __str__ only when you want a second, user-facing rendering distinct from the debugging one. Both belong to the wider family of dunder methods.
Frequently Asked Questions
What does "everything is an object" actually mean?
Every value in Python — numbers, strings, functions, modules, classes — is a full object with a type (type(x)), an identity (id(x)), and attributes. There are no primitive non-object values as in Java or C. Practically it means anything can be assigned to a variable, passed to a function, stored in a container, or inspected with dir().
When is an object destroyed?
When nothing references it anymore. CPython counts references and reclaims an object the moment its count reaches zero; a cycle-detecting garbage collector handles objects that reference each other. del only removes one reference (a name) — the object survives as long as any other reference exists. Never depend on the exact moment of destruction for cleanup; use a with statement.
x = [1, 2]
y = x
del x # the list lives on — y still references it
print(y) # -> [1, 2]
Why does is sometimes work for small numbers?
CPython caches the integers -5 through 256 and reuses (interns) some strings, so two names for the value 100 can point at the very same cached object and 100 is 100 happens to be True. This is an implementation optimization, not a promise — it fails for larger numbers and varies between contexts. Always compare values with ==; reserve is for None.
x = 1000
y = 10 * 100
print(x == y) # -> True — always correct
print(x is y) # implementation-dependent: may be True or False