Contando colaboraciones en Cuis Smalltalk

¿Qué tan difícil resultaría hacer un programa que cuente colaboraciones de un método? En Smalltalk, no tan difícil.

Contando colaboraciones en Cuis Smalltalk

Continuamos la serie de artículos dedicada a contar colaboraciones como una métrica de calidad de software. Si no viste la primera parte, te recomiendo leerla. Allí podemos ver de dónde sale esta métrica y por qué creo que es mejor que contar líneas de código.

Recordemos brevemente a qué llamábamos colaboración: envío de un mensaje a un objeto. Entonces, lo que nos interesa medir es cuántos envíos de mensajes aparecen en un método, ya que de esa manera podemos entender cuántas responsabilidades o decisiones ocurren en un mismo lugar. Idealmente, queremos reducir ese número tanto como sea posible.

Entonces, ¿podemos hacer un programa que cuente las colaboraciones de un método? ¡Sí! Aquí voy a comentar en detalle la solución hecha en Cuis Smalltalk, un ambiente Smalltalk de código abierto y orientado a la simplicidad, del que tengo el orgullo de haber realizado algunas contribuciones.

¿Por qué esta elección de lenguaje? Al ser los ambientes Smalltalk reflexivos y metacirculares, es muy sencillo manipular un programa de la misma manera que manipulamos, por ejemplo, una lista, una cuenta bancaria, o cualquier objeto que se les ocurra. Y, además, contamos con herramientas que nos permiten visualizar estos objetos ¡y testearlos como cualquier otro!

Empezar por el principio: los tests

Vamos a hacer este contador con TDD, como no podía ser de otra manera. Y claro, al momento de escribir el primer test, y sobre todo en un metaprograma como éste, nos puede resultar un poco difícil. Pensemos el caso más sencillo de alguien que quiera contar colaboraciones: ¡cuando no hay ninguna! Vamos a crear una clase, que usaremos para dos propósitos: escribir los tests correspondientes a nuestro contador y tener métodos que nos ayuden a generar escenarios de prueba. Entonces, el primero de estos escenarios es un método vacío:

CollaborationsCounterTest >> #emptyMethod
  "...nada por aquí..."

Y el primer test quedaría planteado de la siguiente manera:

CollaborationsCounterTest >> #test01ItCountsZeroCollaborationsInAnEmptyMethod
    | counter |
    counter := CollaborationCounter for: self class >> #emptyMethod.
    self assert: 0 equals: counter value

Pequeña referencia para ubicarnos mejor en el mundo Smalltalk: el mensaje >> nos permite obtener un método compilado (instancia de CompiledMethod) de la clase que recibe el mensaje (en este caso, la misma clase de test).

Luego de escribir el primer test... que nos guíen los ZOMBIES. Esta técnica (propuesta por James Greening) consiste en organizar tu estrategia de pruebas planteándolas en el siguiente orden: (Z)ero, (O)ne, (M)any, (B)oundaries, (I)nterfaces, (E)xceptional Behavior. Es decir, empezar con el caso de cero elementos que, probablemente, sea el más sencillo de hacer pasar. Luego, seguir con uno, con muchos, con casos borde. Luego, con aquellos tests que terminen de definir nuestra interfaz con el mundo exterior y, por último, el comportamiento excepcional. El acrónimo cierra con la "S" de "Simple solutions, simple scenarios" que nos recuerda el valor de la simplicidad cada vez que pensamos un caso de prueba.

Así, los tests que fuimos construyendo paso a paso fueron los siguientes: (copio sólo los nombres para no redundar en código de test que es muy similar al de test01 pero con diferentes valores esperados):

#test01ItCountsZeroCollaborationsInAnEmptyMethod
#test02ItCountsOneCollaborationInAMethodWithOnlyOneMessageSend
#test03ItCountsTwoCollaborationsThatArePlacedInDifferentsStatements
#test04ItCountsTwoCollaborationsThatArePlacedInTheSameMessageSend
#test05ItCountsThreeCollaborationsFromACascadeMessage
#test06ItCountsThreeCollaborationsInsideBlocks

La implementación

La clave es poder tener un objeto que recorra el código de nuestro método bajo análisis e identifique cada vez que se envíe un mensaje para sumar uno en un contador. Para ello, no vamos a recorrer el código fuente sino que vamos a recorrer su representación en Árbol de Sintaxis Abstracta (AST, por sus siglas en inglés) que es muchísimo más fácil de manipular. En Cuis, como en la mayoría de los sistemas Smalltalk, tenemos un objeto MethodNode que representa un método en su totalidad y sería la raíz de nuestro árbol de sintaxis. Luego vienen los diferentes elementos sintácticos como "hojas" de ese árbol (asignaciones, retornos, envíos de mensajes, variables, bloques...). Cada tipo de nodo se corresponde con una clase diferente, por ejemplo, MessageNode es el nodo que representa un envío de mensaje.
Dijimos que íbamos a recorrer nuestro código, pero, para ser estrictos, tampoco vamos a recorrer, sino que vamos interactuar con otro objeto que lo haga por nosotros (classic orientación a objetos 🤣). Esta estructura de MethodNode y sus nodos hijos necesita ser transitada por varias tareas con fines diversos. Esto es un buen caso de uso para el patrón Visitor, que justamente propone la idea de objeto "visitante" que sabe cómo ir recorriendo cada elemento de una estructura usando mensajes polimórficos para todos los diferentes tipos de elementos con los que se puede encontrar.
Es por eso que, entonces, en Cuis tenemos al ParseNodeVisitor, una clase abstracta que sabe cómo recorrer la estructura de parse nodes pero no hace nada. La idea es crear una subclase que haga algo en los pasos en los que nos podemos encontrar con envíos de mensajes. Por lo que nuestro CollaborationCounter redefine dos pasos del ParseNodeVisitor en los que aparecen envíos de mensajes:

CollaborationCounter >> #visitMessageNode: aMessageNode
    super visitMessageNode: aMessageNode.
    self countOneCollaboration

CollaborationCounter >> #visitMessageNodeInCascade: aMessageNode
    super visitMessageNodeInCascade: aMessageNode.
    self countOneCollaboration

Nótese el uso de super para continuar haciendo lo mismo que hace la superclase (ParseNodeVisitor) que sabe cómo continuar la visita del árbol de sintaxis. La implementación de #countOneCollaboration es trivial, suma uno a una variable de instancia inicializada en 0:

CollaborationCounter >> #countOneCollaboration
    numberOfCollaborations := numberOfCollaborations + 1

Luego, la implementación de #value necesita iniciar este recorrido de nuestro objeto visitante para finalizar retornando el resultado obtenido:

CollaborationCounter >> #value
    methodToAnalyze methodNode accept: self.
    ^ numberOfCollaborations

Dos cosas a tener en cuenta:

  • methodToAnalyze es el método compilado que estamos analizando. Si le enviamos el mensaje #methodNode, obtenemos como resultado el MethodNode, que representa nuestro árbol de sintaxis de nuestro método.
  • MethodNode sabe "aceptar visitas" a través del mensaje #accept:. Aquí es donde invocamos al visitor.

La demo!


Para que la herramienta sea más fácil de utilizar, extendí el panel de "annotation" del Browser de Cuis. De este modo para que, cada vez que visualizamos un método, podemos ver cuántas colaboraciones tiene. ¡Así se ve!

Todo el código y las instrucciones de instalación están acá: https://github.com/ngarbezza/Cuis-Smalltalk-Utilities#collaborations-counter.

En las próximas ediciones, veremos cómo resolver esto mismo en otros lenguajes de programación.