Binary Coffee

Refactorizando c贸digo duplicado y poder ejecutar pruebas f谩cilmente

python

En este post abordaremos el principio DRY (Don't repeat yourself) refactorizando el c贸digo resultante de este otro post (fechas pal铆ndromos) para hacerlo reusable y f谩cilmente comprobable por pruebas unitarias. Pero primero veamos brevemente de Wikipedia la definici贸n de DRY:

"DRY is a principle of software development aimed at reducing repetition of software patterns, replacing it with abstractions or using data normalization to avoid redundancy."

Si analizamos el c贸digo a refactorizar:

from datetime import date
from datetime import timedelta

oneDay = timedelta(days=1)

def IsPalindromoDate(date):
    strDate = str(date).replace('-', '')
    return strDate == strDate[: : -1]

print('Past palindromes:')
while str(currentDate) != '1000-01-01':
    if IsPalindromoDate(currentDate):
        print(currentDate)
    currentDate -= oneDay

currentDate = date.today() + oneDay
print('Future palindromes:')
while str(currentDate) != '2999-12-31':
    if IsPalindromoDate(currentDate):
        print(currentDate)
    currentDate += oneDay

Notamos que los dos ciclos tienen diferentes condiciones de parada y la variable que itera lo hace en sentido opuesto en cada caso, pero en esencia ambos ciclos tienen la misma estructura: iterar por un rango de fechas y obtener las que cumplen cierta propiedad. Esto puede abstraerse usando el patr贸n Iterator, y Python tiene un protocolo para implementar este patr贸n en clases. Ve谩moslo en un ejemplo simple que itera sobre el rango [a, b) de enteros:

class my_range:
    def __init__(self, a, b):
        self.__a = a
        self.__b = b

    def __iter__(self):
        return self

    def __next__(self):
        if self.__a >= self.__b:
            raise StopIteration()
        else:
            result = self.__a
            self.__a += 1
            return result

Luego para consumir el iterador lo hacemos por medio de un for:

for i in my_range(1, 5):
    print(i)
1
2
3
4

En este ejemplo vemos que la clase debe implementar los m茅todos __iter__ y __next__ y lanzar la excepci贸n StopIteration cuando __next__() no pueda producir mas elementos a iterar.

Entonces redise帽emos la iteraci贸n sobre las fechas as铆:

import datetime

class DateRange:
    """
    Iterator over every date in a range [date_a, date_b). If a predicate is defined
    then iterate only over those dates thats evaluate filter(date) as True. Examples:

    >>> list(DateRange(datetime.date(2019, 12, 30), datetime.date(2020, 1, 3)))
    [datetime.date(2019, 12, 30), datetime.date(2019, 12, 31), datetime.date(2020, 1, 1), datetime.date(2020, 1, 2)]

    >>> list(DateRange(datetime.date(2019, 12, 30), datetime.date(2020, 1, 3), lambda x: x.day % 2 == 0))
    [datetime.date(2019, 12, 30), datetime.date(2020, 1, 2)]
    """
    def __init__(self, date_a, date_b, filter=None):
        # TODO: add impl 
        pass

    def __iter__(self):
        return self

    def __next__(self):
        # TODO: add impl 
        raise StopIteration() 

Antes de pasar a implementar la l贸gica de la clase comentaremos un poco sobre las pruebas. Primero notemos que el comentario con la documentaci贸n de la clase tiene 2 ejemplos, estos dos ejemplos pueden servir como pruebas unitarias de la clase, para correrlas ejecutemos python -m doctest -v DateRange.py y python se encarga de descubrirlas, ejecutarlas y darnos los resultados:

Trying:
    list(DateRange(datetime.date(2019, 12, 30), datetime.date(2020, 1, 3)))
Expecting:
    [datetime.date(2019, 12, 30), datetime.date(2019, 12, 31), datetime.date(2020, 1, 1), datetime.date(2020, 1, 2)]
**********************************************************************
File "....\Sources\BinaryCoffeeBlogPosts\Codes\DateRange.py", line 13, in DateRange.DateRange
Failed example:
    list(DateRange(datetime.date(2019, 12, 30), datetime.date(2020, 1, 3)))
Expected:
    [datetime.date(2019, 12, 30), datetime.date(2019, 12, 31), datetime.date(2020, 1, 1), datetime.date(2020, 1, 2)]
Got:
    []
Trying:
    list(DateRange(datetime.date(2019, 12, 30), datetime.date(2020, 1, 3), lambda x: x.day % 2 == 0))
Expecting:
    [datetime.date(2019, 12, 30), datetime.date(2020, 1, 2)]
**********************************************************************
File "....\Sources\BinaryCoffeeBlogPosts\Codes\DateRange.py", line 16, in DateRange.DateRange
Failed example:
    list(DateRange(datetime.date(2019, 12, 30), datetime.date(2020, 1, 3), lambda x: x.day % 2 == 0))
Expected:
    [datetime.date(2019, 12, 30), datetime.date(2020, 1, 2)]
Got:
    []
**********************************************************************
1 items had failures:
   2 of   2 in DateRange.DateRange
2 tests in 5 items.
0 passed and 2 failed.
***Test Failed*** 2 failures.

Como vemos tenemos 2 pruebas fallidas. Estas dos pruebas son parte del docstring de la clase DateRange y puede accederse por medio del atributo DateRange.__doc__. A帽adamos un test m谩s que pruebe el problema original de fechas pal铆ndromos antes de pasar a la implementar la l贸gica del iterador:

>>> IsPal = lambda date: str(date).replace('-', '') == str(date).replace('-', '')[: : -1]
>>> list(DateRange(datetime.date(2020, 1, 1), datetime.date(2020, 12, 31), IsPal))
[datetime.date(2020, 2, 2)]

El lector en este punto puede no seguir leyendo para no ver la implementaci贸n propuesta que pasa las pruebas y hacer su propia implementaci贸n del iterador haciendo uso as铆 del desarrollo dirigido por pruebas o TDD por sus siglas en ingl茅s.

La implementaci贸n nuestra:

import datetime
from datetime import date
from datetime import timedelta

def IsDate(value):
    return type(value) is date

class DateRange:
    """
    Iterator over every date in a range [date_a, date_b). If a predicate is defined
    then iterate only over those dates thats evaluate filter(date) as True. Examples:

    >>> list(DateRange(datetime.date(2019, 12, 30), datetime.date(2020, 1, 3)))
    [datetime.date(2019, 12, 30), datetime.date(2019, 12, 31), datetime.date(2020, 1, 1), datetime.date(2020, 1, 2)]

    >>> list(DateRange(datetime.date(2019, 12, 30), datetime.date(2020, 1, 3), lambda x: x.day % 2 == 0))
    [datetime.date(2019, 12, 30), datetime.date(2020, 1, 2)]

    >>> IsPal = lambda date: str(date).replace('-', '') == str(date).replace('-', '')[: : -1]
    >>> list(DateRange(datetime.date(2020, 1, 1), datetime.date(2020, 12, 31), IsPal))
    [datetime.date(2020, 2, 2)]
    """

    __oneDay = timedelta(days=1)

    def __init__(self, date_a, date_b, filter=None):
        pass
        if not IsDate(date_a) or not IsDate(date_b):
            raise ValueError("Arguments must be of type datetime.date")
        if date_a > date_b:
            raise ValueError("The first date must be the same or before the second date")
        self.__A = date_a
        self.__B = date_b
        self.__filter = filter

    def __iter__(self):
        return self

    def __next__(self):
        while not self._filter(self.__A):
            self.__checkif_must_stop()
            self.__A += DateRange.__oneDay
        result = self.__A
        self.__checkif_must_stop()
        self.__A += DateRange.__oneDay
        return result

    def __checkif_must_stop(self):
        if self.__A >= self.__B:
            raise StopIteration()

    def _filter(self, date):
        if self.__filter is None:
            return True
        return self.__filter(date)

Luego la soluci贸n al problema original usando el m贸dulo DateRange:

from datetime import date
from DateRange import DateRange

def IsPalindromoDate(date):
    strDate = str(date).replace('-', '')
    return strDate == strDate[: : -1]

print('Past palindromes:')
for pd in DateRange(date(1000, 1, 1), date.today(), IsPalindromoDate):
    print(pd)

print('Future palindromes:')
for pd in DateRange(date.today(), date(2999, 12, 31), IsPalindromoDate):
    print(pd)

Todo el c贸digo del post est谩 disponible en gist. Adem谩s, all铆 tambi茅n hay otra implementaci贸n de DateRange usando la instruci贸n yield que hace que la funci贸n retorne un generator.

Conclusiones

En este post vimos cuando en nuestras soluciones identificamos c贸digo repetido podemos llevar a cabo una refactorizaci贸n de la soluci贸n que use una abstracci贸n adecuada y haga m谩s mantenible, reusable y comprobable dicho c贸digo.

Opiniones
noavatar
Mi buen art铆culo rafa
noavatar
La soluci贸n usando yield es m谩s simple.