Interfaces funcionales

1.1. Interfaces funcionales#

Java no es un lenguaje de programación en que las funciones sean ciudadanas de primera clase, lo cual es indispensable para que un lenguaje pueda ser funcional. Para sortear esta deficiencia (o característica, según se quiera ver), Java introdujo las interfaces funcionales y las expresiones lambda.

La idea que subyace es la de crear un objeto que represente la función que querríamos tratar como ciudadana de primera clase. Como en Java los objetos sí lo son, entonces no tendremos problemas para asignarles un nombre o pasarlos como argumento de un método.

1.1.1. Implementación#

Para ilustrarla supongamos que queremos implementar una partida con varios jugadores cada uno de los cuales puede obtener distintos puntos. Como no queremos complicar el código supongamos que la mecánica de jugar consiste, simplemente, en indicarle a la partida cuántos puntos ha sacado cada uno:

public class Partida {

    private String[] jugador;
    private int[] puntuacion;

    Partida(String[] nombres) {
        jugador = nombres;
        puntuacion = new int[nombres.length];
    }

    /**
    * Juega una ronda que consiste, simplemente, en  sumar los puntos
    * que se proporcionan. Ni siquiera hacemos control de errores
    *
    * @param puntos - Los puntos que ha obtenido cada jugador en esa ronda.
    *
    * @return false si los puntos eran inválidos y no se pudieron sumar.
    */
    public boolean jugar(int[] puntos) {
        if(puntos.length != puntuacion.length) return false;
        for(int i=0; i<puntos.length; i++) puntuacion[i] += puntos[i];
        return true;
    }

    // Faltan los getters, pero no queremos perder más el tiempo.
}

Supongamos ahora que queremos comprobar qué jugador ha ganado. Es sencillo, pero, dependiendo de cuál sea el juego, el criterio para escoger el ganador es distinto. En algunos juegos gana el que tiene más puntos, mientras que en otros el que tiene menos. Incluso el criterio podría ser mucho más extravagante. Consecuentemente, decidimos definir fuera de esta implementación el algoritmo para calcular el ganador[1] y pasárselo al constructor al crear la partida, para lo cual debemos definir la interfaz funcional adecuada:

// Victoria.java
@FunctionalInterface
interface Victoria {
    int comprobar(int[] puntos);
}

que vemos que tiene un método que recibe las puntuaciones y devuelve cuál de ellas es la ganadora. @FunctionalInterface es opcional: simplemente se encarga de comprobar que la interfaz no tiene más de un método. Reescribamos ahora la partida:

// Partida.java
public class Partida {
    
    private String[] jugador;
    private int[] puntuacion;
    private Victoria condicion;

    Partida(String[] nombres, Victoria condicion) {
        jugador = nombres;
        puntuacion = new int[nombres.length];
        this.condicion = condicion;
    }

    public boolean jugar(int[] puntos) {
        if(puntos.length != puntuacion.length) return false;
        for(int i=0; i<puntos.length; i++) puntuacion[i] += puntos[i];
        return true;
    }

    public String vencedor() {
        int vencedor = condicion.comprobar(puntuacion);
        return jugador[vencedor];
    }
}

Como vemos, al crear la partida proporcionamos la condición que servirá para decidir el ganador y en el método vencedor la utilizamos ejecutando su método comprobar.

Queda por último ilustrar cómo usarlo:

// App.java
public class App {
    public static void main(String[] args) throws Exception {
        Victoria ganaElMayor = new Victoria() {
            public int comprobar(int[] num) {
                int max = -1;
                int idx = 0, i;

                for(i=0; i<num.length; i++) {
                    if(num[i] > max) {
                        max = num[i];
                        idx = i;
                    }
                }
                return idx;
            }
        };

        String[] jugadores = {"Juan", "Alberto", "Pablo"};
        Partida partida = new Partida(jugadores, ganaElMayor);
        partida.jugar(new int[] {100, 20, 35});
        partida.jugar(new int[] {0, 90, 45});
        System.out.printf("El vencedor es %s.\n", partida.vencedor());
    }

}

Simplemente hemos implementado de forma anónima la interfaz definiendo cómo es el método comprobar. En este caso, hemos escogido la puntuación mayor de todas y para simplificar el ejemplo no nos hemos preocupado de los empates que podrían producirse.

Este estrategia no es la más corta, pero ilustra más claramente cuál es la estrategia de Java para solventar el problema de que en Java no existan funciones que sean ciudadanas de primera clase.

Una alternativa es definir un método estático y usarlo directamente, ya que Java sobreentenderá que este método es el método comprobar de la interfaz:

// App.java
public class App {
    public static void main(String[] args) throws Exception {
        Victoria ganaElMenor = App::ganaElMenor;

        String[] jugadores = {"Juan", "Alberto", "Pablo"};
        Partida partida = new Partida(jugadores, ganaElMenor);
        partida.jugar(new int[] {100, 20, 35});
        partida.jugar(new int[] {0, 90, 45});
        System.out.printf("El vencedor es %s.\n", partida.vencedor());
    }

    private static int ganaElMenor(int[] num) {
        int min = num[0];
        int idx = 0, i;

        for(i=1; i<num.length; i++) {
            if(num[i] < min) {
                min = num[i];
                idx = i;
            }
        }
        return idx;
    }

}

La tercera y última alternativa es usar expresiones lambda:

// App.java
public class App {
    public static void main(String[] args) throws Exception {
        Victoria ganaElMenor = puntos -> {
            int min = puntos[0];
            int idx = 0, i;

            for(i=1; i<puntos.lpuntos; i++) {
                if(puntos[i] < min) {
                    min = puntos[i];
                    idx = i;
                }
            }
            return idx;
        };

        String[] jugadores = {"Juan", "Alberto", "Pablo"};
        Partida partida = new Partida(jugadores, ganaElMenor);
        partida.jugar(new int[] {100, 20, 35});
        partida.jugar(new int[] {0, 90, 45});
        System.out.printf("El vencedor es %s.\n", partida.vencedor());
    }
}

en donde podemos observar que:

  • No es necesario indicar el tipo del argumento num porque ya se conoce gracias a la definición de la interfaz.

  • No es necesario indicar la lista de argumentos entre paréntesis, porque sólo hay uno. En caso contraría si deberían haberse escritos ((arg1, arg2)).

  • El cuerpo de la función se escribe entre llaves, como se hace habitualmente.

  • Como la implementación de la función es algo larga, no ganamos en claridad y, de hecho, queda algo más limpia la segunda alternativa.

Respecto a esto último, imaginemos, sin embargo, que nuestro criterio es que el ganador es siempre el último con independencia de las puntuaciones que saquen. Entonces la implementación del criterio se puede hacer en una sola línea y queda así:

// App.java
public class App {
    public static void main(String[] args) throws Exception {
        String[] jugadores = {"Juan", "Alberto", "Pablo"};
        Partida partida = new Partida(jugadores, puntos -> puntos.length - 1);
        partida.jugar(new int[] {100, 20, 35});
        partida.jugar(new int[] {0, 90, 45});
        System.out.printf("El vencedor es %s.\n", partida.vencedor());
    }
}

en donde:

  • Ni siquiera hemos definido (aunque podríamos haberlo hecho) una variable aparte:

    Victoria ganaElUltimo = puntos -> puntos.length - 1;
    

    para hacer más compacto el código.

  • Cuando la implementación es en una sola línea, puede prescindirse de las llaves y la sentencia return.

1.1.2. Interfaces predefinidas#

Aunque podemos definir interfaces funcionales arbitrarias, tal como hemos ilustrado arriba, Java dispone de algunas interfaces funcionales genéricas ya definidas que podemos usar:

Function<T, R>

Permte la definición de funciones que toman un argumento de entrada (el genérico T) y devuelven un valor (el genético R). Para la ejecución se requiere aplicar apply. Justamente nuestra interfaz funcional era de este tipo así que podríamos haber usado la predefinida en vez de definir una nosotros. La única dificultad es que es una interfaz genérica:

//App.java
import java.util.function.Function;

public class App {
    public static void main(String[] args) throws Exception {
        Function<int[], Integer> ganaElMenor = puntos -> {
            int min = puntos[0];
            int idx = 0, i;

            for(i=1; i<puntos.length; i++) {
                if(puntos[i] < min) {
                    min = puntos[i];
                    idx = i;
                }
            }
            return idx;
        };

        String[] jugadores = {"Juan", "Alberto", "Pablo"};
        Partida partida = new Partida(jugadores, ganaElMenor);
        partida.jugar(new int[] {100, 20, 35});
        partida.jugar(new int[] {0, 90, 45});
        System.out.printf("El vencedor es %s.\n", partida.vencedor());
    }
}

La implementación de la partida queda así:

//Partida.java
import java.util.function.Function;

public class Partida {
    
    private String[] jugador;
    private int[] puntuacion;
    private Function<int[], Integer> condicion;

    Partida(String[] nombres, Function<int[], Integer> condicion) {
        jugador = nombres;
        puntuacion = new int[nombres.length];
        this.condicion = condicion;
    }

    public boolean jugar(int[] puntos) {
        if(puntos.length != puntuacion.length) return false;
        for(int i=0; i<puntos.length; i++) puntuacion[i] += puntos[i];
        return true;
    }

    public String vencedor() {
        int vencedor = condicion.apply(puntuacion);
        return jugador[vencedor];
    }
}
UnaryOperator<T>

Como el anterior, pero tanto el argumento como el valor devuelto deben ser del tipo T.

BiFunction<T, U, R>

La única diferencia con el primero es que toma dos argumentos como entrada (los genéricos T y U).

BinaryOperator<T>

Como el anterior, pero los tres tipos deben iguales (el genérico T).

Predicate<T>

Toma un argumento de entrada (el genérico T) y devuelve un valor booleano. Para aplicarla debe ejecutarse el método test (no apply).

BiPredicate<T, U>

Como el anterior, pero toma dos argumento de entrada.

Consumer<T>

Toma un argumento de entrada (el genérico T) y no devuelve ningún valor. Para aplicarla debe ejecutarse el método accept.

BiConsumer<T, U>

Toma dos argumentos de entrada (los genéricos T y U) y no devuelve ningún valor.

Supplier<T>

No toma ningún argumento de entrada y devuelve un valor de tipo T. Para aplicarla debe ejecutarse el método get.

Ver también

Para profundizar en todas estas interfaces puede echarle un ojo al artículo Function Interface in Java with examples

Notas al pie