Operador == personalizado, ¿debemos mantenerlo?

Cuando haga esto en Unity:
if (myGameObject == null) {}
Unity hace algo especial con el operador ==. En lugar de lo que la mayoría de la gente esperaría, tenemos una implementación especial del operador ==.
Esto sirve a dos propósitos:
1) Cuando un MonoBehaviour tiene campos, sólo en el editor[1], no establecemos esos campos a "null real", sino a un objeto "null falso". Nuestro operador personalizado == es capaz de comprobar si algo es uno de estos falsos objetos null, y se comporta en consecuencia. Aunque se trata de una configuración exótica, nos permite almacenar información en el falso objeto null que le proporciona más información contextual cuando se invoca un método sobre él, o cuando se pregunta al objeto por una propiedad. Sin este truco, sólo obtendría una NullReferenceException, un stack trace, pero no tendría ni idea de qué GameObject tenía el MonoBehaviour que tenía el campo que era null. Con este truco, podemos resaltar el GameObject en el inspector, y también podemos darle más indicaciones: "parece que está accediendo a un campo no inicializado en este MonoBehaviour de aquí, utilice el inspector para hacer que el campo apunte a algo".
El segundo propósito es un poco más complicado.
2) Cuando se obtiene un objeto c# de tipo "GameObject"[2], no contiene casi nada. esto se debe a que Unity es un motor C/C++. Toda la información real sobre este GameObject (su nombre, la lista de componentes que tiene, sus HideFlags, etc) vive en la parte c++. Lo único que tiene el objeto c# es un puntero al objeto nativo. A estos objetos de C# los llamamos "objetos envoltorio". El tiempo de vida de estos objetos c++ como GameObject y todo lo demás que derive de UnityEngine.Object se gestiona explícitamente. Estos objetos se destruyen cuando se carga una nueva escena. O cuando llame a Object.Destroy(myObject); sobre ellos. La vida útil de los objetos c# se gestiona a la manera c#, con un recolector de basura. Esto significa que es posible tener un objeto envoltorio en c# que todavía existe, que envuelve un objeto en c++ que ya ha sido destruido. Si compara este objeto con null, nuestro operador personalizado == devolverá "true" en este caso, aunque la variable real de c# no sea en realidad null.
Aunque estos dos casos de uso son bastante razonables, la comprobación personalizada de null también tiene un montón de inconvenientes.
- Es contraintuitivo.
- Comparar dos UnityEngine.Objects entre sí o con null es más lento de lo que cabría esperar.
- El operador personalizado == no es seguro para hilos, por lo que no puede comparar objetos fuera del hilo principal. (esto podríamos arreglarlo).
- Se comporta de forma incoherente con el operador ??, que también hace una comprobación de nulos, pero ése hace una comprobación de nulos en c# puro, y no puede saltarse para llamar a nuestra comprobación de nulos personalizada.
Repasando todas estas ventajas e inconvenientes, si estuviéramos construyendo nuestra API desde cero, habríamos optado por no hacer una comprobación personalizada de nulos, sino tener una propiedad myObject.destroyed que se puede utilizar para comprobar si el objeto está muerto o no, y simplemente vivir con el hecho de que ya no podemos dar mejores mensajes de error en caso de que se invoque una función en un campo que es null.
Lo que estamos considerando es si debemos cambiar esto o no. Lo que supone un paso más en nuestra interminable búsqueda por encontrar el equilibrio adecuado entre "arreglar y limpiar cosas viejas" y "no romper proyectos antiguos". En este caso nos preguntamos qué piensa usted. Para Unity5 hemos estado trabajando en la posibilidad de que Unity actualice automáticamente sus scripts (más sobre esto en un blogpost posterior). Lamentablemente, en este caso no podríamos actualizar automáticamente sus scripts. (porque no podemos distinguir entre "este es un script antiguo que realmente quiere el comportamiento antiguo", y "este es un script nuevo que realmente quiere el comportamiento nuevo").
Nos inclinamos por "eliminar el operador personalizado ==", pero dudamos, porque cambiaría el significado de todas las comprobaciones de null que hacen actualmente sus proyectos. Y para los casos en los que el objeto no es "realmente null" sino un objeto destruido, un nullcheck solía devolver true, y si cambiamos esto devolverá false. Si quisiera comprobar si su variable apunta a un objeto destruido, tendría que cambiar el código para comprobar "if (myObject.destroyed) {}" en su lugar. Estamos un poco nerviosos por ello, ya que si no ha leído este blogpost, y lo más probable es que si lo ha hecho, es muy fácil que no se dé cuenta de este cambio de comportamiento, sobre todo porque la mayoría de la gente no se da cuenta en absoluto de que existe esta comprobación personalizada de nulos[3].
Sin embargo, si lo cambiamos, deberíamos hacerlo para Unity5, ya que el umbral de cuánto dolor de actualización estamos dispuestos a que soporten los usuarios es aún más bajo para las versiones no mayores.
¿Qué prefiere que hagamos? ¿Darle una experiencia más limpia, a costa de que tenga que cambiar las comprobaciones null en su proyecto, o mantener las cosas como están?
Bye, Lucas (@lucasmeijer)
[1] Sólo lo hacemos en el editor. Por eso, cuando llame a GetComponent() para buscar un componente que no existe, verá que se produce una asignación de memoria de C#, porque estamos generando esta cadena de advertencia personalizada dentro del objeto falso null recién asignado. Esta asignación de memoria no se produce en los juegos construidos. Este es un muy buen ejemplo de por qué si está perfilando su juego, siempre debe perfilar el reproductor autónomo real o el reproductor móvil, y no perfilar el editor, ya que hacemos un montón de controles adicionales de seguridad / protección / uso en el editor para hacer su vida más fácil, a expensas de un poco de rendimiento. Cuando realice perfiles de rendimiento y asignaciones de memoria, no perfile nunca el editor, perfile siempre el juego construido.
[2] Esto es cierto no sólo para GameObject, sino para todo lo que derive de UnityEngine.Object
[3] Historia divertida: Me topé con esto mientras optimizaba el rendimiento de GetComponent<T>(), y al implementar algo de caché para el componente de transformación no veía ningún beneficio en el rendimiento. Entonces @jonasechterhoff analizó el problema y llegó a la misma conclusión. El código de la caché tiene el siguiente aspecto:
private Transform m_CachedTransform
public Transform transform
{
get
{
if (m_CachedTransform == null)
m_CachedTransform = InternalGetTransform();
return m_CachedTransform;
}
}
Resulta que a dos de nuestros propios ingenieros se les escapó que la comprobación de null era más cara de lo esperado, y era la causa de que no se viera ningún beneficio de velocidad por el almacenamiento en caché. Esto nos llevó al "bueno, si hasta nosotros nos lo perdimos, ¿cuántos de nuestros usuarios se lo perderán?", lo que da lugar a este blogpost :)