6 de marzo de 2013

String Calculator revisada

Bueno, después de practicar esta kata unas cuantas veces, logré bajar mi tiempo promedio a 12 minutos. Esta vez, ¡cumpliendo con todos los requerimientos del kata!

A continuación anexo el código actualizado:

Pruebas:

#!/usr/bin/env python

import unittest
#from strcalc_re import add
from strcalc import add


class SimpleTest(unittest.TestCase):

  def testEmptyString(self):
    self.assertEquals(0, add(''))

  def testSingleNumberString(self):
    self.assertEquals(2, add('2'))
    self.assertEquals(2, add('\n2'))
    self.assertEquals(2, add('2\n'))

  def testTwoNumberString(self):
    self.assertEquals(7, add('2\n5'))
    self.assertEquals(7, add('2, 5'))

  def testThreeNumberString(self):
    self.assertEquals(15, add('2,5,8'))
    self.assertEquals(15, add('2,5\n8'))


class UserDefinedSeparators(unittest.TestCase):

  def testEmptyString(self):
    self.assertEquals(0, add('//|\n'))

  def testSingleNumberString(self):
    self.assertEquals(2, add('//;\n2'))

  def testTwoNumberString(self):
    self.assertEquals(7, add('///\n2/5'))

  def testThreeNumberString(self):
    self.assertEquals(15, add('//#\n2#5#8'))
    self.assertEquals(15, add('''//!\n2!5\n8'''))


class NegativesNotAllowed(unittest.TestCase):

  def runTest(self):
    with self.assertRaises(ValueError):
      add('2,-1')
    with self.assertRaises(ValueError):
      add('//&\n2&-1')


class IgnoreGreaterThan1000(unittest.TestCase):

  def runTest(self):
    self.assertEquals(1, add('1\n1001'))
    self.assertEquals(8, add('//$\n1\n1002$3$4$1005'))


class ArbitraryLengthSeparators(unittest.TestCase):

  def runTest(self):
    self.assertEquals(8, add('//[$$$$]\n1\n1002$$$$3$$$$4\n0'))
    self.assertEquals(6, add('//[***]\n1***3***2\n**1'))


class MultipleSeparators(unittest.TestCase):

  def runTest(self):
    self.assertEquals(10, add('//[$][#][%]\n1$2#3%4'))
    self.assertEquals(10, add("//[a][lil][delim]\n1a2lil3delim4"))


unittest.TestSuite.addNestedSuites = lambda self, *classes: \
  self.addTests(map(unittest.makeSuite, classes))

unittest.TestSuite.addAllTests = lambda self, *classes: \
  self.addTests(cls() for cls in classes)


def suite():
  result = unittest.TestSuite()
  result.addNestedSuites(SimpleTest, UserDefinedSeparators)
  result.addAllTests(NegativesNotAllowed, IgnoreGreaterThan1000,
                     ArbitraryLengthSeparators, MultipleSeparators)
  return result


if __name__ == '__main__':
  runner = unittest.TextTestRunner(verbosity=2)
  runner.run(suite())

Existen algunas diferencias con respecto a la última versión que publiqué:

  • A diferencia del post anterior, esta ocasión he organizado las pruebas según el requerimiento, a fin de hacerlas más comprensibles.
  • La clase MultipleSeparators incluye el caso de prueba para los requerimientos 8 y 9.
  • Las lineas 4 y 5 indican que estas pruebas pueden ser usadas con dos implementaciones del código de producción: strcalc y strcalc_re.

Código: Implementación con expresiones regulares

#!/usr/bin/env python

import re


def parse_separators(line):
  if line.startswith('//[') and line.endswith(']'):
    result = line[3:-1].split('][')
  else:
    result = line[2:3]
  return '|'.join(re.escape(r) for r in result)


def config(s):
  separators = ','
  lines = s.split()
  if lines and lines[0].startswith('//'):
    separators = parse_separators(lines[0])
    lines = lines[1:]
  return (lines, separators)


def to_num(s):
  try:
    return int(s)
  except ValueError:
    return 0


def to_nums(lines, sep):
  pattern = re.compile(sep)
  return [to_num(part) for line in lines for part in
          pattern.split(line)]


def negatives_in(nums):
  return [n for n in nums if n < 0]


def add(s):
  (lines, sep) = config(s)
  nums = to_nums(lines, sep)
  negatives = negatives_in(nums)
  if negatives:
    raise ValueError('No se permiten negativos: %r' % negatives)
  return sum(n for n in nums if n <= 1000)

La forma más sencilla que encontré de soportar múltiples separadores fue utilizar re.split (linea 33). La complicación, claro, es que los delimitadores soportados por la implementación deberían ser arbitrarios. Sin embargo, re interpreta ciertos caracteres de forma especial, así que es necesario evitar que re haga de las suyas. ¿La solución? Escapar los delimitadores antes de llamar a re.split (linea 11).

Código: Implementación sin expresiones regulares

Por supuesto, quienes me conozcan sabrán que no me iba a contentar con la solución de la librería de python sin haber por lo menos implementado esa solución por mi mismo una vez. El resultado es algo así:

#!/usr/bin/env python

import re

def parse_separators(line):
  if line.startswith('//[') and line.endswith(']'):
    return line[3:-1].split('][')
  return [line[2:3]]


def config(s):
  separators = [',']
  lines = s.split()
  if lines and lines[0].startswith('//'):
    separators = parse_separators(lines[0])
    lines = lines[1:]
  return (lines, separators)


def to_num(s):
  try:
    return int(s)
  except ValueError:
    return 0


def to_nums(lines, sep):
  for s in sep:
    lines = sum((line.split(s) for line in lines), [])
  return map(to_num, lines)


def negatives_in(nums):
  return [n for n in nums if n < 0]


def add(s):
  (lines, sep) = config(s)
  nums = to_nums(lines, sep)
  negatives = negatives_in(nums)
  if negatives:
    raise ValueError('No se permiten negativos: %r' % negatives)
  return sum(n for n in nums if n <= 1000)

Las lineas resaltadas indican las ediciones para pasar de una versión a la otra.

La parte más dificil de esta versión fue encontrar una manera eficiente y legible de expandir una lista anidada en una lista simple con todos los elementos de sus sub-listas. Despues de probar varias soluciones llegué a una comprensión un tanto monstruosa:

lines = [part for parts in [line.split(s) for line in lines] for part in parts]

¡Ugh!, ¡Me lloran los ojos de solo verla!

La solución es simplemente que sum aplica el operador + a todos los elementos de la secuencia que recibe como argumento, sustituyendo in situ el resultado y las listas, claro está, implementan dicho operador (linea 29). Esta aplicación puede parecer un hack, por lo que es posible encapsularla en una función con un nombre ilustrativo como:

def to_nums(lines, sep):
    flatten = lambda nested_list: sum(nested_list, [])
    for s in sep:
        lines = flatten(line.split(s) for line in lines)
    return map(to_num, lines)

Sin embargo, me pregunto ¿qué tanto realmente gana el código en legibilidad con dicho cambio?

Saludos

6 de febrero de 2013

String Calculator en Python

Para complementar la entrada del joven +Alcides Flores Pineda, aquí anexo mi versión de la Kata en Python:

Pruebas:

#!/usr/bin/env python
import unittest
from strcalc import calc

def expected(exception):
    def argcatcher(f):
        def wrapper(self, *args):
            self.assertRaises(exception, lambda: f(self, *args))
        return wrapper
    return argcatcher


class StringCalcTest(unittest.TestCase):
    def testEmptyString(self):
        self.assertEquals(0, add(""))

    def testOneNumberString(self):
        self.assertEquals(2, add("2"))

    def testTwoNumberString(self):
        self.assertEquals(7, add("2,5"))

    def testThreeNumberString(self):
        self.assertEquals(15, add("2,5,8"))

    def testThreeNumberStringNewLine(self):
        self.assertEquals(15, add("2,5\n8"))

    def testEmptyStringWithUserSeparator(self):
        self.assertEquals(0, add("//;\n"))

    def testOneNumberStringWithUserSeparator(self):
        self.assertEquals(2, add("//;\n2"))

    def testTwoNumberStringWithUserSeparator(self):
        self.assertEquals(7, add("///\n2/5"))

    def testThreeNumberStringWithUserSeparator(self):
        self.assertEquals(15, add("//#\n2#5#8"))

    def testThreeNumberStringNewLineAndUserSeparator(self):
        self.assertEquals(15, add("//!\n2!5\n8"))

    @expected(ValueError)
    def testNegativesNotAllowed(self):
        add("2,-1")

    @expected(ValueError)
    def testNegativesNotAllowedWithUserSeparator(self):
        add("//&\n2&-1")

    def testIgnoreGreaterThan1000(self):
        self.assertEquals(1, add("1\n1001"))

    def testIgnoreGreaterThan1000WithUserSeparator(self):
        self.assertEquals(8, add("//$\n1\n1002$3$4$1005"))


if __name__ == '__main__':
    unittest.main()

Código:

#!/usr/bin/env python

def parse(s):
    return int(s) if s else 0

def tonums(lines, sep=','):
    return [parse(part) for line in lines for part in line.split(sep)]

def config(s):
    lines = s.split()
    if lines and lines[0].startswith('//'):
        return lines[1:], lines[0][2:]
    return lines, ','

def add(s):
    lines, sep = config(s)
    nums = tonums(lines, sep)
    negatives = [n for n in nums if n < 0]
    if negatives:
        raise ValueError('No se permiten negativos: %r' % negatives)
    return sum(n for n in nums if n <= 1000)

Al igual que en el caso de +Alcides Flores Pineda y +rodrigo salado anaya , el presente código no implementa el requerimiento de separadores de longitud arbitraria.

El código resultante es más largo que la versión en scheme/kawa, en algunos casos por la verbosidad propia de las comprensiones y generadores de Python, y en otras por las diferencias propias de la implementación, más que por cuestiones sintácticas.

A mi en lo particular me resulta muy ilustrativo ver las diferentes respuestas que se han generado para la misma kata en diversos lenguajes o incluso de personas distintas usando el mismo lenguaje.

La siguiente parte del ejercicio es: ejecutarlo en 15 o menos minutos todos los días por una semana y al cabo de ese tiempo, reflexionar:

  • ¿Mi solución a la kata está evolucionando cada vez que la ejecuto o por el contrario, repito los mismos pasos "como periquito"?
  • ¿Puedo encontrar alguna forma más elegante/clara/eficiente de resolver el problema o alguna parte de el mismo?
  • ¿Si la primera vez resolví el problema de forma, digamos, procedural, puedo intentar una solución orientada a objetos?, ¿funcional?
  • ¿Qué ventajas/desventajas tendrían dichas soluciones?

Saludos

4 de febrero de 2013

String Calculator en Kawa

Hace un par de días, un amigo nuestro compartió en su blog de Java México, su solución a la Kata String Calculator en Groovy. Esto me motivó a compartir también mi solución en Kawa y compararlas para ver si las mismas ideas se podrían implementar/traducir en/a Groovy.

Cabe mencionar que yo también me tardé mas de media hora en resolver el ejercicio (dos pomodoros de 20 min c/u), lo cual confirma la teoría que teníamos con un amigo sobre el tiempo que sería necesario para este ejercicio en un Coding-Dojo.

He aquí el código fuente en Scheme Kawa:
; -*- coding: utf-8; mode: Scheme -*-
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Coding Kata String Calculator 

(require 'srfi-1 ) ;; List Library
(require 'srfi-13) ;; String Library
(require 'srfi-14) ;; Character-set Library

;; (define-simple-class StringCalculator ()
;;   ;; methods
;;   ((Add numbers ::String) ::int allocation: 'static

(define (Add numbers ::String) ::int
  (let* ((lstnum (filter (lambda (n)
                           (if (<= n 1000) #t #f))
                         (map string->number
                              (string-tokenize 
                               numbers
                               (char-set-adjoin char-set:digit #\-)))))
         (negativos (filter negative? lstnum)))
    (if (null? negativos)
        (apply + lstnum)
        (throw (java.lang.RuntimeException 
                (format #f 
                        "No se permiten negativos: ~s~%" 
                        negativos))) )))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Pruebas Unitarias
(require 'srfi-64) ;; Testing Library

;;(define-alias Add StringCalculator:Add)

(test-begin "string-calculator")
;;(test-assert "Prueba no implementada" #f)
;;Req 1.1 y 1.2
(test-equal 0 (Add "Hola"))
(test-equal 0 (Add ""))
(test-equal "Suma '1'" 1 (Add "1"))
(test-equal "Suma '1,2'" 3 (Add "1,2"))
;;Req 2 y 3
(test-equal "Suma '1,2,3'" 6 (Add "1,2,3"))
(test-equal "Suma '1\n2,3'" 6 (Add "1\n2,3"))
;;Req 4
(test-equal 3 (Add "//;\n1;2"))
;;Req 6, 8 y 9
(test-equal 2 (Add "2,1001"))
(test-equal 6 (Add "//[*][%]\n1*2%3"))
;; pruebas del script de groovy
(test-equal 0 (Add "")) ;;assert 0 == "".sum()
(test-equal 0 (Add " ")) ;;assert 0 == " ".sum()
(test-equal 1 (Add "1")) ;;assert 1 == "1".sum()
(test-equal 1 (Add " 1")) ;;assert 1 == " 1".sum()
(test-equal 1 (Add " 1 ")) ;;assert 1 == " 1 ".sum()
(test-equal 1 (Add "  1  ")) ;;assert 1 == "  1  ".sum()
(test-equal 0 (Add "a")) ;;assert 0 == "a".sum()
(test-equal 3 (Add "1,2")) ;;assert 3 == "1,2".sum()
(test-equal 1 (Add "1, a")) ;;assert 1 == "1, a".sum()
(test-equal 2 (Add "a, 2")) ;;assert 2 == "a, 2".sum()
(test-equal 6 (Add "1,2,3")) ;;assert 6 == "1, 2, 3".sum()
(test-equal 6 (Add "//;\n1, 2, 3")) ;;assert 6 == "//;\n1, 2, 3".sum()
(test-equal 15 (Add "//;'\n1, 2, 3' 4' 5")) ;;assert 15 == "//;'\n1, 2, 3' 4' 5".sum()
(test-equal 15 (Add "//;'\n1, 2, 3' 4' 5, \n'")) ;;assert 15 == "//;'\n1, 2, 3' 4' 5, \n'".sum()
(test-equal 15 (Add "//;'\n1, 2, 3' 4' 5, \n', a, b; c' d")) ;;assert 15 == "//;'\n1, 2, 3' 4' 5, \n', a, b; c' d".sum()
(test-equal 0 (Add ",;\n")) ;;assert 0 == ",;\n".sum()
(test-error  (Add "-1, 2, 3")) ;;try{ "-1, 2, 3".sum()
(test-error  (Add "-1, 2, 3, -4, -5")) ;;try{ "-1, 2, 3, -4, -5".sum() }catch(e){ println e}
(test-error  (Add "//;'\n1, 2, -3' -4' 5")) ;;try{ "//;'\n1, 2, -3' -4' 5".sum() }catch(e){ println e}

(test-end "string-calculator")


Notas:
  • Escribir una clase con un solo método (que además es estático) es una mala práctica. Aunque en este caso el requerimiento así lo estipule, he preferido dejar el método Add como una función ordinaria de Scheme y dejar el código de definición de clase y método comentados solo como ejemplo de como se hace en Kawa.
  • Agregué al final de las pruebas unitarias, la traducción a Scheme de las pruebas del script de Groovy para comprobar que el código cumple con los mismos requerimientos. E.d. Pasa las mismas pruebas

El suponiendo que código anterior se guarde en un archivo llamado "string-calc.scm", éste puede ser ejecutado desde la línea de comandos de la siguiente forma:
$ kawa -f string-calc.scm
Produciendo el siguiente resultado:
%%%% Starting test string-calculator  (Writing full log to "string-calculator.log")
# of expected passes      28
Hasta aquí la coding kata de hoy.

Saludos cordiales.