Comparaciones en C#

¡Buenas!

Este post pertenece al “calendario de adviento de C#“, y me gustaría hablaros de un tema que parece sencillo pero que bueno, esconde sus cosillas. En concreto sobre comparaciones en C#.

Sabemos que en C# tenemos dos formas básicas de comparar objetos. Por un lado el operador de igualdad (==) y por otro el método Equals que se define en Object y por lo tanto está disponible en cualquier objeto. Si te preguntas el por qué hay dos métodos de comparar y qué diferencia hay, mucha gente viene con esa respuesta:

“El operador == compara referencias (es decir si dos variables apuntan al mismo objeto). Por otro lado Equals compara valores (es decir si dos objetos son idénticos)”.

Pero esta afirmación es rotundamente falsa. Ni == compara forzosamente referencias, ni Equals compara siempre valores. Simplemente, en C# hay dos métodos de comparación porque uno (==) lo proporciona el lenguaje y otro (Equals) el Framework. Lenguaje y Framework son cosas distintas: cualquier lenguaje que exista en .NET ofrecerá el método Equals. Que tenga también un operador de igualdad, eso ya depende del lenguaje.

La confusión de que Equals compare valores y == referencias viene, creo yo, por una comparación con Java, donde en efecto eso es así… para las cadenas. Cualquier javero conoce el consejo elemental de “no uses == para comparar cadenas, usa siempre equals“. En Java, si comparas cadenas usando ==, lo que verificas es que dos variables sean la misma cadena, no que dos cadenas sean iguales. Si comparas una variable con una constante cadena, puedes terminar comparando una referencia (la variable) que contiene una cadena, con otra referencia temporal (la constante cadena) que contiene otra cadena. Aunque las cadenas sean idénticas, son dos cadenas diferentes, por lo que == devolverá false. Por su lado equals compara el contenido de ambas cadenas.

Pero ¡ojo! equals en Java compara contenidos de cadenas, porque la clase java.lang.String redefine dicho método. Porque la implementación base de equals en Java hace exactamente lo mismo que la implementación base de Equals en C#: comparar si dos objetos son el mismo.

Veamos un ejemplo en C#, claro:

class A
{
    public int Value { get; set; }
}

class Program
{
    static void Main(string[] args)
    {
        var a1 = new A() { Value = 42 };
        var a2 = new A() { Value = 42 };
        var equals = a1.Equals(a2);
    }
}

En este código el valor de la variable equals es “false”, porque a1 y a2 son dos objetos distintos. Por lo tanto Equals no compara por Valor.

Redefiniendo Equals

De hecho, por defecto, Equals y == se comportan casi igual. Devolverán truefalse en los mismos casos, pero por supuesto, dado que Equals es un método virtual lo podemos redefinir:

public override bool Equals(object obj)
{
    return obj is A a ? a.Value == Value : base.Equals(obj);
}

Ahora hemos redefinido Equals para que compare por valor. Por lo tanto, la comparación anterior ahora devolvería true. Por supuesto, dado que Equals acepta un object como parámetro, en nuestro método Equals, verificamos que realmente el parámetro obj es de tipo A. En caso contrario pasamos el control al método Equals de la clase base (en nuestro caso Object).

Redefiniendo ==

He aquí una de las grandes diferencias iniciales de C# con Java, y es que en C# se puede redefinir el operador ==. Es decir, puedo hacer que el operador == se comporte como yo quiera. Eso es un gran poder, ya que estamos “alterando” el comportamiento habitual del lenguaje, así que bueno ya sabéis lo de la responsabilidad y todo eso. De hecho, redefinir el operador == es lo que hace la clase System.String en C#: lo redefine para que compare valores, no referencias. Por eso en C# podemos comparar cadenas usando == y todo nos funciona sin preocuparnos!

public static bool operator ==(A a1, A a2)
{
    return a1.Value == a2.Value;
}
public static bool operator !=(A a1, A a2)
{
    return a1.Value != a2.Value;
}

Este código muestra la redefinición el operador== para la clase A para que compare por valor. Un tema al que C# obliga, es que si redefines == debes redefinir también el operador !=. Y observa que el operador se define como una función estática.

De hecho, usando este operador == redefinido, ahora el siguiente código devuelve true:

var a1 = new A() { Value = 42 };
var a2 = new A() { Value = 42 };
var equals2 = a1 == a2;

Por lo tanto, tanto Equals como == pueden comparar por valor. Todo depende de nuestras necesidades.

Ahora bien, la regla de oro a tener en cuenta sobre como se comportan Equals o el operador == cuando están redefinidos y es que en C# los operadores se seleccionan en tiempo de compilación, no de ejecución… al revés que los métodos virtuales, que se seleccionan en tiempo de ejecución. Es decir, cuando C# debe decidir que operador == llama, usa el tipo de las variables. Pero cuando C# debe decidir que método Equals llama, usa el tipo de los objetos. La diferencia es fundamental. Así, suponiendo nuestros métodos Equals y el operador == redefinido:

var e1 = a1.Equals(a2);             // true
var o1 = a1 == a2;                  // true
var e2 = ((object)a1).Equals(a2);   // true
var o2 = ((object)a1) == a2;        // false

La última comparación devuelve false, no true como sería de esperar. Y la razón es que ((object)a1) es una expresión que se evalúa a object, por lo que, cuando el compilador debe elegir, elige un operador== cuyo primer parámetro sea “object” y el segundo “A”. Pero ese no es nuestro operador==. Nuestro operador== se definió con ámbos parámetros a “A”.

Eh! Lo acabo de comprobar con cadenas y me estás engañando!

Está bien comprobar las cosas y no creértelo todo! Quizá has probado un código como ese:

var x = ((object)"c# mola") == "c# mola";

Y esperabas que x fuese false y resulta que no. Pero eso no es porque no te he engañado. Eso es porque el compilador tiene una optimización que cuando aparece dos veces la misma constante cadena, las reutiliza en un MISMO objeto. Pero, observa lo siguiente:

var x = ((object)"c# mola") == new string("c# mola");

Aquí estoy forzando que se cree otro objeto cadena y ahora sí que el resultado es false.  Por otro lado si quitamos el casting a object entonces sí que x vale true, como es de esperar:

var x = "c# mola" == new string("c# mola");

El caso especial de null

Con el papa hemos topado. La realidad es que null no es de ningún tipo (o es de todos ellos). Cabe preguntarse, dado el siguiente código:

if (a1 == null) { }

¿Qué operador llamará el compilador? La realidad es que el compilador asume que el tipo de null es el mismo que el del otro operando. Es decir, en este caso el operando a1 es de tipo A, por lo que el compilador asume que este null es de tipo A. Por lo tanto nos llamará a nuestro operador ==, lo que nos dará un error (NullReferenceException ya que usábamos .Value sin verificar que a1 o a2 podían ser null). En resumen: ten en cuenta null cuando redefinas tus operadores de igualdad.

El hecho de que las comparaciones con null, puedan llamar a operadores== redefinidos, puede causar casos interesantes. El siguiente es el intento de crear un objeto que siempre es distinto a cualquier otro objeto de la misma clase:

class Unique
{
    public Guid Id { get; }
    public Unique() => Id = Guid.NewGuid();
    public override bool Equals(object obj) => false;
    public static bool operator ==(Unique first, Unique second) => false;
    public static bool operator !=(Unique first, Unique second) => true;
}

Un objeto Unique siempre es distinto a cualquier objeto Unique… incluso es distinto a sí mismo:

var u1 = new Unique();
var u2 = new Unique();

var b1 = u1 == u2;          // false;
var b2 = u1 == u1;          // false!!!

Pero, recuerda que comparando con null también se llamará a nuestro operador. Así, si tenemos el siguiente código:

Unique u1 = null;

if (u1 != null)
{
    Console.WriteLine($"Id: {u1.Id}");
}

Eso es lo que ocurre:

Comparación con null

Recibimos una NullReferenceExceptionEso es porque la comparación “u1 != null” que tenemos en el if, usa nuestro operador redefinido (que recuerda, devolvía siempre true). Vale, es un caso extremo, pero… que te sirva de recordatorio de hasta donde podemos llegar.

Por supuesto recuerda que si la variable u1 ya no es de tipo Unique ya no se llamará a nuestro operador, o lo mismo ocurre si es null el que no es de tipo Unique. Así, eso funciona:

Unique u1 = null;
object o = u1;
if (o != null)
{
    Console.WriteLine($"Id: {u1.Id}");
}

Lo mismo que eso:

Unique u1 = null;
if (u1 != (object)null)
{
    Console.WriteLine($"Id: {u1.Id}");
}

Pero… no te líes demasiado. Hay una manera más sencilla y natural de comparar con null de forma segura, con independencia de cualquier operador redefinido y de la forma más eficienteY además la conoces:

Unique u1 = null;
if (!(u1 is null))
{
    Console.WriteLine($"Id: {u1.Id}");
}

De hecho, deberíamos usar siempre is (o su equivalente as) al comparar con nullSi no usamos is al comparar con null, se ejecuta el operador== redefinido, si existe. No sabes que hace este operador, pero piénsalo bien, ¿para qué quieres ejecutar una versión propia del operador== para comparar con null? Lo que quieres de verdad es comprobar que la referencia no es nula, y para ello mejor usar is.

Evidentemente, si no hay operador== redefinido, entonces “x is null” y “x == null” se comportan igual.

Y… ¡nada más! Os dejo algunos posts como lectura complementaria a este por si os apetece seguir leyendo más sobre el tema de las comparaciones.

Un saludo y recordad “abrir” el resto de posts del calendario!

  • https://blogs.msdn.microsoft.com/ericlippert/2009/04/09/double-your-dispatch-double-your-fun/
  • https://www.c-sharpcorner.com/UploadFile/3d39b4/difference-between-operator-and-equals-method-in-C-Sharp/
  • https://coding.abel.nu/2014/09/net-and-equals/

Deja un comentario

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