Advanced Python Concepts - Organizing and Designing Code¶
We learned that classes and objects provide a powerful way to model real-world concepts. Today, we're going to expand on that by learning how to structure entire applications. We'll go from single classes to well-organized programs, and we'll introduce design patterns that make our code more flexible and scalable.
Functions vs Classes
Although the following lesson is heavily focused on object-oriented design patterns, you don't have to consolidate all of your functions under classes! Sometimes, object oriented is actually a bad choice, both in terms of readability/maintainability but also performance. You can define functions in your module and import them when needed.
From Scripts to Packages¶
When a project is small, a single Python file works just fine. But as your codebase grows, it's essential to organize your code into logical, reusable units. These are called modules and packages.
- A module is simply a single Python file (
.py). - A package is a directory that contains multiple modules and a special (often empty)
__init__.pyfile. This is how you group related functionality.
Let's walk through an example. Imagine we have a project that involves different kinds of vehicles. A bad way to structure this would be to put all the classes in one file:
Instead, let's create a well-organized package for our project.
-
We'll start by creating a project directory with the following structure. The
__init__.pyfiles are what make Python recognize these folders as a package.__init__.py: The GatekeeperAn
__init__.pyfile serves two main purposes:-
It tells Python that the directory should be treated as a package. Without this file, a directory with Python scripts is just a regular folder and can't be imported from.
-
It can contain initialization code for the package. For example, you can use it to automatically import certain modules or define a public API for the package, so users can
import my_project.transport.Carinstead of the full path likemy_project.transport.cars.Car.
my_project/ ├── __init__.py ├── core/ │ ├── __init__.py │ └── components.py # For things like an Engine class ├── transport/ │ ├── __init__.py │ └── cars.py # For our Car class └── main.pyModuleNotFoundError...Where am I?Note, a common annoyance of Python modules is making sure that Python/scripts know where to find your custom module(s). There are many ways to solve this issue, however since we are working in containers, in our build/compose we can set the
PYTHONPATHenvironment variable to point to our module. For examplePYTHONPATH="${PYTHONPATH}:/path/to/my_project/" -
-
Next, we'll write our
Engineclass incore/components.pyand ourCarclass intransport/cars.py. -
Finally, we'll open
main.pyand demonstrate how easy it is to import and use classes from any module within the package:
This structure makes our code more readable, reusable, and easy to navigate.
Deepening Your OOP¶
We've already learned about inheritance and composition, but there are other powerful OOP concepts which are crucial for building maintainable codebases.
Encapsulation (Hiding Complexity)¶
Encapsulation is the principle of hiding an object's internal state and only exposing what is necessary. It protects your object's data from unintended changes. In Python, we use a naming convention to signal encapsulation:
_variable_name(single underscore): A convention that signals, "This is an internal variable; please don't use it directly."-
__variable_name(double underscore): Python "mangles" the name, making it harder to access from outside the class. -
We'll update our
Engineclass to have a private attribute called__rpmto represent its speed. -
We'll create public methods (
get_rpmandset_rpm) to control access to this data.
This ensures the __rpm attribute can only be modified through the set_rpm method, allowing us to enforce business logic (e.g., rpm can't be negative).
Abstraction¶
Abstraction is about simplifying complex reality by modeling classes based on the essential properties and behaviors of an object. You hide the internal details and only show what's necessary to the user. A great analogy is driving a car. You know how to start it and press the gas pedal, but you don't need to understand the internal combustion engine's intricate mechanics. The car's controls are the abstraction.
In Python, you can achieve abstraction through abstract base classes (ABCs) using the abc module. An ABC can't be instantiated; it's meant to be inherited by other classes. It can also define abstract methods, which a subclass must implement. If a concrete subclass doesn't implement all the abstract methods, you'll get a TypeError.
Polymorphism¶
Polymorphism, which means "many forms," is the ability of an object to take on many forms. In OOP, it refers to the ability of different classes to respond to the same method call in their own way. This allows you to write more flexible and reusable code.
The most common form of polymorphism in Python is duck typing. The saying goes, "If it looks like a duck, swims like a duck, and quacks like a duck, then it's a duck." In programming, this means if an object has the methods and properties you need, you can use it, regardless of its class. The two main ways to achieve polymorphism are method overriding and method overloading.
- Method Overriding: A subclass provides a specific implementation of a method that is already defined in its parent class. This is what you already did with the
__init__method in yourStudentclass example. - Method Overloading: This involves defining multiple methods with the same name but with different parameters. Python doesn't support traditional method overloading like Java or C++. Instead, you can achieve similar functionality using optional arguments, default values, or variable-length arguments.
| Polymorphism with Method Overriding | |
|---|---|
Introducing Design Patterns: Strategies for Common Problems¶
A design pattern is a reusable solution to a common problem in software design. They aren't concrete code you copy and paste; they are templates you adapt to solve a specific problem. Knowing them gives you a common vocabulary to discuss code architecture.
The Factory Pattern¶
The Factory Pattern provides a centralized way to create objects without exposing the complex creation logic. It's especially useful when you need to create different types of objects based on some input.
- Let's expand on our
CarandEngineclasses. We'll create aTruckclass that has a more powerfulTruckEngine. - Next, we'll create a central
VehicleFactoryclass that can create different vehicles based on a type.
classDiagram
direction LR
class VehicleFactory {
+create_vehicle(vehicle_type)
}
class Vehicle
class Car
class Truck
Vehicle <|-- Car
Vehicle <|-- Truck
VehicleFactory ..> Vehicle
Here's how we'd implement it:
| transport/vehicles.py | |
|---|---|
| transport/cars.py | |
|---|---|
| transport/trucks.py | |
|---|---|
| factory.py | |
|---|---|
This design hides the logic of creating the correct Car or Truck from the rest of your application. You simply ask the factory for a "car" or a "truck," and it handles the rest.
The Strategy Pattern¶
The Strategy Pattern allows you to define a family of algorithms and make them interchangeable. This is useful when an object needs to perform an action but the specific implementation of that action can change.
-
Imagine our
Carneeds to warn the user about a low fuel level. We could have aFuelWarningmethod that uses a specific strategy. -
First, let's define a family of notification strategies:
-
Now, our
Carclass can use composition to hold a reference to aNotificationStrategyobject.- The
notification_strategy: NotificationStrategyis called a type hint, we will expand on this in the next lesson.
- The
This design allows us to easily switch between sending console warnings and emails by simply passing a different strategy object when creating the Car.
Updated Exercises & Homework¶
Your homework is to apply these code organization and design principles to build a single, cohesive application. Follow these steps in the order they are presented.
-
Project Setup:
- Create a new project folder called
my_project. - Inside it, set up the following package structure. Make sure to add
__init__.pyfiles to each folder to define them as Python packages.
- Create a new project folder called
-
Abstraction with Abstract Base Classes:
- In the
transportsubpackage, create a file namedbase.py. - Using the
abcmodule, define an abstract base class calledVehicle. - This class should have a private attribute
__fuel_levelinitialized to100and a concrete methodget_fuel_level()that returns the fuel level. - Add an abstract method
refuel()that takesamountas an argument. Therefuelmethod should be responsible for updating the__fuel_level, but its specific implementation will be handled by subclasses. - This step establishes the core contract that all vehicles must follow.
- In the
-
Polymorphism with Method Overriding:
- Inside
transport/vehicles/, create two files:cars.pyandmotorcycles.py. - In
cars.py, create a classCarthat inherits from the abstractVehicleclass (fromtransport.base). - In
motorcycles.py, create a classMotorcyclethat also inherits fromVehicle. - Each of these concrete classes must provide its own implementation of the abstract
refuel()method, which they inherited. For example, theCar.refuel()method could print "Car is refueling..." and update the fuel level, while theMotorcycle.refuel()method prints "Motorcycle is refueling...". - This demonstrates polymorphism by showing different objects responding to the same method call (
refuel()) in their own unique way.
- Inside
-
Implementing the Strategy Pattern:
- Inside the
notificationspackage, create a file namedbase.py. - In this file, define an abstract base class
FuelWarningStrategywith an abstract methodsend_warning(message). - Next, create a file
console_strategy.pyin the samenotificationspackage. In this file, create a concrete classConsoleWarningStrategythat inherits fromFuelWarningStrategyand implements thesend_warning()method by printing the message to the console. - Create another file
email_strategy.pyand implement a concrete classEmailWarningStrategythat prints a simulated email message. - This prepares the different "strategies" that our vehicle objects will use.
- Inside the
-
Putting It All Together with Composition:
- Update your
Vehicleabstract class intransport/base.py. - Add a concrete method called
check_fuel()to this class. This method should accept aFuelWarningStrategyobject as an argument. - Inside
check_fuel(), add logic to check if the vehicle's fuel level is below a certain threshold (e.g., less than 50). - If the fuel is low, call the
send_warning()method on the provided strategy object with a message. This demonstrates composition—aVehicleobject "has a"FuelWarningStrategyobject.
- Update your
-
Creating the Vehicle Factory:
- Inside
transport/vehicles/, create a filevehicle_factory.py. - Create a
VehicleFactoryclass that has a single method,create_vehicle(vehicle_type). - This method should return a new instance of a
CarorMotorcyclebased on thevehicle_typestring.
- Inside
-
Final Execution:
- In the root
my_projectdirectory, create amain.pyfile. - Import your
VehicleFactory, your concrete vehicle classes, and your warning strategies. - Use the
VehicleFactoryto create aCarand aMotorcycle. - Call the
refuel()method on one of the vehicles to demonstrate the polymorphic behavior. - Next, call the
check_fuel()method on both vehicles, but pass a different strategy object to each one (e.g.,Car.check_fuel(ConsoleWarningStrategy())andMotorcycle.check_fuel(EmailWarningStrategy())). This will show how your design allows for flexible and interchangeable behavior.
- In the root
Final Directory Structure
Here is the final, complete directory structure for your project. This layout is what you will have created after completing all the exercises. The use of __init__.py files is crucial as they tell Python that these directories are packages and can be imported from.
my_project/
├── main.py
├── __init__.py
├── transport/
│ ├── __init__.py
│ ├── base.py
│ └── vehicles/
│ ├── __init__.py
│ ├── cars.py
│ ├── motorcycles.py
│ └── vehicle_factory.py
└── notifications/
├── __init__.py
├── base.py
├── console_strategy.py
└── email_strategy.py
This structure effectively separates the different components of your application:
main.py: The entry point of your program.transport/: Holds all classes related to vehicles.transport/base.py: Contains the abstractVehicleclass, which acts as the blueprint for all vehicles.transport/vehicles/: Contains the concrete vehicle implementations (CarandMotorcycle) and theVehicleFactory.notifications/: Contains all the different warning strategies.notifications/base.py: Holds the abstractFuelWarningStrategyclass.notifications/console_strategy.pyandemail_strategy.py: The concrete implementations of the warning strategies.
Suggested Readings & Resources¶
- Real Python: Python Packages
- Refactoring Guru: Design Patterns - A fantastic resource for learning about design patterns.
- Python.org: Modules
- Python ABCs
- More on Duck Typing