Chapter Eight Python Program/Notes

What is an Inside Class?

An inner class, also known as a nested class, is a class defined entirely within the body of another class. This structure is a feature of object-oriented programming in Python, used primarily for logical grouping and organization of code when one class is conceptually an integral part of another.

Chapter 8 - Inside Class

  • __new__ is automatically called to create the instance.

  • __init__ is called after the __new__ to add instance attributes to the new instance.

  • __new__ generally works by calling the superclass __new__ to create an instance, but you can use any mechanism that creates an object.

  • As long as the object is of the same type as the class or is a subtype the __init__ method is called.

  • One standard example of how new can be used is to create a singleton, i.e. a class that can only be instantiated once.

  • Another use of __new__ is to customize immutable data objects.

  • Adding a custom_call method can be used to convert any object into a callable. This is how class is converted to a callable.

  • When you make something in Python using __new__ it doesn’t get any traits and starts as a blank slate.

  • Singletons are classes that you can only instance once, or there can only be one of em basically. Some people don’t like these, and neither do I tbh (mostly cuz I don’t what the use of it is).

  • To use a class you gotta call it using __call__, which you gotta define in the object.

Chapter 8 Demo: Inside Classes, __new__, Singletons, and __call__

Demonstrates: inner/nested classes, __new__ vs __init__, singleton pattern, customizing immutable objects, and __call__ to make objects callable.

# -------------------------------------------------------------------
# 1. Inner / Nested Class — a class defined inside another class
#    Used for logical grouping when one thing belongs to another
# -------------------------------------------------------------------
print("=== 1. Inner (Nested) Class ===")

class Car:
    def __init__(self, make, model):
        self.make  = make
        self.model = model
        self.engine = Car.Engine(cylinders=4, horsepower=180)   # uses inner class

    def describe(self):
        print(f"  {self.make} {self.model} — {self.engine}")

    class Engine:                       # nested inside Car — belongs conceptually to Car
        def __init__(self, cylinders, horsepower):
            self.cylinders   = cylinders
            self.horsepower  = horsepower

        def __repr__(self):
            return f"Engine({self.cylinders}-cyl, {self.horsepower}hp)"

civic = Car("Honda", "Civic")
civic.describe()

# You can also access the inner class directly from outside if needed
big_engine = Car.Engine(8, 500)
print(f"  Stand-alone engine: {big_engine}")


# -------------------------------------------------------------------
# 2. __new__ vs __init__
#    __new__  → called first, CREATES the blank instance (blank slate)
#    __init__ → called after, POPULATES it with attributes
# -------------------------------------------------------------------
print("\n=== 2. __new__ vs __init__ ===")

class Tracked:
    instance_count = 0

    def __new__(cls, *args, **kwargs):
        print(f"  __new__  called — creating blank {cls.__name__} instance")
        instance = super().__new__(cls)   # call superclass __new__ to do the actual creation
        cls.instance_count += 1
        return instance                   # must return the new instance

    def __init__(self, name):
        print(f"  __init__ called — populating instance with name={name!r}")
        self.name = name                  # __init__ adds attributes to the blank slate

t1 = Tracked("first")
t2 = Tracked("second")
print(f"  Total Tracked instances created: {Tracked.instance_count}")


# -------------------------------------------------------------------
# 3. Singleton — __new__ ensures only ONE instance ever exists
#    (classic use case for overriding __new__)
# -------------------------------------------------------------------
print("\n=== 3. Singleton via __new__ ===")

class AppConfig:
    _instance = None    # class-level slot to hold the single instance

    def __new__(cls):
        if cls._instance is None:
            print("  Creating the ONE AppConfig instance")
            cls._instance = super().__new__(cls)
        else:
            print("  Instance already exists — returning the same one")
        return cls._instance

    def __init__(self):
        # guard so we don't re-init on subsequent calls
        if not hasattr(self, "initialized"):
            self.debug   = False
            self.version = "1.0"
            self.initialized = True

cfg1 = AppConfig()
cfg2 = AppConfig()
cfg3 = AppConfig()

print(f"  cfg1 is cfg2: {cfg1 is cfg2}")   # True — exact same object
print(f"  cfg1 is cfg3: {cfg1 is cfg3}")   # True
cfg1.debug = True
print(f"  cfg2.debug after setting cfg1.debug=True: {cfg2.debug}")  # True — same object


# -------------------------------------------------------------------
# 4. __new__ to customize an IMMUTABLE object
#    Immutable types (tuple, str, int) must be set up in __new__
#    because by the time __init__ runs, the value is already locked in
# -------------------------------------------------------------------
print("\n=== 4. Customizing immutable objects via __new__ ===")

class PositiveInt(int):
    """An integer that refuses to be negative or zero."""

    def __new__(cls, value):
        if value <= 0:
            raise ValueError(f"  PositiveInt requires value > 0, got {value}")
        instance = super().__new__(cls, value)  # int is immutable — value set here in __new__
        return instance

p = PositiveInt(42)
print(f"  PositiveInt(42) = {p}  (type: {type(p).__name__})")
print(f"  Arithmetic still works: {p + 8}")

try:
    bad = PositiveInt(-5)
except ValueError as e:
    print(f"  Caught: {e}")


# -------------------------------------------------------------------
# 5. __call__ — makes any object callable like a function
#    This is how a class itself is callable: type.__call__ handles it
# -------------------------------------------------------------------
print("\n=== 5. __call__ — callable objects ===")

class Multiplier:
    """An object that acts like a function once you call it."""

    def __init__(self, factor):
        self.factor = factor

    def __call__(self, value):          # now instances can be called like functions
        return value * self.factor

double = Multiplier(2)
triple = Multiplier(3)

print(f"  double(5)  = {double(5)}")   # calls double.__call__(5)
print(f"  triple(5)  = {triple(5)}")
print(f"  Is double callable? {callable(double)}")  # True because __call__ is defined


# -------------------------------------------------------------------
# 6. Full combo — inner class + __new__ + __call__ together
# -------------------------------------------------------------------
print("\n=== 6. Full combo: nested class with __new__ and __call__ ===")

class Pipeline:
    """Processes data through a series of steps."""

    class Step:
        """Inner class — a single processing step. Callable via __call__."""

        def __new__(cls, name, func):
            print(f"  __new__: blank Step created for '{name}'")
            instance = super().__new__(cls)
            return instance

        def __init__(self, name, func):
            self.name = name
            self.func = func

        def __call__(self, data):       # Step instances are callable
            result = self.func(data)
            print(f"  Step '{self.name}': {data!r} → {result!r}")
            return result

    def __init__(self):
        self.steps = []

    def add(self, name, func):
        self.steps.append(Pipeline.Step(name, func))
        return self                     # allow chaining

    def run(self, data):
        for step in self.steps:
            data = step(data)           # each Step is callable
        return data

print()
result = (
    Pipeline()
    .add("strip",     str.strip)
    .add("uppercase", str.upper)
    .add("exclaim",   lambda s: s + "!!!")
    .run("  hello world  ")
)
print(f"\n  Final output: {result!r}")
  • Section 1 — Inner/Nested class: Car.Engine lives inside Car because an engine conceptually belongs to a car. You can use it from inside the class or access it directly as Car.Engine from outside.

  • Section 2 — __new__ vs __init__: Shows the two-step process clearly. __new__ fires first and creates the blank slate by calling super().__new__(cls). Then __init__ runs on that blank object and fills in the attributes.

  • Section 3 — Singleton: The classic __new__ use case your notes mention. The class stores its one instance in _instance and returns it on every subsequent call instead of creating a new one. The cfg1 is cfg2 check proves they’re literally the same object in memory.

  • Section 4 — Customizing immutable objects: Since int is immutable, its value has to be set during __new__ — by the time __init__ runs, it’s too late. PositiveInt intercepts the creation and rejects negatives before the object even exists.

  • Section 5 — __call__: Multiplier instances become callable like functions once __call__ is defined. double(5) is just syntactic sugar for double.__call__(5). callable() confirms it.

  • Section 6 — Full combo: A Pipeline with a nested Step class that uses all three concepts together — __new__ creates each step, __init__ sets it up, and __call__ makes each step invokable so the pipeline can chain them.