Programación funcional en Java, Parte 2: reutilización de Lambda, alcance léxico y cierres, y reduce ()

Las lambdas crean un código más expresivo y conciso con menos mutabilidad y menos errores. En el primer artículo de esta serie de dos partes, demostré cómo las expresiones lambda aprovechan el poder del estilo funcional de programación en Java. En esta parte final, exploro esto más a fondo y considero una advertencia de precaución. (Le sugiero que lea Programación funcional en Java, Parte 1: Listas, lambdas y referencias de métodos , si aún no lo ha hecho).

Como verá, las expresiones lambda son engañosamente concisas y es fácil duplicarlas descuidadamente en el código. El código duplicado conduce a un código de mala calidad que es difícil de mantener; si necesita hacer un cambio, tendrá que buscar y tocar el código relevante en varios lugares.

Evitar la duplicación también puede ayudar a mejorar el rendimiento. Al mantener el código relacionado con un conocimiento concentrado en un solo lugar, puede estudiar fácilmente su perfil de rendimiento y realizar cambios en un solo lugar para obtener un mejor rendimiento.

Reutilizar expresiones lambda

Demostraré lo fácil que es caer en la trampa de la duplicación al usar expresiones lambda y sugeriré formas de evitar esa trampa. Suponga que tiene un par de colecciones de nombres: 

friends
editors
, y 
comrades
.


final List<String> friends =
   Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");
final List<String> editors =
   Arrays.asList("Brian", "Jackie", "John", "Mike");
final List<String> comrades =
   Arrays.asList("Kate", "Ken", "Nick", "Paula", "Zach");

El objetivo es filtrar los nombres que comienzan con una letra determinada. Primero, aquí hay un enfoque ingenuo para esta tarea utilizando el 

filter()
método.


final long countFriendsStartN =
   friends.stream()
      .filter(name -> name.startsWith("N"))
      .count();
final long countEditorsStartN =
   editors.stream()
      .filter(name -> name.startsWith("N"))
      .count();
final long countFriendsStartN =
   friends.stream()
      .filter(name -> name.startsWith("N"))
      .count();

Las expresiones lambda hicieron que el código fuera conciso, pero silenciosamente condujeron a un código duplicado. En el ejemplo anterior, un cambio en la expresión lambda requiere cambios en más de un lugar, eso es un no-no. Afortunadamente, puede asignar expresiones lambda a variables y reutilizarlas, como lo haría con los objetos.

El 

filter()
método, el receptor de la expresión lambda en el ejemplo anterior, toma una referencia a una 
java.util.function.Predicate
interfaz funcional. En este caso, el compilador de Java funciona su magia para sintetizar una implementación de la 
Predicate
‘s 
test()
método de la expresión lambda dado. En lugar de pedirle a Java que sintetice el método en la ubicación de la definición del argumento, puede ser más explícito. En este ejemplo, es posible almacenar la expresión lambda en una referencia explícita de tipo 
Predicate
y luego pasarla a la función. Esta es una forma sencilla de eliminar la duplicación.

A continuación, se explica cómo refactorizar el código anterior para que se adhiera a las mejores prácticas DRY (Don’t Repeat Yourself).


final Predicate<String> startsWithN =
   name -> name.startsWith("N");
final long countFriendsStartN =
   friends.stream()
      .filter(startsWithN)
      .count();
final long countEditorsStartN =
   editors.stream()
      .filter(startsWithN)
      .count();
final long countComradesStartN =
   comrades.stream()
      .filter(startsWithN)
      .count();

En lugar de duplicar la expresión lambda varias veces, la creó una vez y la almacenó en una referencia con el nombre 

startsWithN
de tipo 
Predicate
. En las tres llamadas al 
filter()
método, el compilador de Java tomó felizmente la expresión lambda almacenada en la variable bajo la apariencia de la 
Predicate
instancia.

La nueva variable eliminó suavemente la duplicación que se coló. Desafortunadamente, la duplicación está a punto de volver a colarse con fuerza, como verá a continuación, y necesita algo un poco más poderoso para frustrarla.

Uso de cerramientos y alcance léxico

Algunos desarrolladores creen que el uso de expresiones lambda podría generar duplicación y reducir la calidad del código. Contrariamente a esa creencia, incluso cuando el código se vuelve más complicado, no es necesario comprometer la calidad del código para disfrutar de la concisión proporcionada por las expresiones lambda.

Consiguió reutilizar la expresión lambda en el ejemplo anterior; sin embargo, la duplicación se infiltrará rápidamente cuando traiga una segunda carta para que coincida. Exploremos el problema más a fondo y luego lo resolveremos utilizando límites y cierres léxicos.

Duplicación en expresiones lambda. Escojamos los nombres que comienzan con N o B de la 

friends
colección de nombres. Continuando con el ejemplo anterior, es posible que tenga la tentación de escribir algo como lo siguiente:


final Predicate<String> startsWithN =
   name -> name.startsWith("N");
final Predicate<String> startsWithB =
   name -> name.startsWith("B");
final long countFriendsStartN =
   friends.stream()
      .filter(startsWithN)
      .count();
final long countFriendsStartB =
   friends.stream()
      .filter(startsWithB)
.count();

Las primeras pruebas de predicado si el nombre comienza con una N y las segundas pruebas para un B . Pasas estas dos instancias a las dos llamadas al 

filter()
método, respectivamente. Eso parece razonable, pero los dos predicados son duplicados, y solo la letra que usan es diferente. El objetivo es eliminar esta duplicación.

Eliminando la duplicación usando el alcance léxico. Como primera opción, puede extraer la letra como parámetro de una función y pasar la función como argumento al 

filter()
método. Esa es una idea razonable, pero 
filter()
no aceptará ninguna función arbitraria; insiste en recibir una función que acepta un parámetro que representa el elemento de contexto en la colección y devuelve un 
Boolean
resultado. Está esperando un 
Predicate
.

Para fines de comparación, necesita una variable que almacene la letra en caché para su uso posterior y la mantenga hasta que 

name
se reciba el parámetro , en este ejemplo. Aquí se explica cómo crear una función para eso.


public static Predicate<String>
   checkIfStartsWith(final String letter) {
   return name -> name.startsWith(letter);
}

Esta definida 

checkIfStartsWith()
como una 
static
función que toma un 
letter
tipo de 
String
como parámetro. Luego devuelve un 
Predicate
que se puede pasar al 
filter()
método para una evaluación posterior; 
checkIfStartsWith()
devuelve una función como resultado.

El 

Predicate
que 
checkIfStartsWith()
devolvió es diferente de las expresiones lambda que ha visto hasta ahora. En 
return name -> name.startsWith(letter)
, está claro qué 
name
es: es el parámetro que se pasa a esta expresión lambda.

Pero, ¿a qué está 

letter
vinculada la variable ? Debido a que eso no está dentro del alcance de esta función anónima, Java llega al alcance de la definición de esta expresión lambda y encuentra la variable 
letter
en ese alcance. A esto se le llama alcance léxico . El alcance léxico es una técnica poderosa que le permite almacenar en caché los valores proporcionados en un contexto para usarlos más tarde en otro contexto. Dado que esta expresión lambda se cierra sobre el alcance de su definición, también se denomina cierre .

Vale la pena señalar aquí que existen algunas restricciones para el alcance léxico. Por un lado, desde dentro de una expresión lambda, puede acceder solo a las variables locales que están 

final
o están efectivamente 
final
en el ámbito adjunto. Una expresión lambda puede invocarse de inmediato, o puede invocarse de forma perezosa o desde varios subprocesos.

Para evitar condiciones de carrera, las variables locales a las que accede en el ámbito adjunto no pueden cambiar una vez que se inicializan. Cualquier intento de cambiarlos resultará en un error de compilación. Las variables marcadas 

final
directamente se ajustan a este proyecto de ley, pero Java no insiste en que las marque como tales. En cambio, Java busca las siguientes dos cosas:

  • Las variables a las que se accede deben inicializarse dentro de los métodos adjuntos antes de que se defina la expresión lambda.
  • Los valores de estas variables no cambian en ningún otro lugar, es decir, están efectivamente 
    final
    incluso si no están marcados como tales.

Cuando usa expresiones lambda que capturan el estado local, también debe tener en cuenta que las expresiones lambda sin estado son constantes en tiempo de ejecución, pero aquellas que capturan el estado local tienen un costo de evaluación adicional.

Con estas restricciones en mente, veamos cómo usar la expresión lambda devuelta por 

checkIfStartsWith()
en las llamadas al 
filter()
método.


final long countFriendsStartN =
   friends.stream()
      .filter(checkIfStartsWith("N"))
      .count();
final long countFriendsStartB =
   friends.stream()
      .filter(checkIfStartsWith("B"))
      .count();

En las llamadas al 

filter()
método, primero invoca el 
checkIfStartsWith()
método, pasando la letra deseada. Esta llamada devuelve inmediatamente una expresión lambda que luego se pasa al 
filter()
método.

Al crear una función de orden superior 

checkIfStartsWith()
, en este ejemplo, y al usar el alcance léxico, logró eliminar la duplicación en el código. No fue necesario repetir la comparación para comprobar si el nombre comienza con letras diferentes.

Refactorización para reducir el alcance. En el ejemplo anterior, usó un 

static
método, pero no desea contaminar la clase con 
static
métodos para almacenar en caché cada variable en el futuro. Sería bueno limitar el alcance de la función a donde sea necesario. Puede lograrlo utilizando una 
Function
interfaz.


final Function<String, Predicate<String>>
   startsWithLetter = (String letter) -> {
      Predicate<String> checkStarts =
         (String name) -> name.startsWith(letter);
   return checkStarts;
};

Esta expresión lambda reemplaza el 

static
método 
checkIfStartsWith()
y puede aparecer dentro de una función, justo antes de que se necesite. La 
checkIfStartsWith
variable se refiere a a 
Function
que toma a 
String
y devuelve a 
Predicate
.

Esta versión es detallada en comparación con el 

static
método que vio anteriormente, pero pronto la refactorizará para que sea concisa. A todos los efectos prácticos, esta función es equivalente al 
static
método; toma 
String
ay devuelve a 
Predicate
. En lugar de crear explícitamente la instancia de 
Predicate
y devolverla, puede reemplazarla con una expresión lambda.


final Function<String, Predicate<String>>
   startsWithLetter =
      letter -> name -> name.startsWith(letter);

Ha completado el círculo con funciones de orden superior en esta sección. Los ejemplos ilustran cómo pasar funciones a funciones, crear funciones dentro de funciones y devolver funciones desde dentro de funciones. También demuestran la concisión y la capacidad de reutilización que facilitan las expresiones lambda.

Hizo un buen uso de ambos 

Function
Predicate
en esta sección, pero analicemos en qué se diferencian.

  • Predicate<T>
    toma un parámetro de tipo 
    T
    y devuelve un 
    Boolean
    resultado para indicar una decisión para cualquier verificación que represente. Puede usarlo en cualquier momento que desee tomar una decisión de aprobar o no aprobar para un candidato que pase al 
    Predicate
    . Métodos como 
    filter()
    el de evaluar elementos candidatos toman a 
    Predicate
    como parámetro.
  • Function <T, R>
    representa una función que toma un parámetro de tipo 
    T
    y devuelve un resultado de tipo 
    R
    . Esto es más general que un 
    Predicate
    que siempre devuelve un booleano. Puede emplear un en 
    Function
    cualquier lugar donde desee transformar una entrada en otro valor, por lo que es bastante lógico que el 
    map()
    método use 
    Function
    como parámetro.

Seleccionar todos los elementos coincidentes de una colección fue fácil. A continuación, le mostraré cómo elegir un solo elemento de una colección.

Escogiendo un solo elemento

Es razonable esperar que elegir un elemento de una colección sea más simple que elegir varios elementos, pero eso no siempre es cierto. Comenzaré explorando la complejidad introducida por el enfoque habitual y luego incorporaré expresiones lambda para reducir esa complejidad.

Creemos un método que busque un elemento que comience con una letra determinada y la imprima.


public static void pickName(
   final List<String> names,
   final String startingLetter) {
      String foundName = null;
      for(String name : names) {
         if(name.startsWith(startingLetter)) {
            foundName = name;
            break;
         }
      }
   System.out.print(
      String.format(
         "A name starting with %s: ",
         startingLetter));
         
   if(foundName != null) {
      System.out.println(foundName);
   } else {
      System.out.println("No name found");
   }
}

El olor de este método puede competir fácilmente con los camiones de basura que pasan. Primero creó una 

foundName
variable y la inicializó en: 
null
esa es la fuente del primer mal olor. Esto forzará una 
null
verificación, y si se olvida de manejar esa verificación, el resultado podría ser una 
NullPointerException
o alguna otra respuesta desagradable.

Luego usó un iterador externo para recorrer los elementos, pero tuvo que salir del bucle si encontraba un elemento; aquí hay otras fuentes de olores rancios: obsesión primitiva, estilo imperativo y mutabilidad. Una vez fuera del ciclo, tenía que verificar la respuesta e imprimir el resultado apropiado. Eso es bastante código para una tarea simple.

Reconsideremos el problema. Simplemente desea elegir el primer elemento coincidente y lidiar con seguridad con la ausencia de dicho elemento. Reescribamos el 

pickName()
método usando expresiones lambda.


public static void pickName(
   final List<String> names,
   final String startingLetter) {
      final Optional<String> foundName =
      names.stream()
         .filter(name -> name.startsWith(startingLetter))
         .findFirst();
      System.out.println(
         String.format( "A name starting with %s: %s",
            startingLetter, foundName.orElse("No name found")));
}

Algunas funciones poderosas en la biblioteca JDK se unieron para ayudar a lograr esta concisión. Primero, el 

filter()
método tomó todos los elementos que coincidían con el patrón deseado. Luego, el 
findFirst()
método de la 
Stream
clase ayudó a elegir el primer valor de esa colección. Este método devuelve un 
Optional
objeto especial , que es el 
null
desodorizador designado por el estado en Java.

La 

Optional
clase es útil siempre que el resultado pueda estar ausente. Le protege de contraer un 
NullPointerException
accidente y le hace bastante explícito al lector que «no se encontró ningún resultado» es un resultado posible.

Puede consultar si un objeto está presente utilizando el 

isPresent()
método y puede obtener el valor actual utilizando su 
get()
método. Alternativamente, puede sugerir un valor sustituto para la instancia que falta, usando el método (con el nombre vagamente amenazante) 
orElse()
, como en el código anterior. Ejercitemos la 
pickName()
función con la 
friends
colección de muestra que ha utilizado en los ejemplos hasta ahora.


pickName(friends, "N");
pickName(friends, "Z");

El código selecciona el primer elemento coincidente, si se encuentra uno, e imprime un mensaje apropiado en caso contrario.


A name starting with N: Nate
A name starting with Z: No name found

La combinación del 

findFirst()
método y la 
Optional
clase redujo bastante el código y su olor. 
Optional
Sin embargo, no está limitado a las opciones anteriores cuando trabaja con . Por ejemplo, en lugar de proporcionar un valor alternativo para la instancia ausente, puede solicitar 
Optional
ejecutar un bloque de código o una expresión lambda solo si hay un valor presente, de la siguiente manera:


foundName.ifPresent( name ->
   System.out.println("Hello " + name));

En comparación con el uso de la versión imperativa para elegir el primer nombre coincidente, el estilo funcional agradable y fluido se ve mejor. Pero, ¿está haciendo más trabajo en la versión fluida que en la versión imperativa? La respuesta es no: estos métodos tienen la inteligencia para realizar solo el trabajo necesario.

La búsqueda del primer elemento coincidente demostró algunas capacidades más ordenadas en el JDK. A continuación, le mostraré cómo las expresiones lambda ayudan a calcular un solo resultado de una colección.

Reducir una colección a un solo valor

He repasado bastantes técnicas para manipular colecciones hasta ahora: elegir elementos coincidentes, seleccionar un elemento en particular y transformar una colección. Todas estas operaciones tienen una cosa en común: todas trabajaron de forma independiente en elementos individuales de la colección. Ninguno requirió comparar elementos entre sí o transferir cálculos de un elemento al siguiente.

En esta sección, compararé elementos y transferiré un estado computacional a través de una colección. Esto comenzará con algunas operaciones básicas y luego se desarrollará hasta algo un poco más sofisticado. Como primer ejemplo, leerá los valores de la 

friends
colección de nombres y determinará el número total de caracteres.


System.out.println(
   "Total number of characters in all names: " +
   friends.stream()
      .mapToInt(name -> name.length())
      .sum());

Para encontrar el total de caracteres, necesita la longitud de cada nombre, que se puede encontrar usando el 

mapToInt()
método. Una vez que transforma los nombres a sus longitudes, el paso final es agregarlos, que se realiza mediante el 
sum()
método incorporado . Aquí está el resultado de esta operación.


Total number of characters in all names: 26

Este código aprovechó el 

mapToInt()
método, una variación de la 
map()
operación (variaciones como 
mapToInt()
mapToDouble()
crear flujos especializados de tipo como 
IntStream
DoubleStream
), y luego redujo la longitud resultante al 
sum
valor.

En lugar de usar el 

sum()
método, por supuesto, el código podría usar una variedad de métodos, como 
max()
encontrar la longitud más larga, 
min()
encontrar la longitud más corta, 
average()
encontrar las longitudes promedio, etc.

El encanto oculto en el ejemplo anterior es el patrón MapReduce cada vez más popular, siendo el 

map()
método la operación de propagación y el 
sum()
método el caso especial de la operación de reducción más general. De hecho, la implementación del 
sum()
método en el JDK usa un 
reduce()
método. Aquí hay un vistazo a la forma más general de la operación de reducción.

Por ejemplo, la tarea consiste en leer la colección de nombres dada y mostrar el más largo. Si hay más de un nombre con la misma longitud más larga, mostrará el primero que encuentre. Una forma de hacerlo es averiguar la longitud más larga y luego elegir el primer elemento de esa longitud. Pero eso requeriría revisar la lista dos veces, lo cual no es eficiente. Aquí es donde 

reduce()
entra en juego un método.

Puede utilizar el 

reduce()
método para comparar dos elementos entre sí y pasar el resultado para una comparación más detallada con los elementos restantes de la colección. Al igual que las otras funciones de orden superior en colecciones que ha visto hasta ahora, el 
reduce()
método itera sobre la colección. Además, 
reduce()
traslada el resultado del cálculo que devuelve la expresión lambda. Un ejemplo ayudará a aclarar esto.


final Optional<String> aLongName =
   friends.stream()
      .reduce((name1, name2) ->
         name1.length() >= name2.length() ? name1 : name2);
   aLongName.ifPresent(name ->
      System.out.println(
         String.format("A longest name: %s", name)));

La expresión lambda que está pasando al 

reduce()
método toma dos parámetros 
name1
name2
, y devuelve uno de ellos según la longitud. El 
reduce()
método, por supuesto, no tiene ni idea de la intención específica de la lógica de la aplicación. Esa preocupación se separa de este método en la expresión lambda que le pasa; esta es una aplicación ligera del patrón de estrategia.

Esta expresión lambda se ajusta a la interfaz de un 

apply()
método de una interfaz funcional JDK denominada 
BinaryOperator
. Este es el tipo de parámetro 
reduce()
que recibe el método. Veamos si el 
reduce()
método elige el primero de los dos nombres más largos de la 
friends
lista.


A longest name: Brian

A medida que el 

reduce()
método recorría la colección, llamaba primero a la expresión lambda, con los dos primeros elementos de la lista. El resultado de la expresión lambda se usa para la siguiente llamada. En la segunda llamada, 
name1
está vinculado al resultado de la llamada anterior a la expresión lambda y 
name2
está vinculado al tercer elemento de la colección. Las llamadas a la expresión lambda continúan para el resto de los elementos de la colección. El resultado de la llamada final se devuelve como resultado de la 
reduce()
llamada al método.

El resultado del 

reduce()
método es 
Optional
porque la lista en la que 
reduce()
se llama podría estar vacía y, en ese caso, no habría un nombre más largo. Si la lista tuviera solo un elemento, 
reduce()
devolvería ese elemento y no se invocaría la expresión lambda.

Del ejemplo, puede inferir que el 

reduce()
resultado del método es como máximo un elemento de la colección. Si desea establecer un valor base o predeterminado, puede pasar ese valor como un parámetro adicional a una variación sobrecargada del 
reduce()
método. Por ejemplo, si el nombre más corto predeterminado que desea elegir es «Steve», puede pasarlo al 
reduce()
método de la siguiente manera:


final String steveOrLonger =
   friends.stream()
      .reduce("Steve", (name1, name2) ->
         name1.length() >= name2.length() ? name1 : name2);

Si algún nombre es más largo que la base dada, se recoge; de lo contrario, la función devuelve el valor base, que es «Steve» en este ejemplo. Esta versión de 

reduce()
no devuelve un 
Optional
porque si la colección está vacía, se devolverá el valor predeterminado de «Steve»; no hay preocupación por un valor ausente o inexistente.

Antes de terminar, visitemos una operación fundamental, pero aparentemente difícil, sobre colecciones: unir elementos.

Uniendo elementos

Ha explorado cómo seleccionar elementos, iterar y transformar colecciones. Sin embargo, en una operación trivial, concatenando una colección, podrías perder todas las ganancias obtenidas con un código conciso y elegante si no fuera por la 

join()
función, introducida en JDK 8. Este método simple es tan útil que está a punto de convertirse en uno de los más utilizados. funciones en el JDK. Veamos cómo usarlo para imprimir los valores en una lista separada por comas.

Volviendo a la 

friends
lista: ¿Qué se necesita para imprimir la lista de nombres, separados por comas, sin usar 
join()
? Tienes que recorrer la lista e imprimir cada elemento. Debido a que la 
for
construcción mejorada de Java 5 es mejor que el 
for
ciclo arcaico , comencemos con eso.


for(String name : friends) {
   System.out.print(name + ", ");
}
System.out.println();

Ese código simple produce lo siguiente:


Brian, Nate, Neal, Raju, Sara, Scott,

¡UH oh! Hay una coma maloliente al final (¿se puede culpar a Scott?). ¿Cómo le dices a Java que no coloque una coma allí? Desafortunadamente, el ciclo seguirá su curso y no hay una manera fácil de distinguir el último elemento del resto. Para solucionar este problema, puede volver al bucle habitual.


for(int i = 0; i < friends.size() - 1; i++) {
   System.out.print(friends.get(i) + ", ");
}
if(friends.size() > 0)
   System.out.println(friends.get(friends.size() - 1));

Veamos si el resultado de esta versión es decente.


Brian, Nate, Neal, Raju, Sara, Scott

La salida se ve bien, pero el código para producir la salida no. Transpórtame, Java moderno.

Buenas noticias: la 

StringJoiner
clase agregada en Java 8 limpia ese desorden. La 
String
clase tiene un método de conveniencia adicional 
join()
, para convertir ese código maloliente en una simple frase.


System.out.println(String.join(", ", friends));

Lo siguiente verifica que el resultado sea tan encantador como el código que lo produjo:


Brian, Nate, Neal, Raju, Sara, Scott

Bajo el capó, el 

String.join()
método llama al 
StringJoiner
para concatenar los valores en el segundo argumento, a 
varargs
, en una cadena más grande separada por el primer argumento. No está limitado a concatenar solo con una coma usando esta función. Por ejemplo, podría tomar un montón de rutas y concatenarlas para formar una ruta de clases fácilmente, gracias a los nuevos métodos y clases.

También puede transformar los elementos antes de unirlos. Ya sabes cómo transformar elementos usando el 

map()
método. También puede seleccionar qué elementos desea conservar utilizando métodos como 
filter()
. El paso final de unir los elementos, separados por comas u otra cosa, es simplemente una 
reduce
operación.

Puede usar el 

reduce()
método para concatenar elementos en una cadena, pero eso requeriría más esfuerzo del necesario. El JDK tiene un método de conveniencia llamado 
collect()
, que es otra forma de 
reduce
que puede recopilar valores en un destino objetivo.

El 

collect()
método realiza la reducción, pero delega la implementación real o el objetivo a un recopilador. Podría colocar los elementos transformados en un 
ArrayList
, por ejemplo. O, para continuar con el ejemplo actual, puede recopilar los elementos transformados en una cadena concatenada con comas.


System.out.println(
   friends.stream()
      .map(String::toUpperCase)
      .collect(joining(", ")));

Este código invocó el 

collect()
método en la lista transformada y le proporcionó un recopilador devuelto por el 
joining()
método, que es un 
static
método en una 
Collectors
clase de utilidad. Un colector actúa como un objeto receptor para recibir los elementos que pasa el 
collect()
método y los almacena en un formato deseado, como 
ArrayList
String
.

Aquí están los nombres, ahora en mayúsculas y claramente separados por comas.


BRIAN, NATE, NEAL, RAJU, SARA, SCOTT

El 

StringJoiner
da mucho más control sobre el formato de la concatenación; pude especificar un prefijo, un sufijo y secuencias de caracteres infijos, si lo desea.

FUENTE:

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *