Python
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax and Data Types
-
Section 3: Collections
-
39: Set Operations: Union, Intersection, Difference
-
Section 4: Control Flow
-
Section 5: Functions
-
Section 6: Turtle Graphics and Early Practice Projects
-
Section 7: Working with Files and I/O
-
Section 8: Regular Expressions
-
Section 9: Object-Oriented Python
-
Section 10: Error Handling
-
Section 11: Modules and Packages
-
Section 12: Iterators, Generators, and Functional Tools
-
Section 13: Decorators and Metaprogramming
-
Section 14: Concurrency and Parallelism
-
Section 15: Working with Dates, Times, and Numbers
-
Section 16: Standard Library Deep Dive I: Data Structures
-
Section 17: Standard Library Deep Dive II: System and Introspection
-
Section 18: Standard Library Deep Dive III: Security and Encoding
-
Section 19: Standard Library Deep Dive IV: Text and Data Utilities
-
Section 20: Networking and Web Basics
-
Section 21: Working with Databases
-
Section 22: Testing and Quality
-
Section 23: Advanced Typing
-
Section 24: Context Managers and Resource Handling
-
Section 25: Text, Unicode, and Binary Data
-
Section 26: More Functional and Iteration Tools
-
Section 27: Data Validation and Configuration
-
Section 28: Working with Images and Media
-
Section 29: Property-Based and Documentation Testing
-
Section 30: Packaging and Deployment
-
Section 31: Performance and Internals
-
Section 32: Design Patterns in Python
-
Section 33: GUI Programming
-
Section 34: Security Basics
-
Section 35: Data Structures and Algorithms
-
Section 36: Practical Projects
-
Section 37: Capstone Projects
-
Section 38: Interview and Algorithm Practice
-
Section 39: Writing Idiomatic Python
132: Protocols and Structural Subtyping
A few years ago, I was building a data pipeline that needed to support multiple different storage backends—S3, local disk, and a proprietary database. I did what any "proper" engineer would do: I created an Abstract Base Class (ABC) called StorageBackend with a save() method, and I made every backend inherit from it. It worked great until we had to integrate a third-party library for Azure Blob Storage. The library's client had a save() method that matched my signature perfectly, but because it didn't explicitly inherit from my StorageBackend class, my type checker (mypy) started screaming at me. I was stuck choosing between writing a tedious wrapper class for a library I didn't control or just ignoring the type errors.
That's where Protocols come in. They allow us to move from "nominal subtyping" (where a type is defined by its name and inheritance tree) to "structural subtyping" (where a type is defined by what it can actually do). In simpler terms, it's static duck typing.
The Nominal vs. Structural Divide
Up until now, you've likely used nominal typing. If you want a function to accept any Shape, you make sure the objects passed into it inherit from the Shape class. The type checker looks at the "name" of the class in the inheritance chain to decide if it's valid. This is rigid. It requires the author of the class to have known about your base class when they wrote their code.
Structural subtyping, implemented via typing.Protocol, flips this. Instead of saying "This object must be a Shape," you say "This object must have a draw() method that returns None." If the object has that method, it's a match. It doesn't matter where it came from or what its parents are. This is incredibly powerful when you're working with external libraries or want to keep your components decoupled.
Defining the Shape of Your Objects
To use a Protocol, you inherit from typing.Protocol. You define the methods and attributes you expect, but you don't actually implement them. Here is how I would have handled that storage problem differently:
from typing import Protocol
class StorageBackend(Protocol):
def save(self, data: str, filename: str) -> bool:
...
class S3Backend:
# Notice: No inheritance from StorageBackend!
def save(self, data: str, filename: str) -> bool:
print(f"Uploading {filename} to S3")
return True
class LocalBackend:
# This also works, even without explicit inheritance
def save(self, data: str, filename: str) -> bool:
print(f"Writing {filename} to disk")
return True
def upload_report(backend: StorageBackend, content: str):
backend.save(content, "report.txt")
# Both of these are now perfectly valid to the type checker
upload_report(S3Backend(), "Hello S3")
upload_report(LocalBackend(), "Hello Disk")
The ... (ellipsis) is the standard way to indicate that the method has no implementation. My favorite part about this approach is that S3Backend and LocalBackend remain "pure." They don't need to know that StorageBackend exists. They just happen to satisfy the requirements.
When to Prefer Protocols Over ABCs
You might be wondering if ABCs are now obsolete. Not quite. ABCs are still great when you want to provide a base implementation that subclasses can share to avoid code duplication. If you have five classes that all share 80% of the same logic, use an ABC.
However, use a Protocol when:
- You are defining an interface for classes you don't own (third-party libraries).
- You want to keep your packages loosely coupled.
- You care more about the behavior (the methods) than the identity (the class hierarchy).
I've found that as projects grow, leaning toward Protocols usually leads to less friction. It prevents that "inheritance hell" where you find yourself creating deep, fragile hierarchies just to satisfy a type checker.
📋 Practical Task
Implementing a Pluggable Notification System
You are building a system that sends notifications. You want to support different channels (Email, SMS, Slack), but you want the main NotificationManager to be agnostic about who actually sends the message. Some of these senders might come from external SDKs that you cannot modify.
Your task:
- Define a
SenderProtocol that requires a methodsend_message(message: str, recipient: str) -> None. - Create a class
EmailSenderand a classSmsSender. Crucially: Do not make them inherit from theSenderprotocol. - Implement the
send_messagemethod in both classes so they satisfy the protocol. - Create a function
broadcast_notification(sender: Sender, message: str, recipients: list[str])that loops through the recipients and uses the sender to deliver the message. - Instantiate both senders and use the
broadcast_notificationfunction with each of them to verify it works.
There are no comments for now.