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