An object's attributes are its data; its methods are its behavior. In Python the line between them is thinner than in many other languages: methods are just attributes whose value happens to be a callable. That uniformity lets you replace, wrap, or compute attributes on the fly — tricks that make Python both flexible and, occasionally, surprising.
Attributes live in two places: on the class (shared across instances) and on each instance (unique per instance). Reading obj.attr first looks at the instance dictionary, then falls back to the class. Writing obj.attr = value always sets an instance attribute, shadowing any class-level one. Knowing this lookup rule explains a lot of otherwise confusing behavior.
Methods have two main flavors. Instance methods take self and work with per-instance data. Class methods (decorated with @classmethod) take cls and work with the class itself, usually as alternative constructors. Static methods (decorated with @staticmethod) don't take an implicit first argument at all and are just functions nested in the class's namespace for organization.
Finally, @property lets you expose a computed value as if it were an attribute: obj.area runs a method under the hood but reads like a simple attribute. That makes APIs feel natural while hiding whether a value is stored or computed — a gentle form of encapsulation.
Instance attributes vs class attributes
Set instance attributes in __init__: self.count = 0. Set class attributes at class scope: count = 0 outside any method. Class attributes are shared; mutating a class-level list from one instance changes the list for every instance, which is usually a bug.
To reset a per-instance attribute to the class default, del self.attr deletes the instance-level entry; reads then fall back to the class attribute. Rarely used, but useful when you need it.
Methods and properties
Instance method: def do(self, x): .... Class method: @classmethod def from_string(cls, s): return cls(...). Static method: @staticmethod def is_valid(s): .... Property: @property def area(self): return self.w * self.h.
A read-only property needs only @property. For a settable property, add a setter: @area.setter def area(self, value): .... For computed properties that are expensive but stable, @functools.cached_property stores the first result on the instance.
Attribute and method toolbox.
| Tool | Purpose |
|---|---|
@propertydecorator | Expose a computed attribute read-only. |
@classmethoddecorator | Method with `cls` first; usually alternative constructor. |
@staticmethoddecorator | Function-in-namespace with no implicit first argument. |
@cached_propertydecorator | Expensive property computed once per instance. |
getattr(obj, name)built-in | Dynamic attribute read. |
setattr(obj, name, v)built-in | Dynamic attribute write. |
hasattr(obj, name)built-in | Check whether attribute exists. |
vars(obj)built-in | Returns instance __dict__ when available. |
Working with Object Attributes and Behaviors code example
The script defines a small Circle class and demonstrates every method kind plus a property.
# Lesson: Working with Object Attributes and Behaviors
from functools import cached_property
from math import pi, sqrt
class Circle:
unit = "meters" # class attribute
def __init__(self, radius: float) -> None:
if radius <= 0:
raise ValueError("radius must be positive")
self.radius = radius
@property
def area(self) -> float:
return pi * self.radius ** 2
@cached_property
def expensive_hash(self) -> int:
print(" (computing expensive_hash once)")
return int(sqrt(self.area) * 1_000_000) % 997
def scaled(self, factor: float) -> "Circle":
return Circle(self.radius * factor)
@classmethod
def unit_circle(cls) -> "Circle":
return cls(1.0)
@staticmethod
def is_valid_radius(value) -> bool:
return isinstance(value, (int, float)) and value > 0
def __repr__(self) -> str:
return f"Circle(r={self.radius}, unit={Circle.unit!r})"
c = Circle(3)
print(c, "| area =", round(c.area, 3))
print("cached:", c.expensive_hash, c.expensive_hash) # only once
print("scaled:", c.scaled(2))
print("unit: ", Circle.unit_circle())
print("valid? ", Circle.is_valid_radius(-1), Circle.is_valid_radius(2.5))
# Dynamic attribute access
print("getattr:", getattr(c, "radius"), "| hasattr:", hasattr(c, "radius"))
setattr(c, "note", "hello")
print("vars(c):", vars(c))
Observe the different method styles:
1) `area` is a read-only property; callers use `c.area`, not `c.area()`.
2) `expensive_hash` is cached per instance — the message prints only once.
3) `unit_circle` is a classmethod, a common 'alternative constructor' pattern.
4) `is_valid_radius` takes no self/cls and lives on the class purely for namespacing.
Try a read-write property with validation.
class TempSensor:
def __init__(self, celsius: float):
self._celsius = celsius
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
if value < -273.15:
raise ValueError("below absolute zero")
self._celsius = value
@property
def fahrenheit(self) -> float:
return self._celsius * 9 / 5 + 32
t = TempSensor(20)
print(t.celsius, t.fahrenheit)
t.celsius = 100
print(t.celsius, t.fahrenheit)
try:
t.celsius = -300
except ValueError as err:
print("blocked:", err)
Small checks to verify lookup and method kinds.
class C:
shared = 0
def __init__(self): self.x = 1
@classmethod
def make(cls): return cls()
@staticmethod
def add(a, b): return a + b
c = C()
assert c.shared == 0
C.shared = 42
assert c.shared == 42
assert isinstance(C.make(), C)
assert C.add(1, 2) == 3
Running prints something like:
Circle(r=3, unit='meters') | area = 28.274
(computing expensive_hash once)
cached: 312 312
scaled: Circle(r=6, unit='meters')
unit: Circle(r=1.0, unit='meters')
valid? False True
getattr: 3 | hasattr: True
vars(c): {'radius': 3, 'expensive_hash': 312, 'note': 'hello'}