Extensiones en Kotlin. ¿Atavismo peligroso o una herramienta útil?



Kotlin todavía es un idioma joven, pero ya ha estallado en nuestras vidas. Debido a esto, no siempre está claro cómo implementar correctamente esto o aquello funcional y qué mejor práctica aplicar.

Particularmente difícil es el caso con las características de un lenguaje que no están en Java. Uno de estos escollos ha sido la expansión .

Esta es una herramienta conveniente que hace que el código sea más legible y no requiere casi nada a cambio. Pero al mismo tiempo, conozco al menos a una persona que, si no considera que la expansión es malvada, definitivamente es escéptica con respecto a ellos. A continuación, me gustaría discutir las características de este mecanismo, que puede causar controversia y malentendidos.

Extensiones a DTO: violación de la plantilla de objeto de transferencia de datos


Por ejemplo, hay una clase Usuario

class User(val name: String, val age: Int, val sex: String) 

Todo un DTO! Además, en el código en varios lugares, se requiere una verificación para determinar si el usuario es un adulto. La opción más fácil es hacer una condición en todos los lugares.

 if (user.age >= 18) { ... } 

Pero, dado que puede haber arbitrariamente muchos de esos lugares, tiene sentido poner esta verificación en el método.
Aquí hay tres opciones:

  1. Fun fun isAdult (user: User): las clases de utilidad generalmente consisten en tales funciones.
  2. Ponga la función isAdult dentro de la clase Usuario

     class User(val name: String, val age: Int, val sex: String) { fun isAdult() = age >= 18 } 

  3. Escriba un contenedor para el usuario que contendrá funciones similares.

Las tres opciones tienen técnicamente derecho a la vida. Pero el primero agrega inconvenientes en la forma de la necesidad de conocer todas las funciones de utilidad, aunque esto, por supuesto, no es un gran problema.
La segunda opción parece violar el patrón del Objeto de transferencia de datos, ya que la clase no es solo getters y setters. Pero romper patrones es malo.

La tercera opción no viola ni los principios de OOP ni las plantillas, pero cada vez que tiene que crear un contenedor si desea utilizar funciones similares. Esta opción tampoco es muy parecida. Al final, resulta que todavía tienes que hacer sacrificios.

En mi opinión, es más fácil sacrificar una plantilla DTO. En primer lugar, no encontré una sola explicación de por qué las funciones (excepto getters y setters) no se pueden realizar en el DTO. Y en segundo lugar, solo en términos de significado, es conveniente tener dicho código junto a los datos en los que estamos operando.

Pero no siempre es posible poner dicho código dentro del shek DTO, ya que el desarrollador no siempre tiene la capacidad de editar las clases con las que trabaja. Por ejemplo, estas pueden ser clases generadas a partir de xsd. Además, para alguien puede ser inusual e incómodo escribir dicho código en las clases de datos. Kotlin ofrece una solución para tales situaciones en forma de funciones y campos de extensión:

 fun User.isAdult() = age >= 18 

Este código puede usarse como si fuera declarado dentro de la clase Usuario:

 if(user.isAdult()) {...} 

El resultado es una solución bastante precisa que, con el menor compromiso, satisface nuestras necesidades. Si hablamos sobre el hecho de que se viola la plantilla DTO, queremos recordar que en Java será un método estático regular de la forma:

 public static final boolean isAdult(@NotNull User receiver) 

Como puede ver, formalmente, incluso la plantilla no se viola. El uso de esta función parece haber sido declarado en Usuario y Idea lo ofrecerá en autocompletado. Es muy conveniente.

Las extensiones son específicas. No puede conocer su existencia y confundir los métodos y campos de la entidad con extensiones


La idea es que el desarrollador llegó al proyecto, y alrededor del código implementado en las extensiones, no está claro qué método es original y cuál es el método de extensión.

Esto no es un problema, ya que Idea ayuda al desarrollador en este asunto y destaca dichas funciones. Aunque para ser justos, debe decirse que la diferencia se nota mejor en el tema de Darcula. Si lo cambia a Light, todo se vuelve menos obvio y la extensión solo difiere en letra cursiva.

A continuación vemos un ejemplo de llamada a dos métodos: isAdult es el método de extensión, isMale es el método habitual dentro de la clase Usuario. La captura de pantalla a la izquierda es el tema Darcula, a la derecha es el tema Light habitual.



Algo peor están con los campos. Si, por ejemplo, decidimos implementar isAdult como un campo de extensión, entonces solo podemos distinguirlo de un campo normal por tipo de fuente. En este ejemplo, el nombre es un campo regular. Un campo de extensión produce solo fuentes en cursiva.



El entorno de desarrollo de Idea le ayuda a determinar qué método es una extensión y cuál es el original cuando se completa automáticamente. Esto es conveniente



La situación es similar con los campos.



"Para el usuario en <root>" significa que es una extensión.

Además, el hecho mismo de que Idea "vincule" una extensión a una entidad extensible ayuda mucho en el desarrollo, ya que se proponen métodos y campos de extensión para la finalización automática.

Las extensiones están dispersas por todo el proyecto, formando un bote de basura.


No tenemos ese problema en los proyectos, ya que no ponemos extensiones arbitrariamente ni sacamos código con extensiones públicas en archivos o paquetes separados.

Por ejemplo, la función isAdult del ejemplo anterior podría aparecer en el archivo de usuario del paquete de extensiones. Si el paquete no es suficiente y solo desea no confundir dónde está la clase y dónde está el archivo de funciones, puede nombrarlo, por ejemplo, _User.kt. Lo mismo hicieron los desarrolladores de JetBrains para colecciones. O, si la conciencia prohíbe comenzar el archivo con un guión bajo, puede llamar a user.kt. De hecho, no hay diferencia en qué forma de uso, lo principal es que hay uniformidad a la que se adhiere todo el equipo.

Los creadores del lenguaje, al desarrollar métodos de extensión para colecciones, los colocaron en el archivo _Collections.kt .

Generalmente se trata de organizar el código, no un problema de extensiones. Las funciones estáticas en Java, y no solo las estáticas, se pueden dispersar no menos aleatoriamente que las extensiones.

No pase por alto las funciones de extensión durante las pruebas unitarias


En mi opinión, no es necesario mojar las funciones de las extensiones, así como tampoco es necesario mojar los métodos estáticos. En la función de expansión, debe poner la lógica de trabajar con datos existentes. Por ejemplo, en el caso de la función isAdult para la clase Usuario, todo lo que necesita está en isAdult. No necesitas mojarte.

Considere un ejemplo un poco más complejo. Hay un cierto componente que sirve para obtener usuarios de un sistema externo: UserComponent. El método para obtener usuarios se llama getUsers. Suponga que era necesario obtener todos los usuarios activos y decidió agregar lógica de filtrado en forma de una función: una extensión. Como resultado, tenemos la función:

 fun UserComponent.getActiveUsers(): List<Users> = this.getUsers().filter{it.status == “Active”} 

Puede parecer que aquí está: una situación en la que necesita un simulacro para la expansión. Pero si recuerda que getActiveUsers es solo un método estático, resulta que no se necesita simulacro. Dip debe ser los métodos y funciones que se llaman en la extensión, y nada más.

Es posible que la función de extensión se superponga con la función del mismo nombre ubicada dentro de la clase extendida


Consideraremos este caso utilizando el ejemplo del primer párrafo. Supongamos que hay una extensión de función isAdult, que verifica si el usuario es un adulto:

 fun User.isAdult() = age >= 18 

Después de eso, implementamos la función del mismo nombre dentro de Usuario:

 class User(val name: String, val age: Int, val sex: String){ fun isAdult() = age >= 21 } 

Cuando se llama a user.isAdult (), se llamará a una función de la clase, a pesar de que hay una extensión del mismo nombre y una función adecuada. Tal caso puede ser confuso, ya que los usuarios que no conocen la función declarada dentro de la clase esperarán a que se complete la función de extensión. Esta es una situación desagradable que puede tener consecuencias extremadamente graves. En este caso, no estamos hablando de los posibles inconvenientes de una revisión o violación de la plantilla, sino del comportamiento potencialmente erróneo del código.

La situación descrita anteriormente muestra que cuando se usan las funciones de extensión, pueden surgir problemas reales.

Para evitarlos, no debe olvidar cubrir las funciones de expansión con pruebas unitarias tanto como sea posible. En el peor de los casos, si las pruebas fallan, habrá dos funciones que funcionan de la misma manera. Uno es una extensión y el otro está en la clase misma. Si las pruebas fallan, llamará la atención sobre el hecho de que una función se superpone a otra.

La extensión está vinculada a una clase, no a un objeto, y esto puede causar confusión.


Por ejemplo, considere la clase Usuario del primer párrafo. Hagámoslo abierto y creemos su sucesor Estudiante:

 class Student(name: String, age: Int, sex: String): User(name, age, sex) 

Definimos la función de extensión para Estudiante, que también determinará si el estudiante es un adulto o no. Solo para el alumno cambiamos la condición:

 fun Student.isAdult() = this.age >= 16 

Y ahora escribimos el siguiente código:

 val user: User = Student("", 17, "M") 

¿Qué devolverá user.isAdult ())?
Parecería que un objeto de tipo Estudiante y la función deberían devolver verdadero. Pero no es tan simple. Las extensiones se adjuntan a la clase, no al objeto, y el resultado será falso.

No hay nada extraño en esto, si recordamos que las extensiones son métodos estáticos, y una entidad extensible es el primer parámetro en este método. Este es otro punto a tener en cuenta al usar este mecanismo. De lo contrario, puede obtener un efecto desagradable e inesperado.

En lugar de salida


Estos puntos controvertidos no parecen peligrosos, si recuerdas que decimos extensión, nos referimos a un método estático. Además, cubrir dicha funcionalidad con pruebas unitarias ayudará a minimizar la posible confusión asociada con la naturaleza estática de las extensiones.

En mi opinión, las extensiones son una herramienta poderosa y conveniente que puede mejorar la calidad y la legibilidad del código, y no requieren casi nada a cambio. Por eso los amo:

  • Las extensiones le permiten escribir lógica específica para el contexto de una clase extensible. Gracias a esto, los campos y los métodos de extensión se leen como si siempre estuvieran presentes en la entidad extendida, lo que, a su vez, mejora la comprensión del código a nivel superior. En Java, por desgracia, esto no se puede hacer. Además, las extensiones tienen los mismos modificadores de acceso que las funciones normales. Esto le permite escribir código similar con el alcance que es realmente necesario para una función en particular.
  • Es conveniente utilizar las funciones de extensión para el mapeo, que tiene que ver mucho al resolver las tareas cotidianas. Por ejemplo, en el proyecto hay una clase UserFromExternalSystem, que se usa cuando se llama a un sistema externo, y sería genial poner el mapeo en la función de extensión, olvidarlo y usarlo como si estuviera originalmente en User.

     callExternalSystem(user.getUserFromExternalSystem()) 

    Por supuesto, lo mismo se puede hacer con el método habitual, pero esta opción es menos legible:

     callExternalSystem(getUserFromExternalSystem(user)) 

    o tal opción:

     val externalUser = getUserFromExternalSystem(user) callExternalSystem(externalUser) 

    De hecho, no ocurre magia, pero gracias a tales pequeñeces es más agradable trabajar con el código.
  • Soporte de idea y autocompletado. A diferencia de los métodos de las clases de utilidad, el entorno de desarrollo admite bien las extensiones. Con la finalización automática, el entorno ofrece extensiones como funciones y campos "nativos". Esto permite un buen aumento en la productividad del desarrollador.
  • A favor de las extensiones está el hecho de que una gran parte de las bibliotecas de Kotlin están escritas como extensiones. Muchos métodos convenientes y favoritos para trabajar con colecciones son las extensiones (Filtro, Mapa, etc.). Puede verificar esto examinando el archivo _Collections.kt .

Las ventajas de las extensiones cubren posibles desventajas. Por supuesto, existe un gran riesgo de mal uso de este mecanismo y la tentación de meter todo el código en extensiones. Pero aquí la pregunta es más sobre la organización del código y el uso competente de la herramienta. Cuando se usan correctamente, las extensiones se convertirán en un verdadero amigo y ayudante para escribir código bien leído y mantenido.

A continuación hay enlaces a materiales que se usaron para preparar este artículo:

  1. proandroiddev.com/kotlin-extension-functions-more-than-sugar-1f04ca7189ff - a partir de aquí se toman ideas interesantes sobre el hecho de que, usando extensiones, trabajamos más estrechamente con el contexto.
  2. www.nikialeksey.com/2017/11/14/kotlin-is-bad.html - aquí el autor se opone a las extensiones y da un ejemplo interesante, que se discute en uno de los puntos anteriores.
  3. medium.com/@elizarov/i-do-not-see-much-reason-to-mock-extension-functions-7f24d88a188a - La opinión de Roman Elizarov sobre la humectación de los métodos de extensión.

También me gustaría agradecer a los colegas que ayudaron con casos y reflexiones interesantes sobre este material.

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


All Articles