Acompaña el evento final de la Maratón Behind the Code 2020: 05/12 - Online ¡Inscríbete ahora!

Interfaces funcionales

¿Cuál es el tipo de una expresión lambda? Algunos lenguajes utilizan valores de funciones o objetos de funciones para representar expresiones lambda, aunque no el popular Java™. En vez de eso, Java utiliza interfaces funcionales para representar tipos de expresiones de lambda. Puede parecer extraño al principio, pero, de hecho, es una forma eficiente de asegurar la compatibilidad con versiones anteriores de Java.

La siguiente porción de código debería ser familiar:

Thread thread = new Thread(new Runnable() {
  public void run() {
    System.out.println("En otra hebra");
  }
});

thread.start();
System.out.println("En la principal");

La Thread y su constructor se introdujeron en Java 1.0, hace más de 20 años. Desde entonces, el constructor no ha cambiado. Lo tradicional es pasar al constructor una instancia anónima de Runnable. Pero, en cambio, desde Java 8 tiene la opción de pasar la expresión lambda:

Thread thread = new Thread(() -> System.out.println("En otra hebra"));

Acerca de esta serie Java 8 es la actualización más significativa para el lenguaje Java desde su creación; está tan llena de funciones nuevas que usted se puede preguntar por dónde comenzar. En esta serie el autor y educador Venkat Subramaniam ofrece un enfoque idiomático a Java 8: exploraciones cortas que le invitan a repensar las convenciones de Java que usted puede dar por hecho, mientras que integra gradualmente técnicas y sintaxis nuevas en sus programas.

El constructor de la clase Thread está esperando una instancia que implementa Runnable. En este caso, en vez de pasar un objeto, pasamos una expresión lambda. Tenemos la opción de pasar la expresión lambda con diferentes métodos y constructores, incluso algunos creados antes de Java 8. Funciona porque en Java las expresiones lambda se representan como interfaces funcionales.

Hay tres reglas importantes para las interfaces funcionales:

  1. La interfaz funcional solamente tiene un método abstracto.
  2. Cualquier método abstracto que también sea un método público en la clase Object no se cuenta como ese método.
  3. La interfaz funcional puede tener métodos predeterminados y métodos estáticos.

Cualquier interfaz que satisfaga la regla del método abstracto único se considerará automáticamente una interfaz funcional. Esto incluye interfaces tradicionales como: Runnable y Callable, e interfaces personalizadas que usted mismo construya.

Interfaces funcionales incorporadas

Además de las interfaces de método abstracto único ya mencionadas, JDK 8 incluye varias interfaces funcionales nuevas. Las más comunes son Function<T, R>, Predicate<T> y Consumer<T>, que están definidos en el paquete java.util.function. El método map de Stream toma Function<T, R> como parámetro. De igual manera, filter utiliza Predicate<T> y forEach utiliza Consumer<T>. El paquete también tiene otras interfaces funcionales, como Supplier<T>, BiConsumer<T, U> y BiFunction<T, U, R>.

Es posible utilizar una interfaz funcional incorporada como parámetro para nuestros propios métodos. Por ejemplo, supongamos que tenemos la clase Device con métodos como checkout y checkin para indicar si el dispositivo está en uso. Cuando un usuario solicita un dispositivo nuevo, el método getFromAvailable devuelve uno de un grupo de dispositivos disponibles o crea uno nuevo, en caso necesario.

Podemos implementar una función para pedir prestado un dispositivo, de esta manera:

public void borrowDevice(Consumer<Device> use) {
  Device device = getFromAvailable();

  device.checkout();

  try {
    use.accept(device);
  } finally {
    device.checkin();
  }
}

La borrowDevice:

  • Toma Consumer<Device> como parámetro.
  • Obtiene un dispositivo del grupo (para este ejemplo no estamos preocupados con la seguridad de las hebras).
  • Llama al método checkout para establecer el estado del dispositivo prestado.
  • Proporciona el dispositivo al cliente.

Cuando un dispositivo se devuelve desde la llamada al método accept del consumidor, se cambia el estado a devuelto llamando al método checkin.

Ésta es una forma de utilizar el método borrowDevice:

new Sample().borrowDevice(device -> System.out.println("utilizando " + device));

Debido a que el método recibe una interfaz funcional como un parámetro suyo, es aceptable pasar una expresión lambda como argumento.

Interfaces funcionales personalizadas

Aunque siempre que sea posible es mejor utilizar una interfaz funcional incorporada, a veces necesitará una interfaz funcional personalizada.

Para crear su propia interfaz funcional tiene que hacer dos cosas:

  1. Anotar la interfaz con @FunctionalInterface, que es la convención de Java 8 para interfaces funcionales personalizadas.
  2. Asegúrese de que la interfaz solamente tiene un método abstracto.

La convención deja claro que el propósito de la interfaz es recibir expresiones de lambda. Cuando el compilador vea la anotación, verificará que la interfaz tenga un método abstracto solamente.

Utilizar la anotación @FunctionalInterface asegura que si usted viola accidentalmente la regla de abstract-method-count durante un próximo cambio a la interfaz, recibirá un mensaje de error. Esto es útil porque usted detectará el problema inmediatamente, en vez de dejarlo para que otro desarrollador se encargue de él más tarde. Nadie quiere obtener un mensaje de error cuando pasa una expresión de lambda a la interfaz personalizada de otro.

Crear una interfaz funcional personalizada

Como ejemplo, vamos a crear una clase Order que tenga una lista de OrderItem y un método para transformarlos e imprimirlos. Iniciaremos con una interfaz.

El código siguiente crea una interfaz funcional Transformer.

@FunctionalInterface public interface Transformer<T> {
  T transform(T input); }

La interfaz está etiquetada con la anotación @FunctionalInterface, lo que transmite que se trata de una interfaz funcional. Ya que esa anotación forma parte del paquete java.lang, no se necesitan importaciones. La interfaz tiene un método llamado transform que toma un objeto de tipo parametrizado T y devuelve un objeto transformado del mismo tipo. La semántica de la transformación se decidirá por la implementación de la interfaz.

Aquí está la clase OrderItem:

public class OrderItem {
  private final int id;
  private final int price;

  public OrderItem(int theId, int thePrice) {
    id = theId;
    price = thePrice;
  }

  public int getId() { return id; }
  public int getPrice() { return price; }

  public String toString() { return String.format("id: %d price: %d", id, price); }
}

OrderItem es una clase simple que tiene dos propiedades: id y price, y un método toString.

Ahora, eche un vistazo a la clase Order puede considerar utilizar su propio paquete de Java.

import java.util.*; import java.util.stream.Stream;
public class Order {
  List<OrderItem> items;

  public Order(List<OrderItem> orderItems) {
    items = orderItems;
  }

  public void transformAndPrint(
    Transformer<Stream<OrderItem>> transformOrderItems) {

    transformOrderItems.transform(items.stream())
      .forEach(System.out::println);
  }
}

La transformAndPrint toma Transform<Stream<OrderItem> como parámetro, invoca el método transform para transformar los elementos del pedido que pertenecen a la instancia Order e imprime los elementos del pedido en la secuencia transformada.

Éste es un ejemplo que utiliza este método:

import java.util.*; import static java.util.Comparator.comparing; import java.util.stream.Stream; import
java.util.function.*;
class Sample {
  public static void main(String[] args) {
    Order order = new Order(Arrays.asList(
      new OrderItem(1, 1225),
      new OrderItem(2, 983),
      new OrderItem(3, 1554)
    ));

    order.transformAndPrint(new Transformer<Stream<OrderItem>>() {
      public Stream<OrderItem> transform(Stream<OrderItem> orderItems) {
        return orderItems.sorted(comparing(OrderItem::getPrice));
      }
    });
  }
}

Pasamos una clase interna anónima como argumento para el método transformAndPrint. Dentro del método transform, invocamos el método sorted del flujo dado, lo que ordenara los elementos del pedido. Éste es el resultado de nuestro código, que muestra los elementos del pedido ordenados en orden ascendente del precio:

id: 2 price: 983 id: 1 price: 1225 id: 3 price: 1554

El poder de las expresiones de lambda

En todos los lugares en los que se espera una interfaz funcional tenemos tres opciones:

  1. Pasar una clase interna anónima.
  2. Pasar una expresión de lambda.
  3. Pasar una referencia a un método en vez de una expresión de lambda, en algunos casos.

Pasar una clase interna anónima es redundante, y solo podemos pasar la referencia al método como una alternativa a una expresión de lambda de paso. Considere lo que ocurre si escribimos nuestra llamada para la función transformAndPrint para utilizar una expresión de lambda en vez de una clase interna anónima:

order.transformAndPrint(orderItems -> orderItems.sorted(comparing(OrderItem::getPrice)));

Eso es mucho más conciso y fácil de leer que la clase interna anónima con la que comenzamos.

Interfaces funcionales personalizadas o incorporadas

Nuestra interfaz funcional personalizada ilustra las ventajas y desventajas de crear interfaces personalizadas. Considere primero las ventajas:

  • Puede proporcionar a su interfaz personalizada un nombre descriptivo que ayude a que otros desarrolladores la modifiquen o la reutilicen. Nombres como Transformer, Validator y ApplicationEvaluator son específicos al dominio y pueden ayudar al que esté leyendo los métodos de la interfaz a deducir lo que se espera como argumento.
  • Usted puede dar al método abstracto cualquier nombre válido que quiera. Esto solo es beneficioso para el receptor de la interfaz y solo en los casos en los que se está pasando un método abstracto. Un llamador que pase expresiones de lambda o referencias a métodos no recibirá este beneficio.
  • Es posible utilizar tipos parametrizados en la interfaz o mantenerlo simple y específico para algunos tipos. En este caso, usted escribiría la interfaz Transformer para utilizar OrderItems en vez del tipo parametrizado T.
  • Es posible escribir métodos predeterminados personalizados y métodos estáticos, los cuales se pueden utilizar por otras implementaciones de la interfaz.

Por supuesto, también hay desventajas en la utilización de interfaces funcionales personalizadas:

  • Imagine crear varias interfaces, todas con métodos abstractos con la misma firma, como tomar String como parámetro y devolver Integer. Mientras que los nombres de los métodos pueden ser diferentes, son principalmente redundantes y se podían reemplazar por una interfaz con un nombre genérico.
  • Quien quiera utilizar interfaces personalizadas debe realizar un esfuerzo extra para aprender, entender y recordarla. Todos los programadores de Java están familiarizados con Runnable en el recuadro del texto java.lang. Lo hemos visto continuamente, así que no hay que esforzarse para recordar su propósito. Sin embargo, si se utiliza un Executor personalizado, antes de usarlo habrá que aprender cuidadosamente acerca de la interfaz. Ese esfuerzo vale la pena en algunos casos, pero se desperdicia si Executor es demasiado similar a Runnable.

¿Cuál es mejor?

Sabiendo los pros y los contras de las interfaces funcionales personalizadas y de las incorporadas, ¿cuál utilizaría usted? Vamos a volver a visitar la interfaz Transformer para descubrirlo.

Rellamar a ese Transformer existe para expresar la semántica de transformar un objeto en otro. Aquí, nos referimos a él por su nombre:

public void transformAndPrint(Transformer<Stream<OrderItem>> transformOrderItems) {

El método transformAndPrint recibe un argumento que está a cargo de la transformación. La transformación puede volver a secuenciar elementos de la colección OrderItems. Alternativamente, puede enmascarar algunos detalles entre cada elemento del pedido. También la transformación podía decidir no hacer nada y simplemente volver a la colección original. La implementación se deja al llamador.

Lo que es esencial es que el llamador conozca que puede brindar una implementación de una transformación como argumento para el método transformAndPrint. El nombre de la interfaz funcional y su documentación deberían brindar esos detalles. En este caso, también está claro a partir del nombre del parámetro (transformOrderItems) y se debería incluir con la documentación de la función transformAndPrint. Aunque el nombre de la interfaz funcional es útil, no es la única ventana con ese propósito y uso.

Al mirar más de cerca a la interfaz de Transformer y comparar su propósito con las interfaces funcionales incorporadas de JDK vemos que Function<T, R> podría reemplazar Transformer. Para probarlo, eliminamos de nuestro código la interfaz funcional de Transformer y cambiamos la función transformAndPrint, de esta manera:

public void transformAndPrint(Function<Stream<OrderItem>, Stream<OrderItem>>
transformOrderItems) {
  transformOrderItems.apply(items.stream())
    .forEach(System.out::println); }

El cambio es mínimo; además de cambiar Transformer<Stream<OrderItem>> como Function<Stream<OrderItem>>, atream<OrderItem>>, cambiamos el método de llamada de transform() como apply().

En el caso de que la llamada a transformAndPrint utilizase una clase interna anónima también habríamos cambiado eso. Sin embargo, ya hemos cambiado la llamada para utilizar una expresión lambda:

order.transformAndPrint(orderItems -> orderItems.sorted(comparing(OrderItem::getPrice)));

El nombre de la interfaz funcional es irrelevante para la expresión de lambda, solo sería relevante para el compilador, que ata el argumento de la expresión de lambda al parámetro del método. Utilizar el nombre del método transform o apply es igual de irrelevante para el que llama.

Utilizar una interfaz opcional incorporada nos ha dejado con una interfaz de menos, y la llamada al método funciona de igual manera. Tampoco hemos comprometido la legibilidad del código. Este ejercicio nos dice que podemos reemplazar fácilmente nuestra interfaz funcional personalizada por una incorporada. Solo necesitaríamos brindar la documentación para transformAndPrint (no se muestra) y dar un nombre más descriptivo al argumento.

Conclusión

La decisión de diseño para hacer la expresión de lambda un tipo de interfaz funcional facilita la compatibilidad con versiones anteriores entre Java 8 y versiones anteriores de Java. Es posible pasar una expresión de lambda para cualquier otra función que normalmente recibiría una única interfaz de método abstracto. Para recibir expresiones de lambda, el tipo de parámetro del método debe ser una interfaz funcional.

En algunos casos tiene sentido crear su propia interfaz funcional, pero debería hacerlo con cautela. Considere utilizar una interfaz funcional personalizada solo si su aplicación requiere métodos altamente especializados o si ninguna interfaz existente satisface sus necesidades. Siempre verifique si la funcionalidad existe en una de las interfaces funcionales incorporadas de JDK. Utilice interfaces funcionales incorporadas siempre que pueda.

Avisos

El contenido aquí presentado fue traducido de la página IBM Developer US. Puede revisar el contenido original en este link