Lo que extraño en Java después de trabajar con Kotlin / Scala

Recientemente, a menudo escucho que Java se ha convertido en un lenguaje obsoleto en el que es difícil construir grandes aplicaciones compatibles. En general, no estoy de acuerdo con este punto de vista. En mi opinión, el lenguaje sigue siendo adecuado para escribir aplicaciones rápidas y bien organizadas. Sin embargo, lo admito, también sucede que cuando escribes código todos los días, a veces piensas: "qué tan bien se resolvería con esto desde otro idioma". En este artículo, quería compartir mi dolor y experiencia. Analizaremos algunos problemas de Java y cómo podrían resolverse en Kotlin / Scala. Si tiene una sensación similar o simplemente se pregunta qué pueden ofrecer otros idiomas, le pregunto bajo cat.



Extender clases existentes


A veces sucede que es necesario expandir una clase existente sin cambiar su contenido interno. Es decir, después de crear la clase, la complementamos con otras clases. Considere un pequeño ejemplo. Supongamos que tenemos una clase que es un punto en el espacio bidimensional. En diferentes lugares de nuestro código, necesitamos serializarlo tanto en Json como en XML.

Veamos cómo puede verse en Java usando el patrón Visitor
public class DotDemo { public static class Dot { private final int x; private final int y; public Dot(int x, int y) { this.x = x; this.y = y; } public String accept(Visitor visitor) { return visitor.visit(this); } public int getX() { return x; } public int getY() { return y; } } public interface Visitor { String visit(Dot dot); } public static class JsonVisitor implements Visitor { @Override public String visit(Dot dot) { return String .format("" + "{" + "\"x\"=%d, " + "\"y\"=%d " + "}", dot.getX(), dot.getY()); } } public static class XMLVisitor implements Visitor { @Override public String visit(Dot dot) { return "<dot>" + "\n" + " <x>" + dot.getX() + "</x>" + "\n" + " <y>" + dot.getY() + "</y>" + "\n" + "</dot>"; } } public static void main(String[] args) { Dot dot = new Dot(1, 2); System.out.println("-------- JSON -----------"); System.out.println(dot.accept(new JsonVisitor())); System.out.println("-------- XML ------------"); System.out.println(dot.accept(new XMLVisitor())); } } 

Más información sobre el patrón y su uso.

Se ve bastante voluminoso, ¿verdad? ¿Es posible resolver este problema de manera más elegante con la ayuda de herramientas de lenguaje? Scala y Kotlin asienten positivamente. Esto se logra utilizando el mecanismo de extensión del método. Veamos como se ve.

Extensiones en Kotlin
 data class Dot (val x: Int, val y: Int) //      fun Dot.convertToJson(): String = "{\"x\"=$x, \"y\"=$y}" fun Dot.convertToXml(): String = """<dot> <x>$x</x> <y>$y</y> </dot>""" fun main() { val dot = Dot(1, 2) println("-------- JSON -----------") println(dot.convertToJson()) println("-------- XML -----------") println(dot.convertToXml()) } 


Extensiones en Scala
 object DotDemo extends App { // val is default case class Dot(x: Int, y: Int) implicit class DotConverters(dot: Dot) { def convertToJson(): String = s"""{"x"=${dot.x}, "y"=${dot.y}}""" def convertToXml(): String = s"""<dot> <x>${dot.x}</x> <y>${dot.y}</y> </dot>""" } val dot = Dot(1, 2) println("-------- JSON -----------") println(dot.convertToJson()) println("-------- XML -----------") println(dot.convertToXml()) } 


Se ve mucho mejor. A veces esto no es suficiente con un mapeo abundante y otras transformaciones.

Cadena informática multiproceso


Ahora todo el mundo habla de computación asincrónica y las prohibiciones de bloquear hilos de ejecución. Imaginemos el siguiente problema: tenemos varias fuentes de números, donde el primero solo devuelve el número, el segundo, devuelve la respuesta después de calcular el primero. Como resultado, debemos devolver una cadena con dos números.

Esquemáticamente, esto se puede representar de la siguiente manera


Intentemos resolver el problema en Java primero

Ejemplo de Java
  private static CompletableFuture<Optional<String>> calcResultOfTwoServices ( Supplier<Optional<Integer>> getResultFromFirstService, Function<Integer, Optional<Integer>> getResultFromSecondService ) { return CompletableFuture .supplyAsync(getResultFromFirstService) .thenApplyAsync(firstResultOptional -> firstResultOptional.flatMap(first -> getResultFromSecondService.apply(first).map(second -> first + " " + second ) ) ); } 


En este ejemplo, nuestro número está envuelto en Opcional para controlar el resultado. Además, todas las acciones se realizan dentro de CompletableFuture para un trabajo conveniente con subprocesos. La acción principal tiene lugar en el método thenApplyAsync. En este método, obtenemos Opcional como argumento. A continuación, se llama a flatMap para controlar el contexto. Si el Opcional recibido regresó como Opcional.empty, entonces no iremos al segundo servicio.

¿El total que recibimos? Al usar las características CompletableFuture y Opcional con flatMap y map, pudimos resolver el problema. Aunque, en mi opinión, la solución no se ve de la manera más elegante: antes de comprender qué sucede, debe leer el código. ¿Y qué pasaría con dos o más fuentes de datos?

¿Podría el lenguaje ayudarnos de alguna manera a resolver el problema? Y de nuevo, dirígete a Scala. Así es como puede resolverlo con las herramientas Scala.

Ejemplo de Scala
 def calcResultOfTwoServices(getResultFromFirstService: Unit => Option[Int], getResultFromSecondService: Int => Option[Int]) = Future { getResultFromFirstService() }.flatMap { firsResultOption => Future { firsResultOption.flatMap(first => getResultFromSecondService(first).map(second => s"$first $second" ) )} } 


Se ve familiar. Y esto no es casualidad. Utiliza la biblioteca scala.concurrent, que es principalmente un contenedor sobre java.concurrent. Bueno, ¿con qué más nos puede ayudar Scala? El hecho es que las cadenas de la forma flatMap, ..., map se pueden representar como una secuencia para.

Ejemplo de segunda versión en Scala
  def calcResultOfTwoServices(getResultFromFirstService: Unit => Option[Int], getResultFromSecondService: Int => Option[Int]) = Future { getResultFromFirstService() }.flatMap { firstResultOption => Future { for { first <- firstResultOption second <- getResultFromSecondService(first) } yield s"$first $second" } } 


Ha mejorado, pero intentemos cambiar nuestro código nuevamente. Conecta la biblioteca de gatos.

Tercera versión del ejemplo de Scala
 import cats.instances.future._ def calcResultOfTwoServices(getResultFromFirstService: Unit => Option[Int], getResultFromSecondService: Int => Option[Int]): Future[Option[String]] = (for { first <- OptionT(Future { getResultFromFirstService() }) second <- OptionT(Future { getResultFromSecondService(first) }) } yield s"$first $second").value 


Ahora no es tan importante lo que significa OptionT. Solo quiero mostrar cuán simple y breve puede ser esta operación.

¿Pero qué hay de Kotlin? Tratemos de hacer algo similar en las corutinas.

Ejemplo de Kotlin
 val result = async { withContext(Dispatchers.Default) { getResultFromFirstService() }?.let { first -> withContext(Dispatchers.Default) { getResultFromSecondService(first) }?.let { second -> "$first $second" } } } 


Este código tiene sus propias peculiaridades. Primero, utiliza el mecanismo Kotlin de la corutina. Las tareas dentro de asíncrono se realizan en un grupo de subprocesos especial (no ForkJoin) con un mecanismo de robo de trabajo. En segundo lugar, este código requiere un contexto especial, del que se toman palabras clave como async y withContext.

Si te gustó Scala Future, pero escribes en Kotlin, entonces puedes prestar atención a envoltorios Scala similares. Escribe tal.

Trabaja con streams


Para mostrar el problema con más detalle arriba, intentemos expandir el ejemplo anterior: recurrimos a las herramientas de programación Java más populares - Reactor , en Scala - fs2 .

Considere la lectura línea por línea de 3 archivos en una secuencia e intente encontrar coincidencias allí.
Aquí está la forma más fácil de hacer esto con Reactor en Java.

Ejemplo de reactor en Java
  private static Flux<String> glueFiles(String filename1, String filename2, String filename3) { return getLinesOfFile(filename1).flatMap(lineFromFirstFile -> getLinesOfFile(filename2) .filter(line -> line.equals(lineFromFirstFile)) .flatMap(lineFromSecondFile -> getLinesOfFile(filename3) .filter(line -> line.equals(lineFromSecondFile)) .map(lineFromThirdFile -> lineFromThirdFile ) ) ); } 


No es la forma más óptima, sino indicativa. No es difícil adivinar que con más lógica y acceso a recursos de terceros, la complejidad del código crecerá. Veamos la sintaxis de azúcar para la comprensión alternativa.

Ejemplo de fs2 en Scala
  def findUniqueLines(filename1: String, filename2: String, filename3: String): Stream[IO, String] = for { lineFromFirstFile <- readFile(filename1) lineFromSecondFile <- readFile(filename2).filter(_.equals(lineFromFirstFile)) result <- readFile(filename3).filter(_.equals(lineFromSecondFile)) } yield result 


Parece que no hay muchos cambios, pero se ve mucho mejor.

Separar la lógica de negocios con una clase superior e implícita


Sigamos adelante y veamos cómo podemos mejorar nuestro código. Quiero advertir que la siguiente parte puede no ser inmediatamente comprensible. Quiero mostrar las posibilidades y dejar el método de implementación fuera de los corchetes por ahora. Una explicación detallada requiere al menos un artículo separado. Si hay un deseo / comentarios, seguiré los comentarios para responder preguntas y escribiré la segunda parte con una descripción más detallada :)

Por lo tanto, imagine un mundo en el que podamos establecer la lógica empresarial independientemente de los efectos técnicos que puedan surgir durante el desarrollo. Por ejemplo, podemos hacer que cada solicitud posterior a un DBMS o un servicio de terceros se realice en un hilo separado. En las pruebas unitarias, necesitamos hacer una estúpida simulación en la que no pasa nada. Y así sucesivamente.

Quizás algunas personas pensaron en el motor BPM, pero hoy no se trata de él. Resulta que este problema puede resolverse con la ayuda de algunos patrones de programación funcional y soporte de lenguaje. En un lugar podemos describir la lógica de esta manera.

En un lugar, podemos describir la lógica como esta
  def makeCatHappy[F[_]: Monad: CatClinicClient](): F[Unit] = for { catId <- CatClinicClient[F].getHungryCat memberId <- CatClinicClient[F].getFreeMember _ <- CatClinicClient[F].feedCatByFreeMember(catId, memberId) } yield () 


Aquí F [_] (leído como "ef con un agujero") significa un tipo sobre un tipo (a veces se llama una especie en la literatura rusa). Puede ser Lista, Conjunto, Opción, Futuro, etc. Todo eso es un contenedor de un tipo diferente.

A continuación, solo cambiamos el contexto de la ejecución del código. Por ejemplo, para el entorno de producción podemos hacer algo como esto.

¿Cómo podría ser el código de combate?
 class RealCatClinicClient extends CatClinicClient[Future] { override def getHungryCat: Future[Int] = Future { Thread.sleep(1000) // doing some calls to db (waiting 1 second) 40 } override def getFreeMember: Future[Int] = Future { Thread.sleep(1000) // doing some calls to db (waiting 1 second) 2 } override def feedCatByFreeMember(catId: Int, memberId: Int): Future[Unit] = Future { Thread.sleep(1000) // happy cat (waiting 1 second) println("so testy!") // Don't do like that. It is just for debug } } 


Cómo se vería el código de prueba
 class MockCatClinicClient extends CatClinicClient[Id] { override def getHungryCat: Id[Int] = 40 override def getFreeMember: Id[Int] = 2 override def feedCatByFreeMember(catId: Int, memberId: Int): Id[Unit] = { println("so testy!") // Don't do like that. It is just for debug } } 


Nuestra lógica de negocios ahora no depende de qué marcos, clientes http y servidores usamos. En cualquier momento, podemos cambiar el contexto y la herramienta cambiará.

Esto se logra mediante características tales como higherKind e implícito. Consideremos el primero, y para esto volveremos a Java.

Miremos el código
 public class Calcer { private CompletableFuture<Integer> getCalc(int x, int y) { } } 


¿Cuántas formas de devolver el resultado? Bastante mucho. Podemos restar, sumar, intercambiar y mucho más. Ahora imagine que se nos han dado requisitos claros. Necesitamos agregar el primer número al segundo. ¿De cuántas maneras podemos hacer esto? si te esfuerzas y te refinas mucho ... en general, solo uno.

Aqui esta
 public class Calcer { private CompletableFuture<Integer> getCalc(int x, int y) { return CompletableFuture.supplyAsync(() -> x + y); } } 


Pero, ¿qué sucede si la llamada a este método está oculta y queremos probar en un entorno de subproceso único? O qué pasa si queremos cambiar la implementación de la clase eliminando / reemplazando CompletableFuture. Desafortunadamente, en Java somos impotentes y tenemos que cambiar el método API. Echa un vistazo a la alternativa en Scala.

Considerar rasgo
 trait Calcer[F[_]] { def getCulc(x: Int, y: Int): F[Int] } 


Creamos rasgos (el análogo más cercano es la interfaz en Java) sin especificar el tipo de contenedor de nuestro valor entero.

Además, simplemente podemos crear varias implementaciones si es necesario.

Como asi
  val futureCalcer: Calcer[Future] = (x, y) => Future {x + y} val optionCalcer: Calcer[Option] = (x, y) => Option(x + y) 


Además, hay algo tan interesante como lo implícito. Le permite crear el contexto de nuestro entorno y seleccionar implícitamente la implementación del rasgo basado en él.

Como asi
  def userCalcer[F[_]](implicit calcer: Calcer[F]): F[Int] = calcer.getCulc(1, 2) def doItInFutureContext(): Unit = { implicit val futureCalcer: Calcer[Future] = (x, y) => Future {x + y} println(userCalcer) } doItInFutureContext() def doItInOptionContext(): Unit = { implicit val optionCalcer: Calcer[Option] = (x, y) => Option(x + y) println(userCalcer) } doItInOptionContext() 


Simplificado implícito antes de val: agregar una variable al entorno actual e implícito como argumento de una función significa tomar la variable del entorno. Esto recuerda un poco a un cierre implícito.

En conjunto, resulta que podemos crear un entorno de combate y prueba de manera bastante concisa sin usar bibliotecas de terceros.
¿Pero qué hay de Kotlin?
En realidad, de manera similar, podemos hacer en Kotlin:
 interface Calculator<T> { fun eval(x: Int, y: Int): T } object FutureCalculator : Calculator<CompletableFuture<Int>> { override fun eval(x: Int, y: Int) = CompletableFuture.supplyAsync { x + y } } object OptionalCalculator : Calculator<Optional<Int>> { override fun eval(x: Int, y: Int) = Optional.of(x + y) } fun <T> Calculator<T>.useCalculator(y: Int) = eval(1, y) fun main() { with (FutureCalculator) { println(useCalculator(2)) } with (OptionalCalculator) { println(useCalculator(2)) } } 

Aquí también establecemos el contexto de ejecución de nuestro código, pero a diferencia de Scala, lo marcamos explícitamente.
Gracias a Beholder por el ejemplo.


Conclusión


En general, estos no son todos mis dolores. Hay mas. Creo que cada desarrollador tiene el suyo. Por mi parte, me di cuenta de que lo principal es entender lo que realmente es necesario para el beneficio del proyecto. Por ejemplo, en mi opinión, si tenemos un servicio de descanso que actúa como un tipo de adaptador con un montón de mapeo y lógica simple, entonces toda la funcionalidad anterior no es muy útil. Spring Boot + Java / Kotlin es perfecto para tales tareas. Hay otros casos con una gran cantidad de integraciones y agregación de cierta información. Para tales tareas, en mi opinión, la última opción se ve muy bien. En general, es genial si puedes elegir una herramienta basada en una tarea.

Recursos utiles:

  1. Enlace a todos los ejemplos completos anteriores
  2. Más sobre Corotin en Kotlin
  3. Un buen libro introductorio sobre programación funcional en Scala

Source: https://habr.com/ru/post/456774/


All Articles