Python Topics : Duck Typing
Getting to Know Duck Typing in Python
Duck Typing: Behaving Like a Duck
If it walks like a duck and it quacks like a duck, then it must be a duck.
duck typing is a type system where an object is considered compatible with a given type if it has all the methods and attributes that the type requires
this type system supports the ability to use objects of independent and decoupled classes in a specific context as long as they adhere to some common interface
class Duck:
    def swim(self):
        print("The duck is swimming")

    def fly(self):
        print("The duck is flying")

class Swan:
    def swim(self):
        print("The swan is swimming")

    def fly(self):
        print("The swan is flying")

class Albatross:
    def swim(self):
        print("The albatross is swimming")

    def fly(self):
        print("The albatross is flying")
the classes above all have the same interface
Python does not type check so the code below works
>>> from birds_v1 import Duck, Swan, Albatross

>>> birds = [Duck(), Swan(), Albatross()]

>>> for bird in birds:
...     bird.fly()
...     bird.swim()
...
The duck is flying
The duck is swimming
The swan is flying
The swan is swimming
The albatross is flying
The albatross is swimming
Duck Typing and Polymorphism
duck typing is a popular way to support polymorphism in Python
objects don't have to inherit from a common superclass
Understanding the Pros and Cons of Duck Typing
Pros
flexibility: can use different objects interchangeably based on their behavior without worrying about their types
promotes modularity and extensibility in the code
simplicity can simplify code by focusing on the required behavior rather than thinking of relationships between types
allows for more concise and expressive(?) code
code reuse can reuse one or more of your classes in other apps without having to export a complex class hierarchy
facilitates code reuse
easier prototyping can quickly create objects that exhibit the necessary behavior without complex type definitions
useful during the initial stages of development
may not have fully fleshed out class hierarchies or interfaces
Cons
potential runtime errors might face errors related to missing methods or attributes
may only appear at runtime
lack of explicitness makes code less explicit and more challenging to understand
the lack of explicit interface definitions might make it more difficult to grasp what behavior an object must exhibit
potential maintenance issues might have issues tracking which objects must exhibit certain behaviors
behavior changes in certain objects may impact other parts of the code
harder to maintain, reason about, and debug
Exploring Duck Typing in Python's Built-in Tools
built-in types, such as lists, tuples, strings, and dictionaries, support duck typing
numbers = [1, 2, 3]
person = ("Jane", 25, "Python Dev")
letters = "abc"
ordinals = {"one": "first", "two": "second", "three": "third"}
even_digits = {2, 4, 6, 8}
collections = [numbers, person, letters, ordinals, even_digits]

for collection in collections:
    for value in collection:
        print(value)
Operation Lists Tuples Strings Ranges Dictionaries Sets
Iteration X X X X X X
Indexing X X X X
Slicing X X X X
Concatenating X X X
Finding Length X X X X X X
Reversing X X X X X
Sorting X X X X X X
Supporting Duck Typing in Custom Classes
can support duck typing in custom classes using two different approaches
  • regular methods
  • special methods
Using Regular Methods
file.txt
John
25
Engineer
Jane
22
Designer
file.csv
name,age,job
John,25,Engineer
Jane,22,Designer
file.json
[
    {
        "name": "John",
        "age": 25,
        "job": "Engineer"
    },
    {
        "name": "Jane",
        "age": 22,
        "job": "Designer"
    }
]
use duck typing to read the files
import csv
import json
from itertools import batched  # Python >= 3.12

class TextReader:
    def __init__(self, filename):
        self.filename = filename

    def read(self):
        with open(self.filename, encoding="utf-8") as file:
            return [
                {
                    "name": batch[0].strip(),
                    "age": batch[1].strip(),
                    "job": batch[2].strip(),
                }
                for batch in batched(file.readlines(), 3)
            ]

class CSVReader:
    def __init__(self, filename):
        self.filename = filename

    def read(self):
        with open(self.filename, encoding="utf-8", newline="") as file:
            return list(csv.DictReader(file))

class JSONReader:
    def __init__(self, filename):
        self.filename = filename

    def read(self):
        with open(self.filename, encoding="utf-8") as file:
            return json.load(file)
using the classes
>>> from readers import TextReader, CSVReader, JSONReader

>>> readers = [
...     TextReader("file.txt"),
...     CSVReader("file.csv"),
...     JSONReader("file.json"),
... ]

>>> for reader in readers:
...     print(reader.read())
...
[
    {'name': 'John', 'age': '25', 'job': 'Engineer'},
    {'name': 'Jane', 'age': '22', 'job': 'Designer'}
]
[
    {'name': 'John', 'age': '25', 'job': 'Engineer'},
    {'name': 'Jane', 'age': '22', 'job': 'Designer'}
]
[
    {'name': 'John', 'age': 25, 'job': 'Engineer'},
    {'name': 'Jane', 'age': 22, 'job': 'Designer'}
]
Using Special Methods and Protocols
special methods are dunders
protocols are sets of special methods that support specific features of the language
  • iterator
  • context manager
  • sequence protocols
protocols are informal interfaces that are defined in the documentation

class implements a queue data structure
needs to be iterable and support the built-in len() and reversed() functions
needs to support the in operator

from collections import deque

class Queue:
    def __init__(self):
        self._elements = deque()

    def enqueue(self, element):
        self._elements.append(element)

    def dequeue(self):
        return self._elements.popleft()

    def __iter__(self):
        return iter(self._elements)

    def __len__(self):
        return len(self._elements)

    def __reversed__(self):
        return reversed(self._elements)

    def __contains__(self, element):
        return element in self._elements
class uses a deque object to store the data.
class has the classic queue operations enqueue and dequeue
__iter__() allows supporting iterations
implements 3 other dunder methods
Exploring Alternatives to Duck Typing
Using Abstract Base Classes
Abstract base classes (ABCs) define a specific set of public methods and attributes (API) that all their subclasses must implement
use the abc module from the standard library
module provides a couple of ABC-related tools
from abc import ABC, abstractmethod

class Vehicle(ABC):
    def __init__(self, make, model, color):
        self.make = make
        self.model = model
        self.color = color

    @abstractmethod
    def start(self):
        raise NotImplementedError("start() must be implemented")

    @abstractmethod
    def stop(self):
        raise NotImplementedError("stop() must be implemented")

    @abstractmethod
    def drive(self):
        raise NotImplementedError("drive() must be implemented")

# ...

class Car(Vehicle):
    def start(self):
        print("The car is starting")

    def stop(self):
        print("The car is stopping")

    def drive(self):
        print("The car is driving")

class Truck(Vehicle):
    def start(self):
        print("The truck is starting")

    def stop(self):
        print("The truck is stopping")

    def drive(self):
        print("The truck is driving")
Checking Types Explicitly
to check whether an object comes from a given class, use the built-in isinstance() function
to check whether an object has a specific method or attribute, can use the built-in hasattr() function

birds_v2.py

class Duck:
    def fly(self):
        print("The duck is flying")

    def swim(self):
        print("The duck is swimming")

class Pigeon:
    def fly(self):
        print("The pigeon is flying")
classes partially support duck typing
they work interchangeably if only the .fly() method is used
Pigeon.swim() will fail with an AttributeError
check the type before calling the swim() method
>>> for bird in birds:
...     if isinstance(bird, Duck):
...         bird.swim()
...
The duck is swimming
need to know too much about the types when using isinstance()

can explicitly check if the current object has the desired method using the hasattr()function

for bird in birds:
    if hasattr(bird, "swim"):
        bird.swim()
Using Duck Typing and Type Hints
Understanding Type Hints and Static Duck Typing
type hints express the type of objects in a piece of code
associated advantages include
  • preventing type-related bugs
  • powering automatic type checkers
  • supporting auto-completion in editors
  • providing code documentation
  • allowing for data validation
  • allowing for data serialization
Python supports a structural type system
a class can replace another if both have the same structure
can use the len() function with all the classes that define the .__len__() method
the method is part of the class's internal structure

Using Protocols and ABCs
Python 3.8 introduced protocols in the type-hinting system
a protocol specifies one or more methods that a class must implement to support a given feature
protocols have to do with a class's internal structure

protocols and abstract base classes fill the gap between duck typing and type hints
help type checkers catch type-related issues and potential errors

duck typing and type hints may not always work together

>>> def mean(grades: list) -> float:
... return sum(grades) / len(grades)
...

>>> mean([4, 3, 3, 4, 5])
3.8
>>> mean((4, 3, 3, 4, 5))
3.8
function works but type hints limit its use to lists
a union type expression makes the function duck type compatible
def mean(grades: list | tuple | set) -> float:
    return sum(grades) / len(grades)
type hint makes the function signature cumbersome
a more generic solution is using the abstract base class Collection from the module collections.abc
class expects support for ineration and the len() function
from collections.abc import Collection

def mean(grades: Collection) -> float:
    return sum(grades) / len(grades)
Creating Custom Protocol Objects
can create custom classes to define protocols in code which relies on duck typing
below is a group of shape classes
all the classes need to have .area() and .perimeter() methods
shapes.py
from math import pi

def describe_shape(shape):
    print(f"{type(shape).__name__}")
    print(f" Area: {shape.area():.2f}")
    print(f" Perimeter: {shape.perimeter():.2f}")

class Circle:
    def __init__(self, radius: float) -> None:
        self.radius = radius

    def area(self) -> float:
        return pi * self.radius**2

    def perimeter(self) -> float:
        return 2 * pi * self.radius

class Square:
    def __init__(self, side: float) -> None:
        self.side = side

    def area(self) -> float:
        return self.side**2

    def perimeter(self) -> float:
        return 4 * self.side

class Rectangle:
    def __init__(self, length: float, width: float) -> None:
        self.length = length
        self.width = width

    def area(self) -> float:
        return self.length * self.width

    def perimeter(self) -> float:
        return 2 * (self.length + self.width)
all these classes have the required methods supporting duck typing
also use type hints to describe the types of input arguments and the return values of every method
>>> from shapes import Circle, Rectangle, Square, describe_shape

>>> describe_shape(Circle(3))
Circle
 Area: 28.27
 Perimeter: 18.85

>>> describe_shape(Square(5))
Square
 Area: 25.00
 Perimeter: 20.00

>>> describe_shape(Rectangle(4, 5))
Rectangle
 Area: 20.00
 Perimeter: 18.00
there are no type hints for the method describe_shape
argument can be any object whether or not it supports the desired interface
create a Protocol subclass to describe the required set of methods
from math import pi
from typing import Protocol

class Shape(Protocol):
    def area(self) -> float: ...
    def perimeter(self) -> float: ...
# rest of shapes.py
the class inherits from Protocol and defines the required methods
the methods don't have a proper implementation
class uses an ellipsis to define the protocol's body to express these methods don't do anything
can use a pass statement instead
methods just define a custom protocol
refactor describe_shape() adding type hints
def describe_shape(shape: Shape) -> None:
    print(f"{type(shape).__name__}")
    print(f" Area: {shape.area():.2f}")
    print(f" Perimeter: {shape.perimeter():.2f}")
index