En esta entrada vamos a ver exactamente cómo funciona el paso por parámetros en Java y para qué sirve poner el modificador final en las entradas de los métodos. Aunque sea muy básico siempre es bueno revisar estos aspectos de un lenguaje de vez en cuando y entrar en profundidad en ellos.
Introducción
De un tiempo a esta parte he visto a mucha gente anotar los parámetros de los métodos como final por simple costumbre. Esto no es un problema, para nada, y de hecho yo mismo lo hago casi siempre. Lo malo es que se haga sin pensar, porque nos han dicho que todo sea siempre final o porque creemos que hace lo que no hace.
Vamos a entrar en detalle de para qué sirve exactamente el modificador final en los parámetros de un método y lo ilustraremos con algunos ejemplos. Por último entraremos al bytecode generado y veremos las diferencias, si las hay, entre usar o no esta anotación.
Con esto espero lograr dos objetivos. Por un lado que quien no supiese de qué sirve tener parámetros finales ahora tenga una idea más completa. Y por otro lado ayudar a tener consciencia de qué hace por debajo todo lo que escribimos.
Entorno
Este tutorial ha sido escrito y desarrollado con:
- Hardware: Portátil Mac Book Pro 15″ (2,5 Ghz Intel Core i7, 16 GB DDR3)
- Sistema Operativo: Mac OS High Sierra
- Entorno de desarrollo: IntelliJ IDEA 2018.3
- Java 1.8.0.77
Tipos de parámetros
Lo primero que debemos aclarar es qué se pasa como parámetros cuando llamamos a una función en Java. En este aspecto tenemos dos casos: tipos primitivos y objetos.
Los tipos primitivos en Java (int, double, char, boolean…) se caracterizan por no ser objetos y, por tanto, no tienen una dirección de memoria que les apunte (entre otras características). Por ello en memoria se guarda directamente el valor concreto de la variable. Cuando llamamos a un método metemos en la pila de llamada una copia de la variable original.
Por otro lado tenemos los objetos, que vienen representados por una dirección de memoria. Al pasar un objeto por parámetro se guardará en la pila la dirección de memoria de la variable externa apuntada. Lo importante es ver que no es la propia referencia original la que se manda, por lo que el objeto al que apunta es el original pero no lo es el espacio de memoria donde se almacena la dirección. Es decir, si cambiamos la dirección de memoria de la pila no cambiará en la variable original.
Como vemos en esencia estamos ante el mismo caso. Lo que se pasa es una copia de lo que estemos pasando, sea un valor directo o una dirección de memoria. Haciendo referencia a C, en Java todos los parámetros son siempre pasados por valor.
Anotando parámetros de métodos como finales
Con lo anterior en mente podemos entender qué hace el modificador final con un parámetro en Java. Realmente a nivel de memoria tenemos exactamente el mismo comportamiento sin importar el tipo de parámetros. Poner final evita que podamos sobreescribir la región de memoria asociada a ese parámetro, sea un valor primitivo o una dirección de memoria.
Sin importar el tipo de parámetro nos encontraremos ante dos escenarios.
- Si no tiene modificador final se puede modificar el valor de la pila sin ningún tipo de problemas. Pero al ser una copia las modificaciones que hagamos aquí no van a afectar a la variable original fuera del método llamado.
- En caso de tener el modificador final no se permitirá cambiar el valor en la pila. Si lo intentamos obtendremos un error de compilación por lo que ni siquiera lograremos generar el bytecode. No obstante el comportamiento de puertas afuera de la función será el mismo puesto que aún cambiándolo no tendrá efecto en la variable original.
Vamos a ver ahora un ejemplo de esto:
////////////////////////// 1. Tipos primitivos ////////////////////////// // 1.A Aunque no sean finales no cambian fuera de la función private void changeNonFinalPrimitiveValue(int x){ x = x + 5; } @Test void primitiveParameterShouldNotBeChangedOutside(){ int x = 5; changeNonFinalPrimitiveValue(x); assertThat(x, is(5)); } // 1.B Tenemos error de compilación si intentamos cambiarlo private void changeFinalPrimitiveValue(final int x){ // x = x+5; } ////////////////////////// 2. Objetos ////////////////////////// // 2.A Mismo comportamiento que con un tipo primitivo private void changeNonFinalObject(Integer x){ x = x + 5; } @Test void objectParameterShouldNotBeChangedOutside(){ Integer x = 5; changeNonFinalPrimitiveValue(x); assertThat(x, is(5)); } // 2.B Mismo comportamiento que con un tipo primitivo private void changeFinalObjectValue(final Integer x){ // x = x+5; }
Como vemos la única utilidad de anotar el parámetro como final es que no se podrá modificar dentro del método. Pero aún haciéndolo no hay ninguna manera (al menos directa) de afectar a datos fuera del método en ejecución. Sería como si siempre guardásemos lo que nos llega en una variable interna y ésta fuese final o no. Por tanto no anotar un parámetro como final implica que podemos usarlo como variable modificable dentro del método. Por el contrario si lo anotásemos sería el compilador el que nos avisaría.
Sin embargo, trabajar con objetos tiene una particularidad. Lo que llega al método es una copia de la dirección de memoria del objeto real. En este caso estaremos trabajando con la instancia concreta en su propia región de memoria, por lo que los cambios que realicemos se verán desde fuera. Podremos acceder a sus métodos y atributos como si la hubiésemos creado en ese mismo momento.
Podemos ver esto en funcionamiento con este pequeño fragmento de código:
////////////////////////// 3. Acceso interno a un objeto ////////////////////////// private static class ClassWithAttributes { int x; final int y; Integer x1; final Integer y1; ClassWithAttributes(final int x, final int y, final Integer x1, final Integer y1){ this.x = x; this.y = y; this.x1 = x1; this.y1 = y1; } } // 3.A Atributos no finales quedan modificados dentro del objeto private void changeNonFinalAttributes(final ClassWithAttributes input){ input.x = 3; input.x1 = 3; } @Test void nonFinalAttributesOfParameterShouldChange(){ final ClassWithAttributes input = new ClassWithAttributes(0, 0, 0, 0); changeNonFinalAttributes(input); assertThat(input.x, is(3)); assertThat(input.x1, is(3)); } // 3.B Atributos finales dan error de compilación si se intentan cambiar private void changeFinalAttributes(final ClassWithAttributes input){ // input.y = 3; // input.y1 = 3; }
Echando un vistazo al bytecode
Dicho esto, y por darle más profundidad al tutorial, vamos a ver el bytecode resultante de compilar una función con parámetros finales o no y compararlo. El código Java es muy parecido a lo que hemos visto hasta ahora pero simplificado para poder entender bien el bytecode. Tenemos un par de métodos con tipos primitivos y objetos en los que llegan dos enteros y se devuelve la suma.
public class ParametersToBytecode{ public int sumNonFinalPrimitiveTypes(int x, int y){ return x + y; } public int sumFinalPrimitiveTypes(final int x, final int y){ return x + y; } public int sumNonFinalObjects(Integer x, Integer y){ return x + y; } public int sumFinalObjects(final Integer x, final Integer y){ return x + y; } }
Ahora vamos a compilar esta clase, abrir el bytecode generado y comparar las parejas de funciones con y sin parámetros finales. Pongo la parte que nos interesa, que son los propios métodos, a continuación:
// TIPOS PRIMITIVOS public int sumNonFinalPrimitiveTypes(int, int); Code: 0: iload_1 1: iload_2 2: iadd 3: ireturn public int sumFinalPrimitiveTypes(int, int); Code: 0: iload_1 1: iload_2 2: iadd 3: ireturn // OBJETOS public int sumNonFinalObjects(java.lang.Integer, java.lang.Integer); Code: 0: aload_1 1: invokevirtual #2 // Method java/lang/Integer.intValue:()I 4: aload_2 5: invokevirtual #2 // Method java/lang/Integer.intValue:()I 8: iadd 9: ireturn public int sumFinalObjects(java.lang.Integer, java.lang.Integer); Code: 0: aload_1 1: invokevirtual #2 // Method java/lang/Integer.intValue:()I 4: aload_2 5: invokevirtual #2 // Method java/lang/Integer.intValue:()I 8: iadd 9: ireturn
Leyendo este código podemos comprobar que efectivamente el código resultante es exactamente el mismo tengamos o no parámetros finales. No hay ningún tipo de indicador a nivel de bytecode y no se está aplicando ningún tipo de optimización en este sentido. Esto demuestra que añadir final a un parámetro es una ayuda en tiempo de compilación pero no afecta al comportamiento.
Por último, por comparar, veamos qué ocurre cuando final se aplica a variables. A la clase anterior vamos a añadirle estos dos métodos:
public int sumNonFinalVariables(){ int x = 10; int y = 10; return x + y; } public int sumFinalVariables(){ final int x = 10; final int y = 10; return x + y; }
Compilamos de nuevo, abrimos el bytecode y veremos que esta es la parte correspondiente a los dos nuevos métodos:
public int sumNonFinalVariables(); Code: 0: bipush 10 2: istore_1 3: bipush 10 5: istore_2 6: iload_1 7: iload_2 8: iadd 9: ireturn public int sumFinalVariables(); Code: 0: bipush 10 2: istore_1 3: bipush 10 5: istore_2 6: bipush 20 8: ireturn
Las variables siguen declaradas exactamente igual, no hay ninguna anotación especial indicando que sean constantes. Pero en este caso sí podemos ver una ligera diferencia entre ellos debida a un efecto secundario de que las variables sean finales. El compilador sabe de antemano los valores de x e y. Gracias a esto puede calcular de antemano el resultado y crear un bytecode con ese valor para ahorrar instrucciones.
Como hemos visto, en caso de trabajar con parámetros finales no tenemos este tipo optimización. El motivo es simple, como los valores con los que se llama al método solo son conocidos en tiempo de ejecución no se puede precalcular el resultado. Vemos, por tanto, que el modificador final en parámetros pierde algunas ventajas que nos aporta con variables.
Conclusiones
Después de todo lo que hemos visto podemos llegar a la conclusión de que, a efectos prácticos, poner o no final a un parámetro no tiene consecuencias notables. Java protege automáticamente todos los datos fuera del ámbito de un método, por lo que no tenemos que preocuparnos de esto. Para lo que puede ser útil a nivel de desarrollo es para asegurarnos de que un parámetro no cambie de valor a lo largo de la ejecución del método, lo que sería una buena práctica en la gran mayoría de los casos. Pero el comportamiento y la eficiencia por debajo serán exactamente los mismos siempre.
Personalmente considero que es mejor añadir el modificador final. De esta forma con solo mirar la firma de un método podemos estar seguros (salvo excepciones) de que un parámetro siempre tendrá el valor inicial. Solo en casos muy concretos no lo hago. Además al ser una palabra reservada los IDEs suelen ponerle un color distinto y el código queda más vistoso 😛
Genial articulo, nunca se me había ocurrido mirar el bytecode para ver las diferencias que añade un final.
Solo un apunte, muestras que si que hay diferencia en el bytecode al poner la palabra final en una variable. Pero esto es por que el valor de esa variable viene ya definido, y con el final lo que se hace es que se parezca a una constante (le faltaria el static).
Pero que pasa si el valor de esa variable no se conoce en tiempo de compilación?
public int sumNonFinalVariables(int foo){
int x = 10 + foo;
int y = 10 + foo;
return x + y;
}
public int sumFinalVariables(final int foo){
final int x = 10 + foo;
final int y = 10 + foo;
return x + y;
}
En este caso el bytecode de las dos funciones es idéntico:
public int sumNonFinalVariables(int);
Code:
0: bipush 10
2: iload_1
3: iadd
4: istore_2
5: bipush 10
7: iload_1
8: iadd
9: istore_3
10: iload_2
11: iload_3
12: iadd
13: ireturn
public int sumFinalVariables(int);
Code:
0: bipush 10
2: iload_1
3: iadd
4: istore_2
5: bipush 10
7: iload_1
8: iadd
9: istore_3
10: iload_2
11: iload_3
12: iadd
13: ireturn
Por lo que la única mejora a nivel de eficiencia que se consigue con los final, es en los casos en el que el valor sea conocido en tiempo de compilación.
En mi opinión usar final en todos lados creo que empeora el código, lo hace menos legible ya que mentalmente tienes que ir ignorando la palabra final que no aporta nada mientras buscas lo que realmente te aporta información. Una de las criticas de los detractores de Java es su verbosidad, y con esto lo que se hace es que el lenguaje sea aún mas verboso.
Buenas Victor.
En efecto en este caso que comentas no se puede hacer ninguna optimización porque el resultado está dependiendo de un parámetro y no se conoce de ante mano. Aunque estaríamos en el mismo caso si, por ejemplo, el valor lo estuviéramos obteniendo de una función.
Sobre usar o no final estoy de acuerdo contigo en que a la verbosidad le hace un flaco favor. Muchos de los lenguajes que están surgiendo ahora lo solucionan de una manera mucho más elegante y además no usan una palabra reservada que en función de si la usas para una clase, método o variable tiene un funcionamiento u otro. Pero en mi opinión en este caso concreto estoy acostumbrado a ver el final y cuando no aparece al momento me doy cuenta de que es posible que se esté reutilizando y alguna vez me ha servido. Aunque al final todo son opiniones y nunca habrá nada que contente a todos.
Muchas gracias por el comentario y por el ejemplo para que se entienda mejor!