Java: ¿Por qué no me funciona la comparación de cadenas? Literales, clases y el repositorio de cadenas

jmalarcon

José M. Alarcón 🌐

Posted on November 13, 2019

Java: ¿Por qué no me funciona la comparación de cadenas? Literales, clases y el repositorio de cadenas

Una pregunta habitual de los que comienzan con Java (e incluso en entrevistas de trabajo para puestos que usan este lenguaje) tiene que ver con las comparaciones entre cadenas de texto. Si vienes de otros lenguajes puedes estar acostumbrado a comparar cadenas con el operador igualdad == o, simplemente, te puede parecer la forma más evidente y obvia de hacerlo.

Sin embargo, consideremos un código como este:

String s1 = new String("campusMVP");
String s2 = new String("campusMVP");
System.out.println(s1 == s2); //Devuelve false

Enter fullscreen mode Exit fullscreen mode

Aunque las cadenas son idénticas y obtienes dos objetos String idénticos, la comparación devuelve false 🤔

En este otro ejemplo similar:

String s1 = "campusMVP";
String s2 = new String("campusMVP");
System.out.println(s1 == s2); //Devuelve false

Enter fullscreen mode Exit fullscreen mode

devuelve false también.

Sin embargo esto:

String s1 = "campusMVP";
String s2 = "campusMVP";
System.out.println(s1 == s2); //Devuelve true

Enter fullscreen mode Exit fullscreen mode

devuelve true.

Es más, un caso más común sería el de comparar el resultado de una función que devuelve una cadena, con otra cadena cualquiera. Algo así:

String s1 = "OK";
if (s1 == FuncionQueDevuelveCadena()) {
    System.out.println("¡Coinciden!");
}
else {
    System.out.println("No coinciden"));
}

Enter fullscreen mode Exit fullscreen mode

Bien, lo curioso del fragmento anterior es que, dependiendo de lo que haga la función y aunque la cadena devuelta sea exactamente la misma en ambos casos, el resultado puede ser true o false.

¿A qué es debido esto?

El funcionamiento de las cadenas en Java - Repositorio o Pool de cadenas

Las cadenas en Java son una especie aparte. Tienen muchos detalles que es preciso conocer para entenderlas (por ejemplo, que son inmutables, o Final en Java, o que internamente se pueden codificar en memoria de varias formas). Conocerlas bien es un paso indispensable para dominar el lenguaje.

Un concepto sencillo una vez que lo entiendes, pero que no todo el mundo tiene claro es la forma de instanciarlas dependiendo de cómo se declaren.

Básicamente tenemos dos maneras de declarar una cadena: con un literal o instanciando una clase:

String literal = "campusMVP";
String clase = new String("campusMVP");

Enter fullscreen mode Exit fullscreen mode

En ambos casos lo que obtenemos es lo mismo: una clase String con una cadena dentro (que no es más que una matriz de caracteres en memoria). Sin embargo, hay una gran diferencia entre hacerlo de una forma o la otra.

Cuando se declara una cadena de manera literal por primera vez, la JVM la coloca en un espacio especial denominado repositorio de cadenas ( string pool en inglés). Este repositorio contiene una copia única de las diferentes cadenas declaradas como literales en el código. Aquí la palabra importante es "única". Como las cadenas son inmutables, si declaras más de una vez la misma cadena no tiene sentido tenerla varias veces en memoria, así que la siguiente vez que la declares lo que hace la JVM es ir al repositorio de cadenas y localizarla, devolviendo una referencia al mismo objeto String que en la primera declaración. OJO: es el mismo objeto, no una copia. Por eso al escribir esto:

String s1 = "campusMVP";
String s2 = "campusMVP";
String s3 = new String(s1);
System.out.println(s1 == s2); //true
System.out.println(s1 == "campusMVP"); //true

Enter fullscreen mode Exit fullscreen mode

En todos los casos la comparación se realiza con éxito. Lo que ocurre es que en todos los casos se usa el mismo objeto String exactamente. En la línea 1, en la primera declaración, se almacena en el repositorio de cadenas. En la línea 2, la segunda declaración del literal, la JVM va al repositorio y mira si ya existe la misma cadena de antes. Como en este caso sí que existe, lo que hace es devolver una referencia a la misma cadena que en la línea 1. ¡Por eso son iguales! Porque no solo representan los mismos caracteres, sino que son de hecho la misma clase (y apuntan a la misma matriz en memoria). En la línea 4, cuando se hace la comparación en s1 y el mismo literal, vuelve a pasar lo mismo: la JVM localiza el literal en el repositorio de cadenas y devuelve la misma referencia, por eso es true de nuevo.

Se puede ver visualmente mejor en esta figura, que trata de representar la situación:

El repositorio de cadenas de Java

Esto funciona incluso con la combinación de varios literales. La JVM es lo suficientemente inteligente como para hacer que estas dos cadenas sean la misma:

String s1 = "campusMVP";
String s2 = "campus" + "MVP";
System.out.println(s1 == s2); //true

Enter fullscreen mode Exit fullscreen mode

A pesar de que podría parecer que hay 3 cadenas en el repositorio de cadenas, y que devolvería dos objetos diferentes.

Declaración de cadenas como clases

Sin embargo, cuando declaramos una cadena usando una clase (con new String()), la operación es diferente. Lo que se hace es asignar en memoria el espacio necesario para la cadena y devolver la clase que permite manejarla. No se pasa por el repositorio de cadenas para nada.

Por ello este fragmento:

String s1 = new String("campusMVP");
String s2 = new String("campusMVP");
System.out.println(s1 == s2); //Devuelve false

Enter fullscreen mode Exit fullscreen mode

devuelve false en la comparación. Se trata de dos cadenas diferentes, aunque internamente representen los mismos caracteres:

Imagen que muestra dos cadenas iguales, pero en dos posiciones de memoria diferentes

Nota : lo sé, es absurdo declarar cadenas de esta manera envolviendo un literal con una clase String. Y de hecho, raramente lo verás hecho por ahí. Pero es que hay infinidad de maneras de obtener clases String en una variable: desde transformar un literal hasta sacarlas de una base de datos. Solo pongo el ejemplo de esta manera para que se entienda lo que quiero explicar, no para indicar que esta es una manera recomendada de declarar las cadenas.

¿Qué ocurre si tenemos dos cadenas literales y queremos compararlas con == sin considerar mayúsculas y minúsculas?

Podríamos hacer esto:

String s1 = "campusMVP";
String s2 = "campusMVP";
System.out.println(s1.toLowerCase() == s2.toLowerCase()); //false!

Enter fullscreen mode Exit fullscreen mode

En este caso la comparación devuelve false, ya que lo que obtenemos son dos cadenas idénticas pero representadas por dos objetos diferentes. El operador == compara referencias a objetos y en este caso, aunque los datos que contienen son los mismos, no se trata del mismo objeto. Incluso siendo s1 y s2 el mismo objeto String,la operación toLowerCase() lo que hace es generar una nueva cadena a partir de la misma cadena original, pero se trata de dos objetos String diferentes en memoria. Es como el caso anterior. Al no ser literales, no se obtienen desde el repositorio de cadenas aunque ya existan allí.

Por eso es tan peligroso comparar cadenas con ==. Aunque las cadenas sean idénticas, como lo que se comparan son referencias a objetos String y no el valor subyacente, solo devuelven true para cadenas iguales cuando se refieren al mismo literal.

Conclusión : no utilices nunca el operador == para comparar cadenas si quieres hacerlo bien en Java.

Y entonces ¿cómo comparo cadenas con seguridad?

Pues con el método equals() que tienen todos los objetos. De hecho, es heredado de Object, la clase base "raíz" de todas las existentes. Este método lo que hace es comparar el estado interno de dos objetos para ver si son iguales, mientras que == lo que hace es comparar las referencias.

En el caso de las cadenas, lo que hace es comparar que las dos matrices de caracteres que contienen los objetos String que comparamos sean iguales, que es realmente lo que nos interesa por regla general.

Así, para comparar dos cadenas cualquiera con seguridad debes usar este método:

String s1 = new String("campusMVP");
String s2 = new String("campusMVP");
System.out.println(s1.equals(s2)); //Devuelve true
System.out.println(s1.equals("campusMVP")); //Devuelve true
System.out.println("campusMVP".equals(s1)); //Devuelve true

Enter fullscreen mode Exit fullscreen mode

Pero es que da igual cómo obtengamos la cadena final : siempre devuelve true si las cadenas que representan los dos objetos son iguales, por ejemplo:

String s1 = new String("campusMVP");
String s2 = new String("CAMPUSMVP");
System.out.println(s1.equals("campus" + "MVP")); //Devuelve true
System.out.println(s1.toLowerCase().equals(
            s2.toLowerCase()
        )); //Devuelve true

Enter fullscreen mode Exit fullscreen mode

En este caso sí que podemos comparar las dos cadenas en minúsculas para ver si son iguales.

Conclusión 2 : las comparaciones de cadenas en Java hazlas siempre con equals().

"Internalizando" objetos de tipo cadena

Esto es ya casi una curiosidad más que otra cosa. Pero es que, aunque no es algo que se use muy habitualmente, existe la posibilidad de hacer que los objetos de tipo String que tengamos se "internalicen" de modo que pasen a formar parte del pool de cadenas de texto, como si los hubiésemos declarado como literales.

Esto se consigue gracias al método intern() que tienen las cadenas. Lo que hace este método es lo mismo que ocurre cuando se declara una cadena literal: va al repositorio de cadenas y mira si ya existe la misma cadena ahí (en caso negativo la crea), y devuelve como resultado una referencia a la cadena preexistente, en lugar de crear una nueva.

Si llamamos a intern(), así, por ejemplo esto:

String s1 = new String("campusMVP");
String s2 = new String("campusMVP");
System.out.println(s1.intern() == s2.intern()); //Devuelve true

Enter fullscreen mode Exit fullscreen mode

Devolverá true ya que intern() devuelve como resultado de la llamada la misma referencia a la misma cadena del pool de cadenas.

Es decir, intern() lo que hace es desligar el objeto de los datos originales y asignarle el mismo dato que hay en el repositorio de cadenas. Visualmente sería algo así (fíjate en la cadena s3):

Dibujo que muestra cómo una variable se desasigna de la ubicación en memoria original y se apunta a la misma cadena en el repositorio de cadenas

¿Para qué vale esto de intern()? Bueno, como decía, realmente pocas veces lo vas a utilizar, pero esto podría ayudarte a ahorrar mucha memoria en los casos en los que vas a generar muchas cadenas y un gran número de las cuales van a ser iguales.

Por ejemplo, imagínate que vas a obtener desde una base de datos muchos miles de registros de pedidos de clientes, y que tienes un número bastante limitado de clientes y de productos, lo que pasa que hacen muchos pedidos de lo mismo. "Internalizando" las cadenas evitarías tener los mismos nombres de cliente y de pedidos duplicados miles de veces en memoria, dejando tan solo unas pocas copias de los mismos dentro del repositorio de cadenas y ahorrando potencialmente mucha memoria, sobre todo si los nombres son largos.

Como nota técnica adicional sobre esto decir que, hasta Java 7, ya hace muchos años (se lanzó en 2011), el espacio disponible para almacenar las cadenas en el pool era limitado y además no entraba dentro de la recolección de basura. Eso significaba que si abusabas mucho del "interning" podrías acabar con una excepción de tipo OutOfMemory, lo cual podría provocar un desastre en tu aplicación. Es por esto que en muchos artículos que encontrarás por ahí sobre este tema, y que están anticuados, te recomiendan no usar intern(). La realidad es que, a partir de Java 7, y por lo tanto en la práctica totalidad de las aplicaciones Java que vas a encontrar por ahí, el repositorio de cadenas se guarda ya en el "montón" (o heap) (es decir, en memoria dinámica), por lo que no hay problemas de límites y además puede ser reclamada por el recolector de basura. Además en Java 13 (de septiembre de 2019) se permite también la devolución al sistema operativo de memoria dinámica no utilizada, con lo que incluso está más optimizado.

En resumen

Las cadenas de texto son "bichos raros" en casi todos los lenguajes, pero en Java especialmente, al poder actuar a la vez como literales y como objetos, pero al mismo tiempo presentan diferencias a la hora de utilizarlas y en especial de compararlas.

Por ello las comparaciones de cadenas deben evitar el uso del operador == (que compara referencias de objetos y no sus datos) y emplear en cambio el método equals(). Además es importante conocer el funcionamiento interno de las cadenas, las implicaciones que tiene y cómo podemos optimizar el uso de memoria cuando manejamos cadenas de gran tamaño (o muchas) con valores idénticos.

Te de dejado un pequeño patio de juegos para que puedas comprobar el funcionamiento de todo lo explicado y probar a realizar cambios y ver cómo funcionan.

¡Espero que te resulte útil!

💖 💪 🙅 🚩
jmalarcon
José M. Alarcón 🌐

Posted on November 13, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related