Professional Python (2024)
Professional Python (2024)
Professional Python (2024)
Python
Object-Oriented Approaches
1st Edition
2024
Aria Thane
© 2024 Aria Thane . All rights reserved.
This book is provided for informational purposes only and, while every
attempt has been made to ensure its accuracy, the contents are the author's
opinions and views. The author or publisher shall not be liable for any loss
of profit or any other damages, including but not limited to special,
incidental, consequential, or other damages.
P R EFAC E
Welcome to Professional Python a comprehensive guide designed to take
you on a transformative journey through the landscape of object-oriented
programming (OOP) in Python. This book is a culmination of years of
experience, insights, and a deep passion for programming, distilled into a
structured path that aims to unlock the full potential of Python for
developers who aspire to elevate their coding skills to a professional level.
The genesis of this book can be traced back to a series of observations and
interactions within the programming community. Python, with its versatility
and ease of use, has emerged as a lingua franca for developers across
diverse domains, from web development to data science and beyond.
However, amidst its widespread adoption, a common thread surfaced: a gap
in understanding and applying object-oriented programming principles
effectively to build scalable, maintainable, and efficient software.
Object-oriented programming is not merely a programming paradigm; it's a
way of thinking, a methodology that, when leveraged correctly, can lead to
robust software design. This book is an endeavor to bridge this gap, to
move beyond the basics of Python, and delve into the hows and whys of
OOP, presenting it not just as a set of concepts, but as a toolkit for solving
real-world software development challenges.
Educators and students will also find this book a valuable resource, offering
a structured curriculum for teaching and learning Python's OOP features,
complemented by examples, exercises, and real-world case studies.
Furthermore, professionals transitioning to Python from other programming
languages can leverage this book to fast-track their understanding of
Pythonic OOP.
Acknowledgments
This book is a product of not just one author's effort but the support,
feedback, and contributions of countless individuals—colleagues, peers,
mentors, and the programming community at large. I am deeply grateful to
everyone who played a part in making this book a reality, from the early
reviewers who provided invaluable feedback to the team that worked
tirelessly behind the scenes to bring this project to fruition.
Aria Thane
PART I : F OUNDATI ONS OF P YT HON AND
OB JEC T- OR I E NT ED P R OGR AMMI NG
Part I serves as the cornerstone of your journey into the world of Python and
object-oriented programming. This section is meticulously designed to build
a strong foundation, starting from the very basics of Python programming,
advancing through its more complex features, and finally delving into the
core principles of object-oriented programming (OOP). Whether you're new
to Python or looking to deepen your understanding of its OOP capabilities,
this part equips you with the essential knowledge and skills to start building
robust, scalable, and efficient software applications.
A code editor is a text editor designed for writing and editing code. It's
typically lighter-weight than an IDE and focuses on offering flexible editing
tools with a customizable environment. Key features and characteristics
include:
Syntax Highlighting: Colors and styles different elements of
source code, such as keywords, variables, and symbols, to
improve readability.
Code Formatting: Automatically formats code according to
specified style guidelines to maintain consistency.
Basic Code Navigation: Allows developers to quickly
navigate to different files and symbols within a project.
Extensibility: Many code editors can be extended with
plugins to add new features, such as language support,
linting, and version control integration.
Language Agnostic: While some code editors are optimized
for specific languages, most are language-agnostic,
supporting a wide range of programming languages through
extensions.
Examples of code editors include Visual Studio Code (VS Code), Sublime
Text, Atom, and Notepad++.
IDE vs. Code Editor: The Differences
Functionality: IDEs offer a more comprehensive set of
integrated development tools (e.g., debugging, build
automation) out of the box, whereas code editors are
primarily focused on editing code with the option to add
extra features through extensions.
Performance: Due to their lightweight nature, code editors
generally start up faster and consume fewer system resources
compared to IDEs, which can be more resource-intensive.
Complexity and Learning Curve: IDEs can have a steeper
learning curve due to their extensive features and settings.
Code editors, being simpler, can be easier for beginners to
grasp.
Flexibility: Code editors are often praised for their flexibility
and customization options, allowing developers to tailor the
tool to their specific needs. While IDEs can also be
customized, they are sometimes perceived as more rigid.
PyCharm requires a Python interpreter to execute your Python code. You can
use an interpreter provided by a Python installation on your system or
configure PyCharm to use a virtual environment.
1. Using System Python:
If you've already installed Python on your system, PyCharm
should automatically detect it. You can confirm or change the
interpreter via File > Settings (or PyCharm >
Preferences on macOS), then navigate to Project:
<YourProjectName> > Python Interpreter.
If PyCharm does not automatically detect your Python installation, click the
gear icon next to the interpreter path, select "Add," and navigate to your
Python executable to add it manually.
2. Creating a Virtual Environment:
A virtual environment is a self-contained directory that contains a Python
installation for a particular version of Python, plus a number of additional
packages.
Basic Syntax
if True:
print("This is correct indentation.")
Variables: Variables in Python are created when you assign a value to them.
Python is dynamically-typed, which means you don't need to declare a
variable's type ahead of time.
x=5
name = "Alice"
Control Structures
for i in range(5):
print(i)
count = 5
while count > 0:
print(count)
count -= 1
Functions
def greet(name):
return "Hello, " + name + "!"
print(greet("Alice"))
Lambda Functions: Also known as anonymous functions,
lambda functions are small, one-line functions defined
without a name using the lambda keyword.
square = lambda x: x * x
print(square(4))
Collections
coordinates = (4, 5)
x = 10
message = "Hello, Python!"
Control Structures
x = 10
if x > 5:
print("x is greater than 5")
This basic form checks a condition and executes the indented block if
the condition is true.
If-Else Statement
x=2
if x > 5:
print("x is greater than 5")
else:
print("x is not greater than 5")
The if-else structure allows you to define an action for the false
condition of the if statement.
Elif Statement
For multiple conditions, use the elif (short for "else if") statement:
x = 10
if x > 10:
print("x is greater than 10")
elif x == 10:
print("x is exactly 10")
else:
print("x is less than 10")
Loops
Loops in Python are used to iterate over a block of code multiple
times. Python provides two loop commands: for loops and while
loops.
For Loops
The for loop in Python is used to iterate over the elements of a
sequence (such as a list, tuple, string) or other iterable objects.
You can use the range() function if you need a sequence of numbers.
for x in range(5):
print(x)
While Loops
The while loop in Python repeats as long as a certain boolean
condition is met.
x=0
while x < 5:
print(x)
x += 1
Iteration
Iteration refers to the process of looping through the elements of an
iterable (like lists, tuples, dictionaries, etc.). Python makes iteration
easy with its for loop syntax and iterable objects.
Iterating Over Dictionaries
Nested Loops
You can nest loops within loops, but be mindful of the complexity this
adds to your program.
for x in range(3):
for y in range(3):
print(f"({x}, {y})")
for x in range(5):
if x == 3:
break
print(x)
python
for x in range(5):
if x == 3:
continue
print(x)
for x in range(3):
print(x)
else:
print("Finished looping")
Functions
Functions in Python are blocks of organized, reusable code that
perform a single, related action. Functions provide better modularity
for your application and a high degree of code reusing. As you dive
deeper into Python, understanding functions—how to define them,
pass arguments, and return results—is crucial for writing efficient,
readable, and maintainable code.
Defining a Function
In Python, a function is defined using the def keyword, followed by a
function name, parentheses (), and a colon :. The indented block of
code following the colon is the body of the function.
def greet():
print("Hello, Python!")
Arguments
Arguments are the actual values you pass to the function when you
call it.
result = add(5, 3)
print(result) # Output: 8
def greet(name):
print("Hello, " + name)
Using a Module
You can use any Python file as a module by executing an import
statement in some other Python script or interactive instance.
import mymodule
mymodule.greet("Alice")
When you import a module, Python searches for the module in the
following locations:
The directory of the script you are running.
The list of directories contained in the Python path
(sys.path).
Importing Module Objects
You can import specific attributes or functions from a module directly:
import mymodule as mm
mm.greet("Carol")
Understanding Packages
A package is essentially a directory with Python files and a file
named __init__.py. This presence of __init__.py signals to Python
that this directory should be treated as a package. Packages allow
for a hierarchical structuring of the module namespace using dot
notation.
Creating a Package
Suppose you have the following directory structure:
In submodule1.py, you might have:
def foo():
print("foo from submodule1")
And in submodule2.py:
def bar():
print("bar from submodule2")
Using a Package
You can import individual modules from the package like this:
try:
print("Trying to open the file...")
file = open('file.txt', 'r')
except FileNotFoundError:
print("File not found.")
else:
print("File opened successfully.")
file.close()
The Finally Block
A finally block can be used to execute code that should run
regardless of whether an exception occurs or not. This is often used
for cleanup actions, such as closing files or releasing resources.
try:
file = open('file.txt', 'r')
except FileNotFoundError:
print("File not found.")
finally:
print("This block executes no matter what.")
file.close() # This would raise an exception if 'file' wasn't opened
Raising Exceptions
You can raise exceptions manually with the raise statement. This is
useful when you need to enforce certain conditions in your code.
x = -1
if x < 0:
raise ValueError("x must be non-negative")
Custom Exceptions
For more tailored error handling, you can define your own exception
classes by inheriting from Python's built-in Exception class.
class MyCustomError(Exception):
pass
try:
raise MyCustomError("An error occurred")
except MyCustomError as e:
print(e)
Opening a File
To work with a file, you first need to open it using the built-
in open() function. This function returns a file object and is
most commonly used with two arguments: the filename and
the mode.
Once a file is opened, you can read its content using methods
like .read(), .readline(), or .readlines().
Writing to a File
# pytasker.py
def add_task(task):
with open('tasks.txt', 'a') as file:
file.write(task + "\n")
print("Task added.")
def list_tasks():
print("Tasks:")
with open('tasks.txt', 'r') as file:
for number, task in enumerate(file, start=1):
print(f"{number}. {task.strip()}")
def complete_task(task_number):
tasks = []
with open('tasks.txt', 'r') as file:
tasks = file.readlines()
try:
completed_task = tasks.pop(task_number - 1)
with open('completed.txt', 'a') as file:
file.write(completed_task)
except IndexError:
print("Invalid task number.")
return
with open('tasks.txt', 'w') as file:
file.writelines(tasks)
print("Task completed.")
def delete_task(task_number):
tasks = []
with open('tasks.txt', 'r') as file:
tasks = file.readlines()
try:
tasks.pop(task_number - 1)
except IndexError:
print("Invalid task number.")
return
with open('tasks.txt', 'w') as file:
file.writelines(tasks)
print("Task deleted.")
def main():
while True:
print("\nPyTasker - Simple Task Manager")
print("1. Add Task")
print("2. List Tasks")
print("3. Complete Task")
print("4. Delete Task")
print("5. Exit")
choice = input("Enter choice: ")
if choice == '1':
task = input("Enter task description:
")
add_task(task)
elif choice == '2':
list_tasks()
elif choice == '3':
task_number = int(input("Enter task
number to complete: "))
complete_task(task_number)
elif choice == '4':
task_number = int(input("Enter task
number to delete: "))
delete_task(task_number)
elif choice == '5':
print("Exiting PyTasker.")
break
else:
print("Invalid choice. Please choose a valid
option.")
if __name__ == "__main__":
main()
This single line of code replaces multiple lines of a more traditional loop:
squares = []
for x in range(10):
squares.append(x**2)
Adding Conditions
List comprehensions can also include a condition to filter items from the
original iterable:
even_squares = [x**2 for x in range(10) if x % 2 == 0]
print(even_squares)
Nested Loops
You can use nested loops in list comprehensions, which is especially useful
for working with multi-dimensional data:
Multiple Conditions
Multiple conditions can be added to further control the output:
You can iterate over multiple sequences within a single list comprehension:
Advanced Expressions
Efficiency Considerations
List comprehensions are not just a more concise syntax; they are often faster
than equivalent code using loops and append() method calls. This is because
the list comprehension compiles the loop into a bytecode that runs more
efficiently than a manual loop.
Readability
While list comprehensions can make your code more concise and potentially
faster, it's important not to sacrifice readability for brevity. Extremely
complex or nested list comprehensions can be hard to read and understand,
so in these cases, breaking the operation into a for loop or using functions
may be more appropriate.
my_list = [1, 2, 3]
my_iter = iter(my_list)
gen = my_generator()
This prints:
3
Generators are a powerful concept in Python, allowing for efficient
and concise code, especially in scenarios requiring the lazy
evaluation of potentially large or infinite sequences.
Generator Expressions
Similar to list comprehensions, Python supports generator
expressions, which are a more memory-efficient shortcut for creating
generators:
gen_expr = (x**2 for x in range(10))
Understanding Decorators
At its core, a decorator is a callable that takes a callable as an input
and returns another callable. This might sound a bit abstract, but the
power of decorators lies in their ability to transparently "wrap" or
"decorate" a function or method with additional functionality.
Basic Decorator Structure
Here's a simple example of a decorator that prints additional
information when a function is called:
def my_decorator(func):
def wrapper():
print("Something is happening before the function is called.")
func()
print("Something is happening after the function is called.")
return wrapper
def say_hello():
print("Hello!")
say_hello()
@my_decorator
def say_hello():
print("Hello!")
@my_decorator
def say_hello(name):
print(f"Hello {name}!")
Real-world Use Cases
Decorators are widely used in web frameworks like Flask and Django
for routing URLs to view functions, authentication, logging, and more.
They're also used in data science and web scraping tools for caching,
retrying requests, and timing function executions.
Chaining Decorators
You can apply multiple decorators to a function by stacking them on
top of each other. The decorators are applied from the innermost
outward, which is sometimes not intuitive at first glance.
@decorator_one
@decorator_two
def my_function():
pass
def my_decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
# Decorator logic
return func(*args, **kwargs)
return wrapper
Context Managers
Context managers are a feature of Python that enables resource management
patterns, ensuring that resources like files, network connections, or locks are
properly managed and released, regardless of how or when you exit a block
of code. This is particularly useful for resources that need to be explicitly
opened and closed to prevent leaks or corruption, such as files or network
sockets.
The with Statement
The use of context managers is implemented with the with
statement in Python, which ensures that resources are
properly cleaned up after use. The with statement sets up a
temporary context and reliably tears it down under all
circumstances.
class ManagedFile:
def __init__(self, filename):
self.filename = filename
def __enter__(self):
self.file = open(self.filename, 'r')
return self.file
@contextmanager
def managed_file(filename):
try:
f = open(filename, 'r')
yield f
finally:
f.close()
with managed_file('example.txt') as f:
data = f.read()
Lambda Functions
Lambda functions, also known as anonymous functions, are a
concise way to create functions in Python. Unlike a regular function
defined with def, a lambda function is a small, nameless function
expressed in a single line of code. Lambda functions can have any
number of arguments but can only contain one expression.
Basic Syntax
The basic syntax of a lambda function is:
square = lambda x: x * x
print(square(5)) # Output: 25
Multiple Arguments
multiply = lambda x, y: x * y
print(multiply(2, 3)) # Output: 6
No Arguments
import json
data = {
"name": "Jane",
"age": 25,
"city": "Los Angeles"
}
import xml.etree.ElementTree as ET
root = ET.fromstring(xml_data)
print(root.find('name').text) # Output: John Doe
import xml.etree.ElementTree as ET
JSON and XML are widely used formats for data interchange on the
web. Python's standard library provides robust support for both
formats, making it easy to parse data from the web, serialize your
Python objects for storage or network transmission, and work with
data in these formats for analysis or other purposes. Understanding
how to effectively work with JSON and XML is an essential skill for
Python developers involved in web development, data science, or
any field that involves data interchange or storage.
import requests
from bs4 import BeautifulSoup
import csv
if __name__ == "__main__":
main()
class Car:
# The __init__ method initializes the attributes of the class
def __init__(self, color, make, year):
self.color = color
self.make = make
self.year = year
In this Car class, make, model, and year are attributes of the class.
Each Car object created from this class will have its own specific
make, model, and year.
Methods
Methods are functions defined within a class. They describe the
behaviors of the objects created from the class. Methods operate on
the attributes of an object and can modify its state or perform
computations that involve the object's attributes.
Example of Methods
class Car:
def __init__(self, make, model, year):
self.make = make
self.model = model
self.year = year
class Car:
def __init__(self, make, model, year): # Initializer
self.make = make # Initializing attribute
self.model = model # Initializing attribute
self.year = year # Initializing attribute
When you create a new Car object, Python calls the __init__ method
for the Car class:
This line creates a new Car object with the make as "Toyota", the
model as "Corolla", and the year as 2020. The __init__ method
initializes these attributes with the values provided.
class Car:
def __init__(self, make, model):
self.make = make
self.model = model
def display(self):
print(f"This car is a {self.make} {self.model}")
class Car:
count = 0 # A class attribute
@classmethod
def number_of_cars(cls):
return cls.count
Let's create a simple but complete program that illustrates the use of
self and cls in a real-life context. We'll model a simple Book class that
keeps track of books and the total number of books created. This
example will help demonstrate how self is used to refer to individual
instances of a class, and how cls is used within class methods to
interact with class-level attributes.
The Book Class Program
class Book:
total_books = 0 # Class attribute to count total books
def display_book_info(self):
# Instance method uses 'self' to access instance attributes
print(f"Book: {self.title} by {self.author}")
@classmethod
def get_total_books(cls):
# Class method uses 'cls' to access class attribute
return f"Total books: {cls.total_books}"
# Creating instances of Book
book1 = Book("The Great Gatsby", "F. Scott Fitzgerald")
book2 = Book("To Kill a Mockingbird", "Harper Lee")
This example showcases how Python's OOP features, like self and
cls, can be employed to model and manage real-world entities and
behaviors, providing a structured and intuitive approach to software
design.
class MyClass:
pass
class Person:
def __init__(self, name, age):
self.name = name # An instance attribute
self.age = age # Another instance attribute
def greet(self):
return f"Hello, my name is {self.name} and I am {self.age} years
old."
In this example, the Person class has two instance attributes, name
and age, defined within the special __init__ method. This method is
known as the constructor, and it's called automatically when a new
instance of the class is created. The self keyword is used to refer to
the current instance of the class, allowing access to the class's
attributes and methods.
class Person:
def __init__(self, name):
self.name = name
def greet(self):
return f"Hello, my name is {self.name}"
class Person:
population = 0 # A class attribute
@classmethod
def get_population(cls):
return f"Population: {cls.population}"
Here, get_population is a class method that accesses the population
class attribute using cls.
Static Methods
Static methods do not operate on an instance or the class. They are
utility methods that perform a task in isolation. They don't access
instance or class data unless it's passed explicitly to the method.
Static methods are defined using the @staticmethod decorator and
do not require self or cls as parameters.
Example of Static Method
class MathOperations:
@staticmethod
def add(x, y):
return x + y
@staticmethod
def multiply(x, y):
return x * y
class Book:
def __init__(self, title, author):
self.title = title # Initialize instance attribute
self.author = author # Initialize instance attribute
def display(self):
print(f"{self.title} by {self.author}")
In this example, __init__ is the constructor of the Book class. It
initializes two attributes, title and author, whenever a new Book
instance is created.
Destructors
A destructor in Python is defined using the __del__ method. This
method is called automatically when an object's reference count
drops to zero, and Python's garbage collector decides to destroy it.
The destructor allows you to perform any necessary cleanup before
the object is destroyed, such as releasing external resources like
files or network connections.
class TemporaryFile:
def __init__(self, file_path):
self.file_path = file_path
print(f"Creating file {self.file_path}")
def __del__(self):
print(f"Deleting file {self.file_path}")
# Code to delete the file from filesystem goes here
class Car:
def __init__(self, make, model):
self.make = make # Public attribute
self.model = model # Public attribute
class Car:
def __init__(self, make, model):
self._make = make # Protected attribute
self._model = model # Protected attribute
class ElectricCar(Car):
def display_info(self):
print(f"This electric car is a {self._make} {self._model}.") #
Accessing protected attributes in a subclass
class Car:
def __init__(self, make, model):
self.__make = make # Private attribute
self.__model = model # Private attribute
def public_method(self):
self.__display_info() # Accessing private method from within the
class
Here's a table that outlines the differences between public, protected, and
private access modifiers :
class Library:
_library_name = "Central Library" # Protected class attribute
__total_books = 0 # Private class attribute
available_books = {} # Public class attribute
@classmethod
def get_total_books(cls):
# Class method to access private attribute
return f"Total books in {cls._library_name}: {cls.__total_books}"
@staticmethod
def library_info():
# Static method to display library info
print("Welcome to the Central Library. It's a place of knowledge and
wisdom.")
def __del__(self):
# Destructor to update total books count
Library.__total_books -= 1
print(f"{self.book_title} by {self.author} has been removed from the
library.")
@staticmethod
def display_available_books():
# Static method to display available books
print("Available books:")
for title, info in Library.available_books.items():
print(f"{title} by {info['author']} - Copies: {info['count']}")
def check_balance(self):
print(f"Current balance: {self.balance}.")
@classmethod
def update_interest_rate(cls, new_rate):
cls.interest_rate = new_rate
print(f"New interest rate across all accounts is
{cls.interest_rate}.")
@staticmethod
def validate_transaction(transaction_amount):
return transaction_amount > 0
def main():
# Creating an account
account = BankAccount("John Doe", 1000) #
Starting with an initial deposit of 1000
account.check_balance()
# Depositing money
deposit_amount = 500
if
BankAccount.validate_transaction(deposit_amount):
account.deposit(deposit_amount)
else:
print("Invalid deposit amount.")
# Withdrawing money
withdrawal_amount = 200
if
BankAccount.validate_transaction(withdrawal_amount
):
account.withdraw(withdrawal_amount)
else:
print("Invalid withdrawal amount.")
if __name__ == "__main__":
main()
Running BankSys
Simply run the script in your Python environment to see the banking
operations in action.
Through the CLI, users can create accounts, deposit, and withdraw
funds, showcasing basic object-oriented programming capabilities.
Expanding the Project:
Add functionality for transferring money between accounts.
Implement a more complex interest calculation method
that applies the interest rate to the account balance over
time.
Enhance the CLI with a menu-driven interface, allowing
users to select actions and input details dynamically.
Incorporate data persistence by saving and loading
account information using a file or a database.
PART I I : DEE P DI VE I NTO OB JE C T- OR I ENTE D
P R OGR AMM I NG I N P YT HON
After completing Part II: Deep Dive into Object-Oriented
Programming in Python, readers will have advanced their
understanding and practical skills in several key areas of OOP with
Python. Here is a concise summary of what readers are expected to
learn from each chapter in this part:
def display_car_info(self):
print(f"The car {self.name} runs at a maximum speed of
{self.max_speed} km/h and has a mileage of {self.mileage} mpg.")
In this example, Car inherits from Vehicle. The Car class uses the
super() function to call the __init__ method of the Vehicle class. This
way, Car extends the functionality of Vehicle by adding a new
attribute, mileage, and a new method, display_car_info, while
retaining the properties and methods of Vehicle.
The Power of super()
The super() function in Python is used to give access to the methods
of a superclass from the subclass that inherits from it. This function is
most commonly used in the __init__ method because it allows the
child class to inherit the initialization of the parent class, but it can
also be used to access other inherited methods that might be
overridden in the child class. The use of super() is considered best
practice when dealing with inheritance, as it makes the code more
maintainable and less susceptible to errors associated with direct
superclass calls.
Advanced Inheritance Concepts
Python supports multiple inheritance, where a subclass can inherit
from more than one parent class. This feature can be incredibly
powerful but also introduces complexity, particularly with the diamond
problem, where a particular attribute or method can be inherited from
multiple ancestor paths. Python addresses this challenge through its
method resolution order (MRO), which defines the order in which
base classes are searched when executing a method. The MRO
follows the C3 linearization algorithm, ensuring a consistent and
predictable order in complex inheritance hierarchies.
class A:
pass
class B(A):
pass
class C(A):
pass
print(D.mro())
class Animal:
def speak(self):
print("This animal does not have a specific sound.")
class Dog(Animal):
def speak(self):
print("Woof!")
class Cat(Animal):
def speak(self):
print("Meow!")
In this example, Dog and Cat subclasses override the speak method
of the Animal superclass. When the speak method is called on an
instance of Dog or Cat, Python executes the overridden method,
resulting in "Woof!" or "Meow!" instead of the generic message
defined in Animal.
The Rationale Behind Method Overriding
Method overriding is fundamental to achieving polymorphism, which
is one of the core tenets of OOP. It allows for a more abstract and
intuitive design by enabling objects of different classes to respond to
the same method calls in class-specific ways. This not only makes
the code more modular and easier to understand but also facilitates
the implementation of sophisticated design patterns, such as the
Template Method and Strategy patterns, which rely heavily on
overriding methods in subclasses.
Moreover, method overriding can significantly simplify code
maintenance and evolution. When changes are required in the
behavior of an inherited method, developers can modify the method
in the subclass without altering the superclass or other subclasses.
This localized approach to modifying behavior ensures that changes
have a minimal impact on the overall system, reducing the risk of
introducing bugs and simplifying testing.
Practical Applications of Method Overriding
Method overriding has myriad practical applications in real-world
programming scenarios. It is extensively used in GUI (Graphical User
Interface) development frameworks, web development frameworks,
and game development, among other areas. For instance, a GUI
framework might define a generic Widget class with a draw() method.
Subclasses such as Button, TextBox, and Slider would override the
draw() method to implement widget-specific rendering logic.
class Base1:
pass
class Base2:
pass
class A:
pass
class B(A):
pass
class C(A):
pass
print(D.mro())
The MRO for D will show that Python searches for methods in D, then
in B, followed by C, and finally in A. This order respects the
inheritance hierarchy and the order in which the base classes are
listed in the class definition.
Practical Implications of MRO
The MRO affects how methods are overridden and called in complex
inheritance hierarchies. Understanding MRO is crucial when
designing classes to ensure that the correct methods are called at the
right time, which becomes especially important in frameworks and
libraries where classes are extended and customized frequently.
Polymorphism in Action
Polymorphism, a fundamental concept in object-oriented
programming (OOP), embodies the ability of different objects to
respond to the same message—or method call—in unique ways. This
principle enables a single interface to serve as the conduit for
different underlying forms of data or object classes. In Python,
polymorphism is not just a theoretical concept; it's a practical tool that
significantly enhances flexibility and scalability in software
development. This exploration dives into the essence of
polymorphism, showcases its application through examples, and
illustrates its pivotal role in crafting dynamic, efficient code.
The Essence of Polymorphism
The term "polymorphism" originates from the Greek words "poly,"
meaning many, and "morph," meaning form. In the context of
programming, it allows entities to take on many forms, enabling more
abstract and flexible code. Polymorphism manifests in Python in
several ways, including method overriding (seen in inheritance),
method overloading, and duck typing.
Polymorphism Through Method Overriding
Method overriding is a direct expression of polymorphism. When a
subclass provides its unique implementation of a method that is
already defined in its superclass, it exemplifies polymorphism by
allowing the same method call to exhibit different behaviors
depending on the subclass instance.
class Animal:
def speak(self):
return "Some generic sound"
class Dog(Animal):
def speak(self):
return "Woof"
class Cat(Animal):
def speak(self):
return "Meow"
# Polymorphism in action
animals = [Dog(), Cat()]
class Duck:
def quack(self):
return "Quack quack!"
class Person:
def quack(self):
return "I'm pretending to be a duck!"
def make_it_quack(ducky):
print(ducky.quack())
class SavingsAccount(BankAccount):
def __init__(self, owner, balance=0):
super().__init__(owner, balance)
self.minimum_balance = 500
def add_interest(self):
# Assuming a monthly interest calculation for
simplicity
interest_earned = self.balance *
self.interest_rate / 12
self.deposit(interest_earned)
print(f"Interest added: {interest_earned}. New
balance: {self.balance}.")
class CheckingAccount(BankAccount):
transaction_fee = 1.00 # A fixed fee for
demonstration purposes
class BusinessAccount(BankAccount):
transaction_fee_percentage = 0.01 # 1% of the
transaction
def main():
# Example usage
savings = SavingsAccount("Alice", 1000)
checking = CheckingAccount("Bob", 500)
business = BusinessAccount("Charlie", 2000)
savings.add_interest()
checking.withdraw(100)
business.withdraw(200)
# Demonstrating polymorphism
accounts = [savings, checking, business]
for account in accounts:
print(f"Processing monthly maintenance for
{account.owner}'s account.")
account.withdraw(50) # Polymorphic call to
each account's specific withdraw method
if __name__ == "__main__":
main()
class Shape(ABC):
@abstractmethod
def area(self):
pass
@abstractmethod
def perimeter(self):
pass
def area(self):
return self.length * self.width
def perimeter(self):
return 2 * (self.length + self.width)
class Account:
def __init__(self, owner, balance=0):
self.owner = owner
self.__balance = balance # Private attribute
def get_balance(self):
return self.__balance
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
"""The radius property."""
return self._radius
@radius.setter
def radius(self, value):
if value >= 0:
self._radius = value
else:
raise ValueError("Radius must be non-negative")
@radius.deleter
def radius(self):
print("Deleting radius")
del self._radius
class NonNegative:
def __init__(self, name):
self.name = name
class Circle:
radius = NonNegative('radius')
class Notification(ABC):
@abstractmethod
def send(self, message):
pass
class EmailNotification(Notification):
def __init__(self, recipient_email):
self.recipient_email = recipient_email
SMS Notification
class SMSNotification(Notification):
def __init__(self, phone_number):
self.phone_number = phone_number
# Example usage
notifications = [
EmailNotification("[email protected]"),
SMSNotification("+1234567890")
]
class Engine:
def start(self):
print("Engine starts")
def stop(self):
print("Engine stops")
class Car:
def __init__(self):
self.engine = Engine() # Composition
def start(self):
self.engine.start()
print("Car starts")
def stop(self):
self.engine.stop()
print("Car stops")
class Stream(ABC):
@abstractmethod
def read(self, maxbytes=-1):
pass
@abstractmethod
def write(self, data):
pass
class File:
def read(self, maxbytes=-1):
# Implementation here
pass
def process_stream(stream):
if hasattr(stream, 'read') and callable(stream.read) and \
hasattr(stream, 'write') and callable(stream.write):
# Treat stream as readable and writable
pass
class Meta(type):
def __new__(cls, name, bases, dct):
# Custom actions here
print(f"Creating class {name}")
return super().__new__(cls, name, bases, dct)
class MyClass(metaclass=Meta):
def foo(self):
pass
Decorators in OOP
In Python, decorators are a powerful and expressive tool for
extending and modifying the behavior of functions and methods, and
by extension, classes. While decorators are widely recognized for
their ability to enhance functions, their application in object-oriented
programming (OOP) to augment classes opens a realm of
possibilities for more dynamic and flexible code design. This
exploration delves into the use of decorators in OOP, demonstrating
how they can be utilized to extend class functionality, enforce
patterns, or inject additional behavior without altering the original
class definitions.
Understanding Decorators
At its simplest, a decorator is a callable that takes another callable
as its argument and extends or modifies its behavior. Decorators can
be applied to both functions and methods, but when it comes to
classes, they can modify class definition itself. A class decorator is
thus a function that receives a class object as an argument and
returns either a modified version of the class or a completely new
class.
Applying Decorators to Classes
Class decorators can be used to add, modify, or wrap methods of the
class, add class variables, or enforce constraints. The syntax for
applying a decorator to a class is the same as that for functions:
def my_decorator(cls):
# Modify the class
cls.new_attribute = 'New Value'
return cls
@my_decorator
class MyClass:
pass
def method_decorator(method):
def wrapper(*args, **kwargs):
# Do something before
result = method(*args, **kwargs)
# Do something after
return result
return wrapper
class MyClass:
@method_decorator
def my_method(self):
print("Original Method Execution")
def singleton(cls):
instances = {}
def get_instance(*args, **kwargs):
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance
@singleton
class SingletonClass:
pass
class Event:
def __init__(self, name, description):
self.name = name
self.description = description
self.attributes = []
def display_event(self):
print(f"Event: {self.name}\nDescription:
{self.description}")
for attr in self.attributes:
attr.display()
class Location:
def __init__(self, location):
self.location = location
def display(self):
print(f"Location: {self.location}")
EventDate Attribute
class EventDate:
def __init__(self, date):
self.date = date
def display(self):
print(f"Date: {self.date}")
Reminder Attribute
class Reminder:
def __init__(self, message):
self.message = message
def display(self):
print(f"Reminder: {self.message}")
def send_notification(func):
def wrapper(event, *args, **kwargs):
print(f"Notification: Don't forget about the
event '{event.name}' on {event.date}")
return func(event, *args, **kwargs)
return wrapper
def log_event_action(func):
def wrapper(event, *args, **kwargs):
result = func(event, *args, **kwargs)
print(f"Logged Action: {func.__name__} was called
for event '{event.name}'")
return result
return wrapper
@send_notification
@log_event_action
def create_event(event):
event.display_event()
def main():
event = Event("Python Workshop", "A workshop
for learning Python.")
event.add_attribute(Location("Community
Center"))
event.add_attribute(EventDate("2024-05-15"))
event.add_attribute(Reminder("Bring your
laptop."))
create_event(event)
if __name__ == "__main__":
main()
class FileHandler:
def __init__(self, filename, mode):
self.filename = filename
self.mode = mode
self.file = None
def open(self):
self.file = open(self.filename, self.mode)
def read(self):
if self.file:
return self.file.read()
else:
return "File is not open"
def close(self):
if self.file:
self.file.close()
self.file = None
else:
return "File is already closed"
This FileHandler class encapsulates all the basic file operations,
making it easy to manage file access within OOP projects. It handles
opening, reading from, writing to, and closing files, abstracting the
underlying complexity from the user.
Exception Handling and File Operations
Robust file handling requires careful attention to exception handling
to manage errors gracefully, such as when files do not exist or the
program lacks the necessary permissions to read or write.
Integrating exception handling into the file handler class improves its
reliability:
def open(self):
try:
self.file = open(self.filename, self.mode)
except IOError as e:
return f"An error occurred opening the file: {e}"
class CSVFileHandler(FileHandler):
def read_csv(self):
# Implementation for reading CSV files
pass
class ExampleClass:
a_number = 35
a_string = "hey"
a_list = [1, 2, 3]
a_dict = {"first": "a", "second": 2, "third": [1, 2, 3]}
# Serializing an object
my_object = ExampleClass()
serialized_object = pickle.dumps(my_object)
class ExampleClass:
def __init__(self):
self.a_number = 35
self.a_string = "hey"
self.a_list = [1, 2, 3]
self.a_dict = {"first": "a", "second": 2, "third": [1, 2, 3]}
def to_json(self):
return json.dumps(self.__dict__)
# Creating an object
my_object = ExampleClass()
# Deserializing the object, note that this will not create an instance of
ExampleClass but a dictionary
deserialized_object = json.loads(serialized_object)
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
name = Column(String)
age = Column(Integer)
engine = create_engine('sqlite:///mydatabase.db')
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
# Querying users
users = session.query(User).filter_by(name='John Doe').first()
print(users.name, users.age)
Django ORM
Django ORM comes with the Django web framework and provides a
high-level, model-centric approach to database interaction. It is
designed to facilitate rapid development and clean, pragmatic
design. With Django ORM, you:
Define models inheriting from django.db.models.Model.
Use Django's model query API for data retrieval, insertion,
update, and deletion.
Example of defining a model in Django:
from django.db import models
class User(models.Model):
name = models.CharField(max_length=100)
age = models.IntegerField()
# Querying users
user = User.objects.get(name='Jane Doe')
print(user.name, user.age)
System Requirements:
Persistence: Store contact information persistently using
both a JSON file (for simplicity and learning serialization)
and a database (to demonstrate ORM).
Functionality: Allow adding, viewing, editing, and deleting
contacts. Include a search functionality based on contact
attributes (e.g., name, email).
Interface: While a graphical user interface (GUI) would be
ideal, a command-line interface (CLI) is sufficient for
focusing on the back-end logic and learning objectives.
Implementation Steps:
1. Design the Contact Model Define a simple Contact class
that includes attributes such as name, email, phone
number, and any other relevant information.
class Contact:
def __init__(self, name, email, phone):
self.name = name
self.email = email
self.phone = phone
import json
def save_contacts_to_file(contacts,
filename='contacts.json'):
with open(filename, 'w') as file:
json.dump([contact.__dict__ for contact in
contacts], file)
def
load_contacts_from_file(filename='contacts.json'):
try:
with open(filename, 'r') as file:
contacts_dict_list = json.load(file)
return [Contact(**contact_dict) for contact_dict
in contacts_dict_list]
except FileNotFoundError:
return []
Base = declarative_base()
class Contact(Base):
__tablename__ = 'contacts'
id = Column(Integer, primary_key=True)
name = Column(String)
email = Column(String)
phone = Column(String)
engine = create_engine('sqlite:///contacts.db')
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
4. CRUD Operations Implement functions to handle CRUD
operations, both for file-based storage and using ORM for
database interactions.
Add a new contact
View all contacts
Search for a contact by name
Edit a contact
Delete a contact
5. Command-Line Interface (CLI) Develop a simple CLI to
interact with the system, using input prompts to perform
the CRUD operations.
def main_loop():
session = Session()
while True:
print("\nContact Management System")
print("1: Add Contact")
print("2: View Contacts")
print("3: Search Contacts")
print("4: Edit Contact")
print("5: Delete Contact")
print("6: Exit")
choice = input("Enter choice: ")
if choice == '1':
# Implement add contact
pass
elif choice == '2':
# Implement view contacts
pass
# Continue for other options...
if __name__ == "__main__":
main_loop()
The chapter begins with an overview of what design patterns are and why
they are crucial for developing sophisticated software systems that are easy
to manage, extend, or modify. It underscores the importance of design
patterns in facilitating communication among developers, offering a shared
vocabulary of solutions that are well understood and have been proven
effective over time.
Following the introduction, the chapter is organized into three main sections,
each dedicated to a different category of design patterns: Creational,
Structural, and Behavioral patterns, reflecting the nature of the problems
they solve.
Creational Patterns focus on object creation
mechanisms, aiming to create objects in a manner suitable
to the situation. The primary goal is to enhance flexibility
and reuse of existing code through patterns like Singleton,
Factory, Abstract Factory, Builder, and Prototype.
Structural Patterns deal with object composition or the
structure of classes. They help ensure that if one part of a
system changes, the entire system doesn't need to do the
same while also promoting flexibility in choosing
interfaces or implementations. Patterns covered include
Adapter, Decorator, Proxy, Composite, Bridge, and
Facade.
Behavioral Patterns are concerned with algorithms and
the assignment of responsibilities between objects. They
describe not just patterns of objects or classes but also the
patterns of communication between them. This section
explores patterns such as Strategy, Observer, Command,
Iterator, State, and Template Method.
Each pattern is dissected to understand its structure, including its classes,
their roles in the pattern, and how they interact with each other. Real-world
examples illustrate how each pattern can be applied in software development
projects to solve specific problems, enhance code readability, and improve
software quality.
By the end of this chapter, readers will have a solid understanding of various
design patterns, empowering them to apply these patterns effectively in their
projects. The knowledge gained will enable developers to craft elegant,
scalable, and maintainable software architectures, making their software
development process more efficient and their outcomes more robust.
class Singleton:
_instance = None
@classmethod
def getInstance(cls):
if cls._instance is None:
cls._instance = Singleton()
return cls._instance
Factory Method Pattern
The Factory Method pattern defines an interface for creating an
object, but lets subclasses alter the type of objects that will be
created. This pattern is particularly useful when a class cannot
anticipate the class of objects it needs to create beforehand. The
Factory Method pattern is implemented by defining a separate
method, often called a factory method, which subclasses can override
to create specific instances.
class Creator:
def factory_method(self):
pass
class ConcreteCreatorA(Creator):
def factory_method(self):
return ConcreteProductA()
class ConcreteCreatorB(Creator):
def factory_method(self):
return ConcreteProductB()
Abstract Factory Pattern
The Abstract Factory pattern provides an interface for creating
families of related or dependent objects without specifying their
concrete classes. It is useful when the system needs to be
independent of how its products are created, composed, and
represented. This pattern is like the Factory Method but at a higher
level of abstraction and without the need for a concrete class for the
factory itself.
class AbstractFactory:
def create_product_a(self):
pass
def create_product_b(self):
pass
class ConcreteFactory1(AbstractFactory):
def create_product_a(self):
return ProductA1()
def create_product_b(self):
return ProductB1()
class ConcreteFactory2(AbstractFactory):
def create_product_a(self):
return ProductA2()
def create_product_b(self):
return ProductB2()
Builder Pattern
The Builder pattern separates the construction of a complex object
from its representation, allowing the same construction process to
create different representations. This pattern is used when an object
needs to be created with many possible configurations and
constructing such an object is complex. The Builder pattern
encapsulates the construction of a product and allows it to be
constructed in steps.
class Director:
def __init__(self, builder):
self._builder = builder
def construct(self):
self._builder.create_part_a()
self._builder.create_part_b()
class Builder:
def create_part_a(self):
pass
def create_part_b(self):
pass
class ConcreteBuilder(Builder):
def create_part_a(self):
# Implement part A creation
pass
def create_part_b(self):
# Implement part B creation
pass
Prototype Pattern
The Prototype pattern is used when the type of objects to create is
determined by a prototypical instance, which is cloned to produce
new objects. This pattern is useful when creating an instance of a
class is more expensive or complex than copying an existing
instance. The Prototype pattern lets you copy existing objects without
making your code dependent on their classes.
import copy
class Prototype:
def clone(self):
return copy.deepcopy(self)
class ConcretePrototype(Prototype):
pass
original_object = ConcretePrototype()
cloned_object = original_object.clone()
Each of these patterns addresses specific challenges in object
creation, offering solutions that increase the flexibility and
maintainability of the code. By abstracting the instantiation process,
they also help in reducing system dependencies, enhancing
modularity, and supporting the principles of good software design.
Structural Patterns: Adapter, Decorator, Proxy, Composite,
Bridge, Facade
Adapter Pattern
The Adapter pattern allows objects with incompatible interfaces to
collaborate. It acts as a bridge between two incompatible interfaces
by wrapping the interface of a class and transforming it into another
interface expected by the clients. This is particularly useful when
integrating new features or libraries without changing the existing
codebase.
class Target:
def request(self):
return "Target's default behavior."
class Adaptee:
def specific_request(self):
return ".eetpadA eht fo roivaheb laicepS"
class Adapter(Target):
def __init__(self, adaptee):
self.adaptee = adaptee
def request(self):
return self.adaptee.specific_request()[::-1]
# Usage
adaptee = Adaptee()
adapter = Adapter(adaptee)
print(adapter.request())
Decorator Pattern
The Decorator pattern allows for the dynamic addition of behaviors to
objects without modifying their existing classes. It provides a flexible
alternative to subclassing for extending functionality. This pattern
wraps an object in a set of "decorator" classes that add new
behaviors or responsibilities.
class Component:
def operation(self):
pass
class ConcreteComponent(Component):
def operation(self):
return "ConcreteComponent"
class Decorator(Component):
def __init__(self, component):
self._component = component
def operation(self):
return self._component.operation()
class ConcreteDecoratorA(Decorator):
def operation(self):
return f"ConcreteDecoratorA({self._component.operation()})"
# Usage
component = ConcreteComponent()
decorated = ConcreteDecoratorA(component)
print(decorated.operation())
Proxy Pattern
The Proxy pattern provides a placeholder for another object to control
access to it, either to delay its creation until it is needed or to add a
layer of protection. This is useful for implementing lazy initialization,
access control, logging, monitoring, and more.
class Subject:
def request(self):
pass
class RealSubject(Subject):
def request(self):
return "RealSubject: Handling request."
class Proxy(Subject):
def __init__(self, real_subject):
self._real_subject = real_subject
def request(self):
if self.check_access():
self._real_subject.request()
self.log_access()
def check_access(self):
print("Proxy: Checking access before firing a real request.")
return True
def log_access(self):
print("Proxy: Logging the time of request.")
# Usage
real_subject = RealSubject()
proxy = Proxy(real_subject)
proxy.request()
Composite Pattern
The Composite pattern composes objects into tree structures to
represent part-whole hierarchies. This pattern lets clients treat
individual objects and compositions of objects uniformly. It’s
particularly useful for representing hierarchical structures like
graphical user interfaces or file systems.
class Component:
def operation(self):
pass
class Leaf(Component):
def operation(self):
return "Leaf"
class Composite(Component):
def __init__(self):
self._children = []
def operation(self):
results = []
for child in self._children:
results.append(child.operation())
return f"Branch({'+'.join(results)})"
# Usage
tree = Composite()
left = Composite()
left.add(Leaf())
left.add(Leaf())
right = Leaf()
tree.add(left)
tree.add(right)
print(tree.operation())
Bridge Pattern
The Bridge pattern separates an object’s abstraction from its
implementation, allowing the two to vary independently. This pattern
is designed to separate a class into two parts: an abstraction that
represents the interface (UI) and an implementation that provides the
platform-specific functionality. This promotes decoupling and
improves code maintainability.
class Implementation:
def operation_implementation(self):
pass
class ConcreteImplementationA(Implementation):
def operation_implementation(self):
return "ConcreteImplementationA: Here's the result on the platform
A."
class ConcreteImplementationB(Implementation):
def operation_implementation(self):
return "ConcreteImplementationB: Here's the result on the platform
B."
class Abstraction:
def __init__(self, implementation):
self.implementation = implementation
def operation(self):
return f"Abstraction: Base operation
with:\n{self.implementation.operation_implementation()}"
# Usage
implementation = ConcreteImplementationA()
abstraction = Abstraction(implementation)
print(abstraction.operation())
Facade Pattern
The Facade pattern provides a simplified interface to a complex
system of classes, a library, or a framework. It hides the complexities
of the system and provides an easier interface to interact with it. This
pattern is often used to create a simple API over a complex set of
systems.
class Subsystem1:
def operation1(self):
return "Subsystem1: Ready!\n"
def operationN(self):
return "Subsystem1: Go!\n"
class Subsystem2:
def operation1(self):
return "Subsystem2: Get ready!\n"
def operationZ(self):
return "Subsystem2: Fire!\n"
class Facade:
def __init__(self, subsystem1, subsystem2):
self._subsystem1 = subsystem1 or Subsystem1()
self._subsystem2 = subsystem2 or Subsystem2()
def operation(self):
return "Facade initializes subsystems:\n" + \
self._subsystem1.operation1() + \
self._subsystem2.operation1() + \
"Facade orders subsystems to perform the action:\n" + \
self._subsystem1.operationN() + \
self._subsystem2.operationZ()
# Usage
facade = Facade(Subsystem1(), Subsystem2())
print(facade.operation())
Each of these structural patterns plays a crucial role in simplifying the
design by identifying a simple way to realize relationships between
entities. They enhance the flexibility in structuring systems, promote
principled design, and facilitate clearer and more scalable
implementations.
Behavioral Patterns: Strategy, Observer, Command, Iterator,
State, Template Method
Strategy Pattern
The Strategy pattern defines a family of algorithms, encapsulates
each one, and makes them interchangeable. Strategy lets the
algorithm vary independently from clients that use it. This pattern is
particularly useful for situations where you need to dynamically
change the algorithms used in an application based on certain
criteria.
from abc import ABC, abstractmethod
class Strategy(ABC):
@abstractmethod
def algorithm_interface(self):
pass
class ConcreteStrategyA(Strategy):
def algorithm_interface(self):
return "Algorithm A"
class ConcreteStrategyB(Strategy):
def algorithm_interface(self):
return "Algorithm B"
class Context:
def __init__(self, strategy: Strategy):
self._strategy = strategy
def context_interface(self):
return self._strategy.algorithm_interface()
# Usage
strategyA = ConcreteStrategyA()
context = Context(strategyA)
print(context.context_interface())
strategyB = ConcreteStrategyB()
context = Context(strategyB)
print(context.context_interface())
Observer Pattern
The Observer pattern defines a one-to-many dependency between
objects so that when one object changes state, all its dependents are
notified and updated automatically. It's widely used in implementing
distributed event handling systems, in model-view-controller (MVC)
architectures, for example.
class Subject:
def __init__(self):
self._observers = []
def notify(self):
for observer in self._observers:
observer.update(self)
class ConcreteSubject(Subject):
_state = None
@property
def state(self):
return self._state
@state.setter
def state(self, value):
self._state = value
self.notify()
class Observer(ABC):
@abstractmethod
def update(self, subject: Subject):
pass
class ConcreteObserverA(Observer):
def update(self, subject: Subject):
if subject.state < 3:
print("ConcreteObserverA: Reacted to the event")
class ConcreteObserverB(Observer):
def update(self, subject: Subject):
if subject.state == 0 or subject.state >= 2:
print("ConcreteObserverB: Reacted to the event")
# Usage
subject = ConcreteSubject()
observer_a = ConcreteObserverA()
subject.attach(observer_a)
observer_b = ConcreteObserverB()
subject.attach(observer_b)
subject.state = 2
subject.detach(observer_a)
subject.state = 3
Command Pattern
The Command pattern encapsulates a request as an object, thereby
allowing for parameterization of clients with queues, requests, and
operations. It also allows for the support of undoable operations. The
Command pattern is valuable when you need to issue requests
without knowing the requested operation or the requesting object.
class Command(ABC):
@abstractmethod
def execute(self):
pass
class Receiver:
def action(self):
return "Receiver: Execute action"
class ConcreteCommand(Command):
def __init__(self, receiver: Receiver):
self._receiver = receiver
def execute(self):
return self._receiver.action()
class Invoker:
_on_start = None
_on_finish = None
if self._on_finish:
print(f"Invoker: Does anybody want something done after I finish?")
print(self._on_finish.execute())
# Usage
invoker = Invoker()
invoker.set_on_start(ConcreteCommand(Receiver()))
invoker.do_something_important()
Iterator Pattern
The Iterator pattern provides a way to access the elements of an
aggregate object sequentially without exposing its underlying
representation. The Iterator pattern is widely used in Python through
the iter() and next() functions which allow for custom objects to be
iterated over in a for loop.
class Iterator(ABC):
@abstractmethod
def next(self):
pass
@abstractmethod
def has_next(self):
pass
class ConcreteAggregate:
def __init__(self, collection):
self._collection = collection
def get_iterator(self):
return ConcreteIterator(self._collection)
class ConcreteIterator(Iterator):
def __init__(self, collection):
self._collection = collection
self._position = 0
def next(self):
try:
value = self._collection[self._position]
self._position += 1
except IndexError:
raise StopIteration()
return value
def has_next(self):
return self._position < len(self._collection)
# Usage
aggregate = ConcreteAggregate([1, 2, 3, 4, 5])
iterator = aggregate.get_iterator()
while iterator.has_next():
print(iterator.next())
State Pattern
The State pattern allows an object to alter its behavior when its
internal state changes. The object will appear to change its class.
This pattern is useful for implementing finite state machines in object-
oriented programming.
class State(ABC):
@abstractmethod
def handle(self, context):
pass
class ConcreteStateA(State):
def handle(self, context):
print("State A is handling the request.")
context.state = ConcreteStateB()
class ConcreteStateB(State):
def handle(self, context):
print("State B is handling the request.")
context.state = ConcreteStateA()
class Context(State):
_state = None
def request(self):
self._state.handle(self)
# Usage
context = Context(ConcreteStateA())
context.request()
context.request()
Template Method Pattern
The Template Method pattern defines the skeleton of an algorithm in
the superclass but lets subclasses override specific steps of the
algorithm without changing its structure. This pattern is useful when
there are multiple steps involved in an algorithm, and each step can
have different implementations.
class AbstractClass(ABC):
def template_method(self):
self.base_operation1()
self.required_operations1()
self.base_operation2()
self.hook1()
self.required_operations2()
self.base_operation3()
self.hook2()
def base_operation1(self):
print("AbstractClass says: I am doing the bulk of the work")
def base_operation2(self):
print("AbstractClass says: But I let subclasses override some
operations")
def base_operation3(self):
print("AbstractClass says: But I am doing the bulk of the work
anyway")
@abstractmethod
def required_operations1(self):
pass
@abstractmethod
def required_operations2(self):
pass
def hook1(self):
pass
def hook2(self):
pass
class ConcreteClass1(AbstractClass):
def required_operations1(self):
print("ConcreteClass1 says: Implemented Operation1")
def required_operations2(self):
print("ConcreteClass1 says: Implemented Operation2")
class ConcreteClass2(AbstractClass):
def required_operations1(self):
print("ConcreteClass2 says: Implemented Operation1")
def required_operations2(self):
print("ConcreteClass2 says: Implemented Operation2")
def hook1(self):
print("ConcreteClass2 says: Overridden Hook1")
# Usage
concrete_class1 = ConcreteClass1()
concrete_class1.template_method()
concrete_class2 = ConcreteClass2()
concrete_class2.template_method()
class ConfigurationManager:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super(ConfigurationManager,
cls).__new__(cls)
# Initialization or loading of configuration goes
here
return cls._instance
class TransactionSubject:
def __init__(self):
self._observers = []
class TransactionHistoryObserver:
def update(self, transaction):
# Update the transaction history view
pass
class BudgetTrackerObserver:
def update(self, transaction):
# Update budget status
pass
class TransactionFactory:
@staticmethod
def create_transaction(type, amount, category,
description):
if type == "income":
return Income(amount, category, description)
elif type == "expense":
return Expense(amount, category, description)
else:
raise ValueError("Invalid transaction type")
class Income:
def __init__(self, amount, category, description):
self.amount = amount
self.category = category
self.description = description
class Expense:
def __init__(self, amount, category, description):
self.amount = amount
self.category = category
self.description = description
import unittest
Step 2: Writing Test Cases
A test case is created by subclassing unittest.TestCase. Within this
subclass, you define a series of methods to test different aspects of
your code. Each method must start with the word test.
class TestStringMethods(unittest.TestCase):
def test_upper(self):
self.assertEqual('foo'.upper(), 'FOO')
def test_isupper(self):
self.assertTrue('FOO'.isupper())
self.assertFalse('Foo'.isupper())
def test_split(self):
s = 'hello world'
self.assertEqual(s.split(), ['hello', 'world'])
with self.assertRaises(TypeError):
s.split(2)
Step 3: Running Tests
There are several ways to run the tests. One common method is to
include the following code at the bottom of your test file. This code
checks if the script is being run directly and then executes the tests.
if __name__ == '__main__':
unittest.main()
Alternatively, you can run your tests from the command line:
This command will find all files named test*.py and execute the tests
within them.
Step 5: Assert Methods
unittest provides a set of assert methods to check for various
conditions:
assertEqual(a, b): Check that a == b
assertTrue(x): Check that x is true
assertFalse(x): Check that x is false
assertRaises(exc, fun, *args, **kwds): Check that an
exception is raised when fun is called with arguments *args
and keyword arguments **kwds.
Best Practices
# arithmetic_operations.py
class ArithmeticOperations:
@staticmethod
def add(a, b):
return a + b
@staticmethod
def subtract(a, b):
return a - b
Step 2: Setting Up Your Test Environment
1. Now right click on class
Step 5: Running the Tests in PyCharm
PyCharm will execute the tests and provide you with a test runner
window showing the results. Green indicates that all tests passed,
while red indicates failures. If a test fails, PyCharm will show you
which one, allowing you to investigate and fix the issue.
Best Practices
1. Red: Write a test for the next bit of functionality you want
to add. The test should fail because the functionality
doesn't exist yet. This red phase ensures that the test
correctly detects an unfulfilled feature.
Facilitates Continuous
Integration/Continuous Deployment (CI/CD):
With a comprehensive test suite in place, teams can more
safely and frequently merge changes, leading to more
agile deployment cycles.
Implementing TDD in Python
In Python, TDD can be implemented using the built-in unittest
framework or third-party libraries like pytest which offer more
features and a simpler syntax. Here is a simple example of the TDD
cycle using unittest:
1. Red Phase
First, we create a test case for a function add, which we have not yet
implemented.
import unittest
class TestAddFunction(unittest.TestCase):
def test_add(self):
self.assertEqual(add(1, 2), 3)
if __name__ == "__main__":
unittest.main()
Running this test will result in a NameError since add is not defined.
2. Green Phase
Next, we write the simplest add function that will make the test pass.
Now, when we run the test, it passes because the add function
returns the correct result for the inputs given in the test.
3. Refactor Phase
In this example, the add function might not need much refactoring
since it's already simple. However, this phase would be where we
clean up our code, improve naming, and remove any redundancy.
Challenges and Considerations
Learning Curve: For teams new to TDD, there can be
an initial slowdown as developers adjust to writing tests
first.
def get_user_data(user_id):
response =
requests.get(f"https://api.example.com/users/{user_id}")
return response.json()
# Test
@patch('requests.get')
def test_get_user_data(mock_get):
# Setup mock
mock_get.return_value.json.return_value = {"id": "123", "name":
"John Doe"}
# Assertions
mock_get.assert_called_once_with("https://api.example.com/users/
123")
assert result == {"id": "123", "name": "John Doe"}
Patching
Patching is closely related to mocking but focuses on temporarily
replacing the actual objects in your code with mock objects during
testing. The patch decorator/function from the unittest.mock module
is used to replace the real implementations of methods, functions, or
attributes with mocks for the duration of a test.
Example of Patching
If your application has a function that reads from a file, you might not
want to perform actual file I/O during testing:
import some_module
def read_from_file(filename):
with open(filename, 'r') as f:
return f.read()
# Test
@patch('builtins.open', new_callable=mock_open,
read_data='mocked file content')
def test_read_from_file(mocked_open):
result = read_from_file('fake_file.txt')
assert result == 'mocked file content'