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)](https://binary-coffee.dev/post/fechas-palindromos) 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](https://gist.github.com/frarteaga/5dbaa605a1e494b81c665a9d33496b40). Adem√°s, all√≠ tambi√©n hay otra [implementaci√≥n](https://gist.github.com/frarteaga/5dbaa605a1e494b81c665a9d33496b40#file-daterange_yield-py) 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