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.