Working with Object Attributes and Behaviors

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.

ToolPurpose
@property
decorator
Expose a computed attribute read-only.
@classmethod
decorator
Method with `cls` first; usually alternative constructor.
@staticmethod
decorator
Function-in-namespace with no implicit first argument.
@cached_property
decorator
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'}