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. .. code-block:: python # ------------------------------------------------------------------- # 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.