Script de clase Microcontroller

Enunciado

Microcontrolador

Casos de uso

¿Cuál es el objetivo del sistema?

  • Ejecutar un programa en memoria

En un primer momento el enunciado no distingue los momentos de carga en memoria y ejecución propiamente dicho, porque la interfaz que propone es el mensaje

public void run(List<Instruccion> program);

¿Objetos o bytes?

El microcontroller tiene una interfaz un tanto rara:

  • por un lado, nos permite ejecutar un conjunto de instrucciones que le pasamos como parámetro, escondiendo la implementación interna
  • por otra parte, expone en la misma interfaz que maneja datos y acumuladores como bytes

Internamente, las instrucciones no se pasan como string, sino que son códigos en bytes (y los parámetros se entremezclan con los códigos de instrucción, esto lo vamos a ver en la próxima clase).

Por otra parte el program counter en sí no tiene mucho sentido, ya que no necesitamos hacer ningún control de flujo (esto lo implementa el propio Microcontroller).

¿Qué objetos pueden aparecer?

  • Una implementación del microcontrolador
  • Y sobre todo, un objeto que modele una Instrucción, en lugar de pensar en un código int, vamos a querer modelar cada instrucción como un objeto que le hace cosas al microcontrolador. Después veremos si hay que generar subclases (porque el comportamiento es diferencial) o podemos manejarlo con valores diferentes en instancias de la clase Instrucción.

Test Driven Development

La aparición providencial de los casos de prueba nos permiten aplicar la metodología TDD con este ejercicio. Lo que se pide es avanzar 3 veces el program counter mediante tres instrucciones NOP.

  • ¿Qué tenemos que pedirle al sistema? Que ejecute tres instrucciones NOP
  • ¿Qué esperamos que pase? El program counter tiene que avanzar tres posiciones
    @Test
    public void programCounter() {
        List<Instruccion> instrucciones = new ArrayList<Instruccion>();
        instrucciones.add(new NOP());
        instrucciones.add(new NOP());
        instrucciones.add(new NOP());
        micro.run(instrucciones);
        Assert.assertEquals(3, micro.getPC());
    }

Y aquí vemos la utilidad de tener un getPC() como interfaz del microcontroller. Eso sí, muchas veces los tests nos llevan a publicar atributos que de otra manera no serían necesarios (para tener una forma de verificar el efecto colateral de la ejecución de las instrucciones).

Una cosa importante es que el test nos ayudó a entender que NOP hereda o implementa de Instrucción. ¿Qué tiene que hacer la Instrucción al ejecutarse? Avanzar el program counter (esto lo hacen todas las instrucciones) y ejecutar la instrucción propiamente dicha. O sea, un template method:

public abstract class Instruccion {

    public void execute(Microcontroller micro) {
        micro.advancePC();
        this.doExecute(micro);
    }

    public abstract void doExecute(Microcontroller micro);

}

public class NOP extends Instruccion {

    @Override
    public void doExecute(Microcontroller micro) {
        // No operation no hace nada por default
    }

}

Vemos cómo va quedando el diagrama:

Microcontroller.Instrucciones.PNG

¿Qué relación tienen el microcontroller y la instrucción?
El micro recibe como parámetro las instrucciones, pero no tiene un atributo List<Instruccion>, por lo tanto la relación es más débil (se llama relación de dependencia).
La implementación del microcontroller respecto al program counter es trivial:

    @Override
    public byte getPC() {
        return this.programCounter;
    }

    @Override
    public void advancePC() {
        this.programCounter++;
    }

    @Override
    public void reset() {
        this.programCounter = 0;
        this.acumuladorA = 0;
        this.acumuladorB = 0;
        this.datos = new byte[1024];
    }

Mostramos también cómo se implementa el run, que simplemente delega la ejecución a cada instrucción (pasándose como parámetro):
    @Override
    public void run(List<Instruccion> program) {
        this.reset();
        for (Instruccion instruccion : program) {
            instruccion.execute(this);
        }
    }

Sumar dos números

El primer test nos sirvió para

  • entender la dinámica de ejecución de un programa (que en una primera instancia se ejecuta directamente)
  • tener dos implementaciones posibles para las interfaces Instruccion y Microcontroller

Ahora vamos a trabajar con los acumuladores, que trabajan con bytes, tipos primitivos un tanto molestos.
Primero, codificamos el test:

    @Test
    public void sumaSimple() {
        List<Instruccion> instrucciones = new ArrayList<Instruccion>();
        instrucciones.add(new LODV(10));
        instrucciones.add(new SWAP());
        instrucciones.add(new LODV(22));
        instrucciones.add(new ADD());
        micro.run(instrucciones);
        Assert.assertEquals(0, micro.getAAcumulator());
        Assert.assertEquals(32, micro.getBAcumulator());
    }

Ahora tenemos que trabajar sobre tres instrucciones nuevas: LODV, SWAP y ADD.

Hablábamos del byte como un tipo molesto, ¿cómo nos convendrá instanciar un LODV, con un byte como parámetro del constructor o como un int?
Bueno, en la implementación vamos a trabajar con un byte (número de 8 bits que va del -128 al 127), pero podemos tener una interfaz más amigable, generando un constructor que reciba un int (con la posibilidad de sobrecargarlo).

public class LODV extends Instruccion {

    int value;

    public LODV(int value) {
        if (value > 127) {
            throw new IllegalArgumentException("No debe crear una instrucción LODV de un número mayor a 127");
        } else {
            this.value = value;
        }
    }

    @Override
    public void doExecute(Microcontroller micro) {
        micro.setAAcumulator((byte) value);
    }

}

Entonces vemos:
  • que el constructor puede tirar un IllegalArgumentException para un número mayor a 127
  • que la ejecución de la instrucción LODV carga en el acumulador A ese valor

Y aquí empezamos a ver que la Instrucción tiene cosas interesantes:

  • la puedo guardar en una colección, o bien pasarla como parámetro
  • construir una instrucción no significa ejecutarla (eso puede ocurrir en otro momento), por ejemplo puedo ejecutar muchas veces la misma instrucción, o decir "todas estas instrucciones ejecutalas… ahora", o eventualmente reprocesar acciones a partir de un punto. Si la instrucción no tiene estado, podemos utilizar el mismo objeto para hacer varias ejecuciones de la misma instrucción:
    @Test
    public void programCounter() {
        List<Instruccion> instrucciones = new ArrayList<Instruccion>();
        Instruccion nop = new NOP();
        instrucciones.add(nop);
        instrucciones.add(nop);
        instrucciones.add(nop);
        micro.run(instrucciones);
        Assert.assertEquals(3, micro.getPC());
    }
  • estamos modelando comportamiento como un objeto, eso suena raro porque objetos son atributos + comportamiento, pero es algo muy buscado por todos los programadores

Esto refleja el Command (2) pattern.
Sí, se parece al Strategy, pero los objetivos son diferentes:

  • Los strategies son algoritmos que puedo intercambiar, los commands son acciones que puedo diferir –pasándolos como parámetros a otros objetos- o ejecutar cuando yo quiera.
  • Los commands se pueden combinar y agrupar ¿Qué sucede respecto al cliente y los strategies en los ejemplos que hemos visto en la cursada?

Implementamos el SWAP y el ADD:

public class SWAP extends Instruccion {

    @Override
    public void doExecute(Microcontroller micro) {
        byte swapValue = micro.getBAcumulator();
        micro.setBAcumulator(micro.getAAcumulator());
        micro.setAAcumulator(swapValue);
    }

}

public class ADD extends Instruccion {

    @Override
    public void doExecute(Microcontroller micro) {
        int suma = micro.getAAcumulator() + micro.getBAcumulator();
        if (suma > Byte.MAX_VALUE) {
            micro.setBAcumulator(Byte.MAX_VALUE);
            micro.setAAcumulator((byte) (suma - Byte.MAX_VALUE));
        } else {
            micro.setBAcumulator((byte) suma);
            micro.setAAcumulator((byte) 0);
        }
    }

}

El lector puede profundizar con el proyecto de ejemplo la implementación de los otros tests y de DIV.

Undo! Undo!

Otra de las ventajas de la implementación de los commands es que podemos definir un método undo() para deshacer los efectos del execute().
Eso está pedido en el punto 2 de los agregados.
Para eso tenemos que guardar el estado del microcontroller justo antes de ejecutar la acción:

public abstract class Instruccion {

    private Microcontroller microBefore;

    public void execute(Microcontroller micro) {
        microBefore = (Microcontroller) micro.clone();
        micro.advancePC();
        this.doExecute(micro);
    }

    public abstract void doExecute(Microcontroller micro);

}

Aquí vemos que se guarda una copia del estado del microcontrolador haciendo que esta interfaz extienda de Cloneable:
public interface Microcontroller extends Cloneable {
    ...

Entonces se genera una copia "superficial" del estado: ¿por qué no guardar una referencia al objeto Microcontroller original?
Porque no queremos que haya efecto colateral al ejecutar la instrucción, entonces debemos guardar el estado en un objeto microcontroller aparte:
shallowCopy.PNG
Esto es lo que se conoce como shallow copy, se genera un nuevo objeto Microcontroller y se reutilizan las referencias, pero a partir de aquí los cambios que se hagan al microcontroller original no afectarán al microBefore referenciado.
Entonces el método undo es fácil de implementar:
    public void undo(Microcontroller micro) {
        micro.copyFrom(microBefore);
    }

Si en lugar de tener una copia del microcontrolador hubiéramos guardado el estado en una clase eso es un patrón que se llama Memento (2) (3), como la película. Pero en este caso tener una clase adicional plantea temas de redundancia que no queremos tener.

Sólo nos falta el test que prueba que nuestro approach es correcto:

    /**
     * BONUS 3 : requiere mayor manejo del micro
     * Se desea poder deshacer la última instrucción ejecutada 
     * (o sea, que el microprocesador vuelva al estado anterior). 
     * Ejemplo: si se hizo un SWAP, el acumulador A debe volver a tener lo que
     *  el acumulador B tenía y viceversa. En el caso del ADD se debe deshacer 
     *  la suma y los valores de los acumuladores deben quedar como estaban
     *  previamente. 
     **/
    @Test
    public void undo() {
        Instruccion carga100 = new LODV(100);
        Instruccion swap = new SWAP();
        carga100.execute(micro);
        swap.execute(micro);
        Assert.assertEquals(100, micro.getBAcumulator());
        Assert.assertEquals(0, micro.getAAcumulator());
        swap.undo(micro);
        Assert.assertEquals(0, micro.getBAcumulator());
        Assert.assertEquals(100, micro.getAAcumulator());
        swap.execute(micro);
        new LODV(50).execute(micro);
        Instruccion suma = new ADD();
        suma.execute(micro);
        Assert.assertEquals(23, micro.getAAcumulator());
        Assert.assertEquals(127, micro.getBAcumulator());
        suma.undo(micro);
        Assert.assertEquals(50, micro.getAAcumulator());
        Assert.assertEquals(100, micro.getBAcumulator());
    }

Instrucciones compuestas

Para el agregado 3, vamos a implementar el while not zero (WHNZ).
Esta vez pensemos qué test podemos hacer… ejecutar un for de 1 a 10. Esto es lo mismo que hacer un while de 10 a 1, cuando lleguemos a cero, se detiene la ejecución del programa. La instrucción WHNZ va a saber la cantidad de veces que evaluó las instrucciones, en realidad con la finalidad de poder construir un test válido: si la cantidad de veces fue 10 el test está ok:

    @Test
    public void for1a10() {
        List<Instruccion> instrucciones = new ArrayList<Instruccion>();
        // Cargo diez 
        instrucciones.add(new LODV(1));
        instrucciones.add(new SWAP());
        instrucciones.add(new LODV(10));

        List<Instruccion> subInstrucciones = new ArrayList<Instruccion>();
        subInstrucciones.add(new SUB());
        subInstrucciones.add(new LODV(1));
        subInstrucciones.add(new SWAP());
        WHNZ bloqueWhile = new WHNZ(subInstrucciones);
        instrucciones.add(bloqueWhile);
        micro.run(instrucciones);

        Assert.assertEquals(10, bloqueWhile.getVecesQueFueEjecutado());
    }

Dos cosas:
  • el WHNZ se construye con un conjunto de instrucciones
  • el enunciado pide que no se aniden las instrucciones, pero quizás podríamos diseñar la solución de manera que esto fuera posible (y restringirlo por código de negocio)

Nos quedan

  • instrucciones simples
  • e instrucciones compuestas

ambos objetos son polimórficos, entonces tenemos un Composite de Commands:

Microcontroller.WHNZ.PNG

Vemos la implementación:

public abstract class InstruccionMultiple extends Instruccion {

    private List<Instruccion> instrucciones;

    @Override
    public void doExecute(Microcontroller micro) {
        for (Instruccion instruccion : this.instrucciones) {
            instruccion.execute(micro);
        }
    }

    public InstruccionMultiple(List<Instruccion> instrucciones) {
        this.instrucciones = instrucciones;
    }

}

public class WHNZ extends InstruccionMultiple {

    private int vecesQueFueEjecutado;

    public WHNZ(List<Instruccion> instrucciones) {
        super(instrucciones);
    }

    public void doExecute(Microcontroller micro) {
        vecesQueFueEjecutado = 0;
        while (micro.getAAcumulator() != 0) {
            vecesQueFueEjecutado++;
            super.doExecute(micro);
        }
    }

    /**
     * Solo para test
     * @return
     */
    public int getVecesQueFueEjecutado() {
        return vecesQueFueEjecutado;
    }

}
  • La InstruccionMultiple provee un comportamiento común para todos los casos (manda a ejecutar todas las instrucciones asociadas)
  • En lugar de implementar un template method, WHNZ redefine el doExecute() para terminar delegando en la superclase la ejecución de las instrucciones. Esto se hace porque no es fácil armar un template method que incluya tanto un while como un if, parado en la superclase, donde no sabemos (ni queremos saber) qué futuras subclases nos esperan
  • Lo que agrega es el while propiamente dicho y la actualización de las veces que fue ejecutado, algo que ensucia un poco la implementación a fines de simplificar el test

Paso a paso

Por último dejamos una modificación que implica también cambiar la interfaz del Microcontroller, y es algo que está bueno mencionar: a veces la interfaz es incómoda o no nos sirve… entonces tenemos que cambiarla:

  • agregamos métodos load, start, execute y stop
  • eso nos facilita la prueba del undo, que antes tuvimos que hacerla a nivel Instruccion
  • y trabajar de una u otra manera no deja de ser invocar al run() anterior o bien trabajarla paso por paso

El lector puede implementar este último agregado.

Unless otherwise stated, the content of this page is licensed under Creative Commons Attribution-ShareAlike 3.0 License